Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
107 changes: 107 additions & 0 deletions scripts/__tests__/ds-lint-rules.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { countOffScaleSpacing, 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('does not misread longer utilities or words as spacing classes', () => {
for (const text of [
'className="max-p-5"',
'theme-3',
'frame-2',
'className="p-[13px]"',
'className="ms-auto"',
]) {
expect(countOffScaleSpacing(text)).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": 13,
"offScaleSpacing": 181,
"iconOffScale": 64,
"offScaleRadius": 33,
"rawDuration": 9
}
36 changes: 36 additions & 0 deletions scripts/ds-lint-counts.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import { readdirSync, readFileSync, writeFileSync } from 'node:fs'
import { join, relative, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { countOffScaleSpacing, 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 +253,36 @@ 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.
const WEIGHT_STACK_RE = /\bfont-(?:bold|semibold|extrabold)\b/
const TYPE_TOKEN_RE = /\btext-(?:body|heading|label|button)-[a-z-]+\b/
counts.fontWeightOnTypeToken = files
.filter((f) => isTsx(f) && !allowed(f.path))
Comment thread
innolope-dev marked this conversation as resolved.
Outdated
.reduce(
(sum, f) => sum + f.text.split('\n').filter((l) => TYPE_TOKEN_RE.test(l) && WEIGHT_STACK_RE.test(l)).length,
Comment thread
innolope-dev marked this conversation as resolved.
Outdated
0
)
// matchers live in ds-lint-rules.mjs (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 +297,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
41 changes: 41 additions & 0 deletions scripts/ds-lint-rules.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// 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.
const SPACING_STEPS = new Set(['0', '0.5', '1', '2', '3', '4', '6', '8', '10', '12', '14', '16'])
const NUMERIC_SPACING_RE =
/(?<![a-z0-9-])-?(?: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)-([0-9]+(?:\.[0-9]+)?)(?![0-9.a-z%\]])/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++
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,
countOffScaleSpacing,
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
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
6 changes: 3 additions & 3 deletions src/app/(mobile-ui)/qr-pay/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1454,7 +1454,7 @@ export default function QRPayPage() {
<PointsCard points={pointsData.estimatedPoints} pointsDivRef={pointsDivRef} />
)}

<div className="space-y-5 w-full">
<div className="space-y-4 w-full">
{/* Show Claim Reward button if eligible and not claimed yet */}
{rewardClaimable ? (
<Button
Expand Down Expand Up @@ -1486,7 +1486,7 @@ export default function QRPayPage() {
>
{/* progress fill from left to right */}
<div
className="absolute inset-0 bg-black transition-all duration-100"
className="absolute inset-0 bg-black transition-all duration-instant"
style={{
width: `${holdProgress}%`,
left: 0,
Expand All @@ -1498,7 +1498,7 @@ export default function QRPayPage() {
<>
<span className="relative z-10">{label}</span>
<span
className="absolute inset-0 z-20 flex items-center justify-center text-white transition-all duration-75"
className="absolute inset-0 z-20 flex items-center justify-center text-white transition-all duration-instant"
style={{ clipPath: `inset(0 ${100 - holdProgress}% 0 0)` }}
>
{label}
Expand Down
2 changes: 1 addition & 1 deletion src/app/(mobile-ui)/qr/[code]/success/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export default function RedirectQrSuccessPage() {
<div className="flex gap-3">
<Icon name="star" size={20} className="flex-shrink-0 text-action-secondary" />
<div className="space-y-1">
<p className="text-body-s font-bold">{t('claimSuccess.putItAnywhere')}</p>
<p className="text-label-l">{t('claimSuccess.putItAnywhere')}</p>
<p className="text-body-xs text-foreground-secondary">
{t('claimSuccess.stickerDescription')}
</p>
Expand Down
4 changes: 2 additions & 2 deletions src/app/(mobile-ui)/rewards/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@
useEffect(() => {
// re-fetch user to get the latest invitees list for showing heart icon
fetchUser()
}, [])

Check warning on line 106 in src/app/(mobile-ui)/rewards/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

// isPending, not isLoading: both queries wait on `user`, and a disabled query
// reports isLoading false. isLoading would send the first paint to the error
Expand Down Expand Up @@ -206,7 +206,7 @@
/>
<div className="relative h-1 flex-1 overflow-hidden rounded-full bg-background-disabled">
<div
className="h-full rounded-full bg-gradient-to-r from-action-primary to-action-primary-hover transition-all duration-500"
className="h-full rounded-full bg-gradient-to-r from-action-primary to-action-primary-hover transition-all duration-slow"
style={{
width: `${
tierInfo?.data.currentTier >= 2
Expand Down Expand Up @@ -284,7 +284,7 @@
onClick={() => router.push(profileUrl(user.invitedBy!))}
className="inline-flex cursor-pointer items-center gap-1 font-bold"
>
{user.invitedBy} <Icon name="invite-heart" size={14} />
{user.invitedBy} <Icon name="invite-heart" size={16} />
</span>{' '}
{t('invitedYou')}{' '}
</>
Expand Down
2 changes: 1 addition & 1 deletion src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -409,7 +409,7 @@

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

Check warning on line 412 in src/app/(mobile-ui)/withdraw/[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

// Balance validation
useEffect(() => {
Expand Down Expand Up @@ -454,7 +454,7 @@
/>

{view === 'INITIAL' && (
<div className="my-auto space-y-4 flex h-full w-full flex-col justify-center pb-5">
<div className="my-auto space-y-4 flex h-full w-full flex-col justify-center pb-4">
<PeanutActionDetailsCard
countryCodeForFlag={countryCodeForFlag()}
avatarSize="small"
Expand Down
2 changes: 1 addition & 1 deletion src/app/(mobile-ui)/withdraw/crypto/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -624,7 +624,7 @@ export default function WithdrawCryptoPage() {
}

return (
<div className="mx-auto space-y-4 min-h-[inherit] w-full max-w-md self-center">
<div className="mx-auto flex min-h-[inherit] w-full max-w-md flex-col gap-4 self-center">
{currentView === 'INITIAL' && (
<InitialWithdrawView
amount={usdAmount}
Expand Down
2 changes: 1 addition & 1 deletion src/app/(mobile-ui)/withdraw/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@
if (amountFromContext) {
setShowAllWithdrawMethods(true)
}
}, [])

Check warning on line 204 in src/app/(mobile-ui)/withdraw/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useEffect has missing dependencies: 'amountFromContext' and 'setShowAllWithdrawMethods'. Either include them or remove the dependency array

const validateAmount = useCallback(
(amountStr: string): boolean => {
Expand Down Expand Up @@ -245,7 +245,7 @@
setError({ showError: true, errorMessage: message })
return false
},
[balance, maxDecimalAmount, setError, selectedTokenData?.price, isFromSendFlow, minUsdAmount, t, tErrors]

Check warning on line 248 in src/app/(mobile-ui)/withdraw/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useCallback has an unnecessary dependency: 'selectedTokenData.price'. Either exclude it or remove the dependency array
)

const handleTokenAmountChange = useCallback(
Expand Down Expand Up @@ -405,7 +405,7 @@
const showLimitsCard = !isCryptoWithdraw && (limitsValidation.isBlocking || limitsValidation.isWarning)

return (
<div className="space-y-8 flex min-h-[inherit] flex-col justify-start">
<div className="flex min-h-[inherit] flex-col justify-start gap-8">
<NavHeader
title={isFromSendFlow ? tNav('send') : tNav('withdraw')}
onPrev={() => {
Expand Down
2 changes: 1 addition & 1 deletion src/app/(setup)/setup/finish/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ function FinishSetupPageContent() {
description={t('finish.description')}
showBackButton={false}
showSkipButton={false}
contentClassName="flex flex-col items-center justify-center gap-5"
contentClassName="flex flex-col items-center justify-center gap-6"
>
<SignTestTransaction />
</SetupWrapper>
Expand Down
2 changes: 1 addition & 1 deletion src/app/(setup)/setup/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,7 @@
return () => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
}
}, [dispatch, steps, inviteCodeParam, legacyStepParam])

Check warning on line 293 in src/app/(setup)/setup/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useEffect has missing dependencies: 'detectedDeviceType' and 'inviteCode'. Either include them or remove the dependency array

useEffect(() => {
if (step) {
Expand All @@ -315,7 +315,7 @@
image={PeanutWavingHello.src}
title={t('existingSession.title')}
description={t('existingSession.description', { username: existingSessionUsername })}
contentClassName="flex flex-col items-center justify-center gap-5"
contentClassName="flex flex-col items-center justify-center gap-6"
>
<div className="flex w-full flex-col gap-3">
<Button shadowSize="4" onClick={handleContinueSession} disabled={isLoggingOut}>
Expand Down
2 changes: 1 addition & 1 deletion src/app/kyc/success/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export default function KycSuccessPage() {
<div className="flex h-screen min-h-full w-full flex-col items-center justify-center gap-4">
<Image src={HandThumbsUp} alt="Peanut HandThumbsUp" className="size-34" />
<div className="space-y-2">
<p className="text-body-l font-semibold">{t('successTitle')}</p>
<p className="text-heading-card">{t('successTitle')}</p>
<p className="text-body-s text-foreground-secondary">{t('successCloseWindow')}</p>
</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/components/0_Bruddle/DataRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export const DataRow = ({
{moreInfoText && (
<div className="relative z-20 flex items-center justify-center px-2">
<Tooltip content={moreInfoText} position="right">
<Icon name="info" size={12} />
<Icon name="info" size={16} />
</Tooltip>
</div>
)}
Expand Down
6 changes: 3 additions & 3 deletions src/components/AddMoney/components/ChainChip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ interface ChainChipProps {

const ChainChip = ({ chainName, chainSymbol, logo, logoClassName }: ChainChipProps) => {
return (
<Card className="flex w-fit flex-row items-center gap-1 rounded-full border-0 bg-transparent p-1 px-1.5">
{chainSymbol && <Image src={chainSymbol} alt={chainName} width={18} height={18} />}
{logo && <Icon name={logo} width={18} height={18} className={logoClassName} />}
<Card className="flex w-fit flex-row items-center gap-1 rounded-full border-0 bg-transparent p-1 px-2">
{chainSymbol && <Image src={chainSymbol} alt={chainName} width={16} height={16} />}
{logo && <Icon name={logo} width={16} height={16} className={logoClassName} />}
<p className="text-body-xs text-foreground-primary">{chainName}</p>
</Card>
)
Expand Down
Loading
Loading