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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>/);
|
||||
|
||||
@@ -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
|
||||
|
||||
2
todo.md
2
todo.md
@@ -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 / 0–0). `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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) { -%>
|
||||
|
||||
@@ -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><% } %><% }) %>
|
||||
|
||||
Reference in New Issue
Block a user