Skill: sat-99/yao-tutorial-skill (version 0.2.3)

--- name: yao-tutorial-skill description: Create standalone beginner tutorial packages from a topic or supplied references, with adaptive research, course-style outline design, chapter visuals, and Markdown/DOCX/PDF/HTML exports. Use for textbook-like tutorials, course guides, teaching documents, or long beginner guides; not for quick answers, link summaries, pure diagrams, or file conversion. > 📌 反向发布自 [yaojingang/yao-open-skills](https://github.com/yaojingang/yao-open-skills/tree/main/skills/yao-tutorial-skill)，保留原始功能和结构，版权归原作者所有。 --- # Yao Tutorial Skill ## Workflow 1. Normalize topic, audience, outcome, language, formats, user material, style references, and exclusions into `brief.json`. 2. Read `references/input-adaptation.md`; use user material as the spine when sufficient, then add only needed external research. 3. Read `references/research-sourcing.md`; create `research/user-materials-register.md` when needed, `research/source-register.md`, and `research/evidence-map.md`. 4. Read `references/tutorial-outline-and-writing.md` plus `references/course-design-principles.md`; write `outline.md`, standalone public `tutorial.md` using `第1章` and `1.1`, and `research/chapter-quality-review.md`. 5. Read the editorial and visual references; create `visuals/visual-spec.json`, then run `build_visual_pack.py` and `capture_visuals.py`. 6. Read `references/export-workflow.md`; run `export_tutorial.py` and then `validate_package.py`. 7. Report exact failures and fallbacks. Never fabricate X posts, papers, repo details, dates, or citations. ## Quality Gates - User material controls intent when strong enough; external evidence fills verification and gaps. - Public exports never show internal source IDs or reference-packet provenance. - Copy reads as a standalone formal teaching product. - Every numbered chapter has a matching visual spec and embedded visual. - Depth is governed by learning sufficiency, not a fixed word limit; continue until the learner can understand, apply, and self-check the topic. - Every numbered chapter has an independent quality review for depth, examples, practice, evidence, visual fit, and back-half consistency. - HTML uses centered `report-shell`; DOCX/PDF have no visible headers, footers, local paths, or print chrome. - Delivery passes `scripts/validate_package.py` or names the remaining warnings/failures. ## References - `references/input-adaptation.md` - `references/research-sourcing.md` - `references/tutorial-outline-and-writing.md` - `references/course-design-principles.md` - `references/editorial-production.md` - `references/visual-html-workflow.md` - `references/visual-board-benchmarks.md` - `references/export-workflow.md` - `scripts/build_visual_pack.py`, `scripts/capture_visuals.py`, `scripts/export_tutorial.py`, `scripts/validate_package.py` - `templates/topic-brief-template.json`, `templates/visual-spec-template.json`, `templates/tutorial-style.css`

path: evals/semantic_config.json
content: {
  "optimizer_hints": {
    "capability": "Create source-backed beginner tutorials with adaptive research, chapter visuals, and DOCX/PDF/HTML exports",
    "inputs": [
      "topic",
      "notes",
      "URLs",
      "papers",
      "repositories",
      "reference materials",
      "主题",
      "资料",
      "链接",
      "论文",
      "GitHub 项目"
    ],
    "trigger_actions": [
      "create a beginner tutorial",
      "build a textbook-style tutorial",
      "research and write a tutorial",
      "generate a tutorial package",
      "做成教程",
      "生成教程",
      "写成教材",
      "做成教程输出物"
    ],
    "exclusions": [
      "quick explanation",
      "summary only",
      "finished document conversion",
      "standalone diagram",
      "SEO blog post",
      "简单解释",
      "只总结",
      "只转换文件",
      "单独画图"
    ],
    "artifacts": [
      "outline",
      "source register",
      "evidence map",
      "chapter visuals",
      "markdown",
      "docx",
      "pdf",
      "html"
    ]
  },
  "fallback_positive_concepts": [
    "tutorial_package",
    "source_backed_research",
    "chapter_visuals",
    "multi_format_exports"
  ],
  "positive_concepts": {
    "tutorial_package": {
      "weight": 0.34,
      "phrases": [
        "tutorial",
        "beginner tutorial",
        "textbook-like tutorial",
        "course guide",
        "teaching document",
        "beginner guide",
        "long beginner guide",
        "tutorial package",
        "教程",
        "小白教程",
        "教材",
        "教学文档",
        "教程输出物",
        "高审美教程",
        "系统讲透",
        "系统化讲透",
        "真正学会",
        "新手能真正学会",
        "概念系统讲透"
      ]
    },
    "source_backed_research": {
      "weight": 0.24,
      "phrases": [
        "source-backed",
        "adaptive research",
        "research",
        "references",
        "user material",
        "x.com",
        "papers",
        "authoritative sources",
        "github evidence",
        "github repos",
        "source citations",
        "资料",
        "链接",
        "论文",
        "权威资料",
        "GitHub",
        "参考项目",
        "补充必要的论文"
      ]
    },
    "chapter_visuals": {
      "weight": 0.2,
      "phrases": [
        "chapter visuals",
        "visuals",
        "diagrams",
        "visual html diagrams",
        "one visual per chapter",
        "每章",
        "配图",
        "清晰配图",
        "图示",
        "可视化",
        "visual HTML",
        "独立质检",
        "每章都要有独立质检"
      ]
    },
    "multi_format_exports": {
      "weight": 0.16,
      "phrases": [
        "markdown",
        "docx",
        "word",
        "pdf",
        "html",
        "exports",
        "final docx",
        "pdf/word/html",
        "Word",
        "导出",
        "固定锚文本目录",
        "HTML",
        "PDF"
      ]
    },
    "outline_learning_path": {
      "weight": 0.06,
      "phrases": [
        "outline",
        "outline design",
        "textbook-style headings",
        "第1章",
        "1.1",
        "大纲",
        "章节标题"
      ]
    }
  },
  "negative_concepts": {
    "quick_answer": {
      "weight": 0.34,
      "exclusive": true,
      "phrases": [
        "quick answers",
        "quick explanation",
        "explain in five bullets",
        "explain rag in five bullets",
        "five bullets",
        "简单解释",
        "快速解释",
        "五个要点"
      ]
    },
    "summary_only": {
      "weight": 0.3,
      "exclusive": true,
      "phrases": [
        "summarize links only",
        "summary only",
        "link summaries",
        "只总结",
        "只要摘要",
        "总结链接"
      ]
    },
    "conversion_only": {
      "weight": 0.36,
      "exclusive": true,
      "phrases": [
        "finished document conversion",
        "convert this finished markdown file",
        "convert this finished markdown file to a pdf",
        "file conversion",
        "只转换文件",
        "转成 PDF"
      ]
    },
    "pure_diagram": {
      "weight": 0.34,
      "exclusive": true,
      "phrases": [
        "standalone diagram",
        "interactive diagram page",
        "no tutorial text",
        "pure diagram",
        "单独画图",
        "只做图"
      ]
    },
    "marketing_or_page": {
      "weight": 0.26,
      "exclusive": true,
      "phrases": [
        "seo blog post",
        "course landing page",
        "landing page",
        "marketing",
        "营销文章",
        "落地页"
      ]
    },
    "deck_only": {
      "weight": 0.24,
      "exclusive": true,
      "phrases": [
        "powerpoint deck",
        "ppt",
        "slide deck",
        "presentation",
        "PPT",
        "幻灯片"
      ]
    },
    "personalized_study_plan": {
      "weight": 0.2,
      "exclusive": false,
      "phrases": [
        "personalized study guide",
        "learner profile",
        "study plan",
        "个性化学习计划"
      ]
    }
  }
}


path: evals/trigger_cases.json
content: {
  "recommended_threshold": 0.34,
  "should_trigger": [
    "Use X.com, papers, GitHub repos, and authoritative blogs to build a beginner tutorial on retrieval augmented generation, with diagrams for every chapter and PDF/Word/HTML exports.",
    "I will give you any topic. Research theory, practice, cases, and code examples, then create a textbook-style tutorial for beginners.",
    "Create a Chinese beginner guide to AI agents with source citations, an outline first, visual HTML diagrams per chapter, textbook-style headings, and final docx/pdf/html files.",
    "把这个主题做成5000到1万字的高审美教程，要求每章有清晰配图，章节标题采用第1章和1.1这种格式，HTML有固定锚文本目录，并导出Word和PDF。",
    "我给你一批资料、链接和参考项目，你以这些资料为核心，再补充必要的论文、GitHub和权威资料，生成一套教程输出物。",
    "不要限制字数，把这个概念系统讲透，让新手能真正学会，并且每章都要有独立质检和配图。"
  ],
  "should_not_trigger": [
    "Explain RAG in five bullets.",
    "Convert this finished markdown file to a PDF.",
    "Design a standalone interactive diagram page with no tutorial text.",
    "Write an SEO blog post about AI agents without source research."
  ],
  "near_neighbor": [
    "Build a personalized study guide from a learner profile.",
    "Research this topic and summarize links only.",
    "Make a course landing page.",
    "Create a PowerPoint deck from this tutorial."
  ]
}


path: interface.yaml
content: interface:
  display_name: "Yao Tutorial Skill"
  short_description: "Research any topic and build a polished beginner tutorial with chapter visuals and DOCX/PDF/HTML exports"
  default_prompt: "Use $yao-tutorial-skill to turn my topic and supplied notes, URLs, files, papers, repos, or style references into a source-backed beginner tutorial with chapter visuals, 第1章/1.1 headings, centered HTML report, Word/PDF/HTML exports, and package validation."
compatibility:
  canonical_format: "agent-skills"
  adapter_targets:
    - "openai"
    - "claude"
    - "generic"
  activation:
    mode: "manual"
    paths: []
  execution:
    context: "inline"
    shell: "bash"
  trust:
    source_tier: "local"
    remote_inline_execution: "forbid"
    remote_metadata_policy: "allow-metadata-only"
  degradation:
    openai: "metadata-adapter"
    claude: "neutral-source-plus-adapter"
    generic: "neutral-source"


path: manifest.json
content: {
  "name": "yao-tutorial-skill",
  "version": "0.2.2",
  "owner": "Yao Team",
  "updated_at": "2026-04-30",
  "status": "active",
  "maturity_tier": "production",
  "lifecycle_stage": "production",
  "context_budget_tier": "production",
  "review_cadence": "monthly",
  "category": "education",
  "complexity_tier": "complex",
  "target_platforms": [
    "openai",
    "claude",
    "generic"
  ],
  "factory_components": [
    "references",
    "scripts",
    "templates",
    "evals",
    "reports"
  ]
}


path: README.md
content: # Yao Tutorial Skill

`yao-tutorial-skill` 是一个面向教程成品生产的 Skill：输入一个主题，或输入一组参考资料、网址、论文、GitHub 仓库、草稿，它会把这些信息整理成一套带来源、带大纲、带章节配图、带多格式导出的完整教程。

它适合用来做“从入门到精通”类教程、系统化学习文档、产品或技术教材、方法论手册，以及需要正式交付的长篇教学内容。

## 它会做什么

1. 把输入归一化为 `brief.json`，明确主题、受众、目标、材料、格式和限制。
2. 优先吸收用户给的资料；资料不足时，再补充官方文档、论文、GitHub、实践案例和高质量分享。
3. 生成来源登记和证据映射，避免教程变成无依据的泛泛写作。
4. 用课程设计方法重构标题和大纲，让章节既有专业体系，又能说人话。
5. 写出完整教程正文，并以“学习充分性”决定深度：不设固定字数上限，讲到用户能系统理解、应用和自检为止。
6. 为每个编号章节生成独立质检记录，检查深度、案例、练习、证据和配图是否足够。
7. 为每个编号章节生成一个 HTML 可视化配图，再截图嵌入正文。
8. 导出 `Markdown`、`Word`、`PDF` 和 `HTML`。
9. 运行验证脚本，检查章节、质检、配图、引用、截图、导出文件、页眉页脚和本地路径泄漏。

## 典型输出

```text
output/
├── brief.json
├── outline.md
├── tutorial.md
├── research/
│   ├── source-register.md
│   ├── evidence-map.md
│   └── chapter-quality-review.md
├── visuals/
│   ├── visual-spec.json
│   └── index.html
├── assets/
│   └── screenshots/
└── exports/
    ├── tutorial.html
    ├── tutorial.docx
    └── tutorial.pdf
```

## 关键约束

- 用户资料足够时，以用户资料为主线，不机械扩大搜索范围。
- 用户资料不足时，外部来源优先级为官方/一手来源、论文、GitHub、权威实践分享。
- 内部研究文件保留来源 ID；公开 Markdown/HTML/Word/PDF 不显示 `[U1]`、`[X1]` 这类角标。
- 教程正文必须像正式出版物，不写“基于用户资料”“根据原文整理”等内部来源话术。
- 标题和大纲要面向用户利益、痛点和学习路径，避免只堆专业术语。
- 每个小节标题要对应具体大纲内容，避免反复使用“你要做的事”“检查点”等泛化标题。
- 教程深度不按固定字数截断，默认讲到学习目标被完整解决；短版只在用户明确要求时输出。
- 中后段章节必须和前段一样细致，不能越写越像提纲。
- 每个编号章节都必须有独立质检记录，覆盖学习问题、深度、案例/练习、证据和配图。
- 每个编号章节都必须有一个对应视觉规格和一张嵌入配图。
- HTML 报告要使用居中内容容器、粘性目录、日期和章节跳转。
- Word/PDF 默认不保留页眉页脚，避免路径、页码和打印信息影响阅读。

## 主要文件

- [`SKILL.md`](SKILL.md): Skill 入口和最小工作流
- [`references/input-adaptation.md`](references/input-adaptation.md): 输入资料优先级和补充研究逻辑
- [`references/research-sourcing.md`](references/research-sourcing.md): 来源选择和证据登记规则
- [`references/tutorial-outline-and-writing.md`](references/tutorial-outline-and-writing.md): 大纲与正文写作规则
- [`references/course-design-principles.md`](references/course-design-principles.md): 课程标题、大纲和内容体验设计规则
- [`references/visual-html-workflow.md`](references/visual-html-workflow.md): HTML 配图画板生成规则
- [`references/export-workflow.md`](references/export-workflow.md): Word/PDF/HTML 导出规则
- [`scripts/validate_package.py`](scripts/validate_package.py): 输出包验证脚本
- [`examples/`](examples/): 三套完整教程示例，包含 Markdown、HTML、Word、PDF、配图画板和验证产物


path: references/course-design-principles.md
content: # Course Design Principles

Use these rules when turning evidence into a tutorial or course-like document. They come from the local course marketing reference packet and should be treated as durable design constraints, not visible source notes.

## Public Artifact Rule

The final tutorial is a formal public-facing teaching product. It should not say it is based on a pasted article, user notes, source packet, X thread, draft, or original text. Absorb user references silently into the structure, examples, and teaching angle.

Keep source IDs such as `[U1]`, `[X1]`, `[A2]`, `[P3]`, or `[G4]` only in `research/` files. Public `tutorial.md`, HTML, DOCX, and PDF must not display these IDs. If references are useful to readers, write a human-readable `参考资料` or `延伸阅读` section with titles and links, not internal IDs.

## Title Design

A tutorial title should work like a compact value proposition, not a neutral file name.

Use at least one of these title forces:

- user benefit: make the outcome obvious
- tension or contrast: name the misunderstanding the tutorial fixes
- visual concreteness: create a memorable picture or action
- attachment: connect a new idea to a familiar frame
- authority: use credible scope, framework, or experience when available

Preferred title shape:

```text
主题/领域 + 时代或场景 + 方法动作 + 用户结果
```

If a title must be short, preserve the user-facing result before the professional category. A good title makes the reader think "this is for my problem", not just "this is about a topic".

## Outline Design

Avoid the knowledge curse. Most weak outlines are organized around what the author wants to teach; strong outlines are organized around what the learner wants to solve.

Use this formula:

```text
Outline item = professional content + learner pain or desired outcome
```

When space is limited, keep the learner-facing pain/outcome and hide the professional term inside the section body.

Build the outline with the `point-line-surface-system` method:

1. `Point`: list key knowledge points, user questions, pain points, examples, terms, and mistakes.
2. `Line`: merge related points into learner-facing section questions or tasks.
3. `Surface`: group sections with a clear logic such as process, cause-effect, comparison, or category.
4. `System`: make the chapters form one coherent method, not a bag of tips.

Scope rules:

- Choose what the author can explain well, what the learner truly needs, and what forms a self-consistent path.
- Do not pursue "big and complete" if it weakens learning certainty.
- Every H2 chapter and H3 section should map to a distinct outline item.
- Avoid repeating generic H3 labels such as `你要做的事`, `你要注意什么`, `示例`, or `检查点` across chapters. Make section headings specific, e.g. `3.2 用熟悉场景降低听力负担`.

## Section Content Design

Do not default to `concept + explanation + example`. That structure often starts too abstractly and fails to create need.

For important sections, use the experience formula:

```text
Content = scene challenge + failed old approach + new solution + new result
```

Each teaching unit should move through three levels:

1. `Transfer knowledge`: explain the concept with clear logic.
2. `Change cognition`: expose why the old mental model fails and why the new model matters.
3. `Awaken behavior`: give a low-friction action the learner can perform.

Use behavior design when a section asks the learner to act:

- motivation: show the benefit, contrast, or emotional reason to act
- ability: reduce the first action to a small feasible step
- prompt: provide a clear trigger, checklist, template, or practice moment

Good sections usually contain:

- a concrete scene or learner problem
- one method, model, or principle
- a short case or walkthrough
- a visible artifact such as table, checklist, diagram, or template
- one small action or reflection prompt

## Material And Memory

Strong tutorials need material, not only explanation. Use stories, cases, diagrams, tables, short formulas, memorable lines, and before/after comparisons to create recognition and memory.

Use `method + case + reusable template` as the default rhythm when the tutorial teaches a repeatable workflow. The template can be a checklist, prompt, worksheet, decision table, or experiment plan.

Avoid fake marketing hype. The goal is not to sell inside the tutorial; the goal is to make the value, path, and next action obvious.



path: references/editorial-production.md
content: # Editorial Production System

This guide controls the visual quality of DOCX, PDF, HTML, and chapter visuals. The priority order is:

1. `frontend-slides`: content density, responsive `clamp()` sizing, strong hierarchy, no generic AI aesthetic, no overflow-prone layouts
2. `kami`: Chinese/English document typography, warm paper restraint, ink-blue accent discipline, formal PDF/Word polish
3. `yao-ppt-skill`: guizang/e-ink-inspired editorial rhythm, image-first planning, and quality gates

Use these ideas as constraints, not decoration.

## Default Visual Direction

Default to a `frontend-slides`-first teaching report, not a decorative paper artifact:

- every visible element must serve real tutorial content or navigation
- use a clean centered report shell: sticky text navigation on the left, reading column on the right, and the two columns centered together as one layout
- use `clamp()` for major type and spacing
- use strong typographic hierarchy instead of decorative frames
- use one accent color, preferably ink blue `#1B365D`, under 5 percent of the page
- use warm off-white only as a quiet background; avoid visible paper textures unless the user asks for a document-artifact style
- use serif for titles/headings and readable sans for Chinese body text
- use hairline rules and light borders; avoid hard shadows, heavy cards, and ornamental surfaces
- keep corner radius `6-8px` for framed visuals and tables

Avoid:

- purple-blue gradient defaults
- glassmorphism
- thick shadows
- background grids or paper textures in formal output unless they encode content
- decorative blobs
- emoji icons
- repeated generic cards
- loose blog-like spacing in long documents
- visible helper labels such as "generated by", "visual pack", or instructions inside the final artifact
- absolute local filesystem paths in the visible source appendix

## Element Budget

Treat the final output as a formal deliverable. Remove anything that is not content, navigation, citation, or a teaching visual.

Allowed persistent elements:

- document title
- sticky anchor-text table of contents
- section headings
- body text
- tables, callouts, source IDs
- chapter visuals and captions
- source appendix, with local source files cited by readable labels only; keep absolute audit paths in internal research notes, not final deliverables

Not allowed in final output:

- duplicate title blocks
- decorative subtitles not supplied by the tutorial
- generic "Contents" badges when the anchor text menu is already clear
- visible usage instructions for the HTML report
- background patterns that do not carry information
- extra wrappers around visuals that do not improve reading
- repeated card surfaces around ordinary paragraphs

## Typography

### Chinese

- Title and major headings: `Noto Serif SC`, `Source Han Serif SC`, `Songti SC`, or another serious serif fallback.
- Body: `Noto Sans SC`, `Source Han Sans SC`, `PingFang SC`, `Microsoft YaHei`, or system CJK sans.
- Metadata and source IDs: `IBM Plex Mono`, `JetBrains Mono`, `SFMono-Regular`, or monospace fallback.

### English

- Title and major headings: a serif/editorial face such as `Newsreader`, `Charter`, `Georgia`.
- Body: a restrained sans or serif depending on the document tone.
- Avoid using Inter/Roboto/Arial as the main visual identity, though they are acceptable fallbacks.

## Tutorial Depth And Completeness

Default tutorial length should be substantial enough to teach. Do not cap a complete tutorial at a fixed word count unless the user explicitly asks for a short artifact.

Use learning sufficiency as the depth standard:

- The tutorial should be as long as needed for the learner to understand the problem, build the mental model, see examples, practice the method, and self-check results.
- The chapter count should follow the learning path, not a default quota. Start from `6-10` chapters for broad topics, then expand or merge based on prerequisite load and learner questions.
- Each chapter should feel complete on its own: scene challenge, explanation, example or case, visual, pitfall, practice/checkpoint, and a success state.
- Middle and later chapters must not become outline-like, compressed, or less careful than early chapters. If the back half becomes thin, revise chapter by chapter before export.
- Short versions are allowed only when the user asks for a sample, brief, one-pager, or fast draft.
- Deep manuals and course-style outputs may become long. Keep structure, navigation, and pacing strong rather than cutting necessary explanation.
- Final practice section: at least one applied exercise and one self-check rubric.

## HTML Report Contract

The final HTML report must feel like a designed reading artifact:

- Include a table of contents generated from headings.
- Style `nav#TOC` as a plain anchor-text menu, not as a decorative card.
- On desktop, place `nav#TOC` and the article body inside one centered `report-shell` grid. The menu and reading column should feel like one balanced container, not two independently positioned elements.
- Make the menu sticky on the left side while content scrolls.
- Keep the title area compact: H1, date line, one divider, then opening text. A long table of contents must stay outside the article body so it cannot create a large blank area below the title.
- On mobile, make the menu a compact sticky top block.
- Highlight hierarchy with chapter numbers, short headings, and visible source/caption rhythm.
- Use textbook-style numbering: `第1章 章节标题` for H2 and `1.1 小节标题` for H3. The same visible numbering must carry through HTML, PDF, and DOCX.
- Use chapter-aware visual captions when helpful, such as `图 1.1：...` for the first figure in chapter 1.
- Keep the reading column between `700px` and `860px`.
- Keep the combined desktop layout width restrained: table of contents around `196px-252px`, a responsive gap, and the reading column around `820px`, centered within the viewport.
- Use responsive type with `clamp()`.
- Use `scroll-margin-top` on headings so anchor jumps do not hide content behind sticky chrome.
- For print/PDF, avoid sticky UI chrome; PDF should read like a clean document.
- Do not create a duplicate title block. The markdown `H1` is the visible title; export metadata should only set the browser/PDF page title. A single date line below H1 is allowed.

## DOCX And PDF Contract

DOCX and PDF should be generated from the same markdown source, but they need document-specific discipline:

- Use a Word reference document when available.
- Keep Word and PDF clean: no running headers, no running footers, no file paths, no browser-generated page counters.
- Heading styles should be hierarchical and compact.
- Body line-height should feel editorial, not web-loose.
- Images should be high resolution and centered, with captions directly below.
- Tables should use light borders and restrained header shading.
- Use Kami-style warm parchment `#f5f4ed`, ivory `#faf9f5`, warm borders, and one ink-blue accent. Avoid pure white table slabs.
- HTML image figures must reset browser-default `figure` margins.
- Do not add an extra border around chapter screenshots when the screenshot already contains an artboard frame.
- If markdown images create automatic pandoc figcaptions and the tutorial also has a `图 N` caption, hide the automatic figcaption and keep the authored caption.
- Long HTML tables must be wrapped in a horizontal-scroll container with stable borders, row rhythm, and readable cell wrapping.
- Avoid manual page-break hacks unless a layout issue is visible.
- PDF should be rendered from the polished HTML after visuals are embedded.

## Visual Artifact Contract

Every chapter visual should be a teaching object:

- One visual explains one idea.
- Use the same frontend-slides-first visual system as the document.
- Treat chapter visuals as a real canvas system: artboard, nodes, edges, groups, axes, marks, and annotations.
- Prefer diagrams, models, matrices, flows, layered stacks, and comparison charts over decorative illustrations.
- Titles should be short enough to fit in one or two lines.
- Captions should tell the reader what to notice.
- Use high-resolution screenshots: default capture should be at least `2x` scale.
- Keep diagram labels concise; if the label needs a paragraph, the diagram is too dense.
- Avoid stock-like atmosphere. The visual must reveal a real relationship, process, model, or tradeoff.
- Avoid large empty poster-like whitespace unless the whitespace clarifies hierarchy.
- Avoid using two accent colors to imply categories unless the data genuinely has two categories.
- Prefer one focal accent and neutral secondary nodes.
- Prefer deterministic layouts inspired by open-source diagram tools over arbitrary placement.
- Do not use the same box-and-arrow template for every chapter; vary the visual grammar according to the teaching job.

## Rhythm Rules

Borrow presentation rhythm without turning the tutorial into slides:

- Open with a strong hook, then slow down into explanation.
- Alternate dense teaching chapters with lighter examples, cases, or diagrams.
- Avoid three consecutive sections with the same structure.
- Use callouts sparingly: checkpoint, common pitfall, what to notice.
- Keep source appendix audit-friendly but visually quieter than teaching content.

## Production Quality Gate

Before delivery, check:

- body depth matches the requested learning outcome and is not artificially compressed by a word-count ceiling
- each numbered chapter has an internal quality review in `research/chapter-quality-review.md`
- middle and later chapters are not noticeably thinner or less actionable than early chapters
- every chapter has a visual and caption
- screenshots are crisp enough for PDF/Word
- HTML image frames do not show double borders or duplicate captions
- HTML tables do not overflow, collapse awkwardly, or squeeze long Chinese text into unreadable columns
- Word and PDF have no visible headers or footers
- HTML has a sticky anchor menu
- HTML desktop layout centers the table of contents and reading column together as one `report-shell`, instead of pinning the menu to the viewport edge
- HTML has no duplicate visible title
- PDF opens and images are visible
- DOCX opens and contains embedded images
- no absolute filesystem paths appear in final HTML, PDF, or DOCX
- typography, color, captions, and tables follow the same design system
- no visible element is decorative-only


path: references/export-workflow.md
content: # Export Workflow

Use markdown as the single editable source of truth. Generate DOCX, PDF, and HTML from `tutorial.md`.

Read `references/editorial-production.md` before exporting. The export phase is not just format conversion; it is where the tutorial becomes a polished reading artifact.

## Folder Convention

Inside `outputs/yao-tutorials/<topic-slug>/`:

```text
brief.json
research/source-register.md
research/evidence-map.md
outline.md
visuals/visual-spec.json
visuals/index.html
visuals/*.svg
assets/screenshots/*.png
tutorial.md
exports/
exports/tutorial-reference.docx
```

## Export Command

Run from the tutorial output folder:

```bash
python3 /path/to/yao-tutorial-skill/scripts/export_tutorial.py tutorial.md exports/ --css /path/to/yao-tutorial-skill/templates/tutorial-style.css
```

The exporter creates a default `tutorial-reference.docx` for Word styling when no `--reference-doc` is provided and `python-docx` is available.

Optional title, basename, date, and reference document:

```bash
python3 /path/to/yao-tutorial-skill/scripts/export_tutorial.py tutorial.md exports/ --title "Beginner Guide To <Topic>" --basename tutorial --date "2026年4月29日" --css /path/to/yao-tutorial-skill/templates/tutorial-style.css
python3 /path/to/yao-tutorial-skill/scripts/export_tutorial.py tutorial.md exports/ --reference-doc custom-reference.docx --css /path/to/yao-tutorial-skill/templates/tutorial-style.css
```

## Dependencies

The default script uses:

- `pandoc` for DOCX and HTML
- `weasyprint` for clean PDF printing without browser URL/page footers
- a local Chromium-family browser for visual screenshots and PDF fallback
- `Pillow` for exact post-capture PNG cropping
- `python-docx` for generating the default Word reference document when available

If the environment lacks these tools:

- still deliver `tutorial.md` and `visuals/index.html`
- use a dedicated local document or PDF skill if available
- report exactly which export target could not be produced

## Formatting Rules

- Keep headings short and hierarchical.
- Do not show internal source IDs in public text. Keep `[U1]`, `[X1]`, `[A2]`, `[P3]`, `[G4]`, and similar audit IDs inside `research/` files only.
- If references are useful to readers, use a human-readable `参考资料` or `延伸阅读` section with names and links, not bracket IDs.
- Public outputs should not say they are based on user notes, pasted source material, a supplied article, or the original text.
- When a source is a local file, use a human-readable label in final deliverables and keep absolute audit paths only in internal research notes.
- Keep image paths relative to `tutorial.md`.
- Use captions directly below images.
- Use high-resolution screenshots for chapter visuals.
- In HTML, render chapter images as clean figures: no browser-default figure indentation, no extra border when the image already has an artboard frame, and no duplicate auto/generated figcaption.
- Chapter headings must stay visible in all formats as `第1章 标题`; chapter subheadings must stay visible as `1.1 标题`, `1.2 标题`.
- In HTML, wrap tables in a scroll-safe table container so wide or dense tables stay readable.
- HTML must include a plain sticky `nav#TOC` anchor menu that remains functional while scrolling. The exporter should wrap `nav#TOC` and the article in a centered `report-shell`, so the menu and reading column align as one balanced layout. The TOC must stay outside the article body; a long TOC must never push the first paragraphs below the fold.
- Put a compact document date directly below the visible H1 title in HTML, PDF, and DOCX. The date is content metadata, not a browser print footer.
- Do not create duplicate visible title blocks; the markdown `H1` is the only visible title, with one optional date line below it.
- PDF should hide sticky UI chrome and render as a clean document.
- PDF must not include browser-generated URL footers, page counters, print dates, or file paths.
- Word output must not include document headers or footers unless the user explicitly asks for them.
- DOCX should use a reference document for heading, body, table, and caption styles.
- Avoid manual page-break hacks unless the user requires print layout.
- Make the HTML readable before printing to PDF.

## Export Quality Gate

Run the package validator before final delivery:

```bash
python3 /path/to/yao-tutorial-skill/scripts/validate_package.py . --formats docx html pdf --check-deps
```

Use `--formats html` or another subset when the user requested fewer formats.

- DOCX opens and contains images.
- PDF opens and images are visible.
- PDF has no visible header, footer, local file URL, browser date, or browser page counter.
- DOCX has no header/footer parts or section header/footer references unless explicitly requested.
- HTML is standalone or has correctly linked local assets.
- HTML has a sticky anchor-text table of contents when the tutorial is longer than three chapters.
- HTML first viewport shows the title, date, and opening text without a large blank area below the title.
- HTML desktop viewport centers the combined `nav#TOC + article-body` layout, leaving visually balanced outer margins.
- HTML does not contain duplicated title blocks or visible generation/helper labels.
- Links are clickable in exported files when the format supports links.
- No chapter is missing its visual.
- HTML tables are readable at desktop and mobile widths.
- HTML image captions are not duplicated.
- No absolute local filesystem paths appear in final HTML, PDF, or DOCX.
- No public output contains bracket source markers or internal provenance wording.
- Output typography, colors, captions, and tables match the editorial production system.


path: references/input-adaptation.md
content: # Input Adaptation

Use this guide before research. The user may provide only a topic, or they may provide a full packet of notes, URLs, drafts, papers, repos, examples, and style references. The skill should adapt its research effort to the evidence already provided.

## Priority Rule

User-provided material is the first-order reference for:

- intent and angle
- audience assumptions
- examples, cases, and terminology
- required claims or arguments
- style, tone, and output constraints
- URLs, named people, repos, papers, or projects the user explicitly wants considered

External research exists to verify, complete, update, and challenge the user material. Do not replace the user's angle with a generic web-research angle unless the user material is clearly wrong, unsafe, stale, or too thin to support the tutorial.

## Material Intake

Classify each item into one of these buckets:

- `must_use`: central material the tutorial should preserve or build around
- `supporting`: useful detail, example, data point, or source
- `style_reference`: layout, writing, visual, or tone reference
- `caution`: material that may be wrong, stale, promotional, unsupported, or out of scope
- `exclude`: material the user says not to use

Record the classification in `research/user-materials-register.md`.

Suggested schema:

```markdown
| id | type | title_or_label | user_priority | use_for | key_takeaway | limits_or_cautions |
| --- | --- | --- | --- | --- | --- | --- |
| U1 | pasted note | ... | must_use | angle, examples | ... | user-authored, verify factual claims |
```

Use `U1`, `U2`, `U3` for user material. External sources should keep `P`, `G`, `A`, `X`, or other source prefixes.

## Sufficiency Tiers

After intake, choose a research tier.

### Rich User Packet

Use this when the user provides at least one of:

- a substantial draft, transcript, or notes packet
- `5+` relevant URLs or files with a clear angle
- specific must-use papers, repos, cases, or benchmark projects
- enough examples and claims to support most chapters

Research behavior:

- use user material as the tutorial spine
- add only `3-8` external records for verification, freshness, missing theory, missing implementation, and counterexamples
- do not over-research familiar background if the user packet already supports the chapter

### Moderate User Packet

Use this when the user provides:

- a topic plus a few notes or URLs
- a clear angle but incomplete evidence
- examples but weak theory
- theory but weak practice

Research behavior:

- preserve the user's angle and examples
- add `6-12` external records
- fill only the missing source layers, such as papers, GitHub, official docs, or practitioner cases

### Thin User Packet

Use this when the user provides:

- only a topic
- one vague paragraph
- weak or mostly stylistic references
- unsupported claims with no source trail

Research behavior:

- preserve the user's wording and implied intent
- run the full source ladder from `references/research-sourcing.md`
- target `10-18` external records for a full tutorial

## Conflict Handling

When user material conflicts with external evidence:

- keep the user's goal visible
- separate opinion, practitioner signal, and supported fact
- cite the stronger source for factual claims
- explain the limitation briefly in the tutorial if it affects learning
- do not silently erase the user's idea; reshape it into a safer, source-backed version

When the user provides many references but no topic, infer the topic from the repeated pattern and state that assumption before writing.

When the user provides style references, extract rules rather than copying surface decoration.

## Personalization Hooks

Use supplied details to personalize:

- audience role: founder, engineer, teacher, student, operator, creator
- learning objective: understand, build, decide, teach, evaluate, sell, or operate
- domain context: business, education, software, AI, design, finance, healthcare, legal, or another field
- tone: formal textbook, practical playbook, narrative tutorial, internal manual
- output depth: sample, complete tutorial, deep manual

If the user gives none of these, default to a beginner practical tutorial with concrete examples and restrained document design.

## Research Budget Rule

External research should shrink when user material is strong and expand when user material is weak. It should never disappear entirely for current, factual, technical, legal, medical, financial, or tool/version-dependent topics.

Minimum external check even for rich packets:

- one authority or primary source for core definitions
- one implementation or case source when practice is involved
- one freshness check when tools, APIs, laws, prices, benchmarks, or public figures are involved


path: references/research-sourcing.md
content: # Research Sourcing

Use this guide to gather enough evidence to teach the topic without turning research into an endless crawl.

Read `references/input-adaptation.md` before applying this guide. External research should adapt to the strength of user-provided material.

## User Material First

If the user supplies notes, URLs, files, drafts, papers, repos, or named references:

1. Register those items first in `research/user-materials-register.md` with `U` source IDs.
2. Extract the user's intended angle, examples, terms, and constraints before opening new sources.
3. Decide whether the packet is rich, moderate, or thin.
4. Build the external search plan only around missing support, freshness, contradiction checks, and source diversity.

Do not treat user material as automatically factual. Treat it as primary intent and candidate evidence. Verify factual claims when they matter.

## Source Ladder

1. Primary authority: official docs, standards, specifications, maintainer docs, regulator guidance, university or lab pages.
2. Scholarly evidence: peer-reviewed papers, preprints from known venues, paper platform metadata, citations, surveys, benchmark papers.
3. Implementation evidence: GitHub repositories, examples, issues, releases, docs folders, notebooks, demos, and tests.
4. Practitioner discussion: X.com posts, long threads, conference slides, engineering blogs, podcasts with transcripts, and respected expert notes.
5. Secondary explainers: only use them to fill teaching gaps after stronger sources are in place.

Do not give social posts the same weight as papers, official docs, or working code. Use them to find names, cases, failure modes, debates, and current language.

## Minimum Research Set

For a full tutorial with thin user material, aim for `10-18` external source records:

- `2-5` papers or paper-platform records
- `2-5` GitHub records
- `2-4` official or first-party authority records
- `2-5` practitioner discussion or case records, including X.com when accessible

For moderate user material, aim for `6-12` external records focused on gaps.

For rich user material, aim for `3-8` external records focused on verification, freshness, implementation evidence, and counterexamples.

For niche topics, use fewer sources but state the gap. For high-stakes topics, broaden primary sources and avoid unsupported claims.

## Search Recipes

### X.com

Use X for current discourse and practitioner signals:

- `"<topic>" (tutorial OR guide OR thread OR "case study") has:links -is:retweet lang:<target-language>`
- `"<topic>" (mistake OR lessons OR "what I learned") has:links -is:retweet`
- `from:<expert_handle> "<topic>"`
- `"<topic>" (paper OR repo OR demo) has:links`

Use `lang:zh-CN` for Simplified Chinese, `lang:zh-TW` for Traditional Chinese, and `lang:en` for English. When the target language has weak results, search both the target language and English, then write the tutorial in the requested language.

Record the author handle, post URL, date, author credibility, linked source, and the claim you can safely use. If X is blocked by login, rate limits, or search instability, use scoped web search such as `site:x.com "<topic>" "repo"` and report the limitation.

### Papers And Paper Platforms

Use Semantic Scholar, arXiv, PubMed, ACM Digital Library, IEEE Xplore, SSRN, Google Scholar, Papers With Code, or domain-specific indexes.

Search patterns:

- `"<topic>" survey`
- `"<topic>" tutorial`
- `"<topic>" benchmark`
- `"<topic>" application case study`
- `"<topic>" limitations`

Capture title, authors, year, venue, URL or DOI, abstract-level relevance, citation or influence signals when visible, and what the tutorial should borrow.

### GitHub

Use GitHub search and repository pages for implementation and examples:

- `"<topic>" in:name,description,readme stars:>100 pushed:>2025-01-01`
- `topic:<topic-slug> stars:>50`
- `"<topic>" "example" language:<language>`
- `"<topic>" "notebook" OR "demo"`
- inspect `README`, `docs/`, `examples/`, `tests/`, releases, issues, and discussions

Do not treat stars as proof. Check recency, maintainer activity, license, examples, issue quality, and whether code matches the current API.

### Authority Sharing Sites

Look for:

- official product docs and blogs
- research lab posts
- engineering blogs from reputable teams
- conference talks and slide decks
- standards bodies and specifications
- university lecture notes or lab tutorials
- public notebooks from credible maintainers

Use these to translate theory into approachable teaching language, but keep claims traceable.

## Source Record Schema

Each source record should include:

- `id`: short stable ID such as `P1`, `G2`, `X3`, `A4`
- `title`
- `url`
- `source_type`: paper, official, GitHub, X, talk, engineering blog, standard, course note
- `date_accessed`
- `published_or_updated`
- `authority_reason`
- `use_for`: theory, practice, case, terminology, failure mode, example, visual
- `key_takeaway`
- `limits_or_cautions`

Use a separate `research/user-materials-register.md` for user material. User material records should include `user_priority` and should not be mixed with external authority scoring.

## Evidence Map

Before drafting, map each chapter to source IDs:

- chapter title
- learner question answered
- user material IDs that define the angle, example, or voice
- source IDs supporting theory
- source IDs supporting example or case
- visual idea
- claims to avoid or qualify

## Stop Rule

Stop research when:

- the tutorial outline can be supported chapter by chapter
- the central claims have primary or scholarly support
- examples have working-code or first-party evidence
- no chapter depends only on social posts

Continue research when:

- sources contradict each other
- the topic changed recently
- a tool/API/version matters
- a chapter has no credible source backing

## Public Reference Rules

Research files must keep source IDs for auditability. Public tutorial outputs must not expose those internal IDs inline or in appendix tables.

Use this split:

- `research/*`: keep `U1`, `X1`, `A1`, `P1`, `G1` IDs, evidence maps, cautions, and internal provenance.
- `tutorial.md` and exports: write standalone teaching prose. If references help readers, use human-readable names and links only.

Do not write "based on the supplied article", "from the user's pasted material", or similar provenance notes in final public copy.

## Citation And Copyright Rules

- Cite exact URLs in the source appendix.
- Use short quotes only when wording is essential.
- Paraphrase source material in beginner-friendly language.
- Never copy long passages, proprietary slides, paywalled text, or full social threads.
- If evidence is weak, label it as a practitioner signal rather than a fact.


path: references/tutorial-outline-and-writing.md
content: # Tutorial Outline And Writing

The tutorial must feel like a guided learning path for a beginner, not a research report.

Read `references/input-adaptation.md` and `references/course-design-principles.md` before outlining. If the user supplied notes, drafts, URLs, or examples, the outline should preserve the user's core angle and only add external structure where it improves teaching quality.

Final public copy must read as a standalone tutorial, not as a summary of supplied references. Do not write phrases such as "based on the article", "the user supplied", "the pasted note says", or "this tutorial is organized from the original text".

## Outline Shape

Use this structure unless the topic clearly needs another order:

1. Hook: a concrete problem, surprising contrast, or relatable scene.
2. Promise: what the learner will be able to understand or build by the end.
3. Roadmap: the smallest useful path through the topic.
4. Chapter 1: the beginner mental model.
5. Chapter 2: the first concrete example.
6. Chapter 3: how the pieces connect.
7. Chapter 4: practical workflow or implementation.
8. Chapter 5: real cases, mistakes, and tradeoffs.
9. Practice: tasks that reinforce the path.
10. Human-readable reference or further-reading section when useful, plus next learning path.

For a short tutorial, use `3-5` chapters. For a textbook-like guide, start with `6-10` chapters, but add or split chapters whenever the learner outcome cannot be taught cleanly inside that frame. Keep each chapter scoped to one learner question.

## User-Material Adaptation

When the user gives substantial material:

- start the outline from the user's strongest claim, case, or teaching angle
- keep user examples as the running example when they are clear enough
- convert loose notes into chapter questions instead of replacing them with generic chapters
- add external theory only where it helps the learner understand or verify the user's point
- mark unsupported user claims as claims to verify before drafting
- do not tell readers which parts came from user material; convert the material into polished teaching structure

When the user gives only a topic:

- use the default beginner path
- choose one running example during research
- make the first chapter solve the most common beginner confusion

When the user gives URLs or named references:

- create at least one chapter, section, visual, or source note that explains how those references matter
- avoid turning the tutorial into a link summary; the references should become teaching structure

## Depth Contract

Default to a complete tutorial, not a short article. Do not use a fixed word-count ceiling as the stopping rule. The stopping rule is learning sufficiency: the tutorial is done only when a beginner can understand the concept, apply the method, avoid common mistakes, and self-check their result.

Use these depth rules instead of a hard length rule:

- If the user asks for a sample, preview, brief, one-pager, or time-boxed draft, intentionally keep it short and say it is a short version.
- If the user asks for a complete tutorial, course, manual, or "from beginner to mastery" guide, continue as long as needed to teach the outcome thoroughly.
- Chapter count is determined by the learning path. Use fewer chapters only when the concept is narrow; use more chapters when hidden prerequisites, workflows, cases, or safety boundaries would otherwise be compressed.
- Each chapter should be written with comparable care. Do not let middle or later chapters become thinner, more abstract, or less example-rich than early chapters.
- Every chapter needs enough explanation, examples, pitfalls, practice, and checkpoint detail for the learner question it answers.
- The final practice section should include a concrete exercise, a checkpoint, and a small self-review rubric.
- When a chapter is intentionally short, record the reason in `research/chapter-quality-review.md`.

Avoid padding. The goal is not more words; the goal is enough teaching surface for the learner to actually learn.

## Opening Rules

The opening should:

- start with a real situation or pain point
- avoid fake urgency and empty hype
- show why the topic matters before naming too much theory
- make the learner feel the path is doable
- state the outcome plainly

Good opening patterns:

- "You have seen X, but Y is the part that usually makes it confusing."
- "Imagine you need to do X tomorrow. The hard part is not A; it is B."
- "Most explanations start from the formula. We will start from the moment you actually need it."

## Chapter Contract

Each chapter must use this numbering pattern:

- H2: `## 第1章 章节标题`
- H3: `### 1.1 小节标题`, `### 1.2 小节标题`, continuing within the chapter
- Final practice can be a numbered chapter when it is part of the learning path.
- H3 headings must be specific to the outline item. Avoid repeated generic headings such as `你要做的事`, `你要注意什么`, `示例`, `检查点`, or `小结`.

Each chapter must include:

- chapter goal
- plain-language concept
- chapter visual with caption
- one guided example or walkthrough
- `what to notice` callouts
- common pitfall
- practice task
- checkpoint question
- source traceability in internal research notes; no visible bracket source IDs in the public tutorial

## Per-Chapter Quality Loop

After drafting each numbered chapter, run an independent chapter check before moving on or before final export. Create `research/chapter-quality-review.md` with one row per numbered chapter.

Minimum review columns:

```markdown
| chapter | learner_question | depth_status | example_or_case | practice_or_checkpoint | evidence_support | visual_fit | revision_notes |
|---|---|---|---|---|---|---|---|
| 第1章 ... | ... | pass / revise | ... | ... | ... | ... | ... |
```

Each chapter must pass these questions:

1. Does this chapter answer one clear learner question?
2. Would a beginner understand the idea without hidden prerequisites?
3. Does it include a concrete example, walkthrough, table, or reusable template?
4. Does it include a common pitfall, practice prompt, or checkpoint that makes the learner act?
5. Is the chapter's visual doing real teaching work rather than decoration?
6. Is the chapter at least as careful as the chapters before it, especially in the middle and back half?
7. Are factual claims traceable in `research/evidence-map.md` or clearly framed as practical guidance?

If any answer is weak, revise the chapter before export. Do not compensate for a weak chapter by adding a short summary at the end; strengthen the chapter itself.

Recommended chapter rhythm:

1. Start from a concrete scene challenge or learner question.
2. Name the failed old approach and why it does not work.
3. Introduce the new method or mental model.
4. Show the visual and tell the reader what to notice.
5. Walk through one concrete example, table, or template.
6. Give one small behavior prompt or practice task.
7. End with a specific checkpoint.

## Beginner-Friendly Rules

- Introduce terms only when they are needed.
- Define a term the first time it appears.
- Use one running example across chapters when possible.
- Prefer concrete nouns and visible steps.
- Separate must-know from nice-to-know.
- Put formulas, APIs, and jargon after a simple mental model.
- Use analogies only when they reduce confusion; say where the analogy breaks.
- End each chapter with a small success state.

## Evidence-To-Teaching Translation

For every important source-backed claim:

1. State the claim in plain language.
2. Explain why it matters to the learner.
3. Show it in an example or visual.
4. Keep the source ID in `research/evidence-map.md`, not in public prose.
5. Mention limits when the source is narrow, old, or context-specific.

For user-provided claims:

1. Preserve the intent.
2. Verify or qualify the factual part.
3. Keep the user's example when it teaches well.
4. Keep user material IDs in `research/user-materials-register.md`, not in public prose.
5. Add external source IDs in internal research notes when the claim needs authority beyond the user's note.

## Exercises

Include a mix of:

- recognition: identify the concept in a real example
- transformation: convert raw material into the learned structure
- application: solve a small realistic task
- reflection: explain a tradeoff in the learner's own words

Avoid exercises that require tools or accounts the learner has not been prepared to use.

## Final Tutorial Checklist

- The first page answers "why should I care?"
- The body depth is sufficient for the user's learning goal; no chapter is compressed only to fit a word count.
- The chapter order has no hidden prerequisites.
- Every chapter has a visual and a source-backed purpose.
- `research/chapter-quality-review.md` exists and covers every numbered chapter.
- The tutorial includes theory, practice, and case material.
- User-provided material has been used as the spine when it is strong enough, and external material is added only where it improves support.
- The source appendix is audit-friendly.
- Public prose contains no bracket source markers like `[U1]`, `[X1]`, `[A2]`, `[P3]`, or `[G4]`.
- Public prose does not expose internal provenance such as "user supplied material" or "based on the original article".
- Chapter and section headings match the learner path and do not repeat generic labels.
- The conclusion gives the next concrete learning step.


path: references/visual-board-benchmarks.md
content: # Visual Board Benchmarks

Use this guide before generating `visuals/visual-spec.json` or running `scripts/build_visual_pack.py`.

The visual board should not look like a random collection of generated SVG cards. Treat it as a small teaching canvas system inspired by proven open-source tools, while keeping the final output restrained and `frontend-slides`-first.

## Reference Projects

Checked on 2026-04-28:

| Project | GitHub | Current signal | What to borrow |
| --- | --- | ---: | --- |
| Excalidraw | https://github.com/excalidraw/excalidraw | ~122k stars | visual thinking canvas, reusable shape library, SVG/PNG export, approachable diagrams |
| Mermaid | https://github.com/mermaid-js/mermaid | ~87.7k stars | diagram grammar, deterministic text-to-diagram generation, flow/sequence/state semantics |
| reveal.js | https://github.com/hakimel/reveal.js | ~71.1k stars | HTML-first presentation pages, strong viewport framing, PDF export discipline |
| tldraw | https://github.com/tldraw/tldraw | ~46.6k stars | infinite-canvas mental model, custom shapes, bindings, snapping, exportable canvas primitives |
| Slidev | https://github.com/slidevjs/slidev | ~46.1k stars | Markdown-first authoring, themeable slides, diagrams, icons, PDF/PNG/PPTX export |
| xyflow / React Flow | https://github.com/xyflow/xyflow | ~36.3k stars | explicit nodes/edges, handles, background, controls, graph UI conventions |
| Markmap | https://github.com/markmap/markmap | ~12.7k stars | markdown-to-mindmap hierarchy, readable radial/branch structures |
| AntV G6 | https://github.com/antvis/G6 | ~12.1k stars | graph layout, palettes, interaction patterns, clustered relationships |
| Observable Plot | https://github.com/observablehq/plot | ~5.2k stars | layered marks, scales, compact data visualization grammar |

## Borrowed Principles

### 1. Canvas Before Card

Each visual is an artboard, not a decorative card:

- fixed 16:9 artboard for export
- safe area on all sides
- one dominant teaching model per artboard
- no nested cards unless they represent real nodes
- use a faint canvas surface only when it helps separate the diagram from the document background

### 2. Semantic Elements

Every shape must have a teaching role:

- `node`: concept, decision, actor, step, layer, criterion
- `edge`: dependency, causality, transition, feedback
- `group`: category, phase, system boundary, comparison side
- `axis`: tradeoff dimension or evaluation scale
- `mark`: data point, score, status, evidence
- `annotation`: short reading cue, never a paragraph

Do not add visual furniture that has no semantic role.

### 3. Explicit Diagram Grammar

Before styling, decide the grammar:

- `flow`: ordered transformation
- `layer`: stacked abstraction or maturity levels
- `comparison`: two-sided contrast
- `cycle`: feedback or iteration
- `mindmap`: concept dependencies around a center
- `matrix`: two-dimensional decision model
- `network`: nodes and relationships
- `timeline`: chronological change

If the chapter cannot be mapped to one grammar, rewrite the chapter visual idea before drawing.

### 4. Layout Rules

Use deterministic layouts rather than arbitrary placement:

- align shapes to a clear grid
- keep one focal axis or one focal center
- keep 6 or fewer primary nodes on one artboard; use groups for more detail
- keep node labels under 12 Chinese characters or 4 English words when possible
- keep detail labels under 18 Chinese characters or 9 English words
- use line weight hierarchy: structure `1-2px`, relationships `2-3px`, focal edges `3-4px`
- never let arrows cross labels
- keep at least `24px` between neighboring shapes

### 5. Typography Rules

Use slide-grade hierarchy:

- artboard title: `28-36px`, one or two lines
- node title: `17-22px`
- node detail: `12-15px`
- metadata/kicker: `11-13px`
- captions live outside the SVG unless the visual must stand alone
- no negative letter spacing
- no viewport-scaled font sizes inside SVG exports

### 6. Color Rules

Use color as information:

- one ink-blue focal accent by default
- neutral fills for most nodes
- second accent only for real contrast, such as "before vs after" or "risk vs opportunity"
- background should be quieter than all content
- use warm ivory or parchment surfaces instead of pure white screenshot slabs
- avoid gradients, glassmorphism, heavy shadows, decorative texture, and stock-poster colors

### 7. Board HTML Rules

The `visuals/index.html` page is a working board for inspection:

- left sticky text rail for chapter navigation
- right workspace with one artboard per chapter
- each artboard keeps its exact export aspect ratio
- show only chapter label, title, summary, visual, and caption
- no visible helper copy such as "generated pack", "how to use", or placeholder marketing text
- no duplicate document title blocks

### 8. Export Rules

The board is not finished until:

- every SVG opens directly in a browser
- every PNG screenshot is at least `2x` scale
- labels do not clip or overlap
- the main diagram is vertically balanced, with no large empty lower band or browser gutter in the captured PNG
- every captured PNG shows the complete artboard, including the bottom border or a deliberate bottom safe area
- the HTML board is readable at desktop and mobile widths
- Word/PDF screenshots remain crisp after embedding


path: references/visual-html-workflow.md
content: # Visual HTML Workflow

Every chapter needs one visual artifact. Use visuals to make structure visible, not to decorate.

Read `references/editorial-production.md` and `references/visual-board-benchmarks.md` before choosing the diagram style. The visual system should follow the `frontend-slides`-first rule: strong hierarchy, responsive density, no decorative-only elements, and crisp screenshots. Use `kami` only for restrained document typography and ink-blue discipline.

## Visual Types

Choose the simplest visual that explains the chapter:

- `flow`: process, sequence, pipeline, lifecycle
- `layer`: stack, architecture, abstraction levels
- `comparison`: before/after, option A vs option B
- `cycle`: feedback loop, iteration, reinforcement
- `mindmap`: concept map, vocabulary map, dependency map
- `matrix`: tradeoff grid, decision map, 2x2 model
- `network`: nodes and relationships
- `timeline`: chronological change

If a chapter needs a visual grammar outside these types, create a bespoke SVG/HTML visual rather than forcing the idea into the nearest template. Keep the same artboard, typography, color, caption, and screenshot rules.

## Visual Spec

Create `visuals/visual-spec.json` before generating graphics. Use `templates/visual-spec-template.json` as a starting point.

Each chapter entry should include:

- `id`: stable ID such as `chapter-01`; visible labels must render as `第1章`
- `title`
- `diagram_type`
- `summary`: what this visual teaches
- `nodes`: labels or objects with `label` and `detail`
- `caption`
- optional `center`
- optional `columns` for comparison charts
- optional `quadrants` for matrix charts
- optional `edges` for network/relationship charts
- optional `layout_note`: internal note explaining why this grammar is the clearest choice; do not render it in the final visual

For each chapter, also decide the visual job:

- mental model: explain a concept
- process: show a sequence
- architecture: show parts and relationships
- decision: show tradeoffs
- case: show what happens in practice
- checklist: show evaluation criteria

## Board Design Rules

Borrow the mature patterns from Excalidraw, tldraw, Mermaid, React Flow, Slidev, reveal.js, Markmap, AntV G6, and Observable Plot without copying their UI wholesale:

- Start from a semantic grammar: nodes, edges, groups, axes, marks, or annotations.
- Keep one artboard to one idea. If the chapter needs more than six primary nodes, group or split the visual.
- Use a fixed `16:9` export artboard with a visible safe area and deterministic layout.
- Prefer readable node-edge composition over generic card grids.
- Use Kami-style warm ivory paper instead of pure white slabs; the artboard should feel like a designed teaching canvas, not a browser screenshot.
- Keep the main diagram vertically balanced inside the safe area. Flow diagrams should not leave a large empty lower band after the final row of nodes.
- Give every visual a visible semantic frame: chapter label, title, short teaching summary, one diagram panel, and a small diagram-type label. Avoid a plain row of boxes floating on an empty canvas.
- Use restrained depth only when it improves scanability: subtle shadows, ink-blue emphasis, fine dividers, and warm surfaces. Do not add decorative gradients, blobs, or ornamental shapes.
- For `layer` visuals, split labels like `结构：文件和边界` into a primary concept and a secondary explanation so the learner can scan both levels.
- For `cycle` visuals, prefer curved arrows and a central idea over disconnected straight arrows.
- Use a quiet canvas board page for inspection: sticky text rail on the left, artboards on the right.
- Show only chapter label, title, summary, visual, and caption in the board page.
- Do not put helper copy, placeholder text, marketing copy, or generator labels into the board.
- Do not use background texture unless it encodes layout or canvas context.

## Generate The Visual Pack

From the tutorial output folder, run:

```bash
python3 /path/to/yao-tutorial-skill/scripts/build_visual_pack.py visuals/visual-spec.json visuals/
```

This creates:

- `visuals/index.html`
- one SVG file per chapter

Open `visuals/index.html` in a browser and inspect the layout before screenshotting.

## Screenshot And Embedding

Preferred path:

1. Generate SVG and HTML.
2. Open `visuals/index.html`.
3. Capture each visual into high-resolution `assets/screenshots/<chapter-id>.png`. The capture script should render the SVG inside a temporary zero-margin HTML page, capture a slightly taller browser viewport, then crop back to the exact SVG aspect ratio. This avoids macOS Chrome headless clipping the lower part of the artboard.

   ```bash
   python3 /path/to/yao-tutorial-skill/scripts/capture_visuals.py visuals/ assets/screenshots/
   ```

4. Embed the screenshot in `tutorial.md`:

   ```markdown
   ![第1章：Chapter title](assets/screenshots/chapter-01.png)
   ```

Fallback path:

- If screenshots fail, embed the local SVG path in markdown and keep `visuals/index.html` as the visual deliverable.
- If a diagram is too dense, reduce nodes before styling.
- If the built-in renderer cannot express the chapter's core relationship, manually create a custom SVG or HTML artboard in `visuals/`, document the choice in `visual-spec.json`, and capture it with the same screenshot workflow.

## Visual Quality Gate

- Each visual explains one idea.
- The visual has an explicit grammar and semantic elements, not arbitrary boxes.
- Labels fit inside shapes.
- The caption tells the learner what to notice.
- The visual can stand alone in PDF and Word exports.
- The visual is referenced in the chapter text.
- The visual uses source-backed entities when it represents facts, architecture, or a published model.
- Visuals based on user-provided examples should cite or trace to `U` source IDs in the chapter text or caption.
- PNG screenshots are generated at `2x` scale unless the user asks for lighter files.
- Screenshots should not contain visible browser gutters, pure-white bottom strips, or large unused artboard areas.
- The bottom edge of the visual artboard must be visible in the PNG; if the last rows match the page background instead of the artboard/border, recapture before exporting.
- No visual uses generic decoration as a substitute for structure.
- Repeated visual types are intentional; avoid using the same flow layout for every chapter.
- The visual HTML index must not include helper copy such as "generated visual pack"; it should only show the real title, chapter visuals, and captions.
- The visual HTML index should look like a compact canvas board, not a blog page or a stack of decorative cards.


path: reports/course-marketing-design-notes.md
content: # Course Marketing Design Notes

Source reviewed: `/Users/laoyao/Downloads/《课程营销学：AI时代，如何快速做出爆品课》 .pdf`.

This report records the distilled principles integrated into `yao-tutorial-skill`. It is an internal design note, not public tutorial content.

## Sections Prioritized

- Opening method: every section should close the loop from method to case to reusable template.
- `2.1 名称`: course title design and five title attraction methods.
- `2.2 大纲`: "say human words" outline design, knowledge curse, and point-line-surface-system.
- `2.3 内容`: three levels of course content and the experience formula.
- `2.4 材料`: use stories, models, diagrams, tables, memorable lines, and emotional material to improve retention.

## Integrated Rules

1. Public tutorials must not expose internal source markers such as `[U1]`.
2. Final copy should be standalone, as if it were a finished public tutorial, not a note saying it was based on a reference article.
3. Titles should communicate user benefit, contrast, visual action, attachment, or authority.
4. Outlines should combine professional content with learner pain or desired outcome.
5. H3 headings should be specific learning items, not repeated generic labels.
6. Chapter structure should move from concrete scene to failed old approach, new method, result, and practice.
7. Content should not stop at knowledge transfer; it should change cognition and prompt a small action.
8. The default teaching rhythm is method, case, reusable checklist/template when appropriate.

## Validation Added

`scripts/validate_package.py` now fails packages that:

- expose `[U1]`, `[X1]`, `[A1]`, `[P1]`, `[G1]`, or similar source markers in `tutorial.md`, HTML, DOCX, or PDF text
- use public provenance wording such as `用户粘贴`, `基于...文章`, `根据...原文整理`
- repeat generic H3 labels such as `你要做的事`, `你要注意什么`, `示例`, or `检查点` across chapters



path: reports/reference-scan.md
content: # Reference Scan

## Current Skill Anchor

Create a reusable production-style skill that turns any topic into a beginner tutorial package using multi-source research, a verified outline, per-chapter visuals, and DOCX/PDF/HTML exports.

## Scan Focus

- `method`: how to preserve tutorial pedagogy instead of dumping research notes
- `execution`: how to search X, papers, GitHub, and authority sources reliably
- `structure`: how to keep the package lean while still supporting visuals and exports

## External Benchmark Objects

1. Diataxis tutorials
   - URL: `https://diataxis.fr/tutorials/`
   - Pattern borrowed: a tutorial is a guided learning experience with meaningful learner action, small steps, and visible results.
2. X API Search Posts and Search Operators docs
   - URLs: `https://docs.x.com/x-api/posts/search/introduction`, `https://docs.x.com/x-api/posts/search/integrate/operators`
   - Pattern borrowed: use explicit search operators and treat access limits as part of the evidence record.
3. Semantic Scholar Academic Graph API
   - URL: `https://www.semanticscholar.org/product/api`
   - Pattern borrowed: paper search should capture metadata, source links, and rate/access caveats rather than relying on memory.
4. GitHub Docs: Searching for repositories
   - URL: `https://docs.github.com/en/search-github/searching-on-github/searching-for-repositories`
   - Pattern borrowed: use repository qualifiers such as `in:readme`, `stars`, `pushed`, `language`, and `topic`, then verify quality manually.
5. Local `learning-builder`
   - URL: local skill package
   - Pattern borrowed: markdown as canonical source, authority-first research, and export as a downstream step.
6. Local `kami`
   - URL: local skill package
   - Pattern borrowed: warm-paper editorial design, serif-led hierarchy, ink-blue accent discipline, bilingual document typography, and tight document spacing.
7. Local `frontend-slides`
   - URL: local skill package
   - Pattern borrowed: viewport awareness, content density limits, distinctive typography, and anti-generic visual guidance.
8. Local `yao-ppt-skill`
   - URL: local skill package
   - Pattern borrowed: editorial rhythm, image-first planning, quality gates, and guizang-inspired magazine/e-ink constraints.

## User-Supplied References

The user supplied the target workflow in Chinese:

- arbitrary topic input
- research from X.com, papers and paper platforms, authoritative sharing sites, and GitHub
- theory, practice, cases, and related information
- beginner-friendly tutorial outline
- attractive opening
- detailed tutorial content
- one visual per chapter through HTML visual pages
- final PDF, Word, and HTML outputs

## Local Fit Constraints

- The workspace already contains `learning-builder`, `docx`, `pdf`, `kami`, and page-generation skills. This skill should orchestrate document export and visual generation without becoming a full layout engine.
- The requested `guizang-ppt-skill` was not present as a standalone local directory in this workspace. The available `yao-ppt-skill` explicitly borrows guizang-style magazine/e-ink constraints, so this iteration uses `yao-ppt-skill` as the local PPT design reference.
- The new skill should be routeable separately from `learning-builder` because it emphasizes arbitrary-topic research breadth, visual HTML generation, and full publishing artifacts.
- Scripts should use stdlib Python where possible and rely on `pandoc` plus a Chromium-family browser only for export.

## What To Borrow

- From Diataxis: tutorial as learning path, not reference dump.
- From X docs: operator-based social search with access limitation notes.
- From Semantic Scholar: source metadata discipline.
- From GitHub docs: qualifier-driven repository discovery.
- From local `learning-builder`: markdown source of truth and export pipeline.
- From local `frontend-slides`: primary layout discipline, density control, `clamp()` responsiveness, and anti-generic visual standards.
- From local `kami`: document typography, restrained ink-blue use, and Word/PDF polish.
- From local `yao-ppt-skill`: guizang/e-ink-inspired rhythm, quality gates, and diagram restraint.

## What Not To Borrow

- Do not duplicate a full LMS or curriculum operations system.
- Do not treat social posts as authoritative evidence.
- Do not make visuals decorative or separate from the chapter learning goals.
- Do not build a heavyweight publishing platform in v1.
- Do not force slide rules onto long-form tutorials; borrow their rhythm and quality gates only.

## Borrow Plan

1. Keep `SKILL.md` as the route and operator flow.
2. Put search rules, writing rules, visual rules, and export rules in references.
3. Add one visual-generation script and one export script because they directly support the requested recurring job.
4. Add trigger evals because this skill has near-neighbor confusion with generic research, learning guides, document conversion, and webpage design.
5. Add an editorial production reference, sticky HTML TOC styling, high-resolution screenshot capture, and default DOCX reference generation for polished deliverables.
6. Revise the visual priority to `frontend-slides` first: remove decorative-only paper texture, duplicate title blocks, helper copy, heavy shadows, and visual-pack boilerplate from formal outputs.


path: scripts/build_reference_doc.py
content: #!/usr/bin/env python3
"""Create an editorial Word reference document for tutorial exports."""

from __future__ import annotations

import argparse
import shutil
import subprocess
import tempfile
import zipfile
from xml.etree import ElementTree as ET
from pathlib import Path


W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
ET.register_namespace("w", W_NS)


def w_tag(name: str) -> str:
    return f"{{{W_NS}}}{name}"


def ensure_child(parent: ET.Element, name: str) -> ET.Element:
    child = parent.find(w_tag(name))
    if child is None:
        child = ET.SubElement(parent, w_tag(name))
    return child


def set_attr(element: ET.Element, name: str, value: str) -> None:
    element.set(w_tag(name), value)


def patch_style(style: ET.Element, latin: str, east_asia: str, size_half_points: str, color: str = "172033") -> None:
    rpr = ensure_child(style, "rPr")
    rfonts = ensure_child(rpr, "rFonts")
    for attr in ["ascii", "hAnsi", "cs"]:
        set_attr(rfonts, attr, latin)
    set_attr(rfonts, "eastAsia", east_asia)
    sz = ensure_child(rpr, "sz")
    set_attr(sz, "val", size_half_points)
    szcs = ensure_child(rpr, "szCs")
    set_attr(szcs, "val", size_half_points)
    color_node = ensure_child(rpr, "color")
    set_attr(color_node, "val", color)


def patch_reference_docx(target: Path) -> None:
    with tempfile.TemporaryDirectory() as tmp_dir:
        tmp_path = Path(tmp_dir)
        with zipfile.ZipFile(target, "r") as archive:
            archive.extractall(tmp_path)

        styles_path = tmp_path / "word" / "styles.xml"
        tree = ET.parse(styles_path)
        root = tree.getroot()

        doc_defaults = root.find(w_tag("docDefaults"))
        if doc_defaults is not None:
            rpr_default = ensure_child(doc_defaults, "rPrDefault")
            rpr = ensure_child(rpr_default, "rPr")
            rfonts = ensure_child(rpr, "rFonts")
            for attr in ["ascii", "hAnsi", "cs"]:
                set_attr(rfonts, attr, "Georgia")
            set_attr(rfonts, "eastAsia", "Noto Sans CJK SC")

        style_specs = {
            "Normal": ("Georgia", "Noto Sans CJK SC", "22", "172033"),
            "BodyText": ("Georgia", "Noto Sans CJK SC", "22", "172033"),
            "Title": ("Georgia", "Noto Serif CJK SC", "48", "172033"),
            "Heading1": ("Georgia", "Noto Serif CJK SC", "40", "172033"),
            "Heading2": ("Georgia", "Noto Serif CJK SC", "32", "172033"),
            "Heading3": ("Georgia", "Noto Serif CJK SC", "26", "172033"),
            "Caption": ("Arial", "Noto Sans CJK SC", "18", "697083"),
        }

        for style in root.findall(w_tag("style")):
            style_id = style.get(w_tag("styleId"))
            if style_id in style_specs:
                patch_style(style, *style_specs[style_id])

        tree.write(styles_path, encoding="UTF-8", xml_declaration=True)

        patched = target.with_suffix(".patched.docx")
        with zipfile.ZipFile(patched, "w", zipfile.ZIP_DEFLATED) as archive:
            for path in tmp_path.rglob("*"):
                if path.is_file():
                    archive.write(path, path.relative_to(tmp_path).as_posix())
        shutil.move(patched, target)


def build_reference_doc_with_pandoc(target: Path) -> None:
    data = subprocess.check_output(["pandoc", "--print-default-data-file", "reference.docx"])
    target.parent.mkdir(parents=True, exist_ok=True)
    target.write_bytes(data)
    patch_reference_docx(target)


def set_style_font(style, latin: str, east_asia: str, size_pt: float | None = None, bold: bool | None = None) -> None:
    from docx.oxml.ns import qn
    from docx.shared import Pt

    font = style.font
    font.name = latin
    if size_pt:
        font.size = Pt(size_pt)
    if bold is not None:
        font.bold = bold
    style.element.rPr.rFonts.set(qn("w:eastAsia"), east_asia)


def build_reference_doc(target: Path) -> None:
    try:
        from docx import Document
        from docx.enum.style import WD_STYLE_TYPE
        from docx.enum.text import WD_ALIGN_PARAGRAPH
        from docx.shared import Cm, Pt, RGBColor
    except Exception:
        build_reference_doc_with_pandoc(target)
        return

    document = Document()
    section = document.sections[0]
    section.top_margin = Cm(2.2)
    section.bottom_margin = Cm(2.2)
    section.left_margin = Cm(2.35)
    section.right_margin = Cm(2.35)
    section.header_distance = Cm(0)
    section.footer_distance = Cm(0)

    styles = document.styles
    set_style_font(styles["Normal"], "Georgia", "Noto Sans CJK SC", 11.0, False)
    styles["Normal"].paragraph_format.line_spacing = 1.48
    styles["Normal"].paragraph_format.space_after = Pt(6)

    for name, size, after in [
        ("Title", 24, 14),
        ("Heading 1", 20, 12),
        ("Heading 2", 16, 10),
        ("Heading 3", 13, 8),
    ]:
        set_style_font(styles[name], "Georgia", "Noto Serif CJK SC", size, True)
        styles[name].font.color.rgb = RGBColor(23, 32, 51)
        styles[name].paragraph_format.space_before = Pt(after)
        styles[name].paragraph_format.space_after = Pt(6)
        styles[name].paragraph_format.keep_with_next = True

    set_style_font(styles["Body Text"], "Georgia", "Noto Sans CJK SC", 11.0, False)
    styles["Body Text"].paragraph_format.line_spacing = 1.48
    styles["Body Text"].paragraph_format.space_after = Pt(6)

    if "Caption" in styles:
        set_style_font(styles["Caption"], "Arial", "Noto Sans CJK SC", 9.0, False)
        styles["Caption"].font.color.rgb = RGBColor(105, 112, 131)
        styles["Caption"].paragraph_format.alignment = WD_ALIGN_PARAGRAPH.CENTER

    if "Source ID" not in styles:
        source_style = styles.add_style("Source ID", WD_STYLE_TYPE.CHARACTER)
        set_style_font(source_style, "Courier New", "Noto Sans Mono CJK SC", 9.0, False)
        source_style.font.color.rgb = RGBColor(31, 78, 121)

    document.add_paragraph("Reference document for yao-tutorial-skill exports.", style="Normal")
    target.parent.mkdir(parents=True, exist_ok=True)
    document.save(target)


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Build an editorial DOCX reference file.")
    parser.add_argument("target", help="Path to write the reference docx.")
    return parser.parse_args()


def main() -> None:
    args = parse_args()
    build_reference_doc(Path(args.target).resolve())
    print(f"Reference doc written to {Path(args.target).resolve()}")


if __name__ == "__main__":
    main()


path: scripts/build_visual_pack.py
content: #!/usr/bin/env python3
"""Build a self-contained HTML/SVG visual pack from a tutorial visual spec."""

from __future__ import annotations

import argparse
import html
import json
import math
import re
from pathlib import Path
from typing import Any


DEFAULT_THEME = {
    "accent": "#1f4e79",
    "accent2": "#6f7682",
    "ink": "#111827",
    "muted": "#5f6673",
    "surface": "#f5f4ed",
    "border": "#d9d6cc",
    "paper": "#faf9f5",
    "canvas": "#f2f0ea",
    "node": "#faf9f5",
    "node_alt": "#f7f5ee",
    "grid": "#e8e4da",
    "strong_border": "#c9c4b8",
}


def esc(value: Any) -> str:
    return html.escape(str(value), quote=True)


def slugify(value: str) -> str:
    slug = re.sub(r"[^a-zA-Z0-9]+", "-", value.strip().lower()).strip("-")
    return slug


def normalize_node(node: Any) -> dict[str, str]:
    if isinstance(node, dict):
        label = node.get("label") or node.get("title") or node.get("name") or "Item"
        detail = node.get("detail") or node.get("description") or ""
        return {"label": str(label), "detail": str(detail)}
    text = str(node)
    if "：" in text:
        label, detail = text.split("：", 1)
        return {"label": label.strip(), "detail": detail.strip()}
    if ":" in text:
        label, detail = text.split(":", 1)
        return {"label": label.strip(), "detail": detail.strip()}
    return {"label": text, "detail": ""}


def wrap_text(value: str, max_chars: int = 20, max_lines: int = 3) -> list[str]:
    text = " ".join(str(value).split())
    if not text:
        return [""]
    words = text.split(" ")
    lines: list[str] = []
    current = ""
    if len(words) == 1:
        chunks = [text[i : i + max_chars] for i in range(0, len(text), max_chars)]
        return chunks[:max_lines]
    for word in words:
        candidate = word if not current else f"{current} {word}"
        if len(candidate) <= max_chars:
            current = candidate
        else:
            if current:
                lines.append(current)
            current = word
    if current:
        lines.append(current)
    return lines[:max_lines]


def text_block(
    x: float,
    y: float,
    value: str,
    max_chars: int,
    size: int,
    color: str,
    weight: int = 600,
    anchor: str = "middle",
    max_lines: int = 3,
) -> str:
    lines = wrap_text(value, max_chars=max_chars, max_lines=max_lines)
    out = [f'<text x="{x:.1f}" y="{y:.1f}" text-anchor="{anchor}" fill="{color}" font-size="{size}" font-weight="{weight}">']
    for index, line in enumerate(lines):
        dy = 0 if index == 0 else size + 4
        out.append(f'<tspan x="{x:.1f}" dy="{dy}">{esc(line)}</tspan>')
    out.append("</text>")
    return "".join(out)


def theme_color(theme: dict[str, str], key: str) -> str:
    return theme.get(key) or DEFAULT_THEME[key]


def secondary_accent(theme: dict[str, str]) -> str:
    accent = theme_color(theme, "accent")
    accent2 = theme_color(theme, "accent2")
    return accent2 if accent2.lower() != accent.lower() else "#6f7682"


def chapter_number(module_id: str) -> int | None:
    match = re.search(r"(?:module|chapter)-0*([0-9]+)$", module_id, re.IGNORECASE)
    if not match:
        return None
    return int(match.group(1))


def chapter_label(module_id: str, padded: bool = False) -> str:
    number = chapter_number(module_id)
    if number is None:
        return module_id.upper() if module_id else "章节"
    if padded:
        return f"第{number:02d}章"
    return f"第{number}章"


def line(
    x1: float,
    y1: float,
    x2: float,
    y2: float,
    theme: dict[str, str],
    width: int = 2,
    arrow: bool = False,
    color: str | None = None,
) -> str:
    marker = ' marker-end="url(#arrow)"' if arrow else ""
    stroke = color or theme_color(theme, "accent")
    return f'<line x1="{x1:.1f}" y1="{y1:.1f}" x2="{x2:.1f}" y2="{y2:.1f}" stroke="{stroke}" stroke-width="{width}" stroke-linecap="round"{marker}/>'


def curved_line(
    x1: float,
    y1: float,
    x2: float,
    y2: float,
    theme: dict[str, str],
    bend: float = 36,
    width: int = 2,
    arrow: bool = True,
    color: str | None = None,
) -> str:
    dx, dy = x2 - x1, y2 - y1
    length = math.hypot(dx, dy) or 1
    nx, ny = -dy / length, dx / length
    mx, my = (x1 + x2) / 2, (y1 + y2) / 2
    cx, cy = mx + nx * bend, my + ny * bend
    marker = ' marker-end="url(#arrow)"' if arrow else ""
    stroke = color or theme_color(theme, "accent")
    return f'<path d="M{x1:.1f},{y1:.1f} Q{cx:.1f},{cy:.1f} {x2:.1f},{y2:.1f}" fill="none" stroke="{stroke}" stroke-width="{width}" stroke-linecap="round"{marker}/>'


def node_card(
    x: float,
    y: float,
    w: float,
    h: float,
    node: dict[str, str],
    theme: dict[str, str],
    index: int | None = None,
    accent: bool = False,
    fill: str | None = None,
) -> str:
    node_fill = fill or (theme_color(theme, "node_alt") if accent else theme_color(theme, "node"))
    border = theme_color(theme, "accent") if accent else theme_color(theme, "border")
    parts = [
        f'<rect x="{x:.1f}" y="{y:.1f}" width="{w:.1f}" height="{h:.1f}" rx="8" fill="{node_fill}" stroke="{border}" stroke-width="1.4" filter="url(#soft-shadow)"/>'
    ]
    if accent:
        parts.append(f'<rect x="{x:.1f}" y="{y:.1f}" width="{w:.1f}" height="5" rx="2.5" fill="{theme_color(theme, "accent")}"/>')
    text_x = x + 22
    if index is not None:
        parts.append(
            f'<circle cx="{x + 26:.1f}" cy="{y + 28:.1f}" r="15" fill="{theme_color(theme, "accent")}"/>'
        )
        parts.append(
            text_block(x + 26, y + 33, str(index), 2, 12, "#ffffff", 800, max_lines=1)
        )
        text_x = x + 52
    parts.append(text_block(text_x, y + 33, node["label"], 15, 17, theme_color(theme, "ink"), 760, anchor="start", max_lines=2))
    if node["detail"]:
        parts.append(text_block(x + 22, y + h - 28, node["detail"], 22, 12, theme_color(theme, "muted"), 560, anchor="start", max_lines=2))
    return "\n  ".join(parts)


def svg_shell(
    title: str,
    body: str,
    theme: dict[str, str],
    module_id: str = "",
    summary: str = "",
    width: int = 1200,
    height: int = 675,
) -> str:
    kicker = esc(chapter_label(module_id))
    return f'''<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}" role="img" aria-label="{esc(title)}" font-family="Noto Sans SC, Source Han Sans SC, PingFang SC, system-ui, sans-serif">
  <defs>
    <filter id="soft-shadow" x="-8%" y="-12%" width="116%" height="128%">
      <feDropShadow dx="0" dy="5" stdDeviation="7" flood-color="#111827" flood-opacity="0.08"/>
    </filter>
    <pattern id="dot-grid" width="28" height="28" patternUnits="userSpaceOnUse">
      <circle cx="1.5" cy="1.5" r="1.2" fill="{theme_color(theme, "grid")}" opacity="0.85"/>
    </pattern>
    <marker id="arrow" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto" markerUnits="strokeWidth">
      <path d="M2,2 L10,6 L2,10" fill="none" stroke="{theme_color(theme, "accent")}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
    </marker>
  </defs>
  <rect width="{width}" height="{height}" fill="{theme_color(theme, "surface")}"/>
  <rect x="1" y="1" width="{width - 2}" height="{height - 2}" rx="18" fill="{theme_color(theme, "paper")}" stroke="{theme_color(theme, "border")}" stroke-width="1.6"/>
  <rect x="1" y="1" width="{width - 2}" height="{height - 2}" rx="18" fill="url(#dot-grid)" opacity="0.34"/>
  <rect x="72" y="206" width="1056" height="390" rx="16" fill="{theme_color(theme, "surface")}" stroke="{theme_color(theme, "border")}" stroke-width="1.1" opacity="0.82"/>
  <text x="84" y="82" fill="{theme_color(theme, "accent")}" font-size="12" font-weight="800" letter-spacing="1.2">{kicker}</text>
  <text x="1116" y="82" text-anchor="end" fill="{theme_color(theme, "muted")}" font-size="11" font-weight="700" letter-spacing="1">教学图示</text>
  <rect x="84" y="100" width="54" height="4" rx="2" fill="{theme_color(theme, "accent")}"/>
  {text_block(84, 136, title, 28, 31, theme_color(theme, "ink"), 800, anchor="start", max_lines=2)}
  {text_block(84, 178, summary, 58, 15, theme_color(theme, "muted"), 500, anchor="start", max_lines=2) if summary else ""}
  {body}
</svg>
'''


def module_nodes(module: dict[str, Any]) -> list[dict[str, str]]:
    nodes = module.get("nodes") or []
    return [normalize_node(node) for node in nodes][:8]


def draw_flow(module: dict[str, Any], theme: dict[str, str]) -> str:
    nodes = module_nodes(module)[:6] or [{"label": "Step", "detail": ""}]
    count = len(nodes)
    y = 334
    card_h = 148
    gap = 18
    usable_w = 1012
    card_w = (usable_w - gap * (count - 1)) / count
    start_x = 94
    parts = [
        f'<text x="94" y="252" fill="{theme_color(theme, "muted")}" font-size="12" font-weight="700">从输入到结果的最短学习路径</text>',
        f'<line x1="94" y1="274" x2="1106" y2="274" stroke="{theme_color(theme, "border")}" stroke-width="1.4"/>',
    ]
    for index, node in enumerate(nodes):
        x = start_x + index * (card_w + gap)
        if index < count - 1:
            parts.append(line(x + card_w + 8, y + card_h / 2, x + card_w + gap - 8, y + card_h / 2, theme, 3, True))
        parts.append(node_card(x, y, card_w, card_h, node, theme, index=index + 1, accent=index in {0, count - 1}))
    return svg_shell(str(module.get("title", "Flow")), "\n  ".join(parts), theme, str(module.get("id", "")), str(module.get("summary", "")))


def draw_layers(module: dict[str, Any], theme: dict[str, str]) -> str:
    nodes = module_nodes(module)[:6] or [{"label": "Layer", "detail": ""}]
    count = len(nodes)
    start_y = 236
    layer_h = min(54, (330 - (count - 1) * 12) / count)
    parts = [
        f'<text x="168" y="222" fill="{theme_color(theme, "muted")}" font-size="12" font-weight="700">越往下越接近底层机制，越往上越接近用户可见结果</text>',
    ]
    for index, node in enumerate(nodes):
        y = start_y + index * (layer_h + 12)
        x = 168 + index * 28
        w = 864 - index * 56
        fill = theme_color(theme, "node") if index % 2 == 0 else theme_color(theme, "node_alt")
        parts.append(f'<rect x="{x:.1f}" y="{y:.1f}" width="{w:.1f}" height="{layer_h:.1f}" rx="8" fill="{fill}" stroke="{theme_color(theme, "border")}" stroke-width="1.4" filter="url(#soft-shadow)"/>')
        parts.append(f'<rect x="{x:.1f}" y="{y:.1f}" width="7" height="{layer_h:.1f}" rx="3.5" fill="{theme_color(theme, "accent")}"/>')
        parts.append(text_block(x + 30, y + layer_h / 2 + 7, node["label"], 18, 19, theme_color(theme, "ink"), 780, anchor="start", max_lines=1))
        if node["detail"]:
            parts.append(text_block(x + w - 24, y + layer_h / 2 + 5, node["detail"], 22, 13, theme_color(theme, "muted"), 560, anchor="end", max_lines=1))
    return svg_shell(str(module.get("title", "Layers")), "\n  ".join(parts), theme, str(module.get("id", "")), str(module.get("summary", "")))


def draw_comparison(module: dict[str, Any], theme: dict[str, str]) -> str:
    columns = module.get("columns")
    if not isinstance(columns, list) or len(columns) < 2:
        nodes = module_nodes(module)
        midpoint = max(1, math.ceil(len(nodes) / 2))
        columns = [
            {"title": "Before", "items": [node["label"] for node in nodes[:midpoint]]},
            {"title": "After", "items": [node["label"] for node in nodes[midpoint:]]},
        ]
    columns = columns[:2]
    parts = []
    colors = [theme_color(theme, "accent"), secondary_accent(theme)]
    for index, column in enumerate(columns):
        x = 94 + index * 520
        title = str(column.get("title", f"Option {index + 1}")) if isinstance(column, dict) else f"Option {index + 1}"
        items = column.get("items", []) if isinstance(column, dict) else []
        parts.append(f'<rect x="{x}" y="222" width="492" height="344" rx="12" fill="{theme_color(theme, "node")}" stroke="{theme_color(theme, "border")}" stroke-width="1.6"/>')
        parts.append(f'<rect x="{x}" y="222" width="492" height="58" rx="12" fill="{colors[index]}" opacity="0.96"/>')
        parts.append(f'<rect x="{x}" y="262" width="492" height="18" fill="{colors[index]}" opacity="0.96"/>')
        parts.append(text_block(x + 28, 258, title, 24, 21, "#ffffff", 800, anchor="start", max_lines=1))
        for item_index, item in enumerate(items[:5]):
            y = 324 + item_index * 46
            parts.append(f'<circle cx="{x + 36}" cy="{y - 6}" r="6" fill="{colors[index]}"/>')
            parts.append(text_block(x + 56, y, str(item), 38, 17, theme_color(theme, "ink"), 600, anchor="start", max_lines=2))
    return svg_shell(str(module.get("title", "Comparison")), "\n  ".join(parts), theme, str(module.get("id", "")), str(module.get("summary", "")))


def draw_cycle(module: dict[str, Any], theme: dict[str, str]) -> str:
    nodes = module_nodes(module)[:6] or [{"label": "Iterate", "detail": ""}]
    count = len(nodes)
    cx, cy = 600, 408
    positions = [
        (600, 244),
        (840, 318),
        (748, 508),
        (452, 508),
        (360, 318),
        (600, 560),
    ][:count]
    parts = [
        f'<circle cx="{cx}" cy="{cy}" r="168" fill="none" stroke="{theme_color(theme, "border")}" stroke-width="1.2" stroke-dasharray="5 9"/>',
    ]
    centers = [(x, y) for x, y in positions]
    card_w, card_h = 172, 84

    def edge_point(source: tuple[float, float], target: tuple[float, float]) -> tuple[float, float]:
        dx, dy = target[0] - source[0], target[1] - source[1]
        if dx == 0 and dy == 0:
            return source
        sx = (card_w / 2 + 8) / abs(dx) if dx else float("inf")
        sy = (card_h / 2 + 8) / abs(dy) if dy else float("inf")
        scale = min(sx, sy)
        return source[0] + dx * scale, source[1] + dy * scale

    for index, source in enumerate(centers):
        target = centers[(index + 1) % count]
        sx, sy = edge_point(source, target)
        ex, ey = edge_point(target, source)
        parts.append(curved_line(sx, sy, ex, ey, theme, bend=22, width=2, arrow=True, color=secondary_accent(theme)))
    parts.append(f'<circle cx="{cx}" cy="{cy}" r="86" fill="{theme_color(theme, "node_alt")}" stroke="{theme_color(theme, "border")}" stroke-width="1.4" filter="url(#soft-shadow)"/>')
    center = str(module.get("center") or module.get("title") or "Cycle")
    parts.append(text_block(cx, cy - 4, center, 16, 20, theme_color(theme, "ink"), 800, max_lines=2))
    for index, node in enumerate(nodes):
        x, y = positions[index]
        parts.append(node_card(x - card_w / 2, y - card_h / 2, card_w, card_h, node, theme, index=index + 1, accent=index == 0))
    return svg_shell(str(module.get("title", "Cycle")), "\n  ".join(parts), theme, str(module.get("id", "")), str(module.get("summary", "")))


def draw_matrix(module: dict[str, Any], theme: dict[str, str]) -> str:
    quadrants = module.get("quadrants")
    if not isinstance(quadrants, list) or len(quadrants) < 4:
        nodes = module_nodes(module)[:4]
        quadrants = [{"title": node["label"], "detail": node["detail"]} for node in nodes]
    quadrants = quadrants[:4]
    coords = [(104, 236), (624, 236), (104, 424), (624, 424)]
    parts = [
        f'<line x1="600" y1="220" x2="600" y2="588" stroke="{theme_color(theme, "strong_border")}" stroke-width="2"/>',
        f'<line x1="86" y1="406" x2="1114" y2="406" stroke="{theme_color(theme, "strong_border")}" stroke-width="2"/>',
    ]
    for index, quadrant in enumerate(quadrants):
        x, y = coords[index]
        title = str(quadrant.get("title", f"Quadrant {index + 1}")) if isinstance(quadrant, dict) else str(quadrant)
        detail = str(quadrant.get("detail", "")) if isinstance(quadrant, dict) else ""
        node = {"label": title, "detail": detail}
        parts.append(node_card(x, y, 472, 142, node, theme, index=index + 1, accent=index == 0))
        if detail:
            pass
    return svg_shell(str(module.get("title", "Matrix")), "\n  ".join(parts), theme, str(module.get("id", "")), str(module.get("summary", "")))


def draw_mindmap(module: dict[str, Any], theme: dict[str, str]) -> str:
    nodes = module_nodes(module)[:6] or [{"label": "Idea", "detail": ""}]
    cx, cy = 600, 408
    positions = [
        (210, 246),
        (802, 246),
        (164, 372),
        (848, 372),
        (210, 498),
        (802, 498),
    ][: len(nodes)]
    parts = []
    for x, y in positions:
        end_x = x + 204 if x < cx else x
        parts.append(line(cx, cy, end_x, y + 43, theme, 2, False, theme_color(theme, "border")))
    parts.append(f'<rect x="452" y="350" width="296" height="116" rx="14" fill="{theme_color(theme, "accent")}"/>')
    center = str(module.get("center") or module.get("title") or "Core idea")
    parts.append(text_block(cx, cy - 4, center, 18, 21, "#ffffff", 800, max_lines=2))
    for index, node in enumerate(nodes):
        x, y = positions[index]
        parts.append(node_card(x, y, 204, 86, node, theme, index=None, accent=index == 0))
    return svg_shell(str(module.get("title", "Mindmap")), "\n  ".join(parts), theme, str(module.get("id", "")), str(module.get("summary", "")))


def draw_network(module: dict[str, Any], theme: dict[str, str]) -> str:
    nodes = module_nodes(module)[:6] or [{"label": "Node", "detail": ""}]
    positions = [
        (184, 260),
        (486, 232),
        (812, 260),
        (930, 470),
        (540, 512),
        (226, 470),
    ][: len(nodes)]
    parts = []
    edges = module.get("edges")
    if isinstance(edges, list) and edges:
        for edge in edges[:10]:
            if not isinstance(edge, dict):
                continue
            try:
                source = int(str(edge.get("source", "1")).replace("node-", "")) - 1
                target = int(str(edge.get("target", "1")).replace("node-", "")) - 1
            except ValueError:
                continue
            if source < 0 or target < 0 or source >= len(positions) or target >= len(positions):
                continue
            x1, y1 = positions[source]
            x2, y2 = positions[target]
            parts.append(line(x1 + 102, y1 + 43, x2 + 102, y2 + 43, theme, 2, True, secondary_accent(theme)))
    else:
        for index in range(len(positions) - 1):
            x1, y1 = positions[index]
            x2, y2 = positions[index + 1]
            parts.append(line(x1 + 102, y1 + 43, x2 + 102, y2 + 43, theme, 2, True, secondary_accent(theme)))
    for index, node in enumerate(nodes):
        x, y = positions[index]
        parts.append(node_card(x, y, 204, 86, node, theme, index=index + 1, accent=index == 0))
    return svg_shell(str(module.get("title", "Network")), "\n  ".join(parts), theme, str(module.get("id", "")), str(module.get("summary", "")))


def draw_timeline(module: dict[str, Any], theme: dict[str, str]) -> str:
    nodes = module_nodes(module)[:6] or [{"label": "Event", "detail": ""}]
    count = len(nodes)
    y = 410
    start_x = 128
    end_x = 1072
    step = 0 if count == 1 else (end_x - start_x) / (count - 1)
    parts = [line(start_x, y, end_x, y, theme, 3, False)]
    for index, node in enumerate(nodes):
        x = start_x + index * step
        parts.append(f'<circle cx="{x:.1f}" cy="{y:.1f}" r="10" fill="{theme_color(theme, "accent")}"/>')
        card_y = 252 if index % 2 == 0 else 456
        parts.append(line(x, y, x, card_y + (88 if index % 2 == 0 else 0), theme, 1, False, theme_color(theme, "border")))
        parts.append(node_card(x - 88, card_y, 176, 88, node, theme, index=index + 1, accent=index == 0))
    return svg_shell(str(module.get("title", "Timeline")), "\n  ".join(parts), theme, str(module.get("id", "")), str(module.get("summary", "")))


def draw_module(module: dict[str, Any], theme: dict[str, str]) -> str:
    diagram_type = str(module.get("diagram_type", "mindmap")).lower()
    if diagram_type == "flow":
        return draw_flow(module, theme)
    if diagram_type == "layer":
        return draw_layers(module, theme)
    if diagram_type == "comparison":
        return draw_comparison(module, theme)
    if diagram_type == "cycle":
        return draw_cycle(module, theme)
    if diagram_type == "matrix":
        return draw_matrix(module, theme)
    if diagram_type == "network":
        return draw_network(module, theme)
    if diagram_type == "timeline":
        return draw_timeline(module, theme)
    return draw_mindmap(module, theme)


def build_index(spec: dict[str, Any], modules: list[dict[str, str]], theme: dict[str, str]) -> str:
    title = esc(spec.get("title", "Tutorial Visual Pack"))
    nav_items = []
    sections = []
    for module in modules:
        nav_items.append(f'<a href="#{esc(module["id"])}"><span>{esc(chapter_label(module["id"]))}</span>{esc(module["title"])}</a>')
        sections.append(
            f'''<section class="chapter" id="{esc(module["id"])}">
  <figure class="artboard">
    <img src="{esc(module["file"])}" alt="{esc(chapter_label(module["id"]))}：{esc(module["title"])} diagram">
  </figure>
  <p class="caption">{esc(module["caption"])}</p>
</section>'''
        )
    return f'''<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>{title}</title>
  <style>
    :root {{
      --ink: {theme["ink"]};
      --muted: {theme["muted"]};
      --accent: {theme["accent"]};
      --accent2: {theme["accent2"]};
      --border: {theme["border"]};
      --surface: {theme["surface"]};
      --paper: {theme["paper"]};
      --canvas: {theme_color(theme, "canvas")};
      --grid: {theme_color(theme, "grid")};
    }}
    * {{
      box-sizing: border-box;
    }}
    html {{
      scroll-behavior: smooth;
    }}
    body {{
      margin: 0;
      background:
        radial-gradient(circle at 1px 1px, var(--grid) 1px, transparent 1.4px),
        var(--canvas);
      background-size: 26px 26px;
      color: var(--ink);
      font-family: "Noto Sans SC", "Source Han Sans SC", "PingFang SC", system-ui, sans-serif;
    }}
    .shell {{
      display: grid;
      grid-template-columns: minmax(260px, 330px) minmax(0, 1fr);
      gap: clamp(24px, 4vw, 56px);
      max-width: 1440px;
      margin: 0 auto;
      padding: clamp(24px, 4vw, 56px);
    }}
    .rail {{
      position: sticky;
      top: 32px;
      align-self: start;
      min-height: calc(100vh - 64px);
      padding-right: 20px;
      border-right: 1px solid var(--border);
    }}
    .rail h1 {{
      margin: 0;
      padding-bottom: 18px;
      border-bottom: 2px solid var(--accent);
      font-size: clamp(1.45rem, 2.3vw, 2.35rem);
      line-height: 1.12;
      letter-spacing: 0;
    }}
    .rail nav {{
      display: grid;
      gap: 10px;
      margin-top: 24px;
    }}
    .rail a {{
      color: var(--muted);
      text-decoration: none;
      font-size: 0.94rem;
      line-height: 1.35;
    }}
    .rail a:hover {{
      color: var(--ink);
    }}
    .rail span {{
      display: block;
      margin-bottom: 3px;
      color: var(--accent);
      font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
      font-size: 0.72rem;
      font-weight: 700;
      text-transform: uppercase;
    }}
    .workspace {{
      display: grid;
      gap: clamp(34px, 5vw, 72px);
    }}
    .chapter {{
      scroll-margin-top: 32px;
    }}
    p {{
      margin: 0;
      line-height: 1.6;
    }}
    .artboard {{
      margin: 0;
      aspect-ratio: 16 / 9;
      border: 1px solid var(--border);
      border-radius: 10px;
      background: var(--paper);
      overflow: hidden;
    }}
    img {{
      display: block;
      width: 100%;
      height: auto;
    }}
    .caption {{
      margin: 12px auto 0;
      max-width: 820px;
      color: var(--muted);
      text-align: center;
      font-size: 0.95rem;
    }}
    @media (max-width: 860px) {{
      .shell {{
        display: block;
        padding: 20px;
      }}
      .rail {{
        position: sticky;
        top: 0;
        z-index: 2;
        min-height: auto;
        margin: -20px -20px 28px;
        padding: 18px 20px 16px;
        border-right: 0;
        border-bottom: 1px solid var(--border);
        background: color-mix(in srgb, var(--canvas) 92%, white);
      }}
      .rail h1 {{
        font-size: 1.6rem;
      }}
      .rail nav {{
        grid-auto-flow: column;
        grid-auto-columns: minmax(140px, 1fr);
        overflow-x: auto;
        gap: 16px;
      }}
      .workspace {{
        gap: 42px;
      }}
    }}
  </style>
</head>
<body>
  <div class="shell">
    <aside class="rail">
      <h1>{title}</h1>
      <nav>
        {"".join(nav_items)}
      </nav>
    </aside>
    <main class="workspace">
      {"".join(sections)}
    </main>
  </div>
</body>
</html>
'''


def demo_spec() -> dict[str, Any]:
    return {
        "title": "Demo Tutorial Visual Pack",
        "chapters": [
            {
                "id": "chapter-01",
                "title": "From Confusion To Model",
                "diagram_type": "flow",
                "summary": "A beginner path from raw topic to first useful example.",
                "nodes": ["Raw topic", "Key question", "Mental model", "First example"],
                "caption": "A useful tutorial turns confusion into a small successful action.",
            },
            {
                "id": "chapter-02",
                "title": "The Learning Stack",
                "diagram_type": "layer",
                "summary": "A topic can be taught as stacked levels of understanding.",
                "nodes": ["Goal", "Workflow", "Concepts", "Tools", "Tradeoffs"],
                "caption": "The learner needs to know where each new idea belongs.",
            },
        ],
    }


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Build tutorial chapter visuals as SVG files plus an index HTML page.")
    parser.add_argument("spec", nargs="?", help="Path to visual-spec.json.")
    parser.add_argument("output_dir", nargs="?", help="Directory for SVG and HTML outputs.")
    parser.add_argument("--demo", action="store_true", help="Generate a demo visual pack without a spec file.")
    return parser.parse_args()


def main() -> None:
    args = parse_args()
    if args.demo:
        spec = demo_spec()
        output_dir = Path(args.output_dir or args.spec or "visuals-demo").resolve()
    else:
        if not args.spec or not args.output_dir:
            raise SystemExit("Usage: build_visual_pack.py visual-spec.json output_dir/ or use --demo output_dir/")
        spec_path = Path(args.spec).resolve()
        if not spec_path.exists():
            raise SystemExit(f"Spec file not found: {spec_path}")
        spec = json.loads(spec_path.read_text(encoding="utf-8"))
        output_dir = Path(args.output_dir).resolve()

    output_dir.mkdir(parents=True, exist_ok=True)
    theme = {**DEFAULT_THEME, **(spec.get("theme") or {})}
    raw_modules = spec.get("chapters") or spec.get("modules") or []
    if not raw_modules:
        raise SystemExit("No chapters found in visual spec.")

    index_modules: list[dict[str, str]] = []
    used_ids: set[str] = set()
    for number, module in enumerate(raw_modules, start=1):
        module = dict(module)
        raw_id = str(module.get("id") or "")
        module_id = slugify(raw_id) or f"chapter-{number:02d}"
        if module_id in used_ids:
            module_id = f"{module_id}-{number:02d}"
        used_ids.add(module_id)
        module["id"] = module_id
        title = str(module.get("title") or module_id.replace("-", " ").title())
        svg = draw_module(module, theme)
        file_name = f"{module_id}.svg"
        (output_dir / file_name).write_text(svg, encoding="utf-8")
        index_modules.append(
            {
                "id": module_id,
                "title": title,
                "summary": str(module.get("summary") or ""),
                "caption": str(module.get("caption") or ""),
                "file": file_name,
            }
        )

    index_html = build_index(spec, index_modules, theme)
    (output_dir / "index.html").write_text(index_html, encoding="utf-8")
    print(f"Visual pack written to {output_dir}")
    for module in index_modules:
        print(f"- {module['file']}")


if __name__ == "__main__":
    main()


path: scripts/capture_visuals.py
content: #!/usr/bin/env python3
"""Capture SVG chapter visuals as PNG files with a headless browser."""

from __future__ import annotations

import argparse
import os
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path


def find_browser() -> str:
    candidates = [
        "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
        "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
        "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
        "/Applications/Chromium.app/Contents/MacOS/Chromium",
    ]
    for candidate in candidates:
        if os.access(candidate, os.X_OK):
            return candidate

    for name in ["google-chrome", "chromium", "chromium-browser", "msedge", "brave"]:
        path = shutil.which(name)
        if path:
            return path

    raise SystemExit("Missing screenshot browser: install Google Chrome, Microsoft Edge, Brave, or Chromium.")


def capture_svg(browser: str, svg_file: Path, png_file: Path, width: int, height: int, scale: float) -> None:
    svg = svg_file.read_text(encoding="utf-8")
    html = f"""<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <style>
    html, body {{
      width: {width}px;
      height: {height}px;
      margin: 0;
      overflow: hidden;
      background: #f5f4ed;
    }}
    svg {{
      display: block;
      width: {width}px;
      height: {height}px;
    }}
  </style>
</head>
<body>
{svg}
</body>
</html>
"""
    with tempfile.TemporaryDirectory() as tmp_dir:
        html_file = Path(tmp_dir) / f"{svg_file.stem}.html"
        html_file.write_text(html, encoding="utf-8")
        raw_png = Path(tmp_dir) / f"{svg_file.stem}.raw.png"
        # Chrome on macOS can treat --window-size as an outer window size, which leaves
        # less vertical viewport than requested. Capture taller, then crop to the SVG.
        capture_height = height + 96
        cmd = [
            browser,
            "--headless=new",
            "--disable-gpu",
            "--hide-scrollbars",
            "--run-all-compositor-stages-before-draw",
            f"--window-size={width},{capture_height}",
            f"--force-device-scale-factor={scale}",
            f"--screenshot={raw_png}",
            html_file.resolve().as_uri(),
        ]
        subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        crop_png(raw_png, png_file, width, height, scale)


def crop_png(source: Path, target: Path, width: int, height: int, scale: float) -> None:
    try:
        from PIL import Image
    except ImportError:
        raise SystemExit("Missing Pillow: install the Python PIL/Pillow package to crop browser screenshots exactly.")

    expected_width = int(round(width * scale))
    expected_height = int(round(height * scale))
    with Image.open(source) as image:
        cropped = image.crop((0, 0, expected_width, expected_height))
        cropped.save(target)


def svg_dimensions(svg_file: Path) -> tuple[int, int]:
    head = svg_file.read_text(encoding="utf-8", errors="ignore")[:1200]
    width_match = re.search(r'\bwidth="([0-9.]+)"', head)
    height_match = re.search(r'\bheight="([0-9.]+)"', head)
    if width_match and height_match:
        return int(float(width_match.group(1))), int(float(height_match.group(1)))
    viewbox_match = re.search(r'\bviewBox="[^"]*?\s([0-9.]+)\s([0-9.]+)"', head)
    if viewbox_match:
        return int(float(viewbox_match.group(1))), int(float(viewbox_match.group(2)))
    return 1200, 675


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Capture SVG visuals to PNG files.")
    parser.add_argument("visuals_dir", help="Directory containing generated SVG files.")
    parser.add_argument("output_dir", help="Directory for PNG screenshots.")
    parser.add_argument("--width", type=int, default=0, help="Screenshot viewport width. Defaults to each SVG width.")
    parser.add_argument("--height", type=int, default=0, help="Screenshot viewport height. Defaults to each SVG height.")
    parser.add_argument("--scale", type=float, default=2.0, help="Device scale factor for crisper screenshots.")
    return parser.parse_args()


def main() -> None:
    args = parse_args()
    visuals_dir = Path(args.visuals_dir).resolve()
    if not visuals_dir.exists():
        raise SystemExit(f"Visuals directory not found: {visuals_dir}")

    output_dir = Path(args.output_dir).resolve()
    output_dir.mkdir(parents=True, exist_ok=True)
    svg_files = sorted(path for path in visuals_dir.glob("*.svg") if path.is_file())
    if not svg_files:
        raise SystemExit(f"No SVG files found in: {visuals_dir}")

    browser = find_browser()
    for svg_file in svg_files:
        intrinsic_width, intrinsic_height = svg_dimensions(svg_file)
        width = args.width or intrinsic_width
        height = args.height or intrinsic_height
        png_file = output_dir / f"{svg_file.stem}.png"
        capture_svg(browser, svg_file, png_file, width, height, args.scale)
        print(f"- {png_file}")


if __name__ == "__main__":
    try:
        main()
    except subprocess.CalledProcessError as exc:
        print(f"Command failed with exit code {exc.returncode}: {exc.cmd}", file=sys.stderr)
        raise SystemExit(exc.returncode)


path: scripts/export_tutorial.py
content: #!/usr/bin/env python3
"""Export one markdown tutorial to docx, html, and pdf."""

from __future__ import annotations

import argparse
import os
import re
import shutil
import subprocess
import sys
import tempfile
import zipfile
from datetime import date
from html import escape
from xml.etree import ElementTree as ET
from pathlib import Path


def require_tool(name: str) -> str:
    path = shutil.which(name)
    if not path:
        raise SystemExit(f"Missing required tool: {name}")
    return path


def find_pdf_browser() -> str:
    candidates = [
        "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
        "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
        "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
        "/Applications/Chromium.app/Contents/MacOS/Chromium",
    ]
    for candidate in candidates:
        if os.access(candidate, os.X_OK):
            return candidate

    for name in ["google-chrome", "chromium", "chromium-browser", "msedge", "brave"]:
        path = shutil.which(name)
        if path:
            return path

    raise SystemExit("Missing PDF browser: install Google Chrome, Microsoft Edge, Brave, or Chromium.")


def run(cmd: list[str]) -> None:
    subprocess.run(cmd, check=True)


def has_weasyprint() -> bool:
    try:
        import weasyprint  # noqa: F401
    except Exception:
        return False
    return True


def create_default_reference_doc(target: Path) -> Path | None:
    try:
        from build_reference_doc import build_reference_doc
    except Exception as exc:
        print(f"Skipping default DOCX reference style: {exc}", file=sys.stderr)
        return None

    build_reference_doc(target)
    return target


def export_docx(
    pandoc: str,
    source: Path,
    target: Path,
    reference_doc: Path | None,
    title: str | None,
    document_date: str | None,
) -> None:
    with tempfile.TemporaryDirectory() as tmp_dir:
        export_source = source
        if document_date:
            export_source = Path(tmp_dir) / source.name
            export_source.write_text(insert_markdown_date(source.read_text(encoding="utf-8"), document_date), encoding="utf-8")

        cmd = [pandoc, str(export_source), "--resource-path", str(source.parent), "-o", str(target)]
        if reference_doc:
            cmd.append(f"--reference-doc={reference_doc}")
        run(cmd)
    strip_docx_headers_footers(target)
    style_docx_tables(target)


def export_html(
    pandoc: str,
    source: Path,
    target: Path,
    title: str | None,
    css: Path | None,
    document_date: str | None,
) -> None:
    cmd = [
        pandoc,
        str(source),
        "--resource-path",
        str(source.parent),
        "--embed-resources",
        "--standalone",
        "--toc",
        "-o",
        str(target),
    ]
    if title:
        cmd.extend(["-M", f"pagetitle={title}"])
    if css:
        cmd.extend(["-c", str(css)])
    run(cmd)
    postprocess_html(target, document_date)


def postprocess_html(target: Path, document_date: str | None) -> None:
    html = target.read_text(encoding="utf-8")
    html = wrap_html_tables(html)
    if document_date and 'class="doc-date"' not in html:
        date_html = f'\n<p class="doc-date">更新日期：{escape(document_date)}</p>'
        html = re.sub(r"(<h1\b[^>]*>.*?</h1>)", rf"\1{date_html}", html, count=1, flags=re.S)
    html = wrap_report_shell(html)
    target.write_text(html, encoding="utf-8")


def wrap_html_tables(html: str) -> str:
    def replace(match: re.Match[str]) -> str:
        prefix = html[max(0, match.start() - 120):match.start()]
        if 'class="table-wrap"' in prefix:
            return match.group(0)
        return f'<div class="table-wrap">{match.group(0)}</div>'

    return re.sub(r"<table\b[^>]*>.*?</table>", replace, html, flags=re.S | re.I)


def wrap_report_shell(html: str) -> str:
    if 'class="report-shell"' in html:
        return html

    toc_pattern = re.compile(r"(<body\b[^>]*>\s*)(<nav\b[^>]*id=\"TOC\"[^>]*>.*?</nav>\s*)", re.S)
    if toc_pattern.search(html):
        html = toc_pattern.sub(r'\1<div class="report-shell">\n\2<main class="article-body">\n', html, count=1)
        return re.sub(r"\s*</body>", "\n</main>\n</div>\n</body>", html, count=1)

    html = re.sub(r"(<body\b[^>]*>)", r'\1\n<div class="report-shell report-shell--no-toc">\n<main class="article-body">', html, count=1)
    return re.sub(r"\s*</body>", "\n</main>\n</div>\n</body>", html, count=1)


def insert_markdown_date(markdown: str, document_date: str) -> str:
    if "更新日期：" in markdown:
        return markdown
    return re.sub(r"(^# .+?$)", rf"\1\n\n更新日期：{document_date}", markdown, count=1, flags=re.M)


def default_document_date() -> str:
    today = date.today()
    return f"{today.year}年{today.month}月{today.day}日"


def strip_docx_headers_footers(docx_file: Path) -> None:
    rel_ns = "http://schemas.openxmlformats.org/package/2006/relationships"
    w_ns = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
    ET.register_namespace("w", w_ns)

    with tempfile.TemporaryDirectory() as tmp_dir:
        tmp_path = Path(tmp_dir)
        with zipfile.ZipFile(docx_file, "r") as archive:
            archive.extractall(tmp_path)

        document_xml = tmp_path / "word" / "document.xml"
        if document_xml.exists():
            tree = ET.parse(document_xml)
            root = tree.getroot()
            for sect_pr in root.findall(f".//{{{w_ns}}}sectPr"):
                for child in list(sect_pr):
                    if child.tag in {f"{{{w_ns}}}headerReference", f"{{{w_ns}}}footerReference"}:
                        sect_pr.remove(child)
            tree.write(document_xml, encoding="UTF-8", xml_declaration=True)

        rels_xml = tmp_path / "word" / "_rels" / "document.xml.rels"
        if rels_xml.exists():
            tree = ET.parse(rels_xml)
            root = tree.getroot()
            for rel in list(root):
                target = rel.attrib.get("Target", "")
                rel_type = rel.attrib.get("Type", "")
                if "header" in target or "footer" in target or rel_type.endswith("/header") or rel_type.endswith("/footer"):
                    root.remove(rel)
            tree.write(rels_xml, encoding="UTF-8", xml_declaration=True)

        for path in list((tmp_path / "word").glob("header*.xml")) + list((tmp_path / "word").glob("footer*.xml")):
            path.unlink(missing_ok=True)

        patched = docx_file.with_suffix(".clean.docx")
        with zipfile.ZipFile(patched, "w", zipfile.ZIP_DEFLATED) as archive:
            for path in tmp_path.rglob("*"):
                if path.is_file():
                    archive.write(path, path.relative_to(tmp_path).as_posix())
        shutil.move(patched, docx_file)


def w_tag(name: str) -> str:
    return f"{{http://schemas.openxmlformats.org/wordprocessingml/2006/main}}{name}"


def ensure_xml_child(parent: ET.Element, tag: str, first: bool = False) -> ET.Element:
    child = parent.find(tag)
    if child is None:
        child = ET.Element(tag)
        if first:
            parent.insert(0, child)
        else:
            parent.append(child)
    return child


def set_w_attr(element: ET.Element, name: str, value: str) -> None:
    element.set(w_tag(name), value)


def set_cell_shading(cell: ET.Element, fill: str) -> None:
    tc_pr = ensure_xml_child(cell, w_tag("tcPr"), first=True)
    shd = ensure_xml_child(tc_pr, w_tag("shd"))
    set_w_attr(shd, "fill", fill)


def style_docx_tables(docx_file: Path) -> None:
    with tempfile.TemporaryDirectory() as tmp_dir:
        tmp_path = Path(tmp_dir)
        with zipfile.ZipFile(docx_file, "r") as archive:
            archive.extractall(tmp_path)

        document_xml = tmp_path / "word" / "document.xml"
        if document_xml.exists():
            tree = ET.parse(document_xml)
            root = tree.getroot()
            for table in root.findall(f".//{w_tag('tbl')}"):
                tbl_pr = ensure_xml_child(table, w_tag("tblPr"), first=True)
                borders = ensure_xml_child(tbl_pr, w_tag("tblBorders"))
                for side in ["top", "left", "bottom", "right", "insideH", "insideV"]:
                    border = ensure_xml_child(borders, w_tag(side))
                    set_w_attr(border, "val", "single")
                    set_w_attr(border, "sz", "6")
                    set_w_attr(border, "space", "0")
                    set_w_attr(border, "color", "E8E5DA")

                cell_mar = ensure_xml_child(tbl_pr, w_tag("tblCellMar"))
                for side in ["top", "left", "bottom", "right"]:
                    margin = ensure_xml_child(cell_mar, w_tag(side))
                    set_w_attr(margin, "w", "120")
                    set_w_attr(margin, "type", "dxa")

                rows = table.findall(w_tag("tr"))
                for row_index, row in enumerate(rows):
                    fill = "EEF2F7" if row_index == 0 else ("FAF9F5" if row_index % 2 else "F7F5EE")
                    for cell in row.findall(w_tag("tc")):
                        set_cell_shading(cell, fill)
                        if row_index == 0:
                            for run_pr in cell.findall(f".//{w_tag('rPr')}"):
                                ensure_xml_child(run_pr, w_tag("b"))
            tree.write(document_xml, encoding="UTF-8", xml_declaration=True)

        patched = docx_file.with_suffix(".tables.docx")
        with zipfile.ZipFile(patched, "w", zipfile.ZIP_DEFLATED) as archive:
            for path in tmp_path.rglob("*"):
                if path.is_file():
                    archive.write(path, path.relative_to(tmp_path).as_posix())
        shutil.move(patched, docx_file)


def export_pdf(browser: str, html_file: Path, pdf_file: Path) -> None:
    try:
        from weasyprint import HTML
    except Exception:
        export_pdf_with_browser(browser, html_file, pdf_file)
        return

    HTML(filename=str(html_file), base_url=str(html_file.parent)).write_pdf(str(pdf_file))


def export_pdf_with_browser(browser: str, html_file: Path, pdf_file: Path) -> None:
    if not browser:
        browser = find_pdf_browser()
    html_uri = html_file.resolve().as_uri()
    cmd = [
        browser,
        "--headless",
        "--disable-gpu",
        "--no-pdf-header-footer",
        "--print-to-pdf-no-header",
        "--run-all-compositor-stages-before-draw",
        "--virtual-time-budget=3000",
        f"--print-to-pdf={pdf_file}",
        html_uri,
    ]
    run(cmd)


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Export a tutorial markdown file to docx, html, and pdf.")
    parser.add_argument("source", help="Path to the tutorial markdown file.")
    parser.add_argument("output_dir", help="Directory for exported artifacts.")
    parser.add_argument("--title", default=None, help="Optional document title override.")
    parser.add_argument("--basename", default=None, help="Optional base filename for exported files.")
    parser.add_argument("--reference-doc", default=None, help="Optional pandoc reference docx.")
    parser.add_argument("--css", default=None, help="Optional CSS file for html export.")
    parser.add_argument("--date", default=None, help="Date text shown below the HTML/PDF title. Defaults to today's local date.")
    parser.add_argument(
        "--formats",
        nargs="+",
        choices=["docx", "html", "pdf"],
        default=["docx", "html", "pdf"],
        help="Which formats to generate.",
    )
    return parser.parse_args()


def main() -> None:
    args = parse_args()
    source = Path(args.source).resolve()
    if not source.exists():
        raise SystemExit(f"Source file not found: {source}")

    output_dir = Path(args.output_dir).resolve()
    output_dir.mkdir(parents=True, exist_ok=True)

    base = args.basename or source.stem
    title = args.title or source.stem.replace("-", " ").replace("_", " ").title()
    document_date = args.date or default_document_date()
    reference_doc = Path(args.reference_doc).resolve() if args.reference_doc else None
    css = Path(args.css).resolve() if args.css else None

    if reference_doc and not reference_doc.exists():
        raise SystemExit(f"Reference doc not found: {reference_doc}")
    if css and not css.exists():
        raise SystemExit(f"CSS file not found: {css}")

    pandoc = require_tool("pandoc")
    browser = find_pdf_browser() if "pdf" in args.formats and not has_weasyprint() else ""
    html_target = output_dir / f"{base}.html"

    if "docx" in args.formats and not reference_doc:
        reference_doc = create_default_reference_doc(output_dir / f"{base}-reference.docx")

    if "docx" in args.formats:
        export_docx(pandoc, source, output_dir / f"{base}.docx", reference_doc, title, document_date)

    if "html" in args.formats or "pdf" in args.formats:
        export_html(pandoc, source, html_target, title, css, document_date)

    if "pdf" in args.formats:
        export_pdf(browser, html_target, output_dir / f"{base}.pdf")

    print(f"Export complete for {source.name}")
    for fmt in args.formats:
        print(f"- {fmt}")


if __name__ == "__main__":
    try:
        main()
    except subprocess.CalledProcessError as exc:
        print(f"Command failed with exit code {exc.returncode}: {exc.cmd}", file=sys.stderr)
        raise SystemExit(exc.returncode)


path: scripts/validate_package.py
content: #!/usr/bin/env python3
"""Validate a generated tutorial package before delivery."""

from __future__ import annotations

import argparse
import json
import os
import re
import shutil
import sys
import zipfile
from dataclasses import dataclass
from pathlib import Path


@dataclass
class Check:
    status: str
    message: str


class Validator:
    def __init__(self, package_dir: Path, basename: str, formats: list[str], check_deps: bool, strict: bool) -> None:
        self.package_dir = package_dir
        self.basename = basename
        self.formats = formats
        self.check_deps = check_deps
        self.strict = strict
        self.checks: list[Check] = []
        self.chapter_numbers: list[int] = []
        self.chapter_sections: list[tuple[int, str, str]] = []
        self.markdown_image_refs: list[str] = []

    def add(self, status: str, message: str) -> None:
        self.checks.append(Check(status, message))

    def pass_(self, message: str) -> None:
        self.add("PASS", message)

    def warn(self, message: str) -> None:
        self.add("WARN", message)

    def fail(self, message: str) -> None:
        self.add("FAIL", message)

    def run(self) -> int:
        if not self.package_dir.exists():
            self.fail(f"Package directory not found: {self.package_dir}")
            return self.finish()

        if self.check_deps:
            self.validate_dependencies()

        self.validate_package_files()
        self.validate_markdown()
        self.validate_visuals()
        self.validate_html()
        self.validate_docx()
        self.validate_pdf()
        self.validate_no_absolute_paths()
        self.validate_public_provenance_hygiene()

        return self.finish()

    def finish(self) -> int:
        failures = [check for check in self.checks if check.status == "FAIL"]
        warnings = [check for check in self.checks if check.status == "WARN"]
        for check in self.checks:
            print(f"[{check.status}] {check.message}")
        print(f"\nSummary: {len(failures)} failures, {len(warnings)} warnings, {len(self.checks)} checks")
        if failures:
            return 1
        if self.strict and warnings:
            return 2
        return 0

    def validate_dependencies(self) -> None:
        if shutil.which("pandoc"):
            self.pass_("pandoc is available")
        else:
            self.fail("pandoc is missing; DOCX and HTML export will fail")

        browser = find_browser()
        if browser:
            self.pass_(f"Chromium-family browser is available: {browser}")
        else:
            self.warn("No Chrome/Edge/Brave/Chromium browser found; screenshots and PDF fallback may fail")

        if can_import("PIL"):
            self.pass_("Pillow is available for exact screenshot cropping")
        else:
            self.warn("Pillow is missing; screenshot cropping cannot be validated or performed exactly")

        if can_import("weasyprint"):
            self.pass_("WeasyPrint is available for clean PDF export")
        else:
            self.warn("WeasyPrint is missing; PDF export will rely on browser fallback")

        if can_import("docx"):
            self.pass_("python-docx is available for default Word reference generation")
        else:
            self.warn("python-docx is missing; default Word reference style generation may be skipped")

    def validate_package_files(self) -> None:
        required = [
            "brief.json",
            "tutorial.md",
            "outline.md",
            "research/evidence-map.md",
            "research/chapter-quality-review.md",
            "visuals/visual-spec.json",
            "visuals/index.html",
        ]
        for relative in required:
            path = self.package_dir / relative
            if path.exists():
                self.pass_(f"Found {relative}")
            else:
                self.fail(f"Missing {relative}")

        source_register = self.package_dir / "research/source-register.md"
        user_register = self.package_dir / "research/user-materials-register.md"
        if source_register.exists() or user_register.exists():
            self.pass_("Found at least one research register")
        else:
            self.warn("No source register or user material register found")

        exports_dir = self.package_dir / "exports"
        if "html" in self.formats:
            self.require_file(exports_dir / f"{self.basename}.html", "HTML export")
        if "docx" in self.formats:
            self.require_file(exports_dir / f"{self.basename}.docx", "DOCX export")
        if "pdf" in self.formats:
            self.require_file(exports_dir / f"{self.basename}.pdf", "PDF export")

    def require_file(self, path: Path, label: str) -> None:
        if path.exists() and path.stat().st_size > 0:
            self.pass_(f"{label} exists")
        elif path.exists():
            self.fail(f"{label} exists but is empty: {path.relative_to(self.package_dir)}")
        else:
            self.fail(f"{label} missing: {path.relative_to(self.package_dir)}")

    def validate_markdown(self) -> None:
        tutorial = self.package_dir / "tutorial.md"
        if not tutorial.exists():
            return
        text = read_text(tutorial)

        h1_count = len(re.findall(r"^#\s+", text, flags=re.M))
        if h1_count == 1:
            self.pass_("tutorial.md has one H1 title")
        else:
            self.warn(f"tutorial.md should have one H1 title; found {h1_count}")

        chapter_matches = list(re.finditer(r"^##\s+第\s*(\d+)\s*章\b", text, flags=re.M))
        chapters = [match.group(0) for match in chapter_matches]
        self.chapter_numbers = [int(match.group(1)) for match in chapter_matches]
        self.chapter_sections = split_chapter_sections(text, chapter_matches)
        if chapters:
            self.pass_(f"Found {len(chapters)} numbered chapters")
        else:
            self.fail("No numbered chapter headings found; expected H2 headings like '## 第1章 标题'")

        h3_headings = re.findall(r"^###\s+(.+)$", text, flags=re.M)
        non_decimal = [heading for heading in h3_headings if not re.match(r"\d+\.\d+\s+", heading)]
        if h3_headings and not non_decimal:
            self.pass_("All H3 headings use decimal numbering")
        elif non_decimal:
            self.warn(f"{len(non_decimal)} H3 headings do not use decimal numbering")

        images = markdown_images(text)
        self.markdown_image_refs = images
        if images:
            missing = [image for image in images if is_local_image(image) and not (self.package_dir / image).exists()]
            if missing:
                self.fail(f"Missing {len(missing)} local markdown images")
            else:
                self.pass_(f"All {len(images)} markdown image references resolve or are remote")
        else:
            self.warn("No markdown images found; each chapter should normally include a visual")

        source_ids = public_source_markers(text)
        if source_ids:
            self.fail(
                "tutorial.md exposes internal source markers: "
                + ", ".join(sorted(set(source_ids))[:8])
            )
        else:
            self.pass_("tutorial.md contains no public bracket source markers")

        self.validate_specific_h3_headings(h3_headings)
        self.validate_chapter_depth_consistency()
        self.validate_chapter_quality_review()

    def validate_visuals(self) -> None:
        spec_path = self.package_dir / "visuals/visual-spec.json"
        if not spec_path.exists():
            return
        try:
            spec = json.loads(spec_path.read_text(encoding="utf-8"))
        except json.JSONDecodeError as exc:
            self.fail(f"visual-spec.json is invalid JSON: {exc}")
            return

        chapters = spec.get("chapters") or spec.get("modules") or []
        if not isinstance(chapters, list) or not chapters:
            self.fail("visual-spec.json must contain a non-empty chapters array")
            return
        self.pass_(f"visual-spec.json contains {len(chapters)} chapter visual specs")
        visual_ids = {
            str(chapter.get("id") or f"chapter-{index:02d}")
            for index, chapter in enumerate(chapters, start=1)
            if isinstance(chapter, dict)
        }
        self.validate_chapter_visual_coverage(chapters, visual_ids)

        for index, chapter in enumerate(chapters, start=1):
            if not isinstance(chapter, dict):
                self.fail(f"Visual spec item {index} is not an object")
                continue
            visual_id = str(chapter.get("id") or f"chapter-{index:02d}")
            if not chapter.get("title"):
                self.warn(f"{visual_id} has no title")
            if not chapter.get("caption"):
                self.warn(f"{visual_id} has no caption")

            svg = self.package_dir / "visuals" / f"{visual_id}.svg"
            png = self.package_dir / "assets/screenshots" / f"{visual_id}.png"
            if svg.exists():
                self.pass_(f"SVG exists for {visual_id}")
            else:
                self.fail(f"Missing SVG for {visual_id}")
            if png.exists():
                self.pass_(f"PNG screenshot exists for {visual_id}")
                self.validate_png_size(png, visual_id)
            else:
                self.warn(f"Missing PNG screenshot for {visual_id}; SVG fallback may be used")

    def validate_chapter_visual_coverage(self, chapters: list[object], visual_ids: set[str]) -> None:
        if not self.chapter_numbers:
            return

        expected_ids = [chapter_visual_id(number) for number in self.chapter_numbers]
        missing_specs = [visual_id for visual_id in expected_ids if visual_id not in visual_ids]
        extra_specs = sorted(visual_ids - set(expected_ids))

        if len(chapters) == len(self.chapter_numbers):
            self.pass_("Visual spec count matches numbered chapter count")
        else:
            self.fail(
                f"Visual spec count does not match numbered chapters: "
                f"{len(chapters)} specs for {len(self.chapter_numbers)} chapters"
            )

        if missing_specs:
            self.fail(f"Missing visual specs for chapters: {', '.join(missing_specs)}")
        else:
            self.pass_("Every numbered chapter has a matching visual spec ID")

        if extra_specs:
            self.warn(f"Visual spec has extra IDs not matched to numbered chapters: {', '.join(extra_specs)}")

        embedded_ids = local_image_stems(self.markdown_image_refs)
        missing_embeds = [visual_id for visual_id in expected_ids if visual_id not in embedded_ids]
        if missing_embeds:
            self.fail(f"Tutorial markdown is missing embedded visuals for: {', '.join(missing_embeds)}")
        else:
            self.pass_("Every numbered chapter has an embedded visual reference")

    def validate_png_size(self, png: Path, visual_id: str) -> None:
        try:
            from PIL import Image
        except ImportError:
            return
        try:
            with Image.open(png) as image:
                width, height = image.size
        except Exception as exc:
            self.fail(f"Cannot open PNG for {visual_id}: {exc}")
            return
        ratio = width / height if height else 0
        if width >= 1600 and 1.65 <= ratio <= 1.9:
            self.pass_(f"PNG for {visual_id} is high-resolution 16:9-ish ({width}x{height})")
        else:
            self.warn(f"PNG for {visual_id} may be low resolution or wrong aspect ratio ({width}x{height})")

    def validate_html(self) -> None:
        if "html" not in self.formats:
            return
        html_path = self.package_dir / "exports" / f"{self.basename}.html"
        if not html_path.exists():
            return
        html = read_text(html_path)

        if 'class="report-shell"' in html and 'class="article-body"' in html:
            self.pass_("HTML has centered report-shell and article-body layout")
        else:
            self.fail("HTML missing report-shell/article-body layout wrapper")

        if 'id="TOC"' in html:
            self.pass_("HTML has a generated TOC")
        else:
            self.warn("HTML has no nav#TOC; long tutorials should include a sticky anchor menu")

        if 'class="doc-date"' in html:
            self.pass_("HTML has document date below title")
        else:
            self.warn("HTML has no document date line")

        h1_count = len(re.findall(r"<h1\b", html, flags=re.I))
        if h1_count == 1:
            self.pass_("HTML has one visible H1")
        else:
            self.warn(f"HTML should normally have one H1; found {h1_count}")

        table_positions = [match.start() for match in re.finditer(r"<table\b", html, flags=re.I)]
        unwrapped = 0
        for position in table_positions:
            prefix = html[max(0, position - 120):position]
            if 'class="table-wrap"' not in prefix:
                unwrapped += 1
        if table_positions and not unwrapped:
            self.pass_(f"All {len(table_positions)} HTML tables are wrapped")
        elif unwrapped:
            self.fail(f"{unwrapped} HTML tables are not wrapped in .table-wrap")

        if "file:///" in html or "/Users/" in html:
            self.fail("HTML contains a local absolute path")
        else:
            self.pass_("HTML contains no local file URL or /Users path")

    def validate_docx(self) -> None:
        if "docx" not in self.formats:
            return
        docx_path = self.package_dir / "exports" / f"{self.basename}.docx"
        if not docx_path.exists():
            return
        try:
            with zipfile.ZipFile(docx_path, "r") as archive:
                names = archive.namelist()
                header_footer_files = [
                    name for name in names
                    if name.startswith("word/header") or name.startswith("word/footer")
                ]
                document_xml = archive.read("word/document.xml").decode("utf-8", errors="ignore")
        except Exception as exc:
            self.fail(f"Cannot inspect DOCX: {exc}")
            return

        if header_footer_files:
            self.fail(f"DOCX still contains header/footer files: {', '.join(header_footer_files[:4])}")
        elif "headerReference" in document_xml or "footerReference" in document_xml:
            self.fail("DOCX document.xml still references a header or footer")
        else:
            self.pass_("DOCX has no header/footer parts or references")

        if "/Users/" in document_xml or "file:///" in document_xml:
            self.fail("DOCX document XML contains a local absolute path")
        else:
            self.pass_("DOCX document XML contains no local absolute paths")

    def validate_pdf(self) -> None:
        if "pdf" not in self.formats:
            return
        pdf_path = self.package_dir / "exports" / f"{self.basename}.pdf"
        if not pdf_path.exists():
            return
        size = pdf_path.stat().st_size
        if size > 10_000:
            self.pass_(f"PDF exists and is non-trivial ({size} bytes)")
        else:
            self.warn(f"PDF exists but is very small ({size} bytes)")

    def validate_no_absolute_paths(self) -> None:
        text_files = [
            self.package_dir / "tutorial.md",
            self.package_dir / "exports" / f"{self.basename}.html",
        ]
        leaks: list[str] = []
        for path in text_files:
            if not path.exists():
                continue
            text = read_text(path)
            if "file:///" in text or "/Users/" in text:
                leaks.append(str(path.relative_to(self.package_dir)))
        if leaks:
            self.fail(f"Local absolute paths found in final text outputs: {', '.join(leaks)}")
        else:
            self.pass_("No local absolute paths found in final text outputs")

    def validate_specific_h3_headings(self, h3_headings: list[str]) -> None:
        if not h3_headings:
            return
        generic_labels = {
            "你要做的事",
            "你要注意什么",
            "示例",
            "检查点",
            "小结",
            "练习",
            "what to notice",
            "example",
            "checkpoint",
            "summary",
        }
        repeated: dict[str, int] = {}
        for heading in h3_headings:
            label = re.sub(r"^\d+\.\d+\s+", "", heading).strip()
            label = re.sub(r"[：: ].*$", "", label).strip()
            if label.lower() in generic_labels:
                repeated[label] = repeated.get(label, 0) + 1
        offenders = [f"{label} x{count}" for label, count in repeated.items() if count > 1]
        if offenders:
            self.fail(
                "H3 headings repeat generic labels instead of outline-specific section titles: "
                + ", ".join(offenders)
            )
        else:
            self.pass_("H3 headings are specific, not repeated generic labels")

    def validate_chapter_depth_consistency(self) -> None:
        if len(self.chapter_sections) < 4:
            return

        chapter_lengths = [
            (number, visible_text_length(section))
            for number, _heading, section in self.chapter_sections
        ]
        first_half = chapter_lengths[: max(1, len(chapter_lengths) // 2)]
        second_half = chapter_lengths[len(chapter_lengths) // 2 :]
        first_median = median([length for _number, length in first_half])
        second_median = median([length for _number, length in second_half])
        thin_chapters = [
            f"第{number}章 ({length})"
            for number, length in chapter_lengths
            if first_median and length < first_median * 0.45
        ]

        if second_median and first_median and second_median < first_median * 0.65:
            self.warn(
                "Later chapters appear much thinner than early chapters; "
                f"first-half median {first_median}, second-half median {second_median}"
            )
        elif thin_chapters:
            self.warn("Some chapters may be too thin for a full tutorial: " + ", ".join(thin_chapters[:6]))
        else:
            self.pass_("Chapter depth is reasonably balanced across the tutorial")

    def validate_chapter_quality_review(self) -> None:
        review = self.package_dir / "research/chapter-quality-review.md"
        if not review.exists():
            self.fail("Missing research/chapter-quality-review.md for per-chapter quality control")
            return

        text = read_text(review)
        missing = [
            number for number in self.chapter_numbers
            if not re.search(rf"(第\s*{number}\s*章|chapter-0*{number}\b)", text, flags=re.I)
        ]
        if missing:
            self.fail(
                "chapter-quality-review.md is missing review rows for chapters: "
                + ", ".join(f"第{number}章" for number in missing)
            )
        else:
            self.pass_("chapter-quality-review.md covers every numbered chapter")

        required_terms = [
            "learner_question",
            "depth_status",
            "example_or_case",
            "practice_or_checkpoint",
            "visual_fit",
        ]
        missing_terms = [term for term in required_terms if term not in text]
        if missing_terms:
            self.warn("chapter-quality-review.md may be missing expected columns: " + ", ".join(missing_terms))
        else:
            self.pass_("chapter-quality-review.md includes depth, example, practice, and visual review columns")

    def validate_public_provenance_hygiene(self) -> None:
        public_texts: list[tuple[str, str]] = []
        tutorial = self.package_dir / "tutorial.md"
        html_path = self.package_dir / "exports" / f"{self.basename}.html"
        docx_path = self.package_dir / "exports" / f"{self.basename}.docx"
        pdf_path = self.package_dir / "exports" / f"{self.basename}.pdf"

        if tutorial.exists():
            public_texts.append(("tutorial.md", read_text(tutorial)))
        if html_path.exists():
            public_texts.append((f"exports/{self.basename}.html", read_text(html_path)))
        if docx_path.exists():
            public_texts.append((f"exports/{self.basename}.docx", docx_visible_text(docx_path)))
        if pdf_path.exists() and shutil.which("pdftotext"):
            text = extract_pdf_text(pdf_path)
            if text:
                public_texts.append((f"exports/{self.basename}.pdf", text))

        marker_leaks = []
        provenance_leaks = []
        for label, text in public_texts:
            markers = sorted(set(public_source_markers(text)))
            if markers:
                marker_leaks.append(f"{label}: {', '.join(markers[:8])}")
            phrases = public_provenance_phrases(text)
            if phrases:
                provenance_leaks.append(f"{label}: {', '.join(phrases[:6])}")

        if marker_leaks:
            self.fail("Public outputs expose internal source markers: " + "; ".join(marker_leaks))
        else:
            self.pass_("Public outputs contain no internal bracket source markers")

        if provenance_leaks:
            self.fail("Public outputs expose internal provenance wording: " + "; ".join(provenance_leaks))
        else:
            self.pass_("Public outputs do not describe themselves as based on supplied material")


def can_import(module: str) -> bool:
    try:
        __import__(module)
    except Exception:
        return False
    return True


def find_browser() -> str:
    candidates = [
        "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
        "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
        "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
        "/Applications/Chromium.app/Contents/MacOS/Chromium",
    ]
    for candidate in candidates:
        if os.access(candidate, os.X_OK):
            return candidate
    for name in ["google-chrome", "chromium", "chromium-browser", "msedge", "brave"]:
        path = shutil.which(name)
        if path:
            return path
    return ""


def read_text(path: Path) -> str:
    return path.read_text(encoding="utf-8", errors="ignore")


def split_chapter_sections(markdown: str, chapter_matches: list[re.Match[str]]) -> list[tuple[int, str, str]]:
    sections: list[tuple[int, str, str]] = []
    for index, match in enumerate(chapter_matches):
        start = match.start()
        end = chapter_matches[index + 1].start() if index + 1 < len(chapter_matches) else len(markdown)
        number = int(match.group(1))
        heading = match.group(0)
        sections.append((number, heading, markdown[start:end]))
    return sections


def visible_text_length(markdown: str) -> int:
    text = re.sub(r"```.*?```", " ", markdown, flags=re.S)
    text = re.sub(r"!\[[^\]]*\]\([^)]+\)", " ", text)
    text = re.sub(r"\[[^\]]+\]\([^)]+\)", " ", text)
    text = re.sub(r"^#+\s+", " ", text, flags=re.M)
    text = re.sub(r"[|`*_>#\-\[\]():]", " ", text)
    text = re.sub(r"\s+", "", text)
    return len(text)


def median(values: list[int]) -> int:
    if not values:
        return 0
    ordered = sorted(values)
    mid = len(ordered) // 2
    if len(ordered) % 2:
        return ordered[mid]
    return (ordered[mid - 1] + ordered[mid]) // 2


def public_source_markers(text: str) -> list[str]:
    return re.findall(r"\[(?:U|P|G|A|X|L)\d+\]", text)


def public_provenance_phrases(text: str) -> list[str]:
    patterns = [
        r"用户粘贴",
        r"用户提供",
        r"用户给(?:到|的)",
        r"基于[^。\n]{0,30}(?:文章|原文|资料|材料)",
        r"根据[^。\n]{0,30}(?:文章|原文|资料|材料)(?:整理|生成|改写)",
        r"参考原文",
        r"原文(?:中|里|作者)",
        r"X Article",
        r"user-supplied",
        r"pasted (?:article|note|material)",
        r"based on (?:the )?(?:article|source|supplied material|original text)",
    ]
    found: list[str] = []
    for pattern in patterns:
        match = re.search(pattern, text, flags=re.I)
        if match:
            found.append(match.group(0))
    return found


def docx_visible_text(path: Path) -> str:
    try:
        with zipfile.ZipFile(path, "r") as archive:
            chunks = []
            for name in archive.namelist():
                if name == "word/document.xml" or name.startswith("word/header") or name.startswith("word/footer"):
                    chunks.append(archive.read(name).decode("utf-8", errors="ignore"))
            return "\n".join(chunks)
    except Exception:
        return ""


def extract_pdf_text(path: Path) -> str:
    import subprocess

    try:
        completed = subprocess.run(
            ["pdftotext", str(path), "-"],
            check=False,
            stdout=subprocess.PIPE,
            stderr=subprocess.DEVNULL,
            text=True,
            timeout=30,
        )
    except Exception:
        return ""
    return completed.stdout if completed.returncode == 0 else ""


def markdown_images(markdown: str) -> list[str]:
    return [
        normalize_markdown_target(match.group(2))
        for match in re.finditer(r"!\[([^\]]*)\]\(([^)]+)\)", markdown)
    ]


def normalize_markdown_target(target: str) -> str:
    target = target.strip()
    if target.startswith("<"):
        end = target.find(">")
        if end != -1:
            return target[1:end].strip()
    return target.split()[0].strip("\"'")


def is_local_image(path: str) -> bool:
    return not re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*:", path)


def local_image_stems(paths: list[str]) -> set[str]:
    stems: set[str] = set()
    for path in paths:
        if not is_local_image(path):
            continue
        clean_path = path.split("#", 1)[0].split("?", 1)[0]
        stems.add(Path(clean_path).stem)
    return stems


def chapter_visual_id(number: int) -> str:
    return f"chapter-{number:02d}"


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Validate a generated yao-tutorial-skill output package.")
    parser.add_argument("package_dir", help="Generated tutorial package directory, usually outputs/yao-tutorials/<topic-slug>.")
    parser.add_argument("--basename", default="tutorial", help="Base export filename. Defaults to tutorial.")
    parser.add_argument(
        "--formats",
        nargs="+",
        choices=["docx", "html", "pdf"],
        default=["docx", "html", "pdf"],
        help="Expected export formats.",
    )
    parser.add_argument("--check-deps", action="store_true", help="Also check local export dependencies.")
    parser.add_argument("--strict", action="store_true", help="Return a non-zero exit code when warnings exist.")
    return parser.parse_args()


def main() -> int:
    args = parse_args()
    validator = Validator(
        package_dir=Path(args.package_dir).resolve(),
        basename=args.basename,
        formats=args.formats,
        check_deps=args.check_deps,
        strict=args.strict,
    )
    return validator.run()


if __name__ == "__main__":
    raise SystemExit(main())


path: templates/topic-brief-template.json
content: {
  "topic": "",
  "audience": {
    "level": "beginner",
    "role": "",
    "prior_knowledge": "",
    "target_outcome": ""
  },
  "language": "match user request",
  "user_materials": {
    "notes": [],
    "urls": [],
    "files": [],
    "named_references": [],
    "must_use": [],
    "nice_to_use": [],
    "do_not_use": [],
    "style_references": [],
    "sufficiency_tier": "auto: rich | moderate | thin"
  },
  "scope": {
    "desired_length": "auto: as long as needed to teach the outcome; no fixed cap unless the user sets one",
    "chapter_count": "auto",
    "time_budget": "",
    "must_include": [],
    "avoid": []
  },
  "research": {
    "priority_rule": "use user-provided material as the tutorial spine when it is strong enough; add external sources for verification, gaps, freshness, and counterexamples",
    "source_layers": [
      "x.com",
      "papers",
      "authoritative sharing sites",
      "github"
    ],
    "required_domains": [],
    "forbidden_domains": [],
    "freshness_requirement": "verify current facts before writing",
    "external_research_level": "auto: gap-check | targeted | full-source-ladder"
  },
  "visuals": {
    "one_visual_per_chapter": true,
    "preferred_types": [
      "flow",
      "layer",
      "comparison",
      "cycle",
      "mindmap",
      "matrix"
    ],
    "style_notes": "clear, instructional, screenshot-friendly"
  },
  "outputs": [
    "markdown",
    "docx",
    "pdf",
    "html",
    "visual-html"
  ],
  "quality_control": {
    "chapter_quality_review": true,
    "review_file": "research/chapter-quality-review.md",
    "depth_rule": "learning sufficiency, not fixed word count"
  },
  "citation_style": "public human-readable references; internal source IDs only in research files"
}


path: templates/tutorial-style.css
content: :root {
  color-scheme: light;
  --bg: #f5f4ed;
  --surface: #faf9f5;
  --ink: #111827;
  --muted: #5e5d59;
  --accent: #1b365d;
  --line: #e8e5da;
  --line-strong: #d1cfc5;
  --code-bg: #f2eee6;
  --toc-width: clamp(196px, 19vw, 252px);
  --reading-width: 820px;
  --page-gap: clamp(28px, 4vw, 56px);
  --shell-pad-x: clamp(18px, 3vw, 44px);
  --layout-width: calc(var(--toc-width) + var(--page-gap) + var(--reading-width));
  --body-size: clamp(16px, 1.05vw, 18px);
  --small-size: clamp(12px, 0.85vw, 14px);
  --h1-size: clamp(2rem, 4.2vw, 3.55rem);
  --h2-size: clamp(1.55rem, 2.7vw, 2.25rem);
  --h3-size: clamp(1.1rem, 1.7vw, 1.35rem);
}

@page {
  size: A4;
  margin: 20mm 22mm 22mm;
  background: #f5f4ed;
}

html {
  background: var(--bg);
  scroll-behavior: smooth;
}

body {
  margin: 0;
  padding: 0;
  color: var(--ink);
  font-family: "Noto Sans SC", "Source Han Sans SC", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
  font-size: var(--body-size);
  line-height: 1.54;
  letter-spacing: 0;
}

.report-shell {
  display: grid;
  grid-template-columns: var(--toc-width) minmax(0, var(--reading-width));
  column-gap: var(--page-gap);
  align-items: start;
  width: min(calc(100% - (var(--shell-pad-x) * 2)), var(--layout-width));
  margin: 0 auto;
  padding: clamp(32px, 5vw, 72px) 0 80px;
}

.report-shell--no-toc {
  display: block;
  max-width: var(--reading-width);
}

.article-body {
  min-width: 0;
  max-width: var(--reading-width);
}

nav#TOC {
  position: sticky;
  top: clamp(32px, 5vw, 72px);
  width: auto;
  max-height: calc(100vh - 48px);
  overflow: auto;
  padding: 2px 0 2px 14px;
  border-left: 2px solid var(--line-strong);
  background: transparent;
}

nav#TOC ul {
  margin: 0;
  padding: 0;
  list-style: none;
}

nav#TOC li {
  margin: 0;
  padding: 0;
  font-size: var(--small-size);
  line-height: 1.34;
}

nav#TOC li li {
  margin-left: 12px;
  font-size: calc(var(--small-size) * 0.92);
}

