feat(web): upgrade ThemeProvider for multi-theme registry #494
@@ -3,10 +3,12 @@ import { render, screen, act } from "@testing-library/react";
|
|||||||
import { ThemeProvider, useTheme } from "./ThemeProvider";
|
import { ThemeProvider, useTheme } from "./ThemeProvider";
|
||||||
|
|
||||||
function ThemeConsumer(): React.JSX.Element {
|
function ThemeConsumer(): React.JSX.Element {
|
||||||
const { theme, resolvedTheme, setTheme, toggleTheme } = useTheme();
|
const { theme, themeId, themeDefinition, resolvedTheme, setTheme, toggleTheme } = useTheme();
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<span data-testid="theme">{theme}</span>
|
<span data-testid="theme">{theme}</span>
|
||||||
|
<span data-testid="themeId">{themeId}</span>
|
||||||
|
<span data-testid="themeName">{themeDefinition.name}</span>
|
||||||
<span data-testid="resolved">{resolvedTheme}</span>
|
<span data-testid="resolved">{resolvedTheme}</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -22,6 +24,27 @@ function ThemeConsumer(): React.JSX.Element {
|
|||||||
>
|
>
|
||||||
Set Dark
|
Set Dark
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setTheme("nord");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Set Nord
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setTheme("dracula");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Set Dracula
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setTheme("system");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Set System
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
toggleTheme();
|
toggleTheme();
|
||||||
@@ -38,7 +61,9 @@ describe("ThemeProvider", (): void => {
|
|||||||
|
|
||||||
beforeEach((): void => {
|
beforeEach((): void => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
document.documentElement.classList.remove("light", "dark");
|
document.documentElement.removeAttribute("data-theme");
|
||||||
|
// Clear any inline style properties set by theme application
|
||||||
|
document.documentElement.removeAttribute("style");
|
||||||
|
|
||||||
mockMatchMedia = vi.fn().mockReturnValue({
|
mockMatchMedia = vi.fn().mockReturnValue({
|
||||||
matches: false,
|
matches: false,
|
||||||
@@ -65,6 +90,7 @@ describe("ThemeProvider", (): void => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByTestId("theme")).toHaveTextContent("light");
|
expect(screen.getByTestId("theme")).toHaveTextContent("light");
|
||||||
|
expect(screen.getByTestId("themeId")).toHaveTextContent("light");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should NOT read from old 'jarvis-theme' storage key", (): void => {
|
it("should NOT read from old 'jarvis-theme' storage key", (): void => {
|
||||||
@@ -76,7 +102,6 @@ describe("ThemeProvider", (): void => {
|
|||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Should default to system, not read from jarvis-theme
|
|
||||||
expect(screen.getByTestId("theme")).toHaveTextContent("system");
|
expect(screen.getByTestId("theme")).toHaveTextContent("system");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -106,7 +131,6 @@ describe("ThemeProvider", (): void => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should throw when useTheme is used outside provider", (): void => {
|
it("should throw when useTheme is used outside provider", (): void => {
|
||||||
// Suppress console.error for expected error
|
|
||||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {
|
||||||
// Intentionally empty
|
// Intentionally empty
|
||||||
});
|
});
|
||||||
@@ -117,4 +141,201 @@ describe("ThemeProvider", (): void => {
|
|||||||
|
|
||||||
consoleSpy.mockRestore();
|
consoleSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should resolve 'system' to dark when OS prefers dark", (): void => {
|
||||||
|
mockMatchMedia.mockReturnValue({
|
||||||
|
matches: true,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ThemeProvider>
|
||||||
|
<ThemeConsumer />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("themeId")).toHaveTextContent("dark");
|
||||||
|
expect(screen.getByTestId("resolved")).toHaveTextContent("dark");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should resolve 'system' to light when OS prefers light", (): void => {
|
||||||
|
mockMatchMedia.mockReturnValue({
|
||||||
|
matches: false,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ThemeProvider>
|
||||||
|
<ThemeConsumer />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("themeId")).toHaveTextContent("light");
|
||||||
|
expect(screen.getByTestId("resolved")).toHaveTextContent("light");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should support Nord theme", (): void => {
|
||||||
|
localStorage.setItem("mosaic-theme", "nord");
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ThemeProvider>
|
||||||
|
<ThemeConsumer />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("theme")).toHaveTextContent("nord");
|
||||||
|
expect(screen.getByTestId("themeId")).toHaveTextContent("nord");
|
||||||
|
expect(screen.getByTestId("themeName")).toHaveTextContent("Nord");
|
||||||
|
expect(screen.getByTestId("resolved")).toHaveTextContent("dark");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should support Dracula theme", (): void => {
|
||||||
|
localStorage.setItem("mosaic-theme", "dracula");
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ThemeProvider>
|
||||||
|
<ThemeConsumer />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("themeId")).toHaveTextContent("dracula");
|
||||||
|
expect(screen.getByTestId("resolved")).toHaveTextContent("dark");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should support Solarized Dark theme", (): void => {
|
||||||
|
localStorage.setItem("mosaic-theme", "solarized-dark");
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ThemeProvider>
|
||||||
|
<ThemeConsumer />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("themeId")).toHaveTextContent("solarized-dark");
|
||||||
|
expect(screen.getByTestId("resolved")).toHaveTextContent("dark");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fall back to system for unknown theme IDs", (): void => {
|
||||||
|
localStorage.setItem("mosaic-theme", "nonexistent-theme");
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ThemeProvider>
|
||||||
|
<ThemeConsumer />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Falls back to "system" because "nonexistent-theme" is not a valid theme ID
|
||||||
|
expect(screen.getByTestId("theme")).toHaveTextContent("system");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should switch between themes via setTheme", (): void => {
|
||||||
|
render(
|
||||||
|
<ThemeProvider>
|
||||||
|
<ThemeConsumer />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
screen.getByText("Set Nord").click();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByTestId("themeId")).toHaveTextContent("nord");
|
||||||
|
expect(screen.getByTestId("themeName")).toHaveTextContent("Nord");
|
||||||
|
expect(localStorage.getItem("mosaic-theme")).toBe("nord");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
screen.getByText("Set Dracula").click();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByTestId("themeId")).toHaveTextContent("dracula");
|
||||||
|
expect(localStorage.getItem("mosaic-theme")).toBe("dracula");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should toggle between dark and light", (): void => {
|
||||||
|
localStorage.setItem("mosaic-theme", "dark");
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ThemeProvider>
|
||||||
|
<ThemeConsumer />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("resolved")).toHaveTextContent("dark");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
screen.getByText("Toggle").click();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByTestId("themeId")).toHaveTextContent("light");
|
||||||
|
expect(screen.getByTestId("resolved")).toHaveTextContent("light");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should toggle from a dark theme (nord) to light", (): void => {
|
||||||
|
localStorage.setItem("mosaic-theme", "nord");
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ThemeProvider>
|
||||||
|
<ThemeConsumer />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("resolved")).toHaveTextContent("dark");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
screen.getByText("Toggle").click();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByTestId("themeId")).toHaveTextContent("light");
|
||||||
|
expect(screen.getByTestId("resolved")).toHaveTextContent("light");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should apply CSS variables on theme change", (): void => {
|
||||||
|
render(
|
||||||
|
<ThemeProvider>
|
||||||
|
<ThemeConsumer />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
screen.getByText("Set Nord").click();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Nord's bg-900 is #2e3440
|
||||||
|
const bgValue = document.documentElement.style.getPropertyValue("--ms-bg-900");
|
||||||
|
expect(bgValue).toBe("#2e3440");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should set data-theme attribute based on isDark", (): void => {
|
||||||
|
render(
|
||||||
|
<ThemeProvider>
|
||||||
|
<ThemeConsumer />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
screen.getByText("Set Nord").click();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
screen.getByText("Set Light").click();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(document.documentElement.getAttribute("data-theme")).toBe("light");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should expose themeDefinition with full theme data", (): void => {
|
||||||
|
localStorage.setItem("mosaic-theme", "dark");
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ThemeProvider>
|
||||||
|
<ThemeConsumer />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("themeName")).toHaveTextContent("Dark");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,35 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react";
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
type Theme = "light" | "dark" | "system";
|
import {
|
||||||
|
type ThemeDefinition,
|
||||||
|
darkTheme,
|
||||||
|
getThemeOrDefault,
|
||||||
|
isValidThemeId,
|
||||||
|
themeToVariables,
|
||||||
|
} from "@/themes";
|
||||||
|
|
||||||
interface ThemeContextValue {
|
interface ThemeContextValue {
|
||||||
theme: Theme;
|
/** User preference: a theme ID (e.g. "dark", "nord") or "system" */
|
||||||
|
theme: string;
|
||||||
|
/** The active theme's ID after resolving "system" */
|
||||||
|
themeId: string;
|
||||||
|
/** The full active ThemeDefinition object */
|
||||||
|
themeDefinition: ThemeDefinition;
|
||||||
|
/** "light" or "dark" classification of the active theme */
|
||||||
resolvedTheme: "light" | "dark";
|
resolvedTheme: "light" | "dark";
|
||||||
setTheme: (theme: Theme) => void;
|
/** Set theme by ID or "system" */
|
||||||
|
setTheme: (theme: string) => void;
|
||||||
|
/** Quick toggle between "dark" and "light" themes */
|
||||||
toggleTheme: () => void;
|
toggleTheme: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15,105 +37,112 @@ const ThemeContext = createContext<ThemeContextValue | null>(null);
|
|||||||
|
|
||||||
const STORAGE_KEY = "mosaic-theme";
|
const STORAGE_KEY = "mosaic-theme";
|
||||||
|
|
||||||
function getSystemTheme(): "light" | "dark" {
|
function getSystemThemeId(): "light" | "dark" {
|
||||||
if (typeof window === "undefined") return "dark";
|
if (typeof window === "undefined") return "dark";
|
||||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStoredTheme(): Theme {
|
function getStoredPreference(): string {
|
||||||
if (typeof window === "undefined") return "system";
|
if (typeof window === "undefined") return "system";
|
||||||
const stored = localStorage.getItem(STORAGE_KEY);
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
if (stored === "light" || stored === "dark" || stored === "system") {
|
if (stored && (stored === "system" || isValidThemeId(stored))) {
|
||||||
return stored;
|
return stored;
|
||||||
}
|
}
|
||||||
return "system";
|
return "system";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function resolveThemeId(preference: string): string {
|
||||||
* Apply the resolved theme to the <html> element via data-theme attribute.
|
if (preference === "system") return getSystemThemeId();
|
||||||
* The default (no attribute or data-theme="dark") renders dark — dark is default.
|
return preference;
|
||||||
* Light theme requires data-theme="light".
|
}
|
||||||
*/
|
|
||||||
function applyThemeAttribute(resolved: "light" | "dark"): void {
|
function applyThemeVariables(themeDef: ThemeDefinition): void {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
if (resolved === "light") {
|
const vars = themeToVariables(themeDef);
|
||||||
root.setAttribute("data-theme", "light");
|
|
||||||
} else {
|
for (const [prop, value] of Object.entries(vars)) {
|
||||||
// Remove the attribute so the default (dark) CSS variables apply.
|
root.style.setProperty(prop, value);
|
||||||
root.removeAttribute("data-theme");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set data-theme attribute for CSS selectors that depend on light/dark
|
||||||
|
root.setAttribute("data-theme", themeDef.isDark ? "dark" : "light");
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ThemeProviderProps {
|
interface ThemeProviderProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
defaultTheme?: Theme;
|
defaultTheme?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ThemeProvider({
|
export function ThemeProvider({
|
||||||
children,
|
children,
|
||||||
defaultTheme = "system",
|
defaultTheme = "system",
|
||||||
}: ThemeProviderProps): React.JSX.Element {
|
}: ThemeProviderProps): React.JSX.Element {
|
||||||
const [theme, setThemeState] = useState<Theme>(defaultTheme);
|
const [preference, setPreference] = useState<string>(defaultTheme);
|
||||||
const [resolvedTheme, setResolvedTheme] = useState<"light" | "dark">("dark");
|
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
// Initialize theme from storage on mount
|
const themeId = useMemo(() => resolveThemeId(preference), [preference]);
|
||||||
|
const themeDefinition = useMemo(() => getThemeOrDefault(themeId), [themeId]);
|
||||||
|
const resolvedTheme = themeDefinition.isDark ? "dark" : "light";
|
||||||
|
|
||||||
|
// Initialize from storage on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
const storedTheme = getStoredTheme();
|
const stored = getStoredPreference();
|
||||||
const resolved = storedTheme === "system" ? getSystemTheme() : storedTheme;
|
setPreference(stored);
|
||||||
setThemeState(storedTheme);
|
|
||||||
setResolvedTheme(resolved);
|
const id = resolveThemeId(stored);
|
||||||
applyThemeAttribute(resolved);
|
const def = getThemeOrDefault(id);
|
||||||
|
applyThemeVariables(def);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Apply theme via data-theme attribute on html element
|
// Apply theme whenever preference changes (after mount)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
applyThemeVariables(themeDefinition);
|
||||||
|
}, [themeDefinition, mounted]);
|
||||||
|
|
||||||
const resolved = theme === "system" ? getSystemTheme() : theme;
|
// Listen for system theme changes when preference is "system"
|
||||||
applyThemeAttribute(resolved);
|
|
||||||
setResolvedTheme(resolved);
|
|
||||||
}, [theme, mounted]);
|
|
||||||
|
|
||||||
// Listen for system theme changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mounted || theme !== "system") return;
|
if (!mounted || preference !== "system") return;
|
||||||
|
|
||||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
const handleChange = (e: MediaQueryListEvent): void => {
|
const handleChange = (e: MediaQueryListEvent): void => {
|
||||||
const resolved = e.matches ? "dark" : "light";
|
const id = e.matches ? "dark" : "light";
|
||||||
setResolvedTheme(resolved);
|
const def = getThemeOrDefault(id);
|
||||||
applyThemeAttribute(resolved);
|
applyThemeVariables(def);
|
||||||
|
// Force re-render by updating preference to trigger useMemo recalc
|
||||||
|
setPreference("system");
|
||||||
};
|
};
|
||||||
|
|
||||||
mediaQuery.addEventListener("change", handleChange);
|
mediaQuery.addEventListener("change", handleChange);
|
||||||
return (): void => {
|
return (): void => {
|
||||||
mediaQuery.removeEventListener("change", handleChange);
|
mediaQuery.removeEventListener("change", handleChange);
|
||||||
};
|
};
|
||||||
}, [theme, mounted]);
|
}, [preference, mounted]);
|
||||||
|
|
||||||
const setTheme = useCallback((newTheme: Theme) => {
|
const setTheme = useCallback((newPreference: string) => {
|
||||||
setThemeState(newTheme);
|
setPreference(newPreference);
|
||||||
localStorage.setItem(STORAGE_KEY, newTheme);
|
localStorage.setItem(STORAGE_KEY, newPreference);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const toggleTheme = useCallback(() => {
|
const toggleTheme = useCallback(() => {
|
||||||
setTheme(resolvedTheme === "dark" ? "light" : "dark");
|
setTheme(resolvedTheme === "dark" ? "light" : "dark");
|
||||||
}, [resolvedTheme, setTheme]);
|
}, [resolvedTheme, setTheme]);
|
||||||
|
|
||||||
// Prevent flash by not rendering until mounted
|
// SSR placeholder — render children but with dark defaults
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return (
|
return (
|
||||||
<ThemeContext.Provider
|
<ThemeContext.Provider
|
||||||
value={{
|
value={{
|
||||||
theme: defaultTheme,
|
theme: defaultTheme,
|
||||||
|
themeId: "dark",
|
||||||
|
themeDefinition: darkTheme,
|
||||||
resolvedTheme: "dark",
|
resolvedTheme: "dark",
|
||||||
setTheme: (): void => {
|
setTheme: (): void => {
|
||||||
// No-op during SSR
|
/* no-op during SSR */
|
||||||
},
|
},
|
||||||
toggleTheme: (): void => {
|
toggleTheme: (): void => {
|
||||||
// No-op during SSR
|
/* no-op during SSR */
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -123,7 +152,16 @@ export function ThemeProvider({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeContext.Provider value={{ theme, resolvedTheme, setTheme, toggleTheme }}>
|
<ThemeContext.Provider
|
||||||
|
value={{
|
||||||
|
theme: preference,
|
||||||
|
themeId,
|
||||||
|
themeDefinition,
|
||||||
|
resolvedTheme,
|
||||||
|
setTheme,
|
||||||
|
toggleTheme,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</ThemeContext.Provider>
|
</ThemeContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
| id | status | description | issue | repo | branch | depends_on | blocks | agent | started_at | completed_at | estimate | used | notes |
|
| 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-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 | in-progress | 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 | — | 30K | — | |
|
| 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 | not-started | 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 | — | — | 25K | — | |
|
| 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-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-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-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 | — | |
|
||||||
@@ -23,12 +23,12 @@
|
|||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
| Metric | Value |
|
| Metric | Value |
|
||||||
| ------------- | ----------------- |
|
| ------------- | --------------------- |
|
||||||
| Total tasks | 16 |
|
| Total tasks | 16 |
|
||||||
| Completed | 1 (PLAN-001) |
|
| Completed | 2 (PLAN-001, THM-001) |
|
||||||
| In Progress | 1 (THM-001) |
|
| In Progress | 1 (THM-002) |
|
||||||
| Remaining | 14 |
|
| Remaining | 13 |
|
||||||
| PRs merged | — |
|
| PRs merged | — |
|
||||||
| Issues closed | — |
|
| Issues closed | — |
|
||||||
| Milestone | MS18-ThemeWidgets |
|
| Milestone | MS18-ThemeWidgets |
|
||||||
|
|||||||
Reference in New Issue
Block a user