Now

What I'm building, day by day.

Wired up JulianLM to the live site

Got the AI chat panel (JulianLM) working end-to-end on the live version. The local version was already functional but the production deployment was silently broken — the OpenAI API key had been added to Vercel with a typo (`OPEN_API_KEY` instead of `OPENAI_API_KEY`), so every request to `/api/chat` returned a 500 and the panel showed a fallback error message. Fixed by removing the misnamed variable and adding the correctly named `OPENAI_API_KEY`, then redeploying. Small thing, but it means anyone visiting macjulian.com can now actually talk to Julian.

Added Vercel Analytics

Wired up `@vercel/analytics` to the root layout. It auto-detects the Vercel project — no config, no API key, just drop in the component and it works. Useful for seeing which pages people actually land on vs. where they drop off.

Connected JulianLM conversation logging

The logging code was already in place from a previous session — every question asked to JulianLM gets saved to Redis — but the database was never actually connected. Went through creating an Upstash Redis instance, adding the credentials locally and to Vercel's environment variables, then redeploying. Learned that Upstash is serverless-safe Redis. Vercel functions are stateless — they spin up, handle a request, and die — so you can't persist anything in memory between calls. Upstash sits outside that lifecycle as a REST-accessible store. Every conversation is pushed onto a list and readable at `/api/conversations?secret=...`.

Rethought the Now page

The Now page was pulling from git commit messages which are too terse to be meaningful. Switched it to read from markdown files in `content/now/` — one file per day, written at the end of each session. Each entry captures what was built, what was learned, what was tried, and why it mattered. Git log is for diffs; this is for reflection.

Hangar: batch airplane image generation tool

Launched Hangar — a React + Express tool for batch-generating photorealistic miniature airplane scale model images using `gpt-image-2`. You fill in a prompt template, set aspect ratio and quality, select images for approval, and export a batch. Images persist in a local history panel. The tool started as an internal Zomunk image generation experiment called Zomunk-image-gen. Renamed to Hangar because the name is cleaner and doesn't tie it to a single use case — the batch approval flow and persistent history make it useful for any image generation workflow that needs human review before saving. `gpt-image-2` is meaningfully better than DALL-E 3 for photorealism and prompt adherence. The editable prompt template in the UI lets you adjust the style baseline without touching code — useful when you're iterating on what "photorealistic scale model" should look like across different lighting conditions.

Work page grid fixes

Switched from CSS columns to a proper grid for left-to-right date ordering — columns flow top-to-bottom which breaks chronology. Cards now fill row height via flex with no empty gaps. Fixed a layout bug where cards were bleeding to the viewport edge on some screen sizes. Added horizontal padding to the grid container. Corrected card labels: Tria → Ithaca, Claystack year 2025 → 2023. Small things but they matter when the page is supposed to represent real work accurately.

Typography cleanup

Removed Playwrite from Journal, Built, and case study page headings — Inter 500 instead. Playwrite is a cursive display font; it worked for the name heading on the home page but felt wrong everywhere else. Updated back links on work detail pages to use the Phosphor `ph-arrow-left` icon instead of a `←` HTML entity.

Modal and Built strip polish

Added video field support to ProjectModal — Page Editor now plays an inline video, Pollution Tracker shows its thumbnail. Marked Zo Video Generator as "upcoming" in the BuiltStrip (dimmed, badge, non-clickable). Added "Why I built it" section and a stat block (hours/cost saved) to indie app modals. That context makes the projects more interesting — the *why* is usually more telling than the *what*. Reordered the indie apps strip: Hangar first.

Work page masonry redesign

Replaced the constrained 680px layout with a full-viewport 2-column CSS masonry grid (max-width 1800px). Cards cycle through aspect ratios (16:9, 3:4, 4:3, 1:1) for editorial stagger — the variation makes it feel curated rather than generated.

Cursor-tracking hover pill

