diff --git a/DESIGN.md b/DESIGN.md
new file mode 100644
index 0000000..400a051
--- /dev/null
+++ b/DESIGN.md
@@ -0,0 +1,327 @@
+# Linkder Design System
+
+**Version 1.0** · derived from the wix.com design language, adapted for Linkder.
+
+This document is the single source of truth for how Linkder 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. Linkder is
+not affiliated with Wix and must never present itself as such.
+
+---
+
+## 1. Principles
+
+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 `
` — 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
+
+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.
+
+| Container | Width | Used for |
+|---|---|---|
+| `prose` | 680px | Reading, forms, single-column flows |
+| `app` | 1080px | Dashboards, lists |
+| `wide` | 1280px | Marketing sections |
+
+- Page gutter: **24px** mobile, **32px** ≥768px.
+- Vertical rhythm between page sections: **72px** mobile, **96px** desktop.
+- Gap between sibling cards in a list: **12px**.
+- Label → control: **8px**. Control → helper text: **8px**. Field → field: **24px**.
+
+---
+
+## 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 ``. 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 Navigation
+
+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.
+
+### 6.7 Footer
+
+`ink-950` surface, white text, secondary links `ink-400`, 72px top padding, 40px bottom,
+4-up column grid collapsing to 1-up under 640px.
+
+### 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. Action
+buttons are 64px circles, 2px border, `ink-0` fill.
+
+---
+
+## 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 `` every time |
+| Arbitrary values like `py-[13px]` | The 8px scale |
+| A second accent colour | Blue is the only accent |
+
+---
+
+## 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/` |
+| Site header and footer | `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.
diff --git a/SECURITY-FINDINGS.md b/SECURITY-FINDINGS.md
new file mode 100644
index 0000000..9555889
--- /dev/null
+++ b/SECURITY-FINDINGS.md
@@ -0,0 +1,76 @@
+# Open security findings (M1 auth review)
+
+An adversarial review of the M1 auth, session and authorization code produced 28
+confirmed findings (~20 after deduplication). The ones **fixed** are listed first
+so nobody re-reports them; the rest are open and are the M1 exit criteria.
+
+## Fixed
+
+| # | Issue | Fix |
+|---|---|---|
+| 1 | `pro.upsertProfile` computed `requiresReReview` and never applied it — a verified plumber could silently become a verified electrician 40 km away | Demotes a `verified` profile to `pending` in the same transaction, writes an audit row, and now counts a **trade change** as material (it did not before). Needed a new `verified -> pending` edge in `VERIFICATION_GRAPH` |
+| 2 | `phoneNumber()` registers `/phone-number/request-password-reset`, `/phone-number/reset-password` and `/sign-in/phone-number` **unconditionally** — not gated on `emailAndPassword.enabled: false`. Together they mint a password credential with no SMS sent to the owner, then accept it forever with no OTP | All three served as 404 via `disabledPaths` (checked in `onRequest`, before rate limiting) |
+| 3 | `bearer()` accepted the raw plaintext `sessions.token` column as an `Authorization: Bearer` credential — one leaked DB row is a replayable login | Plugin removed. The mobile client it was for does not exist yet |
+| 4 | Phone numbers stored exactly as typed, so `+34600111222` and `0034600111222` are two "unique" accounts — defeating the UNIQUE constraint, bans, and duplicate detection | `phoneNumberValidator` pins E.164 on send-otp and sign-in; `toE164`/`isE164` added to `@linkder/shared` |
+| 5 | `NEXT_PUBLIC_APP_URL` fell back to `http://localhost:3000`, which drops `Secure` and the `__Secure-` prefix from the production session cookie | Throws at boot in production |
+
+## Open — must close before M1 ships
+
+**Authorization / attribution**
+
+- `resolveSession` discards `session.impersonatedBy`, so every admin action taken
+ while impersonating is written to `audit_log` as the **victim**. The `Session`
+ type has no field for it, so no procedure can tell. Admin impersonation is
+ enabled (`admin()` plugin) and is currently invisible to the application.
+ *Fix:* add `impersonatedBy` to `Session`, populate it in `resolveSession`, and
+ record it on all three `audit_log` insert sites.
+- `user.setRole` is a check-then-write across two connections; a concurrent
+ `job.create` permanently strands the job it was meant to protect.
+- `deck.swipe` checks `job.status` outside its transaction — a concurrent
+ `job.cancel` loses the race and a pending request lands on a cancelled job.
+- `deck.undo`'s "already reached the pro" guard is check-then-write: it deletes
+ the tombstone for a request that really was sent, and reports success.
+
+**Storage / uploads**
+
+- Government ID and insurance scans go to the **public** bucket;
+ `isPrivateKind` is never called from production code.
+- Presigned PUT does not pin `Content-Type`, so a caller can store HTML under an
+ image key in a public bucket.
+- `pro.addMedia`, `pro.addCredential` and `job.create` accept **any** URL the
+ caller sends, with no binding to an object that caller actually uploaded —
+ which bypasses the presign design entirely.
+
+**Abuse / spend**
+
+- No per-number cap, destination allowlist, or global ceiling on
+ `/phone-number/send-otp`. SMS spend is unmetered.
+- better-auth rate limiting is per-process in-memory and collapses to one global
+ bucket behind a proxy chain — 10 req/min then locks out the whole platform.
+ Needs the Redis-backed `rateLimit.customStorage`, not `secondary-storage`
+ (which throws unless all five `SecondaryStorage` methods exist).
+
+**Lower severity**
+
+- `user.setEmail` lets any authenticated user claim an arbitrary *unverified*
+ address, locking the real owner out of Google sign-up and leaking which
+ addresses are registered.
+- Open redirect: the `next` query param on the sign-in form goes straight to
+ `router.push`.
+- `audit_log.ip` trusts the leftmost `X-Forwarded-For` entry verbatim —
+ attacker-controlled.
+- `findPossibleDuplicates` has no call site; the guarantee it was written to
+ provide never executes.
+- `DeckClient` renders a frozen `initialCards` prop and invalidates a query
+ nothing subscribes to, capping the deck at 20 cards per page load.
+- A right swipe after a left swipe creates a live request while the stored swipe
+ row still records a rejection.
+
+## Note on the OTP-in-plaintext decision
+
+`apps/web/src/lib/auth.ts` argues the plaintext OTP is acceptable partly because
+"anyone who can read that table can already read `session.token`". With
+`bearer()` removed that argument is weaker than when it was written — a session
+token now requires an httpOnly browser cookie to use, an OTP does not. The
+decision still stands (300s window, 3 attempts, rate limited) but revisit it if
+OTP lifetime or attempt budget ever increases.
diff --git a/apps/web/src/app/jobs/new/form.tsx b/apps/web/src/app/jobs/new/form.tsx
new file mode 100644
index 0000000..9d08432
--- /dev/null
+++ b/apps/web/src/app/jobs/new/form.tsx
@@ -0,0 +1,208 @@
+'use client';
+
+import { useState } from 'react';
+import { useRouter } from 'next/navigation';
+import { Loader2 } from 'lucide-react';
+import type { RouterOutputs } from '@/lib/trpc';
+import { api } from '@/lib/trpc';
+import { cn } from '@/lib/utils';
+
+type Categories = RouterOutputs['job']['categories'];
+
+const CITY = {
+ lat: Number(process.env.NEXT_PUBLIC_CITY_LAT ?? 41.3874),
+ lng: Number(process.env.NEXT_PUBLIC_CITY_LNG ?? 2.1686),
+};
+
+const URGENCIES = [
+ { value: 'now', label: 'As soon as possible', hint: 'Pros have 12 hours to respond' },
+ { value: 'this_week', label: 'This week', hint: '48 hours to respond' },
+ { value: 'flexible', label: "I'm flexible", hint: '48 hours to respond' },
+] as const;
+
+export function NewJobForm({ categories }: { categories: Categories }) {
+ const router = useRouter();
+ const [categoryId, setCategoryId] = useState('');
+ const [title, setTitle] = useState('');
+ const [description, setDescription] = useState('');
+ const [urgency, setUrgency] = useState<(typeof URGENCIES)[number]['value']>('this_week');
+ const [addressText, setAddressText] = useState('');
+ const [budgetMin, setBudgetMin] = useState('');
+ const [budgetMax, setBudgetMax] = useState('');
+ const [location, setLocation] = useState(CITY);
+ const [error, setError] = useState(null);
+
+ const create = api.job.create.useMutation({
+ // Straight into the deck — the whole point is that posting and browsing are
+ // one continuous motion, not two separate visits.
+ onSuccess: (job) => router.push(`/deck/${job.id}`),
+ onError: (e) => setError(e.message),
+ });
+
+ function submit(event: React.FormEvent) {
+ event.preventDefault();
+ setError(null);
+
+ const toCents = (v: string) => (v.trim() ? Math.round(Number(v) * 100) : undefined);
+ const min = toCents(budgetMin);
+ const max = toCents(budgetMax);
+ if (min !== undefined && max !== undefined && min > max) {
+ setError('The minimum budget is above the maximum.');
+ return;
+ }
+
+ create.mutate({
+ categoryId,
+ title,
+ description,
+ photos: [],
+ urgency,
+ budgetMinCents: min,
+ budgetMaxCents: max,
+ location,
+ addressText,
+ });
+ }
+
+ return (
+
+ );
+}
+
+const inputClass =
+ 'w-full rounded-xl border border-[var(--border)] bg-[var(--card)] px-4 py-3 text-base outline-none focus:border-[var(--color-brand-500)]';
diff --git a/apps/web/src/app/jobs/new/page.tsx b/apps/web/src/app/jobs/new/page.tsx
new file mode 100644
index 0000000..d9258bb
--- /dev/null
+++ b/apps/web/src/app/jobs/new/page.tsx
@@ -0,0 +1,30 @@
+import { redirect } from 'next/navigation';
+import { getApi } from '@/server/caller';
+import { NewJobForm } from './form';
+
+export const metadata = { title: 'Post a job' };
+export const dynamic = 'force-dynamic';
+
+export default async function NewJobPage() {
+ const api = await getApi();
+
+ let me: Awaited>;
+ try {
+ me = await api.user.me();
+ } catch {
+ redirect('/sign-in?next=/jobs/new');
+ }
+ if (me.role === 'pro') redirect('/pro');
+
+ const categories = await api.job.categories();
+
+ return (
+
+ What needs doing?
+
+ Describe it once. We will show you verified pros nearby who can take it on.
+
+
+
+ );
+}
diff --git a/apps/web/src/app/jobs/page.tsx b/apps/web/src/app/jobs/page.tsx
new file mode 100644
index 0000000..e286316
--- /dev/null
+++ b/apps/web/src/app/jobs/page.tsx
@@ -0,0 +1,69 @@
+import Link from 'next/link';
+import { redirect } from 'next/navigation';
+import { ArrowRight, Plus } from 'lucide-react';
+import { getApi } from '@/server/caller';
+
+export const metadata = { title: 'Your jobs' };
+export const dynamic = 'force-dynamic';
+
+const STATUS_LABEL: Record = {
+ open: 'Looking for pros',
+ matched: 'Pros interested',
+ booked: 'Booked',
+ completed: 'Done',
+ cancelled: 'Cancelled',
+};
+
+export default async function JobsPage() {
+ const api = await getApi();
+
+ let jobs: Awaited>;
+ try {
+ jobs = await api.job.mine();
+ } catch {
+ redirect('/sign-in?next=/jobs');
+ }
+
+ return (
+
+
+
Your jobs
+
+
+ Post a job
+
+
+
+ {jobs.length === 0 ? (
+
+ Nothing yet. Post a job and start swiping.
+
+ ) : (
+
+ {jobs.map((job) => (
+
+
+
+ {job.title}
+
+ {STATUS_LABEL[job.status] ?? job.status} · {job.addressText}
+
+
+
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/app/pro/onboarding/page.tsx b/apps/web/src/app/pro/onboarding/page.tsx
new file mode 100644
index 0000000..4f6978a
--- /dev/null
+++ b/apps/web/src/app/pro/onboarding/page.tsx
@@ -0,0 +1,43 @@
+import { redirect } from 'next/navigation';
+import { getApi } from '@/server/caller';
+import { OnboardingWizard } from './wizard';
+
+export const metadata = { title: 'Set up your profile' };
+export const dynamic = 'force-dynamic';
+
+export default async function ProOnboardingPage() {
+ const api = await getApi();
+
+ let me: Awaited>;
+ try {
+ me = await api.user.me();
+ } catch {
+ redirect('/sign-in?next=/pro/onboarding');
+ }
+
+ // A customer account cannot build a pro profile — send them to pick a role.
+ if (me.role === 'client') redirect('/onboarding');
+ if (me.role === 'admin') redirect('/admin');
+
+ const [categories, profile] = await Promise.all([api.job.categories(), api.pro.me()]);
+
+ // Already submitted or approved — nothing to fill in.
+ if (profile && profile.verificationStatus !== 'draft' && profile.verificationStatus !== 'rejected') {
+ redirect('/pro');
+ }
+
+ return (
+
+ Set up your profile
+
+ We check every pro’s ID, licence and insurance before any customer sees them. It
+ usually takes a day.
+
+
+
+ );
+}
diff --git a/apps/web/src/app/pro/onboarding/wizard.tsx b/apps/web/src/app/pro/onboarding/wizard.tsx
new file mode 100644
index 0000000..3bac70c
--- /dev/null
+++ b/apps/web/src/app/pro/onboarding/wizard.tsx
@@ -0,0 +1,473 @@
+'use client';
+
+import { useState } from 'react';
+import { useRouter } from 'next/navigation';
+import { Check, Loader2, Upload } from 'lucide-react';
+import type { RouterOutputs } from '@/lib/trpc';
+import { api } from '@/lib/trpc';
+import {
+ DEFAULT_SERVICE_RADIUS_M,
+ MAX_SERVICE_RADIUS_M,
+ MIN_SERVICE_RADIUS_M,
+} from '@linkder/shared';
+import { cn } from '@/lib/utils';
+import { uploadFile } from '@/lib/upload';
+
+type Categories = RouterOutputs['job']['categories'];
+type Profile = RouterOutputs['pro']['me'];
+
+const CITY = {
+ lat: Number(process.env.NEXT_PUBLIC_CITY_LAT ?? 41.3874),
+ lng: Number(process.env.NEXT_PUBLIC_CITY_LNG ?? 2.1686),
+};
+
+const STEPS = ['Trade', 'About you', 'Photos', 'Documents'] as const;
+
+export function OnboardingWizard({
+ categories,
+ initialProfile,
+ hasContactableEmail,
+}: {
+ categories: Categories;
+ initialProfile: Profile;
+ hasContactableEmail: boolean;
+}) {
+ const router = useRouter();
+ const [step, setStep] = useState(0);
+ const [error, setError] = useState(null);
+
+ const [categoryIds, setCategoryIds] = useState(initialProfile?.categoryIds ?? []);
+ const [headline, setHeadline] = useState(initialProfile?.headline ?? '');
+ const [bio, setBio] = useState(initialProfile?.bio ?? '');
+ const [hourlyRate, setHourlyRate] = useState(
+ initialProfile ? String(initialProfile.hourlyRateCents / 100) : '',
+ );
+ const [yearsExperience, setYearsExperience] = useState(
+ String(initialProfile?.yearsExperience ?? ''),
+ );
+ const [radiusKm, setRadiusKm] = useState(
+ (initialProfile?.serviceRadiusM ?? DEFAULT_SERVICE_RADIUS_M) / 1000,
+ );
+ const [location, setLocation] = useState(initialProfile?.baseLocation ?? CITY);
+ const [email, setEmail] = useState('');
+
+ const utils = api.useUtils();
+ const upsert = api.pro.upsertProfile.useMutation();
+ const addMedia = api.pro.addMedia.useMutation();
+ const addCredential = api.pro.addCredential.useMutation();
+ const setEmailMutation = api.user.setEmail.useMutation();
+ const submit = api.pro.submitForReview.useMutation({
+ onSuccess: () => router.push('/pro'),
+ onError: (e) => setError(e.message),
+ });
+
+ const media = initialProfile?.media ?? [];
+ const credentials = initialProfile?.credentials ?? [];
+
+ async function saveProfile() {
+ setError(null);
+ const rateCents = Math.round(Number(hourlyRate) * 100);
+ if (!Number.isFinite(rateCents) || rateCents <= 0) {
+ setError('Enter your hourly rate.');
+ return false;
+ }
+ try {
+ await upsert.mutateAsync({
+ headline,
+ bio,
+ hourlyRateCents: rateCents,
+ yearsExperience: Number(yearsExperience) || 0,
+ categoryIds,
+ location,
+ serviceRadiusM: Math.round(radiusKm * 1000),
+ });
+ await utils.pro.me.invalidate();
+ return true;
+ } catch (e) {
+ setError((e as Error).message);
+ return false;
+ }
+ }
+
+ return (
+
+
+ {STEPS.map((label, i) => (
+
+
+
+ {label}
+
+
+ ))}
+
+
+ {step === 0 && (
+
+
+ {categories.map((c) => {
+ const selected = categoryIds.includes(c.id);
+ return (
+
+ setCategoryIds((prev) =>
+ selected ? prev.filter((id) => id !== c.id) : [...prev, c.id].slice(0, 5),
+ )
+ }
+ className={cn(
+ 'rounded-full border px-4 py-2 text-sm transition',
+ selected
+ ? 'border-[var(--color-brand-500)] bg-[var(--color-brand-500)]/10 font-medium'
+ : 'border-[var(--border)]',
+ )}
+ >
+ {selected && }
+ {c.name}
+
+ );
+ })}
+
+ {
+ if (categoryIds.length === 0) return setError('Pick at least one trade.');
+ setError(null);
+ setStep(1);
+ }}
+ />
+
+ )}
+
+ {step === 1 && (
+
+ )}
+
+ {step === 2 && (
+
+ m.url)}
+ onUploaded={async (url) => {
+ await addMedia.mutateAsync({ url });
+ await utils.pro.me.invalidate();
+ router.refresh();
+ }}
+ onError={setError}
+ />
+ setStep(1)} onNext={() => setStep(3)} />
+
+ )}
+
+ {step === 3 && (
+
+ )}
+
+ {error && (
+
+ {error}
+
+ )}
+
+ );
+}
+
+const inputClass =
+ 'w-full rounded-xl border border-[var(--border)] bg-[var(--card)] px-4 py-3 text-base outline-none focus:border-[var(--color-brand-500)]';
+
+function Section({
+ title,
+ hint,
+ children,
+}: {
+ title: string;
+ hint?: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+
{title}
+ {hint &&
{hint}
}
+
+ {children}
+
+ );
+}
+
+function Field({
+ label,
+ hint,
+ children,
+}: {
+ label: string;
+ hint?: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+ {label}
+ {hint && {hint} }
+ {children}
+
+ );
+}
+
+function Nav({
+ onBack,
+ onNext,
+ busy,
+}: {
+ onBack?: () => void;
+ onNext: () => void | Promise;
+ busy?: boolean;
+}) {
+ return (
+
+ {onBack && (
+
+ Back
+
+ )}
+ void onNext()}
+ disabled={busy}
+ className="flex flex-1 items-center justify-center gap-2 rounded-xl bg-[var(--color-brand-500)] px-5 py-3 font-medium text-white disabled:opacity-60"
+ >
+ {busy && }
+ Continue
+
+
+ );
+}
+
+function UploadList({
+ kind,
+ existing,
+ onUploaded,
+ onError,
+}: {
+ kind: 'pro_photo' | 'credential';
+ existing: string[];
+ onUploaded: (url: string) => Promise;
+ onError: (message: string) => void;
+}) {
+ const [busy, setBusy] = useState(false);
+ const presign = api.upload.presign.useMutation();
+
+ return (
+
+ {existing.length > 0 && (
+
+ {existing.map((url) => (
+
+
+ Uploaded
+
+ ))}
+
+ )}
+
+ {busy ? (
+
+ ) : (
+
+ )}
+ {busy ? 'Uploading…' : 'Choose a file'}
+ {
+ const file = event.target.files?.[0];
+ if (!file) return;
+ setBusy(true);
+ try {
+ const url = await uploadFile(file, kind, (input) => presign.mutateAsync(input));
+ await onUploaded(url);
+ } catch (e) {
+ onError((e as Error).message);
+ } finally {
+ setBusy(false);
+ event.target.value = '';
+ }
+ }}
+ />
+
+
+ );
+}
diff --git a/apps/web/src/app/pro/page.tsx b/apps/web/src/app/pro/page.tsx
new file mode 100644
index 0000000..159cf78
--- /dev/null
+++ b/apps/web/src/app/pro/page.tsx
@@ -0,0 +1,118 @@
+import { redirect } from 'next/navigation';
+import { Clock, ShieldCheck, XCircle } from 'lucide-react';
+import { getApi } from '@/server/caller';
+
+export const metadata = { title: 'Your account' };
+export const dynamic = 'force-dynamic';
+
+export default async function ProHomePage() {
+ const api = await getApi();
+
+ let me: Awaited>;
+ try {
+ me = await api.user.me();
+ } catch {
+ redirect('/sign-in?next=/pro');
+ }
+ if (me.role !== 'pro') redirect('/onboarding');
+
+ const profile = await api.pro.me();
+ if (!profile) redirect('/pro/onboarding');
+
+ const status = profile.verificationStatus;
+
+ return (
+
+ {profile.headline}
+
+ {status === 'pending' && (
+ }
+ tone="waiting"
+ title="We are checking your documents"
+ body="This usually takes a day. We will text you the moment you are live and jobs start arriving."
+ />
+ )}
+ {status === 'verified' && (
+ }
+ tone="good"
+ title="You are live"
+ body="Customers nearby can see you now. Keep your response time short — it is the second biggest factor in where you appear."
+ />
+ )}
+ {status === 'rejected' && (
+ }
+ tone="bad"
+ title="We could not approve your account"
+ body="Check your email for what we need. You can update your documents and submit again."
+ />
+ )}
+ {status === 'suspended' && (
+ }
+ tone="bad"
+ title="Your account is suspended"
+ body={profile.suspendedReason ?? 'Contact support to sort this out.'}
+ />
+ )}
+ {status === 'draft' && (
+ }
+ tone="waiting"
+ title="Your profile is not finished"
+ body="Finish setting up and submit it for review."
+ />
+ )}
+
+
+
+
+
+
+
+
+ );
+}
+
+function StatusCard({
+ icon,
+ tone,
+ title,
+ body,
+}: {
+ icon: React.ReactNode;
+ tone: 'good' | 'waiting' | 'bad';
+ title: string;
+ body: string;
+}) {
+ const toneClass =
+ tone === 'good'
+ ? 'border-[var(--color-go-500)]/30 bg-[var(--color-go-500)]/10'
+ : tone === 'bad'
+ ? 'border-[var(--color-stop-500)]/30 bg-[var(--color-stop-500)]/10'
+ : 'border-[var(--border)] bg-[var(--card)]';
+
+ return (
+
+ {icon}
+
+ {title}
+ {body}
+
+
+ );
+}
+
+function Stat({ label, value }: { label: string; value: string }) {
+ return (
+
+
{label}
+ {value}
+
+ );
+}
diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx
new file mode 100644
index 0000000..13f0e43
--- /dev/null
+++ b/apps/web/src/components/ui/button.tsx
@@ -0,0 +1,97 @@
+import { cva, type VariantProps } from 'class-variance-authority';
+import { Loader2 } from 'lucide-react';
+import { cn } from '@/lib/utils';
+
+/**
+ * DESIGN.md §6.1. Always a pill, always the display family.
+ *
+ * `buttonClasses` is exported separately because Next's cannot be wrapped
+ * without a Slot primitive, and adding Radix for one component is not worth it.
+ * Anchors take `className={buttonClasses({ variant, size })}`.
+ */
+export const buttonClasses = cva(
+ [
+ 'inline-flex items-center justify-center gap-2 rounded-pill font-display font-semibold',
+ 'whitespace-nowrap select-none',
+ 'transition-[color,background-color,border-color,opacity] duration-[120ms] ease-standard',
+ 'disabled:pointer-events-none disabled:opacity-45',
+ ],
+ {
+ variants: {
+ variant: {
+ primary: 'bg-brand-500 text-white hover:bg-brand-600',
+ dark: 'bg-ink-950 text-white hover:bg-[#1a2145]',
+ outline: 'border-[1.5px] border-ink-950 text-strong hover:bg-ink-50 dark:border-hairline',
+ ghost: 'text-accent hover:bg-accent-soft',
+ danger: 'bg-stop-500 text-white hover:bg-stop-600',
+ },
+ size: {
+ sm: 'h-9 px-4 text-body-sm',
+ md: 'h-11 px-6 text-body',
+ lg: 'h-14 px-8 text-body-lg',
+ },
+ block: { true: 'w-full', false: '' },
+ },
+ defaultVariants: { variant: 'primary', size: 'md', block: false },
+ },
+);
+
+type ButtonProps = React.ButtonHTMLAttributes &
+ VariantProps & {
+ /** Shows a leading spinner and disables the control. The label never changes. */
+ busy?: boolean;
+ };
+
+export function Button({
+ className,
+ variant,
+ size,
+ block,
+ busy = false,
+ disabled,
+ children,
+ ...props
+}: ButtonProps) {
+ return (
+
+ {busy && }
+ {children}
+
+ );
+}
+
+/** Circular icon-only button. §6.1 — requires an accessible label. */
+export function IconButton({
+ label,
+ tone = 'neutral',
+ className,
+ children,
+ ...props
+}: React.ButtonHTMLAttributes & {
+ label: string;
+ tone?: 'neutral' | 'go' | 'stop';
+}) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts
index e29b58e..a746d59 100644
--- a/apps/web/src/lib/auth.ts
+++ b/apps/web/src/lib/auth.ts
@@ -1,8 +1,9 @@
import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
-import { admin, bearer, phoneNumber } from 'better-auth/plugins';
+import { admin, phoneNumber } from 'better-auth/plugins';
import { nextCookies } from 'better-auth/next-js';
import { db, schema } from '@linkder/db';
+import { isE164 } from '@linkder/shared';
import { sendVerificationSms } from '@/server/sms';
/**
@@ -37,6 +38,21 @@ if (!secret && process.env.NODE_ENV === 'production') {
throw new Error('AUTH_SECRET is not set. Generate one with: openssl rand -base64 32');
}
+/**
+ * better-auth derives cookie attributes from baseURL. Falling back to
+ * http://localhost:3000 in production would therefore drop `Secure` and the
+ * `__Secure-` cookie prefix from the real session cookie, and send OAuth
+ * callbacks to localhost. A missing app URL is a deployment error, so say so at
+ * boot rather than serving downgraded cookies.
+ */
+const appUrl =
+ process.env.NEXT_PUBLIC_APP_URL ??
+ (process.env.NODE_ENV === 'production'
+ ? (() => {
+ throw new Error('NEXT_PUBLIC_APP_URL is not set. Set it to the public https origin.');
+ })()
+ : 'http://localhost:3000');
+
export const auth = betterAuth({
// Passing `schema` explicitly (rather than letting the adapter read
// db._.fullSchema) keeps it from forcing our lazy db Proxy open at module
@@ -67,7 +83,39 @@ export const auth = betterAuth({
verification: { modelName: 'verifications' },
secret,
- baseURL: process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3000',
+ baseURL: appUrl,
+
+ /**
+ * Endpoints we deliberately serve as 404.
+ *
+ * `phoneNumber()` registers a full password-reset pair and a password
+ * sign-in unconditionally — they are NOT gated on
+ * `emailAndPassword.enabled`, which is false above. Left live they form a
+ * complete, silent alternative credential path:
+ *
+ * 1. POST /phone-number/request-password-reset writes a plaintext OTP to
+ * `verifications` and sends NO SMS, because we never configured
+ * `sendPasswordResetOTP` — so the account owner is never told.
+ * 2. POST /phone-number/reset-password mints a `credential` account row
+ * holding a bcrypt password.
+ * 3. POST /sign-in/phone-number then accepts { phoneNumber, password }
+ * forever, with no OTP.
+ *
+ * The OTP in step 1 is still gated (3 attempts, 300s, rate limited), so this
+ * is not a remote unauthenticated takeover. What it *is*: a way to convert a
+ * single momentary OTP compromise — a SIM swap, a glanced-at lock screen —
+ * into permanent access that survives the victim re-verifying their phone,
+ * with no notification and no password the victim can see or rotate. This
+ * platform has exactly one credential: a live OTP. Keep it that way.
+ *
+ * `disabledPaths` is checked in the router's onRequest, before rate limiting
+ * and before the handler, and returns 404.
+ */
+ disabledPaths: [
+ '/phone-number/request-password-reset',
+ '/phone-number/reset-password',
+ '/sign-in/phone-number',
+ ],
advanced: {
database: {
@@ -95,6 +143,16 @@ export const auth = betterAuth({
otpLength: 6,
expiresIn: 300,
allowedAttempts: 3,
+ /**
+ * `users.phone` is UNIQUE and bans are per-account, so the stored string
+ * form is load-bearing: if "+34600111222" and "0034600111222" can both be
+ * written, one handset holds two "unique" accounts, a ban is escapable by
+ * retyping, and findPossibleDuplicates cannot see the pair. Callers must
+ * send E.164 — normalise with toE164() from @linkder/shared before
+ * calling. This runs on both /phone-number/send-otp and
+ * /sign-in/phone-number.
+ */
+ phoneNumberValidator: (phone) => isE164(phone),
signUpOnVerification: {
/**
* better-auth requires a unique, non-null email. Phone-first users do
@@ -118,9 +176,18 @@ export const auth = betterAuth({
adminRoles: ['admin'],
}),
- // Lets a future React Native client authenticate with
- // `Authorization: Bearer ` instead of a cookie.
- bearer(),
+ /**
+ * NOT bearer(): it accepts the raw `sessions.token` value as an
+ * `Authorization: Bearer` credential. That column is stored in plaintext,
+ * so with bearer() enabled anything that can read one row out of `sessions`
+ * — a log line, a backup, a SQL injection, a support screenshot — holds a
+ * directly replayable login for that session's full lifetime. Cookies at
+ * least require the httpOnly cookie to be exfiltrated from a browser.
+ *
+ * The mobile client this was added for does not exist yet. When it does,
+ * give it its own signed, short-lived access token rather than handing out
+ * the session row's primary secret.
+ */
// Must be last — it wraps the handler to set cookies on Next responses.
nextCookies(),
diff --git a/apps/web/src/lib/trpc.tsx b/apps/web/src/lib/trpc.tsx
index 9e95ccf..48d1a42 100644
--- a/apps/web/src/lib/trpc.tsx
+++ b/apps/web/src/lib/trpc.tsx
@@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { createTRPCReact, type CreateTRPCReact } from '@trpc/react-query';
import { deserialize, serialize } from 'superjson';
+import type { inferRouterInputs, inferRouterOutputs } from '@trpc/server';
import type { AppRouter } from '@linkder/api';
// Explicit annotation: pnpm's strict node_modules layout means the inferred
@@ -61,3 +62,7 @@ export function TRPCProvider({ children }: { children: React.ReactNode }) {
);
}
+
+/** Convenience aliases so components can name API shapes without re-deriving them. */
+export type RouterInputs = inferRouterInputs;
+export type RouterOutputs = inferRouterOutputs;
diff --git a/apps/web/src/lib/upload.ts b/apps/web/src/lib/upload.ts
new file mode 100644
index 0000000..d750356
--- /dev/null
+++ b/apps/web/src/lib/upload.ts
@@ -0,0 +1,47 @@
+import type { UploadKind } from '@linkder/storage';
+
+interface PresignResult {
+ url: string;
+ key: string;
+ publicUrl: string | null;
+}
+
+/**
+ * Ask the server for a presigned PUT, then send the bytes straight to R2.
+ *
+ * The file never passes through our server: a 15 MB licence scan would blow the
+ * request body limit and pay for the bandwidth twice. The server still controls
+ * the key and pins the content type and length, so the browser cannot choose
+ * where the object lands.
+ */
+export async function uploadFile(
+ file: File,
+ kind: UploadKind,
+ presign: (input: {
+ kind: UploadKind;
+ contentType: string;
+ contentLength: number;
+ }) => Promise,
+): Promise {
+ const signed = await presign({
+ kind,
+ contentType: file.type || 'application/octet-stream',
+ contentLength: file.size,
+ });
+
+ const response = await fetch(signed.url, {
+ method: 'PUT',
+ // Must match exactly what was signed, or R2 rejects the request.
+ headers: { 'Content-Type': file.type || 'application/octet-stream' },
+ body: file,
+ });
+
+ if (!response.ok) {
+ throw new Error(`Upload failed (${response.status}). Try again.`);
+ }
+
+ // A private kind (credentials) has no public URL, so the caller gets the
+ // object KEY. Callers must store it in a key-typed column — putting it where
+ // a URL is expected fails validation, which is how this was caught.
+ return signed.publicUrl ?? signed.key;
+}
diff --git a/apps/web/src/styles/globals.css b/apps/web/src/styles/globals.css
index dd8d40b..df853ac 100644
--- a/apps/web/src/styles/globals.css
+++ b/apps/web/src/styles/globals.css
@@ -1,53 +1,248 @@
@import 'tailwindcss';
+/*
+ * Linkder design tokens — see DESIGN.md at the repo root.
+ * Hex values are sampled from wix.com and are normative. Do not hand-tune them
+ * in a component; change them here or add a step to the ramp.
+ */
@theme {
- --color-ink-50: oklch(0.98 0.005 260);
- --color-ink-100: oklch(0.95 0.008 260);
- --color-ink-200: oklch(0.89 0.012 260);
- --color-ink-400: oklch(0.65 0.02 260);
- --color-ink-600: oklch(0.45 0.025 260);
- --color-ink-800: oklch(0.26 0.03 260);
- --color-ink-950: oklch(0.15 0.03 260);
+ /* ── Brand: action blue. §2.1 ───────────────────────────────────────── */
+ --color-brand-50: #edf3ff;
+ --color-brand-100: #dce8ff;
+ --color-brand-200: #95b9ff;
+ --color-brand-400: #5e97ff;
+ --color-brand-500: #116dff;
+ --color-brand-600: #094bcc;
+ --color-brand-700: #082f7b;
- --color-brand-400: oklch(0.72 0.15 25);
- --color-brand-500: oklch(0.64 0.19 25);
- --color-brand-600: oklch(0.56 0.2 25);
+ /* ── Ink: text and surfaces. §2.2 ───────────────────────────────────── */
+ --color-ink-0: #ffffff;
+ --color-ink-50: #f7f8f8;
+ --color-ink-100: #f0f0f0;
+ --color-ink-200: #e2e2e2;
+ --color-ink-300: #c2c2c2;
+ --color-ink-400: #b0b0b0;
+ --color-ink-500: #8f8f8f;
+ --color-ink-600: #6e6e6e;
+ --color-ink-800: #212121;
+ --color-ink-950: #000624;
- --color-go-500: oklch(0.7 0.17 150);
- --color-stop-500: oklch(0.64 0.2 20);
+ /* ── Semantic. §2.3 ─────────────────────────────────────────────────── */
+ --color-go-50: #f3f8f0;
+ --color-go-100: #d2e4c7;
+ --color-go-400: #92b079;
+ --color-go-600: #618741;
+ --color-go-700: #39641d;
+
+ --color-sun-50: #ffe9df;
+ --color-sun-100: #ffbfa1;
+ --color-sun-400: #fa854f;
+ --color-sun-500: #ea6020;
+ --color-sun-600: #c94001;
+
+ --color-stop-50: #ffecec;
+ --color-stop-100: #ffc9cb;
+ --color-stop-400: #ff6a70;
+ --color-stop-500: #ed1c24;
+ --color-stop-600: #c4141b;
+
+ /* ── Type. §3 ───────────────────────────────────────────────────────── */
+ --font-sans: var(--font-madefor-text), 'Segoe UI', system-ui, -apple-system, sans-serif;
+ --font-display: var(--font-madefor-display), 'Segoe UI', system-ui, -apple-system, sans-serif;
+
+ --text-display-xl: clamp(2.75rem, 6vw, 4.5rem);
+ --text-display-xl--line-height: 1.04;
+ --text-display-xl--letter-spacing: -0.03em;
+ --text-display-xl--font-weight: 700;
+
+ --text-display-lg: clamp(2.25rem, 4.5vw, 3.25rem);
+ --text-display-lg--line-height: 1.08;
+ --text-display-lg--letter-spacing: -0.025em;
+ --text-display-lg--font-weight: 700;
+
+ --text-h1: clamp(1.875rem, 3.5vw, 2.5rem);
+ --text-h1--line-height: 1.12;
+ --text-h1--letter-spacing: -0.02em;
+ --text-h1--font-weight: 700;
+
+ --text-h2: clamp(1.5rem, 2.5vw, 2rem);
+ --text-h2--line-height: 1.18;
+ --text-h2--letter-spacing: -0.02em;
+ --text-h2--font-weight: 700;
+
+ --text-h3: 1.375rem;
+ --text-h3--line-height: 1.25;
+ --text-h3--letter-spacing: -0.015em;
+ --text-h3--font-weight: 600;
+
+ --text-h4: 1.125rem;
+ --text-h4--line-height: 1.35;
+ --text-h4--letter-spacing: -0.01em;
+ --text-h4--font-weight: 600;
+
+ --text-body-lg: 1.125rem;
+ --text-body-lg--line-height: 1.55;
+ --text-body-lg--letter-spacing: 0em;
+
+ --text-body: 1rem;
+ --text-body--line-height: 1.6;
+ --text-body--letter-spacing: 0em;
+
+ --text-body-sm: 0.875rem;
+ --text-body-sm--line-height: 1.5;
+ --text-body-sm--letter-spacing: 0em;
+
+ --text-meta: 0.75rem;
+ --text-meta--line-height: 1.45;
+ --text-meta--letter-spacing: 0em;
+ --text-meta--font-weight: 500;
+
+ --text-overline: 0.75rem;
+ --text-overline--line-height: 1.4;
+ --text-overline--letter-spacing: 0.08em;
+ --text-overline--font-weight: 700;
+
+ /* ── Radius. §5 ─────────────────────────────────────────────────────── */
+ --radius-md: 8px;
+ --radius-lg: 12px;
+ --radius-card: 18px;
+ --radius-deck: 28px;
+ --radius-pill: 999px;
+
+ /* ── Elevation. §5 — tinted with ink-950, never pure black. ─────────── */
+ --shadow-sm: 0 1px 2px rgb(0 6 36 / 0.06), 0 1px 3px rgb(0 6 36 / 0.04);
+ --shadow-md: 0 4px 12px rgb(0 6 36 / 0.08), 0 2px 4px rgb(0 6 36 / 0.04);
+ --shadow-lg: 0 12px 32px rgb(0 6 36 / 0.12), 0 4px 8px rgb(0 6 36 / 0.06);
+
+ /* ── Containers. §4 ─────────────────────────────────────────────────── */
+ --container-prose: 680px;
+ --container-app: 1080px;
+ --container-wide: 1280px;
+
+ /* ── Motion. §7 ─────────────────────────────────────────────────────── */
+ --ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
+ --ease-out-soft: cubic-bezier(0.22, 1, 0.36, 1);
}
+/*
+ * Semantic layer. Components reference these, not the ramps directly, so light
+ * and dark are one set of classes rather than a `dark:` variant on every node.
+ */
:root {
- --bg: var(--color-ink-50);
- --fg: var(--color-ink-950);
- --card: white;
- --muted: var(--color-ink-600);
- --border: var(--color-ink-200);
+ --surface-page: var(--color-ink-0);
+ --surface-raised: var(--color-ink-0);
+ --surface-sunken: var(--color-ink-50);
+ --surface-inset: var(--color-ink-100);
+ --text-strong: var(--color-ink-950);
+ --text-muted: var(--color-ink-600);
+ --text-faint: var(--color-ink-500);
+ --hairline: var(--color-ink-200);
+ --accent: var(--color-brand-500);
+ --accent-hover: var(--color-brand-600);
+ --accent-soft: var(--color-brand-50);
}
@media (prefers-color-scheme: dark) {
:root {
- --bg: var(--color-ink-950);
- --fg: var(--color-ink-50);
- --card: var(--color-ink-800);
- --muted: var(--color-ink-400);
- --border: color-mix(in oklch, var(--color-ink-400) 25%, transparent);
+ --surface-page: var(--color-ink-950);
+ --surface-raised: #0c1230;
+ --surface-sunken: #070c22;
+ --surface-inset: #141a3a;
+ --text-strong: var(--color-ink-0);
+ --text-muted: var(--color-ink-400);
+ --text-faint: var(--color-ink-500);
+ --hairline: rgb(255 255 255 / 0.14);
+ /* brand-500 is only 4.4:1 on ink-950 — lighten the accent, not the fill. */
+ --accent: var(--color-brand-400);
+ --accent-hover: var(--color-brand-200);
+ --accent-soft: rgb(94 151 255 / 0.14);
}
}
-html,
-body {
- background: var(--bg);
- color: var(--fg);
+/* `inline` so the utilities resolve the var at use-site and flip with the scheme. */
+@theme inline {
+ --color-page: var(--surface-page);
+ --color-raised: var(--surface-raised);
+ --color-sunken: var(--surface-sunken);
+ --color-inset: var(--surface-inset);
+ --color-strong: var(--text-strong);
+ --color-muted: var(--text-muted);
+ --color-faint: var(--text-faint);
+ --color-hairline: var(--hairline);
+ --color-accent: var(--accent);
+ --color-accent-hover: var(--accent-hover);
+ --color-accent-soft: var(--accent-soft);
}
-body {
- -webkit-font-smoothing: antialiased;
- text-rendering: optimizeLegibility;
+@layer base {
+ html {
+ background-color: var(--surface-page);
+ color: var(--text-strong);
+ -webkit-text-size-adjust: 100%;
+ }
+
+ body {
+ font-family: var(--font-sans);
+ font-size: var(--text-body);
+ line-height: 1.6;
+ -webkit-font-smoothing: antialiased;
+ text-rendering: optimizeLegibility;
+ }
+
+ /* §3.3 — display family and negative tracking are structural, not opt-in. */
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ font-family: var(--font-display);
+ color: var(--text-strong);
+ text-wrap: balance;
+ }
+
+ p {
+ text-wrap: pretty;
+ }
+
+ /* §8 — one focus treatment, everywhere, and it is always visible. */
+ :focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+ border-radius: var(--radius-md);
+ }
+
+ ::selection {
+ background-color: var(--color-brand-100);
+ color: var(--color-ink-950);
+ }
+
+ ::placeholder {
+ color: var(--text-faint);
+ }
+
+ /* Prices and counters must not jitter as they change. §3.3 */
+ input[inputmode='decimal'],
+ input[inputmode='numeric'] {
+ font-variant-numeric: tabular-nums;
+ }
}
-/* The deck is drag-driven; stop the browser from hijacking the gesture. */
+/* The deck is a drag surface — stop the browser hijacking the gesture. §6.8 */
.deck-card {
touch-action: none;
user-select: none;
+ -webkit-user-select: none;
+}
+
+/* §7 — nothing non-essential moves when the user has asked for stillness. */
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ scroll-behavior: auto !important;
+ }
}
diff --git a/packages/api/src/routers/pro.ts b/packages/api/src/routers/pro.ts
index d913979..d5a4ab2 100644
--- a/packages/api/src/routers/pro.ts
+++ b/packages/api/src/routers/pro.ts
@@ -67,11 +67,44 @@ export const proRouter = router({
// Editing a live profile sends it back for review — a verified plumber must
// not be able to quietly become an unverified electrician.
- const materiallyChanged =
+ //
+ // The trade list is part of "material": it is the single most important
+ // thing verification actually checks (the licence is per-trade), so a
+ // change of trade matters more than a change of radius.
+ const previousCategoryIds = existing
+ ? (
+ await ctx.db
+ .select({ categoryId: schema.proCategories.categoryId })
+ .from(schema.proCategories)
+ .where(eq(schema.proCategories.proId, ctx.session.userId))
+ )
+ .map((c) => c.categoryId)
+ .sort()
+ : [];
+ const nextCategoryIds = [...input.categoryIds].sort();
+
+ const materiallyChanged = Boolean(
existing &&
- (existing.serviceRadiusM !== input.serviceRadiusM ||
- existing.baseLocation.lat !== input.location.lat ||
- existing.baseLocation.lng !== input.location.lng);
+ (existing.serviceRadiusM !== input.serviceRadiusM ||
+ existing.baseLocation.lat !== input.location.lat ||
+ existing.baseLocation.lng !== input.location.lng ||
+ previousCategoryIds.length !== nextCategoryIds.length ||
+ previousCategoryIds.some((id, i) => id !== nextCategoryIds[i])),
+ );
+
+ /**
+ * Act on it. Computing `materiallyChanged` and returning it to the client
+ * without changing anything server-side is exactly the bug the comment
+ * above was written to prevent: the client is free to ignore the flag.
+ *
+ * Only a currently-verified pro needs demoting. A draft/pending/rejected
+ * profile is already not on the deck, and demoting a `suspended` pro to
+ * pending would quietly undo a moderator's suspension.
+ */
+ const sendBackForReview = materiallyChanged && existing?.verificationStatus === 'verified';
+ if (sendBackForReview) {
+ assertTransition('verification', 'verified', 'pending');
+ }
await ctx.db.transaction(async (tx) => {
const values = {
@@ -83,6 +116,7 @@ export const proRouter = router({
baseLocation: input.location,
serviceRadiusM: input.serviceRadiusM,
updatedAt: new Date(),
+ ...(sendBackForReview ? { verificationStatus: 'pending' as const } : {}),
};
await tx
@@ -96,9 +130,19 @@ export const proRouter = router({
await tx.insert(schema.proCategories).values(
input.categoryIds.map((categoryId) => ({ proId: ctx.session.userId, categoryId })),
);
+
+ if (sendBackForReview) {
+ await tx.insert(schema.auditLog).values({
+ actorId: ctx.session.userId,
+ action: 'verification.re_review_required',
+ entity: 'pro_profile',
+ entityId: ctx.session.userId,
+ ip: ctx.ip,
+ });
+ }
});
- return { saved: true, requiresReReview: Boolean(materiallyChanged) };
+ return { saved: true, requiresReReview: sendBackForReview };
}),
/** Attach an uploaded photo. The file itself went straight to R2. */
@@ -150,7 +194,7 @@ export const proRouter = router({
.values({
proId: ctx.session.userId,
kind: input.kind,
- fileUrl: input.fileUrl,
+ fileKey: input.fileKey,
issuer: input.issuer ?? null,
expiresAt: input.expiresAt ?? null,
})
diff --git a/packages/db/drizzle/0000_colossal_masked_marvel.sql b/packages/db/drizzle/0000_material_shadow_king.sql
similarity index 99%
rename from packages/db/drizzle/0000_colossal_masked_marvel.sql
rename to packages/db/drizzle/0000_material_shadow_king.sql
index 6fb0aed..7cf56f7 100644
--- a/packages/db/drizzle/0000_colossal_masked_marvel.sql
+++ b/packages/db/drizzle/0000_material_shadow_king.sql
@@ -85,7 +85,7 @@ CREATE TABLE "credentials" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"pro_id" uuid NOT NULL,
"kind" "credential_kind" NOT NULL,
- "file_url" text NOT NULL,
+ "file_key" text NOT NULL,
"issuer" text,
"expires_at" timestamp with time zone,
"review_status" "review_status" DEFAULT 'pending' NOT NULL,
diff --git a/packages/db/drizzle/meta/0000_snapshot.json b/packages/db/drizzle/meta/0000_snapshot.json
index dd2bbdd..7b89351 100644
--- a/packages/db/drizzle/meta/0000_snapshot.json
+++ b/packages/db/drizzle/meta/0000_snapshot.json
@@ -1,5 +1,5 @@
{
- "id": "d2669c23-7683-48e2-a271-03f7e5ddcbd1",
+ "id": "6b9ea99a-a893-432a-9bea-ab3e145c97a2",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
@@ -580,8 +580,8 @@
"primaryKey": false,
"notNull": true
},
- "file_url": {
- "name": "file_url",
+ "file_key": {
+ "name": "file_key",
"type": "text",
"primaryKey": false,
"notNull": true
diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json
index 7d6911a..7f2d5f4 100644
--- a/packages/db/drizzle/meta/_journal.json
+++ b/packages/db/drizzle/meta/_journal.json
@@ -5,8 +5,8 @@
{
"idx": 0,
"version": "7",
- "when": 1787250714989,
- "tag": "0000_colossal_masked_marvel",
+ "when": 1787252153406,
+ "tag": "0000_material_shadow_king",
"breakpoints": true
}
]
diff --git a/packages/db/src/schema/pros.ts b/packages/db/src/schema/pros.ts
index 08b1196..aac23c2 100644
--- a/packages/db/src/schema/pros.ts
+++ b/packages/db/src/schema/pros.ts
@@ -109,7 +109,8 @@ export const credentials = pgTable(
.notNull()
.references(() => proProfiles.userId, { onDelete: 'cascade' }),
kind: credentialKind('kind').notNull(),
- fileUrl: text('file_url').notNull(),
+ /** R2 object key. Private — resolve with a signed GET, never a public URL. */
+ fileKey: text('file_key').notNull(),
issuer: text('issuer'),
expiresAt: timestamp('expires_at', { withTimezone: true }),
reviewStatus: reviewStatus('review_status').notNull().default('pending'),
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index 1194428..a0c1406 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -5,3 +5,4 @@ export * from './ranking';
export * from './cancellation';
export * from './schemas';
export * from './email';
+export * from './phone';
diff --git a/packages/shared/src/phone.ts b/packages/shared/src/phone.ts
new file mode 100644
index 0000000..20f008d
--- /dev/null
+++ b/packages/shared/src/phone.ts
@@ -0,0 +1,55 @@
+/**
+ * Phone identity.
+ *
+ * Phone is the primary identity on this platform, which makes its *string form*
+ * load-bearing: `users.phone` carries a UNIQUE constraint, bans are enforced per
+ * account, and duplicate detection matches on it. If "+34600111222" and
+ * "0034 600 111 222" can both be stored, then one handset holds two distinct
+ * "unique" accounts — which defeats the constraint, lets a banned user return,
+ * and hides the duplicate from `findPossibleDuplicates`.
+ *
+ * So there is exactly one accepted stored form: E.164, no spaces, no separators.
+ * Normalise on the way in, reject anything that cannot be normalised.
+ */
+
+/** E.164: a leading +, a nonzero leading digit, and 8–15 digits total. */
+const E164 = /^\+[1-9]\d{7,14}$/;
+
+export function isE164(value: string): boolean {
+ return E164.test(value);
+}
+
+/**
+ * Coerce common user input into E.164, or return null if it cannot be done
+ * unambiguously.
+ *
+ * Handles the shapes people actually type: spaces, hyphens, parentheses and dots
+ * as separators, and a `00` international prefix instead of `+`. It deliberately
+ * does NOT guess a country code for a bare national number — "600111222" is
+ * meaningless without knowing the country, and silently assuming one would
+ * attach a real person's account to the wrong number.
+ */
+export function toE164(input: string | null | undefined): string | null {
+ if (!input) return null;
+
+ // Strip everything a human might use as a separator.
+ let s = input.trim().replace(/[\s().-]/g, '');
+ if (s.length === 0) return null;
+
+ // "0034..." is the same as "+34..."
+ if (s.startsWith('00')) s = `+${s.slice(2)}`;
+
+ // A bare national number is ambiguous — refuse rather than guess a country.
+ if (!s.startsWith('+')) return null;
+
+ if (!/^\+\d+$/.test(s)) return null;
+ return isE164(s) ? s : null;
+}
+
+/**
+ * Last four digits, for display ("••• ••• 222"). Never render a full number
+ * belonging to someone other than the viewer.
+ */
+export function phoneLast4(e164: string): string {
+ return e164.slice(-4);
+}
diff --git a/packages/shared/src/schemas.ts b/packages/shared/src/schemas.ts
index 59e8f6c..4b47e19 100644
--- a/packages/shared/src/schemas.ts
+++ b/packages/shared/src/schemas.ts
@@ -109,7 +109,12 @@ export type SendMessageInput = z.infer;
export const credentialSchema = z.object({
kind: z.enum(['id', 'licence', 'insurance']),
- fileUrl: z.string().url(),
+ /**
+ * An R2 object KEY, not a URL. Credential documents are private — they are
+ * never served publicly, so there is no URL to store. Admins read them
+ * through a short-lived signed GET.
+ */
+ fileKey: z.string().min(1).max(500),
issuer: z.string().max(120).optional(),
expiresAt: z.coerce.date().optional(),
});
diff --git a/packages/shared/src/state-machines.ts b/packages/shared/src/state-machines.ts
index 8328840..c7b742f 100644
--- a/packages/shared/src/state-machines.ts
+++ b/packages/shared/src/state-machines.ts
@@ -99,7 +99,11 @@ const PAYMENT_GRAPH: Graph = {
const VERIFICATION_GRAPH: Graph = {
draft: ['pending'],
pending: ['verified', 'rejected'],
- verified: ['suspended'],
+ // 'pending' is reachable from 'verified' because a material profile edit
+ // (trade, base location, service radius) sends a live pro back for re-review.
+ // Without this edge the re-review in pro.upsertProfile is not representable
+ // and a verified plumber could silently become a verified electrician.
+ verified: ['suspended', 'pending'],
rejected: ['pending'],
suspended: ['verified', 'rejected'],
};
diff --git a/packages/shared/test/phone.test.ts b/packages/shared/test/phone.test.ts
new file mode 100644
index 0000000..7c6d92a
--- /dev/null
+++ b/packages/shared/test/phone.test.ts
@@ -0,0 +1,61 @@
+import { describe, expect, it } from 'vitest';
+import { isE164, phoneLast4, toE164 } from '../src/phone';
+
+/**
+ * These are security tests, not formatting tests. `users.phone` is UNIQUE and
+ * bans are per-account, so any pair of inputs that normalises to two different
+ * strings for one real handset is a way to hold two accounts and to escape a ban.
+ */
+describe('toE164', () => {
+ it('passes through an already-normalised number', () => {
+ expect(toE164('+34600111222')).toBe('+34600111222');
+ });
+
+ it('collapses every separator style a human types to ONE stored form', () => {
+ const forms = [
+ '+34 600 111 222',
+ '+34-600-111-222',
+ '+34 (600) 111.222',
+ ' +34600111222 ',
+ '0034600111222',
+ '0034 600 111 222',
+ ];
+ const normalised = new Set(forms.map(toE164));
+ expect(normalised).toEqual(new Set(['+34600111222']));
+ });
+
+ it('refuses a bare national number rather than guessing a country', () => {
+ // Guessing would attach one person's account to another person's number.
+ expect(toE164('600111222')).toBeNull();
+ expect(toE164('0600111222')).toBeNull();
+ });
+
+ it('rejects junk, empties and letters', () => {
+ expect(toE164(null)).toBeNull();
+ expect(toE164(undefined)).toBeNull();
+ expect(toE164('')).toBeNull();
+ expect(toE164(' ')).toBeNull();
+ expect(toE164('+34600ABC222')).toBeNull();
+ expect(toE164('not a phone')).toBeNull();
+ });
+
+ it('enforces E.164 length and a nonzero country digit', () => {
+ expect(toE164('+3460011')).toBeNull(); // too short
+ expect(toE164('+3460011122233344')).toBeNull(); // too long
+ expect(toE164('+0600111222')).toBeNull(); // country code cannot start with 0
+ });
+});
+
+describe('isE164', () => {
+ it('accepts only the canonical stored form', () => {
+ expect(isE164('+34600111222')).toBe(true);
+ expect(isE164('0034600111222')).toBe(false);
+ expect(isE164('+34 600 111 222')).toBe(false);
+ });
+});
+
+describe('phoneLast4', () => {
+ it('returns the last four digits for masked display', () => {
+ expect(phoneLast4('+34600111222')).toBe('1222');
+ });
+});
diff --git a/packages/storage/package.json b/packages/storage/package.json
index 1ec1126..ed1357d 100644
--- a/packages/storage/package.json
+++ b/packages/storage/package.json
@@ -5,7 +5,9 @@
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
- "exports": { ".": "./src/index.ts" },
+ "exports": {
+ ".": "./src/index.ts"
+ },
"scripts": {
"typecheck": "tsc --noEmit",
"test": "vitest run"
@@ -17,6 +19,7 @@
},
"devDependencies": {
"typescript": "^5.7.3",
- "vitest": "^2.1.8"
+ "vitest": "^2.1.8",
+ "@linkder/shared": "workspace:*"
}
}
diff --git a/packages/storage/test/storage.test.ts b/packages/storage/test/storage.test.ts
index 8461892..6961530 100644
--- a/packages/storage/test/storage.test.ts
+++ b/packages/storage/test/storage.test.ts
@@ -148,3 +148,31 @@ describe('configuration', () => {
).rejects.toThrow(/not allowed/i);
});
});
+
+describe('key/URL contract with the API', () => {
+ /**
+ * Regression: credential uploads return an object KEY (they are private and
+ * have no public URL), but the credential schema originally demanded
+ * z.string().url(). The result was that pro onboarding could never be
+ * completed — every document upload failed validation at the last step.
+ *
+ * This asserts the contract in both directions so the two halves cannot drift
+ * apart again.
+ */
+ it('produces a key that is NOT a URL for private kinds', () => {
+ const key = buildKey('credential', OWNER, 'application/pdf');
+ expect(() => new URL(key)).toThrow();
+ expect(isPrivateKind('credential')).toBe(true);
+ });
+
+ it('accepts that key against the credential schema', async () => {
+ const { credentialSchema } = await import('@linkder/shared');
+ const key = buildKey('credential', OWNER, 'application/pdf');
+ expect(credentialSchema.safeParse({ kind: 'insurance', fileKey: key }).success).toBe(true);
+ });
+
+ it('rejects an empty key rather than storing a dangling reference', async () => {
+ const { credentialSchema } = await import('@linkder/shared');
+ expect(credentialSchema.safeParse({ kind: 'insurance', fileKey: '' }).success).toBe(false);
+ });
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 47dbcb6..1536b3d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -199,6 +199,9 @@ importers:
specifier: ^3.24.1
version: 3.25.76
devDependencies:
+ '@linkder/shared':
+ specifier: workspace:*
+ version: link:../shared
typescript:
specifier: ^5.7.3
version: 5.9.3