diff --git a/dev-plans/lark-slides/svglide-parser-semantic-generation-plan.zh.md b/dev-plans/lark-slides/svglide-parser-semantic-generation-plan.zh.md new file mode 100644 index 000000000..5c6c02b35 --- /dev/null +++ b/dev-plans/lark-slides/svglide-parser-semantic-generation-plan.zh.md @@ -0,0 +1,1715 @@ +# SVGlide Parser Semantic Generation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Prevent browser-only or unreadable SVG decks from reaching Lark Slides by making generated SVG parser-legible by construction, enforcing readability safety before publish, repairing failed pages as page-level units, and making readback fidelity a hard online success gate. + +**Architecture:** Keep creative planning flexible, but make final SVG syntax deterministic and parser-facing. Agents produce page intent or use a small semantic renderer; validators reject browser SVG constructs and unreadable foreground overlap; repair context scripts isolate failures to page-level regeneration and hash guards prove non-failed pages did not change. `svg_slides_bundle.mjs` and `slides +create-svglide` become the only supported generated-deck publish path; online delivery must pass readback checks for key text and image preservation. + +**Tech Stack:** Node.js ESM scripts, existing `skills/lark-slides` SVG Slides references, Go `slides +create-svglide` manifest validation, existing CLI E2E tests. + +## Global Constraints + +- Final slide files must be raw SVG rooted at ``. +- Generated deck slides must not contain `` or ``; all user-visible text must be `foreignObject slide:role="shape" slide:shape-type="text"` with direct XHTML text children. +- Every visible geometric element must carry `slide:role="shape"` and a concrete `slide:shape-type`, except the single background element with `slide:role="background"`. +- Every content image must be ``; local image hrefs must be uploaded and rewritten by `slides +create-svglide`. +- Foreground text, chart values, table cells, labels, legends, captions, KPI numbers, and primary visual focal points must not be covered by other foreground objects unless an explicit overlap reason is present. +- Text placed over photos, collage walls, or strong texture must have a scrim, gradient mask, quiet safe zone, or equivalent contrast treatment. +- Generated decks must be bundled with `skills/lark-slides/scripts/svg_slides_bundle.mjs`; ad-hoc direct `slides xml_presentation.slide create` is not an accepted generated-deck publish path. +- Repair loops must use page-level replacement: only the failed `slides/slide_NN.svg` may change during a repair attempt, and final acceptance still requires full-deck validation. +- Online success requires readback evidence: expected text strings are present, expected images are present, local asset hrefs are absent, readability receipts pass, and page count matches. + +--- + +## File Structure + +- Modify `skills/lark-slides/scripts/validate_svg_deck.mjs`: strengthen parser-contract validation for shape roles, image roles, and browser-only SVG. +- Modify `skills/lark-slides/scripts/validate_svg_deck_test.mjs`: add regression tests that match the DeepSeek failure mode. +- Create `skills/lark-slides/scripts/svg_slides_semantic_renderer.mjs`: deterministic helper for parser-legible text, image, and shape SVG. +- Create `skills/lark-slides/scripts/svg_slides_semantic_renderer_test.mjs`: prove renderer output validates and never emits browser-only text. +- Modify `skills/lark-slides/prompt-sources/svg-slides/full.debranded.md`: source-of-truth prompt contract for parser semantics and readability safety. +- Regenerate `skills/lark-slides/references/svg-slides/system-prompt.md`: generated runtime reference after source extraction. +- Regenerate `skills/lark-slides/references/svg-slides/svg-reference.md`: generated SVG reference after source extraction. +- Modify `skills/lark-slides/references/lark-slides-create-svglide.md`: state that generated SVG decks must use bundle plus `slides +create-svglide`, not raw slide API calls. +- Modify `skills/lark-slides/scripts/svg_slides_bundle.mjs`: write a parser-contract receipt derived from `validate_svg_deck.json`. +- Create `skills/lark-slides/scripts/svg_slides_readability_safety.mjs`: static readability validator for overlap, text-over-image masking, and out-of-bounds failures. +- Create `skills/lark-slides/scripts/svg_slides_readability_safety_test.mjs`: test current milk-tea-style overlap failures and allowed collage/card cases. +- Create fixtures under `skills/lark-slides/scripts/testdata/readability/`: minimal decks for overlap, collage masking, containment, and decorative overlap. +- Modify `skills/lark-slides/references/svg-slides/validation/README.md`: document protocol validation, frame discipline, and readability safety as separate gates. +- Modify `shortcuts/slides/slides_create_svglide_manifest.go`: require all standard bundle receipts, not only `validate_svg_deck`. +- Modify `shortcuts/slides/slides_create_svglide_test.go` and `tests/cli_e2e/slides/slides_create_svglide_helpers_test.go`: update manifests and add missing-receipt failures. +- Create `skills/lark-slides/scripts/svg_slides_readback_gate.mjs`: compare local bundle expectations against online readback XML or a saved readback file. +- Create `skills/lark-slides/scripts/svg_slides_readback_gate_test.mjs`: prove text/image loss fails locally without real API calls. +- Create `skills/lark-slides/scripts/svg_slides_repair_context.mjs`: collect failed pages from validation receipts and emit one page-level repair context per failed SVG file. +- Create `skills/lark-slides/scripts/svg_slides_repair_hash_guard.mjs`: record pre-repair slide hashes and verify that only allowed failed pages changed. +- Create `skills/lark-slides/scripts/svg_slides_repair_loop_test.mjs`: prove failed-page context generation and hash guard behavior. + +--- + +### Task 1: Make Validator Reject Browser-Only SVG + +**Files:** +- Modify: `skills/lark-slides/scripts/validate_svg_deck.mjs` +- Modify: `skills/lark-slides/scripts/validate_svg_deck_test.mjs` + +**Interfaces:** +- Consumes: deck root or slides directory passed to `validate_svg_deck.mjs --json`. +- Produces: `validate_svg_deck.json` with `totalErrors=0` only when the deck uses parser-supported SVGlide semantics. + +- [ ] **Step 1: Add failing validator tests** + +Append this test block to `skills/lark-slides/scripts/validate_svg_deck_test.mjs`: + +```js +test("browser-only SVG constructs fail parser contract validation", () => { + const root = tempDeck(); + writeSlide(root, "slide_01.svg", ` + + + + + DeepSeek V4 + `); + + const result = runValidator(root); + assert.equal(result.status, 1); + const report = JSON.parse(result.stdout); + const rules = report.results.flatMap((item) => item.errors.map((error) => error.rule)); + assert.ok(rules.includes("shape.role")); + assert.ok(rules.includes("shape.type")); + assert.ok(rules.includes("image.role")); + assert.ok(rules.includes("image.shape-type")); + assert.ok(rules.includes("forbid.svg-text")); +}); +``` + +- [ ] **Step 2: Run the test and verify it fails** + +Run: + +```bash +node --test skills/lark-slides/scripts/validate_svg_deck_test.mjs +``` + +Expected: FAIL because `shape.role`, `shape.type`, `image.role`, and `image.shape-type` are not emitted by the current validator. + +- [ ] **Step 3: Extend parser-contract checks** + +Add these helpers after `attrValue()` in `skills/lark-slides/scripts/validate_svg_deck.mjs`: + +```js +function hasAttr(attrs, name, value) { + return new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}="${value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"`).test(attrs); +} + +function tagMatches(svg, tagName) { + return [...svg.matchAll(new RegExp(`<${tagName}\\b([^>]*)>`, "g"))]; +} +``` + +Add this block in `checkSlide()` after the existing line checks: + +```js + for (const tagName of ["rect", "circle", "ellipse", "path", "polygon", "polyline"]) { + for (const match of tagMatches(bodyNoDefs, tagName)) { + const attrs = match[1]; + if (tagName === "rect" && hasAttr(attrs, "slide:role", "background")) { + continue; + } + if (!hasAttr(attrs, "slide:role", "shape")) { + errors.push({ rule: "shape.role", severity: "error", message: `${tagName} must carry slide:role="shape" unless it is the background` }); + } + if (!/\bslide:shape-type="[^"]+"/.test(attrs)) { + errors.push({ rule: "shape.type", severity: "error", message: `${tagName} must carry slide:shape-type` }); + } + } + } + + for (const match of tagMatches(bodyNoDefs, "image")) { + const attrs = match[1]; + if (hasAttr(attrs, "slide:role", "background")) { + continue; + } + if (!hasAttr(attrs, "slide:role", "image")) { + errors.push({ rule: "image.role", severity: "error", message: 'content image must carry slide:role="image"' }); + } + if (!hasAttr(attrs, "slide:shape-type", "image")) { + errors.push({ rule: "image.shape-type", severity: "error", message: 'content image must carry slide:shape-type="image"' }); + } + } +``` + +- [ ] **Step 4: Verify validator tests pass** + +Run: + +```bash +node --test skills/lark-slides/scripts/validate_svg_deck_test.mjs +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add skills/lark-slides/scripts/validate_svg_deck.mjs skills/lark-slides/scripts/validate_svg_deck_test.mjs +git commit -m "test: reject browser-only svg slides" +``` + +--- + +### Task 2: Add a Deterministic SVGlide Semantic Renderer + +**Files:** +- Create: `skills/lark-slides/scripts/svg_slides_semantic_renderer.mjs` +- Create: `skills/lark-slides/scripts/svg_slides_semantic_renderer_test.mjs` + +**Interfaces:** +- Consumes: plain JS objects describing text, images, and shapes. +- Produces: SVG element strings that satisfy `validate_svg_deck.mjs`. + +- [ ] **Step 1: Write renderer tests** + +Create `skills/lark-slides/scripts/svg_slides_semantic_renderer_test.mjs`: + +```js +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; +import { background, image, rect, renderSlide, textBox } from "./svg_slides_semantic_renderer.mjs"; + +const validator = path.resolve("skills/lark-slides/scripts/validate_svg_deck.mjs"); + +test("semantic renderer emits parser-legible SVG", () => { + const deck = fs.mkdtempSync(path.join(os.tmpdir(), "semantic-renderer-")); + fs.mkdirSync(path.join(deck, "slides")); + const svg = renderSlide({ + id: "semantic-001", + children: [ + background("rgba(255,255,255,1)"), + rect({ id: "card", x: 64, y: 80, width: 420, height: 160, fill: "rgba(248,250,252,1)", rx: 16 }), + textBox({ id: "title", x: 88, y: 104, width: 360, height: 56, text: "DeepSeek V4", fontSize: 34, fontWeight: 800, color: "rgba(15,23,42,1)" }), + image({ id: "fig", href: "assets/fig.png", x: 560, y: 110, width: 280, height: 180 }), + ], + }); + fs.writeFileSync(path.join(deck, "slides", "slide_01.svg"), svg); + + assert.equal(svg.includes("]*slide:role="shape"[^>]*slide:shape-type="text"/); + assert.match(svg, /]*slide:role="image"[^>]*slide:shape-type="image"/); + + const result = spawnSync("node", [validator, deck, "--json"], { encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr || result.stdout); +}); +``` + +- [ ] **Step 2: Run the renderer test and verify it fails** + +Run: + +```bash +node --test skills/lark-slides/scripts/svg_slides_semantic_renderer_test.mjs +``` + +Expected: FAIL with module not found for `svg_slides_semantic_renderer.mjs`. + +- [ ] **Step 3: Implement renderer** + +Create `skills/lark-slides/scripts/svg_slides_semantic_renderer.mjs`: + +```js +function escapeText(value) { + return String(value).replace(/&/g, "&").replace(//g, ">"); +} + +function escapeAttr(value) { + return escapeText(value).replace(/"/g, """); +} + +function attrs(input) { + return Object.entries(input) + .filter(([, value]) => value !== undefined && value !== null && value !== "") + .map(([key, value]) => ` ${key}="${escapeAttr(value)}"`) + .join(""); +} + +export function background(fill = "rgba(255,255,255,1)") { + return ``; +} + +export function rect({ id, x, y, width, height, fill, stroke, strokeWidth, rx = 0, ry = rx, opacity }) { + return ``; +} + +export function circle({ id, cx, cy, r, fill, stroke, strokeWidth, opacity }) { + return ``; +} + +export function line({ id, x1, y1, x2, y2, stroke, strokeWidth = 1, opacity }) { + return ``; +} + +export function image({ id, href, x, y, width, height, opacity }) { + return ``; +} + +export function textBox({ + id, + x, + y, + width, + height, + text, + fontSize = 18, + fontFamily = "DM Sans,PingFang SC,Noto Sans SC,Arial,sans-serif", + fontWeight = 500, + color = "rgba(15,23,42,1)", + lineHeight = 1.25, + textAlign = "left", + verticalAlign = "top", +}) { + const style = [ + `font-size:${fontSize}px`, + `font-family:${fontFamily}`, + `color:${color}`, + `font-weight:${fontWeight}`, + `line-height:${lineHeight}`, + `text-align:${textAlign}`, + `vertical-align:${verticalAlign}`, + "letter-spacing:0px", + "padding:0px", + ].join(";"); + return `