Added a terracotta pill that tracks the exact cursor position over work card images — no spring lag, instant follow, system cursor hidden on hover. The label changes based on link type: "View Case Study", "View Prototype", or "Visit Website". Applied to both `/work` and the home page WorkGrid. Learned that `useMotionValue` from Framer Motion is the right tool here — spring physics would add perceptible lag which defeats the point of a cursor tracker.

Tag filters and card metadata

Added tags to all work items and built a filter system: clicking a tag filters the grid. Extended filter taxonomy to include Fintech and Indie App categories. Standardised caption format: short title left, `company · year` right. Smaller detail but it makes the grid scannable.

Sitewide metadata

Updated root metadata to a proper template — "Julian | Product Designer" as default, "%s | Julian" per page. Cleaned up all page titles to short form (Work, About, Journal, etc.).

Chrome extension: icons and scrollbar

Added extension icons at all required sizes for the Chrome Screen Time extension — 16px, 32px, 48px, 128px — so the extension shows correctly in the Chrome toolbar, the extensions management page, and the Chrome Web Store listing. Chrome is specific about which sizes it uses in each context and silently falls back to a blurry scaled version if the right size is missing. Also hid the scrollbar across all popup elements — the popup has a fixed height and the scrollbar was appearing on hover in some Chrome builds, adding an ugly layout jump.

Photos page moved to fullscreen layout

Created an `app/(fullscreen)/` route group with no sidebar or nav wrapper, so `/photos` gets the full viewport — dark Mapbox style, floating back button overlay. The map always felt cramped inside the portfolio shell. This fixed it. Removed the AboutMap section from the about page as a result — it was a preview of the map that was now redundant.

Writing page

Moved three articles from `content/work` to `content/writing`, created `lib/writing.ts` and a `/writing` page with a clean editorial list layout. Writing and case studies are different things — case studies show process, writing shows how you think. Added a scrollbar restyle: 6px, pill thumb, transparent track, with a reserved gutter to prevent layout shift when switching between short and long pages.

Work page and Built section overhaul

Renamed "Built" to "Indie Apps" and added subtitle copy. Refactored the Work page to use dynamic content with tag filters — work items now live as markdown files with frontmatter, rendered through a `WorkClient` component that handles filtering client-side. Discovered that `AnimateIn` was adding unnecessary complexity without meaningful visual benefit — removed it.

Mobile responsiveness

The sidebar turns into a fixed bottom tab bar on screens ≤640px — icon + label per item, bottom group (theme toggle, AI) hidden on mobile. Main content gets bottom padding to clear the nav. This was overdue; the icon sidebar made no sense on small screens. Found a subtle bug along the way: inline `display: flex` on the sidebar bottom group was overriding the CSS media query `display: none`, so the theme toggle and ask button were appearing misaligned in the mobile nav row. Moving the style to the stylesheet fixed it.

Added Google Analytics

Wired up GA4 (G-5XHD652ETP) via `next/script` with `afterInteractive` strategy. First step toward understanding how people actually use the site.

BuiltStrip and indie app modals

Added a BuiltStrip section to the home page — a horizontal strip of side projects with modal popups. Having the projects visible on the home page meant they were no longer hidden behind a nav click. Built modal components from scratch: open/close state, backdrop, content slots for description, stats, and links.

Symmetry app prototype page

Added `/symmetry-app` as a standalone page outside the portfolio chrome — no sidebar, no nav. Pixel-accurate Figma prototype recreation: three-column card layout (264px sidebar, flex center panel, 321px timeline), IBM Plex Sans + Noto Sans typography, solid `#6f91f7` background. This required restructuring the routing — moved all portfolio pages into `app/(site)/` with its own layout (sidebar, AskPanel, providers), so `/symmetry-app` could exist independently at the root level with no chrome.

Built JulianLM — an AI chatbot for the portfolio

Added an AI chat panel (JulianLM) that slides in from the right sidebar. Keyword-matched responses at first, giving way to a proper LLM backend later. The name felt right — a personal model that speaks as me, about my work. Also added the Zomunk case study page with persona cards and stats. Two case studies now live.

