Hardcopy has been through its biggest update yet. What started as a few CSS tweaks turned into a full redesign — a proper design system, consolidated pages, a new projects showcase, refreshed branding, and a component overhaul that touched nearly every file in the repository.
This post walks through what changed, why I made each decision, and the benefits of the new approach. If you're running your own blog or personal site, some of these patterns might be useful.
The New Design System: CSS Custom Properties and Self-Hosted Fonts
The single biggest change in this update is the move from scattered Tailwind utility classes to a centralised design token system built on CSS custom properties.
Before: A Patchwork of Utility Classes
The old CSS was minimal — about 50 lines of overrides on top of Tailwind's defaults. Dark mode was handled by sprinkling dark: variants across every component:
/* The old approach */
.link-hover {
@apply hover:underline hover:text-blue-500 dark:hover:text-blue-400 transition-colors duration-150;
}
Components were full of classes like text-gray-600, dark:text-gray-200, bg-gradient-to-r from-blue-500 to-blue-400 dark:from-gray-800 dark:to-gray-700. Every colour decision was made inline, in every component, independently. Changing the primary colour or adjusting the dark mode palette meant hunting through dozens of files.
High contrast mode was particularly painful — it had to target specific generated Tailwind class names with !important overrides:
/* The old high contrast approach — fragile */
.high-contrast .bg-white,
.high-contrast .dark\:bg-gray-800,
.high-contrast .dark\:bg-gray-700,
.high-contrast .bg-gray-50 {
background-color: var(--bg-card) !important;
}
This broke whenever a new colour class was introduced and didn't cover every case.
After: Design Tokens
The new app.css is around 450 lines and defines a complete token system:
:root {
--color-primary: #f97316;
--color-primary-hover: #ea580c;
--color-primary-light: #fff7ed;
--color-bg: #fafaf9;
--color-surface: #ffffff;
--color-text: #1c1917;
--color-text-secondary: #57534e;
--color-border: #e7e5e4;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--font-body: 'Inter', system-ui, -apple-system, sans-serif;
--font-heading: 'Space Grotesk', 'Inter', system-ui, sans-serif;
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
--nav-bg: rgba(255, 255, 255, 0.85);
/* ... and more */
}
.dark {
--color-primary: #fb923c;
--color-bg: #0c0a09;
--color-surface: #1c1917;
--color-text: #fafaf9;
--color-text-secondary: #a8a29e;
--color-border: #292524;
/* Same property names, different values */
}
Every component now references these tokens instead of hardcoded colour classes. Dark mode works by simply overriding the same custom properties in the .dark class — no dark: variants needed in components. And high contrast mode? It just overrides the same properties too:
.high-contrast {
--color-bg: #000000;
--color-surface: #1a1a1a;
--color-text: #ffffff;
--color-primary: #ffff00;
--nav-bg: rgba(0, 0, 0, 0.95);
}
Clean, maintainable, and it actually covers every element on the page because everything uses the same tokens.
Self-Hosted Fonts
The update adds two self-hosted web fonts as .woff2 files:
- Inter for body text (400-700 weight)
- Space Grotesk for headings (500-700 weight)
Both use font-display: swap to prevent any flash of invisible text. Having the fonts locally means no external requests to Google Fonts — one less dependency, faster initial load, and no privacy concerns about third-party font services.
The heading font gives section titles and the site logo a distinct, slightly geometric character that separates them from body text. It's a subtle difference that makes the visual hierarchy much clearer.
New Utility Classes
The design system also introduces several reusable CSS classes that replace one-off Tailwind combinations:
.nav-link: A link style with an animated underline that expands from left to right on hover, using a::afterpseudo-element and the primary colour.tag-pill: Rounded pill badges for tags with consistent padding, font size, and a scale-up hover effect.card-hover: A subtle translateY lift with a box-shadow transition on hover — used on almost every card component.sticky-nav: Frosted glass effect withbackdrop-filter: blur(12px)and a semi-transparent background.social-icon: Muted colour that shifts to primary on hover with a 1.15x scale.featured-card: A left border accent in the primary colour for the featured post on the homepage
All animations are wrapped in @media (prefers-reduced-motion: no-preference), so users who've requested reduced motion get a completely static experience. This includes the fadeIn, slideIn, card hover lift, and theme toggle spin animations.
Better Code Blocks
Code rendering got a proper overhaul. Fenced code blocks now have rounded corners, a surface background with a border, and comfortable padding:
.prose pre code {
display: block;
padding: 1rem 1.25rem;
border-radius: 0.75rem;
font-size: 0.875rem;
line-height: 1.7;
background: var(--color-surface-alt);
border: 1px solid var(--color-border);
}
Inline code gets a distinct treatment — smaller text, a border, and the primary colour — making it visually different from fenced blocks and easy to spot in running text.
The Small Touches
A few finishing details round out the design system:
- Custom scrollbar: Themed to match the colour scheme on WebKit browsers
- Selection highlight: Selected text gets an orange background instead of the browser default
- Theme toggle animation: A 180-degree spin on click (respecting reduced motion preferences)
These are the kind of details that individually don't matter much, but together they make the site feel considered and cohesive.
Component Refactoring: Header, Navigation, Footer, and Home Page
With the design system in place, every component was updated to use the new tokens. But several components were also significantly restructured.
Header: From Gradient Banner to Sticky Nav
The old header was a blue gradient banner (bg-gradient-to-r from-blue-500 to-blue-400) with the profile picture, site name, and navigation all crammed together. It scrolled away with the page.
The new header is a sticky nav that stays fixed at the top with a frosted glass effect. The "Hardcopy" text uses the Space Grotesk heading font with the signature orange accent on "copy". The profile picture was moved out of the header entirely — it now lives in a new HeroSection component on the homepage.
This is a much more standard pattern for blog navigation. The sticky nav means you always have access to the links, and the frosted glass effect (backdrop-filter: blur(12px)) keeps it from feeling heavy over the content.
Navigation: Data-Driven and Animated
Both desktop and mobile navigation were refactored from hardcoded link lists to data-driven arrays:
const navItems = [
{ to: '/', label: 'Home' },
{ to: '/about', label: 'About' },
{ to: '/projects', label: 'Projects' },
]
The desktop nav uses the new .nav-link class with its animated underline effect. Controls (theme toggle and accessibility toggle) are visually separated from navigation links with a border divider.
The mobile nav went from a basic dropdown that pushed content down to a proper slide-in panel. It slides in from the right with a backdrop overlay, has a close button, and separates navigation links from settings. It feels more like an app drawer than an afterthought.
Footer: Clean and Minimal
The footer was a blue gradient block matching the old header. Now it's a clean, minimal section with a top border, the Hardcopy logo, attribution, social icons, and copyright. It uses flexbox to lay out horizontally on larger screens and stacks vertically on mobile. No gradients, no heavy styling — just information.
Home Page: Featured Post and Grid Layout
The homepage had the most structural change. Previously, it was a flat list of post summary cards stacked vertically. Now it has three distinct sections:
HeroSection: A new component with the profile picture (clickable to open a modal), the "Hey, I'm Chris" heading with orange accent, and a tagline. This gives the homepage a proper landing feel.
Featured post: The latest post is displayed full-width with a left border accent (
.featured-card), making it visually distinct as the most recent content.Post grid: Remaining posts are displayed in a responsive grid — 1 column on mobile, 2 on medium screens, 3 on large. This is much denser than the old stacked layout and lets visitors scan more posts at a glance.
Tags Page: From Cards to Pills
The tags page went from one card per tag (with a "View Posts" button) to a compact pill-based layout. Each tag is a coloured pill with the post count inline. It's more scannable, takes up less space, and looks better when you have a dozen or more tags.
Consolidating the About Page: Absorbing CV and Tech Stack
One of the bigger architectural decisions was removing the separate /cv and /tech-stack routes and folding all of that content into a single, comprehensive /about page.
Why Consolidate?
Having three separate pages — About, CV, Tech Stack — fragmented what is essentially personal content about the same person. Visitors had to navigate between pages to get the full picture, and the CV page in particular felt out of place on a blog. A single About page with anchor navigation is more discoverable, easier to maintain, and gives visitors everything in one scroll.
What Got Removed
The /cv route had skill bars (the kind with percentage fills), work history, and education. The /tech-stack route listed the technologies used to build the site. Both were deleted entirely — the routes removed from the router configuration, the sitemap generator, and the pre-render config.
The desktop and mobile navigation links were updated to remove the CV and Tech Stack entries.
What the About Page Became
The About page went from roughly 70 lines to over 1,000. It now has ten sections, each accessible via an anchor navigation bar at the top:
- About — the introduction and bio
- Fun Facts — personal tidbits (started with Neopets, favourite colour is orange)
- Hobbies — 3D printing, tool collecting, home server, smart home
- Desk Setup — every piece of hardware on the desk, with links and descriptions
- This Site — the full tech stack breakdown (React Router v7, Vite, TailwindCSS v4, zero JS, and more)
- Unraid Server — hardware specs and running services (Plex, the *arr stack, VMs, Cloudflare Tunnel)
- Home Assistant — devices, integrations, and automations
- Networking — UniFi gateway, OpenWrt access points, switches, tunnels
- Credentials & Education — certifications, degrees, and qualifications
- Software Tech — the full tech skill grid with dot ratings and years of experience
Reusable Sub-Components
To keep the 1,000+ line page manageable, several reusable sub-components were extracted:
TechCard— displays a technology with an emoji, name, dot rating (out of 5), and years of experience. Optionally links to an external URLInfoCard— a generic card for gear, services, and devices with emoji, title, and descriptionSectionHeading— consistent heading style with optional subtitle rendered in muted italicCategoryHeading— sub-section headings with the orange underline accentAnchorNav— a row of pill links for jumping to sections on the pageDotRating— five dots, filled to the rating level in the primary colour
Data-Driven Content
All content — desk setup items, tech skills, homelab services, Home Assistant devices, network gear — is defined as typed arrays at the top of the file. The components are generic and just render whatever data they're given. This means adding a new piece of desk gear or a new tech skill is a one-line addition to the data array, not a template change.
The tech skills section in particular is comprehensive. It covers Languages, Frontend Frameworks, Backend Frameworks, Cloud & DevOps, Databases, Tools & IDEs, and Testing — each as a separate grid of TechCard components with dot ratings.
The Projects Page
The projects page was previously a placeholder — literally a single Card component containing "Content coming soon...". It now showcases six personal projects with proper structure and metadata.
Project Data Structure
Each project is defined with a typed interface:
interface Project {
emoji: string
name: string
tagline: string
description: string
url: string
github?: string
tech: string[]
status: 'active' | 'development' | 'beta'
}
Status Badges
Projects are tagged with a status — Live, Beta, or In Development — displayed as a colour-coded badge. Live projects get the primary orange colour; In Development projects get a muted treatment. The page splits projects into two sections based on this status.
The Projects
Six projects are listed:
- Spanners — a collection of developer utilities (JSON formatter, SQL formatter, Base64 encoder, GUID generator, and more)
- StubKit — a lightweight API mocking service for testing error handling and timeouts
- Hardcopy — this blog, with its zero-JS approach called out explicitly
- WearAmp — an open-source Wear OS app for streaming music from a self-hosted Plex server
- VeryShort — a URL shortener currently being rewritten (for the third time)
- Routinee — a customisable routine and habit tracker with analytics
Each project card displays its tech stack as pills, making the patterns visible — React Router v7, TypeScript, and Cloudflare appear in almost everything.
The page also includes proper SEO meta tags with a title and description optimised for search and social sharing.
New Branding: Favicon and Assets
The final piece of the refresh is a new favicon and optimised assets.
The new favicon is an SVG — an orange rounded rectangle with white "HC" text in a bold sans-serif font. It matches the site's primary colour (#f97316) and the "Hardcopy" logo treatment. The SVG format means it scales perfectly at any size and is only 14 lines of markup.
All the PNG favicon variants (16x16, 32x32, 192x192, 512x512, Apple Touch Icon) were regenerated and significantly smaller. The favicon.ico went from 15KB to 1.8KB. The social preview image (preview.png) was optimised from 46KB to 24KB.
These are small file size wins individually, but they contribute to the overall performance story — especially for first-time visitors and social media link previews.
Under the Hood
A few supporting changes round out the update:
- Dependency updates: Package dependencies were updated across the board, and asset paths were standardised
- Three posts migrated: Older posts on Rails debugging gem conflicts, Entity Framework composite keys, and WSL 2 memory management were added to the posts directory
- Review fixes: The tags page loader was optimised, and a couple of typos in blog posts were fixed
- Post loading improvements: The
posts.tsutility was updated to handle edge cases more gracefully
Wrapping Up
This update touches nearly every file in the repository, but the core changes boil down to a few principles:
- Centralise design decisions — CSS custom properties mean colours, shadows, fonts, and transitions are defined once and used everywhere
- Consolidate related content — one About page with anchor navigation instead of three fragmented routes
- Ship real content — a projects page with actual projects, not a placeholder
- Respect user preferences — reduced motion, font sizes, high contrast, and dark mode all work through the same token system
The site is easier to read, easier to maintain, and better represents what it's supposed to be — a personal blog and portfolio.
If you want to explore the changes, check out the About page or the Projects page. The source code is available on GitHub.