Works/Citameya
CITAMEYA

Booking for people who work with their hands

Product designer · Brand · Solo build2026MadridLive at citameya.com

A hairdresser in Madrid runs her week from a paper notebook. Citameya turns that into a link she sends once — and, when her hands are busy, a sentence she says out loud.

I owned Product & UXBrandDesign system Front endAPI & databaseStripe billing WhatsApp & emailSix languagesAnalytics & A/BDeploys
Citameya public booking page for a Madrid hairdresser
Citameya booking summary and details step
Citameya master dashboard on a phone

What shipped · 29 Apr → 23 Jul 2026

Three months, one person, one product.

191commits · one author
33app routes
83API endpoints
32database migrations
191catalogued services
6languages, end to end
10scheduled jobs
€15per month · 20-day trial

01 Landing

Sell it to someone who has never booked online

The audience is not a salon chain with an office manager. It is one self-employed person, mid-haircut, being asked to trust software with the thing that pays her rent. So the promise is the first line, in her words: «Deja la libreta. Habla, y Citameya lo apunta.» — drop the notebook, speak, and Citameya writes it down.

The voice promise first, then what switching costs her
Citameya landing page on mobile
Mobile — nearly all of the traffic

02 Voice booking

Say it while your hands are busy

A form is the wrong interface for someone holding scissors. So you talk, and the fields fill themselves as you speak — one chip at a time, each tappable if the machine got it wrong. It runs on every public booking page, and this is the reason the product has the shape it has.

01 · IN THE BROWSERWeb Speech streams

Phrases arrive as they are spoken. Nothing has left the device but text.

02 · ON THE SERVERGemini parses

The running transcript is matched against this master’s own service list, once every three seconds.

03 · BACK ON SCREENChips appear

Service, date, time, name, phone. Tap one to correct it and your edit is locked against later passes.

04 · ON STOPOne accurate pass

The recording gets a final, slower transcription that merges with your corrections instead of overwriting them.

Live chips — date, time, name and phone recognised nine seconds into a dictated booking
Nine seconds into «quiero un corte de pelo el viernes a las cinco de la tarde, me llamo Ana López…» — parsed live, from the running transcript.
The voice offer on the public booking page, with its privacy note
The offer on the public page. The disclaimer is part of the design: audio is processed in the browser, only text goes to Google AI, nothing is stored.

Shipped as an experiment, not as an opinion

Live captioning could just as easily be noise — a row of chips jittering while somebody is trying to talk. So it went out as a real 50/50 split in PostHog, measured on a funnel from first tap to a booking actually created by voice, with iOS and Firefox excluded because they have no Web Speech to stream.

The experiment decides whether it stays. That is the point of building it behind a flag instead of shipping it because it demos well.

ControlButton, then a form
50%
KaraokeChips as you speak
50%
ExcludediOS · Firefox — no Web Speech
The hero animation, generated for the landing. It carries the whole pitch without a word of interface: hands busy, phone up, appointment written down.

03 Booking page

The whole product is one link

citameya.com/maria-garcia — four steps, no account, no app. Services, day, time, details — with price and duration visible before the first tap, because the only questions a client really has are what it costs and how long she is there.

Booking step one — choose services
01ServicesA tinted row per service, price and minutes on the row. Pick several and they become one appointment.
Booking step two — pick a day
02DayOnly days she actually works, drawn as tiles rather than a native date input.
Booking step three — pick a time
03TimeSlots computed from the total duration, so a 120-minute colour never lands in a 45-minute gap.
Booking step four — details and confirm
04DetailsName, phone, optional email — summary pinned above, so nothing is confirmed blind.

Colour is doing work here

Each service row carries its own pastel. Not decoration: on a page with eight services, the tint is what a returning client scans for instead of reading the list again. The price pill is always the same shape, in the same place.

The name on the row resolves through a 191-entry catalogue at render time, in the language of whoever is looking — not the language the master typed it in.

Corte de pelo45 min
25.00€
Tinte completo120 min
65.00€
Tratamiento hidratante60 min
40.00€

No calls.
No DMs.
One link.

