import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Uniwind } from 'uniwind'; import { Alert, Box, Button, ListItemIcon, ListItemText, MenuItem, MenuList, Popover, ToggleButton, ToggleButtonGroup, Typography, } from '@mui/material'; import { CheckOutlined, CodeOutlined, ContentCopyOutlined, DarkModeOutlined, LightModeOutlined, } from '@mui/icons-material'; import { applyCoveringThemeOverride, applyOverrideInputTransform, buildStaticManagedTargets, createThemeOverrideSessionState, createThemeOverrideWriterOwnershipToken, expandParsedThemeOverride, overrideCatalogs, overrideRuleEvaluator, overrideRuntimeData, overrideRuntimeMap, parseThemeOverride, presetFonts, type OverrideDiagnostic, type InputDefinition, type InputId, type Preset, type SelectionId, type ThemeOverrideEditorDraft, type ThemeOverrideSessionState, type ThemeSelections, } from '@cdx-ui/styles'; import type { ThemeEditorProps } from './types'; import { loadPresetFonts, loadPlatformFonts, cleanupFontLinks } from './fonts'; import { ControlRenderer, validateControlWidgetRegistry } from './controls'; import { PreviewPanel } from './PreviewPanel'; import { TARGET_CONTROL_DEFINITIONS, TARGET_EDITOR_LAYOUT, resolveControlDefinitions, validateEditorLayout, type ResolvedControlDefinition, } from './override/controls'; import { loadThemeOverrideDraft, reduceThemeOverrideDraft, serializeThemeOverrideDraft, type ThemeOverrideDraftAction, } from './override/draft'; import { evaluateThemeOverrideCandidate, evaluateThemeOverrideDraft } from './override/runtime'; import type { EditorDisplayDefaults, ThemeOverrideEditorRuntime } from './override/types'; type ThemeMode = 'light' | 'dark'; const DEFAULT_BRAND = '#346099'; const DEFAULT_ACCENT = '#ffcf23'; const PALETTE_DISPLAY_FALLBACKS: Readonly>> = { brand: DEFAULT_BRAND, accent: DEFAULT_ACCENT, }; function getDefaultInput(definition: InputDefinition, preset: Preset): string | undefined { if (definition.strategy.kind === 'display-font') { return presetFonts[preset][0]; } if (definition.strategy.fallback) return undefined; const primitivePath = `color.${definition.strategy.category}.input`; const primitiveValue = overrideRuntimeData.primitives.byPreset[preset][primitivePath]; return primitiveValue === undefined ? PALETTE_DISPLAY_FALLBACKS[definition.strategy.category] : String(primitiveValue); } function deriveInput( definition: InputDefinition, values: Readonly>>, ): string | undefined { if (definition.strategy.kind !== 'palette' || !definition.strategy.fallback) { return undefined; } const source = values[definition.strategy.fallback.inputId]; if (!source) return undefined; return applyOverrideInputTransform(definition.strategy.fallback.transform, source); } const DISPLAY_DEFAULTS: EditorDisplayDefaults = { listInputs: () => overrideCatalogs.listInputs(), getDefaultInput, deriveInput, }; validateEditorLayout(TARGET_CONTROL_DEFINITIONS, TARGET_EDITOR_LAYOUT); validateControlWidgetRegistry(TARGET_CONTROL_DEFINITIONS); const RESOLVED_CONTROLS = resolveControlDefinitions(TARGET_CONTROL_DEFINITIONS, overrideCatalogs); const CONTROL_BY_ID = new Map(RESOLVED_CONTROLS.map((definition) => [definition.id, definition])); const STATIC_MANAGED_TARGETS = buildStaticManagedTargets(overrideCatalogs.listManagedTargets()); function adapterDiagnostic( message: string, mode: 'light' | 'dark', artifactRevision: string, ): OverrideDiagnostic { return { code: 'override.adapter_write_failed', severity: 'error', phase: 'adapter', message, mode, artifactRevision, }; } function createEditorRuntime( writerOwner: ReturnType, ): ThemeOverrideEditorRuntime { return { parse: parseThemeOverride, expand: expandParsedThemeOverride, evaluateCandidate: (request) => overrideRuleEvaluator.evaluateCandidate(overrideRuntimeData.rules, request), applyPreview: (expanded, session) => { const result = applyCoveringThemeOverride({ expanded, runtimeMap: overrideRuntimeMap, writer: (mode, variables) => { Uniwind.updateCSSVariables(mode, { ...variables }); }, session, staticManagedTargets: STATIC_MANAGED_TARGETS, writerOwner, }); if (result.applied) { return { applied: true, session: result.session, }; } const { failure } = result; return { applied: false, diagnostics: [ adapterDiagnostic( failure.cause instanceof Error ? failure.cause.message : 'Theme preview could not write runtime variables.', failure.mode, failure.artifactRevision, ), ], }; }, }; } function loadDraft(initialValue: unknown): ThemeOverrideEditorDraft { return loadThemeOverrideDraft({ raw: initialValue, parse: parseThemeOverride, defaults: DISPLAY_DEFAULTS, previewPlatform: 'web', }); } /** * Guided target-contract authoring with intent-only serialization and a * stateful covering preview. */ export function ThemeEditor({ initialValue, onChange, showGetCode = false, showThemeModeToggle = false, }: Readonly) { const writerOwnerRef = useRef(createThemeOverrideWriterOwnershipToken('theme-editor')); const runtime = useMemo(() => createEditorRuntime(writerOwnerRef.current), []); const sessionRef = useRef( createThemeOverrideSessionState({ platform: 'web', writerOwner: writerOwnerRef.current, }), ); const [draft, setDraft] = useState(() => loadDraft(initialValue)); const [themeMode, setThemeMode] = useState('light'); const [jsonAnchorEl, setJsonAnchorEl] = useState(null); const [copied, setCopied] = useState(false); const debounceRef = useRef | undefined>(undefined); const previousInitialValueRef = useRef(initialValue); const userHasEditedRef = useRef(false); const onChangeRef = useRef(onChange); useEffect(() => { onChangeRef.current = onChange; }, [onChange]); useEffect(() => { if (previousInitialValueRef.current === initialValue) return; previousInitialValueRef.current = initialValue; userHasEditedRef.current = false; sessionRef.current = createThemeOverrideSessionState({ platform: 'web', writerOwner: writerOwnerRef.current, }); setDraft(loadDraft(initialValue)); }, [initialValue]); useEffect(() => { loadPlatformFonts(); return cleanupFontLinks; }, []); useEffect(() => { loadPresetFonts(draft.basePreset.displayValue); }, [draft.basePreset.displayValue]); const evaluation = useMemo(() => evaluateThemeOverrideDraft(draft, runtime), [draft, runtime]); const serialized = useMemo(() => serializeThemeOverrideDraft(draft), [draft]); const diagnostics = evaluation.diagnostics; const jsonString = useMemo( () => JSON.stringify( serialized.valid ? serialized.override : { diagnostics: serialized.diagnostics }, null, 2, ), [serialized], ); useEffect(() => { if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => { if (!evaluation.valid) return; const application = runtime.applyPreview(evaluation.expanded, sessionRef.current); if (!application.applied) return; sessionRef.current = application.session; if (userHasEditedRef.current) { onChangeRef.current(evaluation.override); } }, 200); return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; }, [evaluation, runtime]); const dispatch = useCallback( (action: ThemeOverrideDraftAction) => { userHasEditedRef.current = true; setDraft((current) => reduceThemeOverrideDraft(current, action, DISPLAY_DEFAULTS)); }, [setDraft], ); const handlePresetChange = useCallback( (preset: Preset) => dispatch({ type: 'set-preset', preset }), [dispatch], ); const handleInputChange = useCallback( (inputId: InputId, value: string) => dispatch({ type: 'set-input', inputId, value }), [dispatch], ); const handleInputClear = useCallback( (inputId: InputId) => dispatch({ type: 'clear-input', inputId }), [dispatch], ); const handleSelectionChange = useCallback( (definition: ResolvedControlDefinition, value: NonNullable) => { if (definition.binding.kind !== 'selection') { throw new Error(`Editor control "${definition.id}" is not selection-bound.`); } const { selectionId } = definition.binding; const candidate = evaluateThemeOverrideCandidate(draft, selectionId, value, runtime); if (candidate.accepted) { dispatch({ type: 'set-selection', selectionId, value }); } else { setDraft((current) => ({ ...current, diagnostics: candidate.diagnostics, })); } }, [dispatch, draft, runtime], ); const handleSelectionClear = useCallback( (selectionId: SelectionId) => dispatch({ type: 'clear-selection', selectionId }), [dispatch], ); const handleThemeModeChange = useCallback((_event: unknown, value: ThemeMode | null) => { if (value) setThemeMode(value); }, []); const handleCopyJson = useCallback(() => { void navigator.clipboard?.writeText(jsonString); setCopied(true); }, [jsonString]); useEffect(() => { if (!copied) return; const timeout = setTimeout(() => setCopied(false), 1500); return () => clearTimeout(timeout); }, [copied]); const loadError = draft.loadState === 'unsupported' || draft.loadState === 'invalid' ? diagnostics[0]?.message : undefined; return ( {loadError ? {loadError} : null} {TARGET_EDITOR_LAYOUT.map((section) => ( {section.heading ? ( {section.heading} ) : null} {section.controlIds.map((controlId) => { const definition = CONTROL_BY_ID.get(controlId); if (!definition) { throw new Error( `Theme Editor layout references unresolved control "${controlId}".`, ); } return ( ); })} ))} {diagnostics.length > 0 && !loadError ? ( severity === 'error') ? 'error' : 'warning' } > {diagnostics[0].message} ) : null} {showGetCode ? ( setJsonAnchorEl(event.currentTarget)}> Get code ) : null} {showThemeModeToggle ? ( Light Dark ) : null} setJsonAnchorEl(null)} anchorOrigin={{ vertical: 'center', horizontal: 'right' }} transformOrigin={{ vertical: 'center', horizontal: 'left' }} slotProps={{ paper: { sx: { ml: 1 } } }} > Theme Override JSON {jsonString} ); }