Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 50 additions & 2 deletions assets/templates/workbook/workbook-base.html
Original file line number Diff line number Diff line change
Expand Up @@ -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-sub); }

/* ---- 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 {
Expand Down Expand Up @@ -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 */
Expand All @@ -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-sub); }
.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); }

Expand Down Expand Up @@ -328,11 +353,21 @@ <h1>{{course.title}}</h1>
<div class="wb-eyebrow">{{module.title}}</div>
</div>
<h2>{{step.title}}</h2>

<!-- Component: lede (one-line framing that opens the step) -->
<p class="wb-lede">{{lede — the one sentence that frames this step}}</p>

<p>{{step.body}} A key term like <span class="wb-term" data-term="ring">the ring</span> can be linked to the glossary below.</p>

<!-- Component: statement / callout -->
<div class="wb-callout"><strong>{{key word}}</strong> {{statement}}</div>

<!-- Component: quote (pull-quote + attribution) -->
<figure class="wb-quote">
<blockquote>{{memorable line}}</blockquote>
<cite>{{attribution — optional}}</cite>
</figure>

<!-- Component: accordion -->
<div class="wb-accordion">
<button class="wb-accordion__trigger" aria-expanded="false" aria-controls="acc-1-panel" id="acc-1-trigger">{{summary}}</button>
Expand All @@ -349,10 +384,10 @@ <h2>{{step.title}}</h2>
<div class="wb-tabs__panel" id="panel-1b" role="tabpanel" aria-labelledby="tab-1b" hidden>{{panel B}}</div>
</div>

