mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-09-12 13:48:58 +02:00
feat: make the dashboard readable at a glance
The dashboard showed the anonymize date under "Expired", a dash in the
Expires column for 54 of 60 rows, raw status codes such as
branch_not_found, and a chip reading "REMOVED" that actually meant
"removed items are hidden". Muted text sat at 3.5:1 contrast, keyboard
focus was invisible, numbers were left-aligned, and a legacy record
without an identifier rendered as an empty link to /pr/undefined/.
This rewrites the dashboard template and controller and tightens the
theme tokens:
- Status sub-line is labelled ("anonymized on Sep 6, 2026"); Expires
shows Never, the date, or "Expired on <date>".
- Conference folded into the name cell as a tag; Views right-aligned;
every column header is a keyboard-sortable button with aria-sort.
- Sort/Status buttons show their state; chips read "Hiding Removed";
result count and Clear filters; empty state with a way out.
- Status filter covers Ready, In progress, Error, Expired, Removed.
Stuck downloads say so; codes map to sentences; statuses to labels.
- Lists load in parallel and merge once behind a skeleton; broken
records are flagged instead of linking nowhere.
- Actions menu: View, View page, Edit, Force update, divider, Remove,
all as buttons rather than href="#" anchors.
- Theme: warm dark canvas, muted text >= 4.5:1 in both modes, semantic
status tokens, 11/13/14/16px type scale, mono only for identifiers,
6px/10px/pill radii, :focus-visible ring, quota bars in ink until a
limit is near, unlimited shown as such.
- Mobile: no 9px text, filter row wraps in two lines, meta on one line.
Adds scripts/dev-mock.js to serve the dashboard with fixture data and
test/dashboard-ui.test.js covering the filters, template invariants and
token contrast.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
9f00641529
commit
8e9b5b9a45
@@ -0,0 +1,169 @@
|
||||
const { expect } = require("chai");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const vm = require("vm");
|
||||
|
||||
/**
|
||||
* Regression tests for the dashboard redesign (Sept 2026):
|
||||
* - status codes and raw statuses are rendered as sentences,
|
||||
* - dates spell the month so they are unambiguous across locales,
|
||||
* - the template no longer uses href="#" anchors for actions,
|
||||
* - the theme tokens keep muted text above WCAG AA contrast.
|
||||
*/
|
||||
|
||||
const root = path.join(__dirname, "..");
|
||||
const appJs = fs.readFileSync(path.join(root, "public", "script", "app.js"), "utf8");
|
||||
const css = fs.readFileSync(path.join(root, "public", "css", "style.css"), "utf8");
|
||||
const html = fs.readFileSync(path.join(root, "public", "partials", "dashboard.htm"), "utf8");
|
||||
|
||||
// Load app.js with a stub `angular` that records filter factories, so the
|
||||
// filters can be exercised without a browser.
|
||||
function loadFilters() {
|
||||
const filters = {};
|
||||
const chain = new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_, prop) {
|
||||
if (prop === "filter") {
|
||||
return (name, factory) => {
|
||||
filters[name] = Array.isArray(factory) ? factory[factory.length - 1] : factory;
|
||||
return chain;
|
||||
};
|
||||
}
|
||||
return () => chain;
|
||||
},
|
||||
}
|
||||
);
|
||||
// jQuery is used at the top level of app.js for a couple of global
|
||||
// listeners; a self-returning proxy absorbs those calls.
|
||||
const jq = new Proxy(function () {}, {
|
||||
get: () => jq,
|
||||
apply: () => jq,
|
||||
});
|
||||
const sandbox = {
|
||||
angular: { module: () => chain, element: () => ({}) },
|
||||
$: jq,
|
||||
jQuery: jq,
|
||||
window: {},
|
||||
document: { addEventListener() {}, querySelector: () => null },
|
||||
localStorage: { getItem: () => null, setItem() {} },
|
||||
navigator: { language: "en-US" },
|
||||
console,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
setInterval,
|
||||
clearInterval,
|
||||
};
|
||||
sandbox.window = sandbox;
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(appJs, sandbox, { filename: "app.js" });
|
||||
return filters;
|
||||
}
|
||||
|
||||
function contrast(hexA, hexB) {
|
||||
const lum = (hex) => {
|
||||
const c = hex.replace("#", "").match(/../g).map((x) => parseInt(x, 16) / 255);
|
||||
const f = (v) => (v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4));
|
||||
return 0.2126 * f(c[0]) + 0.7152 * f(c[1]) + 0.0722 * f(c[2]);
|
||||
};
|
||||
const [l1, l2] = [lum(hexA), lum(hexB)];
|
||||
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
|
||||
}
|
||||
|
||||
function token(block, name) {
|
||||
const m = block.match(new RegExp(`${name}:\\s*(#[0-9a-fA-F]{6})`));
|
||||
expect(m, `token ${name} not found`).to.not.equal(null);
|
||||
return m[1];
|
||||
}
|
||||
|
||||
describe("dashboard UI", function () {
|
||||
let filters;
|
||||
before(function () {
|
||||
filters = loadFilters();
|
||||
});
|
||||
|
||||
describe("statusMsg filter", function () {
|
||||
it("turns known machine codes into sentences", function () {
|
||||
expect(filters.statusMsg()("branch_not_found")).to.equal("Branch not found on GitHub");
|
||||
});
|
||||
it("turns unknown snake_case codes into a sentence", function () {
|
||||
expect(filters.statusMsg()("token_revoked_by_user")).to.equal("Token revoked by user");
|
||||
});
|
||||
it("leaves free text alone", function () {
|
||||
expect(filters.statusMsg()("Something odd happened")).to.equal("Something odd happened");
|
||||
});
|
||||
});
|
||||
|
||||
describe("statusLabel filter", function () {
|
||||
it("labels in-progress statuses as verbs", function () {
|
||||
const f = filters.statusLabel();
|
||||
expect(f("download")).to.equal("Downloading");
|
||||
expect(f("queue")).to.equal("Queued");
|
||||
expect(f("ready")).to.equal("Ready");
|
||||
});
|
||||
it("title-cases unknown statuses", function () {
|
||||
expect(filters.statusLabel()("half_done")).to.equal("Half done");
|
||||
});
|
||||
});
|
||||
|
||||
describe("humanTime filter", function () {
|
||||
it("spells the month for dates older than two days", function () {
|
||||
const out = filters.humanTime()("2024-03-09T12:00:00Z");
|
||||
expect(out).to.match(/^on /);
|
||||
expect(out).to.match(/Mar/);
|
||||
expect(out).to.not.match(/\d+\/\d+\/\d+/);
|
||||
});
|
||||
it("keeps relative wording for recent dates", function () {
|
||||
const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
|
||||
expect(filters.humanTime()(twoHoursAgo)).to.equal("2 hours ago");
|
||||
});
|
||||
});
|
||||
|
||||
describe("template", function () {
|
||||
it("uses buttons, not href=\"#\" anchors, for row actions", function () {
|
||||
expect(html).to.not.match(/href="#"/);
|
||||
expect(html).to.match(/<button type="button" class="dropdown-item dropdown-item-danger"/);
|
||||
});
|
||||
it("puts the destructive action after a divider", function () {
|
||||
const divider = html.indexOf('<div class="dropdown-divider"></div>\n <button type="button" class="dropdown-item dropdown-item-danger"');
|
||||
expect(divider).to.be.greaterThan(-1);
|
||||
});
|
||||
it("labels hidden statuses as hidden, not as active filters", function () {
|
||||
expect(html).to.match(/Hiding \{\{statusKeyLabels\[f\]\}\}/);
|
||||
});
|
||||
it("shows an unlimited quota without a full bar", function () {
|
||||
expect(html).to.match(/quota-fill" ng-if="!quota\[q\.key\]\.unlimited"/);
|
||||
expect(html).to.not.match(/bg-success|bg-warning|bg-danger/);
|
||||
});
|
||||
it("right-aligns the Views column and marks it sortable", function () {
|
||||
expect(html).to.match(/class="num"[^>]*>\s*<button type="button" class="sortable"[^>]*ng-click="setSort\('pageView'\)"/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("theme tokens", function () {
|
||||
const light = css.slice(css.indexOf("\nbody {"), css.indexOf("--font-serif"));
|
||||
const dark = css.slice(css.indexOf(".dark-mode {"), css.indexOf("\nbody {"));
|
||||
|
||||
it("keeps muted text at or above 4.5:1 in light mode", function () {
|
||||
expect(contrast(token(light, "--ink-muted"), token(light, "--canvas-bg-color"))).to.be.at.least(4.5);
|
||||
});
|
||||
it("keeps muted text at or above 4.5:1 in dark mode", function () {
|
||||
expect(contrast(token(dark, "--ink-muted"), token(dark, "--canvas-bg-color"))).to.be.at.least(4.5);
|
||||
});
|
||||
it("keeps status colours at or above 4.5:1 on both canvases", function () {
|
||||
for (const [block, name] of [[light, "light"], [dark, "dark"]]) {
|
||||
const canvas = token(block, "--canvas-bg-color");
|
||||
for (const t of ["--status-ready", "--status-progress", "--status-error"]) {
|
||||
expect(contrast(token(block, t), canvas), `${t} in ${name}`).to.be.at.least(4.5);
|
||||
}
|
||||
}
|
||||
});
|
||||
it("shows a keyboard focus ring instead of removing outlines", function () {
|
||||
expect(css).to.match(/:focus-visible\s*\{[^}]*outline:\s*2px solid var\(--accent\)/);
|
||||
expect(css).to.not.match(/\.btn:focus,\s*\.btn:active\s*\{[^}]*outline:\s*none/);
|
||||
});
|
||||
it("makes the paper palette win over Bootstrap's !important bg utilities", function () {
|
||||
expect(css).to.match(/\.progress-bar\.bg-success \{ background: var\(--status-ready\) !important; \}/);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user