Light/dark theme toggle

Added a theme toggle to the sidebar with a circular view transition animation — the page inverts from the click point outward. Subtle but satisfying. Dark is default; the preference persists in localStorage so it survives page refreshes.

Design system cleanup

Replaced hardcoded hex colors throughout with `--text-muted` and `--surface` CSS variables. Should have done this earlier — the codebase was accumulating magic values that were impossible to update consistently. Added lightbox components (LightboxImage, LightboxVideo) to the Symmetry case study, replacing bare `img`/`video` tags. Makes the media feel more intentional and gives the reader a way to inspect details.

Symmetry case study

Built out the full Symmetry case study page. Full content restructure, sticky section nav, onboarding flow lightbox, process diagram, and inline media (images and videos throughout). The sticky nav was a useful pattern — case studies are long and people skim. Giving them a way to jump to the section they care about respects their time. Set the lead paragraph to 24px; the size alone signals "this is the thing to read first."

Garage page

Added the Garage/Built page with 6 project cards plus a Photos card linking to `/photos`. Having a dedicated place for side projects separate from client work felt important — they show different things about how you think.

Zomunk video generator: country selector with slot machine animation

Added a country selector to the Zomunk video generator — a slot machine animation on the splash screen that cycles through country flags before landing on the selected country. Added `userCountry` and `userCountryCode` fields to the tool form; the splash screen reel cycles through 8 country flag assets (AE, AU, CN, IN, JP, PH, SG, US) for 3 seconds before locking in. The slot machine feel was the right choice over a simple dropdown animation. The video is meant for social media — it needs to feel like content, not software. A slot machine is more engaging than a fade. Organised the `public/` folder into `icons/`, `images/`, and `flags/` subfolders at the same time — the flat directory had become hard to navigate.

Full v2 redesign

Rebuilt the portfolio from scratch with a dark theme. The v1 static site had served its purpose but wasn't something I wanted to share anymore. New design tokens: near-black background (`#0a0a0a`), white text, a single blue accent. Inter for body, Playwrite GB S for the cursive name heading. Sidebar icon navigation — compact, stays out of the way. New pages: About, Work, Built, Motion Lab, and a full Symmetry case study. Added Phosphor Icons throughout, GitHub activity terminal on the home page, and new case study assets for Symmetry, Ithaca, and Claystack. The biggest decision was the sidebar nav — icon-only with tooltips on hover. It keeps the layout clean and forces good labelling. Migrated fully to Vercel at this point too, moving away from GitHub Pages.

Zomunk video generator: airline name, layout fixes, and documentation

Added an airline name field to the video generator tool — it feeds into the ListScreen card and the DetailsScreen header. Before this, the airline was hardcoded in the composition which meant every render looked like the same carrier. Fixed the verified pill layout — it was overflowing its container on long airline names, and the route alignment was off-centre. Small layout bugs that were obvious once you rendered a real deal rather than placeholder text. Also removed the bundle cache so each render always picks up the latest composition code rather than a stale cached version. During active development, cached bundles meant you'd render, see the old output, wonder why changes didn't show, rebuild manually. Disabling the cache trades a few seconds of rebuild time for reliable output. Wrote a full `README.md` and added a `CLAUDE.md` with instructions on how to pull the latest changes and run the project. The CLAUDE.md habit started here — having a machine-readable project context file means I can hand off context to Claude Code instantly without explaining the project from scratch each session.

Chrome Screen Time extension: initial build