${escapeText(text)}

`; +} + +export function renderSlide({ id, children }) { + if (!id) throw new Error("renderSlide requires id"); + if (!Array.isArray(children) || children.length === 0) throw new Error("renderSlide requires children"); + return `\n ${children.join("\n ")}\n\n`; +} +``` + +- [ ] **Step 4: Verify renderer tests pass** + +Run: + +```bash +node --test skills/lark-slides/scripts/svg_slides_semantic_renderer_test.mjs +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add skills/lark-slides/scripts/svg_slides_semantic_renderer.mjs skills/lark-slides/scripts/svg_slides_semantic_renderer_test.mjs +git commit -m "feat: add svglide semantic svg renderer" +``` + +--- + +### Task 3: Update Runtime Prompt Contract + +**Files:** +- Modify: `skills/lark-slides/prompt-sources/svg-slides/full.debranded.md` +- Regenerate: `skills/lark-slides/references/svg-slides/system-prompt.md` +- Regenerate: `skills/lark-slides/references/svg-slides/svg-reference.md` +- Modify: `skills/lark-slides/references/lark-slides-create-svglide.md` + +**Interfaces:** +- Consumes: source prompt `skills/lark-slides/prompt-sources/svg-slides/full.debranded.md`. +- Produces: regenerated runtime references that prevent local-browser SVG from being mistaken for publish-ready SVGlide SVG. + +- [ ] **Step 1: Patch prompt source with publish-semantics rule** + +In `skills/lark-slides/prompt-sources/svg-slides/full.debranded.md`, insert this section inside the `system_prompt_workflow` source area, immediately after the `` block: + +```markdown +## Publish Semantics Are Not Browser Semantics + +Final generated slide SVG must target the Lark Slides SVGlide parser, not a browser renderer. A local browser preview is only a visual smoke check and is not publish proof. + +Hard rules: +- Do not emit SVG `` or `` in final slide files. +- Emit text as `` with direct XHTML text children. +- Emit visible shapes with `slide:role="shape"` and `slide:shape-type`. +- Emit content images with `slide:role="image" slide:shape-type="image"`. +- Run `validate_svg_deck.mjs`, then `svg_slides_bundle.mjs`; do not publish generated decks by calling raw `xml_presentation.slide.create` directly. +``` + +- [ ] **Step 2: Patch prompt source SVG reference section with canonical bad/good examples** + +In the same source file, insert this section inside the `svg_reference` source area before the Text Shape examples: + +````markdown +### Parser Contract: Bad Browser SVG vs Good SVGlide SVG + +Bad final slide text: + +```xml +DeepSeek V4 +``` + +Good final slide text: + +```xml + +

DeepSeek V4

