diff --git a/src/application/policies/color-scheme.js b/src/application/policies/color-scheme.js new file mode 100644 index 0000000..97e15d6 --- /dev/null +++ b/src/application/policies/color-scheme.js @@ -0,0 +1,21 @@ +export const COLOR_SCHEME_PREFERENCES = Object.freeze([ + "system", + "light", + "dark", +]); + +/** @param {unknown} value */ +export function normalizeColorSchemePreference(value) { + return COLOR_SCHEME_PREFERENCES.includes(/** @type {string} */ (value)) + ? /** @type {"system" | "light" | "dark"} */ (value) + : "system"; +} + +/** + * @param {"system" | "light" | "dark"} preference + * @param {boolean} systemPrefersDark + */ +export function resolveColorScheme(preference, systemPrefersDark) { + if (preference === "system") return systemPrefersDark ? "dark" : "light"; + return preference; +} diff --git a/src/bootstrap/initialize-color-scheme.js b/src/bootstrap/initialize-color-scheme.js new file mode 100644 index 0000000..3492cd6 --- /dev/null +++ b/src/bootstrap/initialize-color-scheme.js @@ -0,0 +1,36 @@ +import { + normalizeColorSchemePreference, + resolveColorScheme, +} from "../application/policies/color-scheme.js"; + +/** + * Applies the persisted public preference before React paints. + * + * @param {import("../application/ports/storage-port.js").StoragePort} storage + * @param {{ + * documentElement?: HTMLElement, + * matchMedia?: (query: string) => MediaQueryList + * }} [browser] + */ +export function initializeColorScheme(storage, browser = {}) { + const documentElement = browser.documentElement ?? document.documentElement; + const matchMedia = + browser.matchMedia ?? + (typeof window.matchMedia === "function" + ? window.matchMedia.bind(window) + : () => /** @type {MediaQueryList} */ ({ matches: false })); + const stored = storage.read("COLOR_SCHEME"); + const preference = normalizeColorSchemePreference( + stored.ok ? stored.value : undefined, + ); + const resolved = resolveColorScheme( + preference, + matchMedia("(prefers-color-scheme: dark)").matches, + ); + + documentElement.dataset.theme = resolved; + documentElement.dataset.themePreference = preference; + documentElement.style.colorScheme = resolved; + + return Object.freeze({ preference, resolved }); +} diff --git a/src/bootstrap/main.jsx b/src/bootstrap/main.jsx index deb5ddc..c6ee9bb 100644 --- a/src/bootstrap/main.jsx +++ b/src/bootstrap/main.jsx @@ -5,6 +5,7 @@ import { QueryClientProvider } from "@tanstack/react-query"; import { BootErrorShell } from "../presentation/boundaries/boot-error-shell.jsx"; import { AppRouter } from "../presentation/routes/app-router.jsx"; import { createRuntimeComposition } from "./create-runtime-composition.js"; +import { initializeColorScheme } from "./initialize-color-scheme.js"; import { BootConfigError } from "./load-runtime-config.js"; import { ReleaseManifestError } from "./load-release-manifest.js"; import "../presentation/styles/theme.css"; @@ -20,6 +21,7 @@ const root = createRoot(rootElement); async function boot() { try { const composition = await createRuntimeComposition(); + initializeColorScheme(composition.ports.storage); root.render( @@ -27,6 +29,7 @@ async function boot() { authSession={composition.ports.authSession} basename={composition.config.build.routerBasePath} buildId={composition.release.buildId} + storage={composition.ports.storage} telemetry={composition.ports.telemetry} /> diff --git a/src/presentation/layouts/app-shell.jsx b/src/presentation/layouts/app-shell.jsx index 35f70d6..6147dd7 100644 --- a/src/presentation/layouts/app-shell.jsx +++ b/src/presentation/layouts/app-shell.jsx @@ -3,6 +3,7 @@ import { NavLink, Outlet, useLocation } from "react-router-dom"; import { NAVIGATION_ROUTES, routePath } from "../../contracts/routes.js"; import { useSession } from "../providers/session-provider.jsx"; +import { useTheme } from "../providers/theme-provider.jsx"; const SESSION_LABELS = Object.freeze({ authenticated: "인증됨", @@ -14,6 +15,7 @@ const SESSION_LABELS = Object.freeze({ export function AppShell() { const location = useLocation(); const { sessionState, beginSignIn, signOut, recover } = useSession(); + const { preference, setPreference } = useTheme(); const [navigationOpen, setNavigationOpen] = useState(false); const [sessionActionPending, setSessionActionPending] = useState(false); const [sessionActionFailed, setSessionActionFailed] = useState(false); @@ -80,6 +82,25 @@ export function AppShell() { Frontend Skeleton
+ + {SESSION_LABELS[sessionState]} diff --git a/src/presentation/providers/theme-provider.jsx b/src/presentation/providers/theme-provider.jsx new file mode 100644 index 0000000..eb181f7 --- /dev/null +++ b/src/presentation/providers/theme-provider.jsx @@ -0,0 +1,86 @@ +import { + createContext, + useContext, + useEffect, + useLayoutEffect, + useMemo, + useState, +} from "react"; + +import { + normalizeColorSchemePreference, + resolveColorScheme, +} from "../../application/policies/color-scheme.js"; + +/** + * @typedef {{ + * preference: "system" | "light" | "dark", + * resolvedTheme: "light" | "dark", + * setPreference: (preference: "system" | "light" | "dark") => void + * }} ThemeContextValue + */ + +const ThemeContext = createContext(/** @type {ThemeContextValue | null} */ (null)); + +function systemPrefersDark() { + return ( + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-color-scheme: dark)").matches + ); +} + +/** + * @param {{ + * storage?: import("../../application/ports/storage-port.js").StoragePort, + * children: React.ReactNode + * }} props + */ +export function ThemeProvider({ storage, children }) { + const [preference, updatePreference] = useState(() => { + const result = storage?.read("COLOR_SCHEME"); + return normalizeColorSchemePreference( + result?.ok ? result.value : undefined, + ); + }); + const [darkSystemTheme, setDarkSystemTheme] = useState(systemPrefersDark); + const resolvedTheme = resolveColorScheme(preference, darkSystemTheme); + + useEffect(() => { + if (typeof window.matchMedia !== "function") return undefined; + const query = window.matchMedia("(prefers-color-scheme: dark)"); + /** @param {MediaQueryListEvent} event */ + const update = (event) => setDarkSystemTheme(event.matches); + setDarkSystemTheme(query.matches); + query.addEventListener("change", update); + return () => query.removeEventListener("change", update); + }, []); + + useLayoutEffect(() => { + document.documentElement.dataset.theme = resolvedTheme; + document.documentElement.dataset.themePreference = preference; + document.documentElement.style.colorScheme = resolvedTheme; + }, [preference, resolvedTheme]); + + const value = useMemo( + () => + Object.freeze({ + preference, + resolvedTheme, + /** @param {"system" | "light" | "dark"} next */ + setPreference(next) { + const normalized = normalizeColorSchemePreference(next); + updatePreference(normalized); + storage?.write("COLOR_SCHEME", normalized); + }, + }), + [preference, resolvedTheme, storage], + ); + + return {children}; +} + +export function useTheme() { + const theme = useContext(ThemeContext); + if (!theme) throw new Error("ThemeProvider is required"); + return theme; +} diff --git a/src/presentation/routes/app-router.jsx b/src/presentation/routes/app-router.jsx index 8cfc004..d7727a9 100644 --- a/src/presentation/routes/app-router.jsx +++ b/src/presentation/routes/app-router.jsx @@ -11,6 +11,7 @@ import { RouteBoundary } from "../boundaries/render-error-boundary.jsx"; import { AppShell } from "../layouts/app-shell.jsx"; import { PageHeader } from "../components/page-header.jsx"; import { SessionProvider, useSession } from "../providers/session-provider.jsx"; +import { ThemeProvider } from "../providers/theme-provider.jsx"; import { decideRouteAccess } from "./navigation-policy.js"; const HomePage = lazy(() => import("../pages/home-page.jsx")); @@ -171,6 +172,7 @@ function PublicRoute({ routeId, buildId, telemetry, children }) { * authSession: import("../../application/ports/auth-session-port.js").AuthSessionPort, * basename?: string, * buildId?: string, + * storage?: import("../../application/ports/storage-port.js").StoragePort, * telemetry?: import("../../application/ports/telemetry-port.js").TelemetryPort * }} props */ @@ -178,13 +180,15 @@ export function AppRouter({ authSession, basename = "/", buildId = "local-build", + storage, telemetry, }) { return ( - - - }> + + + + }> } /> - - - + + + + ); } diff --git a/src/presentation/styles/theme.css b/src/presentation/styles/theme.css index 8b9eebf..7653fe8 100644 --- a/src/presentation/styles/theme.css +++ b/src/presentation/styles/theme.css @@ -3,12 +3,28 @@ @theme { --color-surface: oklch(0.985 0.003 247); --color-surface-muted: oklch(0.94 0.01 247); + --color-panel: oklch(1 0 0); + --color-border: oklch(0.87 0.015 247); --color-content: oklch(0.25 0.025 247); --color-content-muted: oklch(0.48 0.025 247); --color-action: oklch(0.55 0.18 255); --color-action-hover: oklch(0.48 0.2 255); --color-danger: oklch(0.55 0.2 25); + --color-danger-hover: oklch(0.47 0.2 25); + --color-on-action: oklch(1 0 0); --color-focus: oklch(0.72 0.16 225); + --color-info-content: oklch(0.38 0.16 255); + --color-info-surface: oklch(0.95 0.03 255); + --color-info-border: oklch(0.75 0.08 250); + --color-success-content: oklch(0.35 0.12 155); + --color-success-surface: oklch(0.95 0.04 155); + --color-success-border: oklch(0.72 0.1 155); + --color-warning-content: oklch(0.38 0.12 70); + --color-warning-surface: oklch(0.96 0.05 80); + --color-warning-border: oklch(0.75 0.12 80); + --color-danger-content: oklch(0.42 0.18 25); + --color-danger-surface: oklch(0.96 0.035 25); + --color-danger-border: oklch(0.72 0.12 25); --radius-control: 0.5rem; --radius-surface: 0.75rem; --spacing-page: 1.5rem; @@ -22,6 +38,33 @@ font-family: var(--font-sans); } + :root[data-theme="dark"] { + --color-surface: oklch(0.16 0.02 255); + --color-surface-muted: oklch(0.25 0.025 255); + --color-panel: oklch(0.205 0.022 255); + --color-border: oklch(0.36 0.025 255); + --color-content: oklch(0.94 0.012 255); + --color-content-muted: oklch(0.74 0.025 255); + --color-action: oklch(0.7 0.14 250); + --color-action-hover: oklch(0.79 0.12 245); + --color-danger: oklch(0.68 0.19 25); + --color-danger-hover: oklch(0.76 0.16 25); + --color-on-action: oklch(0.16 0.02 255); + --color-focus: oklch(0.82 0.15 220); + --color-info-content: oklch(0.83 0.09 250); + --color-info-surface: oklch(0.27 0.045 255); + --color-info-border: oklch(0.55 0.09 250); + --color-success-content: oklch(0.83 0.1 155); + --color-success-surface: oklch(0.27 0.045 155); + --color-success-border: oklch(0.53 0.1 155); + --color-warning-content: oklch(0.88 0.1 80); + --color-warning-surface: oklch(0.29 0.045 80); + --color-warning-border: oklch(0.58 0.11 80); + --color-danger-content: oklch(0.84 0.11 25); + --color-danger-surface: oklch(0.28 0.055 25); + --color-danger-border: oklch(0.56 0.13 25); + } + body { margin: 0; min-width: 20rem; @@ -81,7 +124,7 @@ left: 0.75rem; padding: 0.75rem 1rem; border-radius: var(--radius-control); - color: white; + color: var(--color-panel); background: var(--color-content); transform: translateY(-200%); } @@ -111,7 +154,7 @@ min-width: 0; padding: 0.75rem 1.25rem; border-bottom: 1px solid var(--color-surface-muted); - background: color-mix(in oklch, white 92%, var(--color-surface)); + background: color-mix(in oklch, var(--color-panel) 92%, var(--color-surface)); box-shadow: 0 1px 8px color-mix(in oklch, var(--color-content) 8%, transparent); } @@ -132,7 +175,7 @@ border-radius: var(--radius-control); padding: 0.55rem 0.75rem; color: var(--color-content); - background: white; + background: var(--color-panel); cursor: pointer; } @@ -143,13 +186,24 @@ margin-inline-start: auto; } + .theme-selector { + min-height: 2.25rem; + max-width: 8.5rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-control); + padding: 0.35rem 2rem 0.35rem 0.65rem; + color: var(--color-content); + background: var(--color-panel); + cursor: pointer; + } + .app-shell__session-error { position: absolute; top: calc(100% + 0.25rem); right: 1rem; padding: 0.5rem; color: var(--color-danger); - background: white; + background: var(--color-panel); } .session-status { @@ -165,8 +219,8 @@ } .session-status[data-state="authenticated"] { - color: oklch(0.35 0.12 155); - background: oklch(0.93 0.05 155); + color: var(--color-success-content); + background: var(--color-success-surface); } .session-status[data-state="integration-failed"] { @@ -179,8 +233,8 @@ grid-area: sidebar; height: calc(100vh - 4.5rem); padding: 1.25rem 0.75rem; - border-right: 1px solid var(--color-surface-muted); - background: white; + border-right: 1px solid var(--color-border); + background: var(--color-panel); } .app-navigation { @@ -208,7 +262,11 @@ .app-navigation__link.is-active { color: var(--color-action-hover); - background: color-mix(in oklch, var(--color-action) 10%, white); + background: color-mix( + in oklch, + var(--color-action) 14%, + var(--color-panel) + ); font-weight: 800; } @@ -224,16 +282,19 @@ } .ui-panel { - @apply rounded-surface border border-surface-muted bg-white p-6 shadow-sm; + @apply rounded-surface p-6 shadow-sm; + border: 1px solid var(--color-border); + background: var(--color-panel); } .ui-button { - @apply rounded-control bg-action px-4 py-2 font-semibold text-white; + @apply rounded-control bg-action px-4 py-2 font-semibold; display: inline-flex; min-height: 2.75rem; align-items: center; justify-content: center; border: 0; + color: var(--color-on-action); text-decoration: none; cursor: pointer; } @@ -250,7 +311,7 @@ .ui-button--secondary { border: 1px solid var(--color-surface-muted); color: var(--color-content); - background: white; + background: var(--color-panel); } .ui-button--secondary:hover { @@ -259,12 +320,12 @@ } .ui-button--danger { - color: white; + color: var(--color-on-action); background: var(--color-danger); } .ui-button--danger:hover { - background: oklch(0.47 0.2 25); + background: var(--color-danger-hover); } .ui-button--ghost { @@ -321,11 +382,12 @@ width: 100%; min-height: 2.75rem; box-sizing: border-box; - border: 1px solid color-mix(in oklch, var(--color-content-muted) 55%, white); + border: 1px solid + color-mix(in oklch, var(--color-content-muted) 55%, var(--color-panel)); border-radius: var(--radius-control); padding: 0.65rem 0.8rem; color: var(--color-content); - background: white; + background: var(--color-panel); } .ui-field__input[aria-invalid="true"] { @@ -337,10 +399,10 @@ min-width: 0; flex-direction: column; gap: 1.25rem; - border: 1px solid var(--color-surface-muted); + border: 1px solid var(--color-border); border-radius: var(--radius-surface); padding: 1.25rem; - background: white; + background: var(--color-panel); box-shadow: 0 8px 28px color-mix(in oklch, var(--color-content) 6%, transparent); } @@ -376,23 +438,23 @@ } .ui-alert--info { - border-color: oklch(0.75 0.08 250); - background: oklch(0.96 0.025 250); + border-color: var(--color-info-border); + background: var(--color-info-surface); } .ui-alert--success { - border-color: oklch(0.72 0.1 155); - background: oklch(0.96 0.03 155); + border-color: var(--color-success-border); + background: var(--color-success-surface); } .ui-alert--warning { - border-color: oklch(0.75 0.12 80); - background: oklch(0.97 0.035 80); + border-color: var(--color-warning-border); + background: var(--color-warning-surface); } .ui-alert--danger { - border-color: oklch(0.72 0.12 25); - background: oklch(0.96 0.03 25); + border-color: var(--color-danger-border); + background: var(--color-danger-surface); } .ui-alert__content, @@ -432,23 +494,23 @@ } .ui-badge--info { - color: oklch(0.38 0.16 255); - background: oklch(0.93 0.04 255); + color: var(--color-info-content); + background: var(--color-info-surface); } .ui-badge--success { - color: oklch(0.35 0.12 155); - background: oklch(0.93 0.05 155); + color: var(--color-success-content); + background: var(--color-success-surface); } .ui-badge--warning { - color: oklch(0.38 0.12 70); - background: oklch(0.94 0.06 80); + color: var(--color-warning-content); + background: var(--color-warning-surface); } .ui-badge--danger { - color: oklch(0.42 0.18 25); - background: oklch(0.94 0.045 25); + color: var(--color-danger-content); + background: var(--color-danger-surface); } .ui-dialog { @@ -460,7 +522,7 @@ border-radius: var(--radius-surface); padding: 0; color: var(--color-content); - background: white; + background: var(--color-panel); box-shadow: 0 24px 70px color-mix(in oklch, var(--color-content) 25%, transparent); } @@ -508,6 +570,10 @@ padding-block: clamp(1rem, 5vw, 3.5rem) 0.5rem; } + [data-route-heading]:focus { + outline: none; + } + .page-header__eyebrow { margin-block-end: 0.75rem; color: var(--color-action); @@ -612,10 +678,10 @@ display: grid; gap: 0.45rem; min-width: 0; - border: 1px solid var(--color-surface-muted); + border: 1px solid var(--color-border); border-radius: var(--radius-control); padding: 0.75rem; - background: white; + background: var(--color-panel); } .token-swatch__color { @@ -633,9 +699,6 @@ } .gallery-notice { - position: sticky; - z-index: 10; - bottom: 1rem; display: block; width: fit-content; max-width: 100%; @@ -644,7 +707,7 @@ border-radius: 999px; padding: 0.65rem 1rem; color: var(--color-content); - background: white; + background: var(--color-panel); box-shadow: 0 8px 28px color-mix(in oklch, var(--color-content) 10%, transparent); font-size: 0.875rem; font-weight: 650; @@ -681,14 +744,14 @@ } .state-surface--warning { - border-color: oklch(0.75 0.12 80); - background: oklch(0.97 0.035 80); + border-color: var(--color-warning-border); + background: var(--color-warning-surface); } .state-surface--danger, .ui-terminal-error { - border-color: oklch(0.72 0.12 25); - background: oklch(0.98 0.02 25); + border-color: var(--color-danger-border); + background: var(--color-danger-surface); } .state-surface--loading { @@ -757,6 +820,10 @@ display: none; } + .app-shell__brand { + display: none; + } + .readiness-grid { grid-template-columns: minmax(0, 1fr); } diff --git a/tests/e2e/theme.spec.js b/tests/e2e/theme.spec.js new file mode 100644 index 0000000..1f4ff4a --- /dev/null +++ b/tests/e2e/theme.spec.js @@ -0,0 +1,38 @@ +import AxeBuilder from "@axe-core/playwright"; +import { expect, test } from "@playwright/test"; + +test("persists an explicit color scheme through the storage contract", async ({ + page, +}) => { + await page.goto("/"); + const selector = page.getByRole("combobox", { name: "색상 테마" }); + + await selector.selectOption("dark"); + await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); + await expect(page.locator("html")).toHaveCSS("color-scheme", "dark"); + const results = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) + .analyze(); + expect( + results.violations.filter((violation) => + ["critical", "serious"].includes(violation.impact ?? ""), + ), + ).toEqual([]); + + await page.reload(); + await expect(selector).toHaveValue("dark"); + await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); +}); + +test("tracks operating-system changes while system preference is selected", async ({ + page, +}) => { + await page.emulateMedia({ colorScheme: "dark" }); + await page.goto("/"); + const selector = page.getByRole("combobox", { name: "색상 테마" }); + await selector.selectOption("system"); + await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); + + await page.emulateMedia({ colorScheme: "light" }); + await expect(page.locator("html")).toHaveAttribute("data-theme", "light"); +}); diff --git a/tests/unit/color-scheme.test.js b/tests/unit/color-scheme.test.js new file mode 100644 index 0000000..065aa96 --- /dev/null +++ b/tests/unit/color-scheme.test.js @@ -0,0 +1,44 @@ +// @vitest-environment jsdom + +import { describe, expect, it } from "vitest"; + +import { + normalizeColorSchemePreference, + resolveColorScheme, +} from "../../src/application/policies/color-scheme.js"; +import { initializeColorScheme } from "../../src/bootstrap/initialize-color-scheme.js"; + +describe("color scheme policy", () => { + it("normalizes unknown persisted values to the safe system default", () => { + expect(normalizeColorSchemePreference("sepia")).toBe("system"); + expect(normalizeColorSchemePreference("dark")).toBe("dark"); + }); + + it("resolves system and explicit preferences deterministically", () => { + expect(resolveColorScheme("system", true)).toBe("dark"); + expect(resolveColorScheme("system", false)).toBe("light"); + expect(resolveColorScheme("light", true)).toBe("light"); + }); + + it("applies a persisted preference before application paint", () => { + const storage = { + read: () => ({ ok: true, value: "system" }), + write: () => ({ ok: true }), + remove: () => ({ ok: true }), + }; + + const result = initializeColorScheme(storage, { + documentElement: document.documentElement, + matchMedia: () => + /** @type {MediaQueryList} */ ({ matches: true }), + }); + + expect(result).toEqual({ preference: "system", resolved: "dark" }); + expect(document.documentElement).toHaveAttribute("data-theme", "dark"); + expect(document.documentElement).toHaveAttribute( + "data-theme-preference", + "system", + ); + expect(document.documentElement.style.colorScheme).toBe("dark"); + }); +});