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
+1 -1
View File
@@ -12,7 +12,7 @@ const Checkbox = React.forwardRef<
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded border border-input transition-colors duration-200 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/35 focus-visible:border-primary/60 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:border-primary data-[state=checked]:text-primary-foreground hover:border-primary/50",
"peer h-4 w-4 shrink-0 rounded-sm border border-input transition-colors duration-200 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/35 focus-visible:border-primary/60 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:border-primary data-[state=checked]:text-primary-foreground hover:border-primary/50",
className
)}
{...props}
+1 -1
View File
@@ -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-[var(--shadow-md)] max-h-[calc(100dvh-2rem)] overflow-y-auto scrollbar-visible 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-98 data-[state=open]:zoom-in-98 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)] max-h-[calc(100dvh-2rem)] overflow-y-auto scrollbar-visible 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-98 data-[state=open]:zoom-in-98 sm:rounded-xl",
className
)}
{...props}
+2 -2
View File
@@ -37,7 +37,7 @@ const DropdownMenuItem = React.forwardRef<
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none transition-colors focus:bg-secondary focus:text-secondary-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-secondary focus:text-secondary-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
@@ -53,7 +53,7 @@ const DropdownMenuRadioItem = React.forwardRef<
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-md py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-secondary focus:text-secondary-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-secondary focus:text-secondary-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
+1 -1
View File
@@ -51,7 +51,7 @@ export function OpenInNewTab({
onKeyDown={(e) => e.stopPropagation()}
className={cn(
HOVER_REVEAL_CLASS,
'relative inline-flex shrink-0 items-center rounded p-1 text-muted-foreground',
'relative inline-flex shrink-0 items-center rounded-sm p-1 text-muted-foreground',
// The icon stays 14px so the row keeps its density, but the pointer
// target is padded out to the 40px the design rules require.
'before:absolute before:left-1/2 before:top-1/2 before:h-10 before:w-10',
+68
View File
@@ -0,0 +1,68 @@
'use client'
import * as React from 'react'
import { cn } from '@/lib/utils'
export interface SegmentedControlOption<T extends string> {
value: T
/** Segment label. Pass a fragment for custom trailing content (e.g. a muted count). */
label: React.ReactNode
/** Renders the standard count chip after the label when > 0. */
count?: number
}
interface SegmentedControlProps<T extends string> {
value: T
onChange: (value: T) => void
options: SegmentedControlOption<T>[]
'aria-label'?: string
className?: string
}
/**
* The toolbar segmented control (view/mode switcher). Pill-in-pill per the
* radius ladder: interactive toolbar controls are pills, and pill nesting is
* concentric by construction. One fixed height (h-8) shared with
* ToolbarSearch and ContextPicker so a toolbar row reads as one line.
*/
export function SegmentedControl<T extends string>({
value,
onChange,
options,
className,
...aria
}: SegmentedControlProps<T>) {
return (
<div
className={cn(
'inline-flex h-8 shrink-0 items-center gap-0.5 rounded-full bg-muted/70 p-[3px]',
className,
)}
role="tablist"
aria-label={aria['aria-label']}
>
{options.map((opt) => (
<button
key={opt.value}
type="button"
role="tab"
aria-selected={value === opt.value}
onClick={() => onChange(opt.value)}
className={cn(
'inline-flex h-full items-center gap-1.5 rounded-full px-3.5 text-[12.5px] transition-colors duration-150',
value === opt.value
? 'border border-border bg-card font-medium text-foreground'
: 'text-muted-foreground hover:text-foreground',
)}
>
{opt.label}
{typeof opt.count === 'number' && opt.count > 0 && (
<span className="rounded-full bg-secondary px-1.5 text-[10px] font-medium tabular-nums">
{opt.count}
</span>
)}
</button>
))}
</div>
)
}
+1 -1
View File
@@ -117,7 +117,7 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-md py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-secondary focus:text-secondary-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-secondary focus:text-secondary-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
+1 -1
View File
@@ -6,7 +6,7 @@ function Skeleton({
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
className={cn("animate-pulse rounded-sm bg-muted", className)}
{...props}
/>
)
+1 -1
View File
@@ -168,7 +168,7 @@ export function SplitButton({
runOption(option)
}}
className={cn(
'flex w-full items-start gap-2.5 rounded-md px-2.5 py-2 text-left transition-colors',
'flex w-full items-start gap-2.5 rounded-sm px-2.5 py-2 text-left transition-colors',
option.disabled
? 'cursor-not-allowed text-muted-foreground/40'
: 'text-muted-foreground hover:bg-secondary/60 hover:text-foreground',
+2 -2
View File
@@ -13,7 +13,7 @@ const TabsList = React.forwardRef<
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground max-w-full overflow-x-auto scrollbar-hide",
"inline-flex h-10 items-center justify-center rounded-full bg-muted p-1 text-muted-foreground max-w-full overflow-x-auto scrollbar-hide",
className
)}
{...props}
@@ -28,7 +28,7 @@ const TabsTrigger = React.forwardRef<
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
"inline-flex items-center justify-center whitespace-nowrap rounded-full px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
+3 -3
View File
@@ -77,7 +77,7 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
return (
<div
className={cn(
'flex min-h-9 w-full flex-wrap items-center gap-1.5 rounded-md border border-input bg-transparent px-3 py-1.5 text-sm transition-colors',
'flex min-h-9 w-full flex-wrap items-center gap-1.5 rounded-lg border border-input bg-transparent px-3 py-1.5 text-sm transition-colors',
'focus-within:ring-1 focus-within:ring-ring',
disabled && 'cursor-not-allowed opacity-50',
className
@@ -87,7 +87,7 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
{tags.map((tag, i) => (
<span
key={`${tag}-${i}`}
className="inline-flex items-center gap-1 rounded-md bg-secondary px-2 py-0.5 text-xs font-medium text-secondary-foreground"
className="inline-flex items-center gap-1 rounded-full bg-secondary px-2 py-0.5 text-xs font-medium text-secondary-foreground"
>
{tag}
{!disabled && (
@@ -97,7 +97,7 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
e.stopPropagation()
removeTag(i)
}}
className="rounded-sm opacity-60 hover:opacity-100 focus:outline-none"
className="rounded-full opacity-60 hover:opacity-100 focus:outline-none"
>
<X className="h-3 w-3" />
</button>
+3 -3
View File
@@ -24,7 +24,7 @@ const ToastViewport = React.forwardRef<
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-0 max-sm:data-[state=open]:slide-in-from-top-full max-sm:data-[state=closed]:slide-out-to-top-full sm:data-[state=open]:slide-in-from-right-full sm:data-[state=closed]:slide-out-to-right-full",
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-lg border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-0 max-sm:data-[state=open]:slide-in-from-top-full max-sm:data-[state=closed]:slide-out-to-top-full sm:data-[state=open]:slide-in-from-right-full sm:data-[state=closed]:slide-out-to-right-full",
{
variants: {
variant: {
@@ -63,7 +63,7 @@ const ToastAction = React.forwardRef<
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
"inline-flex h-8 shrink-0 items-center justify-center rounded-full border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
@@ -78,7 +78,7 @@ const ToastClose = React.forwardRef<
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
"absolute right-2 top-2 rounded-sm p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
+35
View File
@@ -0,0 +1,35 @@
'use client'
import * as React from 'react'
import { Search } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
interface ToolbarSearchProps extends React.InputHTMLAttributes<HTMLInputElement> {
/** Overrides the wrapper (width/flex) classes, not the input itself. */
containerClassName?: string
}
/**
* Page-toolbar search field. A pill, like every other control that lives in a
* toolbar row (buttons, context pickers, segmented controls), at the shared
* h-8 toolbar height. Form fields inside dialogs and forms keep the regular
* rounded-lg Input; this variant exists only for toolbars.
*/
export const ToolbarSearch = React.forwardRef<HTMLInputElement, ToolbarSearchProps>(
({ containerClassName, className, ...props }, ref) => (
<div className={cn('relative min-w-[220px] max-w-xs flex-1', containerClassName)}>
<Search
className="absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
aria-hidden="true"
/>
<Input
ref={ref}
type="search"
{...props}
className={cn('h-8 rounded-full pl-10 pr-4 text-[13px]', className)}
/>
</div>
),
)
ToolbarSearch.displayName = 'ToolbarSearch'