fix(webapp): keep the provenance line on mobile, unclipped in History (BEA-70) (#121)

Below 900px the file view hid the who/when/how-hot line outright
(`#meta { display: none }`) and the History run header ellipsised its
note and author to `claude-…` / `Alice <ali…` — the line the product is
differentiated by, gone exactly on the surface people catch up from.

CSS-only, inside the existing `@media (max-width: 900px)` block:

- `#topbar` wraps (`height: auto; min-height: 52px`) and `#meta` takes its
  own full-width row, left-aligned and wrapping. `order: 1` is what keeps
  Search / Share / ⋯ on row 1 — meta precedes them in the DOM, so a bare
  flex-wrap would drag them down. `#meta:empty` keeps folder, dashboard
  and history routes from gaining a blank strip.
- `.hrun-head` wraps; the note loses its 46% cap and both spans stop
  ellipsising, with `.hrun-meta` on its own line under the note and the
  time still on row 1.

Desktop (≥901px) is untouched — every rule lives inside the breakpoint.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Snow Lee (Sungwon)
2026-08-05 16:38:00 +09:00
committed by GitHub
co-authored by Claude Opus 5
parent 631e5be947
commit 5fb834e3a4
5 changed files with 111 additions and 7 deletions
@@ -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
+20 -4
View File
@@ -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; }
@@ -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
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -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>