AI Typography Tells: 62 Font and Type Mistakes to Avoid in 2026
The complete forensic catalogue of typographic tells that mark a site as AI-generated, from Inter and Geist defaults to leading-relaxed, tracking-tight, missing tabular figures, and unchosen Tailwind gray. 62 numbered entries, each with the AI default and the human fix.
The catalogue
Open the network tab on the next AI-built site you visit, then open the Computed panel on its . Two numbers tell you almost everything: the font-family (almost certainly Inter, Geist, or the bare system stack) and the line-height (almost certainly 1.5). Add a third check, run getComputedStyle($0).letterSpacing on the heading, and watch it return normal. Three reads, three defaults, and you have already graded the site. Typography is the cheapest signal on the page and the most expensive to fake-fix, because the type system touches every component at once.
This is the master reference for those signals. Every entry below is a specific, grep-able typographic default that models emit by reflex, paired with the human alternative that replaces the default with a decision. It is built the same way the 90+ AI design patterns list is built, and it goes deeper on the one dimension that carries the most brand weight: letters.
Each entry gives you the same three things: why it signals AI, the AI default (as code or CSS), and what to ship instead. Read it once to train your eye, then keep it open during review. The numbers (T01, T02, and so on) are stable handles you can cite in a pull request.
One caveat before the list, because the whole thesis depends on it: every escape font named here can become tomorrow’s default. The grotesque pool was once an escape from Helvetica. Inter was once an escape from Arial. A recommendation is a direction (cross a category line, license something, tune what you load), not a permanent safe list. When everyone reads this and picks the same alternative, the alternative is the new tell. Choose against the grain you see, not against the grain this article saw.
Fonts to avoid
T01: Inter as the only family
Why it signals AI: Inter is so common in the training data that it is the model’s most likely next token whenever a font is needed. Rasmus Andersson designed it to be neutral and legible at 13px, which is exactly why it disappears: it communicates nothing, which is what an unconfigured default communicates. The full mechanism is in why Inter is killing your brand.
/* AI default */
font-family: Inter, system-ui, sans-serif;Instead: Pair a body face with a display face that disagrees with it slightly. A serif display over a sans body is, mechanically, the opposite of the average prediction, because the training corpus is overwhelmingly sans.
/* Human alternative */
--font-display: "Fraunces", Georgia, serif;
--font-body: "Hanken Grotesk", system-ui, sans-serif;T02: Geist, the 2026 fingerprint
Why it signals AI: Geist spread through plumbing, not taste. Vercel makes Geist, Next.js, and v0, so create-next-app and v0 emit it by default through next/font. A hashed __GeistSans_ class paired with Geist Mono in the code blocks is the cleanest “generated by v0” signature there is. The teardown is in Geist is the new Inter.
// AI default
import { GeistSans } from "geist/font/sans";
import { GeistMono } from "geist/font/mono";Instead: If you must stay free and Next-friendly, ship IBM Plex Sans (a workhorse sans with no AI cohort) or re-tune Geist so it is not the untouched default. For dev tools, pay for Söhne or pair Berkeley Mono in the code blocks instead of Geist Mono.
/* Human alternative */
:root { --font-body: "IBM Plex Sans", system-ui, sans-serif; }T03: Roboto
Why it signals AI: Roboto is the Android and Material default and one of the most-installed faces on the web, so it carries the same statistical gravity as Inter with an extra layer of “I used the framework default.” A model reaching for a sans without instruction lands here or on Inter.
/* AI default */
font-family: Roboto, Arial, sans-serif;Instead: If you want a neutral grotesque, reach for one the AI cohort does not use: Public Sans, Mona Sans (GitHub’s open variable font), or a licensed face. None of these is permanently safe, so pick by fit, not by name.
/* Human alternative */
font-family: "Public Sans", system-ui, sans-serif;T04: Poppins and Montserrat, the geometric template defaults
Why it signals AI: Poppins and Montserrat are the two “friendly startup” geometric defaults, ubiquitous in template marketplaces and therefore over-represented in training data. Poppins has perfect circular bowls (the o, the dotless geometry) that read as generic-approachable, the typographic equivalent of a stock handshake photo. Montserrat is the most over-used geometric sans on Google Fonts after them, the face that signals “I picked the first headline font in the list.” Both also set badly at body sizes, because geometric construction hurts immersive reading.
/* AI default */
font-family: Montserrat, Poppins, sans-serif;Instead: If you want geometric warmth, use it only for display, and pick something with more character. For body, switch to a humanist sans built for reading.
/* Human alternative */
--font-display: "Clash Display", sans-serif;
--font-body: "Source Sans 3", system-ui, sans-serif;T05: The legacy Google Fonts workhorses
Why it signals AI: Open Sans, Lato, Nunito, and Nunito Sans are the 2015-era safe defaults that still surface, because older training data is saturated with them. They are not wrong, they are dated and unanimous: Open Sans in particular reads as “default WordPress theme circa 2016” the way Geist reads as “v0 circa 2026.” A model trained on a decade of the web reaches for them when it wants something friendlier than Arial and safer than a real choice.
/* AI default */
font-family: "Open Sans", "Lato", sans-serif;Instead: If you want approachable, get it from a face that is not on every template. A humanist sans with real personality does the friendly job without the cohort.
/* Human alternative */
font-family: "Hanken Grotesk", system-ui, sans-serif;T06: The bare system-ui stack
Why it signals AI: font-family: system-ui, -apple-system, sans-serif with no web font loaded is the “I did not choose a font” font. It is what Tailwind’s font-sans resolves to before anyone overrides it, so an untouched scaffold renders in San Francisco on a Mac, Segoe UI on Windows, Roboto on Android: three different brands for three visitors, which is no brand at all.
/* AI default */
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;Instead: Load at least one intentional web face for display, and keep a system stack only as the fallback after it. Two declared typefaces signal design thought on their own.
/* Human alternative */
font-family: "Newsreader", Georgia, serif; /* display, actually loaded */T07: Manrope and the interchangeable-grotesque pool
Why it signals AI: When a model is nudged off Inter, it does not go far. It rotates through the adjacent grotesque pool: Manrope, DM Sans, Plus Jakarta Sans, Space Grotesk, Work Sans, Sora. They look nearly identical at a glance, all geometric-humanist sans in the 2020 Google Fonts mold, so swapping Inter for Manrope is lateral motion, not a decision. Manrope in particular has become the “I knew Inter was a cliché so I picked the next one” signal.
/* AI default (the lateral swap) */
font-family: Manrope, Inter, sans-serif;Instead: Cross a category line. Move to a serif, a slab, a humanist with real ink traps, or a licensed face. The point is contrast with the pool, not a different member of it.
/* Human alternative */
--font-display: "Spectral", Georgia, serif;
--font-body: "Libre Franklin", system-ui, sans-serif;T08: Space Grotesk (or any display face) as body text
Why it signals AI: Space Grotesk, Clash Display, Cal Sans, and the rest of the quirky-display cohort are drawn for headlines, where their wide apertures and odd terminals read as character. Set as running body text at 16px they fall apart: the quirks that look intentional at 56px just look noisy at small sizes and tire the reader. A model that grabbed one expressive font and used it for everything, hero and paragraph alike, produces exactly this, because it never separated the display job from the reading job.
/* AI default: one display face doing every job */
body, h1, h2, p { font-family: "Space Grotesk", sans-serif; }Instead: Keep the expressive face for display only and run body on something built for sustained reading. The split is itself a design decision a model rarely makes.
/* Human alternative */
--font-display: "Space Grotesk", sans-serif; /* headlines only */
--font-body: "Source Sans 3", system-ui, sans-serif;T09: The full Vercel uniform
Why it signals AI: Individual defaults are signals. The combination is a confession. Geist Sans body, Geist Mono code blocks, a bg-zinc-950 dark hero, a text-zinc-400 subhead, and one #0070f3 blue CTA is the 2026 dev-tool uniform, the same way a particular Bootstrap Jumbotron dated a 2014 blog. The font reads as “Vercel,” which now reads as “AI startup,” so a law firm or a skincare brand wearing it has accidentally cosplayed a YC launch. See zinc-950 is the AI default dark hero.
/* AI default: the whole uniform */
<html className={`${GeistSans.variable} ${GeistMono.variable}`}>
<body className="bg-zinc-950 text-zinc-400">Instead: Break at least one axis hard. A serif display over the grotesque body, a warmer paper background, a mono in the code blocks that is not Geist Mono. One decisive break is enough to read as chosen.
/* Human alternative */
--font-display: "Source Serif 4", serif;
--font-code: "Berkeley Mono", "Commit Mono", monospace;Weight and hierarchy
T10: Everything clustered in the 400 to 600 band
Why it signals AI: Models cluster weights in the safe middle because those weights exist in every family and never look broken. A page where body, labels, and headings all sit between 400 and 600 has no typographic dynamics: nothing recedes, nothing commands. The hierarchy is carried entirely by size, which is the lazy axis. This is the band version of the problem; T11 is the literal-700 version.
/* AI default */
body { font-weight: 400; }
.label { font-weight: 500; }
h1, h2, h3 { font-weight: 600; }Instead: Use the real range the font ships. Push display weight up and pull supporting text down, so contrast does work that size alone cannot.
/* Human alternative */
h1 { font-weight: 800; }
h2 { font-weight: 700; }
h3 { font-weight: 600; }
body { font-weight: 400; }
.eyebrow { font-weight: 500; }T11: font-bold as the only emphasis
Why it signals AI: Every heading gets font-bold (literally 700), flat across all levels, because font-bold is Tailwind’s one-word emphasis utility and the model reaches for it the way it reaches for text-center. Where T10 spreads across a narrow band, this collapses to a single value: h1 and h3 are then distinguished only by text-4xl versus text-xl, and the document reads as one undifferentiated shout.
<!-- AI default -->
<h1 class="text-4xl font-bold">…</h1>
<h2 class="text-2xl font-bold">…</h2>
<h3 class="text-xl font-bold">…</h3>Instead: Build a weight ramp that descends with the hierarchy, and consider an italic or a lighter display weight at the very top, which almost no model emits.
/* Human alternative */
h1 { font-weight: 800; font-style: normal; }
h2 { font-weight: 700; }
h3 { font-weight: 600; }T12: No display weights loaded
Why it signals AI: next/font and the typical @font-face block load 400 and 700 and stop, because that is the minimum that renders body and bold without faux-bolding. The genuinely heavy weights that make a 64px headline feel built (Black, 900, or the top of a variable axis) are never requested, so the biggest type on the page is just 700 scaled up.
/* AI default: only the two safe weights exist */
@font-face { font-family: X; font-weight: 400; src: url(x-regular.woff2); }
@font-face { font-family: X; font-weight: 700; src: url(x-bold.woff2); }Instead: Load a true display weight (or a variable font with a wide weight axis) and use it only where the type is large enough to earn it.
/* Human alternative */
@font-face {
font-family: X; font-weight: 100 900; /* variable range */
src: url(x-variable.woff2) format("woff2-variations");
}
.hero-title { font-variation-settings: "wght" 880; }T13: Faux bold from a single weight
Why it signals AI: When only one weight is loaded and the markup still asks for bold, the browser synthesizes it by smearing the strokes. The result is a muddy, slightly-too-fat heading with no real design behind it, which gives away that the font wiring was never finished.
/* AI default: browser fakes the bold */
@font-face { font-family: X; font-weight: 400; src: url(x.woff2); }
h1 { font-family: X; font-weight: 700; } /* synthesized */Instead: Either load the real weight or disable synthesis so the problem is visible in review instead of shipped.
/* Human alternative */
body { font-synthesis: none; }
/* then load the weights you actually use */Type scale
T14: The Tailwind default scale, untouched
Why it signals AI: The whole page runs on text-sm, text-base, text-lg, text-xl, text-2xl straight from Tailwind’s built-in scale, with no custom values added to theme.fontSize. These sizes (0.875, 1, 1.125, 1.25, 1.5rem and up) are shared by every untouched Tailwind project on the web, so the rhythm is identical across millions of sites. The defaults are not wrong; they are just unanimous.
<!-- AI default -->
<h1 class="text-4xl">…</h1> <!-- 2.25rem -->
<p class="text-base">…</p> <!-- 1rem -->
<span class="text-sm">…</span> <!-- 0.875rem -->Instead: Define a custom modular scale with a ratio that is not the default, and override the named steps so the relationships are yours.
// tailwind.config: a 1.2 (minor third) scale, hand-tuned
fontSize: {
sm: ["0.833rem", { lineHeight: "1.5" }],
base:["1rem", { lineHeight: "1.6" }],
lg: ["1.2rem", { lineHeight: "1.5" }],
xl: ["1.44rem", { lineHeight: "1.3" }],
"2xl":["1.728rem",{ lineHeight: "1.2" }],
}T15: Tidy 1rem-increment scale
Why it signals AI: When a model hand-writes a scale instead of using Tailwind’s, it produces suspiciously round numbers: h1 at 3rem, h2 at 2.25rem, h3 at 1.5rem, body at 1rem. The values are arithmetically tidy rather than geometrically related, which reads as “computed, not composed.” Real type scales are usually built from a ratio, so the numbers come out irregular.
/* AI default */
h1 { font-size: 3rem; }
h2 { font-size: 2rem; }
h3 { font-size: 1.5rem; }
body { font-size: 1rem; }Instead: Pick a ratio (1.25 major third, 1.333 perfect fourth) and let it generate the steps. The off-round results are the signature of a real scale.
/* Human alternative: 1.333 ratio from a 1rem base */
body { font-size: 1rem; }
h3 { font-size: 1.333rem; } /* 1.333^1 */
h2 { font-size: 1.777rem; } /* 1.333^2 */
h1 { font-size: 2.369rem; } /* 1.333^3 */T16: Too little contrast between steps
Why it signals AI: The opposite of a missing scale: a scale so shallow that nothing stands apart. When h2 sits at 1.25rem over a 1rem body, the heading barely outranks the paragraph, and a page of near-equal sizes reads as flat and undifferentiated. Models pick a small ratio (or lean on adjacent Tailwind steps like text-lg for headings) because small jumps never look broken, so the whole document hums at one pitch.
/* AI default: h2 barely larger than body */
body { font-size: 1rem; }
h2 { font-size: 1.25rem; } /* only a 1.25x jump */Instead: Open the ratio so the hierarchy is legible at a glance. A larger step between body and headings is what makes a page scannable.
/* Human alternative: a wider gap earns the heading */
body { font-size: 1rem; }
h3 { font-size: 1.5rem; }
h2 { font-size: 2.25rem; }
h1 { font-size: 3.4rem; }T17: One h1 size sitewide
Why it signals AI: Every h1 on every template is the same size, because the model sets a single h1 rule and never reconsiders it per context. A landing-page hero, a blog post title, and a 404 page do not want the same heading size, but the generated site gives them one. The absence of contextual sizing is the giveaway.
/* AI default */
h1 { font-size: 2.25rem; } /* identical everywhere */Instead: Size headings by their job. A hero h1 can be enormous; an article h1 sits closer to its body for reading; a utility page can be modest.
/* Human alternative */
.hero h1 { font-size: clamp(2.5rem, 6vw, 5rem); }
.article h1 { font-size: clamp(1.9rem, 3.5vw, 2.6rem); }
.utility h1 { font-size: 1.6rem; }T18: No vertical rhythm, margins unrelated to leading
Why it signals AI: Spacing between text blocks is set with whatever spacing utility was handy (mb-4, mb-8, space-y-6) with no relationship to the line-height, so paragraphs, headings, and lists do not sit on a shared baseline grid. The page looks vaguely spaced but never settled, because the gaps are arbitrary rather than multiples of the leading. A model never derives margins from the type, because that requires looking at the rendered rhythm.
/* AI default: margins picked from the spacing scale, not the type */
p { line-height: 1.6; margin-bottom: 1rem; }
h2 { line-height: 1.2; margin-top: 2rem; }Instead: Tie vertical spacing to the line-height so blocks share a rhythm. Express margins in the same em/rem terms as the leading, or use a baseline-grid utility.
/* Human alternative: spacing derived from a 1.5rem baseline */
:root { --baseline: 1.5rem; }
p { line-height: var(--baseline); margin-block-end: var(--baseline); }
h2 { margin-block: calc(var(--baseline) * 2) var(--baseline); }Line-height and leading
T19: leading-relaxed everywhere
Why it signals AI: One line-height, set globally, usually Tailwind’s leading-relaxed (1.625) or a flat 1.5, applied to body, headings, and captions alike. Real typography varies leading by size and density: display wants it tight, body wants it open, small dense UI wants something between. A single value across all contexts is the calling card of code that never looked at the rendered text.
/* AI default */
* { line-height: 1.5; }
/* or every block tagged leading-relaxed */Instead: Set leading per role. Tighten as type grows, open it for sustained reading.
/* Human alternative */
h1 { line-height: 1.05; }
h2 { line-height: 1.15; }
h3 { line-height: 1.2; }
body { line-height: 1.62; }
.caption { line-height: 1.4; }T20: 1.5 leading on display headings
Why it signals AI: Large headings inherit the body’s 1.5 line-height, so a two-line 56px headline has a cavernous gap between its lines and reads as two disconnected sentences. The bigger the type, the more wrong default leading looks, and display type at 1.5 is the most visible version of the single-value mistake.
/* AI default */
h1 { font-size: 3.5rem; line-height: 1.5; } /* huge gap */Instead: Tighten display leading toward 1.0 to 1.1 so multi-line headlines hold together as one shape.
/* Human alternative */
h1 { font-size: 3.5rem; line-height: 1.05; }T21: Hard px leading instead of unitless
Why it signals AI: Line-height set in pixels (line-height: 24px) instead of a unitless multiplier breaks the moment font-size changes responsively, because the leading no longer scales with the text. Models do this when they copy a single component’s computed values rather than thinking in ratios.
/* AI default */
p { font-size: 18px; line-height: 28px; }Instead: Use unitless leading so it tracks the font size at every breakpoint.
/* Human alternative */
p { font-size: 1.125rem; line-height: 1.6; }Letter-spacing and tracking
T22: tracking-tight on everything
Why it signals AI: tracking-tight (-0.025em) gets pasted onto every heading regardless of size, because someone learned that headings want negative tracking and the model over-applied it. Tracking should scale with size: large display wants it tighter, body wants it left alone, small text wants it slightly looser. A flat tracking-tight on a 14px subhead just cramps it, and on uppercase it makes letters collide (the worst case, called out in T24).
<!-- AI default -->
<h1 class="text-5xl tracking-tight">…</h1>
<h3 class="text-base tracking-tight">…</h3> <!-- wrong at this size -->
<p class="text-sm tracking-tight">…</p> <!-- cramped -->Instead: Track by size. Negative at display sizes, normal at body, positive at small sizes and caps.
/* Human alternative */
h1 { letter-spacing: -0.03em; }
h2 { letter-spacing: -0.02em; }
h3 { letter-spacing: -0.01em; }
body { letter-spacing: normal; }
.small, .caption { letter-spacing: 0.01em; }T23: No tracking adjustment anywhere
Why it signals AI: The flip side of T22. Models either over-track headings or never touch tracking at all, leaving every run of text at normal. The absence of any per-size letter-spacing across the whole document is itself a signal that no one tuned the type.
/* AI default: no tracking anywhere */
h1, h2, h3, p, span, button { letter-spacing: normal; }Instead: At minimum, pull display tracking negative and nudge small text positive. Two or three lines of CSS that immediately read as designed.
/* Human alternative */
:where(h1, h2) { letter-spacing: -0.02em; }
:where(small, .label) { letter-spacing: 0.02em; }T24: Uppercase without tracking (and negative tracking on caps)
Why it signals AI: All-caps text at default spacing looks cramped, because capitals are designed to sit inside lowercase rhythm, not packed against each other. A model emits uppercase for an eyebrow label and forgets the tracking that makes caps legible. Worse, when tracking-tight is applied globally (T22) it lands on those caps too, the single worst place for it: a text-xs uppercase tracking-tight badge is a specific, common, visibly wrong combination because tight tracking makes capitals collide.
<!-- AI default -->
<span class="uppercase text-xs">New feature</span>
<span class="text-xs uppercase tracking-tight">Beta</span> <!-- collides -->Instead: Always pair uppercase with positive tracking. Where the rest of the page tightens, caps must loosen. Caps and wide tracking are a unit.
/* Human alternative */
.eyebrow, .badge {
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.12em;
font-weight: 600;
}Measure and line length
T25: No measure cap, lines run the full container
Why it signals AI: Body copy spans the entire container, often 100% of a max-w-7xl, so on wide viewports lines run to 120, 140, even 160 characters. The eye loses its place returning to the start of each line, so the text is technically readable and practically exhausting. There is a known comfortable range for sustained reading (roughly 45 to 75 characters), and default output routinely doubles it, because the container already has a width and nothing adds the narrower constraint that reading actually needs.
<!-- AI default: measure as wide as the viewport -->
<section class="max-w-7xl mx-auto">
<p>Long body paragraph spanning the full width…</p>
</section>Instead: Cap the measure of running text at roughly 60 to 75 characters with max-width in ch, and let margin absorb the extra space. The container can be wide; the text column should not be.
/* Human alternative */
.prose p { max-width: 68ch; margin-inline: auto; }T26: Justified text with no hyphenation
Why it signals AI: text-align: justify with no hyphens: auto forces the browser to stretch inter-word spaces to fill each line, opening rivers of white space and ragged gaps, especially in a narrow ch column on mobile. Models reach for justify because it looks tidy and “designed” in a screenshot, then never add the hyphenation and lang attribute that make justification actually work in print typography.
/* AI default: justified with no hyphenation, rivers form */
.prose p { text-align: justify; }Instead: Prefer a clean left rag for screen reading. If you do justify, pair it with automatic hyphenation and declare the language so the browser can break words.
<!-- Human alternative -->
<article lang="en">
<p style="text-align: justify; hyphens: auto;">…</p>
</article>T27: Centered text in long paragraphs
Why it signals AI: text-center applied to multi-line body copy, not just a hero line, creates a ragged left edge that the eye cannot anchor to. Models default to centering because so much of their training data centers hero content, and the habit leaks into paragraphs where it actively hurts reading.
<!-- AI default -->
<p class="text-center max-w-3xl mx-auto">Three full lines of centered body copy…</p>Instead: Left-align running text. Center only a single short line, like a hero headline or a one-line CTA.
/* Human alternative */
.prose p { text-align: left; }
.hero h1 { text-align: center; } /* the one place it earns it */Numerals and figures
T28: No tabular figures in tables and pricing
Why it signals AI: Prices, stats, and data columns use the font’s default proportional figures, so the 1 is narrower than the 8 and numbers fail to align vertically. In a pricing table each row’s total jitters a pixel or two against the row above, and the columns visibly wobble. Models never reach for tabular-nums because it is a craft setting absent from default output.
/* AI default: proportional figures, columns wobble */
.price { font-variant-numeric: normal; }Instead: Switch on tabular figures anywhere numbers stack or align.
/* Human alternative */
.price, .stat, td.num, .data-table {
font-variant-numeric: tabular-nums;
}T29: Lining figures in running prose
Why it signals AI: Most sans webfonts default to lining figures, full-height digits that sit like capitals, which stick out as little towers inside lowercase prose. A sentence with “in 1998 we shipped 3 products” has three uppercase-height shapes punching above the text. Oldstyle figures (which have ascenders and descenders) blend into prose, but default output never requests them.
/* AI default: lining figures shout in body text */
p { font-variant-numeric: lining-nums; }Instead: Use oldstyle figures in running text (where the font supports them) and reserve lining figures for UI and tables.
/* Human alternative */
.prose { font-variant-numeric: oldstyle-nums proportional-nums; }
.ui, table { font-variant-numeric: lining-nums tabular-nums; }T30: No fraction or ordinal features
Why it signals AI: Fractions render as flat 1/2 with a slash, ordinals as un-raised 1st, because the OpenType features that handle them (frac, ordn) are never enabled. The default-everything output ignores the font’s own typographic machinery.
/* AI default: raw slash fractions */
.recipe { font-variant-numeric: normal; }Instead: Enable the relevant features so 1/2 becomes a true fraction and 1st raises its ordinal.
/* Human alternative */
.recipe { font-variant-numeric: diagonal-fractions; }
.ordinal { font-variant-numeric: ordinal; }T31: Unaligned figures in stacked data, no machine-readable time
Why it signals AI: Any column of numbers (read times, durations, counts) set in default proportional figures fails to line up, so a list of “12 min”, “7 min”, “120 min” jitters down the left edge of the numerals. The same output stamps content with confident-looking dates printed as plain text, with no for machines to read. The visible version is a column of figures that should align and does not.
<!-- AI default -->
<span>4 min read</span> <!-- proportional, won't align in a list -->
<span>120 min read</span>
<span>Updated 2 hours ago</span> <!-- no machine-readable time -->Instead: Set any column of figures in tabular numerals so they line up, and use a real, machine-readable for dates.
<!-- Human alternative -->
<span style="font-variant-numeric: tabular-nums">4 min read</span>
<time datetime="2026-06-14" style="font-variant-numeric: tabular-nums">
14 Jun 2026
</time>T32: Letter x and hyphen standing in for true glyphs
Why it signals AI: Generated copy renders dimensions as “1920x1080” with a lowercase letter x instead of the multiplication sign, and ranges as “9-17” with a hyphen where a true range glyph belongs. These are raw-keyboard substitutions that real typesetting replaces, and their presence across a spec sheet or a hours-of-operation block is an unprocessed-text giveaway. (This article uses the entity forms below precisely so it can teach the fix without violating its own no-dash rule.)
<!-- AI default: keyboard stand-ins -->
<span>1920x1080</span>
<span>Open 9-17</span>Instead: Use the real multiplication sign and a true range glyph via their entities (or your build’s smart-typography transform).
<!-- Human alternative -->
<span>1920×1080</span> <!-- × is the multiplication sign -->
<span>Open 9–17</span> <!-- – is the range glyph -->Optical sizing and variable-font features
T33: Ignoring the optical-size axis (opsz)
Why it signals AI: Variable fonts with an opsz axis (Fraunces, Source Serif 4, Roboto Flex, and others) are designed to thicken hairlines and open spacing at small sizes and refine them at large sizes. Models load the font and never set font-optical-sizing or an explicit opsz, so a delicate display cut gets used at 14px where it falls apart, or a text cut gets blown up to 80px where it looks flimsy.
/* AI default: opsz never engaged, font-optical-sizing absent */
h1 { font-family: "Fraunces"; }Instead: Either turn optical sizing on so it tracks the size automatically, or set opsz explicitly per role. These are alternatives, not layers: an explicit opsz in font-variation-settings overrides font-optical-sizing: auto for that element.
/* Human alternative */
/* automatic: let opsz follow font-size */
:root { font-optical-sizing: auto; }
/* or pin it per role (this wins over auto where set) */
.hero h1 { font-variation-settings: "opsz" 144; } /* Fraunces tops out at 144 */
.fine-print { font-variation-settings: "opsz" 9; } /* Fraunces floor is 9 */T34: Variable font loaded but used as two static weights
Why it signals AI: A site imports a variable font and then only ever uses font-weight: 400 and 700, never touching the continuous weight axis between those stops or any of the font’s expressive axes (slant, width, grade, soft, wonk). The variable file is being paid for and not used, which wastes its whole point and signals that nobody opened the axes.
/* AI default: variable font loaded, used as if it were two statics */
@font-face {
font-family: "Fraunces";
src: url(Fraunces-variable.woff2) format("woff2-variations");
font-weight: 100 900;
}
h2 { font-family: "Fraunces"; font-weight: 700; } /* axis untouched */Instead: Use intermediate axis values and any expressive axes the font ships. A wght of 660 or a touch of SOFT and WONK on Fraunces reads as a hand on the dial.
/* Human alternative */
h2 { font-variation-settings: "wght" 660; }
.brand h1 { font-variation-settings: "wght" 540, "SOFT" 60, "WONK" 1; }T35: No width or grade axis where the font ships one
Why it signals AI: Beyond weight, expressive variable fonts (Roboto Flex, Recursive, and others) carry width (wdth), grade (GRAD), and slant (slnt) axes that do work no static cut can. Grade is the advanced, human-only move: it adjusts apparent weight without changing the metrics, so you can compensate for the optical thinning that light text suffers on dark backgrounds, with no layout shift. A model never reaches for GRAD, because using it requires understanding why dark-mode text looks lighter.
/* AI default: width/grade axes never engaged */
body { font-family: "Roboto Flex"; font-weight: 400; }Instead: Use width for fit and grade for dark-mode optical compensation. Bump GRAD slightly in dark mode so the text holds its weight without reflowing.
/* Human alternative */
.condensed { font-variation-settings: "wdth" 75; }
@media (prefers-color-scheme: dark) {
body { font-variation-settings: "GRAD" 50; } /* no metric change, no shift */
}T36: No real small caps
Why it signals AI: Acronyms, legal labels, and lead-in text are either left at full caps or faked with a font-size reduction, never set with true small caps. The font-variant-caps: small-caps feature (and the genuine c2sc/smcp glyphs in fonts that have them) is absent from default output because it is a deliberate typographic move.
<!-- AI default: fake small caps via font-size -->
<span style="text-transform: uppercase; font-size: 0.8em">NASA</span>Instead: Use real small caps where the font supports them, which keeps the color of the text even.
/* Human alternative */
abbr, .lead-in { font-variant-caps: small-caps; letter-spacing: 0.03em; }T37: No ligature, kerning, or stylistic-set control
Why it signals AI: Body text ships with whatever defaults are on and nothing tuned: the property is simply absent, so standard ligatures and contextual alternates run on their defaults, kerning is left implicit, and the font’s drawn-in stylistic sets (a single-storey a, a straight-leg R) go unused. The tell is the absence, not a misconfiguration. A model rarely opens the font’s feature panel because doing so means knowing what ss01 even contains.
/* AI default: no feature control present at all */
body { font-family: "Brand"; } /* no font-variant-*, no feature-settings */Instead: Use the high-level properties for the common features (they are non-destructive), and reserve font-feature-settings for features with no property of their own, like stylistic sets and discretionary ligatures. Note that font-kerning: normal and standard ligatures are already on by default, so reach for font-feature-settings only when you actually need a feature it does not cover.
/* Human alternative */
body { font-variant-ligatures: common-ligatures contextual; }
.display { font-feature-settings: "ss01", "ss02", "dlig"; } /* no high-level property exists */Editorial typography
T38: No hanging punctuation
Why it signals AI: Quotation marks and bullets sit inside the text block instead of hanging into the margin, so the optical left edge of a pull quote or a quoted paragraph looks indented by the width of the quote mark. The hanging-punctuation property that fixes this is essentially never emitted, because it is invisible until you know to look for it, which is exactly what makes it a craft tell.
/* AI default: quotes break the optical margin */
blockquote { /* no hanging-punctuation */ }Instead: Hang the opening punctuation so the text edge stays straight. (Support is Safari-leading, so treat it as progressive enhancement.)
/* Human alternative */
.prose { hanging-punctuation: first last; }T39: No drop cap or lead-in styling
Why it signals AI: Article bodies open with a plain paragraph, identical to every other paragraph, because ::first-letter and ::first-line are never touched. A real editorial type system uses a drop cap or a small-caps lead-in to mark the start of an article, a move that is pure typography and entirely absent from generated layouts.
/* AI default: no entry styling */
.article p:first-of-type { /* same as every other paragraph */ }Instead: Mark the opening with a drop cap or a lead-in, using the pseudo-elements that exist for exactly this.
/* Human alternative */
.article p:first-of-type::first-letter {
float: left; font-size: 3.2em; line-height: 0.8;
padding-right: 0.06em; font-weight: 700;
}
.article p:first-of-type::first-line { font-variant-caps: small-caps; }T40: No text-wrap balance or pretty
Why it signals AI: Headings break with a single orphaned word on the last line and paragraphs leave widows, because the two one-line properties that fix this (text-wrap: balance for headings, text-wrap: pretty for body) are virtually never emitted by a model. Their absence across the whole document is reliable, and adding them is one of the highest-payoff lines you can write.
/* AI default: no wrap control, orphans and widows */
h1, h2, p { text-wrap: normal; }Instead: Balance headings and prettify body. Two declarations, immediate visual upgrade. (balance is widely supported; pretty is Chromium-leading, so treat it as progressive enhancement.)
/* Human alternative */
h1, h2, h3 { text-wrap: balance; }
p, li, dd { text-wrap: pretty; }T41: No text-rendering or font-smoothing intent
Why it signals AI: The page never sets text-rendering or the antialiasing hints, so kerning and ligatures fall to the browser default and the type renders however the platform decides. A craft type system makes the call on purpose. The catch worth knowing: forcing -webkit-font-smoothing: antialiased everywhere is itself a tell when misapplied, because it thins text and can hurt legibility for some readers, so it is a decision, not a blanket fix.
/* AI default: rendering hints never set */
body { /* no text-rendering, no -webkit-font-smoothing */ }Instead: Set text-rendering: optimizeLegibility where kerning and ligatures matter, and make a deliberate, scoped choice about smoothing rather than pasting antialiased onto the whole document.
/* Human alternative */
body { text-rendering: optimizeLegibility; }
.display { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }Font loading
T42: No size-adjust on the fallback (the layout-shift tell)
Why it signals AI: A web font loads with no metric-matched fallback, so the page first paints in a system font with different glyph widths and x-height, then reflows when the real font arrives. That visible jump (text shifting, layout reflowing) is one of the most common AI-build tells, because hand-written @font-face from a model never includes size-adjust, ascent-override, or descent-override. (Ironically, next/font does inject a matched fallback automatically, which is why a hand-rolled face often shifts more than a generated one.)
/* AI default: bare @font-face, fallback metrics mismatched */
@font-face {
font-family: "Brand";
src: url(brand.woff2) format("woff2");
}Instead: Define an adjusted fallback whose metrics match the web font, so the swap is invisible. Compute the percentages per font with a tool like Fontaine or Capsize; the numbers below are illustrative, not universal.
/* Human alternative (compute these per font, do not copy verbatim) */
@font-face {
font-family: "Brand Fallback";
src: local("Arial");
size-adjust: 105%;
ascent-override: 92%;
descent-override: 24%;
}
body { font-family: "Brand", "Brand Fallback", sans-serif; }T43: No font-display strategy
Why it signals AI: The @font-face block omits font-display, so the browser uses its default (auto, usually a long block period) and text is invisible while the font downloads. The flash of invisible text (FOIT) is a tell because deciding between swap, optional, and fallback is exactly the loading decision default output skips.
/* AI default: no font-display, FOIT on slow connections */
@font-face { font-family: "Brand"; src: url(brand.woff2); }Instead: Choose a display strategy on purpose. swap paired with a matched fallback (T42) is the common production choice. optional gives zero layout shift but accepts a real tradeoff: on a cold cache the web font often will not paint at all on the first visit, so first-time users see the fallback for the whole session.
/* Human alternative */
@font-face {
font-family: "Brand";
src: url(brand.woff2) format("woff2");
font-display: swap; /* or optional, if zero-CLS matters more than first-load brand */
}T44: No preconnect or preload for fonts
Why it signals AI: Fonts are pulled from fonts.googleapis.com with no to the font host and no on the critical face, so the browser discovers the font late in the cascade and the text paints slowly. The absence of any font-loading optimization in the is a default-output signature.
<!-- AI default: no preconnect, late font discovery -->
<link href="https://fonts.googleapis.com/css2?family=Inter" rel="stylesheet">Instead: Preconnect to the font origin and preload the one face that paints first.
<!-- Human alternative -->
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="preload" as="font" type="font/woff2"
href="/fonts/brand-display.woff2" crossorigin>T45: Loading every weight and style
Why it signals AI: The font import requests the entire family (every weight from 100 to 900, plus italics) when the design uses three of them, bloating the download and slowing first paint. Models grab the whole family because it is the safe import that guarantees any weight they later reference will exist.
<!-- AI default: ships the whole family -->
<link href="…?family=Inter:wght@100;200;300;400;500;600;700;800;900" rel="stylesheet">Instead: Subset to the weights and styles you actually use, and prefer one variable file over many static ones.
<!-- Human alternative -->
<link href="…?family=Inter:wght@400;600;800&display=optional" rel="stylesheet">T46: TTF or WOFF instead of WOFF2, no unicode-range subset
Why it signals AI: The font ships as a .ttf or legacy .woff rather than .woff2, and the full glyph set comes down with no unicode-range Latin subset, so a Latin-only site pays for Cyrillic, Greek, and Vietnamese it never renders. Default output grabs whatever format the source provided and never subsets, because subsetting is a build step a model does not add.
/* AI default: heavy format, whole glyph set, no subset */
@font-face {
font-family: "Brand";
src: url(brand.ttf) format("truetype");
}Instead: Serve WOFF2 and declare a unicode-range so the browser only fetches the subset a page needs.
/* Human alternative */
@font-face {
font-family: "Brand";
src: url(brand-latin.woff2) format("woff2");
unicode-range: U+0000-00FF, U+0131, U+2000-206F; /* Latin + basic punctuation */
}T47: Render-blocking third-party Google Fonts stylesheet
Why it signals AI: The page links the fonts.googleapis.com stylesheet directly in the , a render-blocking request to a third party that also raises privacy and GDPR questions in some jurisdictions. Self-hosting the same files removes the blocking round-trip and the third-party dependency, but default output uses the copy-paste embed because that is what the Google Fonts page hands you.
<!-- AI default: render-blocking third-party stylesheet -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap"
rel="stylesheet">Instead: Self-host the woff2 files and declare @font-face locally, so there is no third-party round-trip and you control caching and font-display.
/* Human alternative: self-hosted, no third party */
@font-face {
font-family: "Inter";
src: url(/fonts/inter-variable.woff2) format("woff2-variations");
font-weight: 400 800;
font-display: swap;
}Type colour and contrast
T48: text-gray-500/600 body text
Why it signals AI: Body copy set in Tailwind’s text-gray-600 (#4B5563) or text-gray-500 (#6B7280) is the universal “secondary text” default. It is a cool, blue-tinted gray that nobody chose, applied to nearly all non-heading text. The literal class appears so often it functions as a signature, the typographic equivalent of the #3B82F6 blue primary.
<!-- AI default -->
<p class="text-gray-600">Body copy in Tailwind's default gray.</p>Instead: Use a near-black with a hint of your brand hue for primary text, and a warm custom gray for secondary, both pulled from your palette rather than Tailwind’s defaults.
/* Human alternative */
:root { --text: #1A1714; --text-muted: #6B6560; }
body { color: var(--text); }
.muted { color: var(--text-muted); }T49: Gray-on-gray, the muted-everything tell
Why it signals AI: Light gray text on a slightly lighter gray background (text-gray-400 on bg-gray-50) fails contrast and reads as washed out, but it is everywhere in default output because “muted” is the model’s default register for anything that is not a heading. When labels, captions, body, and placeholders are all some flavor of low-contrast gray, the whole page feels foggy.
<!-- AI default: barely-there contrast -->
<div class="bg-gray-50">
<p class="text-gray-400">Hard to read on this background.</p>
</div>Instead: Hold body text at a strong contrast ratio, aiming past the AA minimum (4.5:1) toward AAA (7:1) on primary copy, and reserve genuine muting for truly secondary elements only. Verify your specific pairs in a contrast checker rather than trusting the hex by eye.
/* Human alternative */
body { color: #1A1714; } /* near-black on warm white, clears AAA */
.caption { color: #6B6560; } /* lighter, used sparingly, verify it still clears AA */T50: Pure #000 on pure #FFF
Why it signals AI: The opposite failure: maximum contrast, black text on pure white, with no tuning. It reads as harsh and slightly vibrating on screens, and signals that color was never considered for type at all. Real typographers soften both ends: near-black ink on warm off-white.
/* AI default */
body { color: #000000; background: #FFFFFF; }Instead: Drop the text off pure black and warm the background off pure white. The page feels printed rather than emitted.
/* Human alternative */
body { color: #1A1714; background: #FEFCF8; }T51: Links in default blue-600, distinguished by color alone
Why it signals AI: Inline links in running text take Tailwind’s text-blue-600 (often the only place blue appears), and they are set apart from body text by color alone, with no underline. That is the typographic link tell, distinct from the gray-body tell: it fails for readers who do not perceive the hue difference, and color-only links are an accessibility default that models ship because the framework class is right there.
<!-- AI default: blue-600, no underline, color-only distinction -->
<a class="text-blue-600" href="…">read the guide</a>Instead: Give inline links an underline (the durable signal of “this is a link”) in a color that belongs to your palette, and style the underline so it does not crowd the text.
/* Human alternative */
.prose a {
color: var(--accent);
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 0.15em;
}Casing and punctuation tells
T52: Straight quotes and apostrophes
Why it signals AI: Generated copy uses straight typewriter quotes (" and ') instead of typographic curly quotes (the proper opening and closing marks and the right single quote for apostrophes). Straight quotes are a dead giveaway of unprocessed text, because real publishing converts them. A prime mark standing in for an apostrophe in “it’s” is the most common version.
<!-- AI default -->
<p>"It's the best tool," she said.</p>Instead: Use real curly quotes and a proper apostrophe, either typed directly or via a smart-quote transform in the build.
<!-- Human alternative -->
<p>“It’s the best tool,” she said.</p>T53: The em-dash overuse tell
Why it signals AI: Heavy reliance on the em-dash to join clauses is one of the strongest LLM writing fingerprints, because models lean on it as an all-purpose connector far more than human writers do. A page peppered with em-dashes reads as machine-written even when the design is clean. (This article uses none, on purpose, and encodes the example below as an entity so its own source stays free of the raw character.)
AI default (each — renders as an em-dash):
"It is fast — and reliable — built for teams — at any scale."Instead: Vary the connectors. Commas, colons, parentheses, and full stops each carry a different rhythm, and using the range is what reads as human.
Human alternative:
"It is fast and reliable, built for teams at any scale."T54: Three-dot ellipsis and stray spacing
Why it signals AI: Ellipses typed as three periods (...) instead of the single ellipsis character, or spaced inconsistently, are an unprocessed-text tell, alongside double spaces after periods and spaces before punctuation. Default output ships raw keyboard characters where real typography uses the correct glyphs.
AI default:
"Loading... please wait . . ."Instead: Use the proper ellipsis glyph and clean spacing, ideally enforced by the build.
<!-- Human alternative -->
<span>Loading… please wait.</span>T55: Uppercase eyebrow on every section, untracked
Why it signals AI: Beyond the spacing issue in T24, default output over-uses uppercase labels as a structural crutch: an uppercase eyebrow over every single section heading, set in the same weight and color as everything else. The pattern is mechanical: badge, uppercase eyebrow, h2, subhead, repeated section after section, with no tracking on the caps.
<!-- AI default: an uppercase eyebrow on every section, untracked -->
<span class="uppercase text-sm">FEATURES</span>
<h2 class="font-bold">Everything you need</h2>Instead: Use uppercase labels sparingly, track them, weight them, and color them as a distinct element, or drop the eyebrow entirely on most sections.
/* Human alternative */
.eyebrow {
text-transform: uppercase; letter-spacing: 0.12em;
font-size: 0.75rem; font-weight: 600; color: var(--accent);
}T56: Title Case on every heading
Why it signals AI: Every heading capitalizes every word, including articles and prepositions (“The Best Way To Build A Landing Page”), because the model applies a blanket Title Case rule rather than a real style. Genuine editorial style is either consistent sentence case or proper title case that lowercases short function words, and the giveaway is the mechanical capital on every “To”, “A”, and “Of”.
<!-- AI default: every word capitalized, articles included -->
<h2>How To Pick The Right Font For Your Brand</h2>Instead: Pick one casing convention and apply it correctly. Sentence case reads as modern and human; if you use title case, lowercase the articles, conjunctions, and short prepositions.
<!-- Human alternative: sentence case -->
<h2>How to pick the right font for your brand</h2>T57: Emoji as bullets and inline icons
Why it signals AI: Lists and headings are decorated with emoji as bullets and inline icons (a check, a rocket, an arrow before each feature), one of the loudest LLM-output signals because it lives directly in the text run and ships straight from the model’s training on marketing copy. It also breaks vertical alignment, varies by platform, and reads to screen readers as the emoji’s literal name mid-sentence.
<!-- AI default: emoji doing the bullet's job -->
<ul>
<li>✅ Fast setup</li>
<li>🚀 Scales to any team</li>
<li>➡️ Works everywhere</li>
</ul>Instead: Use a real list with a styled marker, or a proper SVG icon with the decorative role hidden from assistive tech. Let CSS, not the text, carry the ornament.
/* Human alternative */
.features li { list-style: none; padding-left: 1.4em; position: relative; }
.features li::before {
content: ""; /* or a small SVG via background */
position: absolute; left: 0; top: 0.55em;
width: 0.5em; height: 0.5em; border-radius: 50%;
background: var(--accent);
}Responsive type and internationalization
T58: Fixed px type that does not scale
Why it signals AI: Headings set in fixed pixels (font-size: 48px) with no responsive treatment, so the hero h1 that fits on desktop overflows or wraps awkwardly on a 375px phone. Fixed px also defeats the user’s browser font-size preference, an accessibility cost. Models set one px size because the component was generated against one viewport, and the type never adapts.
/* AI default */
h1 { font-size: 48px; } /* same on phone and desktop, ignores user prefs */Instead: Express type in rem so it respects user settings, and either step it at breakpoints or use fluid type so it scales smoothly (see T59).
/* Human alternative */
h1 { font-size: clamp(2rem, 5vw + 1rem, 3.5rem); }T59: No fluid type, only breakpoint jumps
Why it signals AI: Responsive type handled purely with md:text-5xl lg:text-6xl produces sudden size jumps at each breakpoint and awkward in-between zones (an 800px tablet stuck with the small mobile size). clamp() with a viewport-based middle term is the modern approach, and its absence marks the type system as untouched Tailwind defaults.
<!-- AI default: stepped sizes, awkward between breakpoints -->
<h1 class="text-3xl md:text-5xl lg:text-6xl">…</h1>Instead: Use clamp() so the size scales continuously between a sensible minimum and maximum.
/* Human alternative */
h1 { font-size: clamp(1.875rem, 1rem + 4vw, 3.75rem); }
h2 { font-size: clamp(1.5rem, 0.9rem + 2.5vw, 2.5rem); }T60: Hero h1 that does not shrink on mobile
Why it signals AI: The specific worst case of fixed type: a hero headline sized for desktop (60px or 72px) left at that size on mobile, where it either overflows the viewport horizontally or wraps into five cramped lines that swamp the screen. It is the most visible responsive-type failure and a common one in generated heroes.
/* AI default: hero h1 stays huge on phones */
.hero h1 { font-size: 4.5rem; }Instead: Let the headline shrink to fit small screens, with a floor that keeps it impactful.
/* Human alternative */
.hero h1 { font-size: clamp(2.25rem, 8vw, 4.5rem); line-height: 1.05; }T61: Size scales but leading and measure stay frozen
Why it signals AI: Even when size is made responsive, leading and measure stay fixed, so a headline that shrinks on mobile keeps its tight desktop line-height (now too tight for the smaller size) and the body keeps a ch-based measure that never adapts to the device. The polished move relates leading and width to size as well. Note that line-height cannot ride a single clamp() with a constant middle term: clamp(1.05, 1.2, 1.2) pins the value at 1.2 and does nothing dynamic, so use a breakpoint or a genuinely viewport-linked expression.
/* AI default: size scales, leading frozen too tight for the small size */
h1 { font-size: clamp(2rem, 6vw, 4rem); line-height: 1.05; }Instead: Open leading slightly as the headline shrinks (a media query is the honest way), and let the measure breathe on small screens.
/* Human alternative */
h1 { font-size: clamp(2rem, 6vw, 4rem); line-height: 1.1; }
@media (max-width: 480px) { h1 { line-height: 1.2; } }
.prose p { max-width: min(68ch, 92vw); }T62: Hardcoded left and right, no logical properties
Why it signals AI: Type and spacing are pinned with physical directions (margin-left, padding-right, text-align: left) instead of logical properties (margin-inline-start, text-align: start), so the layout breaks the moment the content runs right-to-left. Models hardcode left/right because the training data predates wide logical-property support and because they generate for a single, left-to-right viewport. The absence of any logical property, and of an RTL consideration anywhere, is the tell.
/* AI default: physical directions, breaks in RTL */
.callout { margin-left: 2rem; text-align: left; padding-right: 1rem; }Instead: Use logical properties so the type mirrors correctly in right-to-left languages with no extra stylesheet.
/* Human alternative */
.callout { margin-inline-start: 2rem; text-align: start; padding-inline-end: 1rem; }How to use this list
Open it during review and run the cheap diagnostics first: read the font-family, its line-height, and its letter-spacing. Three defaults and you already know what you are looking at. Then walk the categories in order of visual weight: fonts and weight hit the eye before anything else, then scale and leading, then the finer craft of figures, features, and loading.
A practical order of operations:
- Replace the family. Get off Inter, Geist, Montserrat, and the bare system stack. Pair a display face against a body face that disagrees with it (T01, T02, T04, T06).
- Build a real ramp. Weight, size, leading, and tracking should each vary by role, not sit at one default value (T10, T14, T19, T22).
- Fix the figures and features. Tabular numerals in tables, oldstyle in prose, optical sizing on, ligatures and stylistic sets engaged (T28, T29, T33, T37).
- Tune loading and color. Matched fallback metrics, a
font-displaystrategy, WOFF2 with a subset, body text off Tailwind gray, links that are not bare blue (T42, T43, T46, T48, T51). - Add the cheap wins.
text-wrap: balanceandpretty, curly quotes, real glyphs for×and ranges, fluidclamp()type, logical properties (T40, T52, T32, T59, T62).
Name a tell on sight and it is far harder for one to slip past, whichever way the code reaches the page. For the full forensic method, read why Inter is killing your brand and the CSS audit guide; for the wider pattern set, the 90+ AI design patterns list.
Sailop detects every typographic tell on this list automatically, scores your type system, and tells you which entries are firing on your own pages.
SHIP CODE THAT LOOKS INTENTIONAL
Scan your frontend for AI patterns. Generate a unique design system. Stop shipping the same blue gradient as everyone else.