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
+78 -1
View File
@@ -58,6 +58,14 @@
* the four Skatteverket-bound org-number paths disagreed outright about
* what "valid" meant, which is the kind of drift a customer only discovers
* when a filing fails at the deadline. Tracked as a count.
* 9. off-ladder-radius: a border-radius class outside the locked ladder
* (pill / rounded-xl overlays / rounded-lg surfaces / rounded-sm leaves;
* see .claude/rules/design.md). Before the 2026-08 migration the UI had
* seven radii in circulation (4/5/6/8/12/16px + pill) and one toolbar row
* could mix four of them. `rounded-md`, bare `rounded`, `rounded-2xl`+
* and arbitrary `rounded-[Npx]` are dead vocabulary in app/ and
* components/. No baseline: the count is 0, any new one is a hard
* failure.
*
* Usage:
* node scripts/checks/no-new-antipatterns.mjs # check (CI)
@@ -282,6 +290,59 @@ function countHandRolledInvariants() {
return count
}
// 9. off-ladder-radius. The radius ladder (.claude/rules/design.md) allows
// exactly: rounded-full (interactive toolbar controls, chips, dots),
// rounded-xl (page panel, dialogs, slide-overs, hero surfaces), rounded-lg
// (cards, form fields, popover/menu content, bordered boxes), rounded-sm
// (nested leaf elements), rounded-none, and directional variants of those.
// Everything else is off-ladder. Bare `rounded` is banned as vocabulary: it
// renders the same 4px as rounded-sm but hides from a rounded-sm grep.
const OFF_LADDER_RADIUS_RES = [
// rounded-md and any directional variant (rounded-t-md, rounded-bl-md, ...)
/\brounded(?:-[trbl]{1,2})?-(?:md|2xl|3xl|4xl)\b/,
// arbitrary radius values: rounded-[5px], rounded-t-[10px], ...
/\brounded(?:-[trbl]{1,2})?-\[/,
]
// Bare `rounded` as a class token (not rounded-*): renders the same 4px as
// rounded-sm but hides from a rounded-sm grep. `rounded` is also a common
// variable name and an ordinary English word, so this one only counts inside
// a quoted string that looks like a Tailwind class list (contains at least
// one other utility-class token).
const BARE_ROUNDED_RE = /(?<![-\w])rounded(?![-\w])/
const CLASS_LIST_HINT_RE =
/(?:^|\s)(?:[a-z-]+:)*(?:flex|inline-flex|grid|hidden|absolute|relative|sticky|fixed|bg-\S|text-\S|border\b|border-\S|shadow\S*|p-\d|px-\S|py-\S|pl-\S|pr-\S|pt-\S|pb-\S|h-\S|w-\S|gap-\S|items-\S|justify-\S|font-\S|overflow-\S|transition\S*|animate-\S)/
function lineHasBareRoundedClass(line) {
const strings = line.match(/"[^"]*"|'[^']*'|`[^`]*`/g)
if (!strings) return false
return strings.some(
(s) => BARE_ROUNDED_RE.test(s) && CLASS_LIST_HINT_RE.test(s.slice(1, -1)),
)
}
/** Off-ladder border-radius classes in UI code. */
function findOffLadderRadii() {
const files = [
...walk(path.join(ROOT, 'app'), ['.ts', '.tsx']),
...walk(path.join(ROOT, 'components'), ['.ts', '.tsx']),
]
const findings = []
for (const f of files) {
const lines = fs.readFileSync(f, 'utf8').split('\n')
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
// Prose mentions of "rounded" in comments are not class tokens.
const trimmed = line.trim()
if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) continue
if (OFF_LADDER_RADIUS_RES.some((re) => re.test(line)) || lineHasBareRoundedClass(line)) {
findings.push(`${rel(f)}:${i + 1}`)
}
}
}
return findings.sort()
}
// Dependencies pinned to an EXACT version on purpose, because a bump broke prod
// and must not silently return via `npm update`, a dependabot bump, or a manual
// install. Any drift (in package.json OR the lockfile) fails CI. See DECISIONS.md.
@@ -621,6 +682,7 @@ const current = {
rawUserErrors: findRawUserErrors(),
sekLabelledAmounts: findSekLabelledFxAmounts(ROOT),
extensionRoutes: findExtensionRouteFindings(ROOT),
offLadderRadii: findOffLadderRadii(),
}
const isUpdate = process.argv.includes('--update')
@@ -728,6 +790,21 @@ if (current.sekLabelledAmounts.length) {
)
}
// 1e1. off-ladder-radius: no baseline, the count is 0 after the 2026-08
// migration and any new off-ladder radius class is a hard failure.
if (current.offLadderRadii.length) {
failed = true
console.error(
`\n✗ off-ladder-radius: ${current.offLadderRadii.length} border-radius class(es) outside the locked ladder:`,
)
current.offLadderRadii.forEach((f) => console.error(` ${f}`))
console.error(
' → use the radius ladder (.claude/rules/design.md): rounded-full for toolbar controls/chips,\n' +
' rounded-xl for overlays, rounded-lg for cards/fields/menu content, rounded-sm for nested\n' +
' leaves. rounded-md, bare `rounded`, rounded-2xl and rounded-[Npx] are dead vocabulary.',
)
}
// 1e2. hand-rolled-invariant: counted, may only go down.
if (current.handRolledInvariants > (baseline.handRolledInvariants?.count ?? Infinity)) {
failed = true
@@ -839,5 +916,5 @@ if (failed) {
process.exit(1)
}
console.log(
`\n✓ Antipattern guard passed (raw-route-auth: ${current.rawRouteAuth.length}, naive-ore-round: ${current.naiveOreRound}, hand-rolled-invariant: ${current.handRolledInvariants}, ledger-scanning-report: ${current.ledgerScanningReports.length}, direct-jel-insert: 0, pinned-dep: 0, raw-user-error: 0, sek-labelled-amount: 0, cross-extension-import: 0, ungated-extension-route: ${current.extensionRoutes.ungated.length}/${UNGATED_EXTENSION_ROUTES.size} allowlisted).`,
`\n✓ Antipattern guard passed (raw-route-auth: ${current.rawRouteAuth.length}, naive-ore-round: ${current.naiveOreRound}, hand-rolled-invariant: ${current.handRolledInvariants}, ledger-scanning-report: ${current.ledgerScanningReports.length}, direct-jel-insert: 0, pinned-dep: 0, raw-user-error: 0, sek-labelled-amount: 0, off-ladder-radius: 0, cross-extension-import: 0, ungated-extension-route: ${current.extensionRoutes.ungated.length}/${UNGATED_EXTENSION_ROUTES.size} allowlisted).`,
)