From 16290f3e1186752eafa2ada7ffb4820b9ec922a6 Mon Sep 17 00:00:00 2001 From: Daniel Bejarano Date: Tue, 2 Jun 2026 15:57:41 -0600 Subject: [PATCH 1/2] =?UTF-8?q?DOJ-4839:=20harden=20workbook=20output=20?= =?UTF-8?q?=E2=80=94=20knob=20split-bar,=20lede/quote/code-highlight,=20va?= =?UTF-8?q?lidator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Improvements surfaced reviewing the real intro-to-quantum workbook. - Template: knob `is-split` variant (two-color A-vs-B bar driven by the existing --knob var + a 2-item legend; standard knob unchanged); promote `wb-lede` and `wb-quote` into the kit; dependency-free code-highlight token classes (kw/str/num/fn/cmt) with fixed legible hues on the code surface. - Skill: add lede / statement / quote / split-knob to the catalog; document the highlight convention. - Command: map the new components; add Phase 6 — run the output validator. - scripts/validate_workbook.py (NEW, stdlib only): checks a generated workbook is standalone (no external script/CDN), in-memory-only (no localStorage/ postMessage/etc.), has globally-unique ids (critical post fan-out), and warns on missing a11y landmarks / system-ui fallback. Validated the real intro-to-quantum workbook: 0 errors, 0 warnings. New template components render + the split bar tracks --knob; zero console errors. CI lint green. No invariant change. Created by Claude Code on behalf of @daniel Co-Authored-By: Claude Opus 4.8 (1M context) --- assets/templates/workbook/workbook-base.html | 52 ++++++++- commands/workbook-generate.md | 25 ++++- scripts/validate_workbook.py | 112 +++++++++++++++++++ skills/workbook-generate/SKILL.md | 6 +- 4 files changed, 189 insertions(+), 6 deletions(-) create mode 100644 scripts/validate_workbook.py diff --git a/assets/templates/workbook/workbook-base.html b/assets/templates/workbook/workbook-base.html index 9913e6c..175938a 100644 --- a/assets/templates/workbook/workbook-base.html +++ b/assets/templates/workbook/workbook-base.html @@ -167,6 +167,14 @@ } .wb-callout strong { color: var(--wb-accent); font-weight: 600; } +/* ---- Component: lede (lead paragraph that opens a step) ---- */ +.wb-lede { font-size: 1.18rem; line-height: 1.6; color: var(--wb-sub); margin: 0 0 var(--wb-sp-4); max-width: var(--wb-measure); } + +/* ---- Component: quote (pull-quote + attribution) ---- */ +.wb-quote { margin: var(--wb-sp-5) 0; padding-left: var(--wb-sp-5); border-left: 3px solid var(--wb-accent); } +.wb-quote blockquote { margin: 0; font-style: italic; font-size: 1.15rem; line-height: 1.5; color: var(--wb-text); } +.wb-quote cite { display: block; margin-top: var(--wb-sp-2); font-style: normal; font-size: .9rem; color: var(--wb-muted); } + /* ---- Component: accordion ---- */ .wb-accordion { border: var(--wb-border); border-radius: var(--wb-r-md); background: var(--wb-surface); box-shadow: var(--wb-shadow-sm); margin: var(--wb-sp-5) 0; overflow: hidden; } .wb-accordion__trigger { @@ -256,6 +264,15 @@ .wb-code pre .diff-add { display: block; background: color-mix(in srgb, var(--wb-good) 22%, transparent); } .wb-code pre .diff-del { display: block; background: color-mix(in srgb, var(--wb-bad) 18%, transparent); text-decoration: line-through; text-decoration-color: color-mix(in srgb, var(--wb-bad) 60%, transparent); } +/* ---- Code syntax highlighting (dependency-free, fixed palette on the dark code surface). + Convention: wrap tokens by role — kw=keyword, str=string, num=number, fn=function/type, + cmt=comment. Fixed hues (not brand tokens) for legibility on the code background. ---- */ +.wb-code pre .kw { color: #c79bf0; } /* keyword */ +.wb-code pre .str { color: #9ed7a8; } /* string */ +.wb-code pre .num { color: #e0a87a; } /* number */ +.wb-code pre .fn { color: #7fc7e8; } /* function / type */ +.wb-code pre .cmt { color: #8a8a93; font-style: italic; } /* comment */ + /* ---- Component: bar chart (precomputed SVG coords; highlight the salient datum) ---- */ .wb-chart svg .bar { fill: var(--wb-line); } .wb-chart svg .bar.is-peak { fill: var(--wb-accent); } /* recolor the one datum that matters */ @@ -270,6 +287,14 @@ .wb-knob input[type="range"] { flex: 1; accent-color: var(--wb-accent); } .wb-knob__val { font-family: var(--wb-font-mono); font-size: 13px; color: var(--wb-accent); min-width: 52px; text-align: right; } .wb-knob__preview { height: 14px; border-radius: 999px; background: var(--wb-accent); width: var(--knob, 50%); margin-top: var(--wb-sp-4); transition: width .12s ease; } +/* Split variant: a full-width A-vs-B bar (e.g. P(1) vs P(0)). The value portion is + accent, the remainder accent-2; both driven by the same --knob var (no JS change). */ +.wb-knob__preview.is-split { width: 100%; transition: none; + background: linear-gradient(90deg, var(--wb-accent) 0 var(--knob, 50%), var(--wb-accent-2) var(--knob, 50%) 100%); } +.wb-knob__legend { display: flex; gap: var(--wb-sp-4); margin-top: var(--wb-sp-2); font-size: .8rem; color: var(--wb-muted); } +.wb-knob__legend span::before { content: ""; display: inline-block; width: 10px; height: 10px; border-radius: 3px; margin-right: 6px; vertical-align: middle; } +.wb-knob__legend .lg-a::before { background: var(--wb-accent); } +.wb-knob__legend .lg-b::before { background: var(--wb-accent-2); } @media (prefers-reduced-motion: reduce) { .wb-knob__preview { transition: none; } } .wb-knob__caption { font-size: .9rem; color: var(--wb-sub); margin-top: var(--wb-sp-3); } @@ -328,11 +353,21 @@

{{course.title}}

{{module.title}}

{{step.title}}

+ + +

{{lede — the one sentence that frames this step}}

+

{{step.body}} A key term like the ring can be linked to the glossary below.

{{key word}} {{statement}}
+ +
+
{{memorable line}}
+ {{attribution — optional}} +
+
@@ -349,10 +384,10 @@

{{step.title}}

- +
-
{{code}}
+
{{keyword}} {{code}} {{# comment}}
  • {{annotation}}
@@ -415,6 +450,19 @@

{{step.title}}

{{what dragging this teaches}}

+ +
+
+ {{parameter}} + + 50% +
+
+
{{outcome A — the value portion}}{{outcome B — the remainder}}
+

{{what dragging this teaches — name both outcomes}}

+
+ diff --git a/commands/workbook-generate.md b/commands/workbook-generate.md index 584b5cb..3a76d30 100644 --- a/commands/workbook-generate.md +++ b/commands/workbook-generate.md @@ -108,11 +108,14 @@ or by each `workbook-module-composer` subagent (fan-out). Apply the **content-shape -> component mapping** from the skill. Summary: - Course -> module **chapters** + a **table of contents** + course **progress**. -- Each reading -> a run of steps in its module chapter; each `H2` -> a **step**. +- Each reading -> a run of steps in its module chapter; each `H2` -> a **step**; + open a step with a one-line **lede** when it helps frame the section. - Tables -> **comparison diagram** / **tabs** / **chart** (by intent). - Ordered process lists -> **flow diagram** / stepper. -- Fenced code -> **annotated code block**. -- Load-bearing sentences -> **statement / callout**. +- Fenced code -> **annotated code block** (highlight tokens by role: `kw`/`str`/`num`/`fn`/`cmt`). +- Load-bearing sentence -> **statement / callout**; a memorable/authoritative line -> **quote**. +- A value that's one side of a split (e.g. a probability, P(1) vs P(0)) -> a + **knob** with the `is-split` two-color bar so the viz matches the caption. - Optional depth -> **accordion**; parallel alternatives -> **tabs**. - End of each major section -> an optional **predict-and-reveal** self-check (think first, then reveal the answer — not a graded quiz); `quiz-*.md` -> @@ -203,6 +206,22 @@ This is a quality pass on the *creative payload* only — the invariant frame 3. **Suggest**: "Open in a browser to review; traverse the modules and complete a few checks. Answers reset on refresh (expected — persistence is deferred)." +## Phase 6 — Validate the generated file + +Run the output validator on the file you just wrote: + +``` +python3 ${CLAUDE_PLUGIN_ROOT}/scripts/validate_workbook.py +``` + +It checks the invariants the artifact must hold: **standalone** (no external +`", re.I | re.S) +CODE_BLOCK_RE = re.compile(r"<(pre|code)\b[^>]*>.*?", re.I | re.S) STATE_RE = re.compile(r"\b(localStorage|sessionStorage|indexedDB|postMessage)\b") ID_RE = re.compile(r'\sid\s*=\s*"([^"]+)"', re.I) -ROLE_REGION_RE = re.compile(r'role\s*=\s*"region"', re.I) -ROLE_PROGRESS_RE = re.compile(r'role\s*=\s*"progressbar"', re.I) +ROLE_REGION_RE = re.compile(r'role\s*=\s*["\']?region["\']?', re.I) +ROLE_PROGRESS_RE = re.compile(r'role\s*=\s*["\']?progressbar["\']?', re.I) GFONTS_RE = re.compile(r"fonts\.googleapis\.com", re.I) @@ -46,21 +53,25 @@ def validate(path: str) -> tuple[list[str], list[str]]: # --- Standalone --- if SCRIPT_SRC_RE.search(html): errors.append(f"{path}: external
{{dimension}}{{option A}}{{option B}}