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-themeWithout 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`,
}