nav#TOC a {
  display: block;
  padding: 5px 0;
  color: var(--muted);
  text-decoration: none;
}

nav#TOC a:hover {
  color: var(--accent);
}

h1,
h2,
h3,
h4 {
  color: var(--ink);
  font-family: "Noto Serif SC", "Source Han Serif SC", "Songti SC", Georgia, serif;
  font-weight: 700;
  letter-spacing: 0;
  line-height: 1.16;
  scroll-margin-top: 36px;
}

h1 {
  margin: 0 0 10px;
  font-size: var(--h1-size);
}

.doc-date {
  margin: 0 0 24px;
  padding-bottom: 18px;
  border-bottom: 2px solid var(--accent);
  color: var(--muted);
  font-size: var(--small-size);
  line-height: 1.4;
}

h1 + p,
.doc-date + p {
  margin-top: 0;
  color: var(--muted);
  font-size: clamp(1.02rem, 1.25vw, 1.18rem);
  line-height: 1.58;
}

h2 {
  margin-top: 56px;
  padding-top: 22px;
  border-top: 1px solid var(--line);
  font-size: var(--h2-size);
}

h3 {
  margin-top: 30px;
  font-size: var(--h3-size);
}

h4 {
  margin-top: 24px;
  font-size: 1rem;
}

