open-standards/wai/quantum-studio/src/course.js
Transaction Science 2bb4d4ae86 Quantum Studio: #course opens the circuits course; llms.txt lists the site's pages
- Quantum Studio: `#course` in the page's address opens the Course pane, and
  `#course=<lesson>` opens it at that lesson; it goes with `#qasm=…`. A test
  opens `/#course=bell` and finds the pane open at the Bell lesson, with no
  console error. Both READMEs say so.
- The generated llms.txt (and llms-full.txt) list the quantum IDE, the
  interference course and WAI's home beside the console and Quantum Studio,
  and say the Studio's course opens from `#course`. The site's sync no longer
  needs to add them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 16:38:31 -04:00

281 lines
16 KiB
JavaScript

// The circuits course in the Studio: the lessons, each with a short text and
// an exercise the engine checks (engine::course, the same course and the same
// check as `wai-quantum learn`). Check runs the program in the editor against
// the exercise and gives the engine's verdict for each case and each rule;
// What went wrong? says, in the reader's language, what the reader's
// diagnostics, the verdict and the critique found; Hint gives the lesson's
// hints one at a time with the engine's pointer toward where to look. The
// assistant, when a model is connected, is asked for one hint and told not to
// write the program; any program in its answer is held back. None of these
// shows the solution.
//
// Progress (which lessons passed, how many checks and hints) is kept only in
// this browser, and says so.
import { el } from './dom.js';
import { t, locale, machineAssisted } from './i18n.js';
import { markdown, inline } from './md.js';
import { levelBadge } from './verdict-view.js';
const ITEM = 'wai.quantum-studio.course';
const OPEN = 'wai.quantum-studio.course-open';
const SCHEMA = 'wai.quantum.learn-progress/1';
// The reader's progress, kept in this browser alone.
export function progressStore() {
const fresh = () => ({ schema: SCHEMA, kept: 'only in this browser; nothing is sent anywhere', lessons: {}, current: null });
let p = fresh();
try {
const s = JSON.parse(localStorage.getItem(ITEM) || 'null');
if (s && s.schema === SCHEMA && s.lessons && typeof s.lessons === 'object') p = { ...fresh(), ...s };
} catch (e) {}
const save = () => { try { localStorage.setItem(ITEM, JSON.stringify(p)); } catch (e) {} };
return {
get: () => p,
lesson(id) { p.lessons[id] ??= { passed: false, checks: 0, hints: 0 }; return p.lessons[id]; },
passed: () => Object.values(p.lessons).filter((l) => l.passed).length,
save,
reset() { p = fresh(); save(); },
};
}
// Code blocks out of a model's answer: a hint in words only.
const prose = (text) => String(text || '').replace(/```[\s\S]*?(```|$)/g, '').trim();
export function coursePane({ engine, program, load, ask, status }) {
const store = progressStore();
let course = null;
let lesson = null;
let last = null; // {qasm, check}
const loc = locale();
const button = el('button', { class: 'qs-btn', id: 'qs-course-open', type: 'button', 'aria-expanded': 'false', 'aria-controls': 'qs-course', text: t('course.open'), title: t('course.open_title') });
const list = el('nav', { class: 'qs-course-list', id: 'qs-course-list', 'aria-label': t('course.lessons') });
const title = el('h3', { class: 'qs-course-title', id: 'qs-course-title' });
const where = el('p', { class: 'qs-note', id: 'qs-course-where' });
const text = el('div', { class: 'qs-course-text', id: 'qs-course-text' });
const task = el('p', { class: 'qs-course-task', id: 'qs-course-task' });
const must = el('ul', { class: 'qs-course-must', id: 'qs-course-must' });
const passedBadge = el('span', { class: 'qs-badge', id: 'qs-course-passed', text: t('course.not_checked') });
const statement = el('span', { class: 'qs-why', id: 'qs-course-statement' });
const cases = el('ul', { class: 'qs-course-cases', id: 'qs-course-cases' });
const rules = el('ul', { class: 'qs-course-rules', id: 'qs-course-rules' });
const estimate = el('p', { class: 'qs-note', id: 'qs-course-estimate', hidden: true });
const wrong = el('div', { class: 'qs-course-wrong', id: 'qs-course-wrong', hidden: true, role: 'region', 'aria-label': t('course.why') });
const hints = el('ol', { class: 'qs-course-hints', id: 'qs-course-hints' });
const pointer = el('p', { class: 'qs-note', id: 'qs-course-pointer', hidden: true });
const asked = el('div', { class: 'qs-course-asked', id: 'qs-course-asked', hidden: true });
const starterBtn = el('button', { class: 'qs-btn qs-small', id: 'qs-course-starter', type: 'button', text: t('course.starter'), title: t('course.starter_title') });
const checkBtn = el('button', { class: 'qs-btn qs-small qs-primary', id: 'qs-course-check', type: 'button', text: t('course.check'), title: t('course.check_title') });
const hintBtn = el('button', { class: 'qs-btn qs-small', id: 'qs-course-hint', type: 'button', text: t('course.hint') });
const whyBtn = el('button', { class: 'qs-btn qs-small', id: 'qs-course-why', type: 'button', text: t('course.why') });
const askBtn = el('button', { class: 'qs-btn qs-small', id: 'qs-course-ask', type: 'button', text: t('course.ask'), title: t('course.ask_title') });
const prev = el('button', { class: 'qs-btn qs-small', id: 'qs-course-prev', type: 'button', text: t('course.previous') });
const next = el('button', { class: 'qs-btn qs-small', id: 'qs-course-next', type: 'button', text: t('course.next') });
const progress = el('span', { class: 'qs-note', id: 'qs-course-progress' });
const reset = el('button', { class: 'qs-btn qs-small', id: 'qs-course-reset', type: 'button', text: t('course.reset') });
const companion = el('p', { class: 'qs-note', id: 'qs-course-companion' });
const translation = machineAssisted() ? el('p', { class: 'qs-note qs-course-translation', id: 'qs-course-translation', text: t('course.machine_assisted') }) : null;
const body = el('article', { class: 'qs-course-lesson', id: 'qs-course-lesson', 'aria-labelledby': 'qs-course-title' },
where, title, translation, text,
el('div', { class: 'qs-course-exercise' },
el('div', { class: 'qs-k', text: t('course.exercise') }), task, must,
el('div', { class: 'qs-actions' }, starterBtn, checkBtn, hintBtn, whyBtn, askBtn),
el('div', { class: 'qs-course-result', id: 'qs-course-result', 'aria-live': 'polite' }, el('p', {}, passedBadge, ' ', statement), cases, rules, estimate),
hints, pointer, wrong, asked),
el('div', { class: 'qs-actions qs-course-nav' }, prev, next));
const node = el('section', { class: 'qs-pane qs-course', id: 'qs-course', hidden: true, 'aria-labelledby': 'qs-course-h' },
el('div', { class: 'qs-head' }, el('h2', { id: 'qs-course-h', text: t('course.title') }), el('span', { class: 'qs-grow' }), progress, reset),
el('div', { class: 'qs-course-body' }, list, body),
companion);
const say = (m, cls) => status?.(m, cls);
const showProgress = () => {
progress.textContent = t('course.progress', { passed: store.passed(), of: course?.lessons ?? '?' }) + ' · ' + t('course.kept');
for (const b of list.querySelectorAll('[data-lesson]')) {
const done = !!store.get().lessons[b.dataset.lesson]?.passed;
b.classList.toggle('done', done);
b.querySelector('.qs-course-mark').textContent = done ? '✓' : '·';
b.setAttribute('aria-current', String(b.dataset.lesson === lesson?.id));
}
};
function renderList() {
list.replaceChildren(...course.units.map((u) => el('div', { class: 'qs-course-unit' },
el('div', { class: 'qs-k', text: u.title }),
el('ol', { start: String(u.lessons[0]?.number ?? 1) }, ...u.lessons.map((l) => el('li', {},
el('button', { class: 'qs-course-item', type: 'button', 'data-lesson': l.id, onclick: () => show(l.id) },
el('span', { class: 'qs-course-mark', 'aria-hidden': 'true', text: '·' }), ' ', l.title)))))));
const k = course.companions?.[0];
companion.replaceChildren(...(k ? [t('course.companion') + ' ', el('a', { href: k.url, target: '_blank', rel: 'noopener', text: k.title }), ` · ${k.summary}`] : []));
}
function clearResult() {
last = null;
passedBadge.textContent = t('course.not_checked'); passedBadge.className = 'qs-badge';
statement.textContent = '';
cases.replaceChildren(); rules.replaceChildren();
estimate.hidden = true; wrong.hidden = true; wrong.replaceChildren();
pointer.hidden = true; asked.hidden = true; asked.replaceChildren();
}
async function show(id) {
try {
lesson = await engine.courseLesson(id, loc);
} catch (e) { say(e.message, 'bad'); return; }
store.get().current = id; store.save();
where.textContent = t('course.where', { number: lesson.number, of: lesson.of, unit: lesson.unit_title });
title.textContent = lesson.title;
text.replaceChildren(...markdown(lesson.text));
task.replaceChildren(...inline(lesson.task));
must.replaceChildren(...lesson.requirements.map((r) => el('li', {}, ...inline(r))));
prev.disabled = !lesson.previous; next.disabled = !lesson.next;
clearResult();
// The hints already read stay read.
const seen = Math.min(store.lesson(id).hints || 0, lesson.hints.length);
hints.replaceChildren(...lesson.hints.slice(0, seen).map((h, i) => el('li', {}, el('span', { class: 'qs-k', text: t('course.hint_of', { level: i + 1, of: lesson.hints.length }) }), ' ', ...inline(h))));
hintBtn.textContent = seen >= lesson.hints.length ? t('course.no_more_hints') : t('course.hint');
showProgress();
}
async function check() {
if (!lesson) return null;
const qasm = program();
checkBtn.disabled = true;
try {
const c = await engine.courseCheck(lesson.id, qasm, loc);
last = { qasm, check: c };
const rec = store.lesson(lesson.id);
rec.checks += 1;
if (c.passed && !rec.passed) { rec.passed = true; rec.passed_at = Date.now(); }
store.save();
passedBadge.textContent = c.passed ? t('course.passed') : t('course.not_yet');
passedBadge.className = 'qs-badge ' + (c.passed ? 'ok' : 'bad');
statement.textContent = c.statement;
cases.replaceChildren(...c.cases.map((k) => el('li', { class: k.passed ? 'ok' : 'bad' },
k.name ? el('span', { class: 'qs-course-case', text: `${k.name}: ` }) : null, levelBadge(k.verdict), el('span', { class: 'qs-why', text: ` ${k.statement ?? ''}` }))));
rules.replaceChildren(...c.constraints.map((r) => el('li', { class: r.holds ? 'ok' : 'bad', 'data-rule': r.rule }, `${r.holds ? '✓' : '✗'} `, ...inline(r.text))));
estimate.hidden = !c.estimate?.figures;
estimate.textContent = c.estimate?.figures ?? '';
if (!wrong.hidden) showWrong(c);
pointer.hidden = true;
showProgress();
say(c.passed ? t('course.passed_status', { title: lesson.title }) : t('course.not_yet_status'), c.passed ? 'ok' : 'bad');
return c;
} catch (e) { say(e.message, 'bad'); return null; } finally { checkBtn.disabled = false; }
}
// The last check, if it was of the program as it is now; else a new one.
const current = async () => (last && last.qasm === program() ? last.check : check());
function showWrong(c) {
wrong.hidden = false;
const items = c.what_went_wrong.map((w) => el('li', { class: `qs-course-w-${w.kind}` }, ...inline(w.text),
w.engine ? el('span', { class: 'qs-course-engine' }, el('span', { class: 'qs-k', text: ` ${t('course.engine_says')} ` }), w.engine) : null));
wrong.replaceChildren(el('div', { class: 'qs-k', text: t('course.why') }),
items.length ? el('ul', {}, ...items) : el('p', { class: 'qs-note', text: t('course.nothing_wrong') }),
c.pointer && !c.passed ? el('p', { class: 'qs-note' }, el('span', { class: 'qs-k', text: t('course.where_to_look') + ' ' }), c.pointer) : null);
}
async function why() {
const c = await current();
if (c) showWrong(c);
}
async function hint() {
if (!lesson) return;
const rec = store.lesson(lesson.id);
const level = Math.min((rec.hints || 0) + 1, lesson.hints.length);
try {
const h = await engine.courseHint(lesson.id, program(), level, loc);
if (h.level > (rec.hints || 0)) {
rec.hints = h.level; store.save();
hints.append(el('li', {}, el('span', { class: 'qs-k', text: h.label }), ' ', ...inline(h.hint)));
}
pointer.hidden = !h.pointer || h.passed;
pointer.textContent = h.pointer ? `${t('course.where_to_look')} ${h.pointer}` : '';
hintBtn.textContent = h.more ? t('course.hint') : t('course.no_more_hints');
} catch (e) { say(e.message, 'bad'); }
}
async function askModel() {
if (!lesson) return;
const c = await current();
if (!c) return;
asked.hidden = false;
if (!ask.available()) { asked.replaceChildren(el('p', { class: 'qs-note', text: t('course.ask_needs_model') })); return; }
asked.replaceChildren(el('p', { class: 'qs-note', text: t('course.asking') }));
const findings = c.what_went_wrong.map((w) => `- ${w.text}${w.engine ? ` (${w.engine})` : ''}`).join('\n');
const prompt = [
`I am doing the exercise "${lesson.title}": ${lesson.task}`,
`It must: ${lesson.requirements.join('; ')}.`,
`The engine checked my program: ${c.statement}`,
findings ? `What went wrong, as the engine found it:\n${findings}` : '',
'Give me one hint toward the next step, in a sentence or two. Do not write the program, any line of it, or the answer; point me at what to think about.',
loc === 'zh-CN' ? 'Answer in Simplified Chinese.' : '',
].filter(Boolean).join('\n\n');
try {
const a = await ask.hint(prompt);
// A program in the answer that passes the exercise would be the solution: it is held back.
let held = false;
for (const p of a.proposals || []) {
const r = await engine.courseCheck(lesson.id, p.qasm, loc).catch(() => null);
if (r?.passed) held = true;
}
const words = prose(a.text);
asked.replaceChildren(el('div', { class: 'qs-k', text: t('course.assistant_says') }),
words ? el('p', {}, ...inline(words)) : null,
held || words !== String(a.text || '').trim() ? el('p', { class: 'qs-note', id: 'qs-course-held', text: held ? t('course.held_solution') : t('course.held_code') }) : null);
} catch (e) { asked.replaceChildren(el('p', { class: 'qs-note', text: t('course.ask_failed', { error: e.message }) })); }
}
starterBtn.addEventListener('click', async () => {
if (!lesson) return;
if (await load(lesson.starter, lesson)) { clearResult(); say(t('course.starter_loaded', { title: lesson.title }), 'ok'); }
});
checkBtn.addEventListener('click', check);
hintBtn.addEventListener('click', hint);
whyBtn.addEventListener('click', why);
askBtn.addEventListener('click', askModel);
prev.addEventListener('click', () => lesson?.previous && show(lesson.previous));
next.addEventListener('click', () => lesson?.next && show(lesson.next));
// Two clicks, so that one slip does not clear it.
let armed = null;
reset.addEventListener('click', () => {
if (!armed) {
reset.textContent = t('course.reset_confirm');
armed = setTimeout(() => { armed = null; reset.textContent = t('course.reset'); }, 5000);
return;
}
clearTimeout(armed); armed = null;
reset.textContent = t('course.reset');
store.reset();
if (lesson) show(lesson.id);
say(t('course.reset_done'), 'ok');
});
async function open(on = node.hidden) {
node.hidden = !on;
button.setAttribute('aria-expanded', String(on));
try { localStorage.setItem(OPEN, on ? '1' : '0'); } catch (e) {}
if (!on || course) return;
try {
course = await engine.course(loc);
renderList();
const p = store.get();
const first = course.units.flatMap((u) => u.lessons).find((l) => !p.lessons[l.id]?.passed) ?? course.units[0].lessons[0];
await show(p.current && course.units.some((u) => u.lessons.some((l) => l.id === p.current)) ? p.current : first.id);
} catch (e) { say(e.message, 'bad'); }
}
button.addEventListener('click', () => open());
// `#course` in the page's address opens the pane (a link from the course page), and
// `#course=<lesson>` opens it at that lesson; otherwise it reopens as it was left.
const linked = new URLSearchParams(location.hash.slice(1));
let reopen = linked.has('course');
if (!reopen) try { reopen = localStorage.getItem(OPEN) === '1'; } catch (e) {}
if (reopen) open(true).then(() => {
const id = linked.get('course');
if (id && course?.units.some((u) => u.lessons.some((l) => l.id === id))) return show(id);
});
return { node, button, open, show, check, store, current: () => lesson };
}