← All work 05 / Side project · Mobile PWA · Agentic AI

Six friends.
A 993-sticker album.
One small state machine.

A PWA for trading repeats from the Panini Copa do Mundo 2026 sticker album. Designed and built solo, with agentic AI as a design partner — and a very small audience to be honest with.

loading explore…
Role Designer + builder
Audience 6 friends · 1 group
Duration 3 days · 2026
Stack React · Vite · TS · Firebase · PWA

Six of us were collecting the Panini Copa do Mundo 2026 album. The album has 993 stickers across 48 national teams and two special series. Coordinating who has which repeat — and who agreed to swap what with whom — was happening over scattered WhatsApp messages and screenshots of paper lists.

It is the kind of problem that looks trivial until you try to hold the state of it in your head. Two people ask for the same sticker. Someone forgets they already promised it. A trade is agreed, then one side registers another duplicate they didn't know they had. The album is small; the coordination is not.

The constraint was
the size of the audience.

The app could absorb the coordination cost, but only if it asked for almost nothing in return. Friends, not users. No accounts. No full-album registration. No daily upkeep.

Anything heavier than that and nobody would open it past the first week — and there would be no recovery, because the audience is six people who all have my phone number.

Friends, not users.

The framing that decided every scope question — what to include, what to cut, and how much friction was fair to put in front of a feature nobody could see until they needed it.

Designer, engineer, PM, QA —
and the only customer.

No stakeholders to convince. No backlog inherited. The only check on bad decisions was whether my friends opened the app on a Sunday afternoon.

This was also a deliberate experiment in agentic AI as a design partner. I ran the work through a structured loop: brainstorm the design, commit a spec document, generate an implementation plan, then let the agent execute against the plan while I reviewed the diff.

The point wasn't speed. It was forcing myself to write the design clearly enough that an agent could execute it without supervision — which is also clear enough that a future me can read it.

brainstorming → spec spec → plan plan → execution code review on each PR

Stickers aren't numbered —
they're addressed.

The Copa 2026 album doesn't use sequential numbers. A sticker is identified by the combination of team + number: BRA-07, ARG-12, FRA-19. 48 teams across 12 groups, plus two special series. Total: 993.

That shaped everything. The picker had to walk you through group → team → number, not down a giant list. Every card showed flag and country alongside the number, because the number alone means nothing without context. Modelling the real album — not a tidied-up version of it — was the difference between an app that felt like the album and an app that felt like a database.

BRA
07
Brasil
ARG
12
Argentina
FRA
19
França
GER
04
Alemanha
FWC
05
WC History
CC
03
Coca-Cola
groups/{gid} │ ├── members/{uid} who's in the group ├── repos/{uid} one map per user, sparse │ │ │ └── stickers │ ├── "BRA-07" { total: 3, reserved: 1 } │ ├── "ARG-12" { total: 1, reserved: 0 } │ └── … │ └── trades/{tid} one doc per trade, open or closed
Firestore — one document per user repo, sparse by design

Only register
your repeats.

Asking someone to register 993 stickers is a non-starter. Asking them to register just their duplicates is barely tolerable. The whole system was built around that single asymmetry.

Your repository only stores stickers you actually have duplicates of. An entry quietly disappears when its count drops to zero — no empty rows to clean up. The Explore tab shows what other people have available; what's missing from your own album is never demanded as input.

The hardest UX work was deciding what not to ask the user to do.
Trade lifecycle

One sticker,
one promise at a time.

A trade goes through four states. The moment two friends agree on what they're swapping, both stickers are held aside — they disappear from everyone else's view until the trade is done, or one of them backs out.

That single rule is what stops the same sticker from being promised twice. No double-bookings. No "I thought you'd already given that one away." The bookkeeping that used to live in everyone's head now lives in the app — and only in the app.

interesse negociado trocada cancelada
interesse ──── say yes ───► negociado ──── done ───► trocada │ │ └──── cancel ◄──────────┴──► cancelada
Four states · entering negociado holds both stickers aside
loading trocas…
Trocas · live
loading álbum…
Meu Álbum · added after the first scope was too narrow
The miss

My first scope was
too narrow to be useful.

The first version only tracked repeats. The album itself — what you had already glued in — was nowhere in the data. Within a week of real use, that was clearly wrong.

