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

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

FilePurpose
packages/ui/src/themes/cartographic.cssCartographic theme (default) — all tokens
packages/ui/src/themes/modern.cssModern theme — scoped to .theme-modern
packages/ui/src/styles/globals.cssTailwind setup, imports theme, @theme inline bridge
packages/ui/src/provider.tsxUIProvider — chart themes, map colors, newsletter, UI texts
lib/hooks/use-theme-preset.tsRuntime theme switching via CSS class
lib/constants/theme-presets.tsShared preset definitions, chart/map colors, initialization script
app/_components/theme-preset-picker.tsxPalette icon UI for theme switching
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 (CMS presets)
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

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.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"]; }

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

  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 Types

After registering the block, regenerate Payload’s types from the repository root:

pnpm --filter web exec payload generate:types

The 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).

FielddbName
paddingToppt
paddingBottompb
maxWidthmw
separatorsep

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

ThemeCSS fileSelectorDescription
Cartographicthemes/cartographic.css:root / .darkDefault — warm earth tones
Modernthemes/modern.css.theme-modern / .theme-modern.darkVibrant, 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 localStorage under the key timetiles-theme-preset when 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 :root default
  • 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

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