Built a Chrome extension that tracks time spent on each website — macOS-style UI, dark theme, native feel. MV3 (Manifest v3) service worker architecture: a background worker tracks tab focus events and writes to `chrome.storage.local`, the popup reads and renders the data. The UI has three views (Today/Week/Month), per-site favicons fetched via the Chrome favicon API, an hourly activity bar chart showing peaks across the day, and a live "currently tracking" indicator. The design deliberately copies macOS Screen Time's aesthetic — users already understand that UI pattern. The service worker for time tracking is event-driven: `chrome.tabs.onActivated`, `chrome.tabs.onUpdated`, `chrome.windows.onFocusChanged`. Each event updates a running `activeTime` accumulator keyed by hostname. The challenge is that tab focus events don't fire when you switch apps — `chrome.windows.onFocusChanged` fills that gap by treating focus loss as a pause event. Pushed to GitHub as `chrome-activity-monitor`. First Chrome extension since the Composer experiment.

Zomunk video generator: phone frame, animations, and render server

Major polish pass on the video generator. Added a `PhoneFrame` SVG overlay with Dynamic Island — the video now renders inside a phone silhouette, which immediately reads as "mobile social content" to anyone watching. ListScreen: airport header, filter chips, and a bottom nav bar matching the Zomunk app design. Added a scroll simulation — the list appears to animate downward as if being browsed, with cards revealing as they enter the viewport. DetailsScreen: airport code lookup from a preloaded table, a price count-up animation (`from 0 to £X` over ~60 frames), and a verified pill. ClosingScreen with the Zomunk logo and call to action. Replaced the flip transition between screens with a slide — flip looked like a slideshow presentation, slide looks like navigation between app screens. Wired up the Express render server: the Vite tool UI POSTs deal details to the server, which calls Remotion's `renderMedia()` and streams back an MP4. The tool UI shows a download link when render completes. Total render time for a 13-second video is around 8–12 seconds.

Zomunk video generator: first Remotion composition

Started the Zomunk video generator — a tool for creating short-form flight deal videos for social media. The output is a 13-second vertical video (1080×1920) ready to post: animated screens, deal details, branding. First version was a basic Remotion composition: a sequence of screens (ListScreen, DetailsScreen, ClosingScreen) with simple cuts between them. Remotion's model is React components over time — `useCurrentFrame()` gives you the current frame number and you compute everything from that. It's a different mental model from CSS animation but it clicks quickly once you stop thinking in keyframes and start thinking in pure functions. Set up Remotion Studio on port 3000 for live preview during development. The full project ended up with three processes: the Remotion Studio (preview), a Vite tool UI (form to fill in deal details), and an Express render server. Splitting them out was the right call — Remotion's render process is CPU-heavy and you don't want it blocking the UI.

Tria card prototype: iPhone 15 frame

Wrapped the Tria card selection screen in an iPhone 15-style frame — dark titanium chassis, Dynamic Island, side buttons, all rendered as a React component. Prototypes presented in a device frame read better in stakeholder contexts: you immediately understand it as a mobile experience rather than an abstract UI. The `PhoneFrame` component is SVG-based with CSS for the chassis colours and button highlights. Dynamic Island is a rounded rectangle that sits in the notch area — no image dependency, just shapes. The frame is purely presentational and wraps whatever content is passed as children. The same PhoneFrame approach had already been used in the Zomunk video generator (for the Remotion compositions). Good to have a reusable component at this point.

PollutionTracker v1.0.1: location granularity and UI refresh

Released v1.0.1 of PollutionTracker. The main changes: sub-locality geocoding (now shows "Avadi, Chennai" instead of just "Chennai"), a refresh button in the footer of the popover, and the quit menu item lost its icon (looked inconsistent with the system menu items around it). Moved the refresh button from wherever it was — context menu probably — into the popover footer. It belongs in the popover, next to the data it refreshes. Context menus are for destructive or rare actions; refresh is a common interaction. Fixed location reliability using a last-known-location fallback. The original implementation would show no city name if CoreLocation hadn't finished a geocoding lookup by the time the popover opened. Now it caches the most recent successful result and shows that until a fresher one arrives. Updated the Homebrew Cask to v1.0.1.

Tria card prototype: autoplay and touch indicator

