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): extract hardcoded CSS into design token system (ATB-52) (#85)

* docs: ATB-52 CSS token extraction design doc

* docs: ATB-52 implementation plan

* test(web): add failing preset completeness tests (ATB-52)

* test(web): improve preset test descriptions (ATB-52)

* feat(web): add neobrutal-light and neobrutal-dark JSON presets with font-size-xs token (ATB-52)

* feat(web): switch base layout to JSON preset import, remove TS preset (ATB-52)

* fix(web): replace all hardcoded CSS values with design tokens in mod and structure UI (ATB-52)

authored by

Malpercio and committed by
GitHub
3ff848fb ceddbd09

+888 -41
+31 -31
apps/web/public/static/css/theme.css
··· 752 752 753 753 .post-card__mod-actions { 754 754 display: flex; 755 - gap: var(--space-2); 756 - margin-top: var(--space-2); 757 - padding-top: var(--space-2); 758 - border-top: 1px solid var(--color-border); 755 + gap: var(--space-sm); 756 + margin-top: var(--space-sm); 757 + padding-top: var(--space-sm); 758 + border-top: var(--border-width) solid var(--color-border); 759 759 } 760 760 761 761 .mod-btn { 762 - font-size: 0.75rem; 763 - padding: 0.25rem 0.6rem; 764 - border: 2px solid currentColor; 765 - border-radius: 0; 762 + font-size: var(--font-size-xs); 763 + padding: var(--space-xs) var(--space-sm); 764 + border: var(--border-width) solid currentColor; 765 + border-radius: var(--radius); 766 766 cursor: pointer; 767 767 background: transparent; 768 768 font-family: inherit; 769 - font-weight: 700; 769 + font-weight: var(--font-weight-bold); 770 770 text-transform: uppercase; 771 771 letter-spacing: 0.05em; 772 772 } 773 773 774 774 .mod-btn--hide, 775 775 .mod-btn--lock { 776 - color: var(--color-danger, #d00); 776 + color: var(--color-danger); 777 777 } 778 778 779 779 .mod-btn--hide:hover, 780 780 .mod-btn--lock:hover { 781 - background: var(--color-danger, #d00); 782 - color: #fff; 781 + background: var(--color-danger); 782 + color: var(--color-surface); 783 783 } 784 784 785 785 .mod-btn--unhide, 786 786 .mod-btn--unlock, 787 787 .mod-btn--ban { 788 - color: var(--color-text-muted, #666); 788 + color: var(--color-text-muted); 789 789 } 790 790 791 791 .mod-btn--unhide:hover, 792 792 .mod-btn--unlock:hover, 793 793 .mod-btn--ban:hover { 794 - background: var(--color-text-muted, #666); 795 - color: #fff; 794 + background: var(--color-text-muted); 795 + color: var(--color-surface); 796 796 } 797 797 798 798 .topic-mod-controls { 799 - margin-bottom: var(--space-4); 799 + margin-bottom: var(--space-md); 800 800 } 801 801 802 802 .mod-dialog { 803 - border: 3px solid var(--color-border); 804 - border-radius: 0; 805 - padding: var(--space-6); 803 + border: var(--border-width) solid var(--color-border); 804 + border-radius: var(--radius); 805 + padding: var(--space-lg); 806 806 max-width: 480px; 807 807 width: 90vw; 808 - box-shadow: 6px 6px 0 var(--color-shadow); 808 + box-shadow: var(--card-shadow); 809 809 background: var(--color-bg); 810 810 } 811 811 ··· 815 815 816 816 .mod-dialog__title { 817 817 margin-top: 0; 818 - margin-bottom: var(--space-4); 819 - font-size: 1.25rem; 818 + margin-bottom: var(--space-md); 819 + font-size: var(--font-size-lg); 820 820 } 821 821 822 822 /* ═══════════════════════════════════════════════════════════════════════════ ··· 931 931 } 932 932 933 933 .admin-nav-card__icon { 934 - font-size: var(--font-size-xl, 2rem); 934 + font-size: var(--font-size-xl); 935 935 margin-bottom: var(--space-sm); 936 936 } 937 937 ··· 1005 1005 .structure-page { 1006 1006 display: flex; 1007 1007 flex-direction: column; 1008 - gap: var(--space-6, 1.5rem); 1008 + gap: var(--space-lg); 1009 1009 } 1010 1010 1011 1011 .structure-category { 1012 1012 background: var(--color-surface); 1013 1013 border: var(--border-width) solid var(--color-border); 1014 - border-radius: var(--radius, 0.5rem); 1014 + border-radius: var(--radius); 1015 1015 overflow: hidden; 1016 1016 } 1017 1017 ··· 1051 1051 .structure-board { 1052 1052 background: var(--color-bg); 1053 1053 border: var(--border-width) solid var(--color-border); 1054 - border-radius: var(--radius, 0.375rem); 1054 + border-radius: var(--radius); 1055 1055 } 1056 1056 1057 1057 .structure-board__header { ··· 1094 1094 1095 1095 .structure-add-board { 1096 1096 border: var(--border-width) dashed var(--color-border); 1097 - border-radius: var(--radius, 0.375rem); 1097 + border-radius: var(--radius); 1098 1098 background: transparent; 1099 1099 } 1100 1100 ··· 1109 1109 1110 1110 .structure-add-board__trigger:hover { 1111 1111 background: var(--color-surface); 1112 - border-radius: var(--radius, 0.375rem); 1112 + border-radius: var(--radius); 1113 1113 } 1114 1114 1115 1115 .structure-add-category { ··· 1117 1117 } 1118 1118 1119 1119 .structure-confirm-dialog { 1120 - border-radius: var(--radius, 0.5rem); 1120 + border-radius: var(--radius); 1121 1121 border: var(--border-width) solid var(--color-border); 1122 - padding: var(--space-6, 1.5rem); 1122 + padding: var(--space-lg); 1123 1123 max-width: 24rem; 1124 1124 } 1125 1125 ··· 1142 1142 color: var(--color-danger); 1143 1143 border: var(--border-width) solid var(--color-danger); 1144 1144 border-left-width: calc(var(--border-width) * 3); 1145 - border-radius: var(--radius, 0.5rem); 1145 + border-radius: var(--radius); 1146 1146 padding: var(--space-sm) var(--space-md); 1147 1147 margin-bottom: var(--space-md); 1148 1148 }
+2 -2
apps/web/src/layouts/base.tsx
··· 1 1 import type { FC, PropsWithChildren } from "hono/jsx"; 2 2 import { tokensToCss } from "../lib/theme.js"; 3 - import { neobrutalLight } from "../styles/presets/neobrutal-light.js"; 3 + import neobrutalLight from "../styles/presets/neobrutal-light.json"; 4 4 import type { WebSession } from "../lib/session.js"; 5 5 6 - const ROOT_CSS = `:root { ${tokensToCss(neobrutalLight)} }`; 6 + const ROOT_CSS = `:root { ${tokensToCss(neobrutalLight as Record<string, string>)} }`; 7 7 8 8 const NavContent: FC<{ auth?: WebSession }> = ({ auth }) => ( 9 9 <>
+60
apps/web/src/styles/presets/__tests__/presets.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import neobrutalLight from "../neobrutal-light.json"; 3 + import neobrutalDark from "../neobrutal-dark.json"; 4 + import { tokensToCss } from "../../../lib/theme.js"; 5 + 6 + const REQUIRED_TOKENS = [ 7 + // Colors 8 + "color-bg", "color-surface", "color-text", "color-text-muted", 9 + "color-primary", "color-primary-hover", "color-secondary", 10 + "color-border", "color-shadow", "color-success", "color-warning", 11 + "color-danger", "color-code-bg", "color-code-text", 12 + // Typography 13 + "font-body", "font-heading", "font-mono", 14 + "font-size-base", "font-size-sm", "font-size-xs", 15 + "font-size-lg", "font-size-xl", "font-size-2xl", 16 + "font-weight-normal", "font-weight-bold", 17 + "line-height-body", "line-height-heading", 18 + // Spacing & layout 19 + "space-xs", "space-sm", "space-md", "space-lg", "space-xl", 20 + "radius", "border-width", "shadow-offset", "content-width", 21 + // Components 22 + "button-radius", "button-shadow", "card-radius", "card-shadow", 23 + "btn-press-hover", "btn-press-active", 24 + "input-radius", "input-border", "nav-height", 25 + ]; 26 + 27 + describe("neobrutal-light preset", () => { 28 + it("contains all required tokens", () => { 29 + for (const token of REQUIRED_TOKENS) { 30 + expect(neobrutalLight, `missing token: ${token}`).toHaveProperty(token); 31 + } 32 + }); 33 + 34 + it("produces valid CSS via tokensToCss", () => { 35 + const css = tokensToCss(neobrutalLight as Record<string, string>); 36 + expect(css).toContain("--color-bg:"); 37 + expect(css).toContain("--font-size-xs:"); 38 + expect(css).toContain("--nav-height:"); 39 + }); 40 + }); 41 + 42 + describe("neobrutal-dark preset", () => { 43 + it("contains all required tokens", () => { 44 + for (const token of REQUIRED_TOKENS) { 45 + expect(neobrutalDark, `missing token: ${token}`).toHaveProperty(token); 46 + } 47 + }); 48 + 49 + it("has a different background color than the light preset", () => { 50 + expect((neobrutalDark as Record<string, string>)["color-bg"]).not.toBe( 51 + (neobrutalLight as Record<string, string>)["color-bg"] 52 + ); 53 + }); 54 + 55 + it("produces valid CSS via tokensToCss", () => { 56 + const css = tokensToCss(neobrutalDark as Record<string, string>); 57 + expect(css).toContain("--color-bg:"); 58 + expect(css).toContain("--font-size-xs:"); 59 + }); 60 + });
+47
apps/web/src/styles/presets/neobrutal-dark.json
··· 1 + { 2 + "color-bg": "#1a1a1a", 3 + "color-surface": "#2d2d2d", 4 + "color-text": "#f5f0e8", 5 + "color-text-muted": "#a0a0a0", 6 + "color-primary": "#ff5c00", 7 + "color-primary-hover": "#ff7a2a", 8 + "color-secondary": "#3a86ff", 9 + "color-border": "#f5f0e8", 10 + "color-shadow": "#000000", 11 + "color-success": "#2ec44a", 12 + "color-warning": "#ffbe0b", 13 + "color-danger": "#ff006e", 14 + "color-code-bg": "#111111", 15 + "color-code-text": "#f5f0e8", 16 + "font-body": "'Space Grotesk', system-ui, sans-serif", 17 + "font-heading": "'Space Grotesk', system-ui, sans-serif", 18 + "font-mono": "'JetBrains Mono', ui-monospace, monospace", 19 + "font-size-base": "16px", 20 + "font-size-sm": "14px", 21 + "font-size-xs": "12px", 22 + "font-size-lg": "20px", 23 + "font-size-xl": "28px", 24 + "font-size-2xl": "36px", 25 + "font-weight-normal": "400", 26 + "font-weight-bold": "700", 27 + "line-height-body": "1.6", 28 + "line-height-heading": "1.2", 29 + "space-xs": "4px", 30 + "space-sm": "8px", 31 + "space-md": "16px", 32 + "space-lg": "24px", 33 + "space-xl": "40px", 34 + "radius": "0px", 35 + "border-width": "2px", 36 + "shadow-offset": "2px", 37 + "content-width": "100%", 38 + "button-radius": "0px", 39 + "button-shadow": "2px 2px 0 var(--color-shadow)", 40 + "card-radius": "0px", 41 + "card-shadow": "4px 4px 0 var(--color-shadow)", 42 + "btn-press-hover": "1px", 43 + "btn-press-active": "2px", 44 + "input-radius": "0px", 45 + "input-border": "2px solid var(--color-border)", 46 + "nav-height": "64px" 47 + }
+4 -8
apps/web/src/styles/presets/neobrutal-light.ts apps/web/src/styles/presets/neobrutal-light.json
··· 1 - // apps/web/src/styles/presets/neobrutal-light.ts 2 - export const neobrutalLight: Record<string, string> = { 3 - // Colors 1 + { 4 2 "color-bg": "#f5f0e8", 5 3 "color-surface": "#ffffff", 6 4 "color-text": "#1a1a1a", ··· 15 13 "color-danger": "#ff006e", 16 14 "color-code-bg": "#1a1a1a", 17 15 "color-code-text": "#f5f0e8", 18 - // Typography 19 16 "font-body": "'Space Grotesk', system-ui, sans-serif", 20 17 "font-heading": "'Space Grotesk', system-ui, sans-serif", 21 18 "font-mono": "'JetBrains Mono', ui-monospace, monospace", 22 19 "font-size-base": "16px", 23 20 "font-size-sm": "14px", 21 + "font-size-xs": "12px", 24 22 "font-size-lg": "20px", 25 23 "font-size-xl": "28px", 26 24 "font-size-2xl": "36px", ··· 28 26 "font-weight-bold": "700", 29 27 "line-height-body": "1.6", 30 28 "line-height-heading": "1.2", 31 - // Spacing & Layout 32 29 "space-xs": "4px", 33 30 "space-sm": "8px", 34 31 "space-md": "16px", ··· 38 35 "border-width": "2px", 39 36 "shadow-offset": "2px", 40 37 "content-width": "100%", 41 - // Components 42 38 "button-radius": "0px", 43 39 "button-shadow": "2px 2px 0 var(--color-shadow)", 44 40 "card-radius": "0px", ··· 47 43 "btn-press-active": "2px", 48 44 "input-radius": "0px", 49 45 "input-border": "2px solid var(--color-border)", 50 - "nav-height": "64px", 51 - }; 46 + "nav-height": "64px" 47 + }
+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.
+165
docs/plans/2026-03-02-css-token-extraction-design.md
··· 1 + # ATB-52: CSS Token Extraction — Design 2 + 3 + **Status:** Approved, ready for implementation 4 + **Linear:** ATB-52 5 + **Date:** 2026-03-02 6 + 7 + --- 8 + 9 + ## Context 10 + 11 + The web UI uses a neobrutal aesthetic with a CSS custom property token system. Most of `theme.css` already references `var(--token)` exclusively. Two sections were added separately (moderation UI, structure management UI) and were never aligned with the token schema. This design covers the full extraction. 12 + 13 + `tokensToCss()` and `neobrutal-light.ts` already exist. The work is: fix the remaining hardcoded values, add one missing token, convert presets to JSON, and add the dark preset. 14 + 15 + --- 16 + 17 + ## Audit: Hardcoded Values Remaining 18 + 19 + ### Moderation UI (`theme.css` lines 751–821) 20 + 21 + | Hardcoded value | Replace with | 22 + |----------------|--------------| 23 + | `var(--space-2)` | `var(--space-sm)` (8px = 0.5rem) | 24 + | `var(--space-4)` | `var(--space-md)` (16px = 1rem) | 25 + | `var(--space-6)` | `var(--space-lg)` (24px = 1.5rem) | 26 + | `1px solid var(--color-border)` | `var(--border-width) solid var(--color-border)` | 27 + | `2px solid currentColor` | `var(--border-width) solid currentColor` | 28 + | `border-radius: 0` | `var(--radius)` | 29 + | `font-weight: 700` | `var(--font-weight-bold)` | 30 + | `0.25rem 0.6rem` (mod-btn padding) | `var(--space-xs) var(--space-sm)` | 31 + | `0.75rem` (mod-btn font-size) | `var(--font-size-xs)` ← new token | 32 + | `1.25rem` (dialog title font-size) | `var(--font-size-lg)` (20px = 1.25rem) | 33 + | `6px 6px 0 var(--color-shadow)` | `var(--card-shadow)` | 34 + | `color: #fff` (hover text) | `var(--color-surface)` | 35 + | `var(--color-danger, #d00)` | `var(--color-danger)` (remove fallback) | 36 + | `var(--color-text-muted, #666)` | `var(--color-text-muted)` (remove fallback) | 37 + | `3px solid var(--color-border)` | `var(--border-width) solid var(--color-border)` | 38 + 39 + ### Structure UI (`theme.css` lines 1003–1154) 40 + 41 + | Hardcoded value | Replace with | 42 + |----------------|--------------| 43 + | `var(--space-6, 1.5rem)` | `var(--space-lg)` (remove fallback) | 44 + | `var(--radius, 0.5rem)` | `var(--radius)` (remove fallback) | 45 + | `var(--radius, 0.375rem)` | `var(--radius)` (remove fallback) | 46 + | `var(--font-size-xl, 2rem)` | `var(--font-size-xl)` (remove fallback) | 47 + 48 + --- 49 + 50 + ## Token Schema Addition 51 + 52 + One new token added to complete the type scale: 53 + 54 + | Token | neobrutal-light | neobrutal-dark | Description | 55 + |-------|----------------|----------------|-------------| 56 + | `font-size-xs` | `12px` | `12px` | Extra-small text (mod buttons, badges) | 57 + 58 + --- 59 + 60 + ## Preset Files 61 + 62 + ### Format 63 + 64 + Convert from TypeScript to JSON. `resolveJsonModule: true` is already set in `tsconfig.base.json` — no config changes needed. Import in `base.tsx` changes to: 65 + 66 + ```typescript 67 + import neobrutalLight from "../styles/presets/neobrutal-light.json" assert { type: "json" }; 68 + ``` 69 + 70 + Or, since `moduleResolution: bundler` is set, the assert clause may not be required — verify during implementation. 71 + 72 + ### `neobrutal-light.json` (converted from existing TS, adds `font-size-xs`) 73 + 74 + ```json 75 + { 76 + "color-bg": "#f5f0e8", 77 + "color-surface": "#ffffff", 78 + "color-text": "#1a1a1a", 79 + "color-text-muted": "#555555", 80 + "color-primary": "#ff5c00", 81 + "color-primary-hover": "#e04f00", 82 + "color-secondary": "#3a86ff", 83 + "color-border": "#1a1a1a", 84 + "color-shadow": "#1a1a1a", 85 + "color-success": "#2ec44a", 86 + "color-warning": "#ffbe0b", 87 + "color-danger": "#ff006e", 88 + "color-code-bg": "#1a1a1a", 89 + "color-code-text": "#f5f0e8", 90 + "font-body": "'Space Grotesk', system-ui, sans-serif", 91 + "font-heading": "'Space Grotesk', system-ui, sans-serif", 92 + "font-mono": "'JetBrains Mono', ui-monospace, monospace", 93 + "font-size-base": "16px", 94 + "font-size-sm": "14px", 95 + "font-size-xs": "12px", 96 + "font-size-lg": "20px", 97 + "font-size-xl": "28px", 98 + "font-size-2xl": "36px", 99 + "font-weight-normal": "400", 100 + "font-weight-bold": "700", 101 + "line-height-body": "1.6", 102 + "line-height-heading": "1.2", 103 + "space-xs": "4px", 104 + "space-sm": "8px", 105 + "space-md": "16px", 106 + "space-lg": "24px", 107 + "space-xl": "40px", 108 + "radius": "0px", 109 + "border-width": "2px", 110 + "shadow-offset": "2px", 111 + "content-width": "100%", 112 + "button-radius": "0px", 113 + "button-shadow": "2px 2px 0 var(--color-shadow)", 114 + "card-radius": "0px", 115 + "card-shadow": "4px 4px 0 var(--color-shadow)", 116 + "btn-press-hover": "1px", 117 + "btn-press-active": "2px", 118 + "input-radius": "0px", 119 + "input-border": "2px solid var(--color-border)", 120 + "nav-height": "64px" 121 + } 122 + ``` 123 + 124 + ### `neobrutal-dark.json` (new) 125 + 126 + Same structural/typography/spacing tokens. Color tokens that differ: 127 + 128 + | Token | Value | 129 + |-------|-------| 130 + | `color-bg` | `#1a1a1a` | 131 + | `color-surface` | `#2d2d2d` | 132 + | `color-text` | `#f5f0e8` | 133 + | `color-text-muted` | `#a0a0a0` | 134 + | `color-primary-hover` | `#ff7a2a` (lightened for dark bg) | 135 + | `color-border` | `#f5f0e8` (inverted from light) | 136 + | `color-shadow` | `#000000` | 137 + | `color-code-bg` | `#111111` | 138 + 139 + All other tokens (primary, secondary, success, warning, danger, all typography, all spacing, all component) are identical to neobrutal-light. 140 + 141 + --- 142 + 143 + ## File Changes 144 + 145 + | File | Action | 146 + |------|--------| 147 + | `public/static/css/theme.css` | Fix ~15 hardcoded values in mod UI + structure UI | 148 + | `src/styles/presets/neobrutal-light.ts` | Delete | 149 + | `src/styles/presets/neobrutal-light.json` | Create (converted from TS, adds `font-size-xs`) | 150 + | `src/styles/presets/neobrutal-dark.json` | Create (dark color tokens, same structure) | 151 + | `src/layouts/base.tsx` | Update import to JSON | 152 + | `src/lib/theme.ts` | No changes | 153 + | `src/lib/__tests__/theme.test.ts` | No changes | 154 + 155 + --- 156 + 157 + ## Acceptance Criteria (from ATB-52) 158 + 159 + - [ ] `theme.css` contains zero hardcoded color values, font stacks, spacing values, or font sizes — all use `var(--token)` 160 + - [ ] No fallback values in `var()` calls (fallbacks are hardcoded values in disguise) 161 + - [ ] `tokensToCss()` utility exists and is tested (already satisfied) 162 + - [ ] `neobrutal-light.json` and `neobrutal-dark.json` ship with complete token sets 163 + - [ ] `--font-size-xs` added to both presets 164 + - [ ] `base.tsx` imports from JSON and the forum renders identically to before 165 + - [ ] All existing views render correctly after the refactor