<!-- Component: annotated code -->
<!-- Component: annotated code (wrap tokens by role: kw/str/num/fn/cmt — see skill) -->
<div class="wb-code">
<button class="wb-code__copy" type="button">Copy</button>
<pre><code>{{code}}</code></pre>
<pre><code><span class="kw">{{keyword}}</span> {{code}} <span class="cmt">{{# comment}}</span></code></pre>
<ul class="wb-code__notes">
<li>{{annotation}}</li>
</ul>
Expand Down Expand Up @@ -415,6 +450,19 @@ <h2>{{step.title}}</h2>
<p class="wb-knob__caption">{{what dragging this teaches}}</p>
</div>

<!-- Component: parametric knob — SPLIT variant (use when the value is one side of an
A-vs-B split, e.g. P(1) vs P(0)). Add `is-split` to the preview + a legend. -->
<div class="wb-knob">
<div class="wb-knob__row">
<span class="wb-knob__label" id="knob-2-label">{{parameter}}</span>
<input type="range" id="knob-2" min="0" max="100" value="50" data-knob aria-labelledby="knob-2-label" aria-describedby="knob-2-val">
<span class="wb-knob__val" id="knob-2-val">50%</span>
</div>
<div class="wb-knob__preview is-split" id="knob-2-preview"></div>
<div class="wb-knob__legend"><span class="lg-a">{{outcome A — the value portion}}</span><span class="lg-b">{{outcome B — the remainder}}</span></div>
<p class="wb-knob__caption">{{what dragging this teaches — name both outcomes}}</p>
</div>

<!-- Component: comparison table (mono uppercase th, good/bad cells) -->
<table class="wb-table">
<thead><tr><th>{{dimension}}</th><th>{{option A}}</th><th>{{option B}}</th></tr></thead>
Expand Down
25 changes: 22 additions & 3 deletions commands/workbook-generate.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` ->
Expand Down Expand Up @@ -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 <output-path>
```

It checks the invariants the artifact must hold: **standalone** (no external
`<script src>` / CDN), **in-memory only** (no `localStorage` / `sessionStorage`
/ `indexedDB` / `postMessage`), **globally-unique element ids** (critical after
a fan-out assembly), and warns on missing a11y landmarks or a missing
`system-ui` font fallback. **Errors exit non-zero — fix them before reporting
the workbook as done.** (If `${CLAUDE_PLUGIN_ROOT}` is unavailable, run it by its
repo-relative path `scripts/validate_workbook.py`.)

## Overlay invocation (post-base-draft)

After producing the base draft for this command (the generated workbook HTML
Expand Down
123 changes: 123 additions & 0 deletions scripts/validate_workbook.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
#!/usr/bin/env python3
"""Validate a generated interactive workbook HTML against its invariants (DOJ-4839).

This is an AUTHOR tool, not a plugin-repo CI gate — generated workbooks live in
the *consumer* repo, so run it on demand against a produced file:

python3 scripts/validate_workbook.py path/to/workbook-<slug>.html [more.html ...]

It enforces the contracts the `workbook-generate` command promises:

ERRORS (exit 1 — the artifact is broken or violates a hard rule):
- Standalone: no external `<script src=...>`, no CDN runtime refs.
- In-memory only (V1): no localStorage / sessionStorage / indexedDB / postMessage.
- Globally-unique element ids (invalid HTML + breaks anchors / aria / label `for`;
the per-module fan-out depends on `m{N}-` namespacing to avoid this).

WARNINGS (reported, do not fail):
- Accessibility landmarks present (>=1 role="region", a role="progressbar").
- A `system-ui` fallback alongside any Google Fonts <link>.

Pure stdlib (re only) — same posture as scripts/ci/*.
"""
from __future__ import annotations

import re
import sys

# CDN only counts as a *runtime dependency* when it's a resource tag's href/src
# (link/script/img/iframe) — not a plain <a> link to an educational resource.
CDN_RE = re.compile(
r"""<(?:link|script|img|iframe)\b[^>]*?\b(?:href|src)\s*=\s*["'](https?://[^"']*(?:cdn\.|unpkg\.com|jsdelivr\.net|cdnjs\.)[^"']*)""",
re.I,
)
SCRIPT_SRC_RE = re.compile(r"<script\b[^>]*\bsrc\s*=", re.I)
SCRIPT_BLOCK_RE = re.compile(r"<script\b[^>]*>(.*?)</script>", re.I | re.S)
CODE_BLOCK_RE = re.compile(r"<(pre|code)\b[^>]*>.*?</\1>", 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)
GFONTS_RE = re.compile(r"fonts\.googleapis\.com", re.I)


def validate(path: str) -> tuple[list[str], list[str]]:
errors: list[str] = []
warnings: list[str] = []
try:
with open(path, encoding="utf-8") as f:
html = f.read()
except OSError as e:
return ([f"{path}: cannot read - {e}"], [])

# --- Standalone ---
if SCRIPT_SRC_RE.search(html):
errors.append(f"{path}: external <script src=...> found (must be self-contained)")
cdn = {m.group(1) for m in CDN_RE.finditer(html)}
if cdn:
errors.append(f"{path}: CDN runtime reference(s): {', '.join(sorted(cdn))}")

# --- In-memory only (V1) — scan SCRIPT CONTENTS only, so prose/code that merely
# *mentions* localStorage (e.g. a lesson about it) doesn't false-fail. ---
script_src = "\n".join(m.group(1) for m in SCRIPT_BLOCK_RE.finditer(html))
state_hits = sorted({m.group(1) for m in STATE_RE.finditer(script_src)})
if state_hits:
errors.append(
f"{path}: persistence/parent-comm API(s) used in script (V1 is in-memory only): "
+ ", ".join(state_hits)
)

# --- Globally-unique ids — exclude <pre>/<code> so sample-code placeholder ids
# inside lessons don't count as real document ids. ---
html_no_code = CODE_BLOCK_RE.sub("", html)
seen: dict[str, int] = {}
for m in ID_RE.finditer(html_no_code):
seen[m.group(1)] = seen.get(m.group(1), 0) + 1
dupes = {k: v for k, v in seen.items() if v > 1}
if dupes:
listed = ", ".join(f"{k} (×{v})" for k, v in sorted(dupes.items())[:10])
more = "" if len(dupes) <= 10 else f" (+{len(dupes) - 10} more)"
errors.append(f"{path}: duplicate element id(s): {listed}{more}")

# --- Warnings: a11y landmarks ---
if not ROLE_REGION_RE.search(html):
warnings.append(f'{path}: no role="region" landmarks found (steps should be regions)')
if not ROLE_PROGRESS_RE.search(html):
warnings.append(f'{path}: no role="progressbar" found (progress indicator expected)')

# --- Warnings: font fallback ---
if GFONTS_RE.search(html) and "system-ui" not in html:
warnings.append(f"{path}: Google Fonts <link> present but no system-ui fallback in the stack")

return (errors, warnings)


def main(argv: list[str]) -> int:
paths = argv[1:]
if not paths:
print("usage: validate_workbook.py <workbook.html> [more.html ...]", file=sys.stderr)
return 2

total_err = 0
total_warn = 0
for path in paths:
errors, warnings = validate(path)
total_err += len(errors)
total_warn += len(warnings)
for e in errors:
print(f"ERROR {e}")
for w in warnings:
print(f"WARN {w}")
if not errors and not warnings:
print(f"OK {path}: standalone, in-memory-only, unique ids, a11y landmarks present")
elif not errors:
print(f"OK {path}: no errors ({len(warnings)} warning(s))")

print(
f"\nValidated {len(paths)} file(s): {total_err} error(s), {total_warn} warning(s)."
)
return 1 if total_err else 0


if __name__ == "__main__":
raise SystemExit(main(sys.argv))
6 changes: 5 additions & 1 deletion skills/workbook-generate/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@ preferences do.
| Component | Purpose |
|---|---|
| **step / section** | A unit of the flow. Each `H2` of a reading is typically one step. |
| **lede** | A one-line lead paragraph (`.wb-lede`) that frames a step before the body. Bigger, muted; sets up what the step is about. |
| **statement / callout** | A bordered emphasis block for a load-bearing sentence; key words in `<strong>` render in the accent. |
| **quote** | A pull-quote (`.wb-quote` `<blockquote>` + optional `<cite>`) for a memorable/authoritative line. |
| **progress bar** | Course-level and per-module completion %. Updates as the learner advances. |
| **completion event** | Fired (not persisted) when the learner reaches the end. A hook point for V2. |

Expand Down Expand Up @@ -136,14 +139,15 @@ preferences do.

| Component | Purpose |
|---|---|
| **code block** | Syntax-styled code with a copy button (with a `file://` clipboard fallback) and optional margin annotations / line callouts. High value for a coding curriculum. |
| **code block** | Syntax-styled code with a copy button (with a `file://` clipboard fallback) and optional margin annotations / line callouts. High value for a coding curriculum. **Syntax highlighting** is dependency-free: wrap tokens by role in `<span>`s — `kw` (keyword), `str` (string), `num` (number), `fn` (function/type), `cmt` (comment). Fixed legible hues on the dark code surface (not brand tokens). |
| **code diff** | A before -> after variant of the code block: removed lines (`.diff-del`, struck through) and added lines (`.diff-add`) banded inline. Teaches *the change*, not just the result — the core move of teaching code. |

### Parametric knob (the reliable "felt" interaction)

| Component | Purpose |
|---|---|
| **knob** | A slider whose value writes a CSS custom property (`--knob`) that a preview element consumes and a readout mirrors. Lets a learner *drag and watch the effect* — the felt interactivity of the reference site's simulations, delivered through a fixed, tested component rather than bespoke per-concept JS. In-memory only. |
| **knob (split-bar)** | The `is-split` preview variant: a full-width **A-vs-B bar** where the value portion is the accent and the remainder is `--wb-accent-2`, with a 2-item legend. Use when the value is one side of a split (e.g. P(1) vs P(0)) so the bar matches a caption that names both outcomes. Same `--knob` wiring — no extra JS. |

### Course navigation

Expand Down
Loading