Skip to Content
⚠️Active Development Notice: TimeTiles is under active development. Information may be placeholder content or not up-to-date.

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

FilePurpose
lib/blocks/registry.tsBlock plugin registry
lib/blocks/block-style-fields.tsShared block style field definitions
lib/blocks/*.tsIndividual block definitions
lib/collections/themes.tsThemes collection
lib/collections/layout-templates.tsLayout templates collection
lib/collections/sites/index.tsSites with branding + custom code
lib/context/site-context.tsxSite context with expanded branding types
components/site-branding.tsxCSS custom property injection
components/block-renderer.tsxBlock rendering with style wrapper
components/layout/layout-shell.tsxLayout template resolution
lib/utils/css-sanitizer.tsCSS 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.ts

BlockPlugin 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

  1. Each block file calls registerBlock() at module load, adding its definition to an in-memory Map.
  2. lib/blocks/index.ts imports all block files to trigger registration.
  3. lib/collections/pages.ts imports the barrel and calls getPayloadBlocks(), which:
    • Iterates all registered plugins
    • Automatically appends blockStyleFields to each block’s fields
    • Returns Payload-compatible Block[] definitions
  4. 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):

FielddbName
blockStyle (group)bs
paddingToppt
paddingBottompb
maxWidthmw
separatorsep

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

PatternReason
@importPrevents 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, @namespacePrevents encoding manipulation
position: fixedPrevents 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)
Last updated on