This is the authoritative spec for porting screens to the new design. The reference implementation lives in ui_kits/admin/ (HTML/React prototype). When in doubt, read the prototype — don't invent.
How to use this doc with Claude Code / AI handoffs:
- Attach this file + the prototype files (
Primitives.jsx,AppShell.jsx, one target screen likePayments.jsx). - Attach a screenshot of the target screen rendered.
- Port one screen at a time. Diff. Iterate. Do not request app-wide rewrites.
- After each screen: render it side-by-side with the prototype and compare.
All colors, type, radii, spacing come from colors_and_type.css. Never hardcode hex. Use CSS variables via Tailwind arbitrary values:
bg-[var(--card)] text-[var(--foreground)]
border-[var(--border)] text-[var(--muted-foreground)]
bg-[var(--accent)] text-[var(--success)]
Key semantic pairs:
| Token | Use |
|---|---|
--background / --foreground |
Page body |
--card / --card-foreground |
Cards, table container |
--muted / --muted-foreground |
Subdued surfaces & secondary text |
--accent |
Hover/selected row background |
--border / --input |
All borders and input outlines |
--ring |
Focus ring (single ring, width 1) |
--primary |
Primary button bg (inverted) |
--brand / --brand-muted |
Teal accent — logos, highlights |
--success/warning/destructive/info/purple |
Badge + inline semantic color |
Typography: Geist (sans) + Geist Mono. Body is 14px. Page H1 is 18px semibold. Do not introduce other font families or type scales.
These are the ones Claude Code gets wrong most often. Treat as constraints.
- IDs, numbers, NIPs, IBANs, references, dates, amounts →
font-mono text-xs. Always. The monospace rhythm is core to the identity. - Amounts →
font-mono text-sm tabular-nums whitespace-nowrap text-right. Currency intext-xs text-[var(--muted-foreground)] ml-1after the number. For two-line amounts: gross on top, net below asfont-mono text-[11px] tabular-nums text-[var(--muted-foreground)]with the wordnetinopacity-70. - Status → always a
<Badge variant="…">(not a raw span). Variants:success | warning | error | info | muted | purple | brand. Never colored dots alone. - Row density is tight. Table cells:
px-4 py-3. Headers:px-4 py-2.5 text-xs uppercase tracking-wide text-[var(--muted-foreground)] font-mediumonbg-[var(--muted)]/50. No extra padding "to breathe." - Row hover is
bg-[var(--accent)]. Selected row gets the same. A trailing chevron may appear on hover only:opacity-0 group-hover:opacity-100. - Empty states follow the two-pattern rule in §7. Filter-empty = terse string
"No data for selected period". Zero-data empty =<EmptyState>component. Never mix them. - Bulk action bar on selection: inverted —
bg-[var(--foreground)] text-[var(--background)]above the table header row, with count · summary · actions (secondary inline buttons, not fullButtoncomponents inside). - Tabs above tables: underline style. Active tab has a 2px bottom bar (
bg-[var(--foreground)]), inactive istext-[var(--muted-foreground)]. Each tab shows a count pill (see §4). - Summary tiles are always 3-up (
grid grid-cols-1 sm:grid-cols-3 gap-3 mb-5). Structure: uppercase label,text-2xl font-semibold tabular-numsvalue, muted sub-line. Currency suffix infont-mono text-[var(--muted-foreground)]. - Focus rings are thin.
focus:ring-1 focus:ring-[var(--ring)], neverring-2orring-offset-*. No glowy focus.
Use these exact components from Primitives.jsx. Don't re-implement.
variant:primary | outline | ghost | destructive | brandsize:default | sm | icon- Heights are fixed:
default h-9,sm h-7,icon h-9 w-9 - Always
rounded-md(not pill, not square) - Icon before label with
gap-2
- Variants as listed in rule 3
- Always
px-2 py-0.5 rounded-md text-xs font-medium border whitespace-nowrap - Shows with a tinted background (10%) + same-hue border (20%). Never solid-fill.
rounded-xl border border-[var(--border)] bg-[var(--card)]- Default padding is
p-6; passpadding="p-0"for embedded lists
- 9px tall field,
rounded-md, thin focus ring - Error state uses
--destructiveborder + inline error row
- Fixed 24×24 viewBox,
stroke="currentColor" strokeWidth="1.5". Size in px. - Only use names present in
Primitives.jsx. If you need a new one, add it there — don't inline SVGs elsewhere.
- The canonical zero-data surface. See §7 for when to use it vs. the filter-empty string.
tone:default | locked | warningactionis an optional<Button size="sm">— typicallyvariant="primary"for a direct CTA (e.g. Add note) orvariant="outline"for a softer nudge (e.g. Write a comment).titlemax ~40 chars;submax ~140 chars. No marketing fluff.
- Every page starts with this. Bottom border,
mb-6. - Actions are right-aligned; primary always rightmost.
<div className="-mt-2 mb-5 border-b border-[var(--border)] flex items-center">
{tabs.map(t => {
const active = value === t.id;
return (
<button key={t.id} onClick={() => onChange(t.id)}
className={`relative -mb-px h-10 px-4 text-sm flex items-center gap-2
${active ? "text-[var(--foreground)] font-medium" : "text-[var(--muted-foreground)] hover:text-[var(--foreground)]"}`}>
{t.label}
<span className={`inline-flex items-center justify-center min-w-[20px] h-[18px] px-1
rounded text-[11px] font-mono tabular-nums
${active ? "bg-[var(--foreground)] text-[var(--background)]" : "bg-[var(--muted)] text-[var(--muted-foreground)]"}`}>
{counts[t.id]}
</span>
{active && <span className="absolute left-0 right-0 bottom-0 h-[2px] bg-[var(--foreground)]" />}
</button>
);
})}
</div><div className="rounded-lg border border-[var(--border)] overflow-hidden bg-[var(--card)]">
{/* optional bulk bar */}
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead className="bg-[var(--muted)]/50 border-b border-[var(--border)]">
<tr className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
<th className="px-4 py-2.5 font-medium">…</th>
</tr>
</thead>
<tbody>{…}</tbody>
</table>
</div>
<div className="flex items-center justify-between px-4 py-2.5 border-t border-[var(--border)] text-xs text-[var(--muted-foreground)]">
<span>Showing 1–N of N items</span>
<div className="flex items-center gap-1">…pagination…</div>
</div>
</div><div className="flex items-center justify-between px-4 py-2.5 bg-[var(--foreground)] text-[var(--background)] border-b border-[var(--border)]">
<div className="flex items-center gap-3 text-sm">
<span className="font-medium tabular-nums">{n} selected</span>
<span className="opacity-60">·</span>
<span className="font-mono text-xs tabular-nums opacity-80">{totalPLN} PLN</span>
</div>
<div className="flex items-center gap-2">
{/* inline buttons, NOT full <Button> — h-7 px-2.5 text-xs rounded-md */}
</div>
</div>Native <input type="checkbox"> with indeterminate set via ref. w-4 h-4 rounded border-[var(--border)] accent-[var(--foreground)] focus:ring-1 focus:ring-[var(--ring)].
<Card>
<div className="text-xs text-[var(--muted-foreground)] uppercase tracking-wide">{label}</div>
<div className="mt-1 text-2xl font-semibold tabular-nums">
{value}
<span className="text-sm font-mono text-[var(--muted-foreground)] ml-1.5">{unit}</span>
</div>
<div className="text-xs text-[var(--muted-foreground)] mt-1">{sub}</div>
</Card>Bottom-right, auto-dismiss 3.2s. fixed bottom-6 right-6 z-50 px-3.5 py-2.5 rounded-lg shadow-lg border bg-[var(--card)]. Icon + text, no close button.
Fixed column order when applicable:
[source] Number | Seller (NIP below) | Date | Amount (gross + net) | Kind | Category | Status | Payment | [chevron]
- Number cell:
font-mono text-xs truncatein amax-w-[200px]td. - Seller cell: seller name
text-sm truncate, NIP below asfont-mono text-[11px] text-[var(--muted-foreground)]. - Date cell:
font-mono text-xs text-[var(--muted-foreground)] whitespace-nowrap, formatted as17 Apr. - Amount cell: see rule 2. Right-aligned.
- Kind / Category:
<Badge>only. - Trailing chevron column:
w-6,opacity-0 group-hover:opacity-100.
Do
- Reach for existing primitives first (
Badge,Button,Card,Input,Icon). - Keep tables dense; don't pad "for breathing room."
- Use
tabular-numson every number column and stat. - Put unit/currency as a smaller muted suffix after the number.
- Use
rounded-mdfor interactive,rounded-xlfor cards,rounded-lgfor table containers.
Don't
- Don't add gradients, emoji, drop shadows on cards, or colored left borders on rows.
- Don't introduce solid-filled status pills (always tinted).
- Don't use
<div>s where<Badge>should go. - Don't add new colors outside the token set — compose with
color-mix(in oklch, var(--x) 10%, transparent)if you need a faint surface. - Don't center-align numbers or dates.
- Don't use
text-gray-*/text-zinc-*/text-neutral-*Tailwind classes. Usetext-[var(--muted-foreground)]. - Don't widen focus rings or add
ring-offset. - Don't invent copy for empty states, tab labels, or button verbs — copy from the prototype.
Two distinct patterns. Pick by asking: "Would clearing filters reveal content?"
A list has data, but current filters hide all of it.
- Copy:
"No data for selected period"— exact string, no variations. - Styling:
text-sm text-[var(--muted-foreground)] py-12centered inside the table body. - No icon, no CTA. The resolution (clear filters) lives above the table.
Used by: Invoices list, Payments list, Sync jobs, Companies search, any future table.
There is genuinely nothing here yet, for this record or feature.
Use the <EmptyState> component. Three tones:
| Tone | When | CTA? |
|---|---|---|
default |
Nothing here yet, but the user could create something | Optional primary CTA |
locked |
Feature doesn't apply to this record (e.g. Payments on an income invoice) | Never — don't fake-tease a feature |
warning |
Something failed (OCR, sync) and needs user action | Always — Retry … |
- One paragraph, max 140 chars in
sub. No marketing fluff. - Never use the filter-empty string for a zero-data state — copy must match the real cause.
- CTAs from empty states are never destructive. No "Delete invoice" on empty Payments.
- Locked states don't show disabled CTAs. State why it doesn't apply, then stop.
- No illustrations, no confetti, no color. Same muted circle icon used everywhere else. Exception:
tone="warning"(seeEmptyStateinPrimitives.jsx) applies the warning tint to the circle icon because the state signals a real failure that needs attention; it must still pair with a succinctsuband a retry CTA. All other tones stay colorless. - Activity logs are never empty. A fetch event always exists — don't ship an empty state for Activity.
| Tab | Condition | Tone | Copy |
|---|---|---|---|
| Line items | extraction=failed |
warning |
Line items couldn't be extracted — The OCR run failed. Retry or enter items manually. |
| Payments | No requests, invoice is expense + approved | default |
No payment requests yet — Create one to schedule a transfer for this invoice. |
| Payments | Invoice is income or rejected | locked |
Payments don't apply here — Income and rejected invoices don't generate outgoing transfers. |
| Notes | None | default |
No notes yet — Notes are private to your team. Use them to record decisions or context. |
| Comments | None | default |
Start the conversation — @mention a teammate to pull them in. |
| Access | Owner only | default |
You're the sole user with access — Share this invoice with teammates to collaborate. |
| Surface | State | Tone | Copy |
|---|---|---|---|
| Dashboard tile | New tenant, no invoices yet | default |
No invoices this month yet — Sync runs hourly, or add one manually. |
| Companies | New tenant | default |
Add your first company — Register a NIP to start syncing invoices, or wait for an invitation from an existing company. |
| Settings → Certificates | Not uploaded | warning |
No certificate uploaded — Upload a XAdES certificate to enable KSeF sync. |
| Sync jobs | New tenant | default |
No sync jobs yet — KSeF sync runs hourly, or trigger one manually. |
| Payments list | New tenant | default |
No payment requests yet — Approve an expense to generate your first payment. |
- Commit this file + the prototype into the real repo under
docs/design_system/(this file lives at the repo root asDESIGN_SYSTEM.md; the HTML/JSX kit lives underdocs/design_system/). - Per screen in the real app, open a PR that links to the matching prototype file. In the PR description, paste:
- Screenshot of prototype screen
- Screenshot of current real screen
- Checklist of the 10 rules from §2
- Ask Claude Code one screen at a time, e.g.:
Port
app/views/payments/index.tsxto matchdocs/design_system/ui_kits/admin/Payments.jsx. FollowDESIGN_SYSTEM.mdat the repo root. ReuseButton,Badge,Cardfromapp/components/ui/. Keep existing data hooks. Output a diff, don't touch other files. - Review against §2. Almost every miss is one of those 10 rules.