Added an autoplay loop to the Tria card carousel — cycles Virtual → Signature → Platinum every 1.8 seconds, scrolls down to benefits, then loops every ~9 seconds. Added a touch indicator dot that syncs with the autoplay state to signal that the carousel is interactive. Later simplified the dot to a static fade indicator (the sync animation was overcomplicating a subtle cue).

Micro-interactions: fixed expired Figma asset URLs

Figma's asset URLs have a TTL — links to CDN-hosted assets from the MCP server expire after some time and return 403s. Several components were using these links for SVG icons (USDC, close, success, blockchain explorer) and the PackageCard background. Fixed by replacing every expired URL with inline SVGs and a CSS gradient background. The SVGs are short enough to inline without meaningfully bloating the component files, and CSS handles the background gradient without any image loading. Worth noting for future Figma MCP work: never commit the raw CDN asset URLs into components. They'll expire. Convert them to inline SVGs or local files at build time.

Micro-interactions: restructure and cleanup

Reorganised the micro-interactions grid — moved Balance Reveal and Savings Goal to Experiments, collapsed the Neo Bank group by default. The library had grown to a point where the initial scroll was overwhelming; giving each section a collapsed default state lets someone orient themselves before diving in. The ClaimButton now has a shimmer effect on hover — the kind of polish that makes a button feel like it has value behind it, not just a label. Small but changes how the button reads in context.

Micro-interactions: Neo Bank components

Added a Neo Bank section to the micro-interactions library: PinPad, CardFlip, and BiometricPrompt components. Also added sound utilities — `playPin` and `playSwipe` — using the Web Audio API to generate short synthetic tones without any audio file dependencies. PinPad has haptic-style feedback (CSS scale on key press), CardFlip is a CSS 3D perspective transform with a smooth ease-in-out, BiometricPrompt shows a fingerprint scanner animation with an approval pulse. The sound design was unexpectedly important. Pin-entry feels more satisfying with a subtle tick per key. The Web Audio API `OscillatorNode` approach generates the tone in-memory — no HTTP requests, no user gesture required after the first audio context creation, no audio files to manage. The sounds are short enough (50–80ms) that they don't feel like sound effects, just tactile feedback. Moved Balance Reveal and Savings Goal components to the Experiments section and collapsed the Neo Bank section — the grid was getting long and reorganising by function made it easier to navigate.

Tria card prototype: initial build

Built the Tria card selection prototype — an interactive card carousel showing three card tiers (Virtual, Signature, Platinum) with a benefits reveal section. Built with React + Vite. The UI went through several layers: card images as local PNGs (pulled from the correct PNG assets rather than broken remote URLs), a RECOMMENDED badge with shimmer animation, gradient card names, and type chip pills (Virtual/Plastic/Metal). The benefits section scrolls into view on card select. Also set up Remotion compositions alongside the interactive prototype: `TriaWalkthrough`, `CardsLanding`, and `TriaFullJourney` — video compositions of the same flow for use in presentations. One codebase, two output formats. Added the Agentation toolbar for the live prototype — a floating debug toolbar for testing states without manually triggering them. Useful for demos where you need to jump between states quickly.

Micro-interactions: experiments section and interactive badges

Added an Experiments section to the top of the grid — components that are less client-specific and more exploratory. New additions: LeaderboardCard, LiveChart, StakeButton, ClaimButton (with shimmer), AmountInput, CopyCard, ToastTrigger, and DrawerTrigger. Updated the Ithaca NAV chart with proper distinct data for each range (7D/14D/30D/90D) — they were previously sharing the same dataset which made the range switch feel meaningless. The `IthacaPersonality` animation now loops properly. Fixed a drawer sheet centering issue — the sheet was offset from centre because the fixed positioning was relative to the sidebar width rather than the content area. A CSS containment issue; solved by moving the portal target. Interactive badges on work cards — hover state shows a count that animates up. Small detail but it makes cards feel alive when you hover over them before clicking.

Micro-interactions: full component library

