fix(web): design-review fixes to the column system

A design pass over the new tiers found four problems, two of them
introduced by the refactor itself:

- Insights was assigned `wide`, but its charts are viewBox="0 0 720 …"
  SVGs at width:100% — a wider column didn't show more, it magnified:
  measured 1.67x at 1600px, painting a 10.5px treemap label at 21px,
  larger than the page h1. Insights moves back to `app` and .in-chart
  caps at its 760px design width. Widening a column must never mean
  scaling content up; that line is now written into shell.tsx.
- /install rendered the same ConnectGuide as the project home, but
  wrapped in the .onboard card: x=652 w=560 top=186 against home's
  x=492 w=880 top=96 — two sidebar items apart, same component, three
  different numbers. It renders directly now. .onboard stays what it
  is, the empty-state hero card.
- History was `app`, so `.htime { margin-left: auto }` stranded each
  timestamp ~600px from its path. It's a listing — same rows as the
  folder view — so it belongs in `read` alongside it.
- --hero-top: 10vh is viewport-relative in the wrong direction: 84px on
  a 390x844 phone against 80px on a 1280x800 desktop, i.e. the smallest
  screen paid the most. Now clamp(32px, 8vh, 88px).

Also fixes the Copy button in the guide's code blocks: it was absolutely
positioned over a scrolling box, so its 72px of reserved padding
scrolled away with the content and the button landed on top of the
command (visible mid-token at 560px and on mobile). The block is a grid
now — code scrolls in its own track, the button can't overlap it.

