The showcase was a Barcelona market: Catalan names, +34 numbers, euro rates and "Carrer Example 12" on every job. Presented to a Mexican client, all of that reads as somebody else's product. City comes from NEXT_PUBLIC_CITY_* as before, now Ciudad de México at 19.4326/-99.1332, with MAPBOX_COUNTRY=mx. The seed's fallbacks were Barcelona literals, so an unset env quietly seeded a different city than the app rendered — they now agree. Two db tests pinned the Barcelona centre as a hardcoded constant, which is why the deck returned zero cards on the first run here: every pro was a continent outside the radius. They read the same env as the seed now, so the trap cannot recur. Money: formatCents defaults to USD/en-US, and the nine hardcoded euro signs across the card, search rows, quote strip and forms are dollars. The rate NUMBERS are unchanged and still read high for CDMX — that is a pricing decision, not a currency one, and is left alone deliberately. Seed people are Mexican, addressed on real Roma/Condesa streets rotated by index rather than one placeholder repeated. Phones moved to +52 55, which moves the demo login to +525500000000 / 000000. Also in here, from the same session: - Sending a job now confirms. The mutation always succeeded; the sheet just closed with no receipt, which from the customer's side is indistinguishable from a dead button. Dismissing that receipt resolves as 'sent', so the card does not return to the deck. - Media moves to DigitalOcean Spaces, with the public origin derived from bucket and region instead of a second env var to keep in sync. - Managed-Postgres TLS: DATABASE_CA_CERT takes a path or inline PEM. - The client-facing project panel beside the running app. - Two profiles removed and four renamed to match their photos. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
519 lines
23 KiB
Markdown
519 lines
23 KiB
Markdown
# Linkdr Design System
|
||
|
||
**Version 1.0** · derived from the wix.com design language, adapted for Linkdr.
|
||
|
||
This document is the single source of truth for how Linkdr looks. Every screen must be
|
||
buildable from the tokens and components below. If a screen needs something that is not in
|
||
here, add it here first, then build it.
|
||
|
||
## Provenance and scope
|
||
|
||
The visual language is adapted from **wix.com** (sampled 2026-08-21): the Madefor typeface
|
||
pair, the signature action blue, the navy-black ink, the pill geometry and the generous
|
||
whitespace rhythm. Values below were read off the live stylesheet, not eyeballed.
|
||
|
||
Adopted: typefaces (Wix Madefor Display / Text, published under the SIL Open Font License via
|
||
Google Fonts), colour ramps, radius and spacing conventions, component geometry.
|
||
|
||
**Not** adopted: Wix's logo, wordmark, product names, illustrations or photography. Linkdr is
|
||
not affiliated with Wix and must never present itself as such.
|
||
|
||
---
|
||
|
||
## 1. Principles
|
||
|
||
0. **Mobile only.** One column, one container, thumb-reachable actions, no desktop layout and
|
||
no marketing site. See §4 — this constrains every other decision here.
|
||
1. **Blue means action.** `brand-500` is reserved for things the user can do. It is never
|
||
decoration, never a background wash for a whole section, never body text.
|
||
2. **Ink is near-black, not grey.** Headings sit at `ink-950` (`#000624`) — black with a blue
|
||
cast. Grey text is a deliberate demotion, not a default.
|
||
3. **Type carries the design.** Big, tight display type over ornament. Negative tracking on
|
||
anything above 24px; zero tracking on body.
|
||
4. **Pills for actions, soft rectangles for containers.** A pill is clickable. A rounded
|
||
rectangle holds content. Never the reverse.
|
||
5. **Whitespace over borders.** Separate with space first, a hairline second, a shadow last.
|
||
|
||
---
|
||
|
||
## 2. Colour
|
||
|
||
All values are sampled from wix.com. Hex is normative.
|
||
|
||
### 2.1 Brand — action blue
|
||
|
||
| Token | Hex | Use |
|
||
|---|---|---|
|
||
| `brand-50` | `#EDF3FF` | Hover fill on ghost / outline controls |
|
||
| `brand-100` | `#DCE8FF` | Selected-chip fill, info banner background |
|
||
| `brand-200` | `#95B9FF` | Focus ring halo, disabled primary |
|
||
| `brand-400` | `#5E97FF` | **Dark-mode** link and accent text |
|
||
| `brand-500` | `#116DFF` | **Primary action.** Buttons, links, active states |
|
||
| `brand-600` | `#094BCC` | Primary hover / pressed |
|
||
| `brand-700` | `#082F7B` | Primary active on dark surfaces |
|
||
|
||
`brand-500` on white is **4.5:1** — it clears AA for normal text, but only just. Do not tint it
|
||
lighter for text. On dark surfaces use `brand-400` (7.0:1 on `ink-950`) instead.
|
||
|
||
### 2.2 Ink — text and surfaces
|
||
|
||
| Token | Hex | Use |
|
||
|---|---|---|
|
||
| `ink-0` | `#FFFFFF` | Card surface, page background (light) |
|
||
| `ink-50` | `#F7F8F8` | Alternating section background |
|
||
| `ink-100` | `#F0F0F0` | Inset fills, skeletons, track backgrounds |
|
||
| `ink-200` | `#E2E2E2` | **Hairline borders** |
|
||
| `ink-300` | `#C2C2C2` | Border on dark, disabled border |
|
||
| `ink-400` | `#B0B0B0` | **Dark-mode** secondary text |
|
||
| `ink-500` | `#8F8F8F` | Placeholder and disabled text **only** (3.2:1 — never body copy) |
|
||
| `ink-600` | `#6E6E6E` | **Secondary body text** (5.1:1 on white) |
|
||
| `ink-800` | `#212121` | Body text on tinted surfaces |
|
||
| `ink-950` | `#000624` | **Headings, primary text, dark surfaces** |
|
||
|
||
### 2.3 Semantic
|
||
|
||
| Token | Hex | Meaning |
|
||
|---|---|---|
|
||
| `go-50` | `#F3F8F0` | Success banner background |
|
||
| `go-100` | `#D2E4C7` | Success border |
|
||
| `go-400` | `#92B079` | Success on dark |
|
||
| `go-600` | `#618741` | **Success** — verified, accepted, right-swipe |
|
||
| `go-700` | `#39641D` | Success pressed |
|
||
| `sun-50` | `#FFE9DF` | Warning / pending banner background |
|
||
| `sun-100` | `#FFBFA1` | Warning border |
|
||
| `sun-400` | `#FA854F` | Warning on dark |
|
||
| `sun-500` | `#EA6020` | **Warning** — pending review, expiring soon |
|
||
| `sun-600` | `#C94001` | Warning pressed |
|
||
| `stop-50` | `#FFECEC` | Error banner background |
|
||
| `stop-100` | `#FFC9CB` | Error border |
|
||
| `stop-400` | `#FF6A70` | Error on dark |
|
||
| `stop-500` | `#ED1C24` | **Error / destructive** — rejected, left-swipe |
|
||
| `stop-600` | `#C4141B` | Error pressed |
|
||
|
||
Never use green/red as the *only* signal. Pair with an icon and a word.
|
||
|
||
### 2.4 Dark mode
|
||
|
||
Dark mode inverts the ink ramp and lightens the accent. It is not a separate palette.
|
||
|
||
| Role | Light | Dark |
|
||
|---|---|---|
|
||
| Page background | `ink-0` | `ink-950` |
|
||
| Raised surface (card) | `ink-0` | `#0C1230` |
|
||
| Sunken surface | `ink-50` | `#070C22` |
|
||
| Primary text | `ink-950` | `ink-0` |
|
||
| Secondary text | `ink-600` | `ink-400` |
|
||
| Hairline | `ink-200` | `rgb(255 255 255 / .14)` |
|
||
| Accent text / link | `brand-500` | `brand-400` |
|
||
| Filled primary button | `brand-500` bg, white text | unchanged |
|
||
|
||
---
|
||
|
||
## 3. Typography
|
||
|
||
### 3.1 Families
|
||
|
||
| Role | Family | Weights |
|
||
|---|---|---|
|
||
| Display — h1–h3, big numbers, buttons | **Wix Madefor Display** | 400–800 variable |
|
||
| Text — body, labels, inputs, meta | **Wix Madefor Text** | 400–800 variable |
|
||
|
||
Fallback stack: `'Segoe UI', system-ui, -apple-system, sans-serif`.
|
||
|
||
Load via `next/font/google` with `display: 'swap'`, exposed as CSS variables. Never link a
|
||
stylesheet in `<head>` — it costs a render-blocking round trip.
|
||
|
||
### 3.2 Scale
|
||
|
||
Display sizes use `clamp()` so one token works from 360px to desktop.
|
||
|
||
| Token | Size | Line | Tracking | Weight | Family |
|
||
|---|---|---|---|---|---|
|
||
| `display-xl` | `clamp(2.75rem, 6vw, 4.5rem)` | 1.04 | `-0.03em` | 700 | Display |
|
||
| `display-lg` | `clamp(2.25rem, 4.5vw, 3.25rem)` | 1.08 | `-0.025em` | 700 | Display |
|
||
| `h1` | `clamp(1.875rem, 3.5vw, 2.5rem)` | 1.12 | `-0.02em` | 700 | Display |
|
||
| `h2` | `clamp(1.5rem, 2.5vw, 2rem)` | 1.18 | `-0.02em` | 700 | Display |
|
||
| `h3` | `1.375rem` | 1.25 | `-0.015em` | 600 | Display |
|
||
| `h4` | `1.125rem` | 1.35 | `-0.01em` | 600 | Display |
|
||
| `body-lg` | `1.125rem` | 1.55 | `0` | 400 | Text |
|
||
| `body` | `1rem` | 1.6 | `0` | 400 | Text |
|
||
| `body-sm` | `0.875rem` | 1.5 | `0` | 400 | Text |
|
||
| `meta` | `0.75rem` | 1.45 | `0` | 500 | Text |
|
||
| `overline` | `0.75rem` | 1.4 | `0.08em` | 700 | Text, uppercase |
|
||
|
||
### 3.3 Rules
|
||
|
||
- **One display element per view.** A page has one `h1`. A card has none.
|
||
- Tracking is negative above 24px and exactly zero below it. Never positive except `overline`.
|
||
- Body copy maxes at **68 characters** (`max-w-[68ch]`).
|
||
- Headlines use `text-balance`; paragraphs use `text-pretty`.
|
||
- Prices, counters and table figures use `tabular-nums`.
|
||
|
||
---
|
||
|
||
## 4. Space and layout
|
||
|
||
**Linkdr is a mobile-only product.** There is no desktop layout, no marketing site and no
|
||
responsive breakpoint work. Design for a 360–430px viewport and nothing else. On a wider
|
||
screen the app renders as a single 480px column centred on `ink-50` — that is a courtesy for
|
||
someone who opened it on a laptop, not a layout to design for.
|
||
|
||
Concretely, this bans:
|
||
|
||
- Multi-column grids. Everything is one column. A 2-up grid is allowed only for paired
|
||
numeric fields (from/to, rate/years) and stat tiles.
|
||
- `md:` / `lg:` variants that change structure. Type already scales with `clamp()`.
|
||
- Sticky desktop nav bars with link lists, mega-footers, hero sections, feature grids.
|
||
|
||
An **8px grid**. The only legal spacing values are `4 8 12 16 24 32 40 56 72`. Anything else
|
||
is a bug.
|
||
|
||
| Token | Width | Used for |
|
||
|---|---|---|
|
||
| `app` | 480px | Every screen. There is no second container. |
|
||
|
||
- Page gutter: **20px**. One value, all screens.
|
||
- Vertical rhythm between blocks within a screen: **32px**. Between major groups: **40px**.
|
||
- Gap between sibling cards in a list: **12px**.
|
||
- Label → control: **8px**. Control → helper text: **8px**. Field → field: **24px**.
|
||
- Bottom of scroll content clears the tab bar by **88px** (`pb-22`) so nothing hides under it.
|
||
|
||
### 4.1 Safe areas
|
||
|
||
The tab bar and any fixed footer pad with `env(safe-area-inset-bottom)`. The app bar pads with
|
||
`env(safe-area-inset-top)`. Never assume the viewport edge is reachable.
|
||
|
||
---
|
||
|
||
## 5. Radius and elevation
|
||
|
||
| Token | Value | Applies to |
|
||
|---|---|---|
|
||
| `radius-pill` | `999px` | **All buttons**, chips, avatars, badges |
|
||
| `radius-card` | `18px` | Cards, banners, modals |
|
||
| `radius-lg` | `12px` | Inputs, list rows, small containers |
|
||
| `radius-md` | `8px` | Tags, thumbnails, inline code |
|
||
| `radius-deck` | `28px` | The swipe card only |
|
||
|
||
Elevation is restrained. Four steps, no more:
|
||
|
||
| Token | Shadow | Use |
|
||
|---|---|---|
|
||
| `shadow-none` | — | Default. Most surfaces are flat with a hairline. |
|
||
| `shadow-sm` | `0 1px 2px rgb(0 6 36 / .06), 0 1px 3px rgb(0 6 36 / .04)` | Hover on a list row |
|
||
| `shadow-md` | `0 4px 12px rgb(0 6 36 / .08), 0 2px 4px rgb(0 6 36 / .04)` | Sticky header, popovers |
|
||
| `shadow-lg` | `0 12px 32px rgb(0 6 36 / .12), 0 4px 8px rgb(0 6 36 / .06)` | Deck card, modal |
|
||
|
||
Shadows are tinted with `#000624`, never pure black.
|
||
|
||
---
|
||
|
||
## 6. Components
|
||
|
||
### 6.1 Button
|
||
|
||
Always a pill. Always Display family, weight 600. Label is sentence case, never ALL CAPS.
|
||
|
||
| Variant | Fill | Text | Border | Hover |
|
||
|---|---|---|---|---|
|
||
| `primary` | `brand-500` | white | none | `brand-600` |
|
||
| `dark` | `ink-950` | white | none | `#1A2145` |
|
||
| `outline` | transparent | `ink-950` | `1.5px ink-950` | `ink-50` fill |
|
||
| `ghost` | transparent | `brand-500` | none | `brand-50` fill |
|
||
| `danger` | `stop-500` | white | none | `stop-600` |
|
||
|
||
| Size | Height | Padding-x | Font |
|
||
|---|---|---|---|
|
||
| `sm` | 36px | 16px | `body-sm` |
|
||
| `md` | 44px | 24px | `body` |
|
||
| `lg` | 56px | 32px | `body-lg` |
|
||
|
||
Control heights are their own scale — **36 / 44 / 56** for buttons, **48** for inputs. They are
|
||
exempt from the 8px spacing grid in §4, which governs margins, padding and gaps.
|
||
|
||
- Minimum touch target is **44px**. `sm` is desktop-only or must sit inside a 44px row.
|
||
- Disabled: `opacity: .45`, `cursor: not-allowed`. Never grey out by swapping colour.
|
||
- Busy: leading 16px spinner, label unchanged, control disabled. Never replace the label.
|
||
- Icon-only buttons are circles and require `aria-label`.
|
||
|
||
### 6.2 Input, textarea, select
|
||
|
||
- `radius-lg` (12px), `1.5px ink-200` border, `ink-0` fill, **16px** text (never smaller — iOS
|
||
zooms below 16px), padding `12px 16px`, height 48px.
|
||
- Focus: border `brand-500` + `0 0 0 3px brand-200`. Never remove the outline without
|
||
replacing it.
|
||
- Error: border `stop-500`, message below in `body-sm` `stop-500` with `role="alert"`.
|
||
- Placeholder is `ink-500` and never carries meaning the label doesn't.
|
||
- Every control has a visible `<label>`. Placeholder-as-label is forbidden.
|
||
|
||
### 6.3 Card
|
||
|
||
`ink-0` surface, `radius-card`, `1px ink-200` hairline, `shadow-none`. Padding 24px (20px below
|
||
640px). Interactive cards raise to `shadow-sm` and shift the border to `brand-500` on hover;
|
||
they do not lift or scale.
|
||
|
||
### 6.4 Chip / selectable tag
|
||
|
||
Pill, `1.5px` border, `12px 16px` padding, `body-sm`. Unselected: `ink-200` border on
|
||
transparent. Selected: `brand-500` border, `brand-100` fill, weight 600, leading check icon.
|
||
Selection must never rely on fill alone.
|
||
|
||
### 6.5 Banner / status card
|
||
|
||
`radius-card`, 20px padding, 12px icon-to-text gap, tinted surface + matching border:
|
||
|
||
| Tone | Fill | Border | Icon |
|
||
|---|---|---|---|
|
||
| `info` | `brand-100` | `brand-200` | `brand-500` |
|
||
| `success` | `go-50` | `go-100` | `go-600` |
|
||
| `warning` | `sun-50` | `sun-100` | `sun-500` |
|
||
| `error` | `stop-50` | `stop-100` | `stop-500` |
|
||
|
||
Title in `h4`, body in `body-sm` `ink-600`.
|
||
|
||
### 6.6 App bar
|
||
|
||
Fixed top, **56px** plus top safe-area inset. `page` surface at 88% opacity with
|
||
`backdrop-blur(12px)` and a hairline bottom border.
|
||
|
||
Two layouts only:
|
||
|
||
- **Root screens** — screen title left in `h4`, at most one icon action right.
|
||
- **Pushed screens** — 44px back chevron left, centred title in `h4`, optional icon action right.
|
||
|
||
No link lists. No CTA button in the bar — the primary action lives in the screen or the tab bar.
|
||
|
||
### 6.7 Tab bar
|
||
|
||
Fixed bottom, **56px** plus bottom safe-area inset, `page` surface, hairline top border. Two
|
||
to five destinations, each a 44px-minimum target with a 24px icon over a `meta` label. Active
|
||
tab is `brand-500` icon + label; inactive is `ink-600`. The active state carries both colour and
|
||
weight, never colour alone.
|
||
|
||
The tab bar is the app's only persistent navigation. There is no footer.
|
||
|
||
### 6.8 Deck card
|
||
|
||
The one place that breaks the flat rule. `radius-deck` (28px), `shadow-lg`, full-bleed photo,
|
||
bottom scrim `linear-gradient(to top, rgb(0 6 36 / .92), rgb(0 6 36 / .35) 45%, transparent)`.
|
||
Overlay stamps: `SEND JOB` in `go-600`, `PASS` in `stop-500`, 4px border, ±12° rotation.
|
||
|
||
**The action row** is five circles, in one fixed order:
|
||
|
||
| | Action | Size | Ink |
|
||
|---|---|---|---|
|
||
| 1 | Rewind — bring the last card back | 44px | `ink-600` |
|
||
| 2 | Pass | **64px** | `stop-500` |
|
||
| 3 | Watch — tell me when they are free | 44px | `brand-500` |
|
||
| 4 | Send this job | **64px** | `go-600` |
|
||
| 5 | Ask a question | 44px | `ink-950` |
|
||
|
||
Two sizes, and the size *is* the hierarchy: the two decisions that end the card
|
||
are 64px and reach the thumb first; the three that do not are 44px — still the
|
||
minimum target from §8, never smaller. Gaps are 16px between a small and a large,
|
||
24px between the two larges, so the pair still reads as the pair.
|
||
|
||
All five are 2px bordered circles on `ink-0`. A control whose state persists —
|
||
watch — fills with its own colour when active, and its label changes with it;
|
||
selection is never carried by fill alone (§8).
|
||
|
||
Rewind is `disabled` with nothing to undo rather than hidden. A row that changes
|
||
length as you swipe moves the two buttons underneath your thumb.
|
||
|
||
---
|
||
|
||
### 6.9 Search field
|
||
|
||
The §6.2 input, 48px, with a 20px `Search` glyph inset 16px from the left in `ink-500`, and a
|
||
44px circular clear button on the right that exists only while the field has content.
|
||
|
||
The label is **visible above the field**, never the placeholder — §6.2 applies here more than
|
||
anywhere, because a placeholder disappears at exactly the moment someone needs reminding what
|
||
the box searches. The placeholder carries examples of what to type, not the name of the field.
|
||
|
||
### 6.10 Result row
|
||
|
||
`radius-card`, `1px ink-200`, `raised` fill, 16px padding, **12px between rows**. A 56px
|
||
`radius-md` thumbnail leads; where there is no image, its initial on an `inset` fill — never an
|
||
empty grey square. Title in `h4`, one `body-sm ink-600` line, one `meta` line of figures in
|
||
`tabular-nums`, trailing chevron in `accent`.
|
||
|
||
Hover and press move the border to `brand-500`. Rows do not lift, scale or shadow: a list of
|
||
twenty is a scanning surface, and twenty things that react is noise.
|
||
|
||
A row is **not** a deck card. The deck card is a single-decision object with its own gesture;
|
||
reusing it in a list costs the list its scroll.
|
||
|
||
### 6.11 Skeletons
|
||
|
||
`inset` fill with `animate-pulse`, at the exact height and radius of whatever it stands in for.
|
||
Never more than one screenful — three rows is enough to say "loading"; twenty is a lie about
|
||
what is coming.
|
||
|
||
While REFRESHING existing content, keep the old content on screen instead. A list that blanks on
|
||
every keystroke reads as "no results", repeatedly.
|
||
|
||
### 6.12 Empty and no-result
|
||
|
||
Two different states, two different messages.
|
||
|
||
- **Empty** — nothing asked yet. Say what this screen can do and give a way in.
|
||
- **No result** — something asked, nothing found. Name what was searched and offer the single
|
||
most effective filter to relax. Never a bare "No results."
|
||
|
||
Both use `EmptyState` (§6.3): dashed hairline, centred, `h4` title, `body-sm ink-600` body.
|
||
|
||
### 6.13 Segmented control
|
||
|
||
Two or three mutually exclusive views of **the same list** — "Current / Past", not navigation.
|
||
Full width, `radius-pill`, `ink-100` track, 4px inset padding. The selected segment is an
|
||
`ink-0` pill on `shadow-sm` with `ink-950` text at weight 600; unselected is `ink-600` on the
|
||
bare track. Each segment is a 40px-high target inside a 44px row.
|
||
|
||
A segment may carry a count after its label in `tabular-nums`; a zero count is rendered, not
|
||
hidden, because "Past 0" is information and a missing number reads as a loading state.
|
||
|
||
Use it only where the segments are the same kind of thing and the user is switching lens. Where
|
||
the destinations differ in kind, that is the tab bar's job (§6.7), and where one option is a
|
||
filter on a list that has other filters too, use chips (§6.4). Never more than three segments —
|
||
at four, the labels truncate at 360px and it becomes a worse tab bar.
|
||
|
||
Implemented as a `radiogroup`: the selected segment carries `aria-checked`, and selection is
|
||
never signalled by fill alone (§8) — the weight change carries it too.
|
||
|
||
### 6.14 Bottom sheet
|
||
|
||
The only modal this product has. It rises from the bottom edge, because that is where the thumb
|
||
already is (§4) and a centred dialog on a 390px screen is just a card with the page greyed out.
|
||
|
||
`radius-card` on the **top two corners only**, `page` surface, `shadow-lg`, full width, capped
|
||
at 85% of the viewport height with its body scrolling inside. A 36×4px `ink-200` grab handle sits
|
||
centred at the top — the affordance that says this can be dismissed downward. Behind it, a
|
||
`rgb(0 6 36 / .45)` scrim.
|
||
|
||
Layout is title (`h3`), optional one-line body (`body-sm ink-600`), content, then actions pinned
|
||
at the bottom of the sheet: primary full-width, dismissal as a `ghost` beneath it. Actions never
|
||
scroll out of reach.
|
||
|
||
Dismissal is by scrim tap, Escape, or the grab handle — and **all three mean the same thing**.
|
||
A sheet whose scrim tap silently confirms is a trap. Anything destructive or irreversible gets an
|
||
explicit button; the sheet closing is always "no".
|
||
|
||
Enter and exit use `motion-slow` with the sheet translating and the scrim fading; under
|
||
`prefers-reduced-motion` both simply appear. `role="dialog"` with `aria-modal`, focus moves to the
|
||
sheet on open and returns to the trigger on close.
|
||
|
||
Use it for a decision that needs context the current screen cannot show — picking which job to
|
||
send a pro, confirming a cancellation. Not for navigation, and not for anything with more than one
|
||
input: a form belongs on a screen.
|
||
|
||
### 6.15 Pro profile
|
||
|
||
What a result row (§6.10) opens onto, and the only screen in the app whose job is *reading*
|
||
rather than deciding. It is a pushed screen (§6.6): 44px back chevron top-left labelled with
|
||
where it came from, never a bare arrow.
|
||
|
||
Order is fixed, because it is an argument and the parts only work in sequence: lead photo,
|
||
name in `h1`, headline, then one `meta tabular-nums` line of figures — rating, distance, rate,
|
||
years, jobs done. The verification line sits directly under it in `go-700` with a shield glyph;
|
||
it is the one claim this marketplace is actually selling, so it is never further down the page.
|
||
|
||
Then titled blocks, each skipped entirely when empty rather than rendered as a heading over
|
||
nothing: **Trades** and **Specialises in** as `Tag` pills (§6.4), **About**, **Their work** as a
|
||
horizontally scrolling strip, and **Reviews**.
|
||
|
||
Reviews are a page, not a history. The heading says so — "Showing the most recent of 47" —
|
||
because `ratingCount` in the header counts every rating and the list below it never will, and two
|
||
numbers that disagree without explanation read as a bug. Each review is a `radius-card` row: 36px
|
||
round avatar or initial, author name, five stars filled to the rating, relative date, then the
|
||
body. Stars carry their value in the accessible name (§8); an unrated pro gets the `New` pill and
|
||
never `0.0 ★`.
|
||
|
||
The primary action is pinned to the bottom of the screen, not placed after the reviews — a page
|
||
of reviews is exactly the length that buries a button (§9). It opens the send sheet (§6.14)
|
||
rather than acting directly, because "which job?" is a question this screen cannot answer.
|
||
|
||
### 6.16 Address field
|
||
|
||
An address is the only input in this product that must resolve to something real: distance is
|
||
what the deck ranks on, so a typed line that never became a coordinate is not an answer.
|
||
|
||
The §6.9 search field geometry, with a suggestion list below it and a **precision line** beneath
|
||
that. Suggestions are §6.10 result rows at 56px, no thumbnail, title plus one `meta` line, and
|
||
the list caps at five — a sixth is a scroll inside a form and nobody reads it.
|
||
|
||
The precision line is the component's whole reason for existing and is never optional:
|
||
|
||
| State | Line | Tone |
|
||
|---|---|---|
|
||
| Resolved to a street address | "Matched to <address>" | `go-600` with a check |
|
||
| Resolved to a street/area only | "Approximate — we will match from <area>" | `sun-500` with an alert |
|
||
| Nothing resolved | "No address yet — matching from <city>" | `ink-600`, no icon |
|
||
|
||
Never show only a tick. "We found something" and "we found the right thing" are different
|
||
claims, and a field that renders them identically is how a placeholder gets stored as a
|
||
location.
|
||
|
||
A "use my current location" control sits below, and on success **must** fill the text with a
|
||
reverse-geocoded label — a button that silently sets an invisible pin gives the user nothing to
|
||
check.
|
||
|
||
---
|
||
|
||
## 7. Motion
|
||
|
||
| Token | Duration | Easing | Use |
|
||
|---|---|---|---|
|
||
| `motion-fast` | 120ms | `cubic-bezier(.4,0,.2,1)` | Hover, focus, colour |
|
||
| `motion-base` | 200ms | `cubic-bezier(.4,0,.2,1)` | Enter / exit, expand |
|
||
| `motion-slow` | 320ms | `cubic-bezier(.22,1,.36,1)` | Page and card transitions |
|
||
|
||
Transition `color`, `background-color`, `border-color`, `opacity`, `transform` — never `all`.
|
||
Non-essential animation is wrapped in `@media (prefers-reduced-motion: no-preference)`. The
|
||
deck's drag is essential and stays; its spring flattens under reduced motion.
|
||
|
||
---
|
||
|
||
## 8. Accessibility floor
|
||
|
||
- Body text ≥ 4.5:1, large text and UI borders ≥ 3:1. `ink-500` fails body text — placeholder only.
|
||
- Visible focus on every interactive element: `0 0 0 3px brand-200` plus a `brand-500` border.
|
||
- Touch targets ≥ 44×44px.
|
||
- Colour is never the sole carrier of meaning.
|
||
- One `h1` per page, headings in order, no level skipped for size.
|
||
- Decorative images `alt=""`; icons inside labelled controls `aria-hidden`.
|
||
|
||
---
|
||
|
||
## 9. Do / Don't
|
||
|
||
| Don't | Do |
|
||
|---|---|
|
||
| Square-cornered buttons | `radius-pill`, always |
|
||
| `brand-500` as a section background | White or `ink-50`; blue for the CTA inside it |
|
||
| Grey `#8F8F8F` body copy | `ink-600` for secondary text |
|
||
| Positive letter-spacing on headings | `-0.02em` and tighter |
|
||
| Three shadows on one screen | One raised element per view |
|
||
| `transition: all` | Name the properties |
|
||
| Placeholder standing in for a label | Visible `<label>` every time |
|
||
| Arbitrary values like `py-[13px]` | The 8px scale |
|
||
| A second accent colour | Blue is the only accent |
|
||
| Multi-column grids, `md:` structure changes | One column, always |
|
||
| A marketing hero or feature grid | This is an app, not a website |
|
||
| A footer | The tab bar is the only persistent nav |
|
||
| An action out of thumb reach | Primary actions sit low, near the tab bar |
|
||
|
||
---
|
||
|
||
## 10. Where this lives in code
|
||
|
||
| Concern | File |
|
||
|---|---|
|
||
| Token definitions (`@theme`), base layer, dark mode | `apps/web/src/styles/globals.css` |
|
||
| Font loading, chrome wiring | `apps/web/src/app/layout.tsx` |
|
||
| Button, Input, Card, Chip, Banner, Field primitives | `apps/web/src/components/ui/` |
|
||
| App bar and tab bar | `apps/web/src/components/chrome/` |
|
||
|
||
Screens compose primitives. A screen that writes its own `rounded-xl bg-[#...]` has bypassed the
|
||
system — fix the primitive instead.
|