Add the dashboard table framework design and Codex briefs

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-20 20:54:12 +00:00
co-authored by Claude Fable 5.1
parent 0f17480be7
commit 8889a4e351
5 changed files with 312 additions and 0 deletions
+70
View File
@@ -0,0 +1,70 @@
# Dashboard tables
How every data table on the dashboard is laid out, and the rules a new table
must follow so it never grows a horizontal scrollbar on a desktop screen.
Everything here is plain CSS in `src/web/tailwind.css` plus four cell classes;
there is no table library and no JavaScript involved.
## The problem this solves
A table used to be `min-width: max-content` inside a `.scroll-x` wrapper, and
most cells were `white-space: nowrap`. Any table whose natural width exceeded
the page column (80rem, ~1355px at the site's 110% scale) became a horizontal
scroller, even on a 2560px monitor, and the columns that mattered were hidden
behind the scrollbar. Wrapping columns were squeezed to their minimum while the
nowrap ones kept everything.
## The three rules
1. **Tables get the whole viewport, everything else keeps the page column.**
`.dashboard` is a CSS grid with two named column spans: `content` (at most
80rem minus the page gutters, exactly what the header uses) and `wide` (the
viewport minus the same gutters). Every direct child sits in `content`; a
direct-child `.scroll-x` sits in `wide`. The table inside a wide wrapper is
`width: auto` (shrink-to-fit) with `min-width` equal to the content column
and `max-width: 100%`, centred. A table that fits the page column therefore
looks exactly as before; a table that needs more grows, centred, up to the
viewport edge; the page itself never scrolls sideways.
2. **Columns wrap before the table scrolls.** The `min-width: max-content` rule
is gone. A table shrinks by wrapping its text columns down to their floors,
and only when the floors alone no longer fit (phones, mostly) does the
`.scroll-x` wrapper scroll. Headers always wrap.
3. **A cell declares what it holds, and nothing else decides its width.**
There are exactly four cell classes:
| class | use it for | behaviour |
| ------------ | -------------------------------------------------------------- | ---------------------------------------------------------------------- |
| *(none)* | short prose, names, tokens, badges | wraps at spaces |
| `num` | numbers, money, counts | right-aligned tabular figures; `td.num` never wraps, `th.num` may |
| `cell-tight` | timestamps, dates, ids, a single short token that must not split | `white-space: nowrap`. Nothing longer than ~20 characters |
| `cell-wrap` | titles, URLs, notes, messages, badge lists, anything free-form | floor 10rem, ceiling 32rem, `overflow-wrap: anywhere` |
Anything long that must not wrap gets an inner block with a Tailwind clamp
(`<div class="line-clamp-2" title="…">`) inside a `cell-wrap` cell; the cell
ceiling bounds the column, the clamp bounds the row height. Do not put
`truncate`/`line-clamp-*` on a `td` itself (a table cell cannot be a
`-webkit-box`), and do not use `table-fixed` on a page-level table (fixed
layout needs a definite width and is silently ignored with `width: auto`).
## Checklist for a new table
- Wrap it in `<div class="scroll-x">` (add `tall` for a 70vh vertical cap) and
make that wrapper a **direct child** of `<section class="dashboard">` if the
table has more than a handful of columns. Tables inside `.card`, `.cards` or
`details` stay inside their box and just wrap/scroll there.
- Give every `td` one of the four classes above according to its content.
When in doubt, leave it unclassed: wrapping is the safe default.
- Never `cell-tight` a name, a title, a reason sentence or a free-text field.
- Check the page at 390, 1280 and 2560px. At 1280 and above the wrapper's
`scrollWidth` must equal its `clientWidth`; at 390 a wide table is allowed to
scroll.
## Measuring
`docs/plans/briefs/dashboard-tables/measure.mjs` logs in as the dev-seed admin,
visits every dashboard page at several widths and prints, per page, whether the
document overflows and which table wrappers scroll. Run it against a seeded
dev server (`cargo run --example seed_dev_db -- ./dev`) before and after a CSS
change; the "scrolling" count for widths ≥ 1280 should be zero.
@@ -0,0 +1,50 @@
# Shared brief — dashboard tables (2026-09-20)
The operator sees horizontal scrollbars on most dashboard tables, even on an
ultrawide monitor, because every table is capped at the 80rem page column and
`min-width: max-content` forbids wrapping. The fix is a small, reusable table
framework described in **`docs/dashboard-tables.md` — read it first; it is the
spec.** Two tasks run in parallel in separate git worktrees:
- `01-css.md` — the CSS side (`src/web/tailwind.css`, rebuilt `app.css`).
- `02-templates.md` — the template audit (every `td` gets the right cell class).
## Ground rules
- Work only in the worktree named in your brief. **Never touch the main checkout**
at `/home/thallada/workspace/the-daily-epub`. **Do not `git commit`** (the sandbox
cannot write `.git/worktrees`); leave the work in the tree, the orchestrator
commits.
- Styling lives in **`src/web/tailwind.css`** (Tailwind v4, `@apply` inside
`@layer components`, semantic colour tokens). After every CSS change run
`npm run css` (writes `src/web/static/app.css`, which the binary embeds with
`include_str!`). `npm run css:check` must print no diff at the end.
- CSP forbids inline `<style>`, `<script>` and `style=""` attributes. No new JS,
no new dependencies, no table library: this is plain CSS + four cell classes.
- Keep every hook `app.js` relies on: `table[data-filter]`, `.table-filter`,
`input[data-table-filter]`, `.scroll-x` (the filter script does
`table.closest(".scroll-x")`), `form[data-confirm]`, `details[id]`, `[data-refresh]`.
- Keep `cargo fmt`, `cargo clippy --all-targets -- -D warnings` and the tests
green (`cargo test --lib web::` while iterating, one full `cargo test` at the
end). **Sandbox note:** inside the Codex sandbox `bind()` on 127.0.0.1 is
forbidden; ignore every test that fails with `PermissionDenied`/bind errors
(about 17 pre-existing ones). Do not "fix" them.
- 4 cores / 7 GB shared with another agent building Rust at the same time: one
cargo process at a time, be patient.
- KISS / YAGNI: no config knobs, no per-table width tables, no JS resizing, no
responsive card-ification of rows. Where the brief and the code disagree,
follow the code and note the deviation in your handoff.
- Finish by writing `docs/plans/briefs/dashboard-tables/handoff-<task>.md`:
what changed (file by file), deviations, what you verified and how, anything
left open.
## Previewing (optional — the sandbox usually cannot bind a port)
Seed a throwaway database with `cargo run --example seed_dev_db -- ./dev`
(git-ignored) and serve it with
`DAILY_EPUB_SERVER__HMAC_SECRET=$(head -c 48 /dev/urandom | base64) DAILY_EPUB_SERVER__BIND=127.0.0.1:<port> cargo run -- --config ./dev/config.toml serve`.
`measure.mjs` in this directory (needs `playwright` on the module path; a copy
lives at `~/.npm/_npx/705bc6b22212b352/node_modules`) prints per page and width
whether the document overflows and which table wrappers scroll. If you cannot
bind, rely on the router tests and say so; the orchestrator screenshots every
page at 390/768/1280/1920/2560 after merging.
@@ -0,0 +1,85 @@
# Task 01 — table framework CSS
Worktree: `/home/thallada/workspace/the-daily-epub-tables-css` (branch `tables-css`).
Read `00-shared.md` and `docs/dashboard-tables.md` first. Only `src/web/tailwind.css`
and the rebuilt `src/web/static/app.css` change in this task (plus your handoff).
The template audit happens in a sibling worktree; do not edit templates.
## 1. `.dashboard` becomes a content grid
Replace `.dashboard { @apply mx-auto my-8 max-w-7xl px-4 pb-6 … sm:px-6; }` with a
grid that keeps the page column pixel-identical to the header
(`mx-auto max-w-7xl px-4 sm:px-6` = content width `80rem - 2 * gutter`, centred):
```css
.dashboard {
--gutter: 1rem;
@apply my-8 grid pb-6 font-sans text-base leading-normal;
grid-template-columns:
[full-start] var(--gutter)
[wide-start] 1fr
[content-start] minmax(0, calc(80rem - 2 * var(--gutter)))
[content-end] 1fr
[wide-end] var(--gutter)
[full-end];
}
@media (width >= 40rem) { .dashboard { --gutter: 1.5rem; } } /* Tailwind `sm` */
.dashboard > * { grid-column: content; min-width: 0; }
.dashboard > .scroll-x { grid-column: wide; }
```
Keep `.dashboard > * + * { mt-4 }` and every other `.dashboard …` rule as they
are (margins on grid items work). Verify in the templates that nothing relied
on the old padding: `.save-bar` uses `-mx-4 sm:-mx-6` to bleed to the edges;
under the grid it still overflows its `content` area into the gutter columns
by the same amount, so leave it unless you see a problem. The `sm` breakpoint
is `40rem` in Tailwind v4 and `@media` rem is always 16px-based, so the query
above matches `sm:` exactly.
Prototype measured in Chromium and Firefox at 390/1280/2560: `h1` left edge and
width identical to today, no document overflow, table centred and wider than
the column only when its content needs it.
## 2. Tables shrink by wrapping; page-level tables may grow past the column
- Delete `.scroll-x > table { min-width:max-content; }`.
- Add, for the wide wrapper only:
```css
.dashboard > .scroll-x > table {
width: auto; /* shrink-to-fit */
min-width: min(100%, calc(80rem - 2 * var(--gutter)));
max-width: 100%;
margin-inline: auto;
}
```
A table narrower than the page column fills the column as today; a wider one
grows, centred, to the viewport edge; the wrapper scrolls only past that.
Tables that are not direct children (cards, disclosures, signal tables inside
cells) keep `w-full` from the base rule and simply wrap, then scroll.
## 3. Cell vocabulary (exactly four classes, see the doc's table)
- `.num` → `@apply text-right tabular-nums;` and `td.num { @apply whitespace-nowrap; }`.
Header cells with `.num` may wrap ("cache write", "matched articles").
- `.cell-tight` unchanged (`whitespace-nowrap`).
- `.cell-wrap` → `@apply min-w-[10rem] max-w-[32rem] whitespace-normal; overflow-wrap:anywhere;`
(ceiling raised from 24rem so titles get a single line when the screen has
room; `max-width` on a cell is honoured by Chromium and Firefox for the
column's max-content contribution — measured).
- `th` keeps wrapping (no nowrap); keep the sticky `thead`.
- `td > pre.preview { max-w-md }` and `.rating-cell` rules stay.
## 4. Small things to keep honest
- `.prose-body table` (reader pages) keeps its own rules; do not touch.
- Put a short comment above the table rules pointing at `docs/dashboard-tables.md`.
- `npm run css`, then `cargo build` (the binary embeds app.css) and
`cargo test --lib web::`. Router tests assert on markup, not on these rules,
so nothing should break; if something does, say what and why in the handoff.
- If you can run the dev server + `measure.mjs`, do it and paste the ≥1280 lines
for `/dashboard/articles`, `/dashboard/runs` and `/dashboard/runs/1` into the
handoff. With unchanged templates some tables will still scroll at 1280 —
that is expected until the template audit lands — but at 2560 the articles
and candidates tables must fit without scrolling.
@@ -0,0 +1,65 @@
# Task 02 — template audit: every cell says what it holds
Worktree: `/home/thallada/workspace/the-daily-epub-tables-tpl` (branch `tables-tpl`).
Read `00-shared.md` and `docs/dashboard-tables.md` first. Only files under
`src/web/templates/` change in this task (plus your handoff). The CSS lands in a
sibling worktree; assume the four cell classes behave exactly as the doc's
table says (`cell-wrap` floor 10rem / ceiling 32rem with `overflow-wrap:anywhere`,
`cell-tight` nowrap, `td.num` nowrap right-aligned, unclassed cells wrap at
spaces, `th` always wraps, page-level tables grow past the page column when
their content needs it).
Go through every `<table>` in `src/web/templates/dashboard/*.html`,
`src/web/templates/_candidate_row.html` and `src/web/templates/_signals_table.html`
and apply these rules to each `td`:
1. **`cell-tight` only on timestamps, dates, ids and single tokens ≤ ~20
characters** (`first_seen`, `when`, `started`, `finished`, `requested`,
`created`, `last_login`, `saved_at`, `changed_at`, `event_at`, `issue_date`,
`published`, `run.date`, `run.funnel` "310 → 200 → …", `age → decay`,
`#id`). **Remove it** from feed names/titles (`article.feed`, `candidate.feed`,
`miss.feed`, `row.feed_credits`, nearest-article `feed`), reasons
(`article.reason`, `row.reason`, `candidate.reason`), "admitted by" cells,
usernames / `requested_by` / `saved_by`, roles, sources, kinds, job names,
and from any cell that combines a badge with more text (stage + run date).
A `.badge` is already `whitespace-nowrap` on its own, so a badge-only cell
needs no class.
2. **`cell-wrap` on every free-form column**: titles, URLs, notes, messages,
"reason or comment", facet values, badge lists (interests), config diff
before/after, anything a user typed. Keep it where it already is.
3. **`num` on numbers only** (it already is; just make sure no text column
uses it because it wanted right alignment — `#id` cells may keep `num`).
4. **Long text that should not stretch a row**: if a column can hold hundreds of
characters (job `message`, rating-import `message`, `note`), keep
`cell-wrap` and wrap the text in `<div class="line-clamp-3" title="{{ … }}">`
**only** if the full text is reachable somewhere else on the page or via the
`title`; otherwise let it wrap. Never put `truncate`/`line-clamp-*` on a `td`.
5. **`dashboard/feeds.html`**: drop `table-fixed` (fixed layout is ignored once
the page-level table is `width:auto`). Keep the `w-*` hints on the `th`s
(they act as minimums under auto layout), keep the `line-clamp-*`/`truncate`
inner elements — they already sit on block children, which is the pattern —
and put `cell-wrap` on the Feed and Why cells if it is not there. The
actions cell keeps its forms; give it `cell-tight` only if the buttons wrap
badly, otherwise leave it unclassed.
6. **`dashboard/run.html` Feeds card** (`<table class="table-fixed">` inside
`.card`): this table is `w-full` inside a card, so `table-fixed` still works
there; leave it, but it is the only allowed `table-fixed`.
7. **Header cells**: remove nothing, but make sure no `th` has `whitespace-nowrap`
or `cell-tight`; long headers such as
"considered → eligible → triaged → assessed → shortlisted → selected" may wrap.
8. **Wrappers**: every page-level table (more than a handful of columns:
runs, candidates, articles, ratings current/events, interests, feeds, users,
jobs, stats runs/costs, settings history, profile versions, near misses,
funnel) must sit in a `<div class="scroll-x …">` that is a **direct child**
of `<section class="dashboard …">` — check that no such wrapper is nested in
a stray `<div>`; tables inside `.card`/`.cards`/`details` stay where they are.
Keep `tall` where it is today.
Do not change the columns themselves, their order, the text, or any
`data-*`/`id`/`name`/`action`/`aria-*` attribute. Do not touch reader/public
templates (`issue_*.html`, `article.html`, `world.html`, `behind.html`, `_toc.html`).
Verify: `cargo test --lib web::` (router tests assert on text and some class
names; if one breaks only because a class moved, update the test and say so),
then `cargo fmt`/`clippy`. List in the handoff, per template, which cells lost
`cell-tight`, which gained `cell-wrap`, and any clamp you added.
@@ -0,0 +1,42 @@
// usage: node measure.mjs <port> <outdir> [widths]
import { chromium } from "playwright";
import fs from "node:fs";
const [port, outdir, widthsArg] = process.argv.slice(2);
const base = `http://127.0.0.1:${port}`;
const widths = (widthsArg || "390,768,1280,1920,2560").split(",").map(Number);
const pages = ["/dashboard","/dashboard/runs","/dashboard/runs/1","/dashboard/runs/2","/dashboard/articles","/dashboard/articles/1","/dashboard/ratings","/dashboard/ratings?tab=events","/dashboard/interests","/dashboard/feeds","/dashboard/feeds?status=added","/dashboard/profile","/dashboard/stats","/dashboard/jobs","/dashboard/settings","/dashboard/settings/history","/dashboard/users","/","/issues","/account"];
fs.mkdirSync(outdir, { recursive: true });
const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const page = await ctx.newPage();
await page.goto(`${base}/login`);
await page.fill('input[name="username"]', "admin");
await page.fill('input[name="password"]', "adminpassword123");
await page.click('button[type="submit"]');
await page.waitForLoadState("networkidle");
const report = [];
for (const w of widths) {
await page.setViewportSize({ width: w, height: 1000 });
for (const p of pages) {
const resp = await page.goto(`${base}${p}`, { waitUntil: "networkidle" });
const status = resp?.status();
const info = await page.evaluate(() => {
const de = document.documentElement;
const container = document.querySelector(".dashboard, main > section, main > div, main");
const tables = [...document.querySelectorAll("table")].map((t) => {
const host = t.closest(".scroll-x") || t.parentElement;
const r = t.getBoundingClientRect();
return { cls: t.className, ths: t.querySelectorAll("thead th").length, rows: t.querySelectorAll("tbody tr").length, tableW: Math.round(r.width), hostW: Math.round(host.getBoundingClientRect().width), hostScroll: host.scrollWidth, hostClient: host.clientWidth, overflow: host.scrollWidth > host.clientWidth + 1 };
});
const culprits = [...document.querySelectorAll("body *")].filter((el) => el.scrollWidth > el.clientWidth + 1 && getComputedStyle(el).overflowX === "visible" && !el.closest("table")).slice(0, 5).map((el) => `${el.tagName.toLowerCase()}.${String(el.className).split(" ").slice(0,2).join(".")} ${el.scrollWidth}>${el.clientWidth}`);
return { pageOverflow: de.scrollWidth > de.clientWidth, docW: de.clientWidth, containerW: container ? Math.round(container.getBoundingClientRect().width) : null, tables, culprits };
});
const name = p.replace(/[\/?=]/g, "_").replace(/^_/, "") || "root";
if (w === 1280 || w === 2560 || w === 390) await page.screenshot({ path: `${outdir}/${w}-${name}.png`, fullPage: true });
report.push({ w, p, status, ...info });
const bad = info.tables.filter((t) => t.overflow);
console.log(`${w} ${p} status=${status} page=${info.pageOverflow ? "OVERFLOW" : "ok"} container=${info.containerW} tables=${info.tables.length} scrolling=${bad.length}${bad.length ? " [" + bad.map((t) => `${t.cls || "-"}:${t.hostScroll}>${t.hostClient}`).join(", ") + "]" : ""}${info.culprits.length ? " culprits=" + info.culprits.join(" | ") : ""}`);
}
}
fs.writeFileSync(`${outdir}/report.json`, JSON.stringify(report, null, 1));
await browser.close();