It didn’t start with an audit. It started with a nagging feeling: the interface looked restless. You don’t notice it at first glance, but on the second and third look it is there — a timestamp slightly larger here than there, a dialog title a touch smaller than in the neighboring dialog. Everyone knows the discipline from letters and résumés: same typeface, same font size, same alignment. The same holds for an application interface, except the violation doesn’t show up in any one spot. It shows up as a diffuse restlessness across many screens.
Only that feeling led to a counting command across the frontend, and the count delivered the explanation: 799 hits for raw pixel font sizes like text-[13px], spread across 25 distinct pixel values in 74 files. And that in a project which had a documented font-size scale with six tokens all along. The scale was used 263 times and bypassed roughly 1,180 times. This is not a sloppiness finding from some legacy codebase grown over a decade — it is the state of a codebase built AI-assisted with Claude Code from day one. This is what AI code drift looks like: every single suggestion is locally plausible, and what adds up is the restlessness you can see before you can measure it.
This article is the experience report — with the real numbers, the rule that ended the problem, and the honest admission that a convention living only in prose loses against a language model.
The key points up front:
- The finding: 799 raw pixel font sizes against 263 token usages, even though the scale was documented. The ratio is the message, not the single number.
- The biggest source of drift lies between the tokens: values like
11pxand12px, for which no token existed at all, form the majority at roughly 400 occurrences. - No code review catches this, because every diff is harmless on its own.
- Better prompts raise the hit rate but don’t eliminate the drift — at high generation volume, any residual rate becomes visible.
- What holds: a 6-step scale, a fixed element-to-token mapping, an unambiguous snap rule for all in-between values, and a linter at
errorlevel.
Prerequisites: The example uses Tailwind CSS and ESLint in a Next.js project. The pattern applies to any codebase with design tokens, regardless of framework — and, as the end of the article shows, just as much to SQL conventions.
Contents
- The Finding: 799 Against 263
- Why Nobody Caught It
- The Drift Lives Between the Tokens
- The “Better Prompts” Fallacy
- What Works: Scale, Category Mapping, Snap Rule
- Even the Fresh Rule Drifted
- Enforcement: A Linter at error
- What the Linter Cannot Do
- The Same Pattern in SQL
- FAQ
- Related Articles
The Finding: 799 Against 263
The project behind the numbers is DI², an ETL generator built on Next.js and PostgreSQL whose code was written almost entirely AI-assisted. For font sizes, a clear convention existed: six named tokens from text-di-h1 (18px) down to text-di-label (10px), defined in the Tailwind configuration and described in a brand rule file that the agent loads for every frontend task. Everything that follows is therefore the measurement of a single project — one data point, not proof. What makes the case interesting beyond the project is the mechanism behind it, and that mechanism, as we will see, is not project-specific.
The inventory on June 25, 2026 across all src/**/*.tsx files produced three categories:
| Declaration style | Occurrences | Files | Assessment |
|---|---|---|---|
Raw pixels text-[Xpx] | 799 | 74 | drift, 25 distinct pixel values |
Tailwind defaults text-xs/text-sm/text-base … | 383 | 85 | drift within the app scope |
Canonical tokens text-di-* | 263 | 43 | the target pattern |
The three rows distinguish three ways of declaring the same font size. text-[13px] is Tailwind’s arbitrary-value syntax: the pixel value sits literally inside square brackets and acts like an inline font-size — any value is possible, and that is how 25 different ones come into being. text-xs, text-sm and text-base, on the other hand, are named size steps, but they belong to the framework’s bundled default scale (12, 14 and 16 pixels). That looks disciplined, because it follows a scale. It is just the wrong one: Tailwind’s generic scale instead of the project’s own, whose six tokens don’t even contain those three values. The third row, finally, is the project’s own scale — the target pattern. Read top to bottom, the table is a ladder: freehand value, foreign scale, own scale.
That two scales coexist is not an accident, by the way — it is the framework’s default. Tailwind ships its complete size ladder with every project, and the project’s own tokens were added via theme.extend — and extend means exactly that: extend, not replace. From that point on, every text-sm compiles just as happily as every text-di-body. There is no moment at which the project consents to the foreign scale, and none at which it announces itself. It is simply there, from day one, as the statistically closest choice for anyone who needs small text — human or model.
A second term needs an explanation, because it recurs throughout the article. The app scope is the application behind the login: the dashboard and administration pages plus the components they render. The token scale applies only there. Outside it lie two zones with rights of their own — the public pages (landing, legal, login), which deliberately carry larger formats, and the bundled UI base components, which internally work with Tailwind defaults. A text-sm is therefore not wrong per se; on a marketing page it is legitimate. That is why the second row says drift within the app scope: of the 383 occurrences, only the share inside the application counts as drift.
The convention, then, was ignored at a ratio of roughly 1:4.5. To rerun the inventory in your own project, all you need is a search tool like ripgrep (the examples are written in PowerShell):
1: # Raw pixel font sizes: total number of occurrences
2: rg --no-filename -o 'text-\[[0-9.]+px\]' src | Measure-Object -Line
3:
4: # Distribution: which pixel value occurs how often?
5: rg --no-filename -o 'text-\[[0-9.]+px\]' src | Group-Object | Sort-Object Count -Descending
6:
7: # For comparison: the canonical tokens
8: rg --no-filename -o 'text-di-(h1|h2|body|meta|micro|label)' src | Measure-Object -Line
The timeline delivers a punchline of its own. Between the first count and the recount three weeks later, shortly before the cleanup migration started, the total grew from 799 to 813 occurrences and from 25 to 26 distinct values. The drift kept growing while its removal was already being planned. A convention that is not enforced doesn’t lose once — it loses a little more every day.
Why Nobody Caught It
The first reflex at a number like this: how did that slip through? The answer is uncomfortable because it describes no negligence, but a structural gap.
A text-[12px] is not wrong in any single diff. It renders correctly, it looks fine in the preview, it breaks no test. A reviewer reading the diff of a new dialog checks the logic, the states, the accessibility. They do not compare whether the font size in this file is consistent with the one in 73 other files. The drift doesn’t live in any one file. It lives between the files.
It was visible all the same — just not as a defect, but as the nagging feeling from the beginning. A restless interface shows the symptom, not the location: which of the 74 files do you point at when no single line is wrong on its own? So the impression stayed without consequence for a long time. It couldn’t be pinned to any diff, and what can’t be pinned to a diff ends up in no review comment and no ticket. Visual regression tests usually don’t catch this form of inconsistency either, because they compare each view against its own baseline. Two views that are both brand new share no baseline against which the difference could stand out. Only the inventory turned the feeling into a finding with numbers — and thereby into something fixable.
Then there is the speed. A person who builds one dialog a day makes a handful of font-size decisions per week, and muscle memory keeps them reasonably stable. An agent that creates twenty components in the same week makes the same decision a hundred times over — and each one is optimized locally, not for consistency with all the previous ones. The inconsistency doesn’t come from carelessness; it comes from the sheer volume of independent single decisions. The densest single file in the inventory accounted for 70 raw pixel sizes on its own.
Humans produce the same drift — honesty demands saying so. The difference is not the kind of mistake but the pace and the volume. What a team accumulates in two years of wild growth, AI-assisted development manages in a quarter.
The Drift Lives Between the Tokens
The most revealing part of the inventory is the distribution of the 25 pixel values. It splits into three classes, and the middle one is the interesting one:
- Exact token matches: values that correspond to a token, just written raw —
13pxinstead oftext-di-body(166 occurrences),11.5pxinstead oftext-di-meta(46), plus the remaining token values. This class is mechanically repairable and visually a no-op. - Off-token values: roughly 400 occurrences on values for which no token exists at all — led by
11pxwith 196 and12pxwith 143 occurrences, plus12.5px(42) and14px(19). - Deliberate exceptions: marketing and legal pages with large formats of their own, such as
22pxor44px, which intentionally sit outside the app scale.
The second class deserves the second look. 11px and 12px both sit next to the same token, text-di-meta (11.5px), and both were later migrated to it. Together that is 339 places where two different pixel values played the same semantic role — meta text, timestamps, helper lines. Nobody ever decided that both values should exist. There is no commit with the message “we are introducing 11px as an alternative to 12px.” Both values simply came into being, suggestion by suggestion, because each looked reasonable on its own.
That is what separates this drift from a copying error. Someone who mistypes a documented value produces a findable defect. Someone who puts plausible values into a gap of the scale a hundred times over produces a creeping second scale that was never decided anywhere and therefore never stands out anywhere. That is AI code drift in its purest form.
The “Better Prompts” Fallacy
The obvious reaction to the finding would have been to state the convention more forcefully. Add a chapter to the brand rule, repeat the tokens in the prompt, instruct the agent more insistently.
That doesn’t carry far, and the reason lies in how the model works. A language model is trained on vast amounts of public code, among it countless Tailwind projects, and there text-sm or text-[12px] is the overwhelmingly most common way to write small text. A project-specific convention like text-di-meta, by contrast, is exactly one file in a rules directory. At every single generation the rule competes against that weight, and it wins often — but not always. Across a thousand decisions, a hit rate of 90 percent still leaves a hundred drift spots, and even a considerably higher rate ends up in the dozens at sufficient volume.
Rules in prose improve the rate, and rules with reasons improve it further. How a ruleset comes into being that an agent actually follows is described in the methodology article Deriving SQL Conventions with Claude Code. But any rate below 100 percent means drift at high volume. For a convention that is meant to hold without exception, the prompt is the wrong tool. It needs a check that doesn’t get tired.
What Works: Scale, Category Mapping, Snap Rule
The cleanup consisted of three building blocks that only work in combination.
First, the scale itself. Six steps, defined in exactly one place in the Tailwind configuration, each with its line height built in:
1: // tailwind.config.ts — the scale as the single source of truth
2: fontSize: {
3: 'di-h1': ['18px', { lineHeight: '23.4px' }],
4: 'di-h2': ['15px', { lineHeight: '21px' }],
5: 'di-body': ['13px', { lineHeight: '18.85px' }],
6: 'di-meta': ['11.5px', { lineHeight: '15px' }],
7: 'di-micro': ['10.5px', { lineHeight: '13.65px' }],
8: 'di-label': ['10px', { lineHeight: '12px', letterSpacing: '0.05em' }],
9: },
An important decision was to keep the scale at six steps. The tempting alternative would have been to create new tokens for 11px and 12px and thereby legalize the status quo. That would have turned the drift into an official eight-step scale, and the next in-between value would have found a gap again.
Second, the element-category mapping. A table in the rule file defines which kind of element carries which token. Section headings get di-h1, dialog titles di-h2, table cells and buttons di-body, timestamps and helper texts di-meta, counter pills di-micro, uppercase labels di-label. With that, “what size does this element need?” is no longer a matter of taste but a lookup.
Third, the snap rule. The name says it: roughly 400 occurrences sat on in-between values like 11px or 12px, and during the migration each of them had to snap onto one of the six tokens — like an object being pulled onto the grid in a graphics editor. Which token it becomes is decided in two stages. First comes the element’s role from the category mapping: a timestamp gets di-meta because it is meta text, no matter which pixel value used to be there. Only when an occurrence cannot be assigned to any category does numeric proximity to the nearest token decide. Even the single tie is settled explicitly: 14px sits exactly between di-body (13px) and di-h2 (15px), and di-body wins as the default. Role before number is not a formality, either. A 12px in a sticky table header belongs to di-micro (10.5px) by category, although di-meta (11.5px) would be numerically closer. And because the rule leaves no room for judgment, two people — or two agent runs — resolve the same raw value identically, guaranteed.
With those three building blocks the migration itself was unspectacular: 80 files in the app scope, converted category by category, with shifts of less than a pixel. Exactly one deliberate exception remained — a large page title outside the app scale, marked with an inline comment and a justification.
One side finding from the same cleanup deserves a mention for completeness: the tokens had to be explicitly registered as font-size classes in the tailwind-merge configuration, because the library otherwise classifies unknown text-* classes as text color and silently discards one of them on conflict. That, however, is an ordinary trap when introducing custom tokens and has nothing to do with AI. Know it, pin it with a small test, done.
Even the Fresh Rule Drifted
The most instructive finding of the whole story comes from the migration’s quality assurance, and it cuts against the author.
The category rule — “the element category wins over pixel proximity” — was written into the rule file in the same commit that carried the migration. And in that very commit it was undercut: the data cells of the densest table registers, previously 12px, belonged to di-body (13px) according to the category table. They were migrated numerically to di-meta (11.5px) instead, because that matched the density intent of those views. The freshly written rule and its first application contradicted each other, and it surfaced only in the downstream QA pass, which filed it as a documentation inconsistency.
You can consider the finding small — half a pixel in dense tables. Its value lies elsewhere: it shows that even a carefully worded, freshly printed rule drifts at the moment of its application when only humans and prose carry it. Not out of ignorance, but because a second legitimate consideration intervened in the concrete case and nobody checked against the wording. This rule-based check is one of the tasks a machine performs more reliably than any participant.
Enforcement: A Linter at error
The fourth building block therefore makes the convention machine-checkable. The tool class is secondary: a compiler check, a Tailwind plugin or a CI script can play the same role. For this project, a custom ESLint rule was the most practical form — it reports every raw pixel font size and every Tailwind size default in the app scope as an error, not a warning. The core of the rule fits on a page:
1: // eslint-rules/no-raw-font-size.mjs — the core of the rule
2: const PX_RE = /text-\[(\d+(?:\.\d+)?)px\]/g
3: const DEFAULT_RE = /(?<![\w-])text-(xs|sm|base)(?![\w-])/g
4:
5: // Snap table: raw value -> canonical token (from the rule file)
6: const PX_SNAP = {
7: '11': 'text-di-meta',
8: '12': 'text-di-meta',
9: '12.5': 'text-di-body',
10: '13': 'text-di-body',
11: '14': 'text-di-body',
12: '15': 'text-di-h2',
13: }
14:
15: export default {
16: meta: { type: 'problem', messages: {
17: rawPx: 'Raw pixel font size `{{match}}`. Use {{target}}.',
18: } },
19: create(context) {
20: const check = (node, raw) => {
21: for (const m of raw.matchAll(PX_RE)) {
22: context.report({ node, messageId: 'rawPx', data: {
23: match: m[0], target: PX_SNAP[m[1]] ?? 'a token from the scale',
24: } })
25: }
26: // DEFAULT_RE analogous
27: }
28: return {
29: Literal(node) { if (typeof node.value === 'string') check(node, node.value) },
30: TemplateElement(node) { check(node, node.value?.cooked ?? '') },
31: }
32: },
33: }
Three decisions have proven themselves:
- The rule inspects all string literals and template parts, not just JSX attributes (lines 29 and 30). In practice, class strings also arise in
cn()arguments, in.join(" ")helpers and in exported constants — a rule that only seesclassName="…"would have blind spots there. - The error message names the snap target (line 23). Whoever sees the error sees the fix and doesn’t have to go find the rule file first. That holds for human readers just as much as for the agent reacting to the linter error.
error, notwarn. A warning is a number in a summary; an error breaks the build. Only the second one is enforcement. The halfway path — warnings plus occasional cleanup — ends up reproducing the very state that led to the drift.
One obvious alternative deserves a mention because it looks simpler than it is: remove Tailwind’s default scale from the configuration altogether by defining fontSize without extend. Then text-sm simply would not exist anymore. That fails on two counts. An unknown text-sm produces no error in Tailwind — it produces no CSS at all, so the text would silently fall back to the browser’s default size, and a silent failure like that is harder to find than the drift it is meant to prevent. And the bundled UI base components, like the marketing pages, build on exactly those default classes internally. The radical fix would break the very zones that legitimately live off the standard scale. A linter with a scope can express that; a global configuration cannot.
The deliberate exceptions therefore live not in the rule but in the ESLint configuration: a path list exempts marketing, legal and login pages, whose large formats intentionally sit outside the app scale. That records the convention’s scope machine-readably in one place, congruent with the scope in the rule file. The direction of the definition is worth noting: the app scope itself is never listed anywhere — it is simply everything that was not exempted. A new public page that nobody adds to the exemption list is treated as app scope, and the guard flags its large formats as errors. That is the right direction to fail: a forgotten list entry shows up as a loud false alarm in the build instead of slipping through as silent drift.
1: // eslint.config.mjs — carve-out as a path list (abridged)
2: const FONT_SIZE_CARVE_OUT = [
3: 'src/app/page.tsx', // landing: deliberately larger formats
4: 'src/app/impressum/**',
5: 'src/app/login/**',
6: 'src/**/*.test.{ts,tsx}', // tests reference classes as test data
7: ]
8:
9: export default [
10: { files: ['src/**/*.{ts,tsx}'], rules: { 'di2/no-raw-font-size': 'error' } },
11: { files: FONT_SIZE_CARVE_OUT, rules: { 'di2/no-raw-font-size': 'off' } },
12: ]
One sequencing detail to close: the guard was activated as the last step, after the app scope had been migrated clean. The other way around, every unfinished file would have needed a temporary exemption list, and temporary exemption lists have a tendency to become permanent.
What the Linter Cannot Do
For the experience report to stay honest, the limits belong in it.
The guard checks the spelling, not the assignment. That an element carries a token at all — that it enforces reliably. Whether it is the right token for the element’s category it cannot know — in that gap lived the rule contradiction from the QA finding, and there it can arise again. The category assignment remains a rule in prose, with all the weaknesses described, just on a much smaller attack surface.
Two limits are chosen deliberately. Larger Tailwind defaults from text-lg upward the rule does not flag, because a strikingly large text in an app file is a case for design review, not a candidate for mechanical snapping. And dynamically assembled class strings, such as values concatenated from variables, are only partially covered by a static rule. Both gaps are documented rather than concealed, because a check whose omissions nobody knows creates false confidence.
And finally: the linter preserves the scale, it does not justify it. Whether six steps are the right six and which category deserves which token remains a design decision that precedes the tool.
The Same Pattern in SQL
The case is a frontend case on purpose, because that is where the drift was measurable. The pattern behind it is bound to no framework. Transferring it to SQL is therefore a reasoned analogy, not a second measurement — but one that follows from the same mechanism.
A SQL convention — singular table names, snake_case parameters, a fixed procedure skeleton — is the same class of rule as a font-size scale: a project-specific commitment competing against the statistically more common spelling from other codebases. An agent generating PL/pgSQL drifts there for the same reason as with the pixel values, and the answer has the same structure. The convention lives versioned in a rule file, with reasons and negative examples, the way the Postgres Table Conventions, the PL/pgSQL Procedure Conventions and the PL/pgSQL Function Conventions demonstrate. And what is machine-checkable gets checked by a tool: for SQL layout, say, sqlfluff with a project-specific configuration; for structural conventions, a script in the CI gate.
The division of labor is the same in both worlds. The prose rule explains the why and raises the hit rate. The tool closes the gap between a high hit rate and zero exceptions. Because the underlying pattern stays the same, whether in the frontend or in the database: local plausibility creates global inconsistency. How the overall interplay of rules, skills and agents is set up is described in the pillar article AI-Assisted SQL Development with Claude Code.
FAQ
It often announces itself as an impression first: the interface feels restless without any single spot to point at. It becomes tangible through a counting inventory: one search pattern for the canonical spelling, one for the bypasses, and the two numbers set in relation. For design tokens those are expressions like text-\[[0-9.]+px\] against the token names. More revealing than the total is the distribution of values — clusters on values without a token show where the scale has a gap or where an unofficial second convention has formed.
A documented rule improves the hit rate but doesn’t guarantee it — at high generation volume, any residual rate turns into measurable drift. Rules in prose and machine checks are therefore not alternatives but two halves: the rule explains the why, the linter enforces the what. Where rules live in Claude Code and what they cost in context is covered in Skills vs. Rules in Claude Code.
Because the error message should name the project’s own snap target, and the check has to cover class strings outside JSX attributes as well. Generic approaches like no-restricted-syntax can flag the pattern but cannot suggest a contextual fix — and it is exactly that fix which makes the error immediately actionable for humans and agents alike.
No. AI code drift affects any project-specific convention that competes against a more widespread standard spelling — SQL naming, file structures, error-handling patterns all drift by the same mechanism. The countermeasure is the same everywhere: document with reasons, and pour the machine-checkable part into a tool at error level.
It does, and that is the honest core. The difference is pace: an agent makes as many individual decisions in a week as a team makes in months, and it makes each one optimized locally rather than for consistency with the previous ones. Conventions that held up passably at human speed break visibly under that volume — which also means AI drift merely makes an old problem visible faster.
Related Articles
Going deeper:
- Deriving SQL Conventions with Claude Code — the Generate-Refine-Derive Loop — how a convention comes into being before you can enforce it.
- AI-Assisted SQL Development with Claude Code — Rules, Skills and Agents — the pillar: the enforcement system as a whole.
- Skills vs. Rules in Claude Code — where conventions live and what they cost in context.
Convention spokes:
- Postgres Table Conventions — Naming, Keys and Audit Columns
- PL/pgSQL Procedure Conventions
- PL/pgSQL Function Conventions
Starter kit:
- The open DI² starter kit on GitHub — a project template with the rules structure described here; the font-size guard from this article ships with it as a reusable template.