p,
li {
  font-size: 1rem;
}

p {
  margin: 0 0 1em;
}

ul,
ol {
  padding-left: 1.24em;
}

li + li {
  margin-top: 0.28em;
}

a {
  color: var(--accent);
  text-decoration-thickness: 1px;
  text-underline-offset: 0.18em;
}

blockquote {
  margin: 24px 0;
  padding: 12px 18px;
  border-left: 3px solid var(--accent);
  background: var(--surface);
  color: var(--ink);
}

figure {
  margin: 28px 0 10px;
}

figure > img,
p > img,
img {
  display: block;
}

figure > img,
p > img {
  width: 100%;
  max-width: 100%;
  height: auto;
  margin: 0 auto;
  border: 0;
  border-radius: 8px;
  background: transparent;
}

figure > figcaption[aria-hidden="true"] {
  display: none;
}

figcaption,
.caption,
figure + p,
p:has(> img) + p {
  max-width: 720px;
  margin: 10px auto 30px;
  color: var(--muted);
  font-size: var(--small-size);
  text-align: center;
}

.table-wrap {
  max-width: 100%;
  margin: 26px 0 30px;
  overflow-x: auto;
  border: 1px solid var(--line);
  border-radius: 8px;
  background: transparent;
}

table {
  width: 100%;
  min-width: 680px;
  margin: 0;
  border-collapse: separate;
  border-spacing: 0;
  border: 0;
  background: var(--surface);
  font-size: 0.92rem;
}

