feat(web): add theme selection UI in Settings > Appearance
All checks were successful
ci/woodpecker/push/web Pipeline was successful
All checks were successful
ci/woodpecker/push/web Pipeline was successful
Create Settings > Appearance page with theme browser showing all 5 built-in themes plus System auto option. Each theme displays live color preview swatches, name, description, and dark/light badge. Selection applies instantly via ThemeProvider and persists to UserPreference.theme via API. Refs: #487 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
324
apps/web/src/app/(authenticated)/settings/appearance/page.tsx
Normal file
324
apps/web/src/app/(authenticated)/settings/appearance/page.tsx
Normal file
@@ -0,0 +1,324 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import type { ReactElement } from "react";
|
||||
import Link from "next/link";
|
||||
import { useTheme } from "@/providers/ThemeProvider";
|
||||
import { getAllThemes, type ThemeDefinition } from "@/themes";
|
||||
import { apiPatch } from "@/lib/api/client";
|
||||
|
||||
function ThemeCard({
|
||||
theme,
|
||||
isActive,
|
||||
onSelect,
|
||||
}: {
|
||||
theme: ThemeDefinition;
|
||||
isActive: boolean;
|
||||
onSelect: () => void;
|
||||
}): ReactElement {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onSelect}
|
||||
onMouseEnter={() => {
|
||||
setHovered(true);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setHovered(false);
|
||||
}}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 12,
|
||||
padding: 16,
|
||||
borderRadius: "var(--r-lg)",
|
||||
background: isActive ? "var(--surface-2)" : hovered ? "var(--surface)" : "transparent",
|
||||
border: isActive
|
||||
? "2px solid var(--primary)"
|
||||
: `1px solid ${hovered ? "var(--border)" : "transparent"}`,
|
||||
cursor: "pointer",
|
||||
textAlign: "left",
|
||||
transition: "all 0.15s ease",
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
}}
|
||||
aria-label={`Select ${theme.name} theme`}
|
||||
aria-pressed={isActive}
|
||||
>
|
||||
{/* Color preview swatches */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 0,
|
||||
borderRadius: "var(--r)",
|
||||
overflow: "hidden",
|
||||
height: 48,
|
||||
width: "100%",
|
||||
border: "1px solid rgba(128, 128, 128, 0.15)",
|
||||
}}
|
||||
>
|
||||
{theme.colorPreview.map((color, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
flex: 1,
|
||||
background: color,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Theme info */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.9rem",
|
||||
fontWeight: 600,
|
||||
color: "var(--text)",
|
||||
}}
|
||||
>
|
||||
{theme.name}
|
||||
</span>
|
||||
{isActive && (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="var(--primary)"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<polyline points="13 4 6 12 3 9" />
|
||||
</svg>
|
||||
)}
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.7rem",
|
||||
padding: "2px 6px",
|
||||
borderRadius: "var(--r-sm)",
|
||||
background: theme.isDark ? "rgba(128,128,128,0.15)" : "rgba(245,158,11,0.12)",
|
||||
color: theme.isDark ? "var(--muted)" : "var(--warn)",
|
||||
fontWeight: 500,
|
||||
marginLeft: "auto",
|
||||
}}
|
||||
>
|
||||
{theme.isDark ? "Dark" : "Light"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p
|
||||
style={{
|
||||
fontSize: "0.78rem",
|
||||
color: "var(--muted)",
|
||||
margin: 0,
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{theme.description}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SystemThemeCard({
|
||||
isActive,
|
||||
onSelect,
|
||||
}: {
|
||||
isActive: boolean;
|
||||
onSelect: () => void;
|
||||
}): ReactElement {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onSelect}
|
||||
onMouseEnter={() => {
|
||||
setHovered(true);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setHovered(false);
|
||||
}}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 12,
|
||||
padding: 16,
|
||||
borderRadius: "var(--r-lg)",
|
||||
background: isActive ? "var(--surface-2)" : hovered ? "var(--surface)" : "transparent",
|
||||
border: isActive
|
||||
? "2px solid var(--primary)"
|
||||
: `1px solid ${hovered ? "var(--border)" : "transparent"}`,
|
||||
cursor: "pointer",
|
||||
textAlign: "left",
|
||||
transition: "all 0.15s ease",
|
||||
width: "100%",
|
||||
}}
|
||||
aria-label="Use system theme preference"
|
||||
aria-pressed={isActive}
|
||||
>
|
||||
{/* Split preview (dark | light) */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 0,
|
||||
borderRadius: "var(--r)",
|
||||
overflow: "hidden",
|
||||
height: 48,
|
||||
width: "100%",
|
||||
border: "1px solid rgba(128, 128, 128, 0.15)",
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, background: "#0f141d" }} />
|
||||
<div style={{ flex: 1, background: "#1b2331" }} />
|
||||
<div style={{ flex: 1, background: "#f0f4fc" }} />
|
||||
<div style={{ flex: 1, background: "#dde4f2" }} />
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
background: "linear-gradient(135deg, #2f80ff 50%, #8b5cf6 50%)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={{ fontSize: "0.9rem", fontWeight: 600, color: "var(--text)" }}>System</span>
|
||||
{isActive && (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="var(--primary)"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<polyline points="13 4 6 12 3 9" />
|
||||
</svg>
|
||||
)}
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.7rem",
|
||||
padding: "2px 6px",
|
||||
borderRadius: "var(--r-sm)",
|
||||
background: "rgba(47, 128, 255, 0.12)",
|
||||
color: "var(--primary-l)",
|
||||
fontWeight: 500,
|
||||
marginLeft: "auto",
|
||||
}}
|
||||
>
|
||||
Auto
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p
|
||||
style={{
|
||||
fontSize: "0.78rem",
|
||||
color: "var(--muted)",
|
||||
margin: 0,
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
Follows your operating system appearance preference
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AppearanceSettingsPage(): ReactElement {
|
||||
const { theme: preference, setTheme: setLocalTheme } = useTheme();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const allThemes = getAllThemes();
|
||||
|
||||
const handleThemeSelect = useCallback(
|
||||
async (themeId: string) => {
|
||||
setLocalTheme(themeId);
|
||||
setSaving(true);
|
||||
try {
|
||||
await apiPatch("/users/me/preferences", { theme: themeId });
|
||||
} catch {
|
||||
// Theme is still applied locally even if API save fails
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[setLocalTheme]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
{/* Breadcrumb */}
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Link
|
||||
href="/settings"
|
||||
style={{
|
||||
fontSize: "0.83rem",
|
||||
color: "var(--muted)",
|
||||
textDecoration: "none",
|
||||
}}
|
||||
>
|
||||
Settings
|
||||
</Link>
|
||||
<span style={{ fontSize: "0.83rem", color: "var(--muted)", margin: "0 6px" }}>/</span>
|
||||
<span style={{ fontSize: "0.83rem", color: "var(--text-2)" }}>Appearance</span>
|
||||
</div>
|
||||
|
||||
{/* Page header */}
|
||||
<div style={{ marginBottom: 32 }}>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: "1.875rem",
|
||||
fontWeight: 700,
|
||||
color: "var(--text)",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
Appearance
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "0.9rem",
|
||||
color: "var(--muted)",
|
||||
margin: "8px 0 0 0",
|
||||
}}
|
||||
>
|
||||
Choose a theme for the Mosaic interface
|
||||
{saving && (
|
||||
<span style={{ marginLeft: 12, color: "var(--primary-l)", fontStyle: "italic" }}>
|
||||
Saving...
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Theme grid */}
|
||||
<div
|
||||
className="grid gap-3"
|
||||
style={{
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))",
|
||||
}}
|
||||
>
|
||||
{/* System option first */}
|
||||
<SystemThemeCard
|
||||
isActive={preference === "system"}
|
||||
onSelect={() => void handleThemeSelect("system")}
|
||||
/>
|
||||
|
||||
{/* All registered themes */}
|
||||
{allThemes.map((t) => (
|
||||
<ThemeCard
|
||||
key={t.id}
|
||||
theme={t}
|
||||
isActive={preference === t.id}
|
||||
onSelect={() => void handleThemeSelect(t.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -95,6 +95,31 @@ function SettingsCategoryCard({ category }: SettingsCategoryCardProps): ReactEle
|
||||
}
|
||||
|
||||
const categories: CategoryConfig[] = [
|
||||
{
|
||||
title: "Appearance",
|
||||
description:
|
||||
"Choose a theme for the interface. Switch between Dark, Light, Nord, Dracula, and more.",
|
||||
href: "/settings/appearance",
|
||||
accent: "var(--ms-pink-500)",
|
||||
iconBg: "rgba(236, 72, 153, 0.12)",
|
||||
icon: (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="10" cy="10" r="7.5" />
|
||||
<path d="M10 2.5v15" />
|
||||
<path d="M10 2.5a7.5 7.5 0 0 1 0 15" fill="currentColor" opacity="0.15" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Credentials",
|
||||
description:
|
||||
|
||||
@@ -2,33 +2,33 @@
|
||||
|
||||
> Single-writer: orchestrator only. Workers read but never modify.
|
||||
|
||||
| id | status | description | issue | repo | branch | depends_on | blocks | agent | started_at | completed_at | estimate | used | notes |
|
||||
| ----------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ---- | ---------------------------- | ------------------------------------------------------ | ------------------------------------------- | ------------ | ---------- | ------------ | -------- | ---- | ------------------------------------------ |
|
||||
| TW-PLAN-001 | done | Plan MS18 task breakdown, create milestone + issues, populate TASKS.md | — | — | — | | TW-THM-001,TW-WDG-001,TW-EDT-001,TW-KBN-001 | orchestrator | 2026-02-23 | 2026-02-23 | 15K | ~12K | Planning complete, all artifacts committed |
|
||||
| TW-THM-001 | done | Theme architecture — Create theme definition interface, theme registry, and 5 built-in themes (Dark, Light, Nord, Dracula, Solarized) as TS files | #487 | web | feat/ms18-theme-architecture | TW-PLAN-001 | TW-THM-002,TW-THM-003 | worker | 2026-02-23 | 2026-02-23 | 30K | ~15K | PR #493 merged |
|
||||
| TW-THM-002 | in-progress | ThemeProvider upgrade — Load themes dynamically from registry, apply CSS variables, support instant theme switching without page reload | #487 | web | TBD | TW-THM-001 | TW-THM-003,TW-VER-002 | worker | 2026-02-23 | — | 25K | — | |
|
||||
| TW-THM-003 | not-started | Theme selection UI — Settings page section with theme browser, live preview swatches, persist selection to UserPreference.theme via API | #487 | web | TBD | TW-THM-001,TW-THM-002 | TW-VER-002 | worker | — | — | 25K | — | |
|
||||
| TW-WDG-001 | not-started | Widget definition seeding — Seed 7 existing widgets into widget_definitions table with correct sizing constraints and configSchema | #488 | api | TBD | TW-PLAN-001 | TW-WDG-002 | worker | — | — | 15K | — | |
|
||||
| TW-WDG-002 | not-started | Dashboard → WidgetGrid migration — Replace hardcoded dashboard layout with WidgetGrid, load/save layout via UserLayout API, default layout on first visit | #488 | web | TBD | TW-WDG-001 | TW-WDG-003,TW-WDG-004,TW-WDG-005 | worker | — | — | 40K | — | |
|
||||
| TW-WDG-003 | not-started | Widget picker UI — Drawer/dialog to browse available widgets from registry, preview size/description, add to dashboard | #488 | web | TBD | TW-WDG-002 | TW-VER-001 | worker | — | — | 25K | — | |
|
||||
| TW-WDG-004 | not-started | Widget configuration UI — Per-widget settings dialog using configSchema, configure data source/filters/colors/title | #488 | web | TBD | TW-WDG-002 | TW-VER-001 | worker | — | — | 30K | — | |
|
||||
| TW-WDG-005 | not-started | Layout management UI — Save/rename/switch/delete layouts, reset to default. UI controls in dashboard header area | #488 | web | TBD | TW-WDG-002 | TW-VER-001 | worker | — | — | 20K | — | |
|
||||
| TW-EDT-001 | not-started | Tiptap integration — Install @tiptap/react + extensions, build KnowledgeEditor component with toolbar (headings, bold, italic, lists, code, links, tables) | #489 | web | TBD | TW-PLAN-001 | TW-EDT-002 | worker | — | — | 35K | — | |
|
||||
| TW-EDT-002 | not-started | Markdown round-trip + File Manager integration — Import markdown to Tiptap, export to markdown + HTML. Replace textarea in knowledge create/edit | #489 | web | TBD | TW-EDT-001 | TW-VER-001 | worker | — | — | 30K | — | |
|
||||
| TW-KBN-001 | not-started | Kanban filtering — Add filter bar (project, assignee, priority, search). Support project-level and user-level views. URL param persistence | #490 | web | TBD | TW-PLAN-001 | TW-VER-001 | worker | — | — | 30K | — | |
|
||||
| TW-VER-001 | not-started | Tests — Unit tests for new components, update existing tests, fix any regressions | #491 | web | TBD | TW-WDG-003,TW-WDG-004,TW-WDG-005,TW-EDT-002,TW-KBN-001 | TW-VER-002,TW-DOC-001 | worker | — | — | 25K | — | |
|
||||
| TW-VER-002 | not-started | Theme verification — Verify all 5 themes render correctly on all pages, no broken colors/contrast issues | #491 | web | TBD | TW-THM-003,TW-VER-001 | TW-DOC-001 | worker | — | — | 15K | — | |
|
||||
| TW-DOC-001 | not-started | Documentation updates — TASKS.md, manifest, scratchpad, PRD status updates | #491 | — | — | TW-VER-001,TW-VER-002 | TW-VER-003 | orchestrator | — | — | 10K | — | |
|
||||
| TW-VER-003 | not-started | Deploy to Coolify + smoke test — Deploy, verify themes/widgets/editor/kanban all functional, auth working, no console errors | #491 | — | — | TW-DOC-001 | | orchestrator | — | — | 15K | — | |
|
||||
| id | status | description | issue | repo | branch | depends_on | blocks | agent | started_at | completed_at | estimate | used | notes |
|
||||
| ----------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ---- | -------------------------------- | ------------------------------------------------------ | ------------------------------------------- | ------------ | ---------- | ------------ | -------- | ---- | ------------------------------------------ |
|
||||
| TW-PLAN-001 | done | Plan MS18 task breakdown, create milestone + issues, populate TASKS.md | — | — | — | | TW-THM-001,TW-WDG-001,TW-EDT-001,TW-KBN-001 | orchestrator | 2026-02-23 | 2026-02-23 | 15K | ~12K | Planning complete, all artifacts committed |
|
||||
| TW-THM-001 | done | Theme architecture — Create theme definition interface, theme registry, and 5 built-in themes (Dark, Light, Nord, Dracula, Solarized) as TS files | #487 | web | feat/ms18-theme-architecture | TW-PLAN-001 | TW-THM-002,TW-THM-003 | worker | 2026-02-23 | 2026-02-23 | 30K | ~15K | PR #493 merged |
|
||||
| TW-THM-002 | done | ThemeProvider upgrade — Load themes dynamically from registry, apply CSS variables, support instant theme switching without page reload | #487 | web | feat/ms18-theme-provider-upgrade | TW-THM-001 | TW-THM-003,TW-VER-002 | worker | 2026-02-23 | 2026-02-23 | 25K | ~12K | PR #494 merged |
|
||||
| TW-THM-003 | in-progress | Theme selection UI — Settings page section with theme browser, live preview swatches, persist selection to UserPreference.theme via API | #487 | web | feat/ms18-theme-selection-ui | TW-THM-001,TW-THM-002 | TW-VER-002 | worker | 2026-02-23 | — | 25K | — | |
|
||||
| TW-WDG-001 | not-started | Widget definition seeding — Seed 7 existing widgets into widget_definitions table with correct sizing constraints and configSchema | #488 | api | TBD | TW-PLAN-001 | TW-WDG-002 | worker | — | — | 15K | — | |
|
||||
| TW-WDG-002 | not-started | Dashboard → WidgetGrid migration — Replace hardcoded dashboard layout with WidgetGrid, load/save layout via UserLayout API, default layout on first visit | #488 | web | TBD | TW-WDG-001 | TW-WDG-003,TW-WDG-004,TW-WDG-005 | worker | — | — | 40K | — | |
|
||||
| TW-WDG-003 | not-started | Widget picker UI — Drawer/dialog to browse available widgets from registry, preview size/description, add to dashboard | #488 | web | TBD | TW-WDG-002 | TW-VER-001 | worker | — | — | 25K | — | |
|
||||
| TW-WDG-004 | not-started | Widget configuration UI — Per-widget settings dialog using configSchema, configure data source/filters/colors/title | #488 | web | TBD | TW-WDG-002 | TW-VER-001 | worker | — | — | 30K | — | |
|
||||
| TW-WDG-005 | not-started | Layout management UI — Save/rename/switch/delete layouts, reset to default. UI controls in dashboard header area | #488 | web | TBD | TW-WDG-002 | TW-VER-001 | worker | — | — | 20K | — | |
|
||||
| TW-EDT-001 | not-started | Tiptap integration — Install @tiptap/react + extensions, build KnowledgeEditor component with toolbar (headings, bold, italic, lists, code, links, tables) | #489 | web | TBD | TW-PLAN-001 | TW-EDT-002 | worker | — | — | 35K | — | |
|
||||
| TW-EDT-002 | not-started | Markdown round-trip + File Manager integration — Import markdown to Tiptap, export to markdown + HTML. Replace textarea in knowledge create/edit | #489 | web | TBD | TW-EDT-001 | TW-VER-001 | worker | — | — | 30K | — | |
|
||||
| TW-KBN-001 | not-started | Kanban filtering — Add filter bar (project, assignee, priority, search). Support project-level and user-level views. URL param persistence | #490 | web | TBD | TW-PLAN-001 | TW-VER-001 | worker | — | — | 30K | — | |
|
||||
| TW-VER-001 | not-started | Tests — Unit tests for new components, update existing tests, fix any regressions | #491 | web | TBD | TW-WDG-003,TW-WDG-004,TW-WDG-005,TW-EDT-002,TW-KBN-001 | TW-VER-002,TW-DOC-001 | worker | — | — | 25K | — | |
|
||||
| TW-VER-002 | not-started | Theme verification — Verify all 5 themes render correctly on all pages, no broken colors/contrast issues | #491 | web | TBD | TW-THM-003,TW-VER-001 | TW-DOC-001 | worker | — | — | 15K | — | |
|
||||
| TW-DOC-001 | not-started | Documentation updates — TASKS.md, manifest, scratchpad, PRD status updates | #491 | — | — | TW-VER-001,TW-VER-002 | TW-VER-003 | orchestrator | — | — | 10K | — | |
|
||||
| TW-VER-003 | not-started | Deploy to Coolify + smoke test — Deploy, verify themes/widgets/editor/kanban all functional, auth working, no console errors | #491 | — | — | TW-DOC-001 | | orchestrator | — | — | 15K | — | |
|
||||
|
||||
## Summary
|
||||
|
||||
| Metric | Value |
|
||||
| ------------- | --------------------- |
|
||||
| Total tasks | 16 |
|
||||
| Completed | 2 (PLAN-001, THM-001) |
|
||||
| In Progress | 1 (THM-002) |
|
||||
| Remaining | 13 |
|
||||
| PRs merged | — |
|
||||
| Issues closed | — |
|
||||
| Milestone | MS18-ThemeWidgets |
|
||||
| Metric | Value |
|
||||
| ------------- | ------------------------------ |
|
||||
| Total tasks | 16 |
|
||||
| Completed | 3 (PLAN-001, THM-001, THM-002) |
|
||||
| In Progress | 1 (THM-003) |
|
||||
| Remaining | 12 |
|
||||
| PRs merged | #493, #494 |
|
||||
| Issues closed | — |
|
||||
| Milestone | MS18-ThemeWidgets |
|
||||
|
||||
Reference in New Issue
Block a user