+
+``` + +Bad final slide image: + +```xml + +``` + +Good final slide image: + +```xml + +``` +```` + +- [ ] **Step 3: Patch `lark-slides-create-svglide.md` with publishing boundary** + +Add this paragraph under `What It Does Not Own`: + +```markdown +Generated SVG decks must not bypass this command by calling `slides xml_presentation.slide create` directly. Direct raw API calls can prove transport only; they do not prove that the generated deck passed the SVGlide parser contract, bundle receipts, asset rewrite, or readback gates. +``` + +- [ ] **Step 4: Run reference validation tests** + +Run: + +```bash +node skills/lark-slides/scripts/svg_slides_extract_references.mjs +node --test skills/lark-slides/scripts/svg_slides_extract_references_test.mjs +node --test skills/lark-slides/scripts/svg_slides_source_coverage_check_test.mjs +``` + +Expected: extraction completes and tests pass. `git diff -- skills/lark-slides/references/svg-slides/system-prompt.md skills/lark-slides/references/svg-slides/svg-reference.md` must show the generated reference updates. + +- [ ] **Step 5: Commit** + +```bash +git add skills/lark-slides/prompt-sources/svg-slides/full.debranded.md skills/lark-slides/references/svg-slides/system-prompt.md skills/lark-slides/references/svg-slides/svg-reference.md skills/lark-slides/references/lark-slides-create-svglide.md +git commit -m "docs: clarify svglide parser publish semantics" +``` + +--- + +### Task 4: Require Standard Bundle Receipts Before Publish + +**Files:** +- Modify: `skills/lark-slides/scripts/svg_slides_bundle.mjs` +- Modify: `shortcuts/slides/slides_create_svglide_manifest.go` +- Modify: `shortcuts/slides/slides_create_svglide_test.go` +- Modify: `tests/cli_e2e/slides/slides_create_svglide_helpers_test.go` + +**Interfaces:** +- Consumes: `manifest.json` generated by `svg_slides_bundle.mjs`. +- Produces: `slides +create-svglide` rejects hand-written manifests that include only `validate_svg_deck`. + +- [ ] **Step 1: Add parser contract receipt to bundle output** + +In `skills/lark-slides/scripts/svg_slides_bundle.mjs`, after writing `validate_svg_deck.json`, write a second receipt: + +```js +const parserContractReceipt = { + version: "svg-slides.parser-contract.v1", + ok: receipt.totalErrors === 0, + source: "validate_svg_deck.mjs", + totalErrors: receipt.totalErrors, +}; +fs.writeFileSync(path.join(root, "receipts", "parser_contract.json"), `${JSON.stringify(parserContractReceipt, null, 2)}\n`); +``` + +Add `parser_contract` to the manifest receipt map: + +```js +parser_contract: "receipts/parser_contract.json", +``` + +- [ ] **Step 2: Add Go receipt fields** + +Change `svglideManifestReceipts` in `shortcuts/slides/slides_create_svglide_manifest.go`: + +```go +type svglideManifestReceipts struct { + ValidateSVGDeck string `json:"validate_svg_deck"` + PromptReadTrace string `json:"prompt_read_trace"` + AssetReadiness string `json:"asset_readiness"` + TypographyPlan string `json:"typography_plan"` + FrameDiscipline string `json:"frame_discipline"` + ParserContract string `json:"parser_contract"` +} +``` + +Add this helper below `svglideValidateSVGDeckReceipt`: + +```go +type svglideZeroErrorReceipt struct { + TotalErrors *int `json:"totalErrors"` + OK *bool `json:"ok"` +} +``` + +Extend `validateSVGlideBundleReceipt` after the existing `validate_svg_deck` check: + +```go + required := map[string]string{ + "receipts.prompt_read_trace": receipts.PromptReadTrace, + "receipts.asset_readiness": receipts.AssetReadiness, + "receipts.typography_plan": receipts.TypographyPlan, + "receipts.frame_discipline": receipts.FrameDiscipline, + "receipts.parser_contract": receipts.ParserContract, + } + for label, rel := range required { + if strings.TrimSpace(rel) == "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--manifest %s is required", label).WithParam("--manifest") + } + joined, err := svglideManifestChildPath(label, baseDir, rel) + if err != nil { + return err + } + raw, err := readSVGlideManifestInput(runtime, joined, "--manifest %s receipt not found: %s", label, rel) + if err != nil { + return err + } + if label == "receipts.frame_discipline" || label == "receipts.parser_contract" { + var zero svglideZeroErrorReceipt + if err := json.Unmarshal(raw, &zero); err != nil { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--manifest %s receipt invalid JSON: %v", label, err).WithParam("--manifest").WithCause(err) + } + if zero.TotalErrors != nil && *zero.TotalErrors != 0 { + return errs.NewValidationError(errs.SubtypeFailedPrecondition, "--manifest %s receipt must have totalErrors=0", label).WithParam("--manifest") + } + if zero.OK != nil && !*zero.OK { + return errs.NewValidationError(errs.SubtypeFailedPrecondition, "--manifest %s receipt must have ok=true", label).WithParam("--manifest") + } + } + } +``` + +- [ ] **Step 3: Update tests and fixtures** + +In test helpers that build a manifest, add files: + +```json +{"ok":true,"totalErrors":0} +``` + +for: + +```text +receipts/prompt_read_trace.json +receipts/asset_readiness.json +receipts/typography_plan.json +receipts/frame_discipline.json +receipts/parser_contract.json +``` + +And add these manifest receipt keys: + +```json +"prompt_read_trace": "receipts/prompt_read_trace.json", +"asset_readiness": "receipts/asset_readiness.json", +"typography_plan": "receipts/typography_plan.json", +"frame_discipline": "receipts/frame_discipline.json", +"parser_contract": "receipts/parser_contract.json" +``` + +Add a negative manifest test: + +```go +{ + name: "missing parser contract receipt", + manifest: `{"version":"svglide.manifest.v1","protocol":"svg-slides.v1","title":"x","size":{"width":960,"height":540},"publish_ready":true,"published":false,"receipts":{"validate_svg_deck":"receipts/validate_svg_deck.json","prompt_read_trace":"receipts/prompt_read_trace.json","asset_readiness":"receipts/asset_readiness.json","typography_plan":"receipts/typography_plan.json","frame_discipline":"receipts/frame_discipline.json"},"pages":[{"id":"p1","index":1,"file":"page.svg","sha256":"e49b13606aa9da3e670d91549b1d3500b282a5484ce411326457e68fe672ce56"}]}`, + pageFiles: map[string]string{"page.svg": ``, "receipts/validate_svg_deck.json": `{"totalErrors":0}`, "receipts/prompt_read_trace.json": `{"ok":true}`, "receipts/asset_readiness.json": `{"ok":true}`, "receipts/typography_plan.json": `{"ok":true}`, "receipts/frame_discipline.json": `{"ok":true,"totalErrors":0}`}, + wantMessage: "--manifest receipts.parser_contract is required", +} +``` + +- [ ] **Step 4: Run Go tests** + +Run: + +```bash +go test ./shortcuts/slides -run SVGlide -count=1 +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add skills/lark-slides/scripts/svg_slides_bundle.mjs shortcuts/slides/slides_create_svglide_manifest.go shortcuts/slides/slides_create_svglide_test.go tests/cli_e2e/slides/slides_create_svglide_helpers_test.go +git commit -m "fix: require svglide bundle receipts before publish" +``` + +--- + +### Task 5: Add Readability Safety Gate + +**Files:** +- Create: `skills/lark-slides/scripts/svg_slides_readability_safety.mjs` +- Create: `skills/lark-slides/scripts/svg_slides_readability_safety_test.mjs` +- Create: `skills/lark-slides/scripts/testdata/readability/overlap-callout/slides/slide-001.svg` +- Create: `skills/lark-slides/scripts/testdata/readability/chart-value-covered/slides/slide-001.svg` +- Create: `skills/lark-slides/scripts/testdata/readability/collage-without-mask/slides/slide-001.svg` +- Create: `skills/lark-slides/scripts/testdata/readability/collage-with-mask/slides/slide-001.svg` +- Create: `skills/lark-slides/scripts/testdata/readability/container-card/slides/slide-001.svg` +- Create: `skills/lark-slides/scripts/testdata/readability/decorative-overlap/slides/slide-001.svg` +- Modify: `skills/lark-slides/scripts/svg_slides_bundle.mjs` +- Modify: `shortcuts/slides/slides_create_svglide_manifest.go` +- Modify: `shortcuts/slides/slides_create_svglide_test.go` +- Modify: `tests/cli_e2e/slides/slides_create_svglide_helpers_test.go` +- Modify: `skills/lark-slides/prompt-sources/svg-slides/full.debranded.md` +- Regenerate: `skills/lark-slides/references/svg-slides/system-prompt.md` +- Modify: `skills/lark-slides/references/svg-slides/validation/README.md` + +**Interfaces:** +- Consumes: parser-legible SVG slides after `validate_svg_deck.mjs`. +- Produces: `receipts/readability_safety.json` with `version="svg-slides.readability-safety.v1"`, `ok`, `totalErrors`, `totalWarnings`, and per-slide rule results. + +- [ ] **Step 1: Write readability failing and passing fixtures** + +Create these six fixture files with the exact contents below. + +`skills/lark-slides/scripts/testdata/readability/overlap-callout/slides/slide-001.svg`: + +```xml + + + +

Chart value: 42%

+
+ +

Callout covers the value

+
+
+``` + +`skills/lark-slides/scripts/testdata/readability/chart-value-covered/slides/slide-001.svg`: + +```xml + + + + +

¥12.4B

+
+ +

Explanation panel blocks the bar value label.

+
+
+``` + +`skills/lark-slides/scripts/testdata/readability/collage-without-mask/slides/slide-001.svg`: + +```xml + + + + +

Milk Tea Equity Map

+
+
+``` + +`skills/lark-slides/scripts/testdata/readability/collage-with-mask/slides/slide-001.svg`: + +```xml + + + + + +

Milk Tea Equity Map

+
+
+``` + +`skills/lark-slides/scripts/testdata/readability/container-card/slides/slide-001.svg`: + +```xml + + + + +

Card text inside its own card

+
+
+``` + +`skills/lark-slides/scripts/testdata/readability/decorative-overlap/slides/slide-001.svg`: + +```xml + + + + + +

Decoration may overlap decoration

+
+
+``` + +- [ ] **Step 2: Write readability tests** + +Create `skills/lark-slides/scripts/svg_slides_readability_safety_test.mjs`: + +```js +import assert from "node:assert/strict"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; + +const script = path.resolve("skills/lark-slides/scripts/svg_slides_readability_safety.mjs"); +const fixtureRoot = path.resolve("skills/lark-slides/scripts/testdata/readability"); + +function runFixture(name) { + return spawnSync("node", [script, path.join(fixtureRoot, name), "--json"], { encoding: "utf8" }); +} + +test("foreground overlap fails readability safety", () => { + const result = runFixture("overlap-callout"); + assert.equal(result.status, 1); + const report = JSON.parse(result.stdout); + assert.equal(report.ok, false); + assert.ok(report.results[0].errors.some((error) => error.rule === "readability.overlap")); +}); + +test("chart value covered by explanation block fails readability safety", () => { + const result = runFixture("chart-value-covered"); + assert.equal(result.status, 1); + const report = JSON.parse(result.stdout); + assert.ok(report.results[0].errors.some((error) => error.rule === "readability.overlap")); +}); + +test("collage text without mask fails and collage with mask passes", () => { + const bad = runFixture("collage-without-mask"); + assert.equal(bad.status, 1); + assert.ok(JSON.parse(bad.stdout).results[0].errors.some((error) => error.rule === "readability.text-image-without-mask")); + + const good = runFixture("collage-with-mask"); + assert.equal(good.status, 0, good.stderr || good.stdout); + assert.equal(JSON.parse(good.stdout).ok, true); +}); + +test("container card and decorative overlap pass", () => { + for (const name of ["container-card", "decorative-overlap"]) { + const result = runFixture(name); + assert.equal(result.status, 0, `${name}\n${result.stderr || result.stdout}`); + assert.equal(JSON.parse(result.stdout).ok, true); + } +}); +``` + +- [ ] **Step 3: Run the readability test and verify it fails** + +Run: + +```bash +node --test skills/lark-slides/scripts/svg_slides_readability_safety_test.mjs +``` + +Expected: FAIL with module not found for `svg_slides_readability_safety.mjs`. + +- [ ] **Step 4: Implement the deterministic readability validator** + +Create `skills/lark-slides/scripts/svg_slides_readability_safety.mjs` with this interface: + +```js +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; + +function attrValue(tag, name) { + return tag.match(new RegExp(`${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}="([^"]*)"`))?.[1] || ""; +} + +function numberAttr(tag, name, fallback = 0) { + const value = Number(attrValue(tag, name)); + return Number.isFinite(value) ? value : fallback; +} + +function area(box) { + return Math.max(0, box.width) * Math.max(0, box.height); +} + +function intersection(a, b) { + const x1 = Math.max(a.x, b.x); + const y1 = Math.max(a.y, b.y); + const x2 = Math.min(a.x + a.width, b.x + b.width); + const y2 = Math.min(a.y + a.height, b.y + b.height); + return { x: x1, y: y1, width: Math.max(0, x2 - x1), height: Math.max(0, y2 - y1) }; +} + +function isForeground(object) { + return object.layer === "content" || object.layer === "overlay" || object.tag === "foreignObject"; +} + +function isMask(object) { + return object.role === "shape" && object.layer === "mask"; +} + +function isDecoration(object) { + return object.layer === "decoration" || (object.opacity <= 0.18 && object.tag !== "foreignObject"); +} + +function isContainer(object) { + return object.container || ["card", "callout", "timeline_node", "chart_container", "table_container", "worksheet_area"].includes(object.frameRole); +} + +function containedByContainer(foreground, container) { + const center = { x: foreground.box.x + foreground.box.width / 2, y: foreground.box.y + foreground.box.height / 2 }; + const box = container.box; + const overlap = intersection(foreground.box, box); + return center.x >= box.x && center.x <= box.x + box.width && center.y >= box.y && center.y <= box.y + box.height && area(overlap) / Math.max(1, area(foreground.box)) >= 0.8; +} + +function parseObjects(svg) { + const objects = []; + for (const match of svg.matchAll(/<(rect|image|foreignObject|circle|ellipse|line|polyline|polygon)\b([^>]*)>/g)) { + const tag = match[1]; + const attrs = match[2]; + const role = attrValue(attrs, "slide:role"); + const layer = attrValue(attrs, "data-svglide-layer"); + const opacity = Number(attrValue(attrs, "opacity") || "1"); + const common = { + tag, + role, + layer, + frameRole: attrValue(attrs, "data-frame-role"), + container: attrValue(attrs, "data-svglide-container") === "true", + allowOverlap: attrValue(attrs, "data-svglide-allow-overlap") === "true", + overlapReason: attrValue(attrs, "data-svglide-overlap-reason"), + opacity: Number.isFinite(opacity) ? opacity : 1, + raw: match[0], + }; + if (tag === "circle") { + const cx = numberAttr(attrs, "cx"); + const cy = numberAttr(attrs, "cy"); + const r = numberAttr(attrs, "r"); + objects.push({ ...common, box: { x: cx - r, y: cy - r, width: r * 2, height: r * 2 } }); + } else { + objects.push({ ...common, box: { + x: numberAttr(attrs, "x", numberAttr(attrs, "x1")), + y: numberAttr(attrs, "y", numberAttr(attrs, "y1")), + width: numberAttr(attrs, "width", Math.abs(numberAttr(attrs, "x2") - numberAttr(attrs, "x1"))), + height: numberAttr(attrs, "height", Math.abs(numberAttr(attrs, "y2") - numberAttr(attrs, "y1"))), + } }); + } + } + return objects.filter((object) => object.role !== "background"); +} + +function checkSlide(file) { + const svg = fs.readFileSync(file, "utf8"); + const objects = parseObjects(svg); + const errors = []; + const masks = objects.filter(isMask); + for (let i = 0; i < objects.length; i += 1) { + for (let j = i + 1; j < objects.length; j += 1) { + const a = objects[i]; + const b = objects[j]; + if (isDecoration(a) || isDecoration(b) || isMask(a) || isMask(b)) continue; + if (isContainer(a) && containedByContainer(b, a)) continue; + if (isContainer(b) && containedByContainer(a, b)) continue; + const overlap = intersection(a.box, b.box); + const overlapArea = area(overlap); + if (overlapArea <= 48 || overlapArea / Math.max(1, Math.min(area(a.box), area(b.box))) <= 0.03) continue; + if ((a.allowOverlap && a.overlapReason) || (b.allowOverlap && b.overlapReason)) continue; + if (isForeground(a) || isForeground(b)) { + errors.push({ rule: "readability.overlap", severity: "error", message: "foreground objects overlap without containment or reason", a: a.box, b: b.box }); + } + } + } + for (const text of objects.filter((object) => object.tag === "foreignObject")) { + for (const image of objects.filter((object) => object.tag === "image" && object.role === "image")) { + const overlap = intersection(text.box, image.box); + if (area(overlap) <= 48) continue; + const coveredByMask = masks.some((mask) => area(intersection(text.box, mask.box)) / Math.max(1, area(text.box)) >= 0.8); + if (!coveredByMask) { + errors.push({ rule: "readability.text-image-without-mask", severity: "error", message: "text overlaps image without scrim or mask", text: text.box, image: image.box }); + } + } + } + for (const object of objects.filter(isForeground)) { + if (object.box.x < 0 || object.box.y < 0 || object.box.x + object.box.width > 960 || object.box.y + object.box.height > 540) { + errors.push({ rule: "readability.out-of-bounds", severity: "error", message: "foreground object is outside 960x540 canvas", box: object.box }); + } + if (object.allowOverlap && !object.overlapReason) { + errors.push({ rule: "readability.missing-overlap-reason", severity: "error", message: "allowed overlap requires data-svglide-overlap-reason", box: object.box }); + } + } + return { file: path.relative(process.cwd(), file), errors, warnings: [] }; +} + +const args = process.argv.slice(2); +const targetArg = args.find((arg) => !arg.startsWith("--")); +if (!targetArg) { + console.error("Usage: node skills/lark-slides/scripts/svg_slides_readability_safety.mjs [--json]"); + process.exit(2); +} +const target = path.resolve(targetArg); +const slidesDir = fs.existsSync(path.join(target, "slides")) ? path.join(target, "slides") : target; +const files = fs.readdirSync(slidesDir).filter((file) => file.endsWith(".svg")).sort().map((file) => path.join(slidesDir, file)); +const results = files.map(checkSlide); +const totalErrors = results.reduce((sum, result) => sum + result.errors.length, 0); +const report = { version: "svg-slides.readability-safety.v1", ok: totalErrors === 0, totalErrors, totalWarnings: 0, results }; +console.log(JSON.stringify(report, null, 2)); +process.exit(report.ok ? 0 : 1); +``` + +- [ ] **Step 5: Wire readability into bundle receipts** + +In `skills/lark-slides/scripts/svg_slides_bundle.mjs`, run readability after `validate_svg_deck` and before `frame_discipline`: + +```js +const readabilityValidator = path.resolve("skills/lark-slides/scripts/svg_slides_readability_safety.mjs"); +const validateReadability = spawnSync("node", [readabilityValidator, root, "--json"], { encoding: "utf8" }); +if (validateReadability.stdout.trim()) { + fs.writeFileSync(path.join(root, "receipts", "readability_safety.json"), `${validateReadability.stdout.trim()}\n`); +} +if (validateReadability.status !== 0) { + process.stderr.write(validateReadability.stderr || validateReadability.stdout); + process.exit(validateReadability.status || 1); +} +``` + +Add this manifest receipt: + +```js +readability_safety: "receipts/readability_safety.json", +``` + +Update `skills/lark-slides/scripts/svg_slides_bundle_test.mjs` in the existing success tests. In both `"bundle builder writes manifest and validation receipt"` and `"bundle builder accepts chart deck with deck and chart prompt reads"`, add: + +```js +assert.equal(manifest.receipts.readability_safety, "receipts/readability_safety.json"); +assert.equal(fs.existsSync(path.join(root, "receipts", "readability_safety.json")), true); +assert.equal(JSON.parse(fs.readFileSync(path.join(root, "receipts", "readability_safety.json"), "utf8")).totalErrors, 0); +``` + +Add this negative bundle test: + +```js +test("bundle builder rejects readability safety failures", () => { + const root = tempDeck(); + const refs = tempRoot("svg-slides-bundle-refs-"); + writeDeckReferenceSet(refs); + fs.writeFileSync(path.join(root, "slides", "slide_01.svg"), validSlide.replace( + "", + `

Overlapping overlay

` + )); + createPromptTrace(root, refs); + writeAssetReadiness(root); + writeTypographyPlan(root); + const result = spawnSync("node", [script, root, "--title", "Readability Fail", "--references", refs], { encoding: "utf8" }); + assert.equal(result.status, 1); + assert.match(result.stderr || result.stdout, /readability\.overlap/); + assert.equal(fs.existsSync(path.join(root, "receipts", "readability_safety.json")), true); + assert.equal(fs.existsSync(path.join(root, "manifest.json")), false); +}); +``` + +- [ ] **Step 6: Enforce readability receipt in Go manifest guard** + +Add this field to `svglideManifestReceipts`: + +```go +ReadabilitySafety string `json:"readability_safety"` +``` + +Add it to the `required` map: + +```go +"receipts.readability_safety": receipts.ReadabilitySafety, +``` + +Include `receipts.readability_safety` in the zero-error receipt validation branch: + +```go +if label == "receipts.frame_discipline" || label == "receipts.parser_contract" || label == "receipts.readability_safety" { +``` + +Update every SVGlide manifest fixture in `shortcuts/slides/slides_create_svglide_test.go` and `tests/cli_e2e/slides/slides_create_svglide_helpers_test.go` that is expected to pass: + +```json +"readability_safety": "receipts/readability_safety.json" +``` + +and add this receipt file: + +```json +{"ok":true,"totalErrors":0} +``` + +Add this negative manifest case beside the parser-contract missing case: + +```go +{ + name: "readability safety receipt has errors", + manifest: `{"version":"svglide.manifest.v1","protocol":"svg-slides.v1","title":"x","size":{"width":960,"height":540},"publish_ready":true,"published":false,"receipts":{"validate_svg_deck":"receipts/validate_svg_deck.json","prompt_read_trace":"receipts/prompt_read_trace.json","asset_readiness":"receipts/asset_readiness.json","typography_plan":"receipts/typography_plan.json","frame_discipline":"receipts/frame_discipline.json","parser_contract":"receipts/parser_contract.json","readability_safety":"receipts/readability_safety.json"},"pages":[{"id":"p1","index":1,"file":"page.svg","sha256":"e49b13606aa9da3e670d91549b1d3500b282a5484ce411326457e68fe672ce56"}]}`, + pageFiles: map[string]string{ + "page.svg": ``, + "receipts/validate_svg_deck.json": `{"totalErrors":0}`, + "receipts/prompt_read_trace.json": `{"ok":true}`, + "receipts/asset_readiness.json": `{"ok":true}`, + "receipts/typography_plan.json": `{"ok":true}`, + "receipts/frame_discipline.json": `{"ok":true,"totalErrors":0}`, + "receipts/parser_contract.json": `{"ok":true,"totalErrors":0}`, + "receipts/readability_safety.json": `{"ok":false,"totalErrors":1}`, + }, + wantMessage: "--manifest receipts.readability_safety receipt must have totalErrors=0", +} +``` + +- [ ] **Step 7: Update prompt source and validation docs** + +Add this contract to `skills/lark-slides/prompt-sources/svg-slides/full.debranded.md` inside the `system_prompt_workflow` section near visual composition rules: + +```markdown +### Readability Safety Contract + +Compose every slide freely from the content. Do not use a fixed page-pattern menu. However, free composition must never break readability. + +Foreground text, chart values, table cells, labels, legends, captions, KPI numbers, and primary visual focal points must not be covered by callouts, cards, images, decorative shapes, or other foreground objects. + +Photo walls, collage covers, textured backgrounds, and image-heavy heroes are allowed. When text sits over them, add a text scrim, gradient mask, quiet safe zone, or equivalent contrast treatment so the text remains readable and visually intentional. + +If an overlap is intentional, annotate it with `data-svglide-allow-overlap="true"` and a concrete reason such as `data-svglide-overlap-reason="decorative staff lines pass behind the title"`. Use this only for genuine design layering, not to hide layout mistakes. + +Scrims and masks must remain parser-legible shapes: use `slide:role="shape" slide:shape-type="rect" data-svglide-layer="mask"`, not a new `slide:role` value. +``` + +Update `skills/lark-slides/references/svg-slides/validation/README.md` with three distinct gates: + +```markdown +1. `validate_svg_deck`: protocol legality and parser-supported SVG semantics. +2. `svg_slides_frame_discipline`: text frame and container discipline. +3. `svg_slides_readability_safety`: foreground overlap, out-of-bounds content, and missing text-over-image masking. +``` + +Then regenerate runtime references: + +```bash +node skills/lark-slides/scripts/svg_slides_extract_references.mjs +``` + +- [ ] **Step 8: Verify readability tests and bundle tests** + +Run: + +```bash +node --test skills/lark-slides/scripts/svg_slides_readability_safety_test.mjs +node --test skills/lark-slides/scripts/svg_slides_bundle_test.mjs +go test ./shortcuts/slides -run SVGlide -count=1 +``` + +Expected: PASS. + +- [ ] **Step 9: Commit** + +```bash +git add skills/lark-slides/scripts/svg_slides_readability_safety.mjs skills/lark-slides/scripts/svg_slides_readability_safety_test.mjs skills/lark-slides/scripts/testdata/readability skills/lark-slides/scripts/svg_slides_bundle.mjs skills/lark-slides/scripts/svg_slides_bundle_test.mjs shortcuts/slides/slides_create_svglide_manifest.go shortcuts/slides/slides_create_svglide_test.go tests/cli_e2e/slides/slides_create_svglide_helpers_test.go skills/lark-slides/prompt-sources/svg-slides/full.debranded.md skills/lark-slides/references/svg-slides/system-prompt.md skills/lark-slides/references/svg-slides/validation/README.md +git commit -m "feat: enforce svg slides readability safety" +``` + +--- + +### Task 6: Add Online Readback Fidelity Gate + +**Files:** +- Create: `skills/lark-slides/scripts/svg_slides_readback_gate.mjs` +- Create: `skills/lark-slides/scripts/svg_slides_readback_gate_test.mjs` +- Modify: `skills/lark-slides/references/lark-slides-create-svglide.md` + +**Interfaces:** +- Consumes: local deck directory and either `--readback-file ` or `--presentation-id `. +- Produces: JSON receipt with `ok`, `totalErrors`, missing text, image count, and local href leakage. + +- [ ] **Step 1: Write readback gate tests** + +Create `skills/lark-slides/scripts/svg_slides_readback_gate_test.mjs`: + +```js +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; + +const script = path.resolve("skills/lark-slides/scripts/svg_slides_readback_gate.mjs"); + +function tempDeck() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "readback-gate-")); + fs.mkdirSync(path.join(root, "slides")); + return root; +} + +function writeLocalSlide(root) { + fs.writeFileSync(path.join(root, "slides", "slide_01.svg"), ` + +

DeepSeek V4

+ +
`); +} + +test("readback gate fails when online content lost text and images", () => { + const root = tempDeck(); + writeLocalSlide(root); + const readback = path.join(root, "readback.xml"); + fs.writeFileSync(readback, ``); + + const result = spawnSync("node", [script, root, "--readback-file", readback, "--json"], { encoding: "utf8" }); + assert.equal(result.status, 1); + const report = JSON.parse(result.stdout); + assert.equal(report.ok, false); + assert.deepEqual(report.missing_text, ["DeepSeek V4"]); + assert.equal(report.expected_images, 1); + assert.equal(report.readback_images, 0); +}); + +test("readback gate passes when text and image are preserved", () => { + const root = tempDeck(); + writeLocalSlide(root); + const readback = path.join(root, "readback.xml"); + fs.writeFileSync(readback, `DeepSeek V4`); + + const result = spawnSync("node", [script, root, "--readback-file", readback, "--json"], { encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr || result.stdout); + const report = JSON.parse(result.stdout); + assert.equal(report.ok, true); +}); +``` + +- [ ] **Step 2: Run the test and verify it fails** + +Run: + +```bash +node --test skills/lark-slides/scripts/svg_slides_readback_gate_test.mjs +``` + +Expected: FAIL with module not found. + +- [ ] **Step 3: Implement readback gate** + +Create `skills/lark-slides/scripts/svg_slides_readback_gate.mjs`: + +```js +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; + +function argValue(argv, name) { + const index = argv.indexOf(name); + return index >= 0 ? argv[index + 1] : ""; +} + +function stripTags(value) { + return value.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim(); +} + +function collectLocalText(svg) { + return [...svg.matchAll(/]*slide:shape-type="text"[^>]*>([\s\S]*?)<\/foreignObject>/g)] + .map((match) => stripTags(match[1])) + .filter((text) => text.length >= 3); +} + +function collectLocalSlides(root) { + const slidesDir = fs.existsSync(path.join(root, "slides")) ? path.join(root, "slides") : root; + return fs.readdirSync(slidesDir) + .filter((file) => file.endsWith(".svg")) + .sort() + .map((file) => fs.readFileSync(path.join(slidesDir, file), "utf8")); +} + +function readOnlineContent(argv) { + const readbackFile = argValue(argv, "--readback-file"); + if (readbackFile) { + return fs.readFileSync(path.resolve(readbackFile), "utf8"); + } + const presentationId = argValue(argv, "--presentation-id"); + if (!presentationId) { + throw new Error("Usage: node svg_slides_readback_gate.mjs --readback-file [--json]"); + } + const result = spawnSync("lark-cli", ["slides", "xml_presentations", "get", "--as", "user", "--xml-presentation-id", presentationId, "--json"], { encoding: "utf8" }); + if (result.status !== 0) throw new Error(result.stderr || result.stdout); + const parsed = JSON.parse(result.stdout); + return parsed.data?.xml_presentation?.content || parsed.xml_presentation?.content || ""; +} + +const argv = process.argv.slice(2); +const rootArg = argv.find((arg) => !arg.startsWith("--")); +if (!rootArg) { + console.error("Usage: node svg_slides_readback_gate.mjs --readback-file [--json]"); + process.exit(2); +} + +const root = path.resolve(rootArg); +const localSlides = collectLocalSlides(root); +const localText = [...new Set(localSlides.flatMap(collectLocalText))]; +const expectedImages = localSlides.reduce((sum, svg) => sum + (svg.match(/]*slide:role="image"/g) || []).length, 0); +const content = readOnlineContent(argv); +const missingText = localText.filter((text) => !content.includes(text)); +const readbackImages = (content.match(/ --presentation-id --json > receipts/online_readback_gate.json +``` + +The gate must report `ok: true`. A successful `slide.create` transport receipt without this gate is not sufficient evidence that text and images survived parser conversion. +```` + +- [ ] **Step 5: Verify readback gate tests pass** + +Run: + +```bash +node --test skills/lark-slides/scripts/svg_slides_readback_gate_test.mjs +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add skills/lark-slides/scripts/svg_slides_readback_gate.mjs skills/lark-slides/scripts/svg_slides_readback_gate_test.mjs skills/lark-slides/references/lark-slides-create-svglide.md +git commit -m "feat: add svglide online readback gate" +``` + +--- + +### Task 7: Add Single-Page Repair Loop + +**Files:** +- Create: `skills/lark-slides/scripts/svg_slides_repair_context.mjs` +- Create: `skills/lark-slides/scripts/svg_slides_repair_hash_guard.mjs` +- Create: `skills/lark-slides/scripts/svg_slides_repair_loop_test.mjs` +- Modify: `skills/lark-slides/references/lark-slides-create-svglide.md` + +**Interfaces:** +- Consumes: a deck root, validation receipts under `receipts/`, and the original `slides/*.svg` files. +- Produces: `repairs/repair_context.json`, `repairs/.context.json`, and `receipts/repair_hashes.before.json`. +- Guarantees: repair attempts may replace failed slide files only; all non-failed slide hashes must remain unchanged before full-deck validation resumes. + +- [ ] **Step 1: Write the single-page repair loop test** + +Create `skills/lark-slides/scripts/svg_slides_repair_loop_test.mjs`: + +```js +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; + +const contextScript = path.resolve("skills/lark-slides/scripts/svg_slides_repair_context.mjs"); +const hashGuardScript = path.resolve("skills/lark-slides/scripts/svg_slides_repair_hash_guard.mjs"); + +function tempDeck() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "svg-slides-repair-loop-")); + fs.mkdirSync(path.join(root, "slides"), { recursive: true }); + fs.mkdirSync(path.join(root, "receipts"), { recursive: true }); + return root; +} + +function slide(title, extra = "") { + return ` + + +

${title}

+
+ ${extra} +
`; +} + +function writeReceipts(root) { + fs.writeFileSync(path.join(root, "receipts", "validate_svg_deck.json"), `${JSON.stringify({ + target: root, + slideCount: 2, + totalErrors: 0, + results: [ + { file: "slides/slide_01.svg", errorCount: 0, errors: [] }, + { file: "slides/slide_02.svg", errorCount: 0, errors: [] } + ] + }, null, 2)}\n`); + fs.writeFileSync(path.join(root, "receipts", "frame_discipline.json"), `${JSON.stringify({ + ok: true, + totalErrors: 0, + results: [ + { file: "slides/slide_01.svg", errorCount: 0, errors: [] }, + { file: "slides/slide_02.svg", errorCount: 0, errors: [] } + ] + }, null, 2)}\n`); + fs.writeFileSync(path.join(root, "receipts", "readability_safety.json"), `${JSON.stringify({ + version: "svg-slides.readability-safety.v1", + ok: false, + totalErrors: 1, + results: [ + { file: "slides/slide_01.svg", errors: [], warnings: [] }, + { + file: "slides/slide_02.svg", + errors: [ + { + rule: "readability.overlap", + severity: "error", + message: "foreground objects overlap without containment or reason", + a: { x: 80, y: 80, width: 620, height: 80 }, + b: { x: 96, y: 92, width: 580, height: 70 } + } + ], + warnings: [] + } + ] + }, null, 2)}\n`); + fs.writeFileSync(path.join(root, "receipts", "asset_readiness.json"), `${JSON.stringify({ + version: "svg-slides.asset-readiness.v1", + assets: [ + { + slide: "slides/slide_02.svg", + purpose: "repair visual", + status: "ready", + local_path: "resources/images/repair.png" + } + ], + slides: [ + { file: "slides/slide_01.svg", slide_role: "section" }, + { file: "slides/slide_02.svg", slide_role: "analysis", layout_family: "text-with-callout" } + ] + }, null, 2)}\n`); +} + +test("repair context isolates the failed slide and keeps original SVG", () => { + const root = tempDeck(); + fs.writeFileSync(path.join(root, "slides", "slide_01.svg"), slide("Stable Page")); + fs.writeFileSync(path.join(root, "slides", "slide_02.svg"), slide("Broken Page", ``)); + writeReceipts(root); + + const result = spawnSync("node", [contextScript, root, "--out", path.join(root, "repairs"), "--json"], { encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr || result.stdout); + const report = JSON.parse(result.stdout); + assert.equal(report.ok, true); + assert.deepEqual(report.failed_pages, ["slides/slide_02.svg"]); + + const contextPath = path.join(root, "repairs", "slide_02.context.json"); + assert.equal(fs.existsSync(contextPath), true); + const context = JSON.parse(fs.readFileSync(contextPath, "utf8")); + assert.equal(context.slide_file, "slides/slide_02.svg"); + assert.equal(context.allowed_output_file, "slides/slide_02.svg"); + assert.equal(context.errors[0].rule, "readability.overlap"); + assert.match(context.original_svg, /Broken Page/); + assert.equal(context.available_assets.length, 1); +}); + +test("hash guard allows only the failed page to change during repair", () => { + const root = tempDeck(); + const slide1 = path.join(root, "slides", "slide_01.svg"); + const slide2 = path.join(root, "slides", "slide_02.svg"); + fs.writeFileSync(slide1, slide("Stable Page")); + fs.writeFileSync(slide2, slide("Broken Page")); + + const baseline = path.join(root, "receipts", "repair_hashes.before.json"); + const write = spawnSync("node", [hashGuardScript, root, "--write", baseline, "--json"], { encoding: "utf8" }); + assert.equal(write.status, 0, write.stderr || write.stdout); + + fs.writeFileSync(slide2, slide("Repaired Page")); + const allowed = spawnSync("node", [hashGuardScript, root, "--baseline", baseline, "--allowed", "slides/slide_02.svg", "--json"], { encoding: "utf8" }); + assert.equal(allowed.status, 0, allowed.stderr || allowed.stdout); + assert.deepEqual(JSON.parse(allowed.stdout).changed_files, ["slides/slide_02.svg"]); + + fs.writeFileSync(slide1, slide("Unexpected Change")); + const rejected = spawnSync("node", [hashGuardScript, root, "--baseline", baseline, "--allowed", "slides/slide_02.svg", "--json"], { encoding: "utf8" }); + assert.equal(rejected.status, 1); + const report = JSON.parse(rejected.stdout); + assert.equal(report.ok, false); + assert.deepEqual(report.unexpected_changed_files, ["slides/slide_01.svg"]); +}); +``` + +- [ ] **Step 2: Run the repair loop test and verify it fails** + +Run: + +```bash +node --test skills/lark-slides/scripts/svg_slides_repair_loop_test.mjs +``` + +Expected: FAIL with module not found for `svg_slides_repair_context.mjs` or `svg_slides_repair_hash_guard.mjs`. + +- [ ] **Step 3: Implement failed-page repair context generation** + +Create `skills/lark-slides/scripts/svg_slides_repair_context.mjs`: + +```js +#!/usr/bin/env node +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; + +function argValue(argv, name, fallback = "") { + const index = argv.indexOf(name); + return index >= 0 ? argv[index + 1] : fallback; +} + +function rel(root, file) { + return path.relative(root, file).split(path.sep).join("/"); +} + +function sha256(raw) { + return crypto.createHash("sha256").update(raw).digest("hex"); +} + +function readJsonIfExists(file) { + if (!fs.existsSync(file)) return null; + return JSON.parse(fs.readFileSync(file, "utf8")); +} + +function normalizeReceiptErrors(receiptPath, report) { + const receipt = path.basename(receiptPath); + const failures = []; + for (const result of Array.isArray(report?.results) ? report.results : []) { + const file = String(result.file || "").split(path.sep).join("/"); + if (!file) continue; + const errors = Array.isArray(result.errors) ? result.errors.filter((error) => error?.severity !== "warn") : []; + if (errors.length) { + failures.push(...errors.map((error) => ({ ...error, receipt }))); + } + } + return failures; +} + +function collectFailures(root) { + const receiptFiles = [ + "receipts/validate_svg_deck.json", + "receipts/frame_discipline.json", + "receipts/readability_safety.json" + ]; + const byFile = new Map(); + for (const receiptRel of receiptFiles) { + const receiptPath = path.join(root, receiptRel); + const report = readJsonIfExists(receiptPath); + if (!report) continue; + for (const result of Array.isArray(report.results) ? report.results : []) { + const file = String(result.file || "").split(path.sep).join("/"); + if (!file) continue; + const errors = normalizeReceiptErrors(receiptPath, { results: [result] }); + if (!errors.length) continue; + byFile.set(file, [...(byFile.get(file) || []), ...errors]); + } + } + return byFile; +} + +function collectAssets(root, slideFile) { + const receipt = readJsonIfExists(path.join(root, "receipts", "asset_readiness.json")) || {}; + const assets = Array.isArray(receipt.assets) ? receipt.assets : []; + return assets.filter((asset) => String(asset.slide || "").split(path.sep).join("/") === slideFile); +} + +function collectSlideMetadata(root, slideFile) { + const receipt = readJsonIfExists(path.join(root, "receipts", "asset_readiness.json")) || {}; + const slides = Array.isArray(receipt.slides) ? receipt.slides : []; + return slides.find((slide) => String(slide.file || "").split(path.sep).join("/") === slideFile) || {}; +} + +function writeContext(root, outDir, slideFile, errors) { + const abs = path.join(root, slideFile); + const original = fs.readFileSync(abs, "utf8"); + const base = path.basename(slideFile, ".svg"); + const context = { + version: "svg-slides.repair-context.v1", + slide_file: slideFile, + allowed_output_file: slideFile, + slide_sha256: sha256(original), + original_svg: original, + errors, + slide_metadata: collectSlideMetadata(root, slideFile), + available_assets: collectAssets(root, slideFile), + repair_instructions: [ + "Return one complete replacement SVG for allowed_output_file only.", + "Do not edit, reorder, add, or delete any other slide file.", + "Keep the root SVG at viewBox 0 0 960 540 with slide:role=\"slide\".", + "Use foreignObject slide:role=\"shape\" slide:shape-type=\"text\" for all visible text.", + "Use slide:role=\"image\" slide:shape-type=\"image\" for content images.", + "Fix the listed validation errors without hiding the problem behind data-svglide-allow-overlap." + ] + }; + const contextPath = path.join(outDir, `${base}.context.json`); + fs.writeFileSync(contextPath, `${JSON.stringify(context, null, 2)}\n`); + return { slide_file: slideFile, context_file: rel(root, contextPath), error_count: errors.length }; +} + +const argv = process.argv.slice(2); +const rootArg = argv.find((arg) => !arg.startsWith("--")); +if (!rootArg) { + console.error("Usage: node skills/lark-slides/scripts/svg_slides_repair_context.mjs --out [--json]"); + process.exit(2); +} + +const root = path.resolve(rootArg); +const outDir = path.resolve(argValue(argv, "--out", path.join(root, "repairs"))); +fs.mkdirSync(outDir, { recursive: true }); +const failures = collectFailures(root); +const contexts = [...failures.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([slideFile, errors]) => writeContext(root, outDir, slideFile, errors)); +const report = { + version: "svg-slides.repair-context-index.v1", + ok: true, + failed_pages: contexts.map((item) => item.slide_file), + contexts +}; +fs.writeFileSync(path.join(outDir, "repair_context.json"), `${JSON.stringify(report, null, 2)}\n`); +console.log(JSON.stringify(report, null, 2)); +``` + +- [ ] **Step 4: Implement page hash guard** + +Create `skills/lark-slides/scripts/svg_slides_repair_hash_guard.mjs`: + +```js +#!/usr/bin/env node +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; + +function argValue(argv, name, fallback = "") { + const index = argv.indexOf(name); + return index >= 0 ? argv[index + 1] : fallback; +} + +function argValues(argv, name) { + const values = []; + for (let i = 0; i < argv.length; i += 1) { + if (argv[i] === name && argv[i + 1]) values.push(...argv[i + 1].split(",").map((item) => item.trim()).filter(Boolean)); + } + return values; +} + +function sha256(raw) { + return crypto.createHash("sha256").update(raw).digest("hex"); +} + +function collectHashes(root) { + const slidesDir = fs.existsSync(path.join(root, "slides")) ? path.join(root, "slides") : root; + const hashes = {}; + for (const file of fs.readdirSync(slidesDir).filter((item) => item.endsWith(".svg")).sort()) { + const abs = path.join(slidesDir, file); + const rel = path.relative(root, abs).split(path.sep).join("/"); + hashes[rel] = sha256(fs.readFileSync(abs)); + } + return hashes; +} + +function compareHashes(before, after, allowed) { + const beforeFiles = Object.keys(before).sort(); + const afterFiles = Object.keys(after).sort(); + const changed = beforeFiles.filter((file) => after[file] && before[file] !== after[file]); + const missing = beforeFiles.filter((file) => !after[file]); + const added = afterFiles.filter((file) => !before[file]); + const allowedSet = new Set(allowed); + const unexpectedChanged = changed.filter((file) => !allowedSet.has(file)); + return { + changed_files: changed, + unexpected_changed_files: unexpectedChanged, + missing_files: missing, + added_files: added, + ok: unexpectedChanged.length === 0 && missing.length === 0 && added.length === 0 + }; +} + +const argv = process.argv.slice(2); +const rootArg = argv.find((arg) => !arg.startsWith("--")); +if (!rootArg) { + console.error("Usage: node skills/lark-slides/scripts/svg_slides_repair_hash_guard.mjs --write | --baseline --allowed "); + process.exit(2); +} + +const root = path.resolve(rootArg); +const hashes = collectHashes(root); +const writePath = argValue(argv, "--write"); +if (writePath) { + const report = { version: "svg-slides.repair-hashes.v1", root, hashes }; + fs.mkdirSync(path.dirname(path.resolve(writePath)), { recursive: true }); + fs.writeFileSync(path.resolve(writePath), `${JSON.stringify(report, null, 2)}\n`); + console.log(JSON.stringify({ ok: true, hash_count: Object.keys(hashes).length, file: path.resolve(writePath) }, null, 2)); + process.exit(0); +} + +const baselinePath = argValue(argv, "--baseline"); +if (!baselinePath) { + console.error("Missing --write or --baseline"); + process.exit(2); +} +const baseline = JSON.parse(fs.readFileSync(path.resolve(baselinePath), "utf8")); +const report = { + version: "svg-slides.repair-hash-guard.v1", + ...compareHashes(baseline.hashes || {}, hashes, argValues(argv, "--allowed")) +}; +report.totalErrors = report.ok ? 0 : report.unexpected_changed_files.length + report.missing_files.length + report.added_files.length; +console.log(JSON.stringify(report, null, 2)); +process.exit(report.ok ? 0 : 1); +``` + +- [ ] **Step 5: Document the repair loop contract** + +Add this section to `skills/lark-slides/references/lark-slides-create-svglide.md` after the bundle workflow: + +````markdown +### Page-Level Repair Loop + +When a validation gate fails, do not regenerate the full deck by default. Generate page-level repair context from receipts: + +```bash +node skills/lark-slides/scripts/svg_slides_repair_context.mjs --out /repairs --json +node skills/lark-slides/scripts/svg_slides_repair_hash_guard.mjs --write /receipts/repair_hashes.before.json --json +``` + +Repair only the failed `slides/slide_NN.svg` files listed in `repairs/repair_context.json`. After replacement, verify non-failed pages did not change: + +```bash +node skills/lark-slides/scripts/svg_slides_repair_hash_guard.mjs --baseline /receipts/repair_hashes.before.json --allowed slides/slide_NN.svg --json +node skills/lark-slides/scripts/svg_slides_bundle.mjs --title "" +``` + +The repair unit is one slide file. The acceptance unit remains the full deck. +```` + +- [ ] **Step 6: Verify repair loop tests pass** + +Run: + +```bash +node --test skills/lark-slides/scripts/svg_slides_repair_loop_test.mjs +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add skills/lark-slides/scripts/svg_slides_repair_context.mjs skills/lark-slides/scripts/svg_slides_repair_hash_guard.mjs skills/lark-slides/scripts/svg_slides_repair_loop_test.mjs skills/lark-slides/references/lark-slides-create-svglide.md +git commit -m "feat: add svglide single-page repair loop" +``` + +--- + +## Final Verification + +- [ ] Run Node script tests: + +```bash +node --test skills/lark-slides/scripts/validate_svg_deck_test.mjs +node --test skills/lark-slides/scripts/svg_slides_semantic_renderer_test.mjs +node --test skills/lark-slides/scripts/svg_slides_readability_safety_test.mjs +node --test skills/lark-slides/scripts/svg_slides_bundle_test.mjs +node --test skills/lark-slides/scripts/svg_slides_readback_gate_test.mjs +node --test skills/lark-slides/scripts/svg_slides_repair_loop_test.mjs +node --test skills/lark-slides/scripts/svg_slides_extract_references_test.mjs +node --test skills/lark-slides/scripts/svg_slides_source_coverage_check_test.mjs +``` + +Expected: all pass. + +- [ ] Run Go target tests: + +```bash +go test ./shortcuts/slides -run SVGlide -count=1 +``` + +Expected: PASS. + +- [ ] Run formatting checks: + +```bash +gofmt -l shortcuts/slides/slides_create_svglide_manifest.go shortcuts/slides/slides_create_svglide_test.go tests/cli_e2e/slides/slides_create_svglide_helpers_test.go +``` + +Expected: no output. + +- [ ] Check worktree: + +```bash +git status --short +``` + +Expected: only intentional committed changes, or a clean tree if all tasks were committed. + +## Self-Review + +- Spec coverage: The plan covers prompt/source contract, deterministic renderer, local parser-contract validation, readability safety validation, single-page repair loop, manifest receipt enforcement, and online readback fidelity. +- Placeholder scan: No task uses deferred-work markers, copy-by-reference wording, or undefined follow-up placeholders. +- Type consistency: Receipt names are `validate_svg_deck`, `prompt_read_trace`, `asset_readiness`, `typography_plan`, `frame_discipline`, `parser_contract`, and `readability_safety` consistently across bundle, manifest, and tests. + +## Execution Handoff + +Plan complete and saved to `dev-plans/lark-slides/svglide-parser-semantic-generation-plan.zh.md`. Two execution options: + +1. **Subagent-Driven (recommended)** - Dispatch a fresh subagent per task, review between tasks, faster isolation. +2. **Inline Execution** - Execute tasks in this session using `superpowers:executing-plans`, with checkpoints after each task.