The Physical World Is Part of the Attack Surface

April 2, 2026

The thing that set me off on this line of thought was Safari refusing to support the web Vibration API. At first glance that looks petty. Let the page buzz the phone. Who cares. Then you read the actual privacy discussion and realise the browser vendors are not being paranoid enough, if anything. The problem is not that vibration itself reveals some secret. The problem is that once the web can make a device do something physical, and once some other observable system can sense the result, you have created a new channel.

That is the pattern underneath a huge amount of security research: take a boring capability, combine it with a second boring capability, and suddenly you have something that feels like spycraft.

The web standards discussion around vibration is unusually candid about this. The spec's security and privacy considerations explicitly call out fingerprinting risk when vibration is combined with motion sensors, and also the simpler fact that a physically vibrating device can be identified by nearby observers. The working group's 2024 self-review goes even further: it effectively says "yes, this can be risky, but maybe we can constrain it enough with user activation, silent no-op behaviour, and rate-limiting." WebKit looked at the same class of risk years earlier and chose not to play. I get it.

What fascinates me here is not just the API. It is the deeper lesson. The physical world is part of the attack surface whether we acknowledge it or not.

Composition is where things get weird

Most systems are designed as though capabilities are isolated. Camera access is one thing. Motion sensors are another. Audio output is another. CSS is styling. The cache is performance infrastructure. DRAM is just memory. None of these sound sinister on their own.

The trouble starts when two individually boring systems compose.

  • An actuator plus a sensor becomes a covert channel.
  • A user-facing output plus precise timing becomes a fingerprint.
  • A performance optimisation plus speculative execution becomes a data leak.
  • A browser feature plus CSS state becomes history sniffing.

That is why so many attacks feel magical. They are not usually exploiting one obviously dangerous primitive. They are exploiting the gap between how we categorise primitives and how reality lets them interact.

Once you start looking for this pattern, you see it everywhere.

Vibration is a perfect example

The Vibration API is almost comically simple: a page asks the device to buzz for some duration or pattern. That feels nowhere near the sensitivity of geolocation, the camera, or the microphone. But "simple" is misleading here.

If a phone vibrates in a repeatable pattern, another sensor can potentially observe the response. An accelerometer or gyroscope has manufacturing quirks. The chassis of a device resonates in slightly different ways. A site that can trigger vibration and sample other signals may be able to infer something device-specific. That turns haptics into an indirect fingerprinting primitive. The spec now says this out loud, which is rare and refreshing.

Even before you get into that more sophisticated model, there is the coarse version: a website causes a physical buzz and someone in the room can tell which phone it was. The browser has just let remote content produce an externally observable real-world side effect. That alone is enough to make conservative vendors nervous.

This is also why the standards discussion kept reaching for mitigations instead of pretending the problem did not exist:

  • require user activation
  • let the browser silently ignore requests
  • avoid exposing why a vibration failed
  • rate-limit the calls
  • let the browser or user disable the feature entirely

Those mitigations are sensible. They are also an admission that the primitive is not harmless.

The browser has a long history of "harmless" features going feral

The classic browser example is visited-link sniffing. For a long time, websites could style :visited links differently enough that pages could infer what you had clicked before. That turned what looked like a convenience feature into browsing-history leakage. The modern restrictions around :visited styling exist precisely because browsers learned the hard way that presentation state can become sensitive data.

Another good example is autoplay and user-activation gating more broadly. Audio and video playback look like straightforward media features. In practice, they became abuse surfaces. Browsers added friction not because media was intrinsically unsafe, but because web content will use any unrestricted channel to seize attention, gather signals, or route around user intent.

The same thing happened outside the browser sandbox too. The FTC's action against SilverPush is one of the most surreal examples: ultrasonic audio beacons used to link devices that happened to be in the same room. A television or app emits a near-inaudible tone, a nearby phone hears it, and suddenly "which devices belong to the same person or household" becomes inferable without the user understanding what just happened. Nothing exotic was required. Just sound, microphones, and correlation.

That is what makes this field so interesting. The cleverness is usually not in some impossibly advanced exploit chain. It is in seeing that a mundane signal can be used as glue between systems.

Five examples that feel absurd and are all real

1. Spectre

Spectre is one of the best examples of an attack that sounds fake until you understand it. The CPU speculatively executes instructions for performance, then rolls back the architectural state if the speculation was wrong. Fine. Except microarchitectural side effects, especially cache state, can remain observable. So code that should never have been allowed to read some secret data can still influence the cache in a measurable way. The secure abstraction held at one level and failed at another.

That is the recurring theme in all of these stories: a rollback at the logical level does not mean nothing happened physically.

2. Rowhammer

Rowhammer is another favourite because it turns "memory reads" into "memory corruption" through persistence and physics. By repeatedly accessing certain DRAM rows, attackers could induce bit flips in adjacent rows. No software bug in the conventional sense, just the physical reality of densely packed memory cells behaving badly under stress. Software people think in APIs and permissions. Hardware still thinks in charge leakage and interference.

3. BitWhisper

The BitWhisper paper is exactly the kind of research that sounds like someone joking and then turns out to be real. Two adjacent air-gapped computers communicate through heat. One machine modulates thermal output by changing workload. The other senses tiny temperature changes using its built-in thermal sensors. It is absurdly slow, but that misses the point. The point is that if two systems share a room, they share a medium.

4. Fansmitter

Fansmitter uses computer fan noise as a data exfiltration channel. Change fan speed, modulate acoustic output, let a nearby microphone decode the pattern. Again, the bit rate is terrible. Again, that is not the lesson. The lesson is that output channels you classify as "incidental" are still outputs.

5. Van Eck phreaking and TEMPEST

This is the old-school ancestor of all of it: screens, cables, and electronics leak electromagnetic emissions. With enough skill and the right equipment, you can reconstruct information from those emissions. The broad TEMPEST family of work is what happens when governments take seriously the proposition that computation is a physical process and therefore cannot help but radiate evidence of itself.

None of this requires science fiction. It requires taking side effects seriously.

What these attacks have in common

The common structure is almost always the same.

First, find a thing that can be influenced:

  • vibration motor
  • speaker
  • fan
  • cache
  • DRAM row
  • thermal output

Second, find a thing that can observe the influence:

  • accelerometer
  • microphone
  • nearby phone
  • timing measurement
  • electromagnetic receiver
  • thermal sensor

Third, rely on some implementation detail or physical quirk:

  • manufacturing variance
  • resonance
  • cache eviction behaviour
  • charge leakage
  • room acoustics
  • thermal transfer

That is it. That is the whole trick. The sophistication is in the pairing.

This is also why browser vendors and platform teams sometimes look irrationally conservative from the outside. People ask why a company would block a tiny API, or require user activation, or make a feature no-op in background contexts. The answer is usually not that the feature itself is too powerful. It is that the vendor has learned that every new primitive participates in a larger combinatorial system.

The web is especially sensitive to this because it is hostile multi-tenant computation by design. You are not exposing a primitive to one trusted application. You are exposing it to every ad, analytics script, embedded frame, growth experiment, scam page, and bored teenager with DevTools open.

Why this matters for product and architecture, not just security research

I find this useful beyond the thrill of weird attack papers because it changes how you think about building products.

When you add a capability, ask:

  • Does this let remote content produce a physical side effect?
  • Does it reveal timing, state, or hardware characteristics indirectly?
  • Can this output be sensed by something else in the same device, browser, room, or network?
  • If I say "it does not expose data directly," am I only talking about the obvious API surface?

That last question matters most. "No direct exposure" is one of those technically true phrases that often hides the interesting part. The indirect path is frequently the real one.

The same mindset also explains a lot of my instinctive preference for local-first and privacy-first architectures. Every time you remove a server, an identifier, a tracker, a third-party script, or an unnecessary sensor path, you shrink the number of compositions available to an attacker or an over-curious platform. The safest primitive is not the heavily permissioned one. It is the one you never exposed.

The real lesson

The real lesson is not "everything is broken" or "attackers are geniuses." It is more specific than that.

Computing is physical. Security models are abstractions layered on top of physical systems. Whenever those abstractions ignore timing, sound, heat, movement, power draw, radiation, or mechanical response, reality gets a vote anyway.

That is why vibration tracking feels so clever. It is not because the API is advanced. It is because it exploits the fact that software people keep pretending software is separate from the world. It is not. A device is a machine in a room, making noise, drawing power, heating up, vibrating, radiating, and interacting with other machines. The web sits on top of all of that whether the API surface admits it or not.

Once you see that clearly, a lot of browser decisions stop looking petty and start looking like scar tissue.

Spherical K-Means for Content Clustering

February 11, 2026

Massive needed auto-generated reading threads. I had 200 published pieces, each with a 1024-dimension Voyage-3 embedding, and a connection graph between them. The goal: cluster thematically similar pieces, then build reading paths through each cluster using the connection edges.

Standard k-means with Euclidean distance doesn't work well here. Text embeddings from models like Voyage live on a high-dimensional unit sphere. Two pieces about anxiety with slightly different magnitude vectors might be far apart in Euclidean space but nearly identical in meaning. Cosine similarity captures this, Euclidean distance doesn't.

The spherical variant

Spherical k-means replaces two things in standard k-means:

  1. Distance metric: cosine distance (1 - cosine similarity) instead of Euclidean distance
  2. Centroid update: after averaging cluster members, L2-normalize the centroid back onto the unit sphere

That's it. The rest of the algorithm is identical. Assign each point to its nearest centroid, recompute centroids from assignments, repeat until stable.

The normalization step matters because averaging vectors pulls the centroid toward the origin. Without re-projecting onto the unit sphere, centroids drift to shorter and shorter vectors over iterations, making distance comparisons meaningless.

function l2Normalize(v: number[]): number[] {
  let norm = 0;
  for (const x of v) norm += x * x;
  norm = Math.sqrt(norm);
  if (norm === 0) return v;
  return v.map(x => x / norm);
}

K-means++ initialization

Random centroid initialization is the classic k-means failure mode. Two initial centroids landing in the same dense region means one cluster gets split and another gets merged. K-means++ fixes this by choosing subsequent centroids with probability proportional to their squared distance from the nearest existing centroid. Points far from all current centroids are more likely to be chosen, spreading the initial seeds across the space.

The probabilistic selection uses a weighted random walk. For each candidate point, compute its minimum distance to any existing centroid, square it, and use that as the selection weight. This biases toward spread without being fully deterministic.

Random restarts

K-means finds local optima, not global ones. Running 3 restarts with different random seeds and keeping the result with the lowest total inertia (sum of distances from each point to its assigned centroid) is cheap insurance. With 200 points and k=40, each run converges in under 20 iterations, taking single-digit milliseconds in total. No reason not to restart.

Choosing k

I default to ceil(pieceCount / 5), targeting roughly 5 pieces per cluster. This is a heuristic, not theory. Too many clusters means most won't have enough connected pieces to form a viable reading path. Too few means the themes blur together.

The minimum viable thread in Massive is 3 connected pieces. After clustering, I find the largest connected component within each cluster (using the existing connection graph), then greedily build a path through it. Clusters where the connected component has fewer than 3 pieces get dropped. With k=40 and 200 pieces, roughly 6-9 clusters produce viable threads. That's plenty for a homepage.

What surprised me

The clustering quality was better than expected without any tuning. With k=40, the resulting clusters had cohesion scores (average pairwise cosine similarity within each thread) between 0.45 and 0.70. The Voyage-3 embeddings do a lot of heavy lifting. Pieces about anxiety cluster together. Pieces about attention cluster together. The algorithm just finds what's already there in the embedding space.

The connection graph within clusters is also denser than random chance would predict. Pieces that are semantically similar tend to have more connections between them, which makes the greedy path-building step more effective. The clustering and the connection graph reinforce each other.

11,000 Contributions

January 28, 2026

GitHub says I made 11,000 contributions last year. 204 in a single day. My personal site has a little progress bar that shows this number, normalised against 15,000 as a ceiling, and it's sitting at about 73% full. That sounds like a flex but it's mostly just an honest representation of what happens when you build a lot of things.

The number doesn't mean what people think it means. Most of those contributions aren't handwritten code. They're commits from Claude Code sessions, automated formatting fixes from Biome, dependency bumps, schema pushes. A single feature might generate twenty commits because I work in small increments — write a test, make it pass, commit, move on. The commit count is a proxy for activity, not productivity.

What the number actually represents is that I don't stop building. I have a dozen active projects across ~/Sites, each with its own port, its own Neon database, its own deployment. On any given evening I might fix a bug in Siteinspire, add a feature to Materia, push a schema change to Routerbase, and write a note on my personal site. Four projects, forty commits, one evening.

