Merge pull request #385 from pikasTech/feat/278-sidebar-collapse
feat: add 16666 workbench left sidebar collapse
This commit is contained in:
@@ -727,6 +727,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-left-sidebar-collapse",
|
||||
"layout-gate-desktop",
|
||||
"layout-gate-narrow-desktop",
|
||||
"layout-gate-mobile"
|
||||
@@ -791,9 +792,19 @@ test("layout smoke verifies desktop and mobile default workbench geometry withou
|
||||
assert.equal(mobileDefault.wiring.metadataInDetails, true);
|
||||
assert.equal(mobileDefault.noHorizontalOverflow.right, true);
|
||||
|
||||
const leftCollapse = report.checks.find((check) => check.id === "layout-left-sidebar-collapse")?.observations;
|
||||
assert.equal(leftCollapse.status, "pass");
|
||||
assert.equal(leftCollapse.coverage.desktop.widthReclaimed, true);
|
||||
assert.equal(leftCollapse.coverage["narrow-desktop"].widthReclaimed, true);
|
||||
assert.equal(leftCollapse.coverage.mobile.widthReclaimed, true);
|
||||
assert.equal(leftCollapse.coverage.desktop.collapsed.keyTargetsReachable, true);
|
||||
assert.equal(leftCollapse.coverage.mobile.collapsed.keyTargetsReachable, true);
|
||||
assert.equal(leftCollapse.coverage.desktop.restoredCollapsed.collapsed, true);
|
||||
assert.equal(leftCollapse.coverage.mobile.expanded.routeButtonsVisible, true);
|
||||
|
||||
const compact = compactLayoutSmokeCliOutput(report);
|
||||
assert.equal(compact.status, "pass");
|
||||
assert.equal(compact.artifacts.screenshotCount, 12);
|
||||
assert.equal(compact.artifacts.screenshotCount, 21);
|
||||
assert.equal(compact.failures.length, 0);
|
||||
assert.equal(compact.blockers.length, 0);
|
||||
assert.equal(JSON.stringify(compact).includes('"checks"'), false);
|
||||
|
||||
@@ -268,6 +268,7 @@ const workbenchMarkers = Object.freeze([
|
||||
"data-app-shell",
|
||||
"workbench-shell",
|
||||
"activity-rail",
|
||||
"left-sidebar-toggle",
|
||||
"center-workspace",
|
||||
"conversation-list",
|
||||
"agent-chat-status",
|
||||
@@ -1130,6 +1131,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-left-sidebar-collapse",
|
||||
status: Object.values(viewportResults).every((result) => result.leftCollapse?.pass) ? "pass" : "blocked",
|
||||
summary: "#278 left activity rail collapses and expands on desktop and mobile, persists in localStorage, and gives Code Agent plus M3 panels the reclaimed space without restoring the removed resource explorer.",
|
||||
observations: summarizeLeftSidebarCollapseCoverage(viewportResults)
|
||||
},
|
||||
{
|
||||
id: "layout-right-sidebar-resize-desktop",
|
||||
status: desktop.rightResize.pass ? "pass" : "blocked",
|
||||
@@ -1265,6 +1272,7 @@ export async function runDevCloudWorkbenchLayoutSmoke(args = {}) {
|
||||
screenshotDir: artifactRoot,
|
||||
screenshots: Object.values(viewportResults).flatMap((result) => [
|
||||
...(result.artifacts?.screenshots ?? []),
|
||||
...(result.leftCollapse?.artifacts?.screenshots ?? []),
|
||||
...(result.gate?.currentRoute?.artifacts?.screenshots ?? [])
|
||||
]),
|
||||
reportPath: args.reportPath ?? null
|
||||
@@ -1429,7 +1437,7 @@ function layoutSourceContract() {
|
||||
"docs/reference/cloud-workbench.md",
|
||||
"docs/reference/code-agent-chat-readiness.md"
|
||||
],
|
||||
summary: "Cloud Workbench layout smoke protects the #99 workbench route, #227 visible M3 control area, #352 resource-explorer removal, Code Agent input, right hardware/trusted-record containers, /gate current route, and outer-scroll lock without claiming hardware acceptance."
|
||||
summary: "Cloud Workbench layout smoke protects the #99 workbench route, #227 visible M3 control area, #278 left navigation collapse/expand, #352 resource-explorer removal, Code Agent input, right hardware/trusted-record containers, /gate current route, and outer-scroll lock without claiming hardware acceptance."
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2276,14 +2284,30 @@ function hasResourceExplorerRemovalContract({ html, app, styles }) {
|
||||
return (
|
||||
removedSelectorsAbsent &&
|
||||
removedTextAbsent &&
|
||||
/id=["']activity-rail["']/u.test(html) &&
|
||||
/id=["']left-sidebar-toggle["']/u.test(html) &&
|
||||
/aria-label=["']折叠左侧导航["']/u.test(html) &&
|
||||
/title=["']折叠左侧导航["']/u.test(html) &&
|
||||
/aria-controls=["']activity-rail["']/u.test(html) &&
|
||||
/aria-expanded=["']true["']/u.test(html) &&
|
||||
/LAYOUT_STORAGE_KEY\s*=\s*["']hwlab\.workbench\.layout\.v1["']/u.test(app) &&
|
||||
/leftSidebarCollapsed:\s*payload\.leftSidebarCollapsed === true/u.test(app) &&
|
||||
/leftSidebarCollapsed:\s*state\.layout\.leftSidebarCollapsed/u.test(app) &&
|
||||
/function\s+initLeftSidebarToggle\s*\(/u.test(app) &&
|
||||
/function\s+setLeftSidebarCollapsed\s*\(/u.test(app) &&
|
||||
/is-left-sidebar-collapsed/u.test(app) &&
|
||||
/展开左侧导航/u.test(app) &&
|
||||
/折叠左侧导航/u.test(app) &&
|
||||
/setPointerCapture/u.test(app) &&
|
||||
/localStorage\?\.setItem\(\s*LAYOUT_STORAGE_KEY/u.test(app) &&
|
||||
/event\.key === "Home"/u.test(app) &&
|
||||
/event\.key === "End"/u.test(app) &&
|
||||
/ArrowLeft/u.test(app) &&
|
||||
/ArrowRight/u.test(app) &&
|
||||
/--rail-collapsed-width:\s*44px/u.test(styles) &&
|
||||
/\.workbench-shell\s*\{[^}]*grid-template-columns:\s*var\(--rail-width\)\s+minmax\(420px,\s*1fr\)\s+var\(--right-width-expanded\);/su.test(styles) &&
|
||||
/\.workbench-shell\.is-left-sidebar-collapsed\s*\{[^}]*--rail-width:\s*var\(--rail-collapsed-width\);/su.test(styles) &&
|
||||
/\.workbench-shell\.is-left-sidebar-collapsed \.activity-rail \.rail-button:not\(\.sidebar-toggle\),\s*\n\.workbench-shell\.is-left-sidebar-collapsed \.rail-spacer\s*\{[^}]*display:\s*none;/su.test(styles) &&
|
||||
/@media\s*\(max-width:\s*1240px\)[\s\S]*?\.workbench-shell\s*\{[\s\S]*?grid-template-columns:\s*var\(--rail-width\)\s+minmax\(0,\s*1fr\);/u.test(styles) &&
|
||||
/id=["']hardware-sidebar["']/u.test(html) &&
|
||||
/id=["']right-sidebar-resize["']/u.test(html) &&
|
||||
@@ -6233,11 +6257,12 @@ async function inspectWorkbenchLayoutViewport(browser, url, viewport, options =
|
||||
compareTo: null
|
||||
});
|
||||
artifacts.screenshots.push(...await captureLayoutScreenshots(page, options.artifactRoot, viewport, defaultLayout.mode));
|
||||
const leftCollapse = await inspectLeftSidebarCollapse(page, { viewport, artifactRoot: options.artifactRoot });
|
||||
const rightResize = viewport.width > 1240
|
||||
? await inspectRightSidebarResizeDesktop(page, { viewport })
|
||||
: await inspectRightSidebarResizeDisabled(page, { viewport });
|
||||
const gate = await inspectWorkbenchGateLayout(browser, url, viewport, options);
|
||||
return { default: defaultLayout, rightResize, gate, artifacts };
|
||||
return { default: defaultLayout, leftCollapse, rightResize, gate, artifacts };
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
@@ -6361,6 +6386,250 @@ async function dragRightSidebarResizeHandle(page, targetWidth) {
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
async function inspectLeftSidebarCollapse(page, { viewport, artifactRoot }) {
|
||||
const before = await leftSidebarCollapseMetrics(page);
|
||||
const beforeScreenshot = await captureElementScreenshot(
|
||||
page,
|
||||
"[data-app-shell]",
|
||||
artifactRoot,
|
||||
viewport,
|
||||
"left-sidebar-expanded-before"
|
||||
);
|
||||
await page.locator("#left-sidebar-toggle").click();
|
||||
await page.waitForTimeout(100);
|
||||
const collapsed = await leftSidebarCollapseMetrics(page);
|
||||
const collapsedScreenshot = await captureElementScreenshot(
|
||||
page,
|
||||
"[data-app-shell]",
|
||||
artifactRoot,
|
||||
viewport,
|
||||
"left-sidebar-collapsed"
|
||||
);
|
||||
await page.reload({ waitUntil: "domcontentloaded", timeout: 15000 });
|
||||
await page.locator("#command-input").waitFor({ state: "visible", timeout: 12000 });
|
||||
await page.waitForTimeout(150);
|
||||
const restoredCollapsed = await leftSidebarCollapseMetrics(page);
|
||||
await page.locator("#left-sidebar-toggle").click();
|
||||
await page.waitForTimeout(100);
|
||||
const expanded = await leftSidebarCollapseMetrics(page);
|
||||
const expandedScreenshot = await captureElementScreenshot(
|
||||
page,
|
||||
"[data-app-shell]",
|
||||
artifactRoot,
|
||||
viewport,
|
||||
"left-sidebar-expanded-after"
|
||||
);
|
||||
const widthReclaimed = collapsed.centerWidth >= before.centerWidth + Math.min(12, Math.max(0, before.railWidth - collapsed.railWidth - 2));
|
||||
const pass =
|
||||
before.toggle.ariaExpanded === "true" &&
|
||||
before.toggle.ariaLabel === "折叠左侧导航" &&
|
||||
before.toggle.title === "折叠左侧导航" &&
|
||||
before.routeButtonsVisible &&
|
||||
collapsed.toggle.ariaExpanded === "false" &&
|
||||
collapsed.toggle.ariaLabel === "展开左侧导航" &&
|
||||
collapsed.toggle.title === "展开左侧导航" &&
|
||||
collapsed.railWidth < before.railWidth - 12 &&
|
||||
widthReclaimed &&
|
||||
collapsed.routeButtonsVisible === false &&
|
||||
collapsed.keyTargetsReachable &&
|
||||
collapsed.resourceExplorerRemoved &&
|
||||
collapsed.rootScrollLocked &&
|
||||
collapsed.noHorizontalOverflow &&
|
||||
restoredCollapsed.collapsed === true &&
|
||||
restoredCollapsed.storage?.leftSidebarCollapsed === true &&
|
||||
restoredCollapsed.keyTargetsReachable &&
|
||||
expanded.toggle.ariaExpanded === "true" &&
|
||||
expanded.toggle.ariaLabel === "折叠左侧导航" &&
|
||||
expanded.railWidth >= before.railWidth - 2 &&
|
||||
Math.abs(expanded.centerWidth - before.centerWidth) <= 3 &&
|
||||
expanded.routeButtonsVisible &&
|
||||
expanded.storage?.leftSidebarCollapsed === false &&
|
||||
expanded.keyTargetsReachable &&
|
||||
expanded.resourceExplorerRemoved &&
|
||||
expanded.rootScrollLocked &&
|
||||
expanded.noHorizontalOverflow;
|
||||
return {
|
||||
viewport,
|
||||
pass,
|
||||
widthReclaimed,
|
||||
before,
|
||||
collapsed,
|
||||
restoredCollapsed,
|
||||
expanded,
|
||||
artifacts: {
|
||||
screenshots: [beforeScreenshot, collapsedScreenshot, expandedScreenshot].filter(Boolean)
|
||||
},
|
||||
failures: pass ? [] : [
|
||||
{
|
||||
selector: "#left-sidebar-toggle",
|
||||
failureType: "left-sidebar-collapse-regression",
|
||||
viewport,
|
||||
summary: "#278 left sidebar collapse/expand did not reclaim space, persist state, or preserve key workbench controls."
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
async function leftSidebarCollapseMetrics(page) {
|
||||
return page.evaluate(async () => {
|
||||
const shell = document.querySelector("[data-app-shell]");
|
||||
const rail = document.querySelector("#activity-rail");
|
||||
const center = document.querySelector(".center-workspace");
|
||||
const right = document.querySelector("#hardware-sidebar");
|
||||
const toggle = document.querySelector("#left-sidebar-toggle");
|
||||
const routeButtons = [...document.querySelectorAll(".activity-rail [data-route]")];
|
||||
const ownsHit = (element, hit) => hit === element || element.contains(hit);
|
||||
const hitLabel = (hit) => hit ? `${hit.tagName.toLowerCase()}${hit.id ? `#${hit.id}` : ""}${hit.className ? `.${String(hit.className).trim().replace(/\s+/gu, ".")}` : ""}` : "none";
|
||||
const nearestScrollableAncestor = (element) => {
|
||||
let current = element.parentElement;
|
||||
while (current && current !== document.documentElement) {
|
||||
const style = getComputedStyle(current);
|
||||
const scrollable = current.scrollHeight > current.clientHeight + 2 && !["visible", "clip"].includes(style.overflowY);
|
||||
if (scrollable) return current;
|
||||
current = current.parentElement;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const scrollIntoNearestContainer = async (element) => {
|
||||
const container = nearestScrollableAncestor(element);
|
||||
if (!container) {
|
||||
element.scrollIntoView({ block: "center", inline: "nearest" });
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
return null;
|
||||
}
|
||||
const elementBox = element.getBoundingClientRect();
|
||||
const containerBox = container.getBoundingClientRect();
|
||||
const inset = Math.max(6, Math.min(24, (container.clientHeight - Math.min(elementBox.height, container.clientHeight)) / 2));
|
||||
container.scrollTop += elementBox.top - containerBox.top - inset;
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
return container;
|
||||
};
|
||||
const clipBoxForElement = (element) => {
|
||||
let visible = {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: window.innerWidth,
|
||||
bottom: window.innerHeight
|
||||
};
|
||||
let current = element.parentElement;
|
||||
while (current && current !== document.documentElement) {
|
||||
const style = getComputedStyle(current);
|
||||
if (!["visible", "clip"].includes(style.overflow) || !["visible", "clip"].includes(style.overflowY) || !["visible", "clip"].includes(style.overflowX)) {
|
||||
const box = current.getBoundingClientRect();
|
||||
visible = {
|
||||
left: Math.max(visible.left, box.left),
|
||||
top: Math.max(visible.top, box.top),
|
||||
right: Math.min(visible.right, box.right),
|
||||
bottom: Math.min(visible.bottom, box.bottom)
|
||||
};
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
return visible;
|
||||
};
|
||||
const inspectTarget = async (selector) => {
|
||||
const element = document.querySelector(selector);
|
||||
if (!element) return { selector, ok: false, missing: true };
|
||||
const scrollContainer = await scrollIntoNearestContainer(element);
|
||||
const box = element.getBoundingClientRect();
|
||||
const clipBox = clipBoxForElement(element);
|
||||
const visibleBox = {
|
||||
left: Math.max(box.left, clipBox.left),
|
||||
top: Math.max(box.top, clipBox.top),
|
||||
right: Math.min(box.right, clipBox.right),
|
||||
bottom: Math.min(box.bottom, clipBox.bottom)
|
||||
};
|
||||
const visibleWidth = Math.max(0, visibleBox.right - visibleBox.left);
|
||||
const visibleHeight = Math.max(0, visibleBox.bottom - visibleBox.top);
|
||||
const x = visibleBox.left + visibleWidth / 2;
|
||||
const y = visibleBox.top + visibleHeight / 2;
|
||||
const stack = document.elementsFromPoint(x, y);
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
selector,
|
||||
ok:
|
||||
style.display !== "none" &&
|
||||
style.visibility !== "hidden" &&
|
||||
box.width > 0 &&
|
||||
box.height > 0 &&
|
||||
visibleWidth > 0 &&
|
||||
visibleHeight > 0 &&
|
||||
x >= 0 &&
|
||||
x <= window.innerWidth &&
|
||||
y >= 0 &&
|
||||
y <= window.innerHeight &&
|
||||
stack.some((candidate) => ownsHit(element, candidate)),
|
||||
hit: hitLabel(stack[0] ?? null),
|
||||
visibleBox,
|
||||
scrollContainer: scrollContainer ? scrollContainer.id ? `#${scrollContainer.id}` : scrollContainer.className ? `.${String(scrollContainer.className).trim().replace(/\s+/gu, ".")}` : scrollContainer.tagName.toLowerCase() : null
|
||||
};
|
||||
};
|
||||
const keyTargets = [];
|
||||
for (const selector of [
|
||||
"#left-sidebar-toggle",
|
||||
"#command-input",
|
||||
"#command-send",
|
||||
"#m3-write-do",
|
||||
"#m3-read-di",
|
||||
"#tab-control",
|
||||
"#tab-wiring",
|
||||
"#tab-records",
|
||||
"[data-hardware-tab='overview']",
|
||||
"[data-hardware-tab='gateways']",
|
||||
"[data-hardware-tab='box1']",
|
||||
"[data-hardware-tab='box2']",
|
||||
"[data-hardware-tab='patch']"
|
||||
]) {
|
||||
keyTargets.push(await inspectTarget(selector));
|
||||
}
|
||||
let storage = null;
|
||||
try {
|
||||
storage = JSON.parse(localStorage.getItem("hwlab.workbench.layout.v1") ?? "null");
|
||||
} catch {
|
||||
storage = "parse_failed";
|
||||
}
|
||||
const railBox = rail?.getBoundingClientRect();
|
||||
const centerBox = center?.getBoundingClientRect();
|
||||
const rightBox = right?.getBoundingClientRect();
|
||||
return {
|
||||
viewport: { width: window.innerWidth, height: window.innerHeight },
|
||||
collapsed: shell?.classList.contains("is-left-sidebar-collapsed") === true,
|
||||
storage,
|
||||
shellColumns: shell ? getComputedStyle(shell).gridTemplateColumns : "",
|
||||
railWidth: railBox?.width ?? 0,
|
||||
centerWidth: centerBox?.width ?? 0,
|
||||
rightWidth: rightBox?.width ?? 0,
|
||||
routeButtonsVisible: routeButtons.some((button) => getComputedStyle(button).display !== "none" && button.getBoundingClientRect().width > 0 && button.getBoundingClientRect().height > 0),
|
||||
toggle: {
|
||||
text: toggle?.textContent?.trim() ?? "",
|
||||
ariaExpanded: toggle?.getAttribute("aria-expanded") ?? "",
|
||||
ariaLabel: toggle?.getAttribute("aria-label") ?? "",
|
||||
title: toggle?.getAttribute("title") ?? "",
|
||||
ariaControls: toggle?.getAttribute("aria-controls") ?? ""
|
||||
},
|
||||
keyTargets,
|
||||
keyTargetsReachable: keyTargets.every((target) => target.ok),
|
||||
resourceExplorerRemoved:
|
||||
document.querySelector("#resource-explorer") === null &&
|
||||
document.querySelector("#explorer-resize") === null &&
|
||||
document.querySelector("#resource-tree") === null &&
|
||||
!document.body.textContent.includes("资源浏览与硬件资源树") &&
|
||||
!document.body.textContent.includes("只读工作区") &&
|
||||
!document.body.textContent.includes("常用能力"),
|
||||
noHorizontalOverflow:
|
||||
document.documentElement.scrollWidth <= document.documentElement.clientWidth + 2 &&
|
||||
document.body.scrollWidth <= document.body.clientWidth + 2 &&
|
||||
(!shell || shell.scrollWidth <= shell.clientWidth + 2) &&
|
||||
(!right || right.scrollWidth <= right.clientWidth + 2),
|
||||
rootScrollLocked:
|
||||
getComputedStyle(document.documentElement).overflow === "hidden" &&
|
||||
getComputedStyle(document.body).overflow === "hidden" &&
|
||||
document.documentElement.scrollHeight <= document.documentElement.clientHeight + 2 &&
|
||||
document.body.scrollHeight <= document.body.clientHeight + 2
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function rightSidebarResizeMetrics(page) {
|
||||
return page.evaluate(async () => {
|
||||
const shell = document.querySelector("[data-app-shell]");
|
||||
@@ -7482,6 +7751,76 @@ function summarizeResourceExplorerRemovalCoverage(viewportResults) {
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeLeftSidebarCollapseCoverage(viewportResults) {
|
||||
const coverageEntries = Object.entries(viewportResults).map(([id, result]) => {
|
||||
const observation = result.leftCollapse;
|
||||
return [
|
||||
id,
|
||||
{
|
||||
viewport: observation?.viewport ?? null,
|
||||
covered: observation?.pass === true,
|
||||
widthReclaimed: observation?.widthReclaimed === true,
|
||||
before: observation?.before
|
||||
? {
|
||||
railWidth: observation.before.railWidth,
|
||||
centerWidth: observation.before.centerWidth,
|
||||
toggle: observation.before.toggle,
|
||||
routeButtonsVisible: observation.before.routeButtonsVisible
|
||||
}
|
||||
: null,
|
||||
collapsed: observation?.collapsed
|
||||
? {
|
||||
railWidth: observation.collapsed.railWidth,
|
||||
centerWidth: observation.collapsed.centerWidth,
|
||||
toggle: observation.collapsed.toggle,
|
||||
routeButtonsVisible: observation.collapsed.routeButtonsVisible,
|
||||
keyTargetsReachable: observation.collapsed.keyTargetsReachable,
|
||||
resourceExplorerRemoved: observation.collapsed.resourceExplorerRemoved,
|
||||
rootScrollLocked: observation.collapsed.rootScrollLocked,
|
||||
noHorizontalOverflow: observation.collapsed.noHorizontalOverflow
|
||||
}
|
||||
: null,
|
||||
restoredCollapsed: observation?.restoredCollapsed
|
||||
? {
|
||||
collapsed: observation.restoredCollapsed.collapsed,
|
||||
storage: observation.restoredCollapsed.storage,
|
||||
keyTargetsReachable: observation.restoredCollapsed.keyTargetsReachable
|
||||
}
|
||||
: null,
|
||||
expanded: observation?.expanded
|
||||
? {
|
||||
railWidth: observation.expanded.railWidth,
|
||||
centerWidth: observation.expanded.centerWidth,
|
||||
toggle: observation.expanded.toggle,
|
||||
routeButtonsVisible: observation.expanded.routeButtonsVisible,
|
||||
storage: observation.expanded.storage,
|
||||
keyTargetsReachable: observation.expanded.keyTargetsReachable,
|
||||
resourceExplorerRemoved: observation.expanded.resourceExplorerRemoved,
|
||||
rootScrollLocked: observation.expanded.rootScrollLocked,
|
||||
noHorizontalOverflow: observation.expanded.noHorizontalOverflow
|
||||
}
|
||||
: null
|
||||
}
|
||||
];
|
||||
});
|
||||
const coverage = Object.fromEntries(coverageEntries);
|
||||
const failures = coverageEntries
|
||||
.filter(([, item]) => !item.covered)
|
||||
.map(([id, item]) => ({
|
||||
checkId: "layout-left-sidebar-collapse",
|
||||
viewport: item.viewport,
|
||||
selector: "#left-sidebar-toggle",
|
||||
failureType: "left-sidebar-collapse-regression",
|
||||
summary: `#278 left activity rail collapse guard failed in ${id} viewport.`,
|
||||
observation: item
|
||||
}));
|
||||
return {
|
||||
status: failures.length === 0 ? "pass" : "blocked",
|
||||
coverage,
|
||||
failures
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeCoverageGap(viewportResults, issueKey) {
|
||||
const fieldName = {
|
||||
issue288: "issue288SingleTableReady",
|
||||
|
||||
@@ -2225,14 +2225,14 @@ async function validateDevCloudWorkbenchLayoutReport(report, label) {
|
||||
|
||||
if (report.status !== "skip") {
|
||||
for (const requiredCheck of [
|
||||
"layout-desktop-expanded",
|
||||
"layout-desktop-collapsed",
|
||||
"layout-desktop-restored",
|
||||
"layout-narrow-desktop-expanded",
|
||||
"layout-narrow-desktop-collapsed",
|
||||
"layout-narrow-desktop-restored",
|
||||
"layout-mobile-collapsed",
|
||||
"layout-mobile-drawer",
|
||||
"layout-desktop-default",
|
||||
"layout-narrow-desktop-default",
|
||||
"layout-mobile-default",
|
||||
"layout-feedback-352-resource-explorer-removed",
|
||||
"layout-left-sidebar-collapse",
|
||||
"layout-right-sidebar-resize-desktop",
|
||||
"layout-right-sidebar-resize-narrow-desktop",
|
||||
"layout-right-sidebar-resize-mobile",
|
||||
"layout-gate-desktop",
|
||||
"layout-gate-narrow-desktop",
|
||||
"layout-gate-mobile"
|
||||
|
||||
@@ -95,6 +95,7 @@ const el = {
|
||||
loginShell: byId("login-shell"),
|
||||
shell: query("[data-app-shell]"),
|
||||
logoutButton: byId("logout-button"),
|
||||
leftSidebarToggle: byId("left-sidebar-toggle"),
|
||||
rightSidebarResize: byId("right-sidebar-resize"),
|
||||
routePath: byId("route-path"),
|
||||
liveStatus: byId("live-status"),
|
||||
@@ -164,6 +165,7 @@ const state = {
|
||||
pending: false
|
||||
},
|
||||
layout: {
|
||||
leftSidebarCollapsed: false,
|
||||
rightSidebarWidth: RIGHT_SIDEBAR_DEFAULT_WIDTH,
|
||||
rightDrag: null
|
||||
}
|
||||
@@ -171,6 +173,7 @@ const state = {
|
||||
|
||||
initRoutes();
|
||||
initLayoutSizing();
|
||||
initLeftSidebarToggle();
|
||||
initRightSidebarResize();
|
||||
initSideTabs();
|
||||
initHardwareTabs();
|
||||
@@ -221,6 +224,7 @@ function initRoutes() {
|
||||
function initLayoutSizing() {
|
||||
const persisted = readPersistedLayout();
|
||||
const rightSidebarWidth = clampRightSidebarWidth(persisted?.rightSidebarWidth ?? RIGHT_SIDEBAR_DEFAULT_WIDTH);
|
||||
setLeftSidebarCollapsed(persisted?.leftSidebarCollapsed === true, { persist: false });
|
||||
setRightSidebarWidth(rightSidebarWidth, { persist: false });
|
||||
}
|
||||
|
||||
@@ -231,6 +235,7 @@ function readPersistedLayout() {
|
||||
const payload = JSON.parse(raw);
|
||||
if (payload?.version !== 1) return null;
|
||||
return {
|
||||
leftSidebarCollapsed: payload.leftSidebarCollapsed === true,
|
||||
rightSidebarWidth: Number(payload.rightSidebarWidth)
|
||||
};
|
||||
} catch {
|
||||
@@ -244,6 +249,7 @@ function persistLayout() {
|
||||
LAYOUT_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
leftSidebarCollapsed: state.layout.leftSidebarCollapsed,
|
||||
rightSidebarWidth: state.layout.rightSidebarWidth
|
||||
})
|
||||
);
|
||||
@@ -252,6 +258,25 @@ function persistLayout() {
|
||||
}
|
||||
}
|
||||
|
||||
function initLeftSidebarToggle() {
|
||||
el.leftSidebarToggle.addEventListener("click", () => {
|
||||
setLeftSidebarCollapsed(!state.layout.leftSidebarCollapsed);
|
||||
});
|
||||
}
|
||||
|
||||
function setLeftSidebarCollapsed(collapsed, options = {}) {
|
||||
state.layout.leftSidebarCollapsed = collapsed === true;
|
||||
el.shell.classList.toggle("is-left-sidebar-collapsed", state.layout.leftSidebarCollapsed);
|
||||
el.leftSidebarToggle.setAttribute("aria-expanded", state.layout.leftSidebarCollapsed ? "false" : "true");
|
||||
el.leftSidebarToggle.setAttribute(
|
||||
"aria-label",
|
||||
state.layout.leftSidebarCollapsed ? "展开左侧导航" : "折叠左侧导航"
|
||||
);
|
||||
el.leftSidebarToggle.title = state.layout.leftSidebarCollapsed ? "展开左侧导航" : "折叠左侧导航";
|
||||
el.leftSidebarToggle.textContent = state.layout.leftSidebarCollapsed ? "展开" : "收起";
|
||||
if (options.persist !== false) persistLayout();
|
||||
}
|
||||
|
||||
function setRightSidebarWidth(width, options = {}) {
|
||||
const clamped = clampRightSidebarWidth(width);
|
||||
state.layout.rightSidebarWidth = clamped;
|
||||
|
||||
@@ -31,11 +31,20 @@
|
||||
</section>
|
||||
|
||||
<main class="workbench-shell" data-app-shell data-help-route-policy="non-default-internal-help" data-internal-help-path="/help.md" hidden aria-hidden="true">
|
||||
<aside class="activity-rail" aria-label="工作台活动栏">
|
||||
<aside class="activity-rail" id="activity-rail" aria-label="工作台活动栏">
|
||||
<button class="rail-button active" type="button" data-route="workspace" title="工作台" aria-label="工作台">工作台</button>
|
||||
<button class="rail-button" type="button" data-route="gate" title="内部复核" aria-label="内部复核">内部复核</button>
|
||||
<button class="rail-button" type="button" data-route="help" title="使用说明" aria-label="使用说明">使用说明</button>
|
||||
<span class="rail-spacer" aria-hidden="true"></span>
|
||||
<button
|
||||
class="rail-button sidebar-toggle"
|
||||
id="left-sidebar-toggle"
|
||||
type="button"
|
||||
aria-controls="activity-rail"
|
||||
aria-expanded="true"
|
||||
aria-label="折叠左侧导航"
|
||||
title="折叠左侧导航"
|
||||
>收起</button>
|
||||
</aside>
|
||||
|
||||
<section class="center-workspace">
|
||||
|
||||
@@ -269,6 +269,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-left-sidebar-collapse")?.status,
|
||||
"pass",
|
||||
"#278 left activity rail collapse/expand must remain covered by desktop and 390x844 layout smoke"
|
||||
);
|
||||
assert.equal(
|
||||
layoutSmoke.checks.find((check) => check.id === "layout-issue-288-future-single-table-gate")?.status,
|
||||
"skip",
|
||||
@@ -279,6 +284,7 @@ for (const layoutCheckId of [
|
||||
"layout-narrow-desktop-default",
|
||||
"layout-mobile-default",
|
||||
"layout-feedback-352-resource-explorer-removed",
|
||||
"layout-left-sidebar-collapse",
|
||||
"layout-right-sidebar-resize-desktop",
|
||||
"layout-right-sidebar-resize-narrow-desktop",
|
||||
"layout-right-sidebar-resize-mobile",
|
||||
@@ -410,6 +416,7 @@ assert.match(html, /href="\/styles\.css"/);
|
||||
assert.match(html, /src="\/app\.mjs"/);
|
||||
for (const workbenchElement of [
|
||||
"activity-rail",
|
||||
"left-sidebar-toggle",
|
||||
"conversation-list",
|
||||
"agent-chat-status",
|
||||
"code-agent-summary",
|
||||
@@ -459,6 +466,12 @@ for (const removedUiText of [
|
||||
assert.doesNotMatch(`${html}\n${app}\n${styles}`, new RegExp(escapeRegExp(removedUiText)), `removed resource explorer copy returned: ${removedUiText}`);
|
||||
}
|
||||
assert.match(html, /id="hardware-sidebar"/);
|
||||
assert.match(html, /id="activity-rail"/);
|
||||
assert.match(html, /id="left-sidebar-toggle"/);
|
||||
assert.match(html, /aria-label="折叠左侧导航"/);
|
||||
assert.match(html, /title="折叠左侧导航"/);
|
||||
assert.match(html, /aria-controls="activity-rail"/);
|
||||
assert.match(html, /aria-expanded="true"/);
|
||||
assert.match(html, /id="right-sidebar-resize"/);
|
||||
assert.match(html, /aria-label="拖拽调整右侧硬件状态栏宽度"/);
|
||||
assert.match(html, /aria-valuemin="560"/);
|
||||
@@ -466,6 +479,13 @@ assert.match(html, /aria-valuemax="760"/);
|
||||
assert.match(html, /aria-valuenow="620"/);
|
||||
assert.doesNotMatch(app, /setExplorerCollapsed|initExplorerResize|clampExplorerWidth|syncMobileExplorer|collapseExplorerAfterMobileAction/);
|
||||
assert.match(app, /LAYOUT_STORAGE_KEY\s*=\s*"hwlab\.workbench\.layout\.v1"/);
|
||||
assert.match(app, /leftSidebarCollapsed:\s*payload\.leftSidebarCollapsed === true/);
|
||||
assert.match(app, /leftSidebarCollapsed:\s*state\.layout\.leftSidebarCollapsed/);
|
||||
assert.match(app, /function initLeftSidebarToggle/);
|
||||
assert.match(app, /function setLeftSidebarCollapsed/);
|
||||
assert.match(app, /is-left-sidebar-collapsed/);
|
||||
assert.match(app, /展开左侧导航/);
|
||||
assert.match(app, /折叠左侧导航/);
|
||||
assert.match(app, /function initRightSidebarResize/);
|
||||
assert.match(app, /function clampRightSidebarWidth/);
|
||||
assert.match(app, /function rightSidebarResizeBounds/);
|
||||
@@ -482,6 +502,10 @@ assert.match(styles, /--right-sidebar-min-width:\s*560px/);
|
||||
assert.match(styles, /--right-sidebar-max-width:\s*760px/);
|
||||
assert.match(styles, /--right-width:\s*clamp\(var\(--right-sidebar-min-width\),\s*var\(--right-sidebar-width\),\s*var\(--right-sidebar-max-width\)\)/);
|
||||
assert.match(styles, /--right-width-expanded:\s*clamp\(var\(--right-sidebar-min-width\),\s*var\(--right-sidebar-width\),\s*var\(--right-sidebar-max-width\)\)/);
|
||||
assert.match(styles, /--rail-collapsed-width:\s*44px/);
|
||||
assert.match(styles, /\.workbench-shell\.is-left-sidebar-collapsed\s*{[^}]*--rail-width:\s*var\(--rail-collapsed-width\);/s);
|
||||
assert.match(styles, /\.workbench-shell\.is-left-sidebar-collapsed \.activity-rail \.rail-button:not\(\.sidebar-toggle\),\s*\n\.workbench-shell\.is-left-sidebar-collapsed \.rail-spacer\s*{[^}]*display:\s*none;/s);
|
||||
assert.match(styles, /\.sidebar-toggle\[aria-expanded="false"\]\s*{/);
|
||||
assert.match(styles, /\.right-sidebar-resize\s*{[^}]*cursor:\s*col-resize;/s);
|
||||
assert.match(styles, /@media \(max-width: 1240px\)[\s\S]*?\.right-sidebar-resize\s*{[\s\S]*?display:\s*none;/);
|
||||
for (const viewId of ["workspace", "gate"]) {
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
--right-sidebar-max-width: 760px;
|
||||
--right-width: clamp(var(--right-sidebar-min-width), var(--right-sidebar-width), var(--right-sidebar-max-width));
|
||||
--right-width-expanded: clamp(var(--right-sidebar-min-width), var(--right-sidebar-width), var(--right-sidebar-max-width));
|
||||
--rail-collapsed-width: 44px;
|
||||
--mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
--body: "Aptos", "Segoe UI", system-ui, sans-serif;
|
||||
}
|
||||
@@ -236,7 +237,7 @@ body > [data-app-shell][hidden] {
|
||||
|
||||
.activity-rail {
|
||||
display: grid;
|
||||
grid-template-rows: repeat(3, minmax(42px, auto)) minmax(0, 1fr);
|
||||
grid-template-rows: repeat(3, minmax(42px, auto)) minmax(0, 1fr) auto;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
background: var(--rail);
|
||||
@@ -278,6 +279,41 @@ body > [data-app-shell][hidden] {
|
||||
box-shadow: inset 3px 0 0 var(--accent);
|
||||
}
|
||||
|
||||
.sidebar-toggle {
|
||||
min-height: 34px;
|
||||
border-color: var(--line);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.sidebar-toggle[aria-expanded="false"] {
|
||||
border-color: var(--accent);
|
||||
background: var(--surface-hot);
|
||||
}
|
||||
|
||||
.workbench-shell.is-left-sidebar-collapsed {
|
||||
--rail-width: var(--rail-collapsed-width);
|
||||
}
|
||||
|
||||
.workbench-shell.is-left-sidebar-collapsed .activity-rail {
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
padding: 7px 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workbench-shell.is-left-sidebar-collapsed .activity-rail .rail-button:not(.sidebar-toggle),
|
||||
.workbench-shell.is-left-sidebar-collapsed .rail-spacer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.workbench-shell.is-left-sidebar-collapsed .sidebar-toggle {
|
||||
min-width: 0;
|
||||
padding: 4px 2px;
|
||||
font-size: 11px;
|
||||
line-height: 1.1;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.workbench-shell.is-resizing {
|
||||
cursor: col-resize;
|
||||
user-select: none;
|
||||
@@ -1847,7 +1883,7 @@ tbody tr:last-child td {
|
||||
.activity-rail {
|
||||
grid-column: 1;
|
||||
grid-row: 1 / 3;
|
||||
grid-template-rows: repeat(3, minmax(42px, auto)) minmax(12px, 1fr);
|
||||
grid-template-rows: repeat(3, minmax(42px, auto)) minmax(12px, 1fr) auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -1856,6 +1892,10 @@ tbody tr:last-child td {
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.sidebar-toggle {
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.right-sidebar-resize {
|
||||
display: none;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user