Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 8 additions & 8 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -497,14 +497,14 @@ export function App() {
<span className="brand-mark">D·</span>
<span>Dockmark</span>
</button>
<nav>
<button className={`ghost ${view === "launcher" ? "active" : ""}`} type="button" onClick={() => setView("launcher")}>Launcher</button>
<button className={`ghost ${view === "workspaces" ? "active" : ""}`} type="button" onClick={() => setView("workspaces")}>Workspaces</button>
<button className={`ghost ${view === "sessions" ? "active" : ""}`} type="button" onClick={() => setView("sessions")}>Sessions</button>
<button className={`ghost ${view === "bookmarks" ? "active" : ""}`} type="button" onClick={() => setView("bookmarks")}>Bookmarks</button>
<button className={`ghost ${view === "search" ? "active" : ""}`} type="button" onClick={() => setView("search")}>Search</button>
<button className={`ghost ${view === "transfer" ? "active" : ""}`} type="button" onClick={() => setView("transfer")}>Transfer</button>
<button className={`ghost ${view === "extension" ? "active" : ""}`} type="button" onClick={() => setView("extension")}>Extension</button>
<nav className="workspace-section-nav" aria-label="Workspace sections">
<button className={`ghost ${view === "launcher" ? "active" : ""}`} type="button" aria-current={view === "launcher" ? "page" : undefined} onClick={() => setView("launcher")}>Launcher</button>
<button className={`ghost ${view === "workspaces" ? "active" : ""}`} type="button" aria-current={view === "workspaces" ? "page" : undefined} onClick={() => setView("workspaces")}>Workspaces</button>
<button className={`ghost ${view === "sessions" ? "active" : ""}`} type="button" aria-current={view === "sessions" ? "page" : undefined} onClick={() => setView("sessions")}>Sessions</button>
<button className={`ghost ${view === "bookmarks" ? "active" : ""}`} type="button" aria-current={view === "bookmarks" ? "page" : undefined} onClick={() => setView("bookmarks")}>Bookmarks</button>
<button className={`ghost ${view === "search" ? "active" : ""}`} type="button" aria-current={view === "search" ? "page" : undefined} onClick={() => setView("search")}>Search</button>
<button className={`ghost ${view === "transfer" ? "active" : ""}`} type="button" aria-current={view === "transfer" ? "page" : undefined} onClick={() => setView("transfer")}>Transfer</button>
<button className={`ghost ${view === "extension" ? "active" : ""}`} type="button" aria-current={view === "extension" ? "page" : undefined} onClick={() => setView("extension")}>Extension</button>
</nav>
</header>

Expand Down
1 change: 1 addition & 0 deletions apps/web/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
191 changes: 191 additions & 0 deletions apps/web/src/responsive-shell.css
Original file line number Diff line number Diff line change
@@ -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;
}
}
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
133 changes: 133 additions & 0 deletions scripts/chromium-web-shell-smoke.mjs
Original file line number Diff line number Diff line change
@@ -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 `<!doctype html>
<html data-theme="light">
<head><meta name="viewport" content="width=device-width, initial-scale=1"><style>${css}</style></head>
<body>
<div class="private-utility-bar">
<a class="private-utility-brand"><span>D·</span><b>Public page</b></a>
<nav>${utilityButtons.map((label) => `<button>${label}</button>`).join("")}</nav>
</div>
<main class="shell">
<header class="topbar">
<button class="brand brand-button"><span class="brand-mark">D·</span><span>Dockmark</span></button>
<nav class="workspace-section-nav">${sectionButtons.map((label) => `<button class="ghost">${label}</button>`).join("")}</nav>
</header>
<section class="hero">
<p class="eyebrow">YOUR PERSONAL LAUNCHER</p>
<h1>Everything you return to,<br>one command away.</h1>
<div class="command">
<span class="search-icon">⌕</span>
<input value="github">
<span class="command-shortcuts"><kbd>⌘ K</kbd></span>
</div>
</section>
<section class="panel">
<div class="panel-heading"><span>Command results</span><span class="muted">Browser bridge · 20 open tabs</span></div>
<div class="results">
<button class="result result-button"><span class="favicon">↗</span><span class="result-copy"><strong>Example result with a deliberately long title that must stay inside the responsive shell</strong><small>https://example.com/a/very/long/path/that/should/ellipsis</small></span><span class="pill">Open tab</span></button>
</div>
<div class="command-footer"><span><kbd>↑</kbd><kbd>↓</kbd> select <kbd>Enter</kbd> run <kbd>Tab</kbd> accept bang</span><span><kbd>Shift</kbd>/<kbd>⌘</kbd>/<kbd>Ctrl</kbd>+<kbd>Enter</kbd> new tab <kbd>/</kbd> focus <kbd>Esc</kbd> clear</span></div>
</section>
<section class="management" style="min-height:1600px">
<div class="management-grid">
<aside class="manager-sidebar form-card">Sticky sidebar</aside>
<div class="manager-main"><div class="form-card" style="height:1400px">Long content</div></div>
</div>
</section>
</main>
</body>
</html>`;
}

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();
}
Loading