← Work

Case study

SeansaNote · The design system underneath

Forty-two primitives and seventy-eight tokens for a product that ships to web, iOS and Android from one codebase, with a contrast audit that fails the build and a React Native runtime that has neither CSS grid nor pseudo-elements.

Role
Design Engineer
Timeframe
2026
Focus
Design System, Accessibility, React Native

Mine end to end. The system was built alongside the product rather than ahead of it, which is why every rule here can be traced to the screen that forced it. Names and amounts shown are invented.

The product's palette as a spec sheet: swatches with their hex values and the contrast each pair measures

The product this belongs to is SeansaNote, a dictation-first CRM for therapy practices. This page is about the system underneath it.

01

What the system had to survive

None of the screens in that product were drawn one at a time. They come out of a token set and a small group of primitives, and the rules holding those together had four things to survive at once.

One codebase, three targets. Expo: React Native on iOS and Android, React Native Web at the desk. Not three design languages, and not one scaled three ways.

Two densities with opposite jobs. The phone optimises for a single record at speed; the desk optimises for many records at a glance. A system that only knows how to shrink produces a phone that is a cramped desk.

A runtime missing tools most systems assume. React Native has no CSS grid and no pseudo-elements. Every layout is flexbox and every decorative mark is a real element.

One person. There was nobody to hand a spec to, which cuts both ways: no translation loss, and no second opinion either. Every rule below exists because a screen forced it, not because a document said so in advance.

42Primitives, from Screen and Row down to StatusDot
78Tokens: colour, type, spacing, radius, motion
22Contrast pairs audited on every build
20Places one SheetModal serves, phone and desk
02

A warm axis under a cool accent

Warm neutral axis

  • canvas#FAF9F7App ground, warm paper
  • surface#FFFFFFSheets, inputs, content plates
  • panel#F4F2EFHeaders, toolbars, inset blocks
  • border#E0DDD7Hairline rules and enclosures
  • borderStrong#C7C2BAControl edges only

Ink, measured rather than estimated

  • text#1C191716.62:1 on canvas
  • textSecondary#5C554C6.98:1 on canvas
  • textTertiary#6B645A5.84:1 on surface

One accent, and only one

  • accent#1268D45.04:1 canvas
  • accentHover#0E53AC7.36:1 white text
  • accentSubtle#EAF2FE4.71:1 accent text

The neutrals are warm on purpose. A slate greyscale under a blue accent puts every pixel in one cool family and the screen reads as an admin console; the warm ground is what lets the accent register as the one thing meant to be pressed. That is also why there is exactly one accent: if a second colour could be a primary action, neither of them is.

Colour here is not a matter of taste and it is not checked by eye. The palette has its own audit, and the build fails if a pair drops below its floor:

$ node scripts/check-contrast.mjs
  • text on surface17.49:1min 4.5
  • text on canvas16.62:1min 4.5
  • white on accentHover7.36:1min 4.5
  • textSecondary on canvas6.98:1min 4.5
  • white on danger6.57:1min 4.5
  • textTertiary (placeholder) on surface5.84:1min 4.5
  • white on accent5.31:1min 4.5
  • focus ring on canvas5.04:1min 3.0
  • success on successSubtle4.83:1min 4.5
  • accent text on accentSubtle4.71:1min 4.5

All 22 checks pass. The tick on each track is the floor the build enforces.

The script’s own comment says why it exists: guessing hex values that look about right fails this reliably, so the palette is checked instead. Placeholder text is in the audit deliberately. It is the contrast people most often let slide, and it is the text somebody reads while deciding what a field wants from them.

03

Two faces, one job each

Six steps, fixed — never fluid

  • xxl26pxAna PetrovićPage titles, balance figures
  • xl20pxAna PetrovićPage title
  • lg17pxAna PetrovićScreen and section headings
  • md15pxAna PetrovićBody, list rows, inputs
  • sm13pxAna PetrovićSecondary text, captions
  • xs11pxZAPISI · 2 ZAPISAUppercase section labels, tabular meta

Figures sit in the sans, with tabular numerals

tabular
  • 4.500,00 RSD
  • 12.000,00 RSD
  • 800,00 RSD
  • 4.500,00 RSD
proportional
  • 4.500,00 RSD
  • 12.000,00 RSD
  • 800,00 RSD
  • 4.500,00 RSD

