diff --git a/__tests__/unit/config/timeline-surface-shares-one-grid.test.ts b/__tests__/unit/config/timeline-surface-shares-one-grid.test.ts new file mode 100644 index 000000000..c67be72ec --- /dev/null +++ b/__tests__/unit/config/timeline-surface-shares-one-grid.test.ts @@ -0,0 +1,61 @@ +/** + * The header, the composer and every post sit on ONE grid. + * + * Each is a full-width band in the same column, so their horizontal padding + * decides where their content begins. When they disagree, the feed runs on two + * grids and scrolling past the composer shifts the avatar column sideways. + * + * That is exactly what shipped: `header` and `composer` carried `sm:px-5`, + * `post` carried only `px-4`. Measured in production at 1322px — header and + * composer content began at x=467, every post at x=463. Four pixels, on every + * post, forever. Too small to see and too consistent to ignore; it reads as + * sloppiness rather than as a bug. + * + * A test rather than a code comment because the classes live in three separate + * strings, and nothing about editing one of them suggests looking at the other + * two. + */ + +import { TIMELINE_SURFACE, TIMELINE_AVATAR_SIZE } from '@/config/timeline'; + +/** The horizontal padding utilities on a class string, in order. */ +function horizontalPadding(classes: string): string[] { + return classes + .split(/\s+/) + .filter(c => /(^|:)px-/.test(c)) + .sort(); +} + +describe('the timeline surfaces share one grid', () => { + const bands = { + header: TIMELINE_SURFACE.header, + composer: TIMELINE_SURFACE.composer, + post: TIMELINE_SURFACE.post, + }; + + it('gives every band the same horizontal padding at every breakpoint', () => { + const padding = Object.fromEntries( + Object.entries(bands).map(([name, classes]) => [name, horizontalPadding(classes)]) + ); + + expect(padding.post).toEqual(padding.composer); + expect(padding.post).toEqual(padding.header); + }); + + it('actually declares padding, so the check cannot pass vacuously', () => { + // Three empty arrays are also "equal". Without this, deleting px-* from + // all three would satisfy the test above while breaking the layout. + for (const [name, classes] of Object.entries(bands)) { + expect(horizontalPadding(classes).length).toBeGreaterThan(0); + expect(classes).toContain('px-4'); + expect(name).toBeTruthy(); + } + }); + + it('has one avatar size for the whole feed', () => { + // The avatar's width sets the left edge of the text column in every row. + // The composer used 44 while posts used 40, so the composer's text began + // 4px right of every post body underneath it. + expect(TIMELINE_AVATAR_SIZE).toBe(40); + }); +}); diff --git a/package.json b/package.json index 3a199cdf9..777d37d81 100644 --- a/package.json +++ b/package.json @@ -40,11 +40,12 @@ "check:rpc-exists": "node scripts/check-rpc-exists.mjs", "check:one-current-user": "node scripts/check-one-current-user.mjs", "check:app-locale": "node scripts/check-app-locale.mjs", + "check:type-scale": "node scripts/check-type-scale.mjs", "check:client-ip": "node scripts/check-client-ip.mjs", "check:user-scoped-deletes": "node scripts/check-user-scoped-deletes.mjs", "check:ai-models": "node scripts/check-ai-models.mjs", "check:mdx": "node scripts/check-mdx.mjs", - "verify": "npm run ci:docs && npm run check:accent-ink && npm run type-check && npm run type-check:scripts && npm run check:sizes && npm run audit:routes && npm run lint && npm run check:duplication && npm run check:dead-fields && npm run check:dead-labels && npm run check:migration-versions && npm run check:schema-columns && npm run check:currency-units && npm run check:rpc-exists && npm run check:one-current-user && npm run check:app-locale && npm run check:client-ip && npm run check:user-scoped-deletes && npm run check:mdx && npm run test:unit -- --watchAll=false", + "verify": "npm run ci:docs && npm run check:accent-ink && npm run type-check && npm run type-check:scripts && npm run check:sizes && npm run audit:routes && npm run lint && npm run check:duplication && npm run check:dead-fields && npm run check:dead-labels && npm run check:migration-versions && npm run check:schema-columns && npm run check:currency-units && npm run check:rpc-exists && npm run check:one-current-user && npm run check:app-locale && npm run check:type-scale && npm run check:client-ip && npm run check:user-scoped-deletes && npm run check:mdx && npm run test:unit -- --watchAll=false", "audit:schema": "node scripts/db/audit-schema-drift.mjs", "audit:routes": "node scripts/audit-routes.mjs", "gen:types": "bash scripts/db/gen-types.sh", diff --git a/scripts/check-type-scale.mjs b/scripts/check-type-scale.mjs new file mode 100644 index 000000000..28705fd41 --- /dev/null +++ b/scripts/check-type-scale.mjs @@ -0,0 +1,76 @@ +#!/usr/bin/env node +/** + * Font sizes come from the scale, not from square brackets. + * + * `tailwind.config.ts` defines the type scale — 2xs / xs / sm / post / base / + * lg / xl / 2xl … — and an arbitrary `text-[15px]` bypasses it. That value is + * one nothing else can reference, no theme change can reach, and no audit can + * find without knowing to look for it. It is the same defect as a hardcoded + * hex in a `bg-[#…]`, which this repo already forbids. + * + * Measured in the production timeline before this gate existed: ten distinct + * size/weight pairs in one feed, including body copy at an arbitrary 15px and + * a separator dot rendering 16px among 14px siblings. Ten decisions where + * five were intended. + * + * This is a RATCHET, not a wall. Thirteen instances predate it, spread across + * settings, messaging and form components. The count may fall or hold. It may + * never rise: a new one is a build failure, and fixing an old one means + * lowering the number below, in the same commit, so the improvement is + * recorded rather than quietly re-spent. + */ + +import { readFileSync } from 'node:fs'; +import { execSync } from 'node:child_process'; + +/** + * Lower this when you remove instances. Never raise it. + * + * 14 → 13 on 2026-08-29: the timeline's post body moved from `text-[15px]` to + * the named `text-post` token. + */ +const BASELINE = 13; + +const ARBITRARY_SIZE = /\btext-\[[0-9]/; + +const files = execSync('git ls-files "src/**/*.ts" "src/**/*.tsx"', { encoding: 'utf8' }) + .split('\n') + .filter(Boolean); + +const hits = []; +for (const file of files) { + readFileSync(file, 'utf8') + .split('\n') + .forEach((line, i) => { + if (ARBITRARY_SIZE.test(line)) { + hits.push({ file, line: i + 1, text: line.trim().slice(0, 90) }); + } + }); +} + +if (hits.length > BASELINE) { + console.error( + `✗ Arbitrary font sizes rose to ${hits.length} (baseline ${BASELINE}).\n` + ); + for (const h of hits) { + console.error(` ${h.file}:${h.line}`); + console.error(` ${h.text}`); + } + console.error(''); + console.error('Use a size from the scale in tailwind.config.ts, or add a NAMED one'); + console.error('there if the size is a real decision (as `post` = 15px is).'); + console.error('An arbitrary value is unreachable by theming and invisible to audits.'); + process.exit(1); +} + +if (hits.length < BASELINE) { + console.error( + `✗ Arbitrary font sizes fell to ${hits.length}, below the baseline of ${BASELINE}.` + ); + console.error(''); + console.error(`Good — now lower BASELINE to ${hits.length} in scripts/check-type-scale.mjs`); + console.error('so the ratchet holds the ground you just took.'); + process.exit(1); +} + +console.log(`check:type-scale passed — ${hits.length} arbitrary font sizes, at the baseline`); diff --git a/src/components/timeline/PostCard.tsx b/src/components/timeline/PostCard.tsx index 1c0b15b3f..c5e647f86 100644 --- a/src/components/timeline/PostCard.tsx +++ b/src/components/timeline/PostCard.tsx @@ -18,7 +18,7 @@ import { Check } from 'lucide-react'; import { usePostCardActions } from './usePostCardActions'; import ReplyAiButton from './ReplyAiButton'; import PostAiEditMenu from './PostAiEditMenu'; -import { TIMELINE_SURFACE } from '@/config/timeline'; +import { TIMELINE_SURFACE, TIMELINE_AVATAR_SIZE } from '@/config/timeline'; interface PostCardProps { event: TimelineDisplayEvent; @@ -153,7 +153,7 @@ export function PostCard({ isSimpleRepost ? event.metadata?.original_actor_avatar : event.actor?.avatar } name={isSimpleRepost ? event.metadata?.original_actor_name : event.actor?.name} - size={40} + size={TIMELINE_AVATAR_SIZE} /> diff --git a/src/components/timeline/PostContent.tsx b/src/components/timeline/PostContent.tsx index 83fc8789b..0e04f9590 100644 --- a/src/components/timeline/PostContent.tsx +++ b/src/components/timeline/PostContent.tsx @@ -124,7 +124,7 @@ export function PostContent({ event }: PostContentProps) { authors to tell apart. */} {!articleSlug && displayContent && ( -