Entry screen is the app: phone frame with a live deck inside

The homepage was a marketing brochure -- a hero paragraph, a "three
steps" explainer and a tag list. It described the gesture in prose
while the actual Tinder deck sat behind /deck/[jobId], reachable only
after signing in AND posting a job. Nobody opening the app ever saw
the product.

Now / renders a phone illustration with the real app running inside
it: the same <Deck>, the same drag physics, real verified pros. On a
phone the bezel collapses and the deck simply fills the viewport --
drawing a picture of a phone on a phone is absurd, and it would eat
the width the cards need.

- Removed the fixed app bar and bottom tab bar. AppShell now renders
  the screen title as an in-flow h1; the bar owned the only h1 on
  every screen, so dropping it silently would have left every page
  headingless. The /jobs "post" action moved from bar chrome into the
  content, since the tab bar was its only other route there.

- getShowcaseDeck(): a deck with no job behind it. getDeck is
  job-scoped (joins jobs for category and location, anti-joins
  swipes), which an anonymous visitor has none of, so this centres on
  the launch city. Eligibility rules are copied verbatim -- nobody may
  appear in the shop window who could not appear on a real deck.

- deck.showcase: the only public procedure in the router. list and
  swipe stay behind clientProcedure. It reads nothing about the caller
  and writes nothing, so a right swipe on the entry screen is purely
  local. No real tradesperson is contacted until a job is posted.

- <Deck> filled a hardcoded 560px desktop box; it now fills its
  container. The card counter moved out from between the two action
  buttons so the thumb zone holds nothing but the two controls.

Verified against the seeded database: 22 eligible pros returned, and
all three seeded traps excluded for the right reason -- Pau Ribas
(22km out, 5km radius), Unverified Ulla (pending), Away Arnau (not
accepting). 7 new integration tests cover exactly that.

Also corrects a label I had written as "Plumbers near you" -- the
showcase deck is not category-filtered and shows every trade.

Includes concurrent edits to the mobile shell, ui/ primitives and
DESIGN.md made outside this session.

typecheck, lint, build clean; 141 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
serfowi
2026-08-21 01:38:00 -04:00
co-authored by Claude Opus 5
parent c617bc9687
commit 582f13fa99
28 changed files with 1219 additions and 571 deletions
+48 -16
View File
@@ -22,6 +22,8 @@ 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
@@ -151,19 +153,35 @@ Display sizes use `clamp()` so one token works from 360px to desktop.
## 4. Space and layout
An **8px grid**. The only legal spacing values are `4 8 12 16 24 32 40 56 72 96 128`. Anything
else is a bug.
**Linkder is a mobile-only product.** There is no desktop layout, no marketing site and no
responsive breakpoint work. Design for a 360430px 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.
| Container | Width | Used 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 |
|---|---|---|
| `prose` | 680px | Reading, forms, single-column flows |
| `app` | 1080px | Dashboards, lists |
| `wide` | 1280px | Marketing sections |
| `app` | 480px | Every screen. There is no second container. |
- Page gutter: **24px** mobile, **32px** ≥768px.
- Vertical rhythm between page sections: **72px** mobile, **96px** desktop.
- 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.
---
@@ -253,16 +271,26 @@ Selection must never rely on fill alone.
Title in `h4`, body in `body-sm` `ink-600`.
### 6.6 Navigation
### 6.6 App bar
Sticky top bar, 72px tall, `ink-0` at 88% opacity with `backdrop-blur(12px)`, hairline bottom
border. Wordmark left in Display 700 `-0.02em`. Primary CTA right as a pill. Mobile collapses
links; the CTA stays visible in the bar.
Fixed top, **56px** plus top safe-area inset. `page` surface at 88% opacity with
`backdrop-blur(12px)` and a hairline bottom border.
### 6.7 Footer
Two layouts only:
`ink-950` surface, white text, secondary links `ink-400`, 72px top padding, 40px bottom,
4-up column grid collapsing to 1-up under 640px.
- **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
@@ -311,6 +339,10 @@ deck's drag is essential and stays; its spring flattens under reduced motion.
| 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 |
---
@@ -321,7 +353,7 @@ deck's drag is essential and stays; its spring flattens under reduced motion.
| 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/` |
| Site header and footer | `apps/web/src/components/chrome/` |
| 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.