as it runs on the landing: «Deja la libreta. Habla, y Citameya lo apunta.»

04 The master’s day

The screen she opens with wet hands

The client’s side is four steps and then it is over. This is the side she opens between two clients to answer one question: what is next. So the day is the default view, the week is one tap away, and every action she actually performs is a single round button at the top.

Master dashboard — setup progress, action bar, counters, week strip and the day’s bookings
Setup progress sits at the top until it is finished, then disappears for good
Demo mode showing a full day of bookings
Demo mode — the same screen with a full day, so a prospect sees it working before signing up
Statistics — bookings, revenue, top services and traffic sources
Bookings, revenue, most-booked services, and where the bookings came from
Settings — profile, working hours, services and subscription
Settings — profile, hours, services and the €15 subscription, all in one place

Before the day exists, there is the setup

Asking a hairdresser to type out thirty services with prices and durations is where onboarding dies. So the first question is only «¿A qué te dedicas?» — and answering it loads a slice of a 191-entry catalogue, already translated, with default durations she can correct. Three steps and the public page exists: no sales call, no import, no card.

The magic link goes out over WhatsApp as well as email, because this audience lives there and forgets passwords.

Citameya registration screen
Onboarding — what do you do?
Master dashboard on a phone
The 1080 by 1920 Instagram Story the share kit generates — her name, her services and a QR to her booking page
What the share kit hands her, 1080 × 1920
  • Distribution was a design problem, not a marketing one. The product cannot make her post on Instagram — but it can hand her a finished Story with today’s free slots already in it.

  • Creating a booking used to leave her looking at the wrong day. Now the view jumps to the date she just booked. That bug was invisible until you used the thing for a week.

  • Blocking time off needed its own calendar: a native date input cannot grey out days that are already taken, so she could only find out by getting an error.

05 Trust & no-shows

Everything here exists to stop an empty chair

A missed appointment is not an inconvenience for a self-employed hairdresser. It is an hour of income that does not come back. Four features, one job.

A deposit, when she wants one

Stripe takes a deposit up front on the services where a no-show hurts most. Per-master and off by default — charging a regular for a €15 beard trim is a good way to lose her.

Cancel by link

Every confirmation carries a tokenised cancel link. Letting a client cancel in one tap sounds like a loss; it is how the slot gets back on the market while it can still be sold.

A waiting list on a full day

A sold-out day used to be a dead end. Now it collects a name, a phone and a language — and when a cancellation frees the slot, that list hears about it first.

Reviews nobody invented

The scheduler that sends the reminder also asks for the review, and it lands on the page the next client reads. If nobody has reviewed, nothing is shown — there is no score to fake.

Waiting-list form on a fully booked day

06 Notifications

The product also lives in WhatsApp

Nobody in this market opens a dashboard to learn that a booking arrived. WhatsApp, email and web push carry the product to people who never log in — and each message is written in the language of whoever receives it, which is not the same as the language of whoever caused it.

Templates, master-facing and client-facing

new bookingmorning briefcancelledrescheduled day-before reminderleave a reviewfollow-up slot freed — waiting listmagic linkemail verification password resetrecurring summarytrial ending payment receivedfirst booking

Ten scheduled jobs, all on Madrid time

07:30 the day’s bookings, before she opens

11:00 a nudge to try dictating one

10:00 forty-eight hours with no bookings — something is wrong

10:15 trial ends in two days

hourly registered, never shared the link

Language is stored per person, not per account: the master has a notify_lang, and every booking snapshots what the client was reading when she made it. One row in the database, two languages out.

Settings — profile, hours, services and subscription

07 Design system

One set of values, two places it has to be true

A booking page a client opens once and a dashboard a hairdresser lives in every day are the same product, and they have to read like it. What holds them together is not a component library — it is a set of values that has to be true in two places at once: the Figma variables the screens are drawn from, and the CSS tokens the app ships. This chapter is what that set contains, how a change moves through it, and where it still does not reach.

Tokens — a palette first, then roles

