Themes and payload renderers
The UI source has two extension seams that do not require changing Cheetah's protocol: themes change the presentation palette, while payload renderers teach MessageCard how to explain a product-specific result. Neither seam changes the stored message or its authority.
Override the palette through ThemeProvider
ThemeProvider merges a CheetahThemeOverrides value with the default theme. Ordinary colors may be supplied as a nested partial map. Message-kind colors may override only the named entries the product needs. A supplied tag palette replaces the complete palette. Keep that replacement non-empty; tag coloring indexes the palette and does not validate an empty array first.
<ThemeProvider
theme={{
colors: {
surface: '#18212f',
accent: '#60a5fa',
},
kindColors: {
event: '#c084fc',
},
tagPalette: ['#38bdf8', '#34d399', '#fbbf24'],
}}
>
<OperatorView />
</ThemeProvider>
The value returned by useCheetahTheme() is always complete. The provider publishes the following ordinary colors as CSS variables on its wrapper:
| Theme field | CSS variable | Meaning |
|---|---|---|
background | --ch-bg | page or nested background |
surface, surfaceHover | --ch-surface, --ch-surface-hover | cards and interactive surface state |
border | --ch-border | borders and dividers |
text, textMuted | --ch-text, --ch-text-muted | primary and supporting text |
accent | --ch-accent | selection and navigation emphasis |
success, warning, danger | --ch-success, --ch-warning, --ch-danger | operational status tones |
Message-kind colors and the tag palette remain theme-context values; the provider does not emit a second family of kind or tag CSS variables. Components such as MessageCard read them from context. Theme variables also do not replace the utility CSS described in Component layers and styling.
Let the first matching renderer explain the payload
MessageCard passes the retained payload and its complete HistoryMessage through one module-wide renderer registry. Higher priority runs first. Registering a renderer with an existing name replaces the earlier registration and re-sorts the registry.
| Priority | Supplied renderer | Match rule |
|---|---|---|
product-selected, normally 0 or higher | custom | the product's match(payload, message) returns true |
-10 | screenshot | a nested object contains recognized base64 image data |
-20 | table | a non-empty array contains objects with the same key set |
-30 | key/value | a non-empty object has at most eight top-level fields |
-100 | JSON | unconditional fallback |
The first match wins. A custom renderer does not need a positive priority; the default priority of 0 already runs before every supplied renderer.
Register product meaning during bootstrap
import {
registerRenderer,
type PayloadRendererProps,
} from '@cheetah/ui';
function TemperatureReading({ payload }: PayloadRendererProps) {
if (typeof payload !== 'object' || payload === null) return null;
const value = (payload as Record<string, unknown>).celsius;
return <strong>{typeof value === 'number' ? `${value.toFixed(1)} °C` : 'invalid reading'}</strong>;
}
registerRenderer({
name: 'temperature-reading',
match: (payload) =>
typeof payload === 'object' &&
payload !== null &&
typeof (payload as Record<string, unknown>).celsius === 'number',
component: TemperatureReading,
});
Register stable renderers once during application bootstrap. Registration is module-global, not scoped to a ThemeProvider or component tree. Repeated registration during rendering can reorder the global registry; a plugin that owns a short-lived renderer should remove it with unregisterRenderer(name) during cleanup.
Keep match functions synchronous, fast, side-effect free, and non-throwing. The registry does not isolate a throwing matcher. The renderer receives payload: unknown, so it must validate before reading product fields. It should render meaning, not perform work, send commands, or rewrite the retained message.
Understand supplied image behavior
The screenshot renderer searches nested values for recognized base64 images, displays the image, and replaces image bytes only in the accompanying text copy with a size summary. The retained payload object is not changed. The JSON and key/value renderers use the same display sanitization to avoid printing large base64 values as letter soup.
ImagePreview can also load recognized HTTP image URLs. That makes the operator's browser contact the referenced origin. A product displaying untrusted payloads should set an appropriate content security policy or replace the renderer with a controlled proxy/storage path. A custom renderer owns equivalent HTML, URL, privacy, and resource-size decisions; renderer registration is not a sanitization boundary.