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
+59
View File
@@ -0,0 +1,59 @@
# Exercise Builder — hosted copy
The drag-and-drop workspace for building BI122G exercise sheets, served by the
Workshop at:
/projects/exercise-builder landing page
/projects/exercise-builder/app the builder itself
Open to everyone; no sign-in.
## What is here
app/ the builder — plain HTML, CSS, and ES modules. No build step.
`app/` is the whole application. It runs entirely in the browser: work in
progress lives in the visitor's `localStorage`, and finished sheets and project
files leave as downloads. **Nothing is written on the server**, and there is no
endpoint that could write anything. That is what makes it safe to host without a
login.
Note this differs from the local development setup in the course folder, where
`edit_server.py` accepts `POST /__save` and writes sheets straight to disk. That
server is not deployed here, and must not be — reachable without a login it
would be a write primitive for anyone who could load the page.
## How it is served
`workshop/src/routes/projects/exercise-builder/app/[...file]/+server.ts` reads
this directory at request time. Consequences worth knowing:
- **Updating the builder does not need a site rebuild.** Sync new files here and
a reload picks them up (responses are `no-cache` with an mtime-based ETag).
A rebuild is only needed when the landing page or the route itself changes.
- Only recognised asset extensions are reachable, and paths are contained to
this directory — so nothing outside `app/` can be requested.
- `.js` is served as `text/javascript`, without which browsers refuse ES
modules outright.
- The route injects `<base href="/projects/exercise-builder/app/">` into the
served HTML, so the builder's relative URLs resolve whether or not the visitor
typed a trailing slash.
Override the location with `EXERCISE_BUILDER_ROOT` in the workshop `.env` if it
ever moves.
## Updating
Edit the files here. There is no build step and no sync — a reload picks the
change up, for the reasons above.
## Source of truth
**This directory.** The builder was developed in the BI122G course folder on
the workstation (`/Muspelheim/Employment/HiS/Teaching/BI122G`) and pushed here
by `deploy_builder.sh`. That arrangement has ended and this installation is now
canonical: **do not run `deploy_builder.sh` again**, as it would overwrite this
directory with the older course-folder copy.
The course folder still holds `test.mjs`, a Node test harness that was never
deployed here — it needs `linkedom` and is no use in a browser.
+1350
View File
File diff suppressed because it is too large Load Diff
+247
View File
@@ -0,0 +1,247 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BI122G · Exercise builder</title>
<style>
/* ---------------------------------------------------------------
Builder chrome. Deliberately a different visual register from the
sheet itself — the canvas is the only place the sheet's own design
appears, so there is never any doubt about what is document and
what is tooling. The chrome has one look on purpose: the sheet's
own light/dark switch lives on the sheet, and a dark surround is
what keeps document and tooling apart in either of them.
--------------------------------------------------------------- */
:root{
--bg: #14181C;
--panel: #1C2228;
--panel-2: #232B32;
--line: #313B43;
--line-firm: #465360;
--ink: #E8EDF2;
--ink-soft: #A3AEB8;
--ink-faint: #737F8A;
--blue: #6FBCE8;
--green: #5DBE84;
--ruby: #E87AB0;
--amber: #E0A857;
color-scheme: dark; /* checkboxes, scrollbars, the select popup */
--sans: "Segoe UI", system-ui, -apple-system, "Helvetica Neue", Arial, sans-serif;
--mono: "Cascadia Mono", ui-monospace, "SF Mono", Menlo, "DejaVu Sans Mono", Consolas, monospace;
}
*{ box-sizing: border-box; }
html, body{ height: 100%; }
body{
margin: 0;
font-family: var(--sans);
font-size: 13.5px;
background: var(--bg);
color: var(--ink);
display: grid;
grid-template-rows: auto 1fr;
overflow: hidden;
}
/* ---------- toolbar ---------- */
#bar{
display: flex; align-items: center; gap: .5rem;
padding: .5rem .8rem;
background: var(--panel);
border-bottom: 1px solid var(--line);
}
#bar .name{
font-weight: 700; letter-spacing: .02em; margin-right: .6rem;
color: var(--blue);
}
#bar .sep{ width: 1px; height: 1.4rem; background: var(--line); margin: 0 .25rem; }
/* Only shown when the builder is served from the Workshop; see app.js boot. */
#bar .homelink{
color: var(--ink-faint); text-decoration: none;
font-size: .74rem; margin-right: .4rem;
}
#bar .homelink:hover{ color: var(--blue); }
#bar .homelink[hidden]{ display: none; }
#bar .grow{ flex: 1; }
#bar .status{ font-size: .74rem; color: var(--ink-faint); letter-spacing: .06em; text-transform: uppercase; }
#bar .status.dirty{ color: var(--amber); }
#bar .status.good{ color: var(--green); }
button{
font-family: inherit; font-size: .78rem; font-weight: 600;
padding: .38rem .7rem; border-radius: 4px;
border: 1px solid var(--line-firm);
background: var(--panel-2); color: var(--ink); cursor: pointer;
}
button:hover{ border-color: var(--blue); color: var(--blue); }
button:focus-visible{ outline: 2px solid var(--blue); outline-offset: 2px; }
button.primary{ background: var(--blue); border-color: var(--blue); color: #0B1116; }
button.primary:hover{ opacity: .9; color: #0B1116; }
button.danger:hover{ border-color: var(--ruby); color: var(--ruby); }
button[disabled]{ opacity: .4; cursor: not-allowed; }
/* ---------- three-pane layout ---------- */
#work{
display: grid;
grid-template-columns: 13rem 1fr 20rem;
min-height: 0;
}
#palette, #inspector{
background: var(--panel);
overflow-y: auto;
padding: .8rem;
}
#palette{ border-right: 1px solid var(--line); }
#inspector{ border-left: 1px solid var(--line); }
#canvasWrap{ position: relative; min-width: 0; background: #0B0F12; }
#canvas{ width: 100%; height: 100%; border: 0; display: block; }
h3.group{
font-size: .64rem; letter-spacing: .14em; text-transform: uppercase;
color: var(--ink-faint); margin: 1rem 0 .4rem; font-weight: 700;
}
h3.group:first-child{ margin-top: 0; }
.chip{
display: block; width: 100%; text-align: left;
margin-bottom: .3rem; cursor: grab;
border: 1px solid var(--line); background: var(--panel-2);
padding: .4rem .55rem; border-radius: 4px;
}
.chip:active{ cursor: grabbing; }
.chip{ user-select: none; -webkit-user-select: none; }
.chip b{ display: block; font-size: .78rem; font-weight: 600; }
.chip span{ display: block; font-size: .68rem; color: var(--ink-faint); }
/* ---------- inspector ---------- */
.kindline{
display: flex; align-items: center; gap: .5rem;
padding-bottom: .5rem; margin-bottom: .7rem;
border-bottom: 1px solid var(--line);
}
.kindline b{ font-size: .82rem; }
.kindline .pill{
font-size: .6rem; letter-spacing: .1em; text-transform: uppercase;
padding: .12rem .4rem; border-radius: 3px;
background: var(--panel-2); color: var(--ink-faint); border: 1px solid var(--line);
}
label.f{ display: block; margin-bottom: .7rem; }
label.f > i{
display: block; font-style: normal;
font-size: .64rem; letter-spacing: .11em; text-transform: uppercase;
color: var(--ink-faint); margin-bottom: .22rem;
}
input[type=text], textarea, select{
width: 100%; font-family: var(--sans); font-size: .8rem;
background: var(--bg); color: var(--ink);
border: 1px solid var(--line-firm); border-radius: 4px;
padding: .35rem .45rem;
}
textarea{ font-family: var(--mono); font-size: .76rem; line-height: 1.55; resize: vertical; }
input:focus, textarea:focus, select:focus{ outline: 2px solid var(--blue); outline-offset: -1px; }
label.cb{ display: flex; align-items: center; gap: .4rem; font-size: .78rem; margin-bottom: .7rem; }
.rowbtns{ display: flex; flex-wrap: wrap; gap: .3rem; margin-top: .9rem; }
.hintbox{
font-size: .7rem; color: var(--ink-faint); line-height: 1.5;
border-left: 2px solid var(--line-firm); padding-left: .5rem; margin: .6rem 0;
}
.warn{ color: var(--amber); border-left-color: var(--amber); }
.empty{ color: var(--ink-faint); font-size: .78rem; line-height: 1.6; }
code{ font-family: var(--mono); font-size: .92em; color: var(--blue); }
/* ---------- full-window editors (tables) ---------- */
#overlay{
position: fixed; inset: 0; z-index: 100;
background: rgba(8,11,14,.78);
display: grid; place-items: center; padding: 2rem;
}
#overlay[hidden]{ display: none; }
#overlay .box{
background: var(--panel); border: 1px solid var(--line-firm);
border-radius: 6px; width: min(70rem, 100%); max-height: 100%;
display: grid; grid-template-rows: auto 1fr auto; overflow: hidden;
}
#overlay header{
display: flex; align-items: center; gap: .6rem;
padding: .7rem .9rem; border-bottom: 1px solid var(--line);
font-weight: 700;
}
#overlay header .grow{ flex: 1; }
#overlay .body{ padding: .9rem; overflow: auto; }
#overlay footer{
padding: .7rem .9rem; border-top: 1px solid var(--line);
display: flex; gap: .5rem; align-items: center;
}
#overlay footer .grow{ flex: 1; }
table.grid{ border-collapse: separate; border-spacing: 4px; }
table.grid td{ padding: 0; vertical-align: top; }
table.grid textarea{
min-width: 11rem; min-height: 2.6rem; resize: both;
font-family: var(--sans); font-size: .78rem;
}
table.grid th{ padding: 0; }
table.grid .hdr textarea{ font-weight: 700; }
table.grid .rowbtn, table.grid .colbtn{
padding: .15rem .35rem; font-size: .68rem; line-height: 1;
}
table.grid .axis{ white-space: nowrap; }
#drop{
position: fixed; z-index: 50; pointer-events: none;
height: 3px; background: var(--green); border-radius: 2px;
box-shadow: 0 0 8px var(--green); display: none;
}
#ghost{
position: fixed; z-index: 51; pointer-events: none; display: none;
font-size: .72rem; font-weight: 600; padding: .25rem .5rem;
background: var(--green); color: #0B1116; border-radius: 3px;
}
</style>
</head>
<body>
<div id="bar">
<span class="name">Exercise builder</span>
<a class="homelink" id="bHome" href="/projects/exercise-builder" hidden>← Workshop</a>
<button id="bNew">New</button>
<button id="bImport">Import sheet…</button>
<button id="bOpen">Open project…</button>
<span class="sep"></span>
<button id="bProject">Save project</button>
<button id="bExport" class="primary">Export sheet</button>
<span class="sep"></span>
<button id="bUndo" title="Ctrl+Z">Undo</button>
<button id="bDoc">Document…</button>
<span class="grow"></span>
<button id="bTheme" title="Preview the sheet in dark mode">Sheet: ☀ light</button>
<span class="sep"></span>
<span class="status" id="status">ready</span>
</div>
<div id="work">
<div id="palette"></div>
<div id="canvasWrap"><iframe id="canvas" title="Sheet preview"></iframe></div>
<div id="inspector"></div>
</div>
<div id="drop"></div>
<div id="ghost"></div>
<div id="overlay" hidden>
<div class="box">
<header><span id="ovTitle">Editor</span><span class="grow"></span>
<button id="ovCancel">Cancel</button>
<button id="ovDone" class="primary">Done</button>
</header>
<div class="body" id="ovBody"></div>
<footer id="ovFoot"></footer>
</div>
</div>
<input type="file" id="filePicker" accept=".html,.json" hidden>
<script type="module" src="./app.js"></script>
</body>
</html>
+88
View File
@@ -0,0 +1,88 @@
/* =====================================================================
Assembling and reading whole sheet files.
The preview iframe and the exported file are built from the same
renderer, so what you arrange on the canvas is what lands on disk.
The only difference is that the preview carries editing chrome and
omits the click-to-copy script, because on the canvas a click selects.
===================================================================== */
import { renderBody } from './render.js';
let cache = null;
export async function assets() {
if (!cache) {
const [css, js, logo] = await Promise.all([
fetch('./sheet.css').then((r) => r.text()),
fetch('./sheet.js').then((r) => r.text()),
fetch('./logo.txt').then((r) => r.text()).catch(() => ''),
]);
cache = { css, js, logo: logo.trim() };
}
return cache;
}
const esc = (s) => String(s).replace(/[&<>]/g,
(c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));
function title(doc) {
const code = (doc.meta.courseCode || '').trim() || 'Exercise';
return `${code} · ${doc.meta.exercise} · ${doc.meta.title}`;
}
/** A complete, self-contained sheet — what Export writes. */
export function wholeFile(doc, { css, js }) {
return [
'<!DOCTYPE html>',
'<html lang="en">',
'<head>',
'<meta charset="utf-8">',
'<meta name="viewport" content="width=device-width, initial-scale=1">',
`<title>${esc(title(doc))}</title>`,
'',
// A reader who overrode their system setting has to have it applied
// before the first paint, or every load of a dark sheet starts with a
// white flash. That rules out sheet.js, which runs at the end of the
// body — so the read happens here and the switch itself is still built
// down there. The key is shared with sheet.js; keep the two in step.
'<script>',
'try{var t=localStorage.getItem("bi122g-sheet-theme");',
'if(t==="dark"||t==="light")document.documentElement.setAttribute("data-theme",t)}catch(e){}',
'<\/script>',
'',
'<style>',
css.trim(),
'</style>',
'</head>',
'<body>',
renderBody(doc),
'',
'<script>',
js.trim(),
'<\/script>',
'</body>',
'</html>',
'',
].join('\n');
}
/** An empty shell for the canvas; the body is filled on every update. */
export function previewShell({ css }, chromeCSS) {
return [
'<!DOCTYPE html>',
'<html lang="en">',
'<head><meta charset="utf-8">',
'<style>', css.trim(), chromeCSS, '</style>',
'</head><body></body></html>',
].join('\n');
}
export function download(name, text, type = 'text/html;charset=utf-8') {
const url = URL.createObjectURL(new Blob([text], { type }));
const a = Object.assign(document.createElement('a'), { href: url, download: name });
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 2000);
}
+232
View File
@@ -0,0 +1,232 @@
/* =====================================================================
Importer — an existing exercise sheet → the document model.
Lets you start from a finished sheet rather than a blank page, and
doubles as the proof that the model covers everything the sheet uses:
import → render → import again must reach a fixed point.
Takes a DOM. In the browser that is DOMParser; the test harness feeds
it an equivalent.
===================================================================== */
import { uid } from './model.js';
import { serialise } from './inline.js';
const text = (el) => serialise(el.innerHTML);
/** "BI122G · Long course name · Autumn 2026" back into its three fields. */
function splitEyebrow(line) {
const bits = String(line).split('·').map((s) => s.trim()).filter(Boolean);
if (bits.length >= 3) {
return { courseCode: bits[0], courseName: bits.slice(1, -1).join(' · '), term: bits[bits.length - 1] };
}
return { courseCode: bits[0] || '', courseName: bits[1] || '', term: '' };
}
function slug(s, used) {
let base = (s || 'part').toLowerCase().replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '').split('-').slice(0, 3).join('-') || 'part';
let out = base, n = 2;
while (used.has(out)) out = `${base}-${n++}`;
used.add(out);
return out;
}
/* ---------- blocks ---------- */
function readBlock(el) {
const cls = el.classList;
if (el.tagName === 'P' && !cls.contains('said') && !cls.contains('standfirst')) {
const variant = cls.contains('note') ? 'note' : null;
return { id: uid('p'), kind: 'prose', text: text(el), variant };
}
if (cls.contains('cmd')) {
return { id: uid('c'), kind: 'cmd', command: el.textContent.trim() };
}
if (cls.contains('term')) return readTerminal(el);
if (cls.contains('protip')) return readProtip(el);
if (cls.contains('tablewrap')) return readTable(el);
if (cls.contains('note')) {
return { id: uid('n'), kind: 'note', children: readChildren(el) };
}
if (cls.contains('callout')) {
const h = el.querySelector('h3');
return {
id: uid('k'), kind: 'callout',
heading: h ? text(h) : '',
children: readChildren(el, (c) => c !== h),
};
}
if (cls.contains('side-quest')) {
const sum = el.querySelector('summary');
return {
id: uid('s'), kind: 'sidequest',
summary: sum ? text(sum) : '',
children: readChildren(el, (c) => c !== sum),
};
}
if (el.tagName === 'UL' && cls.contains('plain')) {
return {
id: uid('u'), kind: 'list',
items: [...el.querySelectorAll('li')].map(text),
};
}
// a task list flattens into its steps: the <ol> is regenerated on render
if (el.tagName === 'OL' && cls.contains('tasks')) {
return [...el.querySelectorAll(':scope > li')].map((li) => ({
id: uid('i'), kind: 'task', children: readChildren(li),
}));
}
return null; // decoration, skip
}
function readChildren(el, keep = () => true) {
return [...el.children]
.filter(keep)
.flatMap((c) => readBlock(c) || []) // a task list yields several nodes
.filter(Boolean);
}
function readTerminal(el) {
const capEl = el.querySelector('.cap');
const caption = capEl ? capEl.textContent.trim() : '';
// reading must never alter the document it reads, so the caption is
// stripped from a copy of the markup rather than removed from the DOM
const inner = el.innerHTML.replace(/<span class="cap">[\s\S]*?<\/span>/, '');
// split into lines, then each line into typed segments plus the plain
// text between them — transcripts mix roles freely on a single line
const lines = inner.split('\n').map((raw) => {
const segments = [];
const re = /<span class="([pceo])">(.*?)<\/span>/gs;
let at = 0, m;
while ((m = re.exec(raw)) !== null) {
if (m.index > at) {
const between = decode(raw.slice(at, m.index));
if (between) segments.push({ role: null, text: between });
}
segments.push({ role: m[1], text: decode(m[2]) });
at = m.index + m[0].length;
}
if (at < raw.length) {
const tail = decode(raw.slice(at));
if (tail) segments.push({ role: null, text: tail });
}
return segments;
}).filter((segs) => segs.length);
return { id: uid('m'), kind: 'terminal', caption, lines };
}
function decode(s) {
return s.replace(/<[^>]+>/g, '')
.replace(/&(amp|lt|gt|quot|#39);/g,
(m) => ({ '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"', '&#39;': "'" }[m]));
}
function readProtip(el) {
const said = el.querySelector('.said');
const b = said && said.querySelector('b');
const label = b ? b.textContent.trim() : 'Protip';
const body = said ? said.innerHTML.replace(/^\s*<b>[\s\S]*?<\/b>\s*/, '') : '';
return {
id: uid('r'), kind: 'protip',
keys: [...el.querySelectorAll('.keys kbd')].map((k) => k.textContent.trim()),
label,
text: serialise(body),
emphasis: el.classList.contains('headline'),
};
}
function readTable(el) {
const head = [...el.querySelectorAll('thead th')].map(text);
const rows = [...el.querySelectorAll('tbody tr')]
.map((tr) => [...tr.children].map(text));
return {
id: uid('b'), kind: 'table',
columns: head.length ? head : new Array(rows[0] ? rows[0].length : 2).fill(''),
rows,
};
}
/* ---------- whole document ---------- */
export function importSheet(doc) {
const sheet = doc.querySelector('.sheet');
if (!sheet) throw new Error('no .sheet in that file');
const grab = (sel, fn, fallback = '') => {
const el = sheet.querySelector(sel);
return el ? fn(el) : fallback;
};
const h1 = sheet.querySelector('h1');
const [exercise, title] = h1
? h1.innerHTML.split(/<br\s*\/?>/).map((s) => serialise(s))
: ['Exercise', 'Untitled'];
const out = {
meta: {
...splitEyebrow(grab('.eyebrow', (e) => e.textContent.trim())),
exercise, title,
standfirst: grab('.standfirst', text),
logo: grab('.logo', (e) => e.getAttribute('src')),
legendText: {},
},
intro: [],
parts: [],
closing: [],
};
const legendEl = sheet.querySelector('.legend');
if (legendEl) {
const card = (c) => {
const el = legendEl.querySelector('.' + c);
if (!el) return '';
return el.innerHTML
.replace(/^\s*<b>[\s\S]*?<\/b>\s*/, '')
.trim()
.replace(/\s+/g, ' ');
};
out.meta.legendText = { core: card('l-core'), side: card('l-side'), protip: card('l-protip') };
}
// everything between the masthead and the first part is the intro
for (const el of [...sheet.children]) {
if (el.tagName === 'HEADER') continue;
if (el.classList.contains('legend')) { out.intro.push({ id: uid('l'), kind: 'legend' }); continue; }
if (el.classList.contains('part')) break;
const b = readBlock(el);
if (b) out.intro.push(b);
}
const used = new Set();
sheet.querySelectorAll(':scope > .part').forEach((el) => {
const h2 = el.querySelector('.body > h2');
const heading = h2 ? text(h2) : '';
out.parts.push({
id: slug(heading, used),
kind: 'part',
track: el.classList.contains('is-side') ? 'side' : 'core',
title: heading,
children: readChildren(el.querySelector('.body'), (c) => c !== h2),
});
});
const closing = sheet.querySelector('.closing');
if (closing) {
[...closing.children].forEach((el) => {
if (el.classList.contains('colophon')) return;
if (el.tagName === 'H2') {
out.closing.push({ id: uid('h'), kind: 'heading', text: text(el) });
return;
}
const b = readBlock(el);
if (b) out.closing.push(b);
});
}
return out;
}
BIN
View File
Binary file not shown.
+1
View File
File diff suppressed because one or more lines are too long
+272
View File
@@ -0,0 +1,272 @@
/* =====================================================================
Exercise builder — document model
One JSON tree is the source of truth. Every container holds an ordered
`children` array, which is what makes drag-and-drop a single operation:
move a node from one children array into another.
Node kinds
part major step track: "core" | "side"
task minor step container; only ever inside a part
prose paragraph inline markup, see inline.js
cmd command box
terminal transcript typed lines
protip keystroke tip
sidequest LEGACY only inline optional block; see accepts()
callout emphasis block container
note quiet aside container
table data table
list bullet list
===================================================================== */
export const CONTAINERS = new Set(['part', 'task', 'sidequest', 'callout', 'note']);
export const PALETTE = [
// The sidebar mirrors the shape of a sheet: the three categories from the
// legend first, then the step that lives inside them, then the pieces.
{ kind: 'part', preset: { track: 'core' }, group: 'Structure', label: 'Core', hint: 'An examinable section' },
{ kind: 'part', preset: { track: 'side' }, group: 'Structure', label: 'Side quest', hint: 'An optional section' },
{ kind: 'protip', group: 'Structure', label: 'Protip', hint: 'A quick tip' },
{ kind: 'task', group: 'Structure', label: 'Task', hint: 'A numbered step' },
{ kind: 'callout', group: 'Containers', label: 'Callout', hint: 'Emphasised block' },
{ kind: 'note', group: 'Containers', label: 'Note', hint: 'Quiet aside' },
{ kind: 'prose', group: 'Content', label: 'Prose', hint: 'A paragraph' },
{ kind: 'list', group: 'Content', label: 'Bullet list', hint: 'Plain bullets' },
{ kind: 'table', group: 'Content', label: 'Table', hint: 'Rows and columns' },
{ kind: 'cmd', group: 'Content', label: 'Command', hint: 'Click-to-copy box' },
{ kind: 'terminal', group: 'Content', label: 'Terminal', hint: 'Session transcript' },
];
/** The wording of the three categories. One source of truth. */
export const DEFAULT_LEGEND = {
core: 'Material from the lecture. This is what is examinable. You should complete all of these sections.',
side: 'Not examinable. Useful Information worth knowing, but safe to skip if you are short on time.',
protip: 'A quick tip to save you time',
};
let seq = 0;
export function uid(prefix = 'n') {
seq += 1;
return `${prefix}${Date.now().toString(36)}${seq.toString(36)}`;
}
/* ---------- construction ---------- */
export function blank(kind) {
const id = uid(kind[0]);
switch (kind) {
case 'part':
return { id, kind, track: 'core', title: 'New part', children: [] };
case 'task':
return { id, kind, children: [blank('prose')] };
case 'prose':
return { id, kind, text: 'New paragraph.', variant: null };
case 'cmd':
return { id, kind, command: 'pwd' };
case 'terminal':
// a line is a list of typed segments, because real transcripts mix
// prompt, command and output on one line
return { id, kind, caption: '', lines: [
[{ role: 'p', text: '$' }, { role: null, text: ' ' }, { role: 'c', text: 'pwd' }],
[{ role: 'o', text: '/home/you' }],
]};
case 'protip':
return { id, kind, keys: ['Tab'], label: 'Protip', text: 'What this saves you.', emphasis: false };
case 'sidequest':
return { id, kind, summary: 'Optional extra', children: [blank('prose')] };
case 'callout':
return { id, kind, heading: 'Heading', children: [blank('prose')] };
case 'note':
return { id, kind, children: [blank('prose')] };
case 'table':
return { id, kind, columns: ['Column', 'Column'], rows: [['', '']] };
case 'list':
return { id, kind, items: ['First item'] };
default:
throw new Error(`unknown kind: ${kind}`);
}
}
export function emptyDoc() {
return {
meta: {
courseCode: 'BI122G',
courseName: 'Introduction to Computational Tools for Bioinformatics',
term: 'Autumn 2026',
exercise: 'Exercise 1',
title: 'Untitled',
standfirst: 'In this exercise we will…',
logo: '',
legendText: { ...DEFAULT_LEGEND },
},
intro: [],
parts: [],
closing: [],
};
}
/* ---------- traversal ---------- */
/** Every array of nodes in the document, keyed so a node can be located. */
export function walk(doc, visit) {
const lists = [
{ list: doc.intro, owner: null, field: 'intro' },
{ list: doc.parts, owner: null, field: 'parts' },
{ list: doc.closing, owner: null, field: 'closing' },
];
while (lists.length) {
const { list, owner, field } = lists.shift();
list.forEach((node, index) => {
visit(node, list, index, owner, field);
if (Array.isArray(node.children)) {
lists.push({ list: node.children, owner: node, field: 'children' });
}
});
}
}
export function find(doc, id) {
let hit = null;
walk(doc, (node, list, index, owner) => {
if (node.id === id) hit = { node, list, index, owner };
});
return hit;
}
/** Would moving `id` into `targetList` put a node inside itself? */
function wouldOrphan(doc, id, targetList) {
const found = find(doc, id);
if (!found) return true;
let inside = false;
const scan = (node) => {
if (node.children === targetList) inside = true;
(node.children || []).forEach(scan);
};
scan(found.node);
return inside;
}
/* ---------- mutation ---------- */
export function insert(list, node, index) {
list.splice(index == null ? list.length : index, 0, node);
return node;
}
export function remove(doc, id) {
const found = find(doc, id);
if (!found) return null;
found.list.splice(found.index, 1);
return found.node;
}
export function move(doc, id, targetList, index) {
if (wouldOrphan(doc, id, targetList)) return false;
const found = find(doc, id);
if (!found) return false;
const sameList = found.list === targetList;
found.list.splice(found.index, 1);
let at = index == null ? targetList.length : index;
if (sameList && found.index < at) at -= 1;
targetList.splice(at, 0, found.node);
return true;
}
export function nudge(doc, id, delta) {
const found = find(doc, id);
if (!found) return false;
const to = found.index + delta;
if (to < 0 || to >= found.list.length) return false;
const [node] = found.list.splice(found.index, 1);
found.list.splice(to, 0, node);
return true;
}
export function duplicate(doc, id) {
const found = find(doc, id);
if (!found) return null;
const copy = reid(JSON.parse(JSON.stringify(found.node)));
found.list.splice(found.index + 1, 0, copy);
return copy;
}
/** A deep copy carrying fresh ids, subcomponents and all. */
export function cloneFresh(node) {
return reid(JSON.parse(JSON.stringify(node)));
}
function reid(node) {
node.id = uid(node.kind[0]);
(node.children || []).forEach(reid);
return node;
}
/* ---------- numbering ---------- */
/** Core parts number 1..n; side quests are not part of the sequence. */
export function partNumbers(doc) {
const map = new Map();
let n = 0;
doc.parts.forEach((part) => {
if (part.track !== 'side') { n += 1; map.set(part.id, n); }
});
return map;
}
/**
* Whether a node may legally accept a child of this kind.
*
* A side quest is a PART with track "side" — it never nests inside a core
* part. The `sidequest` node kind is retained only so that sheets written
* before that rule still import and render; it is absent from the palette,
* so nothing new can be built with it.
*/
export function accepts(parentKind, childKind) {
if (childKind === 'sidequest') return false; // legacy, never placeable
if (childKind === 'task') return parentKind === 'part';
if (childKind === 'part') return parentKind === 'root';
if (parentKind === 'root') return childKind === 'part';
return CONTAINERS.has(parentKind);
}
/**
* Fold away shapes from earlier versions of the model:
* a `tasks` wrapper node becomes its task children, sitting directly in the
* part. The <ol> is a rendering detail now, not something you build.
*/
export function migrate(doc) {
// a document saved before a field existed gets it filled in, so stale
// state in a browser cannot leave a category with no description
doc.meta = doc.meta || {};
const legend = { ...doc.meta.legendText };
Object.entries(DEFAULT_LEGEND).forEach(([k, v]) => {
if (!legend[k] || !String(legend[k]).trim()) legend[k] = v;
});
doc.meta.legendText = legend;
const flatten = (list) => {
for (let i = 0; i < list.length; i += 1) {
const node = list[i];
if (node.kind === 'tasks') {
list.splice(i, 1, ...(node.children || []));
i -= 1;
continue;
}
if (Array.isArray(node.children)) flatten(node.children);
}
};
[doc.intro, doc.parts, doc.closing].forEach((l) => Array.isArray(l) && flatten(l));
return doc;
}
/** Parts written as inline side quests, which the rule no longer allows. */
export function legacySideQuests(doc) {
const found = [];
walk(doc, (node, list, index, owner) => {
if (node.kind === 'sidequest') found.push({ node, owner });
});
return found;
}
+224
View File
@@ -0,0 +1,224 @@
/* =====================================================================
Renderer — document model → the exact markup of an exercise sheet.
This is the single code path to output: the builder canvas shows what
this produces, and export writes what this produces. There is no second
renderer to drift out of step.
===================================================================== */
import { parse } from './inline.js';
import { partNumbers } from './model.js';
const esc = (s = '') => String(s).replace(/[&<>"]/g,
(c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const pad = (n) => ' '.repeat(n);
/* ---------- blocks ---------- */
/** Tag the outermost element so the canvas can map DOM back to model. */
function mark(html, node, ctx) {
if (!ctx.marks) return html;
return html.replace(/^(\s*<[a-z0-9]+)/i, `$1 data-node="${node.id}"`);
}
function block(node, ctx, depth) {
return mark(blockInner(node, ctx, depth), node, ctx);
}
function blockInner(node, ctx, depth) {
const p = pad(depth);
switch (node.kind) {
case 'prose': {
const cls = node.variant ? ` class="${node.variant}"` : '';
return `${p}<p${cls}>${parse(node.text, ctx.refs)}</p>`;
}
case 'cmd':
return `${p}<div class="cmd">${esc(node.command)}</div>`;
case 'terminal':
return `${p}${terminal(node)}`;
case 'protip': {
const keys = (node.keys || []).map((k) => `<kbd>${esc(k)}</kbd>`).join('');
const cls = node.emphasis ? 'protip headline' : 'protip';
return [
`${p}<div class="${cls}">`,
`${p} <div class="keys">${keys}</div>`,
`${p} <p class="said"><b>${esc(node.label)}</b> ${parse(node.text, ctx.refs)}</p>`,
`${p}</div>`,
].join('\n');
}
case 'list':
return [
`${p}<ul class="plain">`,
...node.items.map((i) => `${p} <li>${parse(i, ctx.refs)}</li>`),
`${p}</ul>`,
].join('\n');
case 'table':
return table(node, ctx, depth);
case 'note':
return [
`${p}<div class="note">`,
...node.children.map((c) => block(c, ctx, depth + 1)),
`${p}</div>`,
].join('\n');
case 'callout':
return [
`${p}<div class="callout">`,
`${p} <h3>${parse(node.heading, ctx.refs)}</h3>`,
...node.children.map((c) => block(c, ctx, depth + 1)),
`${p}</div>`,
].join('\n');
// legacy: inline side quests from before the rule that they live at part
// level. Still rendered so old sheets survive; not offered in the palette.
case 'sidequest':
return [
`${p}<details class="side-quest" open>`,
`${p} <summary>${parse(node.summary, ctx.refs)}</summary>`,
...node.children.map((c) => block(c, ctx, depth + 1)),
`${p}</details>`,
].join('\n');
default:
throw new Error(`cannot render kind: ${node.kind}`);
}
}
function terminal(node) {
const body = (node.lines || []).map((segments) => segments.map((seg) =>
seg.role ? `<span class="${seg.role}">${esc(seg.text)}</span>` : esc(seg.text)
).join('')).join('\n');
const cap = node.caption ? `<span class="cap">${esc(node.caption)}</span>` : '';
return `<div class="term">${cap}${body}</div>`;
}
function table(node, ctx, depth) {
const p = pad(depth);
const out = [`${p}<div class="tablewrap">`, `${p} <table>`];
if (node.columns && node.columns.some((c) => c)) {
out.push(`${p} <thead>`);
out.push(`${p} <tr>${node.columns.map((c) => `<th>${parse(c, ctx.refs)}</th>`).join('')}</tr>`);
out.push(`${p} </thead>`);
}
out.push(`${p} <tbody>`);
node.rows.forEach((row) => {
out.push(`${p} <tr>${row.map((c) => `<td>${parse(c, ctx.refs)}</td>`).join('')}</tr>`);
});
out.push(`${p} </tbody>`, `${p} </table>`, `${p}</div>`);
return out.join('\n');
}
/* ---------- parts ---------- */
/**
* Consecutive tasks share one <ol>. A run that follows other content
* continues the numbering rather than restarting at 1.
*/
function taskList(items, ctx, depth, offset) {
const p = pad(depth);
const style = offset ? ` style="counter-reset: task ${offset}"` : '';
return [
`${p}<ol class="tasks"${style}>`,
...items.map((t) => [
`${p} <li${ctx.marks ? ` data-node="${t.id}"` : ''}>`,
...t.children.map((c) => block(c, ctx, depth + 2)),
`${p} </li>`,
].join('\n')),
`${p}</ol>`,
].join('\n');
}
function partBody(node, ctx, depth) {
const out = [];
let i = 0, numbered = 0;
while (i < node.children.length) {
if (node.children[i].kind === 'task') {
const run = [];
while (i < node.children.length && node.children[i].kind === 'task') run.push(node.children[i++]);
out.push(taskList(run, ctx, depth, numbered));
numbered += run.length;
} else {
out.push(block(node.children[i], ctx, depth));
i += 1;
}
}
return out;
}
function part(node, ctx) {
const side = node.track === 'side';
const tag = ctx.marks ? ` data-node="${node.id}"` : '';
const rail = side
? ' <span class="tag side">Side quest</span>'
: ' <span class="num"></span>\n <span class="tag core">Core</span>';
return [
`<section class="part${side ? ' is-side' : ''}"${tag}>`,
' <div class="rail">',
rail,
' </div>',
' <div class="body">',
` <h2>${parse(node.title, ctx.refs)}</h2>`,
...partBody(node, ctx, 2),
' </div>',
'</section>',
].join('\n');
}
/* ---------- whole document ---------- */
export function renderBody(doc, opts = {}) {
const ctx = { refs: partNumbers(doc), marks: !!opts.marks };
const m = doc.meta;
const out = ['<div class="sheet">', ''];
out.push('<header class="masthead">');
if (m.logo) out.push(` <img class="logo" alt="University of Skövde" src="${m.logo}">`);
out.push(` <div class="eyebrow">${esc(eyebrow(m))}</div>`);
out.push(` <h1>${esc(m.exercise)}<br>${esc(m.title)}</h1>`);
out.push(` <p class="standfirst">${parse(m.standfirst, ctx.refs)}</p>`);
out.push('</header>', '');
doc.intro.forEach((node) => {
out.push(node.kind === 'legend' ? legend(doc) : block(node, ctx, 0), '');
});
doc.parts.forEach((node) => out.push(part(node, ctx), ''));
if (doc.closing.length) {
out.push('<section class="closing">');
doc.closing.forEach((node) => {
if (node.kind === 'heading') out.push(` <h2>${parse(node.text, ctx.refs)}</h2>`);
else out.push(block(node, ctx, 1));
});
out.push('</section>', '');
}
out.push('</div>');
return out.join('\n');
}
/** Course code · course name · term, skipping whatever is blank. */
export function eyebrow(m) {
return [m.courseCode, m.courseName, m.term].map((s) => (s || '').trim())
.filter(Boolean).join(' · ');
}
function legend(doc) {
const l = doc.meta.legendText || {};
const card = (cls, label, text) =>
` <div class="${cls}">\n <b>${esc(label)}</b>\n ${text}\n </div>`;
return [
'<div class="legend">',
card('l-core', 'Core', l.core || ''),
card('l-side', 'Side quest', l.side || ''),
card('l-protip', 'Protip', l.protip || ''),
'</div>',
].join('\n');
}
+683
View File
@@ -0,0 +1,683 @@
/* ============================================================
BI122G — Exercise 3
Palette from the university's own materials:
#043554 the lecture deck's title-slide navy — masthead only
#0969A8 HiS blue — the lecture-slide heading blue and the
logo crest; the core, examinable track, plus
structural rules and links
#157539 HiS emerald — the side quests
#B52E75 HiS ruby — reserved entirely for the protips
graphite scale for text and rules.
Track system:
core blue edge on the page ground
side quest green tinted panel, dashed green edge
protip ruby tinted band, ruby edge
Each is distinguished by form as well as hue, so the
distinction survives greyscale printing.
Type: Open Sans, as on the university site, with Cascadia
Mono for terminal text — the face students see in VS Code.
============================================================ */
:root{
--paper: #FFFFFF;
--surface: #F4F7FA;
--sunk: #EAEFF4;
--ink: #14181B;
--ink-body: #313538;
--ink-soft: #5C6165;
--ink-faint: #7E858A;
--rule: #DDE4EA;
--rule-firm: #B4BFC7;
--green: #157539;
--green-tint: #EBF4EF;
--green-edge: #9AC7AC;
--blue: #0969A8;
--blue-deep: #06507F;
--blue-tint: #EAF3F9;
--blue-edge: #9CC4DD;
--ruby: #B52E75;
--ruby-tint: #FBEEF4;
--ruby-edge: #E2A8C6;
/* which hue carries which track. Swap these two groups to flip it. */
--core: var(--blue);
--core-tint: var(--blue-tint);
--core-edge: var(--blue-edge);
--side: var(--green);
--side-tint: var(--green-tint);
--side-edge: var(--green-edge);
--term-bg: #17202A;
--term-ink: #DDE4EA;
--term-dim: #939CA3;
--term-cue: #6FBCE8;
--term-warn: #E8A26A;
--logo-filter: none;
--sans: "Open Sans", "Segoe UI", system-ui, -apple-system, "Helvetica Neue", Arial, sans-serif;
--mono: "Cascadia Mono", "Cascadia Code", ui-monospace, "SF Mono", Menlo, "DejaVu Sans Mono", Consolas, monospace;
}
@media (prefers-color-scheme: dark){
:root:not([data-theme="light"]){
--paper: #0B0F12;
--surface: #151B20;
--sunk: #05080A;
--ink: #F4F7FA;
--ink-body: #D6DEE4;
--ink-soft: #A6B0B8;
--ink-faint: #808A92;
--rule: #242C33;
--rule-firm: #3A444C;
--green: #5DBE84;
--green-tint:#0F2318;
--green-edge:#35624A;
--blue: #6FBCE8;
--blue-deep: #A5D6F2;
--blue-tint: #0F2331;
--blue-edge: #35617E;
--ruby: #E87AB0;
--ruby-tint: #26141D;
--ruby-edge: #7C4160;
--term-bg: #161D22;
--logo-filter: invert(1);
}
}
:root[data-theme="dark"]{
--paper: #0B0F12;
--surface: #151B20;
--sunk: #05080A;
--ink: #F4F7FA;
--ink-body: #D6DEE4;
--ink-soft: #A6B0B8;
--ink-faint: #808A92;
--rule: #242C33;
--rule-firm: #3A444C;
--green: #5DBE84;
--green-tint:#0F2318;
--green-edge:#35624A;
--blue: #6FBCE8;
--blue-deep: #A5D6F2;
--blue-tint: #0F2331;
--blue-edge: #35617E;
--ruby: #E87AB0;
--ruby-tint: #26141D;
--ruby-edge: #7C4160;
--term-bg: #161D22;
--logo-filter: invert(1);
}
*{ box-sizing: border-box; }
body{
margin: 0;
background: var(--paper);
color: var(--ink-body);
font-family: var(--sans);
font-size: 16.5px;
line-height: 1.65;
-webkit-font-smoothing: antialiased;
}
.sheet{
counter-reset: part; /* only the core parts take a number */
max-width: 58rem;
margin: 0 auto;
padding: 2.5rem 1.5rem 6rem;
}
/* ---------- masthead ---------- */
.masthead{ margin-bottom: 2.5rem; }
.logo{
display: block;
width: 13.5rem;
max-width: 60%;
height: auto;
margin-bottom: 2.25rem;
filter: var(--logo-filter);
}
.eyebrow{
font-size: .72rem;
font-weight: 600;
letter-spacing: .14em;
text-transform: uppercase;
color: var(--blue);
margin-bottom: .5rem;
}
h1{
font-size: clamp(1.9rem, 4.6vw, 2.7rem);
line-height: 1.14;
font-weight: 700;
/* white in the dark theme; a literal #fff would vanish on the light
theme and in print, so this tracks the theme's strongest ink */
color: var(--ink);
margin: 0 0 .9rem;
letter-spacing: -.015em;
}
/* the masthead spans the full column; the reading measure applies to
body copy further down, not to the title block */
.standfirst{
font-size: 1.08rem;
color: var(--ink);
max-width: none;
margin: 0;
border-top: 3px solid var(--blue);
padding-top: 1rem;
}
/* ---------- generic type ---------- */
p{ margin: 0 0 1rem; max-width: 68ch; }
h2, h3{ text-wrap: balance; }
a{ color: var(--blue); text-underline-offset: .18em; }
strong{ font-weight: 600; color: var(--ink); }
code{
font-family: var(--mono);
font-size: .87em;
background: var(--sunk);
border: 1px solid var(--rule);
border-radius: 3px;
padding: .06em .32em;
white-space: nowrap;
color: var(--ink);
}
h2 code, h3 code, .cmd code, .term code{ background: none; border: 0; padding: 0; }
kbd{
font-family: var(--sans);
font-size: .76em;
font-weight: 600;
background: var(--paper);
border: 1px solid var(--rule-firm);
border-bottom-width: 2px;
border-radius: 4px;
padding: .12em .45em;
color: var(--ink);
white-space: nowrap;
}
/* ---------- legend ---------- */
.legend{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(14.5rem, 1fr));
gap: .9rem;
margin: 2.25rem 0 1rem;
align-items: start;
}
.legend > div{
padding: .85rem 1rem;
font-size: .88rem;
color: var(--ink-soft);
border-radius: 0 4px 4px 0;
}
.legend .l-core{
background: var(--core-tint);
border: 1px solid var(--core-edge);
border-left: 4px solid var(--core);
}
.legend .l-side{
background: var(--side-tint);
border: 1px dashed var(--side-edge);
border-left: 4px solid var(--side);
}
.legend .l-protip{
background: var(--ruby-tint);
border: 1px solid var(--ruby-edge);
border-left: 4px solid var(--ruby);
}
.legend b{
display: block;
font-size: .68rem;
letter-spacing: .14em;
text-transform: uppercase;
margin-bottom: .3rem;
color: var(--ink);
}
.legend .l-core b{ color: var(--core); }
.legend .l-side b{ color: var(--side); }
.legend .l-protip b{ color: var(--ruby); }
/* ---------- parts ---------- */
.part{
display: grid;
grid-template-columns: 5rem 1fr;
gap: 0 2rem;
padding-top: 2.5rem;
margin-top: 2.5rem;
border-top: 1px solid var(--rule);
}
.part:first-of-type{ border-top: 2px solid var(--blue); }
.part:not(.is-side){ counter-increment: part; }
/* a side quest is not a step in the sequence, so it carries no number and
its label sits out to the right, clear of the numbered spine */
.part.is-side{ grid-template-columns: 1fr 6rem; }
.part.is-side > .body{ grid-column: 1; }
.part.is-side > .rail{ grid-column: 2; text-align: right; align-items: flex-end; }
.rail{
font-size: .67rem;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--ink-faint);
display: flex;
flex-direction: column;
gap: .4rem;
padding-top: .5rem;
}
.rail .num::before{ content: counter(part); }
.rail .num{
font-size: 2.1rem;
font-weight: 700;
line-height: .9;
letter-spacing: -.03em;
color: var(--rule-firm);
font-variant-numeric: tabular-nums;
}
.rail .tag{ font-weight: 700; }
.rail .tag.core{ color: var(--core); }
.rail .tag.side{ color: var(--side); }
.part > .body{ min-width: 0; }
.part h2{
font-size: 1.5rem;
font-weight: 700;
color: var(--core);
margin: 0 0 .7rem;
letter-spacing: -.012em;
line-height: 1.2;
}
.part.is-side h2{ color: var(--side); }
/* a whole side-quest part reads as one blue panel */
.part.is-side > .body{
background: var(--side-tint);
border: 1px dashed var(--side-edge);
border-left: 4px solid var(--side);
border-radius: 0 5px 5px 0;
padding: 1.2rem 1.4rem;
}
.part.is-side > .rail{ padding-top: 1.7rem; }
/* ---------- tasks ---------- */
ol.tasks{
list-style: none;
counter-reset: task;
margin: 1.4rem 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 1.3rem;
}
ol.tasks > li{
counter-increment: task;
display: grid;
grid-template-columns: 1.55rem 1fr;
gap: 0 .8rem;
min-width: 0;
}
ol.tasks > li::before{
content: counter(task);
font-size: .72rem;
font-weight: 700;
color: var(--paper);
background: var(--core);
border-radius: 50%;
width: 1.55rem;
height: 1.55rem;
display: grid;
place-items: center;
margin-top: .22rem;
font-variant-numeric: tabular-nums;
}
.is-side ol.tasks > li::before{ background: var(--side); }
ol.tasks > li > *{ grid-column: 2; min-width: 0; }
ol.tasks > li > *:last-child{ margin-bottom: 0; }
ol.tasks p{ margin-bottom: .6rem; }
/* ---------- command + terminal blocks ---------- */
.cmd, .term{
margin: 0 0 .9rem;
border-radius: 4px;
font-family: var(--mono);
font-size: .85rem;
line-height: 1.7;
position: relative;
}
.term{ overflow-x: auto; }
/* the whole block is the copy target, so nothing prints that
would not also work on paper */
.cmd{
background: var(--surface);
border: 1px solid var(--rule);
border-left: 3px solid var(--core);
padding: .6rem 4.5rem .6rem .85rem;
color: var(--ink);
white-space: pre-wrap;
overflow-wrap: anywhere;
cursor: pointer;
}
.cmd::before{
content: "$";
color: var(--core);
padding-right: .55rem;
user-select: none;
}
.cmd::after{
content: "copy";
position: absolute;
top: .45rem;
right: .7rem;
font-family: var(--sans);
font-size: .6rem;
font-weight: 600;
letter-spacing: .1em;
text-transform: uppercase;
color: var(--ink-faint);
opacity: 0;
transition: opacity .15s ease;
}
.cmd:hover::after,
.cmd:focus-visible::after{ opacity: 1; }
.cmd.copied::after{ content: "copied"; opacity: 1; color: var(--core); }
.cmd:focus-visible{ outline: 2px solid var(--core); outline-offset: 2px; }
/* inside blue territory the boxes sit on white so they stay legible */
.is-side .cmd, .side-quest .cmd{
background: var(--paper);
border-left-color: var(--side);
}
.is-side .cmd::before, .side-quest .cmd::before{ color: var(--side); }
.is-side .cmd.copied::after,
.side-quest .cmd.copied::after{ color: var(--side); }
.term{
background: var(--term-bg);
color: var(--term-ink);
padding: .8rem 1rem;
white-space: pre;
border: 1px solid var(--rule-firm);
}
.term .p{ color: var(--term-cue); }
.term .c{ color: var(--term-ink); }
.term .o{ color: var(--term-dim); }
.term .e{ color: var(--term-warn); }
.term .cap{
display: block;
font-family: var(--sans);
font-size: .62rem;
font-weight: 600;
letter-spacing: .13em;
text-transform: uppercase;
color: var(--term-dim);
margin-bottom: .5rem;
white-space: normal;
}
/* ---------- side quest ---------- */
.side-quest{
border: 1px dashed var(--side-edge);
border-left: 4px solid var(--side);
background: var(--side-tint);
padding: 1rem 1.15rem;
margin: 1.5rem 0;
border-radius: 0 4px 4px 0;
}
/* nested inside an already-blue part, drop back to the page ground */
.part.is-side .side-quest{ background: var(--paper); }
.side-quest > summary{
font-size: .71rem;
font-weight: 700;
letter-spacing: .13em;
text-transform: uppercase;
color: var(--side);
cursor: pointer;
list-style: none;
display: flex;
align-items: baseline;
gap: .6rem;
}
.side-quest > summary::-webkit-details-marker{ display: none; }
.side-quest > summary::after{
content: "optional";
font-size: .62rem;
font-weight: 400;
letter-spacing: .08em;
color: var(--ink-faint);
}
.side-quest > summary:focus-visible{ outline: 2px solid var(--side); outline-offset: 3px; }
.side-quest > *:not(summary){ font-size: .95rem; }
.side-quest > summary + *{ margin-top: .85rem; }
.side-quest > *:last-child{ margin-bottom: 0; }
/* ---------- protip ---------- */
.protip{
background: var(--ruby-tint);
border: 1px solid var(--ruby-edge);
border-left: 4px solid var(--ruby);
border-radius: 0 4px 4px 0;
padding: .8rem 1rem;
margin: 1.4rem 0;
display: grid;
grid-template-columns: auto 1fr;
gap: 0 .9rem;
align-items: start;
}
.protip.headline{ border-width: 2px; border-left-width: 5px; }
.protip .keys{ display: flex; gap: .25rem; padding-top: .12rem; }
.protip .said{ margin: 0; font-size: .94rem; color: var(--ink-soft); max-width: 62ch; }
.protip .said b{
font-size: .67rem;
font-weight: 700;
letter-spacing: .16em;
text-transform: uppercase;
color: var(--ruby);
display: block;
margin-bottom: .15rem;
}
.protip .said em{ font-style: italic; }
/* ---------- misc blocks ---------- */
.note{
border-left: 3px solid var(--rule-firm);
padding: .1rem 0 .1rem 1rem;
margin: 1.3rem 0;
color: var(--ink-soft);
font-size: .95rem;
}
.note p:last-child{ margin-bottom: 0; }
.callout{
background: var(--paper);
border: 1px solid var(--core-edge);
border-left: 4px solid var(--core);
padding: 1rem 1.15rem;
margin: 1.5rem 0;
border-radius: 0 4px 4px 0;
}
.callout h3{
font-size: .71rem;
font-weight: 700;
letter-spacing: .13em;
text-transform: uppercase;
color: var(--core);
margin: 0 0 .6rem;
}
.callout > *:last-child{ margin-bottom: 0; }
table{
border-collapse: collapse;
width: 100%;
font-size: .91rem;
}
.tablewrap{ overflow-x: auto; margin: 1.3rem 0; }
.tablewrap table{ min-width: 28rem; }
th, td{
text-align: left;
padding: .5rem .8rem .5rem 0;
border-bottom: 1px solid var(--rule);
vertical-align: top;
}
th{
font-size: .67rem;
font-weight: 700;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--ink-faint);
border-bottom-color: var(--rule-firm);
}
td code{ white-space: nowrap; }
ul.plain{ margin: 1rem 0; padding-left: 1.1rem; }
ul.plain li{ margin-bottom: .45rem; max-width: 66ch; }
/* ---------- closing ---------- */
.closing{
margin-top: 3.5rem;
padding-top: 1.8rem;
border-top: 2px solid var(--blue);
}
.closing h2{
font-size: 1.4rem;
font-weight: 700;
color: var(--blue);
margin: 0 0 .8rem;
}
.colophon{
margin-top: 3rem;
padding-top: 1rem;
border-top: 1px solid var(--rule);
font-size: .74rem;
color: var(--ink-faint);
display: flex;
flex-wrap: wrap;
gap: .3rem 1.5rem;
}
/* ---------- theme switch ---------- */
/* Built by sheet.js rather than written into the markup, so it exists
only where it can actually work. It sits in the corner opposite the
logo, above the reading column at every width. */
.themetoggle{
position: fixed;
top: 1rem;
right: 1rem;
z-index: 10;
width: 2.1rem;
height: 2.1rem;
display: grid;
place-items: center;
padding: 0;
font-family: var(--sans);
font-size: 1rem;
line-height: 1;
color: var(--ink-soft);
background: var(--paper);
border: 1px solid var(--rule-firm);
border-radius: 50%;
cursor: pointer;
transition: color .15s ease, border-color .15s ease;
}
.themetoggle:hover{ color: var(--blue); border-color: var(--blue); }
.themetoggle:focus-visible{ outline: 2px solid var(--blue); outline-offset: 2px; }
/* ---------- narrow screens ---------- */
@media (max-width: 40rem){
body{ font-size: 16px; }
.sheet{ padding: 2rem 1.1rem 4rem; }
.part{ grid-template-columns: 1fr; gap: .7rem; }
.rail{ flex-direction: row; align-items: baseline; gap: .85rem; padding-top: 0; }
.part.is-side{ grid-template-columns: 1fr; }
.part.is-side > .body,
.part.is-side > .rail{ grid-column: auto; }
.part.is-side > .rail{ padding-top: 0; text-align: left; align-items: flex-start; }
.part.is-side > .body{ padding: 1rem 1.1rem; }
.rail .num{ font-size: 1.4rem; }
ol.tasks > li{ grid-template-columns: 1.4rem 1fr; gap: 0 .6rem; }
.cmd{ padding-right: .85rem; }
.cmd::after{ display: none; }
}
@media (prefers-reduced-motion: reduce){
*{ animation: none !important; transition: none !important; }
}
/* ---------- print / PDF ---------- */
@media print{
/* `:root[data-theme]` and not a bare `:root`: the dark blocks above are
attribute-qualified, so they outrank a plain :root here and a reader
printing while in dark mode would get a page flooded with ink. Equal
specificity and later in the file, so print wins either way. */
:root, :root[data-theme]{
--paper: #fff; --surface: #F4F7FA; --sunk: #EEF2F6;
--ink: #000; --ink-body: #1a1c1e; --ink-soft: #3a3d40; --ink-faint: #5a5e62;
--rule: #c4ccd3; --rule-firm: #8d959b;
--green: #0F5A2B; --green-tint: #EDF5F0; --green-edge: #86B69B;
--blue: #06537F; --blue-tint: #EDF4F9; --blue-edge: #7FAECB;
--ruby: #96225F; --ruby-tint: #FBEFF5; --ruby-edge: #C98BAE;
--logo-filter: none;
}
body{ font-size: 10.5pt; background: #fff; }
.sheet{ max-width: none; padding: 0; }
.themetoggle{ display: none; }
.cmd{ cursor: auto; }
.cmd::after{ content: none; }
.part{ break-inside: avoid-page; page-break-inside: avoid; }
ol.tasks > li,
.side-quest, .protip, .callout, .cmd, .term{
break-inside: avoid;
page-break-inside: avoid;
}
.side-quest > summary::after{ content: " — optional"; }
/* terminal blocks invert so they do not flood the page with ink */
.term{ background: #F2F5F8; color: #000; border: 1px solid #8d959b; }
.term .p{ color: #06537F; }
.term .o{ color: #44484b; }
.term .e{ color: #8a3410; }
.term .cap{ color: #5a5e62; }
a{ color: #000; text-decoration: underline; }
a[href^="http"]::after{
content: " (" attr(href) ")";
font-size: .8em;
color: #5a5e62;
word-break: break-all;
}
}
+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(); }
});
});