Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
61 changes: 61 additions & 0 deletions __tests__/unit/config/timeline-surface-shares-one-grid.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
76 changes: 76 additions & 0 deletions scripts/check-type-scale.mjs
Original file line number Diff line number Diff line change
@@ -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`);
4 changes: 2 additions & 2 deletions src/components/timeline/PostCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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}
/>
</div>

Expand Down
2 changes: 1 addition & 1 deletion src/components/timeline/PostContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ export function PostContent({ event }: PostContentProps) {
authors to tell apart.
*/}
{!articleSlug && displayContent && (
<div className="text-fg-primary text-[15px] leading-relaxed whitespace-pre-line break-words">
<div className="text-fg-primary text-post whitespace-pre-line break-words">
{renderMarkdownToReact(displayContent)}
</div>
)}
Expand Down
12 changes: 11 additions & 1 deletion src/components/timeline/PostHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,17 @@ export function PostHeader({
@{displayAuthor.username}
</Link>

<span className="text-fg-secondary">·</span>
{/*
`text-sm` is load-bearing, not decoration. With no size class this span
inherits the 16px base while every other item on the line — the name,
the handle, the timestamp — is 14px. The result is a separator dot
rendered LARGER than the things it separates, on every post in the
feed. Measured in production: 16px/400 among 14px siblings, the only
16px text anywhere in the timeline.
*/}
<span className="text-fg-secondary text-sm" aria-hidden="true">
·
</span>

{/* Timestamp */}
<time
Expand Down
9 changes: 8 additions & 1 deletion src/components/timeline/TimelineComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
TIMELINE_COPY,
TIMELINE_SURFACE,
TIMELINE_VISIBILITY_OPTIONS,
TIMELINE_AVATAR_SIZE,
} from '@/config/timeline';
import {
TextFormatToolbar,
Expand Down Expand Up @@ -207,7 +208,13 @@ const TimelineComposer = React.memo(function TimelineComposer({
userId={user?.id || null}
avatarUrl={profile?.avatar_url || user?.user_metadata?.avatar_url || null}
name={profile?.name || user?.user_metadata?.name || user?.email || 'User'}
size={44}
/*
The avatar sets the feed's text column, so this number must be
the SAME one the posts below use — it was 44 here and 40 there,
which started the composer's text 4px right of every post body.
Shared constant now, so they cannot drift apart again.
*/
size={TIMELINE_AVATAR_SIZE}
className="flex-shrink-0"
isCurrentUser={true}
/>
Expand Down
20 changes: 19 additions & 1 deletion src/config/timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,14 +65,32 @@ export function getTimelineVisibilityOption(
);
}

/**
* The avatar that sets the feed's text column, in pixels.
*
* Every row in the feed — the composer and every post — puts an avatar on the
* left and the text beside it, so the avatar's width IS the left edge of the
* text column. Two components rendering that row with different numbers put
* the feed on two grids: the composer used 44 and posts used 40, so the
* composer's text started 4px right of every post body underneath it.
*
* One number, read by both, so they cannot disagree again.
*/
export const TIMELINE_AVATAR_SIZE = 40;

export const TIMELINE_SURFACE = {
page: 'min-h-screen bg-surface-page text-fg-primary',
rail: 'mx-auto flex w-full max-w-6xl justify-center px-0 sm:px-4 lg:px-8',
feed: 'w-full max-w-2xl border-x border-subtle bg-surface-page',
header:
'sticky top-0 z-20 flex items-center justify-between border-b border-subtle bg-surface-page/90 px-4 py-3 backdrop-blur-xl sm:px-5',
composer: 'border-b border-subtle bg-surface-page px-4 py-4 sm:px-5',
post: 'border-b border-subtle bg-surface-page px-4 py-3 transition-colors hover:bg-surface-raised/35',
// `sm:px-5` matches header and composer above. Without it the feed ran on a
// SECOND grid 4px to the left of them: measured in production, the header
// and composer began at x=467 and every post at x=463. Scrolling past the
// composer shifted the whole avatar column sideways by 4px — small enough
// that you read it as sloppiness rather than seeing it.
post: 'border-b border-subtle bg-surface-page px-4 py-3 transition-colors hover:bg-surface-raised/35 sm:px-5',
selectedPost: 'bg-surface-raised/60 hover:bg-surface-raised/70',
buttonPrimary:
'rounded-md bg-fg-primary px-5 py-2 text-sm font-semibold text-fg-inverted transition-colors hover:bg-fg-primary/90 disabled:bg-surface-raised disabled:text-fg-secondary',
Expand Down
6 changes: 6 additions & 0 deletions tailwind.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ const config: Config = {
'2xs': ['0.625rem', { lineHeight: '1rem' }], // 10px - Badges, micro labels
xs: ['0.75rem', { lineHeight: '1rem', letterSpacing: '0' }], // 12px - Labels, captions
sm: ['0.875rem', { lineHeight: '1.25rem', letterSpacing: '0' }], // 14px - Secondary text
// 15px - the reading size for post bodies. Deliberately between sm and
// base: 16px is a touch wide in a 672px column and 14px is small to
// read at length. It was written as the arbitrary `text-[15px]`, which
// is a value nothing else can reference and no theme change can reach —
// the same problem as a hardcoded hex. Named, it is a decision.
post: ['0.9375rem', { lineHeight: '1.5rem', letterSpacing: '0' }],
base: ['1rem', { lineHeight: '1.5rem', letterSpacing: '0' }], // 16px - Body text (iOS standard)
lg: ['1.125rem', { lineHeight: '1.75rem', letterSpacing: '0' }], // 18px - Large body
xl: ['1.25rem', { lineHeight: '1.75rem', letterSpacing: '0' }], // 20px - Subtitles
Expand Down
Loading