# Plan — Content Remediation (dedash + preflight --fix) + Safe Re-push

Source: trading-course experiment (course 262726, 2026-06-18). One Claude-generated course carried ~400 em/en dashes — every page tripped preflight's own `em_dash` warning. Hand-fixing was a course-wide chore. Two related gaps fall out:

1. **MUST 1** — the plugin GENERATES content that fails its OWN preflight (em/en dashes). Fix at source + ship a deterministic fixer.
2. **MUST 2** — bulk cosmetic re-push of already-live pages must transform the LIVE body (re-pushing a stale local source silently reverts live drift). Whole-body diff verification is unreliable on Canvas.
3. **SHOULD 4** — preflight detects several deterministic flags but offers no `--fix`.

These share one core (deterministic text fixers), so they ship as one PR.

## Acceptance criteria

### A. `scripts/content_remediation.py` (new) — pure deterministic fixers
Each fixer: `def fix_x(html: str) -> tuple[str, int]` returning `(new_html, n_changes)`. Pure, no I/O, idempotent (running twice = 0 further changes).

- `fix_dashes(html)`:
  - Inside `<h1..h6>`: replace `\s*[—–]\s*` → `: ` (colon). If the heading ALREADY contains a colon, use `, ` (comma) instead to avoid double-colon (`Lesson 0.8: Long and Short: One Skill` is a defect we hit).
  - Body (outside headings): ` — ` / ` – ` → `, `; any remaining `—`/`–` → `, `.
  - **NEVER touch** U+2212 minus `−` (used in `−2R`, `−3%`) or U+002D hyphen `-` (used in `risk:reward`, price ranges `$64,191-$64,349`, `hard-to-borrow`). Only U+2014 (em) and U+2013 (en).
  - Assert post-condition: minus count unchanged, 0 em/en dashes left.
- `fix_heading_title_case(html)`: within `<h1..h4>` (+ `<summary>` if preflight checks it), lowercase APA "minor words" (articles a/an/the; short coordinating conjunctions and/but/or/nor/for/so/yet; prepositions ≤3 letters: on/in/to/of/at/by/up/as/off/per/via/etc.) UNLESS first or last word. Reuse the EXACT word list preflight §7d uses — read it from preflight_checks.py, do not invent a divergent list. First/last word always capitalized.
- `fix_table_row_background(html)`: when a `<tr style="...background...">` has children whose own style lacks a background, move the row's `background`/`background-color` (and `color`, if present) declaration onto each child `<td>`/`<th>` inline `style`, and strip it from the `<tr>`. Preserve all other tr/cell styles + attributes.
- `fix_ai_slop_binary_contrast(html)`: **DETECT-ONLY — do NOT auto-rewrite.** Rephrasing "not X, but Y" → "Y" is semantic and unsafe to automate. This fixer returns `(html, 0)` always; remediation stays manual. Document why in the docstring. (Listed so callers know it is intentionally a no-op.)

### B. `scripts/preflight_checks.py --fix`
- New CLI flag `--fix` (works with `--check-page`). Loads the HTML, runs the deterministic fixers from module A in order (dashes → title-case → table-bg), writes the result back to `--html-file` (or stdout with `--json` reporting). 
- Reports: per-fixer change counts, and which remaining issues are MANUAL (e.g. ai-slop binary-contrast, any semantic/WCAG findings). Re-run `check_page` after fixing and include residual issue count in the report.
- `--fix` without `--html-file` (stdin mode) writes fixed HTML to stdout.
- Must not alter pages that have no fixable issues (idempotent; 0-change exit clean).

### C. `scripts/dedash.py` (new) — thin CLI
- `python scripts/dedash.py FILE [FILE...]` or `--stdin`. Calls `content_remediation.fix_dashes`. Reports per-file change count. `--check` mode: exit non-zero if any em/en dash present (for CI / pre-commit use), change nothing.

### D. MUST 1 at-source — `standards/page-design.md`
- Add a craft rule under the existing §Design Craft Standards / anti-AI-tell section: **No em/en dashes (—, –).** Headings use a colon; body uses a comma or a restructured sentence. The minus sign − is allowed ONLY in numeric/technical contexts (−2R, −3%, temperature). Rationale: em/en dashes are an AI-tell AND trip preflight's `em_dash` warning, so all generated content must be born dash-free. One line in the checklist too.
- Confirm generators reference page-design.md for craft (they do per CLAUDE.md §Page Design System) — no per-generator template edits needed beyond the standard, unless a generator hard-codes an em dash in a template string (grep `templates/` + generator scripts for literal `—`/`–`; fix any found).

### E. MUST 2 — safe re-push helper + guardrail docs
- `scripts/repush_transform.py` (new): `--course-id --type page --slugs A,B,C --transform {dedash} [--confirm-verb push] [--dry-run]`. For each slug: fetch the LIVE body via `canvas_api.get_page`, apply the named transform (dedash = `content_remediation.fix_dashes`), and IF changed, stage via `staging_manager.stage` then push via the normal `push_to_canvas` path (respects the Write Safety Guard — stage→push, backup/verify). Skip unchanged pages. `--dry-run` reports per-slug change counts without staging/pushing. This operationalizes "transform the live body, never the stale source."
- Docs (rule, not just tooling):
  - `CLAUDE.md` and `AGENTS.md`: add to §Write Safety Guard (or a new sub-note) — *Bulk cosmetic edits to already-live content must transform the LIVE Canvas body, never re-push a stale local source (which silently reverts live-only drift). Whole-body string/diff verification is unreliable on Canvas — it strips the `<!-- canvas-wrapped -->` marker and re-encodes HTML entities, so a content-identical page reports a false diff. Verify field-targeted (substring / count), not whole-body equality. Use `scripts/repush_transform.py` for deterministic bulk transforms.*
  - `skills/bulk-edit/SKILL.md`: same rule in the skill's guidance + point at `repush_transform.py`.

### F. Tests (must pass in CI, token-less — mock `get_config`/`get_page`/network)
- `tests/test_content_remediation.py`: fix_dashes (heading→colon, heading-with-existing-colon→comma, body→comma, minus −2R/−3% preserved, hyphen ranges preserved, idempotent, 0 dashes after); title-case (minor words lowercased, first/last capitalized, acronyms untouched); table-bg (row bg moved to cells, other styles preserved, idempotent); ai-slop fixer is a no-op.
- `tests/test_preflight_fix.py`: a page with dashes+bad-title-case+row-bg → `--fix` clears those preflight issues; residual manual issues still reported; idempotent second pass.
- `tests/test_dedash_cli.py`: CLI fixes a file; `--check` exits non-zero on dashes, zero when clean.
- `tests/test_repush_transform.py`: mock canvas_api.get_page to return a dashed body; assert transform applied to LIVE body, unchanged pages skipped, `--dry-run` writes nothing. Mock the push path.
- Run the FULL suite (`pytest tests/ -q`) — no regressions.

## Constraints
- Char-precise on dashes: ONLY U+2014 / U+2013. Guard the minus U+2212 + hyphen with explicit assertions.
- No new third-party deps (stdlib `re`, `html.parser` already used by preflight).
- Idempotency is a hard requirement for every fixer (preflight may run --fix repeatedly).
- Follow existing preflight `Issue`/HTMLParser patterns; do not introduce a second HTML-parsing style.
- PR-only; coder ≠ reviewer. Tests alongside code.

## Out of scope (this PR)
- SHOULD 3 (graduate chart_gen) — separate, pending user decision.
- Auto-rewriting AI-slop prose (semantic; stays manual/detect-only).
- Quiz-question-internal dash stripping (separate surface; low value).
