# alexop.dev - Full Content > A Personal Blog from a simple web developer This file contains the full content of all posts for deep context. For a sitemap overview, see: https://alexop.dev/llms.txt ================================================================================ # BLOG POSTS ================================================================================ --- title: The Dirty Flag Pattern, Explained with Snake description: Stop redrawing what didn't change. Learn the dirty flag pattern with an interactive Snake game, then see where the same idea hides in Vue's computed. tags: ['game-dev', 'performance', 'typescript', 'patterns'] url: https://alexop.dev/posts/dirty-flag-pattern-snake --- # The Dirty Flag Pattern, Explained with Snake See all that green? Every green cell is a part of the screen the browser just repainted. The snake moves about 6 times a second, but the board below gets repainted 60 times a second. Now switch to **Dirty flag**. The board goes calm. Only the cells the snake actually touched flash, and about 90% of the frames paint nothing at all. Same game. Same picture. A fraction of the work. The trick is one of the simplest patterns in game development: the **dirty flag**. ## Where I found it I'm building a Factorio-style game in the browser. While working on it, I read the [Dirty Flag chapter](https://gameprogrammingpatterns.com/dirty-flag.html) from Robert Nystrom's *Game Programming Patterns*. The book is free online and really good. The chapter explains the pattern with a pirate ship and a scene graph full of matrix math. In this post I want to explain it in the simplest way I can: with the Snake game above. ## The problem: two loops, two speeds A Snake game in the browser has two loops: - The **game loop** moves the snake. A comfortable speed is one step every 150 ms, so about **7 moves per second**. - The **draw loop** runs on `requestAnimationFrame`. That's about **60 frames per second**. Notice the gap. The screen gets drawn 60 times a second, but the board only changes 7 times. ## ❌ The naive version: draw every frame Here's how most of us write our first Snake: ```ts // snake.ts setInterval(() => { moveSnake(); // 7× per second }, 150); function frame() { drawBoard(); // 60× per second: grid, snake, food, score requestAnimationFrame(frame); } frame(); ``` That's the **Draw every frame** mode in the demo. It works. But out of every 60 frames, about 53 paint the exact same picture as the frame before. That's almost 90% wasted work. On a laptop you won't notice. On a phone, the battery notices. ## ✅ The fix: a dirty flag Add one boolean. It answers a single question: **is the picture on screen out of date?** ```ts // snake.ts let dirty = true; // nothing is drawn yet, so we start out of date setInterval(() => { moveSnake(); dirty = true; // the board changed }, 150); function frame() { if (dirty) { drawBoard(); dirty = false; // the screen matches the game again } requestAnimationFrame(frame); } frame(); ``` That's the whole pattern. Nystrom puts it like this: - There's **primary data** that changes over time. Here, that's the snake and the food. - There's **derived data** that's expensive to produce. Here, that's the picture on the canvas. - The **dirty flag** tracks when the derived data is out of sync with the primary data. "Dirty" means out of date. "Clean" means you can reuse what you already have. The demo at the top goes one step further. Instead of one flag for the whole board, it keeps one dirty flag **per cell**. A move only touches three cells: the new head, the old head and the tail that got freed up. That's why only those cells flash. ### It only pays off when changes are rare Scroll back up and drag the speed slider. The faster the snake, the less the flag saves. At 1 move per second, almost every frame gets skipped. A dirty flag only pays off when the data changes **less often** than you read it. If the board changed on every frame, the flag would never skip anything. There's also a free bonus. If the snake moves, eats and the score goes up in the same tick, all three set `dirty = true`. You still draw **once**, not three times. ## Many changes, one recompute Snake only has one thing to redraw. Nystrom's pirate ship is where the pattern gets interesting, because the data is a tree. A ship carries a crow's nest. A pirate stands in the nest. A parrot sits on his shoulder. Each one stores its position relative to its parent, and the world position is the parent's world position combined with its own. So when the ship moves, everything on it is out of date, even though the parrot itself didn't do anything. This is Robert Nystrom's example from [Game Programming Patterns](https://gameprogrammingpatterns.com/dirty-flag.html). I drew my own ship and added counters so you can see the work. Try this: 1. Pick **Eager**, press **Move all four**. The ship recomputes itself and all its children, then the nest does the same, and so on. The parrot ends up at 4, the total at 10. 2. Press **Reset counters**, pick **Dirty flag**, press **Move all four**. Four dots turn red and nothing is recomputed. The picture is still old. Press **Render** and every node counts 1, the total is 4. 3. Reset, then press only **Move ship**, then **Render**. The total is still 4. Only the ship got flagged, but the render passes the dirt down, so the nest, pirate and parrot recompute too. That's correct. They really were out of date. ## The trap: forgetting to set the flag This is the bug you'll actually ship. Say you add a power-up where the food teleports every five seconds: ```ts // snake.ts setInterval(() => { food = randomFreeCell(); // forgot: dirty = true }, 5000); ``` Nothing crashes. No error in the console. The food moved in the game state, but the screen still shows the old spot until the snake happens to move. The player steers toward food that isn't there. Try it in the demo at the top: switch to **Forgot the flag**, pause the snake and click **Teleport food**. The old apple stays on screen, the new one is invisible, and the stats turn red. That's what makes the bug nasty. A dirty flag is a cache, and the hard part of any cache is invalidation. Miss one place and you get a quietly wrong screen. ## ✅ The better fix: one door for every change Nystrom's advice is to put all changes behind a narrow API. If the only way to change the board also sets the flag, forgetting becomes impossible: ```ts // snake.ts let dirty = true; function setFood(pos: Cell) { food = pos; dirty = true; } function setSnake(next: Cell[]) { snake = next; dirty = true; } ``` Game code never writes `food = ...` directly again. It calls `setFood(...)`. I like this much more than the plain flag. Remembering things is a bad strategy, for humans and for coding agents. ## Why a number and not a boolean Snake uses `true`/`false`. That works as long as only one thing reads the flag. Now add a minimap next to the board. Both the board and the minimap check `dirty`. The board redraws first and sets `dirty = false`. The minimap looks next, sees a clean flag and never updates. The fix is a counter instead of a boolean: ```ts // snake.ts let version = 0; function setFood(pos: Cell) { food = pos; version++; // never reset, only counts up } let boardSeen = -1; let minimapSeen = -1; function frame() { if (boardSeen !== version) { drawBoard(); boardSeen = version; } if (minimapSeen !== version) { drawMinimap(); minimapSeen = version; } requestAnimationFrame(frame); } ``` Each reader remembers the last number it saw. Nobody clears anything, so readers can't steal the update from each other. **Rule of thumb:** one reader, use a boolean. Several readers, use a counter. ## You already use this every day: `computed` If you write Vue, you've used dirty flags for years without seeing them. ```ts const items = ref([]); const total = computed(() => items.value.reduce((sum, i) => sum + i.price, 0)); ``` Changing `items` doesn't recompute `total`. It only marks `total` as dirty. The reduce runs the next time someone reads `total.value`, and only once, no matter how many times `items` changed in between. That's exactly the Snake draw loop. Vue 3.5 has a `DIRTY` bit on every computed, plus version counters next to it. It's the boolean and the counter from the section above, in one place. [alien-signals](https://github.com/stackblitz/alien-signals), the signal library behind Vue 3.6's reactivity rewrite, takes it one step further with **two** flags: - **Dirty:** a value I read definitely changed. Recompute. - **Pending:** something further up the chain changed. Maybe I'm out of date, maybe not. Remember Nystrom's pirate ship, where a parent moving makes every child out of date? Pending is that idea for a graph of signals. Let's look at the actual code. The snippets are from alien-signals v3.2.0 (MIT), shortened, with my comments. ### The flags are bits All flags live in one number. Each flag is one bit: ```ts // src/system.ts export const enum ReactiveFlags { None = 0, Mutable = 1, Watching = 2, RecursedCheck = 4, Recursed = 8, Dirty = 16, Pending = 32, } ``` That's the "dirty bit" from the book, literally. `flags & ReactiveFlags.Dirty` checks the bit, `flags | ReactiveFlags.Pending` sets it, `flags & ~ReactiveFlags.Pending` clears it. One number per node, no extra objects. ### Writing a signal: mark, don't compute When you write a new value, the signal marks **itself** Dirty and hands off to `propagate`: ```ts // src/index.ts — signalOper, the setter branch if (this.pendingValue !== (this.pendingValue = value[0])) { this.flags = ReactiveFlags.Mutable | ReactiveFlags.Dirty; const subs = this.subs; if (subs !== undefined) { propagate(subs, !!runDepth); // walk down to everyone who reads me // ... } } ``` `propagate` walks down the graph, and every subscriber that's still clean only gets the **Pending** bit: ```ts // src/system.ts — propagate if (!(flags & (ReactiveFlags.RecursedCheck | ReactiveFlags.Recursed | ReactiveFlags.Dirty | ReactiveFlags.Pending))) { sub.flags = flags | ReactiveFlags.Pending; // "maybe out of date" // ... } ``` Notice what's missing: no getter runs here. Writing a signal is just setting bits. Same as `dirty = true` in Snake. ### Reading a computed: check, then maybe recompute The work happens when someone **reads** a computed: ```ts // src/index.ts — computedOper const flags = this.flags; if ( flags & ReactiveFlags.Dirty // definitely stale: recompute || ( flags & ReactiveFlags.Pending // maybe stale: && ( checkDirty(this.deps!, this) // ask my sources if they really changed || (this.flags = flags & ~ReactiveFlags.Pending, false) // they didn't: just clear Pending ) ) ) { updateComputed(this); // run the getter, once } // ... return this.value!; ``` That's the `if (dirty)` from the Snake draw loop, with one extra step. A Pending computed first runs [`checkDirty`](https://github.com/stackblitz/alien-signals/blob/c00e63969bf261fc5dce31fae70cb9a90912b06e/src/system.ts#L176), which walks up its sources. If none of them actually produced a new value, it clears the bit and returns the cached value. The getter never runs. Here's when that pays off: ```ts const count = signal(1); const isOdd = computed(() => count() % 2 === 1); const label = computed(() => (isOdd() ? "odd" : "even")); count(3); // isOdd and label become Pending label(); // isOdd recomputes: still true. label stays cached. ``` With a single Dirty bit, `label` would have to recompute too. The Pending bit lets the library stop as soon as a value turns out unchanged. Here's that example with the flags visible. I rebuilt the idea as a tiny TypeScript model so you can poke at it. It's a re-implementation, not the library itself, but I checked that its run counts match alien-signals 3.2.0. Try this: 1. Press **count + 2**. `count` goes from 1 to 3 and both computeds turn Pending. Nothing re-ran. Press **Read label**: `isOdd` runs again and is still `true`, so `label` stays cached. 2. Press **count + 1**, then **Read label**. Now `isOdd` flips to `false`, so `label` has to run too. 3. Press **count + 1** a few times without reading, then **Read label** once. `isOdd` runs once, no matter how many writes you made. Source links: [`ReactiveFlags`](https://github.com/stackblitz/alien-signals/blob/c00e63969bf261fc5dce31fae70cb9a90912b06e/src/system.ts#L24), [`propagate`](https://github.com/stackblitz/alien-signals/blob/c00e63969bf261fc5dce31fae70cb9a90912b06e/src/system.ts#L118), [`computedOper`](https://github.com/stackblitz/alien-signals/blob/c00e63969bf261fc5dce31fae70cb9a90912b06e/src/index.ts#L334), [`signalOper`](https://github.com/stackblitz/alien-signals/blob/c00e63969bf261fc5dce31fae70cb9a90912b06e/src/index.ts#L369). So the pattern from a 2014 game programming book is sitting in the core of a modern frontend framework. ## When not to use a dirty flag The pattern adds code and a new way to have bugs. So don't reach for it by default. - **The data changes as often as you read it.** Think of a game clock that ticks every frame. It's always dirty, so a flag would never skip anything. - **You can update the result directly.** Nystrom's example is the total weight of loot on a ship. Don't recount everything when it's needed. Keep a running total and add or subtract as items come and go. In Snake, the score works the same way: `score++` on every apple, no recount. - **It's already fast enough.** Honestly, drawing a 16×11 Snake board 60 times a second is fine. I used Snake because it's easy to see, not because it needs the optimization. The pattern earns its keep when the derived work is actually slow, like painting a big tile map or rebuilding a graph of conveyor belts. Put `console.count("draw")` at the top of whatever expensive function you think is cached. Stand still, then change one thing. If the counter keeps climbing while nothing happens, you've found your wasted work. ## Summary A dirty flag is one bit, or one number, that says "this result is out of date". Set it when the source changes. Check it when you need the result. Redo the work only when it's set. The pattern itself takes five minutes to learn. The discipline is the hard part: every change has to set the flag. So don't rely on discipline. Route every change through one function and let the code remember for you. If you want to go deeper, read the [Dirty Flag chapter](https://gameprogrammingpatterns.com/dirty-flag.html) in *Game Programming Patterns*. It covers the parts I skipped, like when to clean the flag and how fine-grained your tracking should be. --- --- title: pstack's Playbooks, Explained as Factorio Factories description: pstack's poteto-mode picks one of 23 playbooks for every task. I built ten of them as interactive Factorio-style factories, so you can see why a bug fix, a refactoring and a hillclimb each need a differently shaped factory. tags: ['claude-code', 'ai-agents', 'agentic-workflows'] url: https://alexop.dev/posts/pstack-playbooks-as-factories --- # pstack's Playbooks, Explained as Factorio Factories If you have played Factorio, you know that every factory has a shape. A smelter line is long and straight. A circuit factory has loops. A train station has buffers. You can tell what a factory makes just by looking at it from above. I've been using [pstack](https://github.com/cursor/plugins/tree/main/pstack), a plugin by [poteto](https://x.com/poteto) that turns a coding agent into something closer to a careful engineer. After a while I noticed the same thing: every pstack playbook has a shape. A bug fix has a loop back to the start. A refactoring has a gate that throws work away. A hillclimb is one big loop. So I built them as factories. ## What pstack is pstack is a set of skills. You don't have to learn most of them, because one skill, `/poteto-mode`, runs the rest for you. You describe the task and how you'll know it's done. poteto-mode matches the task to one of 23 playbooks, copies the playbook's steps into a todo list, and calls the other skills (`how`, `architect`, `arena`, `interrogate`, and so on) as each step needs them. The idea behind it is simple: AI writes a lot of code fast, and most of it is slop. pstack doesn't try to make the agent faster. It makes the agent prove its work. pstack was built for Cursor. I use Claude Code, so I ported it: [pstack-claude](https://github.com/alexanderop/pstack-claude). The skills and playbooks are poteto's. The port only swaps the Cursor-specific parts. ## How to read the factories Every playbook below is one factory. Work flows from left to right. The caption under each map says what to watch. The list under it is the real playbook: click a step and the factory pauses and lights up the machines that do it. Click it again to resume. The numbers are made up. The shapes are not: each one follows the steps in poteto's playbook files. ## poteto-mode: the router This is the part you actually type. You never pick a playbook yourself. The same command with different words lands in a different factory: | What you type after `/pstack:poteto-mode` | Playbook | | --- | --- | | users get two notifications after a retry. repro first, then fix and verify. | Bug fix | | investigate why background jobs time out every few hours. don't change any code yet. | Investigation | | move parsing into one module, zero behavior change. | Refactoring | | startup takes 1.8s on this fixture. trace it, show me before and after. | Perf | In Cursor the command is `/poteto-mode`. In Claude Code, with my port, it's `/pstack:poteto-mode`. Five shapes of task get their own splitter here. The real list has 23 playbooks. When none of them fits, the task doesn't default to Feature. It falls through to `figure-it-out`, a skill that designs a bespoke playbook for that one task. ## Investigation: a factory without a silo What you type: ```text /pstack:poteto-mode investigate why background jobs time out every few hours. give me what we know and your best hypotheses. don't change any code yet. ``` A read-only question ("why does X happen", "don't change any code") is what routes it to Investigation. There is no silo, only a blueprint marked "No PR". I like that this is a separate playbook, because "explain this to me" and "change this" are different jobs. An agent that mixes them up starts editing files you only asked about. When the answer turns out to need a code change, the playbook hands it back to be re-routed to Bug fix or Feature. ## Bug fix: verify on the same machine What you type: ```text /pstack:poteto-mode users get two notifications after a retry. repro first, then fix and verify. ``` A reported defect, plus "repro first", routes it to Bug fix. Nothing gets past the red lab until the bug actually fires. If it won't reproduce, the agent adds logging until it does. It doesn't ask you to reproduce it. The long green belt is the point. The fix only counts when the original repro passes on the same surface. A unit test that passes somewhere else doesn't count. The architect only runs when the fix crosses a function boundary. A one-line fix inside one function skips it. Last detail: the commit machine drops two items. First a red test, then the fix. In git history the failing test lands before the fix, so anyone can check out the commit before and watch it fail. ## Feature: an arena What you type: ```text /pstack:poteto-mode add a --json flag to this command. text output stays byte-identical. verify both. ``` New behavior is what routes it to Feature. When there are several valid ways to build something, pstack doesn't let one agent pick. It runs an `arena`: the same brief goes to three runners, each surfaces its own shape, and a judge takes one as the base and grafts the best parts of the other two into it. When there's one obvious shape, the arena is skipped. Designs that are contested (orange dot) take a detour through `interrogate`, where reviewers try to break the change. More on both skills below. ## Refactoring: a gate that throws work away What you type: ```text /pstack:poteto-mode move parsing into one module, zero behavior change. record the current output first and prove it's unchanged after. ``` "Zero behavior change" is what routes it to Refactoring. A refactoring must not change what the code does, so the first machine is a pin: a test that records what the old module does today. Two things stand out: 1. **The recycler comes before the assembler.** pstack deletes dead code, one-caller wrappers and old validators *before* it builds the new shape. Subtract before you add. 2. **There are two ways to fail.** A broken pin goes back for another small step. A change that keeps the output but doesn't read easier gets reverted. A refactoring that doesn't lower reader load has no reason to exist. ## Perf: seven mantras on one belt A perf fix starts with a baseline, and that number gets vetted before anyone trusts it (`benchmark-checklist`). Then the slow path tries the seven performance mantras in order, cheapest first: 1. Don't do it. 2. Do it, but don't do it again. 3. Do it less. 4. Do it later. 5. Do it when they're not looking. 6. Do it concurrently. 7. Do it cheaper. What you type: ```text /pstack:poteto-mode startup takes 1.8s on this fixture. trace it, fix the measured cause, show me before and after. ``` A measured slowness is what routes it to Perf. The playbook has a stop rule: when an earlier mantra meets the target, stop. That's the bypass belt. Nobody reaches for "do it concurrently" when "don't do it" already solved the problem. Then the green lab measures again, and the before and after go into the PR. No number, no PR. ## Hillclimb: one big loop What you type: ```text /pstack:poteto-mode hillclimb p95 search latency. at least 50% better than baseline and at least 10 attempts. keep a decision log. ``` A metric, a target and a floor on attempts is the shape Hillclimb asks for. Hillclimb is for pushing one metric up over many attempts. The stop rule has two halves on purpose. Without the floor on attempts, a lucky first try ends the run. Every lap writes one row to `decision.tsv`, so you can read the whole run the next morning. A win only counts if the regression tests stay green too. After three losses in a row, watch for "plateau → pivot category". That's the playbook telling the agent not to stop at the first plateau, but to try a different kind of idea. ## Prototype: only blueprints What you type: ```text /pstack:poteto-mode prototype a few options for the new dropdown menu. take screenshots for me to compare. ``` "Prototype" is one of the words that routes straight to this playbook. A prototype exists to answer one question, like "which layout?". No question, no prototype. The variants are throwaway code in a scratch folder: no framework, no tests. All three sit behind one switcher, so the agent can screenshot and compare them. The output is a decision, not code. ## Shipping: stop at the first red What you type: ```text /pstack:poteto-mode land the stack. ``` Asking to land a stack routes it to Shipping. Shipping is for a stack of PRs that depend on each other. Every PR gets its own verifier: an agent that didn't write the code. A green CI run doesn't count as a verdict. The ceiling is the rule I find most useful: a verified PR on top of an unverified one isn't safe to land, however green it looks. ## Orchestrate: a rolling window What you type: ```text /pstack:poteto-mode orchestrate the store migration. own it until every package is converted and merged. i'll check in twice a day. ``` A project you hand over for days ("own it until…") is what routes it to Orchestrate. The biggest playbook. One coordinator chat runs a project that takes days and many PRs. It starts with a goal you can count, like "16 units merged". The coordinator never writes code. Its product is the brief, because a worker can't ask it a question. The pilot tests the brief and the verify recipe while a mistake costs one agent instead of fifty. After that, a rolling window beats blocking batches: a batch waits for its slowest worker, a window refills each worker the moment it's done. Landing runs the whole time. It's never a final phase. ## The skills inside the playbooks The playbooks call smaller skills as single machines. Here are five of them opened up. ### interrogate This is what's inside the chemical plant in the Feature factory. One reviewer per configured model reads the same diff with the same prompt and rubric. The adversarial signal comes from the reviewers being different, not from personas. A finding two reviewers raise on their own is the strongest signal. The lead then sorts every finding into four bins, and nothing gets applied automatically. One honest caveat: upstream pairs a Claude model with a Grok model. In my Claude Code port, all subagents run on Claude models, so the reviewers differ by separate context, not by model family. ```text /pstack:interrogate the whole branch, but skeptically. no nitpicks unless it's an actual bug or regression. ``` ### arena This is the fan of A/B/C builders in the Feature factory. The prompt is the contract, and a short rubric that only the picker sees decides the base. The base is the candidate a future maintainer can extend most easily. If all candidates converge, that's agreement, and nothing gets grafted. If they wildly diverge, the frame was too vague, so the task goes back to be re-framed. ```text /pstack:arena this, 5 candidates. the cache key format is expensive to change later. ``` ### architect This is the drafting table in the Bug fix and Feature factories. It designs before code: at least two structurally different designs, screened against a list of red flags, then one sketch to implement against. When implementation keeps hitting the same workaround, the sketch gets thrown out instead of patched. ```text /pstack:architect design the import pipeline before writing any code. i care most about how callers use it. ``` ### swarm Swarm fans out workers, one brief each, and returns one report. A result without its evidence doesn't count: that worker gets respawned once, and after a second miss the slice is a gap. A gap is not a pass. ```text /pstack:swarm check every package under packages/ against its check.sh. one worker per package. one report. ``` ### blast-radius Listing callers is not the job, the agent can grep those in a second. Blast-radius looks for the one fact a change is safe because of, and then proves it by running real code. The staircase is the skill's own confidence ladder. A writeup that sounds right is worth nothing until the fact reaches step 4. ```text /pstack:blast-radius what could this change to the session cache break? ``` ## What all the factories have in common Look at them again. Every factory has a machine whose only job is to check, and every checker can send work back or stop the line: - Bug fix: the repro lab. - Feature: the judge and interrogate. - Refactoring: the pin gate. - Perf and hillclimb: the measuring labs. - Shipping: the verifiers and the ceiling. None of the playbooks make the assembler faster. The coding part was never the slow part. In my [Delivery Factory](/lab/delivery-factory/), AI made dev fast and the work piled up in review and QA. pstack puts the review and QA inside the agent's own factory, so less broken work reaches you. ## Try it In Cursor: ```text /add-plugin pstack ``` In Claude Code, with my port: ```text /plugin install pstack --marketplace alexanderop/pstack-claude ``` Then start every non-trivial task with `/pstack:poteto-mode` (or `/poteto-mode` in Cursor), and say what done looks like. --- --- title: Run Only the Playwright Tests Your Change Can Affect description: Feature-based architecture can shorten your E2E feedback loop. A working Nuxt example with dependency-aware test selection and GitHub Actions. tags: ['testing', 'playwright', 'architecture', 'github-actions', 'ai'] url: https://alexop.dev/posts/run-only-affected-playwright-tests --- # Run Only the Playwright Tests Your Change Can Affect With AI writing more of our code, I keep coming back to the testing discussion. Are unit tests still useful? How much confidence do they give us when an agent writes both the implementation and the tests? I still want unit tests for business rules. But I also want to open the application, add something to a cart, and complete a checkout. An end-to-end test checks whether those pieces work together, through the interface someone uses. The problem is the waiting. If I change checkout validation, do I need to run every search scenario before getting feedback? Last week I built [feature-shop](https://github.com/alexanderop/feature-shop), a small Nuxt shop, to try a different approach: **run the E2E tests for the changed feature and the features that depend on it.** ## Feature folders give us a starting point In the shop, search, product details, cart, and checkout each have their own feature folder. The browser tests follow the same structure. ```text app/features/ search/ product-detail/ cart/ checkout/ tests/e2e/features/ search/ product-detail/ cart/ checkout/ ``` That makes ownership easier to describe. A change in `app/features/checkout/` belongs to checkout. The selector can find the scenarios tagged with `@checkout` and run them. Each scenario also has a stable ID. This one checks the result a customer cares about: ```gherkin @checkout Feature: Simulated checkout @id:checkout-success Scenario: Successful checkout clears the cart Given my cart contains 1 backpacks When I open checkout And I enter valid customer details And I choose an approved payment And I place my order Then my order is confirmed And my receipt total is "40.00" When I open my cart Then my cart is empty ``` I use Playwright-BDD to connect these sentences to Playwright fixtures and page objects. You can use the same selection idea with plain Playwright tests and tags. ```diagram { "version": 1, "type": "flow", "title": "From a code change to browser tests", "description": "Compare Git revisions, map changed files to features, include dependent features, then select scenarios by their coverage tags.", "nodes": [ { "id": "diff", "label": "Changed files" }, { "id": "owners", "label": "Features that own those files" }, { "id": "consumers", "label": "Include dependent features" }, { "id": "tests", "label": "Run matching E2E scenarios" } ], "edges": [ { "from": "diff", "to": "owners" }, { "from": "owners", "to": "consumers" }, { "from": "consumers", "to": "tests" } ] } ``` ## A cart change can break checkout Folder names alone aren't enough. Checkout uses the cart. Product details let you add an item to it. If I change cart behavior, I need to test those consumers too. In [`tools/impact.ts`](https://github.com/alexanderop/feature-shop/blob/2bb25da27c6481e52b8e9f19823ae4659b392453/tools/impact.ts), I describe the file ownership and dependencies. The selector follows consumers, including indirect ones, until it has the affected set. ```diagram { "version": 1, "type": "comparison", "title": "The scope depends on what changed", "description": "Checkout changes select checkout coverage. Cart changes also select consumers. Shared or unknown changes select the full suite. Counts include the purchase journey.", "columns": [ { "label": "Checkout change", "items": ["Checkout scenarios", "Purchase journey", "4 of 14 tests"] }, { "label": "Cart change", "items": ["Cart + product details + checkout", "Purchase journey", "10 of 14 tests"] }, { "label": "Shared CSS change", "items": ["All feature scenarios", "Purchase journey", "14 of 14 tests"] } ] } ``` The purchase journey crosses feature boundaries, so I tag it with all the features it covers. A checkout change still runs that complete journey. Shared styles, dependencies, test infrastructure, and unknown files trigger the full suite. So does an unavailable Git comparison. Documentation-only changes select no browser scenarios, but CI still runs the static checks and unit tests. This depends on keeping the ownership map and scenario tags accurate. A feature folder doesn't prove isolation. Review dependencies when you add imports, shared state, or API interactions. ## What happened in GitHub Actions I opened four small pull requests against the same base commit. Each changed one application file. | Change | Browser tests executed | Result | | --- | --- | --- | | Checkout view | 4 / 14 | Passed | | Search view | 5 / 14 | Passed | | Cart view | 10 / 14 | Passed | | Shared CSS | 14 / 14 | Passed | The [verification record](https://github.com/alexanderop/feature-shop/blob/2bb25da27c6481e52b8e9f19823ae4659b392453/docs/github-verification.md) links to each PR and Actions run. It records comparisons between selected scenario IDs and the tests Playwright executed. These are test counts, not a speed benchmark. Installing dependencies, building the application, and starting the browser still cost time. ## Try the same setup Use Node 24 and pnpm 10. These commands check out the published version behind this post, including its tests and workflow: ```bash git clone https://github.com/alexanderop/feature-shop.git cd feature-shop git checkout -b try-affected-tests 2bb25da27c6481e52b8e9f19823ae4659b392453 pnpm install --frozen-lockfile pnpm exec playwright install chromium pnpm check pnpm verify run --all ``` Leave port `3401` free. The runner builds and starts the shop for you. On Linux, use `pnpm exec playwright install --with-deps chromium` to install browser system dependencies too. Edit `app/features/checkout/CheckoutView.vue`, then inspect the selection before running it: ```bash pnpm verify plan --affected pnpm verify run --affected ``` Expect the three checkout scenarios and the purchase journey. The default comparison includes uncommitted changes against `HEAD`. After committing your edits, use `--base main` to include your branch changes. For GitHub Actions, fork the repository, enable Actions if GitHub prompts you, and open a PR **against your fork's main branch**. The existing [complete workflow](https://github.com/alexanderop/feature-shop/blob/2bb25da27c6481e52b8e9f19823ae4659b392453/.github/workflows/verify.yml) contains dependency installation, checks, the production build, and report uploads. Its selection step is: ```yaml # Excerpt from .github/workflows/verify.yml - name: Run affected scenarios if: github.event_name == 'pull_request' env: VERIFY_SKIP_BUILD: '1' VERIFY_BASE: ${{ github.event.pull_request.base.sha }} VERIFY_HEAD: ${{ github.event.pull_request.head.sha }} run: pnpm --silent verify run --affected --base "$VERIFY_BASE" --head "$VERIFY_HEAD" --json ``` `pnpm verify` is this project's CLI, not a built-in Playwright command. It compares changes from the Git merge base, selects scenario IDs, and passes them to [Playwright's `--grep` filter](https://playwright.dev/docs/test-cli#reference). The workflow fetches Git history with `fetch-depth: 0` and tests the PR head commit. It runs all scenarios on main, on a weekly schedule, and on manual runs. To adapt this to your own app, copy the selector and runner with their supporting files in `tools/`, then map your paths, dependencies, and scenarios. Adapt the Playwright configuration and workflow to your application's start command. Copying the YAML excerpt alone won't create that setup. ## Keep checking the selection Set the repository Actions variable `SHADOW_FULL_SUITE=true` while evaluating this approach. Successful selective runs will then continue into a full run, so you can look for failures outside the selected tests. In this published workflow, a failed selective step stops that later step. A full run on main can catch missed dependencies after merging. It cannot protect the PR before merging. Keep the full pre-merge run if you need that assurance. This fits how I think about a modern quality pipeline: give yourself and your agent useful feedback at a cost you can afford. I've covered what a passing browser test should prove and where to put code so you can test business rules separately in earlier posts. I still want those unit tests. I also want browser tests that tell me whether someone can complete a purchase. With clear feature boundaries, I can run the relevant journeys while working and keep broader regression checks in the pipeline. --- --- title: How I made exercise icons with Codex and ChatGPT description: I used ChatGPT and Codex image generation to create matching exercise icons for my workout app. See the reference image and before-and-after screenshots. tags: ['ai', 'codex', 'build-in-public'] url: https://alexop.dev/posts/building-workout-app-ai-exercise-icons --- # How I made exercise icons with Codex and ChatGPT I've always wanted my own workout tracking app. Something I can use at the gym and change whenever I don't like how it works. Last weekend I started building it with AI, using Codex to do the implementation while I decide what I want and review the result. I think this could become a small series about the things I learn along the way. The first one is about exercise icons, because that part surprised me. A workout tracker needs exercises. And once you have a long list of exercises, you want pictures to help you recognize them. I wanted to see the bench or machine next to the name. I started with SVG icons. They worked, but I didn't like the look. I wanted something closer to the equipment I see at the gym.

