# Changelog

## [Unreleased]

## 7.82.0

### Added
- **file-remediation: heading-promotion two-phase manifest CLI** (`scripts/heading_promote.py`, docs/plan-heading-promote.md) — the mandatory next stage when a tagged PDF has zero (or too few) `/Hn` struct elements. veraPDF checks structural *validity*, never whether headings exist, so a document can be 106/106 compliant while every element is tagged `/P` (confirmed live: `chapter10-remediated.pdf` — 854 `/P`, 0 `/H`), and the Ally checker correctly flags "This PDF does not have headings" on exactly this kind of file. Runs on the already-tagged E3 output — never re-tags — so the candidate set stays stable across phases. `pdf_tree_edit.heading_candidates()` clusters every non-heading, non-Figure/Link leaf struct element's text by font signature (rounded size, bold, fill rgb, base font name), excludes the document's body-text signature (the most common signature among small/non-bold/dark spans), and returns one occurrence per surviving candidate with **`level: null`** — the script never concludes a cluster IS a heading or picks its level; font size is evidence, not a verdict (a large word mid-sentence isn't a heading, a colored label might not be one either). `--build-manifest` writes `heading-manifest.json` plus a `font_size_rank` summary (distinct clusters, largest first) for the AI to see relative sizing at a glance. `--apply` (script) promotes every occurrence with a non-null level via the new `pdf_tree_edit.promote_headings()` (resolves the owning leaf struct element by page+mcids, retags it `/Hn`, then runs `normalize_heading_levels()` to clamp any level jump), backs up first, saves, and re-verifies: veraPDF must still be compliant **and** at least one heading must be present — a manifest left all-`null` (or a post-apply veraPDF regression) restores the pre-apply backup and reports it, never a false success. Retagging changes only a struct element's `/S`, never the content stream, so `get_text()` stays byte-identical and rendered pixels are unchanged. `skills/file-remediation/SKILL.md` documents the mandatory stage (script clusters → AI assigns levels → human approves → apply).

## 7.81.2

### Fixed
- **`scripts/pdf_contrast.py`: background-aware contrast remediation** (docs/plan-contrast-bg-aware.md) — v1 assumed every text span sat on a white page background, so text inside colored callout bars / table-cell fills was measured wrong. Confirmed on a real file via a third-party checker (Ally): a white "Explore 10.1" label was actually **white-on-pink** (real ratio 3.50, failing), which the white-only detector never saw. Worse, because of that, the old tool "fixed" it by darkening the label to **grey — which measures 1.30 against the pink fill**, actively degrading already-imperfect text. v2 detects each span's REAL background (topmost covering fill rect from `page.get_drawings()`, or an image → `unfixable`, never guessed) and **skips anything already passing against that real background** (mandatory — this is what makes the white→grey regression impossible). For a failing span it darkens the TEXT (white/near-white bg, or text already the darker element on a fill) or darkens the **FILL** instead, leaving text color untouched, whenever text is the *lighter* element on a colored fill — the callout-bar case. New fill-recolor path mirrors the existing text-recolor mechanism (same content-stream rewrite, gated on the opposite classifier branch — path-paint instead of text-show).
- **`scripts/pdf_contrast.py`: boundary-adjacent fixes now carry headroom** — fixed spans previously targeted exactly 4.5:1 and landed at 4.51–4.55, which a third-party checker computing luminance/rounding slightly differently still flagged. New `safety_margin` parameter (default **5.0**) sets an effective target of `max(target_ratio, safety_margin)`, so every fixed span/fill now lands with real headroom, not on the boundary. `remediate_contrast()`'s existing positional signature is unchanged; `--safety-margin` is a new optional CLI flag.
- Result dict gains `fills_darkened` (fill recolors, `{from_rgb, to_rgb, pages, ratio_after}`), `effective_target_ratio`, `safety_margin`, and `already_passing`; `unfixable` entries now also cover text-over-image backgrounds and fill/text combinations that can't reach target by darkening alone (never faked as fixed).

## 7.81.1

### Fixed
- **`scripts/pdf_contrast.py`: CMYK `k`-operator text never got recolored** — the detector (`page.get_text("dict")`) reports CMYK color as RGB via PyMuPDF's own color-management conversion, but the content-stream rewrite loop converted `k` operators to RGB with a naive `255*(1-c)*(1-k)` formula. The two diverged enough (e.g. `0.16 0.74 0 0 k` → naive `(214,66,255)` vs. actual `(206,100,166)`) that the ≤2-tolerance RGB match never fired, so every genuinely-fixable CMYK heading was misreported as `unfixable`. Fixed by converting `k` (and the existing `rg`/`g` path is unaffected) through **fitz's own CMYK→RGB pipeline** (a 1×1 `fitz.Pixmap` round-tripped `csCMYK` → `csRGB`) so the rewrite side matches the detection side exactly, instead of reimplementing the conversion.
- **`scripts/pdf_contrast.py`: text/path classifier false-matched path-paint letters inside TJ string literals** — `_classify_occurrence`'s lookahead regex for `f`/`F`/`S`/`s`/`B`/`b`/`n` searched the raw content-stream bytes, so a kerned `TJ` array that splits a word across fragments (e.g. `[ (Incorporate f) 12.1 (eedback) ] TJ`) had its trailing `f` inside the paren-delimited string mistaken for the path-fill operator `f`, misclassifying the occurrence as `"path"` and blocking the rewrite. Fixed by masking the interior of `(...)` string literals (balanced parens, backslash escapes respected) before running the classifier regexes, so only real operators outside string data can match.
- Together these two bugs left CMYK-set low-contrast text in real course PDFs unfixable; on a real 14-page chapter file, remediation now fixes all 17 previously-detected low-contrast CMYK/RGB spans (was 0 fixed / all unfixable) with `ok: true` and zero remaining failures on re-probe.

## 7.81.0

### Added
- **file-remediation: figure-alt two-phase manifest CLI** (`scripts/figure_alt.py`, docs/plan-figure-alt.md) — the mandatory next stage when the placeholder-alt gate (7.79.0) sets `needs_manual`. Runs on the already-tagged output so figure indices stay stable (no re-tag). `--build-manifest` renders every `/Figure` to a PNG and writes `figure-alt-manifest.json` carrying every context field `pdf_tree_edit.list_figures()` now surfaces — width/height, `surrounding_text` (~200 chars each side, from struct-order reading position), `reading_order_index`, `repeat_count` (same Image XObject reused elsewhere in the doc — a branding/decorative signal), `in_link`/`in_heading` (ancestor nesting), and `mcids`. **Every `disposition` is `null`** — the script never concludes decorative vs. content, not even for a 14×14 image; width/height are evidence handed to the AI, never a decider. `--apply` backs up the PDF, applies `set_alts` (describe) / the new `pdf_tree_edit.demote_figures_to_artifact` (decorative — generalizes the by-hand `demote_mcids_to_artifact` call to a 0-based figure-index list), saves, and re-verifies veraPDF compliance **and** zero remaining placeholders; any figure left at `disposition: null` or a `"describe"` with empty/whitespace/placeholder alt is refused (`needs_manual`), and a failed re-verify restores the pre-apply backup wholesale rather than shipping a half-fixed file. Mirrors `audit_image_alt.py`/`apply_image_alt.py`'s manifest-for-agent shape — this is their PDF sibling. `skills/file-remediation/SKILL.md` documents the mandatory 4-step stage (script → AI annotation → human approval → apply).
- **file-remediation: `--fix-contrast` wires `pdf_contrast.py` into `remediate_pdf.py`** (opt-in, off by default) — the standalone WCAG 1.4.3 recolor shipped in 7.80.0 can now run as the final step of the E2/E3 pipeline. It runs strictly **after** `gates_ok` (veraPDF + fidelity) already passed, never before: recoloring text changes pixels by design, and the exact-zero `pixel_diff` fidelity gate would wrongly reject that as a regression if contrast ran earlier. Re-runs veraPDF on the recolored file as its own safety net; a regression there discards the recolor and keeps the tagged-only output (`result.contrast.skipped`) without failing the overall remediation. Contrast is not a PDF/UA checkpoint, so it never affects `gates_ok` either way — it only adds `result.contrast` on top of an already-compliant file. See `skills/file-remediation/SKILL.md` Stage 2 for details.

## 7.80.0

### Added
- **file-remediation: PDF text-contrast remediation** (`scripts/pdf_contrast.py` — `remediate_contrast()` + CLI) — fixes WCAG 2.1 AA §1.4.3 low-contrast **text** in PDFs. Detects failing text spans via PyMuPDF `get_text("dict")`, computes a **hue-preserving** darkened replacement (HLS lightness binary-searched to the minimal darkening that clears the target ratio, default 4.5:1), and rewrites only the matching nonstroking fill-color operators in each page's content stream that are followed by a text-show operator — never a path-paint op, so decorative vectors sharing a heading's color are left untouched. Glyphs, fonts, and positions are preserved exactly; only the fill color changes (verified text byte-identical before/after).
  - **Per-occurrence, not global value substitution.** Each color operator is classified by its next painting operator (text vs path); mechanical color-value matching alone would recolor graphics that happen to share the color.
  - **Honest about what it can't fix.** A failing color set via `scn`/pattern/ICC separation, or one that precedes a path paint rather than text, lands in `unfixable` with a reason — never silently claimed fixed. `ok` is true only when no meaningful failure remains and `unfixable` is empty.
  - Handles `rg` (RGB), `g` (single-number gray), and `k` (CMYK) nonstroking fill operators; whitespace-only spans (invisible spacers) are skipped, not counted as failures.
  - **Appearance-changing by design**, so it deliberately bypasses the pixel-identity fidelity gate (which would fail on the intended color change). Shipped standalone (CLI + importable function); not wired into `remediate_pdf.py`'s tagging pipeline. Verified on a real 10-page course memo: green/orange/pink headings raised from 2.05–3.08:1 to ≥4.5:1, 201 black body spans untouched.

## 7.79.1

### Changed
- **`skills/file-remediation/SKILL.md`: document the two gates the hard gates cannot see** — `reading_order.py` (v7.78.0) and the placeholder-figure-alt gate (v7.79.0) both shipped without any mention in the skill file, so a future run had no way to know they exist. Written instructions reloaded into context are the only path from a lesson to a future session; a tool nobody knows about is not a capability. Also records the caveat that a large reading-order diff is not automatically a regression — a source with ~0 struct-order runs was never really tagged, so 0 → many is recovery, not loss.
- **Records the Indexed-palette truncation defect as a known upstream issue** — the re-tag can truncate an image's Indexed colour-space palette (768 → 766 bytes), making the renderer misread the whole lookup table so the image renders a solid wrong colour, while the image stream itself is unchanged. The fidelity gate catches it (surfaced as a 1.04% page diff), so nothing corrupt ships. Deliberately **not** auto-repaired: the gate already contains it and n=1. Documented because the symptom looks nothing like the cause and rediscovering it costs far more than reading it.

## 7.79.0

### Added
- **file-remediation: placeholder figure-alt detection** (`scripts/pdf_tree_edit.py` — `list_figures()`, `is_placeholder_alt()`; gated in `remediate_pdf.py`) — the pipeline shipped a file that veraPDF called **106/106 compliant** in which all 12 figures carried the alt text `"image 1"` … `"image 12"`. The document was *Understanding Visual Rhetoric*, an essay whose figures are the argument; a screen-reader user would hear "image 7" where the content lives. **No gate caught it**: veraPDF clause 7.3 is satisfied by an `/Alt` entry *existing* and never inspects whether it is useful, pixel fidelity sees no change, and reading order sees no change.
  - Worse, the placeholders **overwrote information already in the document** — every figure had an author-written printed caption (*"Figure 1. A cheeseburger is held in a close-up shot."*, *"Figure 8. A row of three giraffes, ranging from a small giraffe to a larger one, line up outside of a doorway."*). Real descriptions, discarded and replaced with `"image N"`.
  - `list_figures()` reports per figure: `index`, `page`, `alt`, `is_placeholder`, `caption_candidate`. A placeholder now sets `needs_manual` with the caption candidates attached, so `judgment.alt_text` can be filled from the author's own words. **This gate deliberately rejects files veraPDF passes** — a figure whose alt is `"image 1"` is not remediated, and reporting success is the failure this fixes.
  - **Captions are surfaced, never auto-applied.** Association is by per-page vertical order and only when the figure count matches the caption-block count; otherwise `""`. A wrong caption becomes wrong alt text, which is worse than none — same manifest-for-agent doctrine as `audit_image_alt.py` and `pdf_table_weave.py`'s opt-in contract.
  - Over-flagging is the failure mode that would make the gate ignored, so `is_placeholder_alt` matches only whole generic strings: `"image 1"` and `"Untitled"` flag; `"Image of a menu for Oren's Hummus with three columns"` and `"Figure showing the water cycle"` do not.
  - De-hyphenation handles both ASCII `-` and **U+00AD soft hyphen** — the real corpus file uses the latter, which an ASCII-only pattern would have silently missed.

## 7.78.0

### Added
- **file-remediation: reading-order verification** (`scripts/reading_order.py`) — `struct_order_text()` / `struct_order_runs()` extract a tagged PDF's text in **struct-tree order**, which is reading order, plus a `--diff BEFORE AFTER` CLI. This closes the gap named in 7.77.0: veraPDF checks structural validity and the fidelity gate checks rendered pixels, so a remediation that silently changes what a screen reader reads passes both. Reading order is the accessibility payload of a tagged document and nothing verified it.
  - **It found a live regression on its first run.** Two real course PDFs (`taczak`, `cohn Understanding Visual Rhetoric`) came out of the E2 re-tag at veraPDF 106/106 with 0.0% pixel diff — and with their running headers moved from `/Artifact` into the reading flow: `"WRITING SPACES 4"` went from 0 to 14 runs, `"WRITING SPACES 3"` from 0 to 21. A screen reader would announce the header on every page.

### Fixed
- **file-remediation: artifact status is preserved across the E2 re-tag** (`scripts/remediate_pdf.py` `_preserve_artifact_status`, `pdf_tree_edit.demote_mcids_to_artifact`) — text that was an artifact in the source and returns as tagged content is demoted back: the marked-content sequence is rewritten to `/Artifact BMC`, the owning struct element is removed from the tree, and its ParentTree slot is nulled.
  - **Detection is by page distribution, not frequency.** A running header appears **at most once per page across ≥3 pages**; repeated body text **clusters on one page**. Measured on the real file: `"WRITING SPACES 4"` = 14 occurrences over 14 distinct pages, max 1 per page; `"What"` = 5 occurrences on 1 page. The first heuristic tried (raw occurrence count ≥3) failed review — it also demoted the bold lead-in word of parallel bulleted list items, so a screen reader would have read *"is the purpose"* instead of *"What is the purpose."* Worse than the bug it fixed. The docstring names that false positive so the rule is not "simplified" back to a count.
  - **Guarded at <50 source struct-order runs.** Three of the test files arrived essentially untagged (28 runs) and legitimately gained 461–667. Without the guard every new run looks leaked and the entire document body would be demoted to artifact.
  - Live proof: taczak 14 → 0 and cohn 21 → 0 with body text intact and rendered page text byte-identical; carroll's guard fires and its output still reaches 667 runs. All three stay veraPDF 106/106 with identical fidelity.

## 7.77.0

### Added
- **file-remediation: table fragment merging** (`scripts/pdf_table_weave.py` — `merge_tables()`, judgment key `table_merge`) — the last unbuilt member of the table-remediation family. Taggers routinely split ONE logical table into several sibling `/Table` elements; adjudication against real course PDFs hit this four separate times. The header row lands in the first fragment and every later fragment is left **headerless**, so its data cells have no column headers at all — and promoting headers on a headerless fragment would tag a data row as headers, which this module already refuses to do. Those fragments simply could not be remediated. `merge_tables` re-parents `/TR` rows (descending through row groups) onto a survivor, moves donor `/Caption`s, removes emptied donor `/Table`s leaving no orphan group shells, and remaps the ParentTree per page. Rows are **re-parented, never rebuilt** — tests assert the original StructElems survive by `objgen`, so text cannot drift.
  - Opt-in per merge. Refuses (never raises) on differing effective column counts, fewer than two indices, out-of-range or repeated indices. Joins `table_headers`/`table_to_list`/`table_to_figure` in `_validate_judgment`'s co-occurrence guard, since merging removes tables and shifts every later index; the two-pass workflow is merge → re-run `list_tables` → act.
  - **MERGE ORDER IS READING ORDER, and no gate can check it.** A non-ascending merge is refused by default (`force` overrides). Found in review: a demonstration merge specified `[7,4,5,6]` to put a header at row 0 and silently placed page 12's body rows ahead of page 11's. veraPDF saw structurally valid markup; the fidelity gate saw identical pixels, because a tag-tree-only merge moves nothing on the page. Reading order is the entire accessibility payload of a tagged table and is the one property nothing in this pipeline verifies — the third instance of "every gate green, semantically wrong," after `/Headers` attribute placement and WinAnsi-to-Unicode mapping.
- **file-remediation: every page a table spans is reported and rendered** (`scripts/pdf_table_weave.py` `list_tables()` gains `pages`; `scripts/judgment_eval.py` gains `renders`) — `page` reported only the first cell's page, so a table crossing a page break was reviewed from a picture that could not show it. Concrete failure: a syllabus grading table repeats its header row mid-table because it continues onto the next page, the manifest's `grid` showed both header rows, and the render showed only the first — a human reviewer had to infer the second. `pages` lists every page the cells resolve to; `--render-dir` writes one PNG per page and `renders` lists them in order. `page` and `render` are unchanged and still point at the first page. Corpus: 7 of 81 tables span multiple pages (six span 2, one spans 16).

## 7.76.0

### Added
- **file-remediation: unambiguous `/ColSpan` synthesis** (`scripts/pdf_table_weave.py` — inside `promote_headers()`) — v7.75.1 made the ragged check span-*aware*, dropping the corpus from 21 ragged tables to 6. The remaining 6 were ragged because the tagger recorded **no spans at all** on a spanning cell. In every one, each short row holds **exactly one cell** — and a lone cell in a table whose other rows are N columns wide can only span N columns, so the inference needs no geometry and cannot be wrong. Those rows now get `/ColSpan` written before the ragged check, and the table promotes normally.
  - **A short row with 2+ cells is never synthesized and is still refused.** Which cell spans is unknowable without rendered geometry, and guessing would write a false structure that veraPDF accepts while assistive technology reads it wrongly — the "compliant but wrong" class this codebase has been bitten by three times. The corpus contains zero such rows; refusing keeps it that way.
  - Not a new opt-in: `promote_headers` is already opt-in per table, and synthesizing an unambiguous span is part of performing that promotion correctly. `list_tables()` still reports the file's actual recorded state. `force` still bypasses refusal for genuinely ambiguous tables. Return dict gains `spans_synthesized`.
  - Corpus effect: **all 6** previously-ragged tables now promote, **0** still refused. Live veraPDF on the PMP banner table (`DOMAIN I PEOPLE—33%` over `Task 1..7`): ragged `True → False`, passed checks 74985 → 74986, no clause regressed.
  - Fixed a latent hazard the change exposed: the legacy promotion path replaced a cell's `/A` wholesale and would have clobbered a freshly-synthesized `/ColSpan` in the same call — now uses the shared get-or-create helper, matching the hierarchical path. Third defect traced to that attribute dictionary.
- **judgment_eval: scores the action spec, not just the class** (`scripts/judgment_eval.py`) — the harness measured whether a `/Table` was classified correctly, which across four adjudicated batches against real course files scored **22 of 23**. But the engine consumes more than a class: for a `data_table` it consumes `header_rows`, `header_cols` and `band_rows`, which decide which cells become `/TH` and how `/Headers` associations are written — and **header structure was right only 11 of 18 times.** The harness would have reported ~96% while a third of the work was wrong, which is worse than no metric because it licenses exactly the unjustified confidence the instrument exists to prevent.
  - Adds a `spec` block reported in its own clearly separated section: `scorable`, `exact`, `exact_accuracy` (`None`, never `0.0`, when nothing is scorable), `by_key` per `header_rows`/`header_cols`/`band_rows` so it is visible *which* part of the structure is wrong, and a file-level `cluster95` bootstrap (tables cluster by PDF, so an independence-assuming interval is overconfident here for the same reason it was for classification).
  - **Items whose class was wrong are not spec-scorable** (`skipped_wrong_class`) — you cannot grade the header structure of a table the judge did not think was a table. **A bare-class judgment against a spec-carrying label is excluded too** (`judgment_missing_spec`), counted as neither correct nor an error, parallel to how abstention is excluded from `acted_accuracy`; it is reported next to `scorable` so declining to specify stays visible instead of inflating a small denominator.
  - Backward compatible: a judgments file of bare class strings scores exactly as before, and every existing metric keeps its name and meaning.

## 7.75.1

### Fixed
- **pdf_table_weave: the ragged check ignored recorded `/ColSpan` and `/RowSpan`** — `_is_ragged` counted raw cell elements per row while its own docstring quoted the clause it protects against, veraPDF 7.2, as *"table rows shall have the same number of columns, **taking spans into account**."* It cited the spans and did not count them, so a cell with `/ColSpan 2` counted as one column and its row looked short. `promote_headers` then refused the table. Now the effective column count sums each cell's `/ColSpan` and carries in any earlier row's `/RowSpan` still reaching that row. **Guard policy is unchanged** — genuinely ragged tables are still refused by default and `force` still works; only the classification is now correct.
  - Corpus effect (22 documents / 81 tables): tables flagged ragged **21 → 6**. Archetype: an NHST confusion matrix whose banner row is two cells at `/ColSpan 2` — four columns, matching its data rows — with a `/RowSpan 2` corner cell; raw counts `[2,2,4,3]` vs span-aware `[4,4,4,4]`.
  - **Verified against the hazard, not just the logic:** that file is PDF/UA-1 compliant before, promotes 8 cells with `header_rows:[0,1]` / `header_cols:[0,1]` and no `force`, and remains compliant with zero failed clauses after. The guard had been refusing it for no reason.
  - Note this is a *read* fix. Synthesizing `/ColSpan`//`RowSpan` where a tagger omitted them remains unimplemented.

### Changed
- **`standards/pdf-table-judgment.md`: context escalation** — the decision procedure now widens only on need: judge from the current page (grid, caption, render); read `page_context` at ±1 only when that does not resolve; go wider only when that does not. Both failure modes are documented from real cases — reflexively pulling pages wastes context, and refusing to pull any produced a misclassification (a tools-reference table read as `layout_wrapper` when the next page showed it was one row of a category → tools → use-cases → specs table). Also adds the unattached-title rule: a table's real title is often ordinary page content outside the `/Table`, and a converted list belongs under that heading.

## 7.75.0

### Added
- **file-remediation: 2D grid preview in the table manifest** (`scripts/pdf_table_weave.py` — `list_tables()` gains `grid`) — `first_row_text` and `first_col_text` are two flat lists, a shape that **cannot represent a spanning cell**, and that limitation caused a measured misjudgment. On a real PLOS statistics table (Cooper, table 7) column 0 holds row-spanning category cells, so for the spanned rows the *first* cell is column 1 content and `first_col_text` interleaved two columns into one list: `['', 'Unfunny humor (n = 1381)', 'No effect (ref: Decreased se', 'Offensive humor (n = 153)']`. An instructional designer adjudicating the table ruled **both** column 0 and column 1 are header columns; the agent reading the manifest specified only `header_cols:[0]`. The judgment was not careless — the correct answer was unrepresentable in the data it was given. `grid` emits the first 8 cell-bearing rows × 4 columns as an actual 2D array of `{text, tag}` with `colspan`/`rowspan` surfaced, ragged rows left unpadded (raggedness is diagnostic signal). `first_row_text`/`first_col_text` are unchanged; this is purely additive.
- **`standards/pdf-table-judgment.md`** — authoritative, ID-adjudicated rules for classifying a tagged `/Table` (`data_table` / `layout_table` / `layout_wrapper` / `figure`), determining header axis and depth, and detecting headerless fragments. Encodes 20 rulings from three randomized batches over a 22-document corpus, each anchored to a W3C PDF technique or PDF 32000-1 clause. Referenced from `skills/file-remediation/SKILL.md`. Nothing in this pipeline trains a model — written rules reloaded into context are the only mechanism by which an adjudication reaches a future run.
  - Load-bearing rules: the header-axis test is *"does this cell identify its row?"*, not *"is this value data?"* (a schedule's date column IS a header column — supersedes an earlier narrower reading); headers are hierarchical (main + band + nested sub-columns), which `/Scope` cannot express; a caption is decisive evidence, including numeric-garbage captions that are axis tick labels and mean **figure**; a continuation chunk that repeats its header is safe to promote while one that does not is a headerless fragment that must not be; `layout_wrapper` is a real class whose title becomes a heading, not a list item.
  - Also records what the engine *cannot* do (no `/ColSpan` or `/RowSpan` synthesis, no fragment or cross-table merging) so the limits are read rather than rediscovered, and why gates miss semantic errors — three defects in this line of work validated clean while being functionally wrong.

## 7.74.0

### Added
- **file-remediation: hierarchical table headers via `/ID` + `/Headers`** (`scripts/pdf_table_weave.py` — `promote_headers()`, new judgment keys `header_rows`/`header_cols`/`band_rows`) — `/Scope` is single-axis and single-level, and real course tables are not. An adjudicated review of a random sample found the AI classifying tables correctly 11/11 while header *structure* was right only ~6/10 — and roughly half those misses were shapes the engine **could not express at any judgment quality**: a rubric with a row-0 main header over column-0 sub-headers; the PMP outline's `DOMAIN I` banner over `Task 1..7` row headers; a PLOS statistics table with three levels (main header row → full-width `Dependent variable = ...` band rows → column-0 sub-headers within each band). Header cells now receive a document-unique `/ID`; each data cell receives a `/Headers` array naming its column header(s), its row header(s), and the **nearest preceding** band header. Opt-in per table; the legacy `header_row`/`header_col` boolean path is byte-for-byte unchanged when the new keys are absent.
  - **KNOWN LIMITATION** — a spanning main-header row has fewer cells than its data rows, so it always reads `ragged` and the veraPDF-7.2 guard refuses it; `force: true` is the interim workaround. Measured on the 22-document corpus: **8 of 80 tables are blocked this way, 59 are unaffected, 13 are ragged for other reasons.** Emitting `/ColSpan` on the spanning cell is the proper fix and remains unimplemented.
- **file-remediation: `page_context` in the table manifest** (`scripts/pdf_table_weave.py` — `list_tables(pdf, path=None)`) — a table judged in isolation hides its own context. A real course rubric on page 4 looked standalone; page 3 carried the same rubric's earlier rows, making it a continuation fragment, and promoting headers on a fragment tags a data row as headers — the "actively misleading" case this module already warns about. Each record now carries `prev_page`, `next_page`, `same_page_before`, `same_page_after`. `list_tables(pdf)` with a single argument is unchanged and yields empty context.
  - `prev_page`/`next_page` are reliable; the same-page halves anchor on the table's first-cell text via `search_for()` and return `""` on an ambiguous or missing match rather than guess — a wrong context string misleads the exact judgment it informs.

### Fixed
- **pdf_table_weave: `/Headers` written outside the `/Table` attribute dictionary** (pre-merge review) — `/Headers` was set as a top-level key on the cell's structure element rather than inside its `/A` dict with `/O /Table`, where PDF 32000-1 §14.7.5.4 (Table 349) defines it. The tell was internal inconsistency: the same code already wrote `/Scope` correctly into `/A`, and `Scope` and `Headers` are siblings with the same owner. **veraPDF does not verify attribute placement, so the file validated cleanly either way** — structurally compliant, functionally inert, invisible to a conforming reader. New `_table_attrs()` gets-or-creates the `/Table`-owned dict, handling `/A` absent, a single foreign-owner dict, or an array of owner dicts, without dropping existing attributes.

## 7.73.0

### Added
- **file-remediation: real cell text in the table manifest** (`scripts/mcid_text.py` new, `scripts/pdf_table_weave.py`) — `list_tables()` was near-blind: `_cell_text()` read only a cell's `/ActualText`//`Alt`, which real-world PDFs essentially never set, so `first_row_text` came back as empty strings on **all 81 tables of a 22-document corpus**. The text lives in the page content stream addressed by marked-content ID, and PyMuPDF exposes no MCID (verified: 0 of 262 `get_texttrace()` spans carry one), so `mcid_text.page_mcid_text()` parses the content stream directly with pikepdf — tracking the `BDC`/`EMC` stack, accumulating `Tj`/`TJ`/`'`/`"` text, and decoding via the font's `/ToUnicode` CMap with a WinAnsi/MacRoman encoding fallback. `_cell_text()` keeps its existing `/ActualText`//`Alt` precedence exactly and only falls back to MCID text when both are absent.
  - **`first_col_text`** (new) — column-1 content for the first 8 cell-bearing rows. Origin: on 3 of 5 data tables in a random adjudicated sample the header cells are in **column 1, not row 1**, and the manifest had no field describing column 1 at all — so the agent could not see the header axis and applied the wrong one. Corpus effect: tables with real column-1 text **0 → 71 of 81**.
  - **`caption`** (new) — the table's `/Caption` text, decisive evidence for table-vs-figure ("Table 1. …" vs a figure's caption). 13 of 81 carry one; `Table 3. (Continued)` also identifies continuation fragments.
  - **`layout_wrapper`** added to `judgment_eval.py`'s classes — a single-cell bordered box used to emphasize a paragraph. `layout_table` is wrong for it because that implies list conversion and there is no list; the correct treatment is to unwrap. Excluded from `harmful` (no unwrap action exists yet, so a wrong call triggers no rewrite); move it into the actionable set when one does.

### Fixed
- **mcid_text: cross-`Pdf` cache collision** — caching parsed page text by `pikepdf.Object.objgen` is unsafe: object numbers (and `==`) compare equal across *different* `Pdf` instances, which is the common case for small files. In a batch sweep that would have silently leaked one file's cell text onto another file's page — invisible, and corrupting the exact evidence being added. Keyed on true owning-`Pdf` identity (`same_owner_as`) instead.
- **pdf_table_weave: caption resolved to a `/Link` placeholder** — a `/Caption` whose only child is a tagger-inserted `/Link` with the generic `/Alt="Annotation"` resolved to the literal string `"Annotation"` rather than the real caption, because caption text inherited `_cell_text`'s descend-into-children precedence. Worse than empty: caption is meant to be decisive evidence, so a confident wrong string actively misleads the judgment it informs. New `_caption_text()` uses caption-specific precedence (own `/ActualText` → own `/Alt` → own subtree's MCID text) and never takes a descendant's `/Alt`. Same root cause as the junk-`/Link`-row hazard in `promote_headers`.
  - Known limitation, investigated and deliberately not "fixed": consecutive MCIDs are joined with a space, which is required for genuinely separate words (`Decline`/`to`/`state`) but inserts a spurious space when a tagger splits one word across MCIDs (`Race/ethnicit` + `y`). The two cases are indistinguishable from per-MCID text alone; separating them needs text-positioning analysis. Cosmetic only — `T able 1.` still reads as `Table 1.` for judgment purposes.

## 7.72.1

### Fixed
- **pdf_table_weave: /THead/TBody/TFoot row-group traversal** — `list_tables`, `promote_headers`, and `convert_tables_to_lists` enumerated a table's rows as the direct `/K` children of `/Table`, assuming `/Table` → `/TR`. The PDF spec's standard tagged-table structure groups rows under `/THead`/`/TBody`/`/TFoot` (`/Table` → `/TBody` → `/TR` → `/TD`) instead, and real taggers emit it routinely — measured on a 22-document corpus, 30 of 81 tagged tables (37%), across 10 of 22 files. For such a table the old code returned `[THead, TBody]` as if they were rows: `cell_rows`/`cols` came back 0, `header_row_index` came back `None`, and `page` came back `None` — a silent no-op on every engine that touches that table. New `_table_rows()` helper resolves rows in document order, descending one level through row groups while still accepting a `/TR` that's a direct child of the table (a mixed group-plus-loose-`/TR` shape is picked up correctly); `convert_tables_to_figures` already worked correctly (its MCID walk recurses through any struct-element child) and needed no change. Verified against a real corpus file (`New-PMP-Examination-Content-Outline-2026.pdf`, `/Table` → `['/THead', '/TBody']`): `list_tables()` went from `cell_rows: 0, cols: 0, page: None` to real row/column counts and a resolved page number.

  - Corpus effect measured on the same 22 documents: tables with an unresolved page **31 → 5**, tables reporting zero cell rows **30 → 1**. 48 of the 81 already carried proper `/TH` — correctly tagged all along, and invisible to the engine.

## 7.72.0

### Added
- **file-remediation: AI-classification accuracy harness** (`scripts/judgment_eval.py`) — measures how accurately the in-session agent classifies a tagged PDF's `/Table` elements as what they actually ARE (`data_table` / `layout_table` / `figure` / `other`), which is the judgment the three shipped table-conversion engines act on (`promote_headers`, `convert_tables_to_lists`, `convert_tables_to_figures`). Until now that accuracy was asserted, never measured. Purely mechanical — **no LLM call anywhere in the file**, consistent with the manifest-for-agent doctrine: `--candidates` sweeps PDFs and emits a labelling manifest (reusing `pdf_table_weave.list_tables`, optional `--render-dir` page PNGs); `--score` compares an agent's classifications against human ground truth.
  - **Abstention is a first-class outcome.** An item the agent declines to classify (`"unsure"`, or simply absent) is scored as abstained, never as an error — so `acted_accuracy` measures accuracy *on what the agent committed to*, reported alongside `coverage`. This makes the accuracy/coverage tradeoff visible instead of forcing a guess on every item. Requires no engine change: the engine already acts only on what a judgment lists.
  - **`harmful`** isolates wrong classifications that drive a *structural rewrite* of the PDF (judged ≠ truth and judged ≠ `other`) — harm is a property of the action taken, not of the truth class, because every non-`other` class triggers a rewrite that is destructive when wrong.
  - Reports the **cluster-bootstrap** interval as the headline, with the uncorrected Wilson interval shown beside it labelled as assuming independence.

### Fixed
- **judgment_eval: measurement-integrity gaps** (adversarial review, pre-merge) — five ways the instrument could emit a confident wrong number: (1) **clustering by PDF** — tables arrive in clusters sharing a tagger and document, so errors correlate and a Wilson interval on n=tables is badly overconfident (simulated coverage of a nominal 95% interval: 78% at 22 files × 4 tables, 49% at 5 × 14) — now a file-level bootstrap (`cluster95`, deterministic `--seed`) with the distinct-file count reported next to n; (2) `harmful` was scoped to `truth == data_table`, missing `layout_table`→`figure`, `other`→`figure` and `layout_table`→`data_table`, all destructive — the last contradicted by `pdf_table_weave`'s own docstring warning that invented headers "actively mislead"; (3) a **non-canonical index key** (`" 0"`, `"+0"`, `"00"`) passed integer validation but matched neither the scoring lookup nor the unlabeled-items safety net, silently converting a harmful error into an abstention and *raising* the reported accuracy; (4) duplicate `(file, index)` labels were silently double-counted even with contradictory truth values; (5) zero acted items produced `0.0%` with a zero-width interval — the most confident claim possible, from no evidence — now `null`/`n/a`, and an empty labels file exits 2.

## 7.71.0

### Added
- **file-remediation: mis-detected-table → figure conversion** (`scripts/pdf_table_weave.py` — `convert_tables_to_figures()`, judgment key `table_to_figure`) — the last of the tagger-misdetection variants. opendataloader-pdf tags ANY visual grid as `/Table`, so a chart/scatter-plot/diagram (a grid of marks) is emitted as `/Table`, giving a screen reader meaningless row/column semantics for what is actually a figure. This collapses such a table to a single `/Figure` with `/Alt`: it harvests the table's descendant marked-content (MCIDs), builds one `/Figure` whose `/K` is that content, preserves any `/Caption`, replaces the `/Table` in its parent, and remaps the ParentTree so the reverse MCID→element association stays consistent. Origin: a course PDF ("Diagnosing problems in linear and generalized linear models.pdf") whose 8 residual-diagnostic plots were all mis-tagged as tables.
  - **Opt-in per table, alt required** — a `/Figure` with no `/Alt` fails veraPDF 7.3, so the agent inspects the render, decides it's really a figure (never a genuine data table), and supplies the description. Completes the trio with `promote_headers` (real data tables → `/TH`) and `convert_tables_to_lists` (layout grids → `/L`).
  - Live proof: the Diagnosing PDF — all 8 tables → 8 `/Figure`, 5 captions preserved, veraPDF COMPLIANT, fidelity 0.0 pixel-identical.

