Import the exercise builder

The drag-and-drop workspace for building BI122G exercise sheets, served
by the Workshop at /projects/exercise-builder. Plain HTML, CSS and ES
modules; no build step. It runs entirely in the browser — work in
progress lives in localStorage and finished sheets leave as downloads,
so nothing is written on the server.

This directory is canonical. It began as a deployment target for
deploy_builder.sh syncing from the BI122G course folder, and that
arrangement has ended; see the README.

Includes the sheet's light/dark switch: the stylesheet already carried
both themes and followed the reader's system setting, so this adds the
control that overrides it — a corner toggle built by sheet.js, applied
before first paint by a head script the exporter writes, and hidden in
print. The builder's toolbar previews the same attribute on the canvas.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ed5kU3HfZq9dfj7EYj5J2v
This commit is contained in:
ryan
2026-08-11 19:59:59 +02:00
commit c18510b3c3
11 changed files with 3241 additions and 0 deletions
+85
View File
@@ -0,0 +1,85 @@
// The light/dark switch. The stylesheet already carries both themes and
// already follows the reader's system setting on its own — all this adds is
// a way to override that, by setting the data-theme attribute the CSS is
// written to look for. Progressive enhancement, like the copy behaviour
// below: without it the sheet still tracks the system setting, and the
// control is not printed.
//
// No attribute is set unless the reader asks for one, so "follow my system"
// stays the state a sheet arrives in.
(function () {
var KEY = 'bi122g-sheet-theme';
var root = document.documentElement;
// A sheet is often read from a file:// URL or a locked-down browser, where
// storage throws rather than returning null. Losing the preference is fine;
// losing the switch is not.
function stored() { try { return localStorage.getItem(KEY); } catch (e) { return null; } }
function remember(v) { try { localStorage.setItem(KEY, v); } catch (e) {} }
var system = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)');
var saved = stored();
var choice = saved === 'dark' || saved === 'light' ? saved : null;
// Normally already set by the head script the exporter writes (see
// document.js) — repeated here so the switch also works in any page that
// includes this file on its own.
if (choice) root.setAttribute('data-theme', choice);
function current() { return choice || (system && system.matches ? 'dark' : 'light'); }
var btn = document.createElement('button');
btn.className = 'themetoggle';
btn.type = 'button';
function label() {
var next = current() === 'dark' ? 'light' : 'dark';
btn.textContent = current() === 'dark' ? '☀' : '☾';
btn.setAttribute('title', 'Switch to ' + next + ' mode');
btn.setAttribute('aria-label', 'Switch to ' + next + ' mode');
}
btn.addEventListener('click', function () {
choice = current() === 'dark' ? 'light' : 'dark';
root.setAttribute('data-theme', choice);
remember(choice);
label();
});
// still following the system: keep the glyph honest if it changes mid-read
if (system && system.addEventListener) {
system.addEventListener('change', function () { if (!choice) label(); });
}
label();
document.body.appendChild(btn);
})();
// Click anywhere on a command box to copy it. Progressive enhancement only:
// the page is complete without this, and nothing of it appears in print.
document.querySelectorAll('.cmd').forEach(function (block) {
var payload = block.textContent.trim();
block.setAttribute('role', 'button');
block.setAttribute('tabindex', '0');
block.setAttribute('aria-label', 'Copy command: ' + payload);
function copy() {
navigator.clipboard.writeText(payload).then(function () {
block.classList.add('copied');
setTimeout(function () { block.classList.remove('copied'); }, 1400);
}).catch(function () {
// clipboard blocked (insecure context, or permission denied) — select
// the text instead so the reader can copy it by hand
var range = document.createRange();
range.selectNodeContents(block);
var sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
});
}
block.addEventListener('click', copy);
block.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); copy(); }
});
});