refactor(design): lock the border-radius ladder, one radius per role (#1607)

Seven radii were in circulation (4/5/6/8/12/16px + pill) with no rule for
which went where; one toolbar row on /transactions mixed four shape
languages. This locks a 4-tier ladder (design.md convention 16):

- pill: interactive toolbar controls (buttons, chips, pickers, segmented
  controls, toolbar search, count nubs)
- rounded-xl (12px): overlay tier: page panel, dialogs, slide-overs
- rounded-lg (8px): cards, form fields, popover/menu content, boxes
- rounded-sm (4px): nested leaves (menu items, checkboxes, kbd/code nubs)

Changes:
- New SegmentedControl primitive (pill-in-pill tablist, h-8) replaces the
  hand-rolled bg-muted/70 tablist copied across 11 files
- New ToolbarSearch primitive (pill, h-8) adopted on 9 page toolbars;
  dialog/picker searches keep the rounded-lg Input
- dialog.tsx 8px -> 12px, matching SettingsModal/slide-over/CommandPalette
- ContextPicker chips at the shared h-8 toolbar height
- ~300 rounded-md / bare rounded call sites remapped by role; auth icon
  tiles and the mobile nav sheet come down from 16px to 12px
- rounded-md, bare rounded, rounded-2xl and rounded-[Npx] are dead
  vocabulary, enforced by a new off-ladder-radius check in check:guards

Verified: lint 0 errors, 14422 unit tests pass, check:guards green, tsc
clean on all changed files, sandbox screenshots of transactions/
bookkeeping/granskning toolbars and the Ny verifikation dialog.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-14 08:55:37 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 18c20e68e6
commit 9686b54b41
130 changed files with 633 additions and 652 deletions
+3 -3
View File
@@ -301,7 +301,7 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o
<div
ref={listRef}
className={cn(
'absolute z-50 top-full left-0 mt-1 max-h-[300px] overflow-y-auto rounded-md border border-input bg-card shadow-md',
'absolute z-50 top-full left-0 mt-1 max-h-[300px] overflow-y-auto rounded-lg border border-input bg-card shadow-md',
listWidthClass,
)}
>
@@ -348,7 +348,7 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o
{isOpen && !disabled && search.trim() && flatList.length === 0 && (
<div
className={cn(
'absolute z-50 top-full left-0 mt-1 rounded-md border border-input bg-card shadow-md p-3',
'absolute z-50 top-full left-0 mt-1 rounded-lg border border-input bg-card shadow-md p-3',
listWidthClass,
)}
>
@@ -367,7 +367,7 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o
{onCreateAccount && (
<button
type="button"
className="mt-2 flex w-full items-center gap-2 rounded-md border border-input bg-card px-2 py-1.5 text-left text-sm hover:bg-muted/50"
className="mt-2 flex w-full items-center gap-2 rounded-sm border border-input bg-card px-2 py-1.5 text-left text-sm hover:bg-muted/50"
onMouseDown={(e) => {
e.preventDefault()
setIsOpen(false)
@@ -108,7 +108,7 @@ export default function AccrualPeriodControl({
const showK2Hint = shouldShowK2AccrualHint({ amount, currency, exchangeRate })
return (
<div className="rounded-md border bg-muted/30 p-3 space-y-3">
<div className="rounded-lg border bg-muted/30 p-3 space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{t('panel_title')}
@@ -114,7 +114,7 @@ export function ActivateAccountsDialog({
)}
{!loading && knownRows.length > 0 && (
<ul className="divide-y divide-border rounded-md border">
<ul className="divide-y divide-border rounded-lg border">
{knownRows.map((r) => (
<li key={r.account_number} className="flex items-baseline gap-3 px-3 py-2">
<span className="font-mono text-foreground w-14 shrink-0">{r.account_number}</span>
@@ -130,7 +130,7 @@ export function ActivateAccountsDialog({
)}
{!loading && unknownRows.length > 0 && (
<div className="rounded-md border border-border bg-muted/30 px-3 py-2 text-xs text-attn">
<div className="rounded-lg border border-border bg-muted/30 px-3 py-2 text-xs text-attn">
<p className="font-medium">Finns inte i BAS-katalogen:</p>
<p className="mt-1 font-mono">{unknownRows.map((r) => r.account_number).join(', ')}</p>
<p className="mt-1 text-attn/80">
@@ -71,7 +71,7 @@ export function AttachDocumentPreview({ data, params }: AttachDocumentPreviewPro
</div>
{willOverwrite && existingIsAccounting && (
<div className="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive">
<div className="flex items-start gap-2 rounded-lg border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<div>
<p className="font-medium">Ersätter räkenskapsinformation</p>
@@ -85,7 +85,7 @@ export function AttachDocumentPreview({ data, params }: AttachDocumentPreviewPro
</div>
)}
{willOverwrite && !existingIsAccounting && (
<div className="rounded-md border border-border bg-muted/30 p-2 text-xs text-muted-foreground">
<div className="rounded-lg border border-border bg-muted/30 p-2 text-xs text-muted-foreground">
Ersätter befintligt dokument{existingDocName ? `: ${existingDocName}` : ''}.
</div>
)}
@@ -4,7 +4,8 @@ import { Fragment, useState, useEffect, useCallback, useMemo, useRef } from 'rea
import { useTranslations } from 'next-intl'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { SegmentedControl } from '@/components/ui/segmented-control'
import { ToolbarSearch } from '@/components/ui/toolbar-search'
import { Switch } from '@/components/ui/switch'
import { Skeleton } from '@/components/ui/skeleton'
import { TH_CLASS, TD_CLASS, QUIET_LINK_CLASS } from '@/components/ui/dry-table'
@@ -18,7 +19,6 @@ import { AddAccountDialog } from './AddAccountDialog'
import { EditAccountDialog } from './EditAccountDialog'
import { PruneAccountsDialog } from './PruneAccountsDialog'
import {
Search,
ChevronRight,
Plus,
Pencil,
@@ -466,46 +466,20 @@ export default function ChartOfAccountsManager() {
{/* Toolbar: seg + search; the K2 filter rides far right in catalog view */}
<div className="flex flex-wrap items-center gap-2">
<div className="inline-flex shrink-0 gap-0.5 rounded-lg bg-muted/70 p-[3px]" role="tablist">
<button
type="button"
role="tab"
aria-selected={view === 'my-accounts'}
onClick={() => switchView('my-accounts')}
className={cn(
'inline-flex items-center gap-1.5 rounded-md px-3.5 py-[5px] text-[12.5px] transition-colors duration-150',
view === 'my-accounts'
? 'border border-border bg-card font-medium text-foreground'
: 'text-muted-foreground hover:text-foreground',
)}
>
{t('tab_my_accounts')}
<span className="font-normal text-muted-foreground tabular-nums">{accounts.length}</span>
</button>
<button
type="button"
role="tab"
aria-selected={view === 'bas-catalog'}
onClick={() => switchView('bas-catalog')}
className={cn(
'rounded-md px-3.5 py-[5px] text-[12.5px] transition-colors duration-150',
view === 'bas-catalog'
? 'border border-border bg-card font-medium text-foreground'
: 'text-muted-foreground hover:text-foreground',
)}
>
{t('tab_bas_catalog')}
</button>
</div>
<div className="relative min-w-[190px] max-w-xs flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t('search_placeholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-9 pl-10"
/>
</div>
<SegmentedControl
value={view}
onChange={switchView}
options={[
{ value: 'my-accounts', label: t('tab_my_accounts'), count: accounts.length },
{ value: 'bas-catalog', label: t('tab_bas_catalog') },
]}
/>
<ToolbarSearch
placeholder={t('search_placeholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
containerClassName="min-w-[190px] max-w-xs flex-1"
/>
{view === 'my-accounts' && (
<label className="ml-auto flex items-center gap-2 text-sm">
<Switch
+1 -1
View File
@@ -72,7 +72,7 @@ export default function CorrectionChain({ currentEntryId, chain }: Props) {
href={`/bookkeeping/${entry.id}`}
className="block"
>
<div className={`relative pl-7 py-2 rounded-md transition-colors hover:bg-muted/50 ${isCurrent ? 'bg-muted/30' : ''} ${isCancelled ? 'opacity-60' : ''}`}>
<div className={`relative pl-7 py-2 rounded-sm transition-colors hover:bg-muted/50 ${isCurrent ? 'bg-muted/30' : ''} ${isCancelled ? 'opacity-60' : ''}`}>
{/* Timeline dot */}
<div className={`absolute left-0.5 top-[18px] h-3 w-3 rounded-full border-2 border-background ${isCancelled ? 'bg-muted-foreground' : role.color}`} />
@@ -288,7 +288,7 @@ export default function DocumentUploadZone({
{files.map((file, index) => (
<div
key={file.uploadKey}
className="flex min-w-0 items-center gap-2 text-sm py-1.5 px-2 rounded bg-muted/50"
className="flex min-w-0 items-center gap-2 text-sm py-1.5 px-2 rounded-sm bg-muted/50"
>
{isImageType(file.file.type) ? (
<ImageIcon className="h-4 w-4 text-muted-foreground shrink-0" />
@@ -140,7 +140,7 @@ export default function DocumentViewerPane({
<div className="min-h-0 flex-1 overflow-auto bg-background">
{loadingMeta ? (
<div className="p-3">
<Skeleton className="h-full min-h-[40vh] w-full rounded-md" />
<Skeleton className="h-full min-h-[40vh] w-full" />
</div>
) : showAsPdf ? (
<object
+1 -1
View File
@@ -538,7 +538,7 @@ export function EditAccountDialog({ open, onOpenChange, account, onSaved }: Edit
</div>
{account.is_system_account && (
<p className="text-xs text-muted-foreground bg-muted rounded p-2">
<p className="text-xs text-muted-foreground bg-muted rounded-sm p-2">
Detta är ett systemkonto och kan inte tas bort.
</p>
)}
@@ -216,7 +216,7 @@ export default function InboxDocumentPicker({ open, onClose, journalEntryId, onL
onClick={() => void handlePick(it)}
disabled={!!linkingId}
className={cn(
'flex-1 min-w-0 text-left flex items-center gap-3 py-3 px-2 -ml-2 rounded transition-colors hover:bg-secondary/60',
'flex-1 min-w-0 text-left flex items-center gap-3 py-3 px-2 -ml-2 rounded-sm transition-colors hover:bg-secondary/60',
linkingId && !isLinking && 'opacity-50',
)}
>
@@ -307,7 +307,7 @@ export default function JournalEntryAttachments({
const isReplacing = replacingDocId === doc.id
return (
<div key={doc.id}>
<div className="flex items-center gap-2 text-sm py-1.5 px-2 rounded bg-muted/50">
<div className="flex items-center gap-2 text-sm py-1.5 px-2 rounded-sm bg-muted/50">
{isPreviewable(doc.mime_type) ? (
<button
onClick={() => handlePreviewToggle(doc)}
+1 -1
View File
@@ -1792,7 +1792,7 @@ export default function JournalEntryForm({
</Button>
{dimPopoverRow === index && (
<div
className="absolute right-0 top-full z-50 mt-1 w-64 rounded-md border bg-card p-3 shadow-md"
className="absolute right-0 top-full z-50 mt-1 w-64 rounded-lg border bg-card p-3 shadow-md"
onKeyDown={(e) => {
// The comboboxes preventDefault their own Escape
// (closing their dropdown): only an unhandled
+15 -40
View File
@@ -11,6 +11,8 @@ import {
} from '@/components/ui/data-list'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { SegmentedControl } from '@/components/ui/segmented-control'
import { ToolbarSearch } from '@/components/ui/toolbar-search'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
@@ -990,51 +992,24 @@ export default function JournalEntryList({ pristineSlot }: { pristineSlot?: Reac
<div className="flex flex-wrap items-center gap-2">
{/* Verifikat vs Utkast. Drafts live in their own view with a count badge so
they don't sink to the last page of the committed list. */}
<div className="inline-flex shrink-0 gap-0.5 rounded-lg bg-muted/70 p-[3px]" role="tablist">
<button
type="button"
role="tab"
aria-selected={listMode === 'committed'}
onClick={() => switchMode('committed')}
className={cn(
'rounded-md px-3.5 py-[5px] text-[12.5px] transition-colors duration-150',
listMode === 'committed'
? 'border border-border bg-card font-medium text-foreground'
: 'text-muted-foreground hover:text-foreground',
)}
>
{t('mode_vouchers')}
</button>
<button
type="button"
role="tab"
aria-selected={listMode === 'drafts'}
onClick={() => switchMode('drafts')}
className={cn(
'inline-flex items-center gap-1.5 rounded-md px-3.5 py-[5px] text-[12.5px] transition-colors duration-150',
listMode === 'drafts'
? 'border border-border bg-card font-medium text-foreground'
: 'text-muted-foreground hover:text-foreground',
)}
>
{t('mode_drafts')}
{draftCount > 0 && (
<span className="rounded-full bg-secondary px-1.5 text-[10px] font-medium tabular-nums">
{draftCount}
</span>
)}
</button>
</div>
<SegmentedControl
value={listMode}
onChange={switchMode}
options={[
{ value: 'committed', label: t('mode_vouchers') },
{ value: 'drafts', label: t('mode_drafts'), count: draftCount },
]}
/>
<div className="relative flex-1 sm:flex-none sm:w-[280px]">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
<Input
<ToolbarSearch
type="text"
inputMode="search"
placeholder={t('search_placeholder')}
aria-label={t('search_placeholder')}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="h-8 pl-8 pr-7 text-xs"
containerClassName="min-w-0 max-w-none"
className="pr-7"
/>
{searchInput && (
<button
@@ -1506,7 +1481,7 @@ export default function JournalEntryList({ pristineSlot }: { pristineSlot?: Reac
e.stopPropagation()
setPreviewEntryId(entry.id)
}}
className="inline-flex items-center gap-0.5 rounded text-muted-foreground transition-colors duration-150 hover:text-foreground"
className="inline-flex items-center gap-0.5 rounded-sm text-muted-foreground transition-colors duration-150 hover:text-foreground"
>
<Paperclip className="h-3.5 w-3.5" />
<span className="text-xs tabular-nums">{attachmentCounts[entry.id]}</span>
@@ -1553,7 +1528,7 @@ export default function JournalEntryList({ pristineSlot }: { pristineSlot?: Reac
// p-2 grows the tap target to 30px without
// changing row height (the row is ~40px from
// the description cell).
'inline-flex items-center rounded p-2 text-muted-foreground transition-opacity duration-150 hover:text-foreground',
'inline-flex items-center rounded-sm p-2 text-muted-foreground transition-opacity duration-150 hover:text-foreground',
// Quiet at rest on desktop, but the table has no
// mobile card to fall back on, so touch keeps the
// icon visible.
@@ -202,7 +202,7 @@ export default function TemplateBookDialog({ open, onOpenChange, onCreated }: Pr
key={tpl.id}
type="button"
onClick={() => setSelected(tpl)}
className="flex w-full items-center gap-2.5 rounded-md px-2.5 py-2.5 text-left transition-colors hover:bg-secondary/60"
className="flex w-full items-center gap-2.5 rounded-sm px-2.5 py-2.5 text-left transition-colors hover:bg-secondary/60"
>
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] text-foreground">
@@ -313,7 +313,7 @@ export function CreateAssetDialog({ open, onOpenChange, onCreated }: CreateAsset
</p>
</div>
{isK3 && (
<div className="space-y-3 rounded-md border border-border bg-muted/20 p-4">
<div className="space-y-3 rounded-lg border border-border bg-muted/20 p-4">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<Label className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
@@ -340,7 +340,7 @@ export function CreateAssetDialog({ open, onOpenChange, onCreated }: CreateAsset
{componentRows.map((row, idx) => (
<div
key={row.id}
className="grid grid-cols-12 items-end gap-2 rounded-md border border-border bg-background p-2"
className="grid grid-cols-12 items-end gap-2 rounded-lg border border-border bg-background p-2"
>
<div className="col-span-12 sm:col-span-4 space-y-1">
<Label
@@ -464,14 +464,14 @@ export function CreateAssetDialog({ open, onOpenChange, onCreated }: CreateAsset
)}
</div>
)}
<div className="rounded-md border border-border bg-muted/30 p-3 text-xs text-muted-foreground">
<div className="rounded-lg border border-border bg-muted/30 p-3 text-xs text-muted-foreground">
<strong className="text-foreground">Tips:</strong> Anskaffningen måste redan vara
bokförd (debet på 1xxx-kontot mot t.ex. 1930/2440): registret bokför inte
själva köpet. Det här registret styr enbart de planenliga avskrivningarna under
bokslutet.
</div>
{error && (
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
{error}
</div>
)}
@@ -191,7 +191,7 @@ export function EditAssetDialog({ asset, open, onOpenChange, onSaved }: EditAsse
</div>
{basisLocked && (
<div className="flex items-start gap-2 rounded-md border border-border bg-muted/30 p-3 text-xs text-muted-foreground">
<div className="flex items-start gap-2 rounded-lg border border-border bg-muted/30 p-3 text-xs text-muted-foreground">
<Lock className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>
Anskaffningsdatum, anskaffningsvärde och kategori är låsta eftersom avskrivningar
@@ -216,7 +216,7 @@ export function EditAssetDialog({ asset, open, onOpenChange, onSaved }: EditAsse
</div>
{error && (
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
{error}
</div>
)}
@@ -302,7 +302,7 @@ function ManualEntryEditor({
onRemove: () => void
}) {
return (
<div className="rounded-md border border-border p-3 space-y-3">
<div className="rounded-lg border border-border p-3 space-y-3">
<div className="flex items-center justify-between">
<p className="text-sm font-medium">
{entry.kind === 'audit_fee' && 'Revisions-/bokslutsarvode'}
@@ -329,7 +329,7 @@ function ManualEntryEditor({
<div className="space-y-1">
<Label className="text-xs">Konto</Label>
<select
className="border border-border rounded-md h-8 text-sm px-2 w-full bg-background"
className="border border-border rounded-lg h-8 text-sm px-2 w-full bg-background"
value={entry.liabilityAccount}
onChange={(e) =>
onChange({ liabilityAccount: e.target.value as '2991' | '2992' })
@@ -584,7 +584,7 @@ function TaxDepreciationCard({
</div>
{dirty && !preview && (
<p className="rounded-md border border-border bg-muted/20 p-3 text-sm text-muted-foreground">
<p className="rounded-lg border border-border bg-muted/20 p-3 text-sm text-muted-foreground">
Inställningarna är ändrade. Beräkna förslaget för att se rätt gränsbelopp innan du
sparar.
</p>
@@ -592,7 +592,7 @@ function TaxDepreciationCard({
{result && (
<>
<div className="grid grid-cols-2 gap-x-6 gap-y-3 rounded-md border border-border bg-muted/20 p-4 text-sm sm:grid-cols-3">
<div className="grid grid-cols-2 gap-x-6 gap-y-3 rounded-lg border border-border bg-muted/20 p-4 text-sm sm:grid-cols-3">
<TaxValue label="Ingående värde" value={result.openingTaxValue} />
<TaxValue label="Årets anskaffningar" value={result.additions} />
<TaxValue label="Årets avyttringar" value={-result.disposals} />
@@ -639,7 +639,7 @@ function TaxDepreciationCard({
</div>
)}
{dirty && method === 'rakenskapsenlig' && (
<div className="flex items-start gap-2 rounded-md border border-border p-3">
<div className="flex items-start gap-2 rounded-lg border border-border p-3">
<Checkbox
id="tax-book-conformity"
checked={bookConformityConfirmed}
@@ -213,7 +213,7 @@ export function EfDeclarationSection({
<div className="space-y-1">
<Label className="text-xs">Egenavgifter: kategori</Label>
<select
className="border border-border rounded-md h-9 text-sm px-2 w-full bg-background"
className="border border-border rounded-lg h-9 text-sm px-2 w-full bg-background"
value={overrides.category}
onChange={(e) => update('category', e.target.value as EgenavgiftCategory)}
>