Software Development

Mackinations

A marketing site for a solo technical consultancy. The constraints were specific: no CMS overhead, no unnecessary dependencies, SSR for SEO, smooth animations, and content that speaks differently to business and technical audiences.

Nuxt 4Vue 3TypeScriptTailwind v4GSAPPinia
Nuxt 4Vue 3TypeScriptTailwind v4GSAPPinia
2024 – present

Overview

Built to Do One Thing Well

Mackinations is the web presence for a solo cybersecurity and AI consultancy. The brief was simple in scope but demanding in execution: a fast, polished marketing site that converts — with no CMS, no heavy framework overhead, and content that adapts to who's reading it.

It's built on Nuxt 4 with Vue 3, styled with Tailwind v4 (no config file), animated with GSAP ScrollTrigger, and deployed to Cloudflare Pages with a FastAPI backend on Fly.io handling lead capture. The most interesting parts aren't the stack — they're the patterns.

Feature

Dual-Audience Content Rendering

You may have noticed the Business / Technical toggle in the top-right corner. This isn't cosmetic — it bifurcates nearly all content on the site. Service descriptions, skill displays, CTA copy, and even section headings have distinct variants per audience mode.

The preference persists to localStorage so returning visitors see their mode immediately. Every page that varies by mode reads from useAudienceStore() — a Pinia store that exposes isBusiness and isTechnical computed refs.

The Nudge Crossfade Pattern

Each page surfaces a prompt suggesting the visitor try the other mode. Two nudge components are always rendered stacked at the same grid position — one for each direction — and only one is visible at a time. They crossfade without any layout shift because they both occupy the same cell.

<div class="grid">
  <div class="[grid-area:1/1]"><AudienceNudge direction="up" /></div>
  <div class="[grid-area:1/1]"><AudienceNudge direction="down" /></div>
</div>

The SSR hydration problem — where the server renders "business" mode but the client immediately reads a different preference from localStorage — was solved by deferring the localStorage read to onMounted in the root layout. The store initializes with the default on both server and client, Vue reconciles the identical DOMs, then the saved preference is applied as a normal reactive update.

Feature

Availability as a State Machine

Whether I'm actively taking new clients, at capacity, on a break, or not accepting inquiries changes not just the contact form — it changes the entire site's tone. Every CTA, every button label, every header badge, every subheading is driven by a single field in a TypeScript config file.

config/availability.ts
// Change this one line — the whole site updates
export const config = {
  status: 'open' // 'open' | 'setup' | 'waitlist' | 'hiatus' | 'closed'
}

Each status maps to a complete set of copy: pill text, pill color, contact heading, CTA heading, button label, and waitlist form text. The contact page conditionally renders the full form, a waitlist sign-up, or a locked state based on the computed showContactForm and showNotifyForm flags — no logic scattered across components.

Design System

Tokens Over Config

Tailwind v4 eliminates the config file — all theme customization lives in CSS. The entire color palette is defined as CSS custom properties inside a single @theme {} block, which Tailwind exposes as utility classes automatically.

base

#08080f

surface

#0e0e1a

surface-2

#14142a

border

#1e1e3a

blue

#4f8ef7

purple

#9b5de5

text

#e8e8f0

muted

#6b6b8a

Global utilities

.gradient-text

Blue → purple gradient via background-clip: text

.gradient-border

1px gradient border via ::before + mask

.glow-blue

Subtle box-shadow in brand blue

.glow-purple

Subtle box-shadow in brand purple

Animation

GSAP ScrollTrigger Abstraction

GSAP and ScrollTrigger are registered client-side in a Nuxt plugin, which provides $gsap and $ScrollTrigger via useNuxtApp(). A shared composable wraps the three patterns used across the site.

fadeUp(target, options)

Scroll-triggered fade + Y-slide from below. Used for hero text, section headers, and card grids. Accepts stagger for lists.

fadeIn(target, options)

Opacity fade only — for elements that shouldn't move, like background layers or overlays.

staggerChildren(parent, selector, options)

Queries a parent for matching children and applies fadeUp with a configurable stagger delay. Used for skill chips, service cards, and project grids.

Each page-level animation composable (for portfolio deep-dives) follows the same pattern: setup() called from onMounted, cleanup() from onUnmounted, with useNuxtApp() called inside setup() (not at the composable root) to avoid SSR errors.

Detail

The Portrait Scene System

The about page portrait isn't a single image — it's a layered composition. A background-removed PNG of the portrait sits above three absolutely-positioned media layers (an office photo, a Dalhousie campus video, and a Toronto skyline video), all cross-fading via opacity transitions over 2 seconds.

The clever part: instead of swapping the portrait image, the portrait's CSS filter property changes to match the color temperature of each background scene. Work mode cools and desaturates slightly; education mode warms and lifts brightness; Toronto mode adds a golden sepia cast. The portrait always reads as if it was photographed in that environment.

Scene filters

workbrightness(0.95) saturate(0.85) hue-rotate(8deg)
educationbrightness(1.08) saturate(1.15) hue-rotate(-8deg)
locationbrightness(1.05) saturate(1.2) sepia(0.25) hue-rotate(-12deg)

Architecture

Four Decisions Worth Explaining

Manual Pinia, no @pinia/nuxt

Why: The official module has a timing issue in Nuxt 4 where the store state diverges between SSR and client hydration. Bootstrapping it manually in a plugin named 01.pinia.ts — so it runs first — gives explicit control over initialization order.

Trade-off: All stores must explicitly import defineStore, ref, computed from their packages. No magic.

Deferred localStorage hydration

Why: Reading localStorage during store instantiation causes a hydration mismatch: the server renders "business" mode, but the client immediately overwrites with the saved preference before Vue reconciles the DOM.

Trade-off: hydrateFromStorage() is called from app.vue's onMounted — after hydration is complete. Brief flash of default mode is theoretically possible but imperceptible in practice.

Tailwind v4 via Vite plugin, no config file

Why: Tailwind v4 moves theme configuration into CSS @theme {} blocks, eliminating tailwind.config.js entirely. The Vite plugin handles scanning and purging at build time.

Trade-off: Dynamic color values in :class ternaries can be purged. Workaround: use :style bindings for toggled colors, or stick to literal Tailwind class names.

Config-driven availability state machine

Why: A single status field in availability.ts cascades into contact form behaviour, CTA copy, header badges, and button text across every page. Changing availability is a one-line config change.

Trade-off: Requires a redeploy to change status. Acceptable trade-off for a solo practitioner site — no CMS overhead needed.

Interactive

Live Audience Toggle Demo

An interactive diagram showing how the audience store bifurcates content — coming soon.

Try the real thing — it's in the navbar right now.

Interested in working together?

Get in touch