The palette was never the problem. Cyan, ink and four warm accents were right from the first screen. The problem was that they lived as hex literals in the markup: #0b0d10 is written 1 179 times in the app, #00c2e8 246 times, eighty distinct values in all, none of them under a name. Figma had a token collection. The code had the values. Neither knew about the other.

The layer that closes that is two levels deep, and the split is the whole point. Primitives are the palette and know nothing about roles — ink-900, blue-500, cream-100. Semantics are the roles and hold no value of their own — surface-invert, fill-brand, text-on-invert. A role can only point at a primitive, never at a hex. That is what makes a rename cheap and a repaint safe.

Figma · collection “Citameya / Tokens”

  • primitive/*21 colours
  • color/text · bg · border11 roles
  • color/accent · brand10 roles
  • color/semantic · channel4 roles
  • spacing/1 … 2412 steps
  • radius/sm … pill7 steps

65 variables, up from 39. All 25 semantic colours are aliases of a primitive — none of them carries a raw value any more. A junk collection with a single stray variable is gone.

Code · src/styles/tokens.css

/* primitive — the palette, no roles */ --cit-blue-500-c: 0 194 232; --cit-ink-900-c: 11 13 16; /* semantic — roles, no values */ --cit-fill-brand-c: var(--cit-blue-500-c); --cit-text-on-invert-c: var(--cit-blue-500-c); --cit-surface-invert-c: var(--cit-ink-900-c);

Every token exists in two forms derived from one value: --x-c as “R G B” channels, which is what lets Tailwind build rgb(var(--x-c) / <alpha-value>) so an opacity modifier still works, and --x as a finished colour for plain CSS. Skipping the channel form is not a cosmetic choice: bg-cit-surface-invert/5 would emit no rule at all, and 554 places in the app carry an opacity modifier.

How far one edit travels — and how far it does not

--cit-ink-900--cit-surface-invert--wolt-ink.pill-dark

Changing that one primitive moves four declarations in the built stylesheet. Four, not four hundred — because the 1 179 ink call sites are still literals in the markup. The layer is in place and the rails for the sweep are laid; the sweep itself would touch 29 screens and needs a release of its own, so it is listed as debt below rather than claimed as done. The two sides agree on the values, though: a script compares the 21 primitives in Figma against the 21 in CSS, and the drift is zero.

The palette rule is a number, not a preference

ink#0B0D1019.5 on white
brand#00C2E82.1 on white
paper#F7F4EE17.7 with ink
cream#FFF4E017.9 with ink
mint#C6F0D615.6 with ink
sky#BFE9FF15.1 with ink
lilac#E5D6FF14.3 with ink
coral#FFD1C814.1 with ink

Cyan against white is 2.13:1. That single measurement is why the brand colour has two roles instead of one — fill-brand for anything it fills, and text-on-invert for the one case where it may be text, on ink, at 9.16:1. There is deliberately no role for cyan text on a light surface, and the rule is written into the variable descriptions in Figma so it travels with the file. Checked against the app: of 93 places that set cyan as a text colour, every one is either on ink or an icon, never body copy on white.

Components — what exists on each side

 Figma libraryShipped code
Library9 component sets, 106 variants3 primitives in use (dialog, toast, toaster) + 18 product components
The buttonButton — 45 variants.pill — 3 tones × 3 sizes, ~337 uses
Theme reach29 of 29 screens carry it
Off the system169 raw <button>, 56 raw <input>
Removed“Button 56” marked deprecated6 of 9 scaffolded primitives deleted — zero imports

The scaffold that came with the starter kit had nine primitives in it. Three were ever imported. The other six — button, input, badge, card, label, separator — sat in the repository for months looking like a component library while every actual button on every actual screen was a <button> with a .pill class on it. Deleting them is not housekeeping: a library that lies about what it contains is worse than no library, because the next person believes it.

Button component set in Figma — a nine-by-five matrix of Dark, Light and Brand tones in sizes L, S and XS, across default, hover, focus, disabled and loading states
The reconciled set. The old one had 50 variants across ten tones — success, danger, warning, info — of which three ever shipped, and no size property at all, although the code has had three size tiers since 15 May. The new one is three tones × three sizes × five states, with the geometry read off the shipped CSS rather than redrawn: 48.4px, 33.6px, 25.8px. Building it surfaced a bug that had never been visible in either place — on the brand tone the focus ring was brand-coloured, so it sat cyan on cyan and disappeared. It is ink there now.

