jengacore.shadcn registry · Tailwind v4 · Radix and Base UI
Components
Foundation · @jengacore/jc-theme

useTheme / useProduct

Light, Dark and High contrast per user, a no-flash ThemeScript, and the product signature (data-product).

Install

terminal
npx shadcn@latest add @jengacore/jc-theme

Without the namespace configured: npx shadcn@latest add https://ui.jengacore.com/r/<new-york-v4|base-nova>/jc-theme.json

Source

components/jc/theme.tsx
"use client"

import * as React from "react"

export type Theme = "light" | "dark" | "contrast"
export type Product = "mulisa" | "msingi" | "finance" | "disputes"

const KEY = "jc-theme"
const THEMES: Theme[] = ["light", "dark", "contrast"]
const listeners = new Set<() => void>()

function storedTheme(): Theme | null {
  try {
    const stored = localStorage.getItem(KEY) as Theme | null
    return stored && THEMES.includes(stored) ? stored : null
  } catch {
    return null
  }
}

/** The theme on the page right now: <html> carries it (set by ThemeScript before paint, then by setTheme). */
function currentTheme(): Theme {
  const root = document.documentElement
  if (root.classList.contains("contrast")) return "contrast"
  if (root.classList.contains("dark")) return "dark"
  return "light"
}

function applyTheme(theme: Theme) {
  const root = document.documentElement
  root.classList.remove("dark", "contrast")
  if (theme !== "light") root.classList.add(theme)
  root.style.colorScheme = theme === "dark" ? "dark" : "light"
}

function subscribe(listener: () => void) {
  listeners.add(listener)
  // Another tab changed the theme: follow it.
  const onStorage = (event: StorageEvent) => {
    if (event.key !== KEY) return
    applyTheme(storedTheme() ?? "light")
    listener()
  }
  window.addEventListener("storage", onStorage)
  return () => {
    listeners.delete(listener)
    window.removeEventListener("storage", onStorage)
  }
}

function setStoredTheme(theme: Theme) {
  applyTheme(theme)
  try {
    localStorage.setItem(KEY, theme)
  } catch {
    /* storage unavailable: the theme still applies for this session */
  }
  listeners.forEach((listener) => listener())
}

/**
 * Light by default; dark for operations walls; high contrast as an accessibility choice per user.
 * One shared store: every component calling useTheme sees the same value, and only an explicit
 * setTheme writes to storage (mounting never overwrites the saved choice). Pair with <ThemeScript />
 * in the root layout so the saved theme applies before first paint.
 */
export function useTheme(initial: Theme = "light") {
  const theme = React.useSyncExternalStore(subscribe, currentTheme, () => initial)
  return { theme, setTheme: setStoredTheme, themes: THEMES }
}

/** Inline script for <head>: applies the stored theme before first paint. */
export function ThemeScript() {
  const code = `try{var t=localStorage.getItem("${KEY}");if(t==="dark"||t==="contrast"){document.documentElement.classList.add(t)}}catch(e){}`
  return <script dangerouslySetInnerHTML={{ __html: code }} />
}

/** Sets the product signature (wordmark dot, active-nav marker) for the whole app. */
export function useProduct(product: Product) {
  React.useEffect(() => {
    document.documentElement.dataset.product = product
  }, [product])
}

export const PRODUCT_NAME: Record<Product, string> = {
  mulisa: "mulisa.",
  msingi: "msingi.",
  finance: "finance portal.",
  disputes: "disputes.",
}

const FILE: Record<Product, string> = {
  mulisa: "mulisa",
  msingi: "msingi",
  finance: "finance-portal",
  disputes: "disputes",
}

/** White wordmark files, for the navy sidebar. Installed into /public/brand by @jengacore/brand. */
export const PRODUCT_WORDMARK: Record<Product, string> = {
  mulisa: `/brand/${FILE.mulisa}-wordmark-white.svg`,
  msingi: `/brand/${FILE.msingi}-wordmark-white.svg`,
  finance: `/brand/${FILE.finance}-wordmark-white.svg`,
  disputes: `/brand/${FILE.disputes}-wordmark-white.svg`,
}

/** App tiles: favicons, PWA icons, the collapsed sidebar rail and the app switcher. */
export const PRODUCT_TILE: Record<Product, string> = {
  mulisa: `/brand/${FILE.mulisa}-tile.svg`,
  msingi: `/brand/${FILE.msingi}-tile.svg`,
  finance: `/brand/${FILE.finance}-tile.svg`,
  disputes: `/brand/${FILE.disputes}-tile.svg`,
}