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

feat(web): responsive design, accessibility, and UI polish (ATB-32) (#49)

* docs: add design doc for responsive, accessibility, and UI polish (ATB-32)

Covers CSS completion for ~20 unstyled classes, mobile-first responsive
breakpoints, WCAG AA accessibility baseline, error/loading/empty state
audit, and visual polish. Deferred axe-core testing to ATB-34.

* docs: add implementation plan for responsive/a11y/polish (ATB-32)

16-task plan covering CSS completion, responsive breakpoints, accessibility
(skip link, ARIA, semantic HTML), 404 page, and visual polish. TDD for
JSX changes, CSS-only for styling tasks.

* style(web): add CSS for forms, login form, char counter, and success banner (ATB-32)

* style(web): add CSS for post cards, breadcrumbs, topic rows, and locked banner (ATB-32)

* style(web): enhance error, empty, and loading state CSS with error page styles (ATB-32)

* style(web): add mobile-first responsive breakpoints with token overrides (ATB-32)

* feat(web): add skip link, favicon, focus styles, and mobile hamburger nav (ATB-32)

- Add skip-to-content link as first body element with off-screen positioning
- Add main landmark id="main-content" for skip link target
- Add :focus-visible outline styles for keyboard navigation
- Create SVG favicon with atBB branding
- Extract NavContent helper component for DRY desktop/mobile nav
- Add CSS-only hamburger menu using details/summary for mobile
- Add desktop-nav / mobile-nav with responsive show/hide
- Add tests for all new accessibility and mobile nav features

* feat(web): add ARIA attributes to forms, dialog, and live regions (ATB-32)

* feat(web): convert breadcrumbs to nav/ol, post cards to article elements (ATB-32)

* feat(web): add 404 page with neobrutal styling and catch-all route (ATB-32)

* style(web): add smooth transitions and hover polish to interactive elements (ATB-32)

* docs: mark Phase 4 complete and update ATB-32 design status

All Phase 4 web UI items now tracked with completion notes:
- Compose (ATB-30), Login (ATB-30), Admin panel (ATB-24)
- Responsive design (ATB-32) — final done gate for Phase 4
- Category view deferred (boards hierarchy replaced it)

* fix(web): address code review feedback on ATB-32 PR

- Add production-path tests for 404 page (auth, unauth, AppView down)
- Replace aria-live on reply-list with sr-only status element using
hx-swap-oob to announce reply count instead of full card content
- Re-throw programming errors in getSession/getSessionWithPermissions
catch blocks (import isProgrammingError from errors.ts)
- Differentiate nav aria-labels (Main navigation vs Mobile navigation)
- Harden test assertions: skip link position check, nav aria-label
coverage, exact count assertions for duplication detection

authored by

Malpercio and committed by
GitHub
939b361e 5ae38d77

+2579 -81
+591 -2
apps/web/public/static/css/theme.css
··· 26 26 a { 27 27 color: var(--color-primary); 28 28 text-decoration: underline; 29 + transition: color 0.15s ease; 29 30 } 30 31 31 32 a:hover { ··· 56 57 background: none; 57 58 } 58 59 60 + /* ─── Skip Link ────────────────────────────────────────────────────────── */ 61 + 62 + .skip-link { 63 + position: absolute; 64 + left: -9999px; 65 + top: auto; 66 + width: 1px; 67 + height: 1px; 68 + overflow: hidden; 69 + z-index: 1000; 70 + } 71 + 72 + .skip-link:focus { 73 + position: fixed; 74 + top: var(--space-sm); 75 + left: var(--space-sm); 76 + width: auto; 77 + height: auto; 78 + padding: var(--space-sm) var(--space-md); 79 + background-color: var(--color-primary); 80 + color: var(--color-surface); 81 + font-weight: var(--font-weight-bold); 82 + border: var(--border-width) solid var(--color-border); 83 + box-shadow: var(--button-shadow); 84 + text-decoration: none; 85 + z-index: 1000; 86 + } 87 + 88 + /* ─── Screen Reader Only ───────────────────────────────────────────────── */ 89 + 90 + .sr-only { 91 + position: absolute; 92 + width: 1px; 93 + height: 1px; 94 + padding: 0; 95 + margin: -1px; 96 + overflow: hidden; 97 + clip: rect(0, 0, 0, 0); 98 + white-space: nowrap; 99 + border: 0; 100 + } 101 + 102 + /* ─── Focus Styles ─────────────────────────────────────────────────────── */ 103 + 104 + :focus-visible { 105 + outline: 3px solid var(--color-primary); 106 + outline-offset: 2px; 107 + } 108 + 109 + :focus:not(:focus-visible) { 110 + outline: none; 111 + } 112 + 59 113 /* ─── Layout ────────────────────────────────────────────────────────────── */ 60 114 61 115 .site-header { ··· 82 136 font-size: var(--font-size-lg); 83 137 color: var(--color-text); 84 138 text-decoration: none; 139 + transition: color 0.15s ease; 85 140 } 86 141 87 142 .site-header__title:hover { ··· 94 149 gap: var(--space-md); 95 150 } 96 151 152 + .site-header__handle { 153 + font-weight: var(--font-weight-bold); 154 + font-size: var(--font-size-sm); 155 + } 156 + 157 + .site-header__logout-form { 158 + display: inline; 159 + } 160 + 161 + .site-header__logout-btn { 162 + cursor: pointer; 163 + background: none; 164 + border: none; 165 + color: var(--color-text-muted); 166 + font-family: var(--font-body); 167 + font-size: var(--font-size-sm); 168 + padding: var(--space-xs) var(--space-sm); 169 + text-decoration: underline; 170 + transition: color 0.15s ease; 171 + } 172 + 173 + .site-header__logout-btn:hover { 174 + color: var(--color-danger); 175 + } 176 + 177 + .site-header__login-link { 178 + font-weight: var(--font-weight-bold); 179 + } 180 + 97 181 .content-container { 98 182 max-width: var(--content-width); 99 183 margin: 0 auto; ··· 108 192 border-top: var(--border-width) solid var(--color-border); 109 193 } 110 194 195 + /* ─── Mobile Navigation ────────────────────────────────────────────────── */ 196 + 197 + .mobile-nav { 198 + display: block; 199 + position: relative; 200 + } 201 + 202 + .mobile-nav__toggle { 203 + cursor: pointer; 204 + font-size: var(--font-size-lg); 205 + list-style: none; 206 + padding: var(--space-xs) var(--space-sm); 207 + min-width: 44px; 208 + min-height: 44px; 209 + display: flex; 210 + align-items: center; 211 + justify-content: center; 212 + background: none; 213 + border: none; 214 + } 215 + 216 + .mobile-nav__toggle::-webkit-details-marker { 217 + display: none; 218 + } 219 + 220 + .mobile-nav__menu { 221 + position: absolute; 222 + right: 0; 223 + top: 100%; 224 + background-color: var(--color-surface); 225 + border: var(--border-width) solid var(--color-border); 226 + box-shadow: var(--card-shadow); 227 + padding: var(--space-md); 228 + min-width: 200px; 229 + z-index: 100; 230 + display: flex; 231 + flex-direction: column; 232 + gap: var(--space-sm); 233 + } 234 + 235 + .desktop-nav { 236 + display: none; 237 + align-items: center; 238 + gap: var(--space-md); 239 + } 240 + 111 241 /* ─── Card ──────────────────────────────────────────────────────────────── */ 112 242 113 243 .card { ··· 116 246 border-radius: var(--card-radius); 117 247 box-shadow: var(--card-shadow); 118 248 padding: var(--space-md); 249 + transition: transform 0.15s ease, box-shadow 0.15s ease; 119 250 } 120 251 121 252 /* ─── Button ────────────────────────────────────────────────────────────── */ ··· 193 324 .error-display { 194 325 background-color: var(--color-surface); 195 326 border: var(--border-width) solid var(--color-danger); 327 + border-left-width: calc(var(--border-width) * 3); 196 328 border-radius: var(--radius); 197 - padding: var(--space-md); 329 + padding: var(--space-lg); 198 330 color: var(--color-danger); 199 331 } 200 332 333 + .error-display__message { 334 + font-weight: var(--font-weight-bold); 335 + font-size: var(--font-size-lg); 336 + margin-bottom: var(--space-xs); 337 + } 338 + 339 + .error-display__detail { 340 + color: var(--color-text-muted); 341 + font-size: var(--font-size-sm); 342 + } 343 + 201 344 /* ─── Empty State ───────────────────────────────────────────────────────── */ 202 345 203 346 .empty-state { 204 347 text-align: center; 205 348 padding: var(--space-xl) var(--space-md); 206 349 color: var(--color-text-muted); 350 + border: var(--border-width) dashed var(--color-border); 351 + margin: var(--space-md) 0; 352 + } 353 + 354 + .empty-state p { 355 + font-size: var(--font-size-lg); 356 + margin-bottom: var(--space-sm); 357 + } 358 + 359 + .empty-state__action { 360 + margin-top: var(--space-md); 207 361 } 208 362 209 363 /* ─── Loading State (HTMX indicator) ───────────────────────────────────── */ ··· 211 365 .loading-state { 212 366 opacity: 0; 213 367 transition: opacity 0.2s ease; 368 + text-align: center; 369 + padding: var(--space-md); 370 + color: var(--color-text-muted); 214 371 } 215 372 216 373 .loading-state.htmx-request { 217 374 opacity: 1; 218 375 } 219 376 377 + .htmx-indicator { 378 + opacity: 0; 379 + transition: opacity 0.2s ease; 380 + } 381 + 382 + .htmx-request .htmx-indicator, 383 + .htmx-request.htmx-indicator { 384 + opacity: 1; 385 + } 386 + 387 + /* ─── Error Page ────────────────────────────────────────────────────────── */ 388 + 389 + .error-page { 390 + text-align: center; 391 + padding: var(--space-xl) var(--space-md); 392 + } 393 + 394 + .error-page__code { 395 + font-family: var(--font-heading); 396 + font-size: 6rem; 397 + font-weight: var(--font-weight-bold); 398 + line-height: 1; 399 + color: var(--color-primary); 400 + margin-bottom: var(--space-sm); 401 + } 402 + 403 + .error-page__title { 404 + font-size: var(--font-size-xl); 405 + margin-bottom: var(--space-sm); 406 + } 407 + 408 + .error-page__message { 409 + color: var(--color-text-muted); 410 + font-size: var(--font-size-base); 411 + margin-bottom: var(--space-lg); 412 + } 413 + 220 414 /* ─── Homepage ──────────────────────────────────────────────────────────── */ 221 415 222 416 .category-section { ··· 244 438 245 439 .board-card:hover .card { 246 440 transform: translate(-2px, -2px); 247 - box-shadow: 8px 8px 0 var(--color-shadow); 441 + box-shadow: calc(var(--shadow-offset) + 2px) calc(var(--shadow-offset) + 2px) 0 var(--color-shadow); 248 442 } 249 443 250 444 .board-card__name { ··· 259 453 font-size: var(--font-size-sm); 260 454 } 261 455 456 + /* ─── Forms ─────────────────────────────────────────────────────────────── */ 457 + 458 + .form-group { 459 + display: flex; 460 + flex-direction: column; 461 + gap: var(--space-xs); 462 + margin-bottom: var(--space-md); 463 + } 464 + 465 + .form-group label { 466 + font-weight: var(--font-weight-bold); 467 + font-size: var(--font-size-sm); 468 + } 469 + 470 + .form-group input, 471 + .form-group textarea, 472 + .form-group select { 473 + border: var(--input-border); 474 + border-radius: var(--input-radius); 475 + padding: var(--space-sm) var(--space-md); 476 + font-family: var(--font-body); 477 + font-size: var(--font-size-base); 478 + background-color: var(--color-surface); 479 + color: var(--color-text); 480 + min-height: 44px; 481 + } 482 + 483 + .form-group textarea { 484 + min-height: 120px; 485 + resize: vertical; 486 + } 487 + 488 + .form-actions { 489 + display: flex; 490 + flex-direction: row; 491 + gap: var(--space-sm); 492 + margin-top: var(--space-md); 493 + } 494 + 495 + .form-error { 496 + color: var(--color-danger); 497 + font-size: var(--font-size-sm); 498 + font-weight: var(--font-weight-bold); 499 + margin: var(--space-xs) 0; 500 + } 501 + 502 + .char-count { 503 + font-size: var(--font-size-sm); 504 + color: var(--color-text-muted); 505 + } 506 + 507 + .char-count[data-over="true"] { 508 + color: var(--color-danger); 509 + font-weight: var(--font-weight-bold); 510 + } 511 + 512 + .success-banner { 513 + background-color: var(--color-success); 514 + color: var(--color-surface); 515 + padding: var(--space-sm) var(--space-md); 516 + margin-bottom: var(--space-md); 517 + font-weight: var(--font-weight-bold); 518 + border: var(--border-width) solid var(--color-border); 519 + } 520 + 521 + /* ─── Login Form ────────────────────────────────────────────────────────── */ 522 + 523 + .login-form { 524 + max-width: 480px; 525 + } 526 + 527 + .login-form__form { 528 + display: flex; 529 + flex-direction: column; 530 + gap: var(--space-md); 531 + } 532 + 533 + .login-form__label { 534 + font-weight: var(--font-weight-bold); 535 + } 536 + 537 + .login-form__input { 538 + border: var(--input-border); 539 + border-radius: var(--input-radius); 540 + padding: var(--space-sm) var(--space-md); 541 + font-family: var(--font-body); 542 + font-size: var(--font-size-base); 543 + background-color: var(--color-surface); 544 + color: var(--color-text); 545 + min-height: 44px; 546 + width: 100%; 547 + box-sizing: border-box; 548 + } 549 + 550 + .login-form__hint { 551 + color: var(--color-text-muted); 552 + font-size: var(--font-size-sm); 553 + } 554 + 555 + .login-form__error { 556 + border: var(--border-width) solid var(--color-danger); 557 + padding: var(--space-sm) var(--space-md); 558 + color: var(--color-danger); 559 + font-weight: var(--font-weight-bold); 560 + } 561 + 562 + .login-form__submit { 563 + cursor: pointer; 564 + display: inline-flex; 565 + align-items: center; 566 + justify-content: center; 567 + gap: var(--space-sm); 568 + font-family: var(--font-body); 569 + font-weight: var(--font-weight-bold); 570 + font-size: var(--font-size-base); 571 + line-height: 1; 572 + border: var(--border-width) solid var(--color-border); 573 + border-radius: var(--button-radius); 574 + padding: var(--space-sm) var(--space-md); 575 + box-shadow: var(--button-shadow); 576 + background-color: var(--color-primary); 577 + color: var(--color-surface); 578 + text-decoration: none; 579 + transition: transform 0.1s ease, box-shadow 0.1s ease; 580 + user-select: none; 581 + } 582 + 583 + .login-form__submit:hover { 584 + transform: translate(var(--btn-press-hover), var(--btn-press-hover)); 585 + box-shadow: var(--btn-press-hover) var(--btn-press-hover) 0 var(--color-shadow); 586 + } 587 + 588 + .login-form__submit:active { 589 + transform: translate(var(--btn-press-active), var(--btn-press-active)); 590 + box-shadow: none; 591 + } 592 + 593 + /* ─── Post Cards ────────────────────────────────────────────────────────── */ 594 + 595 + .post-card { 596 + background-color: var(--color-surface); 597 + border: var(--border-width) solid var(--color-border); 598 + border-left-width: calc(var(--border-width) * 2); 599 + border-left-color: var(--color-primary); 600 + padding: var(--space-md); 601 + margin-bottom: var(--space-md); 602 + transition: transform 0.15s ease, box-shadow 0.15s ease; 603 + } 604 + 605 + .post-card--op { 606 + border-left-width: calc(var(--border-width) * 3); 607 + box-shadow: var(--card-shadow); 608 + } 609 + 610 + .post-card--reply { 611 + box-shadow: var(--shadow-offset) var(--shadow-offset) 0 var(--color-shadow); 612 + } 613 + 614 + .post-card__header { 615 + display: flex; 616 + flex-direction: row; 617 + align-items: center; 618 + gap: var(--space-sm); 619 + margin-bottom: var(--space-sm); 620 + flex-wrap: wrap; 621 + } 622 + 623 + .post-card__number { 624 + font-weight: var(--font-weight-bold); 625 + background-color: var(--color-primary); 626 + color: var(--color-surface); 627 + padding: var(--space-xs) var(--space-sm); 628 + min-width: 2em; 629 + text-align: center; 630 + } 631 + 632 + .post-card__author { 633 + font-weight: var(--font-weight-bold); 634 + font-size: var(--font-size-sm); 635 + } 636 + 637 + .post-card__date { 638 + color: var(--color-text-muted); 639 + font-size: var(--font-size-sm); 640 + margin-left: auto; 641 + } 642 + 643 + .post-card__body { 644 + white-space: pre-wrap; 645 + word-break: break-word; 646 + line-height: var(--line-height-body); 647 + } 648 + 649 + /* ─── Breadcrumb ────────────────────────────────────────────────────────── */ 650 + 651 + .breadcrumb { 652 + font-size: var(--font-size-sm); 653 + color: var(--color-text-muted); 654 + margin-bottom: var(--space-md); 655 + } 656 + 657 + .breadcrumb a { 658 + color: var(--color-text-muted); 659 + text-decoration: none; 660 + } 661 + 662 + .breadcrumb a:hover { 663 + color: var(--color-primary); 664 + } 665 + 666 + .breadcrumb ol { 667 + display: flex; 668 + flex-wrap: wrap; 669 + gap: 0; 670 + list-style: none; 671 + padding: 0; 672 + margin: 0; 673 + } 674 + 675 + .breadcrumb li { 676 + display: flex; 677 + align-items: center; 678 + } 679 + 680 + .breadcrumb li + li::before { 681 + content: "/"; 682 + margin: 0 var(--space-sm); 683 + color: var(--color-text-muted); 684 + } 685 + 686 + /* ─── Topic Rows ────────────────────────────────────────────────────────── */ 687 + 688 + .topic-row { 689 + display: flex; 690 + flex-direction: row; 691 + justify-content: space-between; 692 + align-items: baseline; 693 + gap: var(--space-md); 694 + padding: var(--space-md); 695 + border: var(--border-width) solid var(--color-border); 696 + background-color: var(--color-surface); 697 + margin-bottom: var(--space-sm); 698 + transition: transform 0.15s ease, box-shadow 0.15s ease; 699 + } 700 + 701 + .topic-row:hover { 702 + transform: translate(-1px, -1px); 703 + box-shadow: 3px 3px 0 var(--color-shadow); 704 + } 705 + 706 + .topic-row__title { 707 + font-weight: var(--font-weight-bold); 708 + color: var(--color-text); 709 + text-decoration: none; 710 + flex: 1; 711 + overflow: hidden; 712 + text-overflow: ellipsis; 713 + white-space: nowrap; 714 + } 715 + 716 + .topic-row__title:hover { 717 + color: var(--color-primary); 718 + } 719 + 720 + .topic-row__meta { 721 + display: flex; 722 + gap: var(--space-sm); 723 + color: var(--color-text-muted); 724 + font-size: var(--font-size-sm); 725 + white-space: nowrap; 726 + } 727 + 728 + /* ─── Topic Locked Banner ───────────────────────────────────────────────── */ 729 + 730 + .topic-locked-banner { 731 + background-color: var(--color-warning); 732 + color: var(--color-text); 733 + padding: var(--space-sm) var(--space-md); 734 + margin-bottom: var(--space-md); 735 + font-weight: var(--font-weight-bold); 736 + border: var(--border-width) solid var(--color-border); 737 + display: flex; 738 + align-items: center; 739 + gap: var(--space-sm); 740 + } 741 + 742 + .topic-locked-banner__badge { 743 + background-color: var(--color-text); 744 + color: var(--color-warning); 745 + padding: var(--space-xs) var(--space-sm); 746 + font-size: var(--font-size-sm); 747 + text-transform: uppercase; 748 + letter-spacing: 0.05em; 749 + } 750 + 262 751 /* ─── Moderation UI ──────────────────────────────────────────────────────── */ 263 752 264 753 .post-card__mod-actions { ··· 329 818 margin-bottom: var(--space-4); 330 819 font-size: 1.25rem; 331 820 } 821 + 822 + /* ═══════════════════════════════════════════════════════════════════════════ 823 + RESPONSIVE BREAKPOINTS (mobile-first) 824 + Base = mobile (0-767px), tablet = 768px+, desktop = 1024px+ 825 + ═══════════════════════════════════════════════════════════════════════════ */ 826 + 827 + /* ─── Tablet (768px+) ──────────────────────────────────────────────────── */ 828 + 829 + @media (min-width: 768px) { 830 + :root { 831 + --content-width: 720px; 832 + } 833 + 834 + .board-grid { 835 + display: grid; 836 + grid-template-columns: repeat(2, 1fr); 837 + } 838 + 839 + .topic-row { 840 + flex-direction: row; 841 + align-items: baseline; 842 + } 843 + 844 + .topic-row__title { 845 + white-space: nowrap; 846 + overflow: hidden; 847 + text-overflow: ellipsis; 848 + } 849 + 850 + .mobile-nav { 851 + display: none; 852 + } 853 + 854 + .desktop-nav { 855 + display: flex; 856 + } 857 + } 858 + 859 + /* ─── Desktop (1024px+) ───────────────────────────────────────────────── */ 860 + 861 + @media (min-width: 1024px) { 862 + :root { 863 + --content-width: 960px; 864 + --border-width: 3px; 865 + --shadow-offset: 4px; 866 + --button-shadow: 4px 4px 0 var(--color-shadow); 867 + --card-shadow: 6px 6px 0 var(--color-shadow); 868 + --btn-press-hover: 2px; 869 + --btn-press-active: 4px; 870 + --input-border: 3px solid var(--color-border); 871 + } 872 + 873 + .board-grid { 874 + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); 875 + } 876 + } 877 + 878 + /* ─── Mobile-specific overrides ────────────────────────────────────────── */ 879 + 880 + @media (max-width: 767px) { 881 + .page-header { 882 + flex-direction: column; 883 + align-items: flex-start; 884 + } 885 + 886 + .topic-row { 887 + flex-direction: column; 888 + gap: var(--space-xs); 889 + } 890 + 891 + .topic-row__title { 892 + white-space: normal; 893 + } 894 + 895 + .topic-row__meta { 896 + flex-wrap: wrap; 897 + } 898 + 899 + .post-card__header { 900 + flex-direction: column; 901 + align-items: flex-start; 902 + gap: var(--space-xs); 903 + } 904 + 905 + .post-card__date { 906 + margin-left: 0; 907 + } 908 + 909 + .login-form { 910 + max-width: 100%; 911 + } 912 + 913 + .desktop-nav { 914 + display: none; 915 + } 916 + 917 + .mobile-nav { 918 + display: block; 919 + } 920 + }
+5
apps/web/public/static/favicon.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"> 2 + <rect width="32" height="32" fill="#ff5c00" rx="0"/> 3 + <rect x="1" y="1" width="30" height="30" fill="#ff5c00" stroke="#1a1a1a" stroke-width="2" rx="0"/> 4 + <text x="16" y="23" font-family="system-ui, sans-serif" font-size="18" font-weight="700" fill="#ffffff" text-anchor="middle">BB</text> 5 + </svg>
+86
apps/web/src/layouts/__tests__/base.test.tsx
··· 122 122 expect(html).toContain("Log out"); 123 123 }); 124 124 }); 125 + 126 + describe("accessibility", () => { 127 + it("renders skip-to-content link before the site header", async () => { 128 + const res = await app.request("/"); 129 + const html = await res.text(); 130 + expect(html).toContain('class="skip-link"'); 131 + expect(html).toContain('href="#main-content"'); 132 + expect(html).toContain("Skip to main content"); 133 + // Skip link must come before header in DOM order 134 + const skipLinkPos = html.indexOf("skip-link"); 135 + const headerPos = html.indexOf("site-header"); 136 + expect(skipLinkPos).toBeLessThan(headerPos); 137 + }); 138 + 139 + it("renders main element with id for skip link target", async () => { 140 + const res = await app.request("/"); 141 + const html = await res.text(); 142 + expect(html).toContain('id="main-content"'); 143 + }); 144 + 145 + it("desktop nav has aria-label for Main navigation", async () => { 146 + const res = await app.request("/"); 147 + const html = await res.text(); 148 + expect(html).toContain('aria-label="Main navigation"'); 149 + }); 150 + 151 + it("mobile nav has distinct aria-label", async () => { 152 + const res = await app.request("/"); 153 + const html = await res.text(); 154 + expect(html).toContain('aria-label="Mobile navigation"'); 155 + }); 156 + }); 157 + 158 + describe("favicon", () => { 159 + it("includes favicon link in head", async () => { 160 + const res = await app.request("/"); 161 + const html = await res.text(); 162 + expect(html).toContain('rel="icon"'); 163 + expect(html).toContain("favicon.svg"); 164 + }); 165 + }); 166 + 167 + describe("mobile navigation", () => { 168 + it("renders details/summary hamburger menu for mobile", async () => { 169 + const res = await app.request("/"); 170 + const html = await res.text(); 171 + expect(html).toContain("mobile-nav"); 172 + expect(html).toContain("mobile-nav__toggle"); 173 + }); 174 + 175 + it("renders desktop nav separately from mobile nav", async () => { 176 + const res = await app.request("/"); 177 + const html = await res.text(); 178 + expect(html).toContain("desktop-nav"); 179 + }); 180 + 181 + it("hamburger has aria-label for accessibility", async () => { 182 + const res = await app.request("/"); 183 + const html = await res.text(); 184 + expect(html).toContain('aria-label="Menu"'); 185 + }); 186 + 187 + it("mobile nav contains login link when not authenticated", async () => { 188 + const res = await app.request("/"); 189 + const html = await res.text(); 190 + // Both mobile and desktop nav should have "Log in" 191 + const loginMatches = html.match(/Log in/g); 192 + expect(loginMatches!.length).toBe(2); 193 + }); 194 + 195 + it("mobile nav contains auth state when logged in", async () => { 196 + const auth: WebSession = { 197 + authenticated: true, 198 + did: "did:plc:abc123", 199 + handle: "alice.bsky.social", 200 + }; 201 + const authApp = new Hono().get("/", (c) => 202 + c.html(<BaseLayout auth={auth}>content</BaseLayout>) 203 + ); 204 + const res = await authApp.request("/"); 205 + const html = await res.text(); 206 + // Both mobile and desktop nav should have "Log out" 207 + const logoutMatches = html.match(/Log out/g); 208 + expect(logoutMatches!.length).toBe(2); 209 + }); 210 + }); 125 211 });
+36 -20
apps/web/src/layouts/base.tsx
··· 5 5 6 6 const ROOT_CSS = `:root { ${tokensToCss(neobrutalLight)} }`; 7 7 8 + const NavContent: FC<{ auth?: WebSession }> = ({ auth }) => ( 9 + <> 10 + {auth?.authenticated ? ( 11 + <> 12 + <span class="site-header__handle">{auth.handle}</span> 13 + <form action="/logout" method="post" class="site-header__logout-form"> 14 + <button type="submit" class="site-header__logout-btn"> 15 + Log out 16 + </button> 17 + </form> 18 + </> 19 + ) : ( 20 + <a href="/login" class="site-header__login-link"> 21 + Log in 22 + </a> 23 + )} 24 + </> 25 + ); 26 + 8 27 export const BaseLayout: FC< 9 28 PropsWithChildren<{ title?: string; auth?: WebSession }> 10 29 > = (props) => { ··· 23 42 /> 24 43 <link rel="stylesheet" href="/static/css/reset.css" /> 25 44 <link rel="stylesheet" href="/static/css/theme.css" /> 45 + <link rel="icon" type="image/svg+xml" href="/static/favicon.svg" /> 26 46 <script src="https://unpkg.com/htmx.org@2.0.4" defer /> 27 47 </head> 28 48 <body> 49 + <a href="#main-content" class="skip-link"> 50 + Skip to main content 51 + </a> 29 52 <header class="site-header"> 30 53 <div class="site-header__inner"> 31 54 <a href="/" class="site-header__title"> 32 55 atBB Forum 33 56 </a> 34 - <nav class="site-header__nav"> 35 - {auth?.authenticated ? ( 36 - <> 37 - <span class="site-header__handle">{auth.handle}</span> 38 - <form 39 - action="/logout" 40 - method="post" 41 - class="site-header__logout-form" 42 - > 43 - <button type="submit" class="site-header__logout-btn"> 44 - Log out 45 - </button> 46 - </form> 47 - </> 48 - ) : ( 49 - <a href="/login" class="site-header__login-link"> 50 - Log in 51 - </a> 52 - )} 57 + <nav class="desktop-nav" aria-label="Main navigation"> 58 + <NavContent auth={auth} /> 53 59 </nav> 60 + <details class="mobile-nav"> 61 + <summary class="mobile-nav__toggle" aria-label="Menu"> 62 + &#9776; 63 + </summary> 64 + <nav class="mobile-nav__menu" aria-label="Mobile navigation"> 65 + <NavContent auth={auth} /> 66 + </nav> 67 + </details> 54 68 </div> 55 69 </header> 56 - <main class="content-container">{props.children}</main> 70 + <main id="main-content" class="content-container"> 71 + {props.children} 72 + </main> 57 73 <footer class="site-footer"> 58 74 <p>Powered by atBB on the ATmosphere</p> 59 75 </footer>
+2 -2
apps/web/src/lib/__tests__/session.test.ts
··· 159 159 160 160 expect(result).toEqual({ authenticated: false }); 161 161 expect(consoleSpy).toHaveBeenCalledWith( 162 - expect.stringContaining("network or unexpected error"), 162 + expect.stringContaining("network error"), 163 163 expect.objectContaining({ error: expect.stringContaining("ECONNREFUSED") }) 164 164 ); 165 165 ··· 250 250 expect(result.authenticated).toBe(true); 251 251 expect(result.permissions.size).toBe(0); 252 252 expect(consoleSpy).toHaveBeenCalledWith( 253 - expect.stringContaining("failed to fetch permissions"), 253 + expect.stringContaining("network error"), 254 254 expect.any(Object) 255 255 ); 256 256 consoleSpy.mockRestore();
+6 -3
apps/web/src/lib/session.ts
··· 1 + import { isProgrammingError } from "./errors.js"; 2 + 1 3 export type WebSession = 2 4 | { authenticated: false } 3 5 | { authenticated: true; did: string; handle: string }; ··· 44 46 45 47 return { authenticated: false }; 46 48 } catch (error) { 49 + if (isProgrammingError(error)) throw error; 47 50 console.error( 48 - "getSession: network or unexpected error — treating as unauthenticated", 51 + "getSession: network error — treating as unauthenticated", 49 52 { 50 53 operation: "GET /api/auth/session", 51 54 error: error instanceof Error ? error.message : String(error), 52 55 } 53 56 ); 54 - // AppView unavailable or network error — treat as unauthenticated 55 57 return { authenticated: false }; 56 58 } 57 59 } ··· 104 106 ); 105 107 } 106 108 } catch (error) { 109 + if (isProgrammingError(error)) throw error; 107 110 console.error( 108 - "getSessionWithPermissions: failed to fetch permissions — continuing with empty permissions", 111 + "getSessionWithPermissions: network error — continuing with empty permissions", 109 112 { 110 113 operation: "GET /api/admin/members/me", 111 114 did: session.did,
+106
apps/web/src/routes/__tests__/not-found.test.tsx
··· 1 + import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 2 + import { Hono } from "hono"; 3 + import { createNotFoundRoute } from "../not-found.js"; 4 + 5 + describe("404 Not Found page", () => { 6 + describe("without appviewUrl (fallback path)", () => { 7 + const app = new Hono().route("/", createNotFoundRoute()); 8 + 9 + it("returns 404 status for unmatched routes", async () => { 10 + const res = await app.request("/some-random-page"); 11 + expect(res.status).toBe(404); 12 + }); 13 + 14 + it("renders error page with 404 code", async () => { 15 + const res = await app.request("/nonexistent"); 16 + const html = await res.text(); 17 + expect(html).toContain("404"); 18 + expect(html).toContain("error-page"); 19 + }); 20 + 21 + it("renders a link back to home", async () => { 22 + const res = await app.request("/anything"); 23 + const html = await res.text(); 24 + expect(html).toContain('href="/"'); 25 + }); 26 + 27 + it("renders user-friendly message", async () => { 28 + const res = await app.request("/nope"); 29 + const html = await res.text(); 30 + expect(html).toContain("error-page__message"); 31 + expect(html).toContain("exist"); 32 + }); 33 + }); 34 + 35 + describe("with appviewUrl (production path)", () => { 36 + const mockFetch = vi.fn(); 37 + 38 + beforeEach(() => { 39 + vi.stubGlobal("fetch", mockFetch); 40 + }); 41 + 42 + afterEach(() => { 43 + vi.unstubAllGlobals(); 44 + mockFetch.mockReset(); 45 + }); 46 + 47 + it("shows handle and logout for authenticated user", async () => { 48 + mockFetch.mockResolvedValueOnce({ 49 + ok: true, 50 + json: () => 51 + Promise.resolve({ 52 + authenticated: true, 53 + did: "did:plc:abc", 54 + handle: "alice.bsky.social", 55 + }), 56 + }); 57 + const app = new Hono().route( 58 + "/", 59 + createNotFoundRoute("http://localhost:3000") 60 + ); 61 + const res = await app.request("/missing", { 62 + headers: { cookie: "atbb_session=token" }, 63 + }); 64 + expect(res.status).toBe(404); 65 + const html = await res.text(); 66 + expect(html).toContain("alice.bsky.social"); 67 + expect(html).toContain("Log out"); 68 + expect(html).toContain("error-page"); 69 + }); 70 + 71 + it("shows login link for unauthenticated user", async () => { 72 + mockFetch.mockResolvedValueOnce({ 73 + ok: false, 74 + status: 401, 75 + }); 76 + const app = new Hono().route( 77 + "/", 78 + createNotFoundRoute("http://localhost:3000") 79 + ); 80 + const res = await app.request("/missing", { 81 + headers: { cookie: "atbb_session=expired" }, 82 + }); 83 + expect(res.status).toBe(404); 84 + const html = await res.text(); 85 + expect(html).toContain("Log in"); 86 + expect(html).toContain("error-page"); 87 + }); 88 + 89 + it("still renders 404 when getSession throws (AppView down)", async () => { 90 + mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 91 + const app = new Hono().route( 92 + "/", 93 + createNotFoundRoute("http://localhost:3000") 94 + ); 95 + const res = await app.request("/missing", { 96 + headers: { cookie: "atbb_session=token" }, 97 + }); 98 + expect(res.status).toBe(404); 99 + const html = await res.text(); 100 + expect(html).toContain("error-page"); 101 + expect(html).toContain("404"); 102 + // Falls back to unauthenticated — page still renders 103 + expect(html).toContain("Log in"); 104 + }); 105 + }); 106 + });
+6 -10
apps/web/src/routes/boards.tsx
··· 242 242 243 243 return c.html( 244 244 <BaseLayout title={`${board.name} — atBB Forum`} auth={auth}> 245 - <nav class="breadcrumb"> 246 - <a href="/">Home</a> 247 - {categoryName && ( 248 - <> 249 - {" / "} 250 - <a href="/">{categoryName}</a> 251 - </> 252 - )} 253 - {" / "} 254 - <span>{board.name}</span> 245 + <nav class="breadcrumb" aria-label="Breadcrumb"> 246 + <ol> 247 + <li><a href="/">Home</a></li> 248 + {categoryName && <li><a href="/">{categoryName}</a></li>} 249 + <li><span>{board.name}</span></li> 250 + </ol> 255 251 </nav> 256 252 257 253 <PageHeader
+3 -1
apps/web/src/routes/index.ts
··· 7 7 import { createNewTopicRoutes } from "./new-topic.js"; 8 8 import { createAuthRoutes } from "./auth.js"; 9 9 import { createModActionRoute } from "./mod.js"; 10 + import { createNotFoundRoute } from "./not-found.js"; 10 11 11 12 const config = loadConfig(); 12 13 ··· 17 18 .route("/", createLoginRoutes(config.appviewUrl)) 18 19 .route("/", createNewTopicRoutes(config.appviewUrl)) 19 20 .route("/", createAuthRoutes(config.appviewUrl)) 20 - .route("/", createModActionRoute(config.appviewUrl)); 21 + .route("/", createModActionRoute(config.appviewUrl)) 22 + .route("/", createNotFoundRoute(config.appviewUrl));
+3 -1
apps/web/src/routes/login.tsx
··· 53 53 required 54 54 autocomplete="username" 55 55 autofocus 56 + aria-required="true" 57 + aria-describedby="login-hint" 56 58 /> 57 - <p class="login-form__hint"> 59 + <p class="login-form__hint" id="login-hint"> 58 60 Use any AT Protocol handle (e.g. <code>alice.bsky.social</code>{" "} 59 61 or a custom domain). You own your posts — they live on your PDS. 60 62 </p>
+10 -8
apps/web/src/routes/new-topic.tsx
··· 81 81 82 82 return c.html( 83 83 <BaseLayout title="New Topic — atBB Forum" auth={auth}> 84 - <nav class="breadcrumb"> 85 - <a href="/">Home</a> 86 - {" / "} 87 - <a href={`/boards/${board.id}`}>{board.name}</a> 88 - {" / "} 89 - <span>New Topic</span> 84 + <nav class="breadcrumb" aria-label="Breadcrumb"> 85 + <ol> 86 + <li><a href="/">Home</a></li> 87 + <li><a href={`/boards/${board.id}`}>{board.name}</a></li> 88 + <li><span>New Topic</span></li> 89 + </ol> 90 90 </nav> 91 91 92 92 <PageHeader title="New Topic" description={`Posting to ${board.name}`} /> ··· 109 109 rows={8} 110 110 placeholder="What's on your mind?" 111 111 oninput="updateCharCount(this)" 112 + aria-required="true" 113 + aria-describedby="char-count" 112 114 /> 113 - <div id="char-count" class="char-count">300 left</div> 115 + <div id="char-count" class="char-count" aria-live="polite">300 left</div> 114 116 </div> 115 117 116 - <div id="form-error" /> 118 + <div id="form-error" role="alert" /> 117 119 118 120 <div class="form-actions"> 119 121 <button type="submit" class="btn btn-primary">
+24
apps/web/src/routes/not-found.tsx
··· 1 + import { Hono } from "hono"; 2 + import { BaseLayout } from "../layouts/base.js"; 3 + import { getSession } from "../lib/session.js"; 4 + 5 + export function createNotFoundRoute(appviewUrl?: string) { 6 + return new Hono().all("*", async (c) => { 7 + const auth = appviewUrl 8 + ? await getSession(appviewUrl, c.req.header("cookie")) 9 + : { authenticated: false as const }; 10 + return c.html( 11 + <BaseLayout title="Page Not Found — atBB Forum" auth={auth}> 12 + <div class="error-page"> 13 + <div class="error-page__code">404</div> 14 + <h1 class="error-page__title">Page Not Found</h1> 15 + <p class="error-page__message"> 16 + This page doesn't exist or has been removed. 17 + </p> 18 + <a href="/" class="btn btn-primary">Back to Home</a> 19 + </div> 20 + </BaseLayout>, 21 + 404 22 + ); 23 + }); 24 + }
+25 -21
apps/web/src/routes/topics.tsx
··· 116 116 const date = post.createdAt ? timeAgo(new Date(post.createdAt)) : "unknown"; 117 117 const cardClass = isOP ? "post-card post-card--op" : "post-card post-card--reply"; 118 118 return ( 119 - <div class={cardClass} id={`post-${postNumber}`}> 119 + <article class={cardClass} id={`post-${postNumber}`}> 120 120 <div class="post-card__header"> 121 121 <span class="post-card__number">#{postNumber}</span> 122 122 <span class="post-card__author">{handle}</span> ··· 147 147 )} 148 148 </div> 149 149 )} 150 - </div> 150 + </article> 151 151 ); 152 152 } 153 153 154 154 function ModDialog() { 155 155 return ( 156 - <dialog id="mod-dialog" class="mod-dialog"> 156 + <dialog id="mod-dialog" class="mod-dialog" aria-labelledby="mod-dialog-title"> 157 157 <h2 id="mod-dialog-title" class="mod-dialog__title">Confirm Action</h2> 158 158 <form 159 159 hx-post="/mod/action" ··· 170 170 name="reason" 171 171 rows={3} 172 172 placeholder="Reason for this action…" 173 + autofocus 173 174 /> 174 175 </div> 175 176 <div id="mod-dialog-error" /> ··· 232 233 <PostCard key={reply.id} post={reply} postNumber={offset + i + 2} modPerms={modPerms} /> 233 234 ))} 234 235 {hasMore && <LoadMoreButton topicId={topicId} nextOffset={nextOffset} />} 236 + {offset > 0 && ( 237 + <div id="reply-status" class="sr-only" aria-live="polite" hx-swap-oob="true"> 238 + {replies.length} more {replies.length === 1 ? "reply" : "replies"} loaded 239 + </div> 240 + )} 235 241 </> 236 242 ); 237 243 } ··· 383 389 384 390 return c.html( 385 391 <BaseLayout title={`${topicTitle} — atBB Forum`} auth={auth}> 386 - <nav class="breadcrumb"> 387 - <a href="/">Home</a> 388 - {categoryName && categoryId && ( 389 - <> 390 - {" / "} 391 - <a href={`/categories/${categoryId}`}>{categoryName}</a> 392 - </> 393 - )} 394 - {boardName && ( 395 - <> 396 - {" / "} 397 - <a href={`/boards/${topicData.post.boardId}`}>{boardName}</a> 398 - </> 399 - )} 400 - {" / "} 401 - <span>{topicTitle}</span> 392 + <nav class="breadcrumb" aria-label="Breadcrumb"> 393 + <ol> 394 + <li><a href="/">Home</a></li> 395 + {categoryName && categoryId && ( 396 + <li><a href={`/categories/${categoryId}`}>{categoryName}</a></li> 397 + )} 398 + {boardName && ( 399 + <li><a href={`/boards/${topicData.post.boardId}`}>{boardName}</a></li> 400 + )} 401 + <li><span>{topicTitle}</span></li> 402 + </ol> 402 403 </nav> 403 404 404 405 <PageHeader title={topicTitle} /> ··· 437 438 /> 438 439 )} 439 440 </div> 441 + <div id="reply-status" class="sr-only" aria-live="polite"></div> 440 442 441 443 <div id="reply-form-slot"> 442 444 {topicData.locked ? ( ··· 459 461 rows={5} 460 462 placeholder="Write a reply…" 461 463 oninput="updateReplyCharCount(this)" 464 + aria-required="true" 465 + aria-describedby="reply-char-count" 462 466 /> 463 - <div id="reply-char-count" class="char-count">300 left</div> 467 + <div id="reply-char-count" class="char-count" aria-live="polite">300 left</div> 464 468 </div> 465 469 466 - <div id="reply-form-error" /> 470 + <div id="reply-form-error" role="alert" /> 467 471 468 472 <div class="form-actions"> 469 473 <button type="submit" class="btn btn-primary">
+8 -8
apps/web/src/styles/presets/neobrutal-light.ts
··· 35 35 "space-lg": "24px", 36 36 "space-xl": "40px", 37 37 "radius": "0px", 38 - "border-width": "3px", 39 - "shadow-offset": "4px", 40 - "content-width": "960px", 38 + "border-width": "2px", 39 + "shadow-offset": "2px", 40 + "content-width": "100%", 41 41 // Components 42 42 "button-radius": "0px", 43 - "button-shadow": "4px 4px 0 var(--color-shadow)", 43 + "button-shadow": "2px 2px 0 var(--color-shadow)", 44 44 "card-radius": "0px", 45 - "card-shadow": "6px 6px 0 var(--color-shadow)", 46 - "btn-press-hover": "2px", 47 - "btn-press-active": "4px", 45 + "card-shadow": "4px 4px 0 var(--color-shadow)", 46 + "btn-press-hover": "1px", 47 + "btn-press-active": "2px", 48 48 "input-radius": "0px", 49 - "input-border": "3px solid var(--color-border)", 49 + "input-border": "2px solid var(--color-border)", 50 50 "nav-height": "64px", 51 51 };
+9 -5
docs/atproto-forum-plan.md
··· 226 226 - ATB-27 | `apps/web/src/routes/home.tsx` — server-renders forum name/description, categories as section headers, boards as cards with links; two-stage parallel fetch (forum+categories, then per-category boards); error display on network (503) or API (500) failures; 12 integration tests in `home.test.tsx` 227 227 - [x] Board view: topic listing with pagination 228 228 - ATB-28 | `apps/web/src/routes/boards.tsx` — breadcrumb navigation, topic list (truncated 80 chars), HTMX "Load More" pagination; auth-aware "New Topic" button; `timeAgo` relative date utility; `isNotFoundError` error helper; AppView: `GET /api/boards/:id`, `GET /api/categories/:id`, offset/limit pagination on `GET /api/boards/:id/topics`; 20 integration tests in `boards.test.tsx`; 8 AppView tests 229 - - [ ] Category view: paginated topic list, sorted by last reply 229 + - [ ] Category view: paginated topic list, sorted by last reply — **Deferred:** boards hierarchy (ATB-23) replaced per-category pages; boards view serves this purpose 230 230 - [x] Topic view: OP + flat replies, pagination 231 231 - ATB-29 | `apps/web/src/routes/topics.tsx` — OP card (#1) + reply cards (#2, #3, …) with post numbers and `timeAgo` dates; breadcrumb (Home → Category → Board → Topic) with graceful degradation on breadcrumb fetch failures; locked-topic banner + reply-slot gating (unauthenticated/authenticated/locked); HTMX "Load More" with `hx-push-url` for bookmarkable offsets; `?offset=N` bookmark support renders all replies 0→N+pageSize inline; three-stage sequential fetch (topic fatal, board/category non-fatal); 35 integration tests in `topics.test.tsx` 232 - - [ ] Compose: new topic form, reply form 233 - - [ ] Login/logout flow 234 - - [ ] Admin panel: manage categories, view members, mod actions 235 - - [ ] Basic responsive design 232 + - [x] Compose: new topic form, reply form 233 + - ATB-30 | `apps/web/src/routes/new-topic.tsx` — new topic form with board selector, character counter, validation; `apps/web/src/routes/topics.tsx` — inline reply form with HTMX submission; both forms write to AppView API which proxies to user's PDS 234 + - [x] Login/logout flow 235 + - ATB-30 | `apps/web/src/routes/login.tsx` — handle input form; `apps/web/src/routes/auth.tsx` — OAuth callback + session management; BaseLayout shows login/logout based on auth state 236 + - [x] Admin panel: manage categories, view members, mod actions 237 + - ATB-24 | Topic view mod buttons (lock/hide/ban) gated on permissions; `<dialog>` confirmation modal; `POST /mod/action` web proxy route; `getSessionWithPermissions()` for permission-aware rendering 238 + - [x] Basic responsive design 239 + - ATB-32 | Mobile-first responsive breakpoints (375px/768px/1024px), CSS-only hamburger nav via `<details>`/`<summary>`, token overrides for mobile, accessibility improvements (skip link, focus-visible, ARIA attributes, semantic HTML), 404 page, visual polish (transitions, hover states), SVG favicon 236 240 237 241 #### Phase 5: Packaging & Deployment (Week 9–10) 238 242 - [x] Dockerfiles for AppView and Web UI — **Complete:** Multi-stage Dockerfile with Node 22 Alpine, nginx reverse proxy, health checks (ATB-28)
+232
docs/plans/2026-02-20-responsive-a11y-polish-design.md
··· 1 + # ATB-32: Responsive Design, Accessibility, and UI Polish 2 + 3 + **Date:** 2026-02-20 4 + **Linear issue:** ATB-32 5 + **Status:** Implemented (2026-02-20) 6 + 7 + ## Summary 8 + 9 + Final polish pass for Phase 4. Makes the forum MVP-ready by completing unstyled CSS classes, adding responsive breakpoints, improving accessibility to WCAG AA baseline, and ensuring consistent loading/error/empty states across all pages. This is the "done gate" for Phase 4. 10 + 11 + ## Priority Order 12 + 13 + 1. CSS completion (~20 unstyled classes) 14 + 2. Responsive design (3 breakpoints, mobile-first) 15 + 3. Accessibility (skip link, focus styles, ARIA, semantic HTML) 16 + 4. Loading/error/empty states + error pages 17 + 5. Visual polish (hover/active/focus, transitions, favicon) 18 + 19 + ## 1. CSS Completion 20 + 21 + ### Unstyled Classes to Add 22 + 23 + **Forms:** 24 + - `.form-group` — Vertical stack: label + input + error, `margin-bottom: var(--space-md)` 25 + - `.form-actions` — Right-aligned or full-width button container 26 + - `.form-error` — Danger-colored text below input, small font 27 + - `.char-count` — Muted small text below textarea, danger color when `[data-over="true"]` 28 + - `.login-form`, `.login-form__input`, `.login-form__label`, `.login-form__hint`, `.login-form__error`, `.login-form__submit` — Neobrutal form with thick-bordered input, bold label, offset-shadow submit button 29 + 30 + **Post cards:** 31 + - `.post-card` — Card variant with left border accent, author header, text body 32 + - `.post-card--op` — Thicker border/larger shadow for the original post 33 + - `.post-card--reply` — Standard weight for replies 34 + - `.post-card__header` — Flex row: post number badge + author + date 35 + - `.post-card__body` — `white-space: pre-wrap` for plain text content 36 + - `.post-card__author`, `.post-card__date`, `.post-card__number` — Typography styles 37 + 38 + **Navigation & structure:** 39 + - `.breadcrumb` — Inline list with `>` separators, muted color, wraps on mobile 40 + - `.topic-row` — Flex row in board view: title + meta, clickable, hover state 41 + - `.topic-row__title`, `.topic-row__meta` — Bold title, muted meta text 42 + - `.topic-locked-banner`, `.topic-locked-banner__badge` — Warning-colored banner 43 + 44 + ### Approach 45 + 46 + All styles follow existing neobrutal conventions: thick borders (`var(--border-width)`), offset shadows (`var(--card-shadow)`), bold typography, existing color tokens. Single-file expansion of `theme.css`. 47 + 48 + ## 2. Responsive Design 49 + 50 + ### Breakpoints (mobile-first) 51 + 52 + | Breakpoint | Width | Target | 53 + |------------|-------|--------| 54 + | Default | 0–767px | Mobile portrait (375px reference) | 55 + | Tablet | 768px+ | Tablet / landscape | 56 + | Desktop | 1024px+ | Desktop | 57 + 58 + ### Token Overrides 59 + 60 + Reduce neobrutal intensity on mobile for touch ergonomics: 61 + 62 + ```css 63 + /* Base (mobile) tokens */ 64 + :root { 65 + --shadow-offset: 2px; 66 + --border-width: 2px; 67 + } 68 + 69 + /* Desktop restores full intensity */ 70 + @media (min-width: 1024px) { 71 + :root { 72 + --shadow-offset: 4px; 73 + --border-width: 3px; 74 + } 75 + } 76 + ``` 77 + 78 + ### Layout Per Breakpoint 79 + 80 + **Mobile (default):** 81 + - Single column layout, full-width content with side padding 82 + - Board grid: single column (stacked cards) 83 + - Topic rows: title stacked above meta 84 + - Header: CSS-only hamburger via `<details>`/`<summary>` 85 + - Post cards: full width, reduced shadow 86 + - Forms: full width, min 44px touch targets 87 + - Compose textarea: full width, taller min-height 88 + 89 + **Tablet (768px+):** 90 + - Content: `max-width: 720px` centered 91 + - Board grid: 2-column 92 + - Topic rows: horizontal flex 93 + - Header: inline nav (no hamburger) 94 + 95 + **Desktop (1024px+):** 96 + - Content: `max-width: 960px` (existing) 97 + - Board grid: 2-3 columns 98 + - Full neobrutal intensity 99 + 100 + ### Hamburger Menu 101 + 102 + CSS-only via `<details>` + `<summary>`: 103 + 104 + ```html 105 + <details class="mobile-nav"> 106 + <summary class="mobile-nav__toggle" aria-label="Menu">☰</summary> 107 + <nav class="mobile-nav__menu"> 108 + <!-- nav links + auth state --> 109 + </nav> 110 + </details> 111 + ``` 112 + 113 + Hidden on tablet+ via `display: none`. Native keyboard and screen reader support. 114 + 115 + ### Viewport Meta 116 + 117 + Add `<meta name="viewport" content="width=device-width, initial-scale=1" />` to BaseLayout. 118 + 119 + ## 3. Accessibility 120 + 121 + ### Skip-to-Content Link 122 + 123 + First child of `<body>`: 124 + ```html 125 + <a href="#main-content" class="skip-link">Skip to main content</a> 126 + ``` 127 + Visually hidden, visible on focus. `<main>` gets `id="main-content"`. 128 + 129 + ### Focus Styles 130 + 131 + Neobrutal thick outline on `:focus-visible`: 132 + ```css 133 + :focus-visible { 134 + outline: 3px solid var(--color-primary); 135 + outline-offset: 2px; 136 + } 137 + ``` 138 + 139 + ### Form Accessibility 140 + 141 + - `aria-required="true"` on required inputs 142 + - Error messages linked via `aria-describedby` 143 + - Character counter: `aria-live="polite"` 144 + - Form errors: `role="alert"` 145 + 146 + ### Dialog Accessibility 147 + 148 + - `aria-labelledby="mod-dialog-title"` on `<dialog>` 149 + - `autofocus` on first form field 150 + - Focus trap handled natively by `showModal()` 151 + 152 + ### HTMX Live Regions 153 + 154 + - Reply list container: `aria-live="polite"` 155 + - Form error containers: `role="alert"` 156 + 157 + ### Semantic HTML Fixes 158 + 159 + - Breadcrumbs: `<nav aria-label="Breadcrumb">` with `<ol>` list 160 + - Board grid: `<section>` with heading 161 + - Post cards: `<article>` elements 162 + 163 + ## 4. Loading / Error / Empty States 164 + 165 + ### Style Existing Components 166 + 167 + Complete CSS for `ErrorDisplay`, `EmptyState`, `LoadingState` components: 168 + - ErrorDisplay: danger-accent card with icon and message 169 + - EmptyState: centered muted text with optional action 170 + - LoadingState: pulsing animation for HTMX indicators 171 + 172 + ### New Pages 173 + 174 + **404 page:** 175 + - Big bold "404" heading 176 + - "This page doesn't exist or has been removed" 177 + - "Back to home" button 178 + - Catch-all route in web app 179 + 180 + **Generic error page:** 181 + - "Something went wrong" heading 182 + - "Please try again later" message 183 + - "Back to home" button 184 + 185 + ### Page Audit 186 + 187 + | Page | Error | Empty | Loading | 188 + |------|-------|-------|---------| 189 + | Homepage | API unavailable → ErrorDisplay | No categories → EmptyState | Handled | 190 + | Board view | Board not found → 404 | No topics → EmptyState + "Create first topic" | HTMX indicator | 191 + | Topic view | Topic not found → 404 | No replies → "No replies yet" | HTMX indicator | 192 + | Login | Auth failed → form error | N/A | HTMX indicator | 193 + | New topic | Submit failed → form error | N/A | HTMX indicator | 194 + 195 + ## 5. Visual Polish 196 + 197 + ### Hover/Focus/Active States 198 + 199 + ```css 200 + .btn:hover, .card:hover { 201 + transform: translate(-2px, -2px); 202 + box-shadow: calc(var(--shadow-offset) + 2px) calc(var(--shadow-offset) + 2px) 0 var(--color-text); 203 + } 204 + 205 + .btn:active { 206 + transform: translate(1px, 1px); 207 + box-shadow: 1px 1px 0 var(--color-text); 208 + } 209 + ``` 210 + 211 + ### Transitions 212 + 213 + ```css 214 + .btn, .card, .topic-row { 215 + transition: transform 0.15s ease, box-shadow 0.15s ease; 216 + } 217 + ``` 218 + 219 + ### Favicon 220 + 221 + SVG favicon with bold "BB" in primary orange, thick black border. SVG scales perfectly in all modern browsers. 222 + 223 + ### Print Stylesheet 224 + 225 + Skipped for MVP. 226 + 227 + ## Deferred to Follow-Up Issues 228 + 229 + - **Axe-core integration tests** — Automated accessibility testing as part of Vitest suite 230 + - **Screen reader testing** — Manual testing with VoiceOver/NVDA for key flows 231 + - **Print stylesheet** — Low priority, digital-first consumption 232 + - **Visual regression screenshots** — Automated screenshot comparison at breakpoints
+1427
docs/plans/2026-02-20-responsive-a11y-polish-plan.md
··· 1 + # ATB-32: Responsive Design, Accessibility, and UI Polish — Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **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. 6 + 7 + **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. 8 + 9 + **Tech Stack:** Hono JSX, HTMX 2.0, CSS custom properties, Vitest, CSS-only `<details>/<summary>` hamburger 10 + 11 + **Design doc:** `docs/plans/2026-02-20-responsive-a11y-polish-design.md` 12 + 13 + **Important context:** 14 + - pnpm is at `.devenv/profile/bin/pnpm` — prepend to PATH in all shell commands 15 + - The viewport meta tag already exists in BaseLayout line 16 16 + - The neobrutal-light.ts token file defines `--border-width: 3px`, `--shadow-offset: 4px`, `--card-shadow: 6px 6px 0`, `--button-shadow: 4px 4px 0` 17 + - Button hover/active transitions already exist in theme.css lines 139-166 18 + - Tests use Hono's `app.request()` pattern — render JSX, check HTML string output 19 + 20 + --- 21 + 22 + ## Task 1: CSS — Form Styles 23 + 24 + **Files:** 25 + - Modify: `apps/web/public/static/css/theme.css` (append after line 261, before Moderation UI section) 26 + 27 + **Step 1: Add form styles to theme.css** 28 + 29 + Add a new `/* ─── Forms ──── */` section before the Moderation UI section. Insert these styles: 30 + 31 + ```css 32 + /* ─── Forms ────────────────────────────────────────────────────────────── */ 33 + 34 + .form-group { 35 + display: flex; 36 + flex-direction: column; 37 + gap: var(--space-xs); 38 + margin-bottom: var(--space-md); 39 + } 40 + 41 + .form-group label { 42 + font-weight: var(--font-weight-bold); 43 + font-size: var(--font-size-sm); 44 + } 45 + 46 + .form-group input, 47 + .form-group textarea, 48 + .form-group select { 49 + border: var(--input-border); 50 + border-radius: var(--input-radius); 51 + padding: var(--space-sm) var(--space-md); 52 + font-family: var(--font-body); 53 + font-size: var(--font-size-base); 54 + background-color: var(--color-surface); 55 + color: var(--color-text); 56 + min-height: 44px; 57 + } 58 + 59 + .form-group textarea { 60 + min-height: 120px; 61 + resize: vertical; 62 + } 63 + 64 + .form-actions { 65 + display: flex; 66 + gap: var(--space-sm); 67 + margin-top: var(--space-md); 68 + } 69 + 70 + .form-error { 71 + color: var(--color-danger); 72 + font-size: var(--font-size-sm); 73 + font-weight: var(--font-weight-bold); 74 + margin: var(--space-xs) 0; 75 + } 76 + 77 + .char-count { 78 + font-size: var(--font-size-sm); 79 + color: var(--color-text-muted); 80 + } 81 + 82 + .char-count[data-over="true"] { 83 + color: var(--color-danger); 84 + font-weight: var(--font-weight-bold); 85 + } 86 + 87 + .success-banner { 88 + background-color: var(--color-success); 89 + color: var(--color-surface); 90 + padding: var(--space-sm) var(--space-md); 91 + margin-bottom: var(--space-md); 92 + font-weight: var(--font-weight-bold); 93 + border: var(--border-width) solid var(--color-border); 94 + } 95 + 96 + /* ─── Login Form ───────────────────────────────────────────────────────── */ 97 + 98 + .login-form { 99 + max-width: 480px; 100 + } 101 + 102 + .login-form__form { 103 + display: flex; 104 + flex-direction: column; 105 + gap: var(--space-md); 106 + } 107 + 108 + .login-form__label { 109 + font-weight: var(--font-weight-bold); 110 + font-size: var(--font-size-base); 111 + } 112 + 113 + .login-form__input { 114 + border: var(--input-border); 115 + border-radius: var(--input-radius); 116 + padding: var(--space-sm) var(--space-md); 117 + font-family: var(--font-body); 118 + font-size: var(--font-size-base); 119 + background-color: var(--color-surface); 120 + color: var(--color-text); 121 + min-height: 44px; 122 + width: 100%; 123 + } 124 + 125 + .login-form__hint { 126 + color: var(--color-text-muted); 127 + font-size: var(--font-size-sm); 128 + line-height: var(--line-height-body); 129 + } 130 + 131 + .login-form__error { 132 + background-color: var(--color-surface); 133 + border: var(--border-width) solid var(--color-danger); 134 + padding: var(--space-sm) var(--space-md); 135 + color: var(--color-danger); 136 + font-weight: var(--font-weight-bold); 137 + } 138 + 139 + .login-form__submit { 140 + cursor: pointer; 141 + font-family: var(--font-body); 142 + font-weight: var(--font-weight-bold); 143 + font-size: var(--font-size-base); 144 + border: var(--border-width) solid var(--color-border); 145 + border-radius: var(--button-radius); 146 + padding: var(--space-sm) var(--space-md); 147 + box-shadow: var(--button-shadow); 148 + background-color: var(--color-primary); 149 + color: var(--color-surface); 150 + min-height: 44px; 151 + transition: transform 0.1s ease, box-shadow 0.1s ease; 152 + } 153 + 154 + .login-form__submit:hover { 155 + transform: translate(var(--btn-press-hover), var(--btn-press-hover)); 156 + box-shadow: var(--btn-press-hover) var(--btn-press-hover) 0 var(--color-shadow); 157 + } 158 + 159 + .login-form__submit:active { 160 + transform: translate(var(--btn-press-active), var(--btn-press-active)); 161 + box-shadow: none; 162 + } 163 + ``` 164 + 165 + **Step 2: Verify build passes** 166 + 167 + Run: `PATH=.devenv/profile/bin:$PATH pnpm build` 168 + Expected: Clean build (CSS is static, no compilation needed, but verify no regressions) 169 + 170 + **Step 3: Commit** 171 + 172 + ```bash 173 + git add apps/web/public/static/css/theme.css 174 + git commit -m "style(web): add CSS for forms, login form, char counter, and success banner (ATB-32)" 175 + ``` 176 + 177 + --- 178 + 179 + ## Task 2: CSS — Post Cards, Breadcrumbs, Topic Rows, Banners 180 + 181 + **Files:** 182 + - Modify: `apps/web/public/static/css/theme.css` (append new sections) 183 + 184 + **Step 1: Add post card, breadcrumb, topic row, and banner styles** 185 + 186 + Add after the Login Form section: 187 + 188 + ```css 189 + /* ─── Post Cards ───────────────────────────────────────────────────────── */ 190 + 191 + .post-card { 192 + background-color: var(--color-surface); 193 + border: var(--border-width) solid var(--color-border); 194 + border-left-width: calc(var(--border-width) * 2); 195 + border-left-color: var(--color-primary); 196 + padding: var(--space-md); 197 + margin-bottom: var(--space-md); 198 + } 199 + 200 + .post-card--op { 201 + border-left-width: calc(var(--border-width) * 3); 202 + box-shadow: var(--card-shadow); 203 + } 204 + 205 + .post-card--reply { 206 + box-shadow: var(--shadow-offset) var(--shadow-offset) 0 var(--color-shadow); 207 + } 208 + 209 + .post-card__header { 210 + display: flex; 211 + align-items: center; 212 + gap: var(--space-sm); 213 + margin-bottom: var(--space-sm); 214 + flex-wrap: wrap; 215 + } 216 + 217 + .post-card__number { 218 + font-weight: var(--font-weight-bold); 219 + font-size: var(--font-size-sm); 220 + background-color: var(--color-primary); 221 + color: var(--color-surface); 222 + padding: 2px var(--space-sm); 223 + min-width: 2em; 224 + text-align: center; 225 + } 226 + 227 + .post-card__author { 228 + font-weight: var(--font-weight-bold); 229 + font-size: var(--font-size-sm); 230 + } 231 + 232 + .post-card__date { 233 + color: var(--color-text-muted); 234 + font-size: var(--font-size-sm); 235 + margin-left: auto; 236 + } 237 + 238 + .post-card__body { 239 + white-space: pre-wrap; 240 + word-break: break-word; 241 + line-height: var(--line-height-body); 242 + } 243 + 244 + /* ─── Breadcrumbs ──────────────────────────────────────────────────────── */ 245 + 246 + .breadcrumb { 247 + font-size: var(--font-size-sm); 248 + color: var(--color-text-muted); 249 + margin-bottom: var(--space-md); 250 + line-height: var(--line-height-body); 251 + } 252 + 253 + .breadcrumb a { 254 + color: var(--color-text-muted); 255 + } 256 + 257 + .breadcrumb a:hover { 258 + color: var(--color-primary); 259 + } 260 + 261 + /* ─── Topic Rows (Board View) ──────────────────────────────────────────── */ 262 + 263 + .topic-row { 264 + display: flex; 265 + justify-content: space-between; 266 + align-items: baseline; 267 + gap: var(--space-md); 268 + padding: var(--space-md); 269 + border: var(--border-width) solid var(--color-border); 270 + background-color: var(--color-surface); 271 + margin-bottom: var(--space-sm); 272 + transition: transform 0.15s ease, box-shadow 0.15s ease; 273 + } 274 + 275 + .topic-row:hover { 276 + transform: translate(-1px, -1px); 277 + box-shadow: 3px 3px 0 var(--color-shadow); 278 + } 279 + 280 + .topic-row__title { 281 + font-weight: var(--font-weight-bold); 282 + color: var(--color-text); 283 + text-decoration: none; 284 + flex: 1; 285 + min-width: 0; 286 + overflow: hidden; 287 + text-overflow: ellipsis; 288 + white-space: nowrap; 289 + } 290 + 291 + .topic-row__title:hover { 292 + color: var(--color-primary); 293 + } 294 + 295 + .topic-row__meta { 296 + display: flex; 297 + gap: var(--space-sm); 298 + color: var(--color-text-muted); 299 + font-size: var(--font-size-sm); 300 + white-space: nowrap; 301 + } 302 + 303 + /* ─── Topic Locked Banner ──────────────────────────────────────────────── */ 304 + 305 + .topic-locked-banner { 306 + background-color: var(--color-warning); 307 + color: var(--color-text); 308 + padding: var(--space-sm) var(--space-md); 309 + margin-bottom: var(--space-md); 310 + font-weight: var(--font-weight-bold); 311 + border: var(--border-width) solid var(--color-border); 312 + display: flex; 313 + align-items: center; 314 + gap: var(--space-sm); 315 + } 316 + 317 + .topic-locked-banner__badge { 318 + background-color: var(--color-text); 319 + color: var(--color-warning); 320 + padding: 2px var(--space-sm); 321 + font-size: var(--font-size-sm); 322 + text-transform: uppercase; 323 + letter-spacing: 0.05em; 324 + } 325 + ``` 326 + 327 + **Step 2: Verify build passes** 328 + 329 + Run: `PATH=.devenv/profile/bin:$PATH pnpm build` 330 + Expected: Clean build 331 + 332 + **Step 3: Commit** 333 + 334 + ```bash 335 + git add apps/web/public/static/css/theme.css 336 + git commit -m "style(web): add CSS for post cards, breadcrumbs, topic rows, and locked banner (ATB-32)" 337 + ``` 338 + 339 + --- 340 + 341 + ## Task 3: CSS — Enhanced Error, Empty, and Loading States 342 + 343 + **Files:** 344 + - Modify: `apps/web/public/static/css/theme.css` (expand existing state sections at lines 191-218) 345 + 346 + **Step 1: Enhance state component styles** 347 + 348 + Replace the existing Error Display, Empty State, and Loading State sections (lines 191-218) with richer versions: 349 + 350 + ```css 351 + /* ─── Error Display ─────────────────────────────────────────────────────── */ 352 + 353 + .error-display { 354 + background-color: var(--color-surface); 355 + border: var(--border-width) solid var(--color-danger); 356 + border-left-width: calc(var(--border-width) * 3); 357 + padding: var(--space-lg); 358 + color: var(--color-text); 359 + } 360 + 361 + .error-display__message { 362 + font-weight: var(--font-weight-bold); 363 + font-size: var(--font-size-lg); 364 + margin-bottom: var(--space-xs); 365 + } 366 + 367 + .error-display__detail { 368 + color: var(--color-text-muted); 369 + font-size: var(--font-size-sm); 370 + } 371 + 372 + /* ─── Empty State ───────────────────────────────────────────────────────── */ 373 + 374 + .empty-state { 375 + text-align: center; 376 + padding: var(--space-xl) var(--space-md); 377 + color: var(--color-text-muted); 378 + border: var(--border-width) dashed var(--color-border); 379 + margin: var(--space-md) 0; 380 + } 381 + 382 + .empty-state p { 383 + font-size: var(--font-size-lg); 384 + margin-bottom: var(--space-sm); 385 + } 386 + 387 + .empty-state__action { 388 + margin-top: var(--space-md); 389 + } 390 + 391 + /* ─── Loading State (HTMX indicator) ───────────────────────────────────── */ 392 + 393 + .loading-state { 394 + opacity: 0; 395 + transition: opacity 0.2s ease; 396 + text-align: center; 397 + padding: var(--space-md); 398 + color: var(--color-text-muted); 399 + } 400 + 401 + .loading-state.htmx-request { 402 + opacity: 1; 403 + } 404 + 405 + .htmx-indicator { 406 + opacity: 0; 407 + transition: opacity 0.2s ease; 408 + } 409 + 410 + .htmx-request .htmx-indicator, 411 + .htmx-request.htmx-indicator { 412 + opacity: 1; 413 + } 414 + 415 + /* ─── Error Page ────────────────────────────────────────────────────────── */ 416 + 417 + .error-page { 418 + text-align: center; 419 + padding: var(--space-xl) var(--space-md); 420 + } 421 + 422 + .error-page__code { 423 + font-family: var(--font-heading); 424 + font-size: 6rem; 425 + font-weight: var(--font-weight-bold); 426 + line-height: 1; 427 + color: var(--color-primary); 428 + margin-bottom: var(--space-sm); 429 + } 430 + 431 + .error-page__title { 432 + font-size: var(--font-size-xl); 433 + margin-bottom: var(--space-sm); 434 + } 435 + 436 + .error-page__message { 437 + color: var(--color-text-muted); 438 + font-size: var(--font-size-base); 439 + margin-bottom: var(--space-lg); 440 + } 441 + ``` 442 + 443 + **Step 2: Verify build passes** 444 + 445 + Run: `PATH=.devenv/profile/bin:$PATH pnpm build` 446 + Expected: Clean build 447 + 448 + **Step 3: Commit** 449 + 450 + ```bash 451 + git add apps/web/public/static/css/theme.css 452 + git commit -m "style(web): enhance error, empty, and loading state CSS with error page styles (ATB-32)" 453 + ``` 454 + 455 + --- 456 + 457 + ## Task 4: CSS — Responsive Breakpoints and Token Overrides 458 + 459 + **Files:** 460 + - Modify: `apps/web/public/static/css/theme.css` (append media queries at end of file) 461 + - Modify: `apps/web/src/styles/presets/neobrutal-light.ts` (adjust token values for mobile-first defaults) 462 + 463 + **Step 1: Update neobrutal-light.ts to mobile-first token values** 464 + 465 + 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. 466 + 467 + Change these values in `apps/web/src/styles/presets/neobrutal-light.ts`: 468 + 469 + ```typescript 470 + // Change these lines: 471 + "border-width": "2px", // was "3px" — mobile-first, desktop overrides to 3px 472 + "shadow-offset": "2px", // was "4px" — mobile-first, desktop overrides to 4px 473 + "content-width": "100%", // was "960px" — mobile-first, tablet/desktop override 474 + // Component tokens that reference shadow-offset also need mobile values: 475 + "button-shadow": "2px 2px 0 var(--color-shadow)", // was "4px 4px 0" 476 + "card-shadow": "4px 4px 0 var(--color-shadow)", // was "6px 6px 0" 477 + "btn-press-hover": "1px", // was "2px" 478 + "btn-press-active": "2px", // was "4px" 479 + "input-border": "2px solid var(--color-border)", // was "3px solid" 480 + ``` 481 + 482 + **Step 2: Add responsive media queries at the end of theme.css** 483 + 484 + Append to the end of `theme.css`: 485 + 486 + ```css 487 + /* ─── Responsive: Tablet (768px+) ──────────────────────────────────────── */ 488 + 489 + @media (min-width: 768px) { 490 + :root { 491 + --content-width: 720px; 492 + } 493 + 494 + .content-container { 495 + padding: var(--space-xl) var(--space-md); 496 + } 497 + 498 + .board-grid { 499 + display: grid; 500 + grid-template-columns: repeat(2, 1fr); 501 + } 502 + 503 + .topic-row { 504 + flex-direction: row; 505 + } 506 + 507 + .topic-row__title { 508 + white-space: nowrap; 509 + } 510 + 511 + .mobile-nav { 512 + display: none; 513 + } 514 + 515 + .desktop-nav { 516 + display: flex; 517 + } 518 + } 519 + 520 + /* ─── Responsive: Desktop (1024px+) ───────────────────────────────────── */ 521 + 522 + @media (min-width: 1024px) { 523 + :root { 524 + --content-width: 960px; 525 + --border-width: 3px; 526 + --shadow-offset: 4px; 527 + --button-shadow: 4px 4px 0 var(--color-shadow); 528 + --card-shadow: 6px 6px 0 var(--color-shadow); 529 + --btn-press-hover: 2px; 530 + --btn-press-active: 4px; 531 + --input-border: 3px solid var(--color-border); 532 + } 533 + 534 + .board-grid { 535 + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); 536 + } 537 + } 538 + 539 + /* ─── Responsive: Mobile overrides ─────────────────────────────────────── */ 540 + 541 + @media (max-width: 767px) { 542 + .page-header { 543 + flex-direction: column; 544 + align-items: flex-start; 545 + } 546 + 547 + .topic-row { 548 + flex-direction: column; 549 + gap: var(--space-xs); 550 + } 551 + 552 + .topic-row__title { 553 + white-space: normal; 554 + } 555 + 556 + .topic-row__meta { 557 + flex-wrap: wrap; 558 + } 559 + 560 + .post-card__header { 561 + flex-direction: column; 562 + align-items: flex-start; 563 + gap: var(--space-xs); 564 + } 565 + 566 + .post-card__date { 567 + margin-left: 0; 568 + } 569 + 570 + .login-form { 571 + max-width: 100%; 572 + } 573 + 574 + .desktop-nav { 575 + display: none; 576 + } 577 + 578 + .mobile-nav { 579 + display: block; 580 + } 581 + } 582 + ``` 583 + 584 + **Step 3: Run tests** 585 + 586 + Run: `PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web test` 587 + Expected: Tests pass. Some base layout tests may need updating if they check for specific token values that changed. 588 + 589 + **Step 4: Commit** 590 + 591 + ```bash 592 + git add apps/web/public/static/css/theme.css apps/web/src/styles/presets/neobrutal-light.ts 593 + git commit -m "style(web): add mobile-first responsive breakpoints with token overrides (ATB-32)" 594 + ``` 595 + 596 + --- 597 + 598 + ## Task 5: Write tests for BaseLayout accessibility changes 599 + 600 + **Files:** 601 + - Modify: `apps/web/src/layouts/__tests__/base.test.tsx` 602 + 603 + **Step 1: Write failing tests for skip link, main id, and favicon** 604 + 605 + Add these test cases to the existing `base.test.tsx`: 606 + 607 + ```typescript 608 + describe("accessibility", () => { 609 + it("renders skip-to-content link as first child of body", async () => { 610 + const res = await app.request("/"); 611 + const html = await res.text(); 612 + expect(html).toContain('class="skip-link"'); 613 + expect(html).toContain('href="#main-content"'); 614 + expect(html).toContain("Skip to main content"); 615 + }); 616 + 617 + it("renders main element with id for skip link target", async () => { 618 + const res = await app.request("/"); 619 + const html = await res.text(); 620 + expect(html).toContain('id="main-content"'); 621 + }); 622 + 623 + it("renders html element with lang attribute", async () => { 624 + const res = await app.request("/"); 625 + const html = await res.text(); 626 + expect(html).toContain('lang="en"'); 627 + }); 628 + }); 629 + 630 + describe("favicon", () => { 631 + it("includes favicon link in head", async () => { 632 + const res = await app.request("/"); 633 + const html = await res.text(); 634 + expect(html).toContain('rel="icon"'); 635 + expect(html).toContain("favicon.svg"); 636 + }); 637 + }); 638 + ``` 639 + 640 + **Step 2: Run tests to verify they fail** 641 + 642 + Run: `PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web test src/layouts/__tests__/base.test.tsx` 643 + Expected: FAIL — skip link, main id, and favicon not yet in the markup 644 + 645 + --- 646 + 647 + ## Task 6: Implement BaseLayout accessibility (skip link, main id, favicon) 648 + 649 + **Files:** 650 + - Modify: `apps/web/src/layouts/base.tsx` 651 + - Create: `apps/web/public/static/favicon.svg` 652 + - Modify: `apps/web/public/static/css/theme.css` (add skip-link class) 653 + 654 + **Step 1: Add skip link CSS to theme.css** 655 + 656 + Add a new section near the top, after the Base/Typography section: 657 + 658 + ```css 659 + /* ─── Skip Link ────────────────────────────────────────────────────────── */ 660 + 661 + .skip-link { 662 + position: absolute; 663 + left: -9999px; 664 + top: auto; 665 + width: 1px; 666 + height: 1px; 667 + overflow: hidden; 668 + z-index: 1000; 669 + } 670 + 671 + .skip-link:focus { 672 + position: fixed; 673 + top: var(--space-sm); 674 + left: var(--space-sm); 675 + width: auto; 676 + height: auto; 677 + padding: var(--space-sm) var(--space-md); 678 + background-color: var(--color-primary); 679 + color: var(--color-surface); 680 + font-weight: var(--font-weight-bold); 681 + border: var(--border-width) solid var(--color-border); 682 + box-shadow: var(--button-shadow); 683 + text-decoration: none; 684 + z-index: 1000; 685 + } 686 + ``` 687 + 688 + **Step 2: Update BaseLayout to add skip link, main id, and favicon** 689 + 690 + In `apps/web/src/layouts/base.tsx`, make these changes: 691 + 692 + 1. Add favicon `<link>` in `<head>` after the theme.css link: 693 + ```tsx 694 + <link rel="icon" type="image/svg+xml" href="/static/favicon.svg" /> 695 + ``` 696 + 697 + 2. Add skip link as first child of `<body>`: 698 + ```tsx 699 + <body> 700 + <a href="#main-content" class="skip-link">Skip to main content</a> 701 + <header class="site-header"> 702 + ... 703 + ``` 704 + 705 + 3. Add id to `<main>`: 706 + ```tsx 707 + <main id="main-content" class="content-container">{props.children}</main> 708 + ``` 709 + 710 + **Step 3: Create favicon SVG** 711 + 712 + Create `apps/web/public/static/favicon.svg`: 713 + 714 + ```svg 715 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"> 716 + <rect width="32" height="32" fill="#ff5c00" rx="0"/> 717 + <rect x="1" y="1" width="30" height="30" fill="#ff5c00" stroke="#1a1a1a" stroke-width="2" rx="0"/> 718 + <text x="16" y="23" font-family="system-ui, sans-serif" font-size="18" font-weight="700" fill="#ffffff" text-anchor="middle">BB</text> 719 + </svg> 720 + ``` 721 + 722 + **Step 4: Run tests to verify they pass** 723 + 724 + Run: `PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web test src/layouts/__tests__/base.test.tsx` 725 + Expected: PASS 726 + 727 + **Step 5: Commit** 728 + 729 + ```bash 730 + git add apps/web/src/layouts/base.tsx apps/web/public/static/favicon.svg apps/web/public/static/css/theme.css 731 + git commit -m "feat(web): add skip-to-content link, main landmark id, and SVG favicon (ATB-32)" 732 + ``` 733 + 734 + --- 735 + 736 + ## Task 7: Write tests for hamburger menu 737 + 738 + **Files:** 739 + - Modify: `apps/web/src/layouts/__tests__/base.test.tsx` 740 + 741 + **Step 1: Write failing tests for mobile hamburger navigation** 742 + 743 + Add to `base.test.tsx`: 744 + 745 + ```typescript 746 + describe("mobile navigation", () => { 747 + it("renders details/summary hamburger menu for mobile", async () => { 748 + const res = await app.request("/"); 749 + const html = await res.text(); 750 + expect(html).toContain("mobile-nav"); 751 + expect(html).toContain("mobile-nav__toggle"); 752 + }); 753 + 754 + it("renders desktop nav separately from mobile nav", async () => { 755 + const res = await app.request("/"); 756 + const html = await res.text(); 757 + expect(html).toContain("desktop-nav"); 758 + }); 759 + 760 + it("hamburger has aria-label for accessibility", async () => { 761 + const res = await app.request("/"); 762 + const html = await res.text(); 763 + expect(html).toContain('aria-label="Menu"'); 764 + }); 765 + 766 + it("mobile nav contains auth links when logged in", async () => { 767 + const auth: WebSession = { 768 + authenticated: true, 769 + did: "did:plc:abc123", 770 + handle: "alice.bsky.social", 771 + }; 772 + const authApp = new Hono().get("/", (c) => 773 + c.html(<BaseLayout auth={auth}>content</BaseLayout>) 774 + ); 775 + const res = await authApp.request("/"); 776 + const html = await res.text(); 777 + // Both desktop and mobile nav should contain auth state 778 + const logoutMatches = html.match(/Log out/g); 779 + expect(logoutMatches?.length).toBeGreaterThanOrEqual(2); 780 + }); 781 + }); 782 + ``` 783 + 784 + **Step 2: Run tests to verify they fail** 785 + 786 + Run: `PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web test src/layouts/__tests__/base.test.tsx` 787 + Expected: FAIL — hamburger not yet implemented 788 + 789 + --- 790 + 791 + ## Task 8: Implement hamburger menu in BaseLayout 792 + 793 + **Files:** 794 + - Modify: `apps/web/src/layouts/base.tsx` 795 + - Modify: `apps/web/public/static/css/theme.css` (add mobile-nav CSS) 796 + 797 + **Step 1: Add mobile nav CSS** 798 + 799 + Add to theme.css, in a new section: 800 + 801 + ```css 802 + /* ─── Mobile Navigation ────────────────────────────────────────────────── */ 803 + 804 + .mobile-nav { 805 + display: block; 806 + } 807 + 808 + .mobile-nav__toggle { 809 + cursor: pointer; 810 + font-size: var(--font-size-lg); 811 + list-style: none; 812 + padding: var(--space-xs) var(--space-sm); 813 + min-width: 44px; 814 + min-height: 44px; 815 + display: flex; 816 + align-items: center; 817 + justify-content: center; 818 + } 819 + 820 + .mobile-nav__toggle::-webkit-details-marker { 821 + display: none; 822 + } 823 + 824 + .mobile-nav__menu { 825 + position: absolute; 826 + right: var(--space-md); 827 + top: var(--nav-height); 828 + background-color: var(--color-surface); 829 + border: var(--border-width) solid var(--color-border); 830 + box-shadow: var(--card-shadow); 831 + padding: var(--space-md); 832 + min-width: 200px; 833 + z-index: 100; 834 + display: flex; 835 + flex-direction: column; 836 + gap: var(--space-sm); 837 + } 838 + 839 + .desktop-nav { 840 + display: none; 841 + align-items: center; 842 + gap: var(--space-md); 843 + } 844 + 845 + /* Hide/show is handled by the responsive media queries in Task 4 */ 846 + ``` 847 + 848 + **Step 2: Refactor BaseLayout header to include both desktop and mobile nav** 849 + 850 + Rewrite the header section in `apps/web/src/layouts/base.tsx`: 851 + 852 + ```tsx 853 + // Extract nav content into a helper to avoid duplication 854 + const NavContent: FC<{ auth?: WebSession }> = ({ auth }) => ( 855 + <> 856 + {auth?.authenticated ? ( 857 + <> 858 + <span class="site-header__handle">{auth.handle}</span> 859 + <form 860 + action="/logout" 861 + method="post" 862 + class="site-header__logout-form" 863 + > 864 + <button type="submit" class="site-header__logout-btn"> 865 + Log out 866 + </button> 867 + </form> 868 + </> 869 + ) : ( 870 + <a href="/login" class="site-header__login-link"> 871 + Log in 872 + </a> 873 + )} 874 + </> 875 + ); 876 + ``` 877 + 878 + Then in the header: 879 + 880 + ```tsx 881 + <header class="site-header"> 882 + <div class="site-header__inner"> 883 + <a href="/" class="site-header__title"> 884 + atBB Forum 885 + </a> 886 + <nav class="desktop-nav" aria-label="Main navigation"> 887 + <NavContent auth={auth} /> 888 + </nav> 889 + <details class="mobile-nav"> 890 + <summary class="mobile-nav__toggle" aria-label="Menu"> 891 + &#9776; 892 + </summary> 893 + <nav class="mobile-nav__menu" aria-label="Main navigation"> 894 + <NavContent auth={auth} /> 895 + </nav> 896 + </details> 897 + </div> 898 + </header> 899 + ``` 900 + 901 + **Step 3: Run tests to verify they pass** 902 + 903 + Run: `PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web test src/layouts/__tests__/base.test.tsx` 904 + Expected: PASS 905 + 906 + **Step 4: Run all web tests** 907 + 908 + Run: `PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web test` 909 + 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. 910 + 911 + **Step 5: Commit** 912 + 913 + ```bash 914 + git add apps/web/src/layouts/base.tsx apps/web/public/static/css/theme.css 915 + git commit -m "feat(web): add CSS-only hamburger menu with details/summary for mobile nav (ATB-32)" 916 + ``` 917 + 918 + --- 919 + 920 + ## Task 9: Accessibility — Focus Styles 921 + 922 + **Files:** 923 + - Modify: `apps/web/public/static/css/theme.css` 924 + 925 + **Step 1: Add global focus-visible styles** 926 + 927 + Add near the top of theme.css, after the skip link section: 928 + 929 + ```css 930 + /* ─── Focus Styles ─────────────────────────────────────────────────────── */ 931 + 932 + :focus-visible { 933 + outline: 3px solid var(--color-primary); 934 + outline-offset: 2px; 935 + } 936 + 937 + /* Remove default focus ring since we use focus-visible */ 938 + :focus:not(:focus-visible) { 939 + outline: none; 940 + } 941 + ``` 942 + 943 + **Step 2: Commit** 944 + 945 + ```bash 946 + git add apps/web/public/static/css/theme.css 947 + git commit -m "style(web): add global focus-visible styles for keyboard navigation (ATB-32)" 948 + ``` 949 + 950 + --- 951 + 952 + ## Task 10: Accessibility — Form ARIA Attributes 953 + 954 + **Files:** 955 + - Modify: `apps/web/src/routes/topics.tsx` (reply form, mod dialog) 956 + - Modify: `apps/web/src/routes/new-topic.tsx` (compose form) 957 + - Modify: `apps/web/src/routes/login.tsx` (login form) 958 + 959 + **Step 1: Update login form accessibility** 960 + 961 + In `apps/web/src/routes/login.tsx`, add ARIA attributes: 962 + 963 + - Add `aria-required="true"` to the handle input 964 + - Add `aria-describedby="login-hint"` to the handle input 965 + - Add `id="login-hint"` to the hint paragraph 966 + - The error div already has `role="alert"` — good 967 + 968 + ```tsx 969 + <input 970 + type="text" 971 + id="login-handle" 972 + name="handle" 973 + placeholder="alice.bsky.social" 974 + class="login-form__input" 975 + required 976 + aria-required="true" 977 + aria-describedby="login-hint" 978 + autocomplete="username" 979 + autofocus 980 + /> 981 + <p id="login-hint" class="login-form__hint"> 982 + ``` 983 + 984 + **Step 2: Update new topic form accessibility** 985 + 986 + In `apps/web/src/routes/new-topic.tsx`: 987 + 988 + - Add `aria-required="true"` to the textarea 989 + - Add `aria-describedby="char-count"` to the textarea 990 + - Add `aria-live="polite"` to the char count div 991 + - Add `role="alert"` to the form error div 992 + 993 + ```tsx 994 + <textarea 995 + id="compose-text" 996 + name="text" 997 + rows={8} 998 + placeholder="What's on your mind?" 999 + oninput="updateCharCount(this)" 1000 + aria-required="true" 1001 + aria-describedby="char-count" 1002 + /> 1003 + <div id="char-count" class="char-count" aria-live="polite">300 left</div> 1004 + ``` 1005 + 1006 + ```tsx 1007 + <div id="form-error" role="alert" /> 1008 + ``` 1009 + 1010 + **Step 3: Update reply form accessibility** 1011 + 1012 + In `apps/web/src/routes/topics.tsx`: 1013 + 1014 + - Add `aria-required="true"` to the reply textarea 1015 + - Add `aria-describedby="reply-char-count"` to the reply textarea 1016 + - Add `aria-live="polite"` to the char count div 1017 + - Add `role="alert"` to the form error div 1018 + - Add `aria-labelledby="mod-dialog-title"` to the `<dialog>` 1019 + - Add `autofocus` to the mod reason textarea 1020 + - Add `aria-live="polite"` to the reply list container 1021 + 1022 + ```tsx 1023 + <textarea 1024 + id="reply-text" 1025 + name="text" 1026 + rows={5} 1027 + placeholder="Write a reply…" 1028 + oninput="updateReplyCharCount(this)" 1029 + aria-required="true" 1030 + aria-describedby="reply-char-count" 1031 + /> 1032 + <div id="reply-char-count" class="char-count" aria-live="polite">300 left</div> 1033 + ``` 1034 + 1035 + ```tsx 1036 + <div id="reply-form-error" role="alert" /> 1037 + ``` 1038 + 1039 + ```tsx 1040 + <dialog id="mod-dialog" class="mod-dialog" aria-labelledby="mod-dialog-title"> 1041 + ``` 1042 + 1043 + ```tsx 1044 + <textarea 1045 + id="mod-reason" 1046 + name="reason" 1047 + rows={3} 1048 + placeholder="Reason for this action…" 1049 + autofocus 1050 + /> 1051 + ``` 1052 + 1053 + ```tsx 1054 + <div id="reply-list" aria-live="polite"> 1055 + ``` 1056 + 1057 + **Step 4: Run tests** 1058 + 1059 + Run: `PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web test` 1060 + Expected: PASS — ARIA attributes are additive, existing tests shouldn't break 1061 + 1062 + **Step 5: Commit** 1063 + 1064 + ```bash 1065 + git add apps/web/src/routes/topics.tsx apps/web/src/routes/new-topic.tsx apps/web/src/routes/login.tsx 1066 + git commit -m "feat(web): add ARIA attributes to forms, dialog, and live regions (ATB-32)" 1067 + ``` 1068 + 1069 + --- 1070 + 1071 + ## Task 11: Accessibility — Semantic HTML Fixes 1072 + 1073 + **Files:** 1074 + - Modify: `apps/web/src/routes/boards.tsx` (breadcrumb as nav/ol) 1075 + - Modify: `apps/web/src/routes/topics.tsx` (breadcrumb as nav/ol, post cards as article) 1076 + - Modify: `apps/web/src/routes/new-topic.tsx` (breadcrumb as nav/ol) 1077 + 1078 + **Step 1: Convert breadcrumbs to semantic markup** 1079 + 1080 + In all three route files, change the breadcrumb from: 1081 + 1082 + ```tsx 1083 + <nav class="breadcrumb"> 1084 + <a href="/">Home</a> 1085 + {" / "} 1086 + <span>Board Name</span> 1087 + </nav> 1088 + ``` 1089 + 1090 + to: 1091 + 1092 + ```tsx 1093 + <nav class="breadcrumb" aria-label="Breadcrumb"> 1094 + <ol> 1095 + <li><a href="/">Home</a></li> 1096 + {categoryName && <li><a href="/">{categoryName}</a></li>} 1097 + <li><span>{board.name}</span></li> 1098 + </ol> 1099 + </nav> 1100 + ``` 1101 + 1102 + **Step 2: Update breadcrumb CSS** 1103 + 1104 + Add to the breadcrumb section in theme.css: 1105 + 1106 + ```css 1107 + .breadcrumb ol { 1108 + display: flex; 1109 + flex-wrap: wrap; 1110 + gap: 0; 1111 + list-style: none; 1112 + padding: 0; 1113 + margin: 0; 1114 + } 1115 + 1116 + .breadcrumb li { 1117 + display: flex; 1118 + align-items: center; 1119 + } 1120 + 1121 + .breadcrumb li + li::before { 1122 + content: "/"; 1123 + margin: 0 var(--space-sm); 1124 + color: var(--color-text-muted); 1125 + } 1126 + ``` 1127 + 1128 + **Step 3: Change PostCard from div to article** 1129 + 1130 + In `apps/web/src/routes/topics.tsx`, in the `PostCard` component, change: 1131 + 1132 + ```tsx 1133 + <div class={cardClass} id={`post-${postNumber}`}> 1134 + ``` 1135 + 1136 + to: 1137 + 1138 + ```tsx 1139 + <article class={cardClass} id={`post-${postNumber}`}> 1140 + ``` 1141 + 1142 + And the closing `</div>` to `</article>`. 1143 + 1144 + **Step 4: Run tests** 1145 + 1146 + Run: `PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web test` 1147 + Expected: PASS — update any tests that check for specific HTML structure of breadcrumbs 1148 + 1149 + **Step 5: Commit** 1150 + 1151 + ```bash 1152 + 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 1153 + git commit -m "feat(web): convert breadcrumbs to nav/ol, post cards to article elements (ATB-32)" 1154 + ``` 1155 + 1156 + --- 1157 + 1158 + ## Task 12: Write tests for 404 page 1159 + 1160 + **Files:** 1161 + - Create: `apps/web/src/routes/__tests__/not-found.test.tsx` 1162 + 1163 + **Step 1: Write failing tests for 404 route** 1164 + 1165 + ```typescript 1166 + import { describe, it, expect } from "vitest"; 1167 + import { Hono } from "hono"; 1168 + import { BaseLayout } from "../../layouts/base.js"; 1169 + import { createNotFoundRoute } from "../not-found.js"; 1170 + 1171 + describe("404 Not Found page", () => { 1172 + const app = new Hono() 1173 + .route("/", createNotFoundRoute()); 1174 + 1175 + it("returns 404 status", async () => { 1176 + const res = await app.request("/some-random-page-that-does-not-exist"); 1177 + expect(res.status).toBe(404); 1178 + }); 1179 + 1180 + it("renders error page with 404 code", async () => { 1181 + const res = await app.request("/nonexistent"); 1182 + const html = await res.text(); 1183 + expect(html).toContain("404"); 1184 + expect(html).toContain("error-page"); 1185 + }); 1186 + 1187 + it("renders a link back to home", async () => { 1188 + const res = await app.request("/nonexistent"); 1189 + const html = await res.text(); 1190 + expect(html).toContain('href="/"'); 1191 + }); 1192 + 1193 + it("renders user-friendly message", async () => { 1194 + const res = await app.request("/nonexistent"); 1195 + const html = await res.text(); 1196 + expect(html).toContain("doesn't exist"); 1197 + }); 1198 + }); 1199 + ``` 1200 + 1201 + **Step 2: Run tests to verify they fail** 1202 + 1203 + Run: `PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web test src/routes/__tests__/not-found.test.tsx` 1204 + Expected: FAIL — module not found 1205 + 1206 + --- 1207 + 1208 + ## Task 13: Implement 404 page and register catch-all route 1209 + 1210 + **Files:** 1211 + - Create: `apps/web/src/routes/not-found.tsx` 1212 + - Modify: `apps/web/src/routes/index.ts` (register catch-all) 1213 + - Modify: `apps/web/src/index.ts` (improve global error handler) 1214 + 1215 + **Step 1: Create the 404 route** 1216 + 1217 + Create `apps/web/src/routes/not-found.tsx`: 1218 + 1219 + ```tsx 1220 + import { Hono } from "hono"; 1221 + import { BaseLayout } from "../layouts/base.js"; 1222 + import { Button } from "../components/index.js"; 1223 + import { getSession } from "../lib/session.js"; 1224 + 1225 + export function createNotFoundRoute(appviewUrl?: string) { 1226 + return new Hono().all("*", async (c) => { 1227 + const auth = appviewUrl 1228 + ? await getSession(appviewUrl, c.req.header("cookie")) 1229 + : { authenticated: false as const }; 1230 + return c.html( 1231 + <BaseLayout title="Page Not Found — atBB Forum" auth={auth}> 1232 + <div class="error-page"> 1233 + <div class="error-page__code">404</div> 1234 + <h1 class="error-page__title">Page Not Found</h1> 1235 + <p class="error-page__message"> 1236 + This page doesn't exist or has been removed. 1237 + </p> 1238 + <a href="/" class="btn btn-primary">Back to Home</a> 1239 + </div> 1240 + </BaseLayout>, 1241 + 404 1242 + ); 1243 + }); 1244 + } 1245 + ``` 1246 + 1247 + **Step 2: Register as catch-all in routes/index.ts** 1248 + 1249 + Add at the end of `apps/web/src/routes/index.ts`: 1250 + 1251 + ```typescript 1252 + import { createNotFoundRoute } from "./not-found.js"; 1253 + 1254 + // ... existing routes ... 1255 + .route("/", createModActionRoute(config.appviewUrl)) 1256 + .route("/", createNotFoundRoute(config.appviewUrl)); 1257 + ``` 1258 + 1259 + **Step 3: Improve global error handler in index.ts** 1260 + 1261 + Update `apps/web/src/index.ts` error handler to use the `BaseLayout` and `error-page` pattern: 1262 + 1263 + ```typescript 1264 + app.onError((err, c) => { 1265 + console.error("Unhandled error in web route", { 1266 + path: c.req.path, 1267 + method: c.req.method, 1268 + error: err.message, 1269 + stack: err.stack, 1270 + }); 1271 + const detail = process.env.NODE_ENV !== "production" ? err.message : undefined; 1272 + return c.html( 1273 + `<!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>`, 1274 + 500 1275 + ); 1276 + }); 1277 + ``` 1278 + 1279 + **Step 4: Run tests** 1280 + 1281 + Run: `PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web test` 1282 + Expected: PASS 1283 + 1284 + **Step 5: Commit** 1285 + 1286 + ```bash 1287 + 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 1288 + git commit -m "feat(web): add 404 page with neobrutal styling and catch-all route (ATB-32)" 1289 + ``` 1290 + 1291 + --- 1292 + 1293 + ## Task 14: Visual Polish — Hover/Active Transitions 1294 + 1295 + **Files:** 1296 + - Modify: `apps/web/public/static/css/theme.css` 1297 + 1298 + **Step 1: Add transition and hover effects to interactive elements** 1299 + 1300 + 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: 1301 + 1302 + ```css 1303 + /* Add transition to .card (after existing .card block): */ 1304 + .card { 1305 + transition: transform 0.15s ease, box-shadow 0.15s ease; 1306 + } 1307 + 1308 + /* Update .board-card:hover .card to use token-based values */ 1309 + .board-card:hover .card { 1310 + transform: translate(-2px, -2px); 1311 + box-shadow: calc(var(--shadow-offset) + 2px) calc(var(--shadow-offset) + 2px) 0 var(--color-shadow); 1312 + } 1313 + 1314 + /* .topic-row already has transition from Task 2 — verify it works */ 1315 + 1316 + /* Post cards need hover state too */ 1317 + .post-card { 1318 + transition: transform 0.15s ease, box-shadow 0.15s ease; 1319 + } 1320 + 1321 + /* .login-form__submit already has transition from Task 1 */ 1322 + 1323 + /* Add hover state for site-header__title */ 1324 + .site-header__title { 1325 + transition: color 0.15s ease; 1326 + } 1327 + 1328 + /* Links should have smooth color transition */ 1329 + a { 1330 + transition: color 0.15s ease; 1331 + } 1332 + ``` 1333 + 1334 + **Step 2: Commit** 1335 + 1336 + ```bash 1337 + git add apps/web/public/static/css/theme.css 1338 + git commit -m "style(web): add smooth transitions to cards, links, and interactive elements (ATB-32)" 1339 + ``` 1340 + 1341 + --- 1342 + 1343 + ## Task 15: Run Full Test Suite and Lighthouse Audit 1344 + 1345 + **Files:** None (verification only) 1346 + 1347 + **Step 1: Run complete build** 1348 + 1349 + Run: `PATH=.devenv/profile/bin:$PATH pnpm build` 1350 + Expected: Clean build, no errors 1351 + 1352 + **Step 2: Run all tests** 1353 + 1354 + Run: `PATH=.devenv/profile/bin:$PATH pnpm test` 1355 + Expected: All tests pass across all packages 1356 + 1357 + **Step 3: Manual Lighthouse audit** 1358 + 1359 + Start the dev server: 1360 + Run: `PATH=.devenv/profile/bin:$PATH pnpm dev` 1361 + 1362 + Then in Chrome: 1363 + 1. Open `http://localhost:3001` 1364 + 2. Open DevTools → Lighthouse 1365 + 3. Run accessibility audit 1366 + 4. Target: 90+ accessibility score 1367 + 5. Fix any issues flagged 1368 + 1369 + **Step 4: Manual keyboard navigation test** 1370 + 1371 + Test these flows using only keyboard (Tab, Enter, Escape): 1372 + 1. Tab through homepage — skip link visible on first Tab 1373 + 2. Tab to a board card → Enter → navigate to board view 1374 + 3. Tab to "New Topic" button → Enter → navigate to compose form 1375 + 4. Tab through form fields, submit 1376 + 5. Tab through login form 1377 + 6. Escape closes mod dialog (if testing with mod user) 1378 + 1379 + **Step 5: Update Linear issue** 1380 + 1381 + After all tests pass and audit is clean, update ATB-32 status. 1382 + 1383 + --- 1384 + 1385 + ## Task 16: Update Documentation 1386 + 1387 + **Files:** 1388 + - Modify: `docs/atproto-forum-plan.md` (mark Phase 4 responsive/a11y complete) 1389 + - Modify: `docs/plans/2026-02-20-responsive-a11y-polish-design.md` (mark implemented) 1390 + 1391 + **Step 1: Update project plan** 1392 + 1393 + Mark the responsive design and accessibility items as complete in `docs/atproto-forum-plan.md`. 1394 + 1395 + **Step 2: Update design doc status** 1396 + 1397 + Change the status line in the design doc from "Design approved, pending implementation" to "Implemented". 1398 + 1399 + **Step 3: Commit** 1400 + 1401 + ```bash 1402 + git add docs/atproto-forum-plan.md docs/plans/2026-02-20-responsive-a11y-polish-design.md 1403 + git commit -m "docs: mark ATB-32 responsive/a11y/polish complete in project plan" 1404 + ``` 1405 + 1406 + --- 1407 + 1408 + ## Summary 1409 + 1410 + | Task | Description | Files | Type | 1411 + |------|-------------|-------|------| 1412 + | 1 | CSS: Form styles | theme.css | CSS | 1413 + | 2 | CSS: Post cards, breadcrumbs, topic rows | theme.css | CSS | 1414 + | 3 | CSS: Enhanced error/empty/loading states | theme.css | CSS | 1415 + | 4 | CSS: Responsive breakpoints + token overrides | theme.css, neobrutal-light.ts | CSS + TS | 1416 + | 5 | Tests: BaseLayout a11y (skip link, favicon) | base.test.tsx | Test | 1417 + | 6 | Impl: Skip link, main id, favicon | base.tsx, favicon.svg, theme.css | TSX + CSS | 1418 + | 7 | Tests: Hamburger menu | base.test.tsx | Test | 1419 + | 8 | Impl: Hamburger menu | base.tsx, theme.css | TSX + CSS | 1420 + | 9 | CSS: Focus-visible styles | theme.css | CSS | 1421 + | 10 | A11y: Form ARIA attributes | topics.tsx, new-topic.tsx, login.tsx | TSX | 1422 + | 11 | A11y: Semantic HTML (breadcrumbs, article) | boards.tsx, topics.tsx, new-topic.tsx, theme.css | TSX + CSS | 1423 + | 12 | Tests: 404 page | not-found.test.tsx | Test | 1424 + | 13 | Impl: 404 page + catch-all route | not-found.tsx, index.ts | TSX | 1425 + | 14 | CSS: Hover/active transitions | theme.css | CSS | 1426 + | 15 | Verification: Build, tests, Lighthouse audit | — | QA | 1427 + | 16 | Docs: Update plan and design doc | plan docs | Docs |