The serif is the argument, not the decoration. This product is about written records and its voice is a colleague rather than a clinic administrator; one system sans at every size was the main reason an earlier build read as a generic admin console. The two display cuts ship as separate families because React Native resolves a face by family name rather than by weight, and a synthesised bold smears a serif's stems. Money stays in the sans with tabular numerals, so a column of amounts stops shifting as the digits change. A monospace here would be a costume: it reads as a terminal rather than as a sum somebody is about to discuss with a client.

The scale is fixed rather than fluid, which is a deliberate break from how most web systems are built now. A caseload read at a consistent distance on a consistent screen is not served by headings that clamp with the viewport; growing the window should earn more columns, not bigger type. Past 1024px that is exactly what happens — the register widens and gains columns, and the type does not move.

A detail that only exists because of the runtime: the two display cuts ship as separate font families rather than as weights of one. React Native resolves a custom face by family name, not by fontWeight, and asking it for bold on a serif gets a synthesised one that smears the stems. So fontWeight is never set on display text anywhere in the product.

04

The register, not the card grid

Radius scales with the element it is on

  • 6Controls
  • 8Blocks
  • 14Desk plates
  • 24Phone sheets
  • 999Pills

The register, not the card grid

Raspored09:00Ana PetrovićSoba 110:00Stefan IlićSoba 211:00Miloš JovanovićSoba 1Label, then rows separated by a hairline. A panel only appears where content genuinely needs a boundary, and never inside another one.

A uniform radius everywhere is a costume: sixteen pixels is wrong on a 32px button and right on a full page plate, which is the actual reason current interfaces read as current. Their controls are no rounder than these, their surfaces are simply bigger. Nothing has a resting shadow; only an overlay gets one, with a real offset rather than a halo at zero. The ruled register replaced a card grid that this codebase had, nested, before the rework.

Three structural primitives carry nearly every screen. Section is an 11px uppercase label over a hairline-ruled group, with more space above a heading than below it. Row is a 56px minimum target with a hairline under it, and it is the primary content unit everywhere. Panel is a bordered block used only where content genuinely needs a boundary, and never inside another one.

One worked example of the rule paying off. The client register at desk width used to carry a phone column, which left roughly 650px of dead space between the name and anything useful. Phone came out; last session and next appointment went in, because a therapist scanning the register wants to know when they last saw someone and when they next will. Status became a 6px dot inside the name cell rather than a filled badge, since status is not an action and a column of saturated pills was contradicting the rule that the only saturated pixels on screen are the ones meant to be pressed.

05

Every state, including the ones people skip

Button, every state it ships with

  • SačuvajdefaultAccent fill, white label, 5.31:1
  • SačuvajhoverWeb only; darker accent, 7.36:1
  • SačuvajpressedSame fill, reduced opacity
  • Sačuvajfocused2px accent edge at 5.04:1, never a pale halo
  • SačuvajdisabledNo hover, no focus, not reachable by tab
  • Čuvam…loadingLabel changes; width holds so nothing jumps

Field, including the state people forget

  • Ime klijentaAna PetrovićdefaultLabel above, control edge rather than a hairline rule
  • Ime klijentaAna PetrovićfocusedThe same 2px accent edge as the button
  • Ime klijentaUnesite ime klijentaerrorMessage under the control, outside its border, never colour alone
  • ZapisZapis je zaključanread-onlyA locked note looks locked in the field, not only in a banner above it

The validation message sits under the control rather than inside it, where the eye already is after reading the field. Error is never colour alone, and focus is a solid edge rather than a glow. A pale halo disappears on a bright screen in a daylit room, which is where this product is used. The loading button keeps its width so a slow save does not move the thing under your thumb.

Lists ship loading, empty and error alongside their normal state. Loading uses skeleton rows that match the real row rhythm rather than a centred spinner, so nothing jumps when the data lands. Empty states teach the interface instead of reporting absence.

Motion is 150 to 250ms, ease-out, and conveys state only. There is exactly one authored moment in the product: while dictation is recording, seven bars track the live input level with the centre bars reacting most, so it reads as a voice rather than as a progress bar. The microphone is solid accent at rest and solid danger while recording — the same two colours the rest of the system already uses, doing a job nobody has to learn.

06

One component, three versions

A system is easiest to judge on a single component that had to change. The booking sheet went through three shapes, and every problem with the first two was found by watching it used rather than by looking at it.

First, a form

Rezervacija sobeDatum15. sep 2026.Početak09:00Trajanje50 minKlijentIzaberite klijentaSobaSoba 1PonavljanjeSamo ovaj terminOtkažiDodaj

Six labelled fields, one per line. It reads like the record it writes, and every question looks equally important.

Then, a composer

