diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8577e88..1664f81 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,6 +84,9 @@ jobs: npm install --no-save --no-audit --no-fund playwright@1.63.0 npx playwright install --with-deps --no-shell chromium + - name: Web responsive shell smoke test + run: npm run smoke:chromium:web-shell + - name: Standard Chromium extension smoke test run: npm run smoke:chromium diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index e4fb087..28824a6 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -497,14 +497,14 @@ export function App() { D· Dockmark - - setView("launcher")}>Launcher - setView("workspaces")}>Workspaces - setView("sessions")}>Sessions - setView("bookmarks")}>Bookmarks - setView("search")}>Search - setView("transfer")}>Transfer - setView("extension")}>Extension + + setView("launcher")}>Launcher + setView("workspaces")}>Workspaces + setView("sessions")}>Sessions + setView("bookmarks")}>Bookmarks + setView("search")}>Search + setView("transfer")}>Transfer + setView("extension")}>Extension diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 5f0ec4a..031f2ea 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -14,6 +14,7 @@ import "./bookmark-tags.css"; import "./inbox.css"; import "./ui-polish.css"; import "./light-theme-consistency.css"; +import "./responsive-shell.css"; const root = document.getElementById("root"); if (!root) throw new Error("Missing #root"); diff --git a/apps/web/src/responsive-shell.css b/apps/web/src/responsive-shell.css new file mode 100644 index 0000000..6b25bbd --- /dev/null +++ b/apps/web/src/responsive-shell.css @@ -0,0 +1,191 @@ +:root { + --dm-private-nav-height: 46px; + --dm-secondary-nav-height: 64px; + --dm-shell-gutter: 20px; +} + +.shell { + width: min(1100px, calc(100% - (2 * var(--dm-shell-gutter)))); +} + +.private-utility-bar { + height: var(--dm-private-nav-height); + min-height: var(--dm-private-nav-height); + overflow: hidden; + padding-inline: max(var(--dm-shell-gutter), calc((100vw - 1100px) / 2)); +} + +.private-utility-brand { + flex: 0 0 auto; +} + +.private-utility-bar nav, +.workspace-section-nav { + min-width: 0; + overflow-x: auto; + overscroll-behavior-inline: contain; + scrollbar-width: none; + -webkit-overflow-scrolling: touch; +} + +.private-utility-bar nav::-webkit-scrollbar, +.workspace-section-nav::-webkit-scrollbar { + display: none; +} + +.private-utility-bar nav { + flex: 0 1 auto; + margin-left: auto; +} + +.private-utility-bar nav button, +.workspace-section-nav .ghost { + flex: 0 0 auto; + white-space: nowrap; +} + +.topbar { + position: sticky; + top: var(--dm-private-nav-height); + z-index: 25; + height: var(--dm-secondary-nav-height); + min-height: var(--dm-secondary-nav-height); + gap: 18px; + background: color-mix(in srgb, var(--dm-bg) 94%, transparent); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); +} + +.topbar .brand { + flex: 0 0 auto; +} + +.workspace-section-nav { + flex: 0 1 auto; + max-width: 100%; + margin-left: auto; + scroll-padding-inline: 8px; +} + +.workspace-section-nav .ghost { + scroll-snap-align: start; +} + +.manager-sidebar, +.search-engine-form { + top: calc(var(--dm-private-nav-height) + var(--dm-secondary-nav-height) + 18px); +} + +.command-footer { + flex-wrap: wrap; +} + +.command-footer span { + flex-wrap: wrap; +} + +.panel-heading { + gap: 10px; +} + +@media (max-width: 900px) { + :root { + --dm-shell-gutter: 16px; + } + + .topbar { + gap: 10px; + } + + .topbar .brand > span:not(.brand-mark) { + display: none; + } + + .workspace-section-nav { + flex: 1 1 auto; + } + + .workspace-section-nav .ghost { + padding-inline: 10px; + } +} + +@media (max-width: 720px) { + :root { + --dm-shell-gutter: 12px; + } + + .private-utility-bar { + gap: 10px; + } + + .private-utility-bar nav { + flex: 1 1 auto; + } + + .topbar { + gap: 8px; + } + + .topbar .brand-mark { + width: 30px; + height: 30px; + } + + .panel-heading { + align-items: flex-start; + flex-wrap: wrap; + } + + .command-footer { + gap: 8px; + } + + .result { + gap: 9px; + padding-inline: 8px; + } + + .pill { + flex: 0 0 auto; + white-space: nowrap; + } +} + +@media (max-width: 520px) { + .topbar .brand { + display: none; + } + + .workspace-section-nav { + flex: 1 1 100%; + width: 100%; + margin-left: 0; + } + + .hero { + padding-top: 42px; + padding-bottom: 34px; + } + + .hero h1 { + font-size: clamp(34px, 11vw, 44px); + } + + .command { + min-height: 56px; + padding-left: 13px; + } + + .command-shortcuts { + display: none; + } + + .command-footer > span { + width: 100%; + } + + .feature-grid { + margin-top: 34px; + } +} diff --git a/package.json b/package.json index 7270842..ead0115 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,8 @@ "deploy:dry-run": "npm run prepare:cloudflare-deploy && wrangler deploy --dry-run --outdir .wrangler/dry-run", "deploy": "npm run build:web && npm run deploy:built", "smoke:chromium": "node scripts/chromium-extension-smoke.mjs", - "smoke:chromium:newtab": "node scripts/chromium-newtab-smoke.mjs && node scripts/chromium-newtab-ux-smoke.mjs && node scripts/chromium-newtab-parity-smoke.mjs" + "smoke:chromium:newtab": "node scripts/chromium-newtab-smoke.mjs && node scripts/chromium-newtab-ux-smoke.mjs && node scripts/chromium-newtab-parity-smoke.mjs", + "smoke:chromium:web-shell": "node scripts/chromium-web-shell-smoke.mjs" }, "devDependencies": { "typescript": "^7.0.2", diff --git a/scripts/chromium-web-shell-smoke.mjs b/scripts/chromium-web-shell-smoke.mjs new file mode 100644 index 0000000..64dff02 --- /dev/null +++ b/scripts/chromium-web-shell-smoke.mjs @@ -0,0 +1,133 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { chromium } from "playwright"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const cssPaths = [ + "apps/web/src/styles.css", + "apps/web/src/theme.css", + "apps/web/src/security.css", + "apps/web/src/search-engines.css", + "apps/web/src/responsive-shell.css", +]; +const css = (await Promise.all(cssPaths.map((file) => readFile(path.join(root, file), "utf8")))).join("\n"); + +const utilityButtons = ["Workspace", "Inbox", "Collections", "Duplicates", "Settings", "Sign out"]; +const sectionButtons = ["Launcher", "Workspaces", "Sessions", "Bookmarks", "Search", "Transfer", "Extension"]; + +function fixture() { + return ` + + + + + D·Public page + ${utilityButtons.map((label) => `${label}`).join("")} + + + + D·Dockmark + ${sectionButtons.map((label) => `${label}`).join("")} + + + YOUR PERSONAL LAUNCHER + Everything you return to,one command away. + + ⌕ + + ⌘ K + + + + Command resultsBrowser bridge · 20 open tabs + + ↗Example result with a deliberately long title that must stay inside the responsive shellhttps://example.com/a/very/long/path/that/should/ellipsisOpen tab + + + + + + + Long content + + + + + `; +} + +const browser = await chromium.launch({ channel: "chromium", headless: true }); +try { + const page = await browser.newPage(); + + for (const width of [1280, 900, 720, 390]) { + await page.setViewportSize({ width, height: 720 }); + await page.setContent(fixture(), { waitUntil: "domcontentloaded" }); + + const layout = await page.evaluate(() => { + const utility = document.querySelector(".private-utility-bar"); + const secondary = document.querySelector(".topbar"); + const sectionNav = document.querySelector(".workspace-section-nav"); + const utilityNav = document.querySelector(".private-utility-bar nav"); + const sidebar = document.querySelector(".manager-sidebar"); + const brand = document.querySelector(".topbar .brand"); + const brandLabel = document.querySelector(".topbar .brand > span:not(.brand-mark)"); + if (!(utility instanceof HTMLElement) || !(secondary instanceof HTMLElement) || !(sectionNav instanceof HTMLElement) || !(utilityNav instanceof HTMLElement) || !(sidebar instanceof HTMLElement) || !(brand instanceof HTMLElement) || !(brandLabel instanceof HTMLElement)) { + throw new Error("Responsive shell fixture is incomplete."); + } + return { + documentWidth: document.documentElement.scrollWidth, + bodyWidth: document.body.scrollWidth, + viewportWidth: window.innerWidth, + utilityHeight: utility.getBoundingClientRect().height, + secondaryHeight: secondary.getBoundingClientRect().height, + secondaryTop: getComputedStyle(secondary).top, + secondaryPosition: getComputedStyle(secondary).position, + sidebarTop: getComputedStyle(sidebar).top, + sectionNavOverflowing: sectionNav.scrollWidth > sectionNav.clientWidth, + utilityNavOverflowing: utilityNav.scrollWidth > utilityNav.clientWidth, + brandDisplay: getComputedStyle(brand).display, + brandLabelDisplay: getComputedStyle(brandLabel).display, + }; + }); + + assert.ok(layout.documentWidth <= width + 1, `${width}px viewport should not have document-level horizontal overflow.`); + assert.ok(layout.bodyWidth <= width + 1, `${width}px viewport should not have body-level horizontal overflow.`); + assert.equal(layout.utilityHeight, 46); + assert.equal(layout.secondaryHeight, 64); + assert.equal(layout.secondaryPosition, "sticky"); + assert.equal(layout.secondaryTop, "46px"); + assert.equal(layout.sidebarTop, "128px"); + + if (width === 1280) { + assert.notEqual(layout.brandLabelDisplay, "none", "Desktop should keep the Dockmark secondary brand label."); + } + if (width === 720) { + assert.equal(layout.brandLabelDisplay, "none", "Tablet layout should reclaim space by hiding the secondary brand label."); + } + if (width === 390) { + assert.equal(layout.brandDisplay, "none", "Phone layout should dedicate the secondary bar to section navigation."); + assert.equal(layout.sectionNavOverflowing, true, "Phone section navigation should scroll within its own bar instead of widening the page."); + assert.equal(layout.utilityNavOverflowing, true, "Phone utility navigation should scroll within the utility bar instead of widening the page."); + } + + await page.evaluate(() => window.scrollTo(0, 900)); + await page.waitForTimeout(50); + const sticky = await page.evaluate(() => { + const utility = document.querySelector(".private-utility-bar")?.getBoundingClientRect(); + const secondary = document.querySelector(".topbar")?.getBoundingClientRect(); + return { utilityTop: utility?.top, secondaryTop: secondary?.top }; + }); + assert.ok(Math.abs((sticky.utilityTop ?? 999) - 0) <= 1, `${width}px utility bar should remain pinned to the viewport top.`); + assert.ok(Math.abs((sticky.secondaryTop ?? 999) - 46) <= 1, `${width}px workspace nav should remain pinned directly below the utility bar.`); + } + + console.log("✓ Web shell has no document-level horizontal overflow at 1280/900/720/390px"); + console.log("✓ Utility and workspace navigation remain stacked and sticky while scrolling"); + console.log("✓ Tablet and phone breakpoints reclaim nav space without clipping the page"); + console.log("✓ Internal sticky sidebars offset below both navigation bars"); +} finally { + await browser.close(); +}
YOUR PERSONAL LAUNCHER