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
88 changes: 88 additions & 0 deletions internal/webapp/frontend/e2e/layout.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,94 @@ test("the gutter belongs to the scroll container, not the column", async ({ page
}
});

/* The who/when/how-hot line is what the product is differentiated by, and a
phone is exactly when you're catching up — but ≤900px used to `display:
none` it on the file view and ellipsise it to `claude-…` / `Alice <ali…` in
History. Desktop values are read first and compared, rather than hard-coded:
the seeded hub's read counts drift, so a literal would flake. */
test("provenance survives to a phone on the file view and in History", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
const runHead = page.locator(".hrun-head").first();

const read = async () => {
await page.goto(`/${pid}/index.md`);
// Not waitForSelector: #meta is always in the DOM, empty until the file
// loads and (before this fix) display:none below 900px.
await expect(page.locator("#meta")).not.toBeEmpty();
const meta = await page.locator("#meta").textContent();
await page.goto(`/${pid}/history`);
await page.waitForSelector(".hrun-head");
return {
meta,
note: await runHead.locator(".hrun-note").textContent(),
runMeta: await runHead.locator(".hrun-meta").textContent(),
time: await runHead.locator(".hrun-time").textContent(),
};
};

await page.setViewportSize({ width: 1200, height: 900 });
const desktop = await read();
expect(desktop.meta, "desktop provenance line").toBeTruthy();

await page.setViewportSize({ width: 390, height: 844 });
await page.goto(`/${pid}/index.md`);
await expect(page.locator("#meta")).not.toBeEmpty();
await expect(page.locator("#meta"), "390px: provenance line visible").toBeVisible();

const m = await page.evaluate(() => {
const bar = document.querySelector("#topbar") as HTMLElement;
const meta = document.querySelector("#meta") as HTMLElement;
const btns = [...bar.querySelectorAll<HTMLElement>(".btn, .icon-btn")].filter(
(b) => b.getBoundingClientRect().width > 0,
);
const last = btns[btns.length - 1].getBoundingClientRect();
const content = document.querySelector("#content") as HTMLElement;
return {
// Actions stay flush right on row 1, above the wrapped meta row.
gapFromRight: Math.round(bar.getBoundingClientRect().right - last.right),
actionsAboveMeta: last.bottom <= meta.getBoundingClientRect().top + 1,
tap: Math.min(...btns.map((b) => b.getBoundingClientRect().height)),
// Nothing clipped, and the grown topbar pushes content down rather
// than overlapping it.
clipped: meta.scrollWidth > meta.clientWidth + 1 || meta.scrollHeight > meta.clientHeight + 1,
contentBelow: content.getBoundingClientRect().top >= bar.getBoundingClientRect().bottom - 1,
overflow: document.documentElement.scrollWidth > document.documentElement.clientWidth,
};
});
expect(m.gapFromRight, "390px: actions flush right").toBeLessThanOrEqual(10);
expect(m.actionsAboveMeta, "390px: actions stayed on the first row").toBe(true);
expect(m.tap, "390px: action tap target").toBeGreaterThanOrEqual(44);
expect(m.clipped, "390px: provenance line clipped").toBe(false);
expect(m.contentBelow, "390px: topbar overlaps the page").toBe(true);
expect(m.overflow, "390px: horizontal page scroll").toBe(false);

const mobile = await read();
expect(mobile, "390px: same provenance as desktop").toEqual(desktop);

const clip = await page.evaluate(() => {
const head = document.querySelector(".hrun-head") as HTMLElement;
const bad = (sel: string) => {
const el = head.querySelector(sel) as HTMLElement;
return el.scrollWidth > el.clientWidth + 1;
};
return { note: bad(".hrun-note"), meta: bad(".hrun-meta") };
});
expect(clip.note, "390px: run note clipped").toBe(false);
expect(clip.meta, "390px: run meta clipped").toBe(false);

// Routes with no provenance must not gain a blank strip: the topbar is
// exactly its desktop height there.
await page.goto(`/${pid}/notes`);
await page.waitForSelector(".dl-row");
const folder = await page.evaluate(() => ({
metaShown: getComputedStyle(document.querySelector("#meta") as HTMLElement).display !== "none",
barHeight: Math.round((document.querySelector("#topbar") as HTMLElement).getBoundingClientRect().height),
}));
expect(folder.metaShown, "390px: empty meta on a folder route").toBe(false);
expect(folder.barHeight, "390px: folder topbar height").toBe(52);
});

/* Mobile folder rows used to drop .dl-meta entirely below 430px, leaving an
unlabelled coloured dot as the only signal — and the dot's meaning lived in
a title= that touch never shows and screen readers never read. The meta now
Expand Down
24 changes: 20 additions & 4 deletions internal/webapp/frontend/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -882,10 +882,19 @@ a.ai-main:hover { color: var(--accent); }
#topbar .btn .ico { width: 18px; height: 18px; }
#more-btn:not([hidden]) { display: inline-flex; }
#history-btn, #upload-btn, #download { display: none !important; }
/* Desktop right-aligns the actions via #meta's flex:1 — with meta
hidden here, the crumb becomes the spacer so Search/Share/⋯ pin to
the top-right corner. */
#meta { display: none; }
/* Who/when/how-hot is the line people catch up from, so it survives to a
phone on its own row instead of being hidden. `order: 1` is load-bearing:
meta sits before the actions in the DOM, so a bare flex-wrap would drag
Search/Share/⋯ down with it. The crumb keeps flex:1 and stays the spacer
that pins them to the top-right corner. */
#topbar { flex-wrap: wrap; height: auto; min-height: 52px; }
#meta {
order: 1; flex: 1 1 100%; text-align: left;
white-space: normal; overflow: visible; padding: 0 0 8px;
}
/* Folder, dashboard and history routes carry no meta — without this they
gain a blank strip under the crumb. */
#meta:empty { display: none; }
#crumb { flex: 1; }
#vault { padding: 0 8px 0 12px; }
.icon-btn2, #signout, #tree .row, #projects .row { height: 44px; }
Expand All @@ -904,6 +913,13 @@ a.ai-main:hover { color: var(--accent); }
.hf-search input, .hf-user, .hf-date, .hf-clear { height: 44px; }
.hf-dates { flex: 1 1 100%; }
.hf-date { flex: 1; width: auto; min-width: 0; }
/* The desktop run header ellipsises the note at 46% and the meta at
whatever is left, which on a phone clips to `claude-…` / `Alice <ali…` —
the two fields the row exists to carry. Let it wrap instead; `order: 1`
drops the meta below the note while the time stays on the first row. */
.hrun-head { flex-wrap: wrap; row-gap: 4px; }
.hrun-note { max-width: none; white-space: normal; overflow: visible; }
.hrun-meta { order: 1; flex: 1 1 100%; white-space: normal; overflow: visible; }
.ai-btn, .ai-del { height: auto; min-height: 44px; padding: 0 12px; }
/* react-table renders rows as `display: table-row`, which makes every
flex rule above inert and lets the last column (Remove) fall outside
Expand Down

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions internal/webapp/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BearDrive</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='%23f5a623'><rect x='4' y='4' width='5.6' height='24'/><rect x='11.2' y='4' width='14.4' height='11.2'/><rect x='11.2' y='16.8' width='16.8' height='11.2'/></svg>">
<script type="module" crossorigin src="/assets/index-Cw2mk7qH.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Cb0dESCp.css">
<script type="module" crossorigin src="/assets/index-C_yRHCCD.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C52IQv2y.css">
</head>
<body>
<div id="root"></div>
Expand Down
Loading