Rezervacija sobe✎Dodajte naziv◷uto 15. sep09:0050 min☺Dodajte klijenta▣Dodajte video poziv⌖Dodajte sobuOtkažiDodaj

Denser, and the labels go. But a title field and a client field now sit four rows apart, each silently clearing the other.

Shippedchosen

Rezervacija sobeSeansaLično◷uto 15. sep09:0050 min☺Dodajte klijentaswaps▣U sobiOnlineswaps⌖Dodajte sobuOtkažiDodaj

The kind is asked first. Two slots swap their contents for a personal hold; every other row stays where it was.

The database had said since an earlier migration that an appointment is either a session with a client or a personal hold with a title, never both and never neither. The sheet left that implicit and let the empty field carry the meaning. Asking outright surfaced a bug the ambiguity had been hiding: validation used to catch a missing client by accident, because an empty client meant "this is a hold" and the title check fired instead. With the kind declared, the two cases stopped covering for each other, and a session with no client would have reached the database and been refused by a constraint. It now fails in the sheet, in Serbian.

The first version was a form: six labelled fields down the sheet, which is how you draw a screen when you are thinking about the row you are about to insert rather than about the person filling it in. Booking a session is not six equal decisions. It is one client, one time, and some details, so the second version dropped the labels for a denser composer with icons and chips.

That bought density and lost clarity. The title field and the client field ended up four rows apart, each silently clearing the other, with nothing to say they were alternatives. Asking the question outright surfaced a bug the ambiguity had been hiding: validation used to catch a missing client by accident, because an empty client meant “this is a hold” and the title check fired instead. With the kind declared up front the two cases stopped covering for each other, and a session with no client would have reached the database and been refused by a constraint. It now fails in the sheet, in Serbian.

Two more problems followed, both about the sheet refusing to stand still.

Switching between the two kinds moved every row below the toggle, because the title field appeared above the time chips while the client row disappeared below them. They are the same question, who or what is this, so they became one row that swaps in place.

Then removing the delivery row for a personal hold made the sheet a row shorter. A bottom sheet is anchored to its bottom edge, so everything above it slid down, including the toggle the finger had just pressed: measured in the browser, it sat at y=1094 as a session and y=1185 as a hold. The fix was not to pad the gap. A hold now takes that slot with a line explaining what a hold is — it blocks the therapist’s own schedule, clients never see it, and the public link cannot book over it. That needed saying anyway, because Lično does not explain itself, and it means the toggle, the chips, the room and the save button sit at identical positions in both states.

app.seansanote.rs
The shipped booking sheet over the schedule: a Seansa or Lično toggle, date and time chips, the chosen client, a live-or-online toggle, and a room field.
The third version, filled in. Five rows: what kind, when, who, where it happens, which room, and the room is the only one that may stay empty.

One more, and it is the kind of thing only building the thing teaches you: the date and time chips were rendering at the right width with nothing drawn inside them. numberOfLines={1} compiles to a -webkit-box line clamp on React Native Web, and inside a fixed-height overflow: hidden pill that collapses the text box to zero height while the width still measures — an empty capsule exactly the size of the label it was hiding. A chip is sized by its own content, so it has nothing to clip and no reason to clamp.

07

What the constraint bought

Working without CSS grid and without pseudo-elements sounds like a tax, and for the first month it was. A focus ring cannot be an ::after; it is a border and a shadow on the component itself. A decorative rule cannot be a ::before; it is a View with a height of one.

What that produces, though, is a visual language that leans on hairlines, row rhythm and spacing rather than on clever boxes — and those are the things that were load-bearing anyway. Every effect that survived the constraint survived because it was doing structural work. The ones that did not survive were decoration I would have kept if the platform had let me.

Shadows went the same way. Nothing has a resting shadow; only an overlay gets one, and it is a real offset with a soft blur rather than a halo at zero. A uniform radius everywhere is a costume for the same reason: sixteen pixels is wrong on a 32px button and right on a full page plate, which is the actual reason current interfaces read as current. Their controls are no rounder than these. Their surfaces are simply bigger.

08

What I would do differently

I built the system alongside the product rather than ahead of it. That is the right order for one person — a spec written first would have described screens I had not met yet — but it cost me two things.

The dashboard was the one screen that contradicted my own structural rule, and I only saw it once it was in front of me. Half a day of rough layouts would have caught that before a week of code did.

And the contrast audit arrived late. It passes now, but it passed because I went back and moved values, not because the palette was built against the floor from the start. A system that gates its own build is worth having; a system that gates it from day one is worth more, and costs less.