Design harness · capture layer

The annotation system How a designer's judgement becomes something a machine can hold on to

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.

The workflow, end to end

1

Build an exploration

A self-contained HTML proto in a problem folder, the way every proto in this repo already works.

2

Annotate it

Open it, drive it to the state you care about, press Shift+C. Click an element, say what you notice.

3

Save

Notes merge into one store shared by the whole problem folder. Siblings are never overwritten.

4

They stay put

Beside the proto, not in a central database. The judgement travels with the work it is about.

Where the notes actually live

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.html            the proto, never modified
├── checkout-with-offer.html    another 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.

How the mechanism works

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.

Why it is a mode you turn on, and not just always on

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.

What a note is anchored to

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.

Reading them back

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.

The rules

An annotated proto is frozen Load bearing

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.

Every note records the file's content hash

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.

To iterate, fork. To start over, clear.

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.

The tool never writes to a proto

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.

Not built yet: there is no clear-all button. Today you delete notes one at a time by clicking a marker and choosing Delete. The rule above describes the intent; the affordance is one small addition away.

Can these annotations drive the next exploration?

Yes, and in two different ways that are worth keeping separate, because only one of them works from annotations alone.

As guardrails, which works today

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.

As anchors, which needs something more

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.

Deliberately out of scope, for now

Bracketed, but designed for

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.

Known gaps

GapWhat 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.
The first two have the same answer, which is usually the sign it is the right one: serialise the rendered DOM at annotation time instead of pointing at a live file. A snapshot bakes the state in, and it is also the only route by which an exploration built in the React app could ever be annotated here, where its dependencies do not exist. Serialise, never rewrite: this repo already judges hand-translating React to HTML "well under 80% accurate".