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
+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>