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; }