layout.spec.ts gains three assertions: /install and home render the
guide identically, no chart scales past ~1.0x at 1600, and the tier map
matches the new assignments.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Snow Lee
2026-07-19 15:08:30 -07:00
co-authored by Claude Opus 4.8
parent 6cc61292ee
commit afac6bef65
9 changed files with 64 additions and 20 deletions
+39 -3
View File
@@ -42,14 +42,50 @@ test("every view shares one column system", async ({ page }) => {
}
};
await visit("", "app"); // project home / install guide
await visit("/history", "app");
await visit("", "app"); // project home
await visit("/install", "app"); // the same guide, so the same column
await visit("/settings", "app");
await visit("/insights", "wide");
await visit("/insights", "app"); // charts cap their own measure; the column is normal
await visit("/history", "read"); // a listing, like the folder view it shares rows with
await visit("/index.md", "read"); // rendered markdown
await visit("/notes", "read"); // folder listing
});
test("the install route and the project home render the guide identically", async ({ page }) => {
// They are two sidebar items apart and show the same component; /install
// used to wrap it in the .onboard card — 320px narrower, 90px lower.
await login(page);
const pid = await wikiId(page);
const box = async (path: string) => {
await page.goto(`http://localhost:8993/${pid}${path}`);
await page.waitForSelector(".guide");
return page.evaluate(() => {
const r = (document.querySelector(".guide") as HTMLElement).getBoundingClientRect();
return { left: Math.round(r.left), width: Math.round(r.width), top: Math.round(r.top) };
});
};
expect(await box("/install")).toEqual(await box(""));
});
test("charts never scale past the size they were drawn at", async ({ page }) => {
// .in-chart SVGs are viewBox="0 0 720 …" at width:100%, so an unbounded
// column magnifies them — labels ended up larger than the page title.
await page.setViewportSize({ width: 1600, height: 900 });
await login(page);
const pid = await wikiId(page);
await page.goto(`http://localhost:8993/${pid}/insights`);
await page.waitForSelector(".in-chart");
const worst = await page.evaluate(() => {
let max = 0;
for (const el of document.querySelectorAll(".in-chart")) {
const vb = (el.getAttribute("viewBox") || "0 0 720 0").split(/\s+/);
max = Math.max(max, el.getBoundingClientRect().width / Number(vb[2]));
}
return max;
});
expect(worst, "chart scale factor").toBeLessThanOrEqual(1.06);
});
test("the gutter belongs to the scroll container, not the column", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
@@ -236,7 +236,6 @@ export default function Browser(props: {
if (panel) {
view = panel.body;
} else if (route.view === "insights") {
pageWidth = "wide"; // treemap + coverage matrix need the room
view = props.canInsights ? (
<Insights
flatFiles={flatFiles}
@@ -251,6 +250,7 @@ export default function Browser(props: {
<div className="empty">Insights is for hub admins and org owners.</div>
);
} else if (route.view === "history") {
pageWidth = "read"; // a scannable list, like the folder listing it shares rows with
view = (
<HistoryView
apiBase={apiBase}
+4 -5
View File
@@ -163,12 +163,11 @@ export default function HubApp({ config }: { config: ServerConfig }) {
? { crumb: "Project settings", body: <ProjectSettings project={current} org={org} /> }
: route.view === "install"
? {
// The same guide the project home shows, in the same column —
// it used to sit in the .onboard card, 320px narrower and 90px
// lower than home, two sidebar items apart.
crumb: "Installation",
body: (
<div className="onboard">
<ConnectGuide project={current} />
</div>
),
body: <ConnectGuide project={current} />,
}
: null;
@@ -84,9 +84,17 @@ export function Icon({ name }: { name: string }) {
/* The column system, in one place. `#content` owns scrolling and the page
gutter; `<Page>` owns width and centering nothing else may set either.
Three widths cover every view: `read` for prose and listings (line length
rules), `app` for structured views, `wide` for data-dense ones. Views used
to declare their own max-width (560px to unbounded, half of them
uncentered), so no two routes shared a column. */
rules), `app` for structured views, `wide` for content that is itself a
page (a rendered HTML file in its frame). Views used to declare their own
max-width (560px to unbounded, half of them uncentered), so no two routes
shared a column.
The line: <Page> sets the COLUMN, a view may still cap its own MEASURE
(`.nf-sub`, a chart's design width). What a view must never do is declare
a page-level width that is how the tiers drifted apart the first time.
Widening a column also never means scaling content up: Insights sits at
`app` and its charts cap themselves, because at `wide` the viewBox SVGs
just zoomed (a 10.5px label painted at 21px). */
export type PageWidth = "read" | "app" | "wide";
export function Page(props: {
+5 -4
View File
@@ -35,7 +35,7 @@
--page-read: 704px; /* prose + listings: line length rules */
--page-app: 880px; /* structured views: guide, history, settings, admin */
--page-wide: 1200px; /* data-dense: treemap, coverage matrix, big tables */
--hero-top: 10vh; /* one vertical start for short centered states */
--hero-top: clamp(32px, 8vh, 88px); /* one vertical start for short centered states */
}
* { box-sizing: border-box; }
@@ -376,8 +376,9 @@ button, input, a.btn { font-family: inherit; }
.gd-step-title { font-weight: 600; font-size: 14px; color: var(--text); }
.gd-desc { margin: 2px 0 8px 32px; color: var(--text-faint); font-size: 13px; line-height: 1.5; }
.gd-extra { font-size: 12.5px; margin-top: 6px; }
.gd-code { position: relative; margin: 6px 0 6px 32px; padding: 10px 72px 10px 12px; background: var(--bg-raise); border: 1px solid var(--border); border-radius: var(--r-card); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12.5px; line-height: 1.6; color: var(--text); overflow-x: auto; white-space: pre; }
.gd-copy { position: absolute; top: 7px; right: 7px; font: inherit; font-family: inherit; font-size: 11px; font-weight: 600; padding: 3px 9px; border-radius: 6px; border: 1px solid var(--border-2); background: var(--bg-raise); color: var(--text-faint); cursor: pointer; box-shadow: -14px 0 12px -6px var(--bg-raise); }
.gd-code { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: 10px; margin: 6px 0 6px 32px; padding: 10px 12px; background: var(--bg-raise); border: 1px solid var(--border); border-radius: var(--r-card); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12.5px; line-height: 1.6; color: var(--text); }
.gd-code > code { display: block; min-width: 0; overflow-x: auto; white-space: pre; }
.gd-copy { align-self: start; font: inherit; font-family: inherit; font-size: 11px; font-weight: 600; padding: 3px 9px; border-radius: 6px; border: 1px solid var(--border-2); background: var(--bg-raise); color: var(--text-faint); cursor: pointer; }
.gd-copy:hover { color: var(--accent-bright); border-color: var(--accent-dim); }
/* A single unnumbered step has no badge to indent under. */
.gd-solo .gd-desc, .gd-solo .gd-code { margin-left: 0; }
@@ -396,7 +397,7 @@ button, input, a.btn { font-family: inherit; }
.in-lens-btn { font: inherit; font-size: 12px; padding: 5px 12px; border-radius: 999px; border: 1px solid var(--border); background: none; color: var(--text-faint); cursor: pointer; }
.in-lens-btn:hover { color: var(--text); }
.in-lens-btn.active { color: var(--accent); border-color: var(--accent); }
.in-chart { width: 100%; height: auto; border: 1px solid var(--border); border-radius: var(--r-card); background: var(--bg-side); margin-bottom: 6px; }
.in-chart { width: 100%; max-width: 760px; height: auto; border: 1px solid var(--border); border-radius: var(--r-card); background: var(--bg-side); margin-bottom: 6px; }
.in-axis { stroke: var(--border); stroke-width: 1; }
.in-threshold { stroke: var(--border); stroke-width: 1; stroke-dasharray: 4 4; }
.in-danger-zone { fill: rgba(242, 109, 109, .05); }
File diff suppressed because one or more lines are too long
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 100 100'><text y='.9em' font-size='90'>&#128059;</text></svg>">
<script type="module" crossorigin src="/assets/index-BZydVmgc.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DgVZuodz.css">
<script type="module" crossorigin src="/assets/index-Dnpnr_Sl.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-177q_1QG.css">
</head>
<body>
<div id="root"></div>