open-standards/wai/quantum-studio/src/versions.js
Transaction Science f090e8b6d1 quantum-studio: the course in the Studio and the desktop app, and the interface in Simplified Chinese
The Course pane lists the 21 lessons, shows a lesson's text and exercise,
loads its starter, and checks the program in the editor with the engine
(engine_course_check in the page, the same commands in the desktop app):
each case's verdict, each rule, What went wrong? from the diagnostics, the
verdict and the critique, the hints one at a time with the engine's pointer,
and a hint from the assistant when a model is connected, any program in its
answer held back. Progress is kept only in this browser and says so.

The interface's words are data, locales/<locale>/<area>.json for every part,
English the source and Simplified Chinese marked machine-assisted; the Studio
follows the browser's language until the reader picks one with the switch.
test/locales.test.mjs holds every locale to every key and placeholder. The
e2e checks the course in English and in Chinese. wai-web's engine tests pin
23 native and 16 wasm32, a course check the same bytes in both.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 08:43:06 -04:00

305 lines
18 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// The program's history, attributed (the engine's versions): every version,
// who made it and how, and whose hand each line and gate is in. The Studio
// records each change as it sees it made: what is typed is the reader's, a
// proposal applied is its proposer's, and a fix is the engine's. A proposal is
// reviewed change by change before it touches the program, and a version
// opened from the past and edited starts a line of work of its own. The
// history stays in this browser; a run's request carries its account, which
// the receipt binds.
import { el } from './dom.js';
import { t } from './i18n.js';
const SAVED = 'wai.quantum-studio.versions';
const IDLE_MS = 4000;
const ROW = 30, LANE = 14;
const SVG = 'http://www.w3.org/2000/svg';
// Whose hand, as the reader is told.
export const HANDS = {
person: t('versions.hand_person'), assistant: t('versions.hand_assistant'), agent: t('versions.hand_agent'), engine: t('versions.hand_engine'), origin: t('versions.hand_origin'),
assistant_then_person: t('versions.hand_assistant_then_person'), agent_then_person: t('versions.hand_agent_then_person'),
};
const KINDS = { person: HANDS.person, assistant: HANDS.assistant, agent: HANDS.agent, engine: HANDS.engine };
const HOW = { start: t('versions.how_start'), edit: t('versions.how_edit'), proposal: t('versions.how_proposal'), merge: t('versions.how_merge') };
// Who, as the reader is told: a program from elsewhere is named for where it came from.
export const whoName = (by) => (by?.kind === 'origin' ? by.name || HANDS.origin : `${KINDS[by?.kind] || by?.kind || '?'}${by?.name ? ` (${by.name})` : ''}`);
// How a version was made, and by whom: “edited by you”, “started from the example Bell pair”.
const made = (v) => (v.by?.kind === 'origin'
? (v.how === 'start' ? t('versions.started_from', { who: whoName(v.by) }) : t('versions.opened_from', { who: whoName(v.by) }))
: t('versions.made_by', { how: HOW[v.how] || v.how, who: whoName(v.by) }));
const nChanges = (n) => (n === 1 ? t('versions.change_one', { n }) : t('versions.changes', { n }));
const nGates = (n) => (n === 1 ? t('versions.gate_one', { n }) : t('versions.gates', { n }));
const nLines = (n) => (n === 1 ? t('versions.line_one', { n }) : t('versions.lines', { n }));
const svg = (tag, attrs = {}) => {
const e = document.createElementNS(SVG, tag);
for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, v);
return e;
};
// Each version's lane: a version continues its first parent's lane unless
// another child already has, and then opens one of its own.
function lanes(versions) {
const lane = new Map(), continued = new Set();
let next = 0;
for (const v of versions) {
const p = v.parents?.[0];
if (p != null && !continued.has(p) && lane.has(p)) { lane.set(v.id, lane.get(p)); continued.add(p); }
else lane.set(v.id, next++);
}
return { lane, count: Math.max(next, 1) };
}
// The pane. `engine.history(name, args)` calls the engine; `text()` and
// `setText(s)` read and write the editor; `inputs()` gives a generator's
// values (or {} for a program with none); `lock(on)` makes the editor wait
// while a proposal is reviewed in `reviewHost`; `onHands(blame)` hears whose
// hand each line and gate is in; `save(name, body)` keeps a file; `rules()`
// gives the reader's rules for proposals (the engine's locks), and `taken()`
// hears that a proposal was decided.
// `editNote()` notes the reader's own edits (a while on their own, say);
// `onRender()` hears each time the history is shown again.
export function versionsPane({ engine, text, setText, inputs, lock, reviewHost, onHands, save, status, rules = () => ({}), taken = () => {}, editNote = () => null, onRender = () => {} }) {
let history = null;
let current = null;
let selected = null;
let timer = null;
let queue = Promise.resolve();
let reviewing = false;
let showing = false;
const call = (name, args) => engine.history(name, args);
const source = (id) => history?.versions.find((v) => v.id === id)?.source;
const keep = () => { try { localStorage.setItem(SAVED, JSON.stringify({ history, current })); } catch (e) {} };
// One change at a time, in the order made.
const serial = (f) => (queue = queue.then(f, f));
const handsBox = el('input', { type: 'checkbox', id: 'qs-vs-hands' });
const handsLabel = el('label', { class: 'qs-check', for: 'qs-vs-hands', title: t('versions.hands_title') }, handsBox, el('span', { text: t('versions.hands') }));
const download = el('button', { class: 'qs-btn qs-small', id: 'qs-vs-download', type: 'button', text: t('versions.download'), title: t('versions.download_title') });
const share = el('div', { class: 'qs-vs-share', id: 'qs-vs-share' });
const graph = el('div', { class: 'qs-vs-graph' });
const list = el('ol', { class: 'qs-vs-list', id: 'qs-vs-list' });
const detail = el('div', { class: 'qs-vs-detail', id: 'qs-vs-detail' });
const node = el('section', { class: 'qs-pane qs-vs', 'aria-labelledby': 'qs-vs-h' },
el('div', { class: 'qs-head' }, el('h2', { id: 'qs-vs-h', text: t('versions.title') }), el('span', { class: 'qs-grow' }), handsLabel, download),
el('p', { class: 'qs-note qs-vs-intro', text: t('versions.intro') }),
share, el('div', { class: 'qs-vs-lines' }, graph, list), detail);
// ---- recording ----
// The program as the editor holds it, recorded as `by`'s edit if it changed.
function record(by = { kind: 'person' }, note = by.kind === 'person' ? editNote() ?? undefined : undefined) {
clearTimeout(timer);
return serial(async () => {
if (!history || reviewing) return current;
const src = text();
if (src === source(current)) return current;
const r = await call('commit', { history, parent: current, source: src, by, note, at: Date.now() });
history = r.history; current = r.version; selected = null; keep();
await render();
return current;
});
}
// The editor changed: the reader's edit is recorded once they pause.
function edited() {
if (!history || reviewing) return;
clearTimeout(timer);
if (text() !== source(current)) timer = setTimeout(() => record(), IDLE_MS);
}
// The history this browser kept goes on, a program that differs from its
// current version recorded as `changed`'s; with none kept, a history starts
// with the program as `fresh`'s (or `changed`'s).
async function begin(changed, fresh = null) {
let was = null;
try { was = JSON.parse(localStorage.getItem(SAVED) || 'null'); } catch (e) {}
await serial(async () => {
if (was?.history?.versions?.length && was.history.versions.some((v) => v.id === was.current)) {
history = was.history; current = was.current;
const log = await call('log', { history }).catch(() => null);
if (!log?.check?.ok) { history = null; current = null; }
}
// A program that is a version's already is that version, opened.
const same = history && text() !== source(current) ? [...history.versions].reverse().find((v) => v.source === text()) : null;
if (same) current = same.id;
else if (history && text() !== source(current)) {
const r = await call('commit', { history, parent: current, source: text(), by: changed, at: Date.now() });
history = r.history; current = r.version;
} else if (!history) {
const r = await call('start', { source: text(), by: fresh || changed, at: Date.now() });
history = r.history; current = r.version;
}
keep();
});
await render();
}
// ---- reviewing ----
// A proposal (or, with `merge`, another line of work) shown change by
// change; only the changes taken reach the program. `by` is its proposer.
async function review(proposed, by, note, { merge = null, all = false } = {}) {
if (reviewing) return null;
await record();
const base = source(current);
const { changes } = await call('changes', { base, proposed });
if (!changes.length) { status?.(merge ? t('versions.merge_nothing', { id: merge }) : t('versions.proposal_same')); return null; }
// The reader's rules bind a proposal, not their own merge.
const breaches = merge ? changes.map(() => []) : (await engine.locks('check', { base, proposed, locks: rules(), inputs: inputs() })).changes.map((c) => c.breaches);
const blocked = breaches.flatMap((b, i) => (b.length ? [i] : []));
const finish = async (picked) => {
const accepted = picked.filter((i) => !blocked.includes(i));
await serial(async () => {
if (merge) {
const { source: merged } = await call('apply', { base, proposed, accepted });
const r = await call('merge', { history, a: current, b: merge, source: merged, by: { kind: 'person' }, note: note || `${merge} brought in`, at: Date.now() });
history = r.history; current = r.version;
} else {
const r = await call('propose', { history, parent: current, proposed, accepted, blocked, by, note, at: Date.now() });
history = r.history; current = r.version;
}
selected = null; keep();
});
reviewing = false; lock(false); reviewHost.replaceChildren(); reviewHost.hidden = true;
setText(source(current));
if (!merge) taken();
await render();
return { offered: changes.length, accepted: accepted.length, blocked: blocked.length, version: current };
};
if (all) return finish(changes.map((_, i) => i));
reviewing = true; lock(true);
return new Promise((resolve) => {
const boxes = changes.map((c, i) => el('input', { type: 'checkbox', id: `qs-review-${i}`, checked: !blocked.includes(i), disabled: blocked.includes(i) }));
const take = el('button', { class: 'qs-btn qs-primary qs-small', id: 'qs-review-apply', type: 'button' });
const open = changes.length - blocked.length;
const count = () => { const n = boxes.filter((b) => b.checked).length; take.textContent = n === changes.length ? t('versions.take_all', { n: changes.length }) : t('versions.take_some', { n, of: open }); take.disabled = n === 0 && open === 0; };
boxes.forEach((b) => b.addEventListener('change', count)); count();
const who = merge ? `${merge} (${whoName(history.versions.find((v) => v.id === merge)?.by)})` : whoName(by);
reviewHost.replaceChildren(
el('p', { class: 'qs-review-head' }, el('b', { text: merge ? t('versions.bring_in', { id: merge }) : t('versions.proposal_from', { who }) }),
el('span', { class: 'qs-note', text: t('versions.review_sum', { changes: nChanges(changes.length), blocked: blocked.length ? t('versions.blocked_by_your_rules', { n: blocked.length }) : '' }) })),
note ? el('p', { class: 'qs-note qs-review-note', text: note }) : null,
el('ol', { class: 'qs-review-list' }, ...changes.map((c, i) => el('li', { class: blocked.includes(i) ? 'qs-blocked' : '' },
el('label', { class: 'qs-check', for: `qs-review-${i}` }, boxes[i], el('span', { text: t('versions.at_line', { line: c.line }) })),
breaches[i].length ? el('p', { class: 'qs-blocked-why', text: t('versions.blocked_why', { why: breaches[i].map((b) => b.why).join('; ') }) }) : null,
el('pre', { class: 'qs-diff' }, ...c.removed.map((l) => el('span', { class: 'qs-del', text: `− ${l}\n` })), ...c.added.map((l) => el('span', { class: 'qs-add', text: `+ ${l}\n` })))))),
el('div', { class: 'qs-actions' }, take,
el('button', { class: 'qs-btn qs-small', id: 'qs-review-reject', type: 'button', text: merge ? t('versions.not_now') : t('versions.reject_all'), onclick: async () => {
if (merge) { reviewing = false; lock(false); reviewHost.replaceChildren(); reviewHost.hidden = true; resolve(null); return; }
resolve(await finish([]));
} })));
reviewHost.hidden = false;
take.addEventListener('click', async () => { take.disabled = true; resolve(await finish(boxes.flatMap((b, i) => (b.checked ? [i] : [])))); });
});
}
// ---- the account a run carries ----
async function attest() {
await record();
return queue.then(() => call('attest', { history, version: current, inputs: inputs() }));
}
const checkJob = (job) => call('check_job', { history, job });
// ---- showing ----
async function blame() {
if (!history) return null;
const b = await call('blame', { history, version: current, inputs: inputs() });
b.source = source(current);
return b;
}
function shareOf(a) {
if (!a) return [el('p', { class: 'qs-note', text: t('versions.unread') })];
const total = a.of.gates;
const hand = (c) => `${HANDS[c.hand]}${c.name ? ` (${c.name})` : ''}`;
const bar = el('div', { class: 'qs-vs-bar', role: 'img', 'aria-label': a.contributors.map((c) => t('versions.bar_part', { n: c.gates, of: total, who: HANDS[c.hand] })).join(', ') },
...a.contributors.filter((c) => c.gates).map((c) => el('span', { class: `qs-vs-seg qs-hand-${c.hand}`, style: `flex-grow:${c.gates}`, title: t('versions.hand_gates', { gates: nGates(c.gates), who: hand(c) }) })));
return [
el('p', { class: 'qs-vs-sum', id: 'qs-vs-sum', text: t('versions.sum', { version: current, gates: nGates(total), lines: nLines(a.of.lines) }) }),
total ? bar : null,
el('ul', { class: 'qs-vs-who' }, ...a.contributors.map((c) => el('li', {},
el('span', { class: `qs-vs-key qs-hand-${c.hand}`, 'aria-hidden': 'true' }),
el('span', { text: t('versions.hand_share', { gates: nGates(c.gates), lines: nLines(c.lines), who: hand(c) }) })))),
];
}
function drawGraph(versions, { lane, count }) {
const order = [...versions].reverse();
const row = new Map(order.map((v, i) => [v.id, i]));
const x = (id) => LANE / 2 + LANE * lane.get(id);
const y = (id) => ROW / 2 + ROW * row.get(id);
const g = svg('svg', { width: LANE * count, height: ROW * order.length, 'aria-hidden': 'true', class: 'qs-vs-svg' });
for (const v of order) for (const p of v.parents || []) {
if (!row.has(p)) continue;
const [x1, y1, x2, y2] = [x(v.id), y(v.id), x(p), y(p)];
g.append(svg('path', { class: 'qs-vs-edge', d: x1 === x2 ? `M${x1} ${y1}V${y2}` : `M${x1} ${y1}C${x1} ${y1 + ROW * 0.8} ${x2} ${y2 - ROW * 0.8} ${x2} ${y2}` }));
}
for (const v of order) g.append(svg('circle', { class: `qs-vs-dot qs-by-${v.by.kind}${v.id === current ? ' qs-vs-here' : ''}`, cx: x(v.id), cy: y(v.id), r: v.id === current ? 5 : 4 }));
return g;
}
function showDetail(v, log) {
selected = v.id;
for (const li of list.children) li.classList.toggle('on', li.dataset.id === v.id);
const e = log.find((x) => x.id === v.id) || {};
const parent = v.parents?.[0];
const heads = log.filter((x) => x.head).map((x) => x.id);
const actions = [];
if (v.id !== current) actions.push(el('button', { class: 'qs-btn qs-small', id: 'qs-vs-open', type: 'button', text: t('versions.open', { id: v.id }), title: t('versions.open_title'), onclick: () => openVersion(v.id) }));
if (heads.includes(v.id) && v.id !== current) actions.push(el('button', { class: 'qs-btn qs-small', id: 'qs-vs-merge', type: 'button', text: t('versions.bring', { id: v.id }), title: t('versions.bring_title'), onclick: () => review(v.source, { kind: 'person' }, null, { merge: v.id }) }));
const r = v.review;
detail.replaceChildren(
el('p', { class: 'qs-vs-what' }, el('b', { text: v.id }), el('span', { text: t('versions.what', { when: new Date(v.at).toLocaleString(), from: v.parents?.length ? t('versions.from', { parents: v.parents.join(t('versions.and')) }) : '', made: made(v) }) })),
v.note ? el('p', { class: 'qs-note', text: v.note }) : null,
r ? el('p', { class: 'qs-note', id: 'qs-vs-review', text: t('versions.review_taken', { n: r.accepted.length, changes: nChanges(r.offered), blocked: r.blocked?.length ? t('versions.blocked_by_the_rules', { n: r.blocked.length }) : '', rest: r.accepted.length < r.offered ? t('versions.rest_kept') : '' }) }) : null,
r && r.accepted.length < r.offered ? el('details', {}, el('summary', { text: t('versions.whole_proposal') }), el('pre', { class: 'qs-ai-code', text: r.proposed })) : null,
parent ? el('p', { class: 'qs-note', text: t('versions.against', { added: e.added ?? 0, removed: e.removed ?? 0, parent, sealed: e.sealed ? t('versions.sealed') : '' }) }) : null,
actions.length ? el('div', { class: 'qs-actions' }, ...actions) : null);
}
async function openVersion(id) {
await record();
current = id; keep();
setText(source(id));
status?.(t('versions.opened', { id }));
await render();
}
async function render() {
if (!history) return;
const log = (await call('log', { history })).versions;
const ls = lanes(history.versions);
graph.replaceChildren(drawGraph(history.versions, ls));
list.replaceChildren(...[...history.versions].reverse().map((v) => {
const e = log.find((x) => x.id === v.id) || {};
const tags = [v.id === current ? t('versions.in_editor') : null, e.head && v.id !== current ? t('versions.a_head') : null].filter(Boolean);
return el('li', { 'data-id': v.id, class: v.id === current ? 'here' : '' },
el('button', { type: 'button', class: 'qs-vs-item', onclick: () => showDetail(v, log) },
el('span', { class: 'qs-vs-id', text: v.id }),
el('span', { class: 'qs-vs-by', text: made(v) }),
el('span', { class: 'qs-note', text: `${e.review ? t('versions.item_review', { n: e.review.accepted, of: e.review.offered, blocked: e.review.blocked ? t('versions.item_blocked', { n: e.review.blocked }) : '' }) : ''}+${e.added ?? 0} −${e.removed ?? 0}${tags.length ? ` · ${tags.join(', ')}` : ''}` })));
}));
const b = await blame().catch(() => null);
share.replaceChildren(...shareOf(b?.authorship));
if (showing) onHands(b);
const v = history.versions.find((x) => x.id === (selected && source(selected) != null ? selected : current));
if (v) showDetail(v, log);
onRender();
}
handsBox.addEventListener('change', async () => {
showing = handsBox.checked;
onHands(showing ? await blame().catch(() => null) : null);
});
download.addEventListener('click', async () => {
await record();
await save('program.qasm', source(current), 'text/plain');
await save('program.history.json', JSON.stringify(history, null, 2) + '\n', 'application/json');
status?.(t('versions.saved'), 'ok');
});
return {
node, begin, edited, record, review, attest, checkJob,
current: () => current, history: () => history, showing: () => showing, busy: () => reviewing, blame,
};
}