diff --git a/scripts/dev-cloud-workbench-smoke.test.mjs b/scripts/dev-cloud-workbench-smoke.test.mjs
index ef3cdd27..82593cbe 100644
--- a/scripts/dev-cloud-workbench-smoke.test.mjs
+++ b/scripts/dev-cloud-workbench-smoke.test.mjs
@@ -799,6 +799,7 @@ test("layout smoke verifies desktop and mobile default workbench geometry withou
"layout-narrow-desktop-default",
"layout-mobile-default",
"layout-feedback-352-resource-explorer-removed",
+ "layout-feedback-437-live-build-overlay",
"layout-left-sidebar-collapse",
"layout-gate-desktop",
"layout-gate-narrow-desktop",
@@ -820,6 +821,7 @@ test("layout smoke verifies desktop and mobile default workbench geometry withou
assert.match(report.safety.statement, /does not send Code Agent chat, call M3 IO/u);
assert.equal(report.checks.find((check) => check.id === "layout-issue-287-future-hardware-status-tabs")?.status, "pass");
assert.equal(report.checks.find((check) => check.id === "layout-feedback-352-resource-explorer-removed")?.status, "pass");
+ assert.equal(report.checks.find((check) => check.id === "layout-feedback-437-live-build-overlay")?.status, "pass");
assert.equal(report.checks.find((check) => check.id === "layout-issue-288-future-single-table-gate")?.status, "skip");
const desktopDefault = report.checks.find((check) => check.id === "layout-desktop-default")?.observations;
@@ -843,6 +845,11 @@ test("layout smoke verifies desktop and mobile default workbench geometry withou
assert.equal(desktopDefault.wiring.metadataInDetails, true);
assert.equal(desktopDefault.wiring.horizontalScroll.panelScrollWidth <= desktopDefault.wiring.horizontalScroll.panelClientWidth + 2, true);
assert.equal(desktopDefault.noHorizontalOverflow.right, true);
+ assert.equal(desktopDefault.liveBuildLayout.overlayPositioned, true);
+ assert.equal(desktopDefault.liveBuildLayout.popoverVisible, true);
+ assert.equal(desktopDefault.liveBuildLayout.popoverViewportContained, true);
+ assert.equal(desktopDefault.liveBuildLayout.stableGeometry, true);
+ assert.equal(desktopDefault.liveBuildLayout.closedByButton, true);
assert.equal(Object.hasOwn(desktopDefault.boxes.shell, "text"), false);
assert.equal(Object.hasOwn(desktopDefault.semanticOverlapChecks[0].boxes["#m3-control-form"], "text"), false);
assert.equal(desktopDefault.failures.length, 0);
@@ -863,6 +870,10 @@ test("layout smoke verifies desktop and mobile default workbench geometry withou
assert.equal(mobileDefault.wiring.metadataInSummary, true);
assert.equal(mobileDefault.wiring.metadataInDetails, true);
assert.equal(mobileDefault.noHorizontalOverflow.right, true);
+ assert.equal(mobileDefault.liveBuildLayout.overlayPositioned, true);
+ assert.equal(mobileDefault.liveBuildLayout.popoverVisible, true);
+ assert.equal(mobileDefault.liveBuildLayout.popoverViewportContained, true);
+ assert.equal(mobileDefault.liveBuildLayout.stableGeometry, true);
const leftCollapse = report.checks.find((check) => check.id === "layout-left-sidebar-collapse")?.observations;
assert.equal(leftCollapse.status, "pass");
@@ -906,5 +917,6 @@ test("repo-owned web checks expose source build and DEV live layout smoke gates"
assert.match(cloudWebCheckSource, /runDevCloudWorkbenchLayoutSmoke/u);
assert.match(cloudWebCheckSource, /tmp\/dev-cloud-workbench-layout-web-check\.json/u);
assert.match(cloudWebCheckSource, /layout-feedback-352-resource-explorer-removed/u);
+ assert.match(cloudWebCheckSource, /layout-feedback-437-live-build-overlay/u);
assert.match(cloudWebCheckSource, /compactLayoutSmokeFailures/u);
});
diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs
index b3117355..e88d08e2 100644
--- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs
+++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs
@@ -1140,6 +1140,12 @@ export async function runDevCloudWorkbenchLayoutSmoke(args = {}) {
summary: "#352 default workbench has no aside#resource-explorer, #explorer-resize, resource tree/capability copy, or reclaimed left panel across desktop and mobile.",
observations: summarizeResourceExplorerRemovalCoverage(viewportResults)
},
+ {
+ id: "layout-feedback-437-live-build-overlay",
+ status: allViewportCoverage(viewportResults, "liveBuildOverlayStable") ? "pass" : "blocked",
+ summary: "#437 workbench status build details open as an overlay without changing the topbar, workspace, or conversation geometry across desktop and mobile.",
+ observations: summarizeCoverageGap(viewportResults, "liveBuildOverlay")
+ },
{
id: "layout-left-sidebar-collapse",
status: Object.values(viewportResults).every((result) => result.leftCollapse?.pass) ? "pass" : "blocked",
@@ -1271,7 +1277,7 @@ export async function runDevCloudWorkbenchLayoutSmoke(args = {}) {
summary: useLiveUrl
? "Live browser layout smoke verifies workbench layout, hit targets, overflow, /gate usability, and the two-column wiring panel only; it is not M3 hardware acceptance."
: "Static local browser layout smoke verifies workbench layout, hit targets, overflow, /gate usability, and the two-column wiring panel only; deployment still requires DEV live verification.",
- refs: ["pikasTech/HWLAB#273", "pikasTech/HWLAB#276", "pikasTech/HWLAB#278", "pikasTech/HWLAB#287", "pikasTech/HWLAB#288", "pikasTech/HWLAB#352", "pikasTech/HWLAB#99", "pikasTech/HWLAB#227"],
+ refs: ["pikasTech/HWLAB#273", "pikasTech/HWLAB#276", "pikasTech/HWLAB#278", "pikasTech/HWLAB#287", "pikasTech/HWLAB#288", "pikasTech/HWLAB#352", "pikasTech/HWLAB#437", "pikasTech/HWLAB#239", "pikasTech/HWLAB#99", "pikasTech/HWLAB#227"],
viewports: layoutViewports.map(({ id, width, height }) => ({ id, width, height })),
checks,
blockers,
@@ -3171,22 +3177,26 @@ async function inspectLiveDom(url, options = {}) {
liveBuildListVisibleWhenClosed: visible("#live-build-list"),
liveBuildDetailWhenOpen: (() => {
const details = document.querySelector("#live-build-summary");
+ const popover = document.querySelector("#live-build-popover");
const list = document.querySelector("#live-build-list");
- if (!details || !list) {
+ if (!details || !popover || !list) {
return {
visible: false,
rows: 0,
scrollContained: false,
+ overlay: false,
text: "",
overflowX: 0
};
}
details.open = true;
+ const popoverStyle = getComputedStyle(popover);
const rows = [...list.querySelectorAll(".live-build-row")];
return {
visible: visible("#live-build-list"),
rows: rows.length,
- scrollContained: list.clientHeight <= Math.ceil(window.innerHeight * 0.42) + 4,
+ scrollContained: list.clientHeight <= Math.ceil(window.innerHeight * 0.58) + 4,
+ overlay: popoverStyle.position === "absolute",
text: list.textContent?.replace(/\s+/gu, " ").trim() ?? "",
overflowX: Math.max(0, ...rows.map((row) => row.scrollWidth - row.clientWidth), list.scrollWidth - list.clientWidth)
};
@@ -7830,36 +7840,103 @@ async function inspectLayoutState(page, { mode, viewport, compareTo }) {
const inspectLiveBuildLayout = async () => {
const details = document.querySelector("#live-build-summary");
const label = document.querySelector("#live-build-latest");
+ const toggle = document.querySelector("#live-build-toggle");
+ const popover = document.querySelector("#live-build-popover");
const list = document.querySelector("#live-build-list");
- if (!details || !label || !list) {
+ const topbar = document.querySelector(".topbar");
+ const workspace = document.querySelector('[data-view="workspace"]');
+ const conversation = document.querySelector(".conversation-column");
+ const boxSnapshot = () => ({
+ topbar: boxForElement(topbar),
+ workspace: boxForElement(workspace),
+ conversation: boxForElement(conversation)
+ });
+ if (!details || !label || !toggle || !popover || !list || !topbar || !workspace || !conversation) {
return { ok: false, exists: false, summaryOverflowX: null, listOverflowX: null, maxRowOverflowX: null, rows: 0 };
}
const wasOpen = details.open;
+ const labelBox = boxForElement(label);
+ const detailsBox = boxForElement(details);
+ const labelStyle = getComputedStyle(label);
const summaryOverflowX = Math.max(0, label.scrollWidth - label.clientWidth);
+ const summaryContained =
+ Boolean(labelBox && detailsBox) &&
+ labelBox.left >= detailsBox.left - 1 &&
+ labelBox.right <= detailsBox.right + 1 &&
+ labelStyle.overflow === "hidden" &&
+ labelStyle.textOverflow === "ellipsis" &&
+ labelStyle.whiteSpace === "nowrap";
+ details.open = false;
+ await new Promise((resolve) => requestAnimationFrame(resolve));
+ const before = boxSnapshot();
details.open = true;
await new Promise((resolve) => requestAnimationFrame(resolve));
const rows = [...list.querySelectorAll(".live-build-row")];
const rowOverflows = rows.map((row) => Math.max(0, row.scrollWidth - row.clientWidth));
const maxRowOverflowX = Math.max(0, ...rowOverflows);
const listOverflowX = Math.max(0, list.scrollWidth - list.clientWidth);
- const scrollContained = list.clientHeight <= Math.ceil(window.innerHeight * 0.42) + 4;
+ const popoverBox = boxForElement(popover);
+ const popoverStyle = getComputedStyle(popover);
+ const toggleExpanded = toggle.getAttribute("aria-expanded") === "true";
+ const popoverVisible = popoverBox && popoverBox.width > 0 && popoverBox.height > 0 && popoverStyle.display !== "none";
+ const popoverViewportContained =
+ Boolean(popoverBox) &&
+ popoverBox.left >= -1 &&
+ popoverBox.right <= window.innerWidth + 1 &&
+ popoverBox.top >= -1 &&
+ popoverBox.bottom <= window.innerHeight + 1;
+ const overlayPositioned = popoverStyle.position === "absolute";
+ const scrollContained = list.clientHeight <= Math.ceil(window.innerHeight * 0.58) + 4;
const text = list.textContent?.replace(/\s+/gu, " ").trim() ?? "";
const expectedRows = rows.length >= 4;
const expectedText = /构建时间不可用/u.test(text) && /外部镜像或非 HWLAB 构建产物/u.test(text);
+ const afterOpen = boxSnapshot();
+ const stableGeometry = ["topbar", "workspace", "conversation"].every((key) => {
+ const a = before[key];
+ const b = afterOpen[key];
+ return a && b && Math.abs(a.top - b.top) <= 1 && Math.abs(a.height - b.height) <= 1;
+ });
+ document.querySelector("#live-build-close")?.click();
+ await new Promise((resolve) => requestAnimationFrame(resolve));
+ const closedByButton = details.open === false && toggle.getAttribute("aria-expanded") === "false";
details.open = wasOpen;
await new Promise((resolve) => requestAnimationFrame(resolve));
return {
- ok: summaryOverflowX <= 1 && listOverflowX <= 1 && maxRowOverflowX <= 1 && scrollContained && expectedRows && expectedText,
+ ok:
+ summaryContained &&
+ listOverflowX <= 1 &&
+ maxRowOverflowX <= 1 &&
+ scrollContained &&
+ expectedRows &&
+ expectedText &&
+ overlayPositioned &&
+ popoverVisible &&
+ popoverViewportContained &&
+ toggleExpanded &&
+ stableGeometry &&
+ closedByButton,
exists: true,
openStateRestored: details.open === wasOpen,
+ summaryContained,
summaryOverflowX,
+ labelBox,
+ detailsBox,
listOverflowX,
maxRowOverflowX,
rowOverflows,
rows: rows.length,
expectedRows,
expectedText,
+ overlayPositioned,
+ popoverVisible,
+ popoverViewportContained,
+ toggleExpanded,
scrollContained,
+ stableGeometry,
+ closedByButton,
+ before,
+ afterOpen,
+ popoverBox,
textSample: text.slice(0, 240)
};
};
@@ -8231,6 +8308,7 @@ async function inspectLayoutState(page, { mode, viewport, compareTo }) {
rightResizeHandle,
sidePanelScroll,
sidePanelOverflowUsable,
+ liveBuildOverlayStable: liveBuildLayout.ok,
hardwareTabsCenterHit,
wiring: {
longTableOk: wiringLongTableOk,
@@ -8640,7 +8718,8 @@ function summarizeCoverageGap(viewportResults, issueKey) {
issue288: "issue288SingleTableReady",
issue287: "issue287HardwareTabsReady",
hardwareTabs: "hardwareTabsCenterHit",
- wiring: "wiringReadableTwoColumn"
+ wiring: "wiringReadableTwoColumn",
+ liveBuildOverlay: "liveBuildOverlayStable"
}[issueKey] ?? "issue287HardwareTabsReady";
return {
status: allViewportCoverage(viewportResults, fieldName) ? "pass" : "skip",
diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs
index 40222e80..23a36ec4 100644
--- a/web/hwlab-cloud-web/app.mjs
+++ b/web/hwlab-cloud-web/app.mjs
@@ -104,6 +104,9 @@ const el = {
liveStatus: byId("live-status"),
liveDetail: byId("live-detail"),
liveBuildSummary: byId("live-build-summary"),
+ liveBuildToggle: byId("live-build-toggle"),
+ liveBuildPopover: byId("live-build-popover"),
+ liveBuildClose: byId("live-build-close"),
liveBuildLatest: byId("live-build-latest"),
liveBuildList: byId("live-build-list"),
agentChatStatus: byId("agent-chat-status"),
@@ -196,6 +199,7 @@ initSideTabs();
initHardwareTabs();
initQuickActions();
initCommandBar();
+initLiveBuildOverlay();
initWorkbenchLogout(el.logoutButton);
initM3Control();
initGateControls();
@@ -228,6 +232,35 @@ function resolveTimeoutMs(name, fallback, { min, max }) {
return Math.min(Math.max(Math.trunc(configured), min), max);
}
+function initLiveBuildOverlay() {
+ syncLiveBuildOverlayState();
+ el.liveBuildSummary.addEventListener("toggle", syncLiveBuildOverlayState);
+ el.liveBuildClose.addEventListener("click", () => closeLiveBuildOverlay({ restoreFocus: true }));
+ document.addEventListener("keydown", (event) => {
+ if (event.key === "Escape" && el.liveBuildSummary.open) {
+ event.preventDefault();
+ closeLiveBuildOverlay({ restoreFocus: true });
+ }
+ });
+ document.addEventListener("click", (event) => {
+ if (!el.liveBuildSummary.open || el.liveBuildSummary.contains(event.target)) return;
+ closeLiveBuildOverlay();
+ });
+}
+
+function syncLiveBuildOverlayState() {
+ const expanded = el.liveBuildSummary.open === true;
+ el.liveBuildToggle.setAttribute("aria-expanded", expanded ? "true" : "false");
+ el.liveBuildPopover.setAttribute("aria-hidden", expanded ? "false" : "true");
+}
+
+function closeLiveBuildOverlay(options = {}) {
+ if (!el.liveBuildSummary.open) return;
+ el.liveBuildSummary.open = false;
+ syncLiveBuildOverlayState();
+ if (options.restoreFocus === true) el.liveBuildToggle.focus();
+}
+
function initRoutes() {
window.addEventListener("hashchange", () => showView(routeFromLocation()));
for (const button of document.querySelectorAll("[data-route]")) {
diff --git a/web/hwlab-cloud-web/index.html b/web/hwlab-cloud-web/index.html
index 6ee2c40c..92a03072 100644
--- a/web/hwlab-cloud-web/index.html
+++ b/web/hwlab-cloud-web/index.html
@@ -72,10 +72,16 @@
等待验证
正在验证 hwlab-cloud-api /health/live、/v1、/v1/agent/chat 与 /v1/m3/io;未完成前不标为通过。
-
+
最新镜像构建时间:等待实时元数据
-
+
diff --git a/web/hwlab-cloud-web/scripts/check.mjs b/web/hwlab-cloud-web/scripts/check.mjs
index 2db1ca61..3c49fc0c 100644
--- a/web/hwlab-cloud-web/scripts/check.mjs
+++ b/web/hwlab-cloud-web/scripts/check.mjs
@@ -278,6 +278,11 @@ assert.equal(
"pass",
"#352 resource explorer removal must remain covered by desktop and 390x844 layout smoke"
);
+assert.equal(
+ layoutSmoke.checks.find((check) => check.id === "layout-feedback-437-live-build-overlay")?.status,
+ "pass",
+ "#437 live build details overlay must not move the topbar, workspace, or conversation geometry"
+);
assert.equal(
layoutSmoke.checks.find((check) => check.id === "layout-left-sidebar-collapse")?.status,
"pass",
@@ -293,6 +298,7 @@ for (const layoutCheckId of [
"layout-narrow-desktop-default",
"layout-mobile-default",
"layout-feedback-352-resource-explorer-removed",
+ "layout-feedback-437-live-build-overlay",
"layout-left-sidebar-collapse",
"layout-right-sidebar-resize-desktop",
"layout-right-sidebar-resize-narrow-desktop",
@@ -934,7 +940,13 @@ assert.match(app, /fetchJson\("\/v1\/diagnostics\/gate"/);
assert.match(app, /fetchJson\("\/v1\/live-builds"\)/);
assert.match(html, /id="live-build-latest"/);
assert.match(html, /id="live-build-list"/);
+assert.match(html, /id="live-build-toggle"[^>]*aria-controls="live-build-popover"[^>]*aria-expanded="false"/);
+assert.match(html, /id="live-build-popover"[^>]*role="dialog"[^>]*aria-modal="false"/);
+assert.match(html, /id="live-build-close"/);
assert.match(styles, /\.live-build-summary\s*{/);
+assert.match(app, /initLiveBuildOverlay/);
+assert.match(app, /closeLiveBuildOverlay/);
+assert.match(app, /aria-expanded/);
assert.match(app, /formatBeijingTime/);
assert.match(app, /Asia\/Shanghai/);
assert.match(app, /最新镜像构建时间/);
@@ -946,9 +958,12 @@ assert.match(app, /liveBuildDesiredStateText/);
assert.match(app, /外部镜像或非 HWLAB 构建产物/);
assert.doesNotMatch(app, /artifact report|artifact-report/);
assert.match(app, /live health/);
-assert.match(styles, /\.live-build-summary-label\s*{[^}]*min-width:\s*0;[^}]*max-width:\s*100%;[^}]*overflow-wrap:\s*anywhere;/s);
-assert.match(styles, /\.live-build-list\s*{[^}]*max-height:\s*min\(280px,\s*42dvh\);[^}]*min-width:\s*0;[^}]*overflow:\s*auto;/s);
+assert.match(styles, /\.live-build-summary-label\s*{[^}]*min-width:\s*0;[^}]*max-width:\s*100%;[^}]*overflow:\s*hidden;[^}]*text-overflow:\s*ellipsis;[^}]*white-space:\s*nowrap;/s);
+assert.match(styles, /\.live-build-popover\s*{[^}]*position:\s*absolute;[^}]*max-height:\s*min\(420px,\s*58dvh\);[^}]*overflow:\s*hidden;/s);
+assert.match(styles, /\.live-build-summary\[open\]\s+\.live-build-popover\s*{[^}]*display:\s*grid;/s);
+assert.match(styles, /\.live-build-list\s*{[^}]*min-width:\s*0;[^}]*max-height:\s*min\(352px,\s*calc\(58dvh - 54px\)\);[^}]*overflow:\s*auto;/s);
assert.match(styles, /\.live-build-row-head\s*{[^}]*grid-template-columns:\s*minmax\(0,\s*0\.8fr\)\s*minmax\(0,\s*1\.2fr\);/s);
+assert.match(styles, /@media \(max-width: 860px\)[\s\S]*?\.live-build-popover\s*{[\s\S]*?width:\s*100%;/);
assert.match(styles, /@media \(max-width: 860px\)[\s\S]*?\.live-build-row-head\s*{[\s\S]*?grid-template-columns:\s*1fr;/);
assert.match(styles, /\.live-build-meta span\s*{[^}]*overflow-wrap:\s*anywhere;/s);
assert.match(app, /fetchJson\("\/health\/live"\)/);
diff --git a/web/hwlab-cloud-web/styles.css b/web/hwlab-cloud-web/styles.css
index 57f952ac..4c049cf9 100644
--- a/web/hwlab-cloud-web/styles.css
+++ b/web/hwlab-cloud-web/styles.css
@@ -504,6 +504,7 @@ h3 {
}
.probe-card {
+ position: relative;
min-width: 0;
display: grid;
align-content: center;
@@ -526,6 +527,7 @@ h3 {
}
.live-build-summary {
+ position: relative;
min-width: 0;
max-width: 100%;
margin-top: 4px;
@@ -543,19 +545,88 @@ h3 {
font-size: 10px;
line-height: 1.35;
cursor: pointer;
- overflow-wrap: anywhere;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ list-style-position: inside;
+}
+
+.live-build-summary summary::marker {
+ color: var(--accent-2);
}
.live-build-summary-label {
min-width: 0;
max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
overflow-wrap: anywhere;
}
-.live-build-list {
- max-height: min(280px, 42dvh);
+.live-build-popover {
+ position: absolute;
+ z-index: 30;
+ top: calc(100% + 8px);
+ right: 0;
+ width: min(100%, 560px);
+ max-width: 100%;
+ max-height: min(420px, 58dvh);
+ display: none;
+ grid-template-rows: auto minmax(0, 1fr);
+ gap: 8px;
+ padding: 10px;
+ background: rgba(20, 22, 20, 0.99);
+ border: 1px solid var(--line-strong);
+ box-shadow: 0 18px 42px rgba(0, 0, 0, 0.42);
+ overflow: hidden;
+}
+
+.live-build-summary[open] .live-build-popover {
+ display: grid;
+}
+
+.live-build-popover-head {
min-width: 0;
- margin-top: 7px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+}
+
+.live-build-popover-head strong {
+ min-width: 0;
+ color: var(--text);
+ font-family: var(--mono);
+ font-size: 11px;
+ line-height: 1.2;
+ text-transform: none;
+ overflow-wrap: anywhere;
+}
+
+.live-build-close {
+ flex: 0 0 auto;
+ min-height: 26px;
+ padding: 3px 8px;
+ border: 1px solid var(--line);
+ background: var(--surface-2);
+ color: var(--muted);
+ font-family: var(--mono);
+ font-size: 10px;
+ font-weight: 760;
+ cursor: pointer;
+}
+
+.live-build-close:hover,
+.live-build-close:focus-visible {
+ border-color: var(--line-strong);
+ color: var(--text);
+ outline: 0;
+}
+
+.live-build-list {
+ min-width: 0;
+ max-height: min(352px, calc(58dvh - 54px));
display: grid;
gap: 6px;
overflow: auto;
@@ -2261,6 +2332,18 @@ tbody tr:last-child td {
gap: 2px;
}
+ .live-build-popover {
+ right: 0;
+ left: auto;
+ width: 100%;
+ max-width: 100%;
+ max-height: min(420px, 56dvh);
+ }
+
+ .live-build-list {
+ max-height: min(352px, calc(56dvh - 54px));
+ }
+
.live-build-time {
text-align: left;
}