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:
+1350
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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) => ({ '&': '&', '<': '<', '>': '>' }[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
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', ''': "'" }[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;
|
||||
}
|
||||
Binary file not shown.
File diff suppressed because one or more lines are too long
+272
@@ -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
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(); }
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user