{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "brand-theme",
  "title": "Brand Theme Runtime",
  "author": "Visant Labs <visantsupply@gmail.com>",
  "description": "Provider e hook que aplicam em runtime o tema compilado de uma brand guideline. Uma folha de estilo só, trocada sem recarregar a página.",
  "files": [
    {
      "path": "registry/visant/lib/brand-theme.tsx",
      "content": "\"use client\";\n\n/**\n * Runtime de marca — cola entre o vault de brand guidelines do Visant Labs e os\n * tokens CSS que os componentes já leem.\n *\n * A ideia inteira do registry é que nenhum componente saiba a cor da marca: ele\n * lê `var(--brand)`, `var(--card)`, `var(--font-display)`. Um tema instalado por\n * `npx shadcn add @visant/theme-<marca>` escreve esses tokens no `globals.css`\n * em tempo de build. Este arquivo faz a MESMA coisa em tempo de execução, para\n * o caso em que a marca só se decide com o app no ar: showroom, preview de\n * proposta, um app multi-cliente.\n *\n * O que ele NÃO faz: derivar cor. A derivação (rampa neutra, contraste AA\n * garantido, tipo por papel) acontece no `@visant/brand-tokens` e chega aqui já\n * compilada em `/r/theme-<slug>.json`. Um mesmo item serve os dois caminhos —\n * é o mesmo JSON que o shadcn instala.\n */\n\nimport * as React from \"react\";\n\n// --- tipos (espelham public/r/brands.json e os registry-items de tema) -------\n\nexport interface BrandPaletteEntry {\n  hex: string;\n  name: string | null;\n  role: string | null;\n}\n\nexport interface BrandSummary {\n  id: string;\n  slug: string;\n  name: string;\n  tagline: string | null;\n  logo: { url: string; thumbnailUrl: string; variant: string | null } | null;\n  palette: BrandPaletteEntry[];\n  fonts: { display: string; sans: string };\n  /** Amostra dos tokens compilados — pinta o card sem baixar o tema inteiro. */\n  preview: { light: Record<string, string>; dark: Record<string, string> };\n  theme: string;\n  themeUrl: string;\n  version: number | null;\n  completeness: number | null;\n  updatedAt: string | null;\n}\n\nexport interface BrandIndex {\n  generatedAt: string;\n  source: string;\n  count: number;\n  brands: BrandSummary[];\n}\n\nexport interface BrandThemeItem {\n  name: string;\n  title?: string;\n  cssVars: {\n    theme?: Record<string, string>;\n    light?: Record<string, string>;\n    dark?: Record<string, string>;\n  };\n  css?: Record<string, Record<string, string>>;\n  meta?: Record<string, unknown>;\n}\n\nexport const VISANT_REGISTRY_URL = \"https://visant-ui.vercel.app/r\";\n\n// --- CSS ---------------------------------------------------------------------\n\nconst STYLE_ID = \"visant-brand-theme\";\n\n/**\n * Item de tema → texto CSS.\n *\n * Um `<style>` único, e não `style.setProperty` no root: o item separa `light`\n * de `dark`, e escrever inline no elemento achataria os dois num só. Com uma\n * folha de estilo, alternar tema continua sendo trocar a classe `.dark`, igual\n * ao que o shadcn instala — o runtime não inventa um segundo mecanismo.\n *\n * Especificidade: `:root:not(.dark)` empata com `.dark` em peso, então a ordem\n * no arquivo decide, e as regras da marca vêm depois das do projeto por estarem\n * num `<style>` injetado no fim do `<head>`.\n */\nexport function brandThemeCss(item: BrandThemeItem): string {\n  const block = (sel: string, vars?: Record<string, string>) => {\n    if (!vars || !Object.keys(vars).length) return \"\";\n    const body = Object.entries(vars)\n      .map(([k, v]) => `  --${k}: ${v};`)\n      .join(\"\\n\");\n    return `${sel} {\\n${body}\\n}\\n`;\n  };\n\n  // `:root:root` em vez de `:root` — peso 0,2,0 contra 0,1,0. Empatar em\n  // especificidade com o `globals.css` do projeto deixaria a decisão para a\n  // ORDEM das folhas, e em dev o HMR reinjeta a folha do projeto depois desta:\n  // a marca aplicaria e desaplicaria conforme se salva um arquivo qualquer.\n  let css = \"\";\n  css += block(\":root:root\", item.cssVars.theme);\n  css += block(\":root:root, :root:root.light\", item.cssVars.light);\n  css += block(\":root:root.dark\", item.cssVars.dark);\n\n  // Blocos livres do item (ex.: `[data-density=\"compact\"]`).\n  for (const [sel, vars] of Object.entries(item.css ?? {})) {\n    const body = Object.entries(vars)\n      .map(([k, v]) => `  ${k.startsWith(\"--\") ? k : `--${k}`}: ${v};`)\n      .join(\"\\n\");\n    css += `${sel} {\\n${body}\\n}\\n`;\n  }\n  return css;\n}\n\n/** Escreve (ou substitui) a folha da marca e marca o `<html>` com o slug. */\nexport function applyBrandTheme(item: BrandThemeItem, slug?: string): void {\n  if (typeof document === \"undefined\") return;\n  let el = document.getElementById(STYLE_ID) as HTMLStyleElement | null;\n  if (!el) {\n    el = document.createElement(\"style\");\n    el.id = STYLE_ID;\n    document.head.appendChild(el);\n  }\n  el.textContent = brandThemeCss(item);\n  document.documentElement.dataset.brand = slug ?? item.name.replace(/^theme-/, \"\");\n}\n\nexport function clearBrandTheme(): void {\n  document.getElementById(STYLE_ID)?.remove();\n  delete document.documentElement.dataset.brand;\n}\n\n/**\n * Troca de marca com View Transition quando o browser tem, direto quando não.\n *\n * A transição é o que faz a troca parecer uma decisão e não um flash. Respeita\n * `prefers-reduced-motion` — para quem pediu menos movimento, o corte seco é a\n * resposta certa, não uma versão mais lenta.\n */\nexport function applyBrandThemeAnimated(item: BrandThemeItem, slug?: string): void {\n  const reduced =\n    typeof window !== \"undefined\" &&\n    window.matchMedia?.(\"(prefers-reduced-motion: reduce)\").matches;\n  const start = (document as any).startViewTransition?.bind(document);\n  if (reduced || !start) {\n    applyBrandTheme(item, slug);\n    return;\n  }\n  start(() => applyBrandTheme(item, slug));\n}\n\n// --- fontes ------------------------------------------------------------------\n\nconst loadedFonts = new Set<string>();\n\n/**\n * Carrega as faces da marca pelo Google Fonts.\n *\n * Best-effort por natureza: parte do acervo das marcas é licenciada fora do\n * Google (Termina, Clash Grotesk, Helvetica Now). Quando a face não existe lá,\n * o link 404 e a pilha de fallback do token assume — a UI continua correta, só\n * não fica na face exata. Um `@font-face` próprio do projeto, se existir, ganha\n * de qualquer jeito por já estar declarado.\n */\nexport function loadBrandFonts(families: string[]): void {\n  if (typeof document === \"undefined\") return;\n  for (const family of families) {\n    const clean = family?.trim();\n    if (!clean || loadedFonts.has(clean)) continue;\n    loadedFonts.add(clean);\n    const link = document.createElement(\"link\");\n    link.rel = \"stylesheet\";\n    link.href = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(\n      clean,\n    ).replace(/%20/g, \"+\")}:wght@300;400;500;600;700&display=swap`;\n    link.dataset.visantFont = clean;\n    document.head.appendChild(link);\n  }\n}\n\n// --- fetch -------------------------------------------------------------------\n\nconst themeCache = new Map<string, BrandThemeItem>();\n\nexport async function fetchBrandIndex(registryUrl = VISANT_REGISTRY_URL): Promise<BrandIndex> {\n  const res = await fetch(`${registryUrl}/brands.json`, { cache: \"no-cache\" });\n  if (!res.ok) throw new Error(`brands.json → HTTP ${res.status}`);\n  return res.json();\n}\n\nexport async function fetchBrandTheme(\n  themeName: string,\n  registryUrl = VISANT_REGISTRY_URL,\n): Promise<BrandThemeItem> {\n  const key = `${registryUrl}/${themeName}`;\n  const hit = themeCache.get(key);\n  if (hit) return hit;\n  const res = await fetch(`${key}.json`);\n  if (!res.ok) throw new Error(`${themeName} → HTTP ${res.status}`);\n  const item = (await res.json()) as BrandThemeItem;\n  themeCache.set(key, item);\n  return item;\n}\n\n// --- React -------------------------------------------------------------------\n\nexport interface BrandThemeContextValue {\n  brands: BrandSummary[];\n  /** Marca efetivada (a que persiste). */\n  brand: BrandSummary | null;\n  /** Marca sendo espiada — `brand` quando não há preview em curso. */\n  active: BrandSummary | null;\n  status: \"idle\" | \"loading\" | \"ready\" | \"error\";\n  error: string | null;\n  setBrand: (slug: string | null) => Promise<void>;\n  /** Aplica sem persistir; `null` volta para a efetivada. */\n  previewBrand: (slug: string | null) => void;\n  refresh: () => Promise<void>;\n}\n\nconst BrandThemeContext = React.createContext<BrandThemeContextValue | null>(null);\n\nexport interface BrandThemeProviderProps {\n  children: React.ReactNode;\n  /** Base do registry. Em dev, `/r` serve os JSONs deste repo. */\n  registryUrl?: string;\n  /** Slug aplicado no primeiro carregamento se nada estiver salvo. */\n  defaultBrand?: string;\n  storageKey?: string;\n}\n\nexport function BrandThemeProvider({\n  children,\n  registryUrl = VISANT_REGISTRY_URL,\n  defaultBrand,\n  storageKey = \"visant-brand\",\n}: BrandThemeProviderProps) {\n  const [brands, setBrands] = React.useState<BrandSummary[]>([]);\n  const [slug, setSlug] = React.useState<string | null>(null);\n  const [previewSlug, setPreviewSlug] = React.useState<string | null>(null);\n  const [status, setStatus] = React.useState<BrandThemeContextValue[\"status\"]>(\"idle\");\n  const [error, setError] = React.useState<string | null>(null);\n\n  const byId = React.useMemo(\n    () => new Map(brands.map((b) => [b.slug, b])),\n    [brands],\n  );\n\n  const apply = React.useCallback(\n    async (target: BrandSummary, animated: boolean) => {\n      const item = await fetchBrandTheme(target.theme, registryUrl);\n      loadBrandFonts([target.fonts.display, target.fonts.sans]);\n      if (animated) applyBrandThemeAnimated(item, target.slug);\n      else applyBrandTheme(item, target.slug);\n    },\n    [registryUrl],\n  );\n\n  const load = React.useCallback(async () => {\n    setStatus(\"loading\");\n    try {\n      const index = await fetchBrandIndex(registryUrl);\n      setBrands(index.brands);\n      setStatus(\"ready\");\n      setError(null);\n\n      const saved =\n        typeof localStorage !== \"undefined\" ? localStorage.getItem(storageKey) : null;\n      const initial = index.brands.find((b) => b.slug === (saved ?? defaultBrand));\n      if (initial) {\n        setSlug(initial.slug);\n        // Mesma separação do `setBrand`: um tema que não baixa é uma marca sem\n        // pintura, não um vault fora do ar. `status` continua \"ready\" — o\n        // seletor lista as marcas e a próxima escolha funciona.\n        try {\n          await apply(initial, false);\n        } catch (err) {\n          setError(err instanceof Error ? err.message : String(err));\n        }\n      }\n    } catch (err) {\n      setStatus(\"error\");\n      setError(err instanceof Error ? err.message : String(err));\n    }\n  }, [registryUrl, storageKey, defaultBrand, apply]);\n\n  React.useEffect(() => {\n    void load();\n  }, [load]);\n\n  const setBrand = React.useCallback(\n    async (next: string | null) => {\n      setPreviewSlug(null);\n      if (!next) {\n        setSlug(null);\n        clearBrandTheme();\n        localStorage.removeItem(storageKey);\n        return;\n      }\n      const target = byId.get(next);\n      if (!target) return;\n\n      /*\n       * Aplicar PRIMEIRO, registrar depois — e nunca rejeitar.\n       *\n       * O índice promete um tema por marca, mas quem serve os dois arquivos é o\n       * deploy, e eles já saíram dessincronizados: `brands.json` com quatro\n       * marcas e um único `theme-*.json` no ar. Como o consumidor natural é um\n       * `onSelect` (`void setBrand(slug)`), a rejeição virava unhandled e\n       * derrubava a tela inteira — um 404 de tema tirando do ar um app que só\n       * queria continuar na marca anterior.\n       *\n       * Falhando: a marca antiga continua pintada, a escolha não é gravada (não\n       * se persiste um estado que a tela não reflete, nem se recarrega nele) e o\n       * motivo vai para `error`.\n       */\n      try {\n        await apply(target, true);\n      } catch (err) {\n        setError(err instanceof Error ? err.message : String(err));\n        return;\n      }\n      setSlug(next);\n      setError(null);\n      localStorage.setItem(storageKey, next);\n    },\n    [byId, apply, storageKey],\n  );\n\n  /*\n   * Preview otimista: pinta assim que o tema estiver em cache e ignora a\n   * resposta se o ponteiro já saiu da linha. Sem essa guarda, passar rápido por\n   * dez marcas deixa a última resposta lenta pintando a tela depois de o mouse\n   * ter ido embora — a UI \"escolhe sozinha\".\n   */\n  const previewRef = React.useRef<string | null>(null);\n  const previewBrand = React.useCallback(\n    (next: string | null) => {\n      previewRef.current = next;\n      setPreviewSlug(next);\n      const target = next ? byId.get(next) : slug ? byId.get(slug) : null;\n      if (!target) {\n        if (!next && !slug) clearBrandTheme();\n        return;\n      }\n      void fetchBrandTheme(target.theme, registryUrl)\n        .then((item) => {\n          if (previewRef.current !== next) return;\n          loadBrandFonts([target.fonts.display, target.fonts.sans]);\n          applyBrandTheme(item, target.slug);\n        })\n        .catch(() => {});\n    },\n    [byId, slug, registryUrl],\n  );\n\n  const value: BrandThemeContextValue = {\n    brands,\n    brand: slug ? byId.get(slug) ?? null : null,\n    active: (previewSlug ? byId.get(previewSlug) : null) ?? (slug ? byId.get(slug) ?? null : null),\n    status,\n    error,\n    setBrand,\n    previewBrand,\n    refresh: load,\n  };\n\n  return <BrandThemeContext.Provider value={value}>{children}</BrandThemeContext.Provider>;\n}\n\nexport function useBrandTheme(): BrandThemeContextValue {\n  const ctx = React.useContext(BrandThemeContext);\n  if (!ctx) throw new Error(\"useBrandTheme precisa de <BrandThemeProvider>\");\n  return ctx;\n}\n",
      "type": "registry:lib"
    }
  ],
  "meta": {
    "uso": {
      "quando": "App que troca de marca em runtime, como multi-cliente ou white-label, aplicando o tema compilado",
      "evite": "Projeto de uma marca só: importe o CSS do tema da marca direto, sem provider"
    }
  },
  "categories": [
    "fundacao",
    "marca",
    "tokens",
    "runtime",
    "provider"
  ],
  "type": "registry:lib"
}