diff --git a/app/config/architecture-inventory.json b/app/config/architecture-inventory.json index 6ba6abd..96315d0 100644 --- a/app/config/architecture-inventory.json +++ b/app/config/architecture-inventory.json @@ -372,9 +372,9 @@ "css_layers": [ "/shared/tokens.css?v=20260820-2", "/shared/base.css?v=20260806-1", - "/shared/shell.css?v=20260820-2", - "/shared/auth.css?v=20260820-3", - "/shared/components/controls.css?v=20260820-1", + "/shared/shell.css?v=20260820-5", + "/shared/auth.css?v=20260820-4", + "/shared/components/controls.css?v=20260820-2", "/shared/components/navigation.css?v=20260820-1", "/shared/components/cards.css?v=20260820-1", "/shared/components/tables.css?v=20260820-1", @@ -451,8 +451,8 @@ }, { "path": "frontend/shared/shell.css", - "bytes": 62384, - "lines": 3718 + "bytes": 63204, + "lines": 3761 }, { "path": "backend/features/heaven/engine.py", @@ -461,7 +461,7 @@ }, { "path": "frontend/index.html", - "bytes": 47056, + "bytes": 47131, "lines": 646 }, { diff --git a/app/frontend/index.html b/app/frontend/index.html index 403c004..1576c9b 100644 --- a/app/frontend/index.html +++ b/app/frontend/index.html @@ -19,9 +19,9 @@ - - - + + + @@ -137,12 +137,12 @@ - - - + + +
- +
diff --git a/app/frontend/pages.config.js b/app/frontend/pages.config.js index 1f14cab..c5b9188 100644 --- a/app/frontend/pages.config.js +++ b/app/frontend/pages.config.js @@ -86,7 +86,7 @@ "/pages/review/page.js?v=20260729-1", "/shared/state.js?v=20260729-1", "/shared/api.js?v=20260729-1", - "/shared/shell.js?v=20260820-1", + "/shared/shell.js?v=20260820-2", "/shared/export.js?v=20260731-1", "/pages/heaven/loading-v2.js?v=20260728-2", "/shared/context.js?v=20260804-1", diff --git a/app/frontend/shared/auth.css b/app/frontend/shared/auth.css index a2b0949..602d3ac 100644 --- a/app/frontend/shared/auth.css +++ b/app/frontend/shared/auth.css @@ -1179,27 +1179,27 @@ button.account-role-badge:focus-visible { gap: 3px; } -@media (min-width: 1280px) and (max-width: 1510px) { +@media (min-width: 1280px) { .app-header .header-command-group .account-role-badge span { - display: inline; + display: none; } .app-header .header-command-group .account-role-badge { - min-width: 0; + min-width: 28px; - justify-content: flex-start; + justify-content: center; + + padding-inline: 6px; } -} -@media (min-width: 1380px) and (max-width: 1510px) { - #settingsButton, .header-command-group .account-button { width: auto; - padding: 0px 9px; + max-width: 108px; + + padding: 0px 8px; } - #settingsButton > span, .header-command-group .account-button > span { display: inline; } diff --git a/app/frontend/shared/components/controls.css b/app/frontend/shared/components/controls.css index 779d504..6ccc5d0 100644 --- a/app/frontend/shared/components/controls.css +++ b/app/frontend/shared/components/controls.css @@ -635,14 +635,16 @@ body.sidebar-collapsed .sidebar-collapse-button .lucide { height: 14px; } -@media (min-width: 1280px) and (max-width: 1510px) { - .header-command-group .command-button { - width: 31px; +@media (min-width: 1280px) { + .header-command-group .command-button:not(.account-button) { + width: 32px; + + min-width: 32px; padding: 0px; } - .header-command-group .command-button > span { + .header-command-group .command-button:not(.account-button) > span { display: none; } } @@ -1010,7 +1012,7 @@ textarea { @media (min-width: 768px) { .header-menu-button { - display: inline-flex; + display: none; } } diff --git a/app/frontend/shared/shell.css b/app/frontend/shared/shell.css index 73e49e8..3250d92 100644 --- a/app/frontend/shared/shell.css +++ b/app/frontend/shared/shell.css @@ -3013,6 +3013,11 @@ body.sidebar-collapsed .status-bar { justify-content: flex-start; } + body.mobile-shell .header-command-group .account-button > span, + body.mobile-shell .header-command-group .account-role-badge span { + display: inline; + } + body.mobile-shell .header-command-group .account-dropdown { position: static; grid-column: 1 / -1; @@ -3329,8 +3334,24 @@ body.sidebar-collapsed .status-bar { color: var(--warn); } -.tape-sentiment .sentiment-gauge { +@media (min-width: 1440px) and (max-width: 1599px) { + .app-header .overview-strip .tape-sentiment.sentiment-block { + flex-direction: row; + align-items: center; + gap: 8px; + } +} + +.app-header .overview-strip .tape-sentiment .metric-label, +.app-header .overview-strip .tape-sentiment .sentiment-text { + font-size: var(--fs-caption); + font-weight: 500; + line-height: 18px; +} + +.app-header .overview-strip .tape-sentiment > .sentiment-gauge { position: absolute; + display: none; width: 0; height: 0; overflow: hidden; @@ -3573,12 +3594,43 @@ body.sidebar-collapsed .status-bar { --focus-ring: rgba(108, 168, 232, .22); } -@media (min-width: 1024px) and (max-width: 1439px) { +@media (min-width: 1024px) and (max-width: 1599px) { .tape-optional { display: none; } } +@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; + } +} + @media (max-width: 1023px) { .app-header { gap: 8px; @@ -3587,10 +3639,6 @@ body.sidebar-collapsed .status-bar { padding: 0 10px; } - .app-page-context { - display: none; - } - .app-header .overview-strip, .app-header .overview-strip[data-overview-expanded="true"] { flex: 0 0 auto; @@ -3653,53 +3701,48 @@ body.sidebar-collapsed .status-bar { @media (min-width: 768px) { .app-header .header-menu-button { - display: inline-flex; + display: none; } .app-header .header-command-group { - position: absolute; - top: calc(100% + 8px); - right: 0; - z-index: 60; - display: none; - flex-direction: column; - align-items: stretch; - gap: 6px; - width: 240px; - padding: 8px; - overflow: visible; - border: 1px solid var(--border); - border-radius: var(--radius-md); - background: var(--surface); - box-shadow: var(--shadow-float); - } - - .app-header .header-command-group.is-open { + position: static; display: flex; + flex: 0 0 auto; + flex-direction: row; + align-items: center; + gap: 6px; + width: auto; + max-width: none; + padding: 0; + overflow: visible; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; } .app-header .header-command-group .command-button, .app-header .header-command-group .account-button { - width: 100%; - justify-content: flex-start; + width: auto; + justify-content: center; } .app-header .header-command-group .account-menu-shell { - display: flex; - flex-direction: column; - align-items: stretch; - width: 100%; + display: inline-flex; + flex-direction: row; + align-items: center; + width: auto; } .app-header .header-command-group .account-role-badges { - display: flex; - flex-wrap: wrap; - min-height: 0; + display: inline-flex; + flex-wrap: nowrap; + min-height: 32px; } .app-header .header-command-group .account-dropdown { - left: 0; - right: auto; + left: auto; + right: 0; } } diff --git a/app/frontend/shared/shell.js b/app/frontend/shared/shell.js index f039003..7177639 100644 --- a/app/frontend/shared/shell.js +++ b/app/frontend/shared/shell.js @@ -22,12 +22,19 @@ const button = document.querySelector("#headerMenuButton"); const backdrop = document.querySelector("#mobileCommandBackdrop"); if (!menu || !button) return; + if (!isMobileViewport()) { + menu.classList.remove("is-open"); + button.setAttribute("aria-expanded", "false"); + document.body.classList.remove("mobile-command-open"); + if (backdrop) backdrop.hidden = true; + return; + } const open = typeof force === "boolean" ? force : !menu.classList.contains("is-open"); menu.classList.toggle("is-open", open); button.setAttribute("aria-expanded", String(open)); - document.body.classList.toggle("mobile-command-open", open && isMobileViewport()); - if (backdrop) backdrop.hidden = !(open && isMobileViewport()); - if (open && isMobileViewport()) { + document.body.classList.toggle("mobile-command-open", open); + if (backdrop) backdrop.hidden = !open; + if (open) { global.requestAnimationFrame(() => menu.querySelector("button:not([hidden])")?.focus({ preventScroll: true })); } else if (force === false && document.activeElement && menu.contains(document.activeElement)) { button.focus({ preventScroll: true }); diff --git a/app/tests/e2e/app-shell.spec.js b/app/tests/e2e/app-shell.spec.js index ca6e359..ed42c9b 100644 --- a/app/tests/e2e/app-shell.spec.js +++ b/app/tests/e2e/app-shell.spec.js @@ -3198,10 +3198,10 @@ test("merged header keeps date, detail fields and 13 pool columns after dual-rev dashboard.limits = previousLimits; await page.waitForTimeout(400); await expect(page.locator("#currentPageSubtitle")).toContainText("市场复盘 ·"); - await expect(page.locator("#headerMenuButton")).toBeVisible(); - await expect(page.locator("#headerCommandGroup")).toBeHidden(); + await expect(page.locator("#headerMenuButton")).toBeHidden(); + await expect(page.locator("#headerCommandGroup")).toBeVisible(); await expect(page.locator("#themeToggle")).toBeVisible(); - await expect(page.locator("#refreshButton")).toBeHidden(); + await expect(page.locator("#refreshButton")).toBeVisible(); await page.locator('[data-view="limitPool"]').first().click(); await expect(page.locator("#currentPageSubtitle")).toContainText("市场复盘 ·"); @@ -3377,3 +3377,119 @@ test("B-199 screener review and account surfaces fit day night viewports", async expect(pageErrors).toEqual([]); }); + +test("desktop header keeps refresh, admin commands and account identity visible", async ({ page }) => { + const fs = require("node:fs"); + const path = require("node:path"); + const shotDir = path.join(__dirname, "../../runtime/b214-shots"); + fs.mkdirSync(shotDir, { recursive: true }); + const pageErrors = []; + page.on("pageerror", (error) => pageErrors.push(String(error))); + await mockApplication(page, session("admin", true)); + await page.goto("/index.html"); + const shot = (name) => page.locator(".app-header").screenshot({ path: path.join(shotDir, `${name}.png`) }); + const shotPage = (name) => page.screenshot({ path: path.join(shotDir, `${name}.png`) }); + + const measureHeader = () => page.evaluate(() => { + const header = document.querySelector(".app-header"); + const tape = document.querySelector(".market-tape"); + const actions = document.querySelector(".header-actions"); + const refresh = document.querySelector("#refreshButton"); + const account = document.querySelector("#accountButton"); + const name = document.querySelector("#accountName"); + const box = (node) => node ? node.getBoundingClientRect() : null; + const visible = (node) => { + if (!node || node.hidden) return false; + const style = getComputedStyle(node); + if (style.display === "none" || style.visibility === "hidden") return false; + const rect = node.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + const tapeBox = box(tape); + const actionsBox = box(actions); + return { + headerOverflow: header ? header.scrollWidth - header.clientWidth : 0, + documentOverflow: document.documentElement.scrollWidth - window.innerWidth, + tapeHeight: tapeBox ? tapeBox.height : 0, + overlap: tapeBox && actionsBox + ? Math.max(0, Math.min(tapeBox.right, actionsBox.right) - Math.max(tapeBox.left, actionsBox.left)) + * Math.max(0, Math.min(tapeBox.bottom, actionsBox.bottom) - Math.max(tapeBox.top, actionsBox.top)) + : 0, + refreshVisible: visible(refresh), + accountVisible: visible(account), + nameVisible: visible(name), + }; + }); + + for (const viewport of [ + { width: 1600, height: 1000 }, + { width: 1440, height: 900 }, + { width: 1280, height: 800 }, + ]) { + await page.setViewportSize(viewport); + await expect(page.locator("#headerMenuButton")).toBeHidden(); + await expect(page.locator("#headerCommandGroup")).toBeVisible(); + await expect(page.locator("#refreshButton")).toBeVisible(); + await expect(page.locator("#syncButton")).toBeVisible(); + await expect(page.locator("#settingsButton")).toBeVisible(); + await expect(page.locator("#accountAdminBadge")).toBeVisible(); + await expect(page.locator("#accountVipBadge")).toBeVisible(); + await expect(page.locator("#accountButton")).toBeVisible(); + await expect(page.locator("#accountName")).toBeVisible(); + const geometry = await measureHeader(); + expect(geometry.headerOverflow, `${viewport.width} header overflow`).toBeLessThanOrEqual(1); + expect(geometry.documentOverflow, `${viewport.width} document overflow`).toBeLessThanOrEqual(1); + expect(geometry.overlap, `${viewport.width} tape overlap`).toBe(0); + expect(geometry.tapeHeight, `${viewport.width} tape height`).toBeGreaterThan(20); + await page.locator("#refreshButton").click(); + await expect(page.locator("#loadingOverlay")).toBeHidden(); + await page.locator("#settingsButton").click(); + await expect(page.locator("#adminDialog")).toBeVisible(); + await page.locator("#closeAdminDialog").click(); + await page.locator("#accountButton").click(); + await expect(page.locator("#accountDropdown")).toBeVisible(); + await page.keyboard.press("Escape"); + await shot(`${viewport.width}-day`); + } + + await page.locator("#themeToggle").click(); + await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); + await page.setViewportSize({ width: 1600, height: 1000 }); + await shot("1600-night"); + await page.locator("#themeToggle").click(); + + await page.locator('[data-view="heavenView"]').first().click(); + await expect(page.locator("#heavenView")).toHaveClass(/active-view/); + await expect(page.locator("#refreshButton")).toBeVisible(); + await expect(page.locator("#accountButton")).toBeVisible(); + await expect(page.locator("#headerMenuButton")).toBeHidden(); + await shot("heaven-1600-day"); + + await page.setViewportSize({ width: 390, height: 844 }); + await expect(page.locator("#headerMenuButton")).toBeVisible(); + await expect(page.locator("#refreshButton")).toBeHidden(); + await page.locator("#headerMenuButton").click(); + await expect(page.locator("#headerCommandGroup")).toBeVisible(); + await expect(page.locator("#refreshButton")).toBeVisible(); + await expect(page.locator("#syncButton")).toBeVisible(); + await expect(page.locator("#settingsButton")).toBeVisible(); + await expect(page.locator("#accountAdminBadge")).toBeVisible(); + await expect(page.locator("#accountVipBadge")).toBeVisible(); + await expect(page.locator("#accountButton")).toBeVisible(); + await expect(page.locator('[data-mobile-command-target="themeToggle"]')).toBeVisible(); + await shotPage("390-menu-open"); + await page.keyboard.press("Escape"); + await expect(page.locator("#headerCommandGroup")).toBeHidden(); + + await mockApplication(page, session("user", false)); + await page.setViewportSize({ width: 1600, height: 1000 }); + await page.goto("/index.html"); + await expect(page.locator("#refreshButton")).toBeVisible(); + await expect(page.locator("#syncButton")).toBeHidden(); + await expect(page.locator("#settingsButton")).toBeHidden(); + await expect(page.locator("#accountAdminBadge")).toBeHidden(); + await expect(page.locator("#accountVipBadge")).toBeVisible(); + await expect(page.locator("#accountName")).toHaveText("normal_user"); + await shot("1600-user-day"); + expect(pageErrors).toEqual([]); +});