Built a comprehensive micro-interactions playground — a Next.js app with a 4-column dark grid showcasing interactive UI components. The goal was to have a single place to develop, test, and show motion design work. First batch of components: magnetic button (cursor attraction field), ripple effect, like animation, text scramble, tilt card (perspective transform on hover), toggle, morphing submit button, and float label input. Each one is isolated and interactive. Also built Ithaca Finance components — a modal with a NAV chart (7D/14D/30D/90D distinct data sets), an IthacaPersonality loop animation, and an animated agent journal. And Claystack's PackageCard with video cards, and Neemo's staking UI. The discipline here was keeping each component self-contained with no shared state — they need to be portable. A micro-interaction that only works inside a specific context isn't a reusable component, it's a piece of a page.

Page Editor bookmarklet

Built a bookmarklet that makes any webpage's text directly editable in-browser and then copies a structured diff back to Claude. The use case: you're iterating on copy with Claude and want to make tweaks directly on the rendered page rather than in a text box, then hand the changes back without manually noting what changed. The bookmarklet sets `contentEditable` on `<p>` and heading elements, captures the before-state, and on save computes a line-by-line diff that it copies to the clipboard in a format Claude can parse. No server, no extension install — just a bookmark. Bookmarklets are an underrated tool. They run in the page's own context which means you can access the live DOM, computed styles, and dynamic state that an extension's content script would need special permissions to reach. The distribution story is also simpler than an extension: drag to bookmarks bar, done.

CarbonFolio: AI crypto portfolio manager

Built CarbonFolio — a crypto portfolio manager with an AI advisor panel. The design system is IBM Carbon G100 (dark theme): a three-panel layout with a portfolio sidebar on the left, a holdings DataTable with expandable rows in the centre, and a persistent AI Advisor chat panel on the right. The AI panel was the interesting part to design: research mode shows a thinking state before streaming the response, with confidence scores and "trust signal" tags (sources, time-sensitivity, risk level) attached to each recommendation. The goal was making AI advice feel legible — you can see why the model is saying what it's saying, not just what it's saying. Carbon's DataTable is opinionated about row interaction. Getting expandable rows to coexist with custom cell renderers (sparkline charts, coloured percentage changes) required reading the source rather than the docs. Worth it — Carbon's accessibility defaults saved a lot of work.

DayTasks: inline editing and final polish

Finished the DayTasks UI pass. Double-click on a task title to edit it inline — the label becomes an editable text field, blur or Return commits. Felt important: forcing users to open a settings modal to rename a task is unnecessary friction for something you do daily. Polished the popover to 260px, replaced the default checkbox with a checklist symbol that matches the macOS aesthetic better. The streak badge is now an orange pill — visible at a glance, not buried in the heatmap. Fixed the heatmap month labels — they were off by one week on the left edge due to how weeks were being calculated from the start of the year. Small off-by-one errors in calendar logic are reliably annoying. The fix was simple once I stopped trying to be clever about it. Collapsed the Missed section by default — it was the right call in the end. The opened state when there were many missed tasks made the popover feel accusatory. Collapsed with a badge count is honest without being overwhelming.

DayTasks: missed tasks, streak heatmap, and history panel

Added three features that change DayTasks from a simple checklist into something with memory. **Missed tasks:** When the day rolls over, any incomplete tasks move to a "Missed" section rather than disappearing silently. This was the right default — tasks shouldn't vanish just because midnight passed. You missed them; the app acknowledges that. **Streak heatmap:** A 16-week GitHub-style contribution grid shows daily completion history — green for full completion, grey for missed, empty for days before the app was installed. The heatmap is purely local, built from `DailyLog` snapshots that the app writes at rollover. Seeing your streak visualised changes the way you interact with the checklist. **Weekly history panel:** A floating `NSPanel` (620×440) with a 7-column calendar layout — click any day to see what tasks were set and whether they were completed. Added week navigation (prev/next). `DailyLog` snapshots capture task titles at rollover so the history is accurate even if you rename or delete tasks later. Added drag-to-reorder for tasks the same session — custom `GripDots` drag handle, drag gesture with haptic feedback on drop.

