refactor(ui): editorial monochrome design refresh (#473)

* refactor(ui): editorial monochrome design refresh

Design-system layer only, no functional changes. Cascades app-wide
from 8 files in the primitives + tokens layer.

- Swap Fraunces → Hedvig Letters Serif for display typography
  (single-weight; drop font-medium from CardTitle and PageHeader)
- Token sweep: pure white background, warm beige secondary
  (40 11% 89%), achromatic primary, calibrated 45 5% 85% border,
  halved-opacity shadows
- Flatten Card: remove shadow, rounded-xl → rounded-lg, full-opacity
  border (was border-border/60)
- Flatten Button: remove shadow-sm, remove active:scale-[0.98],
  drop outline border morph, transition-all 300ms → transition-colors
  150ms
- Soften Dialog overlay (bg-black/80 → bg-black/40 with dark variant)
  and use halved --shadow-md token on DialogContent
- Sidebar: bg-card/90 → bg-background, full-opacity hairline border,
  warm-beige active state (bg-secondary), hover bg-secondary/60
- .hover-lift utility: replace translateY + box-shadow with flat
  background-color shift
- PageHeader title: text-2xl font-medium → text-3xl md:text-4xl
  (no font-medium)
- CLAUDE.md Brand & Aesthetic, Typography, and Forbidden Patterns
  sections rewritten to reflect new system

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(ui): address PR review

- Remove dead .hover-lift utility (no callers; CLAUDE.md now bans
  hover-lift patterns, so leaving the class would contradict the docs)
- Bump Dialog overlay bg-black/40 → bg-black/50 — gives a more
  perceptible separation layer over the new pure-white background
  while keeping the lighter editorial feel

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-05-13 23:01:53 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 2ed8096150
commit ba45b8f661
8 changed files with 63 additions and 71 deletions
+15 -9
View File
@@ -354,13 +354,13 @@ Swedish sole traders (enskild firma) and small business owners (aktiebolag) who
### Brand & Aesthetic
**Minimal. Sharp. Efficient.** The interface should feel like a well-made instrument: considered, quiet, and confident. Reference: Mercury (banking). Anti-reference: enterprise software (SAP/Oracle density).
**Editorial monochrome.** Paper-white surfaces, hairline borders, serif headlines. The interface should feel like a well-made instrument — considered, quiet, confident. Anti-references: enterprise software (SAP/Oracle density), neon SaaS coldness.
- **Palette**: Grayscale foundation with restrained semantic colors — sage green (success/balance), terracotta (errors/overdue), ochre (warnings/attention). No loud brand color.
- **Typography**: Fraunces (serif) for display headings, Geist (sans) for body. Tabular numbers everywhere financial data appears.
- **Surfaces**: White/near-white cards on light gray backgrounds. Subtle borders (60% opacity). Soft shadows. Dark mode follows the same restraint.
- **Palette**: Achromatic foundation. Pure white background, warm beige (`40 11% 89%`) for chips / active sidebar / hover / secondary buttons. Achromatic primary (no cool tint). Semantic colors (`--success` sage, `--warning` ochre, `--destructive` terracotta) exist but are **data-only** — they appear in charts and financial numbers (positive/negative deltas), never as chrome backgrounds. In chrome, only `--destructive` survives.
- **Typography**: Hedvig Letters Serif for display headings, Geist (sans) for body, forms, and tables. Hedvig is single-weight (400) — do not apply `font-medium` to display text; its natural high-contrast strokes carry the weight. Tabular numbers everywhere financial data appears.
- **Surfaces**: Cards sit flat on the page — no shadow, full-opacity hairline border (`border-border`), `rounded-lg` (8px). Card background matches page background; the border carries hierarchy. Dark mode drops the warm tint from secondary for a pure-gray mood shift; light mode keeps the beige.
- **Spacing**: Generous whitespace. Dense data (tables, ledgers) uses tighter spacing but never feels cramped.
- **Motion**: Subtle and purposeful. Stagger animations for list entry, spring easing for feedback. Never decorative.
- **Motion**: Functional, not decorative. No press-scale, no hover-lift, no spring overshoot. Hover state is a flat background shift (`bg-secondary/60`). `transition-colors duration-150` is the default. Stagger animations on list entry are fine. Respect `prefers-reduced-motion` (already wired).
- **Icons**: Lucide — 15px in navigation, slightly larger in empty states.
### Design Principles
@@ -426,11 +426,11 @@ Never render raw `{x.invoice_date}` directly — always route through `formatDat
**Currency.** `formatCurrency(n, currency?)` from `lib/utils.ts`. Default SEK.
**Typography.**
- Page title: `<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">` (or use `PageHeader`).
- Card title: `<CardTitle className="text-base">` for sections, default for primary cards.
- Page title: use `PageHeader` (renders `font-display text-3xl md:text-4xl tracking-tight`). Do not hand-roll an `<h1>`.
- Card title: `<CardTitle className="text-base">` for sections, default for primary cards. The primitive already drops `font-medium` — do not add it back.
- Section divider header inside a page: `<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">`.
- Headline number: `font-display text-xl font-medium tabular-nums`.
- Display font (`font-display`, Fraunces) reserved for h1/h2/h3 and primary financial numbers.
- Headline number: `font-display text-xl tabular-nums`. No `font-medium` — Hedvig's natural weight carries the gravitas.
- Display font (`font-display`, Hedvig Letters Serif) reserved for h1/h2/h3 and primary financial numbers. If a specific `font-display` numeral reads weak inside a compact metric card, override that call site with `font-sans tabular-nums` (Geist) — better legibility on small numerals.
**Forbidden / dead patterns.**
- Page descriptions that paraphrase the page title (e.g. `<PageHeader title="Fakturor" description="Hantera dina fakturor">`) → drop the description.
@@ -438,3 +438,9 @@ Never render raw `{x.invoice_date}` directly — always route through `formatDat
- Mobile-specific `<select>` duplicating desktop tabs in code — use a single Tabs primitive or a single grouped `Select`.
- Hand-rolled icon buttons smaller than `h-10 w-10`. Use shadcn `Button size="icon"`.
- Color-coded status using full-rainbow Tailwind palette (`bg-amber-100`, `bg-emerald-500/10`, etc.). Use Badge variants tied to the brand palette.
- `shadow-sm` / `shadow-md` / `shadow-lg` on cards, buttons, or list items. The aesthetic is flat-with-hairlines — surfaces use `border-border`, not elevation. Shadows survive only on dialogs/popovers/dropdowns (anything that overlays the page).
- `active:scale-[...]` on buttons. Buttons do not bounce.
- `bg-gradient-to-*` on page or card backgrounds. Flat surfaces only.
- `font-medium` on display elements (`font-display`, h1/h2/h3, CardTitle, PageHeader title). Hedvig is single-weight by design.
- `rounded-xl` (12px) on cards. Cards are `rounded-lg` (8px). `rounded-xl` survives only on prominent hero-style surfaces if absolutely needed.
- Opacity-suffixed border classes (`border-border/30`, `border-border/60`) on cards and primary surfaces. Use full-opacity `border-border` — the new border token is calibrated for that.
+22 -37
View File
@@ -11,35 +11,35 @@
:root {
color-scheme: light;
/* Grayscale Chrome Palette */
/* Grayscale Chrome Palette — paper white + warm beige */
--background: 0 0% 97%;
--background: 0 0% 100%;
--foreground: 0 0% 9%;
--card: 0 0% 99%;
--card: 0 0% 100%;
--card-foreground: 0 0% 9%;
--popover: 0 0% 99%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 9%;
--primary: 210 5% 25%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 100%;
--secondary: 0 0% 94%;
--secondary: 40 11% 89%; /* Warm beige paper chip */
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 92%;
--muted: 40 8% 93%;
--muted-foreground: 0 0% 40%;
--accent: 0 0% 92%;
--accent: 40 8% 93%;
--accent-foreground: 0 0% 9%;
--destructive: 6 36% 48%; /* Muted terracotta */
--destructive-foreground: 0 0% 100%;
--border: 0 0% 90%;
--input: 0 0% 90%;
--ring: 210 5% 25%;
--border: 45 5% 85%;
--input: 45 5% 85%;
--ring: 0 0% 9%;
/* Radius */
--radius: 0.5rem;
@@ -61,13 +61,13 @@
--chart-4: 0 0% 50%;
/* Custom tokens */
--font-display: var(--font-fraunces), Georgia, serif;
--font-display: var(--font-hedvig-serif), Georgia, serif;
--font-body: var(--font-geist-sans), system-ui, sans-serif;
/* Shadows — cool-toned */
--shadow-sm: 0 1px 3px rgba(15, 23, 42, 0.06), 0 1px 2px rgba(15, 23, 42, 0.04);
--shadow-md: 0 4px 12px rgba(15, 23, 42, 0.06);
--shadow-lg: 0 12px 40px rgba(15, 23, 42, 0.08);
/* Shadows — barely-there; chrome relies on hairline borders */
--shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.03), 0 1px 1px rgba(15, 23, 42, 0.02);
--shadow-md: 0 4px 12px rgba(15, 23, 42, 0.03);
--shadow-lg: 0 12px 40px rgba(15, 23, 42, 0.05);
/* Animation */
--ease-out: cubic-bezier(0.25, 0.46, 0.45, 0.94);
@@ -83,13 +83,13 @@
--background: 0 0% 7%;
--foreground: 0 0% 87%;
--card: 0 0% 10%;
--card: 0 0% 11%;
--card-foreground: 0 0% 87%;
--popover: 0 0% 10%;
--popover: 0 0% 11%;
--popover-foreground: 0 0% 87%;
--primary: 210 4% 82%;
--primary: 0 0% 87%;
--primary-foreground: 0 0% 7%;
--secondary: 0 0% 14%;
@@ -104,9 +104,9 @@
--destructive: 6 32% 56%;
--destructive-foreground: 0 0% 100%;
--border: 0 0% 18%;
--input: 0 0% 18%;
--ring: 210 4% 82%;
--border: 45 4% 18%;
--input: 45 4% 18%;
--ring: 0 0% 87%;
--success: 155 22% 48%;
--success-foreground: 0 0% 100%;
@@ -278,17 +278,6 @@ h1, h2, h3 {
animation: scaleIn var(--duration-base) var(--ease-out);
}
/* Refined hover effects */
.hover-lift {
transition: transform var(--duration-fast) var(--ease-out),
box-shadow var(--duration-fast) var(--ease-out);
}
.hover-lift:hover {
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
/* Soft focus states */
.focus-ring:focus-visible {
outline: none;
@@ -357,8 +346,4 @@ input[type="number"] {
animation: none;
opacity: 1;
}
.hover-lift:hover {
transform: none;
}
}
+5 -4
View File
@@ -1,6 +1,6 @@
import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Fraunces } from "next/font/google";
import { Hedvig_Letters_Serif } from "next/font/google";
import Script from "next/script";
import { Toaster } from "@/components/ui/toaster";
import { ThemeProvider } from "@/components/theme-provider";
@@ -23,10 +23,11 @@ const geistMono = Geist_Mono({
subsets: ["latin"],
});
const fraunces = Fraunces({
variable: "--font-fraunces",
const hedvigSerif = Hedvig_Letters_Serif({
variable: "--font-hedvig-serif",
subsets: ["latin"],
display: "swap",
weight: "400",
});
export function generateMetadata(): Metadata {
@@ -59,7 +60,7 @@ export default function RootLayout({
}>) {
const branding = getBranding();
return (
<html lang="sv" translate="no" suppressHydrationWarning className={`${geistSans.variable} ${geistMono.variable} ${fraunces.variable}`}>
<html lang="sv" translate="no" suppressHydrationWarning className={`${geistSans.variable} ${geistMono.variable} ${hedvigSerif.variable}`}>
<head>
<meta name="google" content="notranslate" />
<link rel="apple-touch-icon" href={branding.appleTouchIconPath} />
+11 -11
View File
@@ -188,7 +188,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
<>
{/* Desktop sidebar */}
<aside className="hidden md:fixed md:inset-y-0 md:flex md:w-64 md:flex-col">
<div className="flex min-h-0 flex-1 flex-col border-r border-border/30 bg-card/90">
<div className="flex min-h-0 flex-1 flex-col border-r border-border bg-background">
<div className="flex flex-1 flex-col overflow-y-auto pt-7 pb-4">
{/* Company switcher */}
<div className="px-5 mb-8">
@@ -235,8 +235,8 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
? cn(
'transition-colors duration-150',
active
? 'bg-primary/12 text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40'
? 'bg-secondary text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/60'
)
: 'text-muted-foreground/40 cursor-not-allowed'
)
@@ -306,8 +306,8 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
? cn(
'transition-colors duration-150',
active
? 'bg-primary/12 text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40'
? 'bg-secondary text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/60'
)
: 'text-muted-foreground/40 cursor-not-allowed'
)
@@ -364,8 +364,8 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
? cn(
'transition-colors duration-150',
active
? 'bg-primary/12 text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40'
? 'bg-secondary text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/60'
)
: 'text-muted-foreground/40 cursor-not-allowed'
)
@@ -403,8 +403,8 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
? cn(
'transition-colors duration-150',
active
? 'bg-primary/12 text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40'
? 'bg-secondary text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/60'
)
: 'text-muted-foreground/40 cursor-not-allowed'
)
@@ -430,7 +430,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
</div>
{/* Support + Logout */}
<div className="flex-shrink-0 px-3 py-3 border-t border-border/30 space-y-1">
<div className="flex-shrink-0 px-3 py-3 border-t border-border space-y-1">
<div className="px-3 py-1.5">
<SupportLink variant="muted" />
</div>
@@ -762,7 +762,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
</div>
{/* Support + Logout */}
<div className="px-2 py-2 mt-1 border-t border-border/30 space-y-1">
<div className="px-2 py-2 mt-1 border-t border-border space-y-1">
<div className="px-3 py-2">
<SupportLink variant="muted" />
</div>
+5 -5
View File
@@ -4,16 +4,16 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap text-sm font-medium transition-all duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50",
"inline-flex items-center justify-center whitespace-nowrap text-sm font-medium transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-sm hover:bg-primary/80 active:scale-[0.98]",
"bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/80 active:scale-[0.98]",
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-transparent hover:bg-secondary hover:border-secondary-foreground/20",
"border border-input bg-transparent hover:bg-secondary",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/70",
ghost:
@@ -21,7 +21,7 @@ const buttonVariants = cva(
link:
"text-primary underline-offset-4 hover:underline",
success:
"bg-success text-success-foreground shadow-sm hover:bg-success/80 active:scale-[0.98]",
"bg-success text-success-foreground hover:bg-success/90",
},
size: {
default: "h-10 px-5 py-2 rounded-lg",
+2 -2
View File
@@ -8,7 +8,7 @@ const Card = React.forwardRef<
<div
ref={ref}
className={cn(
"rounded-xl border border-border/60 bg-card text-card-foreground shadow-[var(--shadow-sm)]",
"rounded-lg border border-border bg-card text-card-foreground",
className
)}
{...props}
@@ -35,7 +35,7 @@ const CardTitle = React.forwardRef<
<h3
ref={ref}
className={cn(
"font-display text-xl font-medium leading-none tracking-tight",
"font-display text-xl leading-none tracking-tight",
className
)}
{...props}
+2 -2
View File
@@ -20,7 +20,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"fixed inset-0 z-50 bg-black/50 dark:bg-black/60 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
@@ -37,7 +37,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 max-h-[calc(100dvh-2rem)] overflow-y-auto data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-[var(--shadow-md)] duration-200 max-h-[calc(100dvh-2rem)] overflow-y-auto data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
+1 -1
View File
@@ -10,7 +10,7 @@ export function PageHeader({ title, description, action }: PageHeaderProps) {
return (
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between mb-8">
<div>
<h1 className="font-display text-2xl font-medium tracking-tight">{title}</h1>
<h1 className="font-display text-3xl md:text-4xl tracking-tight">{title}</h1>
{description && (
<p className="text-muted-foreground mt-1 text-balance">{description}</p>
)}