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
+2 -2
View File
@@ -371,7 +371,7 @@ export function LoginClient({ initialMethod }: { initialMethod: LoginMethod | nu
<div className="min-h-dvh flex flex-col items-center justify-center bg-frame p-4">
<div className="w-full max-w-sm animate-slide-up space-y-8">
<div className="flex justify-center">
<div className="h-14 w-14 rounded-2xl bg-primary/8 flex items-center justify-center">
<div className="h-14 w-14 rounded-xl bg-primary/8 flex items-center justify-center">
<Mail className="h-7 w-7 text-primary" />
</div>
</div>
@@ -432,7 +432,7 @@ export function LoginClient({ initialMethod }: { initialMethod: LoginMethod | nu
<div className="w-full max-w-sm animate-slide-up">
<div className="text-center mb-10">
<div className="flex justify-center mb-4">
<div className="h-14 w-14 rounded-2xl bg-primary/8 flex items-center justify-center">
<div className="h-14 w-14 rounded-xl bg-primary/8 flex items-center justify-center">
<KeyRound className="h-7 w-7 text-primary" />
</div>
</div>
+3 -3
View File
@@ -179,7 +179,7 @@ function MfaEnrollContent() {
<div className="w-full max-w-sm animate-slide-up">
<div className="text-center mb-10">
<div className="flex justify-center mb-4">
<div className="h-14 w-14 rounded-2xl bg-primary/8 flex items-center justify-center">
<div className="h-14 w-14 rounded-xl bg-primary/8 flex items-center justify-center">
<ShieldCheck className="h-7 w-7 text-primary" />
</div>
</div>
@@ -233,7 +233,7 @@ function MfaEnrollContent() {
<div className="w-full max-w-sm animate-slide-up">
<div className="text-center mb-8">
<div className="flex justify-center mb-4">
<div className="h-14 w-14 rounded-2xl bg-primary/8 flex items-center justify-center">
<div className="h-14 w-14 rounded-xl bg-primary/8 flex items-center justify-center">
<ShieldCheck className="h-7 w-7 text-primary" />
</div>
</div>
@@ -258,7 +258,7 @@ function MfaEnrollContent() {
Kan du inte skanna? Ange denna nyckel manuellt:
</p>
<div className="flex items-center gap-2">
<code className="flex-1 rounded-md border bg-muted/50 px-3 py-2 text-xs font-mono text-center break-all select-all">
<code className="flex-1 rounded-sm border bg-muted/50 px-3 py-2 text-xs font-mono text-center break-all select-all">
{secret}
</code>
<Button
+1 -1
View File
@@ -174,7 +174,7 @@ function MfaVerifyContent() {
<div className="w-full max-w-sm animate-slide-up">
<div className="text-center mb-10">
<div className="flex justify-center mb-4">
<div className="h-14 w-14 rounded-2xl bg-primary/8 flex items-center justify-center">
<div className="h-14 w-14 rounded-xl bg-primary/8 flex items-center justify-center">
<ShieldCheck className="h-7 w-7 text-primary" />
</div>
</div>
+2 -2
View File
@@ -387,7 +387,7 @@ function RegisterPageContent() {
<div className="min-h-dvh flex flex-col items-center justify-center bg-frame p-4">
<div className="w-full max-w-sm animate-slide-up space-y-8">
<div className="flex justify-center">
<div className="h-14 w-14 rounded-2xl bg-primary/8 flex items-center justify-center">
<div className="h-14 w-14 rounded-xl bg-primary/8 flex items-center justify-center">
<Mail className="h-7 w-7 text-primary" />
</div>
</div>
@@ -440,7 +440,7 @@ function RegisterPageContent() {
<div className="min-h-dvh flex flex-col items-center justify-center bg-frame p-4">
<div className="w-full max-w-sm animate-slide-up space-y-8">
<div className="flex justify-center">
<div className="h-14 w-14 rounded-2xl bg-primary/8 flex items-center justify-center">
<div className="h-14 w-14 rounded-xl bg-primary/8 flex items-center justify-center">
<Mail className="h-7 w-7 text-primary" />
</div>
</div>
+1 -1
View File
@@ -212,7 +212,7 @@ function ResetPasswordInner() {
<div className="w-full max-w-sm animate-slide-up">
<div className="text-center mb-10">
<div className="flex justify-center mb-4">
<div className="h-14 w-14 rounded-2xl bg-primary/8 flex items-center justify-center">
<div className="h-14 w-14 rounded-xl bg-primary/8 flex items-center justify-center">
<KeyRound className="h-7 w-7 text-primary" />
</div>
</div>
+11 -14
View File
@@ -7,13 +7,13 @@ import { useSearchParams, useRouter, usePathname } from 'next/navigation'
import { createClient } from '@/lib/supabase/client'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { ToolbarSearch } from '@/components/ui/toolbar-search'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { Skeleton } from '@/components/ui/skeleton'
import { TH_CLASS, TD_CLASS } from '@/components/ui/dry-table'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
import { Plus, Search, Package, Lock, ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react'
import { Plus, Package, Lock, ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react'
import { ActivateAccountsDialog } from '@/components/bookkeeping/ActivateAccountsDialog'
import {
useSubmitWithAccountActivation,
@@ -315,18 +315,15 @@ function ArticlesPageInner() {
{/* Toolbar: search, plus the currency scope far right (convention 8) */}
<div className="flex flex-wrap items-center gap-2">
<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={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value)
setVisibleCount(INITIAL_VISIBLE_ROWS)
}}
className="h-9 pl-10"
/>
</div>
<ToolbarSearch
containerClassName="min-w-[190px]"
placeholder={t('search_placeholder')}
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value)
setVisibleCount(INITIAL_VISIBLE_ROWS)
}}
/>
{/* A single-currency register needs no scope: the chip would be a
control with one meaningful position. */}
{availableCurrencies.length > 1 && (
+4 -4
View File
@@ -325,7 +325,7 @@ export default function DisposeAssetPage({ params }: { params: Promise<{ id: str
<SelectContent>{VAT_TREATMENTS.map((value) => <SelectItem key={value} value={value}>{t(`vat_${value}`)}</SelectItem>)}</SelectContent>
</Select>
</Field>
<div className="rounded-md bg-secondary/40 p-3 text-xs">
<div className="rounded-lg bg-secondary/40 p-3 text-xs">
<SummaryRow label={t('gross')} value={formatCurrency(proceedsNumber)} />
<SummaryRow label={t('vat')} value={formatCurrency(vatAmount)} />
<SummaryRow label={t('net')} value={formatCurrency(netProceeds)} strong />
@@ -351,20 +351,20 @@ export default function DisposeAssetPage({ params }: { params: Promise<{ id: str
</div>
)}
{jamkningAssessment && (
<div className="rounded-md border border-border bg-secondary/40 p-3 text-sm">
<div className="rounded-lg border border-border bg-secondary/40 p-3 text-sm">
<SummaryRow label={t('adjustment_direction')} value={t(`direction_${jamkningAssessment.direction}`)} />
<SummaryRow label={t('adjustment_amount')} value={formatCurrency(jamkningAssessment.amount)} strong />
{jamkningAssessment.capped && <p className="mt-2 text-xs text-muted-foreground">{t('adjustment_capped')}</p>}
</div>
)}
{disposalType === 'business_transfer' && (
<div className="flex items-center gap-3 rounded-md border border-border p-3">
<div className="flex items-center gap-3 rounded-lg border border-border p-3">
<Switch id="businessTransfer" checked={businessTransferConfirmed} onCheckedChange={setBusinessTransferConfirmed} />
<Label htmlFor="businessTransfer" className="cursor-pointer">{t('business_transfer_confirm')}</Label>
</div>
)}
{disposalType === 'business_transfer' && transferNeedsDocument && (
<div className="flex items-center gap-3 rounded-md border border-border p-3">
<div className="flex items-center gap-3 rounded-lg border border-border p-3">
<Switch id="adjustmentDocument" checked={adjustmentDocumentConfirmed} onCheckedChange={setAdjustmentDocumentConfirmed} />
<Label htmlFor="adjustmentDocument" className="cursor-pointer">{t('adjustment_document_confirm')}</Label>
</div>
+3 -3
View File
@@ -842,7 +842,7 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
<button
type="button"
onClick={() => setRetagLine(line as unknown as RetagLine)}
className="p-1 rounded text-muted-foreground/50 hover:text-foreground hover:bg-secondary/60 transition-colors"
className="p-1 rounded-sm text-muted-foreground/50 hover:text-foreground hover:bg-secondary/60 transition-colors"
aria-label="Ändra dimensioner"
title="Ändra dimensioner (påverkar endast internredovisningen)"
>
@@ -932,7 +932,7 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
<button
type="button"
onClick={() => setRetagLine(line as unknown as RetagLine)}
className="p-1 rounded text-muted-foreground/50 hover:text-foreground transition-colors"
className="p-1 rounded-sm text-muted-foreground/50 hover:text-foreground transition-colors"
aria-label="Ändra dimensioner"
>
<Pencil className="h-3.5 w-3.5" />
@@ -996,7 +996,7 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
<li key={`${ref.type}-${ref.id}`}>
<Link
href={ref.type === 'invoice' ? `/invoices/${ref.id}` : `/supplier-invoices/${ref.id}`}
className="flex items-center gap-2 text-sm py-1.5 px-2 rounded bg-muted/50 hover:bg-secondary/60 transition-colors"
className="flex items-center gap-2 text-sm py-1.5 px-2 rounded-sm bg-muted/50 hover:bg-secondary/60 transition-colors"
>
<FileText className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="truncate">
@@ -14,6 +14,7 @@ import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { EmptyState } from '@/components/ui/empty-state'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import { SegmentedControl } from '@/components/ui/segmented-control'
import { TH_CLASS, TD_CLASS, QUIET_LINK_CLASS, RowFoldout } from '@/components/ui/dry-table'
import { useToast } from '@/components/ui/use-toast'
import { useCanWrite } from '@/lib/hooks/use-can-write'
@@ -211,35 +212,15 @@ export default function AccrualSchedulesPage() {
) : null}
{/* Toolbar: status seg (concept scene 33) */}
<div className="inline-flex shrink-0 gap-0.5 rounded-lg bg-muted/70 p-[3px]" role="tablist">
{(
[
{ key: 'active', label: 'Aktiva', count: activeCount },
{ key: 'completed', label: 'Avslutade', count: null },
{ key: 'all', label: 'Alla', count: null },
] as const
).map(({ key, label, count }) => (
<button
key={key}
type="button"
role="tab"
aria-selected={statusFilter === key}
onClick={() => setStatusFilter(key)}
className={`inline-flex items-center gap-1.5 rounded-md px-3.5 py-[5px] text-[12.5px] transition-colors duration-150 ${
statusFilter === key
? 'border border-border bg-card font-medium text-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{label}
{count !== null && count > 0 && (
<span className="rounded-full bg-secondary px-1.5 text-[10px] font-medium tabular-nums">
{count}
</span>
)}
</button>
))}
</div>
<SegmentedControl
value={statusFilter}
onChange={setStatusFilter}
options={[
{ value: 'active', label: 'Aktiva', count: activeCount ?? undefined },
{ value: 'completed', label: 'Avslutade' },
{ value: 'all', label: 'Alla' },
]}
/>
{isLoading ? (
<div className="space-y-3">
@@ -522,7 +522,7 @@ function VacationStep({
</CardHeader>
<CardContent>
{proposal ? (
<div className="flex items-start justify-between gap-4 rounded-md border border-border p-4">
<div className="flex items-start justify-between gap-4 rounded-lg border border-border p-4">
<div className="flex-1 space-y-2">
<p className="text-sm font-medium">{proposal.label}</p>
<p className="text-xs text-muted-foreground">{proposal.description}</p>
@@ -590,7 +590,7 @@ function AuditStep({
</Label>
</div>
{state.enabled && (
<div className="grid grid-cols-2 gap-4 rounded-md border border-border p-4">
<div className="grid grid-cols-2 gap-4 rounded-lg border border-border p-4">
<div className="space-y-1">
<Label className="text-xs">Belopp (kr)</Label>
<Input
@@ -605,7 +605,7 @@ function AuditStep({
<div className="space-y-1">
<Label className="text-xs">Konto</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={state.liabilityAccount}
onChange={(e) =>
onChange({ ...state, liabilityAccount: e.target.value as '2991' | '2992' })
@@ -667,7 +667,7 @@ function AutoStep({
return (
<div
key={key}
className="flex items-start gap-3 rounded-md border border-border p-3"
className="flex items-start gap-3 rounded-lg border border-border p-3"
>
<Checkbox
id={`auto-${key}`}
@@ -808,7 +808,7 @@ function ManualEntryEditor({
template.side === 'deferred_revenue' ? 'Intäktskonto' : 'Kostnadskonto'
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">{template.name}</p>
<Button variant="ghost" size="sm" onClick={onRemove} className="h-7 px-2" aria-label="Ta bort">
@@ -989,7 +989,7 @@ function ReviewStep({
function ReviewLine({ label, amount, note }: { label: string; amount: number; note?: string }) {
return (
<div className="flex items-center justify-between gap-4 rounded-md border border-border px-3 py-2">
<div className="flex items-center justify-between gap-4 rounded-lg border border-border px-3 py-2">
<div className="min-w-0">
<p className="text-sm font-medium truncate">{label}</p>
{note && <p className="text-xs text-muted-foreground">{note}</p>}
+10 -14
View File
@@ -6,13 +6,13 @@ import { useLocale, useTranslations } from 'next-intl'
import { useSearchParams, useRouter, usePathname } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { ToolbarSearch } from '@/components/ui/toolbar-search'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { Skeleton } from '@/components/ui/skeleton'
import { TH_CLASS, TD_CLASS } from '@/components/ui/dry-table'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
import { Plus, Search, Users, Lock, ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react'
import { Plus, Users, Lock, ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react'
import { EmptyCustomers, EmptyState } from '@/components/ui/empty-state'
import { ReportExportMenu } from '@/components/reports/ReportExportMenu'
import { cn } from '@/lib/utils'
@@ -293,18 +293,14 @@ function CustomersPageInner() {
{/* Toolbar: search (concept) */}
<div className="flex flex-wrap items-center gap-2">
<div className="relative min-w-[220px] 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={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value)
setVisibleCount(INITIAL_VISIBLE_ROWS)
}}
className="h-9 pl-10"
/>
</div>
<ToolbarSearch
placeholder={t('search_placeholder')}
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value)
setVisibleCount(INITIAL_VISIBLE_ROWS)
}}
/>
</div>
{isLoading ? (
+12 -26
View File
@@ -10,6 +10,7 @@ import { Badge } from '@/components/ui/badge'
import { PageHeader } from '@/components/ui/page-header'
import { HelpPopover } from '@/components/ui/help-popover'
import { AttnLine } from '@/components/ui/attn-line'
import { SegmentedControl } from '@/components/ui/segmented-control'
import {
Dialog,
DialogContent,
@@ -2048,7 +2049,7 @@ function CSVDataImportWizard() {
onClick={() => setEntity(opt.value)}
aria-pressed={selected}
className={cn(
'relative h-9 rounded-md border px-4 text-sm font-medium transition-colors',
'relative h-9 rounded-lg border px-4 text-sm font-medium transition-colors',
selected
? 'border-foreground bg-foreground text-background'
: 'border-border bg-card text-foreground hover:border-foreground/30 hover:bg-muted',
@@ -2203,29 +2204,14 @@ export default function ImportPage() {
{isSandbox && <AttnLine>{t('sandbox_disabled')}</AttnLine>}
{/* Importera / Exportera as separate tabs (house seg), like before */}
<div className="inline-flex shrink-0 gap-0.5 rounded-lg bg-muted/70 p-[3px]" role="tablist">
{(
[
{ key: 'import', label: t('tab_import') },
{ key: 'export', label: t('tab_export') },
] as const
).map(({ key, label }) => (
<button
key={key}
type="button"
role="tab"
aria-selected={view === key}
onClick={() => handleViewChange(key)}
className={`rounded-md px-3.5 py-[5px] text-[12.5px] transition-colors duration-150 ${
view === key
? 'border border-border bg-card font-medium text-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{label}
</button>
))}
</div>
<SegmentedControl
value={view}
onChange={handleViewChange}
options={[
{ value: 'import', label: t('tab_import') },
{ value: 'export', label: t('tab_export') },
]}
/>
{view === 'import' ? (
<div>
@@ -2370,7 +2356,7 @@ export default function ImportPage() {
<label className="flex cursor-pointer items-start gap-2 text-sm text-muted-foreground">
<input
type="checkbox"
className="mt-0.5 h-4 w-4 rounded border-border"
className="mt-0.5 h-4 w-4 rounded-sm border-border"
checked={exportExcludeClosing}
onChange={(e) => setExportExcludeClosing(e.target.checked)}
/>
@@ -2551,7 +2537,7 @@ function ImportRow({
// with the inverse lift in dark mode.
function LogoChip({ src, name, mono = false }: { src: string; name: string; mono?: boolean }) {
return (
<span className="flex items-center gap-2 rounded border border-border bg-muted/30 px-2 py-1">
<span className="flex items-center gap-2 rounded-sm border border-border bg-muted/30 px-2 py-1">
<img
src={src}
alt=""
+1 -1
View File
@@ -1580,7 +1580,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
return (
<li
key={p.id}
className="flex items-center justify-between gap-3 px-2 py-2 text-sm transition-colors hover:bg-secondary/60 rounded"
className="flex items-center justify-between gap-3 px-2 py-2 text-sm transition-colors hover:bg-secondary/60 rounded-sm"
>
<span className="tabular-nums text-muted-foreground">
{formatDate(p.payment_date)}
+10 -14
View File
@@ -12,7 +12,7 @@ import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
import { RowStatus, type RowStatusDescriptor } from '@/components/ui/row-status'
import { Input } from '@/components/ui/input'
import { ToolbarSearch } from '@/components/ui/toolbar-search'
import { Skeleton } from '@/components/ui/skeleton'
import { Dialog, DialogContent, DialogTitle, DialogVeil } from '@/components/ui/dialog'
import { DataListEmpty } from '@/components/ui/data-list'
@@ -39,7 +39,6 @@ import {
ArrowUp,
ArrowUpDown,
Plus,
Search,
ReceiptText,
Repeat,
FileInput,
@@ -620,18 +619,15 @@ export default function InvoicesPage() {
annotation: tabCounts[tab] > 0 ? String(tabCounts[tab]) : undefined,
}))}
/>
<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={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value)
resetPaging()
}}
className="h-9 pl-10"
/>
</div>
<ToolbarSearch
containerClassName="min-w-[190px]"
placeholder={t('search_placeholder')}
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value)
resetPaging()
}}
/>
<div className="ml-auto">
<FyPicker
value={fyPeriodId}
+3 -3
View File
@@ -31,7 +31,7 @@ export default function DashboardLoading() {
full-width list, so that shape stays there. */}
<aside className="hidden md:flex md:w-12 shrink-0 flex-col items-center border-r border-border bg-card/40 py-3 gap-2">
<Skeleton className="h-8 w-8 rounded-full" />
<Skeleton className="h-8 w-8 rounded-md" />
<Skeleton className="h-8 w-8" />
</aside>
<aside className="flex w-full flex-col border-r border-border bg-card/40 md:hidden shrink-0">
<div className="space-y-3 border-b border-border px-5 py-4">
@@ -42,7 +42,7 @@ export default function DashboardLoading() {
<Skeleton className="h-2.5 w-20" />
</div>
</div>
<Skeleton className="h-8 w-full rounded-md" />
<Skeleton className="h-8 w-full" />
</div>
<div className="space-y-1 p-3">
{['w-40', 'w-48', 'w-36', 'w-44'].map((w, i) => (
@@ -80,7 +80,7 @@ export default function DashboardLoading() {
key={i}
className="flex items-start gap-3 border-b border-border px-1 py-3.5"
>
<Skeleton className="mt-px h-[15px] w-[15px] shrink-0 rounded" />
<Skeleton className="mt-px h-[15px] w-[15px] shrink-0" />
<Skeleton className={`h-3.5 ${w}`} />
<Skeleton className="ml-auto h-5 w-7 shrink-0 rounded-full" />
</div>
+13 -27
View File
@@ -8,6 +8,7 @@ import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { DataListEmpty, DataListLoading } from '@/components/ui/data-list'
import { ContextPicker } from '@/components/common/ContextPicker'
import { SegmentedControl } from '@/components/ui/segmented-control'
import { HOVER_REVEAL_CLASS, QUIET_LINK_CLASS } from '@/components/ui/dry-table'
import {
SlideOver,
@@ -229,7 +230,7 @@ function useAccountNamesSource(): Record<string, string> {
function PeriodLockBanner({ period }: { period: PeriodStatusShape }) {
const lockedThrough = period.lock_date ? formatDate(period.lock_date) : null
return (
<div className="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm">
<div className="flex items-start gap-2 rounded-lg border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm">
<Lock className="h-4 w-4 text-destructive mt-0.5 shrink-0" />
<div className="flex-1">
<p className="font-medium text-destructive">
@@ -704,7 +705,7 @@ export default function PendingOperationsPage() {
</div>
{conversationFilter && (
<div className="flex items-center justify-between rounded-md border bg-muted/30 px-3 py-2 text-sm">
<div className="flex items-center justify-between rounded-lg border bg-muted/30 px-3 py-2 text-sm">
<div className="flex items-center gap-2">
<MessageSquare className="h-4 w-4 text-muted-foreground" />
<span>
@@ -733,30 +734,15 @@ export default function PendingOperationsPage() {
{/* Toolbar (concept): status seg left, source picker (convention 8)
far right. The count chip rides only on Väntar: it is the queue. */}
<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">
{SEG_TABS.map(({ tab, labelKey }) => (
<button
key={tab}
type="button"
role="tab"
aria-selected={activeTab === tab}
onClick={() => setActiveTab(tab)}
className={cn(
'inline-flex items-center gap-1.5 rounded-md px-3.5 py-[5px] text-[12.5px] transition-colors duration-150',
activeTab === tab
? 'border border-border bg-card font-medium text-foreground'
: 'text-muted-foreground hover:text-foreground',
)}
>
{t(labelKey)}
{tab === 'pending' && (pendingCount ?? 0) > 0 && (
<span className="rounded-full bg-secondary px-1.5 text-[10px] font-medium tabular-nums">
{pendingCount}
</span>
)}
</button>
))}
</div>
<SegmentedControl
value={activeTab}
onChange={setActiveTab}
options={SEG_TABS.map(({ tab, labelKey }) => ({
value: tab,
label: t(labelKey),
count: tab === 'pending' ? pendingCount ?? 0 : undefined,
}))}
/>
<div className="ml-auto">
<ContextPicker
value={sourceFilter}
@@ -1190,7 +1176,7 @@ export default function PendingOperationsPage() {
>
<div className="space-y-3 text-sm">
<p>{t('bulk_confirm_intro')}</p>
<ul className="space-y-1 rounded-md border bg-muted/30 px-3 py-2">
<ul className="space-y-1 rounded-lg border bg-muted/30 px-3 py-2">
{selectedBreakdown.map(({ type, count }) => (
<li key={type} className="flex justify-between font-mono tabular-nums">
<span className="font-sans">{bulkActionLabel(type, count, t)}</span>
@@ -163,7 +163,7 @@ export default function SalaryRunEmployeeDetailPage({
>
<ArrowLeft className="mr-1 h-3.5 w-3.5" /> {t('back_to_run')}
</Link>
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
<div className="rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
{error ?? t('error_load_employee')}
</div>
</div>
@@ -321,7 +321,7 @@ export default function SalaryRunEmployeeDetailPage({
function SummaryCard({ label, value, accent, overridden }: { label: string; value: number; accent?: boolean; overridden?: boolean }) {
const t = useTranslations('salary_run_employee')
return (
<div className={cn('rounded-md border bg-card p-3', accent && 'ring-1 ring-primary/40')}>
<div className={cn('rounded-lg border bg-card p-3', accent && 'ring-1 ring-primary/40')}>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
{label}
{/* The override is an exception, so it is a chip, not a second ring:
@@ -337,7 +337,7 @@ function SummaryCard({ label, value, accent, overridden }: { label: string; valu
function AbsenceCount({ label, days }: { label: string; days: number }) {
const t = useTranslations('salary_run_employee')
return (
<div className="rounded-md border bg-muted/30 px-3 py-2">
<div className="rounded-lg border bg-muted/30 px-3 py-2">
<div className="text-[11px] text-muted-foreground">{label}</div>
<div className="text-sm font-medium tabular-nums">{t('days_count', { days })}</div>
</div>
+1 -1
View File
@@ -896,7 +896,7 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string
</DialogDescription>
</DialogHeader>
{approveOverride && approveOverride.length > 0 && (
<div className="flex items-start gap-2 rounded-md border border-border p-3 text-xs">
<div className="flex items-start gap-2 rounded-lg border border-border p-3 text-xs">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-warning" />
<ul className="space-y-1">
{approveOverride.map((reason, i) => (
+1 -1
View File
@@ -888,7 +888,7 @@ function MatchDialog({
)}
{!loading && candidates && candidates.length > 0 && (
<div className="max-h-[420px] overflow-y-auto rounded-md border">
<div className="max-h-[420px] overflow-y-auto rounded-lg border">
<Table>
<TableHeader>
<TableRow>
@@ -1080,7 +1080,7 @@ export default function SupplierInvoiceDetailPage() {
onChange={(e) => updateEditLine(i, { description: e.target.value })}
placeholder="Beskrivning"
/>
<div className="inline-flex rounded-md border bg-background overflow-hidden h-9">
<div className="inline-flex rounded-lg border bg-background overflow-hidden h-9">
<button
type="button"
onClick={() => updateEditLine(i, { side: 'debit' })}
@@ -1197,7 +1197,7 @@ export default function SupplierInvoiceDetailPage() {
? t('duplicate_payment_description_one')
: t('duplicate_payment_description_many')}
</p>
<div className="space-y-2 rounded-md border bg-muted/30 p-3">
<div className="space-y-2 rounded-lg border bg-muted/30 p-3">
{duplicateCandidates?.map((c) => (
<div key={c.id} className="flex items-center justify-between gap-3 text-sm">
<div className="min-w-0">
+8 -11
View File
@@ -8,13 +8,13 @@ import { Skeleton } from '@/components/ui/skeleton'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { ToolbarSearch } from '@/components/ui/toolbar-search'
import { DataListEmpty } from '@/components/ui/data-list'
import { TH_CLASS, TD_CLASS, QUIET_LINK_CLASS } from '@/components/ui/dry-table'
import { FyPicker } from '@/components/common/FyPicker'
import { ContextPicker } from '@/components/common/ContextPicker'
import { HelpPopover } from '@/components/ui/help-popover'
import { Plus, FileInput, Lock, Search } from 'lucide-react'
import { Plus, FileInput, Lock } from 'lucide-react'
import Link from 'next/link'
import { DialogLoadingSkeleton } from '@/components/ui/dialog-loading-skeleton'
import { useCanWrite } from '@/lib/hooks/use-can-write'
@@ -316,15 +316,12 @@ export default function SupplierInvoicesPage() {
: undefined,
}))}
/>
<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={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="h-9 pl-10"
/>
</div>
<ToolbarSearch
containerClassName="min-w-[190px]"
placeholder={t('search_placeholder')}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<div className="ml-auto flex items-center gap-4">
<Link href="/supplier-invoices/payment-files" className={QUIET_LINK_CLASS}>
{t('payment_files_link')}
+10 -14
View File
@@ -6,14 +6,14 @@ import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { createClient } from '@/lib/supabase/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { ToolbarSearch } from '@/components/ui/toolbar-search'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { EmptyState } from '@/components/ui/empty-state'
import { Skeleton } from '@/components/ui/skeleton'
import { TH_CLASS, TD_CLASS } from '@/components/ui/dry-table'
import { ReportExportMenu } from '@/components/reports/ReportExportMenu'
import { useToast } from '@/components/ui/use-toast'
import { Plus, Search, Building2, Lock } from 'lucide-react'
import { Plus, Building2, Lock } from 'lucide-react'
import Link from 'next/link'
import { cn } from '@/lib/utils'
import { useCompany } from '@/contexts/CompanyContext'
@@ -170,18 +170,14 @@ export default function SuppliersPage() {
{/* Toolbar: search (concept) */}
<div className="flex flex-wrap items-center gap-2">
<div className="relative min-w-[220px] 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={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value)
setVisibleCount(INITIAL_VISIBLE_ROWS)
}}
className="h-9 pl-10"
/>
</div>
<ToolbarSearch
placeholder={t('search_placeholder')}
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value)
setVisibleCount(INITIAL_VISIBLE_ROWS)
}}
/>
</div>
{isLoading ? (
+28 -73
View File
@@ -14,10 +14,11 @@ import { ToastAction } from '@/components/ui/toast'
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
import { DataList, DataListEmpty } from '@/components/ui/data-list'
import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import { SegmentedControl } from '@/components/ui/segmented-control'
import { ToolbarSearch } from '@/components/ui/toolbar-search'
import { TH_CLASS, QUIET_LINK_CLASS } from '@/components/ui/dry-table'
import { Loader2, Search } from 'lucide-react'
import { Loader2 } from 'lucide-react'
import TransactionStatusBar from '@/components/transactions/TransactionStatusBar'
import BankSyncStatusChip from '@/components/transactions/BankSyncStatusChip'
import { ContextPicker, type ContextPickerItem } from '@/components/common/ContextPicker'
@@ -3177,71 +3178,25 @@ export default function TransactionsPage() {
{/* Toolbar (concept order): [Att bokföra/Alla-seg] [sök] [Välj flera]
... [source ContextPicker far right] */}
<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={mode === 'inbox'}
onClick={() => setMode('inbox')}
className={`inline-flex items-center gap-1.5 rounded-md px-3.5 py-[5px] text-[12.5px] transition-colors duration-150 ${
mode === 'inbox'
? 'border border-border bg-card font-medium text-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{t('mode_inbox')}
{inboxBadgeCount > 0 && (
<span className="rounded-full bg-secondary px-1.5 text-[10px] font-medium tabular-nums">
{inboxBadgeCount}
</span>
)}
</button>
<button
type="button"
role="tab"
aria-selected={mode === 'history'}
onClick={() => setMode('history')}
className={`rounded-md px-3.5 py-[5px] text-[12.5px] transition-colors duration-150 ${
mode === 'history'
? 'border border-border bg-card font-medium text-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{t('mode_all')}
</button>
{/* Review tab exists only while suggestions do (or while the user is
standing in it after emptying the list): a permanent third tab
would advertise a migrator surface most companies never need. */}
{(suggestionItems.length > 0 || mode === 'review') && (
<button
type="button"
role="tab"
aria-selected={mode === 'review'}
onClick={() => setMode('review')}
className={`inline-flex items-center gap-1.5 rounded-md px-3.5 py-[5px] text-[12.5px] transition-colors duration-150 ${
mode === 'review'
? 'border border-border bg-card font-medium text-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{t('mode_review')}
{suggestionItems.length > 0 && (
<span className="rounded-full bg-secondary px-1.5 text-[10px] font-medium tabular-nums">
{suggestionItems.length}
</span>
)}
</button>
)}
</div>
<div className="relative min-w-[220px] 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="Sök transaktioner…"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="h-9 pl-10"
/>
</div>
<SegmentedControl
value={mode}
onChange={setMode}
options={[
{ value: 'inbox', label: t('mode_inbox'), count: inboxBadgeCount },
{ value: 'history', label: t('mode_all') },
// Review tab exists only while suggestions do (or while the user is
// standing in it after emptying the list): a permanent third tab
// would advertise a migrator surface most companies never need.
...(suggestionItems.length > 0 || mode === 'review'
? [{ value: 'review' as const, label: t('mode_review'), count: suggestionItems.length }]
: []),
]}
/>
<ToolbarSearch
placeholder="Sök transaktioner…"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
{/* Far-right context group, shared by both view modes: period scope
(rakenskapsar chip) and the account chooser (concept scene 10).
Approved deviation from convention 8's single chip: booking is
@@ -3276,12 +3231,12 @@ export default function TransactionsPage() {
<DataList className="stagger-enter">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center gap-3 px-4 py-3">
<Skeleton className="h-5 w-5 rounded" />
<Skeleton className="h-5 w-5" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-48 rounded" />
<Skeleton className="h-3 w-24 rounded" />
<Skeleton className="h-4 w-48" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-5 w-20 rounded" />
<Skeleton className="h-5 w-20" />
</div>
))}
</DataList>
@@ -3857,7 +3812,7 @@ export default function TransactionsPage() {
fakturan istället för att bokföra direkt på leverantörsskuldskontot, annars skapas en
dubblerad verifikation som måste stornas (BFL 5 kap 5 §).
</p>
<div className="space-y-2 rounded-md border bg-muted/30 p-3">
<div className="space-y-2 rounded-lg border bg-muted/30 p-3">
{siMatchSuggestion?.candidates.map((c) => (
<div key={c.supplier_invoice_id} className="flex items-center justify-between gap-3 text-sm">
<div className="min-w-0">
@@ -3915,7 +3870,7 @@ export default function TransactionsPage() {
istället för att bokföra direkt mot kundfordringskontot, annars skapas en dubblerad
verifikation som måste stornas (BFL 5 kap 5 §).
</p>
<div className="space-y-2 rounded-md border bg-muted/30 p-3">
<div className="space-y-2 rounded-lg border bg-muted/30 p-3">
{ciMatchSuggestion?.candidates.map((c) => (
<div key={c.invoice_id} className="flex items-center justify-between gap-3 text-sm">
<div className="min-w-0">
+1 -1
View File
@@ -78,7 +78,7 @@ export default function GlobalError({
</p>
<button
onClick={() => window.location.reload()}
className="rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90"
className="rounded-full bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90"
>
Försök igen
</button>
+1 -1
View File
@@ -134,7 +134,7 @@ export default async function PayslipPage({
<a
href={`/api/payslip/${token}/pdf`}
className="inline-flex h-10 w-full items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors duration-150 hover:bg-primary/90"
className="inline-flex h-10 w-full items-center justify-center rounded-full bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors duration-150 hover:bg-primary/90"
>
Ladda ner PDF (lönespecifikation)
</a>