Template System Phase A — Implementation Plan
Status: Completed
Goal: Extract the current visual layer into a swappable template directory, driven by a site config file, so the site looks identical but the architecture supports template swapping.
Architecture: A site.config.json at the project root defines site identity, template selection, fonts, nav icons, and footer links. Templates live under frontend/src/templates/<name>/ and contain only CSS (tokens, components, layout vars). Shared layout components consume CSS variables and config values instead of hardcoding them.
Tech Stack: Next.js, TypeScript, CSS custom properties, Tailwind CSS v4
Design doc: docs/plans/2026-03-06-template-system-design.md
Task 1: Create site config and typed loader
Files:
- Create:
site.config.json - Create:
frontend/src/config/types.ts - Create:
frontend/src/config/site-config.ts - Create:
frontend/src/config/index.ts
Step 1: Create the config TypeScript types
Create frontend/src/config/types.ts:
export interface SiteConfig {
site: {
title: string;
tagline: string;
author: string;
copyright: string;
};
template: string;
brand: {
primary: string;
accent: string;
};
fonts: {
heading: string;
body: string;
};
navigation: {
iconMap: Record<string, string>;
};
footer: {
links: Array<{ label: string; href: string }>;
};
layout: {
containerMaxWidth: string;
};
}Step 2: Create the config file
Create site.config.json at project root:
{
"site": {
"title": "Ghostmonk",
"tagline": "Sharing Stories, Projects and Ideas",
"author": "Ghostmonk",
"copyright": "\u00a9 {year} Ghostmonk"
},
"template": "default",
"brand": {
"primary": "#4f46e5",
"accent": "#6366f1"
},
"fonts": {
"heading": "Poppins",
"body": "Roboto Slab"
},
"navigation": {
"iconMap": {
"blog": "home",
"about": "user",
"projects": "folder",
"contact": "mail"
}
},
"footer": {
"links": [
{ "label": "Privacy", "href": "/privacy" },
{ "label": "Terms", "href": "/terms" }
]
},
"layout": {
"containerMaxWidth": "75rem"
}
}Step 3: Create the config loader
Create frontend/src/config/site-config.ts:
import { SiteConfig } from './types';
import config from '../../site.config.json';
const siteConfig: SiteConfig = config as SiteConfig;
export function getSiteConfig(): SiteConfig {
return siteConfig;
}Note: This requires a site.config.json symlink or path alias. Since the config is at the project root (one level above frontend/), add a path alias in tsconfig.json:
"@config/*": ["../site.config.*"]Alternatively, the simpler approach: place a site.config.json symlink inside frontend/ or use next.config.js to expose it. Evaluate at implementation time which is cleanest.
Step 4: Create barrel export
Create frontend/src/config/index.ts:
export { getSiteConfig } from './site-config';
export type { SiteConfig } from './types';Step 5: Verify config loads
Add a temporary console.log(getSiteConfig().site.title) in _app.tsx, run make dev-frontend in the worktree, and confirm “Ghostmonk” appears in the console. Remove the console.log after verifying.
Step 6: Commit
git add site.config.json frontend/src/config/
git commit -m "feat: add site.config.json and typed config loader"Task 2: Create default template directory structure
Files:
- Create:
frontend/src/templates/default/styles/tokens.css - Create:
frontend/src/templates/default/styles/components.css - Create:
frontend/src/templates/default/styles/layout.css - Create:
frontend/src/templates/default/index.ts - Create:
frontend/src/templates/index.ts
Step 1: Extract tokens from globals.css into template tokens.css
The current globals.css has all CSS custom properties defined inline in :root (lines 3-78) and .dark (lines 80-101). Extract these into frontend/src/templates/default/styles/tokens.css.
This file should contain:
- All
:rootcustom properties (colors, fonts, spacing, radii, transitions, z-index) - All
.darkoverrides
Do NOT include HTML/body base styles or component classes — only the variable declarations.
Step 2: Extract component styles into template components.css
Extract from globals.css into frontend/src/templates/default/styles/components.css:
.page-title,.section-title.containerstyles.cardand variants.btnand variants.badgeand variants.navstyles (.nav,.nav__container,.nav__links, etc.).bottom-navand all related styles.gridand.grid--responsive.story-header,.story-title,.story-content,.story-excerpt.error-state,.empty-state.proseand variants- Image loading / shimmer styles
- Story progress bar styles
- All responsive media queries for the above
Step 3: Create layout.css with layout dimension variables
Create frontend/src/templates/default/styles/layout.css:
:root {
--layout-nav-height: 56px;
--layout-footer-height: 32px;
--layout-bottom-nav-offset: 2rem;
--layout-container-max-width: 75rem;
--layout-content-padding: 1.5rem;
--layout-bottom-offset: calc(
var(--layout-nav-height) +
var(--layout-bottom-nav-offset) +
var(--layout-footer-height) +
env(safe-area-inset-bottom)
);
}
@media (min-width: 768px) {
:root {
--layout-bottom-offset: 4rem;
}
}Step 4: Create template manifest
Create frontend/src/templates/default/index.ts:
import './styles/tokens.css';
import './styles/components.css';
import './styles/layout.css';
export const template = {
name: 'default',
};Step 5: Create template loader
Create frontend/src/templates/index.ts:
import { getSiteConfig } from '@/config';
export async function loadTemplate(): Promise<void> {
const { template } = getSiteConfig();
await import(`./${template}/index.ts`);
}Step 6: Commit
git add frontend/src/templates/
git commit -m "feat: create default template with extracted tokens, components, and layout CSS"Task 3: Refactor globals.css
Files:
- Modify:
frontend/src/styles/globals.css
Step 1: Strip globals.css down to framework-only concerns
After extracting tokens and components into the template, globals.css should contain ONLY:
@import "tailwindcss"— Tailwind directives- HTML/body base resets (box-sizing, scroll-behavior, font smoothing)
- Typography base styles (h1-h6 sizing, paragraph spacing)
.pb-safeutility (safe area inset)- Editor styles (ProseMirror / TipTap) — these are editor-specific, not template-specific
Remove from globals.css:
- All
:rootcustom property declarations (moved to template tokens.css) - All
.darkoverrides (moved to template tokens.css) - All component classes:
.card,.btn,.badge,.nav,.bottom-nav,.grid,.story-*,.prose,.error-state,.empty-state, shimmer, progress bar (moved to template components.css) - The
.pb-bottom-navclass and its!importantrules (replaced by--layout-bottom-offsetCSS var) .page-title,.section-title,.containerstyles (moved to template components.css)
Step 2: Verify the site still renders correctly
Run make dev-frontend in the worktree. The site should look identical — the template CSS is now loaded via the template manifest import, and globals.css handles only resets and Tailwind.
If styles are missing, the template CSS files are not being imported. Check that _app.tsx loads the template (Task 5).
Step 3: Commit
git add frontend/src/styles/globals.css
git commit -m "refactor: strip globals.css to framework concerns only"Task 4: Refactor layout components to use CSS variables and config
Files:
- Modify:
frontend/src/layout/Layout.tsx - Modify:
frontend/src/layout/Footer.tsx - Modify:
frontend/src/layout/TopNav.tsx - Modify:
frontend/src/layout/BottomNav.tsx - Modify:
frontend/src/shared/lib/navigation.ts
Step 1: Refactor Layout.tsx
Replace hardcoded padding with CSS variable:
import React, { ReactNode } from "react";
import TopNav from "./TopNav";
import BottomNav from "./BottomNav";
import Footer from "./Footer";
interface LayoutProps {
children: ReactNode;
}
const Layout: React.FC<LayoutProps> = ({ children }) => {
return (
<div className="min-h-dvh transition-colors duration-300" style={{ backgroundColor: 'var(--color-surface-primary)', color: 'var(--color-text-primary)' }}>
<TopNav />
<main
className="container mx-auto px-6 pt-6"
style={{ paddingBottom: 'var(--layout-bottom-offset)' }}
>
{children}
</main>
<Footer />
<BottomNav />
</div>
);
};
export default Layout;Step 2: Refactor Footer.tsx
Replace hardcoded links and copyright with config:
import React from 'react';
import Link from 'next/link';
import { getSiteConfig } from '@/config';
const Footer: React.FC = () => {
const { site, footer } = getSiteConfig();
const copyrightText = site.copyright.replace('{year}', String(new Date().getFullYear()));
return (
<footer
className="fixed left-0 right-0 bottom-0 py-2 px-4 text-xs z-[55] pointer-events-none"
style={{
backgroundColor: 'var(--color-surface-primary)',
borderTop: '1px solid var(--color-border-primary)',
color: 'var(--color-text-secondary)'
}}
>
<div className="container mx-auto flex justify-between items-center">
<span className="pointer-events-auto">{copyrightText}</span>
<div className="flex gap-4">
{footer.links.map((link) => (
<Link key={link.href} href={link.href} className="hover:underline pointer-events-auto">
{link.label}
</Link>
))}
</div>
</div>
</footer>
);
};
export default Footer;Step 3: Refactor navigation.ts to accept icon map
import { Section } from '@/shared/types/api';
export type NavIcon = 'home' | 'user' | 'folder' | 'mail' | 'default';
export interface NavSectionItem {
id: string;
slug: string;
path: string;
label: string;
icon: NavIcon;
}
export function sectionToNavItem(section: Section, iconMap: Record<string, string>): NavSectionItem {
return {
id: section.id,
slug: section.slug,
path: `/${section.slug}`,
label: section.title,
icon: (iconMap[section.slug] || 'default') as NavIcon,
};
}
export function sectionsToNavItems(sections: Section[], iconMap: Record<string, string>): NavSectionItem[] {
return sections.map(s => sectionToNavItem(s, iconMap));
}
const DEFAULT_SECTION_SLUG = 'blog';
export function getActiveSectionSlug(pathname: string, sections: NavSectionItem[]): string {
const firstSegment = pathname.split('/').filter(Boolean)[0];
if (!firstSegment) return DEFAULT_SECTION_SLUG;
return sections.find(s => s.slug === firstSegment)?.slug || DEFAULT_SECTION_SLUG;
}Step 4: Update useNavSections hook to pass icon map
Find frontend/src/hooks/useNavSections.ts and update the call to sectionsToNavItems to pass getSiteConfig().navigation.iconMap.
Step 5: Refactor TopNav.tsx
Replace hardcoded site title with getSiteConfig().site.title. The icon map is already handled via the updated useNavSections hook.
Step 6: Verify all navigation and footer render correctly
Run make dev-frontend, check:
- Desktop nav shows correct section links with icons
- Mobile bottom nav shows correct section links with icons
- Footer shows copyright with current year and correct links
- Site title in nav matches config
Step 7: Commit
git add frontend/src/layout/ frontend/src/shared/lib/navigation.ts frontend/src/hooks/useNavSections.ts
git commit -m "refactor: layout components consume config and CSS variables"Task 5: Refactor _app.tsx and _document.tsx
Files:
- Modify:
frontend/src/pages/_app.tsx - Modify:
frontend/src/pages/_document.tsx
Step 1: Load template in _app.tsx
Add the template import at the top of _app.tsx, before the component definition. Since the template manifest has CSS side-effect imports, this loads all template styles:
import '../styles/globals.css';
import { getSiteConfig } from '@/config';
// Load active template CSS
const templateName = getSiteConfig().template;
require(`../templates/${templateName}/index.ts`);Note: Use require for synchronous loading at module level, or use a dynamic import in a top-level await if the build supports it. The key requirement is that template CSS loads before first render. Evaluate the cleanest approach at implementation time — a static import of the default template is acceptable for Phase A if dynamic loading adds complexity:
import '../templates/default';With a comment noting this will become dynamic in Phase B.
Step 2: Refactor _document.tsx for dynamic fonts
Replace hardcoded Poppins/Roboto Slab font imports with config-driven ones:
import { Html, Head, Main, NextScript } from 'next/document';
import siteConfig from '../../site.config.json';
const fontFamilies = [siteConfig.fonts.heading, siteConfig.fonts.body]
.filter((v, i, a) => a.indexOf(v) === i) // deduplicate
.map(f => f.replace(/ /g, '+'))
.join('&family=');
const fontUrl = `https://fonts.googleapis.com/css2?family=${fontFamilies}:wght@300;400;500;600;700&display=swap`;
export default function Document() {
return (
<Html lang="en" className="dark" data-theme="dark">
<Head>
<meta charSet="utf-8" />
<meta name="referrer" content="strict-origin-when-cross-origin" />
<link rel="icon" href="/favicon.ico" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link href={fontUrl} rel="stylesheet" />
</Head>
<body style={{ backgroundColor: '#0f172a' }} className="text-foreground">
<Main />
<NextScript />
</body>
</Html>
);
}Note: _document.tsx runs server-side only. Import the JSON config directly since getSiteConfig() may rely on client-side module resolution. Use the raw JSON import.
Step 3: Verify fonts load correctly
Run make dev-frontend, inspect the <head> in browser DevTools. Confirm:
- Google Fonts URL contains the fonts from config
- No hardcoded Poppins/Roboto Slab references remain in
_document.tsx - Fonts render correctly on the page
Step 4: Commit
git add frontend/src/pages/_app.tsx frontend/src/pages/_document.tsx
git commit -m "refactor: dynamic template loading and config-driven font imports"Task 6: Clean up dead code and unused files
Files:
- Delete:
frontend/src/styles/tokens.css(if not imported anywhere after refactor) - Delete:
frontend/src/styles/base.css(if not imported anywhere after refactor) - Delete:
frontend/src/styles/components.css(if not imported anywhere after refactor) - Modify:
frontend/src/hooks/useMediaQuery.ts— removeuseIsMobileif unused
Step 1: Verify no remaining imports of old style files
Search the codebase for imports of tokens.css, base.css, components.css from the old styles/ location. If globals.css previously imported them, those imports are now gone. If any other file imports them, update to import from the template path or remove.
Step 2: Delete unused style files
Only delete files confirmed to have zero imports. The separate tokens.css, base.css, components.css in frontend/src/styles/ appear to be parallel copies that aren’t the actual source (globals.css is). Verify and delete.
Step 3: Remove useIsMobile if unused
Search for useIsMobile imports. If only referenced in its own definition file and the hooks barrel export, remove it from both. Keep useIsDesktop and useMediaQuery if they’re used elsewhere.
Step 4: Verify no regressions
Run make dev-frontend, check all pages:
- Home page (blog feed)
- Projects page (card grid)
- Privacy / Terms pages
- About / Contact pages
- Mobile viewport (bottom nav, footer, padding)
- Desktop viewport (top nav, footer, no bottom nav)
- Dark mode toggle works
- Light mode toggle works
Step 5: Commit
git add -A
git commit -m "chore: remove dead style files and unused hooks"Task 7: Validation — create a trivial second template
Files:
- Create:
frontend/src/templates/debug/styles/tokens.css - Create:
frontend/src/templates/debug/styles/components.css - Create:
frontend/src/templates/debug/styles/layout.css - Create:
frontend/src/templates/debug/index.ts
Step 1: Create a debug template
Copy the default template directory. Change one or two obvious values in tokens.css — e.g., set brand primary to red (#dc2626), change heading font to monospace.
Step 2: Switch site.config.json to use the debug template
"template": "debug"Step 3: Verify the site renders with the debug template
Run make dev-frontend. Confirm:
- Brand color is now red
- Heading font changed
- Everything else still works (nav, footer, routing, content)
Step 4: Switch back to default
"template": "default"Step 5: Delete the debug template
rm -rf frontend/src/templates/debug/Step 6: Final commit
git add -A
git commit -m "test: validate template swapping works, clean up debug template"Task 8: Run all tests and push
Step 1: Run backend tests
cd /path/to/worktree && source ~/Documents/venvs/field-notes/bin/activate && pytest -qExpected: 221 passed
Step 2: Run frontend lint
cd /path/to/worktree/frontend && npm run lintExpected: No errors
Step 3: Push branch
git push -u origin ghostmonk/issue-129-design-reworkStep 4: Create PR
Reference issue #129 in the PR body.