Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .claude/rules/no-scope-creep.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
77 changes: 77 additions & 0 deletions .claude/rules/ui-standards.md
Original file line number Diff line number Diff line change
@@ -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 (`<details>`), 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).
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
53 changes: 53 additions & 0 deletions apps/studio/e2e/smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
55 changes: 35 additions & 20 deletions apps/studio/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 {
Expand Down Expand Up @@ -58,6 +65,7 @@ function App() {
const [demoModeForced, setDemoModeForced] = useState(false);
const [demoModeAvailable, setDemoModeAvailable] = useState(false);
const [view, setView] = useState<View>("editor");
const [theme, setTheme] = useState<ThemePreference>(() => getStoredTheme());
const [studioConfig, setStudioConfig] = useState<StudioConfig>(
FALLBACK_STUDIO_CONFIG,
);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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" ? (
<main className="studio__settings">
<SettingsPanel config={studioConfig} />
</main>
) : (
<>
<PostLoginWelcomeBanner
demoMode={demoMode}
githubReady={githubReady}
onOpenSettings={() => setView("settings")}
/>
<div className="studio__workspace">
<div className="studio__body">
<NavRail view={view} onNavigate={setView} onLogout={handleLogout} />

<div className="studio__content">
{view === "settings" ? (
<main className="studio__settings">
<SettingsPanel config={studioConfig} />
</main>
) : (
<>
<PostLoginWelcomeBanner
demoMode={demoMode}
githubReady={githubReady}
onOpenSettings={() => setView("settings")}
/>
<div className="studio__workspace">
<PostSidebar
posts={posts}
loading={postsLoading}
Expand Down Expand Up @@ -627,9 +640,11 @@ function App() {
onInsertPdfLink={handleInsertPdfLink}
onUploadSuccess={handleUploadSuccess}
/>
</div>
</>
)}
</div>
</>
)}
</div>
</div>
</div>
);
}
Expand Down
44 changes: 22 additions & 22 deletions apps/studio/src/components/AppBar.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,28 @@
import { DocumentStatusIndicator } from "./DocumentStatus";
import type { DocumentStatus } from "../lib/autosave.js";
import { themeLabel, type ThemePreference } from "../lib/theme";

type AppBarProps = {
adapter: string;
documentStatus: DocumentStatus | null;
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":
Expand Down Expand Up @@ -39,9 +50,8 @@ export function AppBar({
githubOwner,
githubRepo,
githubReady,
settingsActive,
onOpenSettings,
onLogout,
theme,
onToggleTheme,
}: AppBarProps) {
const repoLabel = githubReady
? `${githubOwner}/${githubRepo}`
Expand Down Expand Up @@ -72,23 +82,13 @@ export function AppBar({
<div className="app-bar__actions">
<button
type="button"
className={
settingsActive
? "button button--compact app-bar__action app-bar__action--active"
: "button button--compact app-bar__action"
}
aria-current={settingsActive ? "page" : undefined}
aria-expanded={settingsActive}
onClick={onOpenSettings}
>
{settingsActive ? "Back to editor" : "Settings"}
</button>
<button
type="button"
className="button button--compact app-bar__action"
onClick={onLogout}
className="button button--compact app-bar__theme-toggle"
aria-label={`Theme: ${themeLabel(theme)}. Switch theme.`}
title={`Theme: ${themeLabel(theme)}`}
onClick={onToggleTheme}
>
Log out
<span aria-hidden="true">{themeIcon(theme)}</span>
<span className="app-bar__theme-label">{themeLabel(theme)}</span>
</button>
</div>
</header>
Expand Down
Loading
Loading