From 11163ef6a45d4af0ddefeae484eeba19f36a6e58 Mon Sep 17 00:00:00 2001 From: bnz183 Date: Fri, 19 Jun 2026 12:05:24 +0200 Subject: [PATCH] feat(studio): design-system foundation (Phase 4a) Establish the tokenized design-system + app-shell foundation for the pre-distribution UX pass, on top of main: - 8px spacing scale, radius/elevation scale, regularized modular type scale - tokenized color palette in light and dark (prefers-color-scheme), tuned for WCAG 2.2 AA; dark overridable by an explicit light/dark/system choice persisted across reloads via a new app-bar theme toggle (lib/theme) - consistent button system; Publish is a large, anchored (sticky), high-contrast primary action - sticky top bar; persistent left navigation rail (NavRail) for Posts/Settings - reduced visual noise via tokens Add .claude/rules/ui-standards.md, a Phase 4 plan in docs/roadmap.md, a scope-rule carve-out, and a CHANGELOG entry. Adds unit tests (theme, navigation) and e2e coverage (left nav, theme toggle, anchored Publish). Co-Authored-By: Claude Opus 4.8 --- .claude/rules/no-scope-creep.md | 5 +- .claude/rules/ui-standards.md | 77 ++++ CHANGELOG.md | 14 + apps/studio/e2e/smoke.spec.ts | 53 +++ apps/studio/src/App.tsx | 55 ++- apps/studio/src/components/AppBar.tsx | 44 +-- apps/studio/src/components/NavRail.tsx | 98 +++++ apps/studio/src/components/PublishGate.tsx | 2 +- apps/studio/src/index.css | 405 +++++++++++++++++---- apps/studio/src/lib/navigation.test.ts | 22 ++ apps/studio/src/lib/navigation.ts | 17 + apps/studio/src/lib/theme.test.ts | 96 +++++ apps/studio/src/lib/theme.ts | 114 ++++++ apps/studio/src/main.tsx | 3 + docs/roadmap.md | 63 ++++ 15 files changed, 948 insertions(+), 120 deletions(-) create mode 100644 .claude/rules/ui-standards.md create mode 100644 apps/studio/src/components/NavRail.tsx create mode 100644 apps/studio/src/lib/navigation.test.ts create mode 100644 apps/studio/src/lib/navigation.ts create mode 100644 apps/studio/src/lib/theme.test.ts create mode 100644 apps/studio/src/lib/theme.ts diff --git a/.claude/rules/no-scope-creep.md b/.claude/rules/no-scope-creep.md index b57ead2..f195f23 100644 --- a/.claude/rules/no-scope-creep.md +++ b/.claude/rules/no-scope-creep.md @@ -15,7 +15,10 @@ Do not implement, scaffold, stub, or document as available: - Hosted / multi-tenant Studio - Plugin marketplace - AI writing tools -- Large UI redesigns +- Large UI redesigns — **except** the sanctioned, sequenced Phase 4 UX/UI + quality pass in `docs/roadmap.md`, which is polish and accessibility work on + existing features (no new product surface) measured against + `.claude/rules/ui-standards.md`. It adds no forbidden feature above. Also forbidden: fake screenshots, fake metrics, fake benchmarks, and production/enterprise overclaims in any doc or UI string. diff --git a/.claude/rules/ui-standards.md b/.claude/rules/ui-standards.md new file mode 100644 index 0000000..83e51c2 --- /dev/null +++ b/.claude/rules/ui-standards.md @@ -0,0 +1,77 @@ +# UI standards + +Authoritative reference for SourceDraft Studio UI work. These are the bars every +Studio change must clear. Keep changes incremental and within the project's hard +rules (see `CLAUDE.md`, `no-scope-creep.md`). The Studio should read as a +shippable product, never a prototype. + +## Foundations: design tokens + +- **Spacing** uses the 8px-based scale tokens in `apps/studio/src/index.css` + (`--space-1`…`--space-8`, with `--space-1: 4px` as the half-step). Do not add + ad-hoc `px` padding/gap in new or touched rules — use tokens. +- **Type** uses the modular scale tokens (`--text-xs`…`--text-title`). Do not + introduce new font sizes outside the scale. +- **Color** is fully tokenized. Components reference semantic tokens + (`--text`, `--text-muted`, `--bg`, `--bg-panel`, `--border`, `--accent`, + `--on-accent`, `--success-*`, `--warning-*`, `--error-*`, …) — never raw hex + in component rules. New colors are added as tokens, defined for **both** light + (`:root`) and dark (`:root[data-theme="dark"]` and the + `prefers-color-scheme: dark` block). +- **Radii / elevation** use `--radius-*` and `--shadow-*` tokens. + +## Theme + +- Light and dark are both first-class. Dark follows the OS by default + (`prefers-color-scheme`) and is overridable by an explicit user choice + persisted via `lib/theme.ts` (`light` / `dark` / `system`). + +## Laws of UX + +- **Hick / Miller** — group related controls and keep visible choices small. + Toolbars and panels are chunked into labeled groups. Avoid long flat rows of + undifferentiated buttons. +- **Fitts** — primary actions are large and easy to reach. The Publish action is + a large, high-contrast primary button, anchored so it is reachable without + hunting or long scrolls. +- **Jakob** — match conventions of tools users already know (Google Docs / + Notion): a persistent left navigation rail for primary destinations, a sticky + top bar, and a familiar editor surface and toolbar. + +## Onboarding and configuration + +- **Progressive disclosure** — show essentials first; defer advanced/diagnostic + options behind disclosure (`
`), secondary screens, or later steps. +- **One thing per page/step** — configuration is staged, not dumped as one dense + form. First run must work with **zero credentials** (demo mode). + +## Accessibility — WCAG 2.2 AA (required) + +- **Contrast**: body text ≥ **4.5:1**; large text (≥ 18px regular / 14px bold) + and meaningful UI/graphical boundaries ≥ **3:1**. Verify in light **and** dark. +- **Target size (2.5.8)**: interactive controls ≥ **24×24** CSS px; prefer ~40px+ + for primary actions. +- **Visible focus**: every interactive element has a clear `:focus-visible` + outline. Never remove focus styling without an equal replacement. +- Use semantic roles/labels (`aria-current`, `aria-label`, `role`, + `aria-live` for status) so flows are operable by keyboard and screen reader. +- Respect `prefers-color-scheme`; respect `prefers-reduced-motion` for motion. + +## Responsiveness and feedback + +- User actions get visible feedback in **< 400ms** (state change, spinner, or + status text). +- **Autosave** the working document and show live save status; never rely on a + manual "save" the user can forget. +- Long/remote operations show progress and clear, actionable success/error + states — without leaking secret values. + +## Process + +- Touch only what the change needs; do not refactor unrelated components. +- No new dependencies for styling — the system is plain CSS custom properties. +- Every UI change adds/updates unit and/or Playwright e2e coverage and passes + `pnpm build && pnpm test && pnpm test:e2e`. + +Related: `docs-style.md` (copy/tone), `no-scope-creep.md` (scope), +`docs/roadmap.md` (Phase 4 UX sequence). diff --git a/CHANGELOG.md b/CHANGELOG.md index 991defe..9fcc3d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ All notable changes to SourceDraft are documented here. The project uses [Semantic Versioning](https://semver.org/) where practical. +## Unreleased + +### Changed + +- **Studio design-system foundation (Phase 4a)** — added an 8px spacing scale, + a radius/elevation scale, and a regularized modular type scale as CSS tokens. +- **Tokenized color palette in light and dark**, tuned for WCAG 2.2 AA. Dark + follows the OS by default and is overridable by an explicit light/dark/system + choice persisted across reloads (new app-bar theme toggle). +- **App shell** — sticky top bar, a persistent left navigation rail for Posts + and Settings, a consistent button system, and the Publish action as a large, + anchored, high-contrast primary button. Reduced visual noise via tokens. +- Added `.claude/rules/ui-standards.md` as the authoritative Studio UI bar. + ## v0.1.0 First public open-source MVP for local/private Git-backed publishing. diff --git a/apps/studio/e2e/smoke.spec.ts b/apps/studio/e2e/smoke.spec.ts index c520e5f..50e2a63 100644 --- a/apps/studio/e2e/smoke.spec.ts +++ b/apps/studio/e2e/smoke.spec.ts @@ -141,6 +141,59 @@ test.describe("Studio smoke", () => { await expect(page.getByText("GitHub connection", { exact: true })).toBeVisible(); }); + test("left nav switches between Posts and Settings", async ({ page }) => { + await enterDemoMode(page); + const nav = page.getByRole("navigation", { name: "Primary" }); + await expect(nav.getByRole("button", { name: "Posts", exact: true })).toBeVisible(); + + await nav.getByRole("button", { name: "Settings", exact: true }).click(); + await expect( + page.getByRole("heading", { name: "Status & configuration" }), + ).toBeVisible(); + await expect( + nav.getByRole("button", { name: "Settings", exact: true }), + ).toHaveAttribute("aria-current", "page"); + + await nav.getByRole("button", { name: "Posts", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Articles" })).toBeVisible(); + }); + + test("theme toggle cycles and persists across reload", async ({ page }) => { + await enterDemoMode(page); + const html = page.locator("html"); + await expect(html).not.toHaveAttribute("data-theme", /.+/); + + const toggle = page.getByRole("button", { name: /Switch theme/ }); + await toggle.click(); + await expect(html).toHaveAttribute("data-theme", "light"); + await toggle.click(); + await expect(html).toHaveAttribute("data-theme", "dark"); + + await page.reload(); + await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); + }); + + test("publish action is a large anchored primary button", async ({ page }) => { + await enterDemoMode(page); + await page.getByRole("button", { name: "New article" }).click(); + await postTitleInput(page).fill("Anchored publish test"); + await postDescriptionInput(page).fill("Summary for anchored publish test."); + await fillPostBody(page, "# Anchored\n\nBody content."); + + const publish = page.getByRole("button", { name: "Simulate send to blog" }); + await publish.scrollIntoViewIfNeeded(); + await expect(publish).toBeVisible(); + await expect(publish).toHaveClass(/button--primary/); + await expect(publish).toHaveClass(/button--lg/); + + // The publish bar is anchored (sticky) so the primary action stays + // reachable while scrolling the editor. + const position = await page + .locator(".publish-bar") + .evaluate((el) => getComputedStyle(el).position); + expect(position).toBe("sticky"); + }); + test("publish checklist renders in demo mode", async ({ page }) => { await enterDemoMode(page); await page.getByRole("button", { name: "New article" }).click(); diff --git a/apps/studio/src/App.tsx b/apps/studio/src/App.tsx index d78a951..400589a 100644 --- a/apps/studio/src/App.tsx +++ b/apps/studio/src/App.tsx @@ -6,6 +6,7 @@ import { AppBar } from "./components/AppBar"; import { AstroMdxPreview } from "./components/AstroMdxPreview"; import { DemoBanner } from "./components/DemoBanner"; import { LoginScreen } from "./components/LoginScreen"; +import { NavRail } from "./components/NavRail"; import { PostDetailsPanel } from "./components/PostDetailsPanel"; import { PostLoginWelcomeBanner } from "./components/PostLoginWelcomeBanner"; import { PostSidebar } from "./components/PostSidebar"; @@ -29,6 +30,12 @@ import { type ArticleFormState, } from "./lib/articleForm"; import { fetchPost, fetchPosts, type PostSummary } from "./lib/posts"; +import { + applyTheme, + getStoredTheme, + nextTheme, + type ThemePreference, +} from "./lib/theme"; import { previewPrBranch } from "./lib/prBranch"; import { publishArticle as publishArticleToGitHub } from "./lib/publish"; import { @@ -58,6 +65,7 @@ function App() { const [demoModeForced, setDemoModeForced] = useState(false); const [demoModeAvailable, setDemoModeAvailable] = useState(false); const [view, setView] = useState("editor"); + const [theme, setTheme] = useState(() => getStoredTheme()); const [studioConfig, setStudioConfig] = useState( FALLBACK_STUDIO_CONFIG, ); @@ -241,6 +249,10 @@ function App() { enabled: authenticated && view === "editor", }); + function handleToggleTheme() { + setTheme((current) => applyTheme(nextTheme(current))); + } + function resetEditor(defaultCategory?: string) { setEditingPath(null); setLoadPostError(null); @@ -515,25 +527,26 @@ function App() { githubOwner={studioConfig.githubOwner} githubRepo={studioConfig.githubRepo} githubReady={githubReady} - settingsActive={view === "settings"} - onOpenSettings={() => - setView((current) => (current === "settings" ? "editor" : "settings")) - } - onLogout={handleLogout} + theme={theme} + onToggleTheme={handleToggleTheme} /> - {view === "settings" ? ( -
- -
- ) : ( - <> - setView("settings")} - /> -
+
+ + +
+ {view === "settings" ? ( +
+ +
+ ) : ( + <> + setView("settings")} + /> +
-
- - )} +
+ + )} +
+
); } diff --git a/apps/studio/src/components/AppBar.tsx b/apps/studio/src/components/AppBar.tsx index 2b86f12..2c5a788 100644 --- a/apps/studio/src/components/AppBar.tsx +++ b/apps/studio/src/components/AppBar.tsx @@ -1,5 +1,6 @@ import { DocumentStatusIndicator } from "./DocumentStatus"; import type { DocumentStatus } from "../lib/autosave.js"; +import { themeLabel, type ThemePreference } from "../lib/theme"; type AppBarProps = { adapter: string; @@ -7,11 +8,21 @@ type AppBarProps = { githubOwner: string; githubRepo: string; githubReady: boolean; - settingsActive: boolean; - onOpenSettings: () => void; - onLogout: () => void; + theme: ThemePreference; + onToggleTheme: () => void; }; +function themeIcon(theme: ThemePreference): string { + switch (theme) { + case "light": + return "☀"; + case "dark": + return "☾"; + default: + return "◐"; + } +} + function adapterLabel(adapter: string): string { switch (adapter) { case "markdown": @@ -39,9 +50,8 @@ export function AppBar({ githubOwner, githubRepo, githubReady, - settingsActive, - onOpenSettings, - onLogout, + theme, + onToggleTheme, }: AppBarProps) { const repoLabel = githubReady ? `${githubOwner}/${githubRepo}` @@ -72,23 +82,13 @@ export function AppBar({
-
diff --git a/apps/studio/src/components/NavRail.tsx b/apps/studio/src/components/NavRail.tsx new file mode 100644 index 0000000..4a0b07f --- /dev/null +++ b/apps/studio/src/components/NavRail.tsx @@ -0,0 +1,98 @@ +import { NAV_ITEMS, isNavItemActive, type NavItem } from "../lib/navigation.js"; +import type { View } from "../types/view"; + +type NavRailProps = { + view: View; + onNavigate: (view: View) => void; + onLogout: () => void; +}; + +function NavIcon({ view }: { view: View }) { + if (view === "settings") { + return ( + + ); + } + + return ( + + ); +} + +export function NavRail({ view, onNavigate, onLogout }: NavRailProps) { + return ( + + ); +} diff --git a/apps/studio/src/components/PublishGate.tsx b/apps/studio/src/components/PublishGate.tsx index 64408a8..ed3a6f7 100644 --- a/apps/studio/src/components/PublishGate.tsx +++ b/apps/studio/src/components/PublishGate.tsx @@ -143,7 +143,7 @@ export function PublishGate({