Design starts with input artifacts, not model choice
Why “make it look good” is a poor brief for any model, and what to provide instead: the design workflow behind the blog you are reading.
This blog’s layout was built in one session, and a grep of src/components
finds no literal colors. That was not a lucky choice of model. Before the first
line of layout code, the inputs already existed: a token table, a list of constraints
and ten concept images.
Why “make it look good” does not work
“Build a beautiful landing page” leaves the agent a hundred decisions: background, font, spacing, where to put the accent. It makes those choices from familiar patterns in its training data. The result is the same purple gradients and cards with 16px corners that keep appearing across generated sites.
The method described by @serejaris reverses that:
design is provided as an input artifact. The agent receives design.md and uses
the specified --color-iris, rather than choosing between #6366f1 and #8b5cf6
according to its own taste.
What goes into the input
| Artifact | What it fixes | Without it |
|---|---|---|
design.md |
Color roles, type scale, spacing and radii | Each component gets its own shade of gray |
llms.txt |
Constraints that can be checked | The agent invents “one more accent color” |
| Concept images | Composition and rhythm before layout | A layout based on taste, with no reference to compare it against |
| UI kit (shadcn) | Primitive behavior and states | A custom reinvention of every dropdown |
The order matters. Without design.md, llms.txt can forbid inventing colors but
cannot say which ones to use. Concepts without tokens provide an image that is
hard to reproduce consistently in code.
A reference theme cannot be adopted unchanged
A common mistake is to take someone else’s design.md and apply it as-is.
We chose the Huly theme from styles.refero.design
and started by calculating contrast for its palette:
def contrast(fg: str, bg: str) -> float:
hi, lo = max(lum(fg), lum(bg)), min(lum(fg), lum(bg))
return (hi + 0.05) / (lo + 0.05)
Results on the reference canvas, #303236:
| Pair | Contrast | Verdict |
|---|---|---|
Link #5683da on the canvas |
3.46 | Below AA for normal text |
Secondary text #95979e |
4.40 | Below AA |
White text on a #5683da button |
3.71 | Below AA |
The theme looks good in SaaS landing-page screenshots, but those combinations
fail the text-contrast requirements of a blog. We darkened the canvas to #0b0c0e
and revised the text/accent combinations so the checked text pairs met AA.
Four details we learned while building
Tailwind v4 removes unused tokens. Declare tokens with @theme, then reference
them from scoped Astro styles through var(--spacing-5), and the variable may be
missing from the build: Tailwind emits the tokens used by utilities. One word fixes it:
@theme static {
--spacing-5: 24px;
}
static emits all the variables regardless of usage.
A self-reference in @theme inline. Mapping a variable to itself, such as
--color-canvas: var(--color-canvas), creates a cyclic reference in the built CSS.
You can use the layer cascade, but declaring each token once directly in @theme
is simpler than debugging the cycle.
Cyrillic narrows the display-font choice. The reference’s Esbuild font lacks
Cyrillic, as do many distinctive sans-serif fonts in design galleries.
We used @fontsource/unbounded 5.3.0, with Onest and Manrope recorded as alternatives
in design.md. Our shortlist contained four names.
Preflight strips blocks of their meaning. Tailwind resets browser styles,
including list-style on every list. Prose.astro never restored markers for
article text, and for a while lists looked like broken-off paragraphs. Grep cannot
catch this: there is nothing to forbid, the style is simply missing. We caught it
three weeks after the build, while adding a test of Telegram’s in-app browser: it puts every block the author is allowed to
use into an article and checks the computed styles — a list has markers, a quote
has its rule, long code scrolls. A block allowed without a line in the test is red.
A constraint you can run through grep
A guideline is easy to ignore. Phrase a constraint so that grep can check it,
and the document becomes a test:
# Literal colors and arbitrary pixel utilities: expected output is empty.
grep -rnE '#[0-9a-fA-F]{3,8}|rgb\(|hsl\(|\[[0-9]+px\]' src/components src/layouts src/pages
# Classes from an unrelated Tailwind palette: expected output is empty.
grep -rnE '(bg|text|border)-(slate|gray|zinc|blue|indigo)-[0-9]' src/
Both commands live in llms.txt, and the agent is instructed to run them before
committing. A rule we cannot check with a command does not belong in that file.
The same tokens also produce social cards: the OG generator takes its colors
from design.md, so the Telegram preview
and the page use the same visual material.
The sequence
Start with the calculations: evaluate the reference palette against your own content. The link, the secondary text and white text on a button failed AA.
Then write design.md with tokens and roles and transfer those values mechanically
to tokens.css. A mismatch is a defect, not a matter of taste. Follow with llms.txt,
its constraints and its grep commands.
Only then create the concepts: six to ten different directions, choose one, and record why the others were rejected. Otherwise a week later nobody remembers why the card grid was rejected, and it gets proposed again.
The ten-image concept run cost twenty cents: that is the recorded cost_usd in
run.json, not an estimate. I have not measured the cost of rebuilding a blog
that “looks somehow wrong.” Without a selected concept, there is no agreed visual
reference against which to judge the finished layout.