Make markup semantic + add semantic DOM principle (todo §1); page <h1>, skip link, row-header <th scope=row>, descriptive error pages

This commit is contained in:
2026-06-15 16:53:07 +02:00
parent 6f590148af
commit 645a316419
12 changed files with 44 additions and 22 deletions

View File

@@ -20,6 +20,11 @@ commands and layout.
no `NODE_ENV` (or equivalent) branching. Every behaviour is an **explicit config
toggle** (e.g. `CACHE_TEMPLATES`, `REQUIRE_SECURE_SECRETS`, a future "disable email"),
read once in `src/config.ts`. Compose files set the toggles per deployment.
5. **Semantic, accessible DOM** — markup is a first-class concern. Use the right element
for the job (landmarks, one `<h1>` per page + sane heading order, lists, `<table>` with
row/column headers, `<fieldset>`/`<legend>`, `<button>` vs `<a>`); add ARIA only to fill
real gaps (`aria-current`, `aria-sort`, labels). Classes/ids name *meaning*, not looks.
Prefer native semantics over `div` + ARIA. New views and partials keep this bar.
## Docker only — no host tooling

View File

@@ -41,7 +41,10 @@ a remote site on a flaky link. That's *why* the baseline is boring, standards-co
**HTML + CSS** with zero JavaScript: it loads fast, degrades gracefully, and works on
whatever browser is already there. Where a modern **CSS** feature removes the need for
JavaScript (theme switching, popovers, disclosure) we use it — the trade we avoid is
shipping a client-side runtime, not using the platform.
shipping a client-side runtime, not using the platform. That standards-first stance also
makes **semantic, accessible markup** a priority: real landmarks, one `<h1>` per page,
lists and tables with proper headers, a skip link, and ARIA (`aria-current`/`aria-sort`)
only where the platform leaves a gap (see [AGENTS.md](AGENTS.md)).
> **Status.** This README describes the target architecture. What exists today is the
> **scaffold** — a Node 24 + EJS HTTP server with static serving — plus the **design

View File

@@ -298,9 +298,7 @@
<label class="btn icon-btn hamburger" for="nav-toggle" aria-label="Open menu">
<svg class="ico"><use href="#i-menu"/></svg>
</label>
<div>
<div class="page-title">People</div>
</div>
<h1 class="page-title">People</h1>
<nav class="crumbs" aria-label="Breadcrumb">
<a href="#">Directory</a><span class="sep">/</span><span>People</span>
</nav>

View File

