diff --git a/.github/workflows/html2video-for-mcode-smoke.yml b/.github/workflows/html2video-for-mcode-smoke.yml
new file mode 100644
index 0000000..ce25989
--- /dev/null
+++ b/.github/workflows/html2video-for-mcode-smoke.yml
@@ -0,0 +1,57 @@
+# html2video-for-mcode · scoped smoke CI
+# 官方主 CI(ci.yml)只跑 validate + node --test(无 ffmpeg/playwright, 渲染冒烟会 skip)。
+# 本 workflow 按 CONTRIBUTING/ci.yml 注释给出的"单插件 scoped workflow"模式,
+# 装齐依赖后真实执行全部测试 —— 这是 PR #41 评审要求的可执行测试证据。
+# 触发条件: 仅本插件目录(.github/workflows/ 本文件除外)或本文件自身变更。
+name: html2video-for-mcode smoke
+
+on:
+ pull_request:
+ paths:
+ - 'plugins/Wzdhehe/html2video-for-mcode/**'
+ - '.github/workflows/html2video-for-mcode-smoke.yml'
+ push:
+ branches: [main]
+ paths:
+ - 'plugins/Wzdhehe/html2video-for-mcode/**'
+ - '.github/workflows/html2video-for-mcode-smoke.yml'
+ workflow_dispatch:
+
+jobs:
+ smoke:
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+
+ - name: Install ffmpeg
+ run: sudo apt-get update && sudo apt-get install -y --no-install-recommends ffmpeg
+
+ - name: Install playwright (no package.json / lockfile changes)
+ run: |
+ npm install --no-save --no-package-lock playwright
+ npx playwright install --with-deps chromium
+
+ - name: Security, policy & template tests (containment / overwrite refusal / endpoint allowlist / SSRF / play page / fx keyframes)
+ run: |
+ node --test \
+ plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/safe-paths.test.mjs \
+ plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/no-clobber.test.mjs \
+ plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/endpoint-allowlist.test.mjs \
+ plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/fetch-policy.test.mjs \
+ plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/preview-page.test.mjs \
+ plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/tokens-fx.test.mjs \
+ plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/chart-kit.test.mjs \
+ plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/table-kit.test.mjs \
+ plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/css-kit.test.mjs \
+ --test-timeout=60000
+
+ - name: Render smoke (init → timings → static gate → capture → build)
+ run: |
+ node --test \
+ plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/render-smoke.test.mjs \
+ --test-timeout=300000
diff --git a/plugins/Wzdhehe/html2video-for-mcode/.claude-plugin/plugin.json b/plugins/Wzdhehe/html2video-for-mcode/.claude-plugin/plugin.json
new file mode 100644
index 0000000..ab3e0b1
--- /dev/null
+++ b/plugins/Wzdhehe/html2video-for-mcode/.claude-plugin/plugin.json
@@ -0,0 +1,8 @@
+{
+ "name": "html2video-for-mcode",
+ "version": "1.6.0",
+ "description": "Turn a topic, outline, or script into a narrated MP4: HTML slides with staged entrance animations, TTS voiceover, ffmpeg assembly, and ASR verification.",
+ "skills": [
+ "./skills/html2video-for-mcode/SKILL.md"
+ ]
+}
diff --git a/plugins/Wzdhehe/html2video-for-mcode/CHANGELOG.md b/plugins/Wzdhehe/html2video-for-mcode/CHANGELOG.md
new file mode 100644
index 0000000..05ab398
--- /dev/null
+++ b/plugins/Wzdhehe/html2video-for-mcode/CHANGELOG.md
@@ -0,0 +1,240 @@
+# Changelog
+
+## 1.6.0 — 2026-09-18
+
+**Cover / slide transitions / speech-rate alignment (three field reports from real deliveries)**
+
+- **Cover and a non-black first frame.** Every segment carried `fade=t=in`, so frame 0 of the final video was pure black — the thumbnail people saw when the file was forwarded. Fix: `capture.mjs` now also writes `preview/cover.png` for the first slide (base animations finished, subtitles hidden — the fully revealed design), the first segment dissolves from that cover over 0.25 s (**frame 0 *is* the cover**, no added duration), `build-video.mjs` embeds it as `attached_pic` (main video stream still `-c:v copy`; note `-shortest` must be dropped when a cover is embedded) and exports `out/cover.png` at canvas size for platform uploads.
+- **No more black frames between slides.** Each segment faded to black (0.3 s) and the next faded in from black (0.25 s) ≈ 0.55 s of black at every page change. Now: hard **cut** by default (only the last segment keeps its outro fade; the first gets the cover dissolve above), plus an optional **cross-dissolve** (`script.json` → `"transition": {"type":"xfade","duration":0.4}`, or `--transition xfade`). The dissolve never passes through black: each segment holds `duration` extra tail frames, which the xfade consumes, so the total duration still equals `timings.total`. The play page compares the two live (bottom-bar toggle / `T`).
+- **Speech rate: default 1.1, and the audition now asks.** Default was `speed 1.0` (≈4.8 Chinese chars/sec) — field feedback: "the default pace is a bit slow". Default is now **1.1** (≈5.3 chars/sec; first/last slides 1.05) across SKILL.md, `tts-and-timing.md`, `authoring.md`, the project template and evals. More importantly the **audition step asks two questions at once**: the three candidate voices *and* the same probe line in the chosen voice at three speeds (1.05 / 1.15 / 1.25); the answer is written to `script.json` (`speed`), and `plan-timings.mjs` now **reads that field** (it used to be declarative only) and warns when the measured rate deviates >20% from `baseline × speed`, or when speed is outside 0.8–1.4.
+- **Gate 4 now simulates the preview and asks two more questions.** After the HTML is done: ① slide transition (cut / dissolve / custom duration — comparable live in the play page) ② entrance layering (title + sub-info all at once, or staged as `data-stage` does today). `check-slides.mjs` prints the current transition value and each slide's level count so the agent asks with real numbers instead of impressions. To collapse staging, set the layers to the same stage and re-run `capture --mode motion` for that slide.
+- **Self-check gained three visual gates** (all fail the run non-zero): cover embedded, first-frame luma above black level, and no black frame around any cut point.
+- Tests **171 → 174** (new `cover-transition.test.mjs`: cover present + `attached_pic` stream + non-black first frame + no black at cuts in both cut and xfade modes + duration unchanged + a single-level slide still renders), all green with 0 skips.
+
+**Documentation is now English.** SKILL.md, all six references, CHANGELOG and THIRD-PARTY-NOTICES are written in English so the skill is usable outside Chinese-language teams; `README.zh-CN.md` stays Chinese. Source examples were internationalised at the same time: the announcement/media screenshot path and the source tiering now give **both** international (Reuters, AP, Bloomberg, FT, WSJ, BBC, CNBC) and Chinese (Xinhua, CCTV, The Paper, Caixin, Tencent News, Sina, NetEase) examples, ranked by primary-ness and editorial accountability rather than by country; the regulated-topic guide names jurisdictions (SEC/FTC/FDA, FCA/ESMA/EMA, CSRC/NMPA/SAMR) and states the up/down colour convention per market instead of assuming one.
+
+## 1.5.0 — 2026-09-18
+
+**Round-2 review fixes: output containment, the network boundary at the real request point, the subtitle timeline (3 P1 + 4 P2)**
+
+- **[P1] Subtitle timeline (a finished-video defect reproduced by measurement)**: subtitles are "full-duration percentage animations", while the frame sequence only covers the animation window and the rest was frozen on the last frame by `tpad` — so **every subtitle change after the animation window never made it into the picture** (measured: SRT clause 4 sits at 5.32s, yet the frame at 6.0s still shows clause 2); on the `no-fx`/static path it is worse — `finish()` jumps the subtitle animation to `opacity:0`, so the whole video carries not one subtitle frame. capture now emits, per clause, a "final-state base + that clause's subtitle" still (`build/substills//`, with a manifest `build/substills/.json` recording `framesCover` and every clause's window), and build-video uses `concat` to stitch "frame-sequence segments + the subtitle segments" into the whole slide (the segment lengths must sum to the slide duration, backed by the existing self-check). The regression test uses a **pixel-level criterion**: inside clause 2's window the finished video must match clause 2's subtitle still (measured 0.93) and stay far from clause 1's (7.0); under the old behaviour those two numbers invert exactly (0.99 / 7.1) — the test goes red.
+- **[P1] Output containment (the second class of escape)**: only input paths used to be contained; derived output (`preview/`, `build/frames//`) is "a fixed position inside the project + a validated id", but **a directory segment inside the project can itself be a symlink pointing outside it**, and `rmSync(recursive)` deletes straight through it while the command reports success. New `tools.assertContained/safeOut` (the realpath of every existing ancestor segment must still sit inside the root's realpath), wired into every write/delete site in capture (frame-directory recursive delete, preview writes), build-video (out/build/asr writes and cleanup), preview-page, plan-timings and grab-frames. The canary regression test (on Windows the link is made with a junction, no administrator needed) asserts "the command must refuse + the canary is intact".
+- **[P1] The network boundary now sits at the real request point**: previously only "the one URL supplied at the start" was validated. Now `url-policy.assertResolvedHost` resolves DNS and then validates the IP for **every real request** (page navigation, the 30x redirects Chromium follows on its own, browser subresources, per-hop image downloads); on the browser side it hooks `context.route` with a per-hostname resolution cache. The end-to-end test asserts "a refused target must receive 0 requests", and an injectable fake resolver covers rebinding scenarios (no real DNS needed).
+- **[P2] ASR request errors count as failures**: authentication / rate-limit / network failures used to land in `results` without entering the tally, and `1 通过 / 0 不通过 / 退出 0` slipped the gate through. They are now counted separately, excluded from the pass count, and the whole run exits non-zero naming the affected segments.
+- **[P2] The play page's attribute parsing accepts three quoting styles**: `` (single quotes) and unquoted attributes were not recognised, so a second, duplicate set of attributes was injected — and the browser keeps **the group that appears first** → the measured delay, the canvas size and `no-fx` all failed. The attribute is now rewritten in place and no duplicates are produced.
+- **[P2] ffmpeg discovery gained cwd and the project directory**: the README states that installing `ffmpeg-static`/`ffprobe-static` inside the video project is supported, but `tools.mjs` never looked at `process.cwd()`, and `prep-image.mjs`/`asr.mjs` never passed the project directory → "it is installed in the project, yet it reports not found". Both are now passed, with a source-level assertion against regressions.
+- **[P2] `--out-dir` compares canonical paths**: on macOS `/var/...` and `/private/var/...` are the same directory, and a plain string comparison misread a legitimate in-project path as an escape (hit for real on the official CI's macOS environment); the check now takes the realpath of the deepest existing ancestor before comparing.
+
+**Audit fixes folded into the same batch (after 1.4.0, never released on their own)**
+
+- `preview-page`: a 2MB cap on tokens.css (the same gate as check-slides/init-project — a 6MB input once ran 129s in measurement), numeric validation of `script.width/height` (a string written by mistake kills the play page's script outright), no-ops at the first/last-slide boundaries (pressing → on the last slide no longer resets it to level 1 and replays it), falling back to whole-slide paging when the filename contains `?`/`#` (the stepping query string breaks), and slides in subdirectories are skipped with an explicit explanation.
+- `build-video`: `bgm.volume/fadeIn/fadeOut` and `fps` are now all numerically validated (the same injection surface as `width/height` — previously only W/H was covered); validation moved ahead of encoding (a bad config fails at once instead of after minutes of rendering).
+- `asr`: `String.replace` now uses a function replacement string (a `$&`/`$1` inside the transcript would corrupt the checklist).
+- **Root fix in the template**: all nine `.fx-stagger` rules gained `:not([data-stage])` — the `nth-child` delay's specificity (0,2,0) is higher than the fx class's (0,1,0), and it silently overrode the entrance moment of `data-stage` children inside the container (measured: they popped up 3 seconds early).
+- `check-slides` gained two gates: **5d** `.fx-stagger` and `data-stage` coexisting on one slide (a prompt to confirm the layout), and **5e** an absolutely positioned `bottom` falling inside the subtitle band (the most expensive class in measurement: invisible in the still preview, and in the finished video the subtitles sit on the figure caption).
+- New **`grab-frames.mjs`** (the 12th CLI): computes each slide's absolute start from `timings.json` and pulls frames from `out/final.mp4` to check them — the final reconciliation tool for the class of problem "a still cannot show it, only the finished video reveals it".
+- Tests **151 → 171** (new `review-round2.test.mjs` + two end-to-end subtitle-timeline cases + a grab-frames smoke test), all green with 0 skips.
+
+## 1.4.0 — 2026-09-18
+
+**Play page interaction model reworked: animations on = manual level-by-level entrance**
+
+- Measured feedback: with animations on, ←/→ could only turn whole slides, and second-level sub-headings or small charts had to be waited for as they appeared on schedule — that does not match the feel. Now **with animations on, → reveals the next level first** (the next level animates in on the spot, and only once it is out does the page turn; ← steps back level by level, and stepping back does not replay animation but goes straight to the final state); with animations off it still turns whole slides.
+- **How level stepping works**: a script injected into the snapshot copy's `` runs before the CSS animations start and, given `?s=k`, resets each level's `--tN` delay — passed levels at `-60s` (final state), the current level at `0ms` (enters on the spot), future levels at `+60s` (stay hidden until the key is pressed); the parent page only swaps the iframe src, the same mechanism as the snapshot, with no cross-document access (under file:// the iframe is a separate origin). The top bar's page number shows the current level (e.g. `1 / 8 · 级 2/3` — page 1 of 8, level 2 of 3).
+- **Replay button and the `R` key removed** (under level stepping, replay has no meaning); the touch bar's forward/back arrows enlarged (76px wide, 24px type — measured feedback: too small).
+- **Frame switching no longer flashes white** (measured feedback: a big slide change flashed a white screen): now double-buffered — two stacked iframes, the new address is fed to the hidden one first, and only after `load` plus two more rAF frames (the new document's first frame has painted) do they swap for display, so the old picture is always on screen; level stepping benefits from the same.
+- **Bilingual UI** (measured feedback: an English project got a whole set of Chinese buttons): buttons / hints / panel titles / placeholder copy adapt between Chinese and English from `script.json`'s `lang` (`en*` → Motion on / Narration on / Overview…), while terminal output stays Chinese (the agent's view).
+- **Toggles moved to the bottom bar, labelled with their state** (measured feedback: "动效/口播" gives no clue whether it is on or off, and the top-bar toggle is redundant): the bottom-bar buttons read "动效开/动效关" "口播开/口播关" (motion on / motion off, narration on / narration off — the off state lit in a warning colour), shown on desktop too, and the top-bar animation toggle is removed.
+- Tests +5 (the toggle labels and the top-bar removal / the double-buffer structure and frame-wait order / **behaviour-level** cases: at `?s=1` level 2 must still be waiting, at `?s=2&anim=1` it enters on the spot, stepping back goes straight to the final state / an English page's copy contains no Chinese; a failed assertion also closes the browser in `finally` — otherwise the event loop keeps the runner alive forever, hit in measurement), **151 tests** in total. The play-page instructions in SKILL.md / render.md were kept in sync; also verified live through Playwright (in steady state the double buffer shows exactly one frame, the X/P toggles flip, the en page's copy).
+
+## 1.3.7 — 2026-09-18
+
+**Kickoff alignment checklist reordered + Gate 3 gained the "pick assets / add assets" questions**
+
+- The must-ask kickoff checklist was reordered into 11 rows per the user's measured feedback: **new "Audience"** (domain professionals / general tech-savvy public / management briefing / client demo / internal training — it decides term density and tone of voice, and previously hid inside "Topic and domain" without a row of its own); **new "Sensitive content and disclaimer"** (the disclaimer question expanded from "regulated domains only" to any hit of financial opinions / medical / legal / policy / negative-event reporting / personal information, default yes); "Asset boundaries" renamed to "**Images and asset boundaries**" (ask whether images are wanted at all first, and for negative-event subjects add the announcement/news-screenshot question); "Voice" annotated "a rough direction is enough"; row titles completed with the "theme color" / "aspect ratio" wording.
+- **Gate 3 became three questions** (once asset collection is done and both the facts and candidate images are in hand): ① pick "which ones to use" slide by slide; ② **"is there anything to add"** — links / screenshots / logos / pictures / data files / a reference voiceover script can all be handed to the agent, and user-provided assets have the highest priority (images pass `prep-image --check` and are registered in MANIFEST as "source: user-provided", links go to disk per paths A/B/D); ③ confirm sources and licensing. Previously Gate 3 had only a blanket confirmation, leaving the user no way in to pick slide by slide or to add assets.
+
+## 1.3.6 — 2026-09-18
+
+**Kickoff alignment's "asset boundaries" gained the negative-event question**
+
+- Hit in measurement: for a negative-news video the agent asked about topic / charts / audience but never "do you want news or media-platform screenshots as illustrations" — path D (1.3.4) had been written into the image SOP, but was never wired into the must-ask kickoff checklist, so without the question nothing was left but pure typography. The "asset boundaries" row now carries a note: **for negative-event subjects (collapse / penalty / lawsuit / controversy) one more question is mandatory — "do you want announcement or news-report screenshots as illustrations"** (default suggestion: yes, 1–2; source tiering and screenshot discipline in image-sources.md path D).
+
+## 1.3.5 — 2026-09-18
+
+**Iron rule 4 tightened: kickoff alignment must be "ask, then wait for the answer" — never a packaged announcement of defaults**
+
+- Hit in measurement: inside mcode the agent turned the must-ask checklist into "one message firing out a default block and then pressing on" — the message read 『默认项(不特别说的话就按这个):中文普通话 · 中文字幕 · 1920×1080 · 温润男声 · 10–12 张…』 ("Defaults (unless you say otherwise, these apply): Mandarin Chinese · Chinese subtitles · 1920×1080 · warm male voice · 10–12 slides…") — the user never got the chance to take a position on each item, which amounts to no alignment at all. The root cause was the old wording "(items the user does not answer may take the default, but you **must ask first**)", which left room to read it as "inform first; no objection means consent".
+- The rule now reads: the first response = **ask each question, one by one, in question form, then stop and wait for the answers**; each item may carry a recommended default, but it must be "ask, then wait"; only items the user explicitly answered "go with the defaults" or left unanswered take the default, and before moving to the next step you restate "which defaults were finally adopted". The verbatim counter-example from the field was written into SKILL.md as a prohibited case.
+
+## 1.3.4 — 2026-09-18
+
+**Image SOP gained "path D · announcement and authoritative-coverage screenshots" (negative-event subjects)**
+
+- For collapse / regulatory penalty / lawsuit / scandal subjects the company's official site can never supply the assets (paths A/B are guaranteed to fail) — and the SOP did not cover this real path. New path D: assets come from **the primary carrier of the event itself**, tiered as "exchange filings / regulatory documents > company official statements > media coverage"; **top-tier authorities (Xinhua / CCTV / The Paper) and mainstream portals (Tencent News / Sina / NetEase / Phoenix and the like) are all qualified sources**, and the only things excluded are self-media, aggregator accounts and content farms.
+- Screenshot discipline: the frame must keep the outlet name and date (a screenshot with the source cropped away is unusable), close the cookie banner first, capture the article body only; save to `assets/` + register in MANIFEST with "source URL / publication date / reasonable reporting-based citation"; bring it into the frame with `.img-frame.contain` + `.img-cap` naming the source; negative financial subjects are equally bound by compliance.md. A minimal Playwright screenshot command is included (wait for lazy loading, then capture the article/main body area — the built-in browser's screenshot works the same way).
+- SKILL.md's Phase 3 priority chain and compliance self-check were kept in sync; the subject index gained "collapse / regulatory penalty / lawsuit / scandal → path D".
+
+## 1.3.3 — 2026-09-18
+
+**Workflow: Gate 1's presentation format gained the "table and animation" column**
+
+- Measured feedback: the table the user reviewed at Gate 1 previously held only "layout / frame / narration", and slides using table primitives or carrying entrance animations were left unexplained — the user had to imagine them, saw them move for the first time only at Gate 4, and dissatisfaction meant rework at the most expensive step before rendering. Gate 1's presentation format is now "# / layout / frame / **table and animation** / verbatim voiceover script", and **the table-and-animation column is mandatory**: table slides must state their shape (how many rows and columns, which primitive, which row/column is highlighted); animation slides must name the effect and the order (staggered fade-in item by item / a rolling number / bars growing one after another).
+
+## 1.3.2 — 2026-09-18
+
+**Workflow: kickoff alignment gained the mandatory "data and charts" row**
+
+- Iron rule 4's must-ask kickoff checklist had no entry point for charts: the chart toolkit (merged during 1.1–1.2) hid only in the Phase 1/4 documents, so when the subject contained data the agent did not necessarily think to ask. A new row "**Data and charts**" was added: mandatory when the subject contains key numbers / comparisons / proportions / trends — ① is there any data worth a slide of its own as a visualisation; ② do you want chart slides, and roughly how many (default suggestion: hard data + a suitable layout → 1–2 slides); ③ style preference (horizontal bars / columns / donut / line / progress bar, or let the agent pick per the data shape); ④ numbers on the chart are equally bound by Gate 0 (≥2 independent sources, with scope and point in time). **Even if the user does not raise charts, data-dense subjects must be offered this option proactively**.
+- Phase 1's content-volume hard rules gained "**for data-dense subjects, proactively make charts**": when the voiceover contains ≥2 comparable numbers / proportions / trends, prefer the `data-viz` layout + chart toolkit over listing the numbers as bullets; bar heights / bar lengths are computed from the values (the nine disciplines).
+
+## 1.3.1 — 2026-09-18
+
+**Docs: which environment owns each research tool, and how open the toolset is**
+
+- The two-toolchain comparison table's **research row** was missing mcode's built-in browser (the mcode cell held only `web_search`/`web_fetch`, while the image row did list the built-in browser — the same table, two different standards): the mcode cell now adds "for SPA/JS-rendered pages, open them in the **built-in browser** to read the body text", and the other cell adds "for SPA pages, render the body text with the project's Playwright **headless browser**". The image row's "same as above" became an explicit statement too (this environment uses `fetch-official-images`, which ships its own Playwright headless rendering).
+- Phase 0's "how to get the body text" now marks each environment's owner: the **mcode built-in browser** (a host capability of the mcode environment) vs the **Playwright headless browser** (any environment); research.md's search-tool table and SPA row were kept in sync.
+- Made explicit that **the tools are open, the discipline is not**: if other search skills/plugins are installed on this machine, or any headless browser, use whichever one can pull the body text (try it once to confirm result quality before depending on it); source tiering, two-source cross-checking and scope labelling lose not one rule. research.md's tool table gained the row "any environment | other search skills/plugins installed on this machine".
+
+## 1.3.0 — 2026-09-18
+
+**Three-axis audit fixes (doc alignment / security / process): one high-severity SSRF + the vertical play page + a batch of process breaks**
+
+- **Security · the hexadecimal IPv4-mapped IPv6 form slipped past the egress block (high severity, penetrated in measurement)**: the WHATWG URL parser normalises `http://[::ffff:127.0.0.1]/`'s hostname to `::ffff:7f00:1` before it reaches the check, while the old mapped-address handling only stripped the prefix and the remaining hex groups matched no block rule — loopback / private networks / cloud metadata (`::ffff:a9fe:a9fe`) could all slip past `fetch-official-images`' SSRF line this way (the end-to-end measurement reached a local server). The address is now converted back to dotted IPv4 before the check; the public mapped form (`::ffff:808:808` = 8.8.8.8) is still allowed through. `fetch-policy` gained negative cases that go through the `new URL()` normalisation path.
+- **The play page's iframe forces relative resolution**: when a slide filename contains `:` (e.g. `javascript:alert(1).html`, legal on Linux/macOS), `iframe.src` was treated as a URL scheme and executed same-origin in the play page; a `./` prefix is now always added.
+- **Vertical (1080×1920) play page fixed**: the stage `#fit/#frame` dimensions, the scale divisor and the overview thumbnail ratio were hard-coded to 1920×1080, so the vertical Gate 4 picture was cut off by nearly half; they are now all parameterised from `script.json`'s canvas. authoring.md gained the vertical subtitle-safe-area numbers (≈150–215px from the bottom, ≈787px wide centred, starting from `padding-bottom: 240px`).
+- **A still re-capture silently swallowed the animations**: after capture invalidates that slide's frame directory on the still path (to prevent stale-frame contamination), build-video's fallback to a static image used to ship with no warning at all; it now names the slide with a ⚠ and gives the command to re-run (`capture --mode motion --ids `). render.md's "three-piece visual verification" and SKILL.md's Phase 5 were updated to state "after re-checking in still mode you must re-run motion for the same ids".
+- **The managed-block mechanism hardened**: ① content comparison is normalised to LF uniformly (a CRLF file checked out by git autocrlf used to false-positive all three blocks as "hand-edited"); ② the upgrade runs in two passes (replace everything in place by rev first, then re-assess the result and fill the gaps) — under a pathological layout, "nofx bare text nested inside an old chart block" no longer produces the self-inconsistency of "reported success last time, reported stale again this time"; ③ an existing `.bak` now warns explicitly before it is overwritten; ④ tokens.css / slide HTML over 2MB is refused a scan (crafted input makes managed-block location quadratic — 4MB measured at 39 seconds); ⑤ `--upgrade-css` ends its output by naming the re-run list for "generated preview/frames/out still show the old CSS".
+- **Process gaps filled (SKILL.md)**: the workflow gained the step "to resume an old project, run `--check-css` first" (including the Phase-detection markers and the post-upgrade re-run matrix); Phase 4's check sequence gained the conditional check-theme item (when tokens.css was changed, contrast problems no longer leak through to the most expensive Gate 5); Phase 6's delivery list gained `out/subs.srt` and `build/timings.json`; init-project's closing note had its Gate number corrected (final-state preview = Gate 4); Gate 2's acceptance artefacts no longer hard-code "8 audio segments"; check-slides 5c's `--check-css` pointer is scoped to the toolkit scenario.
+- **Doc alignment**: the plugin package's top-level THIRD-PARTY-NOTICES.md was resynced with the copy inside the skill (it had been left behind as an old version); the README's verification section now gives commands that hold in both repository contexts (monorepo / standalone skill repo); authoring.md's chart-discipline heading changed from "six" to "nine" and the order of items 8/9 was corrected; evals had 4 fixes (id24's animation wording updated to `fx-sweep`/`fx-grow-w`, id0's Gate number, renumbering after a gap, the missing `files` field filled in); SKILL.md's script table gained the flags that actually exist (`--topic`/`--ids`/`--pacing`/`--min`/`--json`/`--dsf`).
+- Tests **132 → 146** (fetch-policy +6, css-kit +4, preview-page +3, render-smoke +1), all green locally.
+
+## 1.2.0 — 2026-09-18
+
+**CSS toolkit managed blocks: the root fix for "you changed the CSS but the project does not pick it up"**
+
+- The three toolkits (`nofx-css.mjs` / `chart-css.mjs` / `table-css.mjs`) become **managed blocks carrying a content rev** in the project's `tokens.css` (`/* >>> html2video: rev= >>> */ … /* <<< html2video: <<< */`), with the rev hashing the block's CSS content — change the content and the rev changes.
+- `init-project --upgrade-css` was rewritten from "presence probe + append" to **replacement in place by rev**: an old / hand-edited block → swapped for the new one in its original position; undelimited but current content → wrapped in place (position unchanged); missing → appended at the end of the file with a note that the old rules will be overridden by what follows. Rules outside the managed blocks (including project-side overrides) are left untouched, and `tokens.css.bak` is backed up before writing. This removes two classes of defect from the old implementation: after one patch it always reported "no upgrade needed", and later source-module changes never propagated; and the append-style upgrade crushed the user's overrides while leaving a whole duplicate block behind.
+- New `init-project --check-css` (inspect only, never modify: outdated / missing items reported one by one, ending with exit code 1); `check-slides` gained two info-level checks: tokens.css toolkit staleness (naming which block), and a `class` that exists in neither `tokens.css` nor the slide's own `
+
+
+