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

docs: ATB-52 implementation plan

+579
+579
docs/plans/2026-03-02-atb-52-css-token-extraction.md
··· 1 + # ATB-52: CSS Token Extraction Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Extract all remaining hardcoded CSS values in `theme.css` into the design token system and ship `neobrutal-light.json` + `neobrutal-dark.json` preset files. 6 + 7 + **Architecture:** `theme.css` already uses `var(--token)` for ~95% of values. Two sections (moderation UI, structure UI) were added separately and never aligned. The preset is currently a TypeScript named export; we convert it to JSON (default import) which is already supported by `resolveJsonModule: true` in `tsconfig.base.json`. `tokensToCss()` and the `BaseLayout` injection are unchanged. 8 + 9 + **Tech Stack:** CSS custom properties, TypeScript JSON imports, Vitest 10 + 11 + **Design doc:** `docs/plans/2026-03-02-css-token-extraction-design.md` 12 + 13 + --- 14 + 15 + ## Running tests 16 + 17 + From the repo root (requires devenv PATH): 18 + 19 + ```bash 20 + PATH=/path/to/repo/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/web test 21 + ``` 22 + 23 + Or from inside `apps/web/`: 24 + 25 + ```bash 26 + PATH=/path/to/repo/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm exec vitest run 27 + ``` 28 + 29 + If you're in a worktree, the `.devenv` symlink is absent — use the absolute path to the main repo's `.devenv/profile/bin`. 30 + 31 + --- 32 + 33 + ## Task 1: Write failing preset completeness tests 34 + 35 + **Files:** 36 + - Create: `apps/web/src/styles/presets/__tests__/presets.test.ts` 37 + 38 + **Step 1: Create the test file** 39 + 40 + ```typescript 41 + // apps/web/src/styles/presets/__tests__/presets.test.ts 42 + import { describe, it, expect } from "vitest"; 43 + import neobrutalLight from "../neobrutal-light.json"; 44 + import neobrutalDark from "../neobrutal-dark.json"; 45 + import { tokensToCss } from "../../../lib/theme.js"; 46 + 47 + const REQUIRED_TOKENS = [ 48 + // Colors 49 + "color-bg", "color-surface", "color-text", "color-text-muted", 50 + "color-primary", "color-primary-hover", "color-secondary", 51 + "color-border", "color-shadow", "color-success", "color-warning", 52 + "color-danger", "color-code-bg", "color-code-text", 53 + // Typography 54 + "font-body", "font-heading", "font-mono", 55 + "font-size-base", "font-size-sm", "font-size-xs", 56 + "font-size-lg", "font-size-xl", "font-size-2xl", 57 + "font-weight-normal", "font-weight-bold", 58 + "line-height-body", "line-height-heading", 59 + // Spacing & layout 60 + "space-xs", "space-sm", "space-md", "space-lg", "space-xl", 61 + "radius", "border-width", "shadow-offset", "content-width", 62 + // Components 63 + "button-radius", "button-shadow", "card-radius", "card-shadow", 64 + "btn-press-hover", "btn-press-active", 65 + "input-radius", "input-border", "nav-height", 66 + ]; 67 + 68 + describe("neobrutal-light preset", () => { 69 + it("contains all required tokens including font-size-xs", () => { 70 + for (const token of REQUIRED_TOKENS) { 71 + expect(neobrutalLight, `missing token: ${token}`).toHaveProperty(token); 72 + } 73 + }); 74 + 75 + it("produces valid CSS via tokensToCss", () => { 76 + const css = tokensToCss(neobrutalLight as Record<string, string>); 77 + expect(css).toContain("--color-bg:"); 78 + expect(css).toContain("--font-size-xs:"); 79 + expect(css).toContain("--nav-height:"); 80 + }); 81 + }); 82 + 83 + describe("neobrutal-dark preset", () => { 84 + it("contains all required tokens including font-size-xs", () => { 85 + for (const token of REQUIRED_TOKENS) { 86 + expect(neobrutalDark, `missing token: ${token}`).toHaveProperty(token); 87 + } 88 + }); 89 + 90 + it("has a darker background than the light preset", () => { 91 + expect((neobrutalDark as Record<string, string>)["color-bg"]).not.toBe( 92 + (neobrutalLight as Record<string, string>)["color-bg"] 93 + ); 94 + }); 95 + 96 + it("produces valid CSS via tokensToCss", () => { 97 + const css = tokensToCss(neobrutalDark as Record<string, string>); 98 + expect(css).toContain("--color-bg:"); 99 + expect(css).toContain("--font-size-xs:"); 100 + }); 101 + }); 102 + ``` 103 + 104 + **Step 2: Run the test to verify it fails** 105 + 106 + Expected: compile error — `neobrutal-light.json` and `neobrutal-dark.json` don't exist yet. 107 + 108 + ```bash 109 + pnpm exec vitest run src/styles/presets/__tests__/presets.test.ts 110 + ``` 111 + 112 + --- 113 + 114 + ## Task 2: Create `neobrutal-light.json` 115 + 116 + **Files:** 117 + - Create: `apps/web/src/styles/presets/neobrutal-light.json` 118 + 119 + **Step 1: Create the file** 120 + 121 + ```json 122 + { 123 + "color-bg": "#f5f0e8", 124 + "color-surface": "#ffffff", 125 + "color-text": "#1a1a1a", 126 + "color-text-muted": "#555555", 127 + "color-primary": "#ff5c00", 128 + "color-primary-hover": "#e04f00", 129 + "color-secondary": "#3a86ff", 130 + "color-border": "#1a1a1a", 131 + "color-shadow": "#1a1a1a", 132 + "color-success": "#2ec44a", 133 + "color-warning": "#ffbe0b", 134 + "color-danger": "#ff006e", 135 + "color-code-bg": "#1a1a1a", 136 + "color-code-text": "#f5f0e8", 137 + "font-body": "'Space Grotesk', system-ui, sans-serif", 138 + "font-heading": "'Space Grotesk', system-ui, sans-serif", 139 + "font-mono": "'JetBrains Mono', ui-monospace, monospace", 140 + "font-size-base": "16px", 141 + "font-size-sm": "14px", 142 + "font-size-xs": "12px", 143 + "font-size-lg": "20px", 144 + "font-size-xl": "28px", 145 + "font-size-2xl": "36px", 146 + "font-weight-normal": "400", 147 + "font-weight-bold": "700", 148 + "line-height-body": "1.6", 149 + "line-height-heading": "1.2", 150 + "space-xs": "4px", 151 + "space-sm": "8px", 152 + "space-md": "16px", 153 + "space-lg": "24px", 154 + "space-xl": "40px", 155 + "radius": "0px", 156 + "border-width": "2px", 157 + "shadow-offset": "2px", 158 + "content-width": "100%", 159 + "button-radius": "0px", 160 + "button-shadow": "2px 2px 0 var(--color-shadow)", 161 + "card-radius": "0px", 162 + "card-shadow": "4px 4px 0 var(--color-shadow)", 163 + "btn-press-hover": "1px", 164 + "btn-press-active": "2px", 165 + "input-radius": "0px", 166 + "input-border": "2px solid var(--color-border)", 167 + "nav-height": "64px" 168 + } 169 + ``` 170 + 171 + --- 172 + 173 + ## Task 3: Create `neobrutal-dark.json` 174 + 175 + **Files:** 176 + - Create: `apps/web/src/styles/presets/neobrutal-dark.json` 177 + 178 + **Step 1: Create the file** 179 + 180 + Same structure as light; only color tokens differ: 181 + 182 + ```json 183 + { 184 + "color-bg": "#1a1a1a", 185 + "color-surface": "#2d2d2d", 186 + "color-text": "#f5f0e8", 187 + "color-text-muted": "#a0a0a0", 188 + "color-primary": "#ff5c00", 189 + "color-primary-hover": "#ff7a2a", 190 + "color-secondary": "#3a86ff", 191 + "color-border": "#f5f0e8", 192 + "color-shadow": "#000000", 193 + "color-success": "#2ec44a", 194 + "color-warning": "#ffbe0b", 195 + "color-danger": "#ff006e", 196 + "color-code-bg": "#111111", 197 + "color-code-text": "#f5f0e8", 198 + "font-body": "'Space Grotesk', system-ui, sans-serif", 199 + "font-heading": "'Space Grotesk', system-ui, sans-serif", 200 + "font-mono": "'JetBrains Mono', ui-monospace, monospace", 201 + "font-size-base": "16px", 202 + "font-size-sm": "14px", 203 + "font-size-xs": "12px", 204 + "font-size-lg": "20px", 205 + "font-size-xl": "28px", 206 + "font-size-2xl": "36px", 207 + "font-weight-normal": "400", 208 + "font-weight-bold": "700", 209 + "line-height-body": "1.6", 210 + "line-height-heading": "1.2", 211 + "space-xs": "4px", 212 + "space-sm": "8px", 213 + "space-md": "16px", 214 + "space-lg": "24px", 215 + "space-xl": "40px", 216 + "radius": "0px", 217 + "border-width": "2px", 218 + "shadow-offset": "2px", 219 + "content-width": "100%", 220 + "button-radius": "0px", 221 + "button-shadow": "2px 2px 0 var(--color-shadow)", 222 + "card-radius": "0px", 223 + "card-shadow": "4px 4px 0 var(--color-shadow)", 224 + "btn-press-hover": "1px", 225 + "btn-press-active": "2px", 226 + "input-radius": "0px", 227 + "input-border": "2px solid var(--color-border)", 228 + "nav-height": "64px" 229 + } 230 + ``` 231 + 232 + **Step 2: Run the preset tests — expect them to pass now** 233 + 234 + ```bash 235 + pnpm exec vitest run src/styles/presets/__tests__/presets.test.ts 236 + ``` 237 + 238 + Expected: all 6 tests PASS. 239 + 240 + --- 241 + 242 + ## Task 4: Update `base.tsx` to import from JSON 243 + 244 + **Files:** 245 + - Modify: `apps/web/src/layouts/base.tsx:1-6` 246 + 247 + **Step 1: Replace the import and usage** 248 + 249 + Old lines 1–6: 250 + ```typescript 251 + import type { FC, PropsWithChildren } from "hono/jsx"; 252 + import { tokensToCss } from "../lib/theme.js"; 253 + import { neobrutalLight } from "../styles/presets/neobrutal-light.js"; 254 + import type { WebSession } from "../lib/session.js"; 255 + 256 + const ROOT_CSS = `:root { ${tokensToCss(neobrutalLight)} }`; 257 + ``` 258 + 259 + New lines 1–6: 260 + ```typescript 261 + import type { FC, PropsWithChildren } from "hono/jsx"; 262 + import { tokensToCss } from "../lib/theme.js"; 263 + import neobrutalLight from "../styles/presets/neobrutal-light.json"; 264 + import type { WebSession } from "../lib/session.js"; 265 + 266 + const ROOT_CSS = `:root { ${tokensToCss(neobrutalLight as Record<string, string>)} }`; 267 + ``` 268 + 269 + Note: TypeScript infers JSON imports as their literal type (e.g., `{ "color-bg": string; ... }`), which is not directly assignable to `Record<string, string>` without a cast. The `as Record<string, string>` cast is safe here — all values in the JSON are strings. 270 + 271 + **Step 2: Run the base layout tests** 272 + 273 + ```bash 274 + pnpm exec vitest run src/layouts/__tests__/base.test.tsx 275 + ``` 276 + 277 + Expected: all tests PASS (especially "injects neobrutal tokens as :root CSS custom properties" which checks `--color-bg:` and `--color-primary:` appear in the HTML). 278 + 279 + --- 280 + 281 + ## Task 5: Delete `neobrutal-light.ts` 282 + 283 + **Files:** 284 + - Delete: `apps/web/src/styles/presets/neobrutal-light.ts` 285 + 286 + **Step 1: Delete the file** 287 + 288 + ```bash 289 + rm apps/web/src/styles/presets/neobrutal-light.ts 290 + ``` 291 + 292 + **Step 2: Run the full test suite to verify nothing else imported it** 293 + 294 + ```bash 295 + pnpm exec vitest run 296 + ``` 297 + 298 + Expected: all tests PASS. If any test fails with "cannot find module neobrutal-light.js", search for other imports of this file and update them. 299 + 300 + **Step 3: Commit** 301 + 302 + ```bash 303 + git add apps/web/src/styles/presets/ 304 + git add apps/web/src/layouts/base.tsx 305 + git commit -m "feat(web): convert presets to JSON, add neobrutal-dark, add font-size-xs token (ATB-52)" 306 + ``` 307 + 308 + --- 309 + 310 + ## Task 6: Fix moderation UI hardcoded values in `theme.css` 311 + 312 + **Files:** 313 + - Modify: `apps/web/public/static/css/theme.css` (lines ~751–821) 314 + 315 + The existing base layout tests act as the regression harness for CSS — they verify the layout renders and tokens are injected. The visual regression (forum looks identical) requires a manual browser check after this task. 316 + 317 + **Step 1: Replace the entire moderation UI section** 318 + 319 + Find this block (from `/* ─── Moderation UI ──` to just before `/* ═══ RESPONSIVE BREAKPOINTS`): 320 + 321 + ```css 322 + /* ─── Moderation UI ──────────────────────────────────────────────────────── */ 323 + 324 + .post-card__mod-actions { 325 + display: flex; 326 + gap: var(--space-2); 327 + margin-top: var(--space-2); 328 + padding-top: var(--space-2); 329 + border-top: 1px solid var(--color-border); 330 + } 331 + 332 + .mod-btn { 333 + font-size: 0.75rem; 334 + padding: 0.25rem 0.6rem; 335 + border: 2px solid currentColor; 336 + border-radius: 0; 337 + cursor: pointer; 338 + background: transparent; 339 + font-family: inherit; 340 + font-weight: 700; 341 + text-transform: uppercase; 342 + letter-spacing: 0.05em; 343 + } 344 + 345 + .mod-btn--hide, 346 + .mod-btn--lock { 347 + color: var(--color-danger, #d00); 348 + } 349 + 350 + .mod-btn--hide:hover, 351 + .mod-btn--lock:hover { 352 + background: var(--color-danger, #d00); 353 + color: #fff; 354 + } 355 + 356 + .mod-btn--unhide, 357 + .mod-btn--unlock, 358 + .mod-btn--ban { 359 + color: var(--color-text-muted, #666); 360 + } 361 + 362 + .mod-btn--unhide:hover, 363 + .mod-btn--unlock:hover, 364 + .mod-btn--ban:hover { 365 + background: var(--color-text-muted, #666); 366 + color: #fff; 367 + } 368 + 369 + .topic-mod-controls { 370 + margin-bottom: var(--space-4); 371 + } 372 + 373 + .mod-dialog { 374 + border: 3px solid var(--color-border); 375 + border-radius: 0; 376 + padding: var(--space-6); 377 + max-width: 480px; 378 + width: 90vw; 379 + box-shadow: 6px 6px 0 var(--color-shadow); 380 + background: var(--color-bg); 381 + } 382 + 383 + .mod-dialog::backdrop { 384 + background: rgba(0, 0, 0, 0.5); 385 + } 386 + 387 + .mod-dialog__title { 388 + margin-top: 0; 389 + margin-bottom: var(--space-4); 390 + font-size: 1.25rem; 391 + } 392 + ``` 393 + 394 + Replace with: 395 + 396 + ```css 397 + /* ─── Moderation UI ──────────────────────────────────────────────────────── */ 398 + 399 + .post-card__mod-actions { 400 + display: flex; 401 + gap: var(--space-sm); 402 + margin-top: var(--space-sm); 403 + padding-top: var(--space-sm); 404 + border-top: var(--border-width) solid var(--color-border); 405 + } 406 + 407 + .mod-btn { 408 + font-size: var(--font-size-xs); 409 + padding: var(--space-xs) var(--space-sm); 410 + border: var(--border-width) solid currentColor; 411 + border-radius: var(--radius); 412 + cursor: pointer; 413 + background: transparent; 414 + font-family: inherit; 415 + font-weight: var(--font-weight-bold); 416 + text-transform: uppercase; 417 + letter-spacing: 0.05em; 418 + } 419 + 420 + .mod-btn--hide, 421 + .mod-btn--lock { 422 + color: var(--color-danger); 423 + } 424 + 425 + .mod-btn--hide:hover, 426 + .mod-btn--lock:hover { 427 + background: var(--color-danger); 428 + color: var(--color-surface); 429 + } 430 + 431 + .mod-btn--unhide, 432 + .mod-btn--unlock, 433 + .mod-btn--ban { 434 + color: var(--color-text-muted); 435 + } 436 + 437 + .mod-btn--unhide:hover, 438 + .mod-btn--unlock:hover, 439 + .mod-btn--ban:hover { 440 + background: var(--color-text-muted); 441 + color: var(--color-surface); 442 + } 443 + 444 + .topic-mod-controls { 445 + margin-bottom: var(--space-md); 446 + } 447 + 448 + .mod-dialog { 449 + border: var(--border-width) solid var(--color-border); 450 + border-radius: var(--radius); 451 + padding: var(--space-lg); 452 + max-width: 480px; 453 + width: 90vw; 454 + box-shadow: var(--card-shadow); 455 + background: var(--color-bg); 456 + } 457 + 458 + .mod-dialog::backdrop { 459 + background: rgba(0, 0, 0, 0.5); 460 + } 461 + 462 + .mod-dialog__title { 463 + margin-top: 0; 464 + margin-bottom: var(--space-md); 465 + font-size: var(--font-size-lg); 466 + } 467 + ``` 468 + 469 + **Step 2: Run tests** 470 + 471 + ```bash 472 + pnpm exec vitest run 473 + ``` 474 + 475 + Expected: all tests PASS (CSS changes don't affect unit tests; the layout tests still pass). 476 + 477 + --- 478 + 479 + ## Task 7: Fix structure UI hardcoded fallback values in `theme.css` 480 + 481 + **Files:** 482 + - Modify: `apps/web/public/static/css/theme.css` (lines ~1003–1154) 483 + 484 + These are fallback values in `var(--token, fallback)` form — the fallback is a hardcoded value. Remove the fallbacks. 485 + 486 + **Step 1: Apply the following replacements** (use your editor's find-and-replace): 487 + 488 + | Find | Replace | 489 + |------|---------| 490 + | `var(--space-6, 1.5rem)` | `var(--space-lg)` | 491 + | `var(--radius, 0.5rem)` | `var(--radius)` | 492 + | `var(--radius, 0.375rem)` | `var(--radius)` | 493 + | `var(--font-size-xl, 2rem)` | `var(--font-size-xl)` | 494 + 495 + There are multiple occurrences of `var(--radius, ...)` in the structure UI — replace all of them. 496 + 497 + **Step 2: Run tests** 498 + 499 + ```bash 500 + pnpm exec vitest run 501 + ``` 502 + 503 + Expected: all tests PASS. 504 + 505 + **Step 3: Commit** 506 + 507 + ```bash 508 + git add apps/web/public/static/css/theme.css 509 + git commit -m "fix(web): replace hardcoded CSS values with design tokens in mod and structure UI (ATB-52)" 510 + ``` 511 + 512 + --- 513 + 514 + ## Task 8: Verify no hardcoded values remain 515 + 516 + **Step 1: Search for remaining hardcoded values** 517 + 518 + Check for any remaining hardcoded color hex values: 519 + ```bash 520 + grep -n '#[0-9a-fA-F]\{3,6\}' apps/web/public/static/css/theme.css 521 + ``` 522 + 523 + Expected: no matches (the `rgba(0, 0, 0, 0.5)` backdrop uses `rgba` not hex — this is acceptable since it's a structural opacity value that isn't part of the token schema). 524 + 525 + Check for remaining hardcoded pixel sizes outside responsive breakpoints: 526 + ```bash 527 + grep -n '[0-9]\+px' apps/web/public/static/css/theme.css 528 + ``` 529 + 530 + Review the output — pixel values inside `@media (min-width: ...)` breakpoint declarations are expected. Any pixel values in rule bodies are a problem. 531 + 532 + Check for non-standard spacing token names: 533 + ```bash 534 + grep -n 'var(--space-[0-9]' apps/web/public/static/css/theme.css 535 + ``` 536 + 537 + Expected: no matches. 538 + 539 + **Step 2: Manual visual check** 540 + 541 + Start the dev server: 542 + ```bash 543 + pnpm --filter @atbb/web dev 544 + ``` 545 + 546 + Visit each view and confirm visual appearance is unchanged: 547 + - Homepage (category/board list) 548 + - Board page (topic list) 549 + - Topic page (post thread) 550 + - New topic compose form 551 + - Login form 552 + - Admin panel index 553 + - Admin members page 554 + - Admin structure management page 555 + - Any mod action dialogs 556 + 557 + --- 558 + 559 + ## Task 9: Run full test suite and final commit 560 + 561 + **Step 1: Run all tests** 562 + 563 + ```bash 564 + pnpm --filter @atbb/web test 565 + ``` 566 + 567 + Expected: all tests PASS. 568 + 569 + **Step 2: Update Linear** 570 + 571 + Mark ATB-52 as Done and add a comment summarizing: 572 + - `theme.css` now zero hardcoded values 573 + - `neobrutal-light.json` and `neobrutal-dark.json` ship as preset files 574 + - `--font-size-xs: 12px` added to the token schema 575 + - `tokensToCss()` unchanged, fully tested 576 + 577 + **Step 3: Update the plan doc status** 578 + 579 + In `docs/plans/2026-03-02-atb-52-css-token-extraction.md`, update the header status or move to `docs/plans/complete/` when the PR is merged.