auto-markup
Recreate a target screenshot as working HTML/CSS by iterating against deterministic signal tools. You are the markup reasoner and the VLM: look at the target image yourself, write HTML/CSS, then let the tools measure what is still wrong. Never guess what the tools can measure.
Invocation
The vlmkit CLI in this repo runs from source:
node --experimental-strip-types src/cli/vlmkit.ts <command...>
build component needs the MoonBit policy core on PATH
(export PATH=$HOME/.moon/bin:$PATH if moon is not already there).
build page, check palette, scan component, contract scaffold
run with no extra setup.
Pipeline
0. Look at the target
Read the target PNG with your own vision. Note the landmark structure (nav / hero / cards / footer), copy text you can read, and rough proportions. Then get measured facts:
vlmkit check palette target.png # dominant hex colors + outer/inner backgrounds
vlmkit scan component target.png # optional: bbox + crops of major components
1. Skeleton
Two options:
Contract-first (best for multi-landmark pages): author a
ui.contract.jsonfrom what you see (screens → landmarks with role/name/layout policies + viewports), then compile it:vlmkit contract scaffold ui.contract.json --out build/The scaffold gives you semantic tags, grid/flex CSS, and responsive @media rules for free; you then replace placeholders.
Direct: write
current.htmlby hand from the image. Fine for a single component.
2. Composition loop (page-level)
vlmkit build page target.png current.html [--crop crops/] [--json]
Fix in this order — each class dominates the next:
- Missing components — the report names bbox + fill hex; add the block.
- Extra components — remove or merge.
- Ordering violations — sections rendered in the wrong vertical order.
- Stacking gap deltas — the report says "reduce 72px"; adjust margins.
- dSize / dPos on matched pairs — width/position tuning.
Repeat until: no missing/extra, no ordering violations, all IoU ≥ 0.9.
--crop writes per-component target/current pairs for step 3.
3. Per-component loop
vlmkit build component target.png current.html --output-dir out/
Read out/report.json (machine-readable twin of report.md): goal
status, bbox deltas, heatmap regions with fill hex + kind, text-row
typography (estimated font-size/weight per row), row-gap deltas
("row #3: reduce preceding margin-bottom by 4px"), palette
missing/extra. Apply the Suggested CSS patch entries, re-run.
Stop when goalEvaluation.status is pass (or diff plateaus).
3.5 Multi-viewport targets (media queries)
When the task supplies target PNGs at several viewport widths, one
HTML file must satisfy all of them via @media:
- Build the widest viewport first as the base stylesheet; express
every narrower viewport as
@media (max-width: ...)overrides (conventional breakpoints like 768px are fine unless the targets imply otherwise). - Run the composition loop per viewport:
build page target-desktop.png attempt.html, thenbuild page target-mobile.png attempt.html.build pagerenders your HTML at each target's own dimensions, so the media query is exercised automatically. - A component present in one viewport and absent in the other (e.g. a
sidebar hidden on mobile) is correct when the narrow target also
lacks it — implement with
display: noneinside the media query. If a viewport's report lists it missing/extra, fix it inside that viewport's media query only; never regress the converged base. - Verify declared breakpoints:
vlmkit scan breakpoints attempt.htmllists them;vlmkit check breakpoints attempt.htmlrenders at B−1/B/B+1 and catches off-by-one boundaries (a width that matches neither regime), elements vanishing exactly on the boundary, and overflow at boundary widths.
3.6 Scrollports (scrollable regions)
A screenshot only shows above-the-fold content of a scrollable panel. Two consequences:
- If the task provides an extra "scrolled to bottom" screenshot, read it for the hidden items (real copy, item count). Without one, do not invent hidden content — build exactly the visible items and say so.
- Implement with a fixed height +
overflow-y: auto(the visible cut-off row in the screenshot is the tell that the panel scrolls). - Verify deterministically that the panel actually scrolls:
vlmkit scan scroll attempt.htmlinventories every real scroll container (axis + overflow px) with no annotations needed, and its--jsonreport includes readyexpectedScrollportsentries (under.report, alongside the sharedverdict/countsenvelope); it also flags unintended page-level horizontal scroll and overflow:hidden cut-offs. (vlmkit contract introspectstill works when the markup carriesdata-scrollportannotations.) A panel that merely looks cut off but grew to fit its content is a bug the pixel diff of the default screenshot won't show.
3.7 Interactive states (:hover / :focus)
State styling is invisible in a default screenshot. When the task
provides extra state screenshots (button hovered, input focused),
diff them against the default target with your eyes to read the
state delta (darker button, focus ring color/width), then write the
:hover / :focus / :focus-visible rules.
Verify deterministically that the states are actually wired:
vlmkit build component target-default.png attempt.html \
--output-dir out/ --states hover focus-visible
The forced-state section flags induced 0% on interactive elements as
suspect (state rule missing), ua-likely when only the browser's
default focus ring fired (you forgot the author rule), and
direction? when :hover lightens what should darken. Avoid CSS
transitions on state properties — they blur forced-state capture and
real-user perception alike unless the target shows them.
3.8 Theme parity (light / dark)
When the task provides light AND dark target screenshots, one HTML
must serve both via prefers-color-scheme:
- Put every themed color in a CSS custom property on
:root, and override the variables only inside@media (prefers-color-scheme: dark)— never fork component rules per theme. Read each theme's palette off its own target (check palette target-light.png/check palette target-dark.png). - Dark themes usually swap more than backgrounds: accents shift a
step lighter (
#2563eb→#3b82f6), text-on-accent may invert, badge fills flip from tint to shade. Compare the same component across the two targets before assuming a shared value. - Converge each theme separately:
build page target-light.png attempt.html, then dark.build pagerenders with the OS default scheme, so verify the dark render explicitly with a small Playwright snippet usingemulateMedia({ colorScheme: "dark" })and pixel-diff it against the dark target (diff png). - Audit:
vlmkit check theme attempt.htmlflags components whose fill is identical in both themes (unthemed — a hard-coded color that ignores the scheme). Every major surface should respond.
4. Decoration audit
vlmkit check palette target.png current-render.png # missing = forgot a color; extra = hard-coded literal
vlmkit check tokens current.html # off-scale radius/spacing/z-index/shadow
vlmkit check theme current.html # unthemed hard-coded colors (if theming required)
vlmkit check animation current.html # if you authored animations: each one visibly moves
# (dead animations flagged), settle time, reduced-motion parity
check animation is also why your own build page / diff png runs can
look nondeterministic: an infinite-animation issue names the selector to
--mask during captures.
Budget & stopping
- 3-5 rounds of step 2 + step 3 combined is normally enough for a single-viewport page; the 2026-05-13 dogfoods converged 87% → <2% in 3-5 rounds. Multi-viewport pages need more — budget 8-12 rounds, since a fix at one viewport can regress another (see the dashboard proof in docs/reports/2026-07-27-auto-markup-skill-haiku-proof.md).
- If a round does not improve the headline number, change what you fix (composition vs decoration), not just the values.
- Report final numbers honestly:
build pagematched/missing counts + worst IoU,build componentdiff % + goal status. - KPIs: every run is scored on rounds (one measure→fix cycle;
keep a one-line log per round, state the total) and tokens
(recorded by your driver from the harness usage — you cannot see
your own; top-level sessions report context usage as an
approximation). Ledger and targets:
docs/knowledge.md"Markup Agent KPI". KPIs only count when the stopping criteria above are met — declaring convergence with leftover missing/extra is a failed run, not a cheap one. Save tokens by reading report files and crops instead of re-reading screenshots you have already seen.
Ground rules
- Never open or copy the reference/original HTML if one exists — the proof is reconstructing from pixels.
- Trust measured hex from
check paletteover your own eyeball estimate; trust bbox/gap numbers over visual guessing. - Placeholder text is a bug: read the real copy off the target image.