diff --git a/config/architecture-inventory.json b/config/architecture-inventory.json index 436e91e..eb229b1 100644 --- a/config/architecture-inventory.json +++ b/config/architecture-inventory.json @@ -370,9 +370,9 @@ } ], "css_layers": [ - "/shared/tokens.css?v=20260820-2", + "/shared/tokens.css?v=20260820-3", "/shared/base.css?v=20260806-1", - "/shared/shell.css?v=20260820-6", + "/shared/shell.css?v=20260820-8", "/shared/auth.css?v=20260820-5", "/shared/components/controls.css?v=20260820-2", "/shared/components/navigation.css?v=20260820-1", @@ -451,8 +451,8 @@ }, { "path": "frontend/shared/shell.css", - "bytes": 63245, - "lines": 3762 + "bytes": 63550, + "lines": 3757 }, { "path": "backend/features/heaven/engine.py", diff --git a/frontend/index.html b/frontend/index.html index 1e9cee1..d4306a9 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -17,9 +17,9 @@ document.documentElement.style.colorScheme = theme; })(); - + - + diff --git a/frontend/shared/shell.css b/frontend/shared/shell.css index 8038db0..0de2241 100644 --- a/frontend/shared/shell.css +++ b/frontend/shared/shell.css @@ -2280,9 +2280,9 @@ body.sidebar-collapsed .status-bar { align-items: center; - gap: 24px; + gap: var(--header-cluster-gap); - padding: 0 20px; + padding: 0 var(--header-pad-x); height: var(--topbar-height); } @@ -3217,8 +3217,8 @@ body.sidebar-collapsed .status-bar { /* Generic page title slot in the shell header. Kept hidden so each page owns its own in-view title header (mentor renders one inside #mentorView). */ .app-page-context { - min-width: 148px; - max-width: 280px; + min-width: var(--header-page-context-min); + max-width: var(--header-page-context-max); flex: 0 1 auto; flex-direction: column; justify-content: center; @@ -3227,6 +3227,19 @@ body.sidebar-collapsed .status-bar { display: flex; } +.app-header .app-page-context, +.main .app-header .app-page-context { + min-width: var(--header-page-context-min); + max-width: var(--header-page-context-max); + overflow: hidden; +} + +.main .app-header .app-page-context span { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + #themeModeText { display: none; } @@ -3255,11 +3268,11 @@ body.sidebar-collapsed .status-bar { .app-header .overview-strip[data-overview-expanded="true"] { position: static; display: flex; - flex: 1 1 auto; + flex: 1 0 auto; align-items: stretch; width: auto; height: auto; - min-width: 0; + min-width: max-content; min-height: 0; margin: 0; padding: 0; @@ -3271,22 +3284,29 @@ body.sidebar-collapsed .status-bar { .app-header .market-tape { display: flex; - flex: 1 1 auto; + flex: 1 0 auto; align-items: center; - min-width: 0; + min-width: max-content; gap: 0; - overflow: hidden; + overflow: visible; white-space: nowrap; } .tape-metric { position: relative; display: flex; + flex: 0 0 auto; flex-direction: column; justify-content: center; gap: 2px; - min-width: 0; - padding: 0 16px; + min-width: max-content; + padding: 0 var(--header-tape-metric-pad-x); +} + +.app-header .tape-metric, +.app-header .tape-sentiment.sentiment-block { + flex: 0 0 auto; + min-width: max-content; } .tape-metric + .tape-metric::before, @@ -3311,7 +3331,7 @@ body.sidebar-collapsed .status-bar { display: flex; align-items: center; gap: 5px; - overflow: hidden; + overflow: visible; color: var(--text-1); font-size: 15px; font-weight: 600; @@ -3400,7 +3420,7 @@ body.sidebar-collapsed .status-bar { gap: 4px; height: 28px; min-height: 28px; - margin-left: 14px; + margin-left: var(--header-tape-toggle-gap); padding: 0 10px; border: 0; border-radius: var(--radius-md); @@ -3473,7 +3493,7 @@ body.sidebar-collapsed .status-bar { display: flex; flex: 0 0 auto; align-items: center; - gap: 6px; + gap: var(--header-action-gap); margin-left: 0; overflow: visible; } @@ -3601,31 +3621,6 @@ body.sidebar-collapsed .status-bar { } } -@media (min-width: 1280px) { - .tape-metric { - padding: 0 10px; - } - - .app-header { - gap: 16px; - } -} - -@media (min-width: 1280px) and (max-width: 1599px) { - .app-page-context { - min-width: 96px; - max-width: 128px; - } - - .app-header .header-actions { - gap: 4px; - } - - .app-header .header-command-group { - gap: 4px; - } -} - @media (max-width: 1440px) { .app-page-context { display: none; @@ -3711,7 +3706,7 @@ body.sidebar-collapsed .status-bar { flex: 0 0 auto; flex-direction: row; align-items: center; - gap: 6px; + gap: var(--header-command-gap); width: auto; max-width: none; padding: 0; diff --git a/frontend/shared/tokens.css b/frontend/shared/tokens.css index 0fec78e..5391ed9 100644 --- a/frontend/shared/tokens.css +++ b/frontend/shared/tokens.css @@ -182,6 +182,14 @@ --sidebar-width: var(--size-sidebar); --sidebar-compact-width: 64px; --topbar-height: var(--size-topbar); + --header-pad-x: 20px; + --header-cluster-gap: 24px; + --header-action-gap: 6px; + --header-command-gap: 6px; + --header-tape-metric-pad-x: 16px; + --header-tape-toggle-gap: 14px; + --header-page-context-min: 148px; + --header-page-context-max: 280px; --summary-height: var(--size-summary); --statusbar-height: var(--size-statusbar); --page-pad-y: var(--size-page-pad-y); @@ -517,3 +525,16 @@ --shadow-soft: 0 1px 2px rgba(0, 0, 0, .28), 0 8px 24px rgba(0, 0, 0, .16); --shadow: 0 18px 50px rgba(0, 0, 0, .46); } + +@media (min-width: 1280px) and (max-width: 1919px) { + :root { + --header-pad-x: 12px; + --header-cluster-gap: 8px; + --header-action-gap: 4px; + --header-command-gap: 4px; + --header-tape-metric-pad-x: 6px; + --header-tape-toggle-gap: 4px; + --header-page-context-min: 96px; + --header-page-context-max: 148px; + } +} diff --git a/tests/e2e/app-shell.spec.js b/tests/e2e/app-shell.spec.js index 9fc9bbe..8364d6f 100644 --- a/tests/e2e/app-shell.spec.js +++ b/tests/e2e/app-shell.spec.js @@ -3610,3 +3610,127 @@ test("header username stays untruncated on desktop and in the mobile menu", asyn fs.writeFileSync(path.join(shotDir, "measurements.json"), `${JSON.stringify(measurements, null, 2)}\n`); }); + +function measureHeaderProbe() { + const name = document.querySelector("#accountName"); + const group = document.querySelector("#headerCommandGroup"); + const strip = document.querySelector(".app-header .overview-strip"); + const stripBox = strip ? strip.getBoundingClientRect() : null; + const stripShown = Boolean(stripBox && stripBox.width > 0 && stripBox.height > 0); + const tapeClip = stripShown + ? [...strip.querySelectorAll("*")].filter((el) => ( + el.children.length === 0 && el.scrollWidth > el.clientWidth + 1 + )).map((el) => (el.textContent || "").trim()).filter(Boolean) + : []; + const groupBox = group ? group.getBoundingClientRect() : null; + const nameBox = name ? name.getBoundingClientRect() : null; + return { + groupRight: groupBox ? groupBox.right : 0, + nameRight: nameBox ? nameBox.right : 0, + nameFits: name ? name.clientWidth >= name.scrollWidth : false, + nameText: name ? String(name.textContent || "") : "", + tapeClip, + documentOverflow: document.documentElement.scrollWidth - window.innerWidth, + viewport: window.innerWidth, + }; +} + +async function applyColorTheme(page, theme) { + const wanted = theme === "night" ? "dark" : "light"; + await page.evaluate((value) => { + document.documentElement.dataset.theme = value; + document.documentElement.style.colorScheme = value; + try { localStorage.setItem("xiaobaiTheme", value); } catch (_error) {} + }, wanted); + await expect(page.locator("html")).toHaveAttribute("data-theme", wanted); +} + +async function probeHeaderWorkspaces(page, { account, theme, viewport, views, measurements }) { + await page.setViewportSize(viewport); + await applyColorTheme(page, theme); + for (const view of views) { + await page.locator(`[data-view="${view}"]`).first().click(); + await expect(page.locator(`#${view}`)).toHaveClass(/active-view/); + const geometry = await page.evaluate(measureHeaderProbe); + const label = `${account.label}-${view}-${viewport.width}-${theme}`; + expect(geometry.nameText, `${label} username`).toBe(account.username); + expect(geometry.nameFits, `${label} accountName truncated`).toBe(true); + expect(geometry.groupRight, `${label} command group overflow ${geometry.groupRight}>${geometry.viewport}`).toBeLessThanOrEqual(geometry.viewport + 0.5); + expect(geometry.nameRight, `${label} accountName overflow ${geometry.nameRight}>${geometry.viewport}`).toBeLessThanOrEqual(geometry.viewport + 0.5); + expect(geometry.documentOverflow, `${label} document overflow`).toBeLessThanOrEqual(1); + expect(geometry.tapeClip, `${label} tape clipped ${geometry.tapeClip.join("|")}`).toEqual([]); + measurements.push({ account: account.username, view, theme, width: viewport.width, ...geometry }); + } +} + +test("desktop header keeps commands in view and tape text unclipped across workspaces", async ({ page }) => { + test.setTimeout(120_000); + const fs = require("node:fs"); + const path = require("node:path"); + const shotDir = path.join(__dirname, "../../runtime/b221-shots"); + fs.mkdirSync(shotDir, { recursive: true }); + const measurements = []; + const views = [ + "sentimentCycleView", + "auctionView", + "themeLibraryView", + "popularityView", + "rotationView", + "mentorView", + "screenerView", + "reviewWorkspaceView", + "heavenView", + ]; + const viewports = [ + { width: 1600, height: 1000 }, + { width: 1440, height: 900 }, + { width: 1280, height: 800 }, + ]; + const accounts = [ + { label: "admin", username: "review_admin", auth: namedSession("review_admin", "admin", true) }, + { label: "user", username: "normal_user", auth: namedSession("normal_user", "user", false) }, + ]; + + for (const account of accounts) { + await mockApplication(page, account.auth); + await page.goto("/index.html"); + await expect(page.locator("#accountName")).toHaveText(account.username); + for (const viewport of viewports) { + await probeHeaderWorkspaces(page, { account, theme: "day", viewport, views, measurements }); + await page.locator('[data-view="themeLibraryView"]').first().click(); + await page.locator(".app-header").screenshot({ + path: path.join(shotDir, `${account.label}-${viewport.width}-day.png`), + }); + } + await probeHeaderWorkspaces(page, { + account, + theme: "night", + viewport: { width: 1600, height: 1000 }, + views, + measurements, + }); + await page.locator('[data-view="sentimentCycleView"]').first().click(); + await page.locator(".app-header").screenshot({ + path: path.join(shotDir, `${account.label}-1600-night.png`), + }); + } + + await mockApplication(page, accounts[0].auth); + await page.goto("/index.html"); + await page.setViewportSize({ width: 390, height: 844 }); + await applyColorTheme(page, "day"); + await page.locator("#headerMenuButton").click(); + await expect(page.locator("#headerCommandGroup")).toBeVisible(); + const mobileDay = await page.evaluate(measureAccountName); + expect(mobileDay.fits, "390 day account name truncated").toBe(true); + expect(mobileDay.stacked, "390 day identity and account should stack").toBe(true); + await page.screenshot({ path: path.join(shotDir, "admin-390-day.png") }); + await page.keyboard.press("Escape"); + await applyColorTheme(page, "night"); + await page.locator("#headerMenuButton").click(); + const mobileNight = await page.evaluate(measureAccountName); + expect(mobileNight.fits, "390 night account name truncated").toBe(true); + expect(mobileNight.stacked, "390 night identity and account should stack").toBe(true); + await page.screenshot({ path: path.join(shotDir, "admin-390-night.png") }); + fs.writeFileSync(path.join(shotDir, "measurements.json"), `${JSON.stringify(measurements, null, 2)}\n`); +});