Claude Code changed the equation. Before AI-assisted development, I'd spend most of my time on ceremony — setting up types, writing boilerplate, configuring tools. Now that's handled. The interesting work — architecture decisions, design choices, data modelling — is what I spend my time on. The velocity increase isn't about working faster. It's about spending less time on the parts that don't require judgment.

The 204-commit day was a Materia sprint. I was building the product import pipeline — parsing CSV files, normalising attributes across brands, generating embeddings, populating Typesense. Each step was a commit. The code quality didn't suffer. If anything it was better because every change was small and testable.

I don't think contribution count is a useful metric for hiring or evaluation. But as a personal dashboard it's interesting. It tells me whether I'm building or just thinking about building. Right now the bar is full and I intend to keep it there.

Strict Tooling Makes AI Write Better Code

January 28, 2026

There's a counterintuitive trick to getting better code from Claude Code: make it impossible for bad code to land. Not "hard" — impossible.

The setup is straightforward. A strict TypeScript configuration with no escape hatches: strict: true, noUncheckedIndexedAccess, noUnusedLocals, noUnusedParameters, exactOptionalPropertyTypes. Biome (or Ultracite, if you want zero-config) handling formatting and linting with no overrides. Husky running pre-commit hooks that block anything that doesn't pass. And the piece that ties it all together: Claude Code tool use hooks that validate every file write against these same rules.

The tool use hook is the critical part. In Claude Code, you can configure hooks that run before or after tool calls. I have a hook that runs the TypeScript compiler and Biome on any file that Claude writes or edits. If the check fails, Claude gets the error output and has to fix it before moving on. It can't just dump code and hope for the best — the feedback loop is immediate and mandatory.

What happens in practice is interesting. The model adapts. When it knows that any will be rejected, it stops reaching for any. When it knows unused imports will fail the commit, it stops leaving them in. When it knows the formatter will rewrite its style choices, it starts writing in the expected style from the first attempt. The constraints don't slow it down — they redirect its output toward correctness.

This is the opposite of how most people use AI coding tools. The common approach is permissive: let the model generate whatever it wants, then review manually. That puts the quality burden on you. The strict approach inverts this: the tooling enforces quality automatically, and your job is to evaluate intent and architecture rather than catching missing semicolons or unsafe type casts.

The practical difference is significant. Without strict tooling, maybe 70% of AI-generated code is correct on the first pass and you spend time fixing the rest. With strict tooling, the model iterates until 100% passes the checks, and you spend your time on whether the approach is right rather than whether the syntax is right.

Husky's pre-commit hook is the last line of defence. Even if something slips past the tool use hook, it gets caught at commit time. The combination of real-time feedback (tool hooks) and gate-based validation (pre-commit) creates a system where bad code genuinely cannot enter the repository. Not "shouldn't" — can't.

A hard-won lesson: lint-staged must run format only, not lint. biome check (which combines format and lint) will catch pre-existing lint warnings in staged files that aren't part of the current change. lint-staged only passes staged files to the command, so if a staged file already had a warning from last month, biome check fails on code you didn't touch. The fix is simple: lint-staged runs biome format --write on staged files, and lint runs separately as a full-project check. Format is safe to scope per-file. Lint needs the full picture.

The same principle applies to the Claude Code hook. Format per-file is instant and safe. Lint per-file catches most issues in real-time. Typecheck needs the full project graph — fast enough for small codebases, but in a monorepo you defer it to the pre-commit hook where it runs once, not on every file write.

Never write manual git stash push --keep-index / git stash pop in pre-commit hooks. lint-staged handles stashing safely with proper rollback. If you write the stash logic yourself and any step fails, conflict markers get baked into the working tree. Every subsequent commit re-introduces them. It's an unrecoverable loop that corrupts your entire working directory. I learned this the hard way across two separate repositories before ripping out the manual stash logic and replacing it with four lines.

One unexpected benefit: the model's code starts looking more consistent with your existing codebase over time within a session. When every deviation gets rejected, the model learns (within the context window) what "correct" looks like for your project specifically. It stops generating generic patterns and starts matching your conventions. Strict tooling is essentially a teacher.

The lesson generalises beyond AI. Any system that tolerates low-quality output will produce low-quality output. The constraints aren't overhead — they're the mechanism that produces quality. Make the boundaries rigid and the work inside them gets better.

Vector Search Everywhere

January 25, 2026

I keep building the same thing. Wove searches museum collections by meaning. Blomma finds similar plants by appearance. Designround's Circle finds interior design inspiration from images. Massive recommends content based on what you've read. Different domains, identical architecture: embed content into vectors, store them in pgvector, query by similarity.

The pattern is always the same. Take a thing — an artwork, a plant photo, a product image, an article — and pass it through an embedding model that turns it into a 1,024-dimensional vector. Store that vector in Postgres alongside the regular relational data. When someone searches, embed their query the same way and find the nearest neighbours.

What makes this interesting is multimodal embeddings. Voyage's voyage-multimodal-3 can embed both images and text into the same vector space. So "melancholic winter landscape" and an actual painting of a bleak snowfield end up near each other, even though one is words and the other is pixels. This is what makes Wove work — you describe what you're looking for and it finds artworks that match the feeling, not just the keywords.

I use Neon's pgvector extension for all of this. No separate vector database. The vectors live in the same Postgres instance as the relational data, which means joins work naturally. Find me plants similar to this one that also grow in shade and flower in spring — that's a vector similarity query joined with regular WHERE clauses. One database, one query.

The cost is manageable. Embedding a batch of images is a one-time expense at ingest. The per-query cost is negligible because you're just embedding the search query and doing a nearest-neighbour lookup, which Postgres handles with an index. The expensive part is the initial embedding pass — Wove needs to process thousands of artworks from eight museum APIs, and each image costs a fraction of a cent. At scale it adds up, but for most projects it's surprisingly cheap.

The part I find most compelling is how the same infrastructure keeps finding new applications. Once you have vector search in your stack, you start seeing everything as an embedding problem. Product recommendations. Content personalisation. Visual similarity. Duplicate detection. The technique is the same every time — the domain is what changes.

I'm not sure where this ends. The embedding models keep getting better, the database support keeps getting more native, and the cost keeps dropping. A year ago I would have reached for Elasticsearch or Algolia for any search problem. Now my first instinct is pgvector and an embedding model, and it usually works better.

Curator to Builder

January 22, 2026

I spent the better part of fifteen years curating other people's work. Siteinspire was a design showcase — I'd find the best websites, screenshot them, tag them, publish them. Thousands of sites over more than a decade. The design community knew the name and I'm proud of what it became, but at some point I realised I was spending all my energy pointing at what other people had built instead of building things myself.

The shift was gradual. I'd always coded — Siteinspire itself was a custom build, rebuilt several times over the years. But the projects were always in service of curation. The database, the CMS, the frontend. Tools for showcasing, not tools for making.

Materia was the turning point. A real product with real enterprise customers, real data complexity, real stakes. Not a showcase — a platform. Products with 165 attributes per item, colour search across millions of assets, AI-generated recommendations. The engineering problems were orders of magnitude harder than anything I'd done before, and I loved it.

What surprised me was how much the curation instinct transferred. Fifteen years of looking at design every day gives you a sense for what works. I can tell within seconds whether a product page layout is right, whether the information hierarchy makes sense, whether the colour palette will read on screen. That's not engineering skill — it's accumulated taste from years of studying the work.

Now I build tools. Arc, Fiction, Falcon, Obsi, Sift — each one scratches a specific itch. The curation muscle is still there. I still run Siteinspire. But the balance has shifted decisively toward making things, and I'm building more in a month than I used to in a year.

The irony is that the velocity comes partly from AI. The same shift that's changing how people think about creative work is what gave me the leverage to become a builder rather than a curator. I don't think that's a coincidence.

Generating Fiction with Claude

January 20, 2026

I've written three novels with Claude. Not outlines or drafts that I then rewrote — actual novels, start to finish, where the prose came out of a collaboration between me and an LLM. This is either impressive or horrifying depending on your perspective, but the books are genuinely good and I'm proud of them.

The Fiction plugin started because I kept doing the same thing manually: plan the structure, develop the characters, write chapter by chapter, review, revise, review again. So I encoded the whole workflow into a Claude Code plugin with 22 specialised agents.

The interesting bit is how opinionated you have to be. Early on, the prose came out sounding like AI — hedging language everywhere, perfectly balanced dialogue, emotions explained rather than shown. "Perhaps she felt a sense of unease" instead of just writing the scene where you can feel it. So I built an anti-slop system. The reviewer explicitly flags hedging, over-explained emotions, and that generic sensory detail that sounds like a creative writing textbook.

I also built four literary critic personas — James Wood, Stephen King, Ursula Le Guin, Roxane Gay — each with a distinct voice and set of concerns. Wood cares about sentences and consciousness. King cares about story and character honesty. Le Guin cares about world-building as meaning. Gay cares about representation and emotional truth. Running the same manuscript through all four gives you wildly different feedback, and the combination is better than any single perspective.

The writer agent itself is tuned toward a specific sensibility: Rachel Cusk's precision, Jenny Offill's fragments, Kazuo Ishiguro's earned revelations. I didn't want generic literary fiction. I wanted prose with a point of view.

Continuity is the hardest problem. A novel is 70,000+ words across months of work. Characters change hair colour between chapters. Timeline inconsistencies creep in. A character knows something they shouldn't yet. I built a continuity agent that runs on Haiku for speed and checks every chapter against established facts. It catches things I'd never notice.

The workflow matters as much as the output. /fiction:plan for architecture. /fiction:character for the Want/Need/Lie framework. /fiction:outline for chapter beats. Then write, review, revise, review again. The review step is iterative — the reviewer reads a chapter, gives feedback, I revise, it reads again. This loop is where the quality actually comes from.

One thing that surprised me: the system enforces writing for the ear. Modern books become audiobooks, so the reviewer checks for clear dialogue attribution, distinctive character names, pronoun clarity. It's a constraint I wouldn't have thought of on my own and it makes the prose better across the board.

I open-sourced the whole thing. Whether you think AI fiction is legitimate or not, the engineering problem of maintaining voice consistency across a long work is genuinely interesting. And the books are good. I'll die on that hill.

Building for One User

January 18, 2026

Offledger is a personal finance app that nobody will ever use except me. SQLite database in iCloud Drive, operated through Claude Code slash commands. No server, no subscription, no data leaving my machine. I built it because every finance app wants my bank credentials and I don't want to give them.

The workflow: download CSV from my bank, run /import, let Claude categorise the transactions against my existing patterns, review the edge cases. Budget planning, spending analysis, trend lines — all through natural language queries over my own data. The UI is Claude Code itself.

Obsi is similar. It's a CLI for my Obsidian vault. obsi "quick note" captures a thought. obsi find "search term" finds it later. obsi daily opens today's daily note. I could use Obsidian's built-in features for all of this but the CLI is faster when I'm already in the terminal, which is always.

Falcon wraps fal.ai for image generation. falcon "prompt" --og generates a social share image. falcon --vary creates variations of the last generation. falcon --up upscales it. Three commands that replace a whole web UI.

Sift sorts email. Bird posts tweets. Claudestatus checks my API usage across accounts. None of these tools have users. None of them need users.

The pattern is always the same: I find friction in my workflow, I build a CLI to eliminate it, and then I use it every day until it becomes invisible. The tools compound. Falcon generates images for Siteinspire posts. Obsi captures ideas that become notes on my personal site. Sift keeps my inbox clear so I can focus on building the next tool.

I think the best software comes from building for yourself first. You know the requirements because you live them. You know the edge cases because you hit them. And you have the most demanding user possible — someone who will actually switch back to the manual process if the tool isn't better.

Not everything needs to be a product. Sometimes a tool for one person is exactly the right scope.

tRPC Changed How I Think About APIs

January 15, 2026

I used to write API types by hand. Define the response shape in the backend, copy it to the frontend, keep them in sync manually, watch them drift apart over months. It was fine until it wasn't — a renamed field, a new nullable column, a changed enum value. Runtime errors in production because the types lied.

tRPC eliminated an entire category of bugs from my work. The setup in Materia: Drizzle schema defines the database tables in TypeScript. tRPC routers import those types and define procedures with Zod input validation. React Query hooks are generated from the router types. Change a column name in the schema and TypeScript tells you every component that breaks, all the way down to the button text.

The flow looks like this. A procedure in packages/trpc defines its input with Zod and returns data queried through Drizzle. The return type is inferred — I never write it. On the client, trpc.products.get.useQuery({ slug }) gives me full autocompletion on the response. Hover over any field and you see the type that originated in the database schema. One source of truth, zero manual type definitions.

Materia has over twenty tRPC routers — products, projects, notes, search, imports, recommendations, saves. Each one composes services from the context: ctx.notes.create(), ctx.search.query(). The context itself is typed, with protected procedures that narrow ctx.session and ctx.user to non-null. Rate limiting is a middleware layer with named tiers — colorSearch at 120 requests per minute, ai at 10.

