fix(hub): keep folder-row metadata on phones and name the heat dot (BEA-14) (#64)

Below 430px `.dl-meta` was `display:none`, so a folder listing on a phone
showed a filename and an unexplained coloured dot — no read count, no size,
no date. The comment justifying it assumed the dot carried the signal, but
the dot's meaning lived entirely in a `title=` attribute: never shown on
touch, never read by a screen reader on any viewport.

- `.dl-row` wraps and `.dl-meta` takes a full-width second line at ≤430px,
  indented 27px to align under the filename. The name still wins line one
  and is never truncated; the full string fits at 360px, so no shortened
  variant is needed.
- The heat dot gets `role="img"` + `aria-label` on every viewport, which
  also fixes desktop screen-reader users.
- Playwright assertion in layout.spec.ts at 360/390/430: meta matches the
  desktop string, filename untruncated, rows ≥44px, no horizontal scroll,
  every dot has an accessible name.

Desktop (≥431px) is unchanged — measured identical row heights and meta
positions before and after.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Snow W. Lee (Sungwon)
2026-07-28 07:07:03 +09:00
committed by GitHub
co-authored by Claude Fable 5
parent eba6fe1a64
commit f773b0c6e7
6 changed files with 80 additions and 21 deletions
@@ -110,3 +110,51 @@ test("the gutter belongs to the scroll container, not the column", async ({ page
expect(r.childMax, `${path}: view sets its own max-width`).toBe("none");
}
});
/* 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
wraps to a second line and the dot carries its own accessible name. */
test("folder rows keep their metadata, and the heat dot a name, on a phone", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
const file = page.locator('.dl-row[title="notes/readme.md"]');
const dir = page.locator('.dl-row[title="notes/deep"]');
await page.setViewportSize({ width: 431, height: 800 });
await page.goto(`/${pid}/notes`);
await page.waitForSelector(".dl-row");
const desktopMeta = await file.locator(".dl-meta").textContent();
for (const width of [360, 390, 430]) {
await page.setViewportSize({ width, height: 800 });
await page.goto(`/${pid}/notes`);
await page.waitForSelector(".dl-row");
// Same information as desktop: read count, size and date all present.
await expect(file.locator(".dl-meta"), `${width}px: file meta`).toBeVisible();
expect(await file.locator(".dl-meta").textContent(), `${width}px: file meta`).toBe(desktopMeta);
await expect(file.locator(".dl-meta")).toContainText(/read/);
await expect(dir.locator(".dl-meta"), `${width}px: folder meta`).toContainText("1 item");
const m = await page.evaluate(() => {
const rows = [...document.querySelectorAll<HTMLElement>(".dl-row")];
const name = rows.map((r) => r.querySelector(".dl-name") as HTMLElement);
return {
truncated: name.some((n) => n.scrollWidth > n.clientWidth + 1),
minHeight: Math.min(...rows.map((r) => r.getBoundingClientRect().height)),
overflow: document.documentElement.scrollWidth > document.documentElement.clientWidth,
};
});
expect(m.truncated, `${width}px: filename truncated`).toBe(false);
expect(m.minHeight, `${width}px: row tap target`).toBeGreaterThanOrEqual(44);
expect(m.overflow, `${width}px: horizontal page scroll`).toBe(false);
// Accessibility tree, not pixels: every dot announces itself. (Read
// counts accumulate across the shared hub, so match the shape not a
// number — what matters is that no dot is nameless.)
const dots = await page.locator(".heatdot").count();
await expect(page.getByRole("img", { name: /read/ }), `${width}px: named dots`).toHaveCount(dots);
await expect(file.locator(".heatdot")).toHaveAttribute("aria-label", /\d+ reads? .*in 30 days/);
}
});
@@ -70,7 +70,16 @@ export function FolderListing(props: {
<Icon name={c.dir ? "folder" : "doc"} />
</span>
<span className="dl-name">{c.name}</span>
{he && <span className={"heatdot lvl" + heatLevel(he)} title={heatText(he) + " in 30 days"} />}
{he && (
/* title= needs hover, which touch never gives and screen
readers never see — the dot carries its own name. */
<span
className={"heatdot lvl" + heatLevel(he)}
role="img"
aria-label={heatText(he) + " in 30 days"}
title={heatText(he) + " in 30 days"}
/>
)}
<span className="dl-meta">{meta}</span>
</div>
);
+5 -3
View File
@@ -784,9 +784,11 @@ a.ai-main:hover { color: var(--accent); }
.modal-actions .ai-del { margin-right: auto; }
@media (max-width: 430px) {
/* The name wins the row: drop the verbose meta (the heat dot still
conveys activity) instead of truncating "readme.md" to "readme...." */
.dl-meta { display: none; }
/* The name still wins the row — but the meta wraps under it instead of
disappearing: hiding it left an unexplained coloured dot as the only
signal. Indent 27px (16px icon + 11px gap) to align under the name. */
.dl-row { flex-wrap: wrap; row-gap: 2px; }
.dl-meta { flex: 1 1 100%; padding-left: 27px; }
.ai-tag { font-size: 11px; }
.htime { white-space: nowrap; font-size: 12px; }
.hline { flex-wrap: wrap; }
File diff suppressed because one or more lines are too long
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-CH2MFMbS.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CzGgqyHx.css">
<script type="module" crossorigin src="/assets/index-Bbj9qBED.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DgC5ZhxZ.css">
</head>
<body>
<div id="root"></div>