Files
mvschwarz 69472e4479 Annotations: mark up any surface, including ones that never heard of it
studio-qa extracted a working annotation layer from ARTIFACTS and handed it over
as a patch rather than a description, so this is one implementation moving into
the SDK instead of a second one being written from a diff. Applied at the SHA
they published (verified before applying) and wired at the studioShell seam.

The shell draws marks on the overlay above the stage. That is the whole design:
a feature requiring surfaces to cooperate would work on none of the surfaces
that already exist, which is the population that matters. Verified against FLOOR,
which knows nothing about annotation.

THE SUB-CONTEXT. ARTIFACTS holds many documents behind one surface id, so a board
keyed on the id alone either hangs page one's marks over page two or collapses
every thread into one. The shell cannot work out what a surface's documents are,
so a surface MAY declare `{t:"annotation-context", id}` and the shell folds it
into the persistence key. A surface that says nothing gets one board keyed on its
id, exactly as before. The key is composed in the shell and nowhere else: two
places composing one key is two places computing one property, and they drift.

`/api/annotations` is scoped, and a read that FORGOT the scope answers empty
rather than everything — the failure to avoid is not an error, it is another
surface's marks rendered plausibly over this one. The verbs are deliberately NOT
reserved in SDK_OWNED: they answer stored marks, which is data on the box like
the files verbs, not the runtime describing itself.

Marks live in MEMORY unless `--annotations <file>` names one. Not under the
fixtures directory, because the change signal watches it recursively and a marks
file there would reload the surface being annotated on every stroke. Not inside
the package, because that makes node_modules state and a copied tree then carries
one instance's marks into another.

FOUND BY THE TESTS AND THE LOOK, not by review:
- I wrote loadAnnotations() and never called it. Writes worked, the board looked
  healthy, and the loss appeared only on the next restart. The test restarts the
  runtime rather than reading back in-process, because a write and a read in one
  process are both served by the in-memory map and agree either way.
- A missing anchor was drawn on the canvas exactly like an anchored one. The side
  panel said "anchor missing" while the arrow pointed confidently at whatever now
  occupied that space. Now dashed, desaturated and badged "?" — visible, because
  deleting it would lose what someone wrote, but never mistakable.
- The contract doc shipped as a BINARY file. A literal NUL in the scope-key
  sentence made it `data` rather than text: it rendered fine and broke every
  grep and diff silently.

The conformance table says which checks are structural. The active-surface guard
on the new message was verified by driving a live shell; the committed check
asserts the guard is present and carries a control proving it can fail.

278 tests, 278 passing (was 263).
2026-08-05 05:44:52 -07:00

98 lines
6.0 KiB
CSS