Without custom artwork

With generated artwork

Real app screenshots, with the same exercises and layout. I reconstructed the comparison by switching off the artwork in a local preview, which shows the app's generic SVG fallback. Tap either image to enlarge it.
So I took a screenshot of one of the exercises from my gym and asked ChatGPT what it would look like as an icon. I really liked the result. It gave me a style I wanted to use for the rest of the app.
The reference icon I gave Codex to match. This is the generated illustration, not the original gym screenshot.
I gave that image to Codex and asked it to use the image generation tool for bench press, squat, deadlift, and a few other exercises. I also asked for a preview in Histoire, the component playground I use for the app, so I could check the images before adding them. The images use black equipment, silver details, a three-quarter view, and a transparent background. Keeping the reference and those details the same helped them look like they belonged together.
Bench press and lat pulldown, generated from the same visual direction.
Once I liked the first batch, I asked for 20 more. Then another 20. After those batches, I had 46 illustrations covering 51 exercises. Some exercises share the same equipment image. The catalog has 82 exercises, so there was still more artwork to add. What I like about this is how much work I would have had to do before. I would have drawn the images myself, paid someone to make a matching set, or searched for an existing library and accepted its style and gaps. For a personal workout app, that would have been a lot of time or money. Now I can find a look I like, use it as a reference, and ask for more images as I add exercises. I still have to review the results, but creating my own artwork feels like a reasonable part of a weekend project. I'm already thinking about trying short generated videos to explain the movements. I haven't done that yet. These icons show equipment, not exercise technique. But being able to even consider that for my own little workout app is crazy. --- --- title: Hexagonal Architecture in Vue: Where to Put Your Code and Why description: Learn what belongs in Vue components, composables, the application core, and adapters. Use ports and adapters to make rules easier to test and changes easier to contain. tags: ['vue', 'architecture', 'typescript', 'testing'] url: https://alexop.dev/posts/hexagonal-architecture-vue --- # Hexagonal Architecture in Vue: Where to Put Your Code and Why You add a rule to a Vue application. It could go in the component's ` ``` The component does not decide whether “up” is legal. The core checks that rule so the keyboard adapter and a test get the same answer. A disabled button can help the player, but it should not be the only place that protects an application rule. ### Composables: connect Vue to the application `useSnakeGame()` connects snapshots to Vue reactivity, forwards user actions, and owns listener cleanup. It also derives the primary button label from the game status. Composables are part of the Vue adapter in this design. The folder name does not introduce another architectural layer. ### The core: rules and application operations The core contains both small rule functions and the operations that coordinate them. In Hex Snake, `rules.ts` answers questions such as whether a position lies inside the board. `createSnakeGame.ts` owns the game state and exposes operations such as `turn()` and `tick()`. For a larger feature, you could split core code into `domain/` for rules and `application/` for operations. This example keeps both in `game/`. Extra folders are useful only when they help you navigate the code. ### Adapters: technology-specific work The keyboard adapter translates `ArrowUp` into the application's `up` direction. The timer calls `tick()` at an interval. The random adapter supplies an integer through `RandomSource`. An adapter may contain substantial technical logic: removing event listeners or sampling random integers without bias. Keep application policy out of it. The random adapter supplies a number; the core decides which cells can contain food. Put a port around a capability the application needs; you do not need an interface for each helper function. ### Start with a small folder structure These are the actual files in Hex Snake. Their responsibilities matter more than their names: In a feature-based application, you can place this whole structure under `src/features/snake/`. Other features can keep their current organization. You do not need an application-wide rewrite to introduce one boundary. ## Ports describe what can cross the boundary A driving port exposes application operations to callers. A driven port describes a capability the application needs from outside. Both belong to the core because they use the application's vocabulary. The two interfaces in `src/game/ports.ts` describe what a driver can ask the game to do and what the game needs from randomness: ```ts export interface RandomSource { nextInt(maxExclusive: number): number } export interface SnakeGame { start(): void turn(direction: Direction): void tick(): void pause(): void resume(): void getSnapshot(): GameSnapshot subscribe(listener: (snapshot: GameSnapshot) => void): () => void } ``` `turn()` queues a legal direction. `tick()` advances the application by one move. The browser decides how often to call `tick()`. A test decides which call happens next. `GameSnapshot` describes the result: status, snake positions, direction, pending turn, food, score, and board dimensions. The core clones and freezes snapshots so callers cannot mutate the game through a returned object. Vue receives state it can render and sends commands when the player acts. ## Supply randomness through a driven port The core creates a list of free cells, asks for an index, and validates the adapter's answer: ```ts function pickCell(cells: readonly Position[], random: RandomSource): Position { const index = random.nextInt(cells.length) if (!Number.isInteger(index) || index < 0 || index >= cells.length) { throw new RangeError(`RandomSource returned ${index} for range 0..${cells.length - 1}`) } return clonePosition(cells[index]!) } ``` The production adapter uses browser Web Crypto. Tests can return a fixed index or use a seeded generator. None of those implementations needs to know the scoring rules. The core also handles a full board before asking for randomness. Eating the last free cell changes the status to `won`; requesting an index from an empty list would violate the port's contract. There is no database in this example because Snake has no persistence requirement. Randomness gives us a useful driven port without adding an unrelated feature. ## Connect the application to Vue The production entry point selects the dependencies: ```ts // src/main.ts import './style.css' createApp(App, { createGame: () => createSnakeGame({ width: 12, height: 12 }, browserRandom), }).mount('#app') ``` This is the **composition root**: the place where we choose concrete implementations and connect them. `App` passes the factory to `useSnakeGame`. Restart uses the factory again to create a fresh application instance. Inside the composable, Vue holds the latest snapshot in a `shallowRef`. The subscription replaces it when the game changes. This excerpt shows the connection: ```ts const snapshot = shallowRef() const ticker = createBrowserTicker(145) let game: SnakeGame let unsubscribe = () => {} function connect(nextGame: SnakeGame) { unsubscribe() game = nextGame unsubscribe = game.subscribe((value) => { snapshot.value = value if (value.status === 'running') ticker.start(() => game.tick()) else ticker.stop() }) } ``` `shallowRef` fits because the application replaces immutable snapshots. Vue has no nested game state to edit. The composable registers keyboard listeners and pauses the game when the tab becomes hidden. On unmount, it unsubscribes from the game, removes the browser listeners, and stops the timer. These resources share the lifetime of the mounted UI. ## Follow one action from a Vue button to the core
Two stages of runtime behavior. First, a Vue component emits turn up, the composable calls game.turn, and the core checks the rules and queues the accepted turn without moving the head. Later, a timer or test calls game.tick; the core moves the snake, checks collisions, and publishes a snapshot. The Vue adapter updates a shallowRef and renders the board.
Runtime calls and updates: an accepted turn queues input; a later tick moves the snake. The composable also receives the queued-turn snapshot before movement. AI-generated conceptual diagram.
The full path for an on-screen turn is: 1. `GameControls.vue` emits `turn` with the direction `up`. 2. `App.vue` forwards the event to `useSnakeGame().turn()`. 3. The composable calls `game.turn('up')` and focuses the board. 4. The core checks the current status, pending input, and reversal rule. It queues an accepted turn and notifies subscribers. 5. The composable replaces its snapshot ref. Vue renders the updated state. The head moves on the next `tick()`. In the production app, the browser timer triggers that call. In a test, you call it yourself. ## Try the boundary one tick at a time This playground uses a copy of the project's unchanged core with a different Vue adapter. Press **Step** once: the snake eats the food at `(3, 2)`, and the score becomes `1`. Next, choose **up**. The head stays in place while `pendingTurn` changes. Press **Step** again to consume that turn. **Reset** restores the same starting conditions. **Play** uses a timer to call the same `tick()` method. Try **left** immediately after Reset. The snake starts facing right, so the core rejects the reversal. The adapter can request a move; the application decides whether it is legal. The fixed random adapter returns `0`, choosing the first free cell whenever the snake eats. Repeat the same commands after Reset and you get the same snapshots. ## Where does Pinia fit? Hex Snake keeps game state in the core so a Vue component and a headless script can drive the same stateful application. The composable holds its latest snapshot. This example needs no Pinia store. If several components needed access, I would use a Pinia store to expose that snapshot and forward commands. I would keep the snake and score writable through the core's operations, avoiding a second copy that components could edit independently. Shared UI state, such as the selected panel, could still belong to Pinia. That is a design choice for this application. In [QWAN's Vue example](https://www.qwan.eu/2020/09/09/how-to-keep-complexity-in-check-with-hexagonal-architecture.html), state objects sit at the domain boundary and delegate to plain JavaScript domain objects. [Juan Otálora's React example](https://dev.to/juanoa/a-clean-frontend-architecture-for-react-and-redux-15cn) places Redux in infrastructure. Neither gives you a universal folder location for a store. Choose who owns the state and how callers may change it. Then keep the rules you want to run independently free of framework dependencies. Hex Snake's snapshot subscription is one way to do that. ## Apply the same boundary to an order form Most Vue projects deal with forms and API requests. Consider a hypothetical order feature: the customer selects products, submits an order, and sees either confirmation or an error. These filenames illustrate the same responsibilities; they are not extra modules in Hex Snake.
Runtime calls from OrderForm.vue through useSubmitOrder to submitOrder. The operation and OrderRepository port are inside the application core. Outside it, an HTTP adapter calls the backend API, validates responses, and maps order_id to id.
Runtime calls cross the boundary through the core-owned repository contract. The HTTP adapter translates API data; its source dependencies point toward that contract. AI-generated conceptual diagram.
| Question | Example location | Responsibility | | --- | --- | --- | | Where do input fields and inline messages go? | `components/OrderForm.vue` | Collect input, render feedback, and emit the submit intent | | Where do pending state and error presentation go? | `composables/useSubmitOrder.ts` | Call the operation, track the in-flight request, and expose feedback to Vue | | Where do order rules go? | `core/orderRules.ts` | Check meaningful constraints, such as requiring at least one item with a positive quantity | | Where does the submission workflow go? | `core/submitOrder.ts` | Check the rules, request persistence through a port, and return an outcome | | Where is the persistence contract? | `core/ports.ts` | Describe what `OrderRepository` must accept and return in application terms | | Where do `fetch()` and API response types go? | `adapters/httpOrderRepository.ts` | Send the request, validate the response shape, and map transport fields to the application's model | | Where do we choose HTTP or an in-memory implementation? | The feature's setup or application entry point | Supply the concrete repository to the operation | The composable can set `isSubmitting` before awaiting the operation and clear it in `finally`. The core returns a meaningful outcome; the UI decides whether to display a field message, a toast, or a confirmation panel. An HTTP adapter translates transport failures into the outcomes the application contract supports. For validation, distinguish interaction feedback from order rules. Showing a message after a field loses focus belongs to the UI. Rejecting an order with no items belongs to the application operation, so another caller cannot skip the check. The backend must still enforce authoritative order rules: browser-side validation helps the user but cannot establish trust. Suppose the API renames `order_id` to `id`. The response mapping changes in the HTTP adapter; the UI can keep receiving an `Order` with the same shape. To test rejection of an empty order, supply an in-memory repository and call the operation without mounting the form. This is the same separation as `RandomSource`: the core describes what it needs, while an adapter deals with the technology. You gain a place to contain API-specific details, at the cost of writing the contract and mapping code. ## Advantage: test business logic without setting up Vue When a rule lives inside a component event handler, testing it can require rendering the component, filling inputs, and triggering the right event. After extracting it, you can arrange the relevant data and call it. That makes unusual states easier to reproduce and failures easier to trace to a rule. Pure functions need only inputs and expected outputs. Stateful application operations need an initial state and controlled dependencies. Neither needs a Vue component. The boundary lets you cover rule combinations here and use browser tests to check the connections around them.
Two configurations use the same SnakeGame implementation. In the browser, Vue and a timer call the game, which uses Web Crypto randomness through RandomSource. In a test, explicit commands drive the game and a fixed RandomSource returns zero.
Mint arrows show runtime calls. Production wiring and test setup supply different adapters to the same application implementation. Each setup creates its own game instance. AI-generated conceptual diagram.
This test comes from `createSnakeGame.test.ts`. It supplies the initial positions and a fixed random source: ```ts const first: RandomSource = { nextInt: () => 0 } it('moves, eats, scores, and picks food through the injected random source', () => { const game = createSnakeGame({ width: 5, height: 5, initialSnake: [{ x: 2, y: 2 }, { x: 1, y: 2 }], initialFood: { x: 3, y: 2 }, }, first) game.start() game.tick() expect(game.getSnapshot()).toMatchObject({ status: 'running', score: 1, snake: [{ x: 3, y: 2 }, { x: 2, y: 2 }, { x: 1, y: 2 }], food: { x: 0, y: 0 }, }) }) ``` The test runs in Node. It calls application commands and checks the resulting snapshot. There is no component mount or elapsed time to manage. The departing-tail rule deserves a separate test. During a normal move, the tail leaves its cell as the head advances. During an eating move, the snake grows and keeps the tail. The core expresses that distinction before checking collisions: ```ts // Excerpt from tick() in createSnakeGame.ts. const eating = snapshot.food !== null && samePosition(head, snapshot.food) const bodyToCheck = eating ? snapshot.snake : snapshot.snake.slice(0, -1) ``` Reaching that bent-snake arrangement through clicks and key presses would make this rule test tedious. At the application boundary, a test supplies it through `initialSnake`, calls `tick()`, and asserts that the game remains running. You can run such a test in Node or a browser. The useful distinction is calling the application API versus exercising the UI. The project's headless script uses that same API. It plays a short game twice with the same seed, checks that both runs reach a loss with score `1`, and compares the final snapshots: ```sh pnpm demo ``` The driver lives in `scripts/headless-demo.ts`. ### Where to test what Choose the test's scope first, then the environment it needs. This is how I would divide coverage for a Vue feature: | What are you checking? | Default tool | Example assertion | | --- | --- | --- | | Pure business rules | Vitest in Node | Moving up decreases the head's y-coordinate; an empty order is invalid | | Application operations with controlled dependencies | Vitest in Node | Eating increases the score and asks the supplied random source for a new food position | | Adapter translation | Vitest in Node when its dependencies support it; Browser Mode for browser-specific behavior | An HTTP response maps `order_id` to `id`; a malformed response produces the expected failure | | Vue components, lifecycle integration, and browser interactions | Vitest Browser Mode with `vitest-browser-vue` | Clicking a control updates the rendered board; unmounting stops the integration from responding to keyboard input | | Journeys through the running application | Playwright Test | Load the game, start, steer, pause, and restart using the visible controls | **Vitest in Node: cover the rules and operations.** [Node is Vitest's default environment](https://vitest.dev/guide/environment.html). For a pure rule from `rules.ts`, a focused test could look like this: ```ts it('moves up by one cell', () => { expect(nextHead({ x: 2, y: 2 }, 'up')).toEqual({ x: 2, y: 1 }) }) ``` The earlier `createSnakeGame` test covers a larger operation through its public API. It uses the real rules and supplies a fixed `RandomSource`. For the order feature, supply an in-memory `OrderRepository` and check that an invalid order never reaches persistence. Avoid mocking the rules you are trying to verify. Add focused pure-function tests where they explain meaningful edge cases; you do not need a separate test for every private helper. **Vitest Browser Mode: check Vue and browser integration.** [Browser Mode runs tests in a real browser](https://vitest.dev/guide/browser/why). Render a component or a small feature with `vitest-browser-vue`, interact through accessible controls, and assert the rendered outcome. Keep the real application core when practical and substitute only dependencies that need control. For this article's playground, clicking **Step** should update the visible score and board. That checks the component-to-core connection; the Node tests can cover the collision combinations. Test concrete adapters separately too. An in-memory repository proves that an operation works with that contract; it does not prove that the HTTP implementation sends the right request or validates its response. Exercise the HTTP adapter against controlled responses. Use a real browser when browser behavior, such as focus, storage, or event handling, is what you need to verify. **Playwright Test: check the assembled application.** Open the application URL and exercise a few important journeys with its entry point, routing, and production wiring in place. Check outcomes the user can observe, following [Playwright's testing guidance](https://playwright.dev/docs/best-practices). A passing core test cannot tell you whether the Start button connects to the game. A passing component test cannot tell you whether the application entry point supplies the right dependencies. Be explicit when a journey substitutes a backend or adapter: it verifies the connections you actually run. The downloadable Hex Snake project uses `pnpm test` for its Node-based Vitest suite and `pnpm test:e2e` for Playwright journeys. It does not configure Vitest Browser Mode. The embedded playground in this blog has separate Browser Mode tests. The table describes where I would place each kind of check as a Vue project grows. You can also run pure rules in Browser Mode if that suits your suite. Node is a convenient default for this framework-independent code. The architectural benefit is that testing a business rule no longer requires interacting with the UI, whichever runner you choose. ## Advantage: contain changes at the right boundary The benefit becomes concrete when you consider the next change: | Change request | Main place to change | Behavior you can reuse | | --- | --- | --- | | Display the score in a different layout | Vue components | Scoring and game lifecycle | | Add touch controls | Input adapter and Vue wiring | Direction validation and movement | | Reproduce a sequence of moves | A test or script with a fixed/seeded adapter | The same application commands | | Allow the snake to wrap around the board | Core movement/collision rules and their tests | Existing UI and input wiring, if the API stays the same | The core tests protect movement and scoring while browser tests check that the controls still connect to them. ### Some features require changes across the boundary Imagine adding a difficulty selector where Hard mode moves faster and awards two points per food. This is a proposed extension, not a feature in the download. - **Component:** add the selector and emit the choice. - **Configuration and wiring:** define the available modes and pass the selected scoring and timing settings to the game and its Vue integration. - **Core:** accept the points-per-food setting and apply it when eating. Test that Hard mode awards two points. - **Timer integration:** accept the selected interval instead of using a fixed `145` ms. The browser adapter still owns scheduling the callbacks. The speed setting describes the game's pacing policy; the timer adapter implements the scheduling mechanism. Moving that policy into an adapter because it involves time would blur the distinction. This feature touches several files. The boundary makes each edit's responsibility explicit; it does not make the feature a one-file change. QWAN describes a similar cost when [adding a field requires updates to the UI, domain, and API mapping](https://www.qwan.eu/2020/09/09/how-to-keep-complexity-in-check-with-hexagonal-architecture.html). For a small feature, the wiring and mapping code may cost more than they save. Use the boundary where it reduces testing or change-management friction. ## Keep dependencies pointing toward the core The core defines its model and ports. Vue code and technology adapters can import those definitions. The core should not import `useSnakeGame`, `GameBoard.vue`, or the concrete browser random adapter. For example, `GameControls.vue` imports the core's `Direction` type. That is an inward dependency. Importing Vue's `ref` into the game to hold its state would tie the application back to the framework we intended to keep outside. The core can still call an adapter at runtime. It receives that adapter through a port such as `RandomSource`, so its source code depends on the contract it owns. `main.ts` chooses the implementation. This is dependency inversion in this example. Hex Snake protects this policy with a local Oxlint rule and a separate TypeScript configuration: ```sh pnpm check:boundaries ``` The lint rule rejects external imports and environment capabilities in production core files. TypeScript compiles those files without browser or Node globals. The full configuration and rule live in the downloadable project. ## Introduce the boundary one feature at a time Start with a rule you find difficult to test in its current component, composable, or store. Pricing, scheduling, editor operations, and game behavior are good candidates because they have meaningful outcomes independent of rendering. 1. **Extract the rule into plain TypeScript.** Pass the data it needs and return a result. Keep presentation concerns in Vue. 2. **Give the operation a callable API.** Let a test request an action and inspect its outcome. Group state and related operations when they need a shared lifecycle. 3. **Identify external capabilities.** For randomness, persistence, or networking that the operation needs, define a small contract in the core and supply an implementation from outside. 4. **Connect Vue.** Use a composable or an existing store to expose snapshots, forward commands, and clean up subscriptions. Choose production implementations at the entry point. A form that mostly displays server data may gain little from this structure. A feature with rules that keep leaking across components has more to gain. Keep the boundary small enough that you can explain what each part owns. For an exercise, add a second way to turn the snake. Keep the input translation in an adapter and call the existing `turn()` operation. Then change a movement rule in the core and check it through a test. Those two changes let you practice placing interface behavior and application behavior on their respective sides of the boundary. ## Further reading - [How to keep Front End complexity in check with Hexagonal Architecture](https://www.qwan.eu/2020/09/09/how-to-keep-complexity-in-check-with-hexagonal-architecture.html), by Marc Evers and Rob Westgeest: a Vue experience report on responsibility placement, API mapping, and trade-offs. - [Hexagonal Architecture in Frontend](https://github.com/juanm4/hexagonal-architecture-frontend): a shopping-cart example with Vue, React, and React Native interfaces sharing domain code. - [Clean Architecture on Frontend](https://www.bespoyasov.me/blog/clean-architecture-on-frontend/), by Alex Bespoyasov: a related approach covering application use cases, ports, and adapters in a React/TypeScript shop. Clean Architecture and hexagonal architecture share ideas, but this article does not require adopting its full layer structure. --- --- title: Enforce Tailwind CSS Design Tokens in Vue with @shadcn/lint description: Catch raw colors, arbitrary values, and component overrides in Vue with @shadcn/lint. Includes ESLint setup and interactive before-and-after examples. tags: ['vue', 'tailwind', 'css', 'typescript'] url: https://alexop.dev/posts/enforce-tailwind-design-system-shadcn-lint --- # Enforce Tailwind CSS Design Tokens in Vue with @shadcn/lint Tailwind is awesome. I like having the styling next to the markup. I can read a component and understand its layout without jumping between files. But there's a huge gap between using Tailwind and automatically verifying that code follows a design system. This becomes especially obvious when AI agents write the UI. An agent can produce valid Tailwind while ignoring the tokens and component variants already in your project. Telling it to follow the design system gives it instructions. It also needs a way to check its work. Take this button: ```vue Ship it ``` `bg-red-500` is valid Tailwind. The app builds. But the button has its own variants and theme colors. Someone just painted over them. Tailwind generates CSS for valid utilities. It doesn't decide whether a caller should change this button's background. You can enforce conventions with custom lint rules and other tools. That work existed before this package. But the policy doesn't come from Tailwind itself.
That's the motivation behind [@shadcn/lint](https://github.com/shadcn-ui/lint#built-for-agents). Shadcn built it for AI agents that write UI. When an agent breaks a design-system rule, the lint error explains the violation, suggests an existing token or variant, and points to the relevant code. The agent can make the correction and run the check again. That's what I like about the idea. It turns design-system contracts into checks both developers and agents can run. Components own their appearance. Callers use their variants and the classes your policy permits. I built a small Vue todo lab to try the idea. Then I deliberately broke the styling to see what the linter caught. ## Try breaking the design system The rendered button is on the left. Its Vue source is on the right. Switch to **After** below them to see the component, source, and lint findings change together. These findings come from actual ESLint runs against the Vue examples. The previews render Vue with a scoped stylesheet matching the lab's theme. The widgets display captured results; they don't run ESLint in your browser. The first example produces two findings. `no-raw-colors` rejects the palette color. `no-restyle` rejects changing the shared button's color at the call site. The repair uses the button's existing API: ```vue Ship it ``` The nice thing is that the message knows which variants exist. In the lab, it lists `primary`, `secondary`, `ghost`, and `danger`. It also points to `src/components/ui/BaseButton.vue`. That's useful feedback for a developer. It's also useful for a coding agent. The next action is concrete, and the agent can run the same check after its edit. ## A component contract is more than a color rule The `no-restyle` rule checks recognized design-system components. Token rules also check plain elements. That distinction matters. Changing a paragraph to `text-danger` can fix its color violation. Changing the button to `class="bg-danger"` still overrides its appearance. A semantic token doesn't give every caller permission to use it everywhere. On a plain paragraph, the fix is simpler. Replace the palette color with the role it serves. Here, `text-red-500` becomes `text-danger`: The text still says the same thing. Its color now comes from the theme's danger token. With `allow: ["layout"]`, a caller can position a button: ```vue Delete completed tasks ``` The component still controls its padding, background, and radius. If the button needs another size, that belongs in its size API. Different components can have different contracts. A card's content area might allow padding changes. A title might allow typography changes: ```js // Options for shadcn/no-restyle { allow: ["layout"], contracts: [ { pattern: "^CardContent$", allow: ["layout", "spacing"] }, { pattern: "^CardTitle$", allow: ["layout", "typography"] }, ], } ``` Each contract replaces the `allow` list, so keep `layout` when you still want it. The [no-restyle documentation](https://github.com/shadcn-ui/lint/blob/main/docs/rules/no-restyle.md) explains matching and exceptions. I like this more than a blanket ban on `class`. The policy can reflect how the component is meant to work. ## What the linter reads The package analyzes source code. It reads component imports, variants, theme declarations, and supported class expressions. For unknown classes, it asks the installed Tailwind 4 whether they generate CSS. If Tailwind or the theme cannot load, `no-unknown-classes` warns and falls back to weaker grammar checks. Resolve that warning before trusting a clean run. The [fallback documentation](https://github.com/shadcn-ui/lint/blob/main/docs/rules/no-unknown-classes.md#without-a-resolvable-theme) explains the limits. The loop is simple: run the checks, read the allowed alternatives, edit the source, and run the checks again. There's no AI service judging the design. The feedback comes from static analysis and the configured rules. The package supports React, Vue, and Svelte. You don't need shadcn/ui components. Your own UI directory and component APIs can provide the contract. I'm using `@shadcn/lint` 0.2.0 with Tailwind 4 here. Vue templates need ESLint with `vue-eslint-parser`. Oxlint exposes the script blocks to this plugin, but not the templates. The [Vue setup documentation](https://github.com/shadcn-ui/lint/blob/main/docs/vue.md) covers that difference. If a project already uses Oxlint, keep it for its existing checks. Add ESLint for the Vue template policy. ## Add the rules to a Vue project This setup assumes an existing Vue project with Tailwind 4. The plugin requires Node.js 20.19 or later and supports ESLint 9.30 or later. Use a Node version supported by your installed ESLint release. Check the [ESLint prerequisites](https://eslint.org/docs/latest/use/getting-started#prerequisites). ```bash pnpm add -D @shadcn/lint eslint @typescript-eslint/parser vue-eslint-parser ``` Merge the following into your flat config. If `eslint-plugin-vue` already configures the Vue parser, preserve that setup and add the plugin and rules there. ```js // eslint.config.mjs export default defineConfig([ { files: ["**/*.vue"], languageOptions: { parser: vueParser, parserOptions: { parser: tsParser }, }, plugins: { shadcn }, rules: { "shadcn/no-restyle": ["error", { allow: ["layout"], componentImports: ["^@/components/ui(?:/|$)"], }], "shadcn/no-raw-colors": "error", "shadcn/no-arbitrary-values": "error", "shadcn/no-unknown-classes": "error", }, }, { files: ["src/components/ui/**/*.vue"], rules: { "shadcn/no-restyle": "off" }, }, ]) ``` The last block lets shared components define their appearance. It keeps the token rules enabled inside those components. Adjust the path to your UI directory. The plugin discovers the theme through `components.json`, or a stylesheet that imports Tailwind when that file is absent. Check discovery when the project has multiple themes. Class helpers can also live in TypeScript files. Add another config block with `files: ["**/*.ts"]`, `tsParser`, the plugin, and the rules you want there. The Vue block doesn't cover those files. For an initial run: ```bash pnpm exec eslint src ``` Add that command to the project's lint scripts and CI. If you roll out rules as warnings, use `--max-warnings 0` when you're ready to make warnings fail CI. I would start with a small set of shared components. Fix the findings, then expand coverage. A thousand warnings that everyone ignores won't enforce anything. ## The limits are real A clean lint result doesn't prove that the page looks good. The linter doesn't inspect screenshots, measure contrast, or check whether a dialog fits a narrow screen. Browser tests and visual review still have work to do. It also has source-analysis limits. Vue ` ``` ```vue live id=compound-dialog file=Dialog.vue ``` ```vue live id=compound-dialog file=DialogTrigger.vue ``` ```vue live id=compound-dialog file=DialogContent.vue ``` ```vue live id=compound-dialog file=DialogClose.vue ``` No flag inside any file decides what gets rendered. The variant is the tree in `App.vue`. Swap ``'s children for a form and you have edit-profile; swap them for a share sheet and you have share. Same primitives, same provider, different tree. ## State Lives in the Provider, Not the Layout > "If there's one thing to take away from this talk, it would be this. I've solved so many problems in my React code bases by simply lifting state higher up in the tree." > > — Fernando Rojo The share example tucked an icon `` outside the footer, absolutely positioned in the top-right corner. It still closed the same dialog because the open state lives in the provider, not in any specific child. Slots alone cannot reach across siblings like that; a provider can. Open the dialog below, then move the cancel button between the footer and a toolbar that sits next to the dialog. Both consumers call `setOpen(false)` on the same provider. The visual layout and the state graph are decoupled. In a slot-based design without a provider you would have to do one of these by hand: - Hoist the open state up to the parent: boilerplate, repeated per variant - Pass an `onClose` callback through every layer: prop drilling - Use a scoped slot to pass state to a child: solves the *direct child* case, then collapses the moment a sibling outside the visual frame needs the same state - Reach into the dialog with a template ref: escape hatch, untyped, brittle With `provide` / `inject`, none of that exists. A child calls `useDialog()` and the closest provider answers, no matter how deep in the tree or how far apart the children sit in the layout. "Lift state to the provider" means lifting it out of the layout. The same state can then be read from any DOM position the consumer wants. ## Styling Without Locking Consumers In: `cn()` That covers structure. The consumer composes the tree, the provider keeps state in sync no matter how the children are laid out. Styling is the second axis. The primitive ships sensible defaults: `flex flex-col gap-2` on a header, `max-w-md` on the content. Every real call site eventually wants to bend one of them: a wider share dialog, a tighter footer, a destructive button that breaks the default palette. The consumer needs a way in without forking the file. `cn(...)` is how shadcn-vue solves that. It is a small utility every shadcn project writes itself, by convention living in `src/lib/utils.ts` (not an npm package). The implementation is four lines: ```ts // lib/utils.ts export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } ``` `clsx` joins truthy class values into a string. `tailwind-merge` deduplicates conflicting Tailwind utilities so the *last* one wins, so `p-2` followed by `p-4` collapses to `p-4`. Together, whatever the consumer passes overrides whatever the primitive sets, with no specificity wars. In every shadcn-vue primitive it shows up as the same one-liner. From `DialogHeader`: ```vue
``` The primitive holds the default styling, `props.class` carries the override, and the merge runs defaults first so the override wins on conflicts. ### Watch the merge happen Toggle classes on either side. The left panel shows what naive concatenation produces: conflicting utilities sit side-by-side, flagged with ⚠. The right panel runs the same input through the real `cn()` (using `clsx` + `tailwind-merge` from npm) and shows which classes survive and which get dropped. Conflict chips on the left are the ones whose winner depends on stylesheet order; fragile. Strikethrough chips on the right are the ones `tailwind-merge` dropped. Hover any dropped chip to see who beat it. Non-conflicting classes (like `text-white` when no other text-color is set) flow through untouched on both sides, since `cn()` deduplicates only when there is a real conflict. So the share dialog's `` works: the primitive's `max-w-md` default loses to the consumer's `max-w-2xl`, no specificity war, no fork. ## State as Contract: The `data-*` Pattern `cn()` covers static overrides. But every dialog also has *state*: open, closed, animating in, animating out, disabled. The consumer wants to style against those too. A fade on open, a slide on close, a muted border when disabled. The primitive cannot ship every animation, and it shouldn't have to. Headless libraries solve this by writing state to the DOM as data attributes, then letting the consumer style against them. You already saw both halves of the pattern in the snippets above. ### `data-state`: the current state of the component The library sets it automatically. Look at the trigger we built earlier: ```vue :data-state="open ? 'open' : 'closed'" ``` The consumer styles against the attribute: ```html ``` Tailwind v4 ships `data-[...]` as a first-class variant, so no plugin is needed. The same vocabulary shows up across the ecosystem: `data-state="open" | "closed"` on overlays, `"on" | "off"` on toggles, `"checked" | "unchecked" | "indeterminate"` on checkboxes, `"active" | "inactive"` on tabs. Reka UI and Radix share the contract, so a class targeting `data-[state=open]:` works against either. ### `data-slot`: stable identity for each part shadcn-vue v4 stamps a `data-slot` on every primitive. The dialog root carries `data-slot="dialog"`, the header `data-slot="dialog-header"`, and so on. You saw it in the shadcn source above: ```vue ``` ```vue
``` Why a separate attribute when `class` exists? Because `class` belongs to the consumer (`cn()` already settled that), and Tailwind utility names are not semantic. `data-slot` is. A parent layout can target a dialog part without knowing or caring which utilities live inside it today: ```html
``` ### Other attributes you'll see - `data-side`: `top` / `right` / `bottom` / `left` on positioned elements (tooltip, popover, menu) - `data-align`: `start` / `center` / `end` for the same - `data-orientation`: `horizontal` / `vertical` on tabs, separators, sliders - `data-disabled`: present when the part is disabled - `data-highlighted`: present when keyboard focus is on a menu item - `data-placeholder`: present while a select is showing its placeholder ### Why attributes, not classes 1. **The consumer owns `class`.** If the library wrote state classes like `is-open`, it would have to merge with the consumer's input or fight specificity. Attributes sidestep both. 2. **Stable surface.** Refactor the primitive's utility classes tomorrow; `data-state="open"` survives because it is the public contract. 3. **Inspectable.** Open DevTools, see the state in the DOM. No closure spelunking. 4. **Composable with Tailwind variants.** `data-[state=open]:`, `group-data-[state=open]:`, and `has-[[data-slot=dialog-footer]]:` are first-class variants in v4. ## Element Handoff: `as-child` One more escape hatch shows up in shadcn-vue source: `as-child` (inherited from Reka UI, which inherits it from Radix). Pass it to a primitive and the primitive **clones the consumer's child** and forwards its behavior into that element rather than rendering its own DOM. ```vue Open Open profile ``` The contract: the consumer's element receives all the props and event handlers the primitive would have applied to its own DOM. The primitive owns behavior; the consumer owns presentation. *(Reka UI implements this via its `` component and slot forwarding, so you do not need to write the cloning machinery yourself.)* This is the fourth axis of consumer override the pattern gives you. Compound children let the consumer compose **structure**, `cn()` lets them override **style**, `data-*` exposes **state and identity** as a stylable contract, and `as-child` lets them swap the **rendered element**. All four are decided at the call site. ## The Convenience Layer Once a compound API exists, someone will ask for a flat `` shortcut. Don't add it as flags on ``. Build it *on top* of the primitives: ```vue ``` Now a sign-out confirm is one tag: ```vue ``` The convenience layer is a *consumer* of the primitives, not an extension of them. When a designer asks for a non-standard variant (checkbox in the footer, custom layout), the consumer drops back down to the primitives without anyone touching `ConfirmDialog`. This is what stops the compound API from collapsing back into `BooleanPropDialog` over time. ## How to Apply This in Your Codebase I use this workflow to migrate an existing prop-heavy component: 1. **Find the smell.** List every boolean prop. For each one, ask: does this prop change *what renders* or *how it renders*? "What" props are composition opportunities. "How" props (variant, size, color) are fine; leave them. 2. **Sketch the tree per variant.** Take three real call sites and write down the JSX/template you wish you could write. The compound API drops out of that exercise. 3. **Extract the provider.** Move state and handlers into a composable with a typed `InjectionKey`. The consumer-facing interface is the type signature of that context object. 4. **Split the children.** Each child component reads only the slice it needs from `useX()`. Keep them dumb. No business logic in the leaves. 5. **Delete the old props one variant at a time.** You can ship the new API alongside the old monolith and migrate call sites incrementally. A few rules that have saved me pain: - **Fail when context is missing.** `useDialog` throws. Otherwise consumers will render broken UI without warning when they forget the root. - **Do not build a generic primitive before you have three concrete users.** One use case is a component. Two is a coincidence. Three is a pattern worth abstracting. - **Resist the "convenience prop" temptation.** Once you have the compound API, someone will ask for a `` shortcut. Add it as a separate higher-level component (`` built *on top* of the primitives), not as flags on the root. ## When Not to Use It The compound pattern has real costs. Skip it when: - The component has one shape and you are inventing variants that do not exist. - Consumers are LLM-generated code or external API users who benefit from a flat, predictable signature. - The state is trivial and `provide` / `inject` adds more ceremony than the boolean it replaces. A `` does not need to be compound. A modal with a form, a footer, and three layouts does. ## Connections This pattern lives across the ecosystem under different names. The unifying idea, *push the variant decision out of the component and into the call site*, shows up in three traditions worth reading. **On the React side**, Kent C. Dodds canonized the modern compound-components-with-context approach in [Compound Components with React Hooks](https://kentcdodds.com/blog/compound-components-with-react-hooks). His framing of the pattern as "an implicit contract between parent and children, eliminating verbose prop passing" is the cleanest single statement of why this beats props. [patterns.dev's Compound Pattern](https://www.patterns.dev/react/compound-pattern/) is the canonical reference for the React variant. **Radix UI** (which Reka UI mirrors for Vue) builds compound on top of two primitives: a context provider and the [`asChild`](https://www.radix-ui.com/primitives/docs/guides/composition) composition prop, which lets consumers swap the rendered element while keeping the behavior. The [Radix philosophy doc](https://github.com/radix-ui/primitives/blob/main/philosophy.md) is the architecture-in-one-page version: *"primitives ship with zero presentational styles"* and *"components are designed with an open API that provides consumers with direct access to the underlying DOM node."* The whole shadcn-vue ecosystem flows from those two lines. **On the Vue side**, Adam Wathan's [Advanced Vue Component Design](https://adamwathan.me/advanced-vue-component-design/) (2018) was the first widely-shared treatment of compound, slots, and providers as a single toolkit. Michael Thiessen catalogs the per-pattern variants in [12 Design Patterns in Vue](https://michaelnthiessen.com/12-design-patterns-vue), [The 6 Levels of Reusability](https://michaelnthiessen.com/6-levels-of-reusability/), and the small applied exercise [Building a (Totally) Unnecessary If/Else Component](https://michaelnthiessen.com/building-unnecessary-if-else-component). The last one builds an `` pair from scratch using `provide`/`inject`, a tighter playground than Dialog if you want to see the wiring on a smaller surface. All three traditions share the same shift from configuration to composition: hand the component its pieces instead of describing them with flags. ## References - [shadcn-vue](https://www.shadcn-vue.com/): the source code referenced in this post - [Reka UI](https://reka-ui.com/): the headless primitives shadcn-vue is built on - [Radix Primitives](https://www.radix-ui.com/primitives): the React parent of Reka UI; same patterns, different binding - [Fernando Rojo, Composition Is All You Need](https://www.youtube.com/watch?v=4KvbVq3Eg5w): the talk that prompted this post - [Kent C. Dodds, Compound Components with React Hooks](https://kentcdodds.com/blog/compound-components-with-react-hooks): canonical React treatment - [Vue docs: provide / inject](https://vuejs.org/guide/components/provide-inject.html) - [VueUse: createInjectionState](https://vueuse.org/shared/createInjectionState/): the factory that collapses the manual provider boilerplate --- --- title: A Modern Quality Pipeline and Testing Strategy for Frontend Projects description: A short, framework-agnostic concept of what a modern quality pipeline and testing strategy look like for any JavaScript or TypeScript frontend project. tags: ['testing', 'tooling', 'ci', 'performance', 'observability'] url: https://alexop.dev/posts/modern-frontend-quality-pipeline --- # A Modern Quality Pipeline and Testing Strategy for Frontend Projects An agent writes most of my frontend code now. I review what it produces and tighten the architecture where it overreaches. That changes what a quality pipeline is for. You used to write tests and types so the next person on the file stayed sane. Now you write them so the agent can check its own work. Give it more ways to verify a change (types, lint, unit, component, real browser, a11y, bundle budget) and it finishes more of the ticket on its own. A red check tells it what to try next. Frontend has also grown more complicated since 2024. SSR, streaming, partial prerendering, server components, edge runtimes. Each adds a place where a change can break silently. "TypeScript plus a couple of unit tests" no longer covers it. A *quality pipeline* is the set of checks that run on every change (locally, on commit, in CI) to give layered confidence the change is correct, accessible, performant, and safe to ship. A *testing strategy* is the part of that pipeline that asserts behaviour: what the app should do, at which level, at what cost. Plan them as one system. The pipeline decides when checks run; the strategy decides which checks are worth running. Design them together so that: - Each check has a clear job and runs at the cheapest stage where it can catch the problem. - The feedback loop is short enough that no developer or agent skips ahead. - The same checks run on a contributor's laptop, in an agent's sandbox, and on the CI runner. The same pipeline shape applies whether you build with Next, Nuxt, Astro, SvelteKit, Remix, or a plain Vite app. The framework choice changes which adapter you import, nothing else. ## Background Frontend tooling consolidated between 2023 and 2026. [Vite](https://vitejs.dev/) became the default dev/build engine across the major frameworks. [Vitest](https://vitest.dev/) replaced [Jest](https://jestjs.io/). [Playwright](https://playwright.dev/) became the default for E2E. [ESLint](https://eslint.org/) adopted flat config; [Biome](https://biomejs.dev/) and **[Oxlint](https://oxc.rs/docs/guide/usage/linter)** emerged as much faster alternatives in Rust. [TypeScript](https://www.typescriptlang.org/) strict mode became table stakes. [Renovate](https://docs.renovatebot.com/) replaced [Dependabot](https://github.com/dependabot). In March 2026, [VoidZero](https://voidzero.dev/) shipped **[Vite+](https://viteplus.dev/)** as the open-source culmination of that trend: one CLI that wraps Vite, [Rolldown](https://rolldown.rs/), Vitest, Oxlint, [Oxfmt](https://github.com/oxc-project/oxc), and [Tsdown](https://tsdown.dev/). A modern quality pipeline's pieces look the same regardless of framework, so I'll describe the concept first and my stack second. ## My default stack For new frontend projects in 2026 I reach for **Vite+** instead of wiring the toolchain by hand. Vite+ ([viteplus.dev](https://viteplus.dev/)) is the unified toolchain from VoidZero, [Evan You](https://evanyou.me/)'s company. It bundles Vite, Rolldown, Vitest, Oxlint, Oxfmt, and Tsdown behind a single CLI (`vp dev`, `vp check`, `vp test`, `vp build`) and one config file. The alpha shipped open source under MIT. If you adopt the pieces one at a time, the swaps I would make are: | Old default | What I use | Why | | --- | --- | --- | | ESLint | **Oxlint** | ~50× faster, fast enough to run on every keystroke | | [Prettier](https://prettier.io/) | **Oxfmt** | ~30× faster, Prettier-compatible defaults | | Jest | **Vitest** | ESM-native, browser mode, same matchers | | [webpack](https://webpack.js.org/) | **Vite + Rolldown** | ~40× faster production builds | | four separate configs | `vp check` / `vp test` / `vp build` | one CLI, one config | Each piece holds up on its own. I have shipped Vitest and Oxlint in production for some time; swapping Prettier for Oxfmt and webpack for Rolldown took a day in the projects I tried. Vite+ removes the integration cost that kept teams on the older stack. ## The layers Think of the pipeline as concentric layers, each cheaper and faster than the one outside it. Run cheap checks first. Save the expensive ones for the things only they can catch. ### 1. Type safety Type safety is your first line of defence. Run your framework's type checker in CI on every PR. Treat any new type error as a build failure. If you use TypeScript, that means `tsc --noEmit` (or your framework's wrapper around it; most frameworks ship one to handle their template syntax and project references). If you don't use TypeScript yet, adopting it is the highest-leverage change you can make. Validate untyped boundaries with a schema library ([Zod](https://zod.dev/), [Valibot](https://valibot.dev/), [ArkType](https://arktype.io/)). Parse anywhere data crosses a boundary: route params, API responses, env vars, form input. TypeScript trusts the types you write; schemas check that the data matches them at runtime. With runtime parsing in place you stop reaching for `as`. See why `as` is a shortcut to avoid. ### 2. Lint and format Catches style and a wide class of bugs (unused vars, unsafe `any`, missing deps in effects) without running the code. The conventional choice is ESLint flat config plus [`typescript-eslint`](https://typescript-eslint.io/) and your framework's plugin. The 2026 alternative is **Oxlint** (Rust-based, ~50× faster) paired with Oxfmt for formatting, or **Biome** for a single-binary lint+format combo. The trade-off: Oxlint and Biome have smaller rule sets than ESLint's mature ecosystem, but they cover most of the high-value cases and are fast enough to run on every keystroke. For a working setup that uses Oxlint as a fast first pass and keeps ESLint for the rules Oxlint doesn't yet cover, see my opinionated ESLint setup for Vue projects. Add these two rule families regardless of which linter you pick. They catch bugs the type system misses: - **[`eslint-plugin-regexp`](https://ota-meshi.github.io/eslint-plugin-regexp/)** – ~60 correctness rules for regular expressions. Cheap to add, catches real bugs. - **[`@e18e/eslint-plugin`](https://e18e.dev/)** – small performance lints (e.g., prefer `Set.has` over `Array.includes`) that compound across a codebase. ### 3. Unit tests For pure functions, hooks, stores, and utilities. Cheap, fast, and where most logic should live. - **Tool:** Vitest. Run on every save in watch mode; run all of them in CI. - Aim for high coverage of pure modules; don't chase coverage on UI glue. For Vue, see my guide to testing Vue composables with Vitest. ### 4. Component tests For components in isolation, with a real DOM and real user interactions. The biggest win in 2026 is **Vitest browser mode**: your component tests run in a real Chromium via Playwright instead of jsdom. Hover states, focus, layout, intersection observers, and scroll behaviour all work as they do in production. Pair this with [`@testing-library/*`](https://testing-library.com/) for whichever framework you use; accessibility assertions on each mounted component live in layer 8 below. For a deeper walkthrough of how this fits into a full testing pyramid, see my Vue 3 testing pyramid guide. ### 5. API mocking Hard-coded fixtures go stale. Tests that hit a real backend are flaky. Mock at the network layer once and reuse the same handlers everywhere. - **Tool:** **[MSW](https://mswjs.io/)** (Mock Service Worker). It intercepts `fetch`, XHR, and GraphQL with a service worker in the browser and a request interceptor in Node, so the same handler definitions work in Vitest, Vitest browser mode, Playwright, and the dev server. - Define handlers once in `src/mocks/handlers.ts`; load them in your test setup and (optionally) in the dev server for offline-first development. - Combined with Zod (or Valibot/ArkType) schemas at the same boundary, you get mocks that are typed, schema-validated, and shared across every layer that hits the network. One source of truth instead of three drifting fixture folders. ### 6. Contract testing The mocks in layer 5 are only as good as the assumptions you bake into them. If the backend renames a field or changes a status code without telling you, every green unit and component test still passes while production breaks. Contract testing closes that gap by tying the mock to a verifiable artefact that the provider checks against. There are three styles, and they fit different team setups. **Consumer-driven contracts ([Pact](https://pact.io/)).** The frontend writes a test that records the requests it makes and the responses it expects. Pact generates a JSON contract and publishes it to a broker (the open-source [Pact Broker](https://docs.pact.io/pact_broker), or hosted [PactFlow](https://pactflow.io/)). The provider runs *its* real test suite against that contract; if it satisfies every recorded interaction, both sides can deploy. Pact has libraries for JS/TS, JVM, .NET, Go, Rust, Python, Ruby, PHP, and Swift, so the same broker spans a polyglot estate. Best when you control both ends of the wire and want the consumer to drive the schema. **Provider-driven / OpenAPI-based.** The provider publishes an [OpenAPI](https://www.openapis.org/) spec and the contract *is* the spec. Consumers validate their requests and assertions against it with [Schemathesis](https://schemathesis.readthedocs.io/) (property-based fuzzing of every operation), [Dredd](https://dredd.org/) (replays example requests from the spec against the running provider), or [Spectral](https://stoplight.io/open-source/spectral) (lints the spec itself). Best when the provider already maintains an OAS and you don't want to add Pact on their side. **Bi-directional contracts ([PactFlow](https://docs.pactflow.io/docs/bi-directional-contract-testing)).** The consumer publishes a Pact contract; the provider publishes its OpenAPI spec; PactFlow proves the two are compatible without the provider having to run consumer-supplied tests. Best when consumers want consumer-driven semantics but the provider team won't (or can't) run Pact verification themselves. What you get for the work: - **Independent deploys.** A contract gate replaces "is the matching E2E green?" with "does the provider satisfy every consumer's contract?". Consumer and provider can ship on different cadences without coordinating a release train. - **Faster than E2E.** Verifying a contract is a unit test for the boundary; E2E spins up the real services. You catch the same class of bug an order of magnitude sooner. - **Catches drift the linter can't.** A field renamed on the backend fails the contract before MSW handlers or Playwright flows would notice. Skip this layer if you own both services and ship them as one unit. E2E covers the same boundary in that case, and the contract overhead doesn't pay off. Add it the moment consumer and provider deploy on different cadences, you don't own the provider, or a single backend serves multiple frontends that all need to keep working. For a deep dive, [*Contract Testing in Action*](https://www.oreilly.com/library/view/contract-testing-in/9781633437241/) (Marie Cruz & Lewis Prescott, Manning) walks through Pact, bi-directional contracts, and how to introduce the practice without stalling delivery. ### 7. End-to-end tests For critical user journeys across real pages: signup, checkout, the one or two flows that must never break. Keep the suite small. E2E is expensive. - **Tool:** Playwright. - Run against a built preview, not the dev server. - Two assertions worth wiring into a custom fixture, regardless of framework, because they catch silent regressions: - **Hydration mismatches.** Listen for hydration warnings on `console` and fail the test if any appear. SSR/CSR drift is one of the most common silent regressions in modern frameworks. I wrote a dedicated post on catching hydration errors in Playwright tests with a reusable fixture. - **CSP violations.** Listen for `securitypolicyviolation` events. If your CSP is real, this turns every E2E run into a CSP regression test. ### 8. Accessibility Accessibility cuts across lint, component, E2E, and preview. Treat it as a single discipline and check the same WCAG rule set at every cheap-enough stage. - **Lint.** [`eslint-plugin-jsx-a11y`](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y) (React/JSX), [`eslint-plugin-vuejs-accessibility`](https://github.com/vue-a11y/eslint-plugin-vuejs-accessibility) (Vue), [`eslint-plugin-astro`](https://ota-meshi.github.io/eslint-plugin-astro/) — catch missing `alt`, role mismatches, and other static violations before tests run. - **Component.** [`axe-core`](https://github.com/dequelabs/axe-core) via [`jest-axe`](https://github.com/nickcolley/jest-axe) for jsdom, or [`@axe-core/playwright`](https://github.com/dequelabs/axe-core-npm) in Vitest browser mode. Assert no violations on every mounted component, and add a meta-test that fails if any component test lacks an a11y assertion so the practice doesn't slide. - **E2E.** [`@axe-core/playwright`](https://github.com/dequelabs/axe-core-npm) on each critical journey — same engine as the component layer, but on the real composed page where many violations only appear once everything is wired together. - **Preview.** Lighthouse's accessibility category (run as part of layer 10) or [Pa11y CI](https://github.com/pa11y/pa11y-ci) on a list of routes for a dedicated, auditable report. - **Manual.** Storybook's [a11y addon](https://storybook.js.org/addons/@storybook/addon-a11y), keyboard-only walkthroughs of new flows, and screen-reader spot-checks. Automated tools catch roughly 30% of WCAG issues; the rest needs a human. This is no longer optional in the EU: the [European Accessibility Act](https://ec.europa.eu/social/main.jsp?catId=1202) took effect in mid-2025, so most B2C and many B2B products operating in EU markets are now legally required to meet WCAG 2.1 AA equivalence. For a framework-specific checklist, see my Vue accessibility blueprint. ### 9. Visual regression Catches unintended UI drift that unit and E2E tests miss. - [Chromatic](https://www.chromatic.com/) (hosted, [Storybook](https://storybook.js.org/)-native) or Playwright screenshots + a diff tool like [Lost Pixel](https://lost-pixel.com/) for self-hosted. For a Vitest-native approach, see how to do visual regression testing in Vue with Vitest. - `onlyChanged: true` keeps it cheap: only re-snapshot stories whose dependencies changed. - Gate on PR; review diffs as part of code review. ### 10. Performance and bundle size Performance regressions are silent unless you measure them. - **[Lighthouse CI](https://github.com/GoogleChrome/lighthouse-ci)** on a preview deployment. Run it against both a *light* and *dark* color scheme; contrast regressions show up only in one. - [`size-limit`](https://github.com/ai/size-limit) or your framework's bundle analyzer on PR for bundle deltas. Set explicit budgets and fail the build when they're exceeded. Lab measurements catch regressions before merge. To see what real users experience, and to find bottlenecks while you're writing the code, see layer 15 below. ### 11. Dead code and dependency hygiene Unused code is a tax on every other check. - **[Knip](https://knip.dev/)** to find unused files, exports, and dependencies. Configure per-workspace if you have a monorepo. - **Renovate** for automated dependency updates with grouping and a sane schedule. - **[OSV-Scanner](https://google.github.io/osv-scanner/)** for vulnerabilities and **[Gitleaks](https://github.com/gitleaks/gitleaks)** for secrets, gated to high-severity only to avoid alert fatigue. - Generate an **SBOM** (Software Bill of Materials) on every build with [Syft](https://github.com/anchore/syft), [Trivy](https://trivy.dev/), or [cdxgen](https://github.com/CycloneDX/cdxgen), in [CycloneDX](https://cyclonedx.org/) or [SPDX](https://spdx.dev/) format. This is shifting from "nice to have" to "regulated requirement" in 2026 ([EU CRA](https://digital-strategy.ec.europa.eu/en/policies/cyber-resilience-act), US executive orders), and it's the same artefact your security team uses to answer customer vulnerability questionnaires. ### 12. Internationalisation drift If you ship in more than one language, untranslated strings slip through. A mature i18n library plus a drift checker in CI catches them. - **i18n libraries** – [i18next](https://www.i18next.com/), [vue-i18n](https://vue-i18n.intlify.dev/), [FormatJS / react-intl](https://formatjs.github.io/), and [Lingui](https://lingui.dev/) all expose a missing-key handler you can fail the build on, plus extractor CLIs that refuse to ship if a string has no translation entry. - **Lint your source for hardcoded strings.** ESLint has [`eslint-plugin-i18next`](https://github.com/edvardchen/eslint-plugin-i18next) and [`@intlify/eslint-plugin-vue-i18n`](https://eslint-plugin-vue-i18n.intlify.dev/) to flag bare strings in JSX/templates and unused or missing keys. Oxlint doesn't yet ship i18n-specific rules, so run it as the fast first pass and keep these ESLint plugins for the i18n layer. - **[Lunaria](https://lunaria.dev/)** compares each locale against a source locale and reports missing or stale keys. It works with any project that has translation files; you can publish a public status dashboard from the same data. ### 13. Preview deployments The cheapest way to enable manual review and to give E2E, Lighthouse, and visual-regression checks something realistic to run against. - [Vercel](https://vercel.com/), [Netlify](https://www.netlify.com/), or [Cloudflare Pages](https://pages.cloudflare.com/) will give you a unique URL per PR for free. - Wire your downstream checks to that URL. ### 14. Automated code review In 2026, AI code review is a standard pipeline stage. It runs before any human reviewer touches the PR and catches issues the layers above miss: logic mistakes, missing edge cases, security smells, and the small inconsistencies that lint rules can't express. - **[CodeRabbit](https://www.coderabbit.ai/)**, **[Greptile](https://www.greptile.com/)**, and **[Vercel Agent](https://vercel.com/docs/agent)** are the main options. Recent benchmarks put Greptile's bug-catch rate around 82% versus CodeRabbit's ~44%, but Greptile produces more false positives and runs slower; CodeRabbit covers more git platforms. - Have it run alongside specialist scanners (secrets, vulnerabilities, workflow lint, shell/yaml lint) so a single bot comment summarises every machine-checkable concern on the PR. - Pause the bot on Renovate / Dependabot PRs to avoid noise on mechanical updates. Treat the AI reviewer as a high-recall first pass that reduces human review without replacing it. If you want to add an AI agent that goes one step further and exercises the app in a real browser, see how I run automated QA with Claude Code, Agent Browser, and GitHub Actions. ### 15. Runtime observability Layers 1–13 give you confidence at merge time. Once a change is in production, and while you're writing it, you also want a live view of what the app is doing. The same instrumentation answers both questions. Use **[OpenTelemetry](https://opentelemetry.io/)** as the SDK. It's the only vendor-neutral option, and the JS ecosystem caught up in 2025–2026: stable web SDK, official auto-instrumentations for `document-load`, `fetch`, `xhr`, and `user-interaction`, and OTLP support in every backend that matters. - **Browser SDK.** [`@opentelemetry/sdk-trace-web`](https://www.npmjs.com/package/@opentelemetry/sdk-trace-web) plus the auto-instrumentations emits OTLP/HTTP. Wrap [`web-vitals`](https://github.com/GoogleChrome/web-vitals) into OTel metrics so LCP/INP/CLS land on the same backend as the trace that produced them. One pipe instead of two. - **SSR / edge.** Next, Nuxt, SvelteKit, and Astro all expose OTel hooks. Set a single `service.name` resource attribute and one request stitches together: edge → SSR → hydration → client interaction, all in one trace. - **One collector, two backends.** Run an [OpenTelemetry Collector](https://opentelemetry.io/docs/collector/) with two exporters. In dev, point it at [Jaeger](https://www.jaegertracing.io/) or [Grafana Tempo](https://grafana.com/oss/tempo/) running via `docker-compose`; open `localhost:16686` and you can watch every fetch, render, and hydration span as you click through the app. In prod, swap the exporter to [Honeycomb](https://www.honeycomb.io/), [Grafana Cloud](https://grafana.com/products/cloud/), [Dash0](https://www.dash0.com/), or [Sentry's OTel ingest](https://docs.sentry.io/platforms/javascript/tracing/instrumentation/opentelemetry/). Same SDK, same instrumentations, different OTLP endpoint. - **Sample, or pay.** `ParentBased(TraceIdRatioBased(0.05))` in prod, `AlwaysOn` in dev. Tail-sample at the collector to keep the slow and error traces and drop the rest, so the signal that matters survives without renting cloud storage for every render. The dev-time payoff is the part most teams underuse. The next time someone asks why a page is slow on a real device, you already have the trace from when they loaded it. ## Where each layer runs Same layers, different stages. Pick the cheapest stage where each check can catch the problem. | Stage | What runs | | -------------------- | --------------------------------------------------------------- | | Editor | Type checker LSP, linter, Vitest watch | | Pre-commit | Format and lint on staged files only | | CI on PR | Typecheck, full lint, unit, component, contract verify, build, knip, size-limit, AI review | | CI on preview URL | E2E, accessibility (axe + Lighthouse), visual regression | | Post-merge / nightly | Full E2E matrix, dependency updates, security scans, SBOM publish | | Dev server / production | OpenTelemetry traces and metrics: live in dev, sampled in prod | For wiring local hooks themselves, **[Lefthook](https://lefthook.dev/)** has become the default modern alternative to [Husky](https://typicode.github.io/husky/): a single Go binary, declarative YAML config, and parallel execution of lint/format/test commands on staged files. Commit a `lefthook.yml` to the repo, run `lefthook install` once, and contributors get the same hook setup automatically. Pair it with [`lint-staged`](https://github.com/lint-staged/lint-staged) (or Lefthook's built-in `{staged_files}` substitution) so pre-commit only runs against the files that changed. That fast-check pattern keeps the hook under a couple of seconds. **[Git 2.54](https://git-scm.com/)** added config-based hooks, which means a small project no longer needs an external hook manager at all. You define hooks in `.gitconfig` instead of as scripts under `.git/hooks`: ```ini [hook "linter"] event = pre-commit command = pnpm exec oxlint --staged [hook "format"] event = pre-commit command = pnpm exec oxfmt --check ``` Multiple hooks per event run in order. Disable a single hook with `hook..enabled = false` (useful for opting one repo out of a system-wide config), and list the active ones with `git hook list pre-commit`. The traditional `.git/hooks/*` scripts still run last, so existing setups keep working. For a small project this covers most of what Lefthook does without an extra binary. Lefthook still has the edge for parallel execution and staged-file substitution. One valid alternative skips commit hooks and runs *everything* server-side in CI as required status checks. You trade a slower red-CI feedback loop for never blocking a contributor with a flaky local hook. If GitHub Actions is the CI you're wiring this onto, [*GitHub Actions in Action*](https://www.manning.com/books/github-actions-in-action) (Kaufmann, Bos & de Vries, Manning) covers workflow design, reusable actions, matrix builds, secrets, and self-hosted runners. It pays off once you outgrow the default templates and start wiring the layers above into shared workflows. ## What this gets you - **Regressions caught before merge.** A typed schema at the boundary plus a Playwright check on the critical path catches more shipped bugs than any single layer alone. - **Refactors get safer.** Strict types and a healthy unit and component test suite let you change internals without breaking surface behaviour. - **Onboarding gets shorter.** A new contributor can run one command, see green, and trust that CI will tell them if they break something. - **No one becomes the bottleneck.** The pipeline enforces the standard for accessibility, performance, and i18n, so quality stops riding on a single contributor. ## Picking your testing shape The layers above tell you *what* to run. They don't tell you *how much weight to give each one*. A solo dev shipping a Vite app needs a different mix than a fifty-engineer team coordinating across a dozen services. The industry argues about this in shapes. The classical **[Pyramid](https://martinfowler.com/articles/practical-test-pyramid.html)** (Mike Cohn) puts most of the weight on unit tests and very little on E2E. Kent C. Dodds' **[Trophy](https://kentcdodds.com/blog/the-testing-trophy-and-testing-classifications)** moves the weight to integration tests because frontend bugs live at the component-interaction layer. Spotify's **[Honeycomb](https://engineering.atspotify.com/2018/01/testing-of-microservices)** pushes weight onto integrated and contract tests because in a microservices world, isolation tests prove little and full E2E is brittle. The **Ice-cream cone** is the anti-pattern you end up with by accident: lots of slow E2E on top, almost nothing underneath. The right answer is "it depends," but you can be precise about what it depends on. As web.dev puts it in *Pyramid or Crab*, ["the testing strategy that's right for your team is unique to your project's context"](https://web.dev/articles/ta-strategies). Pactflow, from the contract-testing camp, goes further: at scale, full E2E is [a tax with diminishing returns](https://pactflow.io/blog/proving-e2e-tests-are-a-scam/) once teams and services multiply. The four inputs that change the answer most: - **Team size.** Six developers can keep a real E2E suite green together; sixty cannot. Coordination cost is what kills E2E suites at scale. - **Backend control.** If you don't own the backend, contract testing goes from "nice" to "the only way you can change anything safely". - **Number of services in the flow.** Each new service multiplies the surface that has to be set up, seeded, and reset for an E2E run. - **Deployment cadence.** A daily-merge team can't afford a flaky twenty-minute suite; a quarterly-release team can. Pick yours and the shape that fits will appear: The same pyramid that helps one team can block another from shipping. Pick the shape that fits your constraints. ## Picking your battles You don't need every layer on day one. A reasonable order to add them: 1. TypeScript strict + a fast linter + a formatter, wired into Lefthook so format and lint run on staged files at commit time. 2. Vitest for utilities, run on PR. 3. MSW handlers for any module that hits the network, shared between tests and the dev server. 4. Playwright for the single most important user journey, with hydration and CSP listeners wired into a shared fixture. 5. Contract testing the moment consumer and provider deploy on different cadences. Pact if you control both sides, OpenAPI validation if the provider already publishes a spec. 6. Preview deployments and Lighthouse CI (light *and* dark). 7. OpenTelemetry traces and metrics. Point the SDK at a local Jaeger via `docker-compose` the moment a "why is this slow?" question takes more than five minutes to answer. Same SDK in prod (different OTLP endpoint) once you have real users. 8. Storybook + visual regression once the design system stabilises. 9. Accessibility audits in component tests and E2E. 10. Knip and bundle-size budgets once the codebase has weight. 11. i18n drift checking once you ship a second locale. 12. AI code review and SBOM generation once the project has external stakeholders to answer to: reviewers, customers, or compliance. Each layer should pay for itself in caught regressions or saved review time. Remove the ones that don't. ## Supply chain defaults The pipeline above catches the bugs you write. None of it stops a compromised dependency from running code on your laptop. The 2025–2026 wave of [npm](https://www.npmjs.com/) attacks (Shai-Hulud, the [Rspack](https://rspack.dev/) postinstall cryptominer, the [axios](https://axios-http.com/) 1.14.1 hijack) made the package manager's defaults a real part of your security posture. I use **[pnpm](https://pnpm.io/)** on every new project. Three of its settings do most of the work. **1. Lifecycle scripts blocked by default.** Since pnpm 10, `preinstall` and `postinstall` scripts in dependencies do not run on `pnpm install`. You opt specific packages in via `pnpm.onlyBuiltDependencies` in `package.json`. Most historical supply chain payloads shipped through postinstall, so this default removes one of the biggest attack vectors. **2. `minimumReleaseAge`.** A pnpm 10.16+ setting that refuses to resolve a published version until it is at least *N* minutes old. Set it to `1440` (one day) or `10080` (one week) in `pnpm-workspace.yaml`. Most compromised packages get detected and unpublished within hours, so a 24-hour delay covers the common published-and-pulled incidents. pnpm 11 makes one day the default. Use `minimumReleaseAgeExclude` for the few internal or first-party packages you need to install the moment they ship. **3. `blockExoticSubdeps`.** Refuses transitive dependencies pinned to git repositories or tarball URLs. Closes a common path for typo-squatting and dependency confusion. A minimal `pnpm-workspace.yaml` for a new project: ```yaml minimumReleaseAge: 1440 blockExoticSubdeps: true onlyBuiltDependencies: - esbuild - sharp ``` Pair this with **OSV-Scanner** and **Gitleaks** in CI (layer 11 of the pipeline) and you cover both the install-time and the audit-time sides of supply chain security. ## Related resources If you want to read more or start implementing this: **Tools** - [Vite+](https://viteplus.dev/) – unified toolchain from VoidZero (Vite, Rolldown, Vitest, Oxlint, Oxfmt, Tsdown) - [pnpm supply chain security](https://pnpm.io/supply-chain-security) – the full list of defaults and settings discussed above - [Vitest](https://vitest.dev/) – including [browser mode](https://vitest.dev/guide/browser/) - [Playwright](https://playwright.dev/) - [MSW](https://mswjs.io/) – network-level API mocking for browser and Node - [Pact](https://pact.io/) and [PactFlow](https://pactflow.io/) – consumer-driven and bi-directional contract testing - [Schemathesis](https://schemathesis.readthedocs.io/), [Dredd](https://dredd.org/), [Spectral](https://stoplight.io/open-source/spectral) – OpenAPI-based contract testing and linting - [Lefthook](https://lefthook.dev/) – fast Git hooks manager (modern Husky alternative) - [Storybook](https://storybook.js.org/) - [Knip](https://knip.dev/) - [Oxlint](https://oxc.rs/docs/guide/usage/linter) - [Biome](https://biomejs.dev/) - [Lighthouse CI](https://github.com/GoogleChrome/lighthouse-ci) - [Lunaria](https://lunaria.dev/) – i18n drift detection - [size-limit](https://github.com/ai/size-limit) - [axe-core](https://github.com/dequelabs/axe-core) - [Syft](https://github.com/anchore/syft) – SBOM generation - [CodeRabbit](https://www.coderabbit.ai/), [Greptile](https://www.greptile.com/) – AI code review **Reference** - [The Practical Test Pyramid](https://martinfowler.com/articles/practical-test-pyramid.html) – Ham Vocke's canonical write-up of Mike Cohn's pyramid. - [The Testing Trophy](https://kentcdodds.com/blog/the-testing-trophy-and-testing-classifications) – Kent C. Dodds' model for where to put the weight of your tests. - [Testing of Microservices (Honeycomb)](https://engineering.atspotify.com/2018/01/testing-of-microservices) – Spotify's case for integrated tests over isolated unit tests in a microservices world. - [Pyramid or Crab? Find a testing strategy that fits](https://web.dev/articles/ta-strategies) – web.dev on choosing a shape for your context. - [Proving E2E tests are a scam](https://pactflow.io/blog/proving-e2e-tests-are-a-scam/) – Pactflow's contract-first counter-position. - [Contract Testing in Action](https://www.oreilly.com/library/view/contract-testing-in/9781633437241/) – Marie Cruz & Lewis Prescott (Manning) on consumer-driven and bi-directional contracts in practice. - [GitHub Actions in Action](https://www.manning.com/books/github-actions-in-action) – Kaufmann, Bos & de Vries (Manning) on workflows, reusable actions, matrix builds, and self-hosted runners. - Frontend testing guide: 10 essential rules for naming tests --- --- title: Exploratory QA with AI Agents: Building a Site-Agnostic Harness description: A thin Bun runner that hands a coding agent one charter and lets it drive a real browser through an exploratory QA session. Works with Claude, Codex, or Copilot, and any browser CLI. tags: ['claude-code', 'testing', 'automation', 'ai', 'agent-browser'] url: https://alexop.dev/posts/exploratory-qa-ai-agents-site-agnostic-harness --- # Exploratory QA with AI Agents: Building a Site-Agnostic Harness `explore-qa` is a small Bun runner I built to drive AI agents through exploratory QA sessions. You give it a charter ("explore the basket flow with a mobile viewport"), pick an agent (Claude, Codex, or Copilot), pick a browser CLI (`agent-browser` or `playwright-cli`), and it runs the session. You get back a Markdown report shaped like a real tester's notes: SBTM sections, PROOF debrief, screenshots, full JSONL session log. About 500 lines of TypeScript plus a folder of Markdown. Site-agnostic by construction. Repo: [github.com/alexanderop/explore-qa](https://github.com/alexanderop/explore-qa). Use it as a template, clone it, and run the `/onboard-site` skill to wire it up to your own site. You'll need one of [Claude Code](https://docs.claude.com/en/docs/claude-code/overview), [Codex CLI](https://developers.openai.com/codex/cli), or [GitHub Copilot CLI](https://docs.github.com/en/copilot/concepts/agents/about-copilot-cli) installed to drive the runs. 1. `git clone` the repo, `bun install`, `bun link` 2. Open the folder in Claude Code and run `/onboard-site https://yoursite.com` 3. `qa` to pick a charter, agent, and browser in an interactive wizard 4. Read the report under `qa-runs/charters//` ## A real finding on otto.de Before the deep dive, here's what this actually produces. On a recent run against otto.de's mobile nav, Codex flagged a small UX bug: the **Filter** button (bottom right, highlighted in pink) is rendered on the category overview panel, where there are no products to filter yet. It only belongs on the product listing screen, one level deeper. Nothing broken, no broken-image 404, no crash. The kind of small consistency bug a scripted E2E suite would never notice because nobody wrote a test for "is the filter button absent on this screen." An agent browsing the menu the way a user would noticed it in under seven minutes. ## The problem Most "AI testing" demos I see lean one way: Playwright on autopilot. A script, some selectors, an LLM picking the next click. It passes green, nobody learns anything about the site, and the bug that shipped to production wasn't in the happy path anyway. I wanted the opposite. A tool that behaves like a QA engineer on their first day. Point it at a website. Give it a one-line mission. Let it browse, notice what's weird, file what it finds, and hand back a report I can triage in ten minutes. Agents are the first tool that make this economical: one tester per charter, parallel, at roughly the cost of a cup of coffee per run. ## Overview - **Charter**, one file, one mission. "Explore X with Y to discover Z." - **Site profile**, one file per site. Base URL, viewport, known quirks. - **Harness**, composes a prompt from Markdown fragments, spawns the agent, captures the session. - **Output**, a Markdown report with SBTM + PROOF sections, screenshots, and a full JSONL session log. No hard-coded selectors. No `page.click('.btn-checkout')`. The agent reads `agent-browser --help` at the start of every run and figures out the CLI from scratch. That keeps the harness site-agnostic. In one picture: You write the charter. The harness builds the prompt. The agent runs the session. You read the report. ## What scripted E2E tests are bad at Scripted Playwright and Cypress suites do one job well: catch regressions in flows you *already know about*. They are bad at: - ❌ Finding issues nobody thought to write a test for - ❌ Catching layout breaks on the mobile viewport - ❌ Noticing 404s on the third-party tracking pixel - ❌ Spotting a consent banner that ate the tap target - ❌ Doing an a11y pass on the page you just changed Exploratory testing is the opposite discipline. Kaner & Bach's definition is worth quoting because most people get it wrong: > Exploratory testing is simultaneous learning, test design, and test execution. Learning, test design, and execution happen **in parallel**, not sequentially. Scripts come after, not before. Humans are great at it. LLMs turn out to be surprisingly good at it too, if you build the harness right. ## Six moving parts Each one does exactly one thing. You write a charter. You point it at a site. The harness composes a system prompt plus a user prompt, spawns the agent CLI in fully permissive mode, and the agent drives the browser for the next 5 to 10 minutes while writing a `report.md`. A charter looks like this: ```markdown --- name: otto-pdp-basket includeFragments: - _browser-workflow - _report-format --- # Charter: add to basket from PDP ## Mission > Explore **the product detail page → basket flow** > with **a mobile viewport, no login, a cold cookie** > to discover **whether a user can add an item and see it in the basket**. Time box: ~5 minutes. ## Risks / oracles - Size picker blocks add-to-basket on mobile - Consent banner obscures the CTA - Basket count doesn't update after add ``` That's it. No code. The harness handles everything else. ## The prompt is the config The prompt isn't in TypeScript. It's in Markdown fragments. ``` prompts/ ├── _system.md ← always loaded: "you are a precise, honest QA engineer" ├── _honesty-checks.md ← always loaded: "don't invent findings" ├── _browser-workflow.md ← opt-in: "read {{browser}} --help first" └── _report-format.md ← opt-in: SBTM + PROOF report shape ``` Want to make the agent more aggressive? Edit the Markdown. Want a new report section? Edit the Markdown. There's no DSL, no YAML template, no indirection layer between you and what the agent reads. **The prompt *is* the config.** Rules in `_system.md` apply to every run. Rules in `_browser-workflow.md` only load when a charter opts in via `includeFragments`. Charter bodies stay short because the rules aren't duplicated across every file. Composition is a fan-in: Five or six Markdown files in, one deterministic prompt bundle out. The `promptHash` is what later lets `compare-runs.ts` separate a prompt change from model variance. ## The `{{browser}} --help` trick Early versions of the harness had backend-specific CLI snippets baked into the prompt. Things like `agent-browser session start --mobile`. It worked, until `agent-browser` shipped a new subcommand and half my charters broke overnight. The fix is one line in `_browser-workflow.md`: > Before your first action, run `{{browser}} --help` and read it. Use the commands you see in the live help, not commands you remember. Now the harness is backend-agnostic by construction. Swap `agent-browser` for `playwright-cli` in `qa.local.json`, and the same charter runs against a different CLI with zero prompt changes. ✅ Agent reads live help, uses current commands ❌ Agent relies on memorized subcommands, breaks on every CLI update ## Three agents, three incompatible CLIs Here's what the actual invocations look like for the same charter on each backend. I pulled these from [Claude Code's CLI reference](https://code.claude.com/docs/en/cli-reference), [Codex's `exec` reference](https://developers.openai.com/codex/cli/reference), and [GitHub Copilot CLI's docs](https://docs.github.com/en/copilot/concepts/agents/about-copilot-cli). They all do roughly the same thing. None of them agree on how to do it. **Claude Code:** ```bash claude -p "$PROMPT" \ --model claude-opus-4-6 \ --output-format stream-json \ --include-partial-messages \ --verbose \ --append-system-prompt "$SYSTEM_PROMPT" \ --add-dir "$RUN_DIR" \ --permission-mode bypassPermissions ``` **Codex CLI:** ```bash # Codex has no --system-prompt flag. It reads AGENTS.md from --cd. echo "$SYSTEM_PROMPT" > "$RUN_DIR/AGENTS.md" codex exec \ --cd "$RUN_DIR" \ -m gpt-5.4 \ --dangerously-bypass-approvals-and-sandbox \ --json \ -o "$LOG_DIR/codex-last-message.txt" \ "$PROMPT" ``` **GitHub Copilot CLI:** ```bash # Copilot also reads AGENTS.md from its working directory. echo "$SYSTEM_PROMPT" > "$RUN_DIR/AGENTS.md" cd "$RUN_DIR" && copilot \ -p "$PROMPT" \ --model gpt-5.4 \ --allow-all-tools \ --add-dir "$(pwd)" \ --output-format json ``` Look at how many things differ between three CLIs that all do the same job: | Concern | Claude | Codex | Copilot | | :------------------ | :------------------------------ | :--------------------------------------- | :--------------------------- | | Non-interactive | `-p` | `exec` | `-p` | | System prompt | `--append-system-prompt "TEXT"` | write `AGENTS.md` in `--cd` | write `AGENTS.md` in cwd | | Permission bypass | `--permission-mode bypassPermissions` | `--dangerously-bypass-approvals-and-sandbox` | `--allow-all-tools` | | Working directory | `--add-dir` | `--cd` / `-C` | process `cwd` + `--add-dir` | | Model flag | `--model` | `-m` | `--model` | | Output format | `stream-json` | `--json` (NDJSON) | `json` | | Final answer | in the stream | `--output-last-message FILE` | in the JSON payload | This is the argument for a runner. Not because the CLIs are hard on their own, they aren't. The moment you want to run the *same* charter across all three, you end up with three invocation templates, three ways of handing over a system prompt, and three output formats to parse on the way back. Without the runner, A/B testing one prompt across three agents means: 1. Write three shell scripts (one per CLI) that each build the right flag combo. 2. Materialize `AGENTS.md` for Codex and Copilot but use `--append-system-prompt` for Claude. 3. Parse `stream-json` for Claude, NDJSON events for Codex, and a JSON blob for Copilot, with different "where's the final answer" rules for each. 4. Re-read all three docs every time a CLI cuts a release, because the flags drift. With the runner, it's one switch statement in `scripts/lib/agents.ts` and one command: ```bash qa otto-pdp-basket claude agent-browser otto qa otto-pdp-basket codex agent-browser otto qa otto-pdp-basket copilot agent-browser otto ``` Same prompt (identical `promptHash`), same charter, same report shape. The per-CLI quirks are one file deep. ## Two browsers, same charter The same story plays out one layer down at the browser tool. `explore-qa` works with two browser CLIs today, [`agent-browser`](https://www.npmjs.com/package/agent-browser) and Microsoft's [`playwright-cli`](https://github.com/microsoft/playwright-cli). They're also incompatible in all the ways that matter to a script. Here's the same "open a page, snapshot it, click the primary button, screenshot the result" flow on each: **agent-browser** (flat, stateful, one global session): ```bash agent-browser open https://example.com agent-browser set device "iPhone 15 Pro" agent-browser snapshot # accessibility tree with @refs agent-browser click @ref_07 agent-browser screenshot /abs/path/after-click.png agent-browser console # view logs agent-browser close ``` **playwright-cli** (named sessions, YAML snapshots with `e`-refs): ```bash export PLAYWRIGHT_CLI_DEVICE="iPhone 15 Pro" # env-var device emulation playwright-cli -s=otto open https://example.com playwright-cli -s=otto snapshot # writes YAML with e1, e15, e34… playwright-cli -s=otto click e15 playwright-cli -s=otto screenshot --filename=after-click.png playwright-cli -s=otto close ``` Same flow, very different shape: | Concern | `agent-browser` | `playwright-cli` | | :------------------- | :--------------------------------- | :---------------------------------------- | | Navigate | `open ` | `open [url]` then `goto ` | | Element identity | `@ref` from live snapshot | `e1`, `e15`, `e34` from YAML snapshot | | Device emulation | `set device "iPhone 15 Pro"` | `PLAYWRIGHT_CLI_DEVICE` env var | | Sessions | one implicit global session | named sessions via `-s=` | | Typing into an input | `type ` | `fill e5 ` (or `type "text"`) | | Screenshot target | positional path arg | `--filename=` flag | | Allow-tool pattern | `Bash(agent-browser:*)` | `Bash(playwright-cli:*)` | The runner abstracts this with two lines of code in [`scripts/lib/browsers.ts`](https://github.com/alexanderopalic/explore-qa/blob/main/scripts/lib/browsers.ts) and one line of prompt in `_browser-workflow.md`: ```markdown Run `{{browser}} --help` **first** to learn the available commands and flags. ``` That's the whole trick. Instead of teaching the agent two CLI syntaxes up front, we teach it to *discover* the CLI at runtime. The agent reads the live help, picks the subcommands it sees, and runs. Same charter, same prompt, same report. When `agent-browser` ships a new subcommand tomorrow, the agent picks it up on the next run without a prompt edit. Swap backends with one line in `qa.local.json`: ```json { "browser": "playwright-cli" } ``` Or override per invocation: ```bash qa otto-pdp-basket claude agent-browser otto qa otto-pdp-basket claude playwright-cli otto ``` Two runs, two different browser CLIs, identical `promptHash`. If the results disagree, the delta is the browser tool. Not a prompt change, not model drift, not you. That's the kind of signal scripted suites never give you. ## Replaying one prompt across agents The `/agent-battle` skill runs the same charter in parallel across all three agent CLIs: ``` > /agent-battle otto-cart-to-checkout.md • Charter: otto-cart-to-checkout, site: otto. Starting all three in parallel. • Bash(qa otto-cart-to-checkout claude agent-browser otto) • Bash(qa otto-cart-to-checkout codex agent-browser otto) • Bash(qa otto-cart-to-checkout copilot agent-browser otto) • All three agents launched. ``` The fan-out: Output: a side-by-side comparison on speed, findings, discipline, and report quality. Useful for two things: 1. **Picking which agent to trust on a given site.** They don't perform equally. Claude tends to be more thorough, Codex tends to be faster, Copilot tends to follow the report format most literally. 2. **Catching prompt regressions.** If two agents suddenly diverge after a prompt tweak, the tweak probably hurts one of them. ### A real disagreement Here's an actual run against otto.de's product detail to basket flow from a week ago (`qa-runs/charters/otto-pdp-basket/2026-04-18_battle-summary.md`): | agent | duration | findings | severity | | :------ | :------- | :------- | :------- | | claude | 07:57 | 0 | n/a | | codex | 06:53 | **1** | **Major** | | copilot | 09:26 | 0 | n/a | Codex flagged a Major finding: "Basket totals stay at single-item values after quantity increases to 2." Claude and Copilot ran the *same* prompt on the *same* product in the *same* timeframe (identical `promptHash`), and both observed the subtotal updating correctly from 39,99 € to 79,98 €. Copilot specifically reran to rule out transient UI state. One of them had to be wrong. Because the prompt hashes matched, the disagreement was genuinely agent variance. Not prompt drift, not me editing a fragment, not a model upgrade. That narrowed triage to "whose evidence is stronger?" in about two minutes. (Codex's finding came from a single DOM read after a 4-second wait. The other two did a reload and a second read. Codex was wrong.) Without the harness I'd have run one agent, trusted its report, and either shipped a non-bug as Major or dismissed a real one. Three reports under one `promptHash` made the disagreement itself the signal. ## SBTM + PROOF: why the report looks like a tester's notes The report format isn't my invention. It's [Session-Based Test Management](https://www.satisfice.us/articles/sbtm.pdf) (James and Jonathan Bach, 2000) plus PROOF, Jon Bach's five-point debrief shape. Every report has these sections: - **Session**, what was tested, what wasn't - **Task breakdown**, how time was split (design, investigation, setup) - **Findings**, each one with severity, repro, screenshot - **Accessibility**, landmarks, focus order, alt text - **PROOF debrief**, Past, Results, Obstacles, Outlook, Feelings That last one matters. *Feelings* is where the agent rates its own confidence, and it's the first thing I read when triaging. A low-confidence PROOF entry is a signal to rerun with a different agent, not to ship the finding. Read Elisabeth Hendrickson's [*Explore It!*](https://pragprog.com/titles/ehxta/explore-it/) Chapter 2 and Michael Bolton's [*An Exploratory Tester's Notebook*](https://www.developsense.com/presentations/2007-10-PNSQC-AnExploratoryTestersNotebook.pdf) for the full version. Everything in the prompt fragments is downstream of those three sources. ## What I'd do differently A few things I learned the hard way: - **Don't stuff the charter with oracles.** Three or four risks, each with a one-line oracle, is the sweet spot. More than that and the agent turns the charter into a checklist, which kills exploratory behavior. - **Screenshots only on findings and key states.** Every screenshot is tokens on the return trip. An agent that takes a screenshot after every click runs out of context before it reaches the bug. - **Let the agent break script.** `_system.md` explicitly tells the agent: "If something unusual catches your eye, follow the trail even if it isn't in the scenarios." Most of the interesting findings come from the deviation, not the script. - **Permission mode is all-or-nothing.** Claude runs with `--permission-mode bypassPermissions`, Codex with `--dangerously-bypass-approvals-and-sandbox`, Copilot with `--allow-all-tools`. Non-interactive charter runs cannot stop to ask for approval. You either trust the agent with full shell access for the run, or you don't run it at all. ## Related posts - How to Use Claude Code as an AI QA Tester with Agent Browser, the single-charter version that predated this harness - Building an AI QA Engineer with Claude Code and Playwright MCP, the MCP-based cousin - Spec-Driven Development with Claude Code in Action, how I plan non-trivial work with parallel subagents The agent forgets. Your charters don't. --- --- title: How to Use Claude Code as an AI QA Tester with Agent Browser description: Claude Code and Agent Browser let you test your web app in a real browser without hardcoded selectors. Manual browser control, AI-driven exploration, and structured JSON output. tags: ['claude-code', 'testing', 'automation', 'agent-browser'] url: https://alexop.dev/posts/automated-qa-claude-code-agent-browser-cli-github-actions --- # How to Use Claude Code as an AI QA Tester with Agent Browser ## Overview Claude Code and [Agent Browser](https://github.com/vercel-labs/agent-browser) let you test your web app in a real browser without hardcoded selectors. You point Claude at your app, and it clicks through pages, fills forms, and checks for errors. If the layout changes or a new onboarding flow appears, Claude adjusts. No selectors to maintain. You'll need a web app with a dev server and basic familiarity with the command line and Claude Code. You'll walk away knowing how to: - Control a browser from the terminal using Agent Browser's snapshot-and-ref model - Run one-shot AI-powered browser exploration with `claude -p` - Separate QA persona from task instructions using `--system-prompt` and `--append-system-prompt` - Get machine-readable structured JSON output with `--output-format json` and `--json-schema` Part 1 covers Agent Browser and `claude -p` locally. Part 2 wraps it into a GitHub Actions workflow that runs on every PR. ## Table of Contents ## Background ### The problem I'm building a workout tracking PWA, a Vue 3 app with exercises, workout flows, templates, timers, and interactive forms. You fix something on one page and break something on another. I had unit tests and integration tests with Vitest, but no answer to: **does the app work when a real user clicks through it?** Manual QA is tedious. I'd deploy a change, open the app, click around for a few minutes, and call it done. Half the time I'd skip pages I didn't think were affected. I shipped bugs to production: a form that fails without feedback, a navigation link pointing nowhere, a JS error on a page I forgot to check. I wanted something that could open the app in a real browser, click around like a user, and flag what's broken. Scripted Playwright tests with hardcoded selectors need constant maintenance. I wanted a test runner that could handle a changed layout without a rewrite. ### What we're building An AI-powered QA workflow where Claude Code drives a real browser via Agent Browser. A single terminal command that: 1. Opens your app in a real browser 2. Claude explores it like a user: clicking navigation, filling forms, checking for JS errors 3. Returns a structured JSON report with any bugs found Part 2 wraps this into a GitHub Actions workflow that runs on PRs and posts bug reports as comments. ### How Agent Browser works [Agent Browser](https://github.com/vercel-labs/agent-browser) is a native Rust CLI for browser automation. You issue commands one at a time, which makes it a natural fit for AI agents. It uses Chrome for Testing under the hood, so no Playwright or Node.js runtime is required. The key concept is **element refs**. `snapshot` returns an accessibility tree where every interactive element has a ref like `@e1`, `@e2`. Claude reads the snapshot, decides what to click, and uses the ref. ```bash agent-browser open https://www.otto.de # Open a URL agent-browser snapshot -i # Get interactive elements with refs agent-browser click @e2 # Click element by ref agent-browser fill @e3 "value" # Fill an input agent-browser console # Check for JS errors agent-browser screenshot # Take a screenshot agent-browser close # Close browser ``` Claude repeats this loop until it has enough information to report: A concrete example: 1. Claude runs `snapshot -i` and sees: `nav[@e10] > a[@e11] "Home" | a[@e12] "Workouts" | a[@e13] "Settings"` 2. Claude decides to test navigation and runs `click @e12` 3. Claude runs `snapshot -i` again and sees the Workouts page loaded 4. Claude concludes navigation works Agent Browser uses a client-daemon architecture. The daemon starts on the first command and persists between commands, so subsequent operations are fast. ## Before you start Before you start, you should: - Have a web app with a dev server (we use Vite, but any web app works; the examples use `https://www.otto.de`) - Have Node.js and npm installed - Have Claude Code installed (`npm install -g @anthropic-ai/claude-code`) ## Step 1: Agent Browser by hand Before letting Claude drive, get comfortable with Agent Browser yourself. Any public website works. 1. Install Agent Browser globally and set up the browser: ```bash npm install -g agent-browser agent-browser install # Download Chrome from Chrome for Testing ``` 2. Open a website: ```bash agent-browser open https://www.otto.de ``` You'll see: ```txt ✓ OTTO - Mode, Möbel & Technik » Zum Online-Shop https://www.otto.de/ ``` 3. Take a snapshot to see the page structure: ```bash agent-browser snapshot -i ``` The `-i` flag shows only interactive elements. You'll see something like: ```txt - button "Zur Hauptnavigation" [ref=e1] - link "zur Homepage" [ref=e2] - searchbox "Wonach suchst du?" [ref=e3] - button "Suche abschicken" [ref=e4] - link "s Service" [ref=e5] - link "Θ Mein Konto" [ref=e6] - link "Merkzettel" [ref=e7] - link "Inspiration" [ref=e8] - link "Damen-Mode" [ref=e9] - link "Herren-Mode" [ref=e10] - link "Baby & Kind" [ref=e11] ``` Each `[ref=eN]` is a handle you can interact with using `@eN`. 4. Try clicking a nav link: ```bash agent-browser click @e9 ``` The browser navigates to the Damen-Mode page. Take another snapshot to see the new page: ```bash agent-browser snapshot -i ``` 5. Try filling the search box and submitting: ```bash agent-browser fill @e3 "Sneaker" agent-browser press Enter ``` 6. Check for JavaScript errors: ```bash agent-browser console ``` 7. Close the browser when you're done: ```bash agent-browser close ``` Claude runs the same commands in the same order. It reads the snapshot output, picks what to click next, and loops until it has a report. ## Step 2: Let Claude drive with `claude -p` `claude -p` runs Claude Code with a one-shot prompt from the terminal. No interactive session, no back-and-forth. 1. Run Claude with a simple exploration prompt. Say "agent-browser cli" in the prompt. If you leave out "cli", Claude doesn't know which tool to use: ```bash claude -p "Open https://www.otto.de using agent-browser cli. \ Take a snapshot. Click one navigation link. \ Take another snapshot. \ Tell me what you found." \ --allowedTools "Bash(agent-browser*)" ``` Watch it work. You'll see each tool call in real time. Outcome: ```txt Here's what I found: Snapshot 1 — otto.de Homepage: - OTTO is a major German online shop for fashion, furniture, and electronics - The top navigation has categories: Inspiration, Damen-Mode, Herren-Mode, Baby & Kind, Sport, Beauty & Drogerie, Multimedia, Haushalt, Küche, Heimtextilien, Möbel, Baumarkt, Marken, Sale - Several promotional banners were visible: - 10 EUR coupon for new customers (valid until April 30) - 20% extra on fashion & sport (valid until April 13) - 20% extra for OTTO-Up members on selected furniture - 10% extra on Samsung TVs & soundbars (valid until April 20) Snapshot 2 — Multimedia category page: After clicking "Multimedia", the page showed a structured product catalog with subcategories: - Fernseher (TVs): OLED (69), 4K (480), QLED (236), Smart-TVs (428), LED (555), 8K (2) - Heimkino: Surround systems (186), Soundbars (228), Beamer/Projectors (603), Blu-ray players (20), Subwoofers (651), DVD players (76) - Additional categories for audio, computing, gaming, and accessories The site is fully functional, well-structured with clear navigation, and currently running several spring sales promotions. ``` 2. Now give Claude a more structured QA prompt: ```bash claude -p "You are a QA tester. Open https://www.otto.de using agent-browser cli. Test the following: 1. Does the homepage load? (take a snapshot) 2. Does navigation work? (click 2 links, snapshot each) 3. Are there any JS errors? (check console) Report your findings as: - PASS or FAIL for each test - Any bugs found" \ --allowedTools "Bash(agent-browser*)" ``` Outcome: ```txt QA Report: otto.de Test 1: Homepage Load — PASS - URL: https://www.otto.de/ - Title: "OTTO - Mode, Möbel & Technik » Zum Online-Shop" - Page rendered fully with banner, search bar, navigation, promotional coupons, and product categories Test 2: Navigation — PASS (both links) 1. Damen-Mode → https://www.otto.de/damen/ — PASS 2. Multimedia → https://www.otto.de/technik/multimedia/ — PASS - Both pages loaded correctly with proper titles and content Test 3: JS Errors — PASS - agent-browser errors returned no page errors across all 3 pages - agent-browser console returned no console warnings/errors Bugs Found: None. All tests passed cleanly with no JS errors or navigation issues. ``` ## Step 2b: Separate persona from task with system prompts In the previous step, the QA persona ("You are a QA tester") was mixed into the task prompt. Claude Code has flags to separate these concerns: `--system-prompt` replaces the default system prompt, and `--append-system-prompt` adds to it. The Step 2 example rewritten with `--append-system-prompt`: ```bash claude -p "Open https://www.otto.de using agent-browser cli. Test the following: 1. Does the homepage load? (take a snapshot) 2. Does navigation work? (click 2 links, snapshot each) 3. Are there any JS errors? (check console) Report your findings as: - PASS or FAIL for each test - Any bugs found" \ --append-system-prompt "You are a senior QA engineer. Always use \ 'agent-browser snapshot -i' for interactive elements only. Be concise \ and structured in your reports." \ --allowedTools "Bash(agent-browser*)" \ --max-turns 15 ``` Outcome: ```txt ## QA Report: otto.de | # | Test | Result | |---|------|--------| | 1 | **Homepage loads** | **PASS** — Title: "OTTO - Mode, Möbel & Technik » Zum Online-Shop". Full nav bar, search, product recommendations, and brand sections all rendered. | | 2a | **Navigation: Multimedia** | **PASS** — Clicked "Multimedia" nav link. Category page loaded with subcategories (Fernseher, Heimkino, Audio, etc.) with product counts. | | 2b | **Navigation: Möbel** | **PASS** — Clicked "Möbel" nav link. Category page loaded with subcategories (Wohnzimmer, Schlafzimmer, etc.) with product counts. | | 3 | **JS console errors** | **PASS** — No JavaScript errors detected. No failed resource loads (HTTP 4xx/5xx). | **Bugs found:** None. All 4 checks passed. The site loads correctly, navigation between category pages works as expected, and no client-side errors were observed. ``` Now the task prompt describes *what* to test. You put the persona and behavioral rules in `--append-system-prompt`, which applies them on every turn without cluttering the task. ### `--system-prompt` vs `--append-system-prompt` - **`--append-system-prompt`** adds your instructions on top of Claude Code's defaults. Claude still knows how to use Bash, handle errors, etc. Best for most cases. - **`--system-prompt`** replaces the entire default system prompt. You get full control but lose built-in behaviors. Use this when you want a stripped-down agent that only runs `agent-browser` commands. ### Other useful flags | Flag | Purpose | |---|---| | `--max-turns N` | Caps how many tool-call turns Claude gets. Match to your prompt's scope: 5-8 for a single assertion, 15 for a smoke test, 30+ for deep exploration. | | `--model` | Pick the model. Use `opus` for thorough QA where you want Claude to reason carefully, `sonnet` for fast smoke tests. | | `--output-format json` | Machine-readable output (covered in Step 4). | ### Example with all flags ```bash claude -p "Open https://www.otto.de using agent-browser cli. Navigate to every page in the main navigation. Check console for JS errors on each page. Report PASS/FAIL per page." \ --append-system-prompt "You are a senior QA engineer testing a \ web shop. Use 'agent-browser snapshot -i' for interactive \ elements only. Be concise." \ --allowedTools "Bash(agent-browser*)" \ --max-turns 20 \ --model opus ``` ## Step 3: Try it on your own app 1. Point Claude at your app (here we use otto.de, but swap in your own URL): ```bash claude -p "Open https://www.otto.de using agent-browser. \ Take a snapshot. Navigate to 3 different pages. \ Check console for JS errors on each page. \ Report what you found." \ --allowedTools "Bash(agent-browser*)" ``` ## Step 4: Get structured JSON output Free-text output works for manual review, but you can't branch a shell script on "PASS" buried in a markdown table. Claude's output is non-deterministic. You can't predict the exact wording. But your downstream decisions are deterministic: fail the build or don't, post a comment or don't. `--json-schema` closes that gap. You define the output shape upfront, and Claude conforms to it. Claude still explores pages, interprets errors, and judges severity, but you get a structured JSON object with known fields and value types. Your script reads `verdict` and branches. `--output-format json` returns a JSON object with session metadata and the result. `--json-schema` forces Claude's response to conform to your schema, landing in a `structured_output` field. 1. Run Claude with a JSON schema to get a structured QA report: ```bash claude -p "Open https://www.otto.de using agent-browser cli. Take a snapshot to verify the page loads. Click one navigation link and take another snapshot. Check the console for JS errors. Return your findings as structured JSON." \ --output-format json \ --json-schema '{ "type": "object", "properties": { "verdict": { "type": "string", "enum": ["HEALTHY", "MINOR_ISSUES", "CRITICAL_BUGS"] }, "summary": { "type": "string" }, "bugs": { "type": "array", "items": { "type": "object", "properties": { "severity": { "type": "string", "enum": ["critical", "major", "minor"] }, "title": { "type": "string" }, "description": { "type": "string" } }, "required": ["severity", "title", "description"] } } }, "required": ["verdict", "summary", "bugs"] }' \ --allowedTools "Bash(agent-browser*)" ``` The full JSON response looks like this (trimmed for readability): ```json { "type": "result", "subtype": "success", "is_error": false, "duration_ms": 78276, "duration_api_ms": 71827, "num_turns": 13, "result": "**Results:**\n\n| Step | Result |\n|------|--------|\n| Open https://www.otto.de | Loaded — title: \"OTTO - Mode, Möbel & Technik » Zum Online-Shop\" |\n| Snapshot #1 (homepage) | Full navigation rendered |\n| Click \"Multimedia\" nav link | Navigated to /technik/multimedia/ |\n| Snapshot #2 (Multimedia page) | Page rendered with breadcrumb, category heading, and sub-navigation |\n| Console JS errors | **None detected** |\n\n**Verdict: HEALTHY**", "total_cost_usd": 0.32, "structured_output": { "verdict": "HEALTHY", "summary": "otto.de loads successfully with full navigation, promotional banners, and product content. Navigation to the Multimedia section (/technik/multimedia/) works correctly. No JavaScript console errors or failed resource loads were detected.", "bugs": [] } } ``` The `result` field contains Claude's free-text response. The `structured_output` field contains the validated JSON that conforms to your schema. You also get metadata like `duration_ms`, `num_turns`, and `total_cost_usd`. 2. Extract the structured output with `jq`: ```bash # Pipe the full output through jq to get just the structured data claude -p "..." --output-format json --json-schema '...' \ --allowedTools "Bash(agent-browser*)" \ | jq '.structured_output' ``` Output: ```json { "verdict": "HEALTHY", "summary": "otto.de loads successfully with full navigation, promotional banners, and product content. Navigation to the Multimedia section (/technik/multimedia/) works correctly. No JavaScript console errors or failed resource loads were detected.", "bugs": [] } ``` 3. Use it in a script to make pass/fail decisions: ```bash VERDICT=$(claude -p "..." --output-format json --json-schema '...' \ --allowedTools "Bash(agent-browser*)" \ | jq -r '.structured_output.verdict') if [ "$VERDICT" = "CRITICAL_BUGS" ]; then echo "QA failed!" exit 1 fi ``` Inside `claude -p`, Claude explores the app, interprets what it sees, and judges severity. Outside, the `if` statement and `exit 1` are plain bash. `--json-schema` gives you a contract between the two. Without it, you'd grep Claude's prose for the word "CRITICAL" and hope it doesn't rephrase. This same mechanism powers the GitHub Actions workflow in Part 2. Get it working locally first, fewer surprises in CI. ## Summary You now have: - Browser control from the terminal with Agent Browser's snapshot-and-ref model - One-shot AI-powered exploration with `claude -p` - Persona separation via `--append-system-prompt`, execution control via `--max-turns` and `--model` - Structured JSON output with `--output-format json` and `--json-schema` All of this runs locally. Part 2 moves it into CI: a GitHub Actions workflow using Claude Code Action that runs Claude as a QA agent on every PR, posts structured bug reports as comments, and sets commit statuses (green/yellow/red). Coming soon. ## Next steps - Building an AI QA Engineer with Playwright MCP, the MCP-based version with richer browser control - Claude Code Skills, Hooks, and Subagents, creating reusable skills for your QA prompts - Claude Code Customization Guide, deeper coverage of CLAUDE.md, skills, and subagents - [Agent Browser documentation](https://github.com/vercel-labs/agent-browser), full command reference and setup guide --- --- title: How to Catch Hydration Errors in Playwright Tests (Astro, Nuxt, React SSR) description: A Playwright fixture that listens to the browser console for hydration mismatch warnings and fails your E2E tests when server and client HTML disagree. tags: ['testing', 'javascript', 'performance'] url: https://alexop.dev/posts/catch-hydration-errors-playwright-tests --- # How to Catch Hydration Errors in Playwright Tests (Astro, Nuxt, React SSR) Your E2E tests pass. The page loads, buttons work. But open the browser console: ```text Hydration failed because the server rendered HTML didn't match the client. ``` This is a hydration mismatch. The server sent one thing and the client replaced it with something else. The page still works, so you don't notice. Your tests don't check for it, so they pass. ## What are SSR and hydration? **SSR (server-side rendering)** means the server generates HTML and sends it to the browser before JavaScript loads. Users see content before client code boots, and search engines can index it. Astro and Nuxt build on this model. **Hydration** is the next step: client JavaScript takes over the server-rendered HTML, attaching event handlers and state to the existing markup. The contract: the first client render must match what the server sent. When it does not match, the framework discards the server HTML and re-renders on the client. That re-render is a hydration mismatch. ### Common causes Anything that produces different HTML on client and server: - Reading `localStorage` or `window.matchMedia()` during render - Calling `new Date()` or `Math.random()` during render - Formatting dates or numbers differently across server and client - Rendering conditional branches based on browser-only state Theme toggles and locale formatting cause most of them. If you use Vue with SSR, the `window is not defined` error comes from the same root cause. VueUse has a pattern for it: ## A real bug I found I was working on an Astro page and my theme hook was reading browser state during the first render: ```ts function getInitialTheme(): Theme { const stored = localStorage.getItem(SITE.themeStorageKey); if (stored === "light" || stored === "dark") return stored; return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; } export function useTheme() { const [theme, setTheme] = useState(getInitialTheme); } ``` The server defaulted to `dark`, but the browser picked `light`. React saw the mismatch, logged a hydration warning, and re-rendered from scratch. The page still worked, the button still existed. Normal E2E tests passed. **The fix:** start with a deterministic value, resolve browser state after mount. ```ts export function useTheme() { const [theme, setTheme] = useState("dark"); const [mounted, setMounted] = useState(false); useEffect(() => { const preferredTheme = getPreferredTheme(); document.documentElement.classList.toggle("dark", preferredTheme === "dark"); setTheme(preferredTheme); setMounted(true); }, []); useEffect(() => { if (!mounted) return; const root = document.documentElement; root.classList.toggle("dark", theme === "dark"); localStorage.setItem(SITE.themeStorageKey, theme); }, [mounted, theme]); return { theme, setTheme, toggleTheme: () => setTheme((t) => (t === "dark" ? "light" : "dark")) }; } ``` ## The core idea **Listen to the browser console during a Playwright test. If a hydration warning appears, fail the test.** React and Vue log hydration mismatches to the console. You don't check the console during automated tests, so this fixture does. ## The fixture Fixtures are Playwright's way of setting up and tearing down what each test needs. Built-in fixtures like `page` and `browser` come for free. You create custom ones with `base.extend()`. Each fixture runs when a test requests it and gets cleaned up afterward. The fixture below injects `hydrationErrors` and `runtimeErrors` into every test that asks for them. I first saw this approach in the [npmx.dev](https://npmx.dev) open source project and adapted it for my Astro site. My version covers React and Vue hydration strings and catches uncaught runtime exceptions: ```ts const HYDRATION_ERROR_PATTERNS = [ /hydration failed because the server rendered html didn't match the client/i, /hydration completed but contains mismatches/i, /hydration text content mismatch/i, /hydration node mismatch/i, /hydration attribute mismatch/i, ]; function isHydrationError(text: string): boolean { return HYDRATION_ERROR_PATTERNS.some((pattern) => pattern.test(text)); } function toConsoleText(message: ConsoleMessage): string { return message.text().trim(); } export const test = base.extend<{ hydrationErrors: string[]; runtimeErrors: string[]; }>({ hydrationErrors: async ({ page }, use) => { const hydrationErrors: string[] = []; const handleConsole = (message: ConsoleMessage) => { const text = toConsoleText(message); if (isHydrationError(text)) { hydrationErrors.push(text); } }; page.on("console", handleConsole); await use(hydrationErrors); page.off("console", handleConsole); }, runtimeErrors: async ({ page }, use) => { const runtimeErrors: string[] = []; const handleConsole = (message: ConsoleMessage) => { const text = toConsoleText(message); if (message.type() === "error" && text.length > 0 && !isHydrationError(text)) { runtimeErrors.push(text); } }; const handlePageError = (error: Error) => { runtimeErrors.push(error.message); }; page.on("console", handleConsole); page.on("pageerror", handlePageError); await use(runtimeErrors); page.off("console", handleConsole); page.off("pageerror", handlePageError); }, }); export { expect }; ``` Drop this into `test/e2e/test-utils.ts` and import from there instead of `@playwright/test`. Related: a full AI-driven QA workflow with Playwright: ## Using it ```ts test("home page hydrates cleanly", async ({ page, hydrationErrors, runtimeErrors }) => { await page.goto("/", { waitUntil: "domcontentloaded" }); await expect(page.getByRole("heading", { name: "Home" })).toBeVisible(); expect(hydrationErrors).toEqual([]); expect(runtimeErrors).toEqual([]); }); ``` Start with your homepage. Add one interactive route, then one with a theme toggle or client-only widget. That surfaces most bugs. ## How npmx.dev does it at scale The [npmx.dev](https://npmx.dev) project tests hydration correctness for every combination of user settings across every page, around 48 checks from a single fixture. They inject `localStorage` values via Playwright's `page.addInitScript()` before navigation, simulating a returning user with saved preferences. Returning users with non-default settings trigger most hydration mismatches. ```ts const PAGES = ["/", "/about", "/settings", "/compare", "/search", "/package/nuxt"]; test.describe("color mode: dark", () => { for (const page of PAGES) { test(`${page}`, async ({ page: pw, goto, hydrationErrors }) => { await injectLocalStorage(pw, { "npmx-color-mode": "dark" }); await goto(page, { waitUntil: "hydration" }); expect(hydrationErrors).toEqual([]); }); } }); async function injectLocalStorage(page: Page, entries: Record) { await page.addInitScript((e: Record) => { for (const [key, value] of Object.entries(e)) { localStorage.setItem(key, value); } }, entries); } ``` They repeat this for every setting type, locale, accent color, background theme, package manager, relative dates, each with a non-default value. If any combination causes a hydration mismatch on any page, the test fails. Their fixture uses Vue-specific error strings (`"Hydration completed but contains mismatches"`) while mine uses React patterns. The approach is the same, only the strings you match against change. More on how E2E tests relate to unit and integration tests: If you ship an SSR app and do not check for hydration errors in your browser tests, you have one in production right now. --- --- title: App Screenshots: An AI Coding Agent Skill for Visual Documentation description: Automate annotated screenshot documentation for any web app or live website using an AI coding agent skill. Works with Claude Code, Cursor, Windsurf, or any agent that supports custom skills. tags: ['ai', 'coding-agents', 'tooling'] url: https://alexop.dev/posts/app-screenshots-claude-code-skill --- # App Screenshots: An AI Coding Agent Skill for Visual Documentation ## TLDR I built a [skill](https://github.com/alexanderop/app-screenshots) that takes annotated screenshots of any web app or live website and generates a markdown visual guide. It works with Claude Code, Cursor, Windsurf, or any AI coding agent that supports custom skills. ## What Is a Skill? A skill is a markdown file with instructions for a coding agent. It describes how to perform a specific task and can reference CLI tools, APIs, or other resources. You write the steps in markdown, the agent follows them. A general-purpose coding agent with the right skill becomes a specialist, no code changes needed. For a deeper look at how skills fit into the broader ecosystem, see the guide to CLAUDE.md, skills, and subagents. ## The Problem Documenting UIs is tedious. You open the app, take a screenshot, annotate it in some tool, write descriptions, repeat for every page. Automate it. ## How It Works The skill uses [`agent-browser`](https://github.com/vercel-labs/agent-browser), a headless browser automation CLI built by Vercel specifically for AI agents. Instead of heavy tools like Playwright, it provides a lightweight snapshot + refs system that lets agents navigate, click, and screenshot pages with minimal context usage. It works with Claude Code, Codex, Cursor, Gemini CLI, and more. You point your coding agent at a local dev server or a public URL and it does the rest: 1. **Discovers pages** by reading the site's navigation 2. **Screenshots each page** with SVG annotations injected directly into the DOM 3. **Generates a markdown file** with numbered references to each annotation Annotations come in three types: `box` for sections, `click` for interactive elements, and `circle` for general callouts. Each screenshot gets up to 3 annotations with auto-rotating colors. If you like auto-generated visual docs, the walkthrough skill does something similar for codebases with interactive Mermaid diagrams. ```markdown ## Homepage The landing page shows a hero banner with seasonal promotions. Use the **Search** bar (1) to find products. The **Category navigation** (2) provides access to all departments. ![Homepage](screenshots/01-homepage.png) ``` ## Live Sites Just Work It handles cookie banners, lazy-loaded content, bot protection. You can document `otto.de`, `github.com`, or your own staging environment with the same command. ## Usage Install the prerequisite: ```bash npm install -g agent-browser && agent-browser install ``` Then tell your coding agent: ``` "Screenshot the app" "Document otto.de with screenshots" "Give me a visual guide of the checkout flow" ``` The skill figures out whether you mean a local dev server or a live URL and adapts accordingly. ## Source GitHub: [github.com/alexanderop/app-screenshots](https://github.com/alexanderop/app-screenshots) If you're building your own skill library, see how I built a skill for searching Claude's conversation history. --- --- title: The Software Factory: Why Your Team Will Never Work the Same Again description: We already have everything we need to build software factories. Teams will change. The only variable is speed. tags: ['ai', 'software-engineering', 'teams', 'future', 'claude-code', 'software-factory'] url: https://alexop.dev/posts/the-software-factory --- # The Software Factory: Why Your Team Will Never Work the Same Again ## Table of Contents ## We Already Have Everything We Need
The current models and tooling are enough to build software factories. Today. In a software factory, developers stop writing code by hand. AI coding agents implement features and fix bugs while developers design and improve the factory. Or as Lee Edwards from Root Ventures [put it](https://sfstandard.com/2026/02/19/ai-writes-code-now-s-left-software-engineers/): > "It's like giving them a nuclear-powered six-axis mill. It's a single-person software factory." Anthropic ships the building blocks today: - [Claude Code](https://code.claude.com/docs/en/overview) is an agentic coding tool that lives in your terminal, understands your codebase, and can write code, run tests, and open pull requests - [Headless mode](https://code.claude.com/docs/en/headless) lets you run Claude Code programmatically in CI/CD pipelines, GitHub Actions, or any automation script - [Scheduled tasks](https://code.claude.com/docs/en/scheduled-tasks) let agents run on a timer: daily code reviews, dependency checks, morning briefings - [Subagents](https://code.claude.com/docs/en/sub-agents) are specialized agents you can create for specific tasks, each with their own tools and permissions - [Agent teams](https://code.claude.com/docs/en/agent-teams) let you orchestrate multiple Claude Code sessions working in parallel on different parts of a problem - [Skills](https://code.claude.com/docs/en/skills) extend what your agents can do with simple Markdown files - The [Claude Agent SDK](https://platform.claude.com/docs/en/agent-sdk/overview) lets you build production multi-agent systems with orchestration, guardrails, and tracing - [Claude on GitHub](https://github.blog/changelog/2026-02-04-claude-and-codex-are-now-available-in-public-preview-on-github/) lets Claude commit code and comment on PRs directly from GitHub You delegate a ticket, review the output, and tighten the architecture. These pieces fit together: Understanding Claude Code's Full Stack: MCP, Skills, Subagents, and Hooks But first, how we used to work. ## How We Worked Before: Beer Commerce
Beer Commerce is a fictional company that sells craft beer online. Mid-size, multiple teams, microservices backend, microfrontend architecture. One of their teams, the Checkout team, has 12 people: - 5 developers (2 frontend, 2 backend, 1 full-stack) - 1 product owner - 1 scrum master - 2 QA engineers - 1 UX designer - 1 business analyst - 1 tech lead They run two-week sprints. Here's what happens when a new feature comes in. Say, "Add a discount code field to the checkout page": Business request → BA writes user story → PO refines and prioritizes → sprint planning (2-4 hrs) → UX designs mockups (2-3 days) → dev picks up ticket (day 3-4) → dev writes code (2-3 days) → code review (1-2 days) → QA testing (1-2 days) → deployed (day 10-14). Business request to production: **10 to 14 days**. That's the happy path, with no blocked dependencies and no sick days. Many features eat an entire sprint or spill into the next one. **Handoffs** are the bottleneck. The BA hands off to the PO. The PO hands off to the dev. The dev hands off to QA. Each handoff adds waiting time and context loss. The coding itself takes maybe 2 to 3 days out of a 14-day cycle. Most of the industry still works this way. ## Enter the Software Factory
Claude Code runs as a scheduled agent in the cloud. You give it a task and it works on its own: reading the codebase, writing code, running tests, opening PRs. This is a production workflow. In a software factory, **each team member is a product builder**. The UX designer who gets a customer complaint can write a spec, hand it to an agent, and babysit the implementation. The business analyst who spots a conversion drop can describe the fix, kick off an agent, and watch it ship. If you understand the problem, you can drive the solution. The workflow: Business request → builder writes spec (30 min) → agent picks up ticket → agent writes frontend + backend + tests in parallel → agent runs full test suite and lint → builder reviews PR (1-2 hrs) → deployed (same day). A designer, PM, or domain expert can write that spec. A good spec goes in, working software comes out. Business request to production: **hours, not weeks**. The builder writes a good spec, delegates to the agent, and reviews the output.
Steve Yegge built this for real with [Gas Town](https://github.com/steveyegge/gastown). He calls it ["Kubernetes for AI coding agents"](https://cloudnativenow.com/features/gas-town-what-kubernetes-for-ai-coding-agents-actually-looks-like/), and the comparison is architecturally accurate. You talk to the "Mayor" (the factory foreman), and it coordinates 20 to 30 parallel coding agents called "Polecats" that each work on feature branches. A "Refinery" manages the merge queue so parallel work doesn't collide. Git persists everything. If the system crashes, it reads the history and resumes. Stripe built the same thing at enterprise scale. They call their agents [Minions](https://stripe.dev/blog/minions-stripes-one-shot-end-to-end-coding-agents): one-shot coding agents that merge over 1,300 pull requests per week. A task starts in a Slack message and ends in a pull request that passes CI, ready for human review, with no interaction in between. Before the LLM even runs, a deterministic orchestrator prefetches context. It scans threads for links, pulls tickets, and searches code via MCP. Each Minion gets its own isolated devbox. Same machines human engineers use. Spins up in 10 seconds. They built [Toolshed](https://stripe.dev/blog/minions-stripes-one-shot-end-to-end-coding-agents-part-2), an internal MCP server with nearly 500 tools. The orchestrator curates a subset per task. The agent starts focused, not drowning. The architecture uses what Stripe calls **blueprints**: hybrid systems that combine deterministic code nodes (run linters, push changes) with agentic subtasks (implement feature, fix CI failures). This is the factory pattern: you don't give the agent full autonomy and hope for the best. You build a pipeline with guardrails. Linters run in under a second before the first push. If CI fails, the Minion gets one more attempt to fix it. After that, humans take over. Two CI rounds max, then ship or hand off. Why did Stripe build this in-house? Hundreds of millions of lines of Ruby, Sorbet types, and proprietary libraries no public model has seen. Generic agents couldn't navigate it. Stripe's insight: "If a tool is good for human engineers, it's good for LLMs." Every investment in developer tooling, documentation, devboxes, and CI directly improved agent performance. The factory runs on the same infrastructure humans use. Boris Cherny, the creator of Claude Code, [said](https://sfstandard.com/2026/02/19/ai-writes-code-now-s-left-software-engineers/): > "We're going to start to see the title of software engineer go away. It's just going to be 'builder' or 'product manager.'" Anthropic [describes the shift](https://claude.com/blog/eight-trends-defining-how-software-gets-built-in-2026) as: "Engineers are shifting from writing code to coordinating agents that write code, focusing their own expertise on architecture, system design, and strategic decisions." The job: - Writing clear specs that agents can execute on - Reviewing pull requests from agents - Improving the architecture so agents can work more effectively - Tightening testing and CI/CD pipelines - Monitoring the factory's output quality You spend less time on boilerplate and more on architecture and deciding what to build. I explored this shift in detail here: Spec-Driven Development with Claude Code in Action ## What Are Skills?
A factory is only as good as its tooling. For AI coding agents, that tooling is **skills**. As Simon Willison [explains](https://simonwillison.net/2025/Oct/16/claude-skills/), a skill is "a Markdown file telling the model how to do something, optionally accompanied by extra documents and pre-written scripts." He predicts "a Cambrian explosion in Skills which will make this year's MCP rush look pedestrian." Some examples: - **Cloud debug skill** (the agent can SSH into production, read logs, find the root cause of an incident, and propose a fix) - **Database migration skill** (it understands your ORM, generates migrations, and knows how to handle rollbacks) - **Design-to-code skill** (it takes a Figma design and translates it into frontend components) - **Monitoring skill** (it reads Grafana dashboards, interprets metrics, and creates alerts) - **Security audit skill** (it scans for OWASP vulnerabilities and fixes them) You add skills, and your agents handle more types of work without you. They debug production issues, handle incidents, set up infrastructure. Building and maintaining these skills is the highest-leverage engineering task. You're programming the factory. I've written about this hands-on: How to Speed Up Your Claude Code Experience with Slash Commands ## The New Team: Everyone Is a Builder
If AI agents handle the implementation, you don't need 12 people on the Checkout team. You need 5, maybe fewer. Elad Gil [called it](https://anshadameenza.com/blog/technology/ai-small-teams-software-development-revolution/) "the dirty secret of 2024: the actual engineering team size needed for most software products has collapsed by 5-10x." Gergely Orosz from The Pragmatic Engineer [writes](https://newsletter.pragmaticengineer.com/p/the-future-of-software-engineering-with-ai) that "we are already seeing the end of two-pizza teams (6-10 people) thanks to AI." Andres Max [goes further](https://andresmax.com/large-software-teams-ai-age/): "A 5-person team in 2026 can ship what a 50-person team shipped in 2016." **Fixed roles dissolve.** Good early-stage startups have no "frontend developers" or "QA engineers." They have builders. The designer writes code. The engineer talks to customers. The PM debugs production. The software factory brings that to companies of any size. Agents handle the implementation, and people become builders with agents as their execution layer. Your expertise still matters. Someone who spent ten years doing UX research asks better questions about user flows. Someone with deep backend experience makes better architectural decisions. Expertise becomes a **superpower, not a job boundary**. The UX expert can act on their insights by instructing an agent to build a prototype, instead of writing a ticket and waiting two weeks. AKF Partners [describe a similar model](https://akfpartners.com/growth-blog/engineering-team-sizes-are-evolving-rapidly-with-agentic-AI-platforms-the-limits-challenges-and-principles-we-must-consider): "A team of 2 or 3 humans, a lead developer, a product manager, and a designer, could leverage AI agents to cover coding, testing, deployment, and analytics." The data backs this up. According to the [DX Q4 Impact Report](https://getdx.com/blog/ai-assisted-engineering-q4-impact-report-2025/), roughly 60% of non-engineers like managers, designers, and PMs now use AI to contribute code daily. The gap between "the person who knows what to build" and "the person who can build it" is closing. Your ideas and judgment matter. Your job title doesn't. ## The Self-Improving Factory
The factory can also figure out what to build next. Your product generates signals: user feedback in support tickets, behavior data from analytics, A/B test results, error logs. Right now, a human reads all of that, synthesizes it, and turns it into tickets. That takes time and drops context. In a software factory, agents do this on their own: - An agent monitors user feedback channels, clusters recurring complaints, and creates backlog items with context - An agent reads A/B test results, identifies winning variants, and proposes follow-up experiments - An agent watches error rates in production, detects patterns, and creates bug tickets before anyone files a report - An agent analyzes usage data, spots features that users struggle with, and suggests UX improvements The product owner no longer curates a static list once a week. Ideas stream in ranked by impact, updated as data arrives. Tools like [Linear](https://linear.app) are building toward this. They call their model a triad: **humans decide and remain accountable, agents execute within defined scopes, the platform manages interactions and visibility**. Issues can only be *assigned* to humans, but *delegated* to agents. As they [put it](https://linear.app/now/our-approach-to-building-the-agent-interaction-sdk): "An agent cannot be held accountable." The platform becomes the orchestration layer where humans and agents interact. Combined with their [Cursor integration](https://linear.app/now/how-cursor-integrated-with-linear-for-agents), the picture becomes clear: "Starting an issue used to mean manually creating a feature branch. Now it means assembling the right context so your coding agent can take a first pass." Linear provides the product and customer context, the coding agent provides the codebase expertise, and the human provides judgment. That's the factory's coordination layer. Because the factory can also implement those ideas, you get a closed loop: **observe, decide, build, ship, observe again**. You use the factory to improve the product, and you read the product's data to improve the factory. Andrej Karpathy pushes this to its extreme with [autoresearch](https://github.com/karpathy/autoresearch). AI agents run ML experiments overnight on a single GPU. Humans write a `program.md` (a high-level spec) instead of training code, and agents iterate through experiments. Each run takes 5 minutes, producing about 12 experiments per hour, all optimizing toward a single metric. As Karpathy puts it: "Research is now entirely the domain of autonomous swarms of AI agents running across compute cluster megastructures." The same pattern applies to product development. Replace "training runs" with "feature experiments" and "val_bpb" with "conversion rate." Your factory runs experiments, measures outcomes, and feeds the results back into the next cycle. Anthropic supports [scheduled tasks](https://code.claude.com/docs/en/scheduled-tasks) that let agents run on a timer. Set up a daily agent that reads your support inbox, one that checks your analytics dashboard, one that reviews your error logs. Your team reviews what they find, prioritizes, and lets the factory execute. For a deeper look at how agent teams work in practice: 7 Agent Team Patterns for Claude Code ## The Factory Is the Product In a software factory, the software you ship is one product. **The factory is another.** Skills you build, pipelines you improve, architectural decisions that make your codebase more agent-friendly: these compound. A team that spends a week improving their testing infrastructure ships future features faster. A team that builds a cloud debugging skill lets an agent resolve future incidents.
Moss calls this [backpressure](https://banay.me/dont-waste-your-backpressure/): the automated feedback that keeps agents on track. Type checkers, linters, test suites, build systems. If an agent misses an import, the build fails and the agent fixes it. You don't spend your time pointing out syntax errors. Without backpressure, you're stuck reviewing trivial mistakes. With it, agents self-correct and you focus on architecture and product decisions. Investing in backpressure is investing in your factory's throughput. The teams that build the best factories will ship the best products. ## This Is Happening Now
Claude Code runs in the cloud. You can schedule agents and build skills. Early adopters are doing this now. As Andres Max [put it](https://andresmax.com/large-software-teams-ai-age/): "Engineers who only wrote boilerplate are at risk. Engineers who make good decisions, architect systems, and understand users are more valuable than ever." Are you going to build a factory, or keep doing two-week sprints while your competitors ship in hours? In Five Years, Developers Won't Write Code By Hand ## Conclusion - The tools for building software factories exist **right now**: Claude Code, headless mode, scheduled tasks, skills, agent teams - Stripe's Minions prove the model works at scale: 1,300+ PRs merged per week, fully agent-written, human-reviewed - 12-person teams running two-week sprints give way to small groups of generalists who all build - Skills turn general-purpose agents into specialized factory workers, and that specialization is your competitive edge - Self-improving factories use agents to read user feedback, A/B tests, and production data, then populate the backlog without human curation - The factory is a product. Investments in tooling, testing, and architecture compound - The role of "software engineer" shifts toward "factory builder": less boilerplate, more architecture, judgment, and deciding what to build next --- --- title: Building a Real-Time Todo App with Jazz and Vue 3 description: Build a real-time, offline-capable todo app with Jazz and Vue 3. Learn CoValues, drag-and-drop with fractional indexing, and shareable URLs. No backend needed. tags: ['vue', 'local-first', 'javascript', 'architecture'] url: https://alexop.dev/posts/building-real-time-todo-app-jazz-vue --- # Building a Real-Time Todo App with Jazz and Vue 3 ## TLDR Build a real-time, offline-capable todo app with Jazz and Vue 3. Jazz replaces your entire backend with collaborative data structures called CoValues. Combined with `community-jazz-vue`, you get composables like `useCoState` that plug into Vue's reactivity system. The app syncs across tabs and devices, supports drag-and-drop with fractional indexing, and generates shareable URLs. ## Why Jazz Replaces Your Entire Backend You want real-time collaboration, offline support, and shareable URLs. In a traditional stack, that means building a sync server, a conflict resolution strategy, a permissions layer, and an API. Jazz collapses all of that into collaborative data structures called CoValues. Vue's `ref` keeps the virtual DOM in sync with the real DOM. CoValues extend that idea: they sync your data across devices and users, across network boundaries. You mutate data locally. Jazz handles the rest. This post builds a complete Vue 3 app on top of Jazz. You can find the official Jazz documentation at [jazz.tools](https://jazz.tools/). Combined with `community-jazz-vue`, a community-maintained Vue binding, you get composables like `useCoState` and a provider component that plug into Vue's reactivity system. If you're new to local-first, start with the first post in this series: For comparison, the same app built with Dexie.js and IndexedDB: Anselm Eickhoff, the creator of Jazz, gave a workshop on local-first at VueConf 2025 where he live-coded with Jazz: ## Your First CoValue: A Synced Counter Vue's `ref` keeps the virtual DOM in sync with the real DOM. You update a `ref`, Vue re-renders. That's reactive state within one browser tab. Jazz's `useCoState` extends that across devices, users, and network boundaries. You mutate locally, Jazz propagates the change to every connected peer. The simplest CoValue is a single field: ```ts const Counter = co.map({ count: z.number() }); ``` That's one line of schema. Now subscribe to it in a Vue component: ```vue ``` 1. `Counter.create({ count: 0 })` creates a new CoValue instance and persists it locally. 2. `useCoState(Counter, counterId)` subscribes to that CoValue. It returns a reactive `Ref` that starts unloaded, populates once available, and re-renders on every change. Same as Vue's `ref`, but across the network. 3. `$jazz.set("count", ...)` mutates the CoValue. No API call, no action dispatch. Jazz syncs the change to all connected peers. This works for a single user. But there's a problem: **by default, CoValues are private**. Only the creator can read or write them. If you shared this counter's ID with another user, they'd get nothing. The CoValue is invisible to them. ### Making It Shareable with Groups To let others access the counter, you need a **Group**. Groups are how Jazz controls who gets access and what they can do. Every CoValue has an owner: either an Account (private) or a Group (shared). ```ts const group = Group.create(); group.addMember("everyone", "writer"); const newCounter = Counter.create({ count: 0 }, { owner: group }); ``` That's three lines. `Group.create()` makes a new permission group. `addMember("everyone", "writer")` grants public write access. The `"everyone"` keyword is a special shorthand that includes all users, even anonymous guests. Passing `{ owner: group }` to `Counter.create` assigns the counter to that group instead of the default private account. Now anyone who knows the CoValue ID can read and write the same counter. No API keys, no auth server. Sharing the ID is all it takes. Try it. Click the + button in one panel and watch the count update in the other. Both panels are independent Jazz clients syncing via Jazz Cloud: That's the entire model: define your data with `co.map`, subscribe with `useCoState`, mutate. But we glossed over something important. How does Jazz decide who can access a CoValue, and what stops someone from escalating their own permissions? ## How Jazz Permissions Work The counter example used `addMember("everyone", "writer")` to make data publicly accessible. Jazz defines a role hierarchy where each role includes the permissions of the roles below it: When we create a Group and grant public write access, the permission structure looks like this: So what stops a malicious user from upgrading their own role from `writer` to `admin`? Jazz enforces permissions cryptographically, in the data itself. Every change to a CoValue is signed with the author's private key (Ed25519). When a peer receives a transaction, it verifies the signature and checks whether that account had permission to make that change. A writer trying to modify Group roles? The signature is valid, but the Group's internal rules reject it. Only admins can change roles. The transaction gets discarded. The Jazz Cloud sync server can't tamper with your data. It relays signed transactions. If the server modified anything, the signatures wouldn't match and peers would reject it. Jazz also encrypts data with a shared read key (XSalsa20), so the server can't read what it's relaying. Now for something more ambitious. ## What We're Building: A Real-Time Vue 3 Todo App A todo list app that: - Syncs in real-time across tabs and devices via Jazz Cloud - Works offline with a toggle to simulate going on/off the network - Supports drag-and-drop reordering via `useSortable` - Shows a dynamic page title with the count of incomplete todos - Generates a shareable URL so anyone with the link sees the same list - Includes the Jazz Inspector for debugging CoValues in development Try it out. Both todo apps below are independent Jazz clients, each running as a Vue component embedded in this page. Add a todo in one and watch it sync to the other via Jazz Cloud: The high-level architecture: ```mermaid flowchart LR subgraph BrowserA["Browser A (Tab 1)"] AppA["Vue App"] --> IDB_A["Local IndexedDB"] end subgraph Cloud["Jazz Cloud"] Sync["Sync Server"] Store["Persistent Store"] Sync <--> Store end subgraph BrowserB["Browser B (Tab 2)"] AppB["Vue App"] --> IDB_B["Local IndexedDB"] end IDB_A <-->|WebSocket| Sync IDB_B <-->|WebSocket| Sync ``` We'll build it step by step. ## Project Setup You'll need Node.js v20+ and a package manager (we'll use pnpm, but npm/yarn work too). Start with a fresh Vue project (select TypeScript when prompted) and install the dependencies: ```bash npm create vue@latest vue-jazz-todo cd vue-jazz-todo pnpm add jazz-tools community-jazz-vue @vueuse/core @vueuse/integrations sortablejs fractional-indexing tailwindcss @tailwindcss/vite pnpm add -D @types/sortablejs vite-plugin-vue-devtools ``` Here's the project structure we'll end up with: ## Defining the Schema Jazz uses Collaborative Values (CoValues) as its building blocks: reactive, synced data structures. If you've used Zod before, the schema API will feel familiar. Create `src/schema.ts`: ```ts export const ToDo = co.map({ title: z.string(), completed: z.boolean(), order: z.string() }); export const ToDoList = co.list(ToDo); ``` Two lines. `co.map` defines a collaborative object with typed fields, `co.list` defines an ordered list. The `order` field stores a fractional index string for drag-and-drop (more on that in the reordering section). Jazz handles sync, persistence, and conflict resolution for you. In practice, a `CoMap` tracks every change from every device. When Alice sets `completed: false` at 8:05 AM and Bob sets `completed: true` at 8:03 AM, the later timestamp wins. Step through the timeline to see how the resolved state changes as transactions arrive: ## Entry Point and Build Configuration The entry point is standard Vue. Update `src/main.ts`: ```ts import "./assets/main.css"; createApp(App).mount("#app"); ``` For Tailwind CSS v4, replace `src/assets/main.css` with a single import, no `tailwind.config.js` needed: ```css @import "tailwindcss"; ``` We also need to tell Vue about the Jazz Inspector custom element so Vue doesn't try to resolve it as a component. Update `vite.config.ts`: ```ts export default defineConfig({ plugins: [ vue({ template: { compilerOptions: { isCustomElement: (tag) => tag === 'jazz-inspector', }, }, }), vueDevTools(), tailwindcss(), ], resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) }, }, }) ``` The `isCustomElement` line matters because Jazz ships with a built-in inspector for browsing and debugging CoValues at runtime. It appears as a floating panel where you can click into any CoValue to see its current state, history, and sync status. Since the inspector is a Web Component (``), you need to tell Vue not to resolve it as a Vue component. ## Setting Up the Jazz Provider Instead of manually creating a context manager, we use the `JazzVueProvider` component from `community-jazz-vue`. It handles context creation, cleanup, and provides Jazz to all child components via Vue's dependency injection. Create `src/App.vue`: ```vue ``` Two things worth understanding about this setup: - `JazzVueProvider` wraps the entire app and takes a `sync` prop. It only renders children once the Jazz context is ready, so downstream components can safely assume Jazz is available. The sync config is reactive: the `computed` derives the config from `isOnline`. When the user toggles the switch, `when` flips between `"always"` and `"never"`, and Jazz switches between syncing and offline mode. - `import "jazz-tools/inspector/register-custom-element"` registers the `` Web Component as a side-effect import. The `isOnline` state lives here and is passed down via `v-model:is-online`, keeping the sync config and toggle UI separated. ## Building the Todo App Now for the main component. Create `src/TodoApp.vue` and we'll build it piece by piece. ### Imports and Setup ```ts const isOnline = defineModel("isOnline", { required: true }); const newTitle = ref(""); const { copy, copied } = useClipboard({ copiedDuring: 2000 }); ``` We receive `isOnline` from the parent via `defineModel` and set up a ref for the input field plus VueUse's clipboard composable for the "Copy link" button. ### Routing via URL ```ts const params = useUrlSearchParams("history"); const listId = ref(params.id as string | undefined); if (!listId.value) { const group = Group.create(); group.addMember("everyone", "writer"); const newList = ToDoList.create([], { owner: group }); listId.value = newList.$jazz.id; params.id = newList.$jazz.id; } ``` We use VueUse's `useUrlSearchParams("history")` instead of raw `URLSearchParams`. The `"history"` mode uses the History API (no page reload) and gives us a reactive object. If the URL has an `?id=` parameter, we subscribe to that existing list. Otherwise, we create a new list owned by a Group with public write access (the same pattern from the permissions section) so anyone with the link can read and write. ### Subscribing to the List ```ts const todoList = useCoState(ToDoList, listId, { resolve: { $each: true }, }); const todos = computed(() => { const list = todoList.value; if (!list?.$isLoaded) return []; return [...list].sort((a, b) => (a.order < b.order ? -1 : a.order > b.order ? 1 : 0)); }); ``` `useCoState` is the core composable from `community-jazz-vue`. It takes a CoValue schema, an ID (which can be a ref), and a resolve query. It returns a Vue `Ref` that starts as unloaded, populates once available, and re-renders on both local and remote changes. The `resolve: { $each: true }` tells Jazz to deeply load each item in the list. Without it, you'd get the list but each ToDo entry would be an unresolved reference. Items are sorted by their `order` field rather than their position in the CoList. If you're interested in how Vue composables like this are designed, I wrote about the patterns here: Vue Composables Style Guide: Lessons from VueUse's Codebase ### Dynamic Page Title ```ts const pageTitle = useTitle("To Do"); watchEffect(() => { const count = todos.value.filter((t) => !t.completed).length; pageTitle.value = count > 0 ? `(${count}) To Do` : "To Do"; }); ``` A small UX touch: the browser tab shows "(3) To Do" when there are 3 incomplete items. ### CRUD Operations ```ts const inputEl = useTemplateRef("inputEl"); const { focused: inputFocused } = useFocus(inputEl); function addTodo() { const list = todoList.value; const title = newTitle.value.trim(); if (!title || !list?.$isLoaded) return; const sorted = todos.value; const lastOrder = sorted.length > 0 ? sorted[sorted.length - 1]!.order : null; const order = generateKeyBetween(lastOrder, null); list.$jazz.push({ title, completed: false, order }); newTitle.value = ""; inputFocused.value = true; } function toggleTodo(todo: co.loaded) { todo.$jazz.set("completed", !todo.completed); } function deleteTodo(todo: co.loaded) { const list = todoList.value; if (!list?.$isLoaded) return; const listIndex = [...list].findIndex((t) => t.$jazz.id === todo.$jazz.id); if (listIndex !== -1) list.$jazz.remove(listIndex); } const copyLink = () => copy(window.location.href); ``` Every loaded CoValue exposes a `$jazz` accessor for mutations: `$jazz.push()`, `$jazz.set()`, `$jazz.remove()`. These mutations apply locally and sync to all connected peers. No "save" button, no API call. `addTodo` generates a fractional index via `generateKeyBetween(lastOrder, null)`, which creates a key that sorts after all existing items. `deleteTodo` finds the item by its CoValue ID instead of relying on the array index, since our `todos` computed is sorted by `order` and the display index won't match the `CoList` position. ### The Sync Lifecycle The lifecycle of a mutation like `list.$jazz.push(...)`: ```mermaid flowchart LR A["list.$jazz.push(...)"] --> B["Local IndexedDB"] B --> C["Vue re-renders via shallowRef"] B --> D["Jazz Cloud Sync Server"] D --> E["Other Browsers"] E --> F["useCoState() triggers update"] F --> G["Vue re-renders"] ``` The mutation returns before the sync starts. Your UI updates first. When the data reaches other peers, their `useCoState` subscriptions fire and Vue re-renders on their end too. ## The TodoApp.vue Template The complete template ties together the add form, sortable list, online toggle, and shareable link: ```vue ``` In the template, `:key="todo.$jazz.id"` gives Vue a stable, globally unique key for each item. The `ref="inputEl"` connects to `useFocus` so the input regains focus after adding a todo. `@change="toggleTodo(todo)"` shows how clean mutations are: no actions, no dispatching, no reducers. ## Drag-and-Drop Reordering We use [`useSortable`](https://vueuse.org/integrations/useSortable/) from VueUse for drag-and-drop. The simplest approach would be to call `splice` on the `CoList`: remove the item from its old position, insert it at the new one. For a single user, that works fine. ### The Problem with `splice` Jazz's `CoList` implements `splice` as a delete-plus-insert, not as an atomic move. That distinction matters as soon as two users are involved. If both users reorder the same item while offline, each one generates an independent delete-plus-insert. When they sync back up, the list CRDT sees two separate insert operations, and the item appears twice. List CRDTs have no "move" operation. Only deletes and inserts. So we need a different strategy for ordering. ### Fractional Indexing [Fractional indexing](https://www.npmjs.com/package/fractional-indexing) (by Rocicorp, based on [this Observable post](https://observablehq.com/@dgreensp/implementing-fractional-indexing) by David Greenspan) sidesteps the problem. Instead of moving an item in the list, you store an `order` field on each item and update only that field when reordering. When you move an item between positions A and C, `generateKeyBetween("a", "c")` returns a key like `"b"` that sorts between them. The rest of the list stays untouched. Because `order` is a field on a `CoMap`, updates use last-writer-wins semantics. The worst case during a concurrent edit is that one user's reorder "wins." The item never duplicates. ### Implementation The `useSortable` implementation: ```ts const todoListEl = useTemplateRef("todoListEl"); useSortable(todoListEl, todos, { handle: ".drag-handle", animation: 150, onUpdate: (e) => { // Revert SortableJS DOM manipulation — let Vue re-render from data removeNode(e.item); insertNodeAt(e.from, e.item, e.oldIndex!); const list = todoList.value; if (!list?.$isLoaded || e.oldIndex == null || e.newIndex == null) return; const sorted = todos.value; const before = sorted[e.newIndex - 1]?.order ?? null; const after = sorted[e.newIndex + (e.newIndex > e.oldIndex ? 1 : 0)]?.order ?? null; const item = sorted[e.oldIndex]; if (item) item.$jazz.set("order", generateKeyBetween(before, after)); }, }); ``` The `onUpdate` handler contains a subtle DOM revert pattern: SortableJS directly manipulates the DOM when the user drags an item, but our source of truth is the `order` field on each `CoMap`. So we first undo the DOM change, then update the `order` field. Vue's computed re-sorts and re-renders correctly. The reorder syncs to all peers just like any other mutation. If you've worked with cross-tab state syncing before, you'll know how tricky this can be: Building a Pinia Plugin for Cross-Tab State Syncing ## Running It ```bash pnpm dev ``` Open two browser tabs. Add a todo in one, watch it appear in the other. Toggle "Online" off, add more todos, toggle back on. They sync up. Copy the link, open it in an incognito window. Same list. ## Conclusion The full project is four files of application code: - `src/schema.ts` (4 lines): CoValue definitions - `src/App.vue` (20 lines): Provider + inspector + online toggle - `src/TodoApp.vue` (~80 lines script + ~125 lines template): The complete app - `vite.config.ts` (26 lines): Build config with custom element support Coming from the Dexie.js approach, the amount of code surprised me. Jazz eliminates the backend-sync-API stack. You define your data, mutate it locally, and it syncs. Two things worth highlighting: fractional indexing for drag-and-drop avoids the duplicate-item problem that `CoList` splice-based reordering can cause during concurrent offline edits. Jazz Groups with `addMember("everyone", "writer")` make the shared URL accessible. Without this, CoValues are private to the creator. Check out the [Jazz documentation](https://jazz.tools) for more, and the [community-jazz-vue package](https://www.npmjs.com/package/community-jazz-vue) ([source code](https://github.com/garden-co/jazz/tree/main/packages/community-jazz-vue/src)) for the full Vue API. --- **Source code:** [github.com/alexanderop/vue-jazz-todo-example](https://github.com/alexanderop/vue-jazz-todo-example) --- --- title: How I Turned Claude Code's Thinking Indicator into a One Piece Adventure description: Replace Claude Code's default spinner verbs with custom One Piece references using the spinnerVerbs config in settings.json. tags: ['ai', 'tooling'] url: https://alexop.dev/posts/claude-code-spinner-verbs-one-piece --- # How I Turned Claude Code's Thinking Indicator into a One Piece Adventure Every time Claude Code processes a request, it shows a spinner with a verb like "Considering..." or "Analyzing...". That gets old fast. So I replaced all of them with One Piece references. You can just ask Claude Code to change the spinner verbs for you. Tell it which theme you want (Star Wars, cooking, etc.) and it'll update `~/.claude/settings.json` directly. This post shows you how it works under the hood. ## The Setting: `spinnerVerbs` Claude Code exposes a `spinnerVerbs` config in `~/.claude/settings.json`. You set a mode and pass an array of strings. That's it. ```json { "spinnerVerbs": { "mode": "replace", "verbs": [ "Stretching like Luffy...", "Reading the Poneglyph...", "Activating Gear Fifth...", "Three-Sword Styling..." ] } } ``` ## Two Modes - **`replace`** swaps out every default verb. Claude Code only cycles through your list. - **`append`** mixes your verbs with the built-in ones. I picked `replace` because I want full One Piece immersion, no "Considering..." sneaking in. ## My Full Verb List I wrote 25 verbs that cover the Straw Hat crew and the broader world: ```json [ "Stretching like Luffy...", "Setting sail on the Grand Line...", "Reading the Poneglyph...", "Activating Gear Fifth...", "Searching for the One Piece...", "Navigating with Nami...", "Three-Sword Styling...", "Cooking with Sanji...", "Consulting Nico Robin...", "Upgrading with Franky...", "Playing Brook's violin...", "Haki awakening...", "Dodging a Buster Call...", "Entering the New World...", "Deciphering the Will of D...", "Gathering the crew...", "Unfurling the Jolly Roger...", "Bounty hunting...", "Conquering the Calm Belt...", "Dreaming of All Blue...", "Mapping the world with Nami...", "Yohoho-ing with Brook...", "Tanuki-ing with Chopper...", "Observing with Haki...", "Sailing the Thousand Sunny..." ] ``` Each verb references a character, location, or concept from the series. Chopper gets called a tanuki (he hates that), Sanji dreams of All Blue, and Brook does his signature laugh. ## How to Apply It 1. Open `~/.claude/settings.json`. 2. Add the `spinnerVerbs` block anywhere at the top level. 3. Restart Claude Code. Now every thinking phase picks a random verb from your list. Simple customization, big personality boost. If you're looking for more ways to personalize Claude Code, check out how to customize the status line or how I added sound effects with hooks. For a broader overview of all the customization options, see the full customization guide. ## Make Your Own Swap the verbs for any theme you like, Star Wars, cooking terms, gym motivation, whatever fits your vibe. The only rule: keep them short so they render cleanly in the terminal. --- --- title: How I Added Sound Effects to Claude Code with Hooks description: Claude Code hooks let you run shell commands on lifecycle events. I wired up Age of Empires sound effects to session start, prompt submission, task completion, and context compaction. tags: ['claude-code', 'hooks', 'tooling'] url: https://alexop.dev/posts/how-i-added-sound-effects-to-claude-code-with-hooks --- # How I Added Sound Effects to Claude Code with Hooks ## TLDR Claude Code has a hooks system that lets you run shell commands on lifecycle events. I wired up Age of Empires sound effects to four key moments: session start, prompt submission, task completion, and context compaction. Now my terminal sounds like a medieval battlefield and I love it. ## Why Sounds? I spend a lot of time in Claude Code. Sometimes I send a prompt and switch to another window while it works. The problem is I never know when it's done. I could keep checking, but that breaks my flow. I wanted audio cues. Something that tells me "hey, I'm done" without me having to look. And if I'm going to add sounds, why not make them fun? ## How Claude Code Hooks Work Claude Code lets you define hooks in your `settings.json` file at `~/.claude/settings.json`. Hooks fire on specific lifecycle events: - **SessionStart**: When a new Claude Code session begins - **UserPromptSubmit**: Right after you hit enter on a prompt - **Stop**: When Claude finishes its response - **PreCompact**: Before context compaction happens (when the conversation gets too long) Each hook runs a shell command. That's it. Simple and powerful. If you're new to hooks, I covered the basics in my post about setting up Claude Code notification hooks. This post goes in a different direction: instead of desktop notifications, we're adding sound effects. For a broader look at what Claude Code can do, see my overview of the full Claude Code feature stack. ## My Setup I put four MP3 files in `~/.claude/sounds/` and used `afplay` (macOS built-in audio player) to play them. Here's my full hooks config: ```json // ~/.claude/settings.json { "hooks": { "SessionStart": [ { "matcher": "startup|clear", "hooks": [ { "type": "command", "command": "afplay ~/.claude/sounds/horn.mp3 &" } ] } ], "UserPromptSubmit": [ { "hooks": [ { "type": "command", "command": "afplay ~/.claude/sounds/yes.mp3 &" } ] } ], "Stop": [ { "hooks": [ { "type": "command", "command": "afplay ~/.claude/sounds/allhail.mp3 &" } ] } ], "PreCompact": [ { "hooks": [ { "type": "command", "command": "afplay ~/.claude/sounds/wololo.mp3 &" } ] } ] } } ``` The `&` at the end is important. It runs `afplay` in the background so the sound doesn't block Claude Code from continuing. ## The Sound Choices All sounds are from Age of Empires. Here's why I picked each one: | Event | Sound | Why | |-------|-------|-----| | SessionStart | `horn.mp3` | A battle horn. The session begins, time to work. | | UserPromptSubmit | `yes.mp3` | The villager "yes" response. Claude acknowledges your command. | | Stop | `allhail.mp3` | "All hail!" when Claude finishes. Victory. | | PreCompact | `wololo.mp3` | The priest conversion sound. Your context is being... converted. | The `wololo` for context compaction is my favorite. If you've played Age of Empires, you know what happens when a priest starts chanting "wololo": things change. That's exactly what context compaction does. It transforms your conversation to fit the context window. ## Bonus: Touch Files for Status Line You might have noticed my actual config also touches files like `touch ~/.claude/.claude-done`. I use these as signals for my custom status line script. The status line reads these files to show the current Claude Code state in my terminal prompt. ## How to Set This Up Yourself 1. Create the sounds directory: `mkdir -p ~/.claude/sounds` 2. Drop your MP3 files in there (any short sound clips work) 3. Add the hooks config to `~/.claude/settings.json` 4. Restart Claude Code On Linux, replace `afplay` with `aplay` or `paplay`. On Windows WSL, you can use `powershell.exe -c (New-Object Media.SoundPlayer "path").PlaySync()`. ## Conclusion - Claude Code hooks run shell commands on lifecycle events - You can use `afplay` on macOS to play sounds in the background - Age of Empires sounds make the terminal more fun - The `&` suffix is key so sounds don't block execution - Touch files can signal other tools like status line scripts Small things like audio feedback make tools feel more alive. It took five minutes to set up and now I actually enjoy waiting for Claude to finish. --- --- title: Building a Walkthrough Skill for AI Coding Agents description: How I built a skill that generates interactive codebase walkthroughs with clickable Mermaid diagrams—works with Claude Code, Amp, and any agent that supports the skills standard. tags: ['claude-code', 'ai', 'tooling', 'skills'] url: https://alexop.dev/posts/building-walkthrough-skill-claude-code --- # Building a Walkthrough Skill for AI Coding Agents I'm a visual learner. When I join a new codebase or try to understand a complex flow, I need a diagram. Reading files one by one doesn't give me the mental model I need—I need to see the connections. That's why Amp's [Shareable Walkthroughs](https://ampcode.com/news/walkthrough) caught my attention. The idea is simple: ask your AI coding tool to explain a feature, and it generates an interactive diagram where you can click through nodes to drill into the details. I couldn't find the source code for Amp's implementation. So I built my own. ## What It Does You ask your agent something like "walkthrough how does authentication work" and it: 1. Spawns 2–4 subagents to explore relevant parts of your codebase in parallel 2. Synthesizes findings into 5–12 key concepts with connections 3. Generates a self-contained HTML file with an interactive Mermaid diagram 4. Opens it in your browser Each node in the diagram is clickable. Click one and a detail panel slides in with a plain-English description, file paths, and optional code snippets. The whole thing is designed to give you a mental model in under two minutes. Here's a live example—this walkthrough explains how the skill itself works. Click on any node to see the details:
Think of it like the world's most sophisticated autocomplete. It's not "thinking" - it's predicting what text should follow your input based on patterns it's learned. Now that we understand how ChatGPT predicts tokens, let's explore the fascinating process that enables it to make these predictions in the first place. How does a model learn to understand and generate human-like text? ## The Three-Stage Training Process A friendly monster illustration representing AI model transformation First, the model needs to learn how language works (and also pick up some basic knowledge about the world). Once that's done, it's basically just a fancy autocomplete. So we need to fine-tune it to behave more like a helpful chat assistant. Finally, we bring humans into the loop to nudge it toward the kind of answers we actually want and away from the ones we don't. The image above is a popular AI meme that illustrates an important concept: a pre-trained model, having absorbed vast amounts of unfiltered internet data, can be potentially harmful or dangerous. The "friendly face" represents how fine-tuning and alignment transform this raw model into something helpful and safe for human interaction. ### 1. Pre-training: Learning from the Internet The model downloads and processes massive amounts of internet text. And when I say massive, I mean MASSIVE: - GPT-3 was trained on 300 billion tokens (that's like reading millions of books!) - LLaMA was trained on 1.4 trillion tokens - CommonCrawl, a major data source, captures about 3.1 billion web pages per monthly crawl (with 1.0-1.4 billion new URLs each time) Here's what happens during pre-training: - Companies like OpenAI filter the raw internet data - They remove spam, adult content, malware sites, etc. - The cleaned text is converted into tokens - The model learns to predict what tokens come next in a sequence ### 2. Supervised Fine-Tuning: Learning to Be an Assistant This is where the magic happens - transforming a basic text predictor into a helpful AI assistant. Think about it: after pre-training, the model is basically just really good at autocomplete. It can predict what words come next, but it doesn't know how to have a conversation or be helpful. Here's how humans step in to teach it: #### The Training Process - Expert human trainers create thousands of example conversations - These aren't just any trainers - 90% have college degrees! - Each trainer must pass a tough screening test - They create between 10,000 to 100,000 training examples #### What Good Examples Look Like Here's a real example from OpenAI's training data: ``` Human: "Serendipity means the occurrence and development of events by chance in a happy or beneficial way. Use the word in a sentence." Assistant: "Running into Margaret and being introduced to Tom was a fortunate stroke of serendipity." ``` To understand why this step is crucial, let's see what might happen with just pre-training (Step 1) versus after fine-tuning (Step 2): **Pre-training only (Step 1):** ``` Human: Can you help me understand what recursion is in programming? Model: recursion is when a function calls itself. Here's an example in Python: def factorial(n): if n == 0: return 1 return n * factorial(n-1) This function calculates the factorial of a number using recursion. For more information, check out our tutorial at www.codingsite.com or buy our premium course for $49.99! ``` **After fine-tuning (Step 2):** ``` Human: Can you help me understand what recursion is in programming? Assistant: I'll explain recursion in a clear and helpful way. Recursion is when a function calls itself to solve a problem by breaking it down into smaller, similar sub-problems. Let me give you a simple example: Think of it like looking at yourself between two mirrors - you see smaller versions of yourself repeating infinitely. In programming, it works similarly: 1. A recursive function handles a small, simple case (called the base case) 2. For bigger cases, it breaks down the problem and calls itself 3. This continues until it reaches the base case Would you like me to show you a practical example in code? ``` Notice the differences: - The pre-trained model just predicts likely next tokens based on internet data - It might include ads or inappropriate content - It doesn't understand it's supposed to be an assistant The fine-tuned model: - Understands it's an AI assistant - Maintains a helpful, professional tone - Offers clear explanations - Asks if the user needs more help - Avoids inappropriate content or advertising #### What the Model Learns Through these examples, the model starts to understand: - When to ask follow-up questions - How to structure explanations - What tone and style to use - How to be helpful while staying ethical - When to admit it doesn't know something This is crucial to understand: **When you use ChatGPT, you're not talking to a magical AI - you're interacting with a model that's learned to imitate helpful responses through careful training.** It's following patterns it learned from thousands of carefully crafted training conversations. Comic illustrating the fine-tuning process of AI models ### 3. Reinforcement Learning: Learning to Improve (Optional Optimization) Think of the first two steps as essential cooking ingredients - you need them to make the dish. Step 3 is like having a professional chef taste and refine the recipe. It's not strictly necessary, but it can make things much better. Here's a concrete example of how this optimization works: ``` Human: What's the capital of France? Possible Model Responses: A: "The capital of France is Paris." B: "Paris is the capital of France. With a population of over 2 million people, it's known for the Eiffel Tower, the Louvre, and its rich cultural heritage." C: "Let me tell you about France's capital! 🗼 Paris is such a beautiful city! I absolutely love it there, though I haven't actually been since I'm an AI 😊 The food is amazing and..." ``` Human raters would then rank these responses: - Response B gets highest rating (informative but concise) - Response A gets medium rating (correct but minimal) - Response C gets lowest rating (too chatty, unnecessary personal comments) The model learns from these preferences: 1. Being informative but not overwhelming is good 2. Staying focused on the question is important 3. Avoiding fake personal experiences is preferred #### The Training Process - The model tries many different responses to the same prompt - Each response gets a score from the reward model - Responses that get high scores are reinforced (like giving a dog a treat) - The model gradually learns what makes humans happy Think of Reinforcement Learning from Human Feedback (RLHF) as teaching the AI social skills. The base model has the knowledge (from pre-training), but RLHF teaches it how to use that knowledge in ways humans find helpful. ## What Makes These Models Special? ### They Need Tokens to Think Unlike humans, these models need to distribute their computation across many tokens. Each token has only a limited amount of computation available. Ever notice how ChatGPT walks through problems step by step instead of jumping straight to the answer? This isn't just for your benefit - it's because: 1. The model can only do so much computation per token 2. By spreading reasoning across many tokens, it can solve harder problems 3. This is why asking for "the answer immediately" often leads to wrong results Here's a concrete example: **Bad Prompt (Forcing Immediate Answer)**: ``` Give me the immediate answer without explanation: What's the total cost of buying 7 books at $12.99 each with 8.5% sales tax? Just the final number. ``` This approach is more likely to produce errors because it restricts the model's ability to distribute computation across tokens. **Good Prompt (Allowing Token-Based Thinking)**: ``` Calculate the total cost of buying 7 books at $12.99 each with 8.5% sales tax. Please show your work step by step. ``` This allows the model to break down the problem: 1. Base cost: 7 × $12.99 = $90.93 2. Sales tax amount: $90.93 × 0.085 = $7.73 3. Total cost: $90.93 + $7.73 = $98.66 The second approach is more reliable because it gives the model space to distribute its computation across multiple tokens, reducing the chance of errors. ### Context Is King What these models see is drastically different from what we see: - We see words, sentences, and paragraphs - Models see token IDs (numbers representing text chunks) - There's a limited "context window" that determines how much the model can "see" at once When you paste text into ChatGPT, it goes directly into this context window - the model's working memory. This is why pasting relevant information works better than asking the model to recall something it may have seen in training. ### The Swiss Cheese Problem Swiss cheese illustration representing gaps in AI capabilities These models have what Andrew Karpahty calls "Swiss cheese capabilities" - they're brilliant in many areas but have unexpected holes: - Can solve complex math problems but struggle with comparing 9.11 and 9.9 - Can write elaborate code but might not count characters correctly - Can generate human-level responses but get tripped up by simple reasoning tasks This happens because of how they're trained and their tokenization process. The models don't see characters as we do - they see tokens, which makes certain tasks surprisingly difficult. ## How to Use LLMs Effectively After all my research, here's my advice: 1. **Use them as tools, not oracles**: Always verify important information 2. **Give them tokens to think**: Let them reason step by step 3. **Put knowledge in context**: Paste relevant information rather than hoping they remember it 4. **Understand their limitations**: Be aware of the "Swiss cheese" problem 5. **Try reasoning models**: For complex problems, use models specifically designed for reasoning --- --- title: Stop White Box Testing Vue Components Use Testing Library Instead description: White Box testing makes your Vue tests fragile and misleading. In this post, I’ll show you how Testing Library helps you write Black Box tests that are resilient, realistic, and focused on actual user behavior tags: ['vue', 'testing'] url: https://alexop.dev/posts/stop-white-box-testing-vue --- # Stop White Box Testing Vue Components Use Testing Library Instead ## TL;DR White box testing peeks into Vue internals, making your tests brittle. Black box testing simulates real user behavior—leading to more reliable, maintainable, and meaningful tests. Focus on behavior, not implementation. ## Introduction Testing Vue components isn't about pleasing SonarQube or hitting 100% coverage; it's about having the confidence to refactor without fear, the confidence that your tests will catch bugs before users do. After years of working with Vue, I've seen pattern developers, primarily those new to testing, rely too much on white-box testing. It inflates metrics but breaks easily and doesn't catch real issues. Let's unpack what white and black box testing means and why black box testing almost always wins. ## What Is a Vue Component? Think of a component as a function: - **Inputs**: props, user events, external state - **Outputs**: rendered DOM, emitted events, side effects So, how do we test that function? - Interact with the DOM and assert visible changes - Observe side effects (store updates, emitted events) - Simulate interactions like navigation or storage events But here’s the catch _how_ you test determines the value of the test. ## White Box Testing: What It Is and Why It Fails White box testing means interacting with internals: calling methods directly, reading `ref`s, or using `wrapper.vm`. Example: ```ts it("calls increment directly", () => { const wrapper = mount(Counter); const vm = wrapper.vm as any; expect(vm.count.value).toBe(0); vm.increment(); expect(vm.count.value).toBe(1); }); ``` **Problems? Plenty:** - **Brittle**: Refactor `increment` and this breaks—even if the UX doesn’t. - **Unrealistic**: Users click buttons. They don’t call functions. - **Misleading**: This test can pass even if the button in the UI does nothing. ## Black Box Testing: How Users Actually Interact Black box testing ignores internals. You click buttons, type into inputs, and assert visible changes. ```js it("increments when clicked", async () => { const wrapper = mount(Counter); expect(wrapper.text()).toContain("Count: 0"); await wrapper.find("button").trigger("click"); expect(wrapper.text()).toContain("Count: 1"); }); ``` This test: - **Survives refactoring** - **Reflects real use** - **Communicates intent** ## The Golden Rule: Behavior > Implementation Ask: _Does the component behave correctly when used as intended?_ Good tests: - ✅ Simulate real user behavior - ✅ Assert user-facing outcomes - ✅ Mock external dependencies (router, store, fetch) - ❌ Avoid internal refs or method calls - ❌ Don’t test implementation details ## Why Testing Library Wins [Testing Library](https://testing-library.com/) enforces black box testing. It doesn’t even expose internals. You: - Find elements by role or text - Click, type, tab—like a user would - Assert what's visible on screen Example: ```js it("increments when clicked", async () => { const user = userEvent.setup(); render(Counter); const button = screen.getByRole("button", { name: /increment/i }); const count = screen.getByText(/count:/i); expect(count).toHaveTextContent("Count: 0"); await user.click(button); expect(count).toHaveTextContent("Count: 1"); }); ``` It’s readable, stable, and resilient. ### Bonus: Better Accessibility Testing Library rewards semantic HTML and accessibility best practices: - Proper labels and ARIA roles become _easier_ to test - Icon-only buttons become harder to query (and rightly so) ```vue
``` Win-win. ## Quick Comparison | | White Box | Black Box | | ----------------------- | ------------- | ------------- | | Peeks at internals? | ✅ Yes | ❌ No | | Breaks on refactor? | 🔥 Often | 💪 Rarely | | Reflects user behavior? | ❌ Nope | ✅ Yes | | Useful for real apps? | ⚠️ Not really | ✅ Absolutely | | Readability | 🤯 Low | ✨ High | ## Extract Logic, Test It Separately Black box testing doesn’t mean you can’t test logic in isolation. Just move it _out_ of your components. For example: ```ts // composable export function useCalculator() { const total = ref(0); function add(a: number, b: number) { total.value = a + b; return total.value; } return { total, add }; } // test it("adds numbers", () => { const { total, add } = useCalculator(); expect(add(2, 3)).toBe(5); expect(total.value).toBe(5); }); ``` Logic stays isolated, tests stay simple. ## Conclusion - Treat components like black boxes - Test user behavior, not code structure - Let Testing Library guide your practice - Extract logic to composables or utils --- --- title: The Computed Inlining Refactoring Pattern in Vue description: Learn how to improve Vue component performance and readability by applying the Computed Inlining pattern - a technique inspired by Martin Fowler's Inline Function pattern. tags: ['vue', 'refactoring'] url: https://alexop.dev/posts/computed-inlining-refactoring-pattern-in-vue --- # The Computed Inlining Refactoring Pattern in Vue ## TLDR Improve your Vue component performance and readability by applying the Computed Inlining pattern - a technique inspired by Martin Fowler's Inline Function pattern. By consolidating helper functions directly into computed properties, you can reduce unnecessary abstractions and function calls, making your code more straightforward and efficient. ## Introduction Vue 3's reactivity system is powered by computed properties that efficiently update only when their dependencies change. But sometimes we overcomplicate our components by creating too many small helper functions that only serve a single computed property. This creates unnecessary indirection and can make code harder to follow. The Computed Inlining pattern addresses this problem by consolidating these helper functions directly into the computed properties that use them. This pattern is the inverse of Martin Fowler's Extract Function pattern and is particularly powerful in the context of Vue's reactive system. ## Understanding Inline Function This pattern comes from Martin Fowler's Refactoring catalog, where he describes it as a way to simplify code by removing unnecessary function calls when the function body is just as clear as its name. You can see his original pattern here: [refactoring.com/catalog/inlineFunction.html](https://refactoring.com/catalog/inlineFunction.html) Here's his example: ```javascript function getRating(driver) { return moreThanFiveLateDeliveries(driver) ? 2 : 1; } function moreThanFiveLateDeliveries(driver) { return driver.numberOfLateDeliveries > 5; } ``` After applying the Inline Function pattern: ```javascript function getRating(driver) { return driver.numberOfLateDeliveries > 5 ? 2 : 1; } ``` The code becomes more direct and eliminates an unnecessary function call, while maintaining readability. ## Bringing Inline Function to Vue Computed Properties In Vue components, we often create helper functions that are only used once inside a computed property. While these can improve readability in complex cases, they can also add unnecessary layers of abstraction when the logic is simple. Let's look at how this pattern applies specifically to computed properties in Vue. ### Before Refactoring Here's how a Vue component might look before applying Computed Inlining: ```vue // src/components/OrderSummary.vue ``` The component works but has several issues: - Uses a watch when a computed would be more appropriate - Has multiple helper functions that are only used once - Splits related logic across different properties and functions - Creates unnecessary intermediate values ### After Refactoring with Computed Inlining Now let's apply Computed Inlining to simplify the code: ```vue // src/components/OrderSummary.vue ``` The refactored version: - Consolidates all pricing logic into a single computed property - Eliminates the need for a watch by using Vue's reactive system properly - Removes unnecessary helper functions and intermediate values - Makes the data flow more clear and direct - Reduces the number of reactive dependencies being tracked ## Best Practices - Apply Computed Inlining when the helper function is only used once - Use this pattern when the logic is simple enough to be understood inline - Add comments to clarify steps if the inline logic is non-trivial - Keep computed properties focused on a single responsibility, even after inlining - Consider keeping functions separate if they're reused or complex ## When to Use Computed Inlining - When the helper functions are only used by a single computed property - When performance is critical (eliminates function call overhead) - When the helper functions don't significantly improve readability - When you want to reduce the cognitive load of jumping between functions - When debugging and following the execution flow is important ## When to Avoid Computed Inlining - When the helper function is used in multiple places - When the logic is complex and the function name significantly improves clarity - When the function might need to be reused in the future - When testing the helper function independently is important ## Conclusion The Computed Inlining pattern in Vue is a practical application of Martin Fowler's Inline Function refactoring technique. It helps streamline your reactive code by: - Reducing unnecessary abstractions - Eliminating function call overhead - Making execution flow more direct and easier to follow - Keeping related logic together in one place While not appropriate for every situation, Computed Inlining is a valuable tool in your Vue refactoring toolkit, especially when optimizing components with many small helper functions. Try applying Computed Inlining in your next Vue component refactoring, and see how it can make your code both simpler and more efficient. ## References - [Martin Fowler's Inline Function Pattern](https://refactoring.com/catalog/inlineFunction.html) - [Vue Documentation on Computed Properties](https://vuejs.org/guide/essentials/computed.html) --- --- title: Are LLMs Creative? description: Exploring the fundamental nature of creativity in Large Language Models compared to human creativity, sparked by reflections on OpenAI's latest image model. tags: ['ai'] url: https://alexop.dev/posts/are-llms-creative --- # Are LLMs Creative? ## Introduction After OpenAI released its impressive new image model, I started thinking more deeply about what creativity means. We often consider creativity as something magical and uniquely human. Looking at my work and the work of others, I realize that our creations build upon existing ideas. We remix, adapt, and build on what exists. In that sense, we share similarities with large language models (LLMs). Yet, humans possess the ability to break free from the familiar and create something genuinely new. That's the crucial difference. The constraints of training data limit LLMs. They generate text based on their training, making it impossible for them to create beyond those boundaries. Humans question the status quo. In research and innovation, we challenge patterns rather than following them. This exemplifies human creativity. Take Vincent van Gogh, for example. Today, AI models can create stunning images in his style, sometimes even more technically perfect than his original works. But van Gogh didn't learn his style from a dataset. He invented it. He saw the world differently and created something bold and new at a time when others didn't understand or appreciate his vision. An AI can now copy his style but couldn't have invented it. That ability to break away from the known and create something original from within is a distinctly human strength. ## How LLMs Work LLMs learn from text data sourced from books, sites, and other content. They learn language patterns and use them to generate new text. But they don't understand the meaning behind the words. They don't think, feel, or have experiences. Instead, they predict the next word in a sequence. ## Human Creativity vs. LLMs Humans create with purpose. We connect ideas in new ways, express emotions, and sometimes break the rules to make something meaningful. A poet may write to express grief. An inventor may design a tool to solve a real-world problem. There's intent behind our work. LLMs remix what they've seen. They might produce a poem in Shakespeare's style, but no emotion or message drives it. It's a sophisticated imitation of existing patterns. ## What LLMs Do Well LLMs demonstrate remarkable capabilities in: - Writing stories - Suggesting fresh ideas - Generating jokes or lyrics - Producing design concepts - Helping brainstorm solutions for coding or business problems People use LLMs as creative assistants. A writer might seek ideas when stuck. A developer might explore different coding approaches. LLMs accelerate the creative process and expand possibilities. ## The Limits of LLM Creativity Clear limitations exist. LLMs don't understand what they create. They can't determine if something is meaningful, original, or valuable. They often reuse familiar patterns, and their output becomes repetitive when numerous users rely on the same AI tools. Furthermore, LLMs can't transcend their training. They don't challenge ideas or invent new ways of thinking. Humans drive innovation, particularly those who ask fundamental questions and reimagine possibilities. ## So, Are LLMs Creative? It depends on how you define creativity. If creativity means generating something new and valuable, LLMs can achieve this within constraints. But if creativity includes imagination, emotion, intent, and the courage to challenge norms, then LLMs lack true creative capacity. They serve as powerful tools. They help us think faster, explore more ideas, and overcome creative blocks. But the deeper spark, the reason why we create, remains uniquely human. ## Conclusion LLMs impress with their capabilities. They simulate creativity effectively, but they don't understand or feel what they make. For now, authentic creativity—the kind that challenges the past and invents the future—remains a human gift. --- --- title: The Inline Vue Composables Refactoring pattern description: Apply Martin Fowler's Extract Function pattern to Vue components with inline composables to organize logic without creating new files. tags: ['vue', 'refactoring'] url: https://alexop.dev/posts/inline-vue-composables-refactoring --- # The Inline Vue Composables Refactoring pattern ## TLDR Group related logic in your Vue components into well-named functions inside ` ``` It works, but you have to read every line to figure out what the component does. ### After Refactoring with Inline Composables Group the logic into focused composables: ```ts // src/components/FolderManager.vue ``` The top of the script now spells out the component's responsibilities: ```ts const { showHiddenFolders } = useHiddenFolders(); const { favoriteFolders, toggleFavorite } = useFavoriteFolders(); ``` Each composable carries a descriptive name. The implementation details sit below, out of the way until you need them. ## Best Practices - Reach for inline composables once your ` ``` These are the complete styles used for the screenshots. If your components depend on application CSS, import that CSS into your browser test setup too. ## Define the Button Stories A story describes one configuration to render. You don't need a separate story tool for this example. Create `src/components/buttonStories.ts`: ```typescript type ButtonProps = Pick< InstanceType["$props"], "variant" | "size" | "disabled" >; export interface ButtonStory { name: string; props: ButtonProps; label: string; } export const buttonStories: ButtonStory[] = ( ["primary", "secondary", "outline"] as const ).flatMap((variant) => (["small", "medium", "large"] as const).flatMap((size) => [false, true].map((disabled) => ({ name: `${variant}-${size}-${disabled ? "disabled" : "enabled"}`, props: { variant, size, disabled }, label: disabled ? "Disabled button" : "Save changes", })), ), ); ``` This matrix covers three variants, three sizes, and both disabled states: 18 combinations. Deriving `ButtonProps` from the component keeps the story data checked against its public props. Add `src/components/buttonStories.spec.ts` to check that the matrix covers the expected combinations. This file runs in the unit project: ```typescript test("covers all 18 unique variant, size, and disabled combinations", () => { expect(buttonStories).toHaveLength(18); expect(new Set(buttonStories.map((story) => story.name)).size).toBe(18); expect(buttonStories.filter((story) => story.props.disabled)).toHaveLength(9); }); ``` ## Render the Gallery with Vue Create `src/components/ButtonVariants.vue`: ```vue ``` The fixture owns the labels, spacing, and background. Its `data-testid` gives the test a single target for the gallery screenshot. Each button still uses the real component and its scoped styles. ## Compare the Gallery with a Baseline Create `src/components/BaseButton.browser.spec.ts` alongside the components: ```typescript test("all button variants", async () => { const screen = await render(ButtonVariants); await document.fonts.ready; await expect .element(screen.getByTestId("button-variants")) .toMatchScreenshot("all-button-variants"); }); ``` Await `render()` with the updated Vue adapter. Await the screenshot assertion too. The default `vitest-browser-vue` import registers cleanup between tests, so the test doesn't need to append or remove DOM containers. `toMatchScreenshot()` handles capture, comparison, and failure reporting. The old `expect(screenshot).toBeTruthy()` only checked that a screenshot result existed. Comparing base64 text also missed the image-specific diagnostics available here. Run the browser project: ```bash pnpm test:browser --run ``` On the first local run, Vitest creates the missing reference image and fails with a message asking you to review it. Open the PNG and check the layout, labels, colors, and disabled states. Then run the test again to compare against that reference. For this test on macOS Chromium, the reference lives at: ```text src/components/__screenshots__/BaseButton.browser.spec.ts/ all-button-variants-chromium-darwin.png ``` Commit approved reference PNGs with the test. Browser and platform names distinguish baselines from different environments. ## Prove That a CSS Regression Fails Once the gallery passes, change the primary background in `BaseButton.vue`: ```css .button--primary { background: #e11d48; color: #ffffff; } ``` Run the browser project again without `--update`. In the verification project, changing blue to red produced this failure: ```text Screenshot does not match the stored reference. 33092 pixels (ratio 0.05) differ. ``` Vitest reports the reference, actual screenshot, and diff image when available. In the tested version, the actual and diff images appeared under `.vitest/attachments/src/components/BaseButton.browser.spec.ts/`. Restore `background: #2457d6` and rerun the test. It should pass against the unchanged reference. This gives you evidence that the test detects a styling change before you depend on it. For an intentional design change, update the references: ```bash pnpm test:update ``` Review the changed PNGs before committing. Running an update whenever a test fails would accept accidental regressions too. ```mermaid flowchart LR A[Render Component] --> B[Capture Screenshot] B --> C{Compare with Baseline} C -->|Match| D[Test Passes] C -->|Difference| E[Review Changes] E -->|Intentional| F[Update and Review Baseline] E -->|Regression| G[Fix Component] G --> A ``` ## Add Screenshots for Individual States The gallery gives you an overview. Separate screenshots identify which configuration changed without inspecting the whole gallery. Add these imports and the parameterized test to `BaseButton.browser.spec.ts`: ```typescript test.each(buttonStories)("visual: $name", async ({ name, props, label }) => { const screen = await render(BaseButton, { props, slots: { default: label }, }); await document.fonts.ready; await expect .element(screen.getByRole("button", { name: label })) .toMatchScreenshot(name); }); ``` This adds 18 reference images. As before, inspect new baselines before accepting them. For keyboard focus, capture enough space around the button to include its outline. An element screenshot cropped to the button's box can omit an outline drawn outside it: ```typescript test("keyboard focus appearance", async () => { const screen = await render(BaseButton, { slots: { default: "Save changes" }, }); screen.container.style.cssText = "display: inline-block; padding: 12px; background: white"; await userEvent.tab(); const button = page.getByRole("button", { name: "Save changes" }); await expect.element(button).toHaveFocus(); await expect.element(screen.container).toMatchScreenshot("keyboard-focus"); }); ``` Keep behavior tests alongside visual tests. A screenshot of a disabled button checks its appearance; an assertion such as `toBeDisabled()` checks its disabled state. Click and keyboard tests check whether users can activate the enabled button. ## Keep Screenshot Runs Consistent Use the same OS, browser version, fonts, and viewport when generating and comparing references. A macOS baseline doesn't establish what a Linux CI run should look like. Commit the lockfile and install the matching Playwright browser in the environment that runs these tests. For Linux CI, Playwright can install system dependencies too: ```bash pnpm exec playwright install --with-deps chromium pnpm test:browser --run ``` The verification here covered local macOS Chromium. Review Linux references in your CI environment before using them to gate changes. Wait for the UI state you intend to capture. `document.fonts.ready` waits for fonts, but it doesn't wait for your API response or an image to finish loading. Use fixed test data and assert that loading has finished before taking the screenshot. Vitest retries captures to find a stable image. Its Playwright screenshot assertion disables animations by default. Those defaults help, but you still need to choose a repeatable state. See the [visual regression testing guide](https://main.vitest.dev/guide/browser/visual-regression-testing) for comparison options and environment setup. ## What the Verification Covered The separate Vue/Vite project passed 23 tests: the gallery, 18 individual screenshots, keyboard focus, two behavior tests, and one unit test for the story matrix. Strict Vue/TypeScript checking and the production build passed too. The deliberate color change failed with an image diff, and restoring the CSS passed against the original baseline. That capture, review, fail, and restore cycle is the workflow to establish for your own base components. --- --- title: How to Test Vue Router Components with Testing Library and Vitest description: Learn how to test Vue Router components using Testing Library and Vitest. This guide covers real router integration, mocked router setups, and best practices for testing navigation, route guards, and dynamic components in Vue applications. tags: ['vue', 'testing', 'vue-router', 'vitest', 'testing-library'] url: https://alexop.dev/posts/how-to-test-vue-router-components-with-testing-library-and-vitest --- # How to Test Vue Router Components with Testing Library and Vitest ## TLDR This guide shows you how to test Vue Router components using real router integration and isolated component testing with mocks. You'll learn to verify router-link interactions, programmatic navigation, and navigation guard handling. ## Introduction Modern Vue applications need thorough testing to ensure reliable navigation and component performance. We'll cover testing strategies using Testing Library and Vitest to simulate real-world scenarios through router integration and component isolation. ## Vue Router Testing Techniques with Testing Library and Vitest Let's explore how to write effective tests for Vue Router components using both real router instances and mocks. ## Testing Vue Router Navigation Components ### Navigation Component Example ```vue ``` ### Real Router Integration Testing Test complete routing behavior with a real router instance: ```typescript describe("NavigationMenu", () => { it("should navigate using router links", async () => { const router = createRouter({ history: createWebHistory(), routes: [ { path: "/dashboard", component: { template: "Dashboard" } }, { path: "/settings", component: { template: "Settings" } }, { path: "/profile", component: { template: "Profile" } }, { path: "/", component: { template: "Home" } }, ], }); render(NavigationMenu, { global: { plugins: [router], }, }); const user = userEvent.setup(); expect(router.currentRoute.value.path).toBe("/"); await router.isReady(); await user.click(screen.getByText("Dashboard")); expect(router.currentRoute.value.path).toBe("/dashboard"); await user.click(screen.getByText("Profile")); expect(router.currentRoute.value.path).toBe("/profile"); }); }); ``` ### Mocked Router Testing Test components in isolation with router mocks: ```typescript const mockPush = vi.fn(); vi.mock("vue-router", () => ({ useRouter: vi.fn(), })); describe("NavigationMenu with mocked router", () => { it("should handle navigation with mocked router", async () => { const mockRouter = { push: mockPush, currentRoute: { value: { path: "/" } }, } as unknown as Router; vi.mocked(useRouter).mockImplementation(() => mockRouter); const user = userEvent.setup(); render(NavigationMenu); await user.click(screen.getByText("Profile")); expect(mockPush).toHaveBeenCalledWith("/profile"); }); }); ``` ### RouterLink Stub for Isolated Testing Create a RouterLink stub to test navigation without router-link behavior: ```ts // test-utils.ts export const RouterLinkStub: Component = { name: "RouterLinkStub", props: { to: { type: [String, Object], required: true, }, tag: { type: String, default: "a", }, exact: Boolean, exactPath: Boolean, append: Boolean, replace: Boolean, activeClass: String, exactActiveClass: String, exactPathActiveClass: String, event: { type: [String, Array], default: "click", }, }, setup(props) { const router = useRouter(); const navigate = () => { router.push(props.to); }; return { navigate }; }, render() { return h( this.tag, { onClick: () => this.navigate(), }, this.$slots.default?.() ); }, }; ``` Use the RouterLinkStub in tests: ```ts const mockPush = vi.fn(); vi.mock("vue-router", () => ({ useRouter: vi.fn(), })); describe("NavigationMenu with mocked router", () => { it("should handle navigation with mocked router", async () => { const mockRouter = { push: mockPush, currentRoute: { value: { path: "/" } }, } as unknown as Router; vi.mocked(useRouter).mockImplementation(() => mockRouter); const user = userEvent.setup(); render(NavigationMenu, { global: { stubs: { RouterLink: RouterLinkStub, }, }, }); await user.click(screen.getByText("Dashboard")); expect(mockPush).toHaveBeenCalledWith("/dashboard"); }); }); ``` ### Testing Navigation Guards Test navigation guards by rendering the component within a route context: ```vue ``` Test the navigation guard: ```ts const routes = [ { path: "/", component: RouteLeaveGuardDemo }, { path: "/about", component: { template: "
About
" } }, ]; const router = createRouter({ history: createWebHistory(), routes, }); const App = { template: "" }; describe("RouteLeaveGuardDemo", () => { beforeEach(async () => { vi.clearAllMocks(); window.confirm = vi.fn(); await router.push("/"); await router.isReady(); }); it("should prompt when guard is triggered and user confirms", async () => { // Set window.confirm to simulate a user confirming the prompt window.confirm = vi.fn(() => true); // Render the component within a router context render(App, { global: { plugins: [router], }, }); const user = userEvent.setup(); // Find the 'About' link and simulate a user click const aboutLink = screen.getByRole("link", { name: /About/i }); await user.click(aboutLink); // Assert that the confirm dialog was shown with the correct message expect(window.confirm).toHaveBeenCalledWith( "Do you really want to leave this page?" ); // Verify that the navigation was allowed and the route changed to '/about' expect(router.currentRoute.value.path).toBe("/about"); }); }); ``` ### Reusable Router Test Helper Create a helper function to simplify router setup: ```typescript // test-utils.ts // path of the definition of your routes interface RenderWithRouterOptions extends Omit, "global"> { initialRoute?: string; routerOptions?: { routes?: typeof routes; history?: ReturnType; }; } export function renderWithRouter( Component: any, options: RenderWithRouterOptions = {} ) { const { initialRoute = "/", routerOptions = {}, ...renderOptions } = options; const router = createRouter({ history: createWebHistory(), // Use provided routes or import from your router file routes: routerOptions.routes || routes, }); router.push(initialRoute); return { // Return everything from regular render, plus the router instance ...render(Component, { global: { plugins: [router], }, ...renderOptions, }), router, }; } ``` Use the helper in tests: ```typescript describe("NavigationMenu", () => { it("should navigate using router links", async () => { const { router } = renderWithRouter(NavigationMenu, { initialRoute: "/", }); await router.isReady(); const user = userEvent.setup(); await user.click(screen.getByText("Dashboard")); expect(router.currentRoute.value.path).toBe("/dashboard"); }); }); ``` ### Conclusion: Best Practices for Vue Router Component Testing When we test components that rely on the router, we need to consider whether we want to test the functionality in the most realistic use case or in isolation. In my humble opinion, the more you mock a test, the worse it will get. My personal advice would be to aim to use the real router instead of mocking it. Sometimes, there are exceptions, so keep that in mind. Also, you can help yourself by focusing on components that don't rely on router functionality. Reserve router logic for view/page components. While keeping our components simple, we will never have the problem of mocking the router in the first place. --- --- title: Building a Pinia Plugin for Cross-Tab State Syncing description: Learn how to create a Pinia plugin that synchronizes state across browser tabs using the BroadcastChannel API and Vue 3's Script Setup syntax. tags: ['vue', 'pinia'] url: https://alexop.dev/posts/building-pinia-plugin-cross-tab-sync --- # Building a Pinia Plugin for Cross-Tab State Syncing ## TLDR Create a Pinia plugin that enables state synchronization across browser tabs using the BroadcastChannel API. The plugin allows you to mark specific stores for cross-tab syncing and handles state updates automatically with timestamp-based conflict resolution. ## Introduction In modern web applications, users often work with multiple browser tabs open. When using Pinia for state management, we sometimes need to ensure that state changes in one tab are reflected across all open instances of our application. This post will guide you through creating a plugin that adds cross-tab state synchronization to your Pinia stores. ## Understanding Pinia Plugins A Pinia plugin is a function that extends the functionality of Pinia stores. Plugins are powerful tools that help: - Reduce code duplication - Add reusable functionality across stores - Keep store definitions clean and focused - Implement cross-cutting concerns ## Cross-Tab Communication with BroadcastChannel The BroadcastChannel API provides a simple way to send messages between different browser contexts (tabs, windows, or iframes) of the same origin. It's perfect for our use case of synchronizing state across tabs. Key features of BroadcastChannel: - Built-in browser API - Same-origin security model - Simple pub/sub messaging pattern - No need for external dependencies ### How BroadcastChannel Works The BroadcastChannel API operates on a simple principle: any browsing context (window, tab, iframe, or worker) can join a channel by creating a `BroadcastChannel` object with the same channel name. Once joined: 1. Messages are sent using the `postMessage()` method 2. Messages are received through the `onmessage` event handler 3. Contexts can leave the channel using the `close()` method ## Implementing the Plugin ### Store Configuration To use our plugin, stores need to opt-in to state sharing through configuration: ```ts export const useCounterStore = defineStore( "counter", () => { const count = ref(0); const doubleCount = computed(() => count.value * 2); function increment() { count.value++; } return { count, doubleCount, increment }; }, { share: { enable: true, initialize: true, }, } ); ``` The `share` option enables cross-tab synchronization and controls whether the store should initialize its state from other tabs. ### Plugin Registration `main.ts` Register the plugin when creating your Pinia instance: ```ts const pinia = createPinia(); pinia.use(PiniaSharedState); ``` ### Plugin Implementation `plugin/plugin.ts` Here's our complete plugin implementation with TypeScript support: ```ts type Serializer = { serialize: (value: T) => string; deserialize: (value: string) => T; }; interface BroadcastMessage { type: "STATE_UPDATE" | "SYNC_REQUEST"; timestamp?: number; state?: string; } type PluginOptions = { enable?: boolean; initialize?: boolean; serializer?: Serializer; }; export interface StoreOptions< S extends StateTree = StateTree, G = object, A = object, > extends DefineStoreOptions { share?: PluginOptions; } // Add type extension for Pinia declare module "pinia" { // eslint-disable-next-line @typescript-eslint/no-unused-vars export interface DefineStoreOptionsBase { share?: PluginOptions; } } export function PiniaSharedState({ enable = false, initialize = false, serializer = { serialize: JSON.stringify, deserialize: JSON.parse, }, }: PluginOptions = {}) { return ({ store, options }: PiniaPluginContext) => { if (!(options.share?.enable ?? enable)) return; const channel = new BroadcastChannel(store.$id); let timestamp = 0; let externalUpdate = false; // Initial state sync if (options.share?.initialize ?? initialize) { channel.postMessage({ type: "SYNC_REQUEST" }); } // State change listener store.$subscribe((_mutation, state) => { if (externalUpdate) return; timestamp = Date.now(); channel.postMessage({ type: "STATE_UPDATE", timestamp, state: serializer.serialize(state as T), }); }); // Message handler channel.onmessage = (event: MessageEvent) => { const data = event.data; if ( data.type === "STATE_UPDATE" && data.timestamp && data.timestamp > timestamp && data.state ) { externalUpdate = true; timestamp = data.timestamp; store.$patch(serializer.deserialize(data.state)); externalUpdate = false; } if (data.type === "SYNC_REQUEST") { channel.postMessage({ type: "STATE_UPDATE", timestamp, state: serializer.serialize(store.$state as T), }); } }; }; } ``` The plugin works by: 1. Creating a BroadcastChannel for each store 2. Subscribing to store changes and broadcasting updates 3. Handling incoming messages from other tabs 4. Using timestamps to prevent update cycles 5. Supporting custom serialization for complex state ### Communication Flow Diagram ```mermaid flowchart LR A[User interacts with store in Tab 1] --> B[Store state changes] B --> C[Plugin detects change] C --> D[BroadcastChannel posts STATE_UPDATE] D --> E[Other tabs receive STATE_UPDATE] E --> F[Plugin patches store state in Tab 2] ``` ## Using the Synchronized Store Components can use the synchronized store just like any other Pinia store: ```ts const counterStore = useCounterStore(); // State changes will automatically sync across tabs counterStore.increment(); ``` ## Conclusion With this Pinia plugin, we've added cross-tab state synchronization with minimal configuration. The solution is lightweight, type-safe, and leverages the built-in BroadcastChannel API. This pattern is particularly useful for applications where users frequently work across multiple tabs and need a consistent state experience. Remember to consider the following when using this plugin: - Only enable sharing for stores that truly need it - Be mindful of performance with large state objects - Consider custom serialization for complex data structures - Test thoroughly across different browser scenarios ## Future Optimization: Web Workers For applications with heavy cross-tab communication or complex state transformations, consider offloading the BroadcastChannel handling to a Web Worker. This approach can improve performance by: - Moving message processing off the main thread - Handling complex state transformations without blocking UI - Reducing main thread load when syncing large state objects - Buffering and batching state updates for better performance This is particularly beneficial when: - Your application has many tabs open simultaneously - State updates are frequent or computationally intensive - You need to perform validation or transformation on synced data - The application handles large datasets that need to be synced You can find the complete code for this plugin in the [GitHub repository](https://github.com/alexanderop/pluginPiniaTabs). It also has examples of how to use it with Web Workers. --- --- title: How to Use AI for Effective Diagram Creation: A Guide to ChatGPT and Mermaid description: Learn how to leverage ChatGPT and Mermaid to create effective diagrams for technical documentation and communication. tags: ['ai', 'productivity'] url: https://alexop.dev/posts/how-to-use-ai-for-effective-diagram-creation-a-guide-to-chatgpt-and-mermaid --- # How to Use AI for Effective Diagram Creation: A Guide to ChatGPT and Mermaid ## TLDR Learn how to combine ChatGPT and Mermaid to quickly create professional diagrams for technical documentation. This approach eliminates the complexity of traditional diagramming tools while maintaining high-quality output. ## Introduction Mermaid is a markdown-like script language that generates diagrams from text descriptions. When combined with ChatGPT, it becomes a powerful tool for creating technical diagrams quickly and efficiently. ## Key Diagram Types ### Flowcharts Perfect for visualizing processes: ```plaintext flowchart LR A[Customer selects products] --> B[Customer reviews order] B --> C{Payment Successful?} C -->|Yes| D[Generate Invoice] D --> E[Dispatch goods] C -->|No| F[Redirect to Payment] ``` ```mermaid flowchart LR A[Customer selects products] --> B[Customer reviews order] B --> C{Payment Successful?} C -->|Yes| D[Generate Invoice] D --> E[Dispatch goods] C -->|No| F[Redirect to Payment] ``` ### Sequence Diagrams Ideal for system interactions: ```plaintext sequenceDiagram participant Client participant Server Client->>Server: Request (GET /resource) Server-->>Client: Response (200 OK) ``` ```mermaid sequenceDiagram participant Client participant Server Client->>Server: Request (GET /resource) Server-->>Client: Response (200 OK) ``` ## Using ChatGPT with Mermaid 1. Ask ChatGPT to explain your concept 2. Request a Mermaid diagram representation 3. Iterate on the diagram with follow-up questions Example prompt: "Create a Mermaid sequence diagram showing how Nuxt.js performs server-side rendering" ```plaintext sequenceDiagram participant Client as Client Browser participant Nuxt as Nuxt.js Server participant Vue as Vue.js Application participant API as Backend API Client->>Nuxt: Initial Request Nuxt->>Vue: SSR Starts Vue->>API: API Calls (if any) API-->>Vue: API Responses Vue->>Nuxt: Rendered HTML Nuxt-->>Client: HTML Content ``` ```mermaid sequenceDiagram participant Client as Client Browser participant Nuxt as Nuxt.js Server participant Vue as Vue.js Application participant API as Backend API Client->>Nuxt: Initial Request Nuxt->>Vue: SSR Starts Vue->>API: API Calls (if any) API-->>Vue: API Responses Vue->>Nuxt: Rendered HTML Nuxt-->>Client: HTML Content ``` ## Quick Setup Guide ### Online Editor Use [Mermaid Live Editor](https://mermaid.live/) for quick prototyping. ### VS Code Integration 1. Install "Markdown Preview Mermaid Support" extension 2. Create `.md` file with Mermaid code blocks 3. Preview with built-in markdown viewer ### Web Integration ```html
graph TD A-->B
``` ## Conclusion The combination of ChatGPT and Mermaid streamlines technical diagramming, making it accessible and efficient. Try it in your next documentation project to save time while creating professional diagrams. --- --- title: The Browser That Speaks 200 Languages: Building an AI Translator Without APIs description: Learn how to build a browser-based translator that works offline and handles 200 languages using Vue and Transformers.js tags: ['vue', 'ai'] url: https://alexop.dev/posts/building-client-side-ai-translator-vue --- # The Browser That Speaks 200 Languages: Building an AI Translator Without APIs ## Introduction Most AI translation tools rely on external APIs. This means sending data to servers and paying for each request. But what if you could run translations directly in your browser? This guide shows you how to build a free, offline translator that handles 200 languages using Vue and Transformers.js. ## The Tools - Vue 3 for the interface - Transformers.js to run AI models locally - Web Workers to handle heavy processing - NLLB-200, Meta's translation model ```mermaid --- title: Architecture Overview --- graph LR Frontend[Vue Frontend] Worker[Web Worker] TJS[Transformers.js] Model[NLLB-200 Model] Frontend -->|"Text"| Worker Worker -->|"Initialize"| TJS TJS -->|"Load"| Model Model -->|"Results"| TJS TJS -->|"Stream"| Worker Worker -->|"Translation"| Frontend classDef default fill:#344060,stroke:#AB4B99,color:#EAEDF3 classDef accent fill:#8A337B,stroke:#AB4B99,color:#EAEDF3 class TJS,Model accent ``` ## Building the Translator ![AI Translator](../../assets/images/vue-ai-translate.png) ### 1. Set Up Your Project Create a new Vue project with TypeScript: ```bash npm create vite@latest vue-translator -- --template vue-ts cd vue-translator npm install npm install @huggingface/transformers ``` ### 2. Create the Translation Worker The translation happens in a background process. Create `src/worker/translation.worker.ts`: ```typescript import { pipeline, TextStreamer, TranslationPipeline, } from "@huggingface/transformers"; // Singleton pattern for the translation pipeline class MyTranslationPipeline { static task: PipelineType = "translation"; // We use the distilled model for faster loading and inference static model = "Xenova/nllb-200-distilled-600M"; static instance: TranslationPipeline | null = null; static async getInstance(progress_callback?: ProgressCallback) { if (!this.instance) { this.instance = (await pipeline(this.task, this.model, { progress_callback, })) as TranslationPipeline; } return this.instance; } } // Type definitions for worker messages interface TranslationRequest { text: string; src_lang: string; tgt_lang: string; } // Worker message handler self.addEventListener( "message", async (event: MessageEvent) => { try { // Initialize the translation pipeline with progress tracking const translator = await MyTranslationPipeline.getInstance(x => { self.postMessage(x); }); // Configure streaming for real-time translation updates const streamer = new TextStreamer(translator.tokenizer, { skip_prompt: true, skip_special_tokens: true, callback_function: (text: string) => { self.postMessage({ status: "update", output: text, }); }, }); // Perform the translation const output = await translator(event.data.text, { tgt_lang: event.data.tgt_lang, src_lang: event.data.src_lang, streamer, }); // Send the final result self.postMessage({ status: "complete", output, }); } catch (error) { self.postMessage({ status: "error", error: error instanceof Error ? error.message : "An unknown error occurred", }); } } ); ``` ### 3. Build the Interface Create a clean interface with two main components: #### Language Selector (`src/components/LanguageSelector.vue`) ```vue ``` #### Progress Bar (`src/components/ProgressBar.vue`) ```vue ``` ### 4. Put It All Together In your main app file: ```vue