diff --git a/app/accent-provider.tsx b/app/accent-provider.tsx new file mode 100644 index 0000000..e61c8e2 --- /dev/null +++ b/app/accent-provider.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { createContext, useCallback, useContext, useEffect, useState } from "react"; + +import { apiJson } from "@/lib/api"; +import { isValidAccentColor, type AccentColorId } from "@/lib/accent-colors"; + +export const ACCENT_STORAGE_KEY = "modelhub-accent"; + +type AccentContextValue = { + accent: AccentColorId | null; + setAccent: (accent: AccentColorId | null) => void; +}; + +const AccentContext = createContext(undefined); + +function applyAccentToDocument(accent: AccentColorId | null) { + const root = document.documentElement; + if (accent && accent !== "default") { + root.setAttribute("data-accent", accent); + } else { + root.removeAttribute("data-accent"); + } +} + +/** + * Aplica a preferência de accent do usuário (UserSettings.accentColor) em + * runtime. A pintura inicial acontece via script inline no layout (anti-flash, + * lendo o localStorage); este provider sincroniza com o valor do servidor. + */ +export function AccentProvider({ children }: { children: React.ReactNode }) { + const [accent, setAccentState] = useState(null); + + useEffect(() => { + let cancelled = false; + + async function loadAccent() { + try { + const data = await apiJson<{ settings?: { accentColor?: string | null } }>( + "/user/settings", + ); + if (cancelled) return; + const stored = data.settings?.accentColor; + if (isValidAccentColor(stored)) { + setAccentState(stored); + applyAccentToDocument(stored); + } + } catch { + // Sem sessão ou offline: mantém o que o script inline aplicou. + } + } + + void loadAccent(); + return () => { + cancelled = true; + }; + }, []); + + const setAccent = useCallback((next: AccentColorId | null) => { + setAccentState(next); + applyAccentToDocument(next); + try { + if (next && next !== "default") { + window.localStorage.setItem(ACCENT_STORAGE_KEY, next); + } else { + window.localStorage.removeItem(ACCENT_STORAGE_KEY); + } + } catch { + // localStorage bloqueado: apenas não persiste localmente. + } + }, []); + + return {children}; +} + +export function useAccent(): AccentContextValue { + const ctx = useContext(AccentContext); + if (!ctx) throw new Error("useAccent must be used within AccentProvider"); + return ctx; +} diff --git a/app/globals.css b/app/globals.css index 0faa5a3..3f14539 100644 --- a/app/globals.css +++ b/app/globals.css @@ -204,6 +204,133 @@ } } +/* + * Accent color presets (issue #177). + * Selecionados via atributo data-accent no . "default" (sem atributo) + * mantém o tema two-tone atual: navy no claro, âmbar no escuro. + * Cada preset sobrescreve --primary/--ring/--accent para dar destaque + * consistente em ambos os modos; o restante das vars segue o tema base. + */ +[data-accent="blue"] { + --primary: oklch(0.48 0.19 262.5); + --primary-foreground: oklch(0.985 0 0); + --ring: oklch(0.62 0.19 262.5); + --accent: oklch(0.93 0.03 255); + --accent-foreground: oklch(0.38 0.13 265); + --sidebar-primary: oklch(0.62 0.19 262.5); + --sidebar-ring: oklch(0.62 0.19 262.5); +} + +.dark[data-accent="blue"] { + --primary: oklch(0.68 0.16 262.5); + --primary-foreground: oklch(0.16 0.03 262); + --ring: oklch(0.68 0.16 262.5); + --accent: oklch(0.33 0.06 262); + --accent-foreground: oklch(0.78 0.12 262.5); + --sidebar-primary: oklch(0.68 0.16 262.5); + --sidebar-ring: oklch(0.68 0.16 262.5); +} + +[data-accent="violet"] { + --primary: oklch(0.51 0.25 293); + --primary-foreground: oklch(0.985 0 0); + --ring: oklch(0.62 0.23 293); + --accent: oklch(0.94 0.03 300); + --accent-foreground: oklch(0.4 0.16 293); + --sidebar-primary: oklch(0.62 0.23 293); + --sidebar-ring: oklch(0.62 0.23 293); +} + +.dark[data-accent="violet"] { + --primary: oklch(0.7 0.19 293); + --primary-foreground: oklch(0.17 0.05 293); + --ring: oklch(0.7 0.19 293); + --accent: oklch(0.34 0.07 293); + --accent-foreground: oklch(0.8 0.12 293); + --sidebar-primary: oklch(0.7 0.19 293); + --sidebar-ring: oklch(0.7 0.19 293); +} + +[data-accent="emerald"] { + --primary: oklch(0.5 0.14 163); + --primary-foreground: oklch(0.985 0 0); + --ring: oklch(0.62 0.14 163); + --accent: oklch(0.94 0.03 163); + --accent-foreground: oklch(0.35 0.09 163); + --sidebar-primary: oklch(0.62 0.14 163); + --sidebar-ring: oklch(0.62 0.14 163); +} + +.dark[data-accent="emerald"] { + --primary: oklch(0.71 0.15 163); + --primary-foreground: oklch(0.17 0.04 163); + --ring: oklch(0.71 0.15 163); + --accent: oklch(0.33 0.05 163); + --accent-foreground: oklch(0.8 0.12 163); + --sidebar-primary: oklch(0.71 0.15 163); + --sidebar-ring: oklch(0.71 0.15 163); +} + +[data-accent="orange"] { + --primary: oklch(0.6 0.2 41); + --primary-foreground: oklch(0.985 0 0); + --ring: oklch(0.68 0.18 41); + --accent: oklch(0.95 0.03 55); + --accent-foreground: oklch(0.42 0.14 41); + --sidebar-primary: oklch(0.68 0.18 41); + --sidebar-ring: oklch(0.68 0.18 41); +} + +.dark[data-accent="orange"] { + --primary: oklch(0.74 0.16 48); + --primary-foreground: oklch(0.19 0.05 48); + --ring: oklch(0.74 0.16 48); + --accent: oklch(0.35 0.06 48); + --accent-foreground: oklch(0.82 0.12 48); + --sidebar-primary: oklch(0.74 0.16 48); + --sidebar-ring: oklch(0.74 0.16 48); +} + +[data-accent="rose"] { + --primary: oklch(0.53 0.21 17); + --primary-foreground: oklch(0.985 0 0); + --ring: oklch(0.64 0.19 17); + --accent: oklch(0.94 0.03 17); + --accent-foreground: oklch(0.42 0.15 17); + --sidebar-primary: oklch(0.64 0.19 17); + --sidebar-ring: oklch(0.64 0.19 17); +} + +.dark[data-accent="rose"] { + --primary: oklch(0.71 0.17 17); + --primary-foreground: oklch(0.18 0.04 17); + --ring: oklch(0.71 0.17 17); + --accent: oklch(0.34 0.06 17); + --accent-foreground: oklch(0.8 0.11 17); + --sidebar-primary: oklch(0.71 0.17 17); + --sidebar-ring: oklch(0.71 0.17 17); +} + +[data-accent="teal"] { + --primary: oklch(0.5 0.1 185); + --primary-foreground: oklch(0.985 0 0); + --ring: oklch(0.62 0.1 185); + --accent: oklch(0.94 0.02 185); + --accent-foreground: oklch(0.36 0.07 185); + --sidebar-primary: oklch(0.62 0.1 185); + --sidebar-ring: oklch(0.62 0.1 185); +} + +.dark[data-accent="teal"] { + --primary: oklch(0.72 0.11 185); + --primary-foreground: oklch(0.17 0.03 185); + --ring: oklch(0.72 0.11 185); + --accent: oklch(0.33 0.04 185); + --accent-foreground: oklch(0.8 0.09 185); + --sidebar-primary: oklch(0.72 0.11 185); + --sidebar-ring: oklch(0.72 0.11 185); +} + @layer components { .markdown-renderer { @apply text-sm leading-7 text-foreground; diff --git a/app/layout.tsx b/app/layout.tsx index d049079..126d926 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -53,6 +53,14 @@ export default function RootLayout({ suppressHydrationWarning className={`${fontSans.variable} ${fontSerif.variable} ${fontMono.variable} antialiased`} > + + {/* Aplica o accent salvo antes da hidratação para evitar flash (issue #177). */} +