UI Customization Architecture
TimeTiles implements a six-layer UI customization system documented in ADR 0035. This page covers the architecture and extension points for developers.
System Overview
┌─────────────────────────────────────────────────┐
│ CSS Output │
│ Semantic tokens + custom CSS + block styles │
├─────────────────────────────────────────────────┤
│ SiteBranding │
│ Injects CSS custom properties from context │
├─────────────────────────────────────────────────┤
│ SiteContext │
│ Resolves: theme preset → site overrides │
├─────────────────────────────────────────────────┤
│ Payload CMS Data │
│ Themes · Sites · Pages · Layout Templates │
├─────────────────────────────────────────────────┤
│ Block Registry │
│ Field definitions + style fields + renderers │
└─────────────────────────────────────────────────┘Key Files
| File | Purpose |
|---|---|
packages/ui/src/themes/cartographic.css | Cartographic theme (default) — all tokens |
packages/ui/src/themes/modern.css | Modern theme — scoped to .theme-modern |
packages/ui/src/styles/globals.css | Tailwind setup, imports theme, @theme inline bridge |
packages/ui/src/provider.tsx | UIProvider — chart themes, map colors, newsletter, UI texts |
lib/hooks/use-theme-preset.ts | Runtime theme switching via CSS class |
lib/constants/theme-presets.ts | Shared preset definitions, chart/map colors, initialization script |
app/_components/theme-preset-picker.tsx | Palette icon UI for theme switching |
lib/blocks/registry.ts | Block plugin registry |
lib/blocks/block-style-fields.ts | Shared block style field definitions |
lib/blocks/*.ts | Individual block definitions |
lib/collections/themes.ts | Themes collection (CMS presets) |
lib/collections/layout-templates.ts | Layout templates collection |
lib/collections/sites/index.ts | Sites with branding + custom code |
lib/context/site-context.tsx | Site context with expanded branding types |
components/site-branding.tsx | CSS custom property injection |
components/block-renderer.tsx | Block rendering with style wrapper |
components/layout/layout-shell.tsx | Layout template resolution |
lib/utils/css-sanitizer.ts | CSS sanitization for injection |
For the full theming API (semantic tokens, UIProvider, dark mode, fonts), see the UI package documentation.
Block Registry
The block registry allows developers to add new block types to the page builder without modifying core code.
Architecture
lib/blocks/
├── registry.ts # Core registry (registerBlock, getPayloadBlocks)
├── block-style-fields.ts # Shared style fields (auto-appended)
├── shared.ts # Shared options (icons, accents)
├── index.ts # Barrel import (triggers registration)
├── hero.ts # Built-in block definitions
├── features.ts
├── stats.ts
├── details-grid.ts
├── timeline.ts
├── testimonials.ts
├── rich-text.ts
├── cta.ts
├── newsletter-form.ts
└── newsletter-cta.tsBlockPlugin Interface
interface BlockPlugin {
/** Unique slug for this block type */
slug: string;
/** Labels for the Payload admin UI */
labels: { singular: string; plural: string };
/** Payload CMS field definitions */
fields: PayloadBlock["fields"];
}Registering a Custom Block
Create a new file in lib/blocks/:
// lib/blocks/pricing-table.ts
import { registerBlock } from "./registry";
registerBlock({
slug: "pricingTable",
labels: { singular: "Pricing Table", plural: "Pricing Tables" },
fields: [
{ name: "title", type: "text", required: true, localized: true },
{
name: "tiers",
type: "array",
required: true,
minRows: 1,
fields: [
{ name: "name", type: "text", required: true },
{ name: "price", type: "text", required: true },
{ name: "description", type: "textarea" },
{ name: "features", type: "array", fields: [{ name: "text", type: "text", required: true }] },
{ name: "ctaText", type: "text", defaultValue: "Get Started" },
{ name: "ctaLink", type: "text", required: true },
{ name: "highlighted", type: "checkbox", defaultValue: false },
],
},
],
});Then add the import to lib/blocks/index.ts:
import "./pricing-table";How It Works
- Each block file calls
registerBlock()at module load, adding its definition to an in-memoryMap. lib/blocks/index.tsimports all block files to trigger registration.lib/collections/pages.tsimports the barrel and callsgetPayloadBlocks(), which:- Iterates all registered plugins
- Automatically appends
blockStyleFieldsto each block’s fields - Returns Payload-compatible
Block[]definitions
- The block style fields (
paddingTop,paddingBottom,maxWidth, etc.) are added automatically — you don’t need to include them in your block definition.
Adding Types
After registering the block, regenerate Payload’s types from the repository root:
pnpm --filter web exec payload generate:typesThe Block union in lib/types/cms-blocks.ts derives from Page["pageBuilder"][number], so the new variant is included automatically. Do not duplicate its fields in a hand-written interface. If a renderer needs a named type, add an alias:
export type PricingTableBlock = Extract<Block, { blockType: "pricingTable" }>;Adding a Renderer
Add a case to the renderBlock switch in components/block-renderer.tsx:
case "pricingTable":
return <PricingTable tiers={block.tiers} title={block.title} />;The switch narrows the generated union to your block’s type; no casts are needed. The outer BlockStyleWrapper already owns the React list key and applies the block’s style settings, so do not pass a key through the renderer helpers.
Registry API
import {
registerBlock, // Register a new block plugin
getRegisteredBlocks, // Get all registered BlockPlugin[]
getPayloadBlocks, // Get Payload Block[] with style fields
} from "@/lib/blocks/registry";Block Style System
Every block automatically receives style controls via the blockStyleFields definition in lib/blocks/block-style-fields.ts.
How Styles Are Applied
The BlockStyleWrapper component in block-renderer.tsx wraps every rendered block:
BlockRenderer
└── for each block:
└── BlockStyleWrapper
├── <div> with classes + inline styles (if style configured)
│ └── rendered block content
└── separator element (if configured)Style Resolution
// Padding maps to Tailwind classes. Both directions are spelled out as full
// literal class names — Tailwind only emits utilities it can find verbatim
// while scanning the source, so a class built at runtime never reaches the CSS.
const PADDING_TOP_MAP = { none: "pt-0", sm: "pt-4", md: "pt-8", lg: "pt-16", xl: "pt-24" };
const PADDING_BOTTOM_MAP = { none: "pb-0", sm: "pb-4", md: "pb-8", lg: "pb-16", xl: "pb-24" };
// Max width maps to Tailwind classes
const MAX_WIDTH_MAP = { sm: "max-w-3xl", md: "max-w-5xl", lg: "max-w-6xl", xl: "max-w-7xl", full: "max-w-full" };Data Attributes for CSS Targeting
Each wrapped block receives data attributes that custom CSS can target:
<div data-block-type="hero" data-block-id="abc123">
<!-- block content -->
</div>The <body> element receives data-site="site-slug", enabling site-scoped CSS.
Database Name Compression
The select fields in lib/blocks/block-style-fields.ts use short dbName values for their PostgreSQL enum types. The blockStyle group has no dbName override; generated column names retain the block_style_ prefix (for example, block_style_padding_top).
| Field | dbName |
|---|---|
paddingTop | pt |
paddingBottom | pb |
maxWidth | mw |
separator | sep |
Theme Token System
CSS Architecture
Theme colors are defined in standalone CSS files under packages/ui/src/themes/. The main stylesheet imports the active theme:
Theme CSS file (cartographic.css or modern.css)
↓
globals.css (@import "../themes/cartographic.css")
↓
@theme inline (bridges CSS vars → Tailwind utilities)
↓
Components (bg-primary, text-foreground, border-border, etc.)Each theme file defines a base palette and maps it to semantic tokens (--primary, --background, --foreground, etc.). Components only use semantic tokens — never base palette variables like --cartographic-navy directly. This means switching themes is just swapping which CSS class is active.
Built-in Themes
| Theme | CSS file | Selector | Description |
|---|---|---|---|
| Cartographic | themes/cartographic.css | :root / .dark | Default — warm earth tones |
| Modern | themes/modern.css | .theme-modern / .theme-modern.dark | Vibrant, high-contrast, system fonts |
The Cartographic theme uses :root so it applies by default. The web app additionally imports @timetiles/ui/themes/modern.css in apps/web/app/styles/app.css. The Modern theme is scoped to .theme-modern and activates when that class is added to <html> and <body>; switching presets does not replace stylesheet imports at runtime.
Runtime Theme Switching
The useThemePreset() hook (lib/hooks/use-theme-preset.ts) manages runtime switching:
- Shares the current selection across consumers through a Zustand store
- Persists the user’s selection to
localStorageunder the keytimetiles-theme-presetwhen storage is available; switching still works when storage is blocked or full - Hydrates the store once and synchronizes subsequent changes from other tabs; clearing the stored selection restores Cartographic
- Applies/removes the
.theme-{id}CSS class on both<html>and<body> - The default preset (Cartographic) uses no class — it is the
:rootdefault - Other presets add
.theme-{id}(e.g.,.theme-modern)
The ThemePresetPicker component renders the Palette icon in the header. It cycles through available presets on click.
Flash Prevention
The frontend layout (app/[locale]/(frontend)/layout.tsx) declares a single next/script component with id="theme-preset" and strategy="beforeInteractive". Its script comes from lib/constants/theme-presets.ts, using the same preset definitions and storage key as useThemePreset(). It applies a known non-default preset to both <html> and <body> and ignores missing or invalid stored values. Both elements need the class because next/font defines font variables on <body>.
Add preset definitions in the shared module; do not duplicate the preset list in the layout or add separate head/body scripts.
UIProvider
The UIProvider component (packages/ui/src/provider.tsx) configures runtime behavior that CSS alone cannot control:
- Chart themes — ECharts color configuration for light and dark modes
- Map colors — MapLibre point, cluster gradient, and stroke colors
- Newsletter handler — custom submission callback
- Labels — translated built-in texts (pagination, navigation drawer, loading, empty and error states, confirm dialogs); missing entries fall back to English
<UIProvider
resolveTheme={() => theme ?? "light"}
lightChartTheme={chartTheme}
darkChartTheme={darkChartTheme}
mapColors={mapColors}
onNewsletterSubmit={handleNewsletter}
labels={labels}
>
{children}
</UIProvider>TimeTiles builds labels from its next-intl messages in apps/web/components/use-translated-ui-labels.ts, so components from @timetiles/ui render in the active locale without passing texts at each call site.
How Site Branding Overrides Work
The SiteBranding component (components/site-branding.tsx) injects CSS custom properties that override semantic tokens:
// Maps SiteBrandingColors keys to CSS custom properties
const COLOR_TOKEN_MAP = {
primary: "--primary",
primaryForeground: "--primary-foreground",
secondary: "--secondary",
background: "--background",
foreground: "--foreground",
// ... 15 tokens total
};Since all components use semantic tokens (e.g., bg-background, text-primary), overriding these variables automatically re-themes the entire UI. CMS theme presets and site branding both work by redefining the same semantic tokens that the built-in themes set.
SiteContext Types
interface SiteContextValue {
site: Site | null;
hasSite: boolean;
branding: {
title?: string;
logoUrl?: string;
logoDarkUrl?: string;
faviconUrl?: string;
colors?: SiteBrandingColors; // 15 semantic color tokens
typography?: SiteTypography; // fontPairing
style?: SiteStyle; // borderRadius, density
};
customCode?: SiteCustomCode; // headHtml, customCSS, bodyStartHtml, bodyEndHtml
}Layout Templates
Resolution Logic
Layout templates resolve using a priority chain:
import {
resolveLayoutTemplate,
getContentMaxWidthClass,
shouldShowHeader,
shouldShowFooter,
DEFAULT_LAYOUT,
} from "@/components/layout/layout-shell";
// Resolution: page override > site default > platform default
const template = resolveLayoutTemplate(pageLayout ?? siteLayout ?? null);
// Use in layout
if (shouldShowHeader(template)) {
/* render header */
}
if (shouldShowFooter(template)) {
/* render footer */
}
const widthClass = getContentMaxWidthClass(template.contentMaxWidth);LayoutTemplateConfig
interface LayoutTemplateConfig {
headerVariant: "marketing" | "app" | "minimal" | "none";
footerVariant: "full" | "compact" | "none";
contentMaxWidth: "sm" | "md" | "lg" | "xl" | "full";
stickyHeader: boolean;
}Platform Defaults
When no layout template is assigned:
const DEFAULT_LAYOUT: LayoutTemplateConfig = {
headerVariant: "marketing",
footerVariant: "full",
contentMaxWidth: "lg",
stickyHeader: true,
};CSS Sanitization
Custom CSS injected via the CMS passes through lib/utils/css-sanitizer.ts before rendering:
Sanitized Patterns
| Pattern | Reason |
|---|---|
@import | Prevents loading external stylesheets |
url() | Prevents loading external resources / data exfiltration |
javascript: | Prevents script injection |
expression() | Prevents legacy IE script execution |
behavior: | Prevents HTC behavior injection |
-moz-binding: | Prevents XBL binding injection |
@charset, @namespace | Prevents encoding manipulation |
position: fixed | Prevents full-page overlay attacks |
<script>, <style>, <link> | Prevents HTML injection via CSS |
Scoping
Custom CSS is wrapped in a site-specific scope:
[data-site="my-site-slug"] {
/* admin's custom CSS here */
}This prevents cross-site style leakage in multi-tenant deployments.
Collections Reference
Themes
Slug: themes
Group: Configuration
Access: Read (public), Write (editor/admin)
Versioning: Yes (with drafts)
Fields:
name (text, required)
description (textarea)
colors (group) — 15 semantic tokens for light mode
darkColors (group) — 15 semantic tokens for dark mode
typography.fontPairing (select)
style.borderRadius (select)
style.density (select)
createdBy (relationship → users)Layout Templates
Slug: layout-templates
Group: Configuration
Access: Read (public), Write (editor/admin)
Versioning: Yes (with drafts)
Fields:
name (text, required)
description (textarea)
headerVariant (select: marketing/app/minimal/none)
stickyHeader (checkbox)
footerVariant (select: full/compact/none)
contentMaxWidth (select: sm/md/lg/xl/full)
createdBy (relationship → users)Sites (New Fields)
Added to existing Sites collection:
branding.colors — expanded from 3 to 15 semantic tokens
branding.typography.fontPairing (select)
branding.style.borderRadius (select)
branding.style.density (select)
branding.theme (relationship → themes)
customCode.headHtml (textarea)
customCode.customCSS (textarea)
customCode.bodyStartHtml (textarea)
customCode.bodyEndHtml (textarea)
defaultLayout (relationship → layout-templates)Pages (New Fields)
Added to existing Pages collection:
layoutOverride (relationship → layout-templates)
pageBuilder blocks — now loaded from block registry
each block includes blockStyle group (auto-appended)