UI Customization Architecture
TimeTiles implements a six-layer UI customization system documented in ADR 0015. 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 |
|---|---|
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 |
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 |
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"];
/** Optional React render function */
render?: (block: Record<string, unknown>, key: string) => React.ReactElement;
}Registering a Custom Block
Create a new file in lib/blocks/:
// lib/blocks/pricing-table.ts
import type { Field } from "payload";
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 a Renderer
To render your custom block, add it to the blockRenderers map in components/block-renderer.tsx:
import type { Block } from "@/lib/types/cms-blocks";
// Add to the blockRenderers map
const blockRenderers: Record<string, (block: Block, key: string) => React.ReactElement> = {
// ... existing renderers
pricingTable: (block, key) => {
const b = block as Record<string, unknown>;
return <PricingTable key={key} tiers={b.tiers as Tier[]} title={b.title as string} />;
},
};Adding Types
Add a TypeScript interface for your block to lib/types/cms-blocks.ts:
export interface PricingTableBlock {
blockType: "pricingTable";
title: string;
tiers: Array<{
name: string;
price: string;
description?: string | null;
features?: Array<{ text: string; id?: string | null }> | null;
ctaText?: string | null;
ctaLink: string;
highlighted?: boolean | null;
id?: string | null;
}>;
blockStyle?: BlockStyle | null;
id?: string | null;
blockName?: string | null;
}Add it to the Block union type:
export type Block =
| HeroBlock
| FeaturesBlock
// ...existing types
| PricingTableBlock;Registry API
import {
registerBlock, // Register a new block plugin
getRegisteredBlocks, // Get all registered BlockPlugin[]
getBlock, // Get a single plugin by slug
getPayloadBlocks, // Get Payload Block[] with style fields
getBlockRenderers, // Get render map from plugins with renderers
} 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
const PADDING_MAP = {
none: "py-0", // → pt-0 or pb-0
sm: "py-4",
md: "py-8",
lg: "py-16",
xl: "py-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
Block style fields use short dbName values to avoid exceeding PostgreSQL’s 63-character identifier limit when nested inside blocks with long slugs (e.g., newsletterCTA):
| Field | dbName |
|---|---|
blockStyle (group) | bs |
paddingTop | pt |
paddingBottom | pb |
maxWidth | mw |
separator | sep |
Theme Token System
Three-Layer Architecture
The CSS theming follows a three-layer architecture defined in packages/ui/src/styles/globals.css:
Layer 1: Base Colors (Cartographic Palette)
--cartographic-parchment, --cartographic-navy, etc.
↓
Layer 2: Semantic Tokens
--background, --foreground, --primary, --accent, etc.
↓
Layer 3: Component Usage
bg-background, text-foreground, border-border, etc.How Site Branding Overrides Work
The SiteBranding component (components/site-branding.tsx) injects CSS custom properties that override Layer 2 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.
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)