Started DayTasks: a menu bar app for daily focus

Built the first version of DayTasks — a macOS menu bar app for tracking up to three tasks per day. Left-click opens a popover, right-click quits. Tasks persist via `UserDefaults`. The three-task limit is a design constraint: if you can have an unlimited list, it stops being a *focus* tool and starts being another todo app. SwiftUI popover from an `NSStatusBarButton` is the standard pattern, but the default sizing is unpredictable — the popover wants to size itself to content and SwiftUI's frame modifiers fight the AppKit wrapper. Settled on a fixed-width popover (`320px`) with a dynamic height that grows with the task count. This sits alongside PollutionTracker as the second macOS menu bar app. The constraint of the menu bar is interesting as a design space — you have one icon, one popover, and no persistent window. Every interaction has to be immediate and obvious.

Lightbox and cluster polish

Fixed a subtle but annoying bug: photos taken in portrait on an iPhone were showing rotated on the map because EXIF orientation wasn't being applied. Sharp's `.rotate()` with no argument reads the EXIF and auto-corrects — one-line fix once you know what to look for. Grouped nearby photos using haversine distance + union-find, so clusters within 100km share prev/next navigation and an "X of Y" counter in the lightbox. Made the photo experience feel much more like browsing an album than isolated images. Clicking a cluster now opens the lightbox instead of zooming in. That was the more useful interaction — you want to see the photos, not pan around a map. Replaced the floating circular nav buttons with clean SVG chevrons overlaid on image edges and removed the redundant close button. ESC and clicking the backdrop already handled closing.

Photo map: thumbnail markers

Replaced generic circle markers with actual photo thumbnails on the map. Clusters now show a representative photo with a count overlay instead of a plain number badge. The map became a lot more interesting to scroll around once you could see the photos directly on it. Added 14 new geotagged photos — India, Singapore, Thailand, UAE, Philippines. Two HEIC files didn't yield GPS data which was a useful reminder that not all phone photos embed location even when location services are on.

Migrated from static HTML to Next.js

Replaced the static site with Next.js 15 App Router. Big jump — static HTML is simple but you hit walls fast once you want dynamic routing, markdown rendering, or build-time data processing. Added a `/journal` page that reads markdown files, renders them with gray-matter and marked, and syntax-highlights code blocks. Writing directly in markdown files felt more honest than a CMS for a personal site.

Built an interactive photo map

Added a `/photos` page — geotagged photos placed on a Mapbox GL map with clustered pins. The build process extracts GPS EXIF data from images and generates thumbnails via Sharp, outputting a `photo-data.json` at build time. Discovered that building the data pipeline (EXIF → JSON → map) was more interesting than the map itself. Clicking a pin shows a popup; clicking the card opens a full-screen lightbox. First time wiring up Mapbox in a Next.js project. Set up GitHub Actions to handle the build-and-deploy step since the site now needs `next build` before it can go live.

Composer v0.2.0: background service worker and side panel

Updated Composer to v0.2.0. The main change was moving from a content-script-only architecture to a background service worker — this unlocks side panel support, which is a different UX from a popup or injected element. Side panels feel more like a permanent workspace than an overlay. Also streamlined the Gmail and Docs content scripts: cleaner message passing between content and background layers, new permissions for `sidePanel` and `activeTab`. The extension went from a set of independent injections to a coordinated system with a central coordinator. MV3 (Manifest v3) service workers are the right architecture but they require thinking differently: the background context is ephemeral, sleeping between events. Anything that needs to persist across requests goes through `chrome.storage`, not module-level variables. The mental model shift from persistent background pages took some adjustment.

Composer: AI-assisted Gmail and Docs writing

