mirror of
https://github.com/mvschwarz/openrig-studio.git
synced 2026-11-07 05:56:21 -10:00
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).
This commit is contained in:
1 parent
4ef723294c
commit
69472e4479
10 files changed
+1066
-6
No files matched your search
@@ -0,0 +1,97 @@
|
||||
.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; }
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
const NS = "http://www.w3.org/2000/svg";
|
||||
const SHAPES = new Set(["circle", "rect", "arrow", "text"]);
|
||||
const KEYS = { c: "circle", b: "rect", a: "arrow", t: "text" };
|
||||
const clone = (value) => structuredClone(value);
|
||||
const clamp = (value, min = 0, max = 1) => Math.max(min, Math.min(max, value));
|
||||
const esc = (value) => String(value ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||
const cssEscape = (value) => globalThis.CSS?.escape ? CSS.escape(String(value)) : String(value).replace(/[^a-zA-Z0-9_-]/g, (c) => `\\${c}`);
|
||||
const id = () => globalThis.crypto?.randomUUID?.() ?? `ann-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
|
||||
function isTypingTarget(target) {
|
||||
if (!(target instanceof Element)) return false;
|
||||
return Boolean(target.closest("input, textarea, select, [contenteditable='true'], [contenteditable='']"));
|
||||
}
|
||||
|
||||
function stableSelector(element) {
|
||||
if (!(element instanceof Element)) return null;
|
||||
if (element.id) return `#${cssEscape(element.id)}`;
|
||||
for (const name of ["data-annotate-id", "data-testid", "name", "aria-label"]) {
|
||||
const value = element.getAttribute(name);
|
||||
if (value) return `[${name}="${cssEscape(value)}"]`;
|
||||
}
|
||||
const parts = [];
|
||||
for (let node = element; node && node.nodeType === 1 && node !== node.ownerDocument.body; node = node.parentElement) {
|
||||
const tag = node.tagName.toLowerCase();
|
||||
const siblings = [...(node.parentElement?.children ?? [])].filter((item) => item.tagName === node.tagName);
|
||||
parts.unshift(siblings.length > 1 ? `${tag}:nth-of-type(${siblings.indexOf(node) + 1})` : tag);
|
||||
if (parts.length >= 5) break;
|
||||
}
|
||||
return parts.length ? parts.join(" > ") : null;
|
||||
}
|
||||
|
||||
function sheet() {
|
||||
const existing = document.querySelector("link[data-studio-annotations]");
|
||||
if (existing) return existing;
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.href = new URL("./annotations.css", import.meta.url).href;
|
||||
link.dataset.studioAnnotations = "";
|
||||
document.head.append(link);
|
||||
return link;
|
||||
}
|
||||
|
||||
export function attach(shell, { store } = {}) {
|
||||
if (!shell?.overlay || typeof shell.active !== "function" || typeof shell.onMarkup !== "function" || typeof shell.onSurface !== "function") {
|
||||
throw new TypeError("annotations need a studioShell with overlay, active(), onMarkup(), and onSurface()");
|
||||
}
|
||||
if (store && (typeof store?.load !== "function" || typeof store?.save !== "function")) {
|
||||
throw new TypeError("annotation store must provide both load(surfaceId) and save(surfaceId, records)");
|
||||
}
|
||||
const persistent = Boolean(store);
|
||||
sheet();
|
||||
const memory = new Map();
|
||||
const root = document.createElement("section");
|
||||
root.className = "studio-annotations";
|
||||
root.setAttribute("aria-label", "Shared annotations");
|
||||
root.innerHTML = `
|
||||
<svg class="studio-annotations__canvas" aria-label="Annotation canvas">
|
||||
<defs>
|
||||
<marker id="studio-ann-arrow-human" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse"><path d="M0 0L10 5L0 10z" fill="#ff6b5a"/></marker>
|
||||
<marker id="studio-ann-arrow-agent" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse"><path d="M0 0L10 5L0 10z" fill="#8b93ff"/></marker>
|
||||
</defs>
|
||||
<g data-marks></g><g data-draft></g>
|
||||
</svg>
|
||||
<div class="studio-annotations__tools" role="toolbar" aria-label="Annotation tools">
|
||||
<input class="studio-annotations__note" aria-label="Annotation note" placeholder="What should change?">
|
||||
<button data-tool="circle">○ Circle <kbd>C</kbd></button>
|
||||
<button data-tool="rect">□ Box <kbd>B</kbd></button>
|
||||
<button data-tool="arrow">↗ Arrow <kbd>A</kbd></button>
|
||||
<button data-tool="text">T Text <kbd>T</kbd></button>
|
||||
<button data-action="undo" title="Undo">↶</button>
|
||||
<button data-action="redo" title="Redo">↷</button>
|
||||
</div>
|
||||
<aside class="studio-annotations__thread">
|
||||
<div class="studio-annotations__thread-head"><strong>Annotations</strong><span class="studio-annotations__status"></span></div>
|
||||
<div class="studio-annotations__list"></div>
|
||||
</aside>`;
|
||||
shell.overlay.append(root);
|
||||
|
||||
const canvas = root.querySelector(".studio-annotations__canvas");
|
||||
const marks = root.querySelector("[data-marks]");
|
||||
const draftLayer = root.querySelector("[data-draft]");
|
||||
const list = root.querySelector(".studio-annotations__list");
|
||||
const status = root.querySelector(".studio-annotations__status");
|
||||
const note = root.querySelector(".studio-annotations__note");
|
||||
const state = { surfaceId: null, scope: null, records: [], tool: null, draft: null, drag: null, selected: null, undo: [], redo: [], loading: 0 };
|
||||
const cleaners = [];
|
||||
|
||||
const bounds = () => {
|
||||
const rect = root.getBoundingClientRect();
|
||||
return { rect, width: Math.max(1, rect.width), height: Math.max(1, rect.height) };
|
||||
};
|
||||
const active = () => shell.active();
|
||||
// THE PERSISTENCE KEY IS THE SHELL'S TO COMPOSE, not this layer's. A surface id
|
||||
// is the default; a surface holding several documents behind one id declares a
|
||||
// sub-context and the shell folds it in. Composing it here as well would be two
|
||||
// places computing one property, which drifts silently.
|
||||
//
|
||||
// Falling back to the surface id keeps this working against a shell that
|
||||
// predates the seam — the layer degrades to one board per surface rather than
|
||||
// throwing, which is what it did before sub-contexts existed.
|
||||
const scopeOf = () => (typeof shell.scope === "function" ? shell.scope() : active().id);
|
||||
const frameDocument = () => {
|
||||
try { return active().frame?.contentDocument ?? null; } catch { return null; }
|
||||
};
|
||||
const point = (event) => {
|
||||
const { rect, width, height } = bounds();
|
||||
return { x: clamp((event.clientX - rect.left) / width), y: clamp((event.clientY - rect.top) / height) };
|
||||
};
|
||||
const elementAt = (event) => {
|
||||
const frame = active().frame;
|
||||
const doc = frameDocument();
|
||||
if (!frame || !doc) return null;
|
||||
const rect = frame.getBoundingClientRect();
|
||||
try { return doc.elementFromPoint(event.clientX - rect.left, event.clientY - rect.top); } catch { return null; }
|
||||
};
|
||||
const elementAnchor = (selector) => {
|
||||
const frame = active().frame;
|
||||
const doc = frameDocument();
|
||||
if (!frame || !doc || !selector) return null;
|
||||
let element;
|
||||
try { element = doc.querySelector(selector); } catch { return null; }
|
||||
if (!element) return null;
|
||||
const outer = bounds();
|
||||
const frameRect = frame.getBoundingClientRect();
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
x: (frameRect.left - outer.rect.left + rect.left) / outer.width,
|
||||
y: (frameRect.top - outer.rect.top + rect.top) / outer.height,
|
||||
width: rect.width / outer.width,
|
||||
height: rect.height / outer.height,
|
||||
text: element.innerText?.trim().replace(/\s+/g, " ").slice(0, 180) || element.getAttribute("aria-label") || "",
|
||||
};
|
||||
};
|
||||
const displayRecord = (record) => {
|
||||
const found = elementAnchor(record.selector);
|
||||
if (!record.selector || !found) return { ...record, status: record.selector ? "missing" : "spatial" };
|
||||
const offset = record.offset ?? { x: 0, y: 0, width: 0, height: 0 };
|
||||
return { ...record, status: "anchored", text: found.text || record.text, anchor: {
|
||||
x: found.x + (offset.x ?? 0), y: found.y + (offset.y ?? 0),
|
||||
width: Math.max(.01, found.width + (offset.width ?? 0)), height: Math.max(.01, found.height + (offset.height ?? 0)),
|
||||
} };
|
||||
};
|
||||
|
||||
const shapeMarkup = (record, index, classes = "") => {
|
||||
const shown = displayRecord(record);
|
||||
const { width, height } = bounds();
|
||||
const a = shown.anchor ?? { x: .4, y: .4, width: .2, height: .1 };
|
||||
const x = a.x * width, y = a.y * height, w = a.width * width, h = a.height * height;
|
||||
const source = shown.source === "agent" ? "agent" : "human";
|
||||
let shape;
|
||||
if (shown.shape === "circle") shape = `<ellipse class="shape" cx="${x + w / 2}" cy="${y + h / 2}" rx="${Math.abs(w / 2)}" ry="${Math.abs(h / 2)}"/>`;
|
||||
else if (shown.shape === "arrow") shape = `<line class="shape" x1="${x}" y1="${y}" x2="${x + w}" y2="${y + h}" marker-end="url(#studio-ann-arrow-${source})"/>`;
|
||||
else if (shown.shape === "text") shape = `<rect class="shape" x="${x}" y="${y}" width="${Math.abs(w)}" height="${Math.abs(h)}" rx="4"/><text class="studio-annotations__label" x="${x + 9}" y="${y + Math.min(20, Math.abs(h) - 7)}">${esc(shown.note || "Text")}</text>`;
|
||||
else shape = `<rect class="shape" x="${Math.min(x, x + w)}" y="${Math.min(y, y + h)}" width="${Math.abs(w)}" height="${Math.abs(h)}" rx="3"/>`;
|
||||
const badgeX = Math.min(x, x + w), badgeY = Math.min(y, y + h);
|
||||
return `<g class="studio-annotations__mark ${source === "agent" ? "agent" : ""} ${state.selected === shown.id ? "selected" : ""} ${shown.status === "missing" ? "missing" : ""} ${classes}" data-id="${esc(shown.id)}">${shape}<circle class="studio-annotations__badge" cx="${badgeX}" cy="${badgeY}" r="9"/><text class="studio-annotations__badge-text" x="${badgeX}" y="${badgeY}">${shown.status === "missing" ? "?" : (source === "agent" ? "A" : "H")}</text>${index >= 0 ? `<text class="studio-annotations__label" x="${badgeX + 13}" y="${badgeY + 4}">${index + 1}</text>` : ""}</g>`;
|
||||
};
|
||||
const describe = (record) => {
|
||||
const shown = displayRecord(record);
|
||||
const target = shown.selector ? `${shown.shape} on ${shown.selector}${shown.text ? ` (\"${shown.text}\")` : ""}` : `${shown.shape} on the surface`;
|
||||
return { shown, target };
|
||||
};
|
||||
function render() {
|
||||
const { width, height } = bounds();
|
||||
canvas.setAttribute("viewBox", `0 0 ${width} ${height}`);
|
||||
marks.innerHTML = state.records.map((record, index) => shapeMarkup(record, index)).join("");
|
||||
draftLayer.innerHTML = state.draft ? shapeMarkup({ ...state.draft, id: "draft", source: "human" }, -1, "studio-annotations__draft") : "";
|
||||
root.classList.toggle("is-browsing", !state.tool);
|
||||
root.querySelectorAll("[data-tool]").forEach((button) => button.classList.toggle("on", button.dataset.tool === state.tool));
|
||||
status.textContent = persistent ? `${state.records.length} · persisted` : `${state.records.length} · session only`;
|
||||
list.innerHTML = state.records.length ? state.records.map((record, index) => {
|
||||
const { shown, target } = describe(record);
|
||||
return `<article class="studio-annotations__item ${shown.source === "agent" ? "agent" : ""} ${state.selected === shown.id ? "selected" : ""}" data-item="${esc(shown.id)}"><div class="studio-annotations__item-top"><span>${index + 1}</span><span class="studio-annotations__source">${shown.source === "agent" ? "Agent" : "You"}</span><span>${esc(shown.shape)}</span>${shown.status === "missing" ? '<span class="studio-annotations__missing">anchor missing</span>' : ""}<button class="studio-annotations__delete" data-delete="${esc(shown.id)}" title="Delete annotation">×</button></div><p>${esc(shown.note || "Review this")}</p><small>${esc(target)}</small></article>`;
|
||||
}).join("") : '<div class="studio-annotations__empty">No marks yet. Choose a tool, then draw on the surface.</div>';
|
||||
}
|
||||
|
||||
const read = async (surfaceId) => {
|
||||
const value = store?.load ? await store.load(surfaceId) : memory.get(surfaceId);
|
||||
return clone(Array.isArray(value) ? value : value?.records ?? []);
|
||||
};
|
||||
const write = async () => {
|
||||
const records = clone(state.records);
|
||||
if (store?.save) await store.save(state.scope, records);
|
||||
else memory.set(state.scope, records);
|
||||
};
|
||||
const checkpoint = () => {
|
||||
state.undo.push(clone(state.records));
|
||||
if (state.undo.length > 50) state.undo.shift();
|
||||
state.redo.length = 0;
|
||||
};
|
||||
const travel = async (direction) => {
|
||||
const source = direction === "undo" ? state.undo : state.redo;
|
||||
const target = direction === "undo" ? state.redo : state.undo;
|
||||
const snapshot = source.pop();
|
||||
if (!snapshot) return false;
|
||||
target.push(clone(state.records));
|
||||
state.records = snapshot;
|
||||
state.selected = null;
|
||||
await write(); render(); return true;
|
||||
};
|
||||
const loadSurface = async () => {
|
||||
const token = ++state.loading;
|
||||
const surfaceId = active().id;
|
||||
const scope = scopeOf();
|
||||
state.surfaceId = surfaceId;
|
||||
state.scope = scope;
|
||||
state.records = scope ? await read(scope) : [];
|
||||
if (token !== state.loading) return;
|
||||
state.selected = null; state.draft = null; state.drag = null; state.undo = []; state.redo = [];
|
||||
render();
|
||||
};
|
||||
const choose = (shape) => {
|
||||
state.tool = SHAPES.has(shape) && state.tool !== shape ? shape : null;
|
||||
state.selected = null; state.draft = null; render();
|
||||
return state.tool;
|
||||
};
|
||||
const add = async ({ selector = null, shape = "circle", note: message = "Review this", source = "agent", anchor = null } = {}) => {
|
||||
if (!SHAPES.has(shape)) throw new TypeError(`unknown annotation shape: ${shape}`);
|
||||
const found = selector ? elementAnchor(selector) : null;
|
||||
const placed = anchor ?? (found ? { x: found.x, y: found.y, width: found.width, height: found.height } : { x: .38, y: .38, width: .24, height: .14 });
|
||||
checkpoint();
|
||||
const record = { id: id(), surfaceId: state.surfaceId, selector, shape, note: message, source: source === "human" ? "human" : "agent", anchor: placed, offset: found ? { x: placed.x - found.x, y: placed.y - found.y, width: placed.width - found.width, height: placed.height - found.height } : null, text: found?.text ?? "", status: selector ? (found ? "anchored" : "missing") : "spatial", createdAt: new Date().toISOString() };
|
||||
state.records.push(record); await write(); render(); return clone(record);
|
||||
};
|
||||
const remove = async (recordId) => {
|
||||
const at = state.records.findIndex((record) => record.id === recordId);
|
||||
if (at < 0) return false;
|
||||
checkpoint(); state.records.splice(at, 1); state.selected = null; await write(); render(); return true;
|
||||
};
|
||||
|
||||
const onPointerDown = (event) => {
|
||||
if (!shell.markup()) return;
|
||||
const mark = event.target.closest?.("[data-id]");
|
||||
if (!state.tool && mark) {
|
||||
const record = state.records.find((item) => item.id === mark.dataset.id);
|
||||
if (!record?.anchor) return;
|
||||
state.selected = record.id;
|
||||
state.drag = { id: record.id, pointerId: event.pointerId, start: point(event), before: clone(record), moved: false };
|
||||
canvas.setPointerCapture?.(event.pointerId); render(); event.preventDefault(); return;
|
||||
}
|
||||
if (!state.tool) return;
|
||||
const start = point(event);
|
||||
state.draft = { shape: state.tool, anchor: { x: start.x, y: start.y, width: state.tool === "text" ? .2 : 0, height: state.tool === "text" ? .05 : 0 }, start, selector: stableSelector(elementAt(event)) };
|
||||
canvas.setPointerCapture?.(event.pointerId); render(); event.preventDefault();
|
||||
};
|
||||
const onPointerMove = (event) => {
|
||||
if (state.drag?.pointerId === event.pointerId) {
|
||||
const record = state.records.find((item) => item.id === state.drag.id);
|
||||
if (!record) return;
|
||||
const now = point(event), dx = now.x - state.drag.start.x, dy = now.y - state.drag.start.y;
|
||||
record.anchor = { ...state.drag.before.anchor, x: state.drag.before.anchor.x + dx, y: state.drag.before.anchor.y + dy };
|
||||
if (record.selector) record.offset = { ...(state.drag.before.offset ?? {}), x: (state.drag.before.offset?.x ?? 0) + dx, y: (state.drag.before.offset?.y ?? 0) + dy };
|
||||
state.drag.moved ||= Math.abs(dx) + Math.abs(dy) > .002; render(); return;
|
||||
}
|
||||
if (!state.draft) return;
|
||||
const now = point(event), start = state.draft.start;
|
||||
if (state.draft.shape !== "text") state.draft.anchor = { x: start.x, y: start.y, width: now.x - start.x, height: now.y - start.y };
|
||||
render();
|
||||
};
|
||||
const onPointerUp = async (event) => {
|
||||
if (state.drag?.pointerId === event.pointerId) {
|
||||
const drag = state.drag; state.drag = null;
|
||||
if (drag.moved) { state.undo.push(state.records.map((item) => item.id === drag.id ? drag.before : clone(item))); state.redo.length = 0; await write(); }
|
||||
render(); return;
|
||||
}
|
||||
if (!state.draft) return;
|
||||
const draft = state.draft; state.draft = null;
|
||||
const raw = draft.anchor;
|
||||
let anchor = draft.shape === "rect" || draft.shape === "circle" ? { x: Math.min(raw.x, raw.x + raw.width), y: Math.min(raw.y, raw.y + raw.height), width: Math.max(.012, Math.abs(raw.width)), height: Math.max(.012, Math.abs(raw.height)) } : raw;
|
||||
if (draft.shape === "text") anchor = { ...anchor, width: .22, height: .06 };
|
||||
let message = note.value.trim();
|
||||
if (draft.shape === "text" && !message) message = window.prompt("Text to place on the surface")?.trim() ?? "";
|
||||
if (!message) message = draft.shape === "text" ? "Text" : "Review this";
|
||||
const found = draft.selector ? elementAnchor(draft.selector) : null;
|
||||
checkpoint();
|
||||
state.records.push({ id: id(), surfaceId: state.surfaceId, selector: draft.selector, shape: draft.shape, note: message, source: "human", anchor, offset: found ? { x: anchor.x - found.x, y: anchor.y - found.y, width: anchor.width - found.width, height: anchor.height - found.height } : null, text: found?.text ?? "", status: draft.selector ? (found ? "anchored" : "missing") : "spatial", createdAt: new Date().toISOString() });
|
||||
await write(); render();
|
||||
};
|
||||
|
||||
canvas.addEventListener("pointerdown", onPointerDown);
|
||||
canvas.addEventListener("pointermove", onPointerMove);
|
||||
canvas.addEventListener("pointerup", (event) => onPointerUp(event).catch(console.error));
|
||||
canvas.addEventListener("pointercancel", () => { if (state.drag) { const at = state.records.findIndex((item) => item.id === state.drag.id); if (at >= 0) state.records[at] = state.drag.before; } state.drag = null; state.draft = null; render(); });
|
||||
root.querySelectorAll("[data-tool]").forEach((button) => button.addEventListener("click", () => choose(button.dataset.tool)));
|
||||
root.querySelector("[data-action='undo']").addEventListener("click", () => travel("undo"));
|
||||
root.querySelector("[data-action='redo']").addEventListener("click", () => travel("redo"));
|
||||
list.addEventListener("click", (event) => {
|
||||
const deleting = event.target.closest("[data-delete]");
|
||||
if (deleting) { remove(deleting.dataset.delete); return; }
|
||||
const item = event.target.closest("[data-item]");
|
||||
if (item) { state.selected = item.dataset.item; render(); }
|
||||
});
|
||||
const onKey = (event) => {
|
||||
if (!shell.markup() || isTypingTarget(event.target)) return;
|
||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "z") { event.preventDefault(); travel(event.shiftKey ? "redo" : "undo"); return; }
|
||||
if (event.key === "Escape") { choose(null); return; }
|
||||
if ((event.key === "Delete" || event.key === "Backspace") && state.selected) { event.preventDefault(); remove(state.selected); return; }
|
||||
const shape = KEYS[event.key.toLowerCase()];
|
||||
if (shape && !event.metaKey && !event.ctrlKey && !event.altKey) { event.preventDefault(); choose(shape); }
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
window.addEventListener("resize", render);
|
||||
cleaners.push(shell.onMarkup((on) => { root.classList.toggle("is-marking", Boolean(on)); if (!on) { state.tool = null; state.draft = null; state.drag = null; } render(); }));
|
||||
const frameLoad = () => render();
|
||||
let observedFrame = null;
|
||||
const observeFrame = () => {
|
||||
observedFrame?.removeEventListener("load", frameLoad);
|
||||
observedFrame = active().frame;
|
||||
observedFrame?.addEventListener("load", frameLoad);
|
||||
};
|
||||
cleaners.push(shell.onSurface(() => { observeFrame(); loadSurface().catch(console.error); }));
|
||||
observeFrame();
|
||||
root.classList.toggle("is-marking", Boolean(shell.markup()));
|
||||
const ready = loadSurface();
|
||||
ready.catch(console.error);
|
||||
|
||||
return Object.freeze({
|
||||
detach() {
|
||||
cleaners.forEach((clean) => clean?.());
|
||||
observedFrame?.removeEventListener("load", frameLoad);
|
||||
window.removeEventListener("keydown", onKey);
|
||||
window.removeEventListener("resize", render);
|
||||
root.remove();
|
||||
},
|
||||
ready,
|
||||
annotate: add,
|
||||
list: () => clone(state.records),
|
||||
remove,
|
||||
undo: () => travel("undo"),
|
||||
redo: () => travel("redo"),
|
||||
tool: choose,
|
||||
state: () => ({ surfaceId: state.surfaceId, scope: state.scope, markup: shell.markup(), tool: state.tool, selected: state.selected, records: clone(state.records), persistence: persistent ? "store" : "session only" }),
|
||||
});
|
||||
}
|
||||
+80
-1
@@ -69,6 +69,23 @@ const OVERLAY_MANIFEST = OVERLAY_DIR ? path.join(OVERLAY_DIR, "surfaces.json") :
|
||||
const IDENTITY_HEADER = (arg("--identity-header", process.env.OPENRIG_STUDIO_IDENTITY_HEADER) || "")
|
||||
.toLowerCase().trim() || null;
|
||||
|
||||
// WHERE ANNOTATIONS LIVE — in MEMORY, unless an operator names a file.
|
||||
//
|
||||
// NOT under FIXTURES, and that is the whole reason this is a separate flag. The
|
||||
// change-signal watch is recursive over FIXTURES, so a marks file written there
|
||||
// would fire the signal on EVERY annotation write — annotating a surface would
|
||||
// reload the surface being annotated, which is a defect that looks like a bug in
|
||||
// the surface rather than in this choice.
|
||||
//
|
||||
// NOT inside the package either. A boot step that writes into the installed
|
||||
// package makes node_modules state rather than dependencies, and a copied tree
|
||||
// then carries one instance's marks into another (failure-modes.md #8).
|
||||
//
|
||||
// So: memory by default, exactly like focus and drive, and the layer SAYS
|
||||
// "session only" when nothing is persisting it. A file is opt-in and lives
|
||||
// wherever the operator puts it.
|
||||
const ANNOTATIONS_FILE = arg("--annotations", process.env.OPENRIG_STUDIO_ANNOTATIONS) || null;
|
||||
|
||||
const CONTRACT_VERSION = "0.1";
|
||||
// PROCESS IDENTITY — the answer to "the agent edited the page, when may I reload?"
|
||||
//
|
||||
@@ -93,7 +110,8 @@ const BOOT_ID = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2
|
||||
// because there are no write verbs — and it lets a runtime offer read-only focus
|
||||
// honestly rather than claiming a namespace it half-implements.
|
||||
const CAPABILITIES = ["contract.meta", "observe.factory-state", "stream.events", "files.read",
|
||||
"shell.protocol", "focus.read", "focus.write", "drive.read", "drive.write"];
|
||||
"shell.protocol", "focus.read", "focus.write", "drive.read", "drive.write",
|
||||
"annotations.read", "annotations.write"];
|
||||
|
||||
const TYPES = { ".html": "text/html; charset=utf-8", ".json": "application/json", ".css": "text/css",
|
||||
".js": "text/javascript", ".mjs": "text/javascript", ".svg": "image/svg+xml", ".png": "image/png",
|
||||
@@ -724,6 +742,34 @@ function writeFocus(patch, verifiedBy = null) {
|
||||
// break on any re-layout, and every surface would have to freeze its markup to
|
||||
// stay drivable. Same decision, for the same reason, as `selection` being typed by
|
||||
// its surface rather than universally.
|
||||
// scope key -> records[]. The key is opaque to the runtime: the shell composes it
|
||||
// from the surface and, when a surface declares one, its sub-context. A runtime
|
||||
// that parsed it would have to know what a surface's documents are.
|
||||
const annotations = new Map();
|
||||
let annotationWrites = 0;
|
||||
|
||||
function loadAnnotations() {
|
||||
if (!ANNOTATIONS_FILE) return;
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(ANNOTATIONS_FILE, "utf8"));
|
||||
for (const [scope, records] of Object.entries(raw?.scopes || {})) {
|
||||
if (Array.isArray(records)) annotations.set(scope, records);
|
||||
}
|
||||
} catch (e) {
|
||||
// A missing file is the ordinary first-run case, not a fault. A CORRUPT one
|
||||
// is reported rather than silently starting empty, because an empty board and
|
||||
// a failed load must not share an appearance.
|
||||
if (e.code !== "ENOENT") console.error("annotations: could not read " + ANNOTATIONS_FILE + " — " + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function persistAnnotations() {
|
||||
if (!ANNOTATIONS_FILE) return;
|
||||
const scopes = Object.fromEntries(annotations);
|
||||
fs.mkdirSync(path.dirname(ANNOTATIONS_FILE), { recursive: true });
|
||||
fs.writeFileSync(ANNOTATIONS_FILE, JSON.stringify({ scopes }, null, 2));
|
||||
}
|
||||
|
||||
let driveOp = null;
|
||||
let driveSeq = 0;
|
||||
// HOW MANY TIMES A SURFACE HAS ACTUALLY POLLED. Not a declaration — an
|
||||
@@ -828,6 +874,13 @@ function filesSearch(q) {
|
||||
// next to the function it calls.
|
||||
watchForChanges();
|
||||
loadManifest();
|
||||
// Marks written by a previous run of this file-backed runtime. Omitting this call
|
||||
// is not a visible failure: writing works, the board looks healthy, and the loss
|
||||
// only appears on the next restart. A test restarts the runtime rather than
|
||||
// reading back within one process, because a write and a read in the same process
|
||||
// are both served by the in-memory map and agree with each other whether or not
|
||||
// this line exists.
|
||||
loadAnnotations();
|
||||
|
||||
http.createServer(async (req, res) => {
|
||||
const u = new URL(req.url, `http://127.0.0.1:${PORT}`);
|
||||
@@ -867,6 +920,27 @@ http.createServer(async (req, res) => {
|
||||
const marker = driveMarker();
|
||||
return sendJson(res, 200, { ok: true, changed: since === null || since !== marker, marker, op: driveOp });
|
||||
}
|
||||
if (u.pathname === "/api/annotations") {
|
||||
if (req.method === "POST") {
|
||||
const raw = await readBody(req);
|
||||
let body;
|
||||
try { body = JSON.parse(raw || "null"); }
|
||||
catch (e) { return sendJson(res, 400, { ok: false, error: `annotations write is not valid JSON: ${e.message}` }); }
|
||||
const scope = typeof body?.scope === "string" && body.scope ? body.scope : null;
|
||||
if (!scope) return sendJson(res, 400, { ok: false, error: "annotations write names no scope — send { scope, records }" });
|
||||
if (!Array.isArray(body?.records)) return sendJson(res, 400, { ok: false, error: "annotations write has no records array — send { scope, records }" });
|
||||
annotations.set(scope, body.records);
|
||||
annotationWrites += 1;
|
||||
try { persistAnnotations(); }
|
||||
catch (e) { return sendJson(res, 500, { ok: false, error: `annotations could not be persisted: ${e.message}` }); }
|
||||
return sendJson(res, 200, { ok: true, scope, records: body.records });
|
||||
}
|
||||
// Read is scoped. Without a scope this answers an EMPTY set rather than
|
||||
// every scope on the box: a caller that forgot the parameter would otherwise
|
||||
// receive another surface's marks and render them over this one.
|
||||
const scope = u.searchParams.get("scope");
|
||||
return sendJson(res, 200, { ok: true, scope: scope || null, records: scope ? (annotations.get(scope) || []) : [] });
|
||||
}
|
||||
if (u.pathname === "/api/contract") {
|
||||
return sendJson(res, 200, {
|
||||
contractVersion: CONTRACT_VERSION,
|
||||
@@ -882,6 +956,11 @@ http.createServer(async (req, res) => {
|
||||
// verified, not who by.
|
||||
focus: { attribution: IDENTITY_HEADER ? "verified" : "caller-declared",
|
||||
identityHeader: IDENTITY_HEADER },
|
||||
// MEASURED, not declared. "session only" and "persisted" are two different
|
||||
// promises to whoever just drew a mark, and a consumer should be able to
|
||||
// tell which one it got without drawing one and restarting to find out.
|
||||
annotations: { persistence: ANNOTATIONS_FILE ? "file" : "memory",
|
||||
scopes: annotations.size, writes: annotationWrites },
|
||||
manifest: manifestReport(),
|
||||
});
|
||||
}
|
||||
|
||||
+74
-1
@@ -212,6 +212,15 @@
|
||||
// id -> the iframe showing that surface. Pre-existing shell state that lived
|
||||
// beside its first use; it belongs here with the rest for the same reason.
|
||||
const frames = new Map();
|
||||
// surface id -> the sub-context that surface last declared, or absent.
|
||||
//
|
||||
// WHY THIS EXISTS: a surface id is not always fine enough. ARTIFACTS holds many
|
||||
// independent documents behind ONE id, so marks keyed on the id alone either
|
||||
// sit over the wrong document after a page change or collapse every document's
|
||||
// thread into one. The shell cannot work out what a surface's documents are —
|
||||
// only the surface knows — so the surface may refine the default, and the
|
||||
// default is the surface id for everything that says nothing.
|
||||
const declaredContexts = new Map();
|
||||
|
||||
// --- contract meta first: the shell itself is a contract consumer -------
|
||||
let meta = null;
|
||||
@@ -315,6 +324,25 @@
|
||||
if (!e.data || typeof e.data !== "object") return;
|
||||
if (e.data.t === "open-surface" && byId(e.data.id)) activate(e.data.id);
|
||||
if (e.data.t === "open-agent") openAgents(e.data.seat);
|
||||
if (e.data.t === "annotation-context") {
|
||||
// Only the ACTIVE surface may speak, for the same reason as the header: a
|
||||
// backgrounded surface still holds a live channel, and without this the
|
||||
// scope would follow whichever surface spoke last rather than the one you
|
||||
// are looking at.
|
||||
const current = frames.get(activeId);
|
||||
if (current && e.source === current.contentWindow) {
|
||||
const id = typeof e.data.id === "string" && e.data.id ? e.data.id : null;
|
||||
const had = declaredContexts.get(activeId) ?? null;
|
||||
if (had !== id) {
|
||||
if (id) declaredContexts.set(activeId, id); else declaredContexts.delete(activeId);
|
||||
// A context change is the same event as a surface change to anything
|
||||
// drawing over the stage: save what you had, load what belongs here,
|
||||
// clear and re-anchor. Reusing the one channel is what keeps a feature
|
||||
// from needing to know that sub-contexts exist at all.
|
||||
for (const fn of surfaceListeners) { try { fn(activeId); } catch {} }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (e.data.t === "header") {
|
||||
const active = frames.get(activeId);
|
||||
if (active && e.source === active.contentWindow) {
|
||||
@@ -542,13 +570,58 @@
|
||||
overlay: overlayHost,
|
||||
// Which surface is showing, and its frame. Read at CALL time — the active
|
||||
// surface changes under a long-lived feature.
|
||||
active: () => ({ id: activeId, frame: frames.get(activeId) || null }),
|
||||
active: () => ({ id: activeId, frame: frames.get(activeId) || null,
|
||||
context: declaredContexts.get(activeId) ?? null }),
|
||||
// The persistence key, composed HERE rather than by each feature. Two features
|
||||
// composing it independently is two places computing one property, and they
|
||||
// drift silently. NUL cannot occur in a surface id or a declared context, so
|
||||
// "a" + "b\0c" and "a\0b" + "c" cannot collide.
|
||||
scope: () => {
|
||||
const context = declaredContexts.get(activeId) ?? null;
|
||||
return activeId ? (context ? activeId + "\u0000" + context : activeId) : null;
|
||||
},
|
||||
// Is markup mode on, and tell me when it changes.
|
||||
markup: () => markupOn,
|
||||
onMarkup: (fn) => { markupListeners.add(fn); return () => markupListeners.delete(fn); },
|
||||
// Tell me when the surface changes, so an overlay can clear or re-anchor.
|
||||
onSurface: (fn) => { surfaceListeners.add(fn); return () => surfaceListeners.delete(fn); },
|
||||
};
|
||||
// --- annotations: a SHELL feature, drawn over the stage --------------------
|
||||
// Annotation belongs to the shell rather than to each surface because it must
|
||||
// work on surfaces that know nothing about it — the ones that already exist.
|
||||
// It attaches at the seam like any other feature and gets no privileges.
|
||||
//
|
||||
// The store is injected from HERE, not built into the layer: the layer must
|
||||
// stay free of any transport so it runs against a runtime that has no
|
||||
// annotations verb at all. That runtime gets an in-memory board that SAYS
|
||||
// "session only" rather than a broken one.
|
||||
try {
|
||||
const capable = Array.isArray(meta?.capabilities) && meta.capabilities.includes("annotations.write");
|
||||
const store = capable ? {
|
||||
load: async (scope) => {
|
||||
const r = await fetch("/api/annotations?scope=" + encodeURIComponent(scope), { cache: "no-store" });
|
||||
const j = await r.json();
|
||||
return Array.isArray(j?.records) ? j.records : [];
|
||||
},
|
||||
save: async (scope, records) => {
|
||||
const r = await fetch("/api/annotations", { method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ scope, records }) });
|
||||
const j = await r.json().catch(() => null);
|
||||
// A save that failed must not look like one that worked. The layer shows
|
||||
// "persisted" while a store is present, so a silent failure here would
|
||||
// make that label a lie.
|
||||
if (!j?.ok) throw new Error(j?.error || "annotations save failed");
|
||||
},
|
||||
} : null;
|
||||
const { attach } = await import("./annotations.js");
|
||||
window.studioAnnotations = attach(window.studioShell, { store });
|
||||
} catch (err) {
|
||||
// The shell keeps working without annotations. It must not take the studio
|
||||
// down with it, and it must not pretend it loaded.
|
||||
console.error("annotations did not attach:", err);
|
||||
}
|
||||
|
||||
document.getElementById("agentsWord").onclick = () => {
|
||||
agents.classList.remove("open");
|
||||
document.getElementById("agentToggle").style.display = "";
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
# Annotations — marking up a surface that knows nothing about it
|
||||
|
||||
contractVersion: 0.1
|
||||
|
||||
Everything here is **opt-in and additive**. A surface that does nothing behaves
|
||||
exactly as it did before this document existed — including being fully
|
||||
annotatable — and `contractVersion` does not move.
|
||||
|
||||
## Why this is a contract
|
||||
|
||||
A human and an agent looking at the same screen need a way to point at the same
|
||||
thing. Focus tells an agent *what the user is looking at*; drive lets it *operate
|
||||
the surface*. Neither lets either party say **"this part, here, is wrong."**
|
||||
|
||||
The mechanism is not speculative: a working annotation layer already existed
|
||||
inside one application. What was missing is the SDK offering it, so that the next
|
||||
application does not build a second, incompatible one — the same reason all three
|
||||
existing primitives are here.
|
||||
|
||||
## Annotation is drawn by the SHELL, over the stage (stable)
|
||||
|
||||
**Not by the surface.** This is the load-bearing decision and everything else
|
||||
follows from it.
|
||||
|
||||
A feature that required surfaces to cooperate would work on none of the surfaces
|
||||
that already exist, which is the population that matters. So the shell draws
|
||||
marks on an overlay above the stage, and a surface that has never heard of
|
||||
annotation is annotatable anyway.
|
||||
|
||||
The consequence to internalise: **`{ "t": "markup", "on": <boolean> }` is
|
||||
advisory.** `shell-protocol.md` already says so. A surface handles it only when it
|
||||
wants to render its own richer marks or move its controls out of the way. Ignoring
|
||||
it costs the surface nothing.
|
||||
|
||||
## What a mark is (stable)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "<opaque>",
|
||||
"surfaceId": "canvas",
|
||||
"selector": "#publish",
|
||||
"shape": "circle | rect | arrow | text",
|
||||
"note": "one line: what should change",
|
||||
"source": "human | agent",
|
||||
"anchor": { "x": 0.4, "y": 0.3, "width": 0.2, "height": 0.1 },
|
||||
"text": "<the anchored element's own text, at the time of writing>",
|
||||
"status": "anchored | spatial | missing",
|
||||
"createdAt": "<ISO timestamp>"
|
||||
}
|
||||
```
|
||||
|
||||
| field | stability | meaning |
|
||||
|---|---|---|
|
||||
| `id` | stable | opaque, assigned by the layer |
|
||||
| `surfaceId` | stable | which surface this mark was drawn on |
|
||||
| `selector` | stable | a stable selector for the anchored element, or `null` for a purely spatial mark |
|
||||
| `shape` | stable | closed set: `circle`, `rect`, `arrow`, `text` |
|
||||
| `note` | stable | one line of free text — the point of the mark |
|
||||
| `source` | stable | `human` or `agent`, and they are rendered differently on purpose |
|
||||
| `anchor` | stable | normalized 0–1 rectangle, so a mark survives a resize |
|
||||
| `status` | stable | see below |
|
||||
|
||||
### `status` is the honest field (stable)
|
||||
|
||||
- **`anchored`** — the selector still resolves; the mark is drawn on the element.
|
||||
- **`spatial`** — the mark was never tied to an element. Correct, not degraded.
|
||||
- **`missing`** — the mark HAD an element and that element is gone.
|
||||
|
||||
**A `missing` anchor is shown as missing rather than quietly relocated.** A mark
|
||||
that silently slides to wherever the geometry now points is worse than one that
|
||||
admits it lost its target: the reader believes it is still pointing at the thing
|
||||
it was drawn on. This is the same rule as honest degradation in
|
||||
`change-signal.md`, applied to a position instead of to a data feed.
|
||||
|
||||
## The sub-context — for a surface holding many documents (stable)
|
||||
|
||||
**The persistence scope defaults to the surface id, and that is right for almost
|
||||
every surface.** One surface, one board of marks.
|
||||
|
||||
It is wrong for a surface that holds several independent documents behind a
|
||||
single id — a canvas with pages, a viewer with tabs. Keyed on the id alone, marks
|
||||
drawn on page 1 either hang over page 2 or collapse into one thread.
|
||||
|
||||
**The shell cannot work this out.** Only the surface knows what its documents are.
|
||||
So a surface that has several MAY refine the default:
|
||||
|
||||
```json
|
||||
{ "t": "annotation-context", "id": "<opaque, this surface's own>" }
|
||||
```
|
||||
|
||||
Sent over the existing surface→shell channel. The id is **opaque to the shell** —
|
||||
it is not parsed, matched, or interpreted, exactly as a drive op is opaque to the
|
||||
runtime. Sending `null` (or omitting `id`) returns the surface to its default
|
||||
single board.
|
||||
|
||||
**This does not make annotation depend on surface cooperation.** A surface that
|
||||
never sends it gets one board keyed on its id, which is the behaviour it would
|
||||
have had if this message did not exist. The message only lets a multi-document
|
||||
surface be *more* correct, never less.
|
||||
|
||||
Only the **active** surface may declare a context, for the same reason only the
|
||||
active surface may write the header: a backgrounded surface still holds a live
|
||||
channel, and without the rule the scope would follow whichever surface spoke last
|
||||
rather than the one on screen.
|
||||
|
||||
**A context change is the same event as a surface change** to anything drawing
|
||||
over the stage — save what you had, load what belongs here, clear and re-anchor.
|
||||
It is delivered on the existing surface-change channel rather than a new one, so a
|
||||
feature never has to know sub-contexts exist.
|
||||
|
||||
### The scope key is composed by the SHELL (stable)
|
||||
|
||||
`surfaceId` when no context is declared; `surfaceId`, a **NUL** (`U+0000`), then
|
||||
`contextId` when one is. **A consumer must treat the whole key as opaque** and must not build it
|
||||
itself — two places composing one key is two places computing one property, and
|
||||
they drift silently. `NUL` cannot appear in either part, so the two halves cannot
|
||||
be confused for one another.
|
||||
|
||||
## The verbs (stable)
|
||||
|
||||
**`GET /api/annotations?scope=<key>`**
|
||||
|
||||
```json
|
||||
{ "ok": true, "scope": "<key>", "records": [ { "...": "a mark" } ] }
|
||||
```
|
||||
|
||||
**A read with no `scope` answers an EMPTY set, never every scope on the box.** A
|
||||
caller that forgot the parameter would otherwise receive another surface's marks
|
||||
and render them over this one — a plausible answer to a question nobody asked.
|
||||
|
||||
**`POST /api/annotations`** — body `{ scope, records }`, answering
|
||||
`{ ok, scope, records }`. The whole set for that scope is replaced, because the
|
||||
layer owns the board and sends what it now holds. A write naming no scope, or
|
||||
carrying no `records` array, is refused by name with HTTP 400 rather than
|
||||
silently doing nothing.
|
||||
|
||||
**These verbs are substitutable.** They answer *stored marks*, which is data on
|
||||
the box — like the files verbs and unlike `/api/contract`. A provider that knows
|
||||
where a real studio keeps its annotations should serve them, and the rule in
|
||||
`runtime-api.md` applies unchanged: the documented shape binds whoever serves it.
|
||||
|
||||
## Persistence is disclosed, never assumed (stable)
|
||||
|
||||
`GET /api/contract` reports:
|
||||
|
||||
```json
|
||||
"annotations": { "persistence": "memory" | "file", "scopes": 0, "writes": 0 }
|
||||
```
|
||||
|
||||
**Measured, not declared** — the same rule as `drive.listening`. And the layer
|
||||
says the same thing where the user can see it: a board with no store behind it
|
||||
reads **"session only"** rather than "persisted".
|
||||
|
||||
That distinction is the whole point of reporting it. "Your marks are saved" and
|
||||
"your marks vanish when you close this tab" are two different promises to whoever
|
||||
just drew one, and they must not look alike.
|
||||
|
||||
The reference runtime keeps marks **in memory** unless started with
|
||||
`--annotations <file>` (or `OPENRIG_STUDIO_ANNOTATIONS`). Two reasons it is not a
|
||||
default path:
|
||||
|
||||
- **Not under the fixtures directory.** The change signal watches it recursively,
|
||||
so a marks file there would fire the signal on every write — annotating a
|
||||
surface would reload the surface being annotated, and the defect would read as a
|
||||
bug in the surface.
|
||||
- **Not inside the package.** A boot step that writes into an installed package
|
||||
makes `node_modules` state rather than dependencies, and a copied tree then
|
||||
carries one instance's marks into another.
|
||||
|
||||
## Conformance — what ships TODAY
|
||||
|
||||
| specified | shipped |
|
||||
|---|---|
|
||||
| the shell drawing marks over the stage, on a surface that does not cooperate | **yes**, browser-verified on an unmodified surface |
|
||||
| the mark record, its fields, and the closed shape set | **yes** |
|
||||
| `anchored` / `spatial` / `missing`, with a missing anchor shown as missing | **yes** |
|
||||
| `GET` / `POST /api/annotations`, scoped | **yes**, with controls on both refusal directions |
|
||||
| a scope-less read answering empty rather than everything | **yes**, with a control |
|
||||
| `{ t: "annotation-context" }` refining the scope | **yes** — verified in a live shell: a surface declaring one gets its own empty board, marks drawn there stay there, and declaring `null` restores the default board intact |
|
||||
| the active-surface-only rule on that message | **yes, but the committed check is STRUCTURAL** — it asserts the `e.source` guard is present in the shell, with a control proving the check can fail. The behaviour was verified by driving a live shell; this repository has no browser dependency to re-run that automatically |
|
||||
| the scope key composed by the shell and opaque to consumers | **yes** |
|
||||
| persistence reported at `GET /api/contract` and shown in the UI | **yes** |
|
||||
| marks surviving a runtime restart | **yes when `--annotations <file>` is configured**; in memory otherwise, and the layer says "session only" |
|
||||
| human and agent marks rendered distinguishably | **yes** |
|
||||
| an agent handle to add and remove marks | **yes** — `window.studioAnnotations.annotate()` / `.remove()` / `.list()` |
|
||||
| **a mark anchored across a CROSS-ORIGIN surface** | **no.** A cross-origin iframe cannot be read for element identity, so marks there are spatial only and say so. Same-origin surfaces get element anchoring |
|
||||
| **agreement about a mark between two people watching at once** | **no.** Last write for a scope wins. Concurrent annotation needs the write-path concurrency `change-signal.md` deliberately excludes, and inventing it before two real watchers exist would be the wrong shape |
|
||||
|
||||
Rows are measured against the shipped runtime rather than recalled.
|
||||
@@ -39,6 +39,7 @@ looking at needs `focus.md`.
|
||||
| `change-signal.md` | Markers, DATA-vs-CODE refresh, and state that survives a reload |
|
||||
| `focus.md` | What the user is looking at, and how an agent reads it |
|
||||
| `drive.md` | How an agent operates a surface the user already has open |
|
||||
| `annotations.md` | Marking up any surface from the shell, and the sub-context a surface may declare |
|
||||
|
||||
Two schemas sit beside them: `surface-row.schema.json` for a manifest row, and
|
||||
`app-manifest.schema.json` for `app.json`.
|
||||
@@ -52,9 +53,11 @@ Two schemas sit beside them: `surface-row.schema.json` for a manifest row, and
|
||||
"contractVersion": "0.1",
|
||||
"runtime": { "name": "openrig-studio", "flavor": "reference-fixture", "boot": "<opaque id, stable for this process>" },
|
||||
"capabilities": ["contract.meta", "observe.factory-state", "stream.events", "files.read",
|
||||
"shell.protocol", "focus.read", "focus.write", "drive.read", "drive.write"],
|
||||
"shell.protocol", "focus.read", "focus.write", "drive.read", "drive.write",
|
||||
"annotations.read", "annotations.write"],
|
||||
"drive": { "listening": false, "reads": 0, "ops": 0 },
|
||||
"focus": { "attribution": "caller-declared", "identityHeader": null },
|
||||
"annotations": { "persistence": "memory", "scopes": 0, "writes": 0 },
|
||||
"manifest": {
|
||||
"ok": true, "errors": [], "warnings": [], "surfaces": 1,
|
||||
"seats": { "count": 1, "source": "package", "attachable": false },
|
||||
|
||||
@@ -133,6 +133,28 @@ Case-insensitive filename search under the root:
|
||||
`{ "ok": true, "hits": [{name, path, kind}] }`. Bounded result count; queries
|
||||
under 2 characters return empty hits.
|
||||
|
||||
## annotations
|
||||
|
||||
Marks drawn over a surface by a human or an agent. Full semantics — the record,
|
||||
the sub-context, and what the shell composes — are in `annotations.md`; this is
|
||||
the verb shape.
|
||||
|
||||
### `GET /api/annotations?scope=<key>` — stable
|
||||
`{ "ok": true, "scope": "<key>", "records": [ … ] }`.
|
||||
|
||||
**Without `scope` this answers an empty set, not every scope on the box.** A
|
||||
caller that forgot the parameter would otherwise be handed another surface's marks
|
||||
and render them over this one.
|
||||
|
||||
### `POST /api/annotations` — stable
|
||||
Body `{ scope, records }`; answers `{ ok, scope, records }`. Replaces the whole
|
||||
set for that scope. A write naming no scope, or carrying no `records` array, is
|
||||
refused with 400 naming which.
|
||||
|
||||
**Substitutable, like the files verbs.** These answer stored marks — data on the
|
||||
box — not the runtime describing itself, so a provider that knows where a real
|
||||
studio keeps annotations should serve them under this shape.
|
||||
|
||||
## runtime-internal — exists in some runtimes; DO NOT build on
|
||||
|
||||
Listed so you are warned at the point of temptation. None of these are
|
||||
|
||||
@@ -42,10 +42,18 @@ Send from inside your iframe with `parent.postMessage(msg, "*")`:
|
||||
| `{ "t": "open-surface", "id": "<surface-id>" }` | The shell activates that surface (same as a rail click). Unknown ids are ignored. |
|
||||
| `{ "t": "open-agent" }` | The shell opens the agent sidebar. |
|
||||
| `{ "t": "open-agent", "seat": "<seat-name>" }` | Opens the sidebar with that roster seat highlighted. Unknown seats open the sidebar unhighlighted. |
|
||||
| `{ "t": "header", … }` | Declares the surface's half of the header. See *The header splits in half* below. |
|
||||
| `{ "t": "annotation-context", "id": "<opaque>" }` | Refines which board of annotations this surface is showing. Only for a surface holding several documents behind one id — see `annotations.md`. `null` or omitted returns to the default. |
|
||||
|
||||
This message set is closed for v0.1: the shell ignores any other `t` value.
|
||||
New message types arrive as contract additions, never by surfaces inventing
|
||||
them.
|
||||
This message set is closed: the shell ignores any other `t` value. **New message
|
||||
types arrive as contract additions, never by surfaces inventing them** — which is
|
||||
why each of the two additions since v0.1 shipped was documented in this table in
|
||||
the same commit that implemented it, rather than being discovered later by reading
|
||||
the shell.
|
||||
|
||||
Every one of these is **additive and optional**. A surface that sends nothing at
|
||||
all is a correct surface: it gets a header from its manifest row, one board of
|
||||
annotations, and no loss of function.
|
||||
|
||||
## The agent sidebar (what "seat" means in v0.1)
|
||||
|
||||
@@ -160,6 +168,12 @@ render its own richer annotations, or to get its own controls out of the way.
|
||||
works on surfaces which cooperate is a feature that does not work on the surfaces
|
||||
that already exist.
|
||||
|
||||
**Annotation is the worked example of that rule.** The shell draws marks on an
|
||||
overlay above the stage, so every surface is annotatable including ones written
|
||||
before the feature existed. A surface may refine *which board* it is showing with
|
||||
`annotation-context` when it holds several documents behind one id, but it is
|
||||
never asked to draw, store, or understand a mark. See `annotations.md`.
|
||||
|
||||
## Surface expectations (visual / accessibility baseline)
|
||||
|
||||
v0.1 deliberately declares **no required visual system and no enforced
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const JS = path.join(REPO, "app", "annotations.js");
|
||||
const CSS = path.join(REPO, "app", "annotations.css");
|
||||
|
||||
test("annotations are one shell feature, not an ARTIFACTS fork", () => {
|
||||
const source = fs.readFileSync(JS, "utf8");
|
||||
assert.match(source, /export function attach\s*\(shell/);
|
||||
assert.match(source, /shell\.overlay/);
|
||||
assert.match(source, /shell\.active\(\)/);
|
||||
assert.match(source, /shell\.onMarkup/);
|
||||
assert.match(source, /shell\.onSurface/);
|
||||
assert.doesNotMatch(source, /artifacts?|\/api\//i);
|
||||
});
|
||||
|
||||
test("persistence is injected and absence is disclosed", () => {
|
||||
const source = fs.readFileSync(JS, "utf8");
|
||||
assert.match(source, /store\?\.load/);
|
||||
assert.match(source, /store\?\.save/);
|
||||
assert.match(source, /session only/i);
|
||||
});
|
||||
|
||||
test("the generic layer ships its own visible tool language", () => {
|
||||
const source = fs.readFileSync(JS, "utf8");
|
||||
const css = fs.readFileSync(CSS, "utf8");
|
||||
for (const shape of ["circle", "rect", "arrow", "text"]) assert.ok(source.includes(shape), shape);
|
||||
assert.match(source, /isTypingTarget/);
|
||||
assert.match(source, /metaKey|ctrlKey/);
|
||||
assert.match(css, /\.studio-annotations/);
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
// The annotations verb, its scoping, and the sub-context seam.
|
||||
//
|
||||
// node --test 'test/*.test.mjs'
|
||||
//
|
||||
// The layer's own behaviour is browser work and lives in
|
||||
// test/annotations-feature.test.mjs plus a live LOOK. What is pinned here is the
|
||||
// part a browser cannot show you cheaply: that a scope genuinely isolates one
|
||||
// board from another, that a forgotten parameter fails toward EMPTY rather than
|
||||
// toward someone else's marks, and that a file-backed runtime actually reloads
|
||||
// what it wrote.
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
let nextPort = 9610;
|
||||
|
||||
async function start({ file } = {}) {
|
||||
const port = nextPort++;
|
||||
const args = [path.join(REPO, "app", "serve-studio.mjs"), "--port", String(port)];
|
||||
if (file) args.push("--annotations", file);
|
||||
const proc = spawn(process.execPath, args, { cwd: REPO, stdio: ["ignore", "pipe", "pipe"] });
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
for (let i = 0; i < 100; i++) {
|
||||
try { await fetch(`${base}/api/contract`, { cache: "no-store" }); break; }
|
||||
catch { await new Promise((r) => setTimeout(r, 50)); }
|
||||
}
|
||||
return {
|
||||
base,
|
||||
stop: () => new Promise((r) => { proc.once("exit", r); proc.kill(); }),
|
||||
read: async (scope) =>
|
||||
(await fetch(`${base}/api/annotations${scope === undefined ? "" : `?scope=${encodeURIComponent(scope)}`}`,
|
||||
{ cache: "no-store" })).json(),
|
||||
write: async (body) => {
|
||||
const res = await fetch(`${base}/api/annotations`, { method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: typeof body === "string" ? body : JSON.stringify(body) });
|
||||
return { status: res.status, body: await res.json() };
|
||||
},
|
||||
contract: async () => (await fetch(`${base}/api/contract`, { cache: "no-store" })).json(),
|
||||
};
|
||||
}
|
||||
|
||||
const mark = (note) => ({ id: `m-${note}`, surfaceId: "canvas", selector: "#publish", shape: "circle",
|
||||
note, source: "human", anchor: { x: .1, y: .1, width: .2, height: .1 }, status: "anchored" });
|
||||
|
||||
test("positive control: a fresh runtime answers a scope it has never seen", async (t) => {
|
||||
const s = await start();
|
||||
t.after(() => s.stop());
|
||||
assert.deepEqual(await s.read("canvas"), { ok: true, scope: "canvas", records: [] });
|
||||
});
|
||||
|
||||
test("marks round-trip through the verb verbatim", async (t) => {
|
||||
const s = await start();
|
||||
t.after(() => s.stop());
|
||||
const records = [mark("tighten this"), mark("and this")];
|
||||
const written = await s.write({ scope: "canvas", records });
|
||||
assert.equal(written.status, 200);
|
||||
assert.deepEqual((await s.read("canvas")).records, records,
|
||||
"what came back is not what was written — the runtime is reshaping marks it is supposed to store opaquely");
|
||||
});
|
||||
|
||||
test("A SUB-CONTEXT IS A DIFFERENT BOARD, which is the whole point of the seam", async (t) => {
|
||||
// The migration regression this exists to prevent: ARTIFACTS holds many pages
|
||||
// behind ONE surface id. Keyed on the id alone, page 1's marks hang over page 2.
|
||||
const s = await start();
|
||||
t.after(() => s.stop());
|
||||
const NUL = String.fromCharCode(0);
|
||||
|
||||
await s.write({ scope: "artifacts", records: [mark("default board")] });
|
||||
await s.write({ scope: `artifacts${NUL}page-2`, records: [mark("page two only")] });
|
||||
|
||||
assert.deepEqual((await s.read("artifacts")).records.map((r) => r.note), ["default board"]);
|
||||
assert.deepEqual((await s.read(`artifacts${NUL}page-2`)).records.map((r) => r.note), ["page two only"],
|
||||
"a sub-context saw the default board's marks — the scopes are not isolated");
|
||||
|
||||
// The discriminating half: an UNRELATED context must be empty rather than
|
||||
// inheriting either of the above. Without this, a store that ignored the
|
||||
// context suffix entirely would pass the two assertions above.
|
||||
assert.deepEqual((await s.read(`artifacts${NUL}page-3`)).records, [],
|
||||
"an undeclared sub-context inherited marks — the suffix is being ignored");
|
||||
});
|
||||
|
||||
test("a read that FORGOT the scope answers empty, never everything", async (t) => {
|
||||
// The failure this forbids is not an error, it is a plausible wrong answer:
|
||||
// another surface's marks rendered over this one.
|
||||
const s = await start();
|
||||
t.after(() => s.stop());
|
||||
await s.write({ scope: "canvas", records: [mark("somebody else's")] });
|
||||
|
||||
const bare = await s.read(undefined);
|
||||
assert.equal(bare.ok, true);
|
||||
assert.deepEqual(bare.records, [], "a scope-less read returned marks belonging to a scope it did not ask for");
|
||||
assert.equal(bare.scope, null);
|
||||
});
|
||||
|
||||
test("malformed writes are refused BY NAME rather than silently doing nothing", async (t) => {
|
||||
const s = await start();
|
||||
t.after(() => s.stop());
|
||||
|
||||
const noScope = await s.write({ records: [] });
|
||||
assert.equal(noScope.status, 400);
|
||||
assert.match(noScope.body.error, /scope/, "the refusal does not say what was missing");
|
||||
|
||||
const noRecords = await s.write({ scope: "canvas" });
|
||||
assert.equal(noRecords.status, 400);
|
||||
assert.match(noRecords.body.error, /records/);
|
||||
|
||||
const notJson = await s.write("not json at all");
|
||||
assert.equal(notJson.status, 400);
|
||||
assert.match(notJson.body.error, /JSON/);
|
||||
|
||||
// And a refusal must not have written anything.
|
||||
assert.deepEqual((await s.read("canvas")).records, [],
|
||||
"a refused write still mutated the board");
|
||||
});
|
||||
|
||||
test("persistence is REPORTED, and the report matches how it was started", async (t) => {
|
||||
const plain = await start();
|
||||
t.after(() => plain.stop());
|
||||
const before = (await plain.contract()).annotations;
|
||||
assert.deepEqual(before, { persistence: "memory", scopes: 0, writes: 0 });
|
||||
|
||||
await plain.write({ scope: "canvas", records: [mark("one")] });
|
||||
const after = (await plain.contract()).annotations;
|
||||
assert.equal(after.persistence, "memory");
|
||||
assert.equal(after.scopes, 1);
|
||||
assert.equal(after.writes, 1, "the write counter is not measuring writes");
|
||||
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ann-"));
|
||||
const filed = await start({ file: path.join(dir, "marks.json") });
|
||||
t.after(() => filed.stop());
|
||||
assert.equal((await filed.contract()).annotations.persistence, "file",
|
||||
"a runtime started with --annotations still reports memory, so the UI will say 'session only' over marks that ARE persisted");
|
||||
});
|
||||
|
||||
test("a file-backed runtime RELOADS what it wrote — the half that is easy to miss", async (t) => {
|
||||
// Writing to disk and reading it back in the same process proves nothing about
|
||||
// the load path. The claim is that marks survive a RESTART, so this restarts.
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ann-"));
|
||||
const file = path.join(dir, "nested", "marks.json");
|
||||
|
||||
const first = await start({ file });
|
||||
await first.write({ scope: "canvas", records: [mark("survives")] });
|
||||
await first.stop();
|
||||
|
||||
assert.ok(fs.existsSync(file), "nothing was written to the configured file");
|
||||
|
||||
const second = await start({ file });
|
||||
t.after(() => second.stop());
|
||||
assert.deepEqual((await second.read("canvas")).records.map((r) => r.note), ["survives"],
|
||||
"a restarted runtime lost marks it had persisted — the write path works and the load path does not");
|
||||
});
|
||||
|
||||
test("negative control: a MEMORY runtime genuinely loses them, so the test above means something", async (t) => {
|
||||
const first = await start();
|
||||
await first.write({ scope: "canvas", records: [mark("gone")] });
|
||||
await first.stop();
|
||||
|
||||
const second = await start();
|
||||
t.after(() => second.stop());
|
||||
assert.deepEqual((await second.read("canvas")).records, [],
|
||||
"an unconfigured runtime persisted marks anyway — then the survival test above cannot distinguish file from memory");
|
||||
});
|
||||
|
||||
test("the annotations verbs are NOT reserved — stored marks are data on the box", async (t) => {
|
||||
// The retraction from the focus/drive slice, applied forward rather than
|
||||
// relearned. focus and drive answer state held in THIS process and nothing else
|
||||
// can answer them. Marks are storage: a real studio should be able to keep them
|
||||
// in its own database, exactly as it serves its own real directories.
|
||||
const src = fs.readFileSync(path.join(REPO, "tools", "studio.mjs"), "utf8");
|
||||
const m = src.match(/const SDK_OWNED = new Set\(\[([\s\S]*?)\]\);/);
|
||||
assert.ok(m, "tools/studio.mjs no longer declares SDK_OWNED — this test cannot find what it checks");
|
||||
const reserved = [...m[1].matchAll(/"([^"]+)"/g)].map((x) => x[1]);
|
||||
assert.ok(!reserved.includes("/api/annotations"),
|
||||
"reserving /api/annotations forbids the substitution that is the point of a provider");
|
||||
});
|
||||
|
||||
test("only the ACTIVE surface may move the scope — STRUCTURAL, and it says so", () => {
|
||||
// HONEST ABOUT WHAT THIS IS: a structural check on shell.html, not a behavioural
|
||||
// one. The SDK has no browser dependency, so the real guard was verified by
|
||||
// driving a live shell; this exists so that REMOVING the guard fails the suite
|
||||
// rather than only failing a LOOK nobody re-runs.
|
||||
//
|
||||
// The property: a backgrounded surface still holds a live channel, so without
|
||||
// an e.source check the scope would follow whichever surface spoke last rather
|
||||
// than the one on screen — and the marks board would swap under the user.
|
||||
const shell = fs.readFileSync(path.join(REPO, "app", "shell.html"), "utf8");
|
||||
const handler = shell.match(/if \(e\.data\.t === "annotation-context"\)\s*\{([\s\S]*?)\n \}/);
|
||||
assert.ok(handler, "shell.html no longer handles annotation-context — this test cannot find what it checks");
|
||||
assert.match(handler[1], /e\.source === current\.contentWindow/,
|
||||
"the annotation-context handler accepts a message without checking it came from the ACTIVE surface");
|
||||
});
|
||||
|
||||
test("positive control: the guard check above can fail", () => {
|
||||
// The check is a regex over prose-adjacent source, which is exactly the shape
|
||||
// that silently matches nothing. Proven against a mutated copy rather than
|
||||
// trusted: same reason the leak sweep needed a live control.
|
||||
const shell = fs.readFileSync(path.join(REPO, "app", "shell.html"), "utf8");
|
||||
const stripped = shell.replace(/e\.source === current\.contentWindow/g, "true");
|
||||
const handler = stripped.match(/if \(e\.data\.t === "annotation-context"\)\s*\{([\s\S]*?)\n \}/);
|
||||
assert.ok(handler, "the mutation broke the extractor rather than the guard — the control proves nothing");
|
||||
assert.doesNotMatch(handler[1], /e\.source === current\.contentWindow/,
|
||||
"the guard survived being removed, so the test above would pass with no guard at all");
|
||||
});
|
||||
|
||||
test("positive control: this suite is capable of failing", () => {
|
||||
assert.throws(() => assert.equal(1, 2));
|
||||
});
|
||||
Reference in new issue
Block a user