docs design
Interface design
2,349 词 · docs/design.md
Rovecode ships no default look. There is no built-in palette, no safe typeface, no house layout, and nothing carries over from the last project. A built-in default would be the exact failure being avoided: it would make every rovecode interface resemble every other one.
What ships instead is a protocol and a checker.
- A protocol in the system prompt: before the first interface in a project, propose three genuinely different directions, let the human choose, record the choice, then build to it.
- A record,
.rovecode/design.json, written by thedesign_directiontool. - A checker, the
design_audittool, which counts patterns in source and compares them against the record.
This page is the whole shipped behaviour; nothing below depends on reading anything else. Two research
write-ups sit beside it in the repository — docs/design-slop-research.md, which argued for recording more
of the direction rather than widening a ban list, and docs/design-audit-calibration.md, which measured the
checker against twenty repositories and set the thresholds quoted here. Both are pinned to the code as it
was before the rework and are kept as history, not as documentation.
The prompt section
Every run carries an "Interface design" section in the system prompt (src/design/rules.ts, appended by
buildDef in src/cli/runtime.ts). Set ROVECODE_DESIGN=off to drop it for runs that have nothing to do
with interfaces.
The section asks for three directions that disagree on all four of these axes, because two proposals that agree on the second or third converge however different their prose sounds:
- what the first screen is made of (real output? prose? an instrument? an image?)
- what carries hierarchy (type scale? density? monospace rhythm? boxes? colour?)
- what colour is for (functional only? near-absent with one accent? state semantics? mood?)
- what counts as proof (real runs? tables and footnotes? live numbers? testimony?)
Asking one model once for three ideas tends to return one idea three times, so each direction is derived from a different source of form and then checked against the four axes.
design_direction
Reads or records the project's chosen direction. action is required.
| action | what it does |
|---|---|
get |
returns the recorded direction, or says none is recorded |
set |
records the direction the human chose; it cannot invent one |
set accepts these fields. Only name is required; everything else is optional, and a malformed field is
dropped rather than throwing, so one bad hand-edit costs that field and not the run.
| field | type | meaning |
|---|---|---|
name |
string | short name of the direction as it was presented to the human |
rationale |
string | one line: why it suits this product |
palette |
object | chosen colours, free-form keys: {"ink":"#0b1a2e","accent":"#c2410c"} |
typeface |
object | faces by role, free-form keys: {"display":"…","text":"…","mono":"…"} |
corners |
sharp | soft | round |
how corners read |
layout |
centered | left | asymmetric | grid |
how the page is composed |
density |
tight | regular | airy |
how tightly the page is packed |
sectionOrder |
string[] | the page's blocks in order, e.g. ["hero","proof","pricing","faq"] |
heroPattern |
string | the hero pattern by name, e.g. "command-first" |
typeScale |
number[] | the chosen type steps, e.g. [14,16,20,28,44] |
motion |
string | the motion rule in the human's words |
copyRegister |
string | how the copy reads |
notes |
string | anything the audit cannot infer |
chosenAt |
string | ISO date; filled in automatically when omitted |
provisional |
boolean | true only when no human could be asked — see below |
chosenBy |
human | agent |
derived: setting provisional forces agent. Absent means a human chose |
alternatives |
string[] | with provisional: the directions that were not built |
The record lives at <cwd>/.rovecode/design.json and is deliberately per project, never global — a
global design file would be the carried-over default the rule forbids.
Headless runs: the provisional direction
ask_user needs a human on the other end. A one-shot rovecode run has none, and a run measured on
2026-09-04 showed what an agent does then: it picks a direction itself and records it as though a human
had chosen — which launders the agent's taste into the one file whose whole job is to hold the human's.
So the record carries who chose. With no human reachable, the agent builds one direction and records
it with provisional: true and the other two names in alternatives. From then on:
design_direction getand the system prompt both open withPROVISIONAL direction: …, notChosen direction: …, and say the choice is still the human's.design_audit's consistency line reads "consistent with the recorded direction X (provisional direction — recorded by the agent, not yet confirmed by a human)". The rules run exactly as they would against a chosen direction — the flag weakens nothing, it only stops the record from silently becoming permanent.- The final summary of the headless run names the two directions it did not build.
- The next interactive session that touches UI asks the question once, then re-records without the flag.
provisional: true always implies chosenBy: "agent"; the two cannot disagree.
set prompts for approval; get does not. That one prompt is the point: it is where the human sees
what is being recorded on their behalf, and it is asked once per project rather than once per task. get
only reads .rovecode/design.json and writes nothing, so it is allowed outright — a card there would cost
an interruption before every UI task, and would train the human to allow a design_direction card
reflexively, which is exactly the card that matters.
The two are told apart by the tool's own resource() (src/core/types.ts Tool.resource), which reports
get or set instead of letting the policy fall back to the tool name; the rules are
tool.design_direction * -> prompt then tool.design_direction get -> allow (last match wins). Anything
that is not literally get is treated as the write, so an unknown action can never read as the safer of
the two, and plan mode still denies set outright.
design_audit
Counts patterns in source and reports evidence, never verdicts. It never prompts and never writes.
| argument | meaning |
|---|---|
files |
paths inside the project to audit |
source |
audit this markup/CSS text directly instead of reading files |
ignore |
rule ids to skip, e.g. ["all-square"] |
Run it on the source tree, not on a fetched page: a built page measures the framework's output, not the design. Pass the page file together with the components it imports — density and centring are scored per page, and "no rounded corner anywhere" is scored per project, so a single-component run cannot see them.
A page means a route file: app/**/page.tsx, app/page.tsx, anything under pages/ that is not
pages/api/, routes/**/+page.svelte, or any .html.
A page is scored as what it renders, not as what its file contains. That means the route file, the
components it imports one level deep, and — because Next.js and its imitators nest them implicitly — every
layout.* from its own directory up to the root, plus what those layouts import. components/ui/** is left
out of the count wherever it comes from. This matters more than it sounds: measured on a fourteen-route
Next.js app, the layout chain carried 8–24 elements per route and on four routes was larger than the page
file, and that is exactly where a site keeps the nav, the footer and the section rules a density check is
looking for. Every finding names the files it counted, so you can disagree with the scope itself.
Pass only components and the two page-scoped rules produce nothing at all — a clean report there means "not
measured", not "fine". Text passed as source is treated as one page, so pasted markup does get the page
checks.
Slop and deviation
Every finding is one of two kinds, and the difference is the whole design of the checker.
- slop — nothing here records a decision. Only assertable when
.rovecode/design.jsonis absent. It is not a claim that the pattern is ugly; it disappears the moment the human chooses and the choice is recorded. - deviation — the code contradicts what this project already chose. This is the half worth acting on, and the half a machine can actually judge.
The reason for the split: one person's brand yellow and a template's default yellow are the same token. No count tells them apart. Only the record does.
The rules
| id | kind | scope | fires when |
|---|---|---|---|
cliche-font |
slop | file | a default webfont is loaded (a next/font/google import, @fontsource, @font-face, a Google Fonts family= URL) and no direction is recorded. A mention in prose, a fallback position and a system stack are not decisions and never count; .md/.mdx and OG-image files are excluded |
font-named-not-loaded |
slop, or deviation when it is the recorded face | project | a face is named in a font-family (or a Tailwind font-[…]) that nothing in the audited set loads. It renders as its fallback, so the page does not look the way the code says. Generic keywords, system stacks and var() indirection are not faces. Project-scoped because the load site is usually a layout or a global stylesheet — pass those in or the rule cannot see them. Blind spot: next/font/local and a bare src: url(…) outside @font-face do not name their face reliably, so a face loaded only that way still reports |
font-deviation |
deviation | file | a face is loaded that is not the recorded one, or a font-family declaration leads with a system stack while a face is recorded |
cliche-accent-amber |
slop | file | amber sits in an accent position (a --primary/--accent/--brand token, or bg/text/from/border-amber-[3-7]00 next to a button, link or heading) with no direction recorded. Warning and status uses, <svg> payloads and data URIs never count, and a document declaring five or more saturated hue families is a palette, not an amber theme |
accent-deviation |
deviation | file | the same, when the recorded palette holds no amber |
template-grid |
slop | file | (md|lg):grid-cols-3 in a page or section file and no layout recorded — the three-up feature grid every template ships |
template-icons |
slop | file | lucide-react is imported and nothing at all is recorded. A marker, not a fault |
reflex-hero |
slop, or deviation once heroPattern is recorded |
file | viewport height and a centring class on the same element and an <h1> within the window and a page or section file that is not an exempt name |
cliche-gradient |
slop / deviation | file | the violet-to-blue pair: a Tailwind from-violet…to-blue, or a CSS gradient with two saturated stops, one violet. Syntax-highlight scopes are excluded |
off-palette |
deviation | file | more than one OKLCH hue family outside the recorded palette. Tints and shades of a recorded colour are in-palette; one extra family is allowed, because semantic states are not a second brand |
decoration-density |
deviation | file | the record itself asks for restraint and decoration (gradients, background images, ornament, motion) runs above 0.6 per element |
rule-line-density |
slop / deviation | page | borders and dividers above 0.4 per element across a route file plus the components it imports. components/ui/** never contributes |
everything-centered |
slop | page | centring above 0.45 per element across a page, with no layout recorded. Page types that are correctly centred — login, register, 404, error, loading, empty, tooltip, dialog, modal, toast, announcement, layout — are exempt |
all-square |
slop | project | not one radius declaration anywhere in the audited set, and corners is not sharp |
Some page types are exempt from centring and hero checks by name because centring is correct for them: a login form, a 404, an empty state, a tooltip.
Every finding names the evidence it counted, so a human can overrule it. Saying plainly why a finding is wrong for this project is a valid response; so is recording the decision and watching the finding disappear.
The amber floor
cliche-accent-amber fires on hues 20–55°. Rust and terracotta measure below that — #b4431d is 15°,
#c2410c 17°, #9a3412 15° — and the reflex amber ramp above it: #ea580c 21°, #b45309 26°,
#d97706 32°, #f59e0b 38°. The floor stays at 20 on purpose: a rust is a colour somebody reaches for,
never a default. The cost is accepted and stated — an amber at exactly 19° escapes too. This rule only
ever fires when no direction is recorded, and one wrong slop finding on a deliberate palette costs more
trust than one missed cliché costs quality.
Known limits
The checker greps source. It reports what is written, misses what is computed at runtime, and can be
fooled by indirection. Colour is read as a hex literal, as a bare H S% L% triplet on a custom property
(the Tailwind hsl(var(--x)) convention), or as a written hsl(); a colour behind color-mix, an
oklch() literal, or a second level of variable indirection is still unread. Path prefixes matter too: the
components/ui/** exemption and the route patterns are matched against the paths you pass, so auditing from
inside components/ rather than from the project root quietly turns them off. It cannot see rhythm, whether the copy is filler, or whether the layout has one
organising idea. The calibration measured the ceiling honestly: without a recorded direction, a deliberate
choice and a reflex look identical, which is why the deviation half is where the value is.