diff --git a/.claude/rules/design.md b/.claude/rules/design.md index a5916c1a..e98b808f 100644 --- a/.claude/rules/design.md +++ b/.claude/rules/design.md @@ -18,7 +18,7 @@ Swedish sole traders (enskild firma) and small business owners (aktiebolag) who - **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. +- **Surfaces**: The page itself is a rounded panel (12px) floating on a warm-toned frame (`--frame`); the sidebar sits borderless on the frame. 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**: 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. @@ -35,6 +35,25 @@ Swedish sole traders (enskild firma) and small business owners (aktiebolag) who WCAG AA (4.5:1 text, 3:1 UI). Keyboard-navigable + visible focus rings. Respect `prefers-reduced-motion`. Color never sole state indicator. Touch targets ≥40px (44px for mobile-critical). Icon-only buttons need `aria-label`. +## Locked UI-migration conventions + +Decided during the 2026-07 concept work (dev_docs/ui_migration_plan.md); they apply system-wide and override anything below that conflicts. + +1. **Frame layout.** The page is a rounded panel (12px) on a warm-toned frame (`--frame`). The panel keeps `--background`; the sidebar is borderless on the frame. +2. **Page title is exactly 24px/32px** Hedvig Letters Serif (`text-2xl leading-8` via PageHeader). +3. **Buttons are pills.** Radius 99px, default padding 7px 16px, 13px text. Set once in `components/ui/button.tsx`, app-wide, never per page. +4. **Table rows are one line.** Secondary info (descriptions, OCR, roles) belongs in the detail view or a click-popup, never as sub-rows in lists. +5. **Chips mark exceptions.** Normal states render as muted text; Badge only when the row deviates. Same chip on every row means the chip is wrong. +6. **Attention is one ochre sentence**, not a banner: the `.attn` pattern (12.5px, `--warning` tone, single line, optionally with an embedded action link). Max one per page. +7. **Help text lives behind a "?"** right after the H1: a small (17px) circular button opening a popover anchored at the button. No instructional copy in the page flow. +8. **One context picker per page, far right in the toolbar**: fiscal year or account/source as a chip-dropdown with a check on the active choice. A chip that looks like a picker must be a picker. +9. **The primary action lives in the page header**, right side. Multiple create paths collapse into a split button whose caret menu remembers the last-used mode (persisted in `user_preferences`, not localStorage). +10. **Confirm up front, don't comment afterwards.** Actions that post or send open a small confirm dialog describing the outcome ("Bokförs som verifikat A-217 ...") instead of writing outcome text into the page afterwards. +11. **Content lands with stagger.** `.stagger-enter` is the standard entry for list/table content, on server render and on client-fetch completion alike. +12. **Status colors are data, not chrome**: sage/ochre/terracotta only in numbers, exception chips and `.attn`. +13. **Overlays**: centered modal for create/confirm (template, assistant, confirmations, settings); right slide-over for reviewing an object (Granskning detail). Both with veil, Esc, and click-outside. +14. **Manual base, AI as opt-in.** Base flows work without AI; AI entry points are clearly labeled discrete choices (e.g. "Skapa med assistenten") and no AI suggestion posts without Granskning. + ## Design System Tokens **Spacing scale.** Only use Tailwind values `1, 2, 3, 4, 6, 8, 10, 12`. **Forbidden:** `2.5`, `5`, hardcoded pixels in page logic. @@ -52,10 +71,13 @@ WCAG AA (4.5:1 text, 3:1 UI). Keyboard-navigable + visible focus rings. Respect Compact metric cards (e.g. dashboard tiles, salary KPI row) use `p-4`. Detail cards use `p-6`. Never mix `p-5`. -**Layout.** -- Sidebar width: `md:w-64` (256px). Main content offset: `md:pl-64`. +**Layout (frame layout).** +- The dashboard wrapper is `bg-frame` (`--frame: 40 18% 96%` light, `0 0% 5%` dark). `
` is the page panel: `bg-background rounded-xl border border-border`, 10px margin against the frame, own inner scroll (`md:h-[calc(100vh-20px)] md:overflow-y-auto`). Defined once as `MAIN_PANEL_CLASS` in `app/(dashboard)/layout.tsx`; never restyle per page. +- The panel is the desktop scroll container: `position: sticky` binds to it automatically; never assume `window` scroll on dashboard pages. Scroll reset on navigation lives in `MainContainer`. +- Sidebar width: `md:w-64` (256px), borderless and transparent on the frame. Panel offset: `md:ml-64`. - Main container: `max-w-5xl mx-auto px-5 py-8 md:px-8 md:py-10` (via `components/dashboard/MainContainer.tsx`). - Page root: `
`. +- Mobile keeps the pre-frame layout: full-width document flow, bottom nav; the panel styles are `md:`-gated. **Primitives: always use these, don't hand-roll.** @@ -63,7 +85,7 @@ Compact metric cards (e.g. dashboard tiles, salary KPI row) use `p-4`. Detail ca |---|---|---| | Page title + action | `components/ui/page-header.tsx` `PageHeader` | Use this, not bespoke `

` + `