### Fixed
- **file-remediation: table-conversion robustness** (adversarial review, pre-merge) — four struct/ParentTree-surgery hazards that no render gate could detect: (1) an OBJR/annotation (e.g. a `/Link`) inside a converted region raised `TypeError` and aborted the whole `_run_e2` run — now classified and preserved, and the call is exception-wrapped; (2) multi-page tables mis-associated off-page MCIDs — now emitted as explicit `/MCR` with the correct `/Pg`; (3) a second `/Caption` was dropped and left an orphan `/Table` surviving save — all captions now re-parented; (4) mixing `table_to_list`/`table_headers`/`table_to_figure` in one judgment silently converted the wrong table (index shift) — `_validate_judgment` now rejects co-occurrence and directs a two-pass workflow.

## 7.70.0

### Added
- **file-remediation: layout-table → list conversion** (`scripts/pdf_table_weave.py` — `convert_tables_to_lists()`, judgment key `table_to_list`) — the other half of the table-semantics gap. opendataloader-pdf emits `/Table` for ANY visual grid, including layout tables with no logical row/column relationship (e.g. a multi-column word list authored to read down each column). Tagging that as a `/Table` gives a screen reader meaningless row/column semantics AND forces row-major reading, scrambling the author's column-major intent. This rewrites such a table to `/L`→`/LI`→`/LBody`; `order: "column"` restores the authored down-the-column sequence. Origin: an accessibility reviewer's finding that a directive-verb grid "should probably be tagged as a list… the table was just used for formatting and there is no logical meaning to the rows and columns."
  - **Opt-in per table, never automatic** — converting a genuine data table to a list would destroy real semantics, so the agent inspects the grid and decides (complements `promote_headers`: real data tables get `/TH`, layout tables become lists).
  - Content is **re-parented** onto the new `/LBody`, never rebuilt (tests assert the original StructElems by `objgen` — text cannot drift). Preserves a `/Caption` as the list's first child; skips junk `/TR` rows, empty cells; leaves nested tables untouched; ragged rows safe in column order.
  - Live proof: the Hinton writing-assignment PDF — verb grid → 21 `/LI` column-major, `/Table` 2→1, the file's genuine data table keeps its 3 `/TH` headers, veraPDF COMPLIANT, fidelity 0.0 pixel-identical.
  - Constraint: mixing `table_to_list` + `table_headers` in one run is not index-safe (converting a table shifts `list_tables()` indices) — documented; use two passes (convert → re-inspect → promote).

## 7.69.0

### Added
- **file-remediation: table header-cell promotion** (`scripts/pdf_table_weave.py` — `list_tables()` + `promote_headers()`, judgment key `table_headers`) — closes the header half of the deferred table-semantics gap. opendataloader-pdf builds `/Table`→`/TR`→`/TD` but never promotes a `/TD` to `/TH`, so every tagged PDF ships `TH: 0` — which is exactly what Anthology Ally reports as "This PDF contains tables that are missing headers" (Ally states resolving it can take a file to 100%). `list_tables()` is the agent-facing manifest (index/page/rows/cell_rows/cols/header_row_index/ragged/has_th/first_row_text); `promote_headers()` applies `/TD`→`/TH` with `/A << /O /Table /Scope /Column|/Row >>`.
  - **Opt-in per table, never automatic.** Inventing headers on a table that has none (a 3-column word list, a continuation fragment whose first row is data, a mis-detected scatter plot) makes a screen reader announce ordinary data as column headers — actively misleading. The agent must inspect the table and decide.
  - **Junk-row resolution**: real tagged tables frequently open with a `/TR` holding only a `/Link` annotation, so the header is the *second* row. Promotion resolves to the first row that actually contains cells (explicit `header_row_index` overrides).
  - **Ragged-table guard**: attributing a table whose rows have differing cell counts (merged cells with no `/ColSpan`) makes veraPDF newly validate column consistency and **fail clause 7.2**, turning a compliant file non-compliant. Such tables are skipped with a reason unless `force: true`. Writing `/ColSpan`//`RowSpan` from the tagger's span data (opendataloader exposes them per cell) is the natural next step and remains unimplemented.
  - Live proof: Cooper_2018 (STP 530, live at Ally 69% flagged "tables missing headers") — 3 well-formed data tables promoted, **15 `/TH` cells**, veraPDF COMPLIANT, pixel-identical (fidelity 0.0); its 2 ragged tables auto-skipped. Two other flagged files were deliberately left untouched: Type I&II (all 3 tables ragged) and Diagnosing (its 8 "tables" are mis-detected scatter plots).

## 7.68.0

### Added
- **file-remediation: CID + `/Differences` font repair** (`scripts/pdf_font_repair.py`) — two new passes that fix the common "untagged academic PDF" residuals ODL tagging leaves behind. **Pass 4**: a `/Type0` font whose descendant is `/CIDFontType2` with no `/CIDToGIDMap` gets `/CIDToGIDMap /Identity` (veraPDF 7.21.3.2). Safe by construction — an absent `/CIDToGIDMap` already *defaults* to Identity per ISO 32000 Table 117, so this makes the implicit default explicit (a rendering no-op). **Pass 2b**: a simple font with a dict `/Encoding` + `/Differences` and no `/ToUnicode` gets a ToUnicode CMap built per-code — `/Differences`-named codes resolve via the Adobe Glyph List, all other codes via the `/BaseEncoding` codec decode (WinAnsi→cp1252, MacRoman→mac_roman), so non-overridden characters map to the *correct* Unicode, not just *a* Unicode (veraPDF 7.21.7). Live proof: two untagged STP 530 papers (Cooper_2018, Pek_2018 — Ally 7%/12%) → veraPDF COMPLIANT, pixel-identical, body text extracts cleanly.