Built Composer, a Chrome extension that adds AI writing assistance directly into Gmail and Google Docs. In Gmail, it injects a shimmer loading state while the AI drafts copy, then uses a typewriter animation to write the response directly into the compose body — Accept, Reject, or Edit. In Docs, it adds an icon to the annotation toolbar, injects content via `ClipboardEvent` dispatch, and shows the same review controls. The technical challenge was making injection feel native. Gmail's DOM is notoriously hostile to extensions — elements are re-rendered constantly and class names are obfuscated. The trick was using a mutation observer to detect when the compose window opened and inject immediately, rather than on a timer. The interface was designed around the review moment: you see what the AI wrote before it lands in the document. Accept/Reject/Edit means you stay in control. The shimmer loading state (not a spinner) was a deliberate choice — it feels more like content arriving than a machine working.

PollutionTracker: Homebrew tap and public release

Shipped PollutionTracker v1.0.0 via a Homebrew Cask tap — `brew install --cask pollution-tracker`. Getting it distributable without Apple notarisation was the main challenge: macOS Gatekeeper quarantines unsigned binaries from the internet, and Homebrew's `postflight` hook is the right place to strip that attribute automatically after install. Creating a Homebrew tap is simpler than it looks: a public GitHub repo (`homebrew-pollutiontracker`) with a single `.rb` cask file that points to a versioned download URL. The formula is minimal — name, version, SHA256, `app` stanza, `postflight` for the quarantine strip. `brew audit --cask` catches the formatting issues before you push. The decision to use Homebrew over a direct download link was about trust. Developers recognize `brew install` as a safe, auditable path. A random `.dmg` link is something people are (rightly) suspicious of.

Started the portfolio

Put down the first commit. Static HTML site, custom domain via CNAME, basic structure in place. Nothing fancy — just something live to iterate on. Swapped IBM Plex Serif out for Zalando Sans. Typography is usually the first thing that makes a personal site feel like *you* vs. a template.

Small copy and links pass

Updated social links to actual profile URLs and fixed a font weight issue on definition text. The kind of cleanup that's easy to put off but necessary before sharing with anyone.

PollutionTracker: polish and release prep

Fixed several code quality issues across the app before cutting a release: corrected the bundle identifier, wrapped view model methods in `@MainActor`, replaced inline `DateFormatter` instantiation with a static shared formatter (constructing one per call is an easy performance mistake in Swift), and added a `UserDefaults` flag for location sharing consent. Added a right-click context menu via a global `NSEvent` monitor — this was the cleanest way to expose Quit without cluttering the popover. Also added a "move to Applications" prompt on first launch and a bundle monitor. The app needed to feel finished, not just functional. A move-to-Applications prompt is a small touch, but it matters. Users who run apps directly from Downloads get confusing behaviour when the OS quarantines updates. One prompt on first launch eliminates that whole class of issues.

PollutionTracker: modular refactor and first release build

Restructured PollutionTracker from a single-file proof of concept into a proper module layout — separate files for the API layer, location handling, menu bar controller, and data models. Small app, but spaghetti even at this scale is worth avoiding. Switched from `MenuBarExtra` to `NSStatusItem` + `NSPopover`. The SwiftUI-native approach kept fighting me on popover dismissal and transparency. The AppKit route is more verbose but behaves exactly as expected — it's what every other menu bar app is using under the hood anyway. Added a gradient card popover: AQI reading with colour-coded background (green → yellow → red), city name via CoreLocation geocoding, and a last-updated timestamp. Preparing to release it as a proper `.app`.

Started PollutionTracker

Built the first version of PollutionTracker — a macOS menu bar app that shows real-time air quality at a glance. The idea came from wanting to know whether it was worth going outside for a run without pulling up a weather app. The initial version was a single SwiftUI file: reads AQI from an open API, formats it as a coloured indicator, sits in the menu bar. No popover, no settings — just a number and a colour. Rough but working. SwiftUI for menu bar apps is more friction than it looks. `MenuBarExtra` is the modern API but it has quirks around popover presentation that AppKit-native NSStatusItem doesn't. Filed it away to deal with later.