Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions scripts/__tests__/ds-lint-rules.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import {
countOffScaleSpacing,
countWeightStacks,
OFF_SCALE_ICON_RE,
OFF_SCALE_RADIUS_RE,
RAW_DURATION_RE,
} from '../ds-lint-rules.cjs'

const countMatches = (text: string, re: RegExp) => (text.match(re) ?? []).length

describe('offScaleSpacing', () => {
it('flags off-scale steps across every spacing family, logical ps/pe/ms/me included', () => {
for (const cls of ['p-5', 'px-7', 'py-2.5', 'gap-11', 'p-4.5', 'pr-18', 'ps-5', 'pe-18', 'ms-2.5', 'me-7']) {
expect(countOffScaleSpacing(`<div className="${cls}" />`)).toBe(1)
}
})

it('flags negative and variant-prefixed forms', () => {
expect(countOffScaleSpacing('className="-m-2.5"')).toBe(1)
expect(countOffScaleSpacing('className="-mt-5"')).toBe(1)
expect(countOffScaleSpacing('className="md:pr-18"')).toBe(1)
expect(countOffScaleSpacing('className="first:ps-7"')).toBe(1)
})

it('accepts every documented scale step, negatives and variants included', () => {
for (const cls of [
'p-0',
'p-0.5',
'gap-1',
'px-2',
'py-3',
'p-4',
'gap-6',
'p-8',
'mt-10',
'pb-12',
'ps-4',
'me-2',
'-mt-4',
'md:gap-6',
'space-y-8',
'gap-x-16',
]) {
expect(countOffScaleSpacing(`<div className="${cls}" />`)).toBe(0)
}
})

it('flags arbitrary spacing values wholesale — the bracket form is always drift', () => {
for (const cls of ['p-[5px]', 'gap-[20px]', 'p-[13px]', '-m-[3px]', 'md:ps-[10%]']) {
expect(countOffScaleSpacing(`<div className="${cls}" />`)).toBe(1)
}
})

it('does not misread longer utilities or words as spacing classes', () => {
for (const text of [
'className="max-p-5"',
'theme-3',
'frame-2',
'className="w-[13px]"',
'className="ms-auto"',
]) {
expect(countOffScaleSpacing(text)).toBe(0)
}
})
})

describe('fontWeightOnTypeToken (countWeightStacks)', () => {
it('counts a token and weight split across lines inside one className expression', () => {
const jsx = [
'<span',
' className={`text-body-m ${twMerge(',
" 'font-semibold text-foreground-primary capitalize',",
' titleClassName',
' )}`}',
'>',
].join('\n')
expect(countWeightStacks(jsx)).toBe(1)
})

it('counts same-line stacks in plain strings and const class strings', () => {
expect(countWeightStacks('<p className="text-body-s font-bold" />')).toBe(1)
expect(countWeightStacks("const style = 'text-body-s font-bold underline'")).toBe(1)
})

it('does not count a token and a weight living in different elements', () => {
const jsx = [
'<p className="text-body-m">a</p>',
'<p className="font-semibold">b</p>',
'<p className={twMerge("text-label-l", cls)}>c</p>',
].join('\n')
expect(countWeightStacks(jsx)).toBe(0)
})
})

describe('iconOffScale', () => {
it('flags off-step size, width/height props, and class-sized Icons', () => {
for (const text of [
'<Icon name="info" size={18} />',
'<Icon name={logo} width={18} height={18} />',
'<Icon name="swap" width={32} height={32} />',
'iconSize={13}',
'<Icon name="check" className="size-4" />',
'<Icon name="paste" className="h-3.5 w-3.5" />',
]) {
expect(countMatches(text, OFF_SCALE_ICON_RE)).toBeGreaterThan(0)
}
})

it('accepts the 16/20/24 steps', () => {
for (const text of [
'<Icon name="info" size={16} />',
'<Icon size={20} />',
'<Icon size={24} />',
'iconSize={20}',
]) {
expect(countMatches(text, OFF_SCALE_ICON_RE)).toBe(0)
}
})
})

describe('offScaleRadius', () => {
it('flags radii off the none/2/4/full scale, sides included', () => {
for (const cls of ['rounded-md', 'rounded-lg', 'rounded-t-2xl', 'rounded-3xl']) {
expect(countMatches(`className="${cls}"`, OFF_SCALE_RADIUS_RE)).toBe(1)
}
})

it('accepts the scale classes', () => {
for (const cls of ['rounded-sm', 'rounded-round', 'rounded-full', 'rounded-none']) {
expect(countMatches(`className="${cls}"`, OFF_SCALE_RADIUS_RE)).toBe(0)
}
})
})

