fix(webapp): say that a read count includes your own views (BEA-61) (#173)

* fix(webapp): say that a read count includes your own views (BEA-61)

"14 human reads" counted the author's own browsing, and no surface said so.
Snow's call was to keep counting them — so this is disclosure, not ingest:
recordRead and /heat are untouched.

One HEAT_DISCLOSURE constant beside heatText in lib/heat.ts, consumed by all
four surfaces that print a count. The file header carries it as hover text
plus an .sr-only span rather than visible text — #meta is nowrap + ellipsis,
so anything appended there is the first thing a narrow window truncates away.
The folder page says it once out loud, covering the summary and every row,
and the heat dot keeps it in title and aria-label for a row met on its own.
The Dashboard folds it into the caption already there, both scope branches.

A unit test pins the constant as the only copy of the sentence in src/ —
"defined once" is the acceptance criterion no browser test can see.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(architecture): note HEAT_DISCLOSURE in the frontend lib diagram (BEA-61)

The diagram enumerates heat.ts's exports, so a new one belongs in it — and
the note says why the constant sits beside the arithmetic instead of in the
four components that print it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Snow Lee (Sungwon)
2026-08-18 18:04:15 -07:00
committed by GitHub
co-authored by Claude Opus 5
parent c3b9fa5858
commit 3fbe1252db
15 changed files with 152 additions and 36 deletions
@@ -1138,3 +1138,24 @@ test("a shared diagram renders on the public page, without one there is no scrip
await expect(page.locator("img")).toHaveCount(0);
expect(await detail.evaluate((el) => getComputedStyle(el).whiteSpace)).toBe("pre");
});
/* BEA-61: a read count that doesn't say your own views are in it reads as
other people's interest. Every surface printing one discloses it — the file
header can't do it visibly (#meta is nowrap + ellipsis), so it does it by
hover text and a screen-reader span. */
test("read counts disclose that your own views count", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/index.md`);
const heat = page.locator("#meta span[title]");
await expect(heat).toContainText("/ 30d");
await expect(heat).toHaveAttribute("title", /Includes your own views\./);
await expect(heat.locator(".sr-only")).toContainText("10 minutes count once");
// The folder page says it once, out loud, for the summary and every row.
await page.goto(`/${pid}/notes`);
await expect(page.locator(".dl-heatnote")).toHaveText(
"Includes your own views. Repeat opens by the same reader inside 10 minutes count once.",
);
});
@@ -260,3 +260,16 @@ test("files with no reads still chart", async ({ page }) => {
await expect(page.locator(".in-treemap")).toBeVisible();
await expect(page.locator(".in-blank")).toHaveCount(0);
});
// BEA-61: the Dashboard's advice is built on these counts, so its caption is
// the one place the disclosure has to survive a scope change too.
test("the reads × freshness caption says own views count, scoped or not", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
for (const route of [`/${pid}/dashboard`, `/${pid}/dashboard/notes`]) {
await page.goto(route);
await expect(page.locator(".insights > .dl-sub")).toContainText(
"Includes your own views. Repeat opens by the same reader inside 10 minutes count once.",
);
}
});
+7 -1
View File
@@ -272,6 +272,12 @@ test("folder rows keep their metadata, and the heat dot a name, on a phone", asy
// 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/);
// BEA-61: the dot's name also has to say what the count includes. Anchored
// on the disclosure, not just the shape — the old unanchored regex would
// have passed just as happily with the sentence dropped again.
await expect(file.locator(".heatdot")).toHaveAttribute(
"aria-label",
/\d+ reads? .*in 30 days\. Includes your own views\..*10 minutes count once\./,
);
}
});
@@ -202,7 +202,7 @@ export default function Browser(props: {
);
/* ---- topbar state + actions ---- */
const [meta, setMeta] = useState("");
const [meta, setMeta] = useState<ReactNode>("");
const [share, setShare] = useState<{ url: string; copied: boolean } | null>(null);
const [moreOpen, setMoreOpen] = useState(false);
const [paletteOpen, setPaletteOpen] = useState(false);
@@ -1,8 +1,9 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { getJSON } from "../api/http";
import type { HeatMap, Node, RenderDoc } from "../api/types";
import { heatTotal, heatText } from "../hooks/useBrowse";
import { HEAT_DISCLOSURE } from "../lib/heat";
import { useTextAt } from "../hooks/useBlob";
import {
CSV_EXT,
@@ -30,7 +31,7 @@ export function FileView(props: {
// Hub mode only; absent in volume mode, where file URLs are "/<path>".
projectId?: string;
onOpenFile: (path: string) => void;
onMeta: (meta: string) => void;
onMeta: (meta: ReactNode) => void;
onRendered?: () => void;
}) {
const { apiBase, path, version, onMeta } = props;
@@ -190,8 +191,24 @@ function MarkdownView(props: Parameters<typeof FileView>[0]) {
// beside content the banner just called historical reads as if they
// counted views of these bytes.
const he = version ? null : heatMap && heatMap[doc.path];
if (he && heatTotal(he)) parts.push(heatText(he) + " / 30d");
onMeta(parts.join(" · "));
// The count says what is in it, but #meta is nowrap + ellipsis
// (style.css:381) — visible text appended here is the first thing a
// narrow window truncates away, so the disclosure rides along as hover
// text and a screen-reader-only span instead.
const heat = he && heatTotal(he) ? heatText(he) + " / 30d" : "";
onMeta(
heat ? (
<>
{parts.length ? parts.join(" · ") + " · " : ""}
<span title={HEAT_DISCLOSURE}>
{heat}
<span className="sr-only"> {HEAT_DISCLOSURE}</span>
</span>
</>
) : (
parts.join(" · ")
),
);
onRendered?.();
}, [doc, version, heatMap, onMeta, onRendered]);
@@ -1,6 +1,7 @@
import { useEffect } from "react";
import type { HeatMap, Node } from "../api/types";
import { heatFor, heatLevel, heatText, useFolderHistory } from "../hooks/useBrowse";
import { HEAT_DISCLOSURE } from "../lib/heat";
import { humanSize } from "../util";
import { Icon } from "./shell";
import { HistoryRow } from "./HistoryRow";
@@ -25,6 +26,11 @@ export function FolderListing(props: {
if (files) counts.push(files + (files === 1 ? " file" : " files"));
const folderHeat = heatFor(heatMap, node.path, true);
if (folderHeat) counts.push(heatText(folderHeat) + " in 30 days");
// One sentence per page covers the summary count and every row count at
// once; a full disclosure repeated down fifty dense rows would not fit and
// would not be read. The rows keep it on the dot for anyone who meets a row
// on its own (hover, screen reader).
const anyHeat = !!folderHeat || kids.some((c) => heatFor(heatMap, c.path, !!c.dir));
return (
<div className="dirlist">
@@ -35,6 +41,7 @@ export function FolderListing(props: {
<span>{node.name}</span>
</h1>
<p className="dl-sub">{counts.join(" · ") || "Empty folder"}</p>
{anyHeat && <p className="dl-heatnote">{HEAT_DISCLOSURE}</p>}
{kids.length === 0 ? (
<div className="dl-empty">Nothing in this folder yet.</div>
) : (
@@ -76,8 +83,8 @@ export function FolderListing(props: {
<span
className={"heatdot lvl" + heatLevel(he)}
role="img"
aria-label={heatText(he) + " in 30 days"}
title={heatText(he) + " in 30 days"}
aria-label={heatText(he) + " in 30 days. " + HEAT_DISCLOSURE}
title={heatText(he) + " in 30 days. " + HEAT_DISCLOSURE}
/>
)}
<span className="dl-meta">{meta}</span>
@@ -3,7 +3,7 @@ import { useQuery } from "@tanstack/react-query";
import { getJSON } from "../api/http";
import type { HeatMap, Node } from "../api/types";
import { heatTotal, hotPathSplit } from "../hooks/useBrowse";
import { ageRange, ageSpanLabel, isFlatRange, orphanPaths, placeLabels } from "../lib/heat";
import { HEAT_DISCLOSURE, ageRange, ageSpanLabel, isFlatRange, orphanPaths, placeLabels } from "../lib/heat";
import { linkProps } from "../nav";
/* ---- the project Dashboard: the read×write matrix ----
@@ -188,8 +188,9 @@ export function Insights(props: {
<h1 className="in-title">Knowledge insights{scope ? <span className="in-scope"> · {scope}</span> : null}</h1>
<p className="dl-sub">
{scope
? `Reads over the last 30 days × freshness, for ${scope} and everything in it.`
: "Reads over the last 30 days × how long since each file changed. Hot but stale knowledge — read a lot, maintained by nobody — is the danger zone."}
? `Reads over the last 30 days × freshness, for ${scope} and everything in it. ${HEAT_DISCLOSURE}`
: "Reads over the last 30 days × how long since each file changed. Hot but stale knowledge — read a lot, maintained by nobody — is the danger zone. " +
HEAT_DISCLOSURE}
</p>
<div className="in-lens">
{LENS_ORDER.map((l) => (
@@ -6,6 +6,9 @@ import assert from "node:assert/strict";
import { heatFor, heatLevel, heatText, heatTotal, hotPathSplit } from "./heat.ts";
import { ageRange, ageSpanLabel, isFlatRange, FLAT_AGE_SPREAD, orphanPaths } from "./heat.ts";
import { placeLabels, LABEL_MAX } from "./heat.ts";
import { HEAT_DISCLOSURE } from "./heat.ts";
import { readdirSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import type { HeatMap } from "../api/types.ts";
// One fixture, read by both surfaces: the file header (heatText/heatTotal) and
@@ -183,3 +186,31 @@ test("placeLabels: stacks upward when downward would leave the frame", () => {
assert.ok(ys.every((y) => y >= BOUNDS.top && y <= BOUNDS.bottom), `out of frame: ${ys}`);
for (let i = 1; i < ys.length; i++) assert.ok(ys[i] - ys[i - 1] >= 11, `overlap: ${ys}`);
});
test("HEAT_DISCLOSURE says both facts", () => {
assert.match(HEAT_DISCLOSURE, /own views/i);
assert.match(HEAT_DISCLOSURE, /10 minutes/);
});
// "Defined once" is the acceptance criterion, and it is the one thing no
// browser test can see: four surfaces each rendering their own copy would
// pass every e2e assertion right up until they drifted apart.
test("HEAT_DISCLOSURE is the only copy of the sentence", () => {
const dir = new URL("..", import.meta.url); // src/
const files: string[] = [];
const walk = (d: URL) => {
for (const e of readdirSync(d, { withFileTypes: true })) {
if (e.name === "node_modules") continue;
const u = new URL(e.name + (e.isDirectory() ? "/" : ""), d);
e.isDirectory() ? walk(u) : /\.(ts|tsx|css)$/.test(e.name) && files.push(fileURLToPath(u));
}
};
walk(dir);
const needle = HEAT_DISCLOSURE.slice(0, 30);
const hits = files.filter((f) => readFileSync(f, "utf8").includes(needle));
assert.deepEqual(
hits.map((f) => f.replace(/.*\/src\//, "src/")),
["src/lib/heat.ts"],
"the disclosure must live only in lib/heat.ts",
);
});
+8
View File
@@ -42,6 +42,14 @@ export function heatText(e: HeatEntry): string {
return s + " (" + parts.join(", ") + ")";
}
/* Every surface that prints a read count prints this beside it. Defined here,
next to the arithmetic, so four components cannot drift into four different
promises about what the number counts. A member browsing their own project
is a reader like any other the count says so rather than quietly
including them. */
export const HEAT_DISCLOSURE =
"Includes your own views. Repeat opens by the same reader inside 10 minutes count once.";
/* Dot intensity 14, log-ish steps: 12, 39, 1029, 30+ reads. */
export function heatLevel(e: HeatEntry): number {
const total = heatTotal(e);
+5
View File
@@ -593,6 +593,11 @@ a.ai-main:hover { color: var(--accent); }
.dl-title-icon { display: flex; color: var(--accent); }
.dl-title-icon .ico { width: 20px; height: 20px; }
.dl-sub { color: var(--text-faint); font-size: 12.5px; margin: 0 0 18px; }
/* The read-count disclosure, printed under the folder's summary line: the
summary's voice one notch quieter, sitting close enough that the two read as
one block. Deliberately NOT .dl-sub that class means "the summary line" to
every other reader of this page, tests included. */
.dl-heatnote { color: var(--text-faint); font-size: 12px; opacity: .8; margin: -14px 0 18px; }
.dl-items { border: 1px solid var(--border); border-radius: var(--r-card); overflow: hidden; background: var(--bg-side); }
.dl-row { display: flex; align-items: center; gap: 11px; padding: 10px 14px; border-bottom: 1px solid var(--border); cursor: pointer; }
.dl-row:last-child { border-bottom: none; }
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,10 +5,10 @@
<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-J4RRWzcZ.js"></script>
<script type="module" crossorigin src="/assets/index-CARRuPsm.js"></script>
<link rel="modulepreload" crossorigin href="/assets/_commonjsHelpers-CqkleIqs.js">
<link rel="modulepreload" crossorigin href="/assets/mermaid-DQuCJ8Gi.js">
<link rel="stylesheet" crossorigin href="/assets/index-DpPcGEM4.css">
<link rel="stylesheet" crossorigin href="/assets/index-CYNuHxXE.css">
</head>
<body>
<div id="root"></div>