col[style] {
  width: auto !important;
}

th,
td {
  padding: 11px 13px;
  border: 0;
  border-right: 1px solid var(--line);
  border-bottom: 1px solid var(--line);
  line-height: 1.48;
  vertical-align: top;
  overflow-wrap: anywhere;
}

th {
  background: #eef2f7;
  color: var(--ink);
  font-size: 0.88rem;
  font-weight: 700;
  text-align: left;
}

td {
  background: var(--surface);
}

th:last-child,
td:last-child {
  border-right: 0;
}

tbody tr:last-child td {
  border-bottom: 0;
}

tbody tr:nth-child(even) td {
  background: #f7f5ee;
}

tbody td:first-child {
  color: var(--ink);
  font-weight: 700;
}

code {
  padding: 0.08em 0.28em;
  border-radius: 4px;
  background: var(--code-bg);
  color: #7c2d12;
  font-family: "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
  font-size: 0.9em;
}

pre {
  overflow-x: auto;
  margin: 24px 0;
  padding: 16px;
  border: 1px solid #20283b;
  border-radius: 6px;
  background: #111827;
  color: #f9fafb;
}

pre code {
  padding: 0;
  background: transparent;
  color: inherit;
}

hr {
  margin: 40px 0;
  border: 0;
  border-top: 1px solid var(--line);
}

