"use client"
import * as React from "react"
import { useTheme } from "@exxatdesignux/ui/hooks/use-color-scheme"
import { FieldGroup } from "@/components/ui/field"
import { RadioGroup, RadioGroupItem, RadioGroupLabel } from "@/components/ui/radio-group"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { SelectionTileGrid } from "@/components/ui/selection-tile-grid"
import { useAppTheme, type Brand, type TextSizePreference } from "@/hooks/use-app-theme"
import { useDashboardView, type DashboardView } from "@/contexts/dashboard-view-context"
import { useChartVariant, type ChartVariant } from "@/contexts/chart-variant-context"
import { SettingsFormRow } from "@/components/settings-form-row"
import { BrandColorPicker } from "@/components/brand-color-picker"
import { ExxatProductLogo, ExxatProductWordmarkEditor } from "@/components/exxat-product-logo"
import { useProduct, syncActiveProductThemeFromStore } from "@/contexts/product-context"
import { useProductSwitch } from "@/contexts/product-route-sync"
import { DEFAULT_CUSTOM_PRODUCT_BRAND, type CustomProductBrand, type Product, isCustomProductPlaceholder, isListedCustomProduct } from "@/stores/app-store"
import {
brandForProduct,
brandPreviewPanelSurfaces,
customProductBrandConfig,
getProductBrand,
productBrandLabel,
} from "@/lib/product-brand"
import { normalizeBrandAccentColor } from "@/lib/brand-accent-color"
import { brandColorsEquivalent } from "@/lib/brand-color-match"
import { validateCustomProductSuffix, customSuffixCollidesWithBuiltInProduct } from "@/lib/product-routing"
import {
isProductRefHidden,
isStartupProductRef,
productRefKey,
type ProductRef,
} from "@/lib/product-ref"
import { Tip } from "@/components/ui/tip"
import {
downloadProductScaffold,
downloadShippedTenantCatalog,
isBuilderDevSyncEnvironment,
tenantRecordFromCustomBrand,
} from "@exxatdesignux/product-framework"
import { useProductAuthoringEnabled } from "@exxatdesignux/ui/components/shell"
import { cn } from "@/lib/utils"
export type SettingsAppearanceMode = "products-only" | "display-only" | "all"
function RadioRow({
value,
id,
label,
iconClass,
}: {
value: string
id: string
label: string
iconClass?: string
}) {
return (
{iconClass ? (
) : null}
{label}
)
}
/** Illustrative split sidebars when “system” shows light+dark (tokens follow active brand hue). */
const SPLIT_SIDEBAR: Record = {
one: {
light: "oklch(0.935 0.024 286.1)",
dark: "oklch(0.32 0.085 286.1)",
markLight: "oklch(0.58 0.18 286.1)",
markDark: "oklch(0.72 0.18 286.1)",
},
prism: {
light: "oklch(0.96 0.04 342)",
dark: "oklch(0.34 0.13 342)",
markLight: "oklch(0.62 0.21 342)",
markDark: "oklch(0.78 0.18 342)",
},
}
/** Fills the square preview in Settings appearance tiles (see SelectionTileGraphic below-mode sizing). */
const APPEARANCE_TILE_SVG = "block h-full w-auto max-h-full max-w-full shrink-0 object-contain"
/** Fixed palettes per labeled mode — do not use `var(--background)` etc. (those follow the *current* theme). */
const CHROME_LIGHT = {
shell: "oklch(1 0 0)",
shellStroke: "oklch(0.90 0.003 270)",
headerBar: "oklch(0.96 0.004 270)",
headerStroke: "oklch(0.92 0.003 270)",
content: "oklch(0.985 0.003 270)",
card: "oklch(1 0 0)",
cardStroke: "oklch(0.91 0.003 270)",
navRow: "oklch(0.86 0.012 270)",
pill: "oklch(0.94 0.003 270)",
windowRed: "#FF5F57",
windowYellow: "#FEBC2E",
windowGreen: "#28C840",
} as const
const CHROME_DARK = {
shell: "oklch(0.13 0.01 270)",
shellStroke: "oklch(0.32 0.015 270)",
headerBar: "oklch(0.19 0.013 270)",
headerStroke: "oklch(0.28 0.013 270)",
content: "oklch(0.155 0.012 270)",
card: "oklch(0.20 0.013 270)",
cardStroke: "oklch(0.32 0.013 270)",
navRow: "oklch(0.42 0.014 270)",
pill: "oklch(0.25 0.013 270)",
windowRed: "#FF5F57",
windowYellow: "#FEBC2E",
windowGreen: "#28C840",
} as const
type ChromeTokens = { -readonly [K in keyof typeof CHROME_LIGHT]: string }
/**
* Reusable mini-chrome — Mac-style traffic lights, sidebar with brand-tinted
* mark + nav rows, header bar with search pill + avatar, three content cards
* (KPI tile, mini bar chart, list rows). Coordinates are scoped to a fixed
* 96×56 viewBox so the System mode can place two side-by-side via ``.
*
* `strokeBoost` thickens every border for the high-contrast variants without
* forking the whole illustration.
*/
function ChromeIllustration({
tokens,
sidebar,
sidebarMark,
strokeBoost = 1,
contentAccent,
}: {
tokens: ChromeTokens
sidebar: string
sidebarMark: string
strokeBoost?: number
contentAccent?: string
}) {
const sw = (n: number) => n * strokeBoost
return (
{/* Mac-style traffic lights */}
{/* Sidebar */}
{/* Brand-tinted product mark + faux team name */}
{/* Active nav row + 4 inactive rows */}
{/* Header bar: search pill + avatar */}
{/* KPI card */}
{/* Bar-chart card */}
{/* List card */}
)
}
/** Mini browser chrome: illustrative light / dark / split (brand sidebars from SPLIT_SIDEBAR). */
/**
* One window, two halves — render the full chrome twice in the same SVG, each
* clipped to a triangular half by a diagonal that runs from the top-right
* corner to the bottom-left corner. Light fills the top-left triangle, dark
* fills the bottom-right triangle. Because both halves use identical geometry
* inside one viewBox, the result reads as a single window with a diagonal
* theme split (macOS / iOS "Auto" pattern) rather than two adjacent windows.
*/
function SplitSystemSvg({
light,
dark,
}: {
light: { tokens: ChromeTokens; sidebar: string; sidebarMark: string; strokeBoost?: number; contentAccent?: string }
dark: { tokens: ChromeTokens; sidebar: string; sidebarMark: string; strokeBoost?: number; contentAccent?: string }
}) {
// useId keeps clipPath ids unique across tile instances on the same page.
const baseId = React.useId().replace(/:/g, "")
const lightId = `chrome-split-light-${baseId}`
const darkId = `chrome-split-dark-${baseId}`
return (
{/* Top-left triangle — top edge + left edge + diagonal to bottom-left. */}
{/* Bottom-right triangle — right edge + bottom edge + diagonal. */}
)
}
function ThemeModeSvg({ mode, brand }: { mode: "system" | "light" | "dark"; brand: Brand }) {
const split = SPLIT_SIDEBAR[brand]
if (mode === "light") {
return (
)
}
if (mode === "dark") {
return (
)
}
// System: one window with a diagonal light↔dark split inside.
return (
)
}
const HC_STROKE = "oklch(0.18 0.02 270)"
const HC_LIGHT_TOKENS: ChromeTokens = {
...CHROME_LIGHT,
shellStroke: HC_STROKE,
headerStroke: HC_STROKE,
cardStroke: HC_STROKE,
}
const HC_WINDOWS_TOKENS: ChromeTokens = {
...CHROME_DARK,
shell: "#000000",
shellStroke: "#FFFFFF",
headerBar: "#FFFF00",
headerStroke: "#FFFF00",
content: "#000000",
card: "#000000",
cardStroke: "#FFFFFF",
navRow: "#FFFFFF",
pill: "#000000",
}
/** Illustrative light chrome; stroke weight shows contrast (not tied to active color theme). */
function ContrastPrefSvg({
pref,
brand,
}: {
pref: "system" | "normal" | "high" | "windows"
brand: Brand
}) {
const split = SPLIT_SIDEBAR[brand]
if (pref === "normal") {
return (
)
}
if (pref === "high") {
return (
)
}
if (pref === "windows") {
return (
)
}
// System: one window with a diagonal Normal↔High split inside.
return (
)
}
const CHART_LABELS: Record = {
normal: "Normal",
tabs: "With tabs",
selector: "With filters",
"metrics-tabs": "Tabs + metrics",
"kpi-chart": "KPI + chart",
}
const VIEW_LABELS: Record = {
report: "Report",
simple: "Simple",
mix: "Mix",
}
/** Settings tile previews — map active product to the built-in split sidebar hue. */
function previewBrandForProduct(product: Product): Brand {
return product === "exxat-prism" ? "prism" : "one"
}
const THEME_CHOICE_LABEL: Record<"system" | "light" | "dark", string> = {
system: "System default",
light: "Light",
dark: "Dark",
}
const TEXT_SIZE_LABEL: Record = {
compact: "Tiny",
default: "Default",
large: "Large",
}
type ProductListOption = {
value: Product
label: string
/**
* Sub-line shown next to the wordmark for the Exxat One siblings. Both
* share `suffix: "One"` so without this qualifier the two rows look
* identical to sighted users — the full label only reaches screen readers.
* Matches the pattern used by `ProductSwitcher`.
*/
scope?: "Schools" | "Sites"
customIndex?: number
}
function productRefFromOption(option: ProductListOption): ProductRef {
return option.customIndex !== undefined
? { product: option.value, customIndex: option.customIndex }
: { product: option.value }
}
import {
BUILTIN_SETTINGS_PRODUCTS,
} from "@/lib/product-switcher-catalog"
function labelForProductRef(
ref: ProductRef,
customProducts: CustomProductBrand[],
): string {
if (ref.product === "exxat-custom" && ref.customIndex !== undefined) {
const brand = customProducts[ref.customIndex]
return brand
? productBrandLabel(customProductBrandConfig(brand))
: "Custom product"
}
return (
BUILTIN_SETTINGS_PRODUCTS.find(entry => entry.id === ref.product)?.label ??
ref.product
)
}
type DeleteProductTarget = {
product: Product
customIndex?: number
}
export function SettingsAppearanceCard({
mode = "display-only",
}: {
/** `products-only` — workspace/org (Add product). `display-only` — personal display prefs. */
mode?: SettingsAppearanceMode
}) {
const showProducts = mode === "products-only" || mode === "all"
const showDisplay = mode === "display-only" || mode === "all"
const productAuthoringEnabled = useProductAuthoringEnabled()
const { theme, setTheme } = useTheme()
const { contrastPref, setContrast, textSizePref, setTextSize, mounted } = useAppTheme()
const {
product: activeProduct,
activeCustomIndex,
customProducts,
addCustomProduct,
updateCustomProduct,
removeCustomProduct,
productBrandColors,
setProductBrandColor,
hiddenProducts,
hideProduct,
showProduct,
startupProduct,
setStartupProduct,
} = useProduct()
const switchProduct = useProductSwitch()
const handleSetDefaultStartup = React.useCallback(
(rowRef: ProductRef) => {
setStartupProduct(rowRef)
switchProduct(rowRef.product, rowRef.customIndex)
},
[setStartupProduct, switchProduct],
)
const { activeView, setActiveView } = useDashboardView()
const { chartVariant, setChartVariant } = useChartVariant()
const productNameId = React.useId()
const productColorId = React.useId()
const [deleteProductOpen, setDeleteProductOpen] = React.useState(false)
const deleteProductTargetRef = React.useRef(null)
const [productEditorOpen, setProductEditorOpen] = React.useState(false)
const [productNameDraft, setProductNameDraft] = React.useState("")
const [productColorDraft, setProductColorDraft] = React.useState(
DEFAULT_CUSTOM_PRODUCT_BRAND.brandColor,
)
const [publishHint, setPublishHint] = React.useState(null)
const safeTheme = mounted ? ((theme ?? "system") as "system" | "light" | "dark") : "system"
const safeBrand = mounted ? previewBrandForProduct(activeProduct) : "one"
const safeContrast = mounted ? contrastPref : "system"
const safeTextSize = mounted ? textSizePref : "default"
const addProductPreviewBrand = React.useMemo(
() => ({
suffix: productNameDraft.trim() || "Product",
brandColor: normalizeBrandAccentColor(
productColorDraft.trim() || DEFAULT_CUSTOM_PRODUCT_BRAND.brandColor,
),
}),
[productColorDraft, productNameDraft],
)
const addProductBlockedReason = React.useMemo(() => {
const suffix = productNameDraft.trim()
if (!suffix) return "Enter a product name after Exxat."
if (isCustomProductPlaceholder({ suffix, brandColor: productColorDraft.trim() || DEFAULT_CUSTOM_PRODUCT_BRAND.brandColor })) {
return "Pick a name other than Custom, or change the brand color."
}
return validateCustomProductSuffix(suffix, customProducts)
}, [customProducts, productColorDraft, productNameDraft])
const addProductPanelSurfaces = React.useMemo(() => {
const dark =
safeTheme === "dark" ||
(safeTheme === "system" &&
typeof window !== "undefined" &&
window.matchMedia("(prefers-color-scheme: dark)").matches)
return brandPreviewPanelSurfaces(addProductPreviewBrand.brandColor, dark)
}, [addProductPreviewBrand.brandColor, safeTheme])
const themeTiles = React.useMemo(
() =>
(["system", "light", "dark"] as const).map((m) => ({
value: m,
label: THEME_CHOICE_LABEL[m],
leading: ,
})),
[safeBrand],
)
const contrastTiles = React.useMemo(
() =>
(["system", "normal", "high", "windows"] as const).map((p) => ({
value: p,
label:
p === "system"
? "System"
: p === "normal"
? "Normal"
: p === "high"
? "High"
: "Windows",
leading: ,
})),
[safeBrand],
)
const textSizeTiles = React.useMemo(
() =>
(["compact", "default", "large"] as const).map((v) => ({
value: v,
label: TEXT_SIZE_LABEL[v],
leading: (
Aa
),
})),
[],
)
// Exxat ships **four apps** in the product switcher: Prism, One — Schools,
// One — Sites, and Custom (Prism IA with tenant-configured branding). Both
// Exxat One variants share the indigo accent + pink wordmark; the
// brand-color picker here recolours each independently so a workspace can
// theme one without affecting the other. The Custom slot only appears in
// this list once the tenant has configured a suffix + brand color via the
// "Add product" affordance below. See
// `apps/web/docs/multi-product-routing-pattern.md`.
const productOptions = React.useMemo((): ProductListOption[] => {
const builtIns: ProductListOption[] = BUILTIN_SETTINGS_PRODUCTS.map(entry => ({
value: entry.id,
label: entry.label,
scope: entry.scope,
}))
const customs: ProductListOption[] = customProducts.flatMap((cp, customIndex) =>
isListedCustomProduct(cp) && !customSuffixCollidesWithBuiltInProduct(cp.suffix)
? [{ value: "exxat-custom" as const, label: `Exxat ${cp.suffix}`, customIndex }]
: [],
)
return [...builtIns, ...customs].filter(
option => !isProductRefHidden(productRefFromOption(option), hiddenProducts),
)
}, [customProducts, hiddenProducts])
return (
{showProducts && !showDisplay
? "Products & branding"
: "Appearance & display"}
{showProducts && !showDisplay
? productAuthoringEnabled
? "Workspace-wide — shared across every product in this browser. Add products, set default startup, and tune panel tints."
: "Products shipped on this deploy are listed below. Builders author and rebrand them in dev, then commit and redeploy."
: "Saved in this browser."}
{!mounted && showDisplay ? (
Loading theme…
) : (
{showProducts ? (
{productOptions.map(option => {
const customBrand =
option.customIndex !== undefined
? customProducts[option.customIndex]
: null
const config = brandForProduct(
option.value,
customBrand,
productBrandColors,
)
const defaultConfig =
option.value === "exxat-custom"
? customProductBrandConfig(customBrand)
: getProductBrand(option.value)
const pickerId = `settings-product-color-${option.value}${
option.customIndex !== undefined ? `-${option.customIndex}` : ""
}`
const canDelete = option.customIndex !== undefined
const isCustom = option.customIndex !== undefined
const isActive =
activeProduct === option.value &&
(option.customIndex === undefined ||
activeCustomIndex === option.customIndex)
const rowRef = productRefFromOption(option)
const isDefault = isStartupProductRef(rowRef, startupProduct)
const usedBy: Record
= {}
for (const other of productOptions) {
if (
other.value === option.value &&
other.customIndex === option.customIndex
) {
continue
}
const otherCustomBrand =
other.customIndex !== undefined
? customProducts[other.customIndex]
: null
const otherConfig = brandForProduct(
other.value,
otherCustomBrand,
productBrandColors,
)
const otherActive =
activeProduct === other.value &&
(other.customIndex === undefined ||
activeCustomIndex === other.customIndex)
const label = otherActive ? `${other.label} (active)` : other.label
usedBy[otherConfig.brandColor] = label
}
const handleColorChange = (next: string) => {
const normalized = normalizeBrandAccentColor(next)
if (isCustom && option.customIndex !== undefined && customBrand) {
updateCustomProduct(option.customIndex, {
suffix: customBrand.suffix.trim() || DEFAULT_CUSTOM_PRODUCT_BRAND.suffix,
brandColor: normalized,
})
setProductBrandColor("exxat-custom", null)
} else if (defaultConfig && brandColorsEquivalent(normalized, defaultConfig.brandColor)) {
setProductBrandColor(option.value, null)
} else {
setProductBrandColor(option.value, normalized)
}
if (isActive) {
requestAnimationFrame(() => syncActiveProductThemeFromStore())
}
}
return (
{option.label}
{option.scope && (
— {option.scope}
)}
{isActive ? (
Active
) : null}
{isDefault ? (
Default
) : productAuthoringEnabled ? (
handleSetDefaultStartup(rowRef)}
>
Set as default
) : null}
{productAuthoringEnabled ? (
<>
Brand color for {option.label}
>
) : (
)}
{/* Per-row overflow: delete (custom only) or hide (any
non-active built-in). Active products can't be hidden
— the user has to switch first, which prevents the
picker from becoming unreachable. */}
{productAuthoringEnabled && canDelete ? (
{
deleteProductTargetRef.current = {
product: option.value,
customIndex: option.customIndex,
}
setDeleteProductOpen(true)
}}
>
) : productAuthoringEnabled ? (
hideProduct(rowRef)}
>
) : null}
)
})}
{productAuthoringEnabled ? (
{
setProductEditorOpen(open => {
const next = !open
if (next) {
setProductNameDraft("")
setProductColorDraft(DEFAULT_CUSTOM_PRODUCT_BRAND.brandColor)
}
return next
})
}}
>
Add product
Create a product name and brand color.
{productEditorOpen ? (
Product name suffix
Brand color
{
const suffix = productNameDraft.trim()
const brandColor = normalizeBrandAccentColor(
productColorDraft.trim() ||
DEFAULT_CUSTOM_PRODUCT_BRAND.brandColor,
)
if (!suffix || addProductBlockedReason) return
const nextIndex = addCustomProduct({ suffix, brandColor })
switchProduct("exxat-custom", nextIndex)
if (productAuthoringEnabled) {
// In dev with the builder dev-sync Vite plugin
// active, the catalog is already being written
// to `public/tenant-products.json` on disk by
// the middleware (POST → /__exxat/builder-sync)
// every time the tenant store changes. Firing
// the browser-side `download*` helpers in that
// mode just produces redundant "Save As"
// dialogs for the same file (plus the four
// scaffold files), which is what users were
// hitting after adding a custom product.
// Skip the downloads when dev-sync is live;
// they remain wired for production builds
// (no Vite middleware → no other persistence
// path).
if (isBuilderDevSyncEnvironment()) {
setPublishHint(
"Saved to ignored public/tenant-products.json on the dev server — force-add it only when you intend to ship this product.",
)
} else {
downloadShippedTenantCatalog()
downloadProductScaffold(
tenantRecordFromCustomBrand(suffix, brandColor),
)
setPublishHint(
"Downloaded public/tenant-products.json and scaffold files — force-add the catalog only when you intend to ship it, then pnpm build and deploy.",
)
}
}
setProductEditorOpen(false)
setProductNameDraft("")
setProductColorDraft(DEFAULT_CUSTOM_PRODUCT_BRAND.brandColor)
}}
>
Add product
{addProductBlockedReason ? (
{addProductBlockedReason}
) : (
Type the suffix after Exxat — wordmark stays Exxat pink; the
panel tint follows your brand color. After Add, review the generated{" "}
public/tenant-products.json and
force-add it only for a deploy that should ship this product.
)}
{publishHint ? (
{publishHint}
) : null}
) : null}
) : null}
{productAuthoringEnabled && hiddenProducts.length > 0 ? (
Hidden:
{hiddenProducts.map(ref => (
showProduct(ref)}
>
Show {labelForProductRef(ref, customProducts)}
))}
) : null}
) : null}
{showDisplay ? (
<>
className="w-full"
labelPlacement="below"
options={themeTiles}
columns={4}
value={safeTheme}
onValueChange={(v) => setTheme(v)}
interaction="button"
/>
className="w-full"
labelPlacement="below"
options={contrastTiles}
columns={4}
value={safeContrast}
onValueChange={(v) => setContrast(v)}
interaction="button"
/>
className="w-full"
labelPlacement="below"
options={textSizeTiles}
columns={4}
value={safeTextSize}
onValueChange={(v) => setTextSize(v)}
interaction="button"
/>
setActiveView(v as DashboardView)}
className="flex flex-col gap-3"
aria-label="Dashboard view"
itemVariant="outline"
itemMotion="glow"
>
setChartVariant(v as ChartVariant)}
>
{(Object.keys(CHART_LABELS) as ChartVariant[]).map((key) => (
{CHART_LABELS[key]}
))}
>
) : null}
)}
{/* Destructive confirm for removing the Custom slot — irreversible
locally (re-adding builds a fresh entry with the default suffix +
colour). Routed through a dialog per the no-toast rule. */}
Remove custom product?
The slot will disappear from the switcher. You can re-add it
later from this page; brand colour and name will reset to
defaults.
setDeleteProductOpen(false)}
>
Cancel
{
const target = deleteProductTargetRef.current
if (target?.product === "exxat-custom" && target.customIndex !== undefined) {
removeCustomProduct(target.customIndex)
if (
activeProduct === "exxat-custom" &&
activeCustomIndex === target.customIndex
) {
switchProduct("exxat-prism")
}
}
setDeleteProductOpen(false)
deleteProductTargetRef.current = null
}}
>
Remove
)
}