diff --git a/.claude/rules/design.md b/.claude/rules/design.md index ce17ea3c..612dda72 100644 --- a/.claude/rules/design.md +++ b/.claude/rules/design.md @@ -54,6 +54,7 @@ Decided during the 2026-07 concept work (dev_docs/ui_migration_plan.md); they ap 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. 15. **Settings speak "Fönster"** (founder-chosen 2026-07-25, applies to every settings tab). Settings content is flat hairline rows, never boxed cards: `SettingsGroup` / `SettingsRow` / `SettingsSectionHeader` from `components/settings/SettingsRows.tsx`. Toggles are switches, not checkboxes. Saving is a sticky bar that fades in only when the form is dirty (`components/settings/SettingsFormWrapper.tsx`); no always-visible save button. The settings surface is a 920x680 modal (`components/settings/SettingsModal.tsx`); switching tabs inside it updates the URL shallowly via `history.replaceState` (`SettingsRail.tsx`), never `router.replace`, which would remount the modal. +16. **One radius per role (the radius ladder, 2026-08-13).** Four tiers, tied to what a thing IS: pills for interactive toolbar controls (buttons, chips, pickers, segmented controls, toolbar search, count badges), `rounded-xl` (12px) for the overlay/panel tier (page panel, dialogs, slide-overs, command palette), `rounded-lg` (8px) for surfaces on the panel (cards, form fields, textareas, popover/dropdown/menu content, bordered boxes), `rounded-sm` (4px) for leaf elements nested inside 8px surfaces (menu items, checkboxes, kbd/code nubs, skeleton text lines). Toolbar controls share one height: `h-8`. `rounded-md`, bare `rounded`, `rounded-2xl` and arbitrary `rounded-[Npx]` are dead vocabulary, enforced by `check:guards` (off-ladder-radius). Nesting is concentric: pill-in-pill by construction, `rounded-sm` items inside `rounded-lg` content with ~4px inset. ## Design System Tokens @@ -72,6 +73,17 @@ Decided during the 2026-07 concept work (dev_docs/ui_migration_plan.md); they ap Compact metric cards (e.g. dashboard tiles, salary KPI row) use `p-4`. Detail cards use `p-6`. Never mix `p-5`. +**Radius ladder** (convention 16; enforced by `check:guards` off-ladder-radius). Radius communicates role, and every role has exactly one: + +| Tier | Class | Use for | +|---|---|---| +| pill | `rounded-full` | Buttons, chips, badges, context pickers, segmented controls, toolbar search, count nubs, dots, avatars | +| 12px | `rounded-xl` | Page panel, dialogs, slide-overs, command palette, hero surfaces | +| 8px | `rounded-lg` | Cards, form inputs, textareas, popover/dropdown/menu content, bordered boxes, toasts | +| 4px | `rounded-sm` | Menu items, checkboxes, kbd/code/account-number nubs, skeleton text lines, small nested leaves | + +**Forbidden radii:** `rounded-md`, bare `rounded`, `rounded-2xl`+, `rounded-[Npx]`. A search field is a pill in a toolbar (`ToolbarSearch`) and `rounded-lg` in a form or dialog (`Input`): the row it sits in decides, and mixing shapes in one row is the bug the ladder exists to prevent. + **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`. @@ -91,6 +103,8 @@ Compact metric cards (e.g. dashboard tiles, salary KPI row) use `p-4`. Detail ca | 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. | +| View/mode switcher in a toolbar | `components/ui/segmented-control.tsx` `SegmentedControl` | Pill-in-pill tablist at the shared `h-8` toolbar height; `options` take an optional `count` for the standard count chip. Never hand-roll the `bg-muted/70` tablist div. | +| Search in a page toolbar | `components/ui/toolbar-search.tsx` `ToolbarSearch` | Pill search at `h-8`, matching the chips and pickers beside it. Searches inside dialogs/pickers keep the regular `Input`. | | Fiscal year picker | `components/common/FyPicker.tsx` | The chip-dropdown context picker of convention 8 (wraps `ContextPicker`). `FiscalYearSelector` is the legacy pre-frame control: don't add new uses. Never use a raw ` { - setSearchTerm(e.target.value) - setVisibleCount(INITIAL_VISIBLE_ROWS) - }} - className="h-9 pl-10" - /> - + { + 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 && ( diff --git a/app/(dashboard)/assets/[id]/dispose/page.tsx b/app/(dashboard)/assets/[id]/dispose/page.tsx index 3d127b62..708ea116 100644 --- a/app/(dashboard)/assets/[id]/dispose/page.tsx +++ b/app/(dashboard)/assets/[id]/dispose/page.tsx @@ -325,7 +325,7 @@ export default function DisposeAssetPage({ params }: { params: Promise<{ id: str {VAT_TREATMENTS.map((value) => {t(`vat_${value}`)})} -
+
@@ -351,20 +351,20 @@ export default function DisposeAssetPage({ params }: { params: Promise<{ id: str
)} {jamkningAssessment && ( -
+
{jamkningAssessment.capped &&

{t('adjustment_capped')}

}
)} {disposalType === 'business_transfer' && ( -
+
)} {disposalType === 'business_transfer' && transferNeedsDocument && ( -
+
diff --git a/app/(dashboard)/bookkeeping/[id]/page.tsx b/app/(dashboard)/bookkeeping/[id]/page.tsx index 86d15fce..68604b8f 100644 --- a/app/(dashboard)/bookkeeping/[id]/page.tsx +++ b/app/(dashboard)/bookkeeping/[id]/page.tsx @@ -842,7 +842,7 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i - ))} -
+ {isLoading ? (
diff --git a/app/(dashboard)/bookkeeping/year-end/periodisering/page.tsx b/app/(dashboard)/bookkeeping/year-end/periodisering/page.tsx index 91abfc6c..d7896b08 100644 --- a/app/(dashboard)/bookkeeping/year-end/periodisering/page.tsx +++ b/app/(dashboard)/bookkeeping/year-end/periodisering/page.tsx @@ -522,7 +522,7 @@ function VacationStep({ {proposal ? ( -
+

{proposal.label}

{proposal.description}

@@ -590,7 +590,7 @@ function AuditStep({
{state.enabled && ( -
+
{ - setSearchTerm(e.target.value) - setVisibleCount(INITIAL_VISIBLE_ROWS) - }} - className="h-9 pl-10" - /> -
+ { + setSearchTerm(e.target.value) + setVisibleCount(INITIAL_VISIBLE_ROWS) + }} + />
{isLoading ? ( diff --git a/app/(dashboard)/import/page.tsx b/app/(dashboard)/import/page.tsx index c1b4a2da..7ee5ff77 100644 --- a/app/(dashboard)/import/page.tsx +++ b/app/(dashboard)/import/page.tsx @@ -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 && {t('sandbox_disabled')}} {/* Importera / Exportera as separate tabs (house seg), like before */} -
- {( - [ - { key: 'import', label: t('tab_import') }, - { key: 'export', label: t('tab_export') }, - ] as const - ).map(({ key, label }) => ( - - ))} -
+ {view === 'import' ? (
@@ -2370,7 +2356,7 @@ export default function ImportPage() {
{['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" > - +
diff --git a/app/(dashboard)/pending/page.tsx b/app/(dashboard)/pending/page.tsx index 8364706e..cef8a5f9 100644 --- a/app/(dashboard)/pending/page.tsx +++ b/app/(dashboard)/pending/page.tsx @@ -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 { function PeriodLockBanner({ period }: { period: PeriodStatusShape }) { const lockedThrough = period.lock_date ? formatDate(period.lock_date) : null return ( -
+

@@ -704,7 +705,7 @@ export default function PendingOperationsPage() {

{conversationFilter && ( -
+
@@ -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. */}
-
- {SEG_TABS.map(({ tab, labelKey }) => ( - - ))} -
+ ({ + value: tab, + label: t(labelKey), + count: tab === 'pending' ? pendingCount ?? 0 : undefined, + }))} + />

{t('bulk_confirm_intro')}

-
    +
      {selectedBreakdown.map(({ type, count }) => (
    • {bulkActionLabel(type, count, t)} diff --git a/app/(dashboard)/salary/runs/[id]/employees/[employeeId]/page.tsx b/app/(dashboard)/salary/runs/[id]/employees/[employeeId]/page.tsx index 63596a8c..dcca5495 100644 --- a/app/(dashboard)/salary/runs/[id]/employees/[employeeId]/page.tsx +++ b/app/(dashboard)/salary/runs/[id]/employees/[employeeId]/page.tsx @@ -163,7 +163,7 @@ export default function SalaryRunEmployeeDetailPage({ > {t('back_to_run')} -
      +
      {error ?? t('error_load_employee')}
      @@ -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 ( -
      +
      {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 ( -
      +
      {label}
      {t('days_count', { days })}
      diff --git a/app/(dashboard)/salary/runs/[id]/page.tsx b/app/(dashboard)/salary/runs/[id]/page.tsx index 3a8840a3..5e94fa47 100644 --- a/app/(dashboard)/salary/runs/[id]/page.tsx +++ b/app/(dashboard)/salary/runs/[id]/page.tsx @@ -896,7 +896,7 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string {approveOverride && approveOverride.length > 0 && ( -
      +
        {approveOverride.map((reason, i) => ( diff --git a/app/(dashboard)/skattekonto/page.tsx b/app/(dashboard)/skattekonto/page.tsx index fb446ad9..fc30a09e 100644 --- a/app/(dashboard)/skattekonto/page.tsx +++ b/app/(dashboard)/skattekonto/page.tsx @@ -888,7 +888,7 @@ function MatchDialog({ )} {!loading && candidates && candidates.length > 0 && ( -
        +
        diff --git a/app/(dashboard)/supplier-invoices/[id]/page.tsx b/app/(dashboard)/supplier-invoices/[id]/page.tsx index 68f15615..af350814 100644 --- a/app/(dashboard)/supplier-invoices/[id]/page.tsx +++ b/app/(dashboard)/supplier-invoices/[id]/page.tsx @@ -1080,7 +1080,7 @@ export default function SupplierInvoiceDetailPage() { onChange={(e) => updateEditLine(i, { description: e.target.value })} placeholder="Beskrivning" /> -
        +
        - - {/* 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') && ( - - )} -
        -
        - - setSearchTerm(e.target.value)} - className="h-9 pl-10" - /> -
        + 0 || mode === 'review' + ? [{ value: 'review' as const, label: t('mode_review'), count: suggestionItems.length }] + : []), + ]} + /> + 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() { {[1, 2, 3].map((i) => (
        - +
        - - + +
        - +
        ))}
        @@ -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 §).

        -
        +
        {siMatchSuggestion?.candidates.map((c) => (
        @@ -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 §).

        -
        +
        {ciMatchSuggestion?.candidates.map((c) => (
        diff --git a/app/global-error.tsx b/app/global-error.tsx index e1638e3c..611295b2 100644 --- a/app/global-error.tsx +++ b/app/global-error.tsx @@ -78,7 +78,7 @@ export default function GlobalError({

        diff --git a/app/payslip/[token]/page.tsx b/app/payslip/[token]/page.tsx index 9c956b15..1dbd0f43 100644 --- a/app/payslip/[token]/page.tsx +++ b/app/payslip/[token]/page.tsx @@ -134,7 +134,7 @@ export default async function PayslipPage({ Ladda ner PDF (lönespecifikation) diff --git a/components/agent-knowledge/LedgerGraph.tsx b/components/agent-knowledge/LedgerGraph.tsx index 4f76c138..7bd8b34d 100644 --- a/components/agent-knowledge/LedgerGraph.tsx +++ b/components/agent-knowledge/LedgerGraph.tsx @@ -329,7 +329,7 @@ export function LedgerGraph({ deep, companyName }: { deep: DeepLedgerContext; co @@ -135,7 +135,7 @@ export default function AgentSessionList({ activeConversationId, onSelect }: Pro placeholder="Namnge konversationen…" maxLength={200} aria-label="Nytt namn på konversationen" - className="w-full rounded-md border border-border bg-background px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-ring" + className="w-full rounded-lg border border-border bg-background px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-ring" />
        ) : ( diff --git a/components/agent/AgentSheet.tsx b/components/agent/AgentSheet.tsx index 5e63dec5..23faec84 100644 --- a/components/agent/AgentSheet.tsx +++ b/components/agent/AgentSheet.tsx @@ -617,7 +617,7 @@ export default function AgentSheet({ >
        {preview != null && typeof preview === 'object' && ( -
        +
        setConfirmText(e.target.value)} disabled={isBusy} - className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" + className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" autoComplete="off" aria-label="Bekräfta med ordet godkänn" /> @@ -384,7 +384,7 @@ export default function ApprovalCard({ {errorMessage &&

        {errorMessage}

        } {showRejectForm ? ( -
        +

        Vad är fel?

        setSearchQuery(e.target.value)} - className="h-9 pl-10" - /> -
        + + setSearchQuery(e.target.value)} + containerClassName="min-w-[190px] max-w-xs flex-1" + /> {view === 'my-accounts' && (