@@ -134,6 +134,14 @@ summary { list-style: none; cursor: pointer; }
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
/* skip link: hidden until focused, then first thing a keyboard user lands on */
.skip-link {
position: fixed; left: 8px; top: -48px; z-index: 100;
padding: 7px 12px; background: var(--surface); color: var(--text);
border: 1px solid var(--border-2); border-radius: var(--radius);
}
.skip-link:focus { top: 8px; }
/* tiny icon helper */
.ico { width: 16px; height: 16px; flex: 0 0 auto; stroke: currentColor;
stroke-width: 1.75; fill: none; stroke-linecap: round; stroke-linejoin: round; }
@@ -330,7 +338,7 @@ span.nav-self { cursor: default; } /* static / non-clickable */
background: var(--surface);
}
.hamburger { display: none; } /* shown only on narrow */
.page-title { font-weight: 600; letter-spacing: -.01em; font-size: 14px; }
.page-title { margin: 0; font-weight: 600; letter-spacing: -.01em; font-size: 14px; }
.page-sub { color: var(--text-faint); font-size: var(--fz-sm); }
.topbar-spacer { flex: 1 1 auto; }
.crumbs { display: flex; align-items: center; gap: 6px;
@@ -533,13 +541,15 @@ table.table {
text-align: left; padding: 0 var(--pad-x); height: var(--row-h);
white-space: nowrap;
}
.table tbody td {
.table tbody td, .table tbody th {
padding: 0 var(--pad-x); height: var(--row-h);
border-bottom: 1px solid var(--border);
white-space: nowrap; color: var(--text);
}
.table tbody tr:hover td { background: var(--surface-2); }
.table tbody tr:has(.row-select:checked) td { background: var(--accent-bg); }
.table tbody th { font-weight: inherit; text-align: left; } /* row-header cell, not bold/centered */
.table tbody tr:hover td, .table tbody tr:hover th { background: var(--surface-2); }
.table tbody tr:has(.row-select:checked) td,
.table tbody tr:has(.row-select:checked) th { background: var(--accent-bg); }
.col-check { width: 36px; text-align: center; padding: 0; }
.col-check input { accent-color: var(--accent); width: 15px; height: 15px;
margin: 0; vertical-align: middle; }

View File

@@ -55,8 +55,8 @@ test("data-table renders sortable headers, row-select, typed cells, badges and k
// Actions header.
assert.match(html, /<th class="col-actions" scope="col"><span class="sr-only">Actions<\/span><\/th>/);
// Typed cells: user (avatar + strong), classed text, badge tone, raw html.
assert.match(html, /<td><span class="cell-user"><span class="avatar" aria-hidden="true">MD<\/span><span class="cell-strong">Mara Delgado<\/span><\/span><\/td>/);
// Typed cells: user identifies the row → <th scope="row">; classed text, badge tone, raw html.
assert.match(html, /<th scope="row"><span class="cell-user"><span class="avatar" aria-hidden="true">MD<\/span><span class="cell-strong">Mara Delgado<\/span><\/span><\/th>/);
assert.match(html, /<td class="cell-muted cell-mono">mara@x.io<\/td>/);
assert.match(html, /<td class="cell-muted">Engineering<\/td>/);
assert.match(html, /<td><span class="badge pos"><span class="dot"><\/span>Active<\/span><\/td>/);

View File

@@ -16,10 +16,14 @@ test("app shell renders sidebar, topbar and the content slot", async () => {
actions: '<button id="action-marker">Add</button>',
});
// Three structural landmarks of the shell.
// Skip link is the first focusable element, targeting the main landmark.
assert.match(html, /<a class="skip-link" href="#main-content">Skip to content<\/a>/);
// Three structural landmarks of the shell; the page title is the page's <h1>.
assert.match(html, /<aside class="sidebar"/);
assert.match(html, /<header class="topbar"/);
assert.match(html, /<main class="content"/);
assert.match(html, /<main class="content" id="main-content"/);
assert.match(html, /<h1 class="page-title">People<\/h1>/);
// Slots render their raw HTML where the page injects it.
assert.match(html, /<a id="nav-marker"/); // sidebar nav slot

View File

@@ -39,7 +39,7 @@ everything via Docker.
- [x] Helper `paginate(total, page, pageSize)` → page model. → `src/paginate.ts`: pure, URL-free math feeding `pagination.ejs`; caller maps page numbers → hrefs. Returns `{ from, to, page, pageCount, pageSize, prev, next, total, pages }`. Inputs clamped/guarded (page pinned to [1,pageCount], total/pageSize coerced to sane ints, empty list ⇒ 1 page / 00). `pages` = first/last `boundaries` + `siblings`-wide window around current, sorted/deduped, with ellipsis for gaps >1 (a lone hole is shown, not collapsed); `siblings`/`boundaries` overridable. `paginate.test.ts` covers model/clamp/empty/windowing.
- [x] Replace placeholder `index` with the app-shell dashboard. → `/` now renders a real app-shell "People" list. `src/dashboard.ts` (pure `buildDashboardModel(url, roles)`) wires the §1 helpers end-to-end: `parseListQuery` → filter (q/status/team) + sort + `paginate` over a 30-row mock dataset → `composeNav`; builds the filter-bar/data-table/pagination/shell configs with canonical, state-preserving links. `views/index.ejs` composes the partials around the shell by capturing each `include()` (EJS returns the string) into a slot. Filtering/sorting/paging all round-trip the URL, zero-JS. Removed the dead `partials/header.ejs`. `dashboard.test.ts` covers default/search/sort/paginate; `app.test.ts` asserts the live page + URL filtering. Mock data + demo profile stand in until §2/§4.
- [x] Check the full system in Playwright and make screenshots and compare to the static original design in html-css-foundation to make sure we're showing the correct graphics. → Dockerized Playwright (official image, browsers preinstalled — no host Node/browsers): `e2e/` (config + `visual.spec.ts`), `Dockerfile.e2e`, `compose.e2e.yml` run the suite against the live `web` service. 6 parallel tests: screenshots live (default/sorted+filtered/dark/mobile) **and** the foundation mockups (App Shell + Auth) → `e2e/artifacts/` (git-ignored); asserts the live DOM computes the **same** design-system styles as the mockup for the shared components (`.sidebar/.topbar/.brand/.btn-primary/.theme-switch/.filters/.pager`), every icon `<use>` resolves, sort/search round-trip the URL, the CSS theme switch flips the palette, and mobile hides the sidebar off-canvas. Verified visually: live dashboard matches the mockup design (light + dark); diffs are data only. All green.
- [ ] Go over all HTML and CSS and make adjust it to be as sematic as we can, css classes, ids html elements and all, then add semantic DOM as a priority in this project.
- [x] Go over all HTML and CSS and make adjust it to be as sematic as we can, css classes, ids html elements and all, then add semantic DOM as a priority in this project. → Added **Semantic, accessible DOM** as core principle (AGENTS.md §5 + README). Fixes: page title is now the page `<h1>` (shell + mockup), a focus-revealed skip link to `#main-content`, data-table identifier cell is `<th scope="row">` (CSS styles tbody `th`), error pages got descriptive headings (code retained). Tests-first: shell/data-table specs assert the new markup; typecheck + 75 units + 6 E2E green.
- [ ] Run the architecture _and_ the stability reviewer agents on the _whole_ project, not just the latest changes, and address their issues.
- [ ] Go over all comments in the code and the README and try to make it shorter and more information dense. Remove not strictly needed stuff.
- [ ] Go over all tests and combine/unify ones that cover the same stuff or are very related and could be combined in a good way. Remove tests that aren't helping, we only want tests that are actually helpful to us.

View File

@@ -8,8 +8,8 @@
</head>
<body>
<main>
<h1>403</h1>
<p>You don't have access to that.</p>
<h1>Access denied</h1>
<p>You don't have permission to view that (403).</p>
<p><a href="/">Back home</a></p>
</main>
</body>

View File

@@ -8,8 +8,8 @@
</head>
<body>
<main>
<h1>404</h1>
<p>That page does not exist.</p>
<h1>Page not found</h1>
<p>We couldn't find that page (404).</p>
<p><a href="/">Back home</a></p>
</main>
</body>

View File

@@ -8,8 +8,8 @@
</head>
<body>
<main>
<h1>500</h1>
<p>Something went wrong on our end.</p>
<h1>Something went wrong</h1>
<p>An unexpected error occurred on our end (500).</p>
<p><a href="/">Back home</a></p>
</main>
</body>

View File

@@ -6,6 +6,7 @@
columns: { label, sortable?, sort?: "asc"|"desc", href?, className? }[]
rows: { name?, cells: Cell[], actions?: Action[] }[]
Cell ∈ string | { text, className? } | { user:{name,initials} } | { badge:{tone,label} } | { html, className? }
user cells render as <th scope="row"> — they identify the row (the row header).
Action = { label, icon?, href?, danger?, separatorBefore? }
%><%
const caption = locals.caption;
@@ -46,7 +47,7 @@
<% if (typeof cell === "string") { -%>
<td><%= cell %></td>
<% } else if (cell.user) { -%>
<td><span class="cell-user"><span class="avatar" aria-hidden="true"><%= cell.user.initials %></span><span class="cell-strong"><%= cell.user.name %></span></span></td>
<th scope="row"><span class="cell-user"><span class="avatar" aria-hidden="true"><%= cell.user.initials %></span><span class="cell-strong"><%= cell.user.name %></span></span></th>
<% } else if (cell.badge) { -%>
<td><span class="badge <%= cell.badge.tone %>"><span class="dot"></span><%= cell.badge.label %></span></td>
<% } else if (cell.html != null) { -%>

View File

@@ -21,6 +21,7 @@
<link rel="icon" href="/public/favicon.svg" />
</head>
<body>
<a class="skip-link" href="#main-content">Skip to content</a>
<%- include("icons") %>
<!-- nav-toggle drives the mobile overlay (pure CSS) -->
<input type="checkbox" id="nav-toggle" aria-hidden="true" tabindex="-1" />
@@ -67,10 +68,10 @@
<!-- scrim closes the mobile menu (label toggles the checkbox) -->
<label class="scrim" for="nav-toggle" aria-label="Close menu"></label>
<main class="content">
<main class="content" id="main-content">
<header class="topbar">
<label class="btn icon-btn hamburger" for="nav-toggle" aria-label="Open menu"><svg class="ico"><use href="#i-menu" /></svg></label>
<div><div class="page-title"><%= title %></div></div>
<h1 class="page-title"><%= title %></h1>
<% if (breadcrumbs.length) { %>
<nav class="crumbs" aria-label="Breadcrumb">
<% breadcrumbs.forEach((c, i) => { %><% if (i) { %><span class="sep">/</span><% } %><% if (c.href) { %><a href="<%= c.href %>"><%= c.label %></a><% } else { %><span><%= c.label %></span><% } %><% }) %>