` blocks. Drop the `description` prop when it just paraphrases the title. | | Data table | `components/ui/table.tsx` `Table / TableHeader / TableHead / TableRow / TableCell` | Header style is baked in: `text-[11px] font-medium uppercase tracking-wider text-muted-foreground`. Wrap in `` when the table is a card's primary content. Add `tabular-nums` to numeric cells. | -| Status indicator | `components/ui/badge.tsx` `` | Variants: `default / secondary / success / warning / destructive / outline`. **Never** use raw Tailwind colors (`bg-blue-100`, `bg-emerald-500/10`, etc.) for status. Map status → variant via a small `Record` per feature. | +| Status indicator | `components/ui/badge.tsx` `` | Chips mark exceptions only: normal states (Aktiv, Bokförd, Betald-i-tid) render as muted text (`text-muted-foreground text-xs`); Badge is reserved for rows that deviate (Utkast, Förfallen, Ej bokförd). A table where every row carries the same chip is wrong. Variants: `default / secondary / success / warning / destructive / outline`. **Never** use raw Tailwind colors (`bg-blue-100`, `bg-emerald-500/10`, etc.) for status. Map status → variant via a small `Record` per feature. | | No-data state | `components/ui/empty-state.tsx` `EmptyState` | Don't hand-roll `

`. Preset variants exist (`EmptyInvoices`, `EmptyCustomers`, `EmptyTransactions`, etc.). | | Loading placeholder | `components/ui/skeleton.tsx` `` | Don't hand-roll `bg-muted rounded animate-pulse` divs. | | Inline help / formulas | `components/ui/info-tooltip.tsx` `InfoTooltip` | Hover-revealed; don't use always-visible info buttons. | @@ -84,7 +106,7 @@ Never render raw `{x.invoice_date}` directly: always route through `formatDate() **Currency.** `formatCurrency(n, currency?)` from `lib/utils.ts`. Default SEK. **Typography.** -- Page title: use `PageHeader` (renders `font-display text-3xl md:text-4xl tracking-tight`). Do not hand-roll an `

`. +- Page title: use `PageHeader` (renders `font-display text-2xl leading-8 tracking-tight`, exactly 24px/32px, locked). Do not hand-roll an `

`. - Card title: `` for sections, default for primary cards. The primitive already drops `font-medium`: do not add it back. - Section divider header inside a page: `

`. - Headline number: `font-display text-xl tabular-nums`. No `font-medium`: Hedvig's natural weight carries the gravitas. @@ -100,5 +122,6 @@ Never render raw `{x.invoice_date}` directly: always route through `formatDate() - `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. +- `rounded-xl` (12px) on cards. Cards are `rounded-lg` (8px). `rounded-xl` survives on the page panel (frame layout) and prominent hero-style surfaces only. +- Overriding the Button pill radius per call site (`rounded-lg`, `rounded-md` on a `

-

{article.name}

+

{article.name}

{article.active ? t('status_active') : t('status_inactive')} diff --git a/app/(dashboard)/bookkeeping/[id]/page.tsx b/app/(dashboard)/bookkeeping/[id]/page.tsx index affdff14..54fb589c 100644 --- a/app/(dashboard)/bookkeeping/[id]/page.tsx +++ b/app/(dashboard)/bookkeeping/[id]/page.tsx @@ -339,7 +339,7 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
-

+

{formatVoucher(entry)}

