tastelint 為套路化登陸頁面評分,低於門檻便令 CI 失敗
tastelint: a linter that fails the build when your coding agent ships the purple-gradient landing page again
bayuh85 開發 tastelint,一款按設計規則掃描前端原始碼、並可在分數低於指定門檻時令 CI 失敗的 linter。它包含 17 條規則,會列出問題位置和修正提示;掃描器不需瀏覽器、網絡或模型,另提供可選的模型評閲及 Claude Code、Cursor 和 Codex 技能安裝。
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 scanwalks 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 installdrops 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>
);
}
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
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>
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>
$ 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
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
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
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
(--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)
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
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
| 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
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
- Code: github.com/bayuh85/tastelint
- Default gateway for
review: atmorouter.dev
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.
來源:DEV Community · dev.to