describe('rawDuration', () => {
it('flags any numeric or arbitrary duration', () => {
for (const cls of ['duration-100', 'duration-250', 'duration-75', 'duration-[250ms]']) {
expect(countMatches(`className="${cls}"`, RAW_DURATION_RE)).toBe(1)
}
})

it('accepts the motion tokens', () => {
for (const cls of ['duration-instant', 'duration-fast', 'duration-moderate', 'duration-slow']) {
expect(countMatches(`className="${cls}"`, RAW_DURATION_RE)).toBe(0)
}
})
})
9 changes: 7 additions & 2 deletions scripts/ds-lint-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"rawHex": 65,
"rawHexFiles": 41,
"inlineStyle": 50,
"stockTextSize": 362,
"stockTextSize": 361,
"dsTextScale": 10,
"nonDsClassesInViews": 138,
"useSearchParamsFiles": 47,
Expand All @@ -11,5 +11,10 @@
"offRampPalette": 0,
"deadLegacyTokens": 0,
"consumedUndefinedTokens": 0,
"classNameSitesInPages": 314
"classNameSitesInPages": 314,
"fontWeightOnTypeToken": 14,
"offScaleSpacing": 198,
"iconOffScale": 64,
"offScaleRadius": 33,
"rawDuration": 9
}
37 changes: 37 additions & 0 deletions scripts/ds-lint-counts.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@
import { readdirSync, readFileSync, writeFileSync } from 'node:fs'
import { join, relative, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import {
countOffScaleSpacing,
countWeightStacks,
OFF_SCALE_ICON_RE,
OFF_SCALE_RADIUS_RE,
RAW_DURATION_RE,
} from './ds-lint-rules.cjs'

const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
const SRC = join(ROOT, 'src')
Expand Down Expand Up @@ -252,6 +259,31 @@ counts.classNameSitesInPages = files
.filter((f) => /^app\/\(mobile-ui\)\//.test(f.path) && /(^|\/)page\.tsx$/.test(f.path) && !f.path.includes('/dev/'))
.reduce((sum, f) => sum + countMatches(f.text, /className=/g), 0)

// composition-drift metrics (2026-09-01 sweep). design.md laws the token
// metrics above cannot see: stacked weights mint off-ramp type styles, the
// spacing/radius/motion scales ban off-scale values, icons have three sizes.
// deliberate holds (geometry-driven indents like Notification's pl-7, boards
// pending a ruling) live inside the baseline, not an allowlist — a ruling
// drives the count down, new drift pushes it up and fails.
counts.fontWeightOnTypeToken = files
.filter((f) => isTsx(f) && !allowed(f.path))
Comment thread
innolope-dev marked this conversation as resolved.
Outdated
.reduce((sum, f) => sum + countWeightStacks(f.text), 0)
// matchers live in ds-lint-rules.cjs (imported at the top) so the regression
// tests in scripts/__tests__/ds-lint-rules.test.ts exercise the exact rules
// this script counts with, without running the src/ scan.
counts.offScaleSpacing = files
.filter((f) => isTsx(f) && !allowed(f.path))
.reduce((sum, f) => sum + countOffScaleSpacing(f.text), 0)
counts.iconOffScale = files
.filter((f) => isTsx(f) && !allowed(f.path))
.reduce((sum, f) => sum + countMatches(f.text, OFF_SCALE_ICON_RE), 0)
counts.offScaleRadius = files
.filter((f) => isTsx(f) && !allowed(f.path))
.reduce((sum, f) => sum + countMatches(f.text, OFF_SCALE_RADIUS_RE), 0)
counts.rawDuration = files
.filter((f) => isTsx(f) && !allowed(f.path))
.reduce((sum, f) => sum + countMatches(f.text, RAW_DURATION_RE), 0)

// dsTextScale and nuqsFiles are adoption counts (should go UP) — everything
// else is debt (must only go DOWN). the ratchet only enforces the debt keys.
const DEBT_KEYS = [
Expand All @@ -266,6 +298,11 @@ const DEBT_KEYS = [
'classNameSitesInPages',
'deadLegacyTokens',
Comment thread
innolope-dev marked this conversation as resolved.
'consumedUndefinedTokens',
'fontWeightOnTypeToken',
'offScaleSpacing',
'iconOffScale',
'offScaleRadius',
'rawDuration',
]

const mode = process.argv[2] ?? ''
Expand Down
104 changes: 104 additions & 0 deletions scripts/ds-lint-rules.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// composition-drift matchers shared by ds-lint-counts.mjs and its regression
// tests (scripts/__tests__/ds-lint-rules.test.ts). the counting script has
// top-level side effects (it walks src/), so the rules live here where a test
// can import them without running the scan. CommonJS on purpose: node's ESM
// loader named-imports it statically, and jest's CJS runtime requires it with
// no transform.

// allowlist inversion, not a blocklist: tailwind v4 compiles ANY numeric step
// (p-4.5, pr-18, -mt-5, ps-5), so the metric flags every numeric spacing
// utility — negative forms, variant prefixes, and the logical ps/pe/ms/me
// families included — whose magnitude is not a documented scale step.
// arbitrary values (p-[5px], gap-[20%]) are rejected wholesale: a value that
// equals a scale step has a numeric class, so the bracket form is always drift.
const SPACING_STEPS = new Set(['0', '0.5', '1', '2', '3', '4', '6', '8', '10', '12', '14', '16'])
const SPACING_FAMILIES = 'px|py|pt|pb|pl|pr|ps|pe|p|mx|my|mt|mb|ml|mr|ms|me|m|gap-x|gap-y|gap|space-y|space-x'
Comment thread
innolope-dev marked this conversation as resolved.
Outdated
const NUMERIC_SPACING_RE = new RegExp(
`(?<![a-z0-9-])-?(?:${SPACING_FAMILIES})-([0-9]+(?:\\.[0-9]+)?)(?![0-9.a-z%\\]])`,
Comment thread
innolope-dev marked this conversation as resolved.
Outdated
'g'
)
const ARBITRARY_SPACING_RE = new RegExp(`(?<![a-z0-9-])-?(?:${SPACING_FAMILIES})-\\[[^\\]]+\\]`, 'g')
Comment thread
innolope-dev marked this conversation as resolved.
Outdated

function countOffScaleSpacing(text) {
let n = 0
for (const m of text.matchAll(NUMERIC_SPACING_RE)) if (!SPACING_STEPS.has(m[1])) n++
n += (text.match(ARBITRARY_SPACING_RE) ?? []).length
return n
}

// a type token carries its own weight, so a weight utility stacked next to one
// mints an off-ramp style. matching happens per className expression (the
// attribute's full string or brace-balanced JSX expression), so a token and a
// weight split across formatted lines inside one twMerge/clsx call still
// count; class strings held in variables outside className= are caught by a
// per-line pass over the remaining text.
const WEIGHT_STACK_RE = /\bfont-(?:bold|semibold|extrabold)\b/
Comment thread
innolope-dev marked this conversation as resolved.
Outdated
const TYPE_TOKEN_RE = /\btext-(?:body|heading|label|button)-[a-z-]+\b/

function classNameExpressions(text) {
const regions = []
const re = /className\s*=\s*/g
Comment thread
innolope-dev marked this conversation as resolved.
Outdated
let m
while ((m = re.exec(text))) {
const i = re.lastIndex
const c = text[i]
if (c === '"' || c === "'") {
const end = text.indexOf(c, i + 1)
if (end === -1) continue
regions.push({ start: i, end: end + 1 })
re.lastIndex = end + 1
} else if (c === '{') {
let depth = 1
let j = i + 1
while (j < text.length && depth > 0) {
if (text[j] === '{') depth++
else if (text[j] === '}') depth--
j++
}
regions.push({ start: i, end: j })
re.lastIndex = j
}
}
return regions
}

function countWeightStacks(text) {
let n = 0
const regions = classNameExpressions(text)
for (const r of regions) {
const expr = text.slice(r.start, r.end)
if (TYPE_TOKEN_RE.test(expr) && WEIGHT_STACK_RE.test(expr)) n++
}
let rest = ''
let cursor = 0
for (const r of regions) {
rest += text.slice(cursor, r.start)
cursor = r.end
}
rest += text.slice(cursor)
for (const line of rest.split('\n')) if (TYPE_TOKEN_RE.test(line) && WEIGHT_STACK_RE.test(line)) n++
Comment thread
innolope-dev marked this conversation as resolved.
Comment thread
innolope-dev marked this conversation as resolved.
Comment thread
innolope-dev marked this conversation as resolved.
Comment thread
innolope-dev marked this conversation as resolved.
return n
}

// three ways an Icon gets sized: the size prop (also width/height, which the
// component forwards), Button's iconSize, and — banned outright by the icon
// law — tailwind size-/h-/w- classes on the Icon itself.
const OFF_SCALE_ICON_RE =
/<Icon\s[^>]*?\b(?:size|width|height)=\{(?!16\}|20\}|24\})[0-9]+\}|\biconSize=\{(?!16\}|20\}|24\})[0-9]+\}|<Icon\s[^>]*?className="[^"]*\b(?:size|h|w)-[0-9]/g
Comment thread
innolope-dev marked this conversation as resolved.
Outdated

