ATB-32: Responsive Design, Accessibility, and UI Polish — Implementation Plan#
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Complete the CSS styling for all unstyled markup, add mobile-first responsive breakpoints, improve accessibility to WCAG AA baseline, add 404/error pages, and polish hover/focus/active states — making the atBB web UI MVP-ready.
Architecture: All CSS changes go into the single apps/web/public/static/css/theme.css file, using the existing token-based var(--token) system. JSX changes touch BaseLayout (skip link, hamburger nav, favicon) and route files (ARIA attributes, semantic HTML). New route for 404 catch-all. Tests update existing test files to verify new HTML structure.
Tech Stack: Hono JSX, HTMX 2.0, CSS custom properties, Vitest, CSS-only <details>/<summary> hamburger
Design doc: docs/plans/2026-02-20-responsive-a11y-polish-design.md
Important context:
- pnpm is at
.devenv/profile/bin/pnpm— prepend to PATH in all shell commands - The viewport meta tag already exists in BaseLayout line 16
- The neobrutal-light.ts token file defines
--border-width: 3px,--shadow-offset: 4px,--card-shadow: 6px 6px 0,--button-shadow: 4px 4px 0 - Button hover/active transitions already exist in theme.css lines 139-166
- Tests use Hono's
app.request()pattern — render JSX, check HTML string output
Task 1: CSS — Form Styles#
Files:
- Modify:
apps/web/public/static/css/theme.css(append after line 261, before Moderation UI section)
Step 1: Add form styles to theme.css
Add a new /* ─── Forms ──── */ section before the Moderation UI section. Insert these styles:
/* ─── Forms ────────────────────────────────────────────────────────────── */
.form-group {
display: flex;
flex-direction: column;
gap: var(--space-xs);
margin-bottom: var(--space-md);
}
.form-group label {
font-weight: var(--font-weight-bold);
font-size: var(--font-size-sm);
}
.form-group input,
.form-group textarea,
.form-group select {
border: var(--input-border);
border-radius: var(--input-radius);
padding: var(--space-sm) var(--space-md);
font-family: var(--font-body);
font-size: var(--font-size-base);
background-color: var(--color-surface);
color: var(--color-text);
min-height: 44px;
}
.form-group textarea {
min-height: 120px;
resize: vertical;
}
.form-actions {
display: flex;
gap: var(--space-sm);
margin-top: var(--space-md);
}
.form-error {
color: var(--color-danger);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-bold);
margin: var(--space-xs) 0;
}
.char-count {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
.char-count[data-over="true"] {
color: var(--color-danger);
font-weight: var(--font-weight-bold);
}
.success-banner {
background-color: var(--color-success);
color: var(--color-surface);
padding: var(--space-sm) var(--space-md);
margin-bottom: var(--space-md);
font-weight: var(--font-weight-bold);
border: var(--border-width) solid var(--color-border);
}
/* ─── Login Form ───────────────────────────────────────────────────────── */
.login-form {
max-width: 480px;
}
.login-form__form {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.login-form__label {
font-weight: var(--font-weight-bold);
font-size: var(--font-size-base);
}
.login-form__input {
border: var(--input-border);
border-radius: var(--input-radius);
padding: var(--space-sm) var(--space-md);
font-family: var(--font-body);
font-size: var(--font-size-base);
background-color: var(--color-surface);
color: var(--color-text);
min-height: 44px;
width: 100%;
}
.login-form__hint {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
line-height: var(--line-height-body);
}
.login-form__error {
background-color: var(--color-surface);
border: var(--border-width) solid var(--color-danger);
padding: var(--space-sm) var(--space-md);
color: var(--color-danger);
font-weight: var(--font-weight-bold);
}
.login-form__submit {
cursor: pointer;
font-family: var(--font-body);
font-weight: var(--font-weight-bold);
font-size: var(--font-size-base);
border: var(--border-width) solid var(--color-border);
border-radius: var(--button-radius);
padding: var(--space-sm) var(--space-md);
box-shadow: var(--button-shadow);
background-color: var(--color-primary);
color: var(--color-surface);
min-height: 44px;
transition: transform 0.1s ease, box-shadow 0.1s ease;
}
.login-form__submit:hover {
transform: translate(var(--btn-press-hover), var(--btn-press-hover));
box-shadow: var(--btn-press-hover) var(--btn-press-hover) 0 var(--color-shadow);
}
.login-form__submit:active {
transform: translate(var(--btn-press-active), var(--btn-press-active));
box-shadow: none;
}
Step 2: Verify build passes
Run: PATH=.devenv/profile/bin:$PATH pnpm build
Expected: Clean build (CSS is static, no compilation needed, but verify no regressions)
Step 3: Commit
git add apps/web/public/static/css/theme.css
git commit -m "style(web): add CSS for forms, login form, char counter, and success banner (ATB-32)"
Task 2: CSS — Post Cards, Breadcrumbs, Topic Rows, Banners#
Files:
- Modify:
apps/web/public/static/css/theme.css(append new sections)
Step 1: Add post card, breadcrumb, topic row, and banner styles
Add after the Login Form section:
/* ─── Post Cards ───────────────────────────────────────────────────────── */
.post-card {
background-color: var(--color-surface);
border: var(--border-width) solid var(--color-border);
border-left-width: calc(var(--border-width) * 2);
border-left-color: var(--color-primary);
padding: var(--space-md);
margin-bottom: var(--space-md);
}
.post-card--op {
border-left-width: calc(var(--border-width) * 3);
box-shadow: var(--card-shadow);
}
.post-card--reply {
box-shadow: var(--shadow-offset) var(--shadow-offset) 0 var(--color-shadow);
}
.post-card__header {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-bottom: var(--space-sm);
flex-wrap: wrap;
}
.post-card__number {
font-weight: var(--font-weight-bold);
font-size: var(--font-size-sm);
background-color: var(--color-primary);
color: var(--color-surface);
padding: 2px var(--space-sm);
min-width: 2em;
text-align: center;
}
.post-card__author {
font-weight: var(--font-weight-bold);
font-size: var(--font-size-sm);
}
.post-card__date {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
margin-left: auto;
}
.post-card__body {
white-space: pre-wrap;
word-break: break-word;
line-height: var(--line-height-body);
}
/* ─── Breadcrumbs ──────────────────────────────────────────────────────── */
.breadcrumb {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
margin-bottom: var(--space-md);
line-height: var(--line-height-body);
}
.breadcrumb a {
color: var(--color-text-muted);
}
.breadcrumb a:hover {
color: var(--color-primary);
}
/* ─── Topic Rows (Board View) ──────────────────────────────────────────── */
.topic-row {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: var(--space-md);
padding: var(--space-md);
border: var(--border-width) solid var(--color-border);
background-color: var(--color-surface);
margin-bottom: var(--space-sm);
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.topic-row:hover {
transform: translate(-1px, -1px);
box-shadow: 3px 3px 0 var(--color-shadow);
}
.topic-row__title {
font-weight: var(--font-weight-bold);
color: var(--color-text);
text-decoration: none;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.topic-row__title:hover {
color: var(--color-primary);
}
.topic-row__meta {
display: flex;
gap: var(--space-sm);
color: var(--color-text-muted);
font-size: var(--font-size-sm);
white-space: nowrap;
}
/* ─── Topic Locked Banner ──────────────────────────────────────────────── */
.topic-locked-banner {
background-color: var(--color-warning);
color: var(--color-text);
padding: var(--space-sm) var(--space-md);
margin-bottom: var(--space-md);
font-weight: var(--font-weight-bold);
border: var(--border-width) solid var(--color-border);
display: flex;
align-items: center;
gap: var(--space-sm);
}
.topic-locked-banner__badge {
background-color: var(--color-text);
color: var(--color-warning);
padding: 2px var(--space-sm);
font-size: var(--font-size-sm);
text-transform: uppercase;
letter-spacing: 0.05em;
}
Step 2: Verify build passes
Run: PATH=.devenv/profile/bin:$PATH pnpm build
Expected: Clean build
Step 3: Commit
git add apps/web/public/static/css/theme.css
git commit -m "style(web): add CSS for post cards, breadcrumbs, topic rows, and locked banner (ATB-32)"
Task 3: CSS — Enhanced Error, Empty, and Loading States#
Files:
- Modify:
apps/web/public/static/css/theme.css(expand existing state sections at lines 191-218)
Step 1: Enhance state component styles
Replace the existing Error Display, Empty State, and Loading State sections (lines 191-218) with richer versions:
/* ─── Error Display ─────────────────────────────────────────────────────── */
.error-display {
background-color: var(--color-surface);
border: var(--border-width) solid var(--color-danger);
border-left-width: calc(var(--border-width) * 3);
padding: var(--space-lg);
color: var(--color-text);
}
.error-display__message {
font-weight: var(--font-weight-bold);
font-size: var(--font-size-lg);
margin-bottom: var(--space-xs);
}
.error-display__detail {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
/* ─── Empty State ───────────────────────────────────────────────────────── */
.empty-state {
text-align: center;
padding: var(--space-xl) var(--space-md);
color: var(--color-text-muted);
border: var(--border-width) dashed var(--color-border);
margin: var(--space-md) 0;
}
.empty-state p {
font-size: var(--font-size-lg);
margin-bottom: var(--space-sm);
}
.empty-state__action {
margin-top: var(--space-md);
}
/* ─── Loading State (HTMX indicator) ───────────────────────────────────── */
.loading-state {
opacity: 0;
transition: opacity 0.2s ease;
text-align: center;
padding: var(--space-md);
color: var(--color-text-muted);
}
.loading-state.htmx-request {
opacity: 1;
}
.htmx-indicator {
opacity: 0;
transition: opacity 0.2s ease;
}
.htmx-request .htmx-indicator,
.htmx-request.htmx-indicator {
opacity: 1;
}
/* ─── Error Page ────────────────────────────────────────────────────────── */
.error-page {
text-align: center;
padding: var(--space-xl) var(--space-md);
}
.error-page__code {
font-family: var(--font-heading);
font-size: 6rem;
font-weight: var(--font-weight-bold);
line-height: 1;
color: var(--color-primary);
margin-bottom: var(--space-sm);
}
.error-page__title {
font-size: var(--font-size-xl);
margin-bottom: var(--space-sm);
}
.error-page__message {
color: var(--color-text-muted);
font-size: var(--font-size-base);
margin-bottom: var(--space-lg);
}
Step 2: Verify build passes
Run: PATH=.devenv/profile/bin:$PATH pnpm build
Expected: Clean build
Step 3: Commit
git add apps/web/public/static/css/theme.css
git commit -m "style(web): enhance error, empty, and loading state CSS with error page styles (ATB-32)"
Task 4: CSS — Responsive Breakpoints and Token Overrides#
Files:
- Modify:
apps/web/public/static/css/theme.css(append media queries at end of file) - Modify:
apps/web/src/styles/presets/neobrutal-light.ts(adjust token values for mobile-first defaults)
Step 1: Update neobrutal-light.ts to mobile-first token values
The token file currently uses desktop values. Since we're going mobile-first in CSS, the token values injected into :root represent the mobile baseline. We override to desktop values in media queries.
Change these values in apps/web/src/styles/presets/neobrutal-light.ts:
// Change these lines:
"border-width": "2px", // was "3px" — mobile-first, desktop overrides to 3px
"shadow-offset": "2px", // was "4px" — mobile-first, desktop overrides to 4px
"content-width": "100%", // was "960px" — mobile-first, tablet/desktop override
// Component tokens that reference shadow-offset also need mobile values:
"button-shadow": "2px 2px 0 var(--color-shadow)", // was "4px 4px 0"
"card-shadow": "4px 4px 0 var(--color-shadow)", // was "6px 6px 0"
"btn-press-hover": "1px", // was "2px"
"btn-press-active": "2px", // was "4px"
"input-border": "2px solid var(--color-border)", // was "3px solid"
Step 2: Add responsive media queries at the end of theme.css
Append to the end of theme.css:
/* ─── Responsive: Tablet (768px+) ──────────────────────────────────────── */
@media (min-width: 768px) {
:root {
--content-width: 720px;
}
.content-container {
padding: var(--space-xl) var(--space-md);
}
.board-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
}
.topic-row {
flex-direction: row;
}
.topic-row__title {
white-space: nowrap;
}
.mobile-nav {
display: none;
}
.desktop-nav {
display: flex;
}
}
/* ─── Responsive: Desktop (1024px+) ───────────────────────────────────── */
@media (min-width: 1024px) {
:root {
--content-width: 960px;
--border-width: 3px;
--shadow-offset: 4px;
--button-shadow: 4px 4px 0 var(--color-shadow);
--card-shadow: 6px 6px 0 var(--color-shadow);
--btn-press-hover: 2px;
--btn-press-active: 4px;
--input-border: 3px solid var(--color-border);
}
.board-grid {
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
}
}
/* ─── Responsive: Mobile overrides ─────────────────────────────────────── */
@media (max-width: 767px) {
.page-header {
flex-direction: column;
align-items: flex-start;
}
.topic-row {
flex-direction: column;
gap: var(--space-xs);
}
.topic-row__title {
white-space: normal;
}
.topic-row__meta {
flex-wrap: wrap;
}
.post-card__header {
flex-direction: column;
align-items: flex-start;
gap: var(--space-xs);
}
.post-card__date {
margin-left: 0;
}
.login-form {
max-width: 100%;
}
.desktop-nav {
display: none;
}
.mobile-nav {
display: block;
}
}
Step 3: Run tests
Run: PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web test
Expected: Tests pass. Some base layout tests may need updating if they check for specific token values that changed.
Step 4: Commit
git add apps/web/public/static/css/theme.css apps/web/src/styles/presets/neobrutal-light.ts
git commit -m "style(web): add mobile-first responsive breakpoints with token overrides (ATB-32)"
Task 5: Write tests for BaseLayout accessibility changes#
Files:
- Modify:
apps/web/src/layouts/__tests__/base.test.tsx
Step 1: Write failing tests for skip link, main id, and favicon
Add these test cases to the existing base.test.tsx:
describe("accessibility", () => {
it("renders skip-to-content link as first child of body", async () => {
const res = await app.request("/");
const html = await res.text();
expect(html).toContain('class="skip-link"');
expect(html).toContain('href="#main-content"');
expect(html).toContain("Skip to main content");
});
it("renders main element with id for skip link target", async () => {
const res = await app.request("/");
const html = await res.text();
expect(html).toContain('id="main-content"');
});
it("renders html element with lang attribute", async () => {
const res = await app.request("/");
const html = await res.text();
expect(html).toContain('lang="en"');
});
});
describe("favicon", () => {
it("includes favicon link in head", async () => {
const res = await app.request("/");
const html = await res.text();
expect(html).toContain('rel="icon"');
expect(html).toContain("favicon.svg");
});
});
Step 2: Run tests to verify they fail
Run: PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web test src/layouts/__tests__/base.test.tsx
Expected: FAIL — skip link, main id, and favicon not yet in the markup
Task 6: Implement BaseLayout accessibility (skip link, main id, favicon)#
Files:
- Modify:
apps/web/src/layouts/base.tsx - Create:
apps/web/public/static/favicon.svg - Modify:
apps/web/public/static/css/theme.css(add skip-link class)
Step 1: Add skip link CSS to theme.css
Add a new section near the top, after the Base/Typography section:
/* ─── Skip Link ────────────────────────────────────────────────────────── */
.skip-link {
position: absolute;
left: -9999px;
top: auto;
width: 1px;
height: 1px;
overflow: hidden;
z-index: 1000;
}
.skip-link:focus {
position: fixed;
top: var(--space-sm);
left: var(--space-sm);
width: auto;
height: auto;
padding: var(--space-sm) var(--space-md);
background-color: var(--color-primary);
color: var(--color-surface);
font-weight: var(--font-weight-bold);
border: var(--border-width) solid var(--color-border);
box-shadow: var(--button-shadow);
text-decoration: none;
z-index: 1000;
}
Step 2: Update BaseLayout to add skip link, main id, and favicon
In apps/web/src/layouts/base.tsx, make these changes:
- Add favicon
<link>in<head>after the theme.css link:
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
- Add skip link as first child of
<body>:
<body>
<a href="#main-content" class="skip-link">Skip to main content</a>
<header class="site-header">
...
- Add id to
<main>:
<main id="main-content" class="content-container">{props.children}</main>
Step 3: Create favicon SVG
Create apps/web/public/static/favicon.svg:
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" fill="#ff5c00" rx="0"/>
<rect x="1" y="1" width="30" height="30" fill="#ff5c00" stroke="#1a1a1a" stroke-width="2" rx="0"/>
<text x="16" y="23" font-family="system-ui, sans-serif" font-size="18" font-weight="700" fill="#ffffff" text-anchor="middle">BB</text>
</svg>
Step 4: Run tests to verify they pass
Run: PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web test src/layouts/__tests__/base.test.tsx
Expected: PASS
Step 5: Commit
git add apps/web/src/layouts/base.tsx apps/web/public/static/favicon.svg apps/web/public/static/css/theme.css
git commit -m "feat(web): add skip-to-content link, main landmark id, and SVG favicon (ATB-32)"
Task 7: Write tests for hamburger menu#
Files:
- Modify:
apps/web/src/layouts/__tests__/base.test.tsx
Step 1: Write failing tests for mobile hamburger navigation
Add to base.test.tsx:
describe("mobile navigation", () => {
it("renders details/summary hamburger menu for mobile", async () => {
const res = await app.request("/");
const html = await res.text();
expect(html).toContain("mobile-nav");
expect(html).toContain("mobile-nav__toggle");
});
it("renders desktop nav separately from mobile nav", async () => {
const res = await app.request("/");
const html = await res.text();
expect(html).toContain("desktop-nav");
});
it("hamburger has aria-label for accessibility", async () => {
const res = await app.request("/");
const html = await res.text();
expect(html).toContain('aria-label="Menu"');
});
it("mobile nav contains auth links when logged in", async () => {
const auth: WebSession = {
authenticated: true,
did: "did:plc:abc123",
handle: "alice.bsky.social",
};
const authApp = new Hono().get("/", (c) =>
c.html(<BaseLayout auth={auth}>content</BaseLayout>)
);
const res = await authApp.request("/");
const html = await res.text();
// Both desktop and mobile nav should contain auth state
const logoutMatches = html.match(/Log out/g);
expect(logoutMatches?.length).toBeGreaterThanOrEqual(2);
});
});
Step 2: Run tests to verify they fail
Run: PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web test src/layouts/__tests__/base.test.tsx
Expected: FAIL — hamburger not yet implemented
Task 8: Implement hamburger menu in BaseLayout#
Files:
- Modify:
apps/web/src/layouts/base.tsx - Modify:
apps/web/public/static/css/theme.css(add mobile-nav CSS)
Step 1: Add mobile nav CSS
Add to theme.css, in a new section:
/* ─── Mobile Navigation ────────────────────────────────────────────────── */
.mobile-nav {
display: block;
}
.mobile-nav__toggle {
cursor: pointer;
font-size: var(--font-size-lg);
list-style: none;
padding: var(--space-xs) var(--space-sm);
min-width: 44px;
min-height: 44px;
display: flex;
align-items: center;
justify-content: center;
}
.mobile-nav__toggle::-webkit-details-marker {
display: none;
}
.mobile-nav__menu {
position: absolute;
right: var(--space-md);
top: var(--nav-height);
background-color: var(--color-surface);
border: var(--border-width) solid var(--color-border);
box-shadow: var(--card-shadow);
padding: var(--space-md);
min-width: 200px;
z-index: 100;
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.desktop-nav {
display: none;
align-items: center;
gap: var(--space-md);
}
/* Hide/show is handled by the responsive media queries in Task 4 */
Step 2: Refactor BaseLayout header to include both desktop and mobile nav
Rewrite the header section in apps/web/src/layouts/base.tsx:
// Extract nav content into a helper to avoid duplication
const NavContent: FC<{ auth?: WebSession }> = ({ auth }) => (
<>
{auth?.authenticated ? (
<>
<span class="site-header__handle">{auth.handle}</span>
<form
action="/logout"
method="post"
class="site-header__logout-form"
>
<button type="submit" class="site-header__logout-btn">
Log out
</button>
</form>
</>
) : (
<a href="/login" class="site-header__login-link">
Log in
</a>
)}
</>
);
Then in the header:
<header class="site-header">
<div class="site-header__inner">
<a href="/" class="site-header__title">
atBB Forum
</a>
<nav class="desktop-nav" aria-label="Main navigation">
<NavContent auth={auth} />
</nav>
<details class="mobile-nav">
<summary class="mobile-nav__toggle" aria-label="Menu">
☰
</summary>
<nav class="mobile-nav__menu" aria-label="Main navigation">
<NavContent auth={auth} />
</nav>
</details>
</div>
</header>
Step 3: Run tests to verify they pass
Run: PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web test src/layouts/__tests__/base.test.tsx
Expected: PASS
Step 4: Run all web tests
Run: PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web test
Expected: PASS — existing tests should still pass. Some tests that check for "Log in" or "Log out" in the output may now match multiple times (desktop + mobile). Update any assertions that use exact count matching.
Step 5: Commit
git add apps/web/src/layouts/base.tsx apps/web/public/static/css/theme.css
git commit -m "feat(web): add CSS-only hamburger menu with details/summary for mobile nav (ATB-32)"
Task 9: Accessibility — Focus Styles#
Files:
- Modify:
apps/web/public/static/css/theme.css
Step 1: Add global focus-visible styles
Add near the top of theme.css, after the skip link section:
/* ─── Focus Styles ─────────────────────────────────────────────────────── */
:focus-visible {
outline: 3px solid var(--color-primary);
outline-offset: 2px;
}
/* Remove default focus ring since we use focus-visible */
:focus:not(:focus-visible) {
outline: none;
}
Step 2: Commit
git add apps/web/public/static/css/theme.css
git commit -m "style(web): add global focus-visible styles for keyboard navigation (ATB-32)"
Task 10: Accessibility — Form ARIA Attributes#
Files:
- Modify:
apps/web/src/routes/topics.tsx(reply form, mod dialog) - Modify:
apps/web/src/routes/new-topic.tsx(compose form) - Modify:
apps/web/src/routes/login.tsx(login form)
Step 1: Update login form accessibility
In apps/web/src/routes/login.tsx, add ARIA attributes:
- Add
aria-required="true"to the handle input - Add
aria-describedby="login-hint"to the handle input - Add
id="login-hint"to the hint paragraph - The error div already has
role="alert"— good
<input
type="text"
id="login-handle"
name="handle"
placeholder="alice.bsky.social"
class="login-form__input"
required
aria-required="true"
aria-describedby="login-hint"
autocomplete="username"
autofocus
/>
<p id="login-hint" class="login-form__hint">
Step 2: Update new topic form accessibility
In apps/web/src/routes/new-topic.tsx:
- Add
aria-required="true"to the textarea - Add
aria-describedby="char-count"to the textarea - Add
aria-live="polite"to the char count div - Add
role="alert"to the form error div
<textarea
id="compose-text"
name="text"
rows={8}
placeholder="What's on your mind?"
oninput="updateCharCount(this)"
aria-required="true"
aria-describedby="char-count"
/>
<div id="char-count" class="char-count" aria-live="polite">300 left</div>
<div id="form-error" role="alert" />
Step 3: Update reply form accessibility
In apps/web/src/routes/topics.tsx:
- Add
aria-required="true"to the reply textarea - Add
aria-describedby="reply-char-count"to the reply textarea - Add
aria-live="polite"to the char count div - Add
role="alert"to the form error div - Add
aria-labelledby="mod-dialog-title"to the<dialog> - Add
autofocusto the mod reason textarea - Add
aria-live="polite"to the reply list container
<textarea
id="reply-text"
name="text"
rows={5}
placeholder="Write a reply…"
oninput="updateReplyCharCount(this)"
aria-required="true"
aria-describedby="reply-char-count"
/>
<div id="reply-char-count" class="char-count" aria-live="polite">300 left</div>
<div id="reply-form-error" role="alert" />
<dialog id="mod-dialog" class="mod-dialog" aria-labelledby="mod-dialog-title">
<textarea
id="mod-reason"
name="reason"
rows={3}
placeholder="Reason for this action…"
autofocus
/>
<div id="reply-list" aria-live="polite">
Step 4: Run tests
Run: PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web test
Expected: PASS — ARIA attributes are additive, existing tests shouldn't break
Step 5: Commit
git add apps/web/src/routes/topics.tsx apps/web/src/routes/new-topic.tsx apps/web/src/routes/login.tsx
git commit -m "feat(web): add ARIA attributes to forms, dialog, and live regions (ATB-32)"
Task 11: Accessibility — Semantic HTML Fixes#
Files:
- Modify:
apps/web/src/routes/boards.tsx(breadcrumb as nav/ol) - Modify:
apps/web/src/routes/topics.tsx(breadcrumb as nav/ol, post cards as article) - Modify:
apps/web/src/routes/new-topic.tsx(breadcrumb as nav/ol)
Step 1: Convert breadcrumbs to semantic markup
In all three route files, change the breadcrumb from:
<nav class="breadcrumb">
<a href="/">Home</a>
{" / "}
<span>Board Name</span>
</nav>
to:
<nav class="breadcrumb" aria-label="Breadcrumb">
<ol>
<li><a href="/">Home</a></li>
{categoryName && <li><a href="/">{categoryName}</a></li>}
<li><span>{board.name}</span></li>
</ol>
</nav>
Step 2: Update breadcrumb CSS
Add to the breadcrumb section in theme.css:
.breadcrumb ol {
display: flex;
flex-wrap: wrap;
gap: 0;
list-style: none;
padding: 0;
margin: 0;
}
.breadcrumb li {
display: flex;
align-items: center;
}
.breadcrumb li + li::before {
content: "/";
margin: 0 var(--space-sm);
color: var(--color-text-muted);
}
Step 3: Change PostCard from div to article
In apps/web/src/routes/topics.tsx, in the PostCard component, change:
<div class={cardClass} id={`post-${postNumber}`}>
to:
<article class={cardClass} id={`post-${postNumber}`}>
And the closing </div> to </article>.
Step 4: Run tests
Run: PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web test
Expected: PASS — update any tests that check for specific HTML structure of breadcrumbs
Step 5: Commit
git add apps/web/src/routes/boards.tsx apps/web/src/routes/topics.tsx apps/web/src/routes/new-topic.tsx apps/web/public/static/css/theme.css
git commit -m "feat(web): convert breadcrumbs to nav/ol, post cards to article elements (ATB-32)"
Task 12: Write tests for 404 page#
Files:
- Create:
apps/web/src/routes/__tests__/not-found.test.tsx
Step 1: Write failing tests for 404 route
import { describe, it, expect } from "vitest";
import { Hono } from "hono";
import { BaseLayout } from "../../layouts/base.js";
import { createNotFoundRoute } from "../not-found.js";
describe("404 Not Found page", () => {
const app = new Hono()
.route("/", createNotFoundRoute());
it("returns 404 status", async () => {
const res = await app.request("/some-random-page-that-does-not-exist");
expect(res.status).toBe(404);
});
it("renders error page with 404 code", async () => {
const res = await app.request("/nonexistent");
const html = await res.text();
expect(html).toContain("404");
expect(html).toContain("error-page");
});
it("renders a link back to home", async () => {
const res = await app.request("/nonexistent");
const html = await res.text();
expect(html).toContain('href="/"');
});
it("renders user-friendly message", async () => {
const res = await app.request("/nonexistent");
const html = await res.text();
expect(html).toContain("doesn't exist");
});
});
Step 2: Run tests to verify they fail
Run: PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web test src/routes/__tests__/not-found.test.tsx
Expected: FAIL — module not found
Task 13: Implement 404 page and register catch-all route#
Files:
- Create:
apps/web/src/routes/not-found.tsx - Modify:
apps/web/src/routes/index.ts(register catch-all) - Modify:
apps/web/src/index.ts(improve global error handler)
Step 1: Create the 404 route
Create apps/web/src/routes/not-found.tsx:
import { Hono } from "hono";
import { BaseLayout } from "../layouts/base.js";
import { Button } from "../components/index.js";
import { getSession } from "../lib/session.js";
export function createNotFoundRoute(appviewUrl?: string) {
return new Hono().all("*", async (c) => {
const auth = appviewUrl
? await getSession(appviewUrl, c.req.header("cookie"))
: { authenticated: false as const };
return c.html(
<BaseLayout title="Page Not Found — atBB Forum" auth={auth}>
<div class="error-page">
<div class="error-page__code">404</div>
<h1 class="error-page__title">Page Not Found</h1>
<p class="error-page__message">
This page doesn't exist or has been removed.
</p>
<a href="/" class="btn btn-primary">Back to Home</a>
</div>
</BaseLayout>,
404
);
});
}
Step 2: Register as catch-all in routes/index.ts
Add at the end of apps/web/src/routes/index.ts:
import { createNotFoundRoute } from "./not-found.js";
// ... existing routes ...
.route("/", createModActionRoute(config.appviewUrl))
.route("/", createNotFoundRoute(config.appviewUrl));
Step 3: Improve global error handler in index.ts
Update apps/web/src/index.ts error handler to use the BaseLayout and error-page pattern:
app.onError((err, c) => {
console.error("Unhandled error in web route", {
path: c.req.path,
method: c.req.method,
error: err.message,
stack: err.stack,
});
const detail = process.env.NODE_ENV !== "production" ? err.message : undefined;
return c.html(
`<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Error — atBB Forum</title></head><body><div class="error-page"><div class="error-page__code">500</div><h1 class="error-page__title">Something Went Wrong</h1><p class="error-page__message">Please try again later.${detail ? ` (${detail})` : ""}</p><a href="/">Back to Home</a></div></body></html>`,
500
);
});
Step 4: Run tests
Run: PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web test
Expected: PASS
Step 5: Commit
git add apps/web/src/routes/not-found.tsx apps/web/src/routes/index.ts apps/web/src/index.ts apps/web/src/routes/__tests__/not-found.test.tsx
git commit -m "feat(web): add 404 page with neobrutal styling and catch-all route (ATB-32)"
Task 14: Visual Polish — Hover/Active Transitions#
Files:
- Modify:
apps/web/public/static/css/theme.css
Step 1: Add transition and hover effects to interactive elements
The button transitions already exist (lines 139-166). Add transitions to cards, topic rows, and other interactive elements. Find elements that are missing transitions and add them:
/* Add transition to .card (after existing .card block): */
.card {
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
/* Update .board-card:hover .card to use token-based values */
.board-card:hover .card {
transform: translate(-2px, -2px);
box-shadow: calc(var(--shadow-offset) + 2px) calc(var(--shadow-offset) + 2px) 0 var(--color-shadow);
}
/* .topic-row already has transition from Task 2 — verify it works */
/* Post cards need hover state too */
.post-card {
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
/* .login-form__submit already has transition from Task 1 */
/* Add hover state for site-header__title */
.site-header__title {
transition: color 0.15s ease;
}
/* Links should have smooth color transition */
a {
transition: color 0.15s ease;
}
Step 2: Commit
git add apps/web/public/static/css/theme.css
git commit -m "style(web): add smooth transitions to cards, links, and interactive elements (ATB-32)"
Task 15: Run Full Test Suite and Lighthouse Audit#
Files: None (verification only)
Step 1: Run complete build
Run: PATH=.devenv/profile/bin:$PATH pnpm build
Expected: Clean build, no errors
Step 2: Run all tests
Run: PATH=.devenv/profile/bin:$PATH pnpm test
Expected: All tests pass across all packages
Step 3: Manual Lighthouse audit
Start the dev server:
Run: PATH=.devenv/profile/bin:$PATH pnpm dev
Then in Chrome:
- Open
http://localhost:3001 - Open DevTools → Lighthouse
- Run accessibility audit
- Target: 90+ accessibility score
- Fix any issues flagged
Step 4: Manual keyboard navigation test
Test these flows using only keyboard (Tab, Enter, Escape):
- Tab through homepage — skip link visible on first Tab
- Tab to a board card → Enter → navigate to board view
- Tab to "New Topic" button → Enter → navigate to compose form
- Tab through form fields, submit
- Tab through login form
- Escape closes mod dialog (if testing with mod user)
Step 5: Update Linear issue
After all tests pass and audit is clean, update ATB-32 status.
Task 16: Update Documentation#
Files:
- Modify:
docs/atproto-forum-plan.md(mark Phase 4 responsive/a11y complete) - Modify:
docs/plans/2026-02-20-responsive-a11y-polish-design.md(mark implemented)
Step 1: Update project plan
Mark the responsive design and accessibility items as complete in docs/atproto-forum-plan.md.
Step 2: Update design doc status
Change the status line in the design doc from "Design approved, pending implementation" to "Implemented".
Step 3: Commit
git add docs/atproto-forum-plan.md docs/plans/2026-02-20-responsive-a11y-polish-design.md
git commit -m "docs: mark ATB-32 responsive/a11y/polish complete in project plan"
Summary#
| Task | Description | Files | Type |
|---|---|---|---|
| 1 | CSS: Form styles | theme.css | CSS |
| 2 | CSS: Post cards, breadcrumbs, topic rows | theme.css | CSS |
| 3 | CSS: Enhanced error/empty/loading states | theme.css | CSS |
| 4 | CSS: Responsive breakpoints + token overrides | theme.css, neobrutal-light.ts | CSS + TS |
| 5 | Tests: BaseLayout a11y (skip link, favicon) | base.test.tsx | Test |
| 6 | Impl: Skip link, main id, favicon | base.tsx, favicon.svg, theme.css | TSX + CSS |
| 7 | Tests: Hamburger menu | base.test.tsx | Test |
| 8 | Impl: Hamburger menu | base.tsx, theme.css | TSX + CSS |
| 9 | CSS: Focus-visible styles | theme.css | CSS |
| 10 | A11y: Form ARIA attributes | topics.tsx, new-topic.tsx, login.tsx | TSX |
| 11 | A11y: Semantic HTML (breadcrumbs, article) | boards.tsx, topics.tsx, new-topic.tsx, theme.css | TSX + CSS |
| 12 | Tests: 404 page | not-found.test.tsx | Test |
| 13 | Impl: 404 page + catch-all route | not-found.tsx, index.ts | TSX |
| 14 | CSS: Hover/active transitions | theme.css | CSS |
| 15 | Verification: Build, tests, Lighthouse audit | — | QA |
| 16 | Docs: Update plan and design doc | plan docs | Docs |