@media (max-width: 980px) {
  .report-shell,
  .report-shell--no-toc {
    display: block;
    max-width: var(--reading-width);
    width: auto;
    margin: 0 auto;
    padding: clamp(24px, 5vw, 48px) clamp(18px, 4vw, 44px) 72px;
  }

  .article-body {
    max-width: none;
  }

  nav#TOC {
    position: sticky;
    top: 0;
    z-index: 10;
    width: auto;
    max-height: 38vh;
    margin: 0 0 24px;
    padding: 10px 0;
    border-left: 0;
    border-bottom: 1px solid var(--line-strong);
    background: var(--bg);
  }
}

@media (max-width: 640px) {
  .report-shell,
  .report-shell--no-toc {
    padding: 22px 16px 56px;
  }

  h1 {
    font-size: clamp(1.75rem, 10vw, 2.6rem);
  }

  .doc-date {
    margin-bottom: 20px;
  }
}

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    transition-duration: 0.01ms !important;
    animation-duration: 0.01ms !important;
  }

  html {
    scroll-behavior: auto;
  }
}

@media print {
  html,
  body {
    background: #f5f4ed;
  }

  body {
    display: block;
    max-width: none;
    padding: 0;
    font-size: 10pt;
    line-height: 1.52;
  }

  .report-shell,
  .report-shell--no-toc,
  .article-body {
    display: block;
    width: auto;
    max-width: none;
    margin: 0;
    padding: 0;
  }

  nav#TOC {
    display: none;
  }

  h1,
  h2,
  h3 {
    break-after: avoid;
  }

  h1 {
    font-size: 34pt;
    line-height: 1.12;
  }

  .doc-date {
    margin: 0 0 16pt;
    padding-bottom: 10pt;
    font-size: 8.5pt;
  }

  h2 {
    font-size: 18pt;
    line-height: 1.22;
  }

  h3 {
    font-size: 12.5pt;
    line-height: 1.3;
  }

  figure {
    margin: 18pt 0 8pt;
  }

  figure + p,
  p:has(> img) + p {
    margin: 6pt auto 18pt;
    font-size: 8.5pt;
  }

  .table-wrap {
    overflow: visible;
    margin: 16pt 0 18pt;
    border-radius: 6pt;
  }

  table {
    min-width: 0;
    font-size: 8.8pt;
  }

  th,
  td {
    padding: 6pt 7pt;
    line-height: 1.38;
  }

  img,
  .table-wrap,
  blockquote,
  pre {
    break-inside: avoid;
  }

  a {
    color: var(--ink);
    text-decoration: none;
  }
}


