A designer looks at a screen and knows what is wrong with it. That knowledge normally evaporates, or lands in a Slack thread nobody can query. This is the mechanism that catches it, attaches it to the exact element it was about, and keeps it valid forever.
A self-contained HTML proto in a problem folder, the way every proto in this repo already works.
Open it, drive it to the state you care about, press Shift+C. Click an element, say what you notice.
Notes merge into one store shared by the whole problem folder. Siblings are never overwritten.
Beside the proto, not in a central database. The judgement travels with the work it is about.
You asked whether they go and sit somewhere else. They do not. They sit in the same folder as the screens they describe, in one file per problem:
explorations/mudita/design-harness/ ├── cancel-flow.htmlthe proto, never modified├── checkout-with-offer.htmlanother proto in the same problem├── zero-credits.html └── annotations.jsonl every note for every screen in this folder
One store per problem folder rather than one per screen, because a problem is the unit you actually think in. You want to ask "what did I say about checkout?" and get one file to read, grep and diff, not thirteen. It also means the notes move, survive and get reviewed with the folder they belong to.
The annotator only ever holds the notes for the screen in front of you, so saving is a merge, not a write: the server replaces the lines belonging to that proto and keeps every sibling's lines untouched, through a temp file so an interrupted save cannot truncate the store.
The whole design follows from one constraint: the proto file is never written to.
serve.py serves a proto
│
└─ injects, on the way out only:
<script src="/tools/annotate/annotate.js" data-hash="<sha of the RAW bytes>">
The file on disk is untouched. The hash is taken before injection,
so the freeze check describes the file, not what the browser got.
annotate.js runs inside the proto
│
├─ idle adds nothing but one keydown listener.
│ The proto clicks, animates and navigates exactly as it always did.
│
└─ Shift+C annotation mode. Chrome is drawn in a shadow root, so the proto's
own DOM stays clean and a stored selector keeps meaning what it meant.
Clicks now select instead of activating.
Because annotation has to steal the click. If the tool were always listening you could never
reach the screen you wanted to talk about. With the mode off you drive the flow to
#step-2, open the modal, get the thing on screen. Then you turn annotation on,
and the note records which state it was made in, so its marker only ever draws there.
A CSS selector, computed at the moment you click. Runtime classes like is-active
are stripped out, because those describe the moment rather than the element. Everything else
about the record exists to make the note legible later without opening anything.
{ "id": "8750e4f6", the note's own identity
"proto": "…/cancel-flow.html",
"hash": "3b73fad92d704562", which version of the file this judgement was made against
"state": "#step-2", so the marker draws on the right screen
"selector": "div.flow > section.step:nth-of-type(2) > … > div.pill:nth-of-type(2)",
"rect": [822, 349, 85, 16], document coords, so it survives scrolling
"note": "The spacing around icons should always be equal on all sides",
"at": "2026-07-31T10:07:19Z" }
id matters more than it looks. Position in the file is not an identity: delete the
second note and everything after it renumbers, so anything holding a reference would silently
re-point at the wrong judgement.
The contact sheet shows every note in the repo with a picture of what it is about. The picture is the real proto in an iframe, scaled so the element is in frame and marked, rather than a screenshot. A note anchors by selector, so the thing worth showing is the DOM that selector resolves against. A raster could not be re-anchored or re-measured, and would go stale without saying so.
It shows a region around the element, never a tight crop. Most judgements are about an element in its context. "This subtitle repeats the title" cannot be seen from the subtitle alone.
Once a note exists against a file, that file is never edited again. To change the design you make a new copy, a v2, which starts with no annotations of its own.
Why: it makes every annotation permanently valid. A stored selector stays exact forever because the DOM it points at cannot change. The entire class of "keep the marker attached as the design drifts" problem simply never arises, and that problem is hard enough that other systems are built around solving it.
The hash is how the freeze is enforced rather than merely requested. If a frozen file is edited anyway, the bar says so and names how many notes predate the change, so you find out immediately instead of discovering it months later.
Two escape hatches, and they mean different things. Fork a v2 when the notes were right and you want to act on them: v1 keeps its annotations as the record of what was wrong, and v2 records what it was forked from, so the harness can later ask whether v2 actually fixed what was flagged on v1. Without that link the loop learns nothing from the iteration.
Clear the annotations when the notes were wrong, or exploratory, or about a direction you have abandoned. Clearing unfreezes the file, and you edit it in place.
No script tag saved into the file, no attribute added, no class toggled. The script that makes
annotation possible is added by the server on the way out and exists only in the browser. This
is what keeps the hash honest, and it is why annotation only works through
serve.py rather than from Finder or GitHub Pages.
Yes, and in two different ways that are worth keeping separate, because only one of them works from annotations alone.
Each note is already a rule in plain words. Four notes on two screens produced three distinct ones: dividers run edge to edge, subtitles must add something the title did not, icon spacing is equal on all sides. Handed to the next build as constraints, they stop the same mistake being made a fourth time.
The repetition is the useful part, not noise to be tidied away. Two of those four notes are the same rule, written independently on two different screens. That is the promotion signal: a note made once is an observation, the same note made on two screens is a rule. It is a fact about the corpus rather than about how strongly anyone feels, which is exactly what makes it safe to act on.
Annotations are mostly negative knowledge. They say what is wrong, and a pile of don'ts constrains a design without composing one. To generate a new exploration you also need a positive reference: this is what good looks like here. That is a different artifact, an approved screen used as a worked example, and it is why the annotated file itself matters as much as the notes on it.
Which is the real argument for the freeze rule doing double duty. A frozen, annotated, approved screen is simultaneously the evidence for a judgement and the example to build from. A screenshot could only ever be the first of those.
What happens to a note after it is captured is a separate job, and it belongs to the harness rather than here. Three consumers will want the same store, and the record is shaped so none of them needs it changed:
| The designer | reads the contact sheet, decides whether to fork a v2 or clear and edit in place |
| The design judge | needs the picture and the element, to ask whether a new screen repeats a known failure |
| The product judge | needs the words and the state, to check a requirement was actually met |
| The lint | needs the selector against a real DOM, which is the reason the store is never a raster |
The line is drawn at annotations.jsonl. Capture and storage sit on this side of it.
Clustering repeats, promoting a rule into a lint check or a visual-language line, and choosing
exemplars all sit on the other. Treating the file as the interface, rather than as a halfway
point, is what lets both halves move without waiting for each other.
| Gap | What it means |
|---|---|
| State needs the hash |
A note records location.hash and nothing else. A modal opened by a button that
does not change the URL records no state, and the note will not re-resolve on the next
visit. Every proto in the corpus today is hash-driven or static, so this has not bitten yet.
It will, on the first click-driven state.
|
| Levels walk the DOM, not the design | Alt+scroll steps up the element tree, which on a Tailwind-heavy proto means layout wrappers rather than the button row, the card, the modal. |
| No clear-all | Notes are deleted one at a time, so the unfreeze path described above is manual. |
| Local server only | No injection means no Shift+C, so annotation does not work from Finder or GitHub Pages. This is the price of never writing to the proto, and it is worth paying. |