.studio-annotations {
--ann-ink: #f3f5f8;
--ann-body: #cbd1db;
--ann-faint: #929cad;
--ann-line: #323a49;
--ann-panel: rgba(18, 21, 28, .96);
--ann-human: #ff6b5a;
--ann-agent: #8b93ff;
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
color: var(--ann-ink);
font: 12px/1.35 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.studio-annotations * { box-sizing: border-box; }
.studio-annotations.is-marking { pointer-events: none; }
.studio-annotations__canvas { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; }
.studio-annotations.is-marking .studio-annotations__canvas { pointer-events: auto; cursor: crosshair; }
.studio-annotations.is-marking.is-browsing .studio-annotations__canvas { cursor: default; }
.studio-annotations__mark { pointer-events: none; }
.studio-annotations.is-marking.is-browsing .studio-annotations__mark { pointer-events: all; cursor: grab; }
.studio-annotations.is-marking.is-browsing .studio-annotations__mark:active { cursor: grabbing; }
.studio-annotations__mark .shape { fill: rgba(255,107,90,.07); stroke: var(--ann-human); stroke-width: 3; vector-effect: non-scaling-stroke; }
.studio-annotations__mark.agent .shape { fill: rgba(139,147,255,.08); stroke: var(--ann-agent); }
.studio-annotations__mark.selected .shape { stroke-width: 5; filter: drop-shadow(0 0 4px rgba(255,255,255,.55)); }
.studio-annotations__badge { fill: var(--ann-human); }
.studio-annotations__mark.agent .studio-annotations__badge { fill: var(--ann-agent); }
.studio-annotations__badge-text { fill: #101217; font-size: 10px; font-weight: 900; text-anchor: middle; dominant-baseline: central; }
.studio-annotations__label { fill: var(--ann-ink); font-size: 11px; font-weight: 800; paint-order: stroke; stroke: #101217; stroke-width: 4px; stroke-linejoin: round; }
.studio-annotations__draft .shape { stroke-dasharray: 7 5; }
/* A MARK WHOSE ELEMENT IS GONE MUST NOT LOOK LIKE ONE THAT FOUND IT.
Drawn at the last geometry it knew, which is the only position available — so
without this it points confidently at whatever now occupies that space, and a
reader believes it means that thing. Dashed, desaturated, and badged "?": still
visible, because deleting it silently would lose what someone wrote, but never
mistakable for an anchored mark. The side panel already said "anchor missing";
this is the half a reader looking at the SURFACE can see. */
.studio-annotations__mark.missing .shape { stroke: var(--ann-faint); stroke-dasharray: 5 4; fill: rgba(146,156,173,.05); }
.studio-annotations__mark.missing .studio-annotations__badge { fill: var(--ann-faint); }
.studio-annotations__mark.missing .studio-annotations__label { opacity: .75; }
.studio-annotations__tools,
.studio-annotations__thread {
position: absolute;
display: none;
pointer-events: auto;
background: var(--ann-panel);
border: 1px solid var(--ann-line);
box-shadow: 0 14px 40px rgba(0,0,0,.3);
backdrop-filter: blur(14px);
}
.studio-annotations.is-marking .studio-annotations__tools,
.studio-annotations.is-marking .studio-annotations__thread { display: flex; }
.studio-annotations__tools { top: 12px; left: 50%; transform: translateX(-50%); z-index: 3; align-items: center; gap: 5px; padding: 6px; border-radius: 4px; }
.studio-annotations__tools button,
.studio-annotations__thread button {
border: 1px solid transparent;
background: transparent;
color: var(--ann-body);
padding: 7px 9px;
border-radius: 3px;
font: inherit;
font-weight: 750;
cursor: pointer;
}
.studio-annotations__tools button:hover,
.studio-annotations__thread button:hover { color: var(--ann-ink); background: rgba(255,255,255,.07); }
.studio-annotations__tools button.on { color: #111319; background: var(--ann-ink); }
.studio-annotations__tools kbd { margin-left: 4px; opacity: .48; font: 800 9px/1 inherit; }
.studio-annotations__note { width: min(260px, 24vw); border: 1px solid var(--ann-line); background: #11151c; color: var(--ann-ink); padding: 7px 9px; border-radius: 3px; outline: none; }
.studio-annotations__note:focus { border-color: #667085; }
.studio-annotations__thread { top: 62px; right: 12px; bottom: 12px; z-index: 2; width: min(300px, 30vw); flex-direction: column; border-radius: 4px; overflow: hidden; }
.studio-annotations__thread-head { padding: 13px 14px; border-bottom: 1px solid var(--ann-line); }
.studio-annotations__thread-head strong { display: block; font-size: 13px; }
.studio-annotations__status { display: block; color: var(--ann-faint); margin-top: 3px; font-size: 10px; text-transform: uppercase; letter-spacing: .08em; }
.studio-annotations__list { overflow: auto; padding: 7px; display: grid; gap: 5px; }
.studio-annotations__empty { color: var(--ann-faint); padding: 18px 10px; }
.studio-annotations__item { border: 1px solid var(--ann-line); border-left: 3px solid var(--ann-human); padding: 9px; border-radius: 3px; background: rgba(255,255,255,.025); cursor: pointer; }
.studio-annotations__item.agent { border-left-color: var(--ann-agent); }
.studio-annotations__item.selected { border-color: var(--ann-ink); }
.studio-annotations__item-top { display: flex; align-items: center; gap: 6px; color: var(--ann-faint); font-size: 10px; text-transform: uppercase; letter-spacing: .06em; }
.studio-annotations__source { color: var(--ann-human); }
.studio-annotations__item.agent .studio-annotations__source { color: var(--ann-agent); }
.studio-annotations__item p { color: var(--ann-ink); margin: 7px 0 4px; }
.studio-annotations__item small { color: var(--ann-faint); display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.studio-annotations__delete { margin-left: auto; padding: 2px 6px !important; color: var(--ann-faint) !important; }
.studio-annotations__missing { color: #f2bd62; }
@media (max-width: 760px) {
.studio-annotations__tools { left: 8px; right: 8px; transform: none; overflow-x: auto; }
.studio-annotations__note { min-width: 150px; }
.studio-annotations__thread { width: 250px; }
}