const OFF_SCALE_RADIUS_RE = /\brounded(?:-[trbl]{1,2})?-(?:md|lg|xl|2xl|3xl)\b/g
Comment thread
innolope-dev marked this conversation as resolved.
Outdated

// any numeric duration is off-token (the motion scale is instant/fast/
// moderate/slow); arbitrary values (duration-[250ms]) count too.
const RAW_DURATION_RE = /\bduration-(?:[0-9]+\b|\[[^\]]+\])/g

module.exports = {
SPACING_STEPS,
NUMERIC_SPACING_RE,
ARBITRARY_SPACING_RE,
countOffScaleSpacing,
countWeightStacks,
OFF_SCALE_ICON_RE,
OFF_SCALE_RADIUS_RE,
RAW_DURATION_RE,
}
2 changes: 1 addition & 1 deletion src/app/(mobile-ui)/add-money/[country]/bank/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@

useEffect(() => {
fetchUser()
}, [])

Check warning on line 185 in src/app/(mobile-ui)/add-money/[country]/bank/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useEffect has a missing dependency: 'fetchUser'. Either include it or remove the dependency array

const peanutWalletBalance = useMemo(() => {
return balance !== undefined ? formatAmount(formatUnits(balance, PEANUT_WALLET_TOKEN_DECIMALS)) : ''
Expand Down Expand Up @@ -476,7 +476,7 @@
<div className="space-y-8 flex flex-col justify-start">
<NavHeader title={t('title')} onPrev={onBack} />
<div className="my-auto flex flex-grow flex-col justify-center gap-4 md:my-0">
<div className="text-body-s font-bold">{t('howMuchToAdd')}</div>
<div className="text-label-l">{t('howMuchToAdd')}</div>
<AmountInput
initialAmount={rawTokenAmount}
setPrimaryAmount={handleTokenAmountChange}
Expand Down
4 changes: 2 additions & 2 deletions src/app/(mobile-ui)/dev/_components/DevSegmented.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ export default function DevSegmented<T extends string>({
aria-pressed={value === option.value}
onClick={() => onChange(option.value)}
className={twMerge(
'rounded-sm font-bold transition-colors',
size === 'sm' ? 'px-2 py-1 text-[11px]' : 'px-3 py-1.5 text-body-xs',
'rounded-sm transition-colors',
size === 'sm' ? 'px-2 py-1 text-[11px] font-bold' : 'px-3 py-1.5 text-label-m',
value === option.value
? 'bg-action-primary text-foreground-primary'
: 'text-foreground-secondary hover:bg-purple-200/40'
Expand Down
6 changes: 3 additions & 3 deletions src/app/(mobile-ui)/profile/backup/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export default function BackupPage() {

<Section title={t('enableNow')}>
<Card>
<ol className="space-y-4 list-decimal py-2 pl-5">
<ol className="space-y-4 list-decimal py-2 pl-6">
{backupSteps.map((step, index) => (
<li key={index}>
<p className="font-bold text-foreground-primary">{step.title}</p>
Expand Down Expand Up @@ -104,7 +104,7 @@ export default function BackupPage() {
titleClassName="text-heading-xs"
content={
<div className="space-y-3 w-full">
<ol className="list-decimal pl-5 text-left text-body-s text-foreground-primary">
<ol className="list-decimal pl-6 text-left text-body-s text-foreground-primary">
<li>{t('changePhoneModal.step1')}</li>
<li>{t('changePhoneModal.step2', { platform })}</li>
<li>{t('changePhoneModal.step3')}</li>
Expand Down Expand Up @@ -135,7 +135,7 @@ export default function BackupPage() {
<p className="mt-1 text-body-s text-foreground-primary">
{t('exportKeysModal.saferIntro')}
</p>
<ul className="space-y-1 mt-2 list-disc pl-5 text-body-s text-foreground-primary">
<ul className="space-y-1 mt-2 list-disc pl-6 text-body-s text-foreground-primary">
<li>{t('exportKeysModal.bullets.screenshot')}</li>
<li>{t('exportKeysModal.bullets.textMessage')}</li>
<li>{t('exportKeysModal.bullets.noteApp')}</li>
Expand Down
Loading
Loading