WIP! A BB-style forum, on the ATmosphere! We're still working... we'll be back soon when we have something to show off!
node typescript hono htmx atproto
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

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


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:

  1. Add favicon <link> in <head> after the theme.css link:
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
  1. 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">
  ...
  1. 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">
        &#9776;
      </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 autofocus to 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:

  1. Open http://localhost:3001
  2. Open DevTools → Lighthouse
  3. Run accessibility audit
  4. Target: 90+ accessibility score
  5. Fix any issues flagged

Step 4: Manual keyboard navigation test

Test these flows using only keyboard (Tab, Enter, Escape):

  1. Tab through homepage — skip link visible on first Tab
  2. Tab to a board card → Enter → navigate to board view
  3. Tab to "New Topic" button → Enter → navigate to compose form
  4. Tab through form fields, submit
  5. Tab through login form
  6. 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