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.
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.
// 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
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