The part that changed my thinking was the refactoring confidence. Materia's product schema has JSONB attributes, vector embeddings, percentile rankings, hierarchical categories. Complex stuff. When I restructure any of it, the type system catches every downstream consumer instantly. No grep, no hoping, no runtime surprises. The compiler is the integration test.

I use superjson as the transformer, which means Dates, Maps, and Sets survive the serialisation boundary. Custom fetch wrapper with a 90-second timeout because some operations — image generation, bulk imports — take a while. These are the boring infrastructure decisions that make the whole system work.

I don't think I can go back to writing REST APIs with manual type definitions. The cognitive overhead of keeping types in sync across a boundary was significant, and I didn't fully appreciate it until it was gone.

Why Routerbase

January 15, 2026

Choosing an LLM shouldn't require reading blog posts. Every provider has a "best model" and every benchmark is designed to make someone look good. I wanted a tool that just showed me the data.

Routerbase pulls model information from OpenRouter — 339 models at last count — and layers on quality scores from LMSYS Arena, speed benchmarks from Artificial Analysis, and weekly popularity data. No editorial. No "best for" recommendations. Just filterable, sortable data.

The filtering is the interesting part. Models differ across so many dimensions that a simple ranked list is useless. You need to filter by what matters for your specific use case: does it support function calling? JSON mode? Vision? What's the price per million tokens? What's the actual measured latency, not the marketing claim?

I built shelf pages for common queries — Best Value, Fastest, Largest Context — because certain comparisons come up over and over. But the real power is the filter combination. Show me models under $1/M tokens that support function calling and have an Arena ELO above 1200. That narrows 339 models to maybe 8, which is a decision I can actually make.

The data pipeline runs on a schedule. OpenRouter model sync, LMSYS quality scores (with fuzzy matching because model names are maddeningly inconsistent across sources), Artificial Analysis speed benchmarks, and popularity rankings. Keeping it current matters because the model landscape changes weekly.

I built this because I use a lot of different models across different projects and I was tired of Googling. OpenRouter's own interface is fine but it doesn't aggregate external quality data. The leaderboards exist separately but don't show pricing. Nobody puts it all in one place, so now I do.

SEO was a deliberate focus. Every model has its own page with structured data, because people search for specific model names. Lighthouse scores 100 across the board. The site is essentially a reference tool and it should behave like one — fast, scannable, and findable.

My Stack in 2026

January 12, 2026

Every year or so I snapshot what I'm actually using across all my projects. Not what's trendy — what's survived contact with real codebases. Here's January 2026.

The core

Next.js 16 with React 19 and TypeScript 5.9. This hasn't changed in years and probably won't. The App Router is mature now. Server Components are how I think about data fetching. I don't miss the pages directory.

Tailwind v4 is a big upgrade. Config-free, no tailwind.config.js, just CSS with @theme tokens. I maintain a shared shared-styles.css across all projects in a monorepo and point @source at the right directories. It's cleaner than v3 by a wide margin.

Data

Drizzle ORM with Neon PostgreSQL. I tried Prisma for a long time and Drizzle is better in every way that matters to me. Schema-first, push-only migrations in development, type inference that actually works. The DX of writing a schema in TypeScript and having it flow through to tRPC and React Query with zero runtime type errors is hard to overstate.

tRPC 11 for APIs. Combined with TanStack Query on the client, this gives me end-to-end type safety from database schema to UI component. I don't write API types manually anymore. Change a column in Drizzle and TypeScript tells me everywhere that breaks, all the way to the button text.

Auth

Clerk for personal projects. WorkOS for Materia (enterprise SSO requirements). Both are good. I stopped rolling my own auth years ago and the amount of time saved is enormous.

AI

This is where the stack has changed most since last year. OpenRouter for LLM access — I use it for everything because I can switch models without changing code. Gemini 2.5 Flash for most text tasks. Voyage AI for embeddings: voyage-3-large for text, voyage-multimodal-3 for images. fal.ai for image generation, though I've also used GPT-image-1 when I need more control.

Vector search with pgvector in Neon. No separate vector database. Postgres does it all — relational data, full-text search, and vector similarity in one place. For projects that need traditional search, Typesense.

The boring parts

Biome replaced ESLint and Prettier. One tool instead of two, faster, less configuration. I use it with Ultracite for even stricter defaults. Combined with Husky and lint-staged, every commit gets checked automatically.

pnpm for package management. Turborepo for monorepo tasks. Vitest for unit tests, Playwright for E2E. These aren't exciting choices but they're reliable and I never think about them, which is the point.

What I'm watching

Zod 4 just landed and I'm migrating to it. The performance improvements are significant. Convex is interesting — I used it for Scenes & Chapters and the real-time DX is remarkable, though I'm not ready to give up Postgres for most things. And Claude Code keeps getting better as a development environment, which changes how I think about tooling more broadly.

How I Build

January 10, 2026