### Fixed
- **file-remediation: `/Differences` ToUnicode base-encoding correctness** — an initial version of Pass 2b seeded all `/Differences` bases from StandardEncoding glyph names, silently mismapping ~117 WinAnsi codes on every non-overridden code (e.g. é→Ø, ñ→æ, ©→', en-dash/smart-quote→U+FFFD) — compliant-but-corrupt text that neither veraPDF (existence-only) nor the render-diff fidelity gate can detect. Caught by adversarial review before merge; WinAnsi/MacRoman bases now decode via the codec. Regression-tested.

## 7.67.0

### Added
- **file-remediation: font-program embedding engine** (`scripts/pdf_font_embed.py`) — fixes veraPDF PDF/UA-1 **7.21.4.1** ("font program not embedded"), the class of defect that previously flagged `needs_manual` ("needs publisher/Acrobat"). Runs automatically before `pdf_font_repair` on both E2 and E3 paths. **P1**: a non-embedded `/TrueType` font (Times/Arial/Courier-family by `/BaseFont`) gets a subset of the matching bundled **Liberation** face (SIL OFL, committed at `standards/fonts/`) injected as `/FontFile2`. **P2**: a non-embedded `/Type1` base-14 font of the same families is embedded the same way, flipping `/Subtype`→`/TrueType` (veraPDF requires the FontFile type match the subtype) and synthesizing `/FontDescriptor` + `/Widths` from the subset font's own metrics when absent. Layout is pinned by the PDF's `/Widths` (Liberation is metric-compatible), so only glyph outlines change — sub-perceptual. Symbol/dingbat/unmapped families have no free bundleable clone → `skipped_no_substitute` → still `needs_manual` (honest, graceful; never a crash). Live proof: 4 real STP 530 files carrying 7.21.4.1 — Zoom-How-to, Critiques_of_HT, Learning-Community all → **all font clauses clear** (7.21.4.1/.4.2/.5/.6/.7 = none), structural diff ≤2.0%, visually verified indistinguishable (dense citation page rendered before/after); census2000manual `/Times-Roman` embedded, its lone `/Symbol` residual (one `/second` glyph) correctly left for manual.
- **file-remediation: structural fidelity gate** (`fidelity_gate.structural_diff`) — the font-embed path uses a new render-diff metric (fraction of pixels whose grayscale intensity changes by more than `FONT_EMBED_DELTA=64`) instead of raw `pixel_diff`. Raw byte-inequality conflates amount-of-text with wrongness — a visually-identical metric-clone substitution reads 10–15% on a dense page purely from anti-aliasing, which would false-reject clean output. `structural_diff` ignores that sub-pixel noise and catches gross page-spanning breakage (missing-glyph `.notdef` boxes, reflow); veraPDF (7.21.4.1 glyph coverage + 7.21.5 width consistency) remains the substitution-correctness backstop. Every other E2/E3 edit stays exact-zero `pixel_diff`.
- **file-remediation: `deliver_batch.py`** — batched-verb delivery fold: one approval verb delivers a manifest of remediated files, routing each by explicit `kind` (pdf/office), gating on per-file veraPDF/fidelity (pdf) or appearance (office), with a retire-smuggling guard and malformed-row rejection (never delivered). Closes the staged-work-rot gap (remediated files forgotten before push).

### Known limitations (adversarial review 2026-07-23, all gate-contained → `needs_manual`, never shipped corruption)
- Fonts declared only on an inherited `/Pages` `/Resources` node are skipped (shared with `pdf_font_repair`); `/Encoding /Differences` glyph names are not resolved to Unicode when subsetting; two `/Type1` dicts sharing one `/FontDescriptor` leave the second inconsistent. Each degrades to under-remediation (veraPDF fails → manual), documented inline for follow-up.

## 7.66.0

### Added
- **file-remediation: `apply_image_alt.py`** — gated writer for image alt/decorative fixes in live Canvas HTML (pages, assignments, discussions). Flow: agent classifies images via `audit_image_alt.py`'s manifest (decorative → `alt="" role="presentation"`, relevant → written alt), then this script LIVE-fetches each object, injects attributes by file-id-anchored src match (robust to query params, no over-match), stages, pushes through `push_to_canvas`, and verifies per-image (field-targeted, never whole-body). Idempotent; `--dry-run` previews; `--clear-cache` removes the downloaded image cache after a successful run. Quiz-question images are skipped with a clear message (manifest lacks `question_id` — follow-up). Live proof: STP 530 — 5 missing-alt images fixed and independently verified; 12 already-decorative banners correctly no-op'd; 126 cached images cleared.

## 7.65.0

### Added
- **file-remediation: retire path** (`scripts/retire_file.py`) — retire = delete-with-backup, NOT unpublish. Field lesson (STP 530): Anthology Ally scans all course files regardless of publish state, so unpublishing a stray leaves it dragging the course accessibility score. Flow: explicit verb gate → download live bytes → collision-proof backup (checksummed `.meta.json` sidecar) → sha256 read-back + Canvas-declared-size verification BEFORE delete → `delete_file(confirm=True)` → blocking post-delete verify. `--restore` re-uploads from backup (new file id, honestly reported). SKILL.md + opportunity-audit plan retire semantics corrected; retire remains human-approved per file batch, never automatic.

## 7.64.0

### Added
- **file-remediation: automatic font repair** (`scripts/pdf_font_repair.py`) — in-place ToUnicode CMap build (from named standard encodings, full 0-255 coverage) and full-coverage rewrite (existing entries preserved incl. bfrange destination arrays and multi-unit ligature mappings; forbidden U+0000/U+FFFE/U+FEFF scrubbed), plus /CharSet deletion for subset Type1 descriptors. Simple fonts only — Type0/CID untouched by construction. Runs automatically on both E2 and E3 paths before the gates; surviving 7.21.x failures still flag `needs_manual` (now accurately = font-binary defects only). Research basis: deep-research verified veraPDF 7.21.x machine-checkable semantics; Ghostscript re-distillation ruled out (drops structure trees). Live proof: 5 previously-blocked STP 530 files → COMPLIANT 106/106, pixel-identical; one-pass shipped-path proof on CI_vs_pred.pdf.
- **file-remediation: decorative-content judgment path** (`vector_artifacts`) — pages of unmarked slide backgrounds/chrome wrapped as /Artifact (hidden from AT) instead of alt-texted; counterpart to `vector_figures`, overlap rejected at validation. Live proof: 29-slide Beamer lecture deck, 125 unmarked-content failures cleared, pixel-identical.

### Fixed
- E2 path flags embedded-font defects as `needs_manual` (parity with E3); DOCX remediation sets alt text on floating/anchored images, not just inline (`_all_image_docpr`).

## 7.63.2

### Fixed
- **file-remediation: vector-figure wrap for unmarked chart pages** (STP 530 shakedown finding 3) — ODL leaves vector-drawn charts (R/matplotlib plot paths) unmarked, failing veraPDF 7.1. New judgment key `vector_figures` wraps each reported page's unmarked-paint clusters as a tagged `/Figure` with alt text: per-cluster MCIDs (sparse-safe, `max(existing)+1`, null-padded ParentTree), single struct elem with `/K` array, validate-before-mutate. Candidates surfaced in `result.edits.unmarked_vector_pages` for the two-run judgment loop. `ensure_role_map` defensively maps non-standard structure types to `/P` (cycle-guarded). `detect_unmarked_paint_pages` counts unmarked inline images and surfaces parse errors. Verified live: model_building_demo.pdf 105/106 → COMPLIANT 106/106, pixel-identical, 3 raster + 3 vector figures described. Codex adversarial review: 6 findings, all fixed and test-locked (10 unit tests).

## 7.63.1

### Fixed
- **file-remediation** — two gaps found remediating live course files, both correctly gated before (no bad output shipped), now handled:
  - E3 (tagged-PDF) routing is baseline-first: a tagged PDF with embedded font-program defects (veraPDF 7.21.x) is flagged `needs_manual` with an honest reason (unfixable by re-tagging) instead of bare-rejected; structural-only failures fall through to a full E2 re-tag.
  - Link annotations missing `/Contents` (LibreOffice/ODL output) are auto-fixed on every PDF output path (veraPDF 7.18.x) — verified taking a DOCX export from 104 to 106/106.

## 7.63.0

### Added
- **file-remediation skill (28th skill), Phases 1-4 complete** — file-level accessibility remediation for PDF/DOCX/PPTX course files, output visually identical to input by checked invariant:
  - **Stage 1 Assess** (`remediate_assess.py`): read-only classifier (digital-tagged/untagged, scanned, hybrid, encrypted, corrupt, oversized) with per-route dispatch; course mode via new `canvas_api.list_files`; `--emit-backlog` writes `file_a11y` findings. Courseless local mode.
  - **Engine E2 in-situ PDF** (`remediate_pdf.py` + `pdf_tree_edit.py`): opendataloader-pdf auto-tagging (sandboxed: bounded heap, timeout, size cap) + pikepdf metadata pass + judgment-driven tag-tree repairs (alt text, heading promotion with level normalizer, table fallback per plan AC6). **Engine E3 metadata patch** for tagged-but-sloppy PDFs. Scanned/hybrid route through macOS Vision OCR invisible text layer (`ocr_text_layer.py`).
  - **Engine E1 Office in-place** (`remediate_docx.py`, `remediate_pptx.py`): copy-first minimal-diff edits (alt text, heading styles with appearance preservation, table header rows, title/language) + optional tagged-PDF export via vendored LibreOffice (`lo_export.py`, includes the Impress double-background artifact post-fix).
  - **Hard gates** (`verapdf_gate.py`, `fidelity_gate.py`): veraPDF PDF/UA-1 machine-verifiable pass (Matterhorn human-checkpoint count always reported) + all-pages pixel-diff; gate failure preserves output as `.rejected.pdf`, never ships.
  - **Stage 4 Canvas delivery** (`push_remediated_file.py`): staging-path enforcement, explicit confirm verb, binary backup with meta sidecar, overwrite upload, post-write verify, `file_a11y` backlog auto-close. Live sandbox test (`tests/integration/test_live_file_replace.py`) is create-then-delete, ASUCB_LIVE_TEST=1-gated.
  - **Vendor bootstrap** (`setup_remediation.py`): consent-first installs of Temurin JRE 21, veraPDF 1.30.2 (unattended izpack), opendataloader-pdf, Phase-2 pip deps, opt-in LibreOffice 26.2.4 — into `~/Library/Application Support/asucb/vendor/` (survives plugin updates). `ASUCB_REMEDIATION_OFFLINE=1` honored.
  - **Reach & retire candidates** (`--with-linkage`): read-only sweep of pages, assignments, quizzes+questions, discussions, announcements, syllabus, module items, and uploaded HTML files — per-file inbound `reach`, `linked_from`, Canvas `folder`, and `retire_candidate` flags (reach 0). Retire is always a human decision; the mandatory caveat names unsearchable surfaces (LTI, Studio, item banks, other courses).
  - Plan docs: `docs/plan-file-remediation-skill.md` (Rev 2, post adversarial review) + `docs/plan-remediation-opportunity-audit.md` (Phase 1.5, deferred).

### Fixed
- `agent_review_and_merge.py` gate 3 now runs pytest under `sys.executable` (plugin venv) instead of bare `python3`.
- `canvas_api.delete_file` added with the standard `confirm=True` PermissionError guard (documented binary no-auto-backup rationale); `upload_file` gains optional `filename`.

## 7.62.0 (2026-07-16) — Assessment Design Playbook: ASU-tools reference, table redesign, front matter + model artifacts

### Added
- **`standards/asu-assessment-tools.md`**: canonical reference mapping ASU/Wes-approved assessment tools (Canvas New Quizzes, VoiceThread, Harmonize, EdDiscussions, WeVideo/PlayPosit, iClicker, Top Hat, Yellowdig, H5P, Google Forms, Inscribe, Orchard, plus Honorlock/Respondus as last-resort proctoring) to assurance levers: can it secure a graded anchor, design use, gradebook passback, sync/async, and caveats. Wired into `standards/assessment-assurance-design.md` (lever 1) and `skills/assurance-audit/SKILL.md` Step 5 so recommendations name a real ASU tool. (#271)
- **Assessment Design Playbook front matter** (`scripts/assessment_design_playbook.py`): a Key Terms glossary (Lane 1 / Lane 2 / secured anchor / TILT) and a "How to Triage Your Course" section (5-step protocol, a design target of roughly 40%+ of grade weight on secured anchors, a grading-cost-vs-enrollment table, an accessibility note, and an academic-integrity escalation pointer). Footer carries a "Last reviewed / Owner" versioning line. (#278)
- **Per-category Model Artifact pages**: each of the five categories now pairs its card with a sample-artifact page showing one AI-forward and one AI-resistant model as structured tables (an AI-use appendix, a process trail, an open-AI verification, a milestone/contribution record, a prep-use disclosure, a targeted reflection, and more), each with a "Why it earns credit" line. This makes the playbook practice its own TILT advice. (#278, #279)

### Changed
- **Playbook technology sections** (`scripts/assessment_design_playbook.py`): replaced non-ASU tool citations (Perusall, Hypothesis, Turnitin, Examity, Mobius, WeBWorK, GitHub, Zoom, Padlet, Flip, Canvas Studio) with the Wes/TILT-approved set; Honorlock and Respondus retained as the last-resort proctoring line. Consolidated into Michael Miller's Written / Multiple Choice / Problem-based category grid, then folded back per-category. (#272, #273)
- **Playbook editorial redesign**: flat design matching the assurance reports (no filled cards or rounded blocks); AI-Resistant moves before AI-Forward; "The Anchor" section dropped in favor of a lead move; a per-category Two-Lane and TILT considerations table; moves and considerations rendered as equal-width tables; one Letter page per category. Tone and content fixes from Michael Miller's second review (faculty/student phrasing, merged and clarified moves, individual-or-team framing, targeted reflection). A stop-slop / APA copy-edit pass across the authored prose. (#274, #275, #276, #277)

## 7.61.0 (2026-07-13) — Assurance: per-assessment Secured/Unsecured + AI-Forward/Resistant labels

### Added
- **`scripts/assurance_audit.py`**: new `security_ledger` in the scorecard — one entry per assessment carrying two plain labels: `security` (`secured`/`unsecured`, with basis `identity`/`authorship`/`none`) and `design` (`ai_forward`/`ai_resistant`/`n/a`). Secured is hybrid: identity control (proctoring/LockDown, a deterministic API fact) OR authorship evidence, cite-or-fail — an `ai_securing` entry secures only when it carries a non-empty evidence citation. A deterministic nominator (`_is_authorship_nominated`: expanded process/oral keywords plus `peer_reviews`, `media_recording`, and rubric-excerpt signals) flags plausible authorship anchors as `needs_ai_review` when no AI verdict exists, but never itself secures. (#269)

### Changed
- **`scripts/assurance_report_html.py` / `scripts/assurance_brief_html.py`**: the report and brief now lead with the per-assessment Secured/Unsecured and AI-Forward/Resistant labels from `security_ledger`. The featured security view no longer shows a coverage percentage or the Assured/Partially/Unassured tier. (#269)
- **`scripts/assurance_audit.py`**: `design` is now classified from the title, prompt body (`content_excerpt`), AND rubric (`rubric_excerpt`) in `_classify_design`, reusing the D4/D5 AI-Forward anchors — so an AI-forward assignment whose signal lives in the body (e.g. an AI-use appendix) is no longer mislabeled AI-resistant. (#269)
- **`scripts/assurance_batch.py`**: cohort ranking sorts by count of unsecured graded assessments (descending) instead of coverage percentage; the ranked and attention tables show SECURED/UNSECURED counts. (#269)
- **`skills/assurance-audit/SKILL.md`**: Step 2's securing `kind` vocabulary now enumerates the authorship-evidence signal types by name; Step 3/5/6 prose describes the ledger-driven Course Snapshot and Verdict matrix. (#269)
- `tier` / `assurance_coverage_pct` are retained in the scorecard JSON as deprecated-not-featured (back-compat and internal batch tiebreak only). (#269)

### Removed
- **`skills/`**: deleted 16 compatibility alias skills (graph + grep verified orphans). (#268)

## 7.60.0 (2026-07-13) — Assignment generator: AI Design Mode up front

### Added
- **`skills/assignment-generator/SKILL.md`**: "Question 1.5: AI Design Mode" in the pre-generation questionnaire — the user picks AI-resistant (default), AI-forward, or both paired variants, each with a one-line definition; an infer-don't-ask rule skips the question when the request already names a direction. Step 2.5 branches on the chosen mode, applying the matching move from `standards/assessment-assurance-design.md` instead of a single default line. (#266)

## 7.59.5 (2026-07-10) — Standard: anchor default is authorship evidence across all generators

### Changed
- **`standards/assessment-assurance-design.md`**: lever 1 now states the design-time default explicitly: when designing or recommending the securing anchor, default to authorship evidence and never propose adding a proctored or LockDown Browser assessment (bare trailing last-resort clause only where authorship evidence cannot fit; describing an already-proctored item is state, not a recommendation). This is the single choke point every assessment generator (quiz, assignment, discussion, rubric, needs-analysis, course-build) inherits "secure an anchor" from, closing the same surveillance-drift 7.59.4 closed for assurance-audit. (#267)

## 7.59.4 (2026-07-10) — Assurance: "secure an anchor" defaults to authorship evidence, not proctoring

### Changed
- **`skills/assurance-audit/SKILL.md`**: hardened the remediation-authoring guidance so a recommendation to secure grade weight defaults to an **authorship-evidence anchor** (required draft, process/decision log, personal or course-specific artifact, graded AI-use appendix), never to adding a proctored or LockDown Browser assessment. Names the trap explicitly: the tier is driven by secured-coverage %, and proctoring is the shortest path to more %, so authoring gravitates to surveillance. Proctoring/LockDown may appear only as a bare trailing last-resort clause where authorship evidence genuinely cannot fit, never as a standalone recommended move; describing an assessment the course already proctors stays fine. Applies to both the report recommendations and the brief `directions`. (#265)

## 7.59.3 (2026-07-10) — Brief: tighter vertical density for one-page fit

### Changed
- **`scripts/assurance_brief_html.py`**: tightened the brief's vertical rhythm (page padding, section spacing, glance/stat padding, the two-reads verdict block, column and opportunity spacing, `@page` margin) so a full brief that includes the two-axis Verdict block and a six-field Course-at-a-Glance band fits on a single Letter page. The one-page briefs that predated the Verdict block had more headroom; the Verdict block reclaimed it, and this restores the one-page fit without dropping content. No schema or content change. (#264)

## 7.59.2 (2026-07-10) — Canvas font-weight strip fix + preflight guard; live integration suite

### Fixed
- **Canvas page templates & generators**: replaced inline `font-weight` with `<strong>`/`<b>` across `templates/page-html/` (11 occurrences), `templates/mockup-html/placeholder-card.html`, `scripts/add_transcripts.py`, `scripts/mockup_manager.py`, and `skills/course-mockup` + `skills/notebooklm` example HTML. canvas.asu.edu's sanitizer strips `font-weight` from inline styles on page-body writes (every element, every value — verified live 2026-07-10 on course 260866), so generated pages were silently losing their bolding on push. Local report/preview/staging chrome and iframe-delivered interactives are unaffected and were left untouched. (#264)

### Added
- **`scripts/preflight_checks.py`**: new Tier 1 `check_inline_font_weight` — a staged page/assignment/discussion/quiz body with `font-weight` inside a `style="..."` attribute now emits a non-blocking `style.font_weight_inline` warning at stage time, scoped to style-attribute values only. Wired into `check_page`, `TIER1_PAGE_CHECKS`, and `check_quiz_tier1`. (#264)
- **`tests/integration/`**: env-gated (`ASUCB_LIVE_TEST=1`) live Canvas integration suite against the prod sandbox (course 260866) — HTML round-trip quirks (including the font-weight strip and entity/Unicode serialization, pinned exactly), the full stage→push→verify→backup→restore cycle, the 3-step file upload + staged-image token rewrite, the delete gate, and Link-header pagination. Skipped by default and in CI; added to the pre-tag release checklist. (#257, #260)

### Docs
- **`standards/page-design.md`**: documented the Canvas inline-style sanitizer constraint (no `font-weight` in page bodies; use `<strong>`/`<b>`; iframe interactives exempt) and converted component examples accordingly. (#264)
- **`CLAUDE.md` / `AGENTS.md`**: PR-only workflow now recognizes a high-capability Claude reviewer (Fable 5) alongside Codex/Opus in the merge-gate reviewer set (`scripts/agent_review_and_merge.py`); release checklist now requires a live-suite run before tagging. (#260)

## 7.59.1 (2026-07-10) — Brief: remove the "In the Instructor's Words" section

### Removed
- **`scripts/assurance_brief_html.py`**: removed the faculty pre-survey section ("In the Instructor's Words") added in 7.59.0, per reviewer direction. The renderer no longer reads `narrative["brief"]["faculty"]`; the section, its CSS, its schema entry, and its tests are gone. The source-currency date ("... - pulled {date}", the `fetched` field) added in the same release is kept. `skills/assurance-audit/SKILL.md` drops the `faculty` field documentation. (#261)

## 7.59.0 (2026-07-10) — Brief: faculty pre-survey + source-currency date

### Added
- **`scripts/assurance_brief_html.py`**: an "In the Instructor's Words" section renders the faculty pre-survey verbatim (per respondent: name, a short readiness/attendance line, and their `{q, a}` answers) in a neutral quoted block, placed before the audit findings so instructor intent frames the diagnosis. It is explicitly labeled self-reported intent, not an audit finding, and is styled neutral (never the green/maroon of the findings) so the two are never confused. Authored from `narrative["brief"]["faculty"]`; pull the responses from the program pre-work source (e.g. the Coda accelerator doc) and reproduce them verbatim. Section omits when no pre-survey exists. This addresses the meeting ask to make faculty input visible in the brief itself, not only in Coda. (#259)
- **`scripts/assurance_brief_html.py`**: the masthead Canvas link now shows "... &middot; pulled {date}" from a new `fetched` field, so a reviewer sees both which shell the brief came from and how current the read is (the course link itself shipped in 7.58.0). (#259)

### Docs
- **`skills/assurance-audit/SKILL.md`**: documented the `faculty` and `fetched` fields, including the verbatim-not-paraphrased rule, the self-report framing, and that the pre-survey MCP may be unavailable in headless runs so it is optional context, never a blocker. (#259)

## 7.58.1 (2026-07-10) — Brief: rename "The Barrier" to "The Constraint"

### Fixed
- **`scripts/assurance_brief_html.py`**: the brief section formerly labeled "The Barrier" is now "The Constraint," with a one-line frame under the eyebrow ("Not a problem to solve, the conditions any change has to work within. The opportunities below are written to fit these."). Reviewers read "barrier" as a quality judgment and could not tell whether it was scoring the course up or down; the section states neither, it names what limits a fix. The JSON field key stays `barrier` for back-compat. `skills/assurance-audit/SKILL.md` adds the constraint-vs-exposure test: a sentence that names something wrong with the course belongs in `exposed`, not here. SOC 334's brief carried a misfiled exposure sentence as its barrier; re-authored to a real feasibility constraint. (#258)

## 7.58.0 (2026-07-10) - Canvas course link + colon subheads on the report

### Added
- **`scripts/assurance_report_html.py`**: the report masthead renders `scorecard["course"]["url"]` as a scheme-validated link under the title (mirroring the brief), so an ID can open the shell and confirm the right course. `http`/`https` only (a `javascript:` href survives HTML escaping); absent when no url. Reuses the brief's `_safe_url`/`_url_label`. (#256)

### Fixed
- **`scripts/assurance_report_html.py`**: the two static Section 03 subhead separators (Security / Design) used an em-dash entity; replaced with a colon for house-style compliance (no em/en dashes). (#256)

## 7.57.0 (2026-07-10) - AI-led securing: process evidence secures, regex confirms only facts

Reframes the assurance engine 80/20: AI judges securing and conclusions by reading content; regex supplies only deterministic facts. Resolves Michael Miller's flag that process evidence should count as securing.

### Changed
- **`scripts/assurance_audit.py`**: securing is no longer limited to what regex finds. `score_course` accepts `ai_securing` (`{assessment_id: {"secured", "kind": "identity"|"process"|"unsecured", "evidence"}}`) and `_classify_lane` classifies an item as secured (lane1) iff `proctoring_signal` is a confirmed API fact OR the AI securing pass judged it secured (an identity control OR authorship evidence: drafts, decision logs, revision history, reflection, a graded AI-use appendix). **`peer_reviews` no longer auto-secures** -- peer review does not verify authorship (Michael Miller); it is recorded as a fact only. The coverage/tier arithmetic stays deterministic; only the classification feeding it is AI. Deterministic-only fallback (no `ai_securing`) is proctoring-only, the conservative floor. (#255)
- **`scripts/assurance_report_html.py`**: the "Secured" definition now covers both kinds of securing (identity control OR authorship evidence), with proctoring as the last-resort subtype. The open-question note about whether process evidence counts as securing is removed; it is resolved -- it secures.

### Docs
- **`skills/assurance-audit/SKILL.md`**: new two-pass workflow (inventory facts -> AI securing pass -> `score_course(ai_securing=...)` -> narrative). The old "AI never alters the deterministic tier" rule is replaced with "AI classifies securing with cited evidence; tier is the deterministic arithmetic over that classification." States the cross-course comparability tradeoff. (#255)
- **`standards/assessment-assurance-design.md`**, **`standards/assurance-sources.md`**: encode the two-kinds-of-securing model and AI-led classification; motivation-as-third-axis remains the one open model question. (#255)

## 7.56.0 (2026-07-10) - Source attribution + calibration disclosure on the report

Answers the "is this based on research?" question by showing the seams: cite the frameworks, label the thresholds.

### Added
- **`standards/assurance-sources.md`** (new): canonical citations for the frameworks the assurance audit invokes (TILT/Winkelmes 2016, Two-Lane/Sydney-Bath-Auckland, Validity/Dawson 2024, TEQSA 2023, AIAS/Perkins 2024, Bloom/Anderson & Krathwohl 2001), each tagged by evidence strength and whether the July 2026 Accelerator program cited it or the tool imports it. Plus an explicit list of house-calibrated defaults (the 40% assurance-tier cut, the 0.75 substitutability threshold, the AI-forward rating bands, the course-level Bloom bands) that no cited source sets. (#254)
- **`scripts/assurance_report_html.py`**: Section 06 gains two optional authored fields. `method.calibration` renders a "Calibration:" note disclosing which numbers are house defaults rather than findings; `method.sources` (a `[{label, cite}]` list) renders a compact references block. Both HTML-escaped, both skipped when absent. (#254)

### Docs
- **`skills/assurance-audit/SKILL.md`**: authoring guidance now requires citing a framework inline when a recommendation move names it (drawn from `standards/assurance-sources.md`), populating `method.sources` with the frameworks the course actually triggered, setting `method.calibration`, and never presenting a numeric threshold as a finding. (#254)

## 7.55.0 (2026-07-10) - Assurance accuracy: drop rules, choice-group flag, course-level Bloom calibration, verdict colors

Michael Miller's COM 100 review surfaced factual and framing errors; a read-only re-audit of the real accelerator dev shell confirmed them.

### Added
- **`scripts/course_level.py`** (new): `detect_level(course_code)` parses a numeric level and floors to the 100..700 band (500+ labeled "graduate"); `bloom_expectation(level)` returns the Bloom band a course is expected to emphasize, so recall/understand-heavy assessment at the introductory level is read as expected, not a design defect. Pure functions. (#252)
- **`scripts/assurance_inventory.py`**: assignment-group `rules` are now honored. `drop_lowest`/`drop_highest`/`never_drop` exclude the dropped items from the group's counted-points denominator in both weighted and points-only modes (a 12-quiz group with drop-lowest-2 counts 10 quizzes against 100 points, not 12 against 120); dropped items get `grade_weight_pct=0` and `dropped_by_rule=true`, and each group reports `counted_after_drops`/`dropped_count`. A flag-only `possible_choice_group` detector marks a group that looks like "choose 1 of N" (>=3 equally-weighted siblings plus a sign-up / "of your choice" text signal) without changing counts, so an author verifies against the syllabus before stating a deliverable count. New top-level `groups` inventory output. (#252)

### Changed
- **`scripts/assurance_report_html.py`** + **`scripts/assurance_brief_html.py`**: the verdict-matrix colors no longer collide with the report's axis colors. The GOAL marker moves to gold (dot `#ffc627` outlined `#b98900`; label `#b98900`, since gold text on white is a banned low-contrast pair) and the course-position dot moves to neutral ink `#12100e`, so green (Security) and maroon (Design) keep their axis meaning everywhere. The verdict SVG is now shared between report and brief. The report masthead shows a course-level badge when the scorecard carries `course.level_label`. (#252)
- **`scripts/assurance_report_html.py`**: the "Secured" definition drops "peer review" (it does not verify authorship and the detector does not detect it) and names proctoring a last resort. Secured stays identity-verification only; whether process evidence should itself count as securing, and whether motivation is a third axis, are noted as open questions, not implemented. (#252)

### Docs
- **`skills/assurance-audit/SKILL.md`**: authoring guidance now requires checking drop rules and the choice-group flag before stating assessment counts, calibrating the Bloom read to course level (recall/understand is not a gap at <=200 level), grounding recommendations in the course's actual items, binding TILT annotated examples inline with any verify/critique-the-AI move, dropping AI-resistant moves defeated by multimodal AI (cohort/classmate reference), and not recommending wholesale removal of recall. (#252)

## 7.54.0 (2026-07-10) — Redesign three misleading assurance report metrics

Three deterministic metrics on the faculty/leadership-facing assurance reports were reworded/recomputed because their output misinformed rather than informed. User-approved from a before/after mockup. Renderers are unchanged — they render an authored narrative, so the display shifts via updated SKILL.md authoring guidance; this changes the scorecard fields those signals derive from. (#219)

### Changed
- **AIAS transparency split into two honest signals** (`scripts/assurance_audit.py`, `scripts/assurance_batch.py`): the single `aias_transparency_pct` (`declared / (syllabus + all assignments)`) rewarded keyword-stuffing and punished correct placement — a course stating its AI policy once in the syllabus scored ~9%, one repeating a keyword in every assignment scored ~100%. Replaced with `ai_policy_in_syllabus` (bool) and `ai_declared_assessments` (`{count, total}`), reported as two separate signals. `aias_transparency_pct` is removed.
- **Mismatch is a flag, not a tier demotion** (`scripts/assurance_audit.py`, `scripts/assurance_batch.py`): a counterfeit-claim `mismatch` no longer forces `tier = Counterfeit` (the worst tier), which had inverted the cohort ranking — a 60%-secured course ranked below a 5%-secured clean one. Tier now derives from secured coverage only; a new `counterfeit_claim` boolean surfaces the claim as a flag, and the cohort sorts by coverage-derived tier then secured-coverage %. `AssuranceTier.COUNTERFEIT` is retained but deprecated-not-assigned (historical scorecards and `_TIER_RANK`/`_ATTENTION_TIERS` still reference the string).
- **Empty courses get a distinct state** (`scripts/assurance_audit.py`, `scripts/assurance_batch.py`): a shell course with no graded assessments was counted in "Certifying on Nothing" alongside courses that have assessments but secure none. A new `has_graded_assessments` bool separates them; `certifying_on_nothing` now counts only courses that have graded assessments and secure none, and empty courses are reported separately as `no_graded_assessments`.

### Docs
- **`skills/assurance-audit/SKILL.md`**: scorecard field list and narrative-authoring guidance updated for the two AIAS signals, the `counterfeit_claim` flag, and the empty-course state. (#219)

## 7.53.0 (2026-07-10) - Brief links the live Canvas course

### Added
- **`scripts/assurance_brief_html.py`**: `narrative["brief"]["course_url"]` renders the live Canvas course link under the brief's title, so an ID can open the shell and confirm they are reviewing the right course. Authors should use the production course URL, not a dev or sandbox copy. The href is scheme-restricted to `http://` and `https://`: HTML-escaping alone does not neutralize a `javascript:` href, so any other scheme is dropped rather than rendered. The link label falls back to the full url when stripping the scheme would leave an empty accessible name (WCAG 2.4.4). (#249)

### Docs
- **`skills/assurance-audit/SKILL.md`**: documents `brief.course_url`, and corrects the Bloom-bar guidance, which still described the pre-7.51.1 warm-neutral luminance ramp instead of the canonical Vanderbilt scale now in use. (#249)

## 7.52.1 (2026-07-10): Paragraph scoped citation check in the transcript verifier

### Fixed
- **`scripts/verify_transcript_clean.py`**: the dropped citation check now scopes to the paragraph. Previously the check asked only whether a citation survived anywhere in the file, so deleting one of several mentions of the same case name or Section reference went unreported. It now compares paragraph i of the source against paragraph i of the cleaned text, reports the paragraph number, and emits at most one error per citation per paragraph.
- When source and cleaned paragraph counts differ, the citation check falls back to the previous whole file behavior, since paragraph alignment is impossible. The paragraph count mismatch is still reported as its own error.

## 7.52.0 (2026-07-09): Transcript cleanup verifier + auto transcript pipeline

### Added
- **`scripts/verify_transcript_clean.py`**: deterministic verifier for transcript cleanup passes. Checks paragraph parity, out/src word ratio band, dropped case citations and Section refs, number formatting, stray em or en dashes, and non canonical spellings from a course glossary. Exits 1 on any error.
- **`standards/transcript-cleanup.md`**: domain neutral transcript cleanup rules with a per course glossary slot, reusable beyond law courses.
- **`standards/glossaries/`**: format README plus a `law-contracts.md` example carrying canonical case spellings.
- **`add_transcripts.py`** gains SRT support, an `--auto` mode that discovers page to video pairs from the Wistia iframe ids already embedded in each page, `[Transcript here]` placeholder filling, and sentence aware paragraphs.
- **`standards/page-design.md`** documents the expandable scrollable transcript block component.

### Changed
- **`add_transcripts.py`** now stages through `staging_manager.stage()` instead of writing straight to Canvas, closing the Write Safety Guard gap (#208). Push still goes through `push_to_canvas.py`.

### Fixed
- Placeholder filling no longer nests a `<details>` inside the page's existing transcript disclosure.
- The verifier now pairs two files directly in single file mode when their basenames differ.

## 7.51.3 (2026-07-09) — Low-severity hardening + tech-debt cleanup (audit remediation, batch 2)

Second remediation batch from the full-repo audit: ten low-severity security-hardening items (#215) and the verifiable slice of the tech-debt issue (#216). Each shipped as its own reviewed PR (coder ≠ reviewer) with a fail-first regression test; full suite green on merge.

### Security (hardening — low severity)
- **`scripts/faithful_restyle.py`** — `restyle()` preserved `<script>`/`on*`/`javascript:` from faculty HTML (the tag-stripped `verify_verbatim` check was blind to it). Now strips active content while preserving verbatim visible text. (#215, #234)
- **`scripts/setup_env.py`, `scripts/push_syllabus.py`, `skills/*/SKILL.md`** — `migrate()` now rewrites `.env` through the shared 0600-enforcing writer (a bare `open('w')` left a pre-existing token file's perms unchanged); three raw `resp.text` error bodies are now passed through `_sanitize_error_text`; five documented `curl` blocks pipe the auth header via stdin (`-H @-`) instead of expanding `$CANVAS_TOKEN` into argv (visible in `ps`). (#215, #235)
- **`scripts/canvas_api.py`, `scripts/course_content_cache.py`** — `paginated_get` parses the `Link` header defensively (regex, no `IndexError` on a malformed part) and caps at 1000 pages (a self-referencing `next` link previously looped forever); `dump_pages` sanitizes the Canvas page slug before using it as a write-path component. (#215, #236)
- **`scripts/staging_server.py`** — the `GET /review?refresh=1` Canvas-fetch + cache-write side effect is now gated behind the Origin + session-token check the mutating verbs use, so a foreign page can't force it. Note: because browsers send no `Origin` on a plain GET navigation, a human manually typing `?refresh=1` now gets cached data rather than a forced re-fetch (the page still renders); the proper path is a future token-authenticated in-app refresh button. (#215, #237)
- **`scripts/audit_report.py`, `scripts/assurance_batch.py`** — report `href`/`src` values now reject `javascript:`/`vbscript:`/`data:` schemes; `_escape`/`_esc` also escape single quotes; `open`/`xdg-open` invocations pass an absolute path that can't be misparsed as a CLI flag. (#215, #238)

### Changed
- **`scripts/alignment_mode_b.py`** — replaced its private, divergent Bloom verb table with the canonical `blooms.py` data (its "Understand" set had been missing `interpret`/`contrast`/`distinguish`/`articulate`, so the same objective classified differently in Mode B than everywhere else). The prose-scanning `_infer_bloom` algorithm is kept (canonical `classify_blooms` only reads a leading verb, which Mode B's full-text inputs don't have). (#216, #239)

### Performance
- **`scripts/audit_rubrics.py`, `scripts/assurance_inventory.py`, `scripts/assurance_audit.py`** — dropped a per-assignment `get_assignment` N+1 in standalone rubric collection (the assignments index already carries the `rubric` field — verified against live Canvas: 39 rubric-bearing assignments, 0 index omissions), taking an unbuilt 40-assignment course from 41 calls to 1; and folded the assurance course-object double-fetch (`_fetch_course_flags` + `get_syllabus` hit the same `/courses/:id`) into one call with `include[]=syllabus_body` (~50 fewer requests per batch cohort). No change to findings. (#216, #240)

### Removed
- **Five superseded/dead scripts (~1,900 LOC)**: `diff_engine.py`, `push_mockup_to_canvas.py` (→ `mockup_manager --promote`), `promote_mockup_to_live.py`, `course_status.py` (→ `multi_course_status.py`), `assurance_throughput.py` (→ the internal `_Throughput` fallback). Each verified to have no live references; tests in shared files (`test_p3.py`, `test_course_status.py`) were pruned surgically, keeping the live-module tests. The `_strip_html` consolidation and the built-but-unwired ID-workflow scripts (`decision_logger`, `learnings_logger`, `generate_handoff`, `audit_remediation_baseline`) were intentionally left in place. (#216, #241)

## 7.51.2 (2026-07-09) — Security, correctness, and reliability sweep (audit remediation)

A batch of eleven fixes from a full-repo QA/security audit. Each shipped as its own reviewed PR (coder ≠ reviewer) with a regression test proven to fail against the pre-fix code, and was merged only after the full suite passed on a clean checkout.

### Security
- **`scripts/staging_server.py`** — the direct preview route (`/courses/{id}/preview/{type}/{key}`) rendered untrusted staged Canvas HTML inline on the `127.0.0.1:8111` origin with no sanitize and no sandbox; the `rubrics` type returned it verbatim as the whole response body. Injected script ran same-origin, could read the synchronizer token from the DOM, and PUT over any staged artifact — so a human could approve one body and push another. Page/assignment/discussion/syllabus/rubric bodies now render through the same sanitize + opaque-origin `<iframe sandbox>` path the unified `/review` route already used. Interactive host pages keep `allow-scripts` and stay cross-origin, unchanged. (#206, #221)
- **`scripts/agent_review_and_merge.py`** — the mandatory reviewer gate interpolated the PR head-branch name (attacker-controlled) into `shell=True` git commands; git permits `;`, `|`, `&`, `$()`, and backticks in branch names, giving command execution on the reviewer's machine. Branch-touching git calls are now list-argv, plus a strict branch-name validator (rejects a leading `-`). (#207, #225)
- **`scripts/url_safety.py` (new), `scripts/vision_audit.py`, `scripts/audit_image_alt.py`, `scripts/course_content_cache.py`** — image `src`/file URLs harvested from Canvas HTML were fetched with no scheme/host/IP validation and streamed to disk with no size cap (SSRF to loopback/RFC1918/link-local + disk-fill). A shared `is_safe_fetch_url` resolves the host and rejects non-public/non-http(s) addresses (checks every resolved IP, incl. IPv4-mapped IPv6, fails closed on resolution error); a 25 MB cap bounds every download. The Canvas token was already withheld from non-Canvas hosts — unchanged. (#210, #229)
- **`scripts/canvas_api.py`, `scripts/deploy_interactives.py`, `scripts/add_transcripts.py`** — `update_page` gained the `from_staging` parity guard that `create_page` already had; `deploy_interactives` now aborts a page's deploy when its pre-write backup fails (was "proceed anyway"); `add_transcripts` takes a pre-write backup and aborts if it fails. The full staging-reroute is intentionally deferred. (#208, #230)

### Fixed
- **`scripts/assurance_audit.py`** — `_compute_bloom_calibration_findings` used `bool(proctoring_signal)`; since `bool("unknown")` is `True`, a high-stakes quiz whose proctoring could not be confirmed was scored as protected and its `recall_unprotected`/`apply_reusable` finding silently dropped on a faculty-facing report. Now `is True`, matching every other consumer. (#217, #222)
- **`scripts/blooms.py`** — `compare`, `contrast`, and `distinguish` lived in both the `Understand` and `Analyze` verb sets, so dict-insertion order (not meaning) decided the level: `"Distinguish…"` classified as Understand while the synonym `"Differentiate…"` classified as Analyze, giving opposite counterfeit verdicts and under-counting those verbs in the grade-weighted Bloom bar. Resolved to Analyze only; a test pins that no verb appears in two levels. (#218, #224)
- **`scripts/push_to_canvas.py`** — `push_new_mockup_quiz` cleared staging after a verify that ignored failed `add_quiz_question` calls, so a partially-created quiz deleted its own only source. Verification now counts question failures and preserves staging on partial failure. (#214, #227)
- **`scripts/staging_server.py`** — the staged-quiz read-modify-write was lock-free under `ThreadingHTTPServer`; a full-quiz Save and a details Save against the same file could clobber each other. A single module-level `threading.Lock` serializes both writer sites. (#213, #228)
- **`scripts/backup_manager.py`, `scripts/push_to_canvas.py`** — backups were written non-atomically (a crash mid-write left a truncated backup) and the stored SHA-256 was never verified on restore (a corrupt backup restored silently); `push_quiz` could write an unrestorable `questions: null` backup. Now: temp-file + `fsync` + `os.replace`; `get_backup_content`/`restore_backup` recompute and verify the checksum and fail closed (legacy checksum-less backups still readable); the quiz backup omits `questions` when the fetch fails and `_restore_quiz` guards against `None`. Adds the first tests for the restore path. (#212, #231)
- **`scripts/canvas_api.py`** — `_request_with_retry` retried every verb on `ReadTimeout` and 5xx, so a non-idempotent POST whose response was lost after Canvas committed it was blindly resent, creating duplicate quizzes/assignments/pages/modules. POST now retries only a connect-phase `ConnectionError` (request provably never sent) or a 429 (rejected before processing); `ReadTimeout` and 5xx surface to the caller. Other verbs unchanged. (#209, #232)
- **`scripts/push_syllabus.py`** — the three `requests` calls on the syllabus push path had no `timeout=` (the only such calls in the repo), so a hung connection stalled the push forever. Added `timeout=30` to each. (#211, #226)

## 7.51.1 (2026-07-09) — Bloom bar adopts the canonical Vanderbilt color scale

### Fixed
- **`scripts/assurance_report_html.py`**: the Bloom bar's warm-neutral ramp put adjacent levels too close to tell apart. It now uses the canonical Bloom's-taxonomy scale (the Vanderbilt CFT pyramid): blue at Remember rising through green and yellow to red-orange at Create. Readers already know this scale. Because hue now carries the level instead of luminance, the ordinal reading comes from the legend and the fixed left-to-right order, which also keeps the bar usable for a colorblind reader (color is a redundant channel, never the only one). The scale necessarily borrows green, which elsewhere means Security, so every Bloom color is held at a measured distance from both the Security green and the Design maroon, and adjacent levels from each other; both bounds are pinned by tests with headroom, so a drifting palette fails the test before it can confuse a reader. (#205)

## 7.51.0 (2026-07-09) — Color-coded Bloom scale on the assurance report

### Changed
- **`scripts/assurance_report_html.py`**: the Design-axis "What the Work Measures" bar now codes each Bloom level separately instead of collapsing the distribution into a two-tone green/gray split, which could not tell a reader which levels the course actually measures. Bloom's revised taxonomy is ordinal, so the bar uses a sequential warm-neutral ramp (Remember lightest through Create darkest, monotonically decreasing luminance so the scale survives grayscale and color blindness) and ships a legend naming each level with its share of the grade. The ramp deliberately avoids green and maroon, which the report reserves for the Security and Design axes, so a Bloom level can never be misread as an axis signal. Segments are sorted into canonical Bloom order regardless of input order, zero-weight levels are dropped, and unknown levels sort last rather than vanishing. (#204)

### Docs
- **`skills/assurance-audit/SKILL.md`**: `snapshot.measures.segments` now takes one `{"level","pct"}` entry per Bloom level (from the deep-pass `cognitive_demand` distribution) instead of pre-bucketed `{"pct","kind"}` low/high segments. (#204)

## 7.50.0 (2026-07-09) — Assurance report redesign: two-axis, six-section, brief design system

### Changed
- **`scripts/assurance_report_html.py`**: the full assurance report is rebuilt in the one-page brief's editorial design system and runs two independent axes in parallel throughout, so a reader cannot mistake "secured" for "done". **Security** (green: can it prove a real student did the work) and **Design** (maroon: does the work measure real thinking and address AI use) each get their own color, subhead, and column in every section. Section order: (01) How to Read This Report, (02) The Bottom Line (Verdict matrix + two parallel reads + tension line + stat band), (03) Course Snapshot (Security per-type table + Design Bloom bar and AI-use), (04) Where It Needs Work (Security Gaps beside Design Gaps), (05) Recommendations by Assessment Type (AI-forward emphasized, proctoring/oral last-resort), (06) Method and Caveats. The renderer is deterministic over an authored `narrative["report"]` object (mirrors the brief's `narrative["brief"]`); the scorecard drives the verdict-matrix dot and stat fallbacks. Sections 01 and 06 are static; 02 always renders; 03/04/05 render only when their data is present. Every dynamic value is HTML-escaped. (#203)

### Docs
- **`skills/assurance-audit/SKILL.md`**: Step 4 rendered-report structure and Step 5 narrative sidecar now document the two-axis six-section layout and the authored `report` object schema (verdict, snapshot, gaps, recommendations). The brief's `narrative["brief"]` object is unchanged and independent. (#203)

## 7.49.1 (2026-07-03) — Brief authoring: plain-language default, drop eval benchmark note, tighter glance band

### Changed
- **`scripts/assurance_brief_html.py`**: the Course-at-a-Glance stat cards use tighter padding for a slimmer band. (#200)

### Docs
- **`skills/assurance-audit/SKILL.md`**: brief-authoring guidance now folds in the jargon-to-plain glossary (author every prose field, including the verdict answers and details, as plainly as the Verdict block: "authorship" becomes "whose work it is", "recall" becomes "memory", a "process trail" becomes "how the student worked", and so on), so a brief needs no separate plain-language pass. The Course-Eval glance cell shows the score only, with no "above avg" / "about avg" / "below avg" benchmark label. (#200)

## 7.49.0 (2026-07-02) — Two-axis Verdict block on the brief, plus LockDown detection & eval-styling fixes

### Added
- **`scripts/assurance_brief_html.py`**: a "The Verdict" block on the assessment brief. It draws a 2x2 matrix that separates the two questions faculty conversations conflate: "Proves a real student did it" (horizontal, auto-plotted from `assurance_coverage_pct`) and "Measures real thinking" (vertical, from the AI-forward rating, with an optional `verdict.y` override for the deep cognitive read). A GOAL marker and the course's own dot make the gap visible; the two plain-language answers are authored into `narrative["brief"]["verdict"]`. Renders last, optional, all text HTML-escaped. (#198)

### Fixed
- **`scripts/assurance_inventory.py`**: `_detect_proctoring` now reads Canvas's `require_lockdown_browser` (with or without webcam monitor). It previously saw only `access_code`/`ip_filter`, so every Respondus-locked exam scored as unsecured, materially under-reporting assurance for LockDown-based courses. (#198)
- **`scripts/assurance_brief_html.py`**: the Course-Eval benchmark note applies the green positive style only to "above avg"; "about avg" and "below avg" render in neutral muted gray (evaluations are population context, not a verdict on the course). (#198)

## 7.48.3 (2026-07-02) — Brief authoring: name the actor in exposed bullets

### Docs
- **`skills/assurance-audit/SKILL.md`**: brief-authoring guidance now says to name the actor in the "Where It's Exposed" bullets ("a student could complete the graded work with AI," not "AI can complete the grade"), so the substitutability point is not misread as AI doing the grading. (#196)

## 7.48.2 (2026-07-02) — Pin Canvas domain to the ASU allowlist before token send

### Security
- **`setup_env.py` pins the Canvas domain to an ASU allowlist before the access token is ever sent** (`scripts/setup_env.py`): a new `ALLOWED_DOMAINS` set (`canvas.asu.edu`, `asu.instructure.com`, `asu-dev.instructure.com`) and `normalize_domain()` strip scheme/path, lowercase, and validate the host on every token-send path (`_fetch_all_courses`, `test_connection`, `init_env`, `verify_course`, `migrate`). A mistyped or hostile `CANVAS_DOMAIN` now returns an error instead of leaking the Bearer token to an arbitrary host. Adding a host is a reviewed code change on purpose; there is no runtime override. (#190)

## 7.48.1 (2026-07-02) — Brief: "How Learning Is Assessed" heading + clearer graded-items lead-in

### Changed
- **Brief section heading and lead-in** (`scripts/assurance_brief_html.py`): the run-in heading renamed from "How it's Graded:" to **"How Learning Is Assessed:"** (APA title case), and the graded-items lead-in replaced with **"Each type below shows its count and its percentage share of the final grade:"** so the two figures in each stat cell (count vs. grade weight) are unambiguous. Presentation only. (#193)

## 7.48.0 (2026-07-02) — Editorial assessment brief; quick-start renamed to brief

### Changed
- **One-page assessment brief redesigned** (`scripts/assurance_brief_html.py`): the ID-facing brief now renders in a refined editorial layout (white background, fully boxed data strips, ASU maroon eyebrow labels, Palatino numerals, two-tone bullet dots) instead of tinted cards. It gains a **Course at a Glance** cohort-stats band (enrollment/term, sections/term, avg per section, success rate, DEW rate, course evaluation with a benchmark note) alongside About, How It's Graded, Graded Items, What's Working / Where It's Exposed, The Barrier, and Improvement Opportunities. Driven by an updated `brief` narrative schema (`period_lines`, `glance[]`, `about`, `how_graded`, `graded_items[]`, `working`/`exposed`, `barrier`, `directions[]`); every field is HTML-escaped and each section skips when absent. Label color meets WCAG AA (`#736c60`, 5.2:1). (#191)
- **Deliverable renamed quick-start to brief** across the generator title, docstrings, CLI, and tests, so the three assurance deliverables read cleanly as **brief / report / playbook**. `skills/assurance-audit/SKILL.md` documents the new brief fields and the cohort-stats guidance (modality-filtered ASU Online; DEW/success/evaluation as neutral population context; student evaluations a 1-5 perception metric with known bias, shown with a benchmark rather than a verdict). (#191)

## 7.47.0 (2026-07-02) — Self-service Canvas onboarding (`setup_env.py --init`)

### Added
- **`setup_env.py --init` — self-service first-run onboarding** (`scripts/setup_env.py`): a new interactive command prompts for the Canvas domain + access token, validates it against `/api/v1/users/self` **before** writing, and stores `.env` at `0600`, merge-preserving so unrelated keys survive. The token is read via `getpass`, so it never echoes to the terminal, lands in shell history, or passes through the chat transcript. Cuts per-user token-onboarding friction with no ASU-IT dependency. (#187)
- **ASU-IT Canvas Developer Key (OAuth2) request** (`docs/oauth-developer-key-request.md`): a sendable request for a Canvas Developer Key so the tooling can move to OAuth — per-user identity preserved, refresh-token expiry handling — and retire per-user personal access tokens entirely. The long-term fix behind the interim `--init`. (#187)

### Fixed
- **`.env` writer + `init_env` hardening** (`scripts/setup_env.py`): surfaced by a post-merge Codex review of #187. A duplicate key in an existing `.env` is now collapsed to one line (a stale dup would otherwise shadow the new value under dotenv's last-wins load); an HTTP 200 with a non-JSON body (captive portal / proxy HTML) returns a clean error instead of a traceback and does not persist the token; and a new `.env` is created `0600` from the start via `os.open`+`O_CREAT` to close a brief world-readable window under a permissive umask. (#188)

### Removed
- **Dead code in the staging server** (`scripts/staging_server.py`): removed an unused `host` re-parse in `_check_host` that ran on every request; the `raw_host` Host-header allowlist (DNS-rebinding guard) is unchanged. (#187)

## 7.46.0 (2026-07-01) — One-page faculty-conversation brief

### Added
- **One-page brief as a selectable assurance deliverable** (`scripts/assurance_brief_html.py`): the audit can now render a third document from the same audit data, an ID-facing faculty-conversation quick-start (plain language, no jargon, 60-second skim, print-perfect US Letter). It is driven by a `brief` object Claude authors into the narrative (`subject_matter`, `learning_outcomes`, `graded_items`, `assessment_strategy`, `working`/`exposed`, `constraints`, `directions`); sections skip when absent and every field is HTML-escaped. Skill Step 5 now leads with a deliverable-selection step: brief, report, and/or playbook, any combination, generated on an explicit choice. Records ASU-Online-async as standing context (take-home is never framed as a finding). (#185)

## 7.45.1 (2026-06-30) — Print-perfect report + playbook

### Fixed
- **Report and playbook print to exact PDF paper size** (`scripts/assurance_report_html.py`, `scripts/assessment_design_playbook.py`): `@page` pins US Letter with 14mm margins so the PDF is deterministic instead of print-dialog-dependent, and `print-color-adjust: exact` (with `-webkit-` prefix) forces the ASU maroon/gold backgrounds and borders to render in the PDF instead of being stripped to white. In print, `.report` fills the printable area; break rules keep the tier card, metric rows, recommendations, table rows, and category blocks off page seams; the playbook categories section breaks while each block stays whole; widow/orphan control added. (#183)

## 7.45.0 (2026-06-30) — Deprioritize proctoring/oral, 3-part recommendations, TILT-grounded playbook

### Changed
- **Recommendations render in three parts** (`scripts/assurance_report_html.py`): each recommendation shows Judgement → AI-forward → AI-resistant when the narrative supplies `judgement`/`ai_forward`/`ai_resistant`, falling back to flat `text` for older narratives. AI-resistant lists proctoring, oral defense, and synchronous/observation moves last, labeled last-resort. Proctoring and peer-review stay detection signals only; this is a remediation-presentation change, with no change to tier, coverage, lane, or proctoring-detection math. (#181)
- **Assessment Design Playbook retoned and TILT-grounded** (`scripts/assessment_design_playbook.py`): each category leads with AI-forward moves, then AI-resistant. Neutral tone (certify learning, not catch students or watch them fail); the "deterrent" framing is dropped. Proctoring, oral defense, and live video are demoted to last-resort in every anchor, with no method claimed fully AI-proof (deepfakes, AI voiceovers named). Every move that asks students to verify or critique AI output is paired with the TILT Criteria component: the rubric plus several annotated models of strong work shared in advance (Winkelmes), to close the equity gap. (#181)
- **Skill house style** (`skills/assurance-audit/SKILL.md`): documents the three-part recommendation structure, the last-resort framing for proctoring/oral/synchronous, and the TILT annotated-models pairing as the standing authoring rule. (#181)

Source: reviewer feedback on the COM 100 assurance report (Michael Miller). TILT reconciled against primary sources (tilthighered.com; Lafayette CITLS; Indiana CITL).

## 7.44.0 (2026-06-30) — Verdict-first assurance report, deep-pass cognitive demand, Assessment Design Playbook

### Added
- **Verdict-first report** (`scripts/assurance_report_html.py`): the assurance report leads with the verdict (tier + one plain sentence + the secured-grade metric), then *What to Change*, then *The Evidence*, then *The Bottom Line*, with assumptions and caveats moved to *How to Read This* at the end. The "Dimension D/A/C/B/E" codes and framework jargon (FIRE, 5DF, needs_ai_review, Lane 1/2, counterfeit) are stripped from all user-facing text. Warm-neutral editorial design in ASU maroon + gold, serif display headings, APA title-case verbs and verdicts. (#173, #178)
- **Deep-pass cognitive demand** (`scripts/assurance_inventory.py`, `scripts/assurance_audit.py`, `scripts/assurance_report_html.py`): the audit reads each weighted assessment's actual prompt and rubric (`rubric_excerpt`, discussion message, quiz stems where the API exposes them) and classifies the Bloom level the task *requires*, instead of inheriting it from the module objectives. Objective items whose questions come from API-hidden banks are classified by format (`bloom_content_available=False`) and that share is labeled on the report. The renderer prefers a narrative `cognitive_demand` object (distribution + per-cluster basis + format-inferred share) over the deterministic distribution. Cognitive demand is a within-course diagnostic and never feeds tier or coverage. On COM 100 / 151110 this corrects the seven discussions from a capped Understand to Evaluate. (#178)
- **Assessment Design Playbook** (`scripts/assessment_design_playbook.py`): a styled five-category playbook (Discussions, Quizzes & Exams, Papers, Projects, Presentations) matching the report design, generated fresh with no external content dependency. (#178)

### Fixed
- **No-Unknown Bloom** (`scripts/assurance_inventory.py`): graded discussions resolve their module via the discussion topic's `content_id` (they previously returned `module=None`); objective quizzes/exams in objective-less modules take an Understand floor; graded-discussion MLO inheritance caps at Understand. Together these eliminate Unknown cognitive-demand weight. (#178)

## 7.43.2 (2026-06-29) — Structured-metadata-only classification; keyword hits advisory

### Fixed
- **`scripts/assurance_audit.py`, `scripts/assurance_inventory.py`**: the assurance audit no longer lets a regex/keyword match on prose set a headline number. Deterministic Lane 1 ("secured") status now comes only from structured Canvas metadata — `proctoring_signal is True` or the `peer_reviews` flag. Oral/process-trail keywords in a title or description are advisory: they classify the item Lane 2 and emit a `lane_review` `needs_ai_review` item (any weight) for Claude to confirm, rather than auto-promoting it to a secured anchor. A shared `_keyword_hit` helper uses word-boundary matching so `"oral"` no longer matches inside `"behavioral"` and `"presentation"` no longer matches a discussion's "physical presentation" prompt topic. Auto-graded objective quizzes cap any MLO-inherited Bloom level at Understand (Analyze+ inheritance is flagged for review instead of stamped), removing phantom high-Bloom weight from the Cognitive-Demand distribution. AIAS keyword detection gains the same word-boundary hygiene. Net effect on COM 100 / 259115: the tier is correctly Unassured (no false anchor), and the Chapter 1/3 quizzes no longer contribute a phantom Create level. See `docs/plan-assurance-classification-boundary.md`. (#175)

## 7.43.1 (2026-06-29) — Assurance audit reads content, not just titles

### Fixed
- **`scripts/objective_extractor.py`**: MLO extraction now falls back to a "Learning Objectives" heading-list scan when a module overview page carries no `MLO N.N`-prefixed lines. Anchors on short heading-like blocks (real `<h1-6>` plus `<p>` pseudo-headings) so a bare "objectives" word in prose cannot false-anchor, and stops before a "Self-check" logistics list. Courses that list objectives as plain `<li>` bullets (e.g. COM 100 / 259115) no longer score 100% Unknown Bloom. (#171)
- **`scripts/assurance_inventory.py`, `scripts/assurance_audit.py`**: the assurance audit now grounds Bloom, AIAS, and lane classification in real assessment content instead of titles. Inventory records carry an HTML-stripped, 2000-char `content_excerpt` (assignment description / discussion message / up to 10 classic-quiz question stems); still-Unknown weighted assessments are packaged as `bloom_content` items for Claude's inline layer. AIAS keyword matching drops the false-positive-prone `prompt`/`chatgpt` triggers and carries matched `policy_excerpts` spans. Proctoring detection is tri-state (`True`/`False`/`"unknown"`) so an unconfirmable classic quiz is never miscounted as a secured anchor. The `needs_ai_review` fan-out is structurally deduplicated (one representative per cluster × judgment type). (#172)
- **`scripts/assurance_report_html.py`**: report labels and recommendation verbs render in APA title case; de-slopped verb rendering. (#173)

## 7.43.0 (2026-06-29) — Opt-in PDF export for the AI Assurance audit

### Added
- **`scripts/assurance_report_html.py`**: renders an AI Assurance audit scorecard (plus an optional narrative sidecar) into a self-contained, print-clean HTML report. Deterministic sections render straight from the scorecard; the narrative layer (data caveats, substitutability findings, AIAS level, 5DF authenticity, recommendations, bottom line) merges when present, with data caveats leading the document. Every dynamic value is HTML-escaped; the renderer degrades to "n/a" on missing fields. (#168)
- **`--pdf` flag** on the renderer: chains to the optional Playwright exporter (`report_pdf_exporter`). When Playwright is absent it writes the HTML, prints the install hint, and exits non-zero — it never auto-installs. (#169)
- **Consent-gated PDF offer** in `skills/assurance-audit/SKILL.md` Step 5, beside the existing JSON offer: the audit asks "Want a PDF?" and generates only on an explicit yes. PDF is never the default and never auto-generated; Playwright stays optional. (#169)

### Notes
- The HTML renderer was reconciled field-by-field against the real `assurance_audit.score_course` output; the test fixtures use the real scorecard schema.

## 7.42.1 (2026-06-24) — Silent-correctness nits in the assurance inventory

### Fixed
- **Module-map keyspace collision.** `assurance_inventory._build_module_map` merged assignment-ids and quiz-ids into one `int` keyspace; the `build_inventory` reader falls back to `module_map.get(int(quiz_id))` when the assignment-id lookup misses, so a `quiz_id` that numerically equaled an unrelated assignment's module-item id silently mis-attributed the quiz to that assignment's module. Keys are now namespaced by Canvas item type — `("Assignment", id)` / `("Quiz", quiz_id)` — on both writer and reader, so the two id spaces can't collide. (#164)
- **MLO regex false-positive on prose.** `objective_extractor._MLO_RE` matched any `MLO d.d` occurrence, so an overview page mentioning "…aligns with MLO 1.1…" in body prose captured the trailing sentence as a fake MLO. The match is now anchored to line starts (MULTILINE) and run against a new `_block_text()` that preserves `<li>`/`<p>`/`<br>`/`<td>`/`<th>`/`<tr>` boundaries as newlines (plain `_strip_html` flattened them) — inline prose references no longer match; real list/line MLOs still do. (#164)

## 7.42.0 (2026-06-23) — CLO/MLO Bloom grounding for assurance-audit

### Added
- **Objective grounding for the assurance inventory.** `build_inventory` now reads the course CLOs (from the syllabus endpoint) and per-module MLOs (from module overview pages), Bloom-classifies them, and attaches `bloom_level` / `bloom_index` / `bloom_source` to every assessment record. Bloom level uses the assessment title's verb when present and falls back to the module's MLO level when the title carries no verb (**fill-Unknown**: a clear title verb always wins). Without this, a generically-titled course ("Module 3 Homework") scored as 100% Unknown Bloom and the cognitive-demand + AI-Forward analysis went blind. New `scripts/objective_extractor.py` (`extract_course_clos`, `extract_module_mlos`) is pure-read, graceful-degrade, and reusable by the `alignment` skill.
- **Inventory output gains `course_clos` and `module_mlos`**; the scorecard gains an `objective_grounding` block and `dimensions.C.bloom_source_breakdown` (counts by title/mlo/unknown) so the report shows how much grounding came from MLOs vs titles. An `assumptions` line records `bloom_grounding=title:N,mlo:N,unknown:N`.
- **Honesty guard on MLO-grounded counterfeit.** An objective quiz whose Analyze+ Bloom claim comes only from its module MLOs (not its own title) is routed to `needs_ai_review` as the new `counterfeit_objective_mlo` judgment type, NOT deterministically branded counterfeit. A low-stakes concept-check quiz in a high-level module is legitimate; AI judges which. Deterministic `counterfeit_objective` stays title-claimed only, so the tier is unaffected by MLO grounding.

### Fixed
- **Quiz module attribution.** `build_inventory` mapped module membership by assignment id, but a module item's `content_id` for a quiz is the **quiz id**, so every quiz mapped to `module=None`. Now falls back to the assignment's `quiz_id`, so quizzes (and their MLO grounding) attach to the right module.

## 7.41.0 (2026-06-23) — AI-Forward axis + Bloom calibration for assurance-audit

### Added
- **AI-Forward rating: a second, orthogonal axis on the assessment assurance audit.** The assurance *tier* answers "can the course certify learning despite AI" (resistance); the new `ai_forward_rating` answers "does the design teach and assess ethical, judgment-based AI use" (forwardness). Enum: `Needs redesign` | `Use with safeguards` | `AI-resistant` | `AI-forward`. A course can be Assured-but-Needs-redesign or AI-forward-but-Unassured; both are reported. Deterministic, like the tier; AIAS level and AI refinements never alter it. New `dimensions.F` carries the six rubric dimensions (D1 transparency, D2 Bloom alignment, D3 assurance anchor, D4 human judgment, D5 process/authorship, D6 equity/feasibility), each scored on the four categorical anchors (0-3). The overall rating is the weakest of the three forward dimensions (D1/D4/D5), with a high-stakes-no-anchor auto-floor.
- **`bloom_calibration_findings`**: deterministic per high-weight (>= 20%) item, applying the rubric's Bloom Guide "Avoid" rules — `recall_unprotected`, `apply_reusable`, `highorder_no_criteria`, `create_oneshot` — each citing assessment + points + grade_weight_pct.
- **Two new `needs_ai_review` judgment types**: `judgment_of_ai` (D4) and `authorship_evidence_depth` (D5). Narrative refinement only; never feed the deterministic rating.
- **New reference**: `skills/assurance-audit/references/ai-forward-framework.md` (six dimensions, four anchors, Bloom calibration, evidence base with per-source limits). AIAS construct-drift caution (Ateskan: authority not frequency) added to `assurance-model.md`.
- Mined from the faculty AI-Forward / AI-Resistant assessment rubric tool; the rubric is consumed as content, never emitted as a spreadsheet. The AI-Forward axis is per-course diagnostic and is NOT a cross-course ranking input (cohort ranking stays on the deterministic assurance tier).

## 7.40.1 (2026-06-22) — Render staged images in unified review + Solo Preview button

### Fixed
- **Unified `/review` now renders staged images.** `_sandbox_preview` rewrites `asucb-staged-file://` tokens to local `staging-files` URLs before embedding them in the review iframe (previously only the solo preview route did this, so banners/images showed as broken). (#160)
- **Staging server is now multi-threaded.** Switched `HTTPServer` → `ThreadingHTTPServer`. The single-threaded server serialized the concurrent banner-image requests from the review iframes, causing `ERR_CONNECTION_TIMED_OUT`; all staged images now load concurrently. (#160)

### Added
- **Solo Preview button** on each item in the unified review, linking to its standalone `/courses/{id}/preview/{type}/{key}` page. (#160)

## 7.40.0 (2026-06-19) — create_locked_module helper

### Added
- **`canvas_api.create_locked_module(config, name, items, prerequisite_module_id=None, position=None, require_sequential_progress=False, publish=True)`**: builds a Canvas module, adds its items in order, optionally locks it behind another module (`prerequisite_module_ids`), sets per-item completion requirements (`must_view`/`must_submit`/etc.), and publishes — in one call. Replaces the ~4 hand-rolled API calls previously needed to wire a gated module. Reuses `create_module`/`add_module_item`/`publish_module`; graceful on per-item failure (returns `failed_items`). (#158)

## 7.39.0 (2026-06-19) — Preflight guard for the page-image staging gate

### Added
- **Page Image Staging Gate preflight guard** (`preflight_checks.py`): a staged page body that embeds an image as a live Canvas `/files/{id}` URL (instead of an `asucb-staged-file://` token) now emits an info-level Page-Image-Staging-Gate warning. This catches the bypass where an image is uploaded directly to Canvas and embedded in a page body, skipping the local-first staged-file preview. Info-level (non-blocking) because a re-staged page whose images were already live legitimately carries `/files/` URLs. Docs (CLAUDE.md, AGENTS.md) reinforce: never `upload_image` + embed into a page body; page images go through the token gate. (#156)

## 7.38.0 (2026-06-19) — Equity & Feasibility dimension (E) for assurance-audit

### Added
- **`assurance-audit` Dimension E: Equity and Feasibility** (`scripts/assurance_audit.py`): a deterministic, per-course lens that scores whether a course's assurance design is feasible and equitable in a fully-online accelerated (7.5-week) term. Computes a `feasibility_band` (low / moderate / high burden) from three burdens, each citing assessments with grade weight: **surveillance** (proctored weight >= 50%; equity cost of bandwidth, private space, accommodations, anxiety), **synchronous** (2+ live/oral Lane 1 anchors; recommend sampling, not all-students), and **high-stakes concentration** (>= 70% of weight in items each >= 20%; fragile in a compressed term). Reports `handgraded_anchor_count` as informational only (no enrollment data to assert a labor burden). (#154)
- **Feasibility-aware recommendations**: `_build_recommendations` now steers "Secure" advice toward lower-burden mechanisms (process-trail, recorded asynchronous defense) when surveillance burden is already high, suggests sampling when synchronous burden is high, and adds a standalone high-burden recommendation. The skill no longer recommends inequitable "secure" moves. (#154)

### Changed
- **Scorecard schema**: `dimensions` gains key `"E"`. Dimension E is deterministic and **per-course only**: it never changes `tier` or `assurance_coverage_pct`, never feeds cohort ranking, and is not cross-course comparable. A course can be `Assured` and `high_burden` at once. `assurance-audit` SKILL.md documents E in the scorecard field list (Step 2) and the report order (Step 4). (#154)

### Provenance
- Ported from a reconciliation of Wayne's v2 "AI-Forward Assessment Rubric" against `assurance-audit`: Wayne's rubric scored equity/workload/7.5-week feasibility as a first-class dimension; the audit captured it only as the binary `over_surveillance` flag. Note: E's `surveillance_weight_pct` is proctoring-only, intentionally distinct from the legacy `over_surveillance` flag (which is lane1-weight). (#154)

## 7.37.0 (2026-06-19) — Content remediation (dedash + preflight --fix) + safe live-body re-push

### Added
- **Deterministic content fixers** (`scripts/content_remediation.py`): `fix_dashes` (em/en dashes → colon in headings, comma in body; never touches the minus sign U+2212 or hyphens), `fix_heading_title_case` (reuses `apa_title_case` minor-word list + acronym/after-colon rules), `fix_table_row_background` (moves row backgrounds onto cells for Canvas portability). All pure and idempotent. AI-slop binary-contrast is a documented no-op (semantic rewrite stays manual). (#152)
- **`preflight_checks.py --fix`**: applies the deterministic fixers in place, reports per-fixer counts and residual manual issues, then re-checks. (#152)
- **`scripts/dedash.py`**: thin CLI over `fix_dashes` (`--check` exits non-zero on any em/en dash, for CI/pre-commit). (#152)
- **`scripts/repush_transform.py`**: safe bulk cosmetic re-push — fetches the LIVE Canvas body, transforms it, then stages + pushes (confirm-verb gate, `--dry-run`, skips unchanged). (#152)
- **`deploy_interactives` iframe auto-resize**: `inject_self_resize` injects a self-resize script into uploaded interactive HTML so taller interactives are not clipped by the host iframe. (#151)

### Fixed
- **`alignment_validator` `_track` NameError**: the `alignment` build path crashed; added the missing no-op. (#151)

### Changed
- **MUST-2 guardrail (docs)**: bulk cosmetic edits to already-live content must transform the LIVE Canvas body, never re-push a stale local source (silently reverts live drift). Whole-body diff verification is unreliable on Canvas (strips the `canvas-wrapped` marker, re-encodes entities) — verify field-targeted. Documented in CLAUDE.md, AGENTS.md, and `skills/bulk-edit`; `standards/page-design.md` gained a minus-sign carve-out and points at the fixers. (#152)

## 7.36.2 (2026-06-16) — Fix assurance-audit syllabus false negative (gen-AI policy now detected)

### Fixed
- **Assurance audit AIAS detection**: `assurance_audit._fetch_syllabus_text` read the wrong dict key — `canvas_api.get_syllabus` returns the syllabus body under `html`, but the consumer read `.get("syllabus_body")` (a key Canvas's raw field name, never present in the return dict), so syllabus text was always `""`. This made `_detect_aias_presence` report `declared_presence: false` even when the syllabus contained a full generative-AI policy. Root cause was a key-name mismatch, not a missing `include[]=syllabus_body` param (that was already correct). Verified live on course 256162 (canvas.asu.edu): syllabus text now reads 23,328 chars and `declared_presence` flips to `true`. Added regression tests covering `_fetch_syllabus_text`, the end-to-end detection path, and the `get_syllabus` include-param + return-key contract. (#149)

## 7.36.1 (2026-06-15) — Assurance Audit reachable by natural language + doc alignment

Finishing touches on the **Assessment Assurance Audit** (shipped v7.36.0) — the headline capability for telling, at scale, whether a course's assessments can certify learning in an AI-capable world. Not flashy, but the substance: a course can now be judged good or bad on assurance, deterministically and across a whole cohort.

### Changed
- **Routing**: "AI-forward assessment" (and "is this assessment AI-forward") now routes to `assurance-audit`, alongside the existing triggers (AI-proof, can-students-bypass, assessment assurance, AIAS, authentic assessment, does-this-course-certify-learning, substitutability check). Added to the skill description, CLAUDE.md, AGENTS.md, and course-concierge so the audit is invocable by natural phrasing on both Claude Code and Codex, without naming the skill. (#147)
- **Docs**: aligned plugin inventory counts to reality (27 primary skills, 83 scripts, 12 standards docs) and listed `assurance-audit` in the README skills reference under its own Assessment Assurance section, separate from QA. (#146)

## 7.36.0 (2026-06-15) — Assessment Assurance Audit (new standalone skill) + gated rubric script

New `assurance-audit` skill: scores whether a course's assessment strategy can certify learning in an AI-capable world. Live Canvas API only (no IMSCC). Built for scale. Framework adapted from the external "AI Assessment Evaluator" skill; no upstream code copied verbatim. Plan dual-reviewed by Opus + GPT-5.5 before coding; built in four verified phases.

### Added
- **`assurance-audit` skill** (`skills/assurance-audit/`): four-part assessment audit led by Dimension D (Assurance & AI-Integration). Two-Lane (secured vs formative AI-rich), AIAS 1-5 AI-use declaration, TVO, Gulikers authenticity 5DF, Counterfeit Assurance, durable-skills. Recommendations are secure / re-lane / declare / liberate, never surveillance. Explicitly SEPARATE from `course-audit` (QA/standards); routing distinguishes them.
- **`scripts/assurance_inventory.py`**: normalized, weight-aware live assessment inventory. Computes per-assessment `grade_weight_pct` correctly for both Canvas weighting modes (`apply_assignment_group_weights` on/off), normalizing by the group-weight sum and excluding omitted/unpublished/zero-point items. Normalizes classic quizzes, New Quizzes (opaque-degrade), graded discussions, and external-tool/proctoring objects. (#139)
- **`scripts/assurance_audit.py`**: scoring engine. Deterministic Lane / AIAS-presence / Assurance Coverage / named tier enum (Assured / Partially Assured / Unassured / Counterfeit) is the cross-course-comparable axis; bounded LLM annotation (top-K by weight, prompt-versioned, content-hash-cacheable) is within-course diagnostic only, never feeds ranking. Bloom distribution via `blooms.py`; counterfeit-high-level test deterministic for auto-graded objective items. (#140)
- **Skill + references + routing** (#141) and **batch cohort runner** `scripts/assurance_batch.py` (#144): ranks a cohort by tier then Assurance Coverage, surfaces the certifying-on-nothing courses first; own aggregate report, independent of `course-audit`'s batch path.
- **Gated rubric-creation script** + `canvas_api` rubric fixes (#143).

### Throughput
- `scripts/assurance_throughput.py`: shared token-bucket throttle + per-run cache + per-course call budget for safe batch runs against Canvas rate limits.

### Tests
- Full suite 2293 passed, 1 xfailed. Includes grade-weight golden tests (weighted vs points-only, non-100 group sums, empty weighted groups), determinism-firewall tests, and batch ranking/aggregate tests.

## 7.35.1 (2026-06-14) — Fix: migrate() no longer leaves an orphan course directory

- **`setup_env.migrate()` orphan-directory fix (#137)** — when a legacy `.env` `CANVAS_COURSE_ID` pointed to a concluded/archived/unenrolled course, migration created `courses/{id}/` but the active-only course fetch omitted that course, so no registry entry referenced the directory (and the course the user was working on was absent from the Setup Gate's list). `migrate()` now detects the unmatched case and adds a registry entry — real metadata via a single-course fetch, or a stub if the course is inaccessible — guaranteeing every created `courses/{id}/` is registry-referenced.

## 7.35.0 (2026-06-14) — Quality batch: robustness, dedup, N+1 collapse, WCAG unification, staging CSRF token

Follow-up to the v7.34.0 security batch — the deferred quality/efficiency findings from the same SWE/QA audit (6 PRs, #130–#135). Same process: sub-agent coded, independently reviewed (diff read on disk + full suite re-run + live verification where applicable), coder ≠ reviewer.

- **Robustness + dead code (#130)** — added timeouts to the raw Canvas upload paths (`upload_file` steps, `get_or_create_folder`, `get_media_entry_id`, `upload_caption`) so a stalled socket can no longer hang indefinitely (no retry added — the multi-step upload POSTs are non-idempotent); narrowed an over-broad `except` in `audit_image_alt`; deleted the never-wired `push_bridge_handler.py`.
- **Dedup (#131)** — consolidated 11 copies of `strip_html` into `scripts/html_utils.py` and the duplicated `load_course_config` loaders into `scripts/config_loader.py`; collapsed byte-identical `get_all_pages`/`list_pages` and `get_modules`/`list_modules` in `canvas_api.py`. Behavior-preserving (copies with genuinely divergent semantics were left local).
- **Test-gap backfill (#132)** — characterization tests for four previously-untested critical scripts: `setup_env` (registry sync + `.env` migration), `agent_review_and_merge` (the auto-merge gate), `course_content_cache`, `link_validator`.
- **N+1 page-body collapse (#133)** — `criterion_evaluator`, `course_content_cache`, and `audit_pages` listed pages then fetched each page body in a separate request. Replaced with a single `?include[]=body` index call (verified byte-identical to the per-page body on canvas.asu.edu); also dropped a per-page `sleep` in `audit_pages`.
- **WCAG engine unification (#134)** — `audit_pages` carried its own pre-`wcag_checks` accessibility engine that could disagree with the shared `WCAGContentParser` used by preflight. Rebased `audit_pages` onto the shared parser; its two audit-only checks (generic link text 2.4.4, large-text 3:1 contrast) were added to `WCAGContentParser` so every consumer gains them. Audit and preflight now agree. (Color-only-without-background contrast is intentionally not flagged — consistent with the shared engine.)
- **Staging-server CSRF token (#135)** — the staging server's mutating PUT/DELETE endpoints now require a per-process synchronizer token (minted at server start, embedded in served pages, attached by the page JS), layered on top of the existing Host-header and Origin-allowlist gates. Closes the M3 CSRF remainder.
- **CI: `action-gh-release` pinned to v3.0.0 (Node 24)** — clears the Node 20 runtime-deprecation warning on the release job; still SHA-pinned per the v7.34.0 supply-chain policy.

## 7.34.0 (2026-06-13) — Security hardening batch: token egress, staging XSS/CSRF, supply-chain pins

Closes a SWE/QA security audit of v7.33.0 (4 PRs, #124–#127). No behavior change for normal course-build flows; the hardened surfaces are the Canvas API client, the local staging server, and the dependency/CI supply chain. Each PR was coded by a sub-agent and independently reviewed (diff read on disk + full suite re-run + live verification), coder ≠ reviewer.

- **Canvas PAT token-egress closed (#124)** — three scripts (`setup_env`, `criterion_evaluator`, `audit_image_alt`) re-implemented Canvas pagination and would attach the Bearer token to whatever URL a response's `Link: rel="next"` header named. All now route through `canvas_api.paginated_get`, which validates each next-link against the exact Canvas host before auth follows it. Also fixes a silent-truncation bug: `criterion_evaluator` now raises on a persistent rate-limit instead of reporting a partial audit as authoritative.
- **Staging-server hardening (#125)** — Host-header validation (blocks DNS-rebinding read-exfiltration of staged/Canvas content); removed the wildcard `Access-Control-Allow-Origin: *` (reflect an allowlisted localhost origin only, single emission); new stdlib-only `scripts/html_sanitize.py` allowlist sanitizer **and** sandboxed-iframe rendering of untrusted Canvas/faculty HTML on the privileged `127.0.0.1` origin (defense in depth); `do_HEAD` no longer leaks file existence/size (e.g. `HEAD /.env` → 405); removed a symlink containment bypass. Also closed a pre-existing raw-HTML XSS in quiz answer comments. Editable quiz fields are sanitized in place (kept editable); only read-only previews are sandboxed.
- **Canvas client efficiency (#126)** — module-level `requests.Session` for connection reuse, and the per-call inter-request delay is now write-only — removing fixed latency from GET-heavy audits. The proactive rate-limit throttle and 429 exponential backoff are unchanged.
- **Supply-chain pins (#127)** — `hooks/ensure_deps.sh` pins the `uv` installer to an immutable release URL + sha256 (no more unpinned `curl | sh`); all GitHub Actions pinned to commit SHAs; `.env` added to the release-zip exclusion list.

## 7.33.0 (2026-06-12) — SCORM packager: gradebook-tracked interactives (opt-in)

Adds a SCORM 1.2 export path for interactives that must post completion/score to the Canvas gradebook — the one delivery path with real passback (the iframe path stays the formative-only default). Motivated by two faculty-shared SCORM samples that declared `scormtype="sco"` but contained zero SCORM runtime, so their gradebook columns never filled.

- **`scripts/package_scorm.py`** — wraps any self-contained interactive HTML as a SCORM 1.2 SCO: injects a `window.ASUCB` bridge (`findAPI` → `LMSInitialize` → `cmi.core.lesson_status`/`score.raw`/`suspend_data` → `LMSCommit` → `LMSFinish`) and emits a zip (`imsmanifest.xml` + `index.html`). `completion` (default) or `scored` (0–100, optional `--mastery-score` → passed/failed). The bridge **no-ops cleanly when no LMS API is present**, so the same injected HTML still works as a plain iframe. Idempotent injection (sentinel), `suspend_data` truncated to 4096, score clamped 0–100, path-safety on identifier/output. 19 tests in `tests/test_package_scorm.py`.
- **`deploy_interactives.py --scorm`** — opt-in flag (`--scorm-mode`/`--scorm-mastery`/`--scorm-output`) that packages a SCORM zip instead of the default iframe deploy; **the default (no `--scorm`) iframe path is unchanged.** Producing a local zip is not a Canvas write (the SCORM import is the ID's manual UI step), so the confirm-verb push gate does not apply. 3 tests in `tests/test_deploy_interactives_scorm.py`.
- **The interactive must call the bridge** — generator/hand-built HTML calls `window.ASUCB.complete(score, suspendData)` at its terminal screen (one line). Without that call nothing posts; this is exactly the gap in the faculty samples.
- **ASU SCORM path documented + verified end-to-end** — ASU Canvas has **no SCORM import API** and does **not** import via the Files UI; SCORM runs through the **SCORM LTI tool** (Course nav → "SCORM" → Upload → "Choose import type" → graded assignment → Go), which creates an External-Tool assignment (100 pts default) + gradebook column. The packager prints these exact steps. Verified on canvas.asu.edu course 260866 (2026-06-12): a wired branching sim posted **88/100** to the gradebook (confirmed via the Submissions API, not just the player display). `skills/interactive-content/SKILL.md` Path 4 documents when to choose SCORM vs iframe vs native quiz.
- **Gradable-vs-formative gate (mandatory)** — `skills/interactive-content/SKILL.md` now requires asking the user, before generating OR deploying any interactive, whether it is a gradable SCORM assessment (gradebook passback) or a formative iframe (visualization/ungraded). The choice forks at generation time (a gradable interactive must call `window.ASUCB.complete()`), so it can't be deferred to deploy; default is formative iframe when the user is unsure.

## 7.32.0 (2026-06-08) — MLO over-claim audit + generalized staging auto-clear + interactive-deploy routing rule

Three more enforce-don't-trust guardrails (#120), continuing the live-session hardening.

- **MLO over-claim audit (`scripts/audit_mlo_overclaim.py`)** — mirror of the orphan-CLO check: flags an assessment that declares alignment to an MLO with no graded task evidence. Operates at module-co-location granularity (the level the config supports): cross-module `mlo_ids` references and modules with assessments but no declared MLOs. Reads all MLO field names the codebase uses (`objectives`, `mlos`, `mlos_draft`, `module_learning_objectives`, `learning_objectives`) and both list- and dict-shaped `assessments`; wired into `criterion_evaluator.py` + the backlog. Codifies the manual M5/M6 homework MLO trimming.
- **Generalized staging auto-clear** — `create_page`/`create_quiz`/`create_assignment`/`create_discussion` accept `staged_key=None`; when `from_staging=True` and a key is given, the staged artifact is cleared on success (never on failure) via `_clear_staged_on_success`. Extends the v7.31.0 mockup-helper auto-clear to the raw create paths that previously left staging behind.
- **Interactive-deploy routing rule** — CLAUDE.md + AGENTS.md: deploy JS interactives only through `scripts/deploy_interactives.py` (which enforces the confirm-verb gate and clears staging); never hand-roll the upload-file + iframe deploy via raw `canvas_api` calls.

## 7.31.0 (2026-06-08) — Enforce-don't-trust guardrails: APA title-case linter + deploy/preflight + faculty-doc preservation

Turns recurring operating failures from a live STP530 session into enforced tooling, so they can't recur by eyeball or memory.

- **APA title-case linter (#117)** — `scripts/apa_title_case.py`: deterministic APA title-case checker (first/last/after-colon + major words + 4+ letters capitalized; <=3-letter articles/conjunctions/prepositions lowercased; hyphenated both-parts), checking only heading tags + summaries (faculty-verbatim body prose exempt). CLI `--file`/`--text`; `standards/page-design.md` spells out the rule.
- **Auto title-case in preflight (#118)** — `preflight_checks.check_title_case` wires the linter into staging preflight, emitting `style.title_case` WARNING-level findings on every staged page/content surface (never blocks; headings only).
- **Gated interactive deploy (#118)** — `deploy_interactives.py --html-file` now requires `--confirm-verb {push,approved,deploy}` (ambiguous affirmatives rejected structurally via argparse choices) and clears the staged page on success. Closes the self-approval/leftover-staging loophole around the Write Safety Guard for hand-built interactives.
- **Mockup-create staging auto-clear (#118)** — `push_new_mockup_quiz`/`push_new_mockup_assignment` clear staging on success (and only on success).
- **Faculty Office-artifact preservation (#118)** — CLAUDE.md Edit Discipline rule: derive from a faculty `.docx`/`.pptx`/`.xlsx` by copying the original and modifying minimally, never rebuilding. New `scripts/docx_append.py` (`copy_docx`/`append_heading`/`append_table`) preserves the source's existing content/tables. `python-docx` pinned in `scripts/requirements.txt`.

## 7.30.1 (2026-06-08) — Fix get_or_create_folder crash on existing folder

- **Fix `get_or_create_folder()` TypeError (#115)** — Canvas `GET /folders/by_path/{full_path}` returns a JSON array of folder dicts (root → target), not a single dict. The found-folder branch indexed the list as a dict (`folder['id']`), raising `TypeError: list indices must be integers or slices, not str` whenever the target folder already existed. Now selects the last element (`folders[-1]`, the deepest/target folder) and falls through to the create path on an empty list. Regression coverage in `tests/test_get_or_create_folder.py` (list path, create path, empty-list fallthrough). Found during a live STP530 session re-uploading an interactive HTML file to an existing Canvas folder.

## 7.30.0 (2026-06-08) — Codify session learnings: mockup type parity, interactive verify+iframe, verbatim fidelity, title case

Turns operating lessons from a live STP530 build session into enforced contracts and tooling.

- **Mockup object-type parity (#113)** — a mockup must mirror the real Canvas object type it represents (homework→Assignment, quiz→Quiz, overview/lesson/syllabus→Page, peer-rating/teamwork→Assignment); identify the target type before creating and never default to Page. Added to CLAUDE.md §Hard Scope Rules + `skills/course-mockup/SKILL.md`. Motivated by homework mockups built as Pages that had to be rebuilt as Assignments.
- **Interactive verification + iframe-only delivery (#113)** — every JS interactive must be browser-verified (render + interact + screenshot, not HTTP 200) before it is claimed to work, and deployed via an uploaded HTML file + `<iframe src=.../files/{id}/preview>` (Canvas strips `<script>` from page bodies). New `scripts/deploy_interactives.py --html-file PATH --title TITLE [--course-id ID] [--module-id ID]` mode for a single standalone interactive (folder → upload text/html → iframe host page → optional module item). CLAUDE.md + `skills/interactive-content/SKILL.md`.
- **Verbatim faculty-content fidelity (#113)** — never paraphrase faculty instruction text in mockups/derived content. New `scripts/faithful_restyle.py` extracts a named section, restyles structural tags only, and verifies tag-stripped byte-equality (`extract_section`/`restyle`/`verify_verbatim` + CLI with drift exit code); `tests/test_faithful_restyle.py` (6 tests) covers nested-list verbatim preservation, href/verifier-token preservation, drift detection, and style injection. CLAUDE.md §Edit Discipline.
- **Title Case convention (#113)** — headings (h2–h4) and page/mockup titles use Title Case; sentence case for body prose only. `standards/page-design.md`.
- **Design standards consolidation (#112)** — merged `design-taste.md` into `page-design.md` and added the UX quality layer.
- **Image alt-text auditor wired into audit skills (#110)** and **challenge-your-verdicts memory rule (#111)**.
- **CI**: added `beautifulsoup4` to `scripts/requirements.txt` (used by `faithful_restyle.py` + `refresh_asu_calendar.py`).

## 7.29.0 (2026-06-05) — Canvas write-gating guard + image alt-text auditor + quiz-push fixes

Hardens Canvas writes against unapproved deletes and adds vision-assisted alt-text auditing. Motivated by a live STP530 session where an unguarded `delete_page` removed a faculty-edited page (no backup, no approval) and filename-only alt text passed unnoticed.

- **Canvas write-gating guard (#108)** — `delete_page`/`delete_quiz`/`delete_assignment`/`delete_discussion` now require `confirm=True` (raise `PermissionError` otherwise) and write a timestamped `backup_manager` backup **before** the delete (abort if backup fails). `create_page`/`create_quiz`/`create_assignment`/`create_discussion` gain `from_staging=False` and emit a `logging.WARNING` when created outside the staging gate; the gated push paths (`push_to_canvas.py`, `push_mockup_to_canvas.py`) pass `from_staging=True`. Closes the raw-`create_*`/`delete_*` back doors around the Write Safety Guard.
- **Vision-assisted image alt-text auditor (#107)** — new `scripts/audit_image_alt.py`: extracts images across pages/quizzes/assignments/discussions (incl. quiz `question_text` + answer HTML), classifies each as content-relevant vs decorative, and applies a deterministic verdict matrix (missing/filename/raw-`LaTeX:` alt → error/warn; relevant-but-marked-decorative → error; decorative-with-descriptive-alt → info). Manifest-for-agent vision model (no API key), sha256 content-hash cache. Heuristic-only subset wired into `preflight_checks.py`; full pass on demand via `criterion_evaluator.py` under `a11y.wcag:1.1.1`.
- **`canvas_api` guards (#106)** — `create_rubric` gains `bookmark=True` to post a Course-context association so API-created rubrics appear on the Rubrics page (they are invisible until associated). `update_quiz_question` warns when `question_text` is passed without `answers` (Canvas replaces the full question on PUT — partial updates silently wipe answers).
- **Numerical quiz answer round-trip fix (#105)** — `_normalize_quiz_answers_for_write` now maps Canvas read keys to write keys (`exact`→`answer_exact`, `margin`→`answer_error_margin`, plus approximate/precision/range). Previously a pushed numerical answer silently reset to 0.0.
- **Quiz schema allowlist (#104)** — `staging_schema.KNOWN_QUESTION_TYPES` adds `multiple_dropdowns_question` and other standard Canvas types that were wrongly rejected as `unknown_question_type`, blocking valid quiz pushes.

## 7.28.0 (2026-06-04) — ASU brand hardening (enforced linter)

Turns ASU brand compliance from advisory docs into an enforced gate.

- **New `scripts/check_asu_brand.py`** — deterministic brand linter. Same JSON contract + positional CLI as `check_interactive_a11y.py`. Reuses `wcag_checks.parse_style`/`parse_color`; scans both inline `style=""` and `<style>` blocks (incl. `:root` CSS variables). Color tokenizer handles `#rgb`/`#rrggbb`/`rgb()`/`rgba()`/named/`var()`/`linear-gradient()`. Checks: font-family must lead with Arial (block), italics banned via `<i>`/`<em>`/`font-style:italic` (block), banned same-element contrast pairs (block), off-palette color (warning). ASU palette allowlist is a single importable constant. Path-based exemptions for report chrome + warm-neutral proposal template.
- **`generator.py`** — font stack switched to ASU digital stack (Arial-first); italic fill-blank hint removed; bootstrap success color mapped to ASU green `#78BE20` (accessible error red retained).
- **Wiring** — `deploy_interactives.py` runs the brand gate before any Canvas API calls (font/italic/contrast failures block deploy; off-palette warnings advisory). `preflight_checks.py` emits advisory `brand.*` findings to the `.issues.json` sidecar (never blocks push).
- **Standards** — `design-taste.md` + `anti-slop.md` document the italics ban, ASU digital font stack, full secondary + gray palettes, and banned contrast pairs.
- Brand-guide source: brandguide.asu.edu (fonts/color/patterns), verified 2026-06-04.

## 7.27.1 (2026-06-01) — Codex contract parity + auto-Coda-sync (release of merged docs/CI)

Ships the docs/CI work merged after v7.27.0 (PRs #92-97) so it reaches installed plugins and Coda.

- **AGENTS.md full parity with CLAUDE.md** so the Codex runtime (which reads AGENTS.md, not CLAUDE.md) has the same always-on contract: Canvas template triggers (SDO JD / ASU Online QM), routing hard-scope rules, Collaboration Stance, Script-Enforced Workflows, Codex Co-Implementation, Page Design System (Minimal-Diff vs Design Enhancement), Gold Standard Module Structure, Batch Execution, Setup Gate 2.5/3.5. Skills/scripts/templates were already shared, so functional capability was identical; this closes the contract gap. `REQUIRED_SECTIONS` test extended to enforce it.
- **Coda sync auto-fires on every release** via a chained `sync-coda` job in `release.yml` (the standalone `release: published` trigger never fired because GITHUB_TOKEN-created releases are suppressed by GitHub's recursion guard).
- Operational rules ported to the shipped `docs/memory/RULES.md` template (never-clobber staged edits, PR-only role separation). Removed 7 completed plan docs (history retained in CHANGELOG).
- Full suite 1421 passed, 1 xfailed, 47 subtests.


## 7.27.0 (2026-06-01) — Remove staging WYSIWYG editor

The staging-server Tiptap WYSIWYG edit layer flattened richly-styled HTML it didn't model (syllabus title spans, table cell backgrounds, divs) and would never be 100% lossless. Observed usage is 100% Claude/Codex-driven editing; the editor served no real editing path while corrupting content. Removed it; kept the read-only preview + push gate + the lossless raw-HTML Source panel.

- `scripts/unified_staging_review.py`: −918 net. Removed the `@tiptap/*` importmap, the module-script editor (Editor, CanvasImage, CanvasTableHeader/Cell, resize/align NodeView, startEditing, autosave, toolbar, color palette, link dialog), editor CSS, the Edit button, and `data-editable` wiring. Kept the read-only `.show-content` render, sidebar TOC, approval checkboxes, the Remove-from-staging button, and the **Source panel** (`toggleSource`/`copySource` + raw-HTML Save) as the lossless edit path.
- `scripts/staging_server.py`: PUT save endpoint retained (Source panel Save) with a comment noting the in-browser autosave is gone; staging writes otherwise come from `staging_manager.py` / push scripts.
- Removed editor test files (`test_unified_staging_review_image_attrs.py`, `test_staging_editor_image.py`); added a regression test asserting `generate_review` output contains no `@tiptap`/`tiptap`/`startEditing`/`contenteditable`/`data-cmd`.
- `skills/staging/SKILL.md` + `CLAUDE.md` §Write Safety Guard: read-only-preview language; the push gate (stage → preview → explicit push verb) is unchanged.
- Supersedes the v7.26.x image-editor work (table color, image align, resize) — that surface is removed with the editor. Coder: Sonnet; Reviewer: Opus 4.8 (Codex unavailable). Full suite 1421 passed, 1 xfailed, 47 subtests.


## 7.26.2 (2026-06-01) — Toolbar focus-steal fix (image align)

Image center/left/right align buttons silently did nothing. Root cause: the `button[data-cmd]` toolbar buttons bound only `click`; mousing down on a button cleared the image NodeSelection before the handler ran, so `editor.isActive('image')` was false and the align `updateAttributes` no-op'd. (Resize was unaffected — direct drag on the handle, never touches the toolbar.)

- Added `mousedown → preventDefault()` to the `data-cmd` toolbar buttons (canonical Tiptap pattern) so they don't steal focus; the editor keeps its selection and image-align applies on click. Text/heading/list commands unaffected (they `.focus()` regardless). `node --check` SYNTAX OK; Codex APPROVE. Test +1; full suite 1435 passed, 1 xfailed, 47 subtests.

## 7.26.1 (2026-06-01) — Image NodeView centering + click-to-select fix

Follow-up to v7.26.0: the image resize NodeView shipped two in-browser defects unit tests could not catch (visual/interaction). Surfaced on the LAW 515 syllabus edit view.

- **Centering no-op fixed.** The single `display:inline-block` wrapper shrank to image width and sat left — the Phase-2 `margin:auto` centering had no room. Replaced with a nested structure: outer block wrapper (`display:block;width:100%`) carrying `text-align` derived from the image's style, inner `inline-block` wrapper holding the `<img>` + resize handle (handle lands on the image corner). Centering now works live.
- **Align/resize click fixed.** Align buttons + resize needed the image node selected; a cursor click left them inert. Added a click listener calling `setNodeSelection(getPos())` so clicking the image selects it and the toolbar/resize act immediately.
- Visible selection outline added. Serialized output stays a bare `<img>` (wrappers editor-DOM only). `node --check` SYNTAX OK; Codex review 7/7. Tests +3; full suite 1434 passed, 1 xfailed, 47 subtests.

## 7.26.0 (2026-06-01) — Staging-editor table + image fixes

Three defects in the Tiptap rich-text editor inside `scripts/unified_staging_review.py`, surfaced on the LAW 515 syllabus edit view. Per `docs/plan-staging-editor-image-table.md`. Coder: Sonnet sub-agent; Reviewer: Codex (substantive diff review, `node --check` JS validation); test gate verified in the real environment by the parent (Codex sandbox false-fails on socket-bind staging tests).

- **Phase 1 — table cell color preservation.** `TableHeader`/`TableCell` dropped cell-level `background-color`/`color` on edit, and the configure-default `background:#f5f5f5` clobbered any per-cell fill (shorthand reset) — making maroon headers render invisible grey-on-white. New `CanvasTableHeader`/`CanvasTableCell` preserve `background-color`, `color`, `text-align`, `vertical-align`, `border`, `padding` via a `preserveCellStyle` whitelist; removed the grey default fill so per-cell backgrounds survive.
- **Phase 2 — image block-alignment preservation.** Centered banners (`<div style="text-align:center"><img></div>`) lost centering on edit because the block image discarded its wrapper. `CanvasImage` now reads the wrapping parent's `text-align` and synthesizes `display:block;margin-left:auto;margin-right:auto` (center) / `margin-right:0` (right) into the image's own inline style — wrapper-free, Canvas-safe, idempotent. Added left/center/right image-align toolbar buttons.
- **Phase 3 — interactive image resize.** Added a Tiptap NodeView giving images a bottom-right drag handle (aspect-locked, clamped 40px–container width); width/height write back via `setNodeMarkup` preserving Phase-2 alignment and v7.6.2 attrs. NodeView chrome lives only in the editor DOM — serialized output stays a clean `<img>`. Drag listeners removed on `pointerup` + `destroy()`.
- Preserves v7.6.2 `CanvasImage` width/height/style preservation and the v7.9.2 `/api/v1/` audience-lint exemption. Tests: +5 in `tests/test_unified_staging_review_image_attrs.py`. Full suite 1431 passed, 1 xfailed, 47 subtests.

## 7.25.2 (2026-05-31) — College-appropriate imagery default (ASU Online audience)

Generated and searched images now default to the real audience. A tester saw a K-12 / child photo generated for a college course; the image guidelines had no audience or representation rule, so nothing steered subjects toward adult learners.

### Added
- **`standards/image-generation-guidelines.md` Audience & Representation section**: every ASU course is college-level on ASU Online. When an image depicts people, show adult college learners (18+ and older, including working adults), diverse across race, age, gender, and ability, in realistic online-study settings. Children / K-12 / juvenile aesthetics are a mismatch by default and are only used when the course topic explicitly requires them (early-childhood education, pediatrics, etc.). Photorealistic adult-learner photos are an explicit carve-out to the "no photorealistic faces" diagram constraint.
- Wired the default into the prompt-structure template (new `[PEOPLE]` line), the negative-prompt defaults (no children / K-12 / juvenile block), and the image review checklist (new "Audience fit" row; Inclusivity row now names diversity dimensions).
- **`skills/images/SKILL.md`**: the audience default is applied at prompt-build (Step 3-G) and at search vetting (Step 3-S), pulling the always-college course level from `course-config.json`.

## 7.25.1 (2026-05-31) — deploy_interactives backs up host page before write

Closes a staging-bypass found in the write-path deep dive. The interactive HTML was staged and preview-approved, but `deploy_interactives.py` injected the iframe into the existing page body via `update_page` with no backup, leaving the host page with no rollback point.

### Fixed
- **`scripts/deploy_interactives.py`** now calls `backup_manager.save_backup(course_id, "pages", slug, current_body)` before the iframe-injection `update_page`, matching the Write Safety Guard (back up before any body write). Backup failure is non-fatal (logged warning), but the backup is always attempted first.

### Tests
- `tests/test_deploy_interactives_backup.py`: backup runs before the write and captures the pre-injection body; deploy still proceeds (logged) when backup fails.

## 7.25.0 (2026-05-29) — Anti-slop lint (stop-slop integration)

Generated Canvas content no longer reads as AI slop. Adapts the stop-slop skill (MIT, (c) 2025 Hardik Pandya, https://hvpandya.com) into ASUCB; no upstream code copied verbatim.

### Added
- **`standards/anti-slop.md`**: instructional-context-tuned anti-slop standard (throat-clearing openers, business jargon, binary-contrast "not X but Y" structures, meta-commentary, vague declaratives, filler phrases), with an explicit instructional carve-out (adverbs, Wh- question starters, and second person are fine in course copy). Referenced from `standards/design-taste.md`.
- **`check_slop()` preflight lint** in `scripts/preflight_checks.py`: deterministic detection of the high-confidence slop subset, `criterion_id="style.slop"`, severity `info` (advisory, never blocks), report capped at 10 + "and N more". Wired into `check_page` (so assessment/discussion inherit it) and `check_quiz` (description + each question_text). Page Tier-1 surfaces it on stage.
- Scope decision: the adverb-purge and no-Wh-starter rules are NOT linted (they false-positive on legitimate pedagogy); they stay as write-time guidance in the standard. The lint was FP-tuned against real course copy ("In today's lesson", "comes to a full stop", "full stop" as the British period mark all pass clean).

### Tests
- `tests/test_slop.py`: phrase/structure categories fire, instructional carve-outs do not, dedupe + cap, all four content surfaces.

## 7.24.0 (2026-05-29) — Image staging gate (images obey stage to preview to push)

Page images now follow the same stage to preview to push gate as all other content, instead of being uploaded to Canvas Files eagerly the moment they are added. This fixes the tester confusion where a correctly-uploaded Canvas image rendered blank in the local staging preview (a Canvas file URL needs a Canvas session), and it stops images from landing on Canvas before the page is approved.

### Added (PR #76, preview path)
- **`files` staging type** in `staging_manager.py`: stage raw image bytes to `courses/{id}/staging/files/{key}.{ext}` with a `.meta.json` sidecar (alt, attribution, source_url, mime, module). Path components validated with the same regex + containment guard as the other types. `list`/`get`/`clear` include staged files.
- **`/courses/{id}/staging-files/{key}` route** in `staging_server.py`: serves staged bytes with a Content-Type derived from the sidecar mime (validated against a strict type/subtype regex to block header injection; falls back to an extension guess). Double path-traversal guard, 404-not-500 on missing.
- **Token rewrite for preview**: the staging server rewrites `asucb-staged-file://{key}` to the local `/staging-files/{key}` URL when rendering a page, so previews show the image with no Canvas involvement.

### Added (PR #77, push path)
- **Token resolution in `push_to_canvas.py`**: before pushing a page, every `asucb-staged-file://{key}` token is resolved by uploading the staged file to Canvas Files (`upload_image()`) and rewriting the `src` to the Canvas URL. Atomic: all uploads run before any Canvas write; if any upload fails the page push aborts and staging is preserved; staged files are cleared only after the page verifies. One upload per unique key. (Documented limitation: a sequential partial failure can orphan an earlier file in Canvas Files; the page is never partially pushed.)
- **Skills stage instead of eager-upload**: `images` and `media-upload` now download to temp, stage via the `files` type, and embed the token; the Canvas upload is deferred to push. Standalone non-page uploads keep a documented carve-out. `AGENTS.md` + `CLAUDE.md` document the page-image staging gate.

### Tests
- `tests/test_image_staging.py` (+34) and `tests/test_image_staging_push.py` (+10): binary staging + path safety, the file route + Content-Type/traversal/injection guards, token rewrite, atomic push resolution, failure-preserves-staging, one-upload-per-key. Full suite 1359 passed, 1 xfailed.

## 7.23.1 (2026-05-29) — Staging server: always port 8111, reuse-on-occupied

Fixes a Codex regression where "port 8111 occupied" led the agent to start a second staging server on 8112 and announce a `localhost:8112` URL. Occupied 8111 means the server is already running; the launcher must reuse it.

### Fixed
- **`skills/staging/SKILL.md`** now instructs launching via `scripts/ensure_staging_server.py` (idempotent, always 8111, reuses if bound, never falls back to another port), not `staging_server.py` directly. Adds explicit "occupied 8111 = already running, reuse it; never announce localhost or another port" guidance.
- **`scripts/staging_server.py`** catches `EADDRINUSE`/`EADDRNOTAVAIL` on bind and exits 0 with a clear "already running on 8111, reuse it, do not pick another port" message instead of an uncaught traceback (the traceback is what invited the 8112 improvisation).
- **`AGENTS.md`** Write Safety Guard step 2 hardened: staging server always uses 8111; occupied means reuse; never start a second server, never 8112, never call `staging_server.py` directly; announce only `127.0.0.1:8111`.

### Tests
- `tests/test_staging_server_port.py`: a busy port yields exit 0 + a reuse message pointing to `ensure_staging_server.py`, no traceback, no 8112 suggestion.

## 7.23.0 (2026-05-29) — Always-on operating contract (Codex + new-setup onboarding fix)

Fixes a recurring onboarding defect: testers on Codex and on fresh Claude Code setups did not follow the ASUCB workflow (Action First, Setup Gate, routing, Write Safety Guard, HPI/ID standard) and had to be prompted each time. Root cause: the orchestration contract lived only in `CLAUDE.md`, which Codex never reads (it reads `AGENTS.md`) and which Claude Code auto-loads only from the cwd tree — so a tester working outside the plugin repo never received it. Gilbert BEM Information-cell gap: the contract existed but was not delivered at point of performance.

### Added
- **`AGENTS.md`** — canonical condensed operating contract at repo root. Codex auto-discovers it; it is also the payload the SessionStart hook injects. Single source of truth for the always-on push behaviors.
- **`hooks/inject_contract.sh`** — SessionStart hook that emits `AGENTS.md` as `hookSpecificOutput.additionalContext` (Claude Code) with a raw-stdout fallback for runtimes that inject hook output directly. Silent + non-blocking on any failure; resolves the plugin root from `ASUCB_PLUGIN_ROOT`/`CODEX_PLUGIN_ROOT`/`CLAUDE_PLUGIN_ROOT` or the script location. Wired into `hooks/hooks.json` SessionStart alongside `ensure_deps.sh`.

### Tests
- `tests/test_contract_injection.py`: AGENTS.md presence + required push-behavior sections + no-dash lint; hook emits valid SessionStart JSON carrying the contract; hook stays silent when the contract is absent; hooks.json wiring intact.

## 7.22.0 (2026-05-29) — Em/en dash ban + doc refresh

### Added
- **`check_no_em_dash` preflight lint** — `scripts/preflight_checks.py` detects literal em dashes (U+2014), en dashes (U+2013), and the `&mdash;`/`&ndash;` entities, wired into `check_page` as a non-blocking `style.em_dash` warning. Reports cap at 10 with an "and N more" summary on dash-heavy bodies. Codifies the no-em-dash convention product-wide instead of leaving it in a personal memory file. (PR #69)
- **Convention docs** — `standards/page-design.md` gains a "No em or en dashes" subsection; `standards/design-taste.md` gains the matching quality-checklist line. (PR #69)

### Docs
- Post-v7.21.0 refresh: CLAUDE.md script count 67→69 + `wcag_checks.py` entry + WCAG-on-all-surfaces note; README scripts 61→69 and standards 10→12; marketplace + Coda template note WCAG static checks on every content surface. (PR #70)

### Tests
- `tests/test_preflight_checks.py` +6 (literal dashes, entities, plain-hyphen no-warn, clean-punctuation no-warn, capped report, check_page wiring).

## 7.21.0 (2026-05-29) — WCAG 2.1 A+AA preflight hardening + collaboration stance

Lifts the WCAG 2.1 A+AA static checks (previously locked inside the interactive-deploy gate) onto every preflight content surface, plus the collaboration-stance and PDF-report work that landed unreleased. WCAG criteria checklist inspired by the accessibility-test-scanner plugin in jeremylongshore/claude-code-plugins-plus-skills (MIT); no upstream code copied. Scope: WCAG 2.1 A+AA static subset only (no 2.2).

### Added
- **Reusable WCAG validator** — `scripts/wcag_checks.py`: extracted the stateful `WCAGContentParser` (contrast 1.4.3, alt 1.1.1, heading 1.3.1, accessible-name + ARIA 4.1.2, tabindex 2.4.3, disallowed-element 2.2.2) + pure contrast helpers out of `check_interactive_a11y.py`. One-directional import; interactive deploy gate output is byte-for-byte unchanged. (PR #66)
- **WCAG checks on all preflight content surfaces** — `scripts/preflight_checks.py`: `_wcag_issues` dict to Issue adapter wires the shared parser into `check_page`, `check_quiz`, `check_assessment`, `check_discussion`. Criterion namespace `a11y.wcag:<sc>`. Findings dedupe by (criterion, message); severity floor never `error` (additive, non-blocking on rollout); fragment mode suppresses document-level 3.1.1/1.4.10 on body fragments. Net-new static signals: 1.4.1 (de-underlined colored link), 1.4.4 (viewport zoom suppression), 2.4.2 (Page Titled, sourced from object metadata not body). Page Tier-1 surfaces `a11y.wcag:*` on stage; quiz Tier-1 stays feedback-only. (PR #67)
- **Collaboration stance + optional PDF report export.** (PR #64)

### Docs
- WCAG 2.1 A+AA static-subset hardening plan (`docs/plan-wcag-2.1-aa-hardening.md`, `docs/plan-wcag-pr2-preflight-wiring.md`). (PR #65)

### Tests
- `tests/test_wcag_checks.py` (+24) and `tests/test_preflight_wcag.py` (+19). Full suite: 1298 passed, 1 xfailed.

## 7.20.1 (2026-05-28) — Staged-table header contrast fix

Patch fixing a recurring contrast bug where staged-content tables with a maroon (or any) `<tr>` background rendered header text invisibly (PR #61).

- **fix(staging-render) — row backgrounds now win over the default cell fill.** The staging renderer forces `.show-content th/td { background: var(--canvas-table-head) }` (opaque light gray). A `<tr style="background:#8C1D40;color:#fff">` header row had its cells painted gray by that rule, so white header text sat on light gray → failed contrast. New scoped CSS — `.show-content tr[style*="background"] > th, > td { background: transparent; }` (specificity 0,2,2 beats the 0,1,1 default, no `!important`) — makes cells transparent ONLY when their row sets an inline background, so the row color shows through. Normal tables (no row background) keep the gray header default unchanged. Applied to all four table-rendering surfaces: unified review (`.show-content`), page/assignment/discussion/syllabus preview (`canvas-shell.html`), quiz preview (`.quiz-description`), and faculty mockup (`.page-content`).
- **feat(preflight) — `check_table_header_contrast`.** Warns (non-blocking, `table.header.row_bg_without_cell_bg`) when a staged table sets a `<tr>` background but its `<th>`/`<td>` cells lack inline backgrounds — portable guidance since Canvas LMS strips `<style>` and may override row backgrounds even with the renderer CSS fix.
- **docs(page-design) — table convention.** `standards/page-design.md` now documents: set table-header colors as inline styles on each `<th>` cell (background + color), not on the `<tr>` and not via a class/`<style>` block — the only portable + override-proof option for both the staging preview and live Canvas.
- **tests:** +3 preflight cases.

## 7.20.0 (2026-05-28) — In-person→online conversion toolkit (5 features)

Five reusable capabilities surfaced during the STP 533 (course 261470) in-person→online conversion. Each shipped via its own reviewed PR (Opus spec+review, Sonnet implementation, 4-gate merge); test suite grew from 1005 to 1246.

### Added
- **Conversion-Readiness Audit mode** (`course-audit`) — `scripts/audit_conversion_readiness.py`: 7 deterministic detectors (bare media-embed pages, off-host/fragile video embeds, caption/transcript gaps, missing objectives, empty syllabus, no discussions, gradebook junk) + a readiness signal ("Build, not port" / "Conversion-ready with cleanup" / "Online-ready"). Batch-eligible; emits to backlog; HTML report. Host matching is exact + fails closed. (PR #53)
- **Accelerated-session scope triage + session calendar** — `scripts/session_calendar.py` (ASU A/B/C session lengths + build-complete window math: start−28d target / −14d floor) and `scripts/scope_triage.py` (flags over-scope vs session capacity, surfaces optional-only cut candidates — the cut decision stays with the instructor). Calendar data in `standards/asu-academic-calendar.json`. (PR #54)
- **Verbatim ASU policy boilerplate asset** — `standards/asu-syllabus-boilerplate.html` (the ASU Online QM-template institutional policy sections, verbatim + genericized, with provenance) + `scripts/syllabus_boilerplate.py` loader. `syllabus-generator` now injects this instead of regenerating policy prose; gaps flagged, never fabricated. (PR #55)
- **Clone module layout from a reference course** — `scripts/extract_course_layout.py`: extracts a source course's module order + SubHeader pattern + per-module page-role sequence + lightweight page signatures + optional syllabus section tagging into a reusable, genericized template. (PR #56)
- **Proposal mode** (`course-mockup`) — `scripts/proposal_builder.py` + `templates/mockup-html/proposal-base.html`: renders a presenter-facing, neutral business-proposal deck in a warm-neutral editorial shell (no maroon re-skin, no faculty-review framing, no AI badge — a documented exception for ID strategy docs). (PR #57)

## 7.19.4 (2026-05-27) — Mockup module-grouping bug fix + HW mockup convention

Patch fixing a silent degradation in the v7.19.2 Canvas-module-aware sidepanel, plus codifying the HW/assignment mockup layout convention (PR #50).

- **fix(staging-review) — Canvas-module grouping now works on a fresh process.** `fetch_canvas_modules` checked `os.environ["CANVAS_TOKEN"]` *before* `import canvas_api`, but the token lives in `.env` and only reaches `os.environ` when `canvas_api`'s module-level `load_dotenv()` runs at import. So on every fresh process the env check short-circuited to offline mode → the sidepanel always fell back to local `module_number` grouping and never showed real Canvas module names. The v7.19.2 feature was effectively dead. Fix: `import canvas_api` now precedes the token check (the import triggers `load_dotenv`). Mockup items (`kind: mockup`) now correctly group under the "Mockups and Suggestions" module instead of "Module N" buckets. Graceful degradation (no token / no network → local fallback) is preserved.
- **docs(course-mockup) — HW/assignment mockup layout convention.** New "Mockup HW / Assignment Layout" section in `skills/course-mockup/SKILL.md`. HW/assignment mockups have no per-question REPLACE/ADD surface, so alignment observations aggregate in a "Possible Misalignments" subsection under "Aligned MLOs": maroon `MLO#` anchor → one of the 5 canonical evidence types → verbatim MLO quote → plain-language reason. No gold `<mark>` highlight, no black `NOTE:` prefix. LLM attribution badges use ASU maroon `#8C1D40`.
- **feat(preflight) — attribution-badge maroon lint.** `check_mockup_lane_discipline` now warns `mockup.lane.attribution_not_maroon` (non-blocking) when an "Example Generated by Claude" badge ships in a `<strong>` without `color:#8C1D40`. Catches the gen-time miss where the badge shipped black.
- **tests:** 927 → 933 passing (+6: token-order regression, mockup→Mockups-module mapping, attribution-maroon lint). `pytest -W error::SyntaxWarning` clean (v7.19.3 cleanup preserved).

## 7.19.3 (2026-05-27) — JS template SyntaxWarning cleanup

Maintenance patch. No runtime behavior change.

- **fix(staging-review) — escape JS regex backslashes.** Three regex literals (`/%\s*$/` ×2, `/^\d+$/`) in the embedded Tiptap JS block of `unified_staging_review.py` used single-backslash escapes in a non-raw Python string, which Python flags as `SyntaxWarning: invalid escape sequence` (and which break in a future Python). Doubled to `\\s`/`\\d` in the source. The rendered JS the browser receives is byte-identical (single-backslash `\s`/`\d`). Verified: `pytest -W error::SyntaxWarning` passes 927 tests with zero warnings.

## 7.19.2 (2026-05-27) — Edit chrome-injection fix + Canvas-module-aware staging sidepanel

Patch fixing a data-corruption bug in the unified staging review editor, plus making the staging sidepanel reflect real Canvas module structure (PR #46).

- **fix(staging-editor) — edit chrome no longer corrupts staged files.** On the unified review, clicking Edit → Done Editing on an assignment/page/discussion (even with zero edits) injected the preview-page chrome (`← Back to Course Review` link + title) into the saved staged file. Root cause: `stopEditing` re-rendered by fetching the per-item preview page and scraping its `.user_content` div — but `_wrap_html` prepended the `preview-top-bar` chrome *inside* that div, so the scrape captured it, the next edit cycle serialized it via `editor.getHTML()`, and the PUT wrote chrome to disk. Two fixes: (1A) `stopEditing` now re-renders from the existing `/api/staging/{c}/{type}/{id}/raw` endpoint (clean staged content), not a preview-page scrape; (1B) `_wrap_html` injects the top-bar into a new `{{TOP_BAR}}` slot in `canvas-shell.html` that lives *outside* `.user_content`, so no `.user_content` scrape can ever capture chrome again. Belt + suspenders.
- **feat(staging-review) — Canvas-module-aware sidepanel.** The unified review sidepanel + section grouping now reflect real Canvas module names + position order (e.g., "Module 3: Categorical Predictors") instead of generic "Module N" labels. Staged items map to their Canvas module via: explicit `meta.target_module_id` → mockup items to the "Mockups and Suggestions" module → `meta.source_canvas_id` lookup in module items → `module_number` filename fallback → Unmapped bucket. Module list cached to `courses/{id}/.module_cache.json` (1h TTL, `?refresh=1` bypass; gitignored).
- **Graceful degradation.** When Canvas is unreachable (no token, no network, API error, or `get_config` sys.exit), the review falls back to the local `module_number` grouping with generic labels. Offline review never breaks or hangs.
- **tests:** 904 → 927 passing (+23: 6 chrome-injection structural guards + 17 Canvas-module-mapping/degradation cases).

## 7.19.1 (2026-05-27) — Staging review meta fallbacks + guideline tone

Patch bundling two follow-ups from the v7.19.0 cycle: the staging-server "Unmapped" + filename-as-title bug surfaced when staging the STP530 M1/M2/M3 HW alignment-review mockups (PR #44), and the tone reframe of `skills/course-mockup/SKILL.md` from rigid template to flexible guidelines (PR #43, no functional code changes).

- **fix(staging) — module-number auto-extraction extended to all object types (PR #44).** `unified_staging_review.py` previously auto-extracted the module number from filename patterns (`m1-…`, `m3-…`, `module-2-…`) only for pages + interactives. Assignments, discussions, and quizzes had to carry `meta.module_number` explicitly or render as "Unmapped Staged Content". Now the cascade is `meta.module_number` → filename pattern fallback → 999 for all object types.
- **fix(staging) — title auto-extraction fallback for HTML bodies (PR #44).** Quizzes already extracted title from their JSON body (`quiz.title`); assignments/discussions had no fallback and fell to the filename slug when `meta.title` was missing. New `_extract_first_heading_text()` helper parses the first `<h1>-<h6>` text from staged HTML for pages/assignments/discussions. Cascade: `meta.title` → JSON quiz title (quizzes) → first heading text (HTML bodies) → filename slug.
- **feat(preflight) — `check_staging_meta_completeness` (PR #44).** Warns when a staged artifact's `.meta.json` lacks `title` or `module_number` AND no fallback resolves the value (i.e., the item *will* render degraded). Severity=warning, non-blocking.
- **docs(staging_manager) — render-relevant meta fields documented (PR #44).** `stage()` docstring + module-level docstring now explicitly call out `meta.title` + `meta.module_number` as the fields the unified review surfaces depends on, plus the fallback behavior + degradation symptoms.
- **docs(course-mockup) — convention reframed as guidelines (PR #43).** `skills/course-mockup/SKILL.md` "Mockup Quiz Layout Convention" section retitled to "Pattern + Principles". New "Principles vs Presentation" subsection separates 5 non-negotiable principles (no body H1, no Bloom jargon, verbatim MLO quotes, LLM attribution on generated content, verbatim questions array) from presentation conventions IDs adapt freely. Templates at `templates/mockup-html/quiz-mockup-{template,block-snippets}.html` reframed as reference patterns, not required structures. Locked tone: third-person neutral reference doc, engineer-readable.
- **tests:** 888 → 904 passing (+16 new in `tests/test_staging_meta_render_fallbacks.py` + extended `tests/test_preflight_checks.py`).

## 7.19.0 (2026-05-27) — Mockup lane discipline conventions + preflight lints

Codifies the ID-vs-SME lane discipline learned during the STP530 M1/M2/M3 mockup iteration. Ports the locked REPLACE/ADD block convention from RULES.md into the plugin so future mockup work doesn't relitigate the same wording battles. Adds 3 new preflight lints to catch the most common drift patterns.

- **feat(course-mockup) — locked quiz layout convention.** `skills/course-mockup/SKILL.md` gains a new `## Mockup Quiz Layout Convention` section documenting the 4-part REPLACE/ADD block pattern (verbatim live Q excerpt + evidence type bold + verbatim MLO quote + LLM-attributed example draft). Five canonical evidence types named: verb mismatch, evidence absence, format mismatch, weight inversion, practice/feedback loop missing. New templates at `templates/mockup-html/quiz-mockup-template.html` + `quiz-mockup-block-snippets.html` (chip spans for KEEP/REPLACE/ADD, NOTE inline maroon callout, example code-block with green-highlight correct-answer pattern, LLM attribution badge).
- **feat(preflight) — `check_mockup_lane_discipline`.** Lints mockup quiz description HTML for ID-lane violations: compound-noun jargon ("confidence-interval-interpretation item"), unexplained abbreviations (CI/MR/df without first-use expansion), and bare Bloom verbs in faculty-facing prose. Skips `<pre>` code blocks + `<em>` verbatim MLO quotes. All warnings, non-blocking.
- **feat(preflight) — `check_body_no_title_repeat`.** Catches body HTML that restates the Canvas object title under its own chrome. Any `<h1>` in body → error (blocks). `<h2>` matching the title (case + whitespace normalized) → warning. Applies to pages, assignments, discussions, quiz descriptions.
- **feat(preflight) — `check_pre_overflow`.** Warns on `<pre>` tags without `white-space: pre-wrap` (Canvas page width overflows otherwise). Canonical pattern: `<pre style="white-space:pre-wrap;word-wrap:break-word;...">`.
- **feat(canvas_api) — `create_quiz(scoring_policy=...)`.** Closes the prior gap that forced a follow-up `update_quiz` call after every quiz create. Validates against `VALID_SCORING_POLICIES = ("keep_highest", "keep_latest", "take_average")`. `add_quiz_question` now also forwards the `*_html` feedback variants (matching the locked feedback convention from v7.18.1).
- **feat(push_to_canvas) — mockup-module CREATE paths.** New `push_new_mockup_quiz(course_id, staged_key, mockup_module_id)` + `push_new_mockup_assignment(...)` functions atomically handle: create object → add questions/details → add module item → verify → clear staging → log timeline. CLI flags `--create-mockup-quiz` / `--create-mockup-assignment`. Replaces the hand-rolled scripts that pushed today's STP530 M1/M2/M3 mockup quizzes.
- **feat(alignment_validator) — `LANE_EVIDENCE_TYPES` enum.** Module-level constant exposing the 5 canonical evidence type names + plain-language definitions. Referenced from `skills/course-mockup/SKILL.md` + `skills/alignment/SKILL.md` so the taxonomy doesn't drift across docs.
- **tests:** 863 → 888 passing (+25 new in `tests/test_mockup_lane_discipline.py`).

## 7.18.1 (2026-05-27) — Quiz feedback `_html` field fallback

Patch fixing silent data-loss path for question-level quiz feedback. Surfaced during STP530 M1/M2/M3 mockup remake — Canvas API uses `correct_comments_html` / `incorrect_comments_html` / `neutral_comments_html` for rich-HTML feedback, but renderer/push/preflight only read the plain field names (`correct_comments` etc.). Feedback written under `_html` keys was invisible in the staging preview AND silently dropped during push to Canvas.

- **fix(renderer) — `_html` fallback in `staging_server._render_quiz` + `unified_staging_review.render_quiz_cards`.** Both renderers now read `q.get("correct_comments_html") or q.get("correct_comments", "")` (and the same pattern for incorrect + neutral). Stopped double-escaping feedback HTML — feedback is faculty-authored, same trust as `quiz.description`, so `<strong>Correct.</strong>` now renders as bold instead of `&lt;strong&gt;`. Added missing `neutral_comments` render path under a "General Feedback" label (was dropped entirely before).
- **fix(push) — `_html` fallback in `push_to_canvas.push_quiz`.** Both the CREATE branch (`canvas_api.create_quiz_question` kwargs) and the UPDATE branch (`update_fields` normalization) now fold `_html` variants into the plain field names before sending to Canvas. Audience-lint allowlist extended to include both variants so they survive the visible-text extraction.
- **fix(preflight) — `_html` fallback in `check_identical_feedback`.** Identical-feedback detection now coerces each field read through `_html or plain` so duplicates surface regardless of variant.
- **docs(staging_schema) — canonical convention documented.** Module docstring now states plain names (`correct_comments` etc.) are canonical (matching `fetch_canvas_to_stage.py` output); `_html` variants are accepted as fallback for resilience.
- **tests:** 854 → 863 passing. New `tests/test_quiz_feedback_html_fallback.py` covers all 4 paths (2 renderers, push CREATE + UPDATE, preflight identical-feedback) across plain + `_html` + mixed variants.

## 7.18.0 (2026-05-27) — Staging editor: image percentage fix + quiz Details/Questions tabs

PR-only workflow shipped two PRs back-to-back (#35, #36), each Sonnet-coded + Opus-4.7-Extra-High reviewed via `scripts/agent_review_and_merge.py` 4-gate auto-merge.

- **fix(staging-editor) — image percentage width preserved (PR #35).** Canvas pages with `<img width="75%">` previously expanded to natural resolution in WYSIWYG edit mode because ProseMirror's DOMSerializer strips non-integer `width` HTML attributes (HTML5 spec). New `preserveDimensionAttr(name)` helper in the `CanvasImage` Tiptap extension drops percentage values from the HTML attribute (returns `null` from parseHTML, emits only pure-numeric strings from renderHTML) and the `style` attribute handler synthesizes `width:75%` into the inline style when the HTML attribute is a percentage and the inline style lacks the property. Style allowlist extended with `aspect-ratio`, `object-fit`, `float`, `margin-left`, `margin-right`. Backlog updated with deferred edit-mode bugs 3, 5, 6, 7. +6 test cases in `tests/test_staging_editor_image.py`.
- **feat(staging-editor) — hybrid quiz Details/Questions tabs (PR #36).** Quiz settings now visible in two places: (1) read-only Details summary card in the unified review (`/courses/{id}/review`) — one row of ASU-brand pill chips (slate quiz-type, gold points, maroon attempts/time-limit, slate-outline scoring/shuffle, red-outline Unpublished when applicable), each chip an anchor to the per-quiz edit page `#details` hash; (2) Canvas-style tab bar (`Details` / `Questions`) on the per-quiz edit page with vanilla-JS URL-hash routing. Details tab is a form (title, quiz_type, points_possible with auto-calc + override, allowed_attempts, time_limit, scoring_policy, shuffle_answers, published) that saves via `PUT /api/staging/{cid}/quizzes/{key}/details` — additive to the existing full-JSON PUT. Strict `QuizDetailsSchema` validation rejects unknown keys (catches typos before they reach Canvas). New `check_quiz_details_consistency` preflight emits four non-blocking warnings: `redundant_scoring_policy` (keep_highest/take_average with single attempt), `published_no_points`, `time_limit_on_practice`. +10 test cases in `tests/test_quiz_details_tabs.py`. Skill doc `skills/course-mockup/SKILL.md` appended with "Quiz Details vs Questions" subsection.
- **tests:** 844 → 854 passing (1 xfailed, 8 subtests passed).

## 7.17.2 (2026-05-23) — Report-generation policy + versioned release zip

- **docs(alignment) — explicit report generation policy.** `skills/alignment/SKILL.md` Mode B Workflow now leads with a "Report generation policy" callout. The rendered HTML preview is ALWAYS optional and never auto-generates as a side effect of an alignment-review conversation. Orchestrator runs only after an explicit ID ask (`generate report`, `render the alignment graph`, `make the preview`, etc.). Review-flavored prompts (`review alignment`, `is this aligned?`, `check CLO/MLO mapping`) stay in chat as thought-partner only. Ambiguous prompts (bare "show me the graph") get a chat-side verdict first + a confirmation offer before invoking the orchestrator. Closes a SKILL.md ambiguity where the "One-command path" and the "Thought-partner workflow" sections could both fire on the same prompt.
- **infra(release) — versioned release assets.** `.github/workflows/release.yml` now embeds `${{ github.ref_name }}` in the zip filename so each tag release ships an asset named after its version (`asu-canvas-builder-v7.17.2.zip`). Users + the Codex upgrade docx can identify the version from Downloads/ at a glance. Backfilled on v7.17.1.
- **tests:** +1 contract test asserts the report-generation policy callout + ambiguous-prompt handling rule live in SKILL.md.

## 7.17.1 (2026-05-23) — Alignment graph cleanup + UX polish

Post-v7.17.0 fixes surfaced during LAW 519 + STP530 alignment review smoke. All read-only renderer / extractor changes — no Canvas writes.

- **fix(alignment) — CLO regex relaxed.** `_CLO_ANCHOR_RE` previously only allowed closing tags between the "Learning Outcomes" header and the `<ul>` bullet list. Real Canvas syllabi commonly wrap the bullet list with a `<p>Students completing this course will be able to:</p>` paragraph in between. Regex now allows up to ~1KB of arbitrary intervening HTML. STP530's 5 CLOs went from 0 extracted → 5 extracted.
- **fix(alignment) — post-build inferred edge layer.** `merge_inferred_into_course_config` only worked on MLOs declared in `course-config.json`. Canvas-extracted MLOs (the common case) lived only in graph nodes and never reached the merge path. New `apply_inferred_mappings_to_graph(graph, inferred)` runs after `build_graph_with_canvas_data` and emits `INFERRED` `aligns_to` (MLO→CLO) and `assesses` (assessment→MLO) edges by node-label lookup. Re-runs gap analysis after.
- **fix(alignment) — sidecar lookup also matches by canvas_id.** `apply_inferred_mappings_to_graph` now resolves assessment entries via `assessment_id` OR `canvas_id` OR `assessment_label`, so per-quiz-question inferences can use raw Canvas question IDs without inventing a synthetic stable_id.
- **fix(alignment) — Module 0 + non-numbered modules excluded.** "Welcome and Start Here" (Module 0) and admin modules without "Module N" in the name ("Links and Tools for Students", "ASU Online Facilitation Guide", "Course Evaluation", "Orientation and Onboarding Review") are skipped at both the MLO pre-pass and the item iteration loops. Their syllabus quizzes, group-charter uploads, Tech Support / Zoom For Students pages, and instructor-facing facilitation docs no longer pollute the alignment graph.
- **fix(alignment) — non-instructional page titles filtered.** New `_NON_INSTRUCTIONAL_PAGE_TITLE_RE` blocks Overview / Task List / Mockup: / [Instructors: / Honorlock / Office Hours / Library Resource / Tech Support / Zoom / AI Tutor / Course Evaluation / Conclusion-and-Reflection pages from being added as Material nodes within numbered modules. STP530 went from 58 material/page nodes → 6 (Lecture Videos + Covered Textbook Sections only).
- **fix(alignment-preview) — table filter actually hides rows.** `.dimmed-by-graph-filter` swapped from `opacity: 0.18` (too subtle to read as filter) to `display: none`. Clicking a CLO/MLO now hides non-matching rows instead of leaving them as ghost content.
- **fix(alignment-preview) — click no longer flings disconnected nodes off-screen.** Drag-start handler used to reheat the force simulation on every mousedown, including pure clicks. Nodes with zero edges (e.g. M4–M7 placeholder CLOs) had no link attraction and were flung away by `forceManyBody` repulsion. The `simulation.alphaTarget(0.3).restart()` call moved from `drag-start` → `drag` event so reheating only happens on actual drag movement.
- **feat(alignment-preview) — click-pinned info panel inside graph.** New `#graph-pinned-info` element lives inside the graph SVG container (positioned relative to `#graph-canvas-wrap`). Clicking a node pins full label + type + LO text + Canvas link to the top-left corner of the graph frame. Stays visible until the selection is cleared (click background, click same node, or Clear selection button). Hover tooltip continues to float near the cursor independently. Pinned panel no longer leaks outside the graph viewport.
- **tests:** full suite stays at 727 passed, 1 xfailed.

## 7.17.0 (2026-05-23) — Thought-partner alignment workflow + inferred-mappings sidecar

Closes the LAW 519 disconnect: Canvas-extracted CLOs/MLOs landed in the alignment graph with zero ladder edges because Canvas rarely declares curriculum mappings. v7.17.0 adds Claude-as-thought-partner — the agent infers mappings in chat, persists them in a sidecar across sessions, and ID approval promotes them into the canonical config.

- **feat(alignment) — inferred-mappings sidecar (PR #29).** New `courses/{id}/alignment/inferred_mappings.json` (schema `alignment-inferred-mappings/v1`) holds chat-derived MLO→CLO ladders + assessment→MLO mappings. Persists across chat sessions to avoid drift. Never overwrites canonical `course-config.json` fields — only fills gaps. Mappings render as `INFERRED` edges (dashed line, "Likely alignment" badge).
- **feat(alignment) — thought-partner workflow in SKILL.md.** New "Thought-partner workflow (alignment review)" section documents the 7-step chat-collaboration pattern. Minimum sidecar JSON schema example included.
- **feat(alignment) — approval verb gate.** Exact-match phrases: `alignment approved` / `faculty approved` / `align approved`. Bare `approved` rejected (collides with push gate). Distinct verb set keeps alignment writes separate from Canvas push writes.
- **feat(timeline) — new event type.** `alignment_mapping_approved` registered in `timeline_logger.py:VALID_EVENT_TYPES` for audit trail.
- **fix(alignment) — orchestrator auto-load + fail-fast.** Malformed sidecar fails the run with a clear error rather than rendering a partial graph.
- **tests.** +11. Full suite: 727 passed, 1 xfailed.
- **deferred.** Per-item KEEP/REVISE/ADD/REMOVE/REPLACE proposed-changes feature.

## 7.16.1 (2026-05-23) — Alignment table LO text inlined

- **fix(alignment):** alignment preview tables now show full CLO / MLO / assessment-part text inline so reviewers don't have to memorize what `CLO1` / `MLO1.1` actually are. Alignment Paths cell adds a muted-text snippet line under each `CLO → MLO → item` path with each node's truncated text. Assessment Detail mapped-MLO list shows MLO text alongside the MLO label, with edge evidence on a separate sub-line. Possible Gaps already surfaced node text in the evidence column — no change there.
- **tests:** +2 renderer tests (`test_alignment_paths_cell_inlines_clo_and_mlo_text_snippets`, `test_assessment_detail_lists_full_mlo_text_next_to_label`). Full suite: 716 passed, 1 xfailed.

## 7.16.0 (2026-05-23) — Graphify-style alignment visualization

Closes Phase 3 (final phase) of `docs/plan-alignment-mode-b-canvas-first-graphify.md`. Alignment preview now leads with an interactive d3 force-directed graph view above the existing tables. Plugin promise of "self-contained, no external deps" preserved via vendor-inlined d3.js v7.

- **feat(alignment) — Graphify-style visualization (PR #27).** New `<svg id="alignment-graph">` section in `alignment-preview.html` renders the typed alignment graph as a force-directed network. Node color encodes type (CLO maroon, MLO gold, materials blue, assessments green, parts gray). Edge stroke encodes confidence (`EXTRACTED` solid, `INFERRED` dashed, `AMBIGUOUS` dotted red). Drag-to-reposition, zoom + pan, hover tooltips with full node text + edge evidence + Canvas link. Click a node to highlight connected nodes/edges + filter the existing tables to rows referencing that node (`dimmed-by-graph-filter` class). Layout toggle switches between force simulation and hierarchical CLO → MLO → material/assessment → part tiers. Tables still render below the graph for accessibility fallback.
- **infra(alignment) — vendor d3.js v7.9.0.** New `templates/vendor/d3.v7.min.js` (~273 KB) checked in with SHA256 in `templates/vendor/SHA256SUMS.txt`. Inlined into rendered HTML; no CDN, no network on open. `_load_vendor_d3()` raises `FileNotFoundError` fail-fast when the vendor file is absent (no partial HTML ships).
- **tests.** +7 new renderer tests in `tests/test_alignment_preview_html.py`. Full suite: 714 passed, 1 xfailed.

## 7.15.0 (2026-05-23) — Mode B Canvas-first extraction + plain-English orchestrator

Closes phases 1 + 2 of `docs/plan-alignment-mode-b-canvas-first-graphify.md`. Plain-English Mode B prompts ("visualize alignment for {course}") now route to a single orchestrator command that extracts CLOs from the Canvas syllabus + MLOs from module overview pages automatically. `course-config.json` becomes an optional planning-map override. IDs no longer need to hand-build configs or invoke CLI scripts.

- **feat(alignment) — Canvas-first extraction (PR #25, Phase 1).** `scripts/alignment_graph_preview.py:build_graph_with_canvas_data` now accepts `course_config=None` and extracts CLOs from `syllabus_body` + MLOs from each module's overview page via two new public helpers (`extract_clos_from_syllabus`, `extract_mlos_from_overview`). Config wins on label collision; text divergence emits `AMBIGUOUS needs_review` edges. CLI `--include-canvas` now works without `--config` / without a config file. Partial / malformed config falls back to Canvas-only with a warning. Unresolvable `--course-id` exits non-zero with a clear error. Silent fetch failures (syllabus or overview page) now log `WARNING` to stderr instead of swallowing. CLI warns when extraction yields zero CLOs AND zero MLOs.
- **feat(alignment) — Mode B orchestrator (PR #26, Phase 2).** New `scripts/alignment_mode_b.py` chains build → render → optionally decisions → optionally auto-open in a single command (`python3 scripts/alignment_mode_b.py --course-id {id} [--with-decisions] [--no-open]`). Direct-import (no subprocess) keeps tracebacks clean. macOS auto-open via `open`. JSON summary with per-step ok/error + counts. `skills/alignment/SKILL.md` Mode B Workflow now leads with the one-command path + states "Canvas is the source of truth; course-config.json is an OPTIONAL planning-map override" + documents auto-open + summary-only chat reporting. Standalone scripts remain available for finer control.
- **fix(alignment) — text divergence false positives (PR #25 follow-up).** Config-vs-Canvas conflict detection now uses `_normalize_for_text_compare` which strips trailing punctuation + lowercases on top of whitespace collapse. Prevents false-positive `needs_review` edges on cosmetic differences.
- **tests.** +12 new tests across the 2 PRs. Full suite: 707 passed, 1 xfailed.
- **LAW 519 (course 223754) read-only smoke** — Canvas-first works with no hand-built config: 9 CLOs from syllabus, 16 MLOs from overview pages, 322 nodes, 444 edges. Build time: same. Manual prep: zero (was ~10 min per course).

## 7.14.1 (2026-05-23) — Canvas module-number parsing fix

- **fix(alignment):** `scripts/alignment_graph_preview.py:_module_number` now parses the module number from the Canvas module title (e.g. "Module 10: Edit") instead of falling back to `module["position"]`. Canvas `position` is visual order on the modules page — it counts Module 0, Spring Break, Links & Tools, etc. — so Canvas "Module 10" ended up with `position=12` in real courses (LAW 519 has 14 visual modules including Spring Break + Links & Tools), and all M10 alignment evidence was silently misrouted to a phantom `module_number=12` slot that no config module matches. Result: declared MLOs for any module past Module 8 showed 0 materials, 0 assessments, 0 questions. Fix: prefer `number` / `module_number` field → parse `Module N` from title → fall back to `position` → fall back to enumerate index. LAW 519 smoke before/after: MLOs without material 4 → 0; MLOs without assessment 4 → 0; assessments without MLO 9 → 6; ADD decisions 4 → 0; all 39 declared MLOs now correctly tied to their module's items (#23).
- **tests:** +2 regression tests — `test_module_number_prefers_name_over_position` (covers explicit-number override, name parsing, position fallback, enumerate fallback) and `test_canvas_module_titles_drive_alignment_match` (end-to-end Canvas extractor regression: tree with Module 0 + Spring Break + Module 10 at position 12 must still link Module 10's assignment to config MLO10.1). Full suite: 690 passed, 1 xfailed.

## 7.14.0 (2026-05-23) — Alignment Canvas extractors + mockup decision support

Completes the alignment graph preview roadmap from PR #16. PRs #19 + #20 close the final two phases.

- **feat(alignment):** read-only Canvas extractors in `scripts/alignment_graph_preview.py`. New `--include-canvas` CLI flag (default OFF) plus `build_graph_with_canvas_data(config, course_config, *, course_tree=None)` API. Lazy-imports `canvas_api` + `course_navigator` so local-only mode stays dep-free. Pulls Pages / Assignments / Quizzes / Discussions / Files / ExternalUrls / ExternalTools from the module tree; fetches per-quiz questions via `get_quiz_questions`; reads assignment rubrics inline from the assignment payload. Surfaces config-vs-Canvas conflicts as `AMBIGUOUS` `needs_review` edges instead of overwriting local design data (matches the embedded `conflict_policy`). Sets `source_mode = "local-course-config+canvas-readonly"`. Read-only by design: a test asserts that write helpers (`create_*` / `update_*` / `delete_*`) raise `AssertionError` if called (#19).
- **feat(alignment):** HTML renderer (`scripts/alignment_preview_html.py`) now emits a top-of-preview "Needs setup review" callout when the graph has zero CLO or MLO nodes. Closes the no-CLOs-no-warning gap from PR #18 (#19).
- **feat(alignment):** mockup decision support in `scripts/alignment_mockup_decisions.py`. Reads `courses/{id}/alignment/graph.json` → writes `courses/{id}/alignment/mockup_decisions.json` with KEEP / ADD / REPLACE / OPTIONAL labels per MLO + per assessment item. Per-MLO logic: ADD when no evidence; KEEP if any `EXTRACTED` edge; OPTIONAL if only `INFERRED`; REPLACE if only `AMBIGUOUS`. Per-item logic mirrors edge confidence. Orphan items default to REPLACE with custom rationale. Schema `alignment-mockup-decisions/v1`. CLI: `--course-id` / `--graph` / `--output` / `--include-types`. Quiz support first per the PR #16 spec; assignments + discussions follow. Faculty-safe rationale per `skills/course-mockup/SKILL.md` framing rule — refuses "bad alignment" / "failed assessment" / defect phrasing. Read-only — never invokes Canvas write helpers; a test enforces this (#20).
- **tests:** +16 new tests across PRs #19 + #20. Full suite: 673 passed, 1 xfailed.

## 7.13.0 (2026-05-23) — Alignment graph preview MVP

- **docs(alignment):** new plan `docs/plan-alignment-graph-preview.md` defines the alignment graph preview architecture — MVP scope (`CLO → MLO → materials → assessments → assessment parts`), typed nodes and typed edges, hybrid `EXTRACTED`/`INFERRED`/`AMBIGUOUS` confidence model, static HTML preview requirements, 5-PR roadmap, mockup decision-support integration (`KEEP`/`ADD`/`REPLACE`/`OPTIONAL`), and explicit non-goals (no general KG, no Graphify dep, no graph DB, no auto-edit) (#16).
- **feat(alignment):** local-only graph builder at `scripts/alignment_graph_preview.py`. Reads `courses/{id}/course-config.json` → writes `courses/{id}/alignment/graph.json`. Stable SHA1-derived IDs. Node types `clo`/`mlo`/`page`/`video`/`reading`/`assignment`/`discussion`/`quiz`/`quiz_question`/`rubric_criterion`. Edge types `aligns_to`/`supports`/`assesses`/`contains`/`uses`/`needs_review`. Embedded `conflict_policy` ("course-config is intended design; future Canvas evidence is implemented state; conflicts preserved as AMBIGUOUS needs_review, not silently overwritten"). Gap buckets surfaced (`mlo_without_clo`/`mlo_without_material`/`mlo_without_assessment`/`assessment_without_mlo`/`quiz_question_without_mlo`/`rubric_criterion_without_mlo`/`ambiguous_edges`). Schema validator catches bad types, duplicate IDs, dangling refs, missing evidence, invalid confidence. CLI: `--course-id`/`--config`/`--output`/`--validate-only` (#17).
- **feat(alignment):** static HTML renderer at `scripts/alignment_preview_html.py`. Reads `graph.json` → writes `courses/{id}/alignment/alignment-preview.html`. Self-contained (inline CSS + JS, no external deps). 6 sections: Course Summary, Filters (module/CLO/MLO/type/confidence), Alignment Paths, Possible Gaps, Assessment Detail, Faculty Review Notes. Faculty-safe label mapping: `EXTRACTED` → "Explicit evidence", `INFERRED` → "Likely alignment", `AMBIGUOUS` → "Needs review" (raw confidence preserved in `data-confidence` attrs). All graph content escaped via `html.escape(quote=True)`. ASU brand chrome (maroon `#8c1d40` + gold `#ffc627` accent). Filter JS uses multi-value `dataset[key].split(/\s+/).includes(value)`. Negative tests assert absence of "bad alignment"/"failed assessment" phrasing (#18).
- **tests:** +8 new alignment-graph tests across builder + renderer. Full suite: 657 passed, 1 xfailed.

## 7.12.0 (2026-05-22) — Staging visibility, quiz-id guard, faculty-mockup framing

- **feat(staging):** unified staging review (`scripts/unified_staging_review.py`) now surfaces the active serving root in the top bar and as a `data-serving-root` body attribute. Empty-state page rebuilt from a one-line stub into a full HTML document that names the courses directory so IDs can tell at a glance which checkout they are previewing (#10).
- **feat(staging):** `scripts/staging_manager.py` gains `--clear-pushed` — cleans staged items whose `push_verified` event already appears in `courses/{id}/timeline.jsonl`, leaves staged items without a verified push untouched, supports `--type` filtering. Handles legacy singular/plural object-type names (page/pages, quiz/quizzes, interactive/interactives) (#12).
- **feat(multi-course):** `scripts/multi_course_status.py` warns when a local course directory has work artifacts (`course-config.json`, `timeline.jsonl`, `build-progress.json`, `open_questions.jsonl`, non-empty `staging`/`backups`/`mockup`/`reports`) but no `course-memory.md`. Non-destructive — points the user at `init_course_memory.py --course-id {id}` instead of auto-creating. Empty local dirs stay neutral (#13).
- **fix(push-quiz):** `scripts/push_to_canvas.py::push_quiz` refuses non-numeric staged quiz IDs (e.g. mockup slugs like `m1-quiz-revised-mockup`). Returns a clear error with the exact `staging_manager.py --rename` command shape. Guard runs AFTER the audience-lint gate so content checks still surface even when the ID is broken (#11).
- **fix(quiz-feedback):** quiz writes now forward `correct_comments` / `incorrect_comments` / `neutral_comments` / `text_after_answers` at the question level and normalize per-answer feedback keys (`comments`/`answer_comment`/`answer_comments_html` → canonical `answer_comments` / `answer_comment_html`) so the Canvas editor's "Comments, if the student chooses this answer" field is populated on push (#9 + earlier v7.11.1 runtime).
- **docs(course-mockup):** SKILL.md now codifies faculty-facing framing — mockups are editable suggestions by default, not imposed design. Acknowledge faculty disciplinary authority on revised prompts/homework/quiz items. Separate suggestions (soft) from standards issues (firm — accessibility, broken links, alignment gaps). Adds Canvas-native delivery surface (visible Mockups/Suggestions module, `Mockup:`/`Suggestion:` title prefix, unpublished by default, staging preview required before push) (#14).
- **docs(course-mockup):** SKILL.md adds Quiz Mockup Item Structure — KEEP/ADD/REPLACE/OPTIONAL status badges (green/ASU gold/ASU maroon), Current question, Why keep-add-replace + MLO evidence, Suggested Alternative Wording / Suggested Question / Suggested Replacement, plus Why revise the wording (only when keeping + rewording). Contract test guards the structure against future SKILL.md edits (#15).
- **tests:** +13 new tests across affected surfaces. Full suite: 648 passed, 1 xfailed.

## 7.11.1 (2026-05-22) — Script-enforced push verb gate

- **fix(push-gate):** `scripts/push_to_canvas.py` and `scripts/push_syllabus.py` now require an explicit `--confirm-verb {push,approved,deploy}` flag for any non-dry-run Canvas write. Ambiguous affirmatives (`ok`/`yes`/`confirm`) are rejected at the argparse layer. Closes a pilot incident in which an agent acting on an ambiguous "yes" pushed contaminated staged content to Canvas — the v7.5.3 Write Safety Guard was behavior-only; this is the script-enforced floor. `--dry-run` is exempt.
- **tests(push-gate):** `tests/test_push_to_canvas.py` adds `TestConfirmVerbGate` — asserts refusal without verb, refusal on `ok`/`yes`/`confirm`, accept on each of the 3 valid verbs, and dry-run exemption. Full suite: 638 passed, 1 xfailed.

## 7.11.0 (2026-05-22) — Dual-runtime Codex support + plugin-wide HPI/ID standard

- **feat(hpi-id):** the plugin now operates as a Human Performance Improvement / Instructional Design practitioner across every instructional-design-bearing skill — automatically, without the user re-stating it. `standards/instructional-design.md` gains Section 13 (HPI Performance Diagnosis): Gilbert BEM 6-cell model, Mager & Pipe skill-vs-will, the four-part recommendation contract (gap → data → principle → mechanism), Hattie effect-size weights, Kirkpatrick/Phillips, the terminal-performance defect test. New `CLAUDE.md` §Operating Standard: HPI/ID Practitioner is the house-wide directive. `course-audit` Pedagogical Review Mode applies the lens automatically; `needs-analysis`, `syllabus-generator`, `course-build`, `alignment`, the assessment generators, and `interactive-content` carry a pointer to the standard. Single-sourced — no duplicated diagnostic tables.
- **feat(codex):** dual-runtime support — the plugin ships both `.claude-plugin/plugin.json` (Claude Code) and `.codex-plugin/plugin.json` (Codex) off one source tree. Plugin-root resolution reads `${ASUCB_PLUGIN_ROOT:-${CODEX_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}}` across hooks and 13 skill docs. `tests/test_plugin_manifest_sync.py` enforces the two manifests' versions stay equal.
- **feat(needs-analysis):** additive HPI layer on top of the existing intake skill — three new fields, nothing reshaped. `task_analysis.core_task_bloom` (map of core-task → Bloom level: Remember/Understand/Apply/Analyze/Evaluate/Create). `planning_flags.risk_notes_bem` (list of `{bem_cell, note}` objects, one per Gilbert BEM concern — cells: Information/Instruments/Incentives/Knowledge/Capacity/Motives). `planning_flags.mager_pipe_branch` (five values: skill_knowledge/will_environment/mixed/unclear/not_applicable). All original fields preserved with unchanged types. Legacy configs missing the 3 new fields still load (defaults: `{}`, `[]`, `"unclear"`).
- **feat(needs-analysis):** Step 2 now builds a Bloom-aligned performance profile and stores task-cluster tags in `core_task_bloom`. Step 4 replaced with a 4-part HPI advisory gate: 4.1 gap check, 4.2 Mager-Pipe stakes probe (only when a performance gap exists), 4.3 lightweight walk of all 6 BEM cells in order (environment cells first per Gilbert's BEM priority), 4.4 `training_fit` assignment. Advisory framing throughout — no hard blocks.
- **tests(needs-analysis):** 20 new tests in `tests/test_needs_analysis_schema.py`. Module-level constants `ALLOWED_BEM_CELLS`, `ALLOWED_BLOOM_LEVELS`, `ALLOWED_MAGER_PIPE_BRANCH`. Parametrized acceptance tests for all allowed values. Skill-text guardrail tests assert SKILL.md references Gilbert/BEM, all 6 cell names, Mager-Pipe, and all 6 Bloom levels. Backward-compat test confirms legacy blocks without new fields load cleanly. Full suite: 620 passed, 1 xfailed.

## 7.10.0 (2026-05-21) — C Margin mockup preview redesign

- **feat(course-mockup):** the faculty-preview shell is redesigned to the "C Margin" direction — a Claude Design handoff (`dir-c-margin.jsx`) ported pixel-faithfully into `templates/mockup-html/preview-base.html` + `scripts/mockup_manager.py`. Paper top bar reading "Arizona State University"; full-bleed ASU maroon (`#8C1D40`) pylon hero with a giant gold (`#FFC627`) Fraunces course number, gold triangle accent, and a Pages/Modules/Assignments stat strip; left jump-to TOC rail with maroon Fraunces module codes, a working page filter, type dots, and collapsible modules; quiet bordered page cards; a right rail with This Preview / Legend / Shortcuts. Fraunces + Source Sans 3 + JetBrains Mono via a single Google Fonts link — no base64-embedded fonts.
- **feat(course-mockup):** right-rail keyboard shortcuts are wired for real — `j`/`k` next/prev page, `g m` go to module, `/` focus the page filter. Suppressed while a text input is focused. The non-functional `e` (edit) shortcut was removed rather than faked.
- **feat(course-mockup):** the hero `<h1>` shows the course *name* with any leading course-code prefix stripped (`_preview_course_title` — "LAW 515: Contracts" → "Contracts"); the pylon number stays the numeric code part.
- **fix(course-mockup):** faculty page bodies render with the original pre-redesign `.page-content` Canvas-faithful styles — only the platform chrome is restyled, page content + layout are unchanged. Faculty-authored HTML is never restyled.
- **tests:** `tests/test_mockup_preview_redesign.py` rewritten for the C Margin shell — self-containment, maroon hero + gold pylon, code-prefix strip (parametrized across code shapes), collapsible TOC + filter + right rail, placeholder/subheader rendering, the `j`/`k` shortcut handler, original `.page-content` styles, `:root` tokens. Full suite 631 passing, 1 xfailed.
- Codex co-implementation; rendered output screenshot-verified against the C Margin reference before ship.

## 7.9.2 (2026-05-21) — Audience-lint URL false positive + staging-rule memory

- **fix(audience-lint):** the version-marker token pattern (`\bv\d+\b`) flagged `vN` inside URL path segments — `/api/v1/` in Canvas API endpoint URLs (auto-inserted by the Canvas editor into `src` / `data-api-endpoint` image attributes). This blocked legitimate syllabus and page pushes. Both the `_FACULTY_FORBIDDEN` pattern and the bare `ta`-audience pattern now carry `(?<!/)` and `(?!/)` guards so URL path segments are not treated as version markers; genuine prose markers ("v2 of the rubric", "see v1") still flag, and the published-instrument cite exclusions (`Taxonomy v1`, `BCT v1`, etc.) still pass. Codex co-implementation; 3 new tests in `tests/test_audience_lint.py` (21 in that file).
- **docs(memory):** added an absolute Canvas-staging rule to the shipped `docs/memory/RULES.md` operator-persona template — never push to Canvas without the user reviewing in the staging server and giving an explicit push verb; "fix please"/"update X" is a prepare request, not push approval; after any re-stage (e.g. a lint fix) return to the preview step before pushing.

## 7.9.1 (2026-05-21) — Revert v7.9.0 mockup redesign

Reverted the v7.9.0 pylon-hero mockup redesign. The ASU maroon/gold re-skin of the rebuilt shell read as worse than the warm-neutral Claude-design reference it was based on — a re-skin call that did not survive contact with the actual rendered output. `git revert` of `3cead69`; `templates/mockup-html/preview-base.html` and `scripts/mockup_manager.py` `_preview_inline_fallback` restored to the pre-v7.9.0 sidebar/top-bar layout. The 6 redesign tests were removed with the revert. Full suite back to 592 passed, 1 xfailed.

A mockup-shell redesign may return later — if so, built from the warm-neutral aesthetic of the Claude-design reference rather than a maroon re-skin.

## 7.8.0 (2026-05-20) — Security audit batch

A full-codebase security audit (Codex co-implementation) found and fixed 7 issues — 2 Critical, 3 High, 2 Medium.

- **C1 — token exfiltration via image-URL substring match** (`vision_audit.py`): the Canvas-host check was a substring test, so `https://evil.com/?ref=canvas.asu.edu` received the Canvas Bearer token. Now `_is_canvas_host()` parses the URL and requires the hostname to *exactly* equal the configured Canvas domain over HTTPS.
- **C2 — Bearer token followed unvalidated Canvas-response URLs** (`canvas_api.py`): `paginated_get` reused auth headers against the next-page URL from the `Link` header, and `upload_file` against the `Location` header. `_validate_canvas_response_url()` now raises `ValueError` if either URL is off-domain or non-HTTPS before any auth header is sent.
- **H1 — stored XSS in interactive `<script>` data islands** (`generator.py`): `json.dumps` did not escape `</script>`. New `_safe_json()` neutralizes `</`, `<!--`, and `<script` sequences for every data island; untrusted activity text now renders via `textContent`, not `innerHTML`.
- **H2 — stored XSS in the Canvas page wrapper** (`deploy_interactives.py`): activity `title`/`instructions` are now `html.escape(..., quote=True)` before interpolation into the embed HTML written to Canvas.
- **H3 — path traversal in staging/backup filenames** (`staging_manager.py`, `backup_manager.py`): `course_id`/`object_type`/`object_id` are regex- and containment-validated; `stage()` and `save_backup()` return `{"ok": False, "error": ...}` on unsafe input instead of writing outside the per-course sandbox. The existing graceful-failure contract is preserved (invalid input → `ok: False`, not an uncaught exception).
- **M1 — Canvas error bodies** (`canvas_api.py`): error printing no longer dumps raw `resp.text`, preventing content/PII disclosure.
- **M4 — dependency pinning** (`requirements.txt`): `>=` lower-bound pins replaced with exact `==` pins (`requests==2.33.1`, `python-dotenv==1.2.2`, `PyYAML==6.0.3`) for reproducible installs.
- **Deferred**: M2 (bootstrap installer checksum pin — install-hook risk) and M3 (staging-server per-session auth — tracked in `docs/backlog.md` Gap A, larger scope).
- Tests: new `tests/test_security_fixes.py` (16 cases). Full suite 592 passed, 1 xfailed.

## 7.7.4 (2026-05-20) — Audience-lint in the push gate

Closed a known gap: the audience-lint (faculty/student-forbidden tokens — version markers like `v3`, `HPI/ID`, internal jargon) ran only on mockup previews, never on the mockup → staging → Canvas promotion path. Forbidden tokens could reach published Canvas content unchecked.

- New `lint_content(html, audience)` in `scripts/mockup_manager.py` — the reusable token-matching core. `audience_lint()` refactored to call it; `FORBIDDEN_TOKENS` stays single-source.
- `push_to_canvas.py` gates every push path (page, assignment, discussion, quiz) — lints staged content before any Canvas write, blocks on violations with `{"ok": False, "audience_violations": [...]}`. Audience resolved from the staged `.meta.json` `audience` field, defaulting to `student` (strictest) when absent. Quiz JSON is linted across question stems, answers, feedback, and description.
- `push_syllabus.py` gates the syllabus push at `audience="student"`.
- `--allow-audience-violations` CLI flag (default off) downgrades the block to a warning for the rare legitimate published-instrument cite the regex missed.
- `CLAUDE.md` §Write Safety Guard gains step 7 documenting the push-gate lint.
- Tests: new `tests/test_push_audience_gate.py`. Full suite 576 passed, 1 xfailed.
- Codex co-implementation.

## 7.7.3 (2026-05-20) — Staging-server preview gate restated in body-write skills

`CLAUDE.md` §Write Safety Guard is global, but the staging-server preview step (start the server, show the `127.0.0.1:8111` preview URL, require an explicit push verb) was spelled out unevenly across skills — `quiz-generator` and `interactive-content` had it; several body-write skills mentioned only `staging_manager.py` / `push_to_canvas.py`; `rubric-creator` had nothing.

- Added a `## Staging-Server Preview (Write Safety Guard)` subsection to `skills/assignment-generator`, `discussion-generator`, `update-module`, `course-build`, `syllabus-generator`, and `rubric-creator` SKILL.md files. Each restates the 5-step gate with the skill's correct staging `{type}` and push script.
- `syllabus-generator` variant uses `scripts/push_syllabus.py` and the `/courses/{id}/syllabus` preview URL. `course-build` variant adds the object-creation (step 6) note for module shells. `rubric-creator` variant explicitly states HTML-body staging does NOT apply — a rubric is structured criteria created via the Canvas API, framed as object creation under step 6.
- Test: `test_body_write_skills_document_staging_preview` added to `tests/test_routing_coverage.py` — asserts all six files contain `ensure_staging_server` and `127.0.0.1`. Full suite 566 passed, 1 xfailed. Codex co-implementation.

## 7.7.1 (2026-05-20) — Brief-filename collision fix

Latent data-loss bug: `mockup_manager.py` `save_brief()` wrote `briefs/{brief_type}-m{module_num}.json` keyed by brief type + module only, with a bare `open(path, "w")` and no existence check. Two briefs of the same `brief_type` in the same module silently overwrote each other. Concrete trigger: a KIN-352-style module 4 with both a "Theory Log weekly entry" and a "mid-term log check-in" — both `assignment`-type → second clobbers first.

- `save_brief()` now writes `briefs/{brief_type}-m{module_num}-{slug}.json`. The `slug` discriminator derives from `content["page_slug"]`, then `content["title"]` (slugified, ≤40 chars), then a deterministic sha1 content-hash fallback — two distinct briefs never collide.
- Collision guard: if the target path exists with **different** content, the write is blocked and `save_brief` returns `{"ok": False, "error": ...}` instead of silently overwriting. Same-content resave stays idempotent (`ok: True`).
- `load_brief()` gains an optional `slug=` kwarg; without it, globs for a single match (compat), returns `None` + warns on multiple matches, and falls back to old-format `{brief_type}-m{N}.json` files.
- `list_briefs()` enumerates both new and old filename shapes; every returned dict includes a `slug` field. Placeholder `meta.json` now records `brief_slug`. Old on-disk briefs are read with `slug=""`; nothing is auto-renamed.
- Tests: +8 collision cases in `tests/test_mockup_manager.py` (47 in that file). Full suite 563 passed, 1 xfailed. Codex co-implementation; live smoke verified the KIN-352 module-4 two-`assignment` scenario.

## 7.7.2 (2026-05-20) — Canvas-template trigger phrases wired into routing

The v7.7.1 tag was cut before the routing-wiring commits landed; this patch release carries them under their own version so the tag matches the shipped code.

- The v7.7.0 Canvas template library shipped the spec docs but never referenced their trigger phrases anywhere the routing classifier reads — saying "Use SDO JD template" matched nothing.
- `CLAUDE.md` §Routing → Hard Scope Rules: two new bullets routing "Use SDO JD template" / "SDO-JDO template" / "JD Online template" / "Sandra Day template" → `standards/canvas-templates/sdo-jdo-online-template.md`, and "Use ASU Online QM template" / "ASU Online template" / "QM template" / "Quality Matters template" → `standards/canvas-templates/asu-online-qm-template.md`.
- `skills/course-mockup/SKILL.md`, `skills/course-build/SKILL.md`, `skills/course-config/SKILL.md`: each gains a `## Canvas Templates` section listing the trigger phrases + spec-doc path, instructing the skill to mirror the active template's module sequence / SubHeader structure / visual signatures.
- Tests: `test_template_trigger_phrases_route` and `test_skill_md_files_carry_template_triggers` added to `tests/test_routing_coverage.py`. Full suite 565 passed, 1 xfailed. Codex co-implementation.

## 7.7.0 (2026-05-20) — Canvas template library, SubHeader items, course-named previews

Minor release. Many `feat` commits accumulated since the v7.6.2 tag with no version bump — this release cuts them. No breaking changes. Full suite: 555 passed, 1 xfailed.

### Canvas template library
- New `standards/canvas-templates/` directory with two saved ASU Canvas course templates, each with trigger phrases that route course-build/mockup/config work through the template's exact structure:
  - `sdo-jdo-online-template.md` — SDO-JDO Online template (Sandra Day O'Connor College of Law), pulled from live Canvas course 222287 via REST API. Captures module sequence, per-module SubHeader item list, Module 0 inventory, visual signatures (gold `#FFC627` H2 underline, yellow `#fbeeb8` placeholder spans, 70/30 table-cell video layout), and per-page content contracts. Trigger: "Use SDO JD template".
  - `asu-online-qm-template.md` — ASU Online QM (Quality Matters / Globally Inclusive Design) template, extracted from the `DEV_FALLB27_STP530_` IMSCC export. Captures interleaved exam modules, three-SubHeader per-module structure, Cultural References section, Wistia video embeds, dual practice/graded quiz pattern. Trigger: "Use ASU Online QM template".
- `templates/canvas-templates/sdo-jdo-online/` — 23 canonical HTML page bodies. `templates/canvas-templates/asu-online-qm/` — 80 wiki HTML pages.

### SubHeader module dividers
- New `subheader` `page_type` in `scripts/mockup_manager.py` representing Canvas text-only SubHeader module items (section dividers like Overview / Learning Materials / Coursework).
- `save_page()` normalizes subheader bodies to `<h3 class="module-subheader">{title}</h3>`, stores `subheader_text` in the `.meta.json` sidecar. `_classify_page()` returns `("#94a3b8", "SubHeader")`. `promote_page()`/`promote_all()` skip subheaders (`subheaders_skipped` count reported).
- `generate_preview()` and `unified_staging_review` render subheaders as quiet italic TOC rows and horizontal body dividers, not clickable items. Labels HTML-escaped throughout (XSS hardening).
- Filename convention: alpha-suffixed positions (`m1-00a-overview-section.html`) slot dividers between numbered items without renumbering.

### Course-named preview output
- `generate_preview()` now writes `{Course Name} Mockup.html` (course name from `manifest.json`, sanitized for filesystem safety) instead of the hardcoded `preview.html`. Version rotation follows the same naming: `{Course Name} Mockup-v{N}.html`. Falls back to `Course {courseId} Mockup.html` when the manifest lacks a course name.

### Feature A — Audience metadata + lint
- Added `AUDIENCE_LEVELS` + `FORBIDDEN_TOKENS` module-level constants to `scripts/mockup_manager.py` defining per-audience forbidden token patterns (student, faculty, ta, id). Uses word-boundary `\bv\d+\b` to avoid false-positives on technical version strings like `cccv1p2`.
- Added `audience_lint(course_id, override_audience=None)` function that scans all mockup pages for audience-forbidden tokens and returns structured violation report.
- Extended `save_page()` to accept optional `audience` kwarg, persisted to `.meta.json` sidecar.
- `generate_preview()` now runs audience lint (non-blocking) after writing preview and emits a stderr warning when violations are found.
- New CLI args: `--audience-lint` (exclusive action), `--audience LEVEL` (override), `--strict` (exit 1 on violations). 17 new tests in `tests/test_audience_lint.py`.

### Feature B — Audit → rationale auto-seed
- Added `--emit-json PATH` flag to `scripts/criterion_evaluator.py` that writes a structured findings JSON (course_id, course_name, audit_run_at, findings[]) consumed by the rationale seeder. Only `Not Met` criteria + phase-2 findings are included. Chosen script: `criterion_evaluator.py` is the canonical orchestrator covering the full deterministic criterion suite + phase-2 detection scripts (rubrics, MLO drift, Bloom, CLO orphans).
- Added `seed_rationale_from_audit(course_id, audit_json_path, rationale_page)` to `scripts/mockup_manager.py`. Uses `<!-- AUDIT-FINDINGS-TABLE:START/END -->` sentinel blocks for idempotent in-place replacement. Fallback heuristic inserts after `<h2>1. Current Course` heading when sentinels are absent.
- Generated `<table>` uses inline `style="background:#8C1D40;color:#ffffff"` on `<th>` for maroon header (compatible with preview-base.html contrast fix).
- New CLI args: `--seed-rationale-from-audit AUDIT_JSON`, `--rationale-page FILENAME`. 17 new tests in `tests/test_audit_seed.py`.

### Phases 1–2 — Backlog persistence + audit pipeline
- Phase 1: per-course backlog persistence (`courses/{id}/backlog.jsonl` mirrored to `course-memory.md` `## Pending`), `scripts/backlog_manager.py`, `/backlog` skill, parameterized fix-path router, report redesign.
- Phase 2: four V1 detection scripts wired into `criterion_evaluator.py` — `audit_rubrics.py` (empty rubric shells), `audit_mlo_drift.py` (MLO verb-object drift), `audit_bloom_alignment.py` (CLO Bloom vs quiz cognitive level), `audit_clo_orphans.py` (assessments lacking CLO mapping). Shared `mlo_extractor.py`. Baseline analyzer `audit_remediation_baseline.py`.

### Marketplace
- New `docs/marketplace-description.md` — 450–500 word long-form listing copy. The `plugin.json` `description` field remains within the 500-character enterprise-marketplace validator cap; the long-form copy lives in this separate doc.

### Release housekeeping
- CLAUDE.md review pass per the version-bump rule: routing decision tree, setup gate (Checks 1–3.5), and Write Safety Guard confirmed still accurate against plugin behavior — no procedural-contract changes required for this release.

---

## 7.6.2 (2026-05-18) — Image-attr preservation in edit mode

Pilot observed images inflating to natural pixel size after edit-mode save: original `<img width="320" height="240">` saved out as bare `<img src loading="lazy">`, and the next render showed a full-resolution Canvas image (often >1000px wide). Root cause: the Tiptap Image node at `scripts/unified_staging_review.py:1540` was configured with no `addAttributes`, so the editor dropped all sizing attrs on parse and emitted them back without them on `getHTML()`. The iframe node at the same file already had the correct preservation pattern (lines 1319-1321) — image node was the only schema missing it.

- New `CanvasImage` Tiptap extension that extends the built-in `Image` and overrides `addAttributes()` to preserve `width`, `height`, `class`, `alt`, `title`, `data-decorative`, `data-canvas-id`, and a filtered `style` attribute. Style filter whitelists `width`, `height`, `max-width`, `min-width`, `display` only — sizing passes through, dirty inline color/border/font styles are stripped on both parse and render via DOM `getPropertyValue` walk.
- New test file `tests/test_unified_staging_review_image_attrs.py` asserts the emitted editor config in the generated review HTML contains the `CanvasImage` extension with all expected attribute keys.
- Live smoke: stage a page with `<img width="120" height="120" style="display:block;border:1px solid #ccc;color:red">`, GET the unified review (200), POST a Tiptap-shaped save through `/api/staging/...`, read the staged file back and assert `width="120"`, `height="120"`, `style="display:block"` are present and `color:red`/`border` are absent. All four assertions pass on 127.0.0.1:8111.
- Tests: 473 total passing, 1 xfailed. Codex co-implementation in isolated worktree per CLAUDE.md §Codex Co-Implementation; smoke + full suite verified by parent agent.

## 7.6.1 (2026-05-18) — Hard-stop on `localhost` URL announcements

Pilot screen-share showed `localhost:8111` in Claude-announced URLs after v7.5.1 had already switched the canonical form to `127.0.0.1:8111`. Two leaks closed:

- `skills/staging/SKILL.md` line 74 still said "the canonical preview shape is `http://localhost:8111/courses/{id}/preview/{type}/{key}`" — relic of the v7.5.0 canonical-routes note that the v7.5.1 sweep missed. Switched to `127.0.0.1`.
- `CLAUDE.md` §Write Safety Guard step 3 now explicitly forbids announcing `localhost` in any URL Claude generates. Adds: "the source of truth for the base URL is `scripts/ensure_staging_server.py` (which always prints the `127.0.0.1` form). Use its output, do not hand-construct the URL from memory."

No code change; documentation-and-instruction fix only. Pilot testers on v7.5.1+ who restart their session will get `127.0.0.1` URLs going forward. Pilot testers on v7.5.0 or earlier should pull v7.6.1 — they had both the soft staging gate (fixed in v7.5.3) and the localhost announcement habit.

## 7.6.0 (2026-05-18) — Question Bank Quiz Mode

- New feature: build a Canvas quiz that pulls random questions from an existing Canvas Question Bank. The bank itself is created/maintained in the Canvas UI (Classic Question Banks have no REST API for creation with a personal access token — verified 2026-05-18 on canvas.asu.edu). The plugin handles the quiz-build side.
- `scripts/canvas_api.py`: added `get_question_bank(config, bank_id)` (GET `/courses/{id}/question_banks/{bank_id}`), `add_question_from_bank(config, quiz_id, question_bank_id, position=None)` (POST `/courses/{id}/quizzes/{quiz_id}/questions` with form-encoded bank pointer), and `create_quiz_group(config, quiz_id, name, pick_count, question_points, assessment_question_bank_id)` (POST `/courses/{id}/quizzes/{quiz_id}/groups` with JSON `quiz_groups` array).
- `skills/quiz-generator/SKILL.md`: added Mode 5 (From-Bank). User provides bank ID + pick count + points; generator verifies bank exists, creates quiz shell, links bank as a question group. Per v7.5.3 §Write Safety Guard, Mode 5 invokes `scripts/ensure_staging_server.py`, shows preview URL, requires explicit push verb before any API write.
- Tests: `tests/test_question_banks.py` covers `get_question_bank` 200/404, `add_question_from_bank` POST shape, `create_quiz_group` JSON body shape, and the end-to-end `get_bank → create_quiz → create_quiz_group` workflow call sequence. 463 total passing, 1 xfailed.
- Codex co-implementation per CLAUDE.md §Codex Co-Implementation. Built in isolated worktree first, verified, then merged.
- Live smoke against Brent sandbox (255724) deferred — no bank ID handy on that sandbox; mocked workflow proves the call sequence.

## 7.5.3 (2026-05-18) — Staging Gate Hotfix (pilot incident)

Pilot incident on 2026-05-18: a tester edited a Canvas page, Claude asked a soft "confirm?" prompt, the tester said yes, and the push went straight to Canvas with no staging-server preview ever shown. The user-side workaround was telling Claude "Always open page previews on the staging server for user to review first." This release hardens the gate so the workaround is no longer needed.

- **CLAUDE.md §Write Safety Guard rewritten** into a 6-step ordered protocol: stage first → ensure staging server bound → show preview URL explicitly → require an explicit push verb (`push`/`approved`/`deploy`, not `ok`/`yes`/`confirm`) → push via `push_to_canvas.py` (no raw API) → object-creation announcement-and-approval rule. Cites the pilot incident as the reason ambiguous affirmatives are no longer accepted.
- **New `scripts/ensure_staging_server.py`** (idempotent helper): returns the base URL whether the server was already bound to `127.0.0.1:8111` or had to be launched in the background. Claude must run this before announcing any preview URL so the URL never 404s on the user.
- **Hard floor unchanged**: `push_to_canvas.py` already refuses to push when `staging_manager.get_staged()` returns `None`. Pages, assignments, discussions, and quizzes all gate on a staged file existing. The behavioral steps (2–4) are what the user actually sees; this floor catches violations of step 1.
- **Tests**: `tests/test_ensure_staging_server.py` covers (a) launches when not running, (b) idempotent when already running, (c) always uses `127.0.0.1` not `localhost`. 458 total passing (+3), 1 xfailed.

## 7.5.2 (2026-05-18) — Plugin Description ≤500 chars

- Marketplace fix: `plugin.json` description shrunk from 1804 chars to exactly 500 to clear the enterprise marketplace upload validator (`Plugin description must be at most 500 characters`). New description preserves the canonical positioning (ASU Canvas Course Builder, 25 skills + 16 aliases, WCAG/ASU standards) and the headline capability surface (concierge routing, tier-calibrated assessments, mockups, image/visual/interactive/media generation, audit + alignment, bulk edit, staging, transfer, syllabus import). Per-release notes moved to the CHANGELOG only.

## 7.5.1 (2026-05-18) — Canonical 127.0.0.1 URLs

- Docs/UX: canonical staging-server URL form switched from `localhost:8111` to `127.0.0.1:8111` across `skills/staging/SKILL.md`, `skills/interactive-content/SKILL.md`, `docs/review-staging-preview.md`, `scripts/generator.py` preview output, and the `scripts/staging_server.py` startup banner. Avoids macOS IPv6 `localhost` resolution races and DNS-rebinding exposure.
- Compatibility: `localhost:8111` remains in the `_check_origin()` allowlist alongside `127.0.0.1:8111`. Existing bookmarks, scripts, and tests using `localhost` continue to work.
- Tests: 455 passing, 1 xfailed. Origin guard fixtures still exercise both URL forms.

## 7.5.0 (2026-05-17) — Staging Route Collapse

- Refactor: `scripts/staging_server.py` now dispatches all staged-content routes through a `StagingType` dataclass registry covering `pages`, `assignments`, `discussions`, `quizzes`, `interactives`, and `syllabus`. Each registry entry declares its staging path, key mode, loader, wrapper, save validator, write helper, sidecar cleanup policy, and preflight hook.
- New routes: `GET /courses/{id}/preview/{type}/{key}` is the canonical preview URL. Legacy per-object routes (`/pages/{slug}`, `/assignments/{id}`, `/discussions/{id}`, `/quizzes/{id}`, `/interactives/{key...}`, `/syllabus`) are preserved permanently as aliases — no breaking change for skill docs, `scripts/generator.py`, or `scripts/unified_staging_review.py`.
- Collapse: `do_GET`, `do_PUT`, and `do_DELETE` route branches collapsed behind `parse_preview_request`, `parse_api_request`, `handle_preview_get`, `handle_api_get`, `handle_api_put`, and `handle_api_delete`. Route blocks shrunk by ~214 LoC.
- Bug fix: syllabus DELETE now removes `.issues.json`, `.vision.json`, and `.meta.json` sidecars consistently with other object types (was only unlinking `syllabus_body.html`).
- Preserved: Origin guard on PUT/DELETE, `_resolve_safe()` traversal block, iframe `sandbox="allow-scripts allow-forms"` (no `allow-same-origin`), 127.0.0.1 bind, `/api/staging` save/delete payload contract, full edit mode on all object types, `/courses/{id}/review` behavior.
- Tests: added `tests/test_staging_route_collapse.py` covering canonical/legacy parity for all six types, syllabus 302 to `/courses/{id}/review#syllabus-body`, sidecar cleanup parity, Origin guard on canonical + legacy, traversal blocks, interactive catch-all key invalidation, and registry dispatch verification. 455 total tests, 1 xfailed.

## 7.4.5 (2026-05-17) — Preview UX + Sequencing Schema Fix

- UX: interactive preview shell removed the engineer-facing Pre-Deploy Checklist sidebar; preview is now customer-facing with full-width iframe centered up to 1200px. Manual checklist remains in `scripts/check_interactive_a11y.py` output and `skills/interactive-content/SKILL.md`.
- Bug fix: `generate_sequencing` now normalizes plain-string `items` to `{text, position}` objects so item cards render with text content (was rendering empty cards because the template reads `item.text`).
- Tests: added `test_sequencing_accepts_string_items` regression covering the string-list input shape and updated preview-route assertions to match the new shell.

## 7.4.4 (2026-05-17) — Optional Download Results

- Generator: built-in interactive activities now accept an optional per-activity `download_results` flag, defaulting off; schema validation accepts presence/absence and warns while coercing truthy non-bool values.
- Workflow: `skills/interactive-content/SKILL.md` now documents the ASK-first behavioral rule; set `download_results: True` only after an explicit Y from the user.
- Privacy/sandbox: downloads are client-side plain-text Blob/data URL artifacts only; no `localStorage`, no Canvas API, no server call, and sandbox-compatible behavior.
- Accessibility: the opt-in results panel uses an ASU maroon button, accessible button text, an optional notes textarea with `aria-label`, and gold focus-visible styling.
- Tests: added `tests/test_download_results.py` covering all five built-in activity types, default-off rendering, schema warning/coercion, and preflight/WCAG checks with downloads enabled.

## 7.4.3 (2026-05-17) — Hardening Batch

- Security: staging interactive preview iframes now omit `allow-same-origin` while retaining `allow-scripts allow-forms`.
- Security: staging path resolution now permits symlinked course directories while keeping resolved candidates contained inside the resolved course directory.
- UX: page, assignment, and discussion staging previews now include a gold Back to Course Review button; related preview shells preserve the same navigation pattern.
- Accessibility workflow: the deterministic gate is now labeled Preflight, with docs and sidebar copy clarifying it covers only a subset of WCAG 2.1 AA and still requires CRC/manual review.
- Schema hardening: `scripts/generator.py` uses `instructions` as the canonical activity field, warns on deprecated singular `instruction`, warns on unknown schema keys, and applies instructions consistently across built-in activity types.

## 7.4.0 (2026-05-12) — Scale-Prep Cleanup

### Security hardening (Phase 1)

- `scripts/staging_server.py` validates every URL path component (`course_id`, `obj_type`, `obj_id`) with a strict regex + `resolve().is_relative_to(COURSES_DIR)` guard. Path traversal blocked across GET/PUT/DELETE routes.
- Origin guard middleware rejects PUT/POST/DELETE without an allow-listed `Origin` header (`http://localhost:8111` / `http://127.0.0.1:8111`). CSRF protection for the local staging API.
- Server now binds `127.0.0.1` explicitly instead of `localhost`.
- New `tests/test_staging_server.py` — 10 security regression tests.

### Studio removal (Phase 2 — breaking change)

- Deleted `playground/` (Node studio editor), `proxy/api/edit-page.js` (local Gemini AI-edit handler), `open-studio.sh`, and `skills/studio/`.
- The remote Vercel deployment is unchanged — `images` and `plugin-feedback` skills still use `asu-plugin-api.vercel.app/api/search-images`, `/api/generate-image-google` (nano banana), and `/api/feedback` (Coda webhook).
- Studio bridge files, launch-config entry, and SKILL.md references removed.

### Dead code removal + alignment fixes (Phase 3)

- Deleted `scripts/imscc_builder.py` (1,045 LoC). Cross-instance course copy now directs users to Canvas's native Common Cartridge export/import UI; in-instance copy still uses `canvas_api.create_content_migration()`.
- Renamed `scripts/alignment_graph.py` → `scripts/alignment_validator.py`. Fixed two integration bugs (`get_assignments_with_rubrics` did not exist; `get_config(instance=...)` was the wrong signature).
- Bloom's helpers extracted to `scripts/blooms.py` so `preflight_checks.py` no longer pulls the full alignment dependency chain.
- New `tests/test_alignment_validator.py` (4 tests) + `tests/test_course_transfer.py` (3 tests).

### Skill consolidation (Phase 4)

- 41 skills → **24 primary skills + 16 compatibility aliases**. Every former skill name still routes correctly via 8-line redirect shims.
- Audit family merged: `accessibility-audit`, `course-readiness-check`, `course-review` → `course-audit` with Quick Check / Deep Audit / Guided Review modes.
- Remediation family merged: `content-fix`, `remediation-brief` → `bulk-edit` modes.
- Router shims: `canvas-setup` → `course-concierge`; `course-plan-import` → `course-build`; `course-fit-quiz` → `quiz-generator`; `lecture-support` → `instructor-guide`; `knowledge`, `templates` → `canvas-nav`; `notebooklm-*` → `notebooklm`.
- `CLAUDE.md` routing table updated; `tests/test_routing_coverage.py` + `tests/test_concierge_routing.py` updated for the 24-primary layout.

### Test coverage broadening (Phase 5)

- New behavioral test for push-to-Canvas quiz path with mocked Canvas (backup, add_quiz_question 3×, verify, clear staging).
- New smoke test for `unified_staging_review.generate_review()`.
- New staging isolation tests (paths don't bleed across course IDs).
- New `tests/test_criterion_evaluator_course_id.py` + fix in `scripts/criterion_evaluator.py`: `--course-id` flag threaded through to eliminate the multi-user collision risk.
- Suite: **341 passed, 1 xfailed** (up from 329, zero regressions).

### Migration notes

- **Studio**: no migration path. Users who used Studio's AI page editor should now ask Claude directly via chat — staging-server preview at `:8111` still works for read-only review + the in-place quiz editor.
- **IMSCC export**: use Canvas's own export-to-Common-Cartridge UI from the source course settings.
- **Old skill names**: continue to work via aliases. No code changes required by users.

## 6.5.2 (2026-04-22)

### Pilot Dependency Hardening

- `scripts/requirements.txt` now keeps only the base runtime dependencies, and new `scripts/requirements-optional.txt` isolates `cairosvg` for visual-builder usage.
- `scripts/convert_svg.py` now imports `cairosvg` optionally, preserves existing behavior when available, and fails with a friendly runtime message only when SVG conversion is requested without the optional dependency.
- Added `scripts/ensure_cairosvg.py` for macOS check/install flows (`--check` supported) and updated `skills/visual-builder/SKILL.md` plus `README.md` to document the optional visual-builder setup.
- Added local release handoff notes in `BUILD-NOTES.md` and bumped `.claude-plugin/plugin.json` plus `VERSION` to `6.5.2`.

## 6.5.1 (2026-04-22)

### Pilot UX Polish

- `README.md`: Prerequisites block moved above Install steps so non-engineers install cairo system libraries before running `pip install` (prevents cryptic `cairosvg` build failure on fresh machines). Install steps renumbered 1-5 with explicit dependency ordering.
- `.claude-plugin/plugin.json` + `VERSION`: bumped to 6.5.1 so the release zip reflects the updated README.

## 6.5.0 (2026-04-22)

### Hardening Sprint

- `open-studio.sh` now resolves `PLUGIN_DIR` relative to the script location instead of a hardcoded local path
- `scripts/canvas_api.py` flips `create_discussion()` to draft-by-default, updates docs, and adds a regression test for `published: False` when omitted
- `scripts/canvas_api.py` sets a default 30-second request timeout, stops retrying HTTP 403 responses, and adds a regression test for 403 short-circuit behavior
- `README.md` now documents Cairo system prerequisites before `pip install`
- Studio is marked experimental in `skills/studio/SKILL.md`, `CLAUDE.md`, `skills/course-concierge/SKILL.md`, and `README.md`; write-intent routing stays on chat-based workflows
- Added minimal GitHub Actions CI workflow for pytest on `main` and `brent-asucb`
- Added GitHub release packaging, release-triggered Coda version sync, and `docs/CODA_SYNC_SETUP.md` for `CODA_API_TOKEN` setup
- Version bumped to `6.5.0` in `VERSION` and `.claude-plugin/plugin.json`

## 6.4.9 (2026-04-22)

### Doc Sync + Version Bump

#### Internal
- `VERSION` bumped 6.2.1 → 6.4.9
- `plugin.json` updated to 34 skills + 7 aliases, expanded description covering idstack port features, tier system, consulting-framed ID standards
- `CHANGELOG.md` backfilled with 6.3.0 + 6.4.0 through 6.4.9 entries (was stale at 6.2.1)
- `.gitignore` hardened: `.claude/worktrees/`, `BUILD-NOTES.md`, `PROXY-BUILD-PLAN.md`

#### External
- Canvas + Claude Guide Coda doc updated to reflect v6.4.9 state (12 pages rewritten: Overview, Getting Started, Plugin Overview, Skill Reference, Workflows, Canvas API Ref, Troubleshooting, Download & Versions)

---

## 6.4.8 (2026-04-22)

### Routing Hardening (pre-pilot)

Live smoke test surfaced 2 edge cases. Fixed additively.

#### Meta feedback precedence
- New first hard-scope rule: prompts containing "report a bug", "leave feedback", "suggest an improvement", or "plugin feedback" route to `plugin-feedback` regardless of other keywords (prevents bug reports misrouting to content skills)

#### Canonical phrasing elasticity
- Build/import: "build me a course from scratch", "can you import my syllabus PDF"
- Push: "push these changes to canvas"
- Templates: "save this as a template"
- Accessibility: "check if this is WCAG compliant"
- 16 new test cases added to routing coverage

#### Verification
- 229 → 245 pytest pass; 33/40 → 40/40 routing smoke
- All changes additive except meta-feedback precedence reorder

---

## 6.4.7 (2026-04-22)

### Timeline Emit Consolidation

- New public `emit_event()` in `scripts/timeline_logger.py` with shared error-swallowing semantics
- `push_to_canvas.py` and `backup_manager.py` private wrappers collapsed to delegate
- Net: +37 / -18 lines, 229 tests pass
- Validated on live Canvas write (Brent sandbox 243527): push_started, push_verified, rollback_completed all emit correctly

Skipped after verification:
- HTML parser dedup (parsers differ — preflight tracks line position, deterministic doesn't)
- `course_status.py` delete (has test coverage, documented port artifact)
- `imscc_builder.py` delete (advertised in README as shipped feature)

---

## 6.4.6 (2026-04-22)

### ID Standards — 5 Consulting-Framed Additions

All additions framed as recommendations/suggestions. ID makes final call, not strict gates.

#### `standards/assessment-best-practices.md` (+70 lines)
- Section 5: Assignment & Discussion Feedback Design — feedback vs feedforward, 3-step structures, tier calibration, timing guidance (5-7d formative / 10-14d summative)
- Section 6: Due Date Distribution Pattern — ≤30% in final 25% recommended, `course-config` surfaces for ID review, capstone exception via `grading.capstoneCourse` flag

#### `standards/instructional-design.md` (+84 lines)
- Section 2: Alignment Gap Classification (Post-Build) — Strong Recommendation / Suggestion severity (not Blocker/Warning); ID may proceed with logged rationale
- §5 subsection: Scaffolding Fade Trigger (Position-Relative) — first 25% worked → middle 50% faded → final 25% independent → capstone open-ended
- §9 subsection: Peer Response Rubric Observability — TA test, observable vs attitudinal examples

---

## 6.4.5 (2026-04-21)

### Tier 2/3 Wave

- `course-review`: offline fallback when Canvas unreachable; tier-aware scoring
- `instructor-guide`: dynamic structure detection, conditional guards (no phantom page references)
- `rubric-creator`: observable descriptors enforced, standalone mode preserved
- Discussion generator: tier bleed fix (Introductory capped at Type B late modules; Advanced floor Type B)

---

## 6.4.4 (2026-04-21)

### Wave G Write-Safety Fixes

- `canvas_api.py`: network errors no longer uncaught; retry with backoff added
- `update-module`: staging bypass eliminated — every page edit routes through staging pipeline
- 5 new list helpers in `canvas_api.py`

---

## 6.4.3 (2026-04-21)

### Tier 2 Wave

- `mockup_manager.py`: LTI brief persistence fix, mockup_promoted timeline event (9th event type)
- Preview template: Google Fonts inlined (no external CDN dependency)
- Routing tests: paraphrase coverage expanded
- Doc ambiguity in CLAUDE.md setup gate resolved

---

## 6.4.2 (2026-04-21)

### Functional Test Fixes

- Tier boundary enforcement on regeneration (Introductory/Advanced/Graduate ceilings + floors)
- Audit pipeline: timeline event emission (audit_completed, accessibility_audit_completed, readiness_check_completed) from audit_report.py + vision_audit.py + criterion_evaluator.py
- Dry-run push: synthetic push_started + push_verified events with dry_run:true payload

---

## 6.4.1 (2026-04-21)

### idstack Port Functional Validation Fixes

- Fixture schema alignment for interactive-content (ALL_ACTIVITIES: module → list of {type, filename} dicts)
- Graduate tier auto-assign removed — opt-in only ("set this to graduate tier")
- Test stability fixes across 14 test files

---

## 6.4.0 (2026-04-20)

### idstack Port

New skills and infrastructure ported from idstack reference plugin.

#### New skills
- `needs-analysis` — structured intake (context, task analysis, learner analysis, planning flags) before course design

#### New infrastructure
- `scripts/timeline_logger.py` — per-course milestone event ledger (JSONL; 9 event types)
- `scripts/learnings_logger.py` — reusable course-specific operational learnings ledger
- `scripts/course_status.py` — compact status summarizer (last push, audit, mockup, build %)

#### Standards additions
- Evidence tiers in `asu-design-standards-enrichment.yaml` (per-criterion research references and evidence weights)

#### Tests
- `test_tier_calibration.py`, `test_concierge_routing.py`, `test_plan_import_needs.py`

---

## 6.3.0 (2026-04-20)

### Graduate Tier, ID Standards Overhaul, Doc Alignment

#### Three-Tier Course Level System
- Introductory (100-299), Advanced (300-499), Graduate (500+ opt-in)
- Auto-detection from `course_code` numeric portion with tier-neutral fallback
- Tier-calibrated Bloom's ranges, scaffolding, word counts, rubric descriptors per tier
- Tier-specific regeneration ceilings/floors ("too easy"/"too hard" bounded by tier band)

#### `standards/instructional-design.md` rewrite (85 → 300 lines)
- Backward design operational workflow
- Bloom's calibration by tier with verb banks
- Cognitive Load Theory scaffolding ladder
- Mayer's 12 multimedia principles with Canvas applications
- UDL plus-one rule
- ARCS engagement framework
- Discussion design (5 types × 3 tiers)
- Retrieval practice, video/audio design from research evidence

#### `standards/assessment-best-practices.md` expansion
- Course Tier System as single source of truth
- Tier-specific quiz mix, assignment word counts, discussion scaffolding
- Regeneration Bloom's range per tier

#### Doc alignment
- CLAUDE.md: Check 3.5 course level detection, tier override instructions
- README.md: three-tier system, assessment regeneration, preflight checks advertised

---

## 6.2.1 (2026-04-12)

### Page-Edit Contract + Live Verification

#### Page-Edit Contract (Minimal-Diff Default)
- CLAUDE.md: new two-mode contract — **Minimal-Diff Edit** (default for edit/update/revise) preserves existing structure; **Design Enhancement** (explicit only) applies design system
- update-module: page edit section defaults to minimal-diff, decision table added, ambiguous = minimal-diff
- bulk-edit: Mode 1 preserves existing page structure unless redesign requested
- course-concierge: "edit this page" routes to update-module in minimal-diff mode
- remediation-brief: "fixes should be surgical — modify minimum HTML necessary"

#### Live Canvas Verification
- Tested on Brent sandbox (255724): staged "TEST" edit, pushed, verified zero design-system markers added, rolled back successfully

---

## 6.2.0 (2026-04-12)

### Audit Gap Plan, Routing Redesign, Remediation Brief

#### Audit Gap Plan (5 phases)
- `course-audit` rewritten with 3 explicit modes: Quick Check (22 deterministic blocker criteria), Deep Audit (full 25-standard two-pass), Guided Review (audit + fix interactively via staging)
- `criterion_evaluator.py`: added `QUICK_CHECK_IDS` profile and `profile` parameter for reduced-scope evaluation
- `audit_report.py`: added `--course-id` flag (writes to per-course `courses/{id}/reports/`) and `--format` flag
- 17 audit pipeline acceptance tests

#### Concierge Routing Redesign
- Replaced 4-path menu + 16 Special Cases with intent-based taxonomy
- 10 intent families with scope classification (course/module/item/file/cross-course/meta)
- Direct-route-first: clear requests skip the concierge entirely
- Fallback question only for genuinely ambiguous messages
- 5 routing coverage regression tests guard against silent drift

#### New Skill: remediation-brief
- Accept QA-at-Scale remediation brief JSON for guided, criterion-by-criterion remediation
- 3-surface workflow: course settings → Canvas objects → page content
- Page content uses full staging/push/verify safety flow
- Delegates to rubric-creator, quiz-generator, etc. for content creation

#### Setup Gate Expansion
- Check 3 now covers all 33 primary skills (was only 5 categories)
- Editing/config, navigation/utility, mockup, media skills all have explicit config-handling rules

#### Documentation Updates
- CLAUDE.md: 33 skills, 29 scripts, remediation-brief in Skills Reference, YAML standards documented
- README.md: 33 Skills + 7 Aliases, remediation-brief in QA section
- All version files synced to 6.2.0

---

## 6.1.0 (2026-04-11)

### Rollouts 1-4: CDM Import, Audit Reports, Operational Skills

#### Rollout 1 — Build Input + Behavior
- CDM (Course Development Map) import as first-class input for course-build
- Standards-driven content generation (25 ASU standards mapped to page roles)
- Content Detection Protocol (4 states: empty/placeholders/custom/populated)
- Flexible template vocabulary (15 content roles with name matching)
- Progress reporting + post-build SME handoff summary

#### Rollout 2 — Audit Report Pipeline (5 scripts, 6,725 lines)
- `alignment_graph.py`, `deterministic_checks.py` (42 checks), `criterion_evaluator.py`, `link_validator.py`, `audit_report.py`
- Ported from IDW-QA, stripped of all RLHF/Supabase/pilot infrastructure

#### Rollout 3 — Operational Skills
- `course-config` skill (5 modes: publish, dates, assignment groups, settings, LTI)
- `media-upload` skill (structured upload + embed workflow)

#### Rollout 4 — Quality-of-Life
- `diff_engine.py` for staging comparisons
- Concierge: 401 token handling, status banner
- Syllabus: 16 ASU institutional policy boilerplate sections (total: 35)
- Script-enforced workflows table in CLAUDE.md

#### Standards Files
- `standards/asu-design-standards.yaml` — 25 standards, 173 criteria
- `standards/asu-design-standards-enrichment.yaml` — expectations + examples

#### Tests
- 103 → 120 regression tests

---

## 6.0.0 (2026-04-10)

### Major Upgrade — Foundation Layer, Skill Consolidation, Staging Review

Sourced from IDW-QA and ID-Workbench patterns. All RLHF/Supabase/pilot infrastructure excluded. Reviewed by Codex across 5 review cycles with live Canvas validation.

#### Foundation Scripts (7 new)
- `staging_manager.py` — per-course, object-aware staging with Tier 1 preflight checks
- `backup_manager.py` — timestamped backups with SHA256 checksums
- `push_to_canvas.py` — atomic push with full quiz question reconciliation
- `post_write_verify.py` — fetch-back verification with hard failures on mismatch
- `course_navigator.py` — course tree with 5-min cache and search
- `course_content_cache.py` — local page dump for offline search
- `template_manager.py` — reusable course structure snapshots

#### Skill Upgrades (4)
- `quiz-generator` — mode routing, tier-aware defaults, fast-path, regenerate
- `assignment-generator` — mode routing, tier-aware defaults, pre-gen questionnaire, regenerate
- `discussion-generator` — mode routing, tier-aware defaults, field-boundary docs, regenerate
- `bulk-edit` — 4-mode skill replacing content-fix

#### New Skills (7)
- `canvas-nav`, `staging`, `knowledge`, `course-transfer`, `templates`, `course-plan-import`, `course-mockup`

#### Skill Consolidation
- `images` merges image-search + image-generator (3 modes)
- `notebooklm` merges podcast + video + notebook (3 modes)
- 7 compatibility shims preserved

#### Staging Review UI
- Unified review page with sidebar TOC, Tiptap RTE (13 toolbar buttons), View Source toggle, color palettes, link dialog, per-section delete, module bulk select, approval checkboxes

#### Bug Fixes
- Quiz push: full reconciliation including empty-list edge case
- Verify strictness: ok:false on count/points mismatch
- Staging atomicity: cleared only after successful verification

#### Tests
- 30 regression tests covering push/verify/template contracts

---

## 5.13.0 (2026-03-27)

### Canvas API Expansion — Full Content Creation & IMSCC Export

Closed the biggest gap in our Canvas integration: the plugin can now programmatically create, publish, and delete **all** content types via the REST API, not just pages and files. Also added IMSCC package building for portable course templates.

#### New Python API Helpers (`scripts/canvas_api.py`)

Previously we only had helpers for pages, files, and folders. Now the full content lifecycle is covered:

- **Modules** — `create_module()`, `publish_module()`, `delete_module()`, `add_module_item()` (Page, Quiz, Assignment, Discussion, File, SubHeader, ExternalUrl types with indent support)
- **Quizzes** — `create_quiz()`, `add_quiz_question()` (MC, T/F, essay, short answer, matching, and 7 more types), `delete_quiz()`, `publish_quiz()`
- **Assignments** — `create_assignment()` (all grading types, submission types, peer reviews, Turnitin), `delete_assignment()`, `publish_assignment()`
- **Discussions** — `create_discussion()` (threaded/side-comment, graded with assignment attachment, require-initial-post), `delete_discussion()`
- **Rubrics** — `create_rubric()` (indexed criteria format with ratings), `associate_rubric()` (link rubric to assignment for grading)
- **Pages** — `create_page()`, `delete_page()`, `publish_page()` (previously only get/update existed)
- **Assignment Groups** — `create_assignment_group()`, `get_assignment_groups()`

All helpers use the existing retry/rate-limit infrastructure (`_request_with_retry`) and consistent error reporting.

#### New Studio Server Endpoints (`playground/server.js`)

Added POST and DELETE routes so the studio UI can create content, not just read and edit it:

- `POST /api/courses/:courseId/modules` — Create module
- `DELETE /api/courses/:courseId/modules/:moduleId` — Delete module
- `POST /api/courses/:courseId/modules/:moduleId/items` — Add module item
- `POST /api/courses/:courseId/quizzes` — Create quiz (all quiz settings)
- `POST /api/courses/:courseId/quizzes/:quizId/questions` — Add quiz question
- `DELETE /api/courses/:courseId/quizzes/:id` — Delete quiz
- `POST /api/courses/:courseId/assignments` — Create assignment
- `DELETE /api/courses/:courseId/assignments/:id` — Delete assignment
- `POST /api/courses/:courseId/discussions` — Create discussion
- `DELETE /api/courses/:courseId/discussions/:id` — Delete discussion
- `POST /api/courses/:courseId/rubrics` — Create rubric (with optional assignment association)
- `DELETE /api/courses/:courseId/rubrics/:id` — Delete rubric
- `GET /api/courses/:courseId/rubrics` — List rubrics
- `GET /api/courses/:courseId/rubrics/:id` — Get rubric details
- `POST /api/courses/:courseId/assignment_groups` — Create assignment group
- `GET /api/courses/:courseId/assignment_groups` — List assignment groups

Also improved the `canvasApi()` helper to handle 204 No Content responses (some DELETE endpoints) and non-JSON responses.

#### IMSCC Package Builder (`scripts/imscc_builder.py`)

New utility for building IMS Common Cartridge 1.3 packages — the standard Canvas import format:

- **Full content support** — pages, assignments, quizzes (MC, T/F, essay, short answer, matching), discussions, files, modules
- **Canvas-native format** — includes `canvas_export.txt` marker, dual QTI files (standard + non_cc_assessments), module_meta.xml, assignment_settings.xml, course_settings.xml
- **Programmatic builder API** — chain `add_page()`, `add_quiz()`, `add_module()`, etc. then `build("output.imscc")`
- **Course export CLI** — `python scripts/imscc_builder.py --course-id 12345` fetches all content from Canvas API and builds a portable .imscc package
- **Use cases**: course templates, cross-institution sharing, offline builds, backup/portability

#### Testing

All helpers verified against `asu-dev.instructure.com` (course 218762):
- 17 create operations passed (page, module, 4 module items, quiz, 3 quiz questions, assignment, rubric, discussion, assignment group, 2 publishes)
- 5 delete operations passed (full cleanup)
- 3 other courses verified untouched after testing
- IMSCC builder smoke-tested with all content types

#### Files Added
- `scripts/canvas_api.py` — +502 lines of content creation helpers
- `scripts/imscc_builder.py` — IMSCC package builder (new file)

#### Files Modified
- `playground/server.js` — +186 lines: 16 new API endpoints + improved DELETE handling
- `scripts/requirements.txt` — Added cairosvg dependency note

---

## 5.12.0 (2026-03-24)

### Studio: Interactive Report Viewer & Actionable Audits

Replaced the editor-based report rendering with an iframe-based read-only viewer. Reports now render with full styling, working buttons, and clickable links — no more fighting with the HTML editor stripping styles.

#### Report Viewer (iframe-based)
- **Dedicated report pane** — reports render in a sandboxed iframe instead of the contenteditable editor, preserving all CSS and interactivity
- **Seamless transitions** — clicking a page in the sidebar exits report view and returns to the editor; navigating back to course list cleans up the report frame
- **Full-fidelity rendering** — gradient headers, score rings, summary cards, category icons all render exactly as designed

#### "Ask Claude to Fix" on Every Issue
- **Every issue gets a fix button** — all warnings, failures, and informational issues across accessibility audits, course readiness checks, course audits, and course reviews now have an "Ask Claude to fix" button
- **Inline fix panel** — clicking the button reveals a panel showing:
  - **What Claude will do** — clear description of the planned fix
  - **Your guidance (optional)** — textarea for user-specific instructions before sending
  - **Send to Claude / Cancel** buttons
- **Fix requests via bridge** — "Send to Claude" writes a `fix-issue` request to the bridge file with the issue details, affected pages, and user guidance, then tracks the task in the status panel
- **Dismiss button** for false positives (e.g., contrast warnings that pass AA)

#### Canvas Links Throughout Reports
- **Page links** — every page mentioned in audit reports links directly to its Canvas URL (opens in new tab)
- **Settings links** — navigation issues link to course navigation settings
- **Syllabus link** — syllabus placeholder warnings link to the syllabus page
- **Clean pages linked** — even the "no issues" page list has clickable Canvas links for quick verification

#### Actions Dropdown in Editor View
- **Restored Actions dropdown** in the editor topbar — users can run audits from both the course list (per-row `···` menu) and the editor view (topbar "Actions" button)
- Same 4 options: Accessibility Audit, Course Audit, Course Review, Course Readiness Check

#### Task Status Improvements
- **10-minute auto-timeout** — stuck tasks automatically transition to error state with "Timed out — try again" instead of spinning forever
- **Documents panel timestamps** — reports show creation date and time (e.g., "Mar 24, 2026, 2:14 PM")

#### Files Modified
- `playground/public/index.html` — Added report iframe pane, editor Actions dropdown
- `playground/public/studio.js` — Report viewer with iframe + onload bridge, postMessage handler for fix requests, task timeout, editor actions dropdown, pane switching logic
- `playground/public/studio.css` — Report pane styles
- `courses/218762/reports/` — Updated report HTML templates with fix buttons on all issues and Canvas links

---

## 5.11.1 (2026-03-24)

### Design Taste Standards for AI-Generated Content

Added a craft-quality layer inspired by [taste-skill](https://github.com/Leonxlnx/taste-skill), adapted for Canvas's inline-CSS-only environment. Elevates "Ask Claude" component generation and page editing from functional to polished.

#### New Standards Document (`standards/design-taste.md`)
- **Typography discipline** — tight letter-spacing on headlines (`-0.5px`), generous body line-height (`1.65`), eyebrow labels for hierarchy, `max-width: 65ch` for readability
- **Color restraint** — one dominant accent per context, no pure black (`#000000`), warm-tinted shadows instead of black, color must signal meaning (not decoration)
- **Layout variety** — break center bias with asymmetric splits (60/40, 65/35), avoid the 3-equal-columns AI tell, prefer lists with border accents over card walls
- **Spacing rhythm** — consistent margins between like elements, defined spacing scale for sections/items/containers
- **Anti-AI tells** — banned clichés ("Elevate," "Dive into," "Embark on"), no placeholder names ("John Doe"), realistic data precision (`47.3%` not `50%`)
- **Component craft** — consistent border-radius, tinted shadows (`rgba(140,29,64,0.06)`), semantic HTML (`<figure>`, `<details>`), empty space as a design element
- **Quality checklist** — mental pre-flight before outputting any generated HTML

#### Integration
- Referenced in `CLAUDE.md` Page Design System section — all content-generating skills now follow both `page-design.md` (what to build) and `design-taste.md` (how to build it well)
- Added to Standards Documents listing in `CLAUDE.md`

#### Files Modified
- `standards/design-taste.md` — New design quality standards document
- `CLAUDE.md` — Added design-taste reference to Page Design System section and Standards Documents list

---

## 5.11.0 (2026-03-24)

### Studio: Component Library & Community Sharing

Full component browsing, creation, and sharing system for the Canvas Content Studio.

#### Component Library View
- **New full-page view** accessible from "Components" button on course list or "Browse Component Library" in the insert dropdown
- **Three tabs**: Built-in (23 ASU design system components), Custom (user-created), Community (shared via Vercel)
- **Card grid** with live iframe previews of every component, category filter chips, and search
- **Detail view** with large preview, component metadata, collapsible HTML editor, and action buttons

#### Enhanced Insert Dropdown
- **Hover previews** — hovering a component in the insert dropdown shows a live rendered preview panel
- **"Browse Component Library →"** link at the bottom navigates to the full library view

#### AI Component Generation
- **"Create with Claude"** button generates custom components using Google Gemini (via Vercel proxy)
- **Loading card** appears inline alongside existing components during generation (~5-10 seconds)
- **"Edit with Claude"** chat interface for natural-language edits to existing components
- Progress status messages shown during generation ("Sending to AI...", "Generating HTML...")

#### Inline HTML Editing
- **Editable code editor** replaces the read-only code block in the detail view
- **Live preview** updates as you type in the HTML editor
- **Save** button appears for custom components when changes are detected
- **"Save as Custom"** creates an editable copy of built-in or community components
- **Collapsible HTML section** — `<details>` toggle keeps the view clean by default

#### Community Component Sharing (Vercel Blob)
- **Share to Community** — publishes custom components to Vercel Blob storage
- **Community tab** shows shared components with live previews and author attribution (from Canvas profiles)
- **Refresh button** on Community/Custom tabs to sync latest components
- **Ownership management**: Unshare, Delete from Community (only your own), Save to My Components
- Auto-refresh when switching between tabs

#### Vercel API Endpoints (asu-plugin-proxy)
- **`POST /api/generate-component`** — Generates HTML components using Gemini 2.0 Flash with Canvas-compatible inline styling
- **`GET /api/components`** — Lists shared components (index) or fetches full HTML by ID
- **`POST /api/components`** — Publishes a component to the shared library
- **`DELETE /api/components`** — Removes a shared component (ownership enforced)
- Vercel Blob storage with `BLOB_READ_WRITE_TOKEN` for persistent community library

#### Studio Server Endpoints (playground/server.js)
- **`GET/POST/DELETE /api/components/custom`** — Local custom component CRUD (stored in `custom-components.json`)
- **`POST /api/generate-component`** — Proxy to Vercel Gemini endpoint
- **`GET/POST/DELETE /api/shared-components`** — Proxy to Vercel community endpoints

#### Files Modified
- `playground/public/index.html` — Component Library view, create modal, Claude chat interface, management buttons
- `playground/public/studio.js` — Library rendering, AI generation, sharing, editing, bridge protocol
- `playground/public/studio.css` — Library layout, card grid, detail view, thinking indicator, chat styles
- `playground/server.js` — Custom component CRUD, Vercel proxy endpoints
- `asu-plugin-proxy/api/generate-component.js` — New Gemini component generation endpoint
- `asu-plugin-proxy/api/components.js` — New Vercel Blob CRUD for shared components
- `.claude/launch.json` — Updated runtimeExecutable to absolute path

---

## 5.10.0 (2026-03-24)

### Studio: Text Formatting Toolbar & Module Management

New inline text editing tools and sidebar module controls for the Canvas Content Studio.

#### Text Formatting Toolbar
- **Floating toolbar** appears on text selection in the visual editor — dark theme, animated, auto-positioned above or below selection
- **Inline formatting**: Bold, Italic, Underline, Strikethrough with active state detection
- **Heading dropdown**: H1, H2, H3, Body Text — applies `formatBlock` commands
- **Font size stepper**: Increment/decrement by 2px with current size display
- **Color picker**: 18-color preset grid (scrollable), hex input field with Apply button, **+** button opens native OS color picker for custom colors. Custom colors saved to localStorage and persist across sessions with a star badge indicator
- **Link editor**: Add, edit, and remove hyperlinks. Panel shows existing URL when editing a link, with Remove/Cancel/Apply actions. Fixed positioning prevents panel from being clipped by toolbar bounds

#### Link Behavior Fix
- **Links no longer navigate** when clicked inside the visual editor — `e.preventDefault()` on all `<a>` clicks within the contenteditable area
- **Toolbar interaction guard** — `toolbarInteracting` flag prevents the toolbar from hiding during button clicks (selection change events no longer dismiss the toolbar prematurely)

#### Module Rename
- **Rename button** (pencil icon) appears on module header hover in the sidebar
- **Inline editing** — click to edit the module name directly in the sidebar tree
- Enter saves, Escape cancels
- Changes are **deferred** — tracked as pending, not pushed immediately

#### Module Drag & Drop Reorder
- **Drag handle** (grip dots) appears on module header hover
- Drag modules to reorder them in the sidebar with a visual **maroon drop indicator**
- New order applied to local state immediately

#### Pending Changes System
- **Floating badge** at bottom of screen tracks pending module renames and reorders
- "Apply Now" button pushes all pending changes to Canvas via API
- Dismiss button to hide the badge

#### Backend
- **`PUT /api/courses/:courseId/modules/:moduleId`** — New endpoint supporting `name` and `position` updates via Canvas API (`{ module: { name, position } }`)

#### Files Modified
- `playground/public/index.html` — Text toolbar HTML, pending changes badge, color picker with native input
- `playground/public/studio.css` — ~200 lines: toolbar, color picker, link panel, module sidebar controls, pending badge styles
- `playground/public/studio.js` — ~380 lines: text toolbar system, module rename, module drag-drop, pending changes management, link fix, custom color persistence
- `playground/server.js` — Module update endpoint

---

## 5.9.1 (2026-03-23)

### Studio UX Overhaul

Comprehensive redesign of the Canvas Content Studio based on instructional designer feedback. Focused on simplifying navigation, improving edit persistence, and adding a flexible column/grid layout system.

#### Course List Page
- **Table view** replaces card grid — columns: checkbox, Course, Code, Term, Last Edited, Latest Report. Scannable and sortable.
- **Select-all checkbox** in table header for batch selection.
- **Actions dropdown** appears when courses are selected: Accessibility Audit, Course Audit, Course Review, Course Readiness Check, Archive Course(s).
- **Topbar cleanup** — undo/redo, status, and editor buttons hidden on course list. Only logo, Refresh, and batch Actions shown.
- **Refresh icon** — clean circular arrow SVG replacing the old icon.

#### Course Editor Topbar
- **Course name replaces logo** when editing a course. Logo returns on the course list.
- **Three-dot overflow menu** next to course name: Visual/Code/Split view toggles, Pull from Canvas, Send to Claude. Reduces topbar clutter.
- **Condensed buttons** — only Actions and Push to Canvas remain as primary buttons. Pull from Canvas and Send to Claude moved to overflow menu.
- **Actions dropdown** for single-course audits (same 4 audit types).
- **Horizontal alignment fix** — all topbar elements now `align-items: center` on a consistent vertical axis.

#### Sidebar
- **Resizable** — drag the right edge (min 200px, max 480px). Width persists in localStorage.
- **Two tabs: Pages + Drafts** — Reports tab removed (moved to documents panel).
- **Sync status dots** on page tree items: green = matches Canvas, yellow = has unpushed local edits.
- **Sidebar toggle** moved from sidebar header to the context bar (left of Insert Component). No more overlap with editor content.

#### Edit Persistence
- **Auto-save page edits** — edits to Canvas pages are debounce-saved to `courses/{courseId}/edits.json` via server API. Navigate away and come back — changes are preserved.
- **Canvas/local toggle** — click the status indicator to swap between your local edits and the live Canvas version.
- **Revert button** — appears when a page has local edits. One click to discard and restore the Canvas version.
- **Edits cleared on push** — after successful push to Canvas, the local edit is removed and sync dot turns green.
- **Server endpoints**: `GET/PUT/DELETE /api/courses/:courseId/edits/:slug`.

#### Documents Panel
- **Right slide-out panel** (320px) triggered by document icon in topbar.
- Lists all HTML reports for the current course with type, date, and download button.
- Click to view reports in-studio (read-only mode).

#### Tooltips
- **CSS-only hover tooltips** via `data-tooltip` attribute on all buttons throughout the app.
- Positioned above for topbar-left, below for topbar-right, left-side for dropdown menu items.
- Descriptive tooltips on all Actions menu items explaining what each audit does.

#### Column/Grid Layout System
- **Grid Section** as a first-class studio concept — any components can be placed in grid cells.
- **2-Column and 3-Column Layout** components added to Insert menu.
- **Grid toolbar** (hover-only): column count label, +/− column buttons, + Cell button.
- **Cell management**: × delete button on each cell (inside cell at top-right to avoid overlap with grid delete). Delete removes cell or converts to empty placeholder based on row balance.
- **Auto-detect existing grids** — inline-styled `display: grid` divs from Canvas or previous edits are auto-upgraded with toolbar, cells, and delete buttons.
- **Auto-detect card siblings** — consecutive divs with matching styles (same background/padding/border-radius) are auto-wrapped in a 2-col grid.
- **Row balancing** — deleting a cell only leaves an empty placeholder if needed to complete the last row. Empty cells delete outright (no infinite cell growth).
- **Native drop** — grid cells accept browser text/HTML drags via `dragover`/`drop` events.

#### Drag & Drop Improvements
- **Component-level drag** — wrapper divs with only structural styles (max-width, margin, font-family) are unwrapped so individual components become drag targets.
- **Drag into grid cells** — drag a component over a grid cell to drop it in. Cell highlights with maroon dashed outline on hover. Rect-based hit testing for reliability.
- **Drag out of grid** — drag a cell far enough outside the grid to promote its content back to a top-level block. Vacated slot follows row-balance logic.
- **Intra-grid reorder** — grab the top 8px edge of a cell to reorder within the grid.
- **`<hr>` dividers** — wrapped in helper divs so they get drag handles (unwrapped on export).
- **Drag handles don't affect layout** — positioned absolutely at `top: -22px`, zero impact on component spacing.
- **Block delete buttons** — × button on every top-level component (top-right corner, visible on hover). Excluded from drag handle click zone.
- **Grid/cell handle separation** — grid drag handle at `top: -22px` with 4px grid padding-top prevents overlap with first-row cell grab zones.

#### Clean Canvas Export
- Grid wrappers export as `<div style="display: grid; grid-template-columns: repeat(N, 1fr); gap: 15px; margin: 15px 0;">` — Canvas preserves inline grid styles.
- All studio-only markup stripped: `data-studio-grid`, `data-grid-cols`, `.grid-cell` wrappers, `.grid-toolbar`, `.grid-cell-delete`, `.block-delete`, `.drag-handle-bar`, `data-drag-block`, `data-drag-hr-wrap`.

#### Server Changes
- `POST /api/courses/:courseId/archive` — moves course to `registry.archived[]`.
- `GET/PUT/DELETE /api/courses/:courseId/edits/:slug` — per-page edit persistence.
- 2-Column Layout and 3-Column Layout added to `DESIGN_COMPONENTS`.

#### Files Modified
- `playground/public/index.html` — Complete topbar redesign, course table, overflow menu, documents panel, sidebar toggle in context bar, grid cell structure
- `playground/public/studio.css` — Tooltips, table styles, overflow menu, documents panel, grid system (toolbar, cells, delete buttons, drop targets, placeholders), resizable sidebar, block delete buttons, drag handle positioning
- `playground/public/studio.js` (v5) — Course table rendering, topbar context switching, sidebar resize, page edit persistence, auto-save, Canvas/local toggle, revert, documents panel, archive, grid system (create, upgrade, auto-detect, toolbar, cell CRUD, balance, native drop, intra-grid drag, drag-into-cell, drag-out-of-grid), block delete, hr wrapping, clean export
- `playground/server.js` — Edit persistence CRUD, archive endpoint, column layout components

---

## 5.9.0 (2026-03-23)

### Multi-Course Support

The plugin was designed for one course at a time. IDs often work across many courses — switching between them, reviewing content in batch, managing a portfolio. This release decouples course identity from `.env`, adds a course registry, builds a studio course list page, and enables parallel batch audits via subagents.

#### Course Registry & Dynamic Course Context
- **Course registry** (`courses/registry.json`) — All active Canvas courses are synced automatically from the Canvas API on first launch. No manual setup per course.
- **Dynamic course context** — `CANVAS_COURSE_ID` removed from `.env`. Course identity is now passed as a parameter (`get_config(course_id=...)` in Python, `:courseId` route params in the studio server). `.env` holds credentials only (`CANVAS_TOKEN`, `CANVAS_DOMAIN`).
- **Per-course data directories** — Each course gets `courses/{courseId}/` with `course-config.json` and `reports/` inside. Directories created lazily on first use.
- **Auto-migration** — Existing users with `CANVAS_COURSE_ID` in `.env` are migrated silently on studio startup or during the setup gate. No terminal scripts required.

#### Studio: Course List Page
- **Course list landing page** — Studio now opens to a grid of all available courses. Each card shows course name, code, term, and most recent report badge.
- **Course switching** — Click a course to enter the editor. Back button returns to the list. No restart needed.
- **Multi-select** — Checkboxes on each course card for selecting multiple courses.
- **Batch audit actions** — With courses selected, a dropdown offers: Accessibility Audit, Course Audit, Course Review, Course Readiness Check. Triggers parallel execution via Claude subagents through the bridge file.
- **Course name badge** — Topbar shows which course you're editing.

#### Studio: Reports Integration
- **Reports tab in sidebar** — When viewing a course, sidebar tabs are now **Live Course / Drafts / Reports** (previously Canvas / Drafts / Visuals). Reports list all HTML reports for the current course.
- **Report viewer** — Click a report to view it in the editor area (read-only mode). Download button for exporting.
- **Report badges on course cards** — Course list shows the most recent report as a clickable badge for quick access.
- **Visuals** — Moved from sidebar tab to the Insert menu (already accessible from the component bar).

#### HTML Report System
- **Report template** (`templates/report-html/report-base.html`) — Self-contained HTML template using ASU design system inline styles. Maroon gradient header, summary cards with pass/warn/fail counts, collapsible category sections with severity badges, print-friendly.
- **Per-course reports** stored in `courses/{courseId}/reports/{YYYYMMDD-HHmmss}-{type}.html`
- **Batch aggregate reports** stored in `courses/reports/{YYYYMMDD-HHmmss}-batch-{type}.html`
- Reports viewable in studio, any browser, or printed to PDF.

#### Batch Execution Pattern
- **Eligible skills**: `accessibility-audit`, `course-readiness-check`, `course-audit`, `course-review` (read-only QA skills only — content-writing skills excluded)
- **Parallel execution** — Each course runs as a Claude subagent via the Agent tool
- **Triggering** — From studio multi-select or natural language ("audit all my courses")
- **Aggregate summary** — After all subagents complete, a summary report shows cross-course trends

#### Backend Changes
- **`playground/server.js`** — Refactored `getCanvasConfig()` to return credentials only. Added course-scoped API routes (`/api/courses/:courseId/modules`, `/pages`, `/quizzes`, `/discussions`, `/assignments`). Added `/api/courses` (registry), `/api/registry/sync`, and `/api/courses/:courseId/reports` endpoints. Legacy routes preserved with `?courseId=` query params. Auto-migration on startup.
- **`scripts/canvas_api.py`** — `get_config()` now accepts optional `course_id` parameter with env var fallback.
- **`scripts/setup_env.py`** — Added `--sync-registry` and `--migrate` commands.
- **`scripts/audit_pages.py`** — Added `--course-id` argument.
- **Bridge file format** — Now includes `courseId` field. Batch requests use `action: "batch-audit"` with `courseIds` array.

#### Setup Gate Updates
- **Check 2 rewritten** — Reads from registry instead of `.env`. If one course, auto-selects. If multiple, shows numbered list.
- **Check 3 updated** — Looks for config in `courses/{courseId}/course-config.json` first, falls back to working directory.
- **Auto-migration** — If `.env` still has `CANVAS_COURSE_ID`, the setup gate silently migrates and proceeds.

#### Files Modified
- `CLAUDE.md`, `playground/server.js`, `playground/public/index.html`, `playground/public/studio.js` (v5), `playground/public/studio.css`, `scripts/canvas_api.py`, `scripts/setup_env.py`, `scripts/audit_pages.py`, `.env.example`

#### New Files
- `courses/` — Directory for registry and per-course data
- `templates/report-html/report-base.html` — HTML report template

---

## 5.8.0 (2026-03-22)

### SVG→PNG Visual Delivery Pipeline

Canvas strips raw SVGs from page HTML (XSS prevention), breaking the visual-builder's inline SVG delivery. This release replaces inline SVG with an SVG→PNG conversion pipeline: Claude generates SVG as the authoring format, converts to PNG at 2x retina scale, uploads to Canvas Files, and embeds as a native `<img>` tag. No iframe needed for static visuals.

#### New: `scripts/convert_svg.py`
- SVG-to-PNG conversion via `cairosvg` with configurable scale factor (default 2x for retina)
- Handles both SVG file paths and raw SVG strings
- macOS Homebrew library path auto-detection (System Python + Homebrew cairo compatibility)
- CLI: `python convert_svg.py input.svg --output output.png --scale 2`
- Graceful error handling with install instructions for cairo system dependency

#### Updated: Visual Delivery
- **visual-builder SKILL.md** — Rewrote delivery pipeline: SVG source → PNG conversion → Canvas upload → `<img>` embed in `<figure>`. Added image embed template.
- **scientific-visuals.md** — Updated scope table, Canvas Embedding section, and file size guidelines for PNG delivery. SVG source preserved as intermediate artifact for iteration.
- **CLAUDE.md** — Updated visual-builder description and standards reference
- **requirements.txt** — Added `cairosvg>=2.7.0`

### Interactive Content: No-Scroll Enforcement

Activities now feel like images embedded in the course — no internal scrolling, no choppy double-scroll UX.

- **generator.py** — Added `html, body { overflow: hidden; height: 100%; }`, changed body to `display: flex; flex-direction: column;`, changed dialog card `.card-back` from `overflow-y: auto` to `overflow: hidden`
- **deploy_interactives.py** — Added `scrolling="no"` to all iframe embeds
- **page-design.md** — Updated Component 12 with `scrolling="no"` and dynamic `{{HEIGHT}}` placeholder
- **interactive-content SKILL.md** — Added "No-Scroll Requirement" section

### Interactive Content: WCAG Accessibility Hardening

Strengthened accessibility beyond the existing baseline across all 5 activity templates.

- **generator.py SHARED_CSS** — Enhanced focus indicators (gold outline + maroon box-shadow), skip-to-content link CSS, feedback icons via `::before` pseudo-elements (✓ correct, ✗ incorrect)
- **All 5 templates** — Added `<a class="skip-link">Skip to activity</a>`, `<main id="main-content">` wrapper, `id="instructions"` on instruction paragraphs, `aria-describedby="instructions"` on primary containers. Added instruction text to branching and quiz templates.
- **interactive-content SKILL.md** — Added "WCAG 2.1 AA Compliance" section with full implementation checklist

### Interactive Content: Layout & Visual Quality Standards

Fixes for text overflow, container misalignment, and general visual polish issues.

- **generator.py** — Added `word-wrap: break-word; overflow-wrap: break-word;` to headings, paragraphs, card faces, scenario boxes, option cards, stems, items, passages, and slots. Added `text-overflow: ellipsis` to slot text. Tightened mobile breakpoint padding.
- **interactive-content SKILL.md** — Added "Layout & Visual Quality Standards" section with text containment rules, container sizing guidelines, chart standards, responsive behavior requirements, and visual polish checklist

### Interactive Content: Instructional Context

Every embedded activity now includes brief instructional framing explaining what students should do.

- **deploy_interactives.py** — Added `DEFAULT_INSTRUCTIONS` dict with sensible defaults per activity type. Updated `iframe_html()` to include instruction paragraph. Supports custom `instruction_text` field override.
- **page-design.md** — Updated Component 12 template with `{{INSTRUCTION_TEXT}}` placeholder
- **interactive-content SKILL.md** — Added "Instructional Context" section, documented `instruction_text` optional field in content data format, expanded quality checklist with 4 new items

#### Files Modified
- **NEW** `scripts/convert_svg.py` — SVG→PNG conversion utility
- `scripts/requirements.txt` — Added cairosvg dependency
- `scripts/generator.py` — No-scroll CSS, WCAG improvements, layout quality fixes across all 5 templates
- `scripts/deploy_interactives.py` — scrolling="no", instruction text, DEFAULT_INSTRUCTIONS dict
- `skills/visual-builder/SKILL.md` — New PNG delivery pipeline
- `skills/interactive-content/SKILL.md` — 4 new sections: No-Scroll, WCAG, Layout Quality, Instructional Context
- `standards/scientific-visuals.md` — SVG→PNG pipeline, updated file size guidelines
- `standards/page-design.md` — Component 12 updated with scrolling="no", instruction text, dynamic height
- `CLAUDE.md` — Updated descriptions for visual-builder and scientific-visuals standard
- `.claude-plugin/plugin.json` — Version 5.8.0

---

## 5.7.2 (2026-03-20)

### Action-First Routing

Rewrote CLAUDE.md routing and setup logic so the plugin executes immediately instead of explaining capabilities or asking users to pick skills. Based on direct user feedback.

#### New: Core Principle — Action First
- **Never ask what you can check yourself.** Plugin now resolves setup state, parses URLs, and calls APIs programmatically before asking any question.
- **Never present skill names or menus.** Skills are internal routing — the plugin decides which skill to run based on user intent and follows it seamlessly.
- **Never explain capabilities instead of using them.** If the user asks for something, start doing it.

#### New: Intent-Based Routing (replaces Default Entry Point)
- **Clear, specific tasks** skip the concierge entirely and go straight to the appropriate skill. Examples: "add images to this page" + URL → `image-search`; "create a quiz for Module 3" → `quiz-generator`.
- **Ambiguous or open-ended requests** fall back to `course-concierge` for guided conversation.
- The concierge is now the fallback for open-ended requests, not the default for everything.

#### Revised: Setup Gate — Silent by Default
- **Check 1 (Credentials)**: Reads `.env` silently. Only asks user if file is missing.
- **Check 2 (Course Confirmation)**: If the user provided a Canvas URL, that *is* their confirmation — extract course ID, verify via API, proceed. If pulling from `.env` with no URL, use `AskUserQuestion` with a simple Yes/No confirmation. No more free-text "Is this the course you want to work on?" prompts.
- **Check 3 (Course Config)**: Creates minimal config silently from API data when possible. Only asks user if genuinely missing context.

#### Files Modified
- `CLAUDE.md` — Replaced "Default Entry Point" with "Core Principle: Action First" and "Routing" sections; rewrote all three Setup Gate checks for silent-first behavior

---

## 5.7.1 (2026-03-20)

### Image Workflow Improvements (Cross-Cutting)

Major refinements to image generation and search behavior based on testing feedback. Changes applied across 7 files.

#### Search-First Principle
- **All image skills now default to searching for vetted images before offering AI generation.** This is critical for scientific, biological, medical, historical, and factual content where AI-generated images may be inaccurate.
- When search yields poor results, the skill **asks the user** before switching to AI generation — never silently falls back to generation.
- Added explicit fallback flow to `image-search`: after 2-3 failed search attempts, presents user with options (generate with AI, try different terms, or skip).

#### Nano Banana as Default Generator
- **Nano Banana (Gemini native) is now the default model** for all image generation, replacing Imagen 4 Fast. Nano Banana produces significantly better text reproduction in images (labels, annotations, titles).
- Imagen 4 is now reserved for batch generation of text-free visuals only.
- Updated model selection tables in `standards/image-generation-guidelines.md`, `image-generator` SKILL.md, and `studio` SKILL.md.

#### Layout-Aware Image Placement
- New **Image Placement & Layout Guidelines** section in `standards/image-generation-guidelines.md` with rules for varying layouts, limiting images per page (1-3 ideal, max 4), and choosing between full-width (Component 13) and side-by-side (Component 14) based on page context.
- All image embedding steps (in `image-search`, `image-generator`, `course-build`, `studio`, `update-module`) now scan existing page HTML before placing images to avoid monotonous stacking of full-width images.
- Layout Decision Guide added: maps image context (focal point, concept explanation, comparison, hero/banner) to recommended component.

#### Files Modified
- `standards/image-generation-guidelines.md` — Added Search-First Principle, Image Placement & Layout Guidelines, updated Model Selection to default Nano Banana
- `skills/image-generator/SKILL.md` — Added Search-First Check, updated model defaults to Nano Banana, added layout-aware embedding (Step 9)
- `skills/image-search/SKILL.md` — Added poor-results fallback flow with user choice, added layout-aware embedding (Step 7)
- `skills/course-build/SKILL.md` — Updated Phase 2.5a table to reflect search-first approach, updated Phase 2.5b execution with explicit user consent for AI fallback and layout awareness
- `skills/course-concierge/SKILL.md` — Added image principles block to Images and Visuals special case (search-first, user choice, Nano Banana default, layout variety)
- `skills/update-module/SKILL.md` — Added image-search and image-generator to Invokes list, added Add/Update Images operation with search-first flow and layout awareness
- `skills/studio/SKILL.md` — Updated Finding Images and Generating Images sections with search-first checks, Nano Banana default, and layout-aware placement

---

## 5.7.0 (2026-03-20)

### New Skills
- **image-search** — Find CC-licensed images from vetted sources (OpenVerse, Wikimedia, Smithsonian, NASA, Library of Congress) and embed them in Canvas pages. Three modes: specific image, per-module, or course-wide scan. Includes inline preview, review card with alt text/caption/attribution, Canvas upload, and config tracking. Hands off to `image-generator` when real images aren't suitable.
- **image-generator** — Generate custom images using Google Imagen 4 (Fast/Standard/Ultra) and Nano Banana (Gemini native conversational image editing). Draft → review → refine → finalize workflow. Includes prompt library with 5 templates (process diagram, concept comparison, system overview, abstract concept, workflow/decision tree). SME review flags for scientific content. AI disclosure in all captions per UNESCO/EDUCAUSE guidance.

### Integrations
- **course-build** — New **Phase 2.5: Course Imagery** inserted between page population and assessment creation. Analyzes all modules, recommends search vs. generate per image, presents plan for approval, processes module by module. Added image tracking to module completion checklist.
- **studio** — New **Image Integration** section: search or generate images while editing pages, preview inline, send to studio bridge for visual adjustment before push. Insert menu updated to reflect 4 semantic image components (Instructional Image, Image + Concept, Image Comparison, Image Gallery).
- **course-concierge** — Path D (Edit or Fix) expanded with **"Add images"** option and new Step D4b routing to image-search or image-generator. Added **Images and Visuals** special case for natural language detection ("find images," "create a diagram," "add visuals").

### Config & Docs
- **CLAUDE.md** — Updated to 26 skills. Added `image-search` and `image-generator` to Skills Reference table (Images category), Setup Gate Check 3 content-generation list, and Standards Documents list.
- **plugin.json** — Version 5.7.0, description updated to 26 skills with image search and AI generation.

---

## 5.6.0 (2026-03-20)

### Image Infrastructure (Phase 1)

New foundation for image search and AI image generation across all content-generating skills.

#### Proxy Endpoints (asu-plugin-proxy)
- **`POST /api/generate-image-google`** — Image generation via Google Imagen 4 (Fast/Standard/Ultra tiers) and Nano Banana (Gemini native image gen with conversational editing). Authenticates via Canvas token, server-side `GOOGLE_AI_KEY`. Supports aspect ratio control, batch generation (1-4 images), and safety filtering.
- **`POST /api/search-images`** — CC-licensed image search via OpenVerse API. Filters by license tier (CC0, BY, BY-SA default), source, and aspect ratio. Returns normalized results with pre-built attribution strings, license URLs, author info, dimensions, and tags. Authenticated with OpenVerse OAuth for 10,000 req/day rate limit.
- **`POST /api/generate-image`** (existing) — fal.ai Flux Pro endpoint, ready when `FAL_AI_KEY` is added.

#### Standards & Guidelines
- **`standards/image-generation-guidelines.md`** — Comprehensive image standards document covering:
  - Mayer's Multimedia Learning principles applied to image decisions
  - Image purpose classification (Instructional / Contextual / Decorative) with alt text rules per type
  - Prompt engineering templates and style directory for AI image generation (6 style presets)
  - Model selection guide (Imagen 4 tiers + Nano Banana with use-case recommendations)
  - Prohibited uses with hard blocks (medical anatomy, historical photos, data viz, text-heavy images, real people, maps)
  - Warned uses requiring extra review (people depictions, scientific diagrams, discipline-specific imagery)
  - Vetted source directory: OpenVerse (primary), Wikimedia, Smithsonian, Library of Congress, NASA, NIH
  - License tier system (Tier 1 safest → Tier 4 restricted) with default search filters
  - Image review checklist and approval workflow (Candidate → Reviewed → Approved → Uploaded → Embedded)
  - Alt text writing standards with formula, examples, and long description pattern for complex diagrams
  - AI disclosure requirements following UNESCO and EDUCAUSE guidance
  - Canvas file organization structure (`images/m{N}/`, `images/shared/`)
  - Image optimization targets (500KB max, 800px min width, sRGB, responsive inline styles)

#### Page Design System (Components 13-15)
- **Component 13: Instructional Image** — `<figure>`/`<figcaption>` with numbered figure reference, descriptive caption, attribution line, and `aria-describedby` support for complex diagrams
- **Component 14: Image + Concept Card** — Side-by-side image and concept explanation following Mayer's Spatial Contiguity Principle. Image capped at 35% width, bordered card with attribution.
- **Component 15: Image Comparison** — Two-column `<figure>` grid for before/after, normal/pathological, or two-approach comparisons with independent alt text and labeled captions.

#### Canvas Content Studio Updates
- Replaced 3 basic placeholder image components (`image-full`, `image-text`, `image-gallery`) with 4 proper components in Studio Insert menu:
  - **Instructional Image** — `<figure>` with numbered caption and attribution
  - **Image + Concept** — Side-by-side with concept card
  - **Image Comparison** — Two-column comparison grid
  - **Image Gallery (3-up)** — Three-column `<figure>` grid with individual captions
- All components use semantic HTML (`<figure>`, `<figcaption>`) and include attribution placeholders

#### Python API Helper
- **`upload_image()`** added to `scripts/canvas_api.py` — Auto-detects MIME type from extension, creates organized `images/m{N}/` or `images/shared/` folder structure in Canvas Files, returns `file_id` + `embed_url` for page embedding.

#### Course Config Schema
- Added `images[]` array to module schema in `templates/course-config.json` — Full image tracking with fields for: id, filename, canvas_file_id, page_slug, placement, purpose classification, alt_text, caption, source metadata (type, origin, URL, author, license, attribution), and review status (relevance, accuracy_verified, sme_review_needed, accessibility_checked, approval).

#### Vercel Environment Variables
- `GOOGLE_AI_KEY` — Google AI API key for Imagen 4 + Nano Banana
- `OPENVERSE_CLIENT_ID` — OpenVerse OAuth client (registered, 10K/day)
- `OPENVERSE_CLIENT_SECRET` — OpenVerse OAuth secret
- `ALLOWED_DOMAINS` — Updated to include `asu-dev.instructure.com` for dev Canvas instance

### Fixes
- **plugin.json** — Fixed capitalization: "Asu" → "ASU" in plugin name

---

## 5.5.0 (2026-03-20)

### New Infrastructure
- **asu-plugin-proxy** — New Vercel serverless proxy (`/Users/apratlif/Documents/asu-plugin-proxy/`) that sits between the plugin and external services (Coda, fal.ai). Holds all third-party API keys as Vercel environment variables. Plugin users authenticate using their existing Canvas API token — no new credentials needed.
  - `api/feedback.js` — Forwards feedback to Coda webhook with user name auto-resolved from Canvas profile
  - `api/generate-image.js` — Forwards image generation prompts to fal.ai (ready when API key is added)
  - `api/health.js` — Status check endpoint
  - `lib/auth.js` — Canvas token validation + domain lock + 30-minute cache
  - Domain lock restricts access to ASU Canvas instances only

### Enhancements
- **plugin-feedback** — Feedback now routes through the Vercel proxy instead of calling Coda directly. User name is auto-detected from Canvas API (no longer asked). Hardcoded Coda credentials removed from SKILL.md.

### Security
- Removed hardcoded Coda webhook URL and bearer token from `skills/plugin-feedback/SKILL.md`. These now live exclusively in Vercel environment variables, encrypted at rest.

## 5.0.0 (2026-03-12)

### New Skills
- **studio** — Canvas Content Studio: a visual HTML editor for previewing, editing, and pushing Canvas pages. Three editor modes (Visual, Code, Split), ASU design system component insertion, draft persistence, undo/redo (50-state history), and full Canvas API integration for pulling and pushing pages. Runs as a local Express server with embedded browser preview.

### New Infrastructure
- **playground/** — Self-contained Express server (`server.js`) serving the Canvas Content Studio UI (`index.html`, `studio.css`, `studio.js`). Proxies Canvas REST API calls, persists drafts to local JSON, and serves ASU-branded page templates as draft starting points.
- **.claude/launch.json** — Preview server configuration for launching the studio via Claude's preview tools.

### Enhancements
- **CLAUDE.md** — Updated to 24 skills; added `studio` to Skills Reference table (Editing category) and Setup Gate (requires `.env` only, no `course-config.json`).
- **plugin.json** — Version bumped to 5.0.0 with updated description reflecting all 24 skills.
- **.gitignore** — Added `node_modules/` and `playground/drafts.json` exclusions.

## 0.4.0 (2026-03-11)

### New Skills
- **resource-discovery** — Research and curate accredited academic resources per module (OER textbooks, peer-reviewed articles, educational videos, professional organization materials). Includes vetting criteria, per-module presentation for approval, and structured storage in `course-config.json`.
- **lecture-support** — Three paths for lecture preparation: link existing recordings, generate full scripts (~2000-3000 words) or detailed outlines (~500-800 words), or insert styled video placeholders. Includes Canvas media embed patterns and placeholder HTML component.
- **notebooklm-notebook** — Create shareable NotebookLM study notebooks for student interaction. Supports per-module, course-wide, or selective scope. Generates Canvas pages with description, CTA button, and usage tips.
- **course-fit-quiz** — Module 0 self-assessment for mixed-level or open-enrollment courses. Generates 2-3 diagnostic questions per module, builds interactive HTML with color-coded results (green/yellow/red), links to module Overview pages. WCAG accessible.
- **plugin-feedback** — Send feedback, bug reports, or feature requests to the plugin team via Coda webhook. Two trigger modes: user-initiated and proactive (detects errors, frustration, repeated rejections). Auto-generates situation recap from session context.

### Page Design System
- **standards/page-design.md** — New comprehensive design system document referenced by all content-generating skills. Defines 12 reusable HTML/CSS components: Module Header Banner (maroon gradient), Dark Audio Overview Section, Left-Border Alerts (gold), Full-Card Alerts, Numbered Concept Items, Concept Grid (2-column), Color-Coded Resource Cards (Video=blue, Reading=purple, Reference=pink), CTA Button, Navigation Button, Interactive Activity Container, Video Placeholder. All components use inline styles (Canvas strips `<style>` blocks).

### Template Updates (Design System Applied)
- **overview.html** — Vibrant gradient header, gold module label, left-border "Why This Matters" alert, concept grid for professional connections, full-card time estimate
- **prepare-to-learn.html** — Dark-themed audio overview section with inline Canvas media embed, left-border intro framing, full-card transition alert
- **lesson.html** — Numbered concept items (maroon circles), color-coded resource cards replacing plain table, left-border "big idea" alert, full-card review reminder
- **knowledge-check.html** — Left-border gold tips alert, full-card attempt info, arrow CTA button
- **guided-practice.html** — Left-border formative note alert, arrow CTA button
- **create-artifact.html** — Left-border tips alert, arrow CTA button
- **conclusion.html** — Concept grid for "Connections Forward", green-accent completion checklist, arrow discussion CTA
- **resources.html** — Vibrant gradient banner header

### Enhancements
- **course-concierge** — Expanded Path A (Build) with 5 new steps: custom requirements interview (A3), resource sourcing (A4), lecture video options (A5), conditional course fit quiz (A6), and updated step numbering. Path B (Import) expanded with custom requirements (B4) and resource/lecture questions (B5). Path D (Edit) expanded with "Find resources" option. New special cases: student study notebooks, course fit quiz, lecture preparation, feedback. End-of-session proactive feedback suggestion. Principle 8: always link to Canvas items after API writes.
- **notebooklm-podcast** — Added "Audio Embedding in Canvas" section: inline-only audio embed requirement, never link to NotebookLM URLs, audio quality best practices (Professional quality, re-generate if choppy), upload workflow documentation

## 0.3.0 (2026-03-10)

### New Skills
- **course-concierge** — Friendly guided entry point that routes users to the right workflow via AskUserQuestion UI. Four paths: build, import syllabus, audit/review, edit/fix. Handles Canvas setup transparently within each flow.
- **course-readiness-check** — ASU Course Readiness Check (CRC) with 9-category pre-launch checklist aligned to the formal IDAsst QA process
- **instructor-guide** — Generates a hidden Faculty Resources module above Module 0 with 6 pages: teaching notes, announcement templates, communication timeline, support directory, grading tips

### Schema & Templates
- **course-config.json** — Extended to v1.2: added `instructor` section, `proctoring` section, `syllabus_source` section, expanded `policies` (gen AI, grading turnaround, course access, technology requirements, submitting coursework, syllabus disclaimer), added `course.credit_hours`, `course.course_description`, `course.general_studies_gold`
- **resources.html** — New Student Support Resources page template with SAILS, Canvas Help, tutoring, advising, counseling, Dean of Students (all links WCAG-compliant)

### Enhancements
- **syllabus-generator** — Expanded with all 19 CRC-required syllabus elements, "alignment-critical vs. metadata" field documentation, existing syllabus import flow, proctoring interview questions
- **canvas-setup** — Added third setup path: import existing syllabus from Canvas URL, file path (PDF/DOCX/HTML), or pasted text
- **course-audit** — Added 3 new check categories: Settings Consistency (syllabus vs. Canvas), Content & Media Best Practices (external links, video duration, citations), Student Support Resources (SAILS, DRC, advising links)
- **accessibility-audit** — Added Document Accessibility & FRAME Compliance category (PDF/DOCX/PPTX flagging, Ally integration, MediaPlus reference) and External Link Security & Screen Reader Accessibility category (`target="_blank"` + `rel="noopener noreferrer"` + sr-only text)
- **course-review** — Added inclusive language checks to Dimension 4: welcoming tone, asset-based framing, diverse examples, consistent voice across modules
- **course-build** — Added existing syllabus import check at Phase 0, syllabus metadata interview (Group 4b), instructor guide generation (Phase 6.5), Course Readiness Check (Phase 6d), resources page (Phase 5.5), expanded publish checklist
- **CLAUDE.md** — Added course-concierge as default entry point; Setup Gate now supports inline setup for all skills

### Standards Documentation
- **canvas-standards.md** — Major expansion (~250 lines): added CRC 9-category requirements, FRAME accessibility framework (Fonts, Readability, Accessible Media, Meaningful Hyperlinks, Equitable Design), navigation menu standards, document accessibility, external link handling, student support ecosystem, inclusive language best practices, expanded quality checklist
- **instructional-design.md** — Added FRAME cross-reference and inclusive language note under UDL
- **assessment-best-practices.md** — Added `course-readiness-check` to skill references, proctoring cross-reference

### Renamed
- **course-qa** → **course-audit** — All references updated across 13+ files

## 0.2.0 (2026-03-09)

### New Skills
- **update-module** — Edit a single module's content, objectives, or assessments without rebuilding the full course
- **course-review** — Pedagogical review against backward design, Bloom's alignment, and UDL principles

### Enhancements
- **canvas-setup** — Quick path (credentials only) and guided path (full blueprint interview)
- **course-build** — Dry-run mode previews the build plan without creating Canvas objects
- **discussion-generator** — Added Type E: Integration discussions for cross-module synthesis
- **rubric-creator** — Standalone mode generates rubrics independently of assignments
- **quiz-generator** — Answer-key appendix with rationales appended to quiz output
- **interactive-content** — Custom activity mode for freeform HTML interactive design
- **notebooklm-podcast** — Clarified as prompts-only (user pastes into NotebookLM)
- **notebooklm-video** — Clarified as prompts-only (user pastes into NotebookLM)
- **content-fix** — WCAG contrast ratio calculation for automated color remediation

### Scripts
- **canvas_api.py** — HTTP 429 rate-limit retry with exponential backoff
- **setup_env.py** — Added `--validate`, `--test`, and `--list-courses` flags

## 0.1.0 (2026-03-09)

### Initial Release
- 12 skills: 8 generalized from Core Physiology course build + 4 new (course-build, course-audit, accessibility-audit, content-fix)
- 4 bundled standards documents (ASU Canvas Standards, Instructional Design, Assessment Best Practices, Canvas Content Types)
- 6 Python utility scripts (canvas_api, generator, deploy_interactives, audit_pages, upload_captions, add_transcripts)
- 7 page HTML templates (Gold Standard module structure)
- Course config JSON template