path: templates/visual-spec-template.json
content: {
  "title": "Tutorial Chapter Visual Pack",
  "theme": {
    "accent": "#1f4e79",
    "accent2": "#1f4e79",
    "ink": "#111827",
    "muted": "#5f6673",
    "surface": "#f8f6f1",
    "border": "#d9d6cc",
    "paper": "#ffffff"
  },
  "board_style": {
    "layout": "sticky-rail-canvas",
    "aspect_ratio": "16:9",
    "visual_system": "frontend-slides-first canvas board"
  },
  "chapters": [
    {
      "id": "chapter-01",
      "title": "The First Mental Model",
      "diagram_type": "flow",
      "layout_note": "A flow is the clearest grammar because this chapter teaches a transformation sequence.",
      "summary": "Show the beginner path from a confusing input to a usable mental model.",
      "nodes": [
        {
          "label": "Raw topic",
          "detail": "What the learner sees first"
        },
        {
          "label": "Key question",
          "detail": "The confusion to resolve"
        },
        {
          "label": "Mental model",
          "detail": "A simpler structure"
        },
        {
          "label": "First example",
          "detail": "A visible result"
        }
      ],
      "caption": "A beginner learns faster when the first chapter turns confusion into one usable model."
    },
    {
      "id": "chapter-02",
      "title": "How The Pieces Fit",
      "diagram_type": "layer",
      "summary": "Show the topic as stacked levels so the learner can place new terms.",
      "nodes": [
        "User goal",
        "Workflow",
        "Core concepts",
        "Tools or implementation",
        "Tradeoffs"
      ],
      "caption": "The layers separate what the learner wants from how the system works."
    }
  ]
}


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (HIGH)