I maintain a reference system for building modern web applications — part documentation, part AI agent instruction set. Ruler aggregates rules from .ruler/*.md files and generates CLAUDE.md files that coding agents follow.

The canonical code principle: no backward compatibility, no deprecation warnings. When updating patterns, update ALL usage sites immediately. This sounds aggressive but it keeps the codebase honest — no cruft accumulates because "someone might need the old way."

Strict conventions: TypeScript with no any, Biome for formatting, battle-tested packages only (usehooks-ts, es-toolkit, dayjs, react-hook-form + zod). The rules are opinionated because opinions reduce decisions.

The Ruler workflow

Every project has a .ruler/ directory with markdown files — one per concern. code-style.md covers formatting and naming. react.md covers component patterns and hooks. nextjs.md covers routing and data fetching. testing.md covers what to test and how.

When I start a project or join a codebase, I drop in the ruler files that apply. They're modular — you don't need all of them. A pure API project skips the React and Tailwind rules. A static site skips the testing rules.

The key insight is that these files serve two audiences. Humans read them for onboarding and reference. AI agents read them as system instructions. The same document that tells a new developer "use for...of over forEach" tells Claude Code the exact same thing. One source of truth for how the codebase works.

What ends up in CLAUDE.md

The generated file is a concatenation of all the ruler files, structured so Claude can parse it efficiently. Each section starts with a clear scope, uses RFC 2119 terms (MUST, SHOULD, NEVER) for precision, and focuses on decisions rather than explanations.

The balance is comprehensive but scannable. Too short and the agent misses important patterns. Too long and it loses focus. I've found that 2,000–4,000 words covers most projects well — enough to encode the conventions without drowning in edge cases.

The rules evolve. Every time I find myself correcting Claude on the same pattern twice, I add a rule. Every time a rule causes friction without preventing real problems, I remove it. The system is alive — it reflects how I actually build, not how I theoretically want to build.

Biome Killed My ESLint

January 8, 2026

My old setup was ESLint, Prettier, and a fragile bridge between them that broke every time either one updated. Dozens of plugins, overlapping rules, config files that nobody understood anymore. It worked until you tried to add a new rule and discovered it conflicted with three others.

Biome replaced all of it. One tool, one config file, one command. biome check src --write does formatting and linting in a single pass. It's written in Rust so it's fast — noticeably fast, not benchmarks-say-it's-fast. On Materia's monorepo with 33 packages, the difference is obvious.

The config is minimal. I extend ultracite/biome/core for strict defaults and override exactly one rule: noBarrelFile is disabled for index.ts files because that's how monorepo packages export their public API. That's the entire configuration. Compare that to the ESLint config I used to maintain — extends, plugins, overrides, parser options, env declarations.

What I like about Biome's approach is that formatting opinions are settled. There's no debate about semicolons (yes), quotes (double), trailing commas (yes), parentheses around arrow function parameters (yes). These decisions are made once in the tool and I never think about them again. ESLint and Prettier let you configure everything, which meant every project started with a configuration discussion that added no value.

Combined with Husky and lint-staged, every commit gets checked automatically. The pre-commit hook runs Biome on staged files only, so it's fast even on large changesets. If something fails, the commit is blocked. No CI surprises.

The migration was straightforward. Remove eslint, prettier, and all their plugins from package.json. Delete .eslintrc, .prettierrc, and whatever other dotfiles had accumulated. Add biome.json. Run biome check --write once across the whole codebase to reformat everything. One commit, done.

I've seen people resist Biome because they have strong opinions about formatting. My advice: let it go. The marginal benefit of your preferred brace style is zero compared to the benefit of never thinking about formatting again.

The Monorepo as Product

January 5, 2026

Materia is a monorepo with 33 packages and 5 apps. That sounds like overkill until you understand what each package does and why it exists. The structure isn't complexity for its own sake — it's the architecture made visible.

The split: apps/ has deployable things — the main web app, a material tagging tool, warehouse management, a brand portal, Storybook. packages/ has shared code — @materia/db for the Drizzle schema, @materia/trpc for the API layer, @materia/ui for the design system, @materia/env for environment validation. Then domain packages: @materia/projects, @materia/notes, @materia/saves, @materia/search, @materia/cart.

The key decision was just-in-time packages. No build step for packages. They export source TypeScript directly via exports in package.json, and the consuming app's bundler handles the compilation. This means changes to a shared package are immediately reflected in the dev server — no build, no watch process, no stale output. The DX improvement is significant.

The database package is the centre of gravity. @materia/db defines every table, enum, index, and relation. It exports the schema types that flow into tRPC, which flow into React Query, which flow into components. Change a column here and the type system propagates the change everywhere. The schema file is over a thousand lines — products with JSONB attributes, vector embeddings with HNSW indexes, hierarchical categories with ltree paths, quantised colour data with dual HSL and OKLab models. It's the most important file in the repository.

I use Turborepo for task orchestration but only lightly. pnpm dev starts everything except Storybook. pnpm build builds everything in dependency order. pnpm test runs all tests. The turbo.json config is small because most of the complexity lives in the packages themselves, not in the task graph.

The environment package deserves mention. @materia/env validates all environment variables at boot using Zod schemas. Server-only vars and client-safe vars are separated. No code anywhere reads process.env directly — it imports from the env package. This catches misconfiguration immediately instead of at runtime when some edge case hits an undefined variable.

I wouldn't use a monorepo for everything. A single app with no shared code doesn't need one. But when you have multiple apps consuming the same database schema, the same UI components, and the same API types, a monorepo eliminates an entire class of coordination problems. The packages aren't boundaries between teams — they're boundaries between concerns. And those boundaries are the architecture.

Drizzle Over Prisma

January 2, 2026

I used Prisma for a long time. It was fine. The schema language was readable, the migrations worked, the client was decent. But "fine" accumulates friction, and eventually I switched to Drizzle and won't go back.

The schema is TypeScript. Not a .prisma file that compiles to TypeScript — actual TypeScript that IS the schema. I define tables with pgTable, columns with typed helpers, indexes with .on(). The types are inferred directly from the definitions. No code generation step, no prisma generate, no waiting for the client to rebuild after every schema change.

Push-only migrations changed my development workflow. In development, I run db:push and Drizzle diffs the schema against the database and applies changes directly. No migration files to manage, no migration history to keep in sync, no prisma migrate reset when things get tangled. The schema file is the source of truth. In production you can generate SQL migrations if you need them, but in development the push workflow is dramatically faster.

The query builder is closer to SQL than Prisma's. This matters when your queries get complex. Materia has products with 165 attributes in JSONB columns, vector embeddings with HNSW indexes, hierarchical categories using ltree, quantised colour data across dual colour models. Writing these queries in Drizzle feels like writing SQL with type safety. In Prisma, complex queries meant escaping to $queryRaw and losing all the type benefits.

Custom column helpers keep the schema DRY. I have id() that generates nanoid primary keys, vector() for pgvector columns with configurable dimensions, timestamp helpers with timezone handling. These compose naturally because they're just functions returning column definitions.

The lazy database client was a small thing that made a big difference. Drizzle wraps the connection in a Proxy that defers initialisation until first use. No eager connection on import, no startup penalty in serverless environments, no connection management boilerplate.

The sql template literal is powerful. JSONB extraction, custom operators, window functions — anything Postgres can do, the template literal can express, and the result type flows through to the consumer. sql<VariantAttributes> tells the type system exactly what shape the raw query returns.

I don't think Prisma is bad. For simpler schemas and teams that prefer a more abstracted API, it works well. But once your data model gets complex enough that you're fighting the ORM instead of using it, Drizzle's closer-to-the-metal approach wins.

AI Image Pipelines

December 15, 2025

I've been running AI image generation through fal.ai's Imagen4 for Popular Archive, and the single biggest lesson is that prompt engineering dominates everything else in the pipeline. You can spend hours tuning post-processing steps, but a well-crafted prompt eliminates most of that work before it starts. The pipeline itself is straightforward: prompt engineering, model selection, generation, quality check. The nuance is in how each stage feeds back into the others.

For Popular Archive, the generation flow tracks each image through a generationStatus field: pending, complete, or failed. When an article enters the system, it gets queued with a prompt derived from the article's content and classification. Imagen4 handles the actual generation, and I run a quality check before marking it complete. Failed generations get re-queued with adjusted prompts rather than just retried blindly. The key metric I watch is first-pass acceptance rate, because every regeneration costs time and credits. With specific prompts, I hit around 85% first-pass acceptance. With vague ones, it drops to 40%.

Deduplication turned out to be a real concern at scale. When you're generating hundreds of images across related content, you start getting visually identical outputs. I hash each generated image with SHA-256 and check against existing hashes before storing. It catches exact duplicates, which happen more often than you'd expect when similar prompts hit the same model. The hash also serves as a content-addressable filename, which simplifies the storage layer. One image, one hash, one URL, no conflicts.

The insight I keep coming back to: specificity in the prompt is worth 10x more than cleverness in post-processing. Telling the model exactly what composition, lighting, and style you want produces consistent results. Telling it "make a nice image about cooking" and then trying to fix it downstream is a losing game. I've seen this pattern across every generation pipeline I've built. The models are good enough now that the bottleneck is almost always in how precisely you describe what you want.

The Popular Archive Pipeline

December 10, 2025

The Popular Archive is a content discovery and transformation pipeline. It finds interesting content from Reddit and the web, rewrites it with a specific editorial voice -- Bill Bryson meets Schott's Miscellany -- and generates whimsical pencil-sketch illustrations. The stack is OpenRouter routing to Claude for the writing, fal.ai for image generation, and ScrapingBee for capturing source material. The hard part isn't any individual piece. It's making the output feel like a human editor curated it.

Prompt engineering for editorial voice is surprisingly nuanced. "Write like Bill Bryson" produces pastiche -- obvious, try-hard imitation. Instead, I decomposed the voice into specific qualities: parenthetical asides that add genuine context, a sense of wonder at mundane facts, dry humour that never signals it's being funny, and the confidence to tell you exactly why something matters. The system randomly selects between prose, annotated lists, and structured tables for each piece, which breaks the monotony that makes AI content feel robotic. A reader shouldn't be able to tell which format is coming next, just like a real magazine varies its editorial approach.

The self-improving discovery loop is where the system gets interesting. As the pipeline processes content and scores it for quality -- engagement signals, source authority, editorial fit -- it generates new search prompts based on what performed well. If articles about obscure Victorian inventions consistently score high, the system generates more specific discovery prompts in that space. If a topic cluster saturates, it pivots. The quality feedback loop means the discovery gets more targeted over time without manual curation. After three months, the hit rate for "content worth publishing" went from roughly 12% to around 40%.

The illustration pipeline uses fal.ai with a consistent pencil-sketch style prompt. Every article gets a header illustration that's thematically relevant but stylistically consistent across the entire archive. SHA-256 deduplication prevents the system from processing the same source material twice, and a quality gate rejects generated images that don't meet a minimum aesthetic threshold. The whole pipeline runs on a schedule -- discover, filter, rewrite, illustrate, publish -- with human review as an optional approval step rather than a bottleneck. The goal is editorial quality at algorithmic scale, and the gap between those two things is where all the interesting engineering lives.

Material Identification with Vision Models

December 2, 2025

The material identification feature in Materia lets architects photograph a room and identify every material in it — the flooring, the wall treatment, the upholstery, the countertop. Then it finds matching products in the catalogue for each one. The pipeline is three stages: detect what materials are in the image, segment each one, and search for similar products.

Detection with Gemini Flash

The first stage uses Gemini Flash 3 as a multimodal vision model. It receives the image and a list of the 236 material categories from our three-level taxonomy (ltree format: materials.textiles.upholstery, materials.flooring.lvt, and so on). The model returns up to six materials, each with a natural language label ("beige linen upholstery"), a bounding box with normalised coordinates, and a category key matched against the database.

Category matching has a fallback chain: exact match, then case-insensitive, then partial string match. This matters because the model occasionally returns category names that are close but not exact — "interior_floor_tile" vs "interior-floor-tile" — and failing to match would mean the user sees "unknown material" for something the model correctly identified.

There's a classification step before detection that determines the image type: swatch (single material filling the frame), installation (a room with multiple surfaces), or flatplan (material samples arranged like a mood board). This classification changes the downstream behaviour — a swatch goes straight to vector search, while an installation needs per-material segmentation.

The classification uses a two-question prompt technique. Instead of asking "what type is this?" directly, I first ask "are there multiple separate material samples visible?" as a boolean, then use that answer to classify. Forcing the model through explicit logical steps produces more reliable classifications than a single open-ended question.

From detection to matching

Each detected material gets its own pipeline. The bounding box crops the original image to isolate the material, then a Voyage AI embedding (multimodal-3, 1024 dimensions) converts the crop into a vector. That vector goes into Typesense for approximate nearest-neighbour search against the product catalogue.

The matching algorithm is more nuanced than raw vector distance. Results get scored by category match (how many levels of the taxonomy hierarchy align), filtered for brand diversity (max three products per brand, round-robin selection), and optionally reordered by colour similarity using OKLab distance on the dominant colours. Category matching is crucial — a beige textile should match other beige textiles, not beige stone, even if the raw vectors are closer.

A practical optimisation: images over 5MB or 12 megapixels get resized to 1024px width before embedding. This reduces Voyage API costs by about 80% with negligible quality loss for material matching. PNG transparency gets flattened against a white background before JPEG conversion — without this, transparent areas compress to black artifacts that poison the embedding.

The attribute system

Each product in the catalogue has up to 165 JSONB attributes split between codeset attributes (90 categorical values like lamping, pattern, construction, abrasion rating) and non-codeset attributes (75 text/numeric/boolean values like designer name, warranty, width, thickness, composition). The schema is the result of mapping an industrial materials data warehouse into a format that supports both filtering and semantic search.

Two derived scores — sustainability percentile and durability percentile — rank each product relative to its category. A wallcovering with a recycled content of 25% might be in the 80th percentile for wallcoverings but the 30th percentile for textiles. Category-relative scoring makes comparisons meaningful.

State machine for multi-material mode

The UX challenge is that a single image can contain six materials, each at a different stage of processing. The match store is a state machine with two paths: swatch mode (idle, detecting, swatch-ready, matching, complete) and scene mode (idle, detecting, scene-ready, extracting-all, multi-ready, multi-complete). Each individual material within a scene tracks its own state: pending, segmenting, cropping, searching, complete, or error.

This granularity matters for the interface. The user sees each material progressing independently — one might already have search results while another is still being segmented. Without per-material state tracking, you'd either have to wait for everything to finish (slow) or show nothing until everything is done (frustrating).

Product extraction from the web

There's a parallel pipeline for extracting product data from manufacturer websites. Gemini Flash 2.5 processes cleaned HTML to extract name, brand, description, images, and a category hint. The HTML cleaning is aggressive: remove scripts, styles, SVGs, templates, iframes, comments, base64 data URIs, event handlers, framework attributes, hidden elements. The goal is to get the page down to about 30K characters (~7.5K tokens) while preserving product information.

Image extraction is especially tricky. URLs get pulled from raw HTML before cleaning (to capture aria-hidden content), then filtered to remove favicons, icons, logos, tracking pixels, and social media images. CDN images get priority, and URLs containing quality hints like "2500px" or "full" get ranked higher.

Fail fast

The entire pipeline follows a fail-fast philosophy. Vision model detection times out at 60 seconds. Classification times out at 10 seconds. There are no fallbacks and no silent degradation. If the model fails, the user sees an error immediately rather than getting bad results minutes later. This was a deliberate choice — in a professional tool, wrong results are worse than no results, and "I couldn't identify this material" is more honest than a confident but incorrect match.

Design System Evolution

December 1, 2025

The Materia design system, @materia/ui, has grown to 33 packages, and the most counterintuitive decision was making tight coupling a deliberate feature. In most monorepo advice, you hear "keep packages loosely coupled." For a design system, the opposite is true. Components should share a single source of truth for tokens, spacing, and color so tightly that changing one value propagates everywhere instantly. A design system that lets individual components drift from the core tokens isn't a system, it's a collection.

The gray-only palette was the constraint that forced the most interesting design thinking. Every neutral in the system maps to Tailwind's built-in gray scale. No slate, no zinc, no neutral, no stone. Just gray. This sounds limiting until you realize that eliminating color variation from the neutral palette forces you to create hierarchy through typography and spacing alone. When you can't reach for a slightly warmer gray to differentiate a sidebar from the main content, you have to use font weight, size, and whitespace to do the work. The result is cleaner. Components look cohesive because they literally cannot clash on neutral tones.

The component API design centers on semantic props over utility classes. Instead of className="flex gap-4 items-center", components expose <HStack gap="4" align="center">. Stack, HStack, and VStack handle layout composition. Heading and Text handle typography. Button variants handle interactive states. The goal is that app-level code almost never needs raw Tailwind classes. When I review code and see className in an app component, it usually means @materia/ui is missing a primitive, which is a signal to add one rather than work around it. The system grows from real needs, not speculative abstractions.

The tight coupling pays off during refactors. When I updated the spacing scale last month, every component in every app picked up the change through a single token update. No find-and-replace across files, no "migration guide," no deprecation period. The current code is always canonical. Old patterns get removed, not deprecated. That philosophy means the system stays small and coherent even as it grows. Thirty-three packages sounds like a lot, but each one has a clear boundary and a single responsibility. The package count reflects domain complexity, not organizational sprawl.

AI Quality Scoring

November 20, 2025

Curating content at scale means you need a way to decide what's worth showing. I built a weighted scoring system that uses LLM evaluation to rate content on an integer 1-10 scale across multiple dimensions. It runs in both Siteinspire and Popular Archive, though the dimensions differ. Siteinspire scores on visual design, technical execution, and layout innovation. Popular Archive scores on editorial quality, factual depth, and topical relevance. The weights shift depending on the content type, but the mechanism is the same.

The scoring prompt is deliberately constrained. I ask for integer scores only, no decimals, no ranges. Each dimension gets a brief rubric so the LLM has concrete anchors: a 3 in visual design means "functional but unremarkable," a 7 means "distinctive, cohesive design language," a 10 means "sets a new standard." Without these anchors, scores drift over time as the model interprets "good" differently across sessions. The rubric keeps it grounded. I also enforce a JSON response format so parsing is deterministic. No free-text reasoning in the scoring response, just numbers.

The composite score is a weighted sum, normalized back to 1-10. For Siteinspire, visual design carries 40% weight, technical execution 35%, layout innovation 25%. These weights came from manual calibration: I scored 50 sites by hand, then adjusted weights until the automated scores matched my rankings within one point for 80% of cases. It took three rounds of adjustment to get there. The remaining 20% divergence is mostly on sites where I have personal taste preferences the model doesn't share, which is fine. Consistency matters more than perfect agreement with my own judgment.

The real insight is that the score itself isn't the product. What matters is having a consistent evaluative lens applied uniformly across thousands of items. Human curation is better for any individual piece, but it doesn't scale. A systematic scoring approach lets me filter 500 new sites per week down to the 30 worth featuring, and the threshold is applied the same way every time. The system doesn't replace taste. It operationalizes it.

Building Colour Search for Materia

November 15, 2025

Materia is a materials marketplace for architects and designers, and one of the most requested features was searching by colour. Not "filter by red" — proper perceptual colour matching. An architect holds up a fabric swatch and wants to find every material in the catalogue that matches. The problem is that "matching" means different things to different people, and computers are terrible at agreeing with humans about colour.

OKLab or nothing

The first lesson was that HSL is useless for perceptual matching. Two colours can be 5 degrees apart in hue and look identical, or 5 degrees apart and look like completely different colours, depending on where you are in the space. OKLab solves this. It's a perceptual colour space where Euclidean distance actually corresponds to how different two colours look to a human eye. A deltaE of 0.02 is imperceptible. 0.05 is "same family." Above 0.1 and you're in different territory.

The conversion pipeline goes sRGB to linear RGB to OKLab, using the proper matrix transforms. I store both HSL (for categorical filtering) and OKLab (for perceptual matching) on every colour, because they serve different purposes. HSL is good for "show me all the blues." OKLab is good for "show me everything that looks like this specific blue."

Quantisation: 720 buckets

Every product image gets its dominant colours extracted and quantised into a grid: 24 hue steps (every 15 degrees), 6 saturation values, 5 lightness values. That's 720 possible colours. The saturation scale is non-uniform — it includes a 15% step specifically for muted textile colours, which is where a huge chunk of the material catalogue lives. Standard 0/25/50/75/100 misses the entire "slightly desaturated" middle where most fabrics and stones exist.

A critical gotcha: when saturation quantises to zero, hue must be forced to zero too. Otherwise you get grey colours with meaningless hue variations — a "red grey" and a "blue grey" that look identical but map to different buckets.

The JPEG artifact problem

This one was painful. JPEG compression creates saturated pastel artifacts, particularly at certain hues (45, 60, 120, 150, 180, 240, 300 degrees) and 90% lightness. When you're extracting dominant colours from product photography, these artifacts show up as real colours. A white background doesn't extract as white — it extracts as a pale saturated yellow or cyan.

The fix is aggressive: if multiple artifact-coloured buckets sum to more than 30% of the image, or any single artifact colour exceeds 10%, they all get filtered. This sounds heavy-handed but in practice it's the only way to prevent false colour matches from compression noise. Background detection helps too — sampling 5x5 pixels from each corner to identify and exclude the background before quantisation.

Five search modes

The feature grew from "search by colour" into five distinct modes, each with different algorithms:

Single colour search uses a GiST cube index on OKLab coordinates for KNN search. The tolerance slider maps from 0 to 1 using a power curve (x^0.7) that makes the slider feel linear even though the perceptual space isn't. At tolerance 0, threshold is 0.02 (imperceptible difference). At 1.0, it's 0.18 (broad family).

Palette search takes 1-5 colours with AND/OR logic. The algorithm cross-joins palette colours with asset colours, computes minimum distance to each palette colour, and filters by how many of the requested colours match. This required careful SQL — a single CTE with LATERAL joins instead of N EXISTS clauses.

Gradient search was the most interesting to build. Given two endpoint colours, it finds materials that contain colours lying along the gradient line between them. The math projects each colour onto the line using dot products, clamps to [0,1] to prevent extrapolation beyond the endpoints, and measures perpendicular distance. Results come back ordered by position along the gradient, divided into 10 segments to prevent any one region from dominating.

Mood search maps emotional descriptors (warm, moody, luxe, coastal) to regions in OKLab space. Each mood generates three sample colours — a centre point and two variations spread across temperature and lightness — then runs palette search with "any" logic. It's a surprisingly effective bridge between how designers think ("I want something warm") and what the database can search.

Image search lets you upload a photo and search by its extracted palette. The extraction pipeline resizes to 200x200, detects and removes the background, builds a pixel histogram in quantised HSL, computes median colours per bucket, filters JPEG artifacts, merges similar colours within an OKLab threshold of 0.03, and returns up to 12 colours sorted by proportion. Every step exists because something went wrong without it.

Match types matter

Designers gave us a crucial insight: "is this colour" and "includes this colour" are different questions. A marble slab that's 60% white and 40% grey veining is white. But it includes grey. The match type toggle switches between minProportion of 0.5 (dominant) and 0.4 (present), which sounds like a small difference but dramatically changes result quality. This came directly from user research — a designer named Renee articulated the distinction in a way that immediately became the specification.

Caching with quantised keys

Colour search is expensive, so everything goes through Upstash Redis with a circuit breaker pattern. The trick is quantising cache keys to 2 decimal places in OKLab — 0.75:0.02:-0.15 — so visually identical colours hit the same cache entry. A 1-hour TTL is generous because the colour data only changes when we refresh the materialized view of dominant colours.

The circuit breaker is important for a serverless environment. If Redis goes down, the breaker opens after 5 failures, the system falls back to uncached queries for 30 seconds, then retests. Colour search degrades gracefully rather than failing hard.

The lesson

Colour is deceptively complex. What seems like a simple feature — "search by colour" — requires perceptual colour science, image processing, custom quantisation, five different search algorithms, and careful UX to bridge the gap between how designers think about colour and how databases store it. Every shortcut I tried (using HSL distance, skipping artifact filtering, using uniform quantisation) produced results that looked wrong to domain experts, even when they were technically correct.

Building a Finance CLI with Claude Code

November 15, 2025

I don't trust cloud finance apps with my data. Every budgeting app wants bank credentials, API access, or at minimum an account where my transaction history sits on someone else's server. Offledger is the opposite: a personal finance CLI that runs entirely locally. SQLite database in iCloud Drive, operated through Claude Code slash commands. No server, no cloud sync, no data leaving my machine. The entire financial history lives in a single file I control.

The insight that unlocked the project was realising Claude Code is already a sophisticated UI. Instead of building a web dashboard with charts and tables, I built slash commands: /import for bank CSVs, /categorise for AI-powered transaction tagging, /budget for planning against actuals, /context for understanding spending patterns in natural language. Want to know how much I spent on coffee shops in the last quarter? That's a natural language query over my own SQLite database, processed locally, with zero API calls to any external service. The AI reads the schema, writes the SQL, returns the answer.

The /categorise command is where the AI earns its keep. Bank transaction descriptions are notoriously cryptic -- "POS 4829 WHOLEFDS MKT" means nothing to a rule-based system without explicit mappings. Claude reads the description, infers the merchant and category, and applies it. First pass accuracy is around 85%. I correct the misses, and those corrections feed back into the prompt context for future runs. After three months of transactions, accuracy is above 95% for recurring merchants. The whole system improves from my corrections without any cloud training pipeline.

The trade-off is real: no collaboration, no shared budgets, no mobile app. For personal finance, I'm fine with that. I open the terminal, run a command, get my answer, and close it. The interaction model is closer to a calculator than an app. Financial tools have been over-designed for years -- dashboards with 40 widgets when all you need is "am I spending more than I earn" and "where is the money going." SQLite handles the storage, Claude Code handles the interface, and I handle the data. That's the entire stack.

Content Discovery Pipelines

November 5, 2025

I run multi-stage content pipelines across Siteinspire, Popular Archive, and Massive. The stages are the same everywhere: discover, enrich, classify, score. Discovery pulls from RSS feeds, Reddit, Exa, and ScrapingBee depending on the project. Enrichment adds metadata the source doesn't provide: screenshots, word counts, technology detection, author information. Classification runs through an LLM to tag content type, topic, quality signals. Scoring produces a composite number that determines whether the content gets published, queued for review, or dropped.

Deduplication is the unglamorous stage that prevents the whole system from degrading. I hash content with SHA-256 at the enrichment step, checking against every existing hash before proceeding. URLs alone aren't reliable for dedup because the same article appears at different URLs across syndication networks, RSS aggregators, and social sharing. Content hashing catches those duplicates. I also run a lighter fuzzy match on titles to catch near-duplicates: same article republished with a slightly different headline. Between exact hash matching and fuzzy title matching, I filter out roughly 15-20% of incoming content as duplicates across the pipeline.

The source mix matters enormously. RSS gives you consistent, high-quality feeds from publications you trust, but it's a closed loop. You only find what you already know to follow. Reddit surfaces emerging content but with unpredictable quality. Exa provides semantic search across the web, which is good for filling topic gaps. ScrapingBee handles the sites that don't offer feeds or APIs. I weight these sources differently per project. Siteinspire leans heavily on curated RSS feeds and manual submissions. Massive pulls more from Reddit and Exa because it needs breadth across topics. The quality ceiling of your pipeline is determined by your worst source, so I'm aggressive about pruning sources that consistently produce low-scoring content.

The lesson I keep relearning is that quality at scale is a maintenance problem, not a launch problem. The pipeline works well on day one when you've hand-picked your sources and tuned your classification prompts. Six months in, sources drift, new content formats appear that your classifier doesn't handle, and the score distribution skews. I schedule monthly reviews where I sample 50 items from each quality tier and check whether the scores still match my judgment. When they don't, I adjust the prompts, re-weight the scoring dimensions, or drop a source entirely. The pipeline is never done.

From Scraping to Product

October 20, 2025

Siteinspire started as manual curation. I'd browse the web, find well-designed sites, screenshot them, and add them to a gallery. That worked for the first hundred entries. It completely broke at a thousand. The shift from manual to automated happened in stages, and each stage taught me something different about turning scraped data into an actual product.

The first stage was adding scrapers. ScrapingBee handles the rendering and extraction, and I use p-limit to cap concurrency at 5 requests to avoid hammering any single domain. The scraper pulls screenshots, extracts metadata, detects technologies, and captures structural information about each site. But raw scraper output is messy: inconsistent titles, missing descriptions, broken screenshots of cookie banners. The scraper gets you data. It doesn't get you a product. That required the second stage: AI classification. Each scraped site runs through an LLM that classifies it by industry, style, layout pattern, and notable design features. The classification turns unstructured visual data into filterable, searchable attributes.

The ethical boundary I draw is clear: I only scrape publicly accessible pages, and I only extract information that any visitor would see. No login-gated content, no private APIs, no data that requires authentication to access. The sites featured on Siteinspire are public by definition, and the metadata I extract, screenshots, tech stacks, design classifications, is all derived from what any browser would render. I also respect robots.txt and rate-limit aggressively. The goal is to index and categorize the public web, not to exfiltrate data that wasn't meant to be shared.

The real product insight is that interpretation beats raw data every time. Anyone can scrape a list of websites. The value is in classifying them consistently, scoring their design quality, organizing them into browsable categories, and surfacing patterns that aren't obvious from individual examples. Siteinspire's users don't come for the URLs. They come for the curation layer: "show me dark-themed SaaS landing pages with bold typography." That query only works because every site has been classified through the same lens. The scraping is infrastructure. The classification is the product.

Image Vector Search at Materia

October 20, 2025

Materia's image search lets architects upload a photo of a material — a tile, a fabric swatch, a piece of stone — and find visually similar products across the entire catalogue. The same technology powers the material identification feature, the "similar products" recommendations, and the text-to-image search. It's all the same embedding pipeline with different entry points.

Voyage multimodal-3

The embedding model is Voyage AI's multimodal-3, which produces 1024-dimensional vectors from both images and text. The "multimodal" part is key: an image of a herringbone wood floor and the text "herringbone oak flooring" land near each other in the same vector space. This means the search index built from product images also works for text queries, without maintaining separate indexes.

Every product's main image gets embedded and stored in Typesense with the vector attached. At query time, the search input — whether it's an uploaded image or a typed description — gets embedded with the same model, and Typesense runs KNN (k-nearest-neighbour) search against the indexed vectors.

The image sizing problem

Voyage charges per token, and image tokens scale with pixel count. A 2400px product image costs about $0.0012 to embed. Resizing to 1024px drops the cost to $0.00022 — an 80% reduction — with negligible quality loss for material matching. The perceptual features that matter (texture, pattern, colour distribution) are well-captured at 1024px.

The resize pipeline detects MIME type from magic numbers (not file extension, because URLs lie), then uses Sharp to resize anything over 5MB or 12 megapixels. This catches the oversized product photography that manufacturers love to provide while leaving small images alone.

The PNG transparency trap

This one cost me a day. PNG images with transparent backgrounds, when converted to JPEG for embedding, render the transparent pixels as black. A product photo of a white ceramic tile on a transparent background becomes a white tile on a pitch-black background. The embedding captures "white object on black background" instead of "white ceramic tile," which poisons similarity search — suddenly your white tile matches dark-coloured products because the dominant visual feature is the black surround.

The fix: detect PNG images, composite against a white background before JPEG conversion. Simple once you know about it, invisible until you notice your search results are wrong in ways that seem random but aren't.

Query enhancement

When a text query comes in, the system checks whether it contains known material category terms (film, flooring, carpet, glass, paint, surface, textile, tile, wallcovering, fabric, upholstery). If it doesn't, it appends " materials" to the query before embedding. This is a small hack with a big impact — "dark oak" returns general wood results, but "dark oak materials" returns flooring and surface products specifically. The embedding model's training data responds well to domain-specific context.

Typesense as vector store

I chose Typesense over pure pgvector for the image search index because Typesense gives you vector search and full-text search in the same query. A search for "sustainable wool upholstery" can combine vector similarity on the image embedding with keyword filtering on product attributes, all in one request. pgvector would need a separate full-text search pass.

The index also acts as a vector cache. When a product needs its embedding, the system checks Typesense first. If the vector is already there, no API call needed. If it's missing (new product, or the embedding model was updated), the system generates the embedding on-the-fly and persists it asynchronously (fire-and-forget — the search succeeds immediately, the persistence happens in the background).

Brand diversity and deduplication

Raw vector search returns a lot of near-duplicates. A manufacturer might have 40 variations of the same tile in different colours — they're all visually similar and they'd dominate the results. Two mitigations:

First, deduplication by brand+name combination. If "Marazzi Treverkmore" appears in five colour variations, only one makes it through. Second, brand diversity capping — maximum three products per brand in any result set, selected round-robin so multiple brands get representation even when one brand dominates the vector similarity scores.

Category scoring

Vector similarity alone isn't enough for material matching. A beige limestone and a beige linen might be close in embedding space (similar colour, similar texture at a distance), but they're completely different materials. Category scoring adds a multiplier based on taxonomy alignment — each matching level of the hierarchy (materials → surfaces → stone → limestone) adds to the score. Products in the same L2 or L3 category get a significant boost, which keeps results within the right material family while still allowing the vector similarity to rank within that family.

The compound effect

The same embedding pipeline serves four features: image search (upload a photo, find similar), text search (type a description), material identification (detect and match materials in a room photo), and similar products (find alternatives to a specific product). Building one robust embedding infrastructure and reusing it across features is the kind of compounding that makes the investment in getting the pipeline right worthwhile. Every improvement to image processing or embedding quality improves all four features simultaneously.

Blomma Vector Search

October 8, 2025

Blomma needed a way to find visually similar plants. Not just "same genus" similar, but "these would look good planted together" similar. I built it using Voyage AI embeddings at 1024 dimensions, stored in Postgres with pgvector and queried through HNSW indexes. The index parameters landed at m=16 and ef_construction=64 after testing, which gives a good balance between recall accuracy and index build time for a catalog of a few thousand plants.

The embedding approach matters here. I generate vectors from plant images, which captures visual features like leaf shape, flower color, and growth habit. A text-based embedding would group plants by their botanical descriptions, which correlates with taxonomy more than aesthetics. Image embeddings let a trailing rosemary show up as similar to a cascading sedum because they share that same draping, textured look, even though they're botanically unrelated. That's the kind of connection a gardener actually wants when browsing.

The harder problem is combining vector similarity with relational filters. A user searching for plants similar to a Japanese maple but filtered to hardiness zone 5 and partial shade needs both systems working together. Pure vector search returns the nearest neighbors in embedding space, but those neighbors might be zone 9 tropicals. I handle this with a hybrid query: first filter by relational constraints (zone, sun exposure, water needs), then run the vector similarity search within that filtered set. The alternative, running vector search first and filtering after, risks returning too few results when the constraints are tight. Pre-filtering keeps the result count predictable.

The interesting tension is that "similar" is genuinely ambiguous in this domain. A landscape designer wants aesthetic similarity: color palette, texture, form. A botanist wants phylogenetic similarity: genus, family, growth characteristics. A home gardener wants care similarity: same watering schedule, same sun needs. I chose to optimize for the aesthetic case because that's the gap no plant database fills well. Care requirements are already structured data you can filter on. Visual harmony is the thing that's hard to search for without embeddings, and it's the thing that makes a garden feel intentional rather than assembled.

Privacy-First Architecture

October 1, 2025

I keep building tools that store data locally and I keep choosing this architecture deliberately. Offledger keeps financial data in SQLite on iCloud Drive. My Obsidian vault is a folder of markdown files. Sift processes everything in-memory without persistence. The pattern is consistent: for personal tools that handle sensitive data, local-first isn't a constraint -- it's the entire point.

The architecture is simple because it can be. SQLite for storage, the filesystem for organisation, no cloud sync, no accounts, no authentication flow. There's no user table because there's only one user. There's no permissions model because the filesystem handles that. There's no API layer because the application reads the database directly. Every layer you remove is a layer that can't leak data, can't have a vulnerability, can't be subpoenaed, can't be acquired by a company whose privacy policy you didn't read. The security model is "the file is on your disk and nowhere else." Try finding a simpler threat model than that.

The trade-off is real and I don't pretend otherwise. You lose collaboration entirely. You lose cross-device sync unless you solve it yourself (iCloud Drive works for single-writer patterns but breaks with concurrent writes from multiple devices). You lose the convenience of a web app you can access from anywhere. You lose onboarding -- there's no signup flow because there's no server. For tools I use alone with data I care about keeping private, every one of those losses is acceptable. My financial data doesn't need collaboration. My notes don't need real-time sync. My image processing doesn't need to persist results on a server.

The decision framework is straightforward: if the data is personal and sensitive, build local-first. If the data needs collaboration or multi-device access, build cloud-first. The mistake I see most often is applying cloud architecture to personal tools because that's what developers know how to build. Not everything needs a database server, an auth provider, and a deployment pipeline. Sometimes a SQLite file and a good CLI is the right architecture, and the privacy guarantee isn't a feature you bolt on -- it's a property that emerges from having no server to compromise in the first place.

Building Kinecho

September 15, 2025

Kinecho is a family connection app, and the hardest constraint isn't technical. It's that half the users are family members who don't install apps willingly, don't read onboarding flows, and will abandon anything that feels like work. Every design decision filters through that reality. If my mum can't figure it out in 30 seconds without instructions, it's too complicated.

That constraint shaped the architecture more than any technical consideration. I chose local-first because sync should be invisible. Family members shouldn't know or care that data is being synchronized. They open the app, their stuff is there, they add something, it shows up for everyone else. No loading spinners, no "waiting for sync" states, no conflict resolution dialogs. The local database is the source of truth for the UI, and background sync happens through a CRDT-based merge strategy that resolves conflicts automatically. I'd rather occasionally show a duplicate that gets cleaned up than ever show a "sync conflict" modal to someone who doesn't know what sync means.

The UX hides real complexity. Sharing photos, messages, and family updates sounds simple, but the interaction patterns are different for every family member. Some will post daily, some will only read. Some use it on a tablet, some on a phone they barely understand. I designed the core loop to be almost entirely passive: content appears in a feed, you scroll through it, you tap a heart if you like something. Active contribution is a big button that opens the camera or a text input. No menus, no settings to configure, no permissions to grant during setup. Everything that requires configuration happens once during the invite flow, and the person sending the invite handles the complexity, not the recipient.

The biggest lesson from building Kinecho is that simple UX requires more engineering, not less. Every edge case you'd normally surface to the user through a dialog or error message has to be handled silently. Offline support, retry logic, image compression, push notification permissions, background sync scheduling. All of it is invisible. The app feels simple because the code isn't.

Design-Driven Development with Ideate

September 1, 2025

The problem with jumping straight into code is that you commit to implementation details before you understand the shape of the problem. I noticed this pattern repeatedly: the best features I shipped were the ones where I spent the most time in a notebook or whiteboard before touching an editor. The worst were the ones where I started coding in the first five minutes.

Ideate enforces a specific flow: discovery, architecture, characters, outline. Discovery is the constraint-gathering phase where you articulate what you're actually solving and what you're explicitly not solving. Architecture maps the technical boundaries. Characters are the interesting part -- they're expert personas who review your design from different angles before you write a line of code. One reviewer might focus on type safety and API ergonomics, another on edge cases and failure modes. The outline is the final artifact: a structured implementation plan that's been stress-tested by multiple perspectives.

The characters concept came from noticing that I catch different classes of bugs depending on my mindset. When I'm thinking about types, I catch interface mismatches. When I'm thinking about UX, I catch missing states. Ideate externalises those mindsets into reviewers you can tune to your own preferences. Mine care about complete UI states (empty, loading, error, sparse, dense), type narrowing over assertions, and code that reveals its structure without comments. When the design gets reviewed, it's like having a strict version of yourself catch things at the cheapest possible moment -- before any code exists. Changing a plan costs nothing. Refactoring code costs hours.

The workflow maps cleanly to software in general: problem, constraints, design, build. Most teams skip straight from problem to build, then wonder why they're refactoring three weeks later. The discovery phase alone -- just writing down "what are we not building" -- eliminates half the scope creep I used to deal with. I used Ideate to design Ideate itself, which felt appropriately recursive. Four design iterations before I wrote the first line of implementation code. Each iteration caught assumptions that would have become bugs. The meta lesson is simple: the tool that slows you down before coding speeds up everything after it.

Building Sorrel

August 20, 2025

Sorrel is a pantry-aware cooking app, and the interesting engineering problem isn't generating recipes. It's the data model that connects ingredients, recipes, and a user's actual pantry into something useful. The core relationship is a three-way join: ingredients exist as canonical entities, recipes reference those ingredients with quantities, and a user's pantry tracks what they have on hand with approximate amounts. The matching algorithm has to balance what you have against what you need, and the answer is rarely a binary yes or no.

The matching logic scores recipes on a 0-1 scale based on pantry coverage. A recipe that needs 8 ingredients where you have 6 scores 0.75, but that number alone isn't enough. I weight by ingredient importance: missing salt is trivial, missing the protein is a dealbreaker. Each ingredient in a recipe has an importance flag, and the algorithm penalizes missing critical ingredients more heavily. A recipe scoring 0.6 with all critical ingredients present ranks higher than one scoring 0.8 that's missing the main component. This sorting feels natural when you browse results. The recipes that bubble up are the ones you can actually cook tonight with maybe one quick substitution.

The taste profile is the part I find most compelling. Rather than asking users to fill out preference surveys, Sorrel builds a profile implicitly from cooking history. Every recipe you cook, rate, or save contributes signal. If you consistently cook dishes with cumin, smoked paprika, and lime, the system learns that flavor cluster. Over time it weights recipe suggestions toward your demonstrated preferences, not your stated ones. People are terrible at articulating what they like to eat, but their cooking history doesn't lie. The profile is a vector of flavor affinities that evolves with every interaction.

Substitution handling rounds out the model. When a recipe calls for an ingredient you don't have, Sorrel suggests substitutions drawn from a compatibility matrix. This isn't just "use butter instead of margarine." It considers the role of the ingredient in the recipe: structural, flavor, textural. A binding agent substitution is different from a garnish substitution. The matrix is seeded from culinary knowledge and refined by user feedback when they report whether a substitution worked. It's a small feature that makes the difference between an app you check once and one you actually cook from.

Claude Code as Platform

August 20, 2025

Offledger treats Claude Code as a platform, not a coding tool. Instead of building a web app with buttons and forms, I built slash commands: /import, /categorise, /budget, /context. Claude is the UI; SQLite is the state.

This sounds like a hack, but the DX is remarkable. Natural language queries over your own data. No forms to design, no state management, no deployment. The "app" is a folder with a database and some scripts.

The slash command pattern

A good CLI command does one thing and does it fast. /import reads a CSV and maps transactions to categories. /budget compares spending to limits. /context dumps a summary of your financial state so Claude can answer follow-up questions. Each command is a skill file — a markdown document that gets injected into Claude's context when you invoke it.

The pattern works because the interface is conversation. You don't need to design a screen for "show me what I spent on food last month" — you just ask. Claude has the schema, the data, and the context to answer. The slash command gave it the tools to query.

This extends further than finance. Any domain where you have structured data and want natural language access is a candidate. Recipe databases, reading logs, project tracking. The pattern is always the same: SQLite for state, slash commands for actions, Claude for the interface.

The limits

Not everything works in a terminal. Anything visual — charts, dashboards, image previews — needs a real interface. Collaborative features are out. Real-time updates are out. If you need to share state with other people, you need a server and a UI.

The other limit is trust. Claude can hallucinate numbers. For finance, I added verification steps — every categorisation gets confirmed, every budget comparison shows raw totals. The slash commands are designed so Claude does the heavy lifting but you see the receipts.

For personal tools where you're the only user, though, this is hard to beat. Zero infrastructure, zero maintenance, and an interface that understands English.

Building Scenes

August 5, 2025

Scenes is a discovery platform for films and books, and the core challenge is cross-modal recommendation — how do you suggest a book to someone based on a film they loved, when the two media share no surface features? A film has cinematography, pacing, performances. A book has prose style, narrative structure, voice. The connection lives in the thematic space between them, and that's where embeddings help.

I built multi-view embeddings for each item. Rather than a single vector per film or book, each gets multiple embeddings capturing different aspects: narrative themes, emotional tone, aesthetic sensibility, cultural context. A Tarkovsky film and a Sebald novel might be distant in a single-embedding space but close in the "meditative pacing + memory as theme" subspace. Multi-view lets the system find these diagonal connections.

The recommendation pipeline works in stages. First, a broad retrieval using IVFFlat indexes on pgvector — fast approximate search across the full catalogue. Then Voyage AI reranking to sort the candidates by actual relevance. The reranker is the crucial step because the initial retrieval is intentionally loose. You want to cast a wide net and then filter intelligently, rather than doing precise retrieval upfront and missing interesting lateral connections.

Cross-modal is the key innovation. Most recommendation systems stay within their medium — film-to-film, book-to-book. Scenes deliberately crosses the boundary. The interface asks "If you loved Stalker, read..." and surfaces literary fiction that shares something essential with Tarkovsky's vision. Not adaptations or novelisations, but works that resonate in the same emotional frequency.

The embedding pipeline processes text descriptions, reviews, and metadata for each item. Films get additional signal from director filmography and cinematographic style descriptions. Books get signal from prose excerpts and critical reception. All of this feeds into Voyage AI to produce the multi-view vectors.

Discovery is the product, not search. You don't come to Scenes knowing what you want — you come with a starting point and follow the connections outward. The interface is designed for browsing: pick a film, see the constellation of related works across media, follow a thread into unfamiliar territory. The best recommendation is the one that surprises you while still feeling inevitable.

Scenes and Chapters

August 5, 2025

Building a collaborative content editor for fiction writers forced me to confront a problem I hadn't thought much about: how do you model creative structure in a database without the model becoming a cage? Fiction has chapters and scenes, but writers don't think in database rows. They think in narrative arcs, character threads, thematic beats. The data model needs to support the technical reality (ordered content blocks with metadata) while staying invisible enough that a writer never feels like they're filling out a form.

The scene/chapter architecture ended up as a tree: a project contains chapters, chapters contain scenes, scenes contain content blocks. Each level carries its own metadata -- chapter summaries, scene-level notes, character tags, status flags. The ordering is explicit rather than implicit, using fractional indexing so reordering scenes never requires updating every sibling's position. Writers rearrange constantly -- moving a scene from chapter 3 to chapter 7 should be a single operation, not a cascade of position updates across dozens of rows.

CRDTs handle the real-time collaboration. Two writers editing different scenes in the same chapter see each other's changes without conflicts. The hard case is concurrent edits to the same scene, where CRDT merge semantics can produce technically correct but narratively nonsensical results. A sentence inserted by one writer and a deletion by another might merge into something neither intended. The solution was granular conflict detection at the paragraph level with manual resolution for true conflicts, rather than silent auto-merging that corrupts the text. Writers would rather see "Alice and Bob both edited this paragraph" than discover their prose was silently mangled.

The deeper insight is that creative tools need structured flexibility -- enough structure to enable features like reordering, filtering, and status tracking, but enough flexibility that the structure doesn't prescribe a workflow. Some writers outline every scene before writing. Others discover the structure as they draft. The data model supports both: you can create empty scene placeholders and fill them in, or you can write continuous prose and split it into scenes later. The technical challenge is making both workflows feel equally natural, which mostly means keeping the UI focused on the content and hiding the data model entirely.

Zero Local Storage

July 25, 2025

A pattern has emerged across my recent projects: zero local storage dependency. Managed object storage for files, Neon for the database, Vercel for compute. The development environment needs Node.js and a .env file. No Docker containers, no local Postgres instance, no filesystem state to manage. Clone the repo, install dependencies, run the dev server. That's it.

The DX improvement is significant and compounding. No "works on my machine" debugging when the local Postgres version doesn't match production. No Docker daemon eating 4GB of RAM in the background. No disk space surprises from accumulated uploads in a local storage directory. No migration conflicts from stale local database state. Every developer hits the same Neon branch, the same managed storage project, the same infrastructure. Environment parity isn't aspirational -- it's structural. The development database is a Neon branch, which means it's running the same Postgres version, extensions, and configuration as production.

The file handling is where this pattern pays off most. Tensile, Faceplacer, Popular Archive -- all of them upload directly to managed storage without touching the local filesystem. An image upload is a function call that returns a URL. That URL goes into the database. There's no filesystem path to manage, no cleanup cron job, no storage volume to provision. CDN distribution is automatic. Image transformations happen at the edge. The conceptual model is simpler too: images are just URLs. Your database stores strings, not files. Backups are database backups, not database-plus-filesystem backups.

The one edge case that still needs local storage: development seed data. When you're building features that depend on having 500 products with images in the database, you don't want to re-upload 500 images to managed storage every time you reset your development branch. I keep a seed script that references already-uploaded URLs from a shared development storage bucket. The URLs are stable, the seed is fast, and the local filesystem stays clean. It's a small compromise in an otherwise zero-local-state architecture, and it's the only place where the pattern bends. For everything else -- files, data, compute -- the cloud handles it and the local machine stays lightweight.

Building Massive

July 22, 2025

Massive started with a question I couldn't stop thinking about: what if knowledge discovery worked the way curiosity actually works? Not keyword search, not category browsing, but following connections the way your mind does — from a concept to a related concept to an unexpected third thing that reframes the first two.

The core idea is a dual-graph data model. There's a reference graph (books, films, articles, podcasts — the things you consume) and a concept graph (themes, movements, ideas — the things you think about). These two graphs are connected by six different relationship types: influences, references, explores, responds_to, extends, and contradicts. The relationship type matters because "Film A references Book B" is fundamentally different information than "Film A contradicts Book B."

The technical implementation uses Neon Postgres with Drizzle ORM, and the connection scoring is where it gets interesting. Each connection has a base strength, but the system also calculates a personalisation score based on your exploration history. If you've been deep in modernist architecture, connections to Bauhaus principles get weighted higher — not because they're objectively more important, but because they're more relevant to where your curiosity currently lives.

Voyage AI handles the embeddings, mapping both references and concepts into the same vector space. This means you can search semantically — type "the relationship between jazz improvisation and abstract expressionism" and get results even if no single reference uses those exact words. The vectors capture the meaning, not the surface.

The hardest part was the connection pipeline. I built it to suggest connections automatically using embedding similarity, but with a human review step. Fully automated connections produce too much noise. Fully manual connections don't scale. The sweet spot is the machine proposing and a human confirming, which is a pattern I keep coming back to across projects.

Navigation is the product. The interface is designed around exploration, not consumption. You land on a concept node, see its connections radiating outward, and follow whichever thread catches your attention. There's no "right" path. The goal is to make serendipitous discovery feel inevitable — every click reveals something you didn't know you were looking for.

How I Build (Summer 2025)

July 15, 2025

The other day I realised I had three Cursor projects open at once, and each was cranking away in Agent mode on various tasks I had given it. All I was doing was hopping between each, tweaking and guiding as it generated what was probably thousands of lines of code largely unsupervised. If this is what's capable now, it's unfathomable what will be possible in 3-6 months time, let alone 5 years.

If you're not embedding AI in your engineering workflow already, you need to start right now. Here are the nerdy details if you want to briefly know what I've found to work.

⚫️ Claude 4 Sonnet feels like a giant leap after recent models being incremental nudges in capability. It's still a bit eager to please but its capability is now vastly better than all others, such that my earlier workhorse Gemini Pro 2.5 just seems lacking in every way. (It swears a lot more than Gemini does too, or maybe that's just me.)

⚫️ Give your projects intensely tight guardrails, way more strict than you'd ever give yourself. I'm 100% Node, and I have elaborate ESLint configs that are strict about what can go where and coding style, so that as soon as the model commits anything to a file, the linter will immediately shout back at it with errors, creating a feedback loop the model physically can't ignore. The same with Typescript: go as strict as you can, and again, Cursor will have no choice but to be disciplined about types. See linting as a secondary prompting layer, continuously feeding back with jobs to be done.

⚫️ Try to give your project a comprehensive and modular architectural structure. Using Turborepo even for a single app, or simply a domain-oriented file structure (enforced by ESLint) means again, the agent has nowhere to go but the correct place first or second shot.

⚫️ Make sure your data model is as complete as possible, including tables and relationships that you're nowhere near needing to use. It'll lay the architectural groundwork and give shape to your project, which will all be consumed by Cursor's context window. And, if you can land on a very strict API: I've found using tRPC works very well, because it's strict. Using Zod for everything will help too.

⚫️ Comment absolutely everything. Casually, or strictly JSDoc every file you can. If comments help you understand the code, it'll help the model. I document architectural notes in .md files and rules files that outline everything there is to know about the project. And for some projects, I keep a "vision.md" in the root, which is an over-arching outline of what I want the app to do eventually—both initially and in the future. Adding this usually non-technical writing often helps Cursor understand what it needs to do.

🟢 I haven't even touched on Warp, Claude Code, and MCP, or even the projects I'm actually working on which almost all now include a huge element of AI and ML experimentation, so I could go on and on. If you're interested in chatting about this I'm more than happy to help.

Faceplacer API

July 10, 2025

Faceplacer is an AI-powered placeholder avatar service, and the core architectural decision is permanent caching. Every avatar is generated once and cached forever. The URL is deterministic: given the same parameters, you always get the same image. This matters for a placeholder service because consumers embed these URLs in their markup and expect them to resolve consistently. If the same URL returned a different face on every request, it would break visual regression tests, design reviews, and any workflow where consistency matters.

The CDN-first architecture means the generation step is a one-time cost per unique parameter combination. A request comes in, the CDN checks its cache, and if the image exists, it serves it directly without hitting the origin. On a cache miss, the origin generates the avatar using fal.ai, stores it permanently, and returns it with aggressive cache headers. After the first request, that avatar is effectively static content. The generation cost is amortized to zero over time as the cache fills. For a service that might get embedded in hundreds of sites, this is the difference between a manageable API bill and a runaway one.

Deterministic URLs are the key design choice that makes everything else work. The URL encodes the parameters: style, seed, size, and any customization options. The same parameters always produce the same hash, which maps to the same cached file. This means developers can use Faceplacer URLs in their code knowing they'll get the same avatar every time, the same way they'd use a static image URL. There's no API key required for reads, no authentication overhead, no rate limiting on cached responses. Generation requests do require authentication and are rate-limited, but the common case of serving cached avatars is completely open and fast.

The lesson from building Faceplacer is that a placeholder service has fundamentally different requirements from a generation service. A generation service optimizes for variety and quality. A placeholder service optimizes for consistency, speed, and zero-friction integration. Developers should be able to drop a URL into an img tag and forget about it. Every architectural decision flows from that principle: deterministic URLs, permanent caching, CDN-first serving, no auth on reads. The AI generation is the interesting part technically, but the caching strategy is what makes it a useful product.

Good Country Index

June 15, 2025

The Good Country Index ranks countries across 7 dimensions measuring their contributions to the common good: science and technology, culture, international peace, world order, planet and climate, prosperity and equality, health and wellbeing. Each dimension has 5 sub-indicators, so the full dataset is 35 metrics per country across 170+ nations. The visualization challenge was making that complexity scannable without dumbing it down.

Radar charts were the obvious first attempt, and they failed immediately. They look impressive in presentations but they're terrible for comparison. The area encoding is misleading because the visual size depends on the order of axes, not the data. Rotating the same values produces a different-looking shape, which means the visual impression is arbitrary. Worse, comparing two countries requires overlaying two radar charts, and the human eye is bad at comparing irregular polygon areas. I abandoned radar charts after the first prototype and switched to a ranked bar approach where each dimension gets a horizontal bar showing the country's rank relative to all others.

The D3.js implementation handles the hierarchical nature of the data. Each dimension expands to show its 5 sub-indicators, and the expand/collapse interaction lets users drill into the detail without overwhelming the default view. The key design decision was making rank position, not score, the primary visual encoding. Raw scores are meaningless to most people. Knowing a country ranks 12th out of 170 in science and technology communicates scale instantly. The bar width maps to percentile rank, so a full bar means first place and a sliver means last.

The hardest interaction to get right was comparison mode. Selecting two countries highlights their ranks side by side across all 7 dimensions, with the difference shown as a gap between bars. Color indicates which country leads in each dimension. The constraint was keeping the comparison readable on mobile where horizontal space is limited. I ended up stacking the two countries vertically within each dimension rather than placing them side by side, which sacrificed some visual immediacy but worked at every screen width. The design principle I kept returning to: visualization should answer a question faster than reading a table would. If the chart doesn't beat the table, the chart is wrong.

Mob Ingredient Images

June 12, 2025

Benjamin asked if I had any ideas on how to get an image for all ~2,500 ingredients that Mob uses for its recipes. I wondered if I could spin up an AI image generation pipeline that could do it, and in a day I had generated all the images. I'm really happy with the results.

The challenge was getting consistent, professional-quality ingredient photos that look like they belong in a recipe app. I started with Fal.ai—superb and fast and cheap—but transparency handling was a nightmare. I've got a new-found appreciation for what really good background removal apps do; it's not trivial at all. Both DALL-E 2 and 3 generated inconsistent results, so I ultimately landed on GPT-image-1 which while slower and more expensive gave me the control I needed.

To get consistent results you need to be incredibly precise with your prompts. Anything left to chance led to crazy results, so I ended up with 2,625 lines of TypeScript just for prompt engineering. This included 6 ingredient categories (proteins, granular, sauces, powders, herbs, liquids), each with custom presentation rules and viewing angles.

One curious side-effect of aggressive prompting: I got content moderated for a while. OpenAI really doesn't want you shouting at it to generate the most raw chicken flesh possible, so I had to skirt around that little detail.

Once a first pass of images were made, I found I had to tweak and regenerate some—but perhaps only 10% of the total, most were spot on. I built a feedback UI which lets me quickly indicate what was wrong with the image (too few items, too many items, wrong angle, or additional custom instructions) which gets fed back into the generator along with the original instructions.

Ultimately, we now have 4 gigabytes of production-ready images which are going to make it into a future release of the Mob app.

In hindsight, I'd start with GPT-image-1 from day one. The time spent debugging transparency issues with cheaper options wasn't worth the cost savings.

Building Blomma

June 10, 2025

Blomma is a plant curation platform with about 250 plants, and the interesting part isn't the catalogue — it's the search. When someone is looking for a plant, they often can't describe what they want in words. They know the vibe: trailing, dark leaves, architectural. Or they have a photo of something they saw at a friend's house. The search needs to handle both.

I built a multimodal embedding pipeline using Voyage AI. Every plant gets two embeddings: one from a text description (care requirements, visual characteristics, growth habit) and one from the primary image. These end up in the same vector space, which means a text query and an image query can return the same results. Upload a photo of a monstera and you'll find monsteras. Type "large dramatic leaves" and you'll also find monsteras. Same embedding space, different entry points.

The search blend is 70% text, 30% image, tuned by experimentation. Pure text search misses the visual similarity that makes plant discovery feel right. Pure image search is too literal — a photo of a pothos returns pothos variants but misses other trailing plants that share the same aesthetic. The 70/30 split captures both meaning and appearance.

Image generation is another layer. I use fal.ai to generate styled plant photographs for the catalogue. Real plant photography is expensive and inconsistent — different lighting, different pots, different backgrounds. Generated images give every plant the same treatment, which makes the browsing experience feel cohesive. The prompt engineering for botanical accuracy is its own rabbit hole. Getting AI to produce a convincing Philodendron gloriosum without hallucinating extra leaves took more iterations than I expected.

The catalogue itself uses a tag system for filtering — light requirements, water needs, pet safety, size category — but the semantic search often outperforms the filters. Someone searching for "low maintenance desk plant" gets better results from the vector search than from manually filtering by light:low + size:small + water:low. The embeddings understand intent in a way that faceted filters can't.

Storage is pgvector on Neon, with HNSW indexes for fast approximate nearest-neighbour lookup. The catalogue is small enough that exact search would work fine, but I built it with HNSW from the start because I use the same pattern across every project and wanted consistent infrastructure.

Why PatternMode

May 20, 2025

PatternMode started as a personal collection of UI patterns I kept referencing across projects. Dropdown menus with specific animation curves, card layouts with particular hover states, navigation patterns that solved mobile and desktop differently. I had screenshots scattered across Figma, bookmarks, and a markdown file that was getting unwieldy. The project was supposed to be simple: organise these patterns into a browsable library. The interesting problem turned out to be classification.

The taxonomy problem with design patterns is that categories overlap constantly. A card component is also a layout pattern. A navigation dropdown is an interaction pattern and a disclosure pattern and sometimes an accessibility pattern. Strict hierarchical categorisation -- the kind where each pattern lives in exactly one category -- forces decisions that feel arbitrary. Is a search-as-you-type input a "search pattern" or a "form pattern" or a "filtering pattern"? The answer depends on context, which means a single hierarchy will always feel wrong to someone looking for it from a different angle.

I tried three classification approaches before landing on one that worked. First, a strict hierarchy (navigation > dropdowns > mega-menu). Too rigid -- patterns that bridged categories had to live in one place arbitrarily. Second, a flat tag system with no hierarchy. Too loose -- 200 patterns with 15 tags each produced results that were technically correct but not useful for browsing. The third approach combined tags with weighted relevance: each pattern has multiple tags, but each tag carries a weight indicating how central that classification is. A mega-menu might be tagged "navigation" at weight 1.0, "disclosure" at 0.7, and "layout" at 0.4. Browsing by "navigation" surfaces it prominently. Browsing by "layout" still includes it, but further down.

The weighted tag system solved the browsing problem but created a curation problem: who decides the weights? I spent a weekend manually weighting 150 patterns before accepting that the weights were subjective and that was fine. The value isn't in perfectly objective classification -- it's in having a classification at all that reflects how I actually think about these patterns. When I'm looking for navigation solutions, I want navigation-primary patterns first. The weights encode editorial judgment, not taxonomy. That distinction turned out to be the whole insight: a pattern library is an editorial product, not an information architecture exercise.

Redesigning Nomad Studio

March 10, 2025

Nomad Studio is a brand identity studio whose client list includes Nike, Apple, and Spotify. The brief for their portfolio site was straightforward and terrifying: the website needs to match the craft of the work it showcases. When your clients judge every typographic detail, every transition, every whitespace decision -- the site itself becomes a piece of the portfolio.

The project went through two major iterations. The first was a ground-up rebuild focused on editorial layouts and full-bleed case studies. Every project page had bespoke layout options: grid configurations, text placement, image sequencing. The second iteration refined the system based on how the studio actually used it -- which layouts they gravitated toward, where they wanted more flexibility, where constraints actually helped them make better editorial choices. That feedback loop between building and observing real usage is where the best design decisions come from.

Typography drove most of the technical decisions. Variable font loading, precise optical sizing, careful attention to line lengths across breakpoints. The studio's designers notice when tracking is off by 10 units or when a heading breaks to a second line at the wrong word. I built responsive type scales that maintained their proportional relationships across viewports rather than just scaling linearly. Pull quotes, captions, body text, and display headings each had their own scale with breakpoint-specific adjustments. The goal was that the type always looked deliberately set, never just "responsive."

Sanity CMS gave the studio full editorial control over layouts without touching code. Each case study is composed from structured content blocks -- image grids, text columns, video embeds, pull quotes -- that the designers arrange themselves. The schema enforces enough structure to keep things consistent while leaving enough flexibility for the bespoke layouts that make each project page feel unique. The studio updates the site weekly with new work, and they've never needed a developer to make a content change. That's the real measure of a good CMS integration: the client forgets the developer exists.

Siteinspire Relaunch

February 10, 2025

Last week I relaunched the redesigned Siteinspire which was years overdue, having not been touched for about 10 years. This iteration is a like-for-like rebuild, but now serves as a baseline from which I can do so much more to promote the web's finest designers and developers.

Huge thanks to Index Studio who jump-started the new design effort... almost 4 years ago (!) and to Travis Ladue who helped me refine a logotype that hadn't been updated since 2009.

Leaving Mob

January 15, 2025

Bar one last handover doc, this week was the final week of working with Mob, the culmination of around 5 years of work, working with OMSE to turn their original Squarespace website into a subscription product that, 2 months ago, broke £200k MRR (and the latest figures are even more wild) and used by hundreds of thousands of users which is growing day by day.

Up until some time last year I was the only engineer, until Andrew Fairlie came and saved the day handling the challenges of a fast growing user base, and for a long time I was the UX/UI designer until Warren Challenger came along to help. And now there's finally a proper team in place on-site in their Shoreditch HQ, my work is done.

It has genuinely been a pleasure working with both Benjamin Lebus and Michael Sladden; the success of the platform has been incredible. I'm looking forward to seeing what happens next.

When I get round to it I'll write up a big post talking about the front- and back-end tech, the challenges; there's a lot of interesting stuff.

Building Arc

January 14, 2025

I've been using Claude Code for a while now and kept finding myself doing the same dance: think through a feature, break it into tasks, write tests first, implement, get it reviewed, ship. Every time. So I figured I'd just encode the whole thing into a plugin.

The first version was terrible. I tried to make it "flexible" and "configurable" which meant it did nothing well. The turning point was deciding to be opinionated. TDD isn't optional, it's mandatory. Reviews happen early, not at the end. Questions over commands.

That last one took me a while to figure out. My first reviewers were bossy—"Remove this caching layer" or "Refactor this into smaller functions." Turns out that's annoying and often wrong. The person building usually knows something the reviewer doesn't. Now they ask questions instead: "Do we need this caching layer in v1?" Same information, completely different energy. You can say "yes, because X" and move on.

I also learned that review at the end is useless. By that point you've already built the thing and any feedback is expensive to act on. Now every stage gets a quick sanity check before moving on. Catches the dumb stuff early when it's cheap to fix.

The plugin architecture itself taught me things. Claude Code has this concept of "skills" (instructions that get injected based on context) and "commands" (things you invoke explicitly). I kept conflating them. A skill is knowledge—how to do TDD, how to debug systematically. A command is an action—start a feature, run a review. Once I separated those cleanly, everything got simpler.

Context management is the whole game. LLMs have limited context windows, and if you fill them with garbage you get garbage out. So I ended up building dedicated agents for noisy tasks like running e2e tests. They do their thing in isolation and just report back results. Keeps the main conversation clean.

The weirdest lesson was about AI-generated code. It has a look. Inter font, purple gradients, white backgrounds, rounded corners on everything. I started calling it "slop." So I built a command specifically to clean it up—remove unnecessary comments, defensive checks that can't trigger, type escapes that shouldn't exist. It's absurd that this is necessary but here we are.

Git worktrees turned out to be essential. Working directly on main is asking for trouble. Now every feature gets its own worktree, tests have to pass before anything gets merged, and there's a proper cleanup process at the end. Boring infrastructure stuff, but it's the difference between "it works on my machine" and actually shipping.

I'm still iterating on it. Every project I use it on reveals something new. But the core insight hasn't changed: encode your process, make it opinionated, and let the machine handle the ceremony so you can focus on the interesting parts.

On Shipping

January 12, 2025

The hardest part of building products isn't writing code—it's deciding when something is ready. There's always one more feature, one more refinement, one more edge case.

I've found that the best way to ship is to define a clear scope upfront and stick to it ruthlessly. Everything else goes in a list for later. The list grows, but the product ships.

The Tensile Prototype

September 15, 2024

Before joining Materia full-time, I built Tensile -- a rapid prototype exploring what the next generation of the platform could look like. NFC scanning for physical material samples, side-by-side comparison tools, palette-based search, and AI-powered categorisation. The goal wasn't to build production software. It was to answer questions fast enough that the answers were still useful.

The cloud-first image architecture was a deliberate constraint from day one. Every image went straight to managed object storage -- no local filesystem, no temp directories, no cleanup scripts. The database stored URLs, not files. This sounds obvious, but it eliminated an entire category of problems: disk space management, deployment concerns about persistent storage, image serving performance. When you're iterating on prototypes weekly, the less infrastructure you manage, the more time you spend on the actual product questions.

Tensile used a Turborepo monorepo structure: a Next.js web app, a shared database package on Drizzle, a Crawlee-based scraper for ingesting product data, and a hosted storage integration. The scraper was the most complex piece -- handling rate limits, deduplication across product variants, and extracting structured data from pages that weren't designed to be scraped. Crawlee's intelligent crawling handled most of the retry and queue management, but the deduplication logic was custom. Materials come in dozens of variants (colours, finishes, sizes) and the system needed to understand which images represented the same base product versus genuinely different items.

The real output of Tensile wasn't the code -- it was the decisions it validated. Over six months at Materia, I built four prototypes. Two of them shipped to production in some form. The other two answered questions that saved weeks of building the wrong thing. NFC scanning worked but the hardware constraints made it impractical for the initial launch. The comparison tool shipped almost unchanged. Palette search evolved into the colour search system using OKLab perceptual colour space. AI categorisation proved the concept but needed significantly more training data for production accuracy. Each prototype took two to three weeks and replaced months of speculation with concrete evidence about what users actually needed.

Joining Materia

June 10, 2024

Joining Materia in 2024 meant walking into a Turborepo monorepo with 33 packages mid-transformation. The platform -- materialbank.com -- was evolving from a traditional product catalogue into something more intelligent: AI-powered search, visual discovery, recommendation engines. My first task was making those AI features feel native, not bolted on. The gap between "we added AI search" and "search just works better now" is enormous, and it's mostly a design problem, not a technical one.

The vector image search was the first real challenge. Users upload a photo or pick a material, and the system finds visually similar products across the entire catalogue. Getting embeddings right is the easy part. Making the results feel intuitive is harder -- users don't think in vector space. They think "something like this but warmer" or "similar texture, different colour." We built colour search using both HSL and OKLab perceptual colour space, because HSL alone produces results that are mathematically correct but visually wrong. OKLab models human colour perception, so "similar colours" actually means what a designer expects it to mean. The difference in result quality was immediately obvious.

The challenge of joining a complex codebase is that you need to ship quickly while learning the system's opinions. Every monorepo has implicit conventions that aren't documented anywhere -- naming patterns, state management preferences, how data flows between packages. I spent the first two weeks reading more code than writing it, tracing data from the API layer through tRPC routers into React components. The 33-package structure meant understanding not just what each package does, but why it's a separate package in the first place. Some boundaries were architectural. Others were historical. Knowing which is which matters when you're deciding where new code lives.

The thing about making AI features feel native is that the AI needs to disappear. Nobody should think "I'm using the AI search." They should think "search works well." That means the vector results blend seamlessly with keyword results, the colour picker feels like a natural filter rather than a separate mode, and the recommendations surface at exactly the right moment in the browse flow. We measured success not by whether people used the AI features, but by whether they noticed them at all.