DEV Community

bayuh85
bayuh85

Posted on

tastelint: a linter that fails the build when your coding agent ships the purple-gradient landing page again

Repo: ๐Ÿ‘‰ github.com/bayuh85/tastelint (MIT, Python 3.10+, standard-library scanner, 37 offline tests)

The problem

Ask any coding agent for a landing page and you can draw the result before it finishes: a purple-to-blue gradient, Inter, a centered hero, "โœจ Supercharge your workflow", a Get Started button, and three identical glass cards with an emoji on top and lorem ipsum underneath.

None of that is a bug. Type checks pass, tests pass, the page renders. It just looks like every other generated page, and nobody catches it until a human opens the preview and sighs.

ESLint and Stylelint check correctness. Nothing in the pipeline checks taste. So I wrote a linter for the habits themselves: name the pattern, point at file:line, give the page a score, and fail CI when the score is too low.

What tastelint does

  • tastelint scan walks HTML, CSS, SCSS, JSX/TSX, Vue, Svelte, MDX and Tailwind class strings with a deterministic rule engine. No browser, no network, no model.
  • tastelint review (optional) sends a compact summary of the findings to an OpenAI-compatible model and asks for concrete edits.
  • tastelint skill install drops a design guide where Claude Code, Cursor and Codex look for instructions, and tells the agent to run the scanner before it says "done".

There are 17 rules, each with a severity and a one-line fix:

Rule Severity Catches
gradient-wash high Cool purple-to-blue gradient
hype-phrase high A sentence that could ship on any other product
nested-card high Card inside a card
gray-on-gray high Neutral text on a nearby neutral
stock-sans medium Inter / Roboto / Poppins / system stack as the actual face
glass-panel medium Translucent blur panels
feature-trio medium Three matching feature cards
violet-default medium Indigo or violet as the unchosen accent
gradient-text medium Gradient clipped into a headline
radius-inflation, shadow-bloat, center-gravity, emoji-heading, filler-copy medium The rest of the costume
blur-orb, emoji-icon, empty-cta low Decorative orbs, emoji icons, "Learn More"

The score: high findings cost 12, medium 5, low 2, each rule is capped at 12, and the result is 100 minus the sum (floor 0). --fail-under 90 means one high finding fails the gate.

A real use case: gating an agent-generated landing page

Say you asked an agent for the homepage of a small shipping tool. This is the kind of component that comes back (a typical example, trimmed to one file):