People wanted to see their album fill up. They wanted progress per team. They wanted to know which stickers a friend had that they actually needed. I had built a trading tool; they wanted a companion to the album.

I rewrote the data model. One field, two meanings: the first copy of any sticker is the album entry; anything beyond that is a repeat. Every other calculation in the system updated to match.

Before quantity: number
reserved: number
After total: number
reserved: number
// src/lib/stickerMath.ts — single source of truth export function inAlbum(c: StickerCount): boolean { return c.total >= 1; } export function repetidas(c: StickerCount): number { return Math.max(0, c.total - 1); } export function availableForTrade(c: StickerCount): number { return Math.max(0, c.total - 1 - c.reserved); }

Two friends already had real data. Migration was small in volume but I treated it like production: a dry-run mode that lists every change, idempotent so a second run does nothing, and a clear signal to detect already-migrated entries. Side projects are the only place where you get to rehearse expensive habits cheaply.

Identity without accounts

Anonymous sign-in was beautiful —
until you swapped phones.

The first version had no sign-in at all. Pick a name, pick an avatar, tap Enter. Beautiful — until you cleared the browser, or opened the app on a laptop, or got a new phone. Then you were a stranger to your own data.

I shipped it that way anyway, because the alternative — email or SMS — killed the welcome flow. The cost surfaced almost immediately: a friend opened the app on a different device and lost their repo.

I replaced it with a recovery code generated on first entry. The user copies it once, pastes it on another device, and becomes the same person. No accounts. No login screen. The failure mode (forget the code) is the same as the anonymous version's, with an escape hatch.

Recovery code A3K · 9P2
The decision was less about auth and more about how much friction is fair to put in front of a feature you can't see until you need it.
loading grupo…
Grupo · members + invite code

Agentic AI as a process,
not as magic.

The Claude Code loop only worked because I wrote clear specs. Vague specs produced vague code, fast. The leverage came from the structure I imposed on myself, not from the speed of execution.

Every non-trivial feature went through the same loop: brainstorm the design, commit a spec, generate a plan, then let the agent execute while I reviewed the diff. The spec doc is the artefact I keep — the code is rewritable.

94f4868
initInitial commit: PWA álbum Copa 2026 com troca de figurinhas
2e557ae
decisionSubstituir auth anônimo por código de recuperação (cross-device sync)
2fdd54a
specSpec: design da feature Meu Álbum
9135647
specSpec: adicionar seção de migração quantity → total + 1 via script ADC
703ef52
featureFeature: Meu Álbum (unified total = colada + extras)
cfe908c
fixBottom nav + FAB perdiam fixed pos por containing block do PullToRefresh
589ab65
uiHero sticky top-0; paper variant ganha bg + border bottom

The two commits I keep coming back to are 2e557ae and 2fdd54a — neither is a feature. One is a reversal of an earlier decision. The other is a spec document that preceded any code. Both are the parts of the work that the agent could not have produced on its own.

× 01

A native gesture fought the layout.

The browser's pull-to-refresh broke a sticky header and reordered the page mid-scroll. Blocked the gesture with overscroll-behavior-y: contain, lost a small affordance to keep the page stable. The right trade-off, but the kind I'd rather have predicted than discovered.

× 02

I labelled the same thing four times.

The first repo card had a sticker badge, a label (CZE #14), a flag emoji and a country caption. I shipped it that way, looked at the grid the next day, stripped it back to one badge and one counter. Redundancy is comfortable to add and uncomfortable to remove.

/ 01

Writing the design forces the product to exist twice.

Once as a spec a stranger could understand, once as the implementation. The gap between those two is where most of the bad decisions hide — and where the spec process catches them, when it works.

/ 02

Agentic AI is a discipline amplifier.

The leverage came from the structure I imposed on myself, not from the speed of execution. Specs first, plans second, code last. A clear spec is a contract; an agent will honour a contract. A vague spec is a wish.

/ 03

Small audiences let you over-engineer the parts that matter.

Six users do not justify a transactional state machine. I built one anyway, because the alternative was the bug I knew would happen — and the practice of solving it well is the kind of habit I want to bring to work where it does justify itself.

/ 04

Honest about scope, even (especially) for side projects.

There are no real metrics here. Six friends opened it; some of them came back. The result of this project is the project — the spec docs in the repo, the migration script that survived a real schema change, the recovery-code flow that saved a friend's data the second time.