Props in code = properties in Figma

CodeFigmaProperty type
.pill-dark / -light / -bluetone = Dark / Light / Brandvariant
.pill / -sm / -xssize = L / S / XSvariant · added in this pass
disabled attributestate = Disabledvariant
:hover translateY(-1px)state = Hovervariant · shown as a shadow, a transform does not travel on a variant
ring-[#0b0d10]state = Focusvariant
Loader2 animate-spin, six copiesstate = Loadingvariant in Figma, copy-paste in code — open debt
icon before / after the labelLeft icon / Right iconboolean

Rules that travel with the component

Each of these is written into the component description in Figma, so it arrives with the component instead of living in a document nobody opens.

Button

For
Anything that changes state — book, save, share, cancel. Dark is the screen’s one main action, Light is the alternative beside it, Brand is anything voice-related.
Not for
Navigation that changes nothing — that is a link. Two Dark buttons side by side means the main action has been named wrong: there is only one per screen.
Limits
Defined inside the theme scope on purpose, so the archived landing pages cannot inherit a restyle. S and XS are below the 44px touch minimum and are only allowed inside a row or header that has its own enlarged hit area.

Input

For
Text, email, password, search.
Not for
Dates, times and phone numbers. Each has its own component, and each exists because the native control misbehaves on the phones this audience owns.
Limits
The iOS placeholder is drawn only when the device is iOS and the field is empty — on every other engine the browser already draws one and the overlay doubled it. Height is pinned at 3.25rem, 3rem above 640px, because native date and time controls have a different intrinsic height per engine and the form row breaks alignment without it.

BlockCalendar

For
Picking a day or a range where some days are not pickable.
Not for
An unconstrained date. There the native control wins — it is cheaper, familiar, and opens the system picker.
Limits
It exists because <input type="date"> cannot disable arbitrary days: a master could pick an already-blocked date and only learn about it from an error after submitting. It is convenience, not validation — the server still answers 409 BLOCKED_OVERLAP. A blocked day is struck through, not only greyed, so colour is not the only signal.

How a change gets in

A one-person team can add a variant in ninety seconds, which is exactly why there has to be a gate. Four questions, in order — the first “no” ends it.

01 · Repeats?

Has this case come up more than once, or is it one screen asking for a favour?

02 · Already sayable?

Can what exists express it, even imperfectly? A pixel of difference is not a reason.

03 · Breaks the axis?

Does the new option turn a clean axis into a list of special cases?

04 · Verdict

Add · widen the existing one · or leave it an exception and write down why.

Said no: two buttons in one row, two heights

15 May · commit ef8b7a5

Was
The voice button carried a bespoke padding: 0.6rem 1.1rem written inline, and the dashboard’s “+ Nueva cita” carried the same override. Side by side in the action row above the bookings list, they were visibly different heights. The obvious fix was a fourth size tier at the size both already wanted.
Instead
Both were pulled onto the existing pill-sm and the inline padding deleted.
Why
The case repeated, so it passed the first gate — but it failed the second: pill-sm is within a pixel of what the inline value was reaching for. Adding a fourth step would have turned a three-value size axis into a four-value one to serve a single row of a single screen. The follow-up sweep took the same tier to the remaining ten files.

Said yes: six phone fields, six ideas about the country code

19–20 May · commit 5ff7bb1

Was
Six raw phone inputs — registration, the booking page twice, the dashboard, settings, the voice invite — each with its own handling of the “+” prefix. People deleted it, doubled it, or typed a local number and got a message that never arrived.
Instead
One PhoneInput with the prefix locked outside the editable area, rolled through all six sites in one commit.
Why
Repeated six times, and the plain input genuinely could not say it: an unerasable prefix is behaviour, not styling. It also adds no axis to anything — it is a new component, not a new variant, so nothing else in the system got wider. The same shape settled NativeDateField two weeks later, again across six call sites.

Two platforms, and what accessibility is actually there

The dashboard is used one-handed between two clients, and the booking page is opened from a WhatsApp message. Both are phone-first in practice, which makes touch size a system property rather than a screen decision.

Touch targets, measured in a browser

  • .pill48.4px — clears 44
  • .pill-sm33.6px — below
  • .pill-xs25.8px — below
  • date / time field52px, 48px ≥640

Only the main tier passes on its own. The two smaller ones sit inside rows and headers with their own enlarged hit area, which is a mitigation and not a fix — so it is on the debt list below rather than in the win column.

What is in the code, counted — not an audit

  • aria-label · aria-hidden26 · 21
  • aria-expanded · pressed · live · busy4 · 3 · 1 · 1
  • explicit roles6
  • prefers-reduced-motion4
  • label bound to field10

No WCAG audit has been run on this product and this chapter does not claim one. Contrast is the part that is genuinely systematic, because it is enforced by which token a role is allowed to point at.

One bug the token work found in itself

First pass, I pointed the framework’s --primary and --ring at the brand colour — it reads like the obviously correct move, and it was wrong. Both land on white: the route-loading spinner is a border-primary circle on the page background, and the dialog’s close button is what the library focuses when a dialog opens. Cyan there is 2.13:1 against a 3:1 floor for non-text graphics — the exact rule written at the top of the token file. Both roles now point at ink, 19.46:1, which is also what the app’s own focus rings were already doing in twelve places. A checkmark in the language menu came along for free: 2.03 → 18.62:1.

What it adds up to

65 / 21variables in Figma, of which 21 primitives — matched against the 21 in CSS by script, zero drift
29 of 29screens on the theme; 9 component sets, 106 variants in the library file
50 → 45button variants after reconciling — fewer options, and the three that ship are now expressible
4declarations moved by one primitive edit today — the honest reach before the sweep

Debt, written down rather than left implied

  • Hex to tokens1 179 ink and 246 brand call sites are still literals. The rails exist; the sweep needs its own release and a pass over all 29 screens.
  • Loading in the buttonSix hand-placed spinners instead of a state the component owns.
  • Focus ring in the base classSet per call site today, not once in .pill.
  • Touch size on S and XS33.6px and 25.8px, mitigated by their containers rather than fixed.
  • Error state on fieldsA red border written by hand in every form instead of a state.
  • Tab switcherThe same pattern written twice, in settings and in the share sheet.

A design system with no debt list is a system nobody has measured. These six are ordered by how much they cost the next change.

Citameya key visual — “Deja la libreta. Habla, y Citameya lo apunta.” over cyan, with a barber dictating an appointment

08 Who it’s for

Two people, and the silence between them

Written as design hypotheses, not research findings — I did not run a study. But every screen is answerable to one of them, and the ones that were not got cut.

Job 01 · the autónoma

“I am booked out for the week, but I am also the receptionist — and I am doing that job with wet hands, from a notebook, while somebody is in the chair.”

Job 02 · the client at eleven at night

“I want the appointment now, while I am thinking about it — not to send a message and wait until tomorrow to find out if that time is even free.”

The dangerous moment is neither of those. It is the gap between booking and turning up, where a client forgets, double-books herself, or quietly decides not to come. The dip in the line below is the whole reason chapters 05 and 06 exist.

Emotion curve across the journey, dipping at the wait stage
Find
Choose
Book
Wait
Attend
Return
Doing

Taps a link from a bio, a Story or a reply

Reads services, price and duration

Day, time, name, phone — or dictates the lot

Waits, for days, with nothing to do

Turns up and pays the master directly

Gets asked for a review, rebooks from the same link

Thinking

“Is this a real business or a dead page?”

“What does it cost and how long am I here?”

“Did that register, or should I message her too?”

“Was it Thursday or Friday?”

“Was that worth the price?”

“Do I have to find the link again?”

Pain

A profile with no way to book anything

“Precio a consultar” — the answer to nothing

A form demanding an account first

Silence — the single biggest cause of a no-show

The slot was double-booked on paper

Nothing carries over; next time starts from zero

Answered by

A Story with real free slots and a QR

Price and minutes on the row, tinted per service

No account; confirmation on the spot

Reminder the day before, plus a one-tap cancel link

Server-side collision check — it cannot happen

Review request, follow-up, details remembered

Find

Thinking
“Is this a real business or a dead page?”
Pain
A profile with no way to book anything
Answered by
A Story with real free slots and a QR

Choose

Thinking
“What does it cost and how long am I here?”
Pain
“Precio a consultar” — the answer to nothing
Answered by
Price and minutes on the row, tinted per service

Book

Thinking
“Did that register, or should I message her too?”
Pain
A form demanding an account first
Answered by
No account; confirmation on the spot

Wait — the dip

Thinking
“Was it Thursday or Friday?”
Pain
Silence — the single biggest cause of a no-show
Answered by
Reminder the day before, plus a one-tap cancel link

Attend

Thinking
“Was that worth the price?”
Pain
The slot was double-booked on paper
Answered by
Server-side collision check — it cannot happen

Return

Thinking
“Do I have to find the link again?”
Pain
Nothing carries over; next time starts from zero
Answered by
Review request, follow-up, details remembered

09 Architecture

The app, mapped

One React app and one Express API. Five surfaces hang off it, and every one renders through the same tokens against the same nine tables.

CITAMEYAone app · one API · one database

Client

  • Public page · /b/:slug
  • Four-step wizard
  • Voice booking
  • Cancel · review · deposit

Master

  • Day & week dashboard
  • Clients & statistics
  • Services, hours, blocks
  • Share kit & promo codes

Notifications

  • WhatsApp
  • Transactional email
  • Web push
  • Ten scheduled jobs

Billing

  • Stripe subscription · €15
  • 20-day trial, no card
  • Client deposits
  • Webhook idempotency

Admin

  • Activity feed
  • Per-master drill-down
  • Force-cancel
  • Bulk test-data cleanup
ONE SHARED FOUNDATION 33 routes · 83 endpoints · 9 tables · 32 migrations · 191 catalogued services · 6 languages

10 How it was built

Three things the product taught me

Working solo means nobody argues back, which makes a bad decision very easy to keep. The code was written with AI assistance; I set the direction, made the product calls and reviewed what landed — and the things that went wrong went wrong where no assistant can see them.

The trial was too short to reach a second week

19 May

Was
A 14-day free trial.
Became
Twenty days, with everyone on the old clock topped up by hand.
Why
Fourteen days is not two weeks of use for someone who works Tuesday to Saturday and only sets things up on a quiet Monday. Twenty buys a second full cycle — the first point at which the product has proved anything.

Headless Chrome cannot see an iPhone

4–5 June

Was
Native date and time inputs everywhere, verified in WebKit.
Became
Two purpose-built components — after two fixes that shipped and made it worse.
Why
On a real iPhone the empty date field rendered blank. The first fix was a styling hack; the second overlaid a placeholder that doubled up on the actual device. Playwright’s WebKit is not iOS Safari.

Guardrails before speed

19 May, after the third incident

Was
Three deploys in a row lost production environment variables.
Became
One vault file as the source of truth, a script that derives the required keys by grepping every process.env read, and a pre-deploy hook that refuses an unsafe sync.
Why
“Be more careful” is not a fix. The hook has blocked one bad sync since.

I was the review gate

The blank date field on a real iPhone. A booking created on the wrong day, invisible until reload. Service names frozen in the language the master typed them in, on five different screens. None of that shows up in a test suite — it shows up when you use the thing as the person who pays for it.

Three analytics stacks, a feature flag and a running experiment exist so the arguments I cannot win on taste get settled by traffic instead. The one number I will not put on this page is how many people pay for it — that is not mine to publish.

191commits, one author
69automated specs
32database migrations
1live A/B experiment

Live, in Spanish, in production

Designed, built and shipped
by one person.

Landing, public booking page, master dashboard, voice booking, WhatsApp notifications, Stripe billing and an admin console — running in Madrid, in six languages.

citameya.com