diff --git a/app/(dashboard)/bookkeeping/year-end/page.tsx b/app/(dashboard)/bookkeeping/year-end/page.tsx index f2005445..5df96122 100644 --- a/app/(dashboard)/bookkeeping/year-end/page.tsx +++ b/app/(dashboard)/bookkeeping/year-end/page.tsx @@ -225,7 +225,7 @@ export default function YearEndPage() { return (
-

Årsbokslut

+

Årsbokslut

-

+

{closingYear ? `Periodisering: Bokslut ${closingYear}` : 'Periodisering'}

diff --git a/app/(dashboard)/e/[sector]/[slug]/loading.tsx b/app/(dashboard)/e/[sector]/[slug]/loading.tsx index 60115268..929bde24 100644 --- a/app/(dashboard)/e/[sector]/[slug]/loading.tsx +++ b/app/(dashboard)/e/[sector]/[slug]/loading.tsx @@ -104,7 +104,7 @@ function TicSkeleton() { function FullScreenWorkspaceSkeleton() { return ( -
+
diff --git a/app/(dashboard)/invoices/[id]/credit/page.tsx b/app/(dashboard)/invoices/[id]/credit/page.tsx index 50424d9d..3c93a2a4 100644 --- a/app/(dashboard)/invoices/[id]/credit/page.tsx +++ b/app/(dashboard)/invoices/[id]/credit/page.tsx @@ -188,7 +188,7 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id:
-

{t('title')}

+

{t('title')}

{t('subtitle', { number: invoice.invoice_number ?? '' })}

diff --git a/app/(dashboard)/invoices/[id]/page.tsx b/app/(dashboard)/invoices/[id]/page.tsx index d10b4b33..9ee2fed6 100644 --- a/app/(dashboard)/invoices/[id]/page.tsx +++ b/app/(dashboard)/invoices/[id]/page.tsx @@ -655,7 +655,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
-

{isSelfBilled ? invoiceDisplayNumber(invoice as Invoice) : (invoice.invoice_number ?? '-')}

+

{isSelfBilled ? invoiceDisplayNumber(invoice as Invoice) : (invoice.invoice_number ?? '-')}

{isProforma && ( {t('badge_proforma')} )} diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx index 98817a67..b25e535b 100644 --- a/app/(dashboard)/layout.tsx +++ b/app/(dashboard)/layout.tsx @@ -28,6 +28,19 @@ import { */ const NO_COMPANY_ALLOWED_PATHS = ['/settings/account'] +/** + * Frame layout: on desktop the page is a rounded panel floating on the + * warm-toned frame (bg-frame on the wrapper div), with its own inner + * scroll. 10px margin against the frame; height is the remaining + * viewport. The sidebar (fixed, w-64) sits borderless on the frame, so + * the panel starts at ml-64. On mobile the panel dissolves: full-width + * document flow with the bottom nav, exactly as before. + */ +const MAIN_PANEL_CLASS = + 'safe-area-main-padding md:!pb-0 relative bg-background min-h-screen ' + + 'md:min-h-0 md:ml-64 md:mt-[10px] md:mr-[10px] md:h-[calc(100vh-20px)] ' + + 'md:overflow-y-auto md:rounded-xl md:border md:border-border' + export default async function DashboardLayout({ children, settingsModal, @@ -95,7 +108,7 @@ export default async function DashboardLayout({ > -
+
@@ -186,14 +199,14 @@ export default async function DashboardLayout({ -
+
-
+
{children}
@@ -265,7 +278,7 @@ export default async function DashboardLayout({ }} > -
+
{/* Skip to content link for keyboard/screen reader users */} -
+
{children}
diff --git a/app/(dashboard)/salary/employees/[id]/page.tsx b/app/(dashboard)/salary/employees/[id]/page.tsx index 2386ec77..585a68aa 100644 --- a/app/(dashboard)/salary/employees/[id]/page.tsx +++ b/app/(dashboard)/salary/employees/[id]/page.tsx @@ -237,7 +237,7 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
-

+

{employee.first_name} {employee.last_name}

diff --git a/app/(dashboard)/supplier-invoices/[id]/page.tsx b/app/(dashboard)/supplier-invoices/[id]/page.tsx index 64036be9..86e68332 100644 --- a/app/(dashboard)/supplier-invoices/[id]/page.tsx +++ b/app/(dashboard)/supplier-invoices/[id]/page.tsx @@ -456,7 +456,7 @@ export default function SupplierInvoiceDetailPage() {

-

+

{t('arrival_header', { number: invoice.arrival_number })}

diff --git a/app/(dashboard)/suppliers/[id]/page.tsx b/app/(dashboard)/suppliers/[id]/page.tsx index 5dd1b774..1bfa4496 100644 --- a/app/(dashboard)/suppliers/[id]/page.tsx +++ b/app/(dashboard)/suppliers/[id]/page.tsx @@ -154,7 +154,7 @@ export default function SupplierDetailPage() {
-

{supplier.name}

+

{supplier.name}

{supplierTypeLabels[supplier.supplier_type]} {supplier.org_number && t('org_number_inline', { number: supplier.org_number })} diff --git a/app/globals.css b/app/globals.css index 126fc4a6..b2cedc90 100644 --- a/app/globals.css +++ b/app/globals.css @@ -16,6 +16,9 @@ --background: 0 0% 100%; --foreground: 0 0% 9%; + /* Warm-toned frame behind the floating page panel (frame layout) */ + --frame: 40 18% 96%; + --card: 0 0% 100%; --card-foreground: 0 0% 9%; @@ -83,6 +86,8 @@ --background: 0 0% 7%; --foreground: 0 0% 87%; + --frame: 0 0% 5%; + --card: 0 0% 11%; --card-foreground: 0 0% 87%; @@ -129,6 +134,7 @@ @theme inline { --color-background: hsl(var(--background)); --color-foreground: hsl(var(--foreground)); + --color-frame: hsl(var(--frame)); --color-card: hsl(var(--card)); --color-card-foreground: hsl(var(--card-foreground)); --color-popover: hsl(var(--popover)); diff --git a/components/dashboard/ConsultantEmptyState.tsx b/components/dashboard/ConsultantEmptyState.tsx index 39b006b9..caeeb619 100644 --- a/components/dashboard/ConsultantEmptyState.tsx +++ b/components/dashboard/ConsultantEmptyState.tsx @@ -14,7 +14,7 @@ export default function ConsultantEmptyState({ firstName }: ConsultantEmptyState return (

-

+

{greeting}{firstName ? `, ${firstName}` : ''}

diff --git a/components/dashboard/DashboardNav.tsx b/components/dashboard/DashboardNav.tsx index dfc93475..f35b635d 100644 --- a/components/dashboard/DashboardNav.tsx +++ b/components/dashboard/DashboardNav.tsx @@ -510,7 +510,8 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa <> {/* Desktop sidebar */}