export default function Landing() {
  return (
    <main className="min-h-screen bg-gradient-to-br from-indigo-600 via-purple-600 to-blue-500 font-sans">
      <section className="flex flex-col items-center justify-center text-center py-32">
        <h1 className="text-6xl font-extrabold">โœจ Supercharge your shipping workflow</h1>
        <p className="mt-6 text-gray-400">Leverage cutting-edge tools to streamline every parcel.</p>
        <button className="mt-10 rounded-full bg-violet-600 px-8 py-4 shadow-2xl">Get Started</button>
      </section>
      <section className="grid grid-cols-3 gap-8 px-16">
        <div className="rounded-3xl bg-white/10 backdrop-blur-xl p-8 shadow-xl"><h3>๐Ÿ“ฆ Fast</h3><p>Lorem ipsum dolor sit amet.</p></div>
        <div className="rounded-3xl bg-white/10 backdrop-blur-xl p-8 shadow-xl"><h3>๐Ÿ”’ Secure</h3><p>Lorem ipsum dolor sit amet.</p></div>
        <div className="rounded-3xl bg-white/10 backdrop-blur-xl p-8 shadow-xl"><h3>๐ŸŒ Global</h3><p>Lorem ipsum dolor sit amet.</p></div>
      </section>
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

Run the scanner with a gate:

$ tastelint scan src --fail-under 90
tastelint 0.1.0
src  ยท  1 file  ยท  24 findings (3 high, 20 medium, 1 low)  ยท  taste score 9/100

src/Landing.tsx:3  high    gradient-wash     Cool purple-to-blue gradient.
src/Landing.tsx:4  medium  center-gravity    Layout collapsed onto the center.
src/Landing.tsx:5  high    hype-phrase       Sentence that could ship on any other product.
src/Landing.tsx:5  medium  emoji-heading     Emoji sitting in a heading.
src/Landing.tsx:6  high    hype-phrase       Sentence that could ship on any other product.
src/Landing.tsx:7  low     empty-cta         Call to action with no object.
src/Landing.tsx:7  medium  shadow-bloat      Heavy drop shadow.
src/Landing.tsx:7  medium  violet-default    Indigo or violet used as the default accent.
src/Landing.tsx:9  medium  feature-trio      Three matching feature cards.
src/Landing.tsx:10  medium  filler-copy       Placeholder copy still in the file.
src/Landing.tsx:10  medium  glass-panel       Glass panel.
src/Landing.tsx:10  medium  radius-inflation  Corner radius larger than the object.
... (11 more lines)
src/Landing.tsx:12  medium  emoji-heading     Emoji sitting in a heading.

gate: 90  ยท  fail
exit 1
Enter fullscreen mode Exit fullscreen mode

Score 9/100, exit code 1. Every line names the rule, and --verbose adds the fix and the offending source line, for example:

  hint: Say what the control does: 'Reserve a firing slot', not 'Get Started' or 'Learn More'.
  > <a class="cta" href="#">Get Started</a>
Enter fullscreen mode Exit fullscreen mode

That is the part an agent can act on. The fix isn't "make it prettier", it's "this button has no object", "this card is inside a card", "this sentence works for a bank and a bakery".

Here is the same page after following the hints: one chosen serif, one pigment, copy that only fits this product, left-aligned, no card grid:

<main className="min-h-screen bg-stone-50 text-stone-900 font-['Fraunces',serif]">
  <section className="max-w-3xl px-8 py-24">
    <h1 className="text-5xl font-semibold">Label, weigh and book a courier for 40 parcels before lunch</h1>
    <p className="mt-6 text-stone-700">Paste your order export. Parcelbay prints the labels, picks the cheapest courier per postcode, and emails buyers their tracking links.</p>
    <button className="mt-10 rounded-md bg-emerald-800 px-6 py-3 text-white">Import today's orders</button>
  </section>
  ...
</main>
Enter fullscreen mode Exit fullscreen mode
$ tastelint scan src --fail-under 90
tastelint 0.1.0
src  ยท  1 file  ยท  0 findings (0 high, 0 medium, 0 low)  ยท  taste score 100/100

gate: 90  ยท  pass
exit 0
Enter fullscreen mode Exit fullscreen mode

The linter doesn't make a page good. It makes the default page fail, which forces somebody (you or the agent) to make a decision instead.

The bundled examples

The repo ships a deliberately generic marketing page in five formats (HTML, CSS, React, Vue, Svelte) and a small ceramics-studio page as the control:

$ tastelint scan examples --quiet
tastelint 0.1.0
examples  ยท  7 files  ยท  85 findings (25 high, 52 medium, 8 low)  ยท  taste score 0/100

$ tastelint scan examples/quiet-studio --fail-under 90 --quiet
tastelint 0.1.0
examples/quiet-studio  ยท  2 files  ยท  0 findings (0 high, 0 medium, 0 low)  ยท  taste score 100/100

gate: 90  ยท  pass
Enter fullscreen mode Exit fullscreen mode

JSON and SARIF are the same report in another shape, so you can upload SARIF to GitHub code scanning and see findings inline on the PR:

tastelint scan src --format sarif -o tastelint.sarif
Enter fullscreen mode Exit fullscreen mode

Need an exception? // tastelint-disable-next-line hype-phrase, or tastelint-disable-line all.

Teaching the agent before it writes the page

Catching it in CI is good. Not generating it is better:

$ tastelint skill install --target claude
installed .claude/skills/tastelint/SKILL.md
$ tastelint skill install --target codex
installed AGENTS.md
Enter fullscreen mode Exit fullscreen mode

(--target cursor writes .cursor/rules/tastelint.mdc.) The installed text is an original guide, the "Plain Surface Charter": name the type, spend one pigment, write a sentence that fails elsewhere, center a line not a page, and run tastelint scan <path> --fail-under 90 before calling the change done. Re-running the Codex install replaces its section instead of duplicating it.

Where the model comes in (and why it stays cheap)

The scanner never needs a model. tastelint review is the optional second opinion: it scans first, stops if the score is already 100, and otherwise posts the score, rule ids and short snippets (capped at 20 findings) and asks for edits that name a file and a replacement. --dry-run shows you exactly what would be sent without opening a socket:

$ tastelint review examples/sloppy-landing --dry-run
dry run  ยท  https://atmorouter.dev/v1/chat/completions  ยท  model atmo/deepseek-v4.1-flash  ยท  taste score 0/100
network was not called

Critique this interface using the Plain Surface Charter. Return concrete edits only.
... (JSON summary of the first 20 findings)
Enter fullscreen mode Exit fullscreen mode

A design critique like this is a small prompt you run on every UI change, so price per token matters more than model size. That's why the default host is AtmoRouter, a cheap AI API gateway and OpenRouter alternative: one OpenAI/Anthropic-compatible key for 169 models, pay per token, at an average of 80.0% below official prices. The default model is atmo/deepseek-v4.1-flash.

export ATMOROUTER_API_KEY=ar-your-key-here
tastelint review src
Enter fullscreen mode Exit fullscreen mode

It isn't locked in. Point it at any OpenAI-compatible server, including a local one:

tastelint review src --base-url http://127.0.0.1:11434/v1 --model llama3.2 --api-key local
Enter fullscreen mode Exit fullscreen mode
Variable Default
ATMOROUTER_BASE_URL https://atmorouter.dev/v1
TASTELINT_MODEL atmo/deepseek-v4.1-flash
TASTELINT_BASE_URL / TASTELINT_API_KEY override the AtmoRouter values

CI in four lines

- run: python -m pip install -e .
- name: Generated pages have to clear the gate
  run: tastelint scan src --fail-under 90
Enter fullscreen mode Exit fullscreen mode

Exit codes: 0 pass, 1 under the gate, 2 bad path or arguments, 3 review request failed, 4 review has no key.

Limits, honestly

tastelint reads source, it doesn't render. It doesn't compute layout or real contrast against a gradient, and a "card" is a class named card or a node that combines a large radius with a shadow, border or fill. It will miss things and occasionally flag something you meant. That's why every finding is one rule you can disable per line, and why the score is a gate, not a verdict.

Try it

Rules are small functions in rules.py, so adding one (bento grids, anyone?) is a contained PR. If it stops one more purple hero from shipping, a star on GitHub helps other people find it.

Top comments (0)