commit d436a5e4ffb85bc57592c977136fd38b21adfd18 Author: wehub-resource-sync Date: Mon Jul 13 13:05:52 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 0000000..5168c89 --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,20 @@ +{ + "name": "kami", + "interface": { + "displayName": "Kami" + }, + "plugins": [ + { + "name": "kami", + "source": { + "source": "local", + "path": "./plugins/kami" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + } + ] +} diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..7e484d7 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,18 @@ +{ + "name": "kami", + "description": "Document typesetting skill for Claude Code.", + "owner": { + "name": "Tw93", + "email": "hitw93@gmail.com" + }, + "plugins": [ + { + "name": "kami", + "version": "1.9.4", + "description": "Typeset professional documents and landing pages with the Kami design system.", + "category": "documents", + "source": "./plugins/kami", + "homepage": "https://github.com/tw93/kami" + } + ] +} diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..34c7c7e --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "kami-site", + "runtimeExecutable": "python3", + "runtimeArgs": ["-m", "http.server", "8321"], + "port": 8321 + } + ] +} diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..5e0abbb --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +github: ['tw93'] +custom: ['https://cats.tw93.fun?name=Kami'] diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..eebdff9 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,79 @@ +name: check + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + lint-and-test: + name: lint and test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install test extras + run: python3 -m pip install Pygments + + - name: Lint templates and check token sync + run: python3 scripts/build.py --check + + - name: Check generated Codex plugin metadata + run: python3 scripts/build_metadata.py --check + + - name: Run test suite + run: python3 scripts/tests/test_build.py + + - name: Build and audit skill package + run: bash scripts/package-skill.sh /tmp/kami-ci.zip + + verify-render: + name: render and verify + runs-on: ubuntu-latest + needs: lint-and-test + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install WeasyPrint system libs and CJK fallback fonts + # libcairo2 / libpango / libharfbuzz are required by WeasyPrint at + # runtime; fonts-noto-cjk provides a serif CJK fallback so the Chinese + # templates render with embedded glyphs even though TsangerJinKai02 is + # commercial and not shipped in CI. The verify step accepts Noto as a + # recognized fallback (see scripts/build.py fallback_present). + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libcairo2 libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz0b \ + fonts-noto-cjk fonts-noto-cjk-extra + + - name: Install Python deps + run: python3 -m pip install weasyprint pypdf Pygments + + - name: Verify strict page-count targets + # Only the six hard-invariant templates (resume == 2, one-pager == 1 + # across CN/EN/KO) run in CI. The KO pair also exercises the Korean + # render path on Linux (fonts-noto-cjk supplies the glyphs). The + # long-doc / portfolio / slides ceilings are soft and checked visually. + # KAMI_ALLOW_FALLBACK_ONLY=1: CI does not ship commercial fonts + # (TsangerJinKai02, Charter). Treat fallback-only embedding as a + # warning instead of a failure so CI can still gate page counts. + env: + KAMI_ALLOW_FALLBACK_ONLY: '1' + run: | + python3 scripts/build.py --verify one-pager + python3 scripts/build.py --verify one-pager-en + python3 scripts/build.py --verify one-pager-ko + python3 scripts/build.py --verify resume + python3 scripts/build.py --verify resume-en + python3 scripts/build.py --verify resume-ko diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..3bdbc0b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,64 @@ +name: release + +on: + push: + tags: ['V*'] + workflow_dispatch: + inputs: + tag: + description: 'Tag to attach kami.zip to (e.g. V1.4.1)' + required: true + +jobs: + attach-archive: + name: build kami.zip and attach to release + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.tag || github.ref }} + + - name: Resolve tag name + id: tag + run: | + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + TAG="${{ github.event.inputs.tag }}" + else + TAG="${GITHUB_REF#refs/tags/}" + fi + echo "name=$TAG" >> "$GITHUB_OUTPUT" + echo "Resolved tag: $TAG" + + - name: Build kami.zip + run: | + bash scripts/package-skill.sh dist/kami.zip + ls -lah dist/kami.zip + + - name: Ensure release exists, then attach archive + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.tag.outputs.name }} + run: | + if ! gh release view "$TAG" >/dev/null 2>&1; then + echo "Release $TAG does not exist, creating placeholder" + gh release create "$TAG" --title "$TAG" --notes "Release notes pending. Edit on GitHub." + fi + gh release upload "$TAG" dist/kami.zip --clobber + echo "OK: kami.zip attached to release $TAG" + + - name: Add release reactions + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.tag.outputs.name }} + run: | + # Match the house style every release carries: one of each positive + # reaction. Idempotent (re-running adds nothing) and non-fatal so a + # reaction hiccup never fails an otherwise-good release. + rid="$(gh api "repos/${{ github.repository }}/releases/tags/$TAG" --jq .id)" + for r in +1 eyes heart hooray laugh rocket; do + gh api -X POST "repos/${{ github.repository }}/releases/$rid/reactions" \ + -f content="$r" --jq .content || echo "skip reaction $r" + done + echo "OK: reactions ensured on release $TAG" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..81fbe59 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# macOS +.DS_Store + +# Agent local settings +.claude/settings.local.json + +# Python +__pycache__/ +*.pyc +.pytest_cache/ + +# Font build artifacts +assets/fonts/*.otf + +# Template examples with placeholder content, regenerate via: python3 scripts/build.py +assets/examples/ + +/tmp/ +/dist/* +!/dist/kami.zip +*.log +.vercel + +# Slides build artifact (moved into assets/examples/ by build.py) +/output.pptx diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9dbd6cd --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,220 @@ +# Kami Agent Guide + +> Personal/global agent rules may live outside this repository. This file records Kami-specific repository maps, Working Rules, Current Risk Areas, Verification, Release Flow, and Fonts. + +## Project + +Kami is a document-generation skill and template system. It ships editorial HTML templates, reference guides, demo assets, and a packaged skill archive. + +## Repository Map + +- `SKILL.md` - skill routing and operating rules. +- `CHEATSHEET.md` - quick design reference. +- `CLAUDE.md` - Claude-specific notes pointing to AGENTS.md. +- `references/` - design, writing, diagram, and production guidance. +- `references/design.md`, `writing.md`, `production.md`, `diagrams.md` - full specs. +- `references/resume-writing.md` - resume-specific bullet/project framing rules. +- `references/anti-patterns.md` - six-category checklist for reviewing drafts. +- `references/mermaid.md` - Mermaid diagram support: the two render paths (PDF vs browser) and the authoring pipeline. +- `references/mermaid-theme.json` - canonical Kami↔beautiful-mermaid color/font theme (kept in sync with `tokens.json`). +- `references/tokens.json` - canonical color tokens (drift-checked by `scripts/tokens.py`). +- `references/checks_thresholds.json` - rhythm / density / orphan check thresholds (loaded by `scripts/checks.py`). +- `references/brand-profile.md` and `references/brand.example.md` - optional brand profile behavior and public example. +- `.claude-plugin/marketplace.json` - **generated** Claude Code plugin marketplace metadata. Points Claude Code at `plugins/kami`. +- `.agents/plugins/marketplace.json` - **generated** Codex repo marketplace. Points Codex at `plugins/kami`; never hand-edit. +- `plugins/kami/` - **generated** Claude Code / Codex plugin tree. Mirrors the lightweight skill package under `plugins/kami/skills/kami/`; edit source files and run `python3 scripts/build_metadata.py`. +- `assets/templates/` - document templates including browser-only landing page variants. +- `scripts/highlight.py` - Pygments-based syntax highlighting for code blocks at build time. +- `assets/demos/` - README showcase demos. +- `assets/showcase/` - README and public-site-only screenshots; excluded from `dist/kami.zip`. +- `assets/diagrams/` - diagram prototypes and generated diagram assets; `src/*.mmd` records the Mermaid source of the `sequence` / `class` / `er` diagrams. +- `scripts/mermaid_normalize.py` - re-themes any beautiful-mermaid SVG to the Kami palette and makes it WeasyPrint-safe (resolves `color-mix()`/`var()` to static hex, rewrites fonts). Pure Python, no Node; ships in the package. +- `assets/fonts/` and `assets/illustrations/` - bundled visual assets. +- `styles.css` - shared web-facing styles. +- `index.html`, `index-zh.html`, `index-en.html`, `index-ja.html`, `index-ko.html`, `index-tw.html` - public site entrypoints. +- `robots.txt`, `sitemap.xml`, and `vercel.json` - public crawler, deployment, and AI visibility files. +- `llms.txt` - AI crawler and model-facing project summary. +- `scripts/build.py` - CLI shell: build targets and dispatch to lint / verify / checks / tokens modules. +- `scripts/verify.py` - end-to-end render verification (page count, embedded fonts, advisory density scan). +- `scripts/lint.py` - template CSS lint rules and base/variant cross-template `:root` consistency check (CN↔EN and CN↔KO). +- `scripts/tokens.py` - `tokens.json` drift check across HTML templates and PPTX slide scripts. +- `scripts/checks.py` - PDF-side checks: placeholders, orphans, density, slide-deck rhythm. +- `scripts/optional_deps.py` - centralized loader for weasyprint / pypdf / PyMuPDF with consistent install hints. +- `scripts/shared.py` - shared constants and the canonical `HTML_TEMPLATES` registry used by the build scripts. +- `scripts/ensure-fonts.sh` - verified font recovery helper (portable across bash 3.2+). +- `scripts/package-skill.sh` - package builder for the release archive. +- `scripts/site_facts.py` - public-site fact drift checks (install commands, version, template and diagram counts across `index*.html`, README, `llms.txt`); wired into `build.py --check`. +- `scripts/check-update.sh` - quiet daily update check invoked from `SKILL.md`; read-only VERSION compare, silent on any failure. +- `scripts/build_metadata.py` - codegen for Claude Code / Codex marketplace metadata and plugin mirror files. Run after changing `SKILL.md`, `CHEATSHEET.md`, `VERSION`, `references/`, `scripts/`, or shipped lightweight assets. +- `scripts/draft-release-notes.py` - bilingual release notes scaffold from `git log`. +- `scripts/tests/test_build.py` - zero-dependency test suite for build and shared helpers. +- `.github/workflows/check.yml` - PR/push CI that runs `--check` and the test suite. +- `.github/workflows/release.yml` - tag-triggered workflow that builds and attaches `dist/kami.zip` to the release. +- `dist/kami.zip` - tracked release archive. + +Reference docs are English-only. Language-specific output differences (CN/EN/KO) belong in templates, not duplicated reference files. + +## Commands + +```bash +python3 scripts/build.py +python3 scripts/build.py --check +python3 scripts/build.py --verify +python3 scripts/build.py --check-placeholders path/to/filled.html +python3 scripts/build.py --check-markdown path/to/filled.pdf +python3 scripts/build.py --check-orphans path/to/doc.pdf +python3 scripts/build.py --check-density path/to/doc.pdf +python3 scripts/build.py --check-rhythm slides slides-en +python3 scripts/build_metadata.py +python3 scripts/build_metadata.py --check +python3 scripts/tests/test_build.py +# Re-theme + WeasyPrint-safe a beautiful-mermaid SVG (no Node), then embed in a diagram shell: +python3 scripts/mermaid_normalize.py raw.svg -o clean.svg +python3 scripts/draft-release-notes.py V1.4.0..HEAD --version V1.4.1 --title "Steadier Hand" +bash scripts/ensure-fonts.sh +bash scripts/package-skill.sh +``` + +## Working Rules + +- Style changes must update `references/design.md` and the matching template tokens. +- Landing or documentation-site work follows `references/design.md` Section 11: «Documentation site» for the doc shell (sidebar rail, on-this-page TOC, borderless prev/next pager, build-time zero-JS code highlighting) and «Responsive screenshot verification» (screenshot at 375px / 1280px per locale, objective line-widow scan) before shipping. +- For hosted Kami site or public landing changes, first separate generic template work from Kami's own website. Generic behavior lives in `assets/templates/landing-page*` and `references/`; Kami site facts live across `index*.html`, `styles.css`, README, `llms.txt`, `robots.txt`, `sitemap.xml`, and `vercel.json`. +- Public facts are wider than the hero. Pricing, install path, version, release, support, analytics, FAQ, and positioning claims must move together across pages, metadata, AI files, and download links. Do not leave site-only analytics or tracking changes contradicting "no analytics" or app/package privacy copy. +- Content changes should avoid CSS churn unless layout behavior is part of the task. +- For document or template tasks, lock the output contract before editing: language, template, output format, page or length target, visual acceptance check, and verification command. +- Prefer the nearest existing template and deterministic verifier. Do not add a template, shared CSS layer, dependency, script flag, or optional mode unless the current request cannot be satisfied without it. +- New templates should copy the nearest existing template, stay aligned with `references/design.md`, and add demo coverage. +- Do not use graphic emoticons in docs, template comments, or script output. +- Do not use em dashes (U+2014) in repository docs, generated documents, template comments, or site copy; use colons, commas, periods, or parentheses instead. Self-check: `grep -rn '—' README.md llms.txt index*.html`. Teaching counter-examples inside `references/anti-patterns.md` are exempt; its rule #27 covers the generated-document side. +- Use `OK:` and `ERROR:` for status text in scripts. +- Use `scripts/ensure-fonts.sh` to recover required fonts with retry and size validation when local font files are missing or truncated. It downloads to the XDG user font dir (`${XDG_DATA_HOME:-~/.local/share}/fonts/kami`), never into the skill's `assets/fonts`, so an installed Claude Desktop skill stays small; inside a repo checkout it is a no-op because the committed large fonts already satisfy the templates' relative path. +- Do not bundle large CJK font files into `dist/kami.zip`; package scripts should exclude them while templates keep stable local-preview paths. The skill ZIP uploaded to Claude Desktop must be the `scripts/package-skill.sh` output under the 6MB package ceiling and must contain a top-level `kami/` skill folder; a hand-zipped checkout includes the tracked large fonts and Claude Desktop rejects it. +- Do not bundle README/public-site-only showcase screenshots into `dist/kami.zip`; keep them under `assets/showcase/` and exclude that directory in `scripts/package-skill.sh`. +- Keep multilingual public pages, `llms.txt`, `robots.txt`, sitemap, JSON-LD, and FAQ content aligned when changing public positioning or install instructions. +- Brand profile support is optional context. Keep public examples in `references/`; do not hard-code a maintainer's private local profile content. +- Slides default to WeasyPrint HTML-to-PDF templates unless the user explicitly needs editable PPTX output. +- Templates intentionally inline their CSS rather than share a `_kami.css` partial: each template must remain a single self-contained HTML file so users can copy-paste it without a build step. When fixing CSS drift, apply the same change across affected templates rather than introducing a build-time include. +- All template registries live in `scripts/shared.py`: `HTML_TEMPLATES` (PDF docs), `SCREEN_TEMPLATES` (browser-only), and `DIAGRAM_TEMPLATES` (assets/diagrams). `build.py` derives its target dicts from them via `build_targets()` / `screen_targets()` / `diagram_targets()`. Update the registry, not the per-script dicts, when adding or removing a template or diagram. +- Mermaid diagrams: never embed raw beautiful-mermaid SVG into a PDF-bound template. WeasyPrint cannot resolve `color-mix()`, render ``, or fetch a runtime web font, so always pipe through `scripts/mermaid_normalize.py` first (`--check` lint enforces this). It is pure Python, no Node bundled. `xychart-beta` is browser-only (it styles via ` + + + + +
+
+
Keynote · 2026
+

Things you don't know about Agents

+
Loops, harness, context, memory: what actually moves the needle in production.
+
+
kami slides demo · A4 landscape · 8 slides
+
+
+ + + + + + + + + + + + Agent + LOOP + +
+
01
+ +
+ + +
+
+ 01 · Agent Loop +
+
Simple core,
complex surroundings
+ +
+
+

The loop is small. The infrastructure around it is what keeps it stable as features grow.

+
    +
  • A working Agent loop fits in about 20 lines of code.
  • +
  • Control flow lives in the tools, not in branchy internal state.
  • +
  • Workflow vs Agent: predefined paths in code vs model picks paths at runtime.
  • +
+
+ If your loop keeps growing every sprint, you are fixing the wrong layer. The tax is paid outside the loop. +
+
+
+ + + + TOOLS · HARNESS · EVALS · MEMORY + + + + + + + PLAN + + + + ACT + + + + OBSERVE + + + + REFLECT + + + 20 LOC + CORE + + + + + + + + + + + + + + + + + + + + + + +
+
+
02
+ +
+ + +
+
+ 01 · Agent Loop +
+
One turn, traced
+ +

The same loop, made concrete: a single user turn flows through the agent to a tool and back.

+ +
+ + + + + + + + + + + + + + + + ask + + + + invoke(query) + + + + result + + + + answer + + + + User + + + + Agent + + + + Tool + + +
+ +
03
+ +
+ + +
+
+ 02 · Harness +
+
Harness wins
over hardware
+ +
+

More expensive models bring gains far smaller than expected. Verification, boundaries, feedback, and fallbacks matter more than model capability.

+
    +
  • Upgrade the harness first. If accuracy does not move, then try the model.
  • +
  • Evaluation is the only honest signal. Test harness before test model.
  • +
  • Smaller model + strong harness routinely beats larger model + weak harness in production.
  • +
+
+ +
+
+
20
+
lines of code in a working Agent core loop
+
+
+
4
+
harness layers that matter: verify, bound, feedback, fallback
+
+
+
10×
+
velocity gains trace to execution discipline, not model swaps
+
+
+ +
04
+ +
+ + +
+
+ 03 · Context +
+
Density beats length
+ +
+
+

Long context windows do not fix weak context design. Context Rot sets in around 300–400K tokens regardless of the model.

+
    +
  • Layer the load: constant, on-demand, runtime, memory, system.
  • +
  • Index first, full content on demand. Beats dumping everything up front.
  • +
  • Stable prompt prefixes let caching actually pay off.
  • +
  • Every token that is not load-bearing is diluting signal.
  • +
+
+
+ + + + + + LOADING STRATEGY vs ACCURACY + + Accuracy + + + + + 85% + + 53% + + + + + 53% + Flat + loading + + + 85% + Layered + loading + + + +
+
+ +
05
+ +
+ + +
+
+ 04 · Tools & Memory +
+
Put state outside
the context
+ +
+
+

Tools should match Agent goals, not underlying API shapes. Memory lives on disk, not in the window.

+
    +
  • ACI principle: Agent-Computer Interface - design for what the Agent wants to do, not for the HTTP verb.
  • +
  • A bad tool description looks like a model failure until you re-read the description.
  • +
  • File-based state survives restarts. In-context state does not.
  • +
+
+
+

Four kinds of memory

+
    +
  • Working: the context window - fast, expensive, temporary.
  • +
  • Procedural: SKILL.md files - how to behave, loaded lazily.
  • +
  • Episodic: JSONL logs - what happened, appendable.
  • +
  • Semantic: MEMORY.md - what to remember across sessions, consolidated at thresholds.
  • +
+
+ Cross-session consistency needs explicit consolidation, not hope. +
+
+
+ +
06
+ +
+ + +
+
+ 05 · Code Style +
+
Pseudocode over syntax
+ +
+
+

Comments should outnumber code lines. The reader sees logic, not a language tutorial.

+
    +
  • Write the intent first, then the implementation detail below it.
  • +
  • Variable names describe what a thing is, not what type it has.
  • +
  • One concept per block. Split ruthlessly.
  • +
+
+
+
# resolve tool call or decide to stop +function agent_step(context, tools): + # model picks the next action + action = model.think(context) + + # terminal condition: model says done + if action.type == "stop": + return action.result + + # delegate to the right tool + result = tools[action.name](action.args) + return agent_step(context + result, tools)
+
+
+ +
07
+ +
+ + +
+

Protocol first.
Then parallelism.

+
+
Fix your evals before you tweak the Agent. Most of what looks like model failure is infrastructure noise in disguise.
+
08
+ +
+ + + diff --git a/assets/demos/demo-agent-slides.pdf b/assets/demos/demo-agent-slides.pdf new file mode 100644 index 0000000..152c3e2 Binary files /dev/null and b/assets/demos/demo-agent-slides.pdf differ diff --git a/assets/demos/demo-agent-slides.png b/assets/demos/demo-agent-slides.png new file mode 100644 index 0000000..c1efb3f Binary files /dev/null and b/assets/demos/demo-agent-slides.png differ diff --git a/assets/demos/demo-changelog.html b/assets/demos/demo-changelog.html new file mode 100644 index 0000000..dee4e7b --- /dev/null +++ b/assets/demos/demo-changelog.html @@ -0,0 +1,224 @@ + + + + + +Mole · v1.7.1 Release Notes + + + + + + + + +
+
+
Release Notes
+

Mole v1.7.1

+
Accessibility throughout, a faster status board, and deeper cleanup, uninstall, and updates.
+
+
June 13, 2026
+
+ +

Highlights

+
    +
  1. Accessibility throughout: VoiceOver labels, smoother keyboard paths, and Reduce Motion across the app and the website, guided by feedback from users with visual impairments.
  2. +
  3. A faster status board: the dashboard opens from your last snapshot while fresh data loads, process rows appear sooner, and power and temperature readings line up better.
  4. +
  5. Deeper software and uninstall: Electron apps update inside Mole alongside Sparkle, Homebrew, and the App Store, and uninstall now connects startup items and package receipts.
  6. +
  7. Clean covers more, still review-first: WeChat caches, oversized live logs, Xcode artifacts, and project build and dist folders are easy to review, group, or exclude.
  8. +
  9. Fan control on Apple Silicon: presets now apply reliably, including M4-class Macs where the thermal controller had kept fan writes locked.
  10. +
  11. License and devices: device lists load faster, you can release every Mac at once, and moving a license to a new machine is handled inside the app.
  12. +
+ +

Fixes & polish

+
    +
  1. Menu bar: the icon is restored when Control Center drops it, and the popover stays compact on shorter screens.
  2. +
  3. Clean: AI tool caches such as ChatGPT, Codex, and Antigravity can be cleaned again.
  4. +
  5. Privacy: camera and microphone use is detected as it starts, with fewer false alerts.
  6. +
  7. Planets: rendered larger and more natural, with a remastered NASA Blue Marble Earth and a steadier spin.
  8. +
  9. Analyze and Doctor: disk analysis uses less CPU on many-core Macs, and Doctor reports carry more system state for clearer issue reports.
  10. +
+ +
+
Acknowledgements
+ Thanks to the Mole users with visual impairments whose feedback shaped this release's accessibility work, and to @_huawuque and @liujiayi1111 for surfacing real-world issues before the stable build. +
+ + + + + diff --git a/assets/demos/demo-changelog.pdf b/assets/demos/demo-changelog.pdf new file mode 100644 index 0000000..d2ae4a1 Binary files /dev/null and b/assets/demos/demo-changelog.pdf differ diff --git a/assets/demos/demo-changelog.png b/assets/demos/demo-changelog.png new file mode 100644 index 0000000..2775906 Binary files /dev/null and b/assets/demos/demo-changelog.png differ diff --git a/assets/demos/demo-kaku.html b/assets/demos/demo-kaku.html new file mode 100644 index 0000000..e651919 --- /dev/null +++ b/assets/demos/demo-kaku.html @@ -0,0 +1,795 @@ + + + + + +Kaku · ターミナルエミュレータ + + + + + +
+
TERMINAL EMULATOR · 2025
+
+
Kaku
+
AI コーディング向けに設計した高速ターミナルエミュレータ。初期設定のまま使え、Lua で深く拡張できる、軽量で高性能な実装です。
+
+
+ +
+
カテゴリ ターミナルエミュレータ
+
技術 Rust · WezTerm · Lua
+
状態 OSS · 4800+ Stars
+
+
+ + +
+
01 · ABOUT
+
Kaku とは
+
Kaku(書く)は、思考をかたちにする行為を指す言葉です。WezTerm をベースに深く最適化し、実用的なデフォルトと完全な Lua カスタマイズ性、軽快な操作感を両立したターミナルです。
+ +
+
+
4800+
+
GitHub Stars
+
+
+
Rust
+
主要言語
+
+
+
40 MB
+
実行ファイル
+
+
+
macOS
+
重点プラットフォーム
+
+
+ +
+

Kaku は三部作の一角です。Kaku(書く)がコードを書き、Waza(技)が習慣を鍛え、Kami(紙)が文書を仕上げます。私は長く CLI 中心で仕事をしてきました。Alacritty を長年使ったあと、AI 補助コーディングへ比重が移るにつれ、より強いタブ管理と内蔵 AI 連携が必要になりました。Kitty、Ghostty、Warp、iTerm2 も試しましたが、性能優先、デフォルトで使いやすい、Lua で全面的に拡張できる、というバランスに最も近かったのが Kaku です。

+

WezTerm は強力なエンジンです。Kaku ではその上で 2 点に集中しました。不要な重量の削減と、AI ワークフローの直接統合です。設定は WezTerm Lua API と互換で、既存設定を移行せず再利用できます。Starship、Delta、Lazygit、Yazi も最初から連携済みです。macOS では、Traffic Light のタブバー統合、システム外観への追従、ネイティブフォント描画、Apple notarization まで含めて、導入直後から実用状態になります。

+
+
+ Kaku terminal interface +
+
+ + +
+
02 · FEATURES
+
主要機能
+
Kaku は複雑な初期設定なしで使い始められます。同時に WezTerm Lua API 互換を維持し、深いカスタマイズにも対応します。
+ +
+
+

ゼロ設定で起動

+

標準フォントは JetBrains Mono。macOS の描画特性に合わせて調整済みです。ディスプレイ解像度を自動判定し、低解像度は 15px、高解像度は 17px、行高 1.28 を自動適用します。

+
+ +
+

テーマ自動切替

+

macOS の外観設定に連動して Kaku Dark / Kaku Light を自動切替します。選択色、字重、色上書きテーブルも同時更新され、color_scheme で固定運用もできます。

+
+ +
+

厳選シェルツール

+

zsh プラグインを自動読み込みします。z ディレクトリ移動、zsh-completions、構文ハイライト、補完候補、Smart Tab に対応し、fish 連携も可能です。

+
+ +
+

高速で軽量

+

シンボル削減と機能整理でバイナリを 40% 圧縮しました(67MB -> 40MB)。リソースは 100MB から 80MB へ。JIT 初期化で Shell 初期化時間も半減します(200ms -> 100ms)。

+
+ +
+

WezTerm 互換設定

+

WezTerm の Lua 設定をそのまま利用でき、API 互換を保ちます。移行作業は不要です。kaku.lua は defaults を先に読み込み、その後 overrides を適用します。

+
+ +
+

洗練されたデフォルト

+

選択コピー、クリック可能なパス、履歴表示、ペイン入力ブロードキャスト、バックグラウンド完了表示、Traffic Light のタブバー統合を標準搭載します。

+
+
+ +
+
+

Lazygit 連携

+

PATH または Homebrew のパスから lazygit バイナリを自動検出します。未コミット変更がある場合は 1 回だけ通知します。

+ Cmd + Shift + G +
+
+

Yazi 連携

+

Shell ラッパー y で起動し、終了時に作業ディレクトリを同期します。配色は yazi/theme.toml に自動反映されます。

+ Cmd + Shift + Y +
+
+

Remote Files(SSHFS)

+

アクティブな SSH ペインから接続先を自動判定し、sshfs でリモートファイルシステムをマウントして Yazi で閲覧できます。

+ Cmd + Shift + R +
+
+
+ + +
+
03 · AI ASSISTANT
+
Kaku AI アシスタント
+
Kaku には AI アシスタントを内蔵しています。2 つのモードで、ターミナル作業の主要な AI ユースケースをカバーします。kaku ai で設定パネルを開き、外部コーディングツールと Kaku Assistant を設定できます。
+ +
+
+

エラー自動リカバリ

+

コマンド失敗時に、失敗コマンド + 終了コード + 作業ディレクトリ + git ブランチを LLM へ送信し、修正案をターミナル内に表示します。Cmd + Shift + E でそのまま適用できます。

+

未発火条件: Ctrl+C 中断、help フラグ、素のパッケージマネージャー実行、git pull 競合、非 shell 前景プロセス。危険コマンド(rm -rfgit reset --hard)は貼り付け可能ですが自動実行しません。

+
+ +
+

自然言語からコマンド

+

プロンプトで # <説明> を入力して Enter すると、Kaku が shell に渡す前に行を取得し、現在のディレクトリと git ブランチを添えて LLM に送信します。生成されたコマンドはプロンプトへ戻り、確認してから実行できます。

+

+ # list all files modified in the last 7 days
+ # find and kill the process on port 3000
+ # compress the src folder excluding node_modules +

+
+
+ +
+
+
assistant.toml 設定項目
+ + + + + + + +
項目説明
enabledtrue で有効 / false で無効
api_keyプロバイダー API Key
modelモデル識別子 (例: DeepSeek-V3.2)
base_urlOpenAI 互換 API のベース URL
custom_headersプロキシ追加 HTTP ヘッダー
+
+
+
対応 AI コーディングツール
+ + + + + + + +
ツール説明
claudeClaude Code · Anthropic
codexOpenAI Codex CLI
geminiGemini CLI · Google
copilotGitHub Copilot CLI
kimiKimi Code · Moonshot
+
+
+
+ + +
+
04 · PERFORMANCE
+
性能比較
+
シンボル削減、機能整理、リソース最適化により、Kaku は機能を維持したままサイズと起動時間を大幅に改善しました。macOS 専用設計でプラットフォーム統合も深めています。
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
指標上流 WezTermKaku最適化手法
実行ファイルサイズ~67 MB~40 MBシンボル削減 + 機能整理
リソース容量~100 MB~80 MBリソース最適化 + 遅延ロード
起動遅延標準即時JIT 初期化
Shell 初期化時間~200ms~100ms環境設定フロー最適化
+ +
+
+

バイナリ最適化

+

コンパイル時に未使用の feature flags を削除し、symbol stripping とデバッグ情報削減を実施。Rust release profile は LTO + codegen-units=1 で最適化を最大化します。

+
+
+

遅延ロード戦略

+

Shell ツール群(Starship、Delta、Yazi テーマ同期)は just-in-time 初期化を採用。最初の Kaku セッションでのみ起動し、システム全体の shell 起動時間を悪化させません。

+
+
+

macOS 統合

+

Traffic Light のタブバー統合(INTEGRATED_BUTTONS|RESIZE)、システム外観連動、Apple notarization(警告なし)、macOS ネイティブフォント描画スタックを提供します。

+
+
+
+ + +
+
05 · INSTALLATION
+
インストールと使い方
+
Kaku はシンプルな導入手順と直感的なショートカット体系を提供します。初回起動時に shell 環境も自動設定されます。
+ +
+
+
1
+
+

インストール

+

GitHub Releases から最新の DMG を取得してアプリケーションフォルダへ移動するか、Homebrew で導入します。

+ brew install tw93/tap/kaku +
+
+ +
+
2
+
+

初回起動

+

Kaku は Apple notarization 済みです。セキュリティ警告なしで起動でき、初回起動時に shell 環境を自動設定します。

+
+
+ +
+
3
+
+

設定カスタマイズ

+

Cmd + , で設定パネルを開くか、kaku config で Lua 設定ファイルを編集します。

+
+
+
+ +
主要ショートカット:
+ +
+
新規タブCmd + T
+
新規ウィンドウCmd + N
+
タブ/ペインを閉じるCmd + W
+
タブ切替Cmd + Shift + [ / ]
+
ペイン縦分割Cmd + D
+
ペイン横分割Cmd + Shift + D
+
ペイン移動Cmd + Opt + 矢印
+
Lazygit を開くCmd + Shift + G
+
Yazi を開くCmd + Shift + Y
+
AI パネルCmd + Shift + A
+
AI 提案を適用Cmd + Shift + E
+
画面クリアCmd + K
+
+ +
CLI コマンド一覧
+ + + + + + + +
コマンド説明
kaku aiAI 設定パネルを開き、Kaku Assistant と外部コーディングツールを設定
kaku config既定エディタで ~/.config/kaku/kaku.lua を開く
kaku doctor診断を実行し、shell 連携、PATH、任意ツールの導入状態を確認
kaku update最新バージョンの確認と更新を実行
kaku initshell 連携を設定し、Starship / Delta / Lazygit / Yazi を任意で導入
+
+ + +
+
06 · GET KAKU
+
Kaku はオープンソースです
+
+
+
GitHubgithub.com/tw93/Kaku
+
公式サイトkaku.app
+
インストールbrew install tw93/tap/kaku
+
Stars4800+ 増加中
+
ドキュメント完全ドキュメントと FAQ
+
作者@HiTw93
+
+ +
+ Kaku terminal in action +
+
+ diff --git a/assets/demos/demo-kaku.pdf b/assets/demos/demo-kaku.pdf new file mode 100644 index 0000000..e59ba36 Binary files /dev/null and b/assets/demos/demo-kaku.pdf differ diff --git a/assets/demos/demo-kaku.png b/assets/demos/demo-kaku.png new file mode 100644 index 0000000..6583329 Binary files /dev/null and b/assets/demos/demo-kaku.png differ diff --git a/assets/demos/demo-kami-print.html b/assets/demos/demo-kami-print.html new file mode 100644 index 0000000..8f15116 --- /dev/null +++ b/assets/demos/demo-kami-print.html @@ -0,0 +1,381 @@ + + + + + +Kami · 为 AI 时代设计的文档排版系统 + + + + + + + + + +
+
+
AI 文档排版系统
+

Kami · 紙

+
好内容值得好纸。给大模型一段简报,得到可直接打印的专业文档。
+
+
+ Tw93
+ 2026.06.29
+ v1.9.1 +
+
+ + +
+
+
8
+
文档模板
+
+
+
18
+
图表类型
+
+
+
5
+
语言支持
+
+
+
1
+
强调色
+
+
+ + +

+ Kami 把编辑级的排版规范沉淀成一套可复用的设计语言:单一墨蓝强调、衬线承担层次、暖灰中性色、克制的留白。你只描述需求,AI 负责选模板、填内容、导出 A4 PDF,无需手写一行 CSS。 +

+ + +
+
+

一种语言,多类文档

+

同一套色彩 token 与排印规则,从一页纸贯通到长文白皮书,风格始终一致。

+
    +
  • 简历、一页纸方案、正式信件
  • +
  • 作品集、白皮书、长文报告
  • +
  • 演示 slides、个股研报、更新日志
  • +
  • 内嵌 18 种矢量图表与流程图
  • +
+
+ +
+

克制的设计原则

+

规则比选项多,刻意收窄表达空间,换来跨文档的稳定与可读。

+
    +
  • 墨蓝强调覆盖面积不超过 5%
  • +
  • 衬线承担层次,字重只用 400 与 500
  • +
  • 暖灰中性色,杜绝冷调蓝灰
  • +
  • 为打印而生,分页与字体内嵌稳定
  • +
+
+
+ + +
+

四步交付从一句话简报到可打印 PDF

+
+
+
第 1 步
+
描述需求
+
一句话简报交给 AI,说清用途与受众。
+
+
+
第 2 步
+
自动选型
+
按用途匹配模板,数据自动配上合适图表。
+
+
+
第 3 步
+
填充排版
+
内容落入衬线网格,层次与节奏自动对齐。
+
+
+
第 4 步
+
导出交付
+
输出 A4 PDF 与 HTML,可直接打印或分享。
+
+
+
+ + +
+

在你的 AI 工具里启用Claude Code / Codex / Claude Desktop

+ # Claude Code +/plugin marketplace add tw93/kami && /plugin install kami@kami +# Codex +codex plugin marketplace add tw93/kami && codex plugin add kami@kami +
+ + +
+ Kami 是 Kaku · Waza · Kami 工具集的一员:Kaku 写代码,Waza 练习惯,Kami 交付文档。把排版交给系统,把注意力留给内容。 +
+ + + + + + diff --git a/assets/demos/demo-kami-print.pdf b/assets/demos/demo-kami-print.pdf new file mode 100644 index 0000000..94fdd9e Binary files /dev/null and b/assets/demos/demo-kami-print.pdf differ diff --git a/assets/demos/demo-kami-print.png b/assets/demos/demo-kami-print.png new file mode 100644 index 0000000..e0beafe Binary files /dev/null and b/assets/demos/demo-kami-print.png differ diff --git a/assets/demos/demo-letter.html b/assets/demos/demo-letter.html new file mode 100644 index 0000000..d1f20d3 --- /dev/null +++ b/assets/demos/demo-letter.html @@ -0,0 +1,248 @@ + + + + + +推荐信 · 陈知远 + + + + + + + + +
+
林川
+
云栖科技 · 平台工程部 技术总监
+
010-8000 1234 · linchuan@example.com
+
+ +
2026 年 6 月 14 日
+ +
+
+
招聘委员会
+
星核实验室 · 研发中心
+
+ +
+
关于
+
推荐陈知远担任高级后端工程师
+
+ +
尊敬的招聘委员会:
+ +
+ +

+我谨以诚挚的态度,推荐陈知远先生加入贵实验室研发团队。过去四年他在我负责的平台工程部任后端工程师,是少有的既能把架构想清楚、又能把代码写扎实的人。 +

+ +

+他最让我看重的,是在复杂系统中定位根因的能力。去年核心结算服务间歇性超时,多位工程师排查两周无果,是他坚持加日志、抓运行时证据,最终定位到一处连接池配置的边界问题,将接口 P99 延迟从 1.8 秒降到 120 毫秒。 +

+ +

+他也是一位可靠的协作者:主导的服务拆分横跨三个团队,既能写清楚设计文档说服评审,也愿在上线当晚守到最后一台机器迁移完成。我相信他的工程判断力与责任心,会让他在更具挑战的课题中迅速成长。如需了解更多细节,欢迎随时与我联系。 +

+ +
+ +
+
+ 此致
敬礼! +
+ +
林 川
+
+ 云栖科技 · 平台工程部 技术总监
+ 2026 年 6 月 14 日 +
+
+ +
+ 附件 + ① 陈知远个人简历 · ② 核心项目技术总结 +
+ + + diff --git a/assets/demos/demo-letter.pdf b/assets/demos/demo-letter.pdf new file mode 100644 index 0000000..874d934 Binary files /dev/null and b/assets/demos/demo-letter.pdf differ diff --git a/assets/demos/demo-letter.png b/assets/demos/demo-letter.png new file mode 100644 index 0000000..a2fd638 Binary files /dev/null and b/assets/demos/demo-letter.png differ diff --git a/assets/demos/demo-mole-clean.jpg b/assets/demos/demo-mole-clean.jpg new file mode 100644 index 0000000..c315367 Binary files /dev/null and b/assets/demos/demo-mole-clean.jpg differ diff --git a/assets/demos/demo-mole.html b/assets/demos/demo-mole.html new file mode 100644 index 0000000..1ca16ae --- /dev/null +++ b/assets/demos/demo-mole.html @@ -0,0 +1,311 @@ + + + + + +Mole · Product Brief + + + + + + + + +
+
+
Product Brief
+

Five Mac tools, one quiet binary

+
Mole cleans, uninstalls, optimizes, analyzes, and monitors, then gets out of your way.
+
+
+ Tw93
+ 2026.06.14
+ v1.7.1 +
+
+ +

+ Mac maintenance scattered into five subscription apps, each wanting a menu-bar slot and a yearly fee. Mole folds them back into one quiet binary you buy once. +

+ +
+
Mole clean view showing an earth and a scan button
+
One window, five tools. Mole replaces CleanMyMac, App Cleaner, Sensei, DaisyDisk, and iStat Menus.
+
+ +
+
+
5
+
tools, one binary
+
+
+
$19
+
no subscription
+
+
+
2
+
Macs per license
+
+
+
0
+
menu-bar clutter
+
+
+ +

+ Each tool gets its own page and a single job, with no browser tab and no menu-bar widget in sight. The result is a utility you set up once and barely notice afterward. +

+ +
+
+

Why Mole

+
    +
  • One-time $19, no yearly fee.
  • +
  • No menu-bar widget or upsell.
  • +
  • No telemetry, runs fully offline.
  • +
  • Native on Apple Silicon and Intel.
  • +
  • Supports macOS 14 and later.
  • +
+
+ +
+

What's inside

+
    +
  • Clean clears caches, logs, and leftovers.
  • +
  • Uninstall removes apps and every trailing file.
  • +
  • Optimize tunes startup and memory load.
  • +
  • Analyze shows where disk space really goes.
  • +
  • Status tracks CPU, disk, and network live.
  • +
+
+
+ + + + + diff --git a/assets/demos/demo-mole.pdf b/assets/demos/demo-mole.pdf new file mode 100644 index 0000000..d00a5f5 Binary files /dev/null and b/assets/demos/demo-mole.pdf differ diff --git a/assets/demos/demo-mole.png b/assets/demos/demo-mole.png new file mode 100644 index 0000000..7f44778 Binary files /dev/null and b/assets/demos/demo-mole.png differ diff --git a/assets/demos/demo-musk-resume.html b/assets/demos/demo-musk-resume.html new file mode 100644 index 0000000..cee1e2d --- /dev/null +++ b/assets/demos/demo-musk-resume.html @@ -0,0 +1,682 @@ + + + + +Elon Musk · Resume + + + + + + + + + +
+
+
Elon Musk
+
+
+ Founder · Chief Engineer · CEO +
+ x.com/elonmusk + · + em@example.com + · + Austin, TX +
+
+ + +
+
30yrsbuilding hard tech
+
6cosfounded / co-founded
+
400+Falcon launches
+
7M+Tesla vehicles
+
+ +
+
Summary
+
+ Founder and engineer across SpaceX, Tesla, xAI, Neuralink, The Boring Company, and X. 30 years running companies where the product is hard and the supply chain is physical. Long arc: move civilization to multi-planetary, sustainable energy, beneficial AI. Core depth in rocket engineering, mass manufacturing, capital markets, and first-principles problem decomposition. +
+
+ +
+
Experience1995 - Present · six companies, one reusable rocket, one mass-market EV
+ + +
+
+
1995
Internet era
+
Zip2 -> X.com -> PayPal. Sold two internet companies before age 32.
+
+
+
2002
Space + autos
+
SpaceX, Tesla, SolarCity. Built physical infrastructure others called impossible.
+
+
+
2023
AI era
+
xAI, Neuralink scaling, X platform ownership. Bet on AGI and BCI.
+
+
+ + +
+
+ SpaceX + · Space launch · reusable rockets + founder +
+
+
+
Role
+
Founded 2002. CEO and Chief Engineer from day one. First private company to orbit (2008), dock ISS (2012), land a booster (2015), and fly crew (2020).
+
+
+
Actions
+
Full vertical integration from propellant to avionics. Merlin and Raptor engines in-house. Reusable-booster thesis pursued through 12 failed landings. Launch cadence: 1/year (2008) to every 2-3 days (2024).
+
+
+
Impact
+
Falcon 9: 400+ launches, 95%+ reuse, 10x cost reduction. Starlink: 7,000+ satellites, largest constellation ever. Valued at $350B (2024).
+
+
+
+ +
+
+ Tesla + · EVs · energy · autonomy + ceo +
+
+
+
Role
+
Lead investor 2004, CEO from 2008 during near-bankruptcy. Built from a two-car project into the world's most valuable automaker.
+
+
+
Actions
+
Master Plan framework: each model funds the next. Four Gigafactories on three continents. FSD shipped as continuous software, not fixed release. Vertically integrated batteries, motors, silicon, charging.
+
+
+
Impact
+
7M+ vehicles delivered (1.8M in 2023), first EV to cross 20% gross margin, first automaker past $1T. Every major OEM announced EV transition within 5 years of Model 3.
+
+
+
+ +
+
+ xAI + · Frontier AI · foundation models + founder +
+
+
+
Role
+
Founded 2023 to understand the true nature of the universe. Shipped Grok within 6 months, one of the fastest foundation-model companies to production.
+
+
+
Actions
+
Built Colossus (100K H100 GPUs) in 122 days from concept to training start. Integrated Grok directly into X for real-time data access and instant distribution to 500M+ users.
+
+
+
Impact
+
Colossus in 122 days, a pace the industry called implausible. Grok reached ChatGPT-level benchmarks within 18 months. xAI valued at $50B (2024).
+
+
+
+ +
+
+ Neuralink + · Brain-computer interfaces · BCI implants + founder +
+
+
+
Role
+
Founded 2016 to build high-bandwidth brain-computer interfaces. Co-leads engineering on the N1 implant, surgical robot, and electrode array design.
+
+
+
Actions
+
Shipped N1 chip with 1,024 electrodes and precision surgical robot. FDA Breakthrough Device designation and IDE approval (2023) after five years of primate trials.
+
+
+
Impact
+
First human implanted January 2024; full cursor control in 30 days. Second patient mid-2024, expanding to speech restoration. Valued at $5B (2023). Long-term goal: high-bandwidth symbiosis between human cognition and AI.
+
+
+
+
+ + + + +
+
Open Source & Indie Work1995 - present · Founder-engineer across six hard-tech companies
+ +
+ First-principles founder across hard tech. Approach: start from the physics, question every received cost assumption, prototype in public. Public reach: 214M followers on X · 125 followers on GitHub. +
+ +
+
+ SpaceX + Launch · Starlink · Starship + * $350B +
+
+ Tesla + EVs · energy · FSD + * $900B +
+
+ +
+ CadenceThe Falcon 9 booster that landed on 22 December 2015 was the first orbital-class rocket to return intact and fly again. The industry assumption that rockets were expendable was gone within 24 months. +
+
+ +
+
Judgment & Conviction
+
+
+
1999Bet on online banking
+
+ Founded X.com while consumer online banking was niche; merger with Confinity became PayPal. Sold to eBay for $1.5B, with proceeds recycled into SpaceX and Tesla within two years. +
+
+
+
2008CEO during near-bankruptcy
+
+ Took Tesla CEO during the 2008 crisis, three weeks from insolvency. Closed a Mercedes partnership that funded Model S, enabling every subsequent Tesla product. +
+
+
+
2015Committed to reusable boosters
+
+ Pushed Falcon 9 reusability after 12 consecutive landing failures when board and industry said stop. Succeeded on attempt 13; reusability is now the industry baseline. +
+
+
+
+ +
+
Public Impact
+ +
+ X · @elonmusk + 214M followers + Direct channel to 214M followers. Each engineering post, launch video, or product hint moves markets within hours. +
+ +
+
+
Selected writing
+
+
+ Master Plan Part Deux + 2016.07 +
+
Tesla blog, 40M+ reads; reframed EV strategy across the industry
+
+
+ +
TED Talk · 5.6M views on youtube.com
+
+
+ +
+
Invited talks
+
+
+
Making Humans Multi-Planetary
+
IAC Guadalajara · 2016
+
+
2016.09
+
+
+
+
Starship and Mars
+
Starbase Texas · 2024
+
+
2024.04
+
+
+
+
+ +
+
Core Skills
+ +
+
Rocket · Engine Design
+
Led Merlin, Raptor, Starship architecture; first reusable orbital booster shipped by any org.
+
+
+
Manufacturing · Scale
+
Built Tesla Fremont, Shanghai, Berlin, Austin; SpaceX assembly scaled from 0 to 100+ launches/year.
+
+
+
Capital · Crisis Mgmt
+
Closed Daimler investment when Tesla was weeks from insolvency; survived five near-bankruptcies across companies.
+
+
+
Public Communication
+
214M followers on X; announcements routinely move the S&P 500 component price of his companies.
+
+
+
First Principles · Cost
+
Questioning every accepted cost assumption (rockets, batteries, tunneling) has driven 10× cost reductions across four industries.
+
+
+ +
+
Education
+
+
+ University of Pennsylvania + · Wharton, B.S. Economics · B.A. Physics +
+
1992 - 1997
+
+
+ + + diff --git a/assets/demos/demo-musk-resume.pdf b/assets/demos/demo-musk-resume.pdf new file mode 100644 index 0000000..ec837c6 Binary files /dev/null and b/assets/demos/demo-musk-resume.pdf differ diff --git a/assets/demos/demo-musk-resume.png b/assets/demos/demo-musk-resume.png new file mode 100644 index 0000000..4bae56a Binary files /dev/null and b/assets/demos/demo-musk-resume.png differ diff --git a/assets/demos/demo-resume-ko.html b/assets/demos/demo-resume-ko.html new file mode 100644 index 0000000..d2c6b4b --- /dev/null +++ b/assets/demos/demo-resume-ko.html @@ -0,0 +1,827 @@ + + + + + +김도현 · 이력서 + + + + + + + + + + +
+
+
김도현Dohyun Kim
+
+
+ AI 에이전트 엔지니어 · 창업자 +
+ GitHub @dohyun + · + dohyun@example.com + · + 31세, 서울 +
+
+ + +
+
9엔지니어링 경력
+
28KGitHub stars
+
5출시 제품
+
12개국사용자 분포
+
+ +
+
자기 소개
+
+ 현재 AI 에이전트 스타트업 Aru의 공동창업자 겸 엔지니어링 리드로, 6인 팀을 이끌며 LLM 기반 업무 자동화 런타임을 설계한다. 백엔드 엔지니어로 출발해 기획부터 배포·운영까지 제품을 혼자 끝까지 만드는 사이클을 여러 번 돌렸고, 그 과정에서 에이전트 런타임, 디자인 시스템, 한국어 조판, 다국어 제품화에 강점을 쌓았다. 오픈소스로 누적 28K stars를 모으며, 작은 도구를 사용자 손에 닿는 완성품까지 끌고 가는 일을 가장 즐긴다. +
+
+ +
+
경력2016 - 현재 (백엔드에서 AI 에이전트까지)
+ + +
+
+
2016
백엔드 엔지니어
+
대형 커머스의 서버 인프라와 결제 시스템을 맡아 트래픽과 데이터 정합성 문제를 다뤘다.
+
+
+
2020
풀스택 · 제품
+
기획·디자인·배포까지 혼자 책임지는 사이클을 익히고 첫 오픈소스 도구를 출시했다.
+
+
+
2023
AI 에이전트 창업
+
LLM을 실무 워크플로에 연결하는 데 집중해 에이전트 런타임 스타트업을 시작했다.
+
+
+ + +
+
+ Aru + · AI 에이전트 플랫폼 + 방향 주도 +
+
+
+
역할
+
2023년 공동창업. 반복 업무를 자동화하는 에이전트 런타임을 0에서 설계하고, 6인 엔지니어링 팀을 꾸려 제품 방향을 직접 이끌었다.
+
+
+
행동
+
도구 호출·장기 메모리·자동 평가 루프를 하나의 런타임으로 묶고, 12개 언어 UI와 온프레미스 배포를 지원해 보안에 민감한 팀까지 끌어들였다.
+
+
+
결과
+
출시 8개월 만에 유료 팀 320곳, 월 처리 작업 140만 건을 넘겼고 재계약률 92%를 기록했다.
+
+
+
+ +
+
+ Nori + · 오픈소스 문서 생성기 + 단독 개발 +
+
+
+
역할
+
마크다운을 편집 품질의 PDF로 바꾸는 오픈소스 도구. 디자인 제약을 코드로 고정한다는 단순한 아이디어에서 시작했다.
+
+
+
행동
+
타이포그래피와 여백 규칙을 토큰으로 정의하고, 한국어 본문용 세리프 조판과 다국어 폰트 폴백을 직접 튜닝해 어떤 언어에서도 깨지지 않게 만들었다.
+
+
+
결과
+
GitHub 14K stars, 누적 다운로드 21만. 콘퍼런스 3곳에서 소개됐고 6개 언어 현지화 PR을 커뮤니티가 자발적으로 기여했다.
+
+
+
+ +
+
+ Mokpo + · 셀프호스팅 메모 앱 + 단독 개발 +
+
+
+
역할
+
로컬 우선 셀프호스팅 메모 앱. 데이터 주권과 즉각적인 입력 속도를 동시에 만족시키는 것을 목표로 삼았다.
+
+
+
행동
+
오프라인 우선 동기화와 충돌 해결(CRDT)을 직접 구현하고, 1년간 매주 릴리스를 거르지 않으며 사용자 요청을 빠르게 반영했다.
+
+
+
결과
+
월 활성 사용자 4만 명, 12개국에서 사용 중. 유료 전환율 6%로 1인 운영만으로 흑자를 유지한다.
+
+
+
+ +
+
+ Gose + · 정적 사이트 빌더 + 단독 개발 +
+
+
+
역할
+
Go로 만든 정적 사이트 빌더. 제작자가 설정 없이 바로 글쓰기에 집중하도록 돕는 것이 목표였다.
+
+
+
행동
+
증분 빌드와 핫 리로드를 직접 구현해 수천 페이지도 1초 내 갱신되게 했고, 별도 설정 없이 바로 쓸 수 있는 기본 테마를 함께 제공했다.
+
+
+
결과
+
GitHub 1.1K stars, 개인 블로그와 문서 사이트 다수가 채택. 빌드 속도 벤치마크에서 동급 도구 대비 3배 빨랐다.
+
+
+
+ +
+
+ Baram + · macOS 메뉴바 유틸리티 + 단독 개발 +
+
+
+
역할
+
메뉴바에서 시스템 상태와 자주 쓰는 작업을 한눈에 다루는 macOS 유틸리티. 작은 화면에서의 정보 밀도를 고민했다.
+
+
+
행동
+
SwiftUI로 가벼운 메뉴바 앱을 만들고, 배터리·네트워크·단축키를 한 패널에 묶어 클릭 한 번으로 끝나도록 설계했다.
+
+
+
결과
+
GitHub 480 stars, 사용자 요청으로 위젯을 12종까지 확장. 디자인 중심 개발자 사이에서 입소문으로 퍼졌다.
+
+
+
+
+ + + + +
+
오픈소스 & 독립 개발2018 - 현재 · 작게 시작해 끝까지 혼자 출시
+ +
+ 디자인 감각을 갖춘 풀스택 개발자. 작은 도구를 공개로 만들고, 사용자 피드백으로 다듬어 완성까지 끌고 간다. GitHub 누적 28K stars · 3.1K forks · 1.9K followers. +
+ +
+
+ Aru + Python · 에이전트 런타임 · 다국어 지원 + ★ 9.8K +
+
+ Nori + Rust · 문서 생성기 · PDF 출력 + ★ 14K +
+
+ Mokpo + TypeScript · 로컬 우선 메모 앱 + ★ 2.4K +
+
+ Gose + Go · 정적 사이트 빌더 + ★ 1.1K +
+
+ Baram + Swift · macOS 메뉴바 유틸리티 + ★ 480 +
+
+ Saebyeok + CSS · 따뜻한 다크 테마 모음 + ★ 320 +
+
+ +
+ 전파Nori는 출시 첫 주에 Hacker News 1면에 올랐고, 일본과 독일 개발자들이 자발적으로 현지화 PR을 보내며 6개 언어로 확장됐다. +
+
+ +
+
AI 판단과 행동
+
+
+
2019회사를 떠나 단독 개발
+
+ 안정적인 시니어 자리를 정리하고 1년간 직접 만든 도구의 사용자 반응만으로 다음을 결정했다. 그해 Nori가 손익분기를 넘겼다. +
+
+
+
2021한국어 조판에 집중
+
+ 영어 우선 도구들이 한국어를 후순위로 둘 때 한국어 본문 가독성을 1순위로 잡았다. 국내 제작자 커뮤니티의 표준 도구가 됐다. +
+
+
+
2023모델보다 워크플로
+
+ 모델 경쟁이 과열될 때 자체 모델 대신 워크플로 연결에 베팅했다. 18개월 만에 유료 팀 300곳을 확보했다. +
+
+
+
+ +
+
외부 영향력
+ +
+ 블로그 · @dohyun.dev + 2.1만 구독 + 에이전트 설계와 한국어 조판을 다루는 주간 뉴스레터. +
+ +
+
+
대표 기술 장문에이전트 · 조판
+
+ +
조회 38만, 국내 기술 블로그 연간 인기글
+
+
+ +
조회 12만, 디자인 커뮤니티 추천
+
+
+ +
+
초청 강연콘퍼런스
+
+
+
《작은 팀을 위한 에이전트 설계》
+
PyCon Korea
+
+
2024.10
+
+
+
+
《제약이 디자인을 만든다》
+
FEConf
+
+
2023.11
+
+
+
+
+ +
+
핵심 역량
+
+
에이전트 설계
+
LLM 도구 호출, 메모리, 평가 루프 설계. 프로덕션 에이전트 런타임을 직접 구축.
+
+
+
백엔드
+
Python · Go · Rust. 대규모 작업 큐와 분산 처리 경험.
+
+
+
프론트 ·
디자인 시스템
+
TypeScript · React. 토큰 기반 디자인 시스템으로 일관된 제품 UI.
+
+
+
한국어 조판
+
세리프 본문 가독성, 자간 · 행간 튜닝. 다국어 폰트 폴백 설계.
+
+
+
제품 · 출시
+
기획부터 배포 · 마케팅까지 단독 사이클. 3개 제품 흑자 전환.
+
+
+ +
+
학력
+
+
+ 한국과학기술원 (KAIST) +  · 전산학부 · 컴퓨터공학 · 대학원 대신 바로 창업 전선으로 +
+
2012 - 2016
+
+
+ + + diff --git a/assets/demos/demo-resume-ko.pdf b/assets/demos/demo-resume-ko.pdf new file mode 100644 index 0000000..12d6bfc Binary files /dev/null and b/assets/demos/demo-resume-ko.pdf differ diff --git a/assets/demos/demo-resume-ko.png b/assets/demos/demo-resume-ko.png new file mode 100644 index 0000000..386f996 Binary files /dev/null and b/assets/demos/demo-resume-ko.png differ diff --git a/assets/demos/demo-tesla.html b/assets/demos/demo-tesla.html new file mode 100644 index 0000000..75a0b5d --- /dev/null +++ b/assets/demos/demo-tesla.html @@ -0,0 +1,577 @@ + + + + + +特斯拉 · Q1 2026 投资分析 + + + + + + + + + +
+
+
Q1 2026 财报点评
+
特斯拉 TSLA
+
汽车与能源 · NASDAQ · 利润率在恢复,但资本开支的账单到了
+
+
+
$373.72
+
财报后 -3.56%
+
2026.04.23 收盘
+
+
+ + +
+
+
$1.2T
+
市值
+
+
+
~220x
+
前瞻 P/E
+
+
+
$22.4B
+
Q1 营收
+
+
+
21.1%
+
毛利率
+
+
+ + +

投资逻辑

+

特斯拉 Q1 2026 营收 $22.39B(同比 +16%),超市场预期的 $21.92B;Non-GAAP EPS $0.41,高于预期的 $0.37。毛利率从去年同期的 16.3% 恢复到 21.1%(+478 bps),能源业务毛利率创历史新高 39.5%。汽车业务释放了矛盾信号:交付 358,023 辆,低于市场预期约 7,600 辆,但利润率扩张的叙事依然成立。

+ +

核心矛盾:短期利润率恢复 vs 2026 全年 $25B+ 资本开支承诺。管理层预期全年自由现金流转负。财报后股价下跌 3.56%,市场定价的是资本开支冲击而非盈利超预期。

+ +
+ 持有。Q1 利润率恢复验证了降本能力,但 $25B capex + 负 FCF 指引意味着当前股价定价了 AI/Robotaxi 的期权价值,而这部分尚无规模化收入贡献。建议等 Q2 交付数据和 Robotaxi 单位经济学落地后再做加仓决策。 +
+ + +

近期价格走势

+
+ + + + + + + + + + + + + + + + 335 + 345 + 355 + 365 + 375 + 385 + 395 + 405 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 3/26 + 4/2 + 4/10 + 4/17 + 4/23 + + + + Earnings + + + + + Up + + Down + 20 trading days | $337 - $409 + +
图 1. TSLA 近 20 个交易日走势(3/26 - 4/23)。4/17 冲高 $409 后回落,财报后因 capex 指引下跌。
+
+ + +

财务概览

+ + + + + + + + + + + + + + + + + + + +
指标FY2024FY2025Q1 2026
营收$97.7B$97.0B$22.39B
GAAP 净利润$7.1B~$3.0B$477M
GAAP EPS$2.04~$0.86$0.13
Non-GAAP EPS----$0.41
毛利率17.9%~17%21.1%
经营利润率7.2%~4%4.2%
自由现金流----$1.4B
资本开支----$2.49B
+ +

毛利率恢复到 21.1% 是本季最重要的数字,COGS 优化和能源业务混合效应驱动了扩张。但 Electrek 指出,Q1 经营利润增长有一部分来自保修准备金调整和关税退税的一次性收益,意味着底层利润率改善可能更接近 19-20%。

+ + +

分部表现

+ +

汽车业务

+

交付 358,023 辆,低于市场预期 365,645 辆。产量 408,386 辆超过交付约 5 万辆,库存在积累。Model 3/Y 贡献 341,893 辆,Cybertruck 及其他车型 16,130 辆。监管积分收入 $380M(占汽车收入 1.9%),占比继续下降(去年同期 3.7%),说明核心业务的利润质量在提升。

+ +

能源业务

+

能源收入 $2.41B,同比下降 12%(去年同期 $2.73B 为高基数),但毛利率创历史新高 39.5%。储能部署 8.8 GWh。这个业务正在成为利润发动机:39.5% 毛利率 vs 汽车业务的 ~17%。Megapack 3 量产预计 2026 年下半年启动。

+ + +

近期催化剂

+

Robotaxi(Q2-Q3):4 月在 Dallas/Houston 上线无人驾驶付费服务,零事故。付费里程环比翻倍,Q2 首次披露单位经济学。

+

FSD 国际化(Q3):荷兰和中国已获批,欧洲更大范围推广预计 Q3。FSD 转纯订阅,Q1 新增订阅创纪录。

+

Cybercab + Q2 交付:Cybercab H2 量产,预期取代 Model Y 成为自动驾驶网络主力。Q2 交付受益于 Model Y 产线爬坡。

+ + +

竞争格局

+ + + + + + + + + + + + + + + + + +
公司市值前瞻 P/E营收增速毛利率
特斯拉$1.2T~220x+16%21.1%
比亚迪~$120B~22x+33%~22%
Rivian~$18BN/A+8%-10%
GM(EV)$48B~5x+12%~8%
+ +

特斯拉前瞻 P/E 是比亚迪的 10 倍,但营收增速更低、毛利率接近。溢价完全建立在 AI/Robotaxi 期权价值上。向自动驾驶和储能的战略转型是要跳出单位销量竞争,但如果 Robotaxi 单位经济学在 Q2-Q3 未能验证,估值有明显压缩空间。

+ + +

风险与总结

+
+
+
资本开支
+
$25B+ capex 指引,全年 FCF 预计转负。AI/Robotaxi 变现延迟将损害资本配置信任度。
+
+
+
交付疲软
+
年度交付连续两年下滑(峰值 181 万 -> 164 万)。Q1 再次 miss 共识预期。
+
+
+
HW3 淘汰
+
HW3 车辆无法支持无监督 FSD。2024 年前车主面临硬件升级壁垒。
+
+
+
竞争与关税
+
比亚迪 $20-30K 价位段成本优势 35%。美国关税政策增加供应链不确定性。
+
+
+ +
+
总结
+ 利润率恢复了,营收超预期了,但股价跌了,因为 $25B capex + 负 FCF 在 Robotaxi 尚无规模收入时是硬伤。多头核心:Dallas/Houston Robotaxi 数据会在 Q2 验证单位经济学并触发重估。空头核心:$25B/年烧在 3-5 年才能变现的项目上,而核心汽车业务在萎缩。已持有者:继续持有到 Q2,等 Robotaxi 数据再评估。新建仓:建议等 7 月交付和 Robotaxi 数据再决定。 +
+ + + + + + diff --git a/assets/demos/demo-tesla.pdf b/assets/demos/demo-tesla.pdf new file mode 100644 index 0000000..d0404e0 Binary files /dev/null and b/assets/demos/demo-tesla.pdf differ diff --git a/assets/demos/demo-tesla.png b/assets/demos/demo-tesla.png new file mode 100644 index 0000000..afe4510 Binary files /dev/null and b/assets/demos/demo-tesla.png differ diff --git a/assets/demos/demo-waza.html b/assets/demos/demo-waza.html new file mode 100644 index 0000000..fb574e5 --- /dev/null +++ b/assets/demos/demo-waza.html @@ -0,0 +1,449 @@ + + + + + +Waza · Engineering Skills for AI Agents + + + + + + + + +
+
+
Engineering Skills · 技
+

Waza

+
Engineering habits you already know, turned into skills any AI agent can run.
+
+
+ Tw93
+ v3.28
+ MIT +
+ +
+ +
+
+
8
+
engineering skills
+
+
+
6
+
agents supported
+
+
+
1
+
line to install
+
+
+
0
+
model lock-in
+
+
+ +

+ Models and tools come and go; the way good engineers actually work, planning, reviewing, debugging, reading the source, does not. Waza turns those habits into eight skills that run on whatever agent you have, so your workflow never depends on a single model staying available. +

+ +
+
+

Plan & build

+
+
/think
pressure-test a task into a plan
+
/design
distinctive UI, never defaults
+
/learn
map a new domain fast
+
/read
distill any URL or PDF
+
+
+ +
+

Review & ship

+
+
/check
review the diff with evidence
+
/hunt
find a bug's true root cause
+
/write
make prose sound human
+
/health
audit your agent setup
+
+
+
+ +
+

Habits, not superpowers

+

The fashionable move is to bolt on a pack of "superpowers", dozens of skills promising an agent can suddenly do anything. But unconstrained capability is the problem, not the cure: it drifts into confident, generic, subtly wrong work. Waza adds no new powers. It encodes the habits a good engineer already has, so the model spends its capability on precision instead of breadth. Eight skills, not eighty, the few that actually decide quality, each carrying reference docs and real-failure gotchas rather than a one-line promise that rots the moment a task gets specific.

+
+ + + + + + + + + + + + + + + INPUT + Engineering habits + plan · review · debug · read + + + WAZA · 8 SKILLS + Goal + guardrails + then let the model run + + + OUTPUT + Precise, reviewed work + on any agent, not generic + +
Capability is the input, not the product. Constraints are what make it land.
+
+
+ +
+ Runs on Claude Code, Codex, Antigravity, OpenCode, Claude Desktop, and Pi. One workflow on every agent, installed once with npx skills add tw93/Waza. +
+ + + + + diff --git a/assets/demos/demo-waza.pdf b/assets/demos/demo-waza.pdf new file mode 100644 index 0000000..7892d35 Binary files /dev/null and b/assets/demos/demo-waza.pdf differ diff --git a/assets/demos/demo-waza.png b/assets/demos/demo-waza.png new file mode 100644 index 0000000..8d4bcba Binary files /dev/null and b/assets/demos/demo-waza.png differ diff --git a/assets/demos/images/kaku-action.jpg b/assets/demos/images/kaku-action.jpg new file mode 100644 index 0000000..8709fe3 Binary files /dev/null and b/assets/demos/images/kaku-action.jpg differ diff --git a/assets/demos/images/kaku-hero.jpg b/assets/demos/images/kaku-hero.jpg new file mode 100644 index 0000000..f29545f Binary files /dev/null and b/assets/demos/images/kaku-hero.jpg differ diff --git a/assets/diagrams/architecture-board.html b/assets/diagrams/architecture-board.html new file mode 100644 index 0000000..b2b30ed --- /dev/null +++ b/assets/diagrams/architecture-board.html @@ -0,0 +1,271 @@ + + + + + +Architecture Board · kami diagram + + + +
+

Architecture Board · kami diagram

+

{{System name}} architecture board

+ + + + + + + + + + + + + + Kaku: a WezTerm core, rewired for AI coding + WORKSPACE VIEW · 2026-07 + Defaults ship on day one, Lua customization stays, and the AI engine is the fork's real addition. + + + + 01 · PLATFORM SURFACE + WHO KAKU SERVES + + + + + + AI coding sessions + in-terminal assistant + ai_chat_engine · ai_tools + + Daily terminal work + panes · tabs · workspaces + local, ssh, mux domains + + macOS desktop + theme follows the system + notarized DMG app + + Shell suite + curated zsh plugins + prompt · diff · navigation + + + 02 · GUI SHELL · KAKU-GUI + GPU FRONT END + + + + + Window frontend + frontend.rs · macos + events · commands + + GPU glyph pipeline + glyphcache · shaders + custom glyphs + + Input map + inputmap.rs · commands.rs + keys to actions + + + AI chat engine + ai_client · conversations + auth · state · remote + + + 03 · TERMINAL CORE + INHERITED FROM WEZTERM · TRIMMED + + + + + + Multiplexer + pane · tab · domain + mux crate + + PTY layer + child shell processes + pty crate + + Escape parsing + vtparse · escape-parser + bytes to actions + + Cell model + surface · cell · termwiz + grid state · attributes + + + 04 · MAIN AXIS · INPUT TO PIXELS + THE PATH EVERY KEYSTROKE TAKES + + + + + + + + + + + + + + + Keystroke + user intent + + + Input map + kaku-gui + + + PTY · shell + pty crate + + + Escape parse + vtparse + + + Cell grid + surface + + + GPU frame + glyphcache + + + AUX · AI PATH: PROMPT → AI_CHAT_ENGINE → AI_CLIENT → PROVIDER API + + + 05 · CONTROL PLANE & GOVERNANCE + HOW THE FORK STAYS MAINTAINABLE + + CONFIG + THEME + DISTRIBUTION + UPSTREAM + + + + + + Lua end to end + config · lua-api-crates + config_tui · doctor + + Follows macOS + kaku_theme · color-funcs + dark and light tuned + + Notarized DMG + GitHub releases + brew tw93/tap/kakuku + + WezTerm fork + core crates tracked + 40% smaller binary + + + + LEGEND + + + Focal · the fork's addition + + + + Band · peers share one shell + + + + Main axis + + Crate-level detail lives in the workspace docs; this board carries the spine. + + +

One focal per board: the AI chat engine, the piece this fork actually adds. Bands group peers behind thin dividers, governance sits in a table shell, and the only brand-colored line on the page is the main axis.

+
+ + diff --git a/assets/diagrams/architecture.html b/assets/diagrams/architecture.html new file mode 100644 index 0000000..606f17a --- /dev/null +++ b/assets/diagrams/architecture.html @@ -0,0 +1,180 @@ + + + + + +Architecture · kami diagram + + + +
+

Architecture · kami diagram

+

{{System name}} in production

+ + + + + + + + + + + + + + + + + + + + + + + + + + HTTPS + + + SSR + + + READ + + + QUERY + + + + + USER + Reader + Browser + + + + + EDGE + CDN + cache · SSL + + + + + ORIGIN + App Server + render · route + + + + + BUNDLE + Content + *.mdx · assets + + + + + STORE + Database + postgres · vectors + + + + LEGEND + + + Focal · origin + + + Backend · bundle + + + Store + + + Cloud + + + External + + + Standard flow + + + Primary path + + +

Focal rule: one ink-blue node per diagram, marking the component the reader should look at first. Every other box stays in warm neutrals so the accent actually means something.

+
+ + diff --git a/assets/diagrams/bar-chart.html b/assets/diagrams/bar-chart.html new file mode 100644 index 0000000..80ddbaf --- /dev/null +++ b/assets/diagrams/bar-chart.html @@ -0,0 +1,187 @@ + + + + + +Bar Chart · kami diagram + + + +
+

Bar Chart · kami diagram

+

{{Chart Title}}

+ + + + + + + + + + + + + + + + + + + 0 + 20 + 40 + 60 + 80 + 100 + 120 + 140 + + + UNIT + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 44 + 32 + + 60 + 48 + + 72 + 64 + + 88 + 76 + + + 2021 + 2022 + 2023 + 2024 + + + + + + + + {{Series A label}} + + + {{Series B label}} + + + +

{{Caption text. The focal series in ink-blue carries the primary argument. State the takeaway here, not a description of what is plotted.}}

+
+ + diff --git a/assets/diagrams/candlestick.html b/assets/diagrams/candlestick.html new file mode 100644 index 0000000..1ba1428 --- /dev/null +++ b/assets/diagrams/candlestick.html @@ -0,0 +1,223 @@ + + + + + +Candlestick Chart · kami diagram + + + +
+

Candlestick · kami diagram

+

{{Chart Title}}

+ + + + + + + + + + + + + + + + + + 100 + 110 + 120 + 130 + 140 + 150 + 160 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{D1}} + {{D6}} + {{D11}} + {{D16}} + {{D20}} + + + + + + Up (close > open) + + + Down (close < open) + + + +

{{Caption: e.g. "20-day price action showing accumulation phase."}}

+
+ + diff --git a/assets/diagrams/class.html b/assets/diagrams/class.html new file mode 100644 index 0000000..13df29c --- /dev/null +++ b/assets/diagrams/class.html @@ -0,0 +1,158 @@ + + + + + +Class · kami diagram + + + +
+

Class · kami diagram

+

{{What structure does this model}}

+ + + + + + + + + + + + + + + + + + + + + + + Order + + + id: String + + createdAt: Date + + + total(): Money + + + + + Customer + + + name: String + + + + + + LineItem + + + qty: int + + + places + 1 + * + contains + 1 + * + + +

Boxes are types; compartments list fields and methods. Arrows carry association and composition.

+
+ + diff --git a/assets/diagrams/donut-chart.html b/assets/diagrams/donut-chart.html new file mode 100644 index 0000000..14fc7a4 --- /dev/null +++ b/assets/diagrams/donut-chart.html @@ -0,0 +1,194 @@ + + + + + +Donut Chart · kami diagram + + + +
+

Donut Chart · kami diagram

+

{{Chart Title}}

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 32% + {{CENTER LABEL}} + + + + + + + + + 32% + {{Category A}} + + + + 24% + {{Category B}} + + + + 18% + {{Category C}} + + + + 12% + {{Category D}} + + + + 8% + {{Category E}} + + + + 6% + {{Category F}} + + + + TOTAL · 100% + {{Source / period}} + + + +

{{Caption text. The ink-blue segment is the focal category. Lead with the insight, not the breakdown.}}

+
+ + diff --git a/assets/diagrams/er.html b/assets/diagrams/er.html new file mode 100644 index 0000000..863ea93 --- /dev/null +++ b/assets/diagrams/er.html @@ -0,0 +1,166 @@ + + + + + +ER · kami diagram + + + +
+

Entity-Relationship · kami diagram

+

{{What schema does this map}}

+ + + + + + + + + + + + CUSTOMER + + (no attributes) + + + + + ORDER + + (no attributes) + + + + + LINE_ITEM + + (no attributes) + + + + + PRODUCT + + (no attributes) + + + + + + + + + + + + + + + + + + + + places + + contains + + appears in + + +

Crow's-foot notation: each connector's ends mark cardinality between entities.

+
+ + diff --git a/assets/diagrams/flowchart.html b/assets/diagrams/flowchart.html new file mode 100644 index 0000000..49c2d26 --- /dev/null +++ b/assets/diagrams/flowchart.html @@ -0,0 +1,170 @@ + + + + + +Flowchart · kami diagram + + + +
+

Flowchart · kami diagram

+

{{Question the flow answers}}

+ + + + + + + + + + + + + + + + + + + + + + + + YES + + + + + NO + + + + + + + + + + + + Start + + + + STEP 01 + {{Describe input}} + one short line + + + + {{Decision}} + BRANCH + + + + OUTCOME A + {{Path taken}} + what happens next + + + + OUTCOME B + {{Alt path}} + skipped / deferred + + + + End + + + + LEGEND + + + Focal decision + + + Step · outcome + + + Start · end + + + Deferred branch + + +

The decision diamond carries the accent. Its two branches diverge into equal weights so the reader sees the question, not an implied answer.

+
+ + diff --git a/assets/diagrams/layer-stack.html b/assets/diagrams/layer-stack.html new file mode 100644 index 0000000..de2bd91 --- /dev/null +++ b/assets/diagrams/layer-stack.html @@ -0,0 +1,184 @@ + + + + + +Layer Stack · kami diagram + + + +
+

Layer Stack · kami diagram

+

{{System name}} architecture layers

+ + + + + + + + + + + + + + + + 01 + Presentation + + UI · views · routing + + + + 02 · FOCAL + Business Logic + + rules · validation · orchestration + + + + 03 + Data Access + + repositories · queries · cache + + + + 04 + Storage + + database · object store · search index + + + + 05 + Infrastructure + + cloud · network · runtime + + + + + + REQUEST + + + + + + RESPONSE + + + + LEGEND + + + Focal layer + + + Top layer + + + Foundation + + + + Request direction + + + + Response direction + + +

Focal rule: one layer in ink-blue marks where the most domain-specific logic lives. Layers above carry user-facing concerns; layers below carry infrastructure. Side arrows orient the reader to flow direction without cluttering the bands.

+
+ + diff --git a/assets/diagrams/line-chart.html b/assets/diagrams/line-chart.html new file mode 100644 index 0000000..e33bf6f --- /dev/null +++ b/assets/diagrams/line-chart.html @@ -0,0 +1,207 @@ + + + + + +Line Chart · kami diagram + + + +
+

Line Chart · kami diagram

+

{{Chart Title}}

+ + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 20 + 40 + 60 + 80 + 100 + 120 + 140 + + + UNIT + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 96 + 76 + + + Jan + Feb + Mar + Apr + May + Jun + Jul + Aug + + + + + + + + + + {{Line 1 label}} + + + + + {{Line 2 label}} + + + +

{{Caption text. The ink-blue line carries the primary trend argument. State what the trend means, not what was plotted.}}

+
+ + diff --git a/assets/diagrams/quadrant.html b/assets/diagrams/quadrant.html new file mode 100644 index 0000000..80e6be0 --- /dev/null +++ b/assets/diagrams/quadrant.html @@ -0,0 +1,173 @@ + + + + + +Quadrant · kami diagram + + + +
+

Quadrant · kami diagram

+

{{X axis}} × {{Y axis}}

+ + + + + + + + + + + + + + + + + + + + + + + {{X axis · low to high}} + {{Y axis · low to high}} + + + HIGH-Y · LOW-X + incubate + + HIGH-Y · HIGH-X + do first + + LOW-Y · LOW-X + drop + + LOW-Y · HIGH-X + delegate + + + + + {{Item A}} + + + + {{Item B · focal}} + + + + {{Item C}} + + + + {{Item D}} + + + + {{Item E}} + + + + {{Item F}} + + + + {{Item G}} + + + + {{Item H}} + + + + LEGEND + + + Focal · do first + + + Near-focal + + + Standard + + + Secondary + + + Drop + + +

The ink-blue point is where the reader's eye lands. The tinted upper-right quadrant is the preferred region. Everything else recedes so the judgment reads instantly.

+
+ + diff --git a/assets/diagrams/sequence.html b/assets/diagrams/sequence.html new file mode 100644 index 0000000..aea8ef0 --- /dev/null +++ b/assets/diagrams/sequence.html @@ -0,0 +1,148 @@ + + + + + +Sequence · kami diagram + + + +
+

Sequence · kami diagram

+

{{What interaction does this trace}}

+ + + + + + + + + + + + + + + + + 提交请求 + + + + 查询记录 + + + + 返回结果 + + + + 响应数据 + + + + 用户 + + + + API + + + + 数据库 + + + +

Lifelines drop from each participant; messages read top to bottom. Dashed returns distinguish responses from calls.

+
+ + diff --git a/assets/diagrams/src/class.mmd b/assets/diagrams/src/class.mmd new file mode 100644 index 0000000..fff388c --- /dev/null +++ b/assets/diagrams/src/class.mmd @@ -0,0 +1,14 @@ +classDiagram + class Order { + +String id + +Date createdAt + +total() Money + } + class Customer { + +String name + } + class LineItem { + +int qty + } + Customer "1" --> "*" Order : places + Order "1" *-- "*" LineItem : contains diff --git a/assets/diagrams/src/er.mmd b/assets/diagrams/src/er.mmd new file mode 100644 index 0000000..45774de --- /dev/null +++ b/assets/diagrams/src/er.mmd @@ -0,0 +1,4 @@ +erDiagram + CUSTOMER ||--o{ ORDER : places + ORDER ||--|{ LINE_ITEM : contains + PRODUCT ||--o{ LINE_ITEM : "appears in" diff --git a/assets/diagrams/src/sequence.mmd b/assets/diagrams/src/sequence.mmd new file mode 100644 index 0000000..96b3512 --- /dev/null +++ b/assets/diagrams/src/sequence.mmd @@ -0,0 +1,8 @@ +sequenceDiagram + participant U as 用户 + participant A as API + participant D as 数据库 + U->>A: 提交请求 + A->>D: 查询记录 + D-->>A: 返回结果 + A-->>U: 响应数据 diff --git a/assets/diagrams/state-machine.html b/assets/diagrams/state-machine.html new file mode 100644 index 0000000..04a565d --- /dev/null +++ b/assets/diagrams/state-machine.html @@ -0,0 +1,217 @@ + + + + + +State Machine · kami diagram + + + +
+

State Machine · kami diagram

+

{{Component name}} lifecycle

+ + + + + + + + + + + + + + + RESET + + + + + + + + + + + + TRIGGER + + + + + + SUBMIT + + + + + + COMPLETE + + + + + + + + + + + + + + + STATE + Idle + waiting + + + + + FOCAL + Active + editing + + + + + STATE + Processing + running + + + + + TERMINAL + Done + resolved + + + + + + + + LEGEND + + + Start + + + Focal state + + + Terminal + + + + Forward transition + + + + Reset / timeout + + +

Focal rule: one ink-blue state per diagram, marking where the user spends most time. Start and terminal markers follow UML convention; the dashed arc shows the exceptional path.

+
+ + diff --git a/assets/diagrams/swimlane.html b/assets/diagrams/swimlane.html new file mode 100644 index 0000000..b52ddc5 --- /dev/null +++ b/assets/diagrams/swimlane.html @@ -0,0 +1,202 @@ + + + + + +Swimlane · kami diagram + + + +
+

Swimlane · kami diagram

+

{{System name}} request flow

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + CLIENT + SERVER + DATABASE + + + + + + + + + + + + + + + + + + + + + + + + + USER + Request + + + + + FOCAL + Validate + + + + + HANDLER + Route + + + + + STORE + Query + + + + + USER + Respond + + + + LEGEND + + Focal lane / node + + Store + + Client / external + + +

Focal rule: the Server lane carries the business logic, so it gets the ink-blue tint. Client and Database lanes stay in warm neutral to keep the eye on what actually decides the outcome.

+
+ + diff --git a/assets/diagrams/timeline.html b/assets/diagrams/timeline.html new file mode 100644 index 0000000..0eff2d4 --- /dev/null +++ b/assets/diagrams/timeline.html @@ -0,0 +1,170 @@ + + + + + +Timeline · kami diagram + + + +
+

Timeline · kami diagram

+

{{Project name}} milestones

+ + + + + + + + + + + + + + + + + + + + + + + 2019 + Concept + + + + + + 2020 + Prototype + + + + + + 2021 · FOCAL + Launch + + + + + + 2022 + Growth + + + + + + 2023 + Scale + + + + LEGEND + + + Focal milestone + + + Standard milestone + + + Connector + + +

Focal rule: ink-blue marks the single most important milestone, the moment the reader should remember. Every other event stays in warm stone so the contrast does the work.

+
+ + diff --git a/assets/diagrams/tree.html b/assets/diagrams/tree.html new file mode 100644 index 0000000..424979c --- /dev/null +++ b/assets/diagrams/tree.html @@ -0,0 +1,227 @@ + + + + + +Tree · kami diagram + + + +
+

Tree · kami diagram

+

{{System name}} hierarchy

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ROOT + {{System}} + + + + + + + MODULE + {{Module A}} + + + + + FOCAL + {{Module B}} + + + + + + + {{Leaf 1}} + service + + + + + {{Leaf 2}} + service + + + + + + + {{Leaf 3}} + component + + + + + {{Leaf 4}} + component + + + + + {{Leaf 5}} + component + + + + LEGEND + + + Focal sub-tree + + + Root + + + Secondary branch + + +

Focal rule: one sub-tree in ink-blue marks the branch where complexity lives, or the path the reader needs to understand first. Orthogonal elbows make parent-child relationships unambiguous.

+
+ + diff --git a/assets/diagrams/venn.html b/assets/diagrams/venn.html new file mode 100644 index 0000000..cbe3e6d --- /dev/null +++ b/assets/diagrams/venn.html @@ -0,0 +1,154 @@ + + + + + +Venn · kami diagram + + + +
+

Venn · kami diagram

+

{{Topic A}} and {{Topic B}} intersection

+ + + + + + + + + + + + + + + + + + + + + + {{Set A only}} + unique to A + + + {{Shared}} + overlap + + + {{Set B only}} + unique to B + + + SET A + SET B + + + item · item · item + item · item + item · item · item + + + + LEGEND + + + Focal set (A) + + + Secondary set (B) + + Overlap + Intersection label (serif callout) + + +

Focal rule: the circle whose contents matter most to the argument gets the ink-blue stroke. The intersection label uses a serif italic callout to draw the eye to the overlap, which is usually the point of the diagram.

+
+ + diff --git a/assets/diagrams/waterfall.html b/assets/diagrams/waterfall.html new file mode 100644 index 0000000..5ca82e3 --- /dev/null +++ b/assets/diagrams/waterfall.html @@ -0,0 +1,192 @@ + + + + + +Waterfall Chart · kami diagram + + + +
+

Waterfall · kami diagram

+

{{Chart Title}}

+ + + + + + + + + + + + + + + + + 0 + 40 + 80 + 120 + 160 + 200 + + + + + 120 + + + + + + + + +30 + + + + + + + + +20 + + + + + + + + -40 + + + + + + + + +10 + + + + + + + + -20 + + + + 120 + + + + {{Start}} + {{Cat A}} + {{Cat B}} + {{Cat C}} + {{Cat D}} + {{Cat E}} + {{End}} + + + + + + Increase + + + Decrease + + + Total + + + +

{{Caption: e.g. "Revenue bridge from FY2024 to FY2025, showing growth drivers and headwinds."}}

+
+ + diff --git a/assets/fonts/JetBrainsMono.woff2 b/assets/fonts/JetBrainsMono.woff2 new file mode 100644 index 0000000..256ca93 Binary files /dev/null and b/assets/fonts/JetBrainsMono.woff2 differ diff --git a/assets/fonts/LICENSE-SourceHanSerifK.txt b/assets/fonts/LICENSE-SourceHanSerifK.txt new file mode 100644 index 0000000..ddf9d29 --- /dev/null +++ b/assets/fonts/LICENSE-SourceHanSerifK.txt @@ -0,0 +1,107 @@ +Source Han Serif K (also distributed as Noto Serif KR) +Copyright 2017-2022 Adobe (http://www.adobe.com/), with Reserved Font +Name 'Source'. Source is a trademark of Adobe in the United States +and/or other countries. + +This Font Software is licensed under the SIL Open Font License, +Version 1.1. + +Source: + https://github.com/adobe-fonts/source-han-serif (Adobe official) + https://fonts.google.com/noto/specimen/Noto+Serif+KR (Google Fonts mirror) + +The Adobe and Google distributions are the same font under two names. +Kami's templates reference three names in the `--serif` chain +("Source Han Serif K", "Source Han Serif KR", "Noto Serif KR"): +"Source Han Serif K" is the `@font-face` declared alias (loads via the +bundled file or CDN), "Source Han Serif KR" is the actual family name +inside the OTFs (so fontconfig resolves the ensure-fonts.sh download by +name on an offline Linux box), and "Noto Serif KR" covers the Google +Fonts install. The font therefore resolves regardless of source. + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font +creation efforts of academic and linguistic communities, and to +provide a free and open framework in which fonts may be shared and +improved in partnership with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply to +any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software +components as distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, +deleting, or substituting -- in part or in whole -- any of the +components of the Original Version, by changing formats or by porting +the Font Software to a new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, +modify, redistribute, and sell modified and unmodified copies of the +Font Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, in +Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the +corresponding Copyright Holder. This restriction only applies to the +primary font name as presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created using +the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/TsangerJinKai02-W04.ttf b/assets/fonts/TsangerJinKai02-W04.ttf new file mode 100644 index 0000000..ae3eb1c Binary files /dev/null and b/assets/fonts/TsangerJinKai02-W04.ttf differ diff --git a/assets/fonts/TsangerJinKai02-W05.ttf b/assets/fonts/TsangerJinKai02-W05.ttf new file mode 100644 index 0000000..652b1df Binary files /dev/null and b/assets/fonts/TsangerJinKai02-W05.ttf differ diff --git a/assets/illustrations/travel-3d-representations.png b/assets/illustrations/travel-3d-representations.png new file mode 100644 index 0000000..68f15d4 Binary files /dev/null and b/assets/illustrations/travel-3d-representations.png differ diff --git a/assets/illustrations/travel-spatialvla.png b/assets/illustrations/travel-spatialvla.png new file mode 100644 index 0000000..7d2c057 Binary files /dev/null and b/assets/illustrations/travel-spatialvla.png differ diff --git a/assets/illustrations/travel-tesla-optimus.png b/assets/illustrations/travel-tesla-optimus.png new file mode 100644 index 0000000..71ebb52 Binary files /dev/null and b/assets/illustrations/travel-tesla-optimus.png differ diff --git a/assets/images/logo.svg b/assets/images/logo.svg new file mode 100644 index 0000000..cf07a2e --- /dev/null +++ b/assets/images/logo.svg @@ -0,0 +1 @@ + diff --git a/assets/showcase/kami-landing.png b/assets/showcase/kami-landing.png new file mode 100644 index 0000000..38f0f6c Binary files /dev/null and b/assets/showcase/kami-landing.png differ diff --git a/assets/showcase/luo-landing.png b/assets/showcase/luo-landing.png new file mode 100644 index 0000000..2390d5e Binary files /dev/null and b/assets/showcase/luo-landing.png differ diff --git a/assets/showcase/mole-landing.png b/assets/showcase/mole-landing.png new file mode 100644 index 0000000..cffbd91 Binary files /dev/null and b/assets/showcase/mole-landing.png differ diff --git a/assets/templates/changelog-en.html b/assets/templates/changelog-en.html new file mode 100644 index 0000000..aa479da --- /dev/null +++ b/assets/templates/changelog-en.html @@ -0,0 +1,249 @@ + + + + + +{{PROJECT_NAME}} · {{VERSION}} Release Notes + + + + + + + + + +
+
+
Release Notes
+

{{PROJECT_NAME}} {{VERSION}}

+
{{One-line release highlight.}}
+
+
{{RELEASE_DATE}}
+
+ + +

Breaking Changes

+
    +
  1. Breaking {{What changed}}: {{what readers must do about it.}}
  2. +
+ +

Features

+
    +
  1. {{Feature}}: {{one-line description.}}
  2. +
  3. {{Feature}}: {{one-line description.}}
  4. +
  5. {{Feature}}: {{one-line description.}}
  6. +
+ +

Fixes

+
    +
  1. {{Area}}: {{what was fixed.}}
  2. +
  3. {{Area}}: {{what was fixed.}}
  4. +
  5. {{Area}}: {{what was fixed.}}
  6. +
+ + +
+
Acknowledgements
+ {{Thank contributors, e.g. "Thanks to @user1 and @user2 for their help."}} +
+ + + + + + diff --git a/assets/templates/changelog-ko.html b/assets/templates/changelog-ko.html new file mode 100644 index 0000000..a454b30 --- /dev/null +++ b/assets/templates/changelog-ko.html @@ -0,0 +1,259 @@ + + + + + +{{프로젝트명}} · {{버전}} 업데이트 로그 + + + + + + + + + + +
+
+
업데이트 로그
+

{{프로젝트명}} {{버전}}

+
{{이번 버전의 핵심 한 문장}}
+
+
{{출시 날짜}}
+
+ + +

호환성 변경 사항

+
    +
  1. Breaking {{변경된 점}}: {{사용자가 해야 할 조치.}}
  2. +
+ +

신기능

+
    +
  1. {{기능}}: {{한 문장 설명.}}
  2. +
  3. {{기능}}: {{한 문장 설명.}}
  4. +
  5. {{기능}}: {{한 문장 설명.}}
  6. +
+ +

수정 사항

+
    +
  1. {{영역}}: {{무엇을 고쳤는지.}}
  2. +
  3. {{영역}}: {{무엇을 고쳤는지.}}
  4. +
  5. {{영역}}: {{무엇을 고쳤는지.}}
  6. +
+ + +
+
감사의 말
+ {{기여자에게 감사. 예: "기여해 주신 @user1, @user2 님께 감사드립니다."}} +
+ + + + + + diff --git a/assets/templates/changelog.html b/assets/templates/changelog.html new file mode 100644 index 0000000..821ce60 --- /dev/null +++ b/assets/templates/changelog.html @@ -0,0 +1,254 @@ + + + + + +{{项目名}} · {{版本号}} 更新日志 + + + + + + + + + +
+
+
更新日志
+

{{项目名}} {{版本号}}

+
{{一句话版本亮点}}
+
+
{{发布日期}}
+
+ + +

破坏性变更

+
    +
  1. Breaking {{变更点}}:{{读者需要做的处理}}
  2. +
+ +

新功能

+
    +
  1. {{功能}}:{{一句话描述}}
  2. +
  3. {{功能}}:{{一句话描述}}
  4. +
  5. {{功能}}:{{一句话描述}}
  6. +
+ +

修复

+
    +
  1. {{范围}}:{{修复了什么}}
  2. +
  3. {{范围}}:{{修复了什么}}
  4. +
  5. {{范围}}:{{修复了什么}}
  6. +
+ + +
+
致谢
+ {{感谢贡献者,如 "感谢 @user1、@user2 的贡献。"}} +
+ + + + + + diff --git a/assets/templates/equity-report-en.html b/assets/templates/equity-report-en.html new file mode 100644 index 0000000..e09657a --- /dev/null +++ b/assets/templates/equity-report-en.html @@ -0,0 +1,498 @@ + + + + + +{{COMPANY_NAME}} · Equity Report + + + + + + + + + +
+
+
{{EYEBROW - e.g. Equity Research / Valuation / Investment Memo}}
+
{{COMPANY_NAME}} {{TICKER}}
+
{{SECTOR}} · {{EXCHANGE}} · {{One-line thesis.}}
+
+
+
{{CURRENT_PRICE}}
+
{{CHANGE - e.g. "+12.3%"}}
+
{{DATE}}
+
+
+ + +
+
+
{{NUMBER}}
+
Market Cap
+
+
+
{{NUMBER}}
+
P/E Ratio
+
+
+
{{NUMBER}}
+
Revenue
+
+
+
{{NUMBER}}
+
Margin
+
+
+ + +

Investment Thesis

+

{{Two or three sentences laying out the core thesis. Use key figures to support the argument. This paragraph is the soul of the report.}}

+ +
+ {{One-line verdict: buy / hold / watch, and the single strongest reason.}} +
+ + +

Price Action

+
+
+ [Candlestick / price chart placeholder - extract SVG from diagrams/] +
+
{{Chart title, e.g. "20-day price action showing accumulation phase."}}
+
+ + +

Financial Overview

+ + + + + + + + + + + + + + + + +
MetricFY2023FY2024FY2025E
Revenue{{VAL}}{{VAL}}{{VAL}}
Net Income{{VAL}}{{VAL}}{{VAL}}
EPS{{VAL}}{{VAL}}{{VAL}}
Gross Margin{{VAL}}{{VAL}}{{VAL}}
ROE{{VAL}}{{VAL}}{{VAL}}
+ + +

Revenue Breakdown

+
+
+ [Revenue breakdown chart placeholder - donut or waterfall] +
+
{{Chart title.}}
+
+ + +
+

Competitive Landscape

+

{{Industry landscape overview, one or two paragraphs.}}

+ + + + + + + + + + + + + + + + + +
CompanyMkt CapP/ERev GrowthMargin
{{TARGET}}{{VAL}}{{VAL}}{{VAL}}{{VAL}}
{{COMP_A}}{{VAL}}{{VAL}}{{VAL}}{{VAL}}
{{COMP_B}}{{VAL}}{{VAL}}{{VAL}}{{VAL}}
{{COMP_C}}{{VAL}}{{VAL}}{{VAL}}{{VAL}}
+ + +

Risk Factors

+
+
+
{{Risk Type 1}}
+
{{Risk description.}}
+
+
+
{{Risk Type 2}}
+
{{Risk description.}}
+
+
+
{{Risk Type 3}}
+
{{Risk description.}}
+
+
+
{{Risk Type 4}}
+
{{Risk description.}}
+
+
+ + +
+
Analyst Summary
+ {{Three to five sentences summarizing the position. Include target price (if applicable), rating, key catalysts, and core assumptions.}} +
+ + + + + + diff --git a/assets/templates/equity-report-ko.html b/assets/templates/equity-report-ko.html new file mode 100644 index 0000000..54cf5d6 --- /dev/null +++ b/assets/templates/equity-report-ko.html @@ -0,0 +1,565 @@ + + + + + +{{종목명}} · 종목 리서치 + + + + + + + + + +
+
+
{{EYEBROW · 예: "종목 리서치" / "밸류에이션 분석" / "투자 메모"}}
+
{{회사명}} {{종목 코드}}
+
{{섹터}} · {{거래소}} · {{핵심 투자 판단 한 문장}}
+
+
+
{{현재가}}
+
{{등락률 예: "+12.3%"}}
+
{{날짜}}
+
+
+ + +
+
+
{{수치}}
+
시가총액
+
+
+
{{수치}}
+
P/E
+
+
+
{{수치}}
+
매출
+
+
+
{{수치}}
+
이익률
+
+
+ + +

투자 논거

+

{{2-3문장의 핵심 투자 포인트. 핵심 데이터로 판단을 뒷받침한다. 이 단락이 리서치 전체의 핵심이다.}}

+ +
+ {{한 문장 요약: 매수/보유/관망의 핵심 이유.}} +
+ + +

가격 추이

+
+ +
+ [캔들스틱 / 가격 추이 차트 자리 · diagrams/에서 SVG를 추출해 삽입] +
+
{{차트 제목, 예: "최근 20 거래일 가격 추이"}}
+
+ + +

재무 개요

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
지표FY2023FY2024FY2025E
매출{{데이터}}{{데이터}}{{데이터}}
순이익{{데이터}}{{데이터}}{{데이터}}
EPS{{데이터}}{{데이터}}{{데이터}}
매출총이익률{{데이터}}{{데이터}}{{데이터}}
ROE{{데이터}}{{데이터}}{{데이터}}
+ + +

매출 구조

+
+
+ [매출 분해 차트 자리 · 도넛 차트 또는 폭포 차트] +
+
{{차트 제목}}
+
+ + +
+

경쟁 구도

+

{{업계 구도 개요, 1-2 단락.}}

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
회사시가총액P/E매출 성장률이익률
{{대상 회사}}{{데이터}}{{데이터}}{{데이터}}{{데이터}}
{{경쟁사 A}}{{데이터}}{{데이터}}{{데이터}}{{데이터}}
{{경쟁사 B}}{{데이터}}{{데이터}}{{데이터}}{{데이터}}
{{경쟁사 C}}{{데이터}}{{데이터}}{{데이터}}{{데이터}}
+ + +

리스크 고지

+
+
+
{{리스크 유형 1}}
+
{{리스크 설명}}
+
+
+
{{리스크 유형 2}}
+
{{리스크 설명}}
+
+
+
{{리스크 유형 3}}
+
{{리스크 설명}}
+
+
+
{{리스크 유형 4}}
+
{{리스크 설명}}
+
+
+ + +
+
애널리스트 총평
+ {{3-5문장 총평. 목표 주가(해당 시), 투자 의견, 핵심 촉매제, 주요 가정을 포함한다.}} +
+ + + + + + diff --git a/assets/templates/equity-report.html b/assets/templates/equity-report.html new file mode 100644 index 0000000..7fb1705 --- /dev/null +++ b/assets/templates/equity-report.html @@ -0,0 +1,559 @@ + + + + + +{{股票名称}} · 个股研报 + + + + + + + + + +
+
+
{{EYEBROW · 如 "个股研报" / "估值分析" / "投资备忘"}}
+
{{公司名称}} {{股票代码}}
+
{{行业}} · {{交易所}} · {{一句核心判断}}
+
+
+
{{当前价格}}
+
{{涨跌幅 如 "+12.3%"}}
+
{{日期}}
+
+
+ + +
+
+
{{数字}}
+
市值
+
+
+
{{数字}}
+
P/E
+
+
+
{{数字}}
+
营收
+
+
+
{{数字}}
+
利润率
+
+
+ + +

投资逻辑

+

{{2-3 句核心投资论点。用 关键数据 支撑判断。这段是整份研报的灵魂。}}

+ +
+ {{一句话总结:买入/持有/观望的核心理由。}} +
+ + +

价格走势

+
+ +
+ [K 线图 / 价格走势图占位 · 从 diagrams/ 提取 SVG 嵌入] +
+
{{图表标题,如 "近 20 个交易日价格走势"}}
+
+ + +

财务概览

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
指标FY2023FY2024FY2025E
营收{{数据}}{{数据}}{{数据}}
净利润{{数据}}{{数据}}{{数据}}
EPS{{数据}}{{数据}}{{数据}}
毛利率{{数据}}{{数据}}{{数据}}
ROE{{数据}}{{数据}}{{数据}}
+ + +

收入结构

+
+
+ [营收分解图占位 · 环形图或瀑布图] +
+
{{图表标题}}
+
+ + +
+

竞争格局

+

{{行业格局概述,1-2 段。}}

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
公司市值P/E营收增速利润率
{{目标公司}}{{数据}}{{数据}}{{数据}}{{数据}}
{{竞品 A}}{{数据}}{{数据}}{{数据}}{{数据}}
{{竞品 B}}{{数据}}{{数据}}{{数据}}{{数据}}
{{竞品 C}}{{数据}}{{数据}}{{数据}}{{数据}}
+ + +

风险提示

+
+
+
{{风险类型 1}}
+
{{风险描述}}
+
+
+
{{风险类型 2}}
+
{{风险描述}}
+
+
+
{{风险类型 3}}
+
{{风险描述}}
+
+
+
{{风险类型 4}}
+
{{风险描述}}
+
+
+ + +
+
分析师总结
+ {{3-5 句总结。包含目标价(如有)、评级建议、核心催化剂、关键假设。}} +
+ + + + + + diff --git a/assets/templates/landing-page-en.html b/assets/templates/landing-page-en.html new file mode 100644 index 0000000..c10771e --- /dev/null +++ b/assets/templates/landing-page-en.html @@ -0,0 +1,1163 @@ + + + + + +{{PRODUCT_NAME}} · {{TAGLINE_SHORT}} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+ {{EYEBROW_TEXT}} · {{VERSION}} + + + +
+ +

{{PRODUCT_NAME}}

+ +

{{TAGLINE}}

+ +
+ +
+ + +
+ + +
+
+

00 · {{GALLERY_SECTION_NUM}}

+

{{GALLERY_TITLE}}

+

{{GALLERY_LEDE}}

+ +
+ + +
+ + +
+
+

01 · {{FEATURES_SECTION_NUM}}

+

{{FEATURES_TITLE}}

+
+ +
    + +
+
+ + +
+
+

02 · {{PRINCIPLES_SECTION_NUM}}

+

{{PRINCIPLES_TITLE}}

+
+ +
    + +
+
+ + +
+
+

03 · {{PRICING_SECTION_NUM}}

+

{{PRICING_TITLE}}

+
+ +
+

{{PRICE_DISPLAY}}

+

{{PRICE_COMPARISON}}

+ {{CTA_PRIMARY_LABEL}} +

{{PRICE_TRIAL_TEXT}}

+

{{PRICE_TERMS}}

+
+
+ + +
+
+

04 · {{FAQ_SECTION_NUM}}

+

{{FAQ_TITLE}}

+
+ +
+ +
+ +
+ + +
+
+ {{PRODUCT_NAME}} + {{PRODUCT_NAME}} + {{FOOTER_TAGLINE}} +
+
+ +

{{FOOTER_ETHOS}}

+
+
+ +
+ + + + + diff --git a/assets/templates/landing-page-ko.html b/assets/templates/landing-page-ko.html new file mode 100644 index 0000000..3e60120 --- /dev/null +++ b/assets/templates/landing-page-ko.html @@ -0,0 +1,1148 @@ + + + + + +{{PRODUCT_NAME}} · {{TAGLINE_SHORT}} + + + + + + + + + + + + + + + + + +
+ + +
+
+ {{EYEBROW_TEXT}} · {{VERSION}} + + + +
+ +

{{PRODUCT_NAME}}

+ +

{{TAGLINE}}

+ +
+ +
+ + +
+ + +
+
+

00 · {{GALLERY_SECTION_NUM}}

+

{{GALLERY_TITLE}}

+

{{GALLERY_LEDE}}

+ +
+ + +
+ + +
+
+

01 · {{FEATURES_SECTION_NUM}}

+

{{FEATURES_TITLE}}

+
+ +
    + +
+
+ + +
+
+

02 · {{PRINCIPLES_SECTION_NUM}}

+

{{PRINCIPLES_TITLE}}

+
+ +
    + +
+
+ + +
+
+

03 · {{PRICING_SECTION_NUM}}

+

{{PRICING_TITLE}}

+
+ +
+

{{PRICE_DISPLAY}}

+

{{PRICE_COMPARISON}}

+ {{CTA_PRIMARY_LABEL}} +

{{PRICE_TRIAL_TEXT}}

+

{{PRICE_TERMS}}

+
+
+ + +
+
+

04 · {{FAQ_SECTION_NUM}}

+

{{FAQ_TITLE}}

+
+ +
+ +
+ +
+ + +
+
+ {{PRODUCT_NAME}} + {{PRODUCT_NAME}} + {{FOOTER_TAGLINE}} +
+
+ +

{{FOOTER_ETHOS}}

+
+
+ +
+ + + + diff --git a/assets/templates/landing-page-llms-full.txt.example b/assets/templates/landing-page-llms-full.txt.example new file mode 100644 index 0000000..61daf3c --- /dev/null +++ b/assets/templates/landing-page-llms-full.txt.example @@ -0,0 +1,76 @@ +# {{PRODUCT_NAME}} - Full Knowledge Base + + + +> {{TAGLINE_LONG_ONE_SENTENCE}} + +This is the long-form companion to llms.txt. Keep llms.txt as the short summary; put feature details, FAQ, and comparison here so AI assistants get accurate answers without having to scrape the site. + +--- + +## Overview + +{{PRODUCT_NAME}} is {{ONE_PARAGRAPH_POSITIONING}}. + +Author: {{AUTHOR_NAME}} ({{AUTHOR_URL}}). +Source: {{SOURCE_REPO_URL}}. +Platform: {{OS_REQUIREMENT}}. + +## Pricing + +{{ONE_PARAGRAPH_PRICE_TERMS_REFUND_LICENSE_SEATS}} + +## Features + +### {{FEATURE_1_NAME}} +{{ONE_PARAGRAPH_FEATURE_1}}. Cover: what it does, when to use it, the most common output it produces, and any limit the user should know. + +### {{FEATURE_2_NAME}} +{{ONE_PARAGRAPH_FEATURE_2}} + +### {{FEATURE_3_NAME}} +{{ONE_PARAGRAPH_FEATURE_3}} + +Repeat one subsection per feature. Aim for 3-7 features. Skip marketing adjectives - LLMs index facts, not enthusiasm. + +## How {{PRODUCT_NAME}} differs + +### vs {{COMPETITOR_A}} +{{TWO_OR_THREE_SENTENCES}}. State the concrete differentiator (workflow, output, distribution model, price). Avoid "we are better" framing. + +### vs {{COMPETITOR_B}} +{{TWO_OR_THREE_SENTENCES}}. + +### vs general-purpose tools (e.g. {{GENERIC_TOOL}}) +{{TWO_OR_THREE_SENTENCES}}. + +## FAQ + +### What is {{PRODUCT_NAME}} for? +{{ANSWER_3_SENTENCES}}. + +### Who is it not for? +{{ANSWER_2_SENTENCES}}. Be specific about workflows that are out of scope, so AI assistants do not over-recommend. + +### How does it integrate with {{COMMON_TOOL_USERS_ALREADY_USE}}? +{{ANSWER_2_SENTENCES}}. + +### What does the {{PRICE_AMOUNT}} buy? +{{ANSWER_2_SENTENCES}} including seat count, updates, and refund window. + +### How do I get support? +{{ANSWER_1_SENTENCE}} with a real contact channel. + +## Links + +- Website: {{SITE_ORIGIN}} +- Website (Chinese): {{SITE_ORIGIN}}/zh/ +- Documentation: {{SITE_ORIGIN}}/docs +- Help: {{SITE_ORIGIN}}/help +- Source: {{SOURCE_REPO_URL}} +- Releases: {{SITE_ORIGIN}}/releases diff --git a/assets/templates/landing-page-llms.txt.example b/assets/templates/landing-page-llms.txt.example new file mode 100644 index 0000000..cffa8c4 --- /dev/null +++ b/assets/templates/landing-page-llms.txt.example @@ -0,0 +1,34 @@ +# {{PRODUCT_NAME}} + + + +> {{TAGLINE_LONG_ONE_SENTENCE}} + +## Author +- Name: {{AUTHOR_NAME}} +- Site: {{AUTHOR_URL}} + +## What is {{PRODUCT_NAME}} +{{PRODUCT_NAME}} is {{ONE_PARAGRAPH_POSITIONING}}. Include who it is for, the core job it does, and the one differentiator that separates it from common alternatives (name the alternatives by their real names; do not write "competitors"). Mention platform requirements at the end of this paragraph. + +## How it differs +- vs {{COMPETITOR_A}}: {{ONE_LINE_CONTRAST}} +- vs {{COMPETITOR_B}}: {{ONE_LINE_CONTRAST}} +- vs general-purpose tools: {{ONE_LINE_CONTRAST}} + +## Pricing +{{ONE_LINE_PRICE_AND_TERMS}}. If free, write "Free. Open source under the {{LICENSE}} license." Mention any trial scope explicitly. + +## Key pages +- [Documentation]({{SITE_ORIGIN}}/docs): {{ONE_LINE}} +- [Help]({{SITE_ORIGIN}}/help): {{ONE_LINE}} +- [Full knowledge base]({{SITE_ORIGIN}}/llms-full.txt): comprehensive feature details for AI assistants +- [Releases]({{SITE_ORIGIN}}/releases): version history and changelogs + +## Links +- Website: {{SITE_ORIGIN}} +- Website (Chinese): {{SITE_ORIGIN}}/zh/ +- Source: {{SOURCE_REPO_URL}} diff --git a/assets/templates/landing-page-robots.txt.example b/assets/templates/landing-page-robots.txt.example new file mode 100644 index 0000000..4e62a32 --- /dev/null +++ b/assets/templates/landing-page-robots.txt.example @@ -0,0 +1,63 @@ +# Companion to landing-page.html / landing-page-en.html. +# Drop into your repo root as robots.txt (without the .example suffix) and replace example.com with your domain. +# AI-crawler allowlist mirrors what Kami's own site already ships - keep these open so Claude, ChatGPT, Perplexity, and Apple Intelligence can index your product. + +User-agent: * +Allow: / + +# Add Disallow lines for paths that should not be indexed, e.g. +# Disallow: /api/ +# Disallow: /admin/ + +Sitemap: https://example.com/sitemap.xml + +# Search engines +User-agent: Googlebot +Allow: / + +User-agent: Bingbot +Allow: / + +User-agent: Baiduspider +Allow: / + +User-agent: Applebot +Allow: / + +# AI training crawlers +User-agent: GPTBot +Allow: / + +User-agent: ChatGPT-User +Allow: / + +User-agent: ClaudeBot +Allow: / + +User-agent: anthropic-ai +Allow: / + +User-agent: PerplexityBot +Allow: / + +User-agent: Google-Extended +Allow: / + +User-agent: CCBot +Allow: / + +# AI search bots (retrieval, not training) +User-agent: OAI-SearchBot +Allow: / + +User-agent: Claude-SearchBot +Allow: / + +User-agent: Claude-User +Allow: / + +User-agent: Perplexity-User +Allow: / + +User-agent: DuckAssistBot +Allow: / diff --git a/assets/templates/landing-page-sitemap.xml.example b/assets/templates/landing-page-sitemap.xml.example new file mode 100644 index 0000000..7078cde --- /dev/null +++ b/assets/templates/landing-page-sitemap.xml.example @@ -0,0 +1,53 @@ + + + + + https://example.com/ + + + + + + + 2026-01-01 + monthly + 1.0 + + + https://example.com/zh/ + + + 2026-01-01 + monthly + 0.9 + + + https://example.com/tw/ + + + 2026-01-01 + monthly + 0.8 + + + https://example.com/ja/ + + + 2026-01-01 + monthly + 0.8 + + + https://example.com/ko/ + + + 2026-01-01 + monthly + 0.8 + + diff --git a/assets/templates/landing-page-vercel.json.example b/assets/templates/landing-page-vercel.json.example new file mode 100644 index 0000000..efa185f --- /dev/null +++ b/assets/templates/landing-page-vercel.json.example @@ -0,0 +1,37 @@ +{ + "rewrites": [ + { "source": "/zh", "destination": "/index-zh.html" }, + { "source": "/zh/", "destination": "/index-zh.html" }, + { "source": "/tw", "destination": "/index-tw.html" }, + { "source": "/tw/", "destination": "/index-tw.html" }, + { "source": "/ja", "destination": "/index-ja.html" }, + { "source": "/ja/", "destination": "/index-ja.html" }, + { "source": "/ko", "destination": "/index-ko.html" }, + { "source": "/ko/", "destination": "/index-ko.html" } + ], + + "redirects": [ + { + "source": "/(.*)", + "has": [{ "type": "host", "value": "www.example.com" }], + "destination": "https://example.com/$1", + "permanent": true + } + ], + + "headers": [ + { + "source": "/(.*)", + "headers": [ + { "key": "X-Content-Type-Options", "value": "nosniff" }, + { "key": "X-Frame-Options", "value": "DENY" } + ] + }, + { + "source": "/:path*.(png|jpg|gif|ico|svg|webp)", + "headers": [ + { "key": "Cache-Control", "value": "public, max-age=31536000, immutable" } + ] + } + ] +} diff --git a/assets/templates/landing-page.html b/assets/templates/landing-page.html new file mode 100644 index 0000000..9337219 --- /dev/null +++ b/assets/templates/landing-page.html @@ -0,0 +1,1149 @@ + + + + + +{{PRODUCT_NAME}} · {{TAGLINE_SHORT}} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+ {{EYEBROW_TEXT}} · {{VERSION}} + + + +
+ +

{{PRODUCT_NAME}}

+ +

{{TAGLINE}}

+ +
+ +
+ + +
+ + +
+
+

00 · {{GALLERY_SECTION_NUM}}

+

{{GALLERY_TITLE}}

+

{{GALLERY_LEDE}}

+
+ + +
+ + +
+
+

01 · {{FEATURES_SECTION_NUM}}

+

{{FEATURES_TITLE}}

+
+ +
    + +
+
+ + +
+
+

02 · {{PRINCIPLES_SECTION_NUM}}

+

{{PRINCIPLES_TITLE}}

+
+ +
    + +
+
+ + +
+
+

03 · {{PRICING_SECTION_NUM}}

+

{{PRICING_TITLE}}

+
+ +
+

{{PRICE_DISPLAY}}

+

{{PRICE_COMPARISON}}

+ {{CTA_PRIMARY_LABEL}} +

{{PRICE_TRIAL_TEXT}}

+

{{PRICE_TERMS}}

+
+
+ + +
+
+

04 · {{FAQ_SECTION_NUM}}

+

{{FAQ_TITLE}}

+
+ +
+ +
+ +
+ + +
+
+ {{PRODUCT_NAME}} + {{PRODUCT_NAME}} + {{FOOTER_TAGLINE}} +
+
+ +

{{FOOTER_ETHOS}}

+
+
+ +
+ + + + diff --git a/assets/templates/letter-en.html b/assets/templates/letter-en.html new file mode 100644 index 0000000..9a7a8fa --- /dev/null +++ b/assets/templates/letter-en.html @@ -0,0 +1,289 @@ + + + + + +{{LETTER_SUBJECT}} + + + + + + + + +
+
{{SENDER_NAME}}
+
{{SENDER_ADDRESS / DEPARTMENT / ORGANIZATION}}
+
{{PHONE}} · {{EMAIL}}
+
+ +
{{DATE, e.g. April 18, 2026}}
+ +
+
To
+
{{RECIPIENT_NAME_OR_TITLE}}
+
{{RECIPIENT_ORG / DEPARTMENT}}
+
+ +
+
Re
+
{{One-sentence subject line: what this letter is about.}}
+
+ +
{{Dear {{NAME}},}}
+ +
+ +

+{{Paragraph 1: state the purpose. Why you are writing and the core intent. +No preamble. Two sentences at most so the reader knows what you want from this letter.}} +

+ +

+{{Paragraph 2: elaborate. Background, reasoning, evidence. Use +brand-color emphasis on the single most important point.}} +

+ +

+{{Paragraph 3: be specific. What you want the reader to do, by when, +and how to reach you. Give the action a clear exit.}} +

+ +

+{{Paragraph 4 (optional): close. Express anticipation, gratitude, or a courteous sign-off line.}} +

+ +
+ +
+
+ {{Closing - "Best regards," / "Sincerely," / "Warm regards,"}} +
+ +
{{HANDWRITTEN_NAME_OR_SIGNATURE}}
+
+ {{TITLE · DEPARTMENT}}
+ {{DATE (optional restatement)}} +
+
+ +
+ Enclosures + {{List - ① Attachment 1 · ② Attachment 2}} +
+ + + diff --git a/assets/templates/letter-ko.html b/assets/templates/letter-ko.html new file mode 100644 index 0000000..5551315 --- /dev/null +++ b/assets/templates/letter-ko.html @@ -0,0 +1,317 @@ + + + + + +{{서한 주제}} + + + + + + + + + +
+
{{발신인 성명}}
+
{{발신인 주소 / 부서 / 기관}}
+
{{전화번호}} · {{EMAIL}}
+
+ + +
{{날짜 예: 2026년 4월 18일}}
+ + +
+
수신
+
{{수신인 성명 / 직함}}
+
{{수신인 기관 / 부서}}
+
+ + +
+
제목
+
{{서한 주제를 한 문장으로 명확하게}}
+
+ + +
{{인사말 예: "안녕하세요, XX 님," / "존경하는 XX 귀중,"}}
+ + +
+ +

+{{첫 번째 단락: 서두. 이 서한을 쓰게 된 배경과 핵심 의도를 밝힌다. +돌려 말하지 않고 1-2문장으로 독자가 무엇에 관한 편지인지 파악하게 한다.}} +

+ +

+{{두 번째 단락: 전개. 배경, 이유, 근거를 제시한다. +핵심 정보는 하이라이트로 강조할 수 있다.}} +

+ +

+{{세 번째 단락: 구체적 요청. 상대방이 무엇을, 언제, 어떻게 해야 하는지 명확히 기술한다. +행동으로 이어질 수 있는 출구를 열어 둔다.}} +

+ +

+{{네 번째 단락 (선택): 마무리. 기대감 표명, 감사 인사, 또는 정중한 맺음말.}} +

+ +
+ + +
+
+ {{맺음말. 예: "감사합니다." / "부탁드립니다." / "Best regards,"}} +
+ +
{{서명 / 성명}}
+
+ {{직함 · 부서}}
+ {{날짜 재기입 · 선택}} +
+
+ + +
+ 첨부 + {{첨부 목록: ① 첨부 파일명 1 · ② 첨부 파일명 2}} +
+ + + diff --git a/assets/templates/letter.html b/assets/templates/letter.html new file mode 100644 index 0000000..209b820 --- /dev/null +++ b/assets/templates/letter.html @@ -0,0 +1,317 @@ + + + + + +{{信件主题}} + + + + + + + + + +
+
{{寄件人姓名}}
+
{{寄件人地址 / 部门 / 机构}}
+
{{电话}} · {{EMAIL}}
+
+ + +
{{日期 如 2026 年 4 月 18 日}}
+ + +
+
+
{{收件人姓名 / 称谓}}
+
{{收件人机构 / 部门}}
+
+ + +
+
关于
+
{{信件主题,一句话说清}}
+
+ + +
{{称呼 如 "尊敬的 XX 先生:" / "Dear XX,"}}
+ + +
+ +

+{{第一段:破题。说明写这封信的缘由和核心意图。 +不要绕弯,1-2 句话让读者知道你要说什么。}} +

+ +

+{{第二段:展开。给出背景、理由、论据。 +如有 关键信息 可用高亮突出。}} +

+ +

+{{第三段:具体。说清楚你希望对方做什么、 +何时做、怎么联系。让行动有明确出口。}} +

+ +

+{{第四段(可选):收尾。表达期待、致谢或礼貌性结束语。}} +

+ +
+ + +
+
+ {{敬语。中文常用 "此致 / 敬礼!"、"顺颂商祺"。英文 "Best regards," / "Sincerely,"}} +
+ +
{{签名 / 亲笔姓名}}
+
+ {{职位 · 部门}}
+ {{日期复写 · 可选}} +
+
+ + +
+ 附件 + {{附件清单:① 附件名 1 · ② 附件名 2}} +
+ + + diff --git a/assets/templates/long-doc-en.html b/assets/templates/long-doc-en.html new file mode 100644 index 0000000..b4e8549 --- /dev/null +++ b/assets/templates/long-doc-en.html @@ -0,0 +1,542 @@ + + + + + +{{DOC_TITLE}} + + + + + + + + + +
+
+
{{EYEBROW - e.g. Technical Report / Annual Review / White Paper}}
+
{{Document title
can span two lines}}
+
{{One-line subtitle - what this is and who it's for.}}
+
+
+ {{AUTHOR / TEAM}}
+ {{Version 1.0}} · {{YYYY.MM}}
+ {{PUBLISHER / ORGANIZATION}} +
+
+ + +
+

Contents

+ + + + + +
+ + +
+
01 · Executive Summary
+

Executive Summary

+ +

+ {{Two or three sentences opening the whole thesis. Use brand-color emphasis to grab attention on the sharpest claim. A reader of only this paragraph should understand what the document argues.}} +

+ +

Key Takeaways

+
    +
  • {{Takeaway 1 - a quantified conclusion in one line.}}
  • +
  • {{Takeaway 2 - an insight backed by data.}}
  • +
  • {{Takeaway 3 - a forward-looking judgment.}}
  • +
+ +
+
Questions this document answers
+ {{List the three core questions as actual questions - so the reader can decide in ten seconds whether to read on.}} +
+
+ + +
+
02 · Background
+

Background & Problem Statement

+ +

+ {{Chapter intro - what this chapter is solving, why it matters. One or two sentences.}} +

+ +

Current State

+

{{Three to five lines describing the status quo. Use specific figures rather than adjectives.}}

+ +

The Core Problem

+

{{State the problem specifically. Use a callout to emphasize a key observation:}}

+ +
+ {{A short quoted line or key observation. Different in tone from the body so the reader gets a breath.}} +
+ +

Metrics of Success

+ + + + + + + + + + + + + +
DimensionCurrentTargetGap
{{DIMENSION_1}}{{VAL}}{{VAL}}{{GAP}}
{{DIMENSION_2}}{{VAL}}{{VAL}}{{GAP}}
+
+ + +
+
03 · Methodology
+

Methodology & Findings

+ +

{{Chapter intro.}}

+ +

Approach

+

{{Describe the methodology. Code or formula examples welcome:}}

+ +
def analyze(data):
+    """Transform raw data."""
+    return transform(data)
+ +

Key Findings

+ +

Finding 1 - {{TITLE}}

+

{{A paragraph with data: specific numbers or ratios.}}

+ +

Finding 2 - {{TITLE}}

+

{{A paragraph with data: specific numbers or ratios.}}

+ +
+ {{A quoted passage - user interview, expert perspective, or cited source.}} + - {{SOURCE / PERSON}}, {{DATE}} +
+
+ + +
+
04 · Conclusions
+

Conclusions & Recommendations

+ +

{{Chapter intro - a one-line summary of the conclusion, then the recommendations below.}}

+ +

Core Conclusions

+
    +
  1. {{Conclusion 1.}}
  2. +
  3. {{Conclusion 2.}}
  4. +
  5. {{Conclusion 3.}}
  6. +
+ +

Recommended Next Steps

+

{{Concrete, executable recommendations tied to the conclusions.}}

+ +
+
Call to Action
+ {{If the reader does one thing, what is it? Specific enough to start Monday morning.}} +
+
+ + +
+
05 · Appendix
+

Appendix

+ +

A. References

+
    +
  • {{Reference 1}}
  • +
  • {{Reference 2}}
  • +
+ +

B. Glossary

+

{{TERM}} - {{definition}}

+

{{TERM}} - {{definition}}

+ +

C. Acknowledgements

+

{{Acknowledgement paragraph.}}

+
+ + + diff --git a/assets/templates/long-doc-ko.html b/assets/templates/long-doc-ko.html new file mode 100644 index 0000000..9a2ec57 --- /dev/null +++ b/assets/templates/long-doc-ko.html @@ -0,0 +1,615 @@ + + + + + +{{문서 제목}} + + + + + + + + + +
+
+
{{EYEBROW · 예: "기술 보고서" / "연간 결산" / "백서"}}
+
{{문서 주제목
두 줄도 가능}}
+
{{부제목: 이 문서가 무엇인지, 누구를 위한 것인지 한 문장으로}}
+
+
+ {{작성자 / 팀}}
+ {{버전 V1.0}} · {{날짜 2026.04}}
+ {{발행 기관}} +
+
+ + +
+

목차

+
+ 01 + 요약 +
+ + + +
+ 05 + 부록 +
+
+ + +
+
01 · Executive Summary
+

요약

+ +

+ {{2-3문장의 핵심 논지 도입부. 키워드 하이라이트로 독자의 주의를 끈다. + 이 단락만 읽어도 문서 전체가 무엇을 말하는지 파악할 수 있어야 한다.}} +

+ +

핵심 요점

+
    +
  • {{요점 1: 한 문장, 수치화 가능한 결론}}
  • +
  • {{요점 2: 데이터 기반의 인사이트}}
  • +
  • {{요점 3: 향후 방향에 대한 판단}}
  • +
+ +
+
이 문서가 답하는 질문
+ {{이 문서의 핵심 질문 3가지를 의문문으로 나열. 독자가 계속 읽을 필요가 있는지 바로 판단하게 한다.}} +
+
+ + +
+
02 · Background
+

배경 및 문제 정의

+ +

+ {{챕터 도입부: 이 챕터가 다루는 문제와 그 중요성. 1-2문장.}} +

+ +

현황

+

{{3-5줄 단락, 현재 상황을 서술한다. 구체적인 수치를 형용사 대신 사용한다.}}

+ +

핵심 문제

+

{{구체적인 핵심 문제를 기술한다. 아래 callout으로 핵심 관찰을 강조할 수 있다:}}

+ +
+ {{중요한 인용 또는 핵심 관찰. 본문과 약간 다른 어조로, 독자에게 호흡 공간을 준다.}} +
+ +

측정 기준

+ + + + + + + + + + + + + + + + + + + + + + + +
항목현재 수준목표 수준격차
{{항목 1}}{{데이터}}{{데이터}}{{격차}}
{{항목 2}}{{데이터}}{{데이터}}{{격차}}
+
+ + +
+
03 · Methodology
+

방법론 및 주요 발견

+ +

{{챕터 도입부}}

+ +

연구 방법

+

{{방법론을 기술한다. 코드 / 수식 예시를 포함할 수 있다:}}

+ +
def analyze(data):
+    """Transform raw data."""
+    return transform(data)
+ +

주요 발견

+ +

발견 1: {{제목}}

+

{{한 단락 논술. 구체적인 수치 / 비율을 포함한다.}}

+ +

발견 2: {{제목}}

+

{{한 단락 논술. 구체적인 수치 / 비율을 포함한다.}}

+ +
+ {{인용: 사용자 인터뷰, 전문가 의견, 또는 문헌 인용}} + - {{출처 / 인물}}, {{날짜}} +
+
+ + +
+
04 · Conclusions
+

결론 및 제언

+ +

{{챕터 도입부: 결론을 한 문장으로 요약하고, 아래에서 제언을 전개한다.}}

+ +

핵심 결론

+
    +
  1. {{결론 1}}
  2. +
  3. {{결론 2}}
  4. +
  5. {{결론 3}}
  6. +
+ +

다음 단계 제언

+

{{결론에 기반한 구체적이고 실행 가능한 제언.}}

+ +
+
Call to Action
+ {{독자가 한 가지만 실행한다면 무엇인가? 월요일 아침에 바로 시작할 수 있을 만큼 구체적으로.}} +
+
+ + +
+
05 · Appendix
+

부록

+ +

A. 참고 자료

+
    +
  • {{참고 문헌 1}}
  • +
  • {{참고 문헌 2}}
  • +
+ +

B. 용어 설명

+

{{용어}}: {{정의}}

+

{{용어}}: {{정의}}

+ +

C. 감사의 말

+

{{감사 단락}}

+
+ + + diff --git a/assets/templates/long-doc.html b/assets/templates/long-doc.html new file mode 100644 index 0000000..58ce400 --- /dev/null +++ b/assets/templates/long-doc.html @@ -0,0 +1,672 @@ + + + + + +{{文档标题}} + + + + + + + + + +
+
+
{{EYEBROW · 如 "技术报告" / "年度总结" / "白皮书"}}
+
{{文档主标题
可以两行}}
+
{{副标题,一句话说清这份文档是什么 / 为谁而写}}
+
+
+ {{作者 / 团队}}
+ {{版本 V1.0}} · {{日期 2026.04}}
+ {{发布方 / 机构}} +
+
+ + +
+

目录

+
+ 01 + 执行摘要 +
+ +
+ 03 + 方法与发现 +
+
+ 04 + 结论与建议 +
+
+ 05 + 附录 +
+
+ + +
+
01 · Executive Summary
+

执行摘要

+ +

+ {{一段 2-3 句话的大论点开场。用 关键词高亮 抓住读者注意力。 + 让读者读这段就能理解整份文档想表达什么。}} +

+ +

核心 Takeaways

+
    +
  • {{Takeaway 1:一句话,可量化的结论}}
  • +
  • {{Takeaway 2:有数据的洞察}}
  • +
  • {{Takeaway 3:对未来的判断}}
  • +
+ +
+
本文档回答的问题
+ {{用疑问句列出 3 个本文档的核心问题。让读者立刻 get 到是否需要读完。}} +
+
+ + +
+
02 · Background
+

背景与问题定义

+ +

+ {{章节导语:这一章要解决什么问题,为什么重要。1-2 句。}} +

+ +

当前现状

+

{{3-5 行段落,铺陈当前状况。用 具体数据 而不是形容词。}}

+ +

核心问题

+

{{陈述具体的核心问题。可以用 callout 突出关键观察:}}

+ +
+ {{一段重要引用或核心观察。和正文语气略有不同,给读者呼吸节奏。}} +
+ +

衡量标准

+ + + + + + + + + + + + + + + + + + + + + + + +
维度当前水平目标水平差距
{{维度 1}}{{数据}}{{数据}}{{差距}}
{{维度 2}}{{数据}}{{数据}}{{差距}}
+
+ + +
+
03 · Methodology
+

方法与发现

+ +

{{章节导语}}

+ +

研究方法

+

{{描述方法论。可以用代码 / 公式示例:}}

+ +
def analyze(data):
+    """Transform raw data."""
+    return transform(data)
+ +
+ + + + + + + + + + + + + + + + 提交请求 + + + + 查询记录 + + + + 返回结果 + + + + 响应数据 + + + + 用户 + + + + API + + + + 数据库 + + +
图 1:用户请求的调用时序
+
+ +

关键发现

+ +

发现 1:{{标题}}

+

{{一段论述。包含数据:具体数字 / 具体比例。}}

+ +

发现 2:{{标题}}

+

{{一段论述。包含数据:具体数字 / 具体比例。}}

+ +
+ {{一段引用,可以是用户访谈、专家观点、文献引用}} + - {{来源 / 人物}},{{日期}} +
+
+ + +
+
04 · Conclusions
+

结论与建议

+ +

{{章节导语:一句话总结结论,下面展开建议。}}

+ +

核心结论

+
    +
  1. {{结论 1}}
  2. +
  3. {{结论 2}}
  4. +
  5. {{结论 3}}
  6. +
+ +

下一步建议

+

{{基于结论的具体可执行建议。}}

+ +
+
Call to Action
+ {{如果读者要做一件事,是什么?具体到可以周一早上就开始行动。}} +
+
+ + +
+
05 · Appendix
+

附录

+ +

A. 参考资料

+
    +
  • {{参考文献 1}}
  • +
  • {{参考文献 2}}
  • +
+ +

B. 术语表

+

{{术语}}:{{定义}}

+

{{术语}}:{{定义}}

+ +

C. 致谢

+

{{致谢段落}}

+
+ + + diff --git a/assets/templates/marp/slides-marp-en.css b/assets/templates/marp/slides-marp-en.css new file mode 100644 index 0000000..aa65fab --- /dev/null +++ b/assets/templates/marp/slides-marp-en.css @@ -0,0 +1,254 @@ +/* @theme kami-en */ +/* Kami Marp theme (EN). Mirrors slides-weasy-en.html tokens. */ + +@font-face { + font-family: "JetBrains Mono"; + src: url("../../fonts/JetBrainsMono.woff2") format("woff2"); + font-weight: 400; +} + +:root { + --parchment: #f5f4ed; + --ivory: #faf9f5; + --near-black: #141413; + --dark-warm: #3d3d3a; + --olive: #504e49; + --stone: #6b6a64; + --brand: #1B365D; + --brand-tint: #EEF2F7; + --border: #e8e6dc; + --border-soft:#e5e3d8; + + --serif: Charter, Georgia, Palatino, serif; + --sans: var(--serif); + --mono: "JetBrains Mono", "SF Mono", Consolas, monospace; + + --rhythm-module: 14pt; + --rhythm-section: 18pt; +} + +section { + width: 280mm; + height: 158mm; + padding: 16mm 20mm; + background: var(--parchment); + color: var(--near-black); + font-family: var(--serif); + font-size: 13pt; + line-height: 1.55; + position: relative; + display: flex; + flex-direction: column; +} + +section * { box-sizing: border-box; } +section h1, section h2, section h3, +section p, section ul, section ol, section table { + margin: 0; + padding: 0; +} + +.eyebrow { + font-family: var(--mono); + font-size: 9.5pt; + letter-spacing: 2pt; + text-transform: uppercase; + color: var(--stone); + margin-bottom: 2pt; +} + +section h2 { + font-family: var(--serif); + font-size: 24pt; + font-weight: 500; + line-height: 1.2; + letter-spacing: -0.3pt; + color: var(--near-black); + margin-bottom: var(--rhythm-module); + text-wrap: balance; +} + +section h3 { + font-family: var(--serif); + font-size: 15pt; + font-weight: 500; + line-height: 1.3; + color: var(--brand); + margin-bottom: 8pt; +} + +.lead { + font-family: var(--serif); + font-size: 12pt; + font-weight: 400; + line-height: 1.5; + color: var(--olive); + margin-bottom: 8pt; +} + +.mt { + font-family: var(--serif); + font-size: 16pt; + font-weight: 500; + line-height: 1.3; + color: var(--near-black); + margin-bottom: 8pt; +} + +.ml { + font-family: var(--serif); + font-size: 24pt; + font-weight: 500; + color: var(--brand); + margin-right: 6pt; +} + +.ms { + font-family: var(--mono); + font-size: 7.5pt; + letter-spacing: 1.5pt; + text-transform: uppercase; + color: var(--stone); + border-bottom: 0.3pt solid var(--border); + padding-bottom: 4pt; + margin-bottom: var(--rhythm-module); +} + +.mb, +section p { + font-family: var(--serif); + font-size: 11pt; + line-height: 1.55; + color: var(--dark-warm); + margin-bottom: var(--rhythm-section); +} + +.mi { + font-family: var(--serif); + font-size: 11pt; + line-height: 1.55; + color: var(--dark-warm); + padding: 8pt 0; +} + +.mc { + font-family: var(--serif); + font-size: 9.5pt; + line-height: 1.5; + color: var(--olive); + border-top: 0.3pt solid var(--border); + padding-top: 8pt; + margin-top: var(--rhythm-section); +} + +.co { + font-family: var(--serif); + font-size: 11pt; + font-weight: 500; + line-height: 1.55; + color: var(--brand); + position: absolute; + bottom: 18mm; + left: 20mm; + right: 20mm; +} + +.c2 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 22pt; +} + +table.t2x2 { + width: 100%; + border-collapse: separate; + border-spacing: 22pt 18pt; + margin: -18pt -22pt; +} +table.t2x2 td { + vertical-align: top; + width: 50%; +} + +table.data { + width: 100%; + border-collapse: collapse; +} +table.data td { + padding: 8pt; + border-bottom: 0.3pt solid var(--border); + font-size: 11pt; + line-height: 1.55; +} +table.data td:first-child { + font-weight: 500; + color: var(--brand); +} + +section ul, section ol { + margin: 0 0 var(--rhythm-section) 0; + padding-left: 1.4em; + font-size: 11pt; + line-height: 1.55; + color: var(--dark-warm); +} +section li { margin-bottom: 4pt; } +section li::marker { color: var(--brand); } + +section svg { + max-height: 105mm; + width: auto; +} + +/* Cover layout: */ +section.cover { + align-items: center; + justify-content: center; + text-align: center; +} +section.cover h1 { + font-family: var(--serif); + font-size: 38pt; + font-weight: 500; + line-height: 1.05; + letter-spacing: -0.5pt; + color: var(--near-black); + margin-bottom: 12pt; +} +section.cover .sub { + font-family: var(--serif); + font-size: 14pt; + line-height: 1.45; + color: var(--olive); + max-width: 50ch; + margin-bottom: 32pt; +} +section.cover .meta { + font-family: var(--mono); + font-size: 9.5pt; + color: var(--stone); + letter-spacing: 1.5pt; + text-transform: uppercase; +} + +/* Pagination (enable with `paginate: true` in deck frontmatter) */ +section::after { + font-family: var(--mono); + font-size: 9pt; + color: var(--stone); + letter-spacing: 0.5pt; + font-variant-numeric: tabular-nums; + bottom: 10mm; + right: 20mm; +} + +/* Footer mark (enable with ``) */ +footer { + font-family: var(--mono); + font-size: 9pt; + color: var(--stone); + letter-spacing: 1.5pt; + position: absolute; + bottom: 10mm; + left: 20mm; +} diff --git a/assets/templates/marp/slides-marp-en.md b/assets/templates/marp/slides-marp-en.md new file mode 100644 index 0000000..55dcb4a --- /dev/null +++ b/assets/templates/marp/slides-marp-en.md @@ -0,0 +1,129 @@ +--- +marp: true +theme: kami-en +size: 280mm 158mm +paginate: true +footer: "Kami · Marp" +--- + + + + + +# Decks that read like paper + +
Kami Marp deck · editorial rhythm in Markdown
+
Kami · 2026
+ +--- + +01 · Origin + +## Kami WeasyPrint deck, ported to Markdown + +

Same palette, fonts, layout tokens. Only the file format and editing posture change.

+ +
+ +
+ +### Shared with WeasyPrint slides + +- `--parchment` `#f5f4ed` warm cream canvas +- `--brand` `#1B365D` the only chromatic accent +- `--serif` Charter on English, Tsanger on Chinese +- 280×158mm 16:9 page +- `.eyebrow` `.lead` `.co` `.c2` `.t2x2` carry over + +
+ +
+ +### What Marp changes + +- Page unit is `section`, not `.slide` +- Page break is `---`, not `break-after: page` +- Pagination via `paginate: true` injects automatically +- Per-slide class via `` +- Renders through local `marp-cli`, not `build.py` + +
+ +
+ +--- + +02 · Four pillars + +## Four decisions to lock before writing + + + + + + + +
+ +
APalette
+ +One ink-blue accent, never above 5% of surface area. Warm neutrals carry the rest. No cool gray, no pure white. + +
+ +
BType
+ +One serif per page. Body 400, headings 500. No synthetic bold. Charter for EN, Tsanger W04 / W05 for CN. + +
+ +
CLayout
+ +`.c2` two-column via CSS Grid. `.t2x2` four-quadrant via HTML ``. Grid will not align row heights in a 2×2. + + + + +
+ +
DRhythm
+ +`--rhythm-module: 14pt` and `--rhythm-section: 18pt`. Two tokens govern all spacing. Do not sprinkle ad-hoc margins. + +
+ +--- + +03 · Title rule + +## Slide titles are claims, not labels + +

"Q3 results" is a topic. "Q3 revenue beat by 12%" is a claim.

+ +Avoid noun phrases like "Q3 results" or "Team intro". Rewrite to "Q3 revenue beat guidance by 12 percent" or "The team has only built retrieval for five years". A reader scanning titles should leave with the takeaway; the body just supplies evidence. + +
Title carries the claim. Body grounds it. The deck gains a spine.
+ +--- + +04 · Render matrix + +## One Markdown source, three export targets + + + + + + +
HTML preview0 MB extra downloadOpen in any browser. Lightest path.
PDF export~150 MB Chromium, or reuse a local ChromeSee production.md «Browser strategy»
PPTX exportSame dependency as PDFSlide-image dump; not an editable deck
VS Code preview0 MB (VS Code bundles Chromium)Install the Marp for VS Code extension
+ +--- + + + + + +# Copy it, swap in your story + +
Replace the content. Leave the structure alone.
+
github.com/tw93/Kami
diff --git a/assets/templates/marp/slides-marp.css b/assets/templates/marp/slides-marp.css new file mode 100644 index 0000000..69f3997 --- /dev/null +++ b/assets/templates/marp/slides-marp.css @@ -0,0 +1,276 @@ +/* @theme kami */ +/* Kami Marp theme (CN). Mirrors slides-weasy.html tokens. */ + +@font-face { + font-family: "TsangerJinKai02"; + src: url("../../fonts/TsangerJinKai02-W04.ttf") format("truetype"), + url("https://cdn.jsdelivr.net/gh/tw93/Kami@main/assets/fonts/TsangerJinKai02-W04.ttf") format("truetype"); + font-weight: 400; +} +@font-face { + font-family: "TsangerJinKai02"; + src: url("../../fonts/TsangerJinKai02-W05.ttf") format("truetype"), + url("https://cdn.jsdelivr.net/gh/tw93/Kami@main/assets/fonts/TsangerJinKai02-W05.ttf") format("truetype"); + font-weight: 500; +} +@font-face { + font-family: "JetBrains Mono"; + src: url("../../fonts/JetBrainsMono.woff2") format("woff2"); + font-weight: 400; +} + +:root { + --parchment: #f5f4ed; + --ivory: #faf9f5; + --near-black: #141413; + --dark-warm: #3d3d3a; + --olive: #504e49; + --stone: #6b6a64; + --brand: #1B365D; + --brand-tint: #EEF2F7; + --border: #e8e6dc; + --border-soft:#e5e3d8; + + --serif: "TsangerJinKai02", "Source Han Serif SC", "Noto Serif CJK SC", "Songti SC", "STSong", Georgia, serif; + --sans: var(--serif); + --mono: "JetBrains Mono", "SF Mono", "Fira Code", Consolas, Monaco, "TsangerJinKai02", "Source Han Serif SC", monospace; + + --rhythm-module: 14pt; + --rhythm-section: 18pt; +} + +section { + width: 280mm; + height: 158mm; + padding: 16mm 20mm; + background: var(--parchment); + color: var(--near-black); + font-family: var(--sans); + font-size: 13pt; + line-height: 1.55; + letter-spacing: 0.3pt; + position: relative; + display: flex; + flex-direction: column; +} + +section * { box-sizing: border-box; } +section h1, section h2, section h3, +section p, section ul, section ol, section table { + margin: 0; + padding: 0; +} + +.eyebrow { + font-family: var(--mono); + font-size: 9.5pt; + letter-spacing: 2pt; + text-transform: uppercase; + color: var(--stone); + margin-bottom: 2pt; +} + +section h2 { + font-family: var(--serif); + font-size: 24pt; + font-weight: 500; + line-height: 1.2; + letter-spacing: 0.3pt; + color: var(--near-black); + margin-bottom: var(--rhythm-module); + text-wrap: balance; +} + +section h3 { + font-family: var(--serif); + font-size: 15pt; + font-weight: 500; + line-height: 1.3; + letter-spacing: 0.3pt; + color: var(--brand); + margin-bottom: 8pt; +} + +.lead { + font-family: var(--sans); + font-size: 12pt; + font-weight: 400; + line-height: 1.55; + letter-spacing: 0.3pt; + color: var(--olive); + margin-bottom: 8pt; +} + +.mt { + font-family: var(--serif); + font-size: 16pt; + font-weight: 500; + line-height: 1.3; + letter-spacing: 0.3pt; + color: var(--near-black); + margin-bottom: 8pt; +} + +.ml { + font-family: var(--serif); + font-size: 24pt; + font-weight: 500; + color: var(--brand); + margin-right: 6pt; +} + +.ms { + font-family: var(--mono); + font-size: 7.5pt; + letter-spacing: 1.5pt; + text-transform: uppercase; + color: var(--stone); + border-bottom: 0.3pt solid var(--border); + padding-bottom: 4pt; + margin-bottom: var(--rhythm-module); +} + +.mb, +section p { + font-family: var(--sans); + font-size: 11pt; + line-height: 1.55; + letter-spacing: 0.3pt; + color: var(--dark-warm); + margin-bottom: var(--rhythm-section); +} + +.mi { + font-family: var(--sans); + font-size: 11pt; + line-height: 1.55; + letter-spacing: 0.3pt; + color: var(--dark-warm); + padding: 8pt 0; +} + +.mc { + font-family: var(--sans); + font-size: 9.5pt; + line-height: 1.5; + letter-spacing: 0.3pt; + color: var(--olive); + border-top: 0.3pt solid var(--border); + padding-top: 8pt; + margin-top: var(--rhythm-section); +} + +.co { + font-family: var(--sans); + font-size: 11pt; + font-weight: 500; + line-height: 1.55; + letter-spacing: 0.3pt; + color: var(--brand); + position: absolute; + bottom: 18mm; + left: 20mm; + right: 20mm; +} + +.c2 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 22pt; +} + +table.t2x2 { + width: 100%; + border-collapse: separate; + border-spacing: 22pt 18pt; + margin: -18pt -22pt; +} +table.t2x2 td { + vertical-align: top; + width: 50%; +} + +table.data { + width: 100%; + border-collapse: collapse; +} +table.data td { + padding: 8pt; + border-bottom: 0.3pt solid var(--border); + font-size: 11pt; + line-height: 1.55; + letter-spacing: 0.3pt; +} +table.data td:first-child { + font-weight: 500; + color: var(--brand); +} + +section ul, section ol { + margin: 0 0 var(--rhythm-section) 0; + padding-left: 1.4em; + font-size: 11pt; + line-height: 1.55; + letter-spacing: 0.3pt; + color: var(--dark-warm); +} +section li { margin-bottom: 4pt; } +section li::marker { color: var(--brand); } + +section svg { + max-height: 105mm; + width: auto; +} + +/* Cover layout: */ +section.cover { + align-items: center; + justify-content: center; + text-align: center; +} +section.cover h1 { + font-family: var(--serif); + font-size: 38pt; + font-weight: 500; + line-height: 1.1; + letter-spacing: 0.3pt; + color: var(--near-black); + margin-bottom: 12pt; +} +section.cover .sub { + font-family: var(--sans); + font-size: 14pt; + line-height: 1.5; + letter-spacing: 0.3pt; + color: var(--olive); + max-width: 50ch; + margin-bottom: 32pt; +} +section.cover .meta { + font-family: var(--sans); + font-size: 10pt; + color: var(--stone); + letter-spacing: 0.3pt; +} + +/* Pagination (enable with `paginate: true` in deck frontmatter) */ +section::after { + font-family: var(--mono); + font-size: 9pt; + color: var(--stone); + letter-spacing: 0.5pt; + font-variant-numeric: tabular-nums; + bottom: 10mm; + right: 20mm; +} + +/* Footer mark (enable with ``) */ +footer { + font-family: var(--mono); + font-size: 9pt; + color: var(--stone); + letter-spacing: 1.5pt; + position: absolute; + bottom: 10mm; + left: 20mm; +} diff --git a/assets/templates/marp/slides-marp.md b/assets/templates/marp/slides-marp.md new file mode 100644 index 0000000..45c9211 --- /dev/null +++ b/assets/templates/marp/slides-marp.md @@ -0,0 +1,129 @@ +--- +marp: true +theme: kami +size: 280mm 158mm +paginate: true +footer: "Kami · Marp" +--- + + + + + +# 把幻灯片做成纸 + +
Kami Marp deck · 用 Markdown 写出版面
+
Kami · 2026
+ +--- + +01 · 出处 + +## 把 Kami slides 搬到 Markdown 上 + +

同一套色板、字体、布局 token。换的是文件格式与编辑姿势。

+ +
+ +
+ +### 共享的部分 + +- `--parchment` `#f5f4ed` 暖纸底色 +- `--brand` `#1B365D` 单一墨蓝 +- `--serif` 中文 TsangerJinKai02 / 英文 Charter +- 280×158mm 16:9 页面 +- `.eyebrow` `.lead` `.co` `.c2` `.t2x2` 一致 + +
+ +
+ +### Marp 这边的差异 + +- 页面单元用 `section`,不是 `.slide` +- 分页用 `---`,不是 `break-after: page` +- 页码用 `paginate: true` 自动注入 +- 单页类名用 `` +- 渲染走本地 `marp-cli`,不进 build.py + +
+ +
+ +--- + +02 · 四个支柱 + +## 一张 deck 立不立得住,看这四件事 + + + + + + + + + + +
+ +
A色板
+ +单一墨蓝做强调色,全场 ≤ 5% 面积。其余靠暖中性灰承托。绝对不要冷色、不要纯白底。 + +
+ +
B字体
+ +一页一种衬线,body 400、heading 500,禁止合成粗体。中文 W04/W05 双字重,英文 Charter 一套通吃。 + +
+ +
C布局
+ +`.c2` 两栏走 grid,`.t2x2` 四象限必须用 table,Grid 在四象限里行高对不齐。 + +
+ +
D节奏
+ +`--rhythm-module: 14pt`、`--rhythm-section: 18pt`。两个数管整套间距,不要再随手加 margin。 + +
+ +--- + +03 · 标题原则 + +## 标题写完整断言,不是话题标签 + +

「Q3 业绩」是话题,「Q3 营收高出 12 个点」是断言。

+ +避免 "Q3 业绩 / 团队介绍 / 下一步规划" 这种 noun phrase。改写成「Q3 营收比 guidance 高出 12 个百分点」「团队五年只做检索一件事」「下一季度把延迟从 800ms 压到 120ms」这种带结论的句子。读者扫标题就能拿到 takeaway,正文是支撑证据。 + +
标题先有立场,正文再补证据,整套 deck 就有了主线。
+ +--- + +04 · 渲染矩阵 + +## 一份 Markdown,三种导出口径 + + + + + + +
HTML 预览0 MB 额外下载浏览器打开即可,最轻
PDF 导出~150 MB Chromium 或复用本地 Chrome看 production.md «Browser strategy»
PPTX 导出同 PDF,依赖浏览器幻灯片图像化,非可编辑 deck
VS Code 预览0 MB(VS Code 内置 Chromium)装 Marp for VS Code 插件即可
+ +--- + + + + + +# 复制走,开始你自己的 deck + +
把内容换成你的故事,结构留下不动
+
github.com/tw93/Kami
diff --git a/assets/templates/one-pager-en.html b/assets/templates/one-pager-en.html new file mode 100644 index 0000000..4675c59 --- /dev/null +++ b/assets/templates/one-pager-en.html @@ -0,0 +1,421 @@ + + + + + +{{DOC_TITLE}} + + + + + + + + +
+
+
{{EYEBROW - e.g. Proposal / Report / Exec Summary}}
+

{{Document headline - verb-led, fits in two lines, bookish.}}

+
{{One-line subtitle or the single sharpest claim.}}
+
+
+ {{AUTHOR}}
+ {{YYYY.MM.DD}}
+ {{VERSION / STATUS}} +
+ +
+ +
+
+
{{NUMBER}}
+
{{LABEL}}
+
+
+
{{NUMBER}}
+
{{LABEL}}
+
+
+
{{NUMBER}}
+
{{LABEL}}
+
+
+
{{NUMBER}}
+
{{LABEL}}
+
+
+ +

+ {{~30-40 words. The one paragraph that sets the whole document's tone. Use brand-color emphasis on the sharpest claim or number. Everything below is in service of this.}} +

+ + + +
+
+

{{Section one}}

+

{{One or two sentences expanding the claim.}}

+
    +
  • {{Short bullet: a data point, observation, or judgment.}}
  • +
  • {{Short bullet with key figure.}}
  • +
  • {{Short bullet: one point per line.}}
  • +
+
+ +
+

{{Section two}}

+

{{One or two sentences expanding the claim.}}

+
    +
  • {{Short bullet: a data point, observation, or judgment.}}
  • +
  • {{Short bullet with key figure.}}
  • +
  • {{Short bullet: one point per line.}}
  • +
+
+
+ +
+

Roadmapthree-step arc for proposals

+
+
+
Phase 1
+
{{STAGE_TITLE}}
+
{{One-line explanation.}}
+
+
+
Phase 2
+
{{STAGE_TITLE}}
+
{{One-line explanation.}}
+
+
+
Phase 3
+
{{STAGE_TITLE}}
+
{{One-line explanation.}}
+
+
+
+ +
+ {{Key quote / critical note / the single takeaway that must not be missed.}} +
+ + + + + diff --git a/assets/templates/one-pager-ko.html b/assets/templates/one-pager-ko.html new file mode 100644 index 0000000..3e3221a --- /dev/null +++ b/assets/templates/one-pager-ko.html @@ -0,0 +1,448 @@ + + + + + +{{문서 제목}} + + + + + + + + + +
+
+
{{EYEBROW · 예: "제품 제안서" / "프로젝트 제안" / "요약 보고"}}
+

{{문서 주제목 (serif, 2줄 이내, 동사+명사 구조 권장)}}

+
{{한 줄 부제목 / 핵심 주장 한 문장}}
+
+
+ {{작성자명}}
+ {{날짜 YYYY.MM.DD}}
+ {{버전 / 상태}} +
+
+ + +
+
+
{{수치}}
+
{{라벨 설명}}
+
+
+
{{수치}}
+
{{라벨 설명}}
+
+
+
{{수치}}
+
{{라벨 설명}}
+
+
+
{{수치}}
+
{{라벨 설명}}
+
+
+ + +

+ {{40~60자 분량의 핵심 주장 도입부. 키워드로 핵심을 강조. 이 단락이 전체 문서의 톤을 결정.}} +

+ + + + +
+
+

{{섹션 제목 1}}

+

{{여기에 1~2문장의 단락을 작성해 주장을 전개.}}

+
    +
  • {{짧은 bullet: 데이터 / 관찰 / 판단}}
  • +
  • {{짧은 bullet: 핵심 수치를 포함한 근거}}
  • +
  • {{짧은 bullet: 한 줄에 한 논점}}
  • +
+
+ +
+

{{섹션 제목 2}}

+

{{여기에 1~2문장의 단락을 작성해 주장을 전개.}}

+
    +
  • {{짧은 bullet: 데이터 / 관찰 / 판단}}
  • +
  • {{짧은 bullet: 핵심 수치를 포함한 근거}}
  • +
  • {{짧은 bullet: 한 줄에 한 논점}}
  • +
+
+
+ + +
+

타임라인 / 로드맵(제안 문서에 적합)

+
+
+
단계 1
+
{{단계 제목}}
+
{{한 문장 설명}}
+
+
+
단계 2
+
{{단계 제목}}
+
{{한 문장 설명}}
+
+
+
단계 3
+
{{단계 제목}}
+
{{한 문장 설명}}
+
+
+
+ + +
+ {{핵심 인용 / 중요 알림 / 주요 takeaway. 본문보다 더 주목을 끌어야 하는 내용을 여기에.}} +
+ + + + + + diff --git a/assets/templates/one-pager.html b/assets/templates/one-pager.html new file mode 100644 index 0000000..cc35830 --- /dev/null +++ b/assets/templates/one-pager.html @@ -0,0 +1,457 @@ + + + + + +{{文档标题}} + + + + + + + + + +
+
+
{{EYEBROW · 如 "产品方案" / "项目提案" / "执行摘要"}}
+

{{文档主标题(serif,2 行内,动词+名词结构最好)}}

+
{{一行副标题 / 一句核心论点}}
+
+
+ {{作者名}}
+ {{日期 YYYY.MM.DD}}
+ {{版本号 / 状态}} +
+ +
+ + +
+
+
{{数字}}
+
{{标签描述}}
+
+
+
{{数字}}
+
{{标签描述}}
+
+
+
{{数字}}
+
{{标签描述}}
+
+
+
{{数字}}
+
{{标签描述}}
+
+
+ + +

+ {{一段 40-60 字的核心论点导语。用「关键词」高亮重点。这段定调整个文档。}} +

+ + + + +
+
+

{{section 标题 1}}

+

{{这里写 1-2 句段落,展开论点。}}

+
    +
  • {{短 bullet:数据/观察/判断}}
  • +
  • {{短 bullet:带 关键数字 的论据}}
  • +
  • {{短 bullet:一句一个论点}}
  • +
+
+ +
+

{{section 标题 2}}

+

{{这里写 1-2 句段落,展开论点。}}

+
    +
  • {{短 bullet:数据/观察/判断}}
  • +
  • {{短 bullet:带 关键数字 的论据}}
  • +
  • {{短 bullet:一句一个论点}}
  • +
+
+
+ + +
+

时间线 / 路线图(适用于方案类文档)

+
+
+
阶段 1
+
{{阶段标题}}
+
{{一句解释}}
+
+
+
阶段 2
+
{{阶段标题}}
+
{{一句解释}}
+
+
+
阶段 3
+
{{阶段标题}}
+
{{一句解释}}
+
+
+
+ + +
+ {{关键引用 / 重要提示 / 核心 takeaway。比正文更需要引起注意的内容放这里。}} +
+ + + + + + diff --git a/assets/templates/portfolio-en.html b/assets/templates/portfolio-en.html new file mode 100644 index 0000000..e323da5 --- /dev/null +++ b/assets/templates/portfolio-en.html @@ -0,0 +1,653 @@ + + + + + +{{NAME}} · Portfolio + + + + + + + + + +
+
+ +
{{YEAR_RANGE - e.g. Selected Works 2023–2026}}
+
+ +
+
{{NAME}}
Portfolio
+
{{One-line self-description or portfolio theme.}}
+
+
+ +
+
+ {{DISCIPLINE / ROLE}}
+ {{LOCATION}} +
+
+ {{EMAIL}}
+ {{WEBSITE / SOCIAL}} +
+
+
+ + +
+
About
+
{{Single-line positioning headline.}}
+
+ {{Two or three lines of introduction in serif. Not sales-y. Describe in your own words what you care about and what you do well.}} +
+
+
+

Background

+

{{A paragraph on your past experience.}}

+
+
+

Focus

+

{{A paragraph on your current focus or methodology.}}

+
+
+
+ + +
+
+
01
+
+
{{PROJECT_TYPE - e.g. Product Design / Open Source}}
+
{{PROJECT_NAME}}
+
{{One-line description of what this project did.}}
+
+
{{DATE - e.g. 2025.04 - 2026.02}}
+
+ +
+ {{Tag 1}} + {{Tag 2}} + {{Tag 3}} +
+ +
+ + [hero image placeholder - replace with <img>] +
+ +
+
+

Context

+

{{Why this project? What problem does it solve? Who is the user?}}

+
+
+

Approach

+

{{How it was done - key decisions, design rationale, technical approach.}}

+
+
+

Outcome

+

{{Results - figures, feedback, impact.}}

+
+
+ +
+
+
{{NUMBER}}
+
{{LABEL}}
+
+
+
{{NUMBER}}
+
{{LABEL}}
+
+
+
{{NUMBER}}
+
{{LABEL}}
+
+
+
+ + +
+
+
02
+
+
{{PROJECT_TYPE - e.g. Product Design / Open Source}}
+
{{PROJECT_NAME}}
+
{{One-line description.}}
+
+
{{DATE - e.g. 2025.04 - 2026.02}}
+
+ +
+ {{Tag}} + {{Tag}} +
+ +
+
[left image]
+
[right image]
+
+ +
+
+

Context

+

{{Why this project? What problem does it solve? Who is the user?}}

+
+
+

Approach

+

{{How it was done - key decisions, design rationale, technical approach.}}

+
+
+

Outcome

+

{{Results - figures, feedback, impact.}}

+
+
+
+ + +
+
Selected Works
+ +
+ 2025 +
+
{{WORK_TITLE}}
+
{{One-line description.}}
+
+ +
+ +
+ 2024 +
+
{{WORK_TITLE}}
+
{{One-line description.}}
+
+ +
+ +
+ 2023 +
+
{{WORK_TITLE}}
+
{{One-line description.}}
+
+ +
+
+ + +
+
Let's Talk
+
{{One-line opening - e.g. "Open to new collaborations."}}
+
+
+ +
Phone{{PHONE}}
+
Website{{URL}}
+
{{PLATFORM}}@{{HANDLE}}
+
+
+ + + diff --git a/assets/templates/portfolio-ko.html b/assets/templates/portfolio-ko.html new file mode 100644 index 0000000..325623f --- /dev/null +++ b/assets/templates/portfolio-ko.html @@ -0,0 +1,662 @@ + + + + + +{{이름}} · 포트폴리오 + + + + + + + + + +
+
{{연도 또는 분야 태그 · 예: "Selected Works 2023–2026"}}
+ +
+
{{이름
포트폴리오}}
+
{{한 줄 자기 설명 / 포트폴리오 주제}}
+
+
+ +
+
+ {{전문 분야 / 역할}}
+ {{소재지}} +
+
+ {{EMAIL}}
+ {{웹사이트 / 소셜 링크}} +
+
+
+ + +
+
About
+
{{자기 정의를 담은 한 문장 headline}}
+
+ {{2-3줄의 자기소개 인트로. serif 서체, 너무 홍보성이 되지 않게. + 자신이 무엇에 관심을 갖고 무엇을 잘하는지 자신의 언어로 기술한다.}} +
+
+
+

경력

+

{{과거 경력에 대한 개요.}}

+
+
+

관심사

+

{{현재의 관심사 / 방법론 / 접근 방식.}}

+
+
+
+ + +
+
+
01
+
+
{{프로젝트 유형 · 예: "Product Design" / "Open Source"}}
+
{{프로젝트 이름}}
+
{{이 프로젝트가 무엇을 했는지 한 문장으로}}
+
+
{{기간 · 예: "2025.04 - 2026.02"}}
+
+ +
+ {{태그 1}} + {{태그 2}} + {{태그 3}} +
+ + +
+ + [프로젝트 메인 이미지 자리 · <img src="xxx.png">로 교체] +
+ + +
+
+

Context

+

{{왜 이 프로젝트를 했는가? 어떤 문제를 해결하려 했는가? 누가 사용자인가?}}

+
+
+

Approach

+

{{어떻게 접근했는가? 핵심 의사결정, 설계 고려사항, 기술 방안.}}

+
+
+

Outcome

+

{{결과는 무엇인가? 수치, 피드백, 영향.}}

+
+
+ + +
+
+
{{수치}}
+
{{라벨}}
+
+
+
{{수치}}
+
{{라벨}}
+
+
+
{{수치}}
+
{{라벨}}
+
+
+
+ + +
+
+
02
+
+
{{프로젝트 유형 · 예: "Product Design" / "Open Source"}}
+
{{프로젝트 이름}}
+
{{한 문장 설명}}
+
+
{{기간 · 예: "2025.04 - 2026.02"}}
+
+ +
+ {{태그}} + {{태그}} +
+ + +
+
[좌측 이미지]
+
[우측 이미지]
+
+ +
+
+

Context

+

{{왜 이 프로젝트를 했는가? 어떤 문제를 해결하려 했는가? 누가 사용자인가?}}

+
+
+

Approach

+

{{어떻게 접근했는가? 핵심 의사결정, 설계 고려사항, 기술 방안.}}

+
+
+

Outcome

+

{{결과는 무엇인가? 수치, 피드백, 영향.}}

+
+
+
+ + +
+
추가 작품
+ +
+ 2025 +
+
{{작품 제목}}
+
{{한 줄 설명}}
+
+ +
+ +
+ 2024 +
+
{{작품 제목}}
+
{{한 줄 설명}}
+
+ +
+ +
+ 2023 +
+
{{작품 제목}}
+
{{한 줄 설명}}
+
+ +
+
+ + +
+
Let's Talk
+
{{연락을 환영하는 한 문장 · 예: "새로운 협업을 기대합니다"}}
+
+
+ +
Phone{{전화번호}}
+
Website{{URL}}
+
{{기타 플랫폼}}@{{ID}}
+
+
+ + + diff --git a/assets/templates/portfolio.html b/assets/templates/portfolio.html new file mode 100644 index 0000000..a67c747 --- /dev/null +++ b/assets/templates/portfolio.html @@ -0,0 +1,715 @@ + + + + + +{{名字}} · 作品集 + + + + + + + + + +
+
+ +
{{年份 或 领域标签 · 如 "Selected Works 2023–2026"}}
+
+ +
+
{{名字
作品集}}
+
{{一句自我描述 / 作品集主题}}
+
+
+ +
+
+ {{专业 / 角色}}
+ {{所在地}} +
+
+ {{EMAIL}}
+ {{网站 / 社交链接}} +
+
+
+ + +
+
About
+
{{一句自我定位的 headline}}
+
+ {{2-3 行的自我介绍引言。serif 字体,斜体感,不写太 sales。 + 用自己的语言描述你关心什么、擅长什么。}} +
+
+
+

经历

+

{{一段关于你过往经历的概述。}}

+
+
+

关注

+

{{一段关于你当前的关注点 / 方法论。}}

+
+
+
+ + +
+
+
01
+
+
{{项目类型 · 如 "Product Design" / "Open Source"}}
+
{{项目名称}}
+
{{一句话描述这个项目做了什么}}
+
+
{{时间 · 如 "2025.04 - 2026.02"}}
+
+ +
+ {{标签 1}} + {{标签 2}} + {{标签 3}} +
+ + +
+ + + + + + + + + + + + + + + + 提交请求 + + + + 查询记录 + + + + 返回结果 + + + + 响应数据 + + + + 用户 + + + + API + + + + 数据库 + + +
+ + +
+
+

Context

+

{{为什么做这个项目?要解决什么问题?谁是用户?}}

+
+
+

Approach

+

{{怎么做的?关键决策、设计考量、技术方案。}}

+
+
+

Outcome

+

{{结果是什么?数据、反馈、影响。}}

+
+
+ + +
+
+
{{数字}}
+
{{标签}}
+
+
+
{{数字}}
+
{{标签}}
+
+
+
{{数字}}
+
{{标签}}
+
+
+
+ + +
+
+
02
+
+
{{项目类型 · 如 "Product Design" / "Open Source"}}
+
{{项目名称}}
+
{{一句话描述}}
+
+
{{时间 · 如 "2025.04 - 2026.02"}}
+
+ +
+ {{标签}} + {{标签}} +
+ + +
+
[左图]
+
[右图]
+
+ +
+
+

Context

+

{{为什么做这个项目?要解决什么问题?谁是用户?}}

+
+
+

Approach

+

{{怎么做的?关键决策、设计考量、技术方案。}}

+
+
+

Outcome

+

{{结果是什么?数据、反馈、影响。}}

+
+
+
+ + +
+
更多作品
+ +
+ 2025 +
+
{{作品标题}}
+
{{一句描述}}
+
+ +
+ +
+ 2024 +
+
{{作品标题}}
+
{{一句描述}}
+
+ +
+ +
+ 2023 +
+
{{作品标题}}
+
{{一句描述}}
+
+ +
+
+ + +
+
Let's Talk
+
{{欢迎联系的一句话 · 如 "期待新的合作机会"}}
+
+
+ +
Phone{{电话}}
+
Website{{URL}}
+
{{其他平台}}@{{ID}}
+
+
+ + + diff --git a/assets/templates/resume-en.html b/assets/templates/resume-en.html new file mode 100644 index 0000000..f7377f8 --- /dev/null +++ b/assets/templates/resume-en.html @@ -0,0 +1,745 @@ + + + + +{{NAME}} · Resume + + + + + + + + + + + + + +
+
+
{{NAME}}{{ALIAS_OR_PREFERRED_NAME}}
+
+
+ {{ROLE_TITLE - e.g. AI / Agent Engineer}} +
+ github.com/{{GITHUB_ID}} + · + x.com/{{X_ID}} + · + {{EMAIL}} + · + {{CITY}}, {{COUNTRY_OR_REGION}} +
+
+ + +
+
{{NUMBER}}{{LABEL}}
+
{{NUMBER}}{{LABEL}}
+
{{NUMBER}}{{LABEL}}
+
{{NUMBER}}{{LABEL}}
+
+ +
+
Summary
+
+ {{~50 words. Structure: current role + seniority + tenure. Team composition (size, seniority mix, cross-functional collaborators). Long-term direction. Three to five core areas of depth.}} +
+
+ +
+
Experience{{START_DATE}} - Present · {{KEY_MILESTONE}}
+ + +
+
+
{{YEAR}}
{{STAGE_TITLE}}
+
{{One sentence on what this stage meant.}}
+
+
+
{{YEAR}}
{{STAGE_TITLE}}
+
{{One sentence on what this stage meant.}}
+
+
+
{{YEAR}}
{{STAGE_TITLE}}
+
{{One sentence on what this stage meant.}}
+
+
+ + +
+
+ {{PROJECT_NAME}} + · {{PROJECT_TYPE}} + {{ROLE - e.g. tech lead}} +
+
+
+
Role
+
{{~40 words: what the project is, why it existed, what your seat at the table was.}}
+
+
+
Actions
+
{{~55 words: technical approach, key decisions, execution path.}}
+
+
+
Impact
+
{{~65 words: numbers-first. Highlight 1-2 key figures.}}
+
+
+
+ +
+
+ {{PROJECT_NAME}} + · {{PROJECT_TYPE}} + {{ROLE - e.g. tech lead}} +
+
+
+
Role
+
{{~40 words: what the project is, why it existed, what your seat at the table was.}}
+
+
+
Actions
+
{{~55 words: technical approach, key decisions, execution path.}}
+
+
+
Impact
+
{{~65 words: numbers-first. Highlight 1-2 key figures.}}
+
+
+
+ +
+
+ {{PROJECT_NAME}} + · {{PROJECT_TYPE}} + {{ROLE - e.g. tech lead}} +
+
+
+
Role
+
{{~40 words: what the project is, why it existed, what your seat at the table was.}}
+
+
+
Actions
+
{{~55 words: technical approach, key decisions, execution path.}}
+
+
+
Impact
+
{{~65 words: numbers-first. Highlight 1-2 key figures.}}
+
+
+
+
+ + + + +
+
Open Source & Indie Work{{TIME_RANGE}} · {{SUBTITLE}}
+ +
+ {{One-line positioning statement.}} {{Short sketch of your indie developer identity: design instinct, solo end-to-end delivery, cross-language range, user reception.}} Cumulative GitHub: {{STARS_TOTAL}} stars · {{FORKS_TOTAL}} forks · {{FOLLOWERS_TOTAL}} followers. +
+ +
+
+ {{PROJECT}} + {{Stack + core positioning + platform.}} + ★ {{STARS}} +
+
+ {{PROJECT}} + {{Stack + core positioning + platform.}} + ★ {{STARS}} +
+
+ {{PROJECT}} + {{Stack + core positioning + platform.}} + ★ {{STARS}} +
+
+ {{PROJECT}} + {{Stack + core positioning + platform.}} + ★ {{STARS}} +
+
+ {{PROJECT}} + {{Stack + core positioning + platform.}} + ★ {{STARS}} +
+
+ {{PROJECT}} + {{Stack + core positioning + platform.}} + ★ {{STARS}} +
+
+ +
+ {{HIGHLIGHT}}{{A single anecdote that sets your work apart: the moment it went viral, the notable person who shared it, the community that formed around it.}} +
+
+ +
+
Judgment & Conviction
+
+
+
{{DATE}}{{EVENT_TITLE}}
+
+ {{Specific call you made or action you took, and the downstream evidence that the judgment was right.}} +
+
+
+
{{DATE}}{{EVENT_TITLE}}
+
+ {{Specific call you made or action you took, and the downstream evidence that the judgment was right.}} +
+
+
+
{{DATE}}{{EVENT_TITLE}}
+
+ {{Specific call you made or action you took, and the downstream evidence that the judgment was right.}} +
+
+
+
+ +
+
Public Impact
+ +
+ {{PLATFORM}} · @{{HANDLE}} + {{FOLLOWERS}} followers + {{Blog / newsletter / other content product.}} +
+ +
+
+
Selected writing{{SUBTITLE}}
+
+
+ {{ARTICLE_TITLE}} + {{DATE}} +
+
{{Views / likes / impact metric.}}
+
+
+
+ {{ARTICLE_TITLE}} + {{DATE}} +
+
{{Views / likes / impact metric.}}
+
+
+ +
+
Invited talks{{SUBTITLE}}
+
+
+
{{TALK_TITLE}}
+
{{HOST / VENUE}}
+
+
{{DATE}}
+
+
+
+
{{TALK_TITLE}}
+
{{HOST / VENUE}}
+
+
{{DATE}}
+
+
+
+
+ +
+
Core Skills
+ +
+
{{SKILL_LABEL_1}}
+
{{Description with at least one emphasis.}}
+
+
+
{{SKILL_LABEL_2}}
+
{{Description with at least one emphasis.}}
+
+
+
{{SKILL_LABEL_3}}
+
{{Description with at least one emphasis.}}
+
+
+
{{SKILL_LABEL_4}}
+
{{Description with at least one emphasis.}}
+
+
+
{{SKILL_LABEL_5}}
+
{{Description with at least one emphasis.}}
+
+
+ +
+
Education
+
+
+ {{SCHOOL}} + · {{COLLEGE}} · {{MAJOR}} · {{One-line judgment-flavored note, e.g. "declined grad school, went straight to industry".}} +
+
{{DATE_RANGE}}
+
+
+ + + diff --git a/assets/templates/resume-ko.html b/assets/templates/resume-ko.html new file mode 100644 index 0000000..1bc9eed --- /dev/null +++ b/assets/templates/resume-ko.html @@ -0,0 +1,802 @@ + + + + + +{{성명}} · 이력서 + + + + + + + + + + + + + +
+
+
{{성명}}{{별명/영문명}}
+
+
+ {{포지션, 예: "AI / 에이전트 엔지니어링"}} +
+ GitHub @{{GITHUB_ID}} + · + X @{{X_ID}} + · + {{PHONE}} + · + {{EMAIL}} + · + {{도시}} + +
+
+ + +
+
{{수치}}{{단위}}{{라벨}}
+
{{수치}}{{단위}}{{라벨}}
+
{{수치}}{{단위}}{{라벨}}
+
{{수치}}{{단위}}{{라벨}}
+
+ +
+
자기 소개
+
+ {{80자 이내. 권장 구조: 현재 직책 + 직급 + 재직 기간. 팀 구성(인원, 협업 방식). 장기 발전 방향. 핵심 역량 영역(4-6개).}} +
+
+ +
+
경력{{시작 연도}} - 현재 ({{핵심 이정표}})
+ + +
+
+
{{연도}}
{{단계 제목}}
+
{{이 단계의 의미를 한 문장으로}}
+
+
+
{{연도}}
{{단계 제목}}
+
{{이 단계의 의미를 한 문장으로}}
+
+
+
{{연도}}
{{단계 제목}}
+
{{이 단계의 의미를 한 문장으로}}
+
+
+ + +
+
+ {{프로젝트명}} + · {{프로젝트 유형}} + {{역할, 예: "방향 주도"}} +
+
+
+
역할
+
{{~60자: 프로젝트 개요 + 배경 + 본인 역할}}
+
+
+
행동
+
{{~80자: 기술 방안 / 핵심 의사결정 / 실행 경로}}
+
+
+
결과
+
{{~100자: 수치 중심. 핵심 수치 1-2곳 하이라이트.}}
+
+
+
+ +
+
+ {{프로젝트명}} + · {{프로젝트 유형}} + {{역할, 예: "방향 주도"}} +
+
+
+
역할
+
{{~60자: 프로젝트 개요 + 배경 + 본인 역할}}
+
+
+
행동
+
{{~80자: 기술 방안 / 핵심 의사결정 / 실행 경로}}
+
+
+
결과
+
{{~100자: 수치 중심. 핵심 수치 1-2곳 하이라이트.}}
+
+
+
+ +
+
+ {{프로젝트명}} + · {{프로젝트 유형}} + {{역할, 예: "방향 주도"}} +
+
+
+
역할
+
{{~60자: 프로젝트 개요 + 배경 + 본인 역할}}
+
+
+
행동
+
{{~80자: 기술 방안 / 핵심 의사결정 / 실행 경로}}
+
+
+
결과
+
{{~100자: 수치 중심. 핵심 수치 1-2곳 하이라이트.}}
+
+
+
+
+ + + + +
+
오픈소스 & 독립 개발{{기간}} · {{부제목 한 문장}}
+ +
+ {{자기 정의 한 문장}},{{개발자 정체성 간략 서술: 디자인 감각 / 독립 완성 프로세스 / 다국어 실전 / 사용자 피드백}}. GitHub 누적 {{STARS_TOTAL}} stars · {{FORKS_TOTAL}} forks · {{FOLLOWERS_TOTAL}} followers. +
+ +
+
+ {{프로젝트명}} + {{언어 + 핵심 정의 + 플랫폼}} + ★ {{STARS}} +
+
+ {{프로젝트명}} + {{언어 + 핵심 정의 + 플랫폼}} + ★ {{STARS}} +
+
+ {{프로젝트명}} + {{언어 + 핵심 정의 + 플랫폼}} + ★ {{STARS}} +
+
+ {{프로젝트명}} + {{언어 + 핵심 정의 + 플랫폼}} + ★ {{STARS}} +
+
+ {{프로젝트명}} + {{언어 + 핵심 정의 + 플랫폼}} + ★ {{STARS}} +
+
+ {{프로젝트명}} + {{언어 + 핵심 정의 + 플랫폼}} + ★ {{STARS}} +
+
+ +
+ {{하이라이트 TAG}}{{이 프로젝트의 독특한 스토리: 오픈소스 시점 / 전파 범위 / 유명인 추천 등}} +
+
+ +
+
AI 판단과 행동
+
+
+
{{시기}}{{사건 제목}}
+
+ {{구체적으로 어떤 판단이나 행동을 했는지, 왜 그것이 판단력을 증명하는지}} +
+
+
+
{{시기}}{{사건 제목}}
+
+ {{구체적으로 어떤 판단이나 행동을 했는지, 왜 그것이 판단력을 증명하는지}} +
+
+
+
{{시기}}{{사건 제목}}
+
+ {{구체적으로 어떤 판단이나 행동을 했는지, 왜 그것이 판단력을 증명하는지}} +
+
+
+
+ +
+
외부 영향력
+ +
+ {{플랫폼}} · @{{HANDLE}} + {{팔로워 수}} 팔로워 + {{블로그 / 뉴스레터 / 기타 콘텐츠 소개}} +
+ +
+
+
대표 기술 장문{{부제목}}
+
+
+ 《{{글 제목}}》 + {{날짜}} +
+
{{조회수 / 좋아요 / 영향력 지표}}
+
+
+
+ 《{{글 제목}}》 + {{날짜}} +
+
{{조회수 / 좋아요 / 영향력 지표}}
+
+
+ +
+
초청 강연{{부제목}}
+
+
+
《{{강연 제목}}》
+
{{주최 / 장소}}
+
+
{{날짜}}
+
+
+
+
《{{강연 제목}}》
+
{{주최 / 장소}}
+
+
{{날짜}}
+
+
+
+
+ +
+
핵심 역량
+ +
+
{{역량 1
라벨}}
+
{{설명. 최소 1곳 강조.}}
+
+
+
{{역량 2
라벨}}
+
{{설명. 최소 1곳 강조.}}
+
+
+
{{역량 3
라벨}}
+
{{설명. 최소 1곳 강조.}}
+
+
+
{{역량 4
라벨}}
+
{{설명. 최소 1곳 강조.}}
+
+
+
{{역량 5
라벨}}
+
{{설명. 최소 1곳 강조.}}
+
+
+ +
+
학력
+
+
+ {{학교}} +  · {{단과대학}} · {{전공}} · {{한 줄 판단 설명, 예: "대학원 포기 후 바로 취업"}} +
+
{{재학 기간}}
+
+
+ + + diff --git a/assets/templates/resume.html b/assets/templates/resume.html new file mode 100644 index 0000000..beba50e --- /dev/null +++ b/assets/templates/resume.html @@ -0,0 +1,804 @@ + + + + +{{姓名}} · 简历 + + + + + + + + + + + + + +
+
+
{{姓名}}{{别名/英文名}}
+
+
+ {{岗位定位,如"AI / Agent 工程"}} +
+ GitHub @{{GITHUB_ID}} + · + X @{{X_ID}} + · + {{PHONE}} + · + {{EMAIL}} + · + {{城市}} + +
+
+ + +
+
{{数字}}{{单位}}{{标签}}
+
{{数字}}{{单位}}{{标签}}
+
{{数字}}{{单位}}{{标签}}
+
{{数字}}{{单位}}{{标签}}
+
+ +
+
个人简介
+
+ {{80 字以内。建议结构:现任职位 + 级别 + 时长。团队构成:人数、梯队、协作方。长期演进方向。核心沉淀领域:4-6 个方向。}} +
+
+ +
+
工作经历{{起始时间}} - 至今 · {{关键里程碑}}
+ + +
+
+
{{年份}}
{{阶段标题}}
+
{{一句解释这一步的意义}}
+
+
+
{{年份}}
{{阶段标题}}
+
{{一句解释这一步的意义}}
+
+
+
{{年份}}
{{阶段标题}}
+
{{一句解释这一步的意义}}
+
+
+ + +
+
+ {{项目名}} + · {{项目类型}} + {{角色定位,如"方向主导"}} +
+
+
+
角色
+
{{~60 字:项目是什么 + 为什么做 + 你的位置}}
+
+
+
动作
+
{{~80 字:技术方案 / 关键决策 / 执行路径}}
+
+
+
结果
+
{{~100 字:数据为王。关键数字 高亮 1-2 处。}}
+
+
+
+ +
+
+ {{项目名}} + · {{项目类型}} + {{角色定位,如"方向主导"}} +
+
+
+
角色
+
{{~60 字:项目是什么 + 为什么做 + 你的位置}}
+
+
+
动作
+
{{~80 字:技术方案 / 关键决策 / 执行路径}}
+
+
+
结果
+
{{~100 字:数据为王。关键数字 高亮 1-2 处。}}
+
+
+
+ +
+
+ {{项目名}} + · {{项目类型}} + {{角色定位,如"方向主导"}} +
+
+
+
角色
+
{{~60 字:项目是什么 + 为什么做 + 你的位置}}
+
+
+
动作
+
{{~80 字:技术方案 / 关键决策 / 执行路径}}
+
+
+
结果
+
{{~100 字:数据为王。关键数字 高亮 1-2 处。}}
+
+
+
+
+ + + + +
+
开源项目 & 独立开发者{{时间跨度}} · {{一句副标题}}
+ +
+ {{一句自我定位}},{{简述开发者身份:设计审美 / 独立完成流程 / 跨语言实战 / 用户反馈}}。GitHub 累计 {{STARS_TOTAL}} stars · {{FORKS_TOTAL}} forks · {{FOLLOWERS_TOTAL}} followers。 +
+ +
+
+ {{项目名}} + {{语言 + 核心定位 + 平台}} + ★ {{STARS}} +
+
+ {{项目名}} + {{语言 + 核心定位 + 平台}} + ★ {{STARS}} +
+
+ {{项目名}} + {{语言 + 核心定位 + 平台}} + ★ {{STARS}} +
+
+ {{项目名}} + {{语言 + 核心定位 + 平台}} + ★ {{STARS}} +
+
+ {{项目名}} + {{语言 + 核心定位 + 平台}} + ★ {{STARS}} +
+
+ {{项目名}} + {{语言 + 核心定位 + 平台}} + ★ {{STARS}} +
+
+ +
+ {{亮点 TAG}}{{这个项目的独特故事:开源时机 / 传播范围 / 知名人物推荐等}} +
+
+ +
+
AI 判断与行动
+
+
+
{{时间}}{{事件标题}}
+
+ {{具体做了什么判断或行动,为什么证明判断力}} +
+
+
+
{{时间}}{{事件标题}}
+
+ {{具体做了什么判断或行动,为什么证明判断力}} +
+
+
+
{{时间}}{{事件标题}}
+
+ {{具体做了什么判断或行动,为什么证明判断力}} +
+
+
+
+ +
+
对外影响力
+ +
+ {{平台}} · @{{HANDLE}} + {{粉丝数}} 粉丝 + {{博客 / 周刊 / 其他内容产品简介}} +
+ +
+
+
代表性技术长文{{副标题}}
+
+
+ 《{{文章标题}}》 + {{日期}} +
+
{{浏览量 / 赞数 / 影响力指标}}
+
+
+
+ 《{{文章标题}}》 + {{日期}} +
+
{{浏览量 / 赞数 / 影响力指标}}
+
+
+ +
+
受邀演讲{{副标题}}
+
+
+
《{{演讲标题}}》
+
{{主办方 / 地点}}
+
+
{{日期}}
+
+
+
+
《{{演讲标题}}》
+
{{主办方 / 地点}}
+
+
{{日期}}
+
+
+
+
+ +
+
核心能力
+ +
+
{{能力 1
标签}}
+
{{描述。至少 1 处强调}}
+
+
+
{{能力 2
标签}}
+
{{描述。至少 1 处强调}}
+
+
+
{{能力 3
标签}}
+
{{描述。至少 1 处强调}}
+
+
+
{{能力 4
标签}}
+
{{描述。至少 1 处强调}}
+
+
+
{{能力 5
标签}}
+
{{描述。至少 1 处强调}}
+
+
+ +
+
教育背景
+
+
+ {{学校}} +  · {{学院}} · {{专业}} · {{一句判断性描述,如"放弃保研直接就业"}} +
+
{{起止时间}}
+
+
+ + + diff --git a/assets/templates/slides-en.py b/assets/templates/slides-en.py new file mode 100644 index 0000000..22513c3 --- /dev/null +++ b/assets/templates/slides-en.py @@ -0,0 +1,375 @@ +#!/usr/bin/env python3 +""" +slides-en.py - parchment design system, English slide deck generator. + +Usage: + pip install python-pptx --break-system-packages + python3 slides-en.py + +Output: + output.pptx (16:9, parchment aesthetic, Charter serif) + +This is a template. Fill in your content and run it directly. +""" + +from pptx import Presentation +from pptx.util import Inches, Pt +from pptx.dml.color import RGBColor +from pptx.enum.shapes import MSO_SHAPE +from pptx.enum.text import PP_ALIGN, MSO_ANCHOR + +# ═══════════════════════════════════════════════════════════ +# Design system constants +# ═══════════════════════════════════════════════════════════ + +PARCHMENT = RGBColor(0xf5, 0xf4, 0xed) +IVORY = RGBColor(0xfa, 0xf9, 0xf5) +BRAND = RGBColor(0x1B, 0x36, 0x5D) +BRAND_DEEP = RGBColor(0x1B, 0x36, 0x5D) +NEAR_BLACK = RGBColor(0x14, 0x14, 0x13) +DARK_WARM = RGBColor(0x3d, 0x3d, 0x3a) +CHARCOAL = RGBColor(0x4d, 0x4c, 0x48) +OLIVE = RGBColor(0x50, 0x4e, 0x49) +STONE = RGBColor(0x6b, 0x6a, 0x64) +BORDER = RGBColor(0xe8, 0xe6, 0xdc) +WHITE = RGBColor(0xff, 0xff, 0xff) + +# English Silicon Valley stack. PowerPoint falls back silently if the +# primary face is not installed on the viewing machine. +SERIF = "Charter" +SANS = SERIF + +SLIDE_W = Inches(13.33) +SLIDE_H = Inches(7.5) + + +# ═══════════════════════════════════════════════════════════ +# Helpers +# ═══════════════════════════════════════════════════════════ + +def blank_slide(prs, bg_color=PARCHMENT): + slide = prs.slides.add_slide(prs.slide_layouts[6]) + bg = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, + 0, 0, prs.slide_width, prs.slide_height) + bg.fill.solid() + bg.fill.fore_color.rgb = bg_color + bg.line.fill.background() + bg.shadow.inherit = False + return slide + + +def add_text(slide, text, left, top, width, height, + font=SANS, size=18, bold=False, italic=False, + color=NEAR_BLACK, align=PP_ALIGN.LEFT, + vanchor=MSO_ANCHOR.TOP): + tb = slide.shapes.add_textbox(left, top, width, height) + tf = tb.text_frame + tf.word_wrap = True + tf.margin_left = tf.margin_right = 0 + tf.margin_top = tf.margin_bottom = 0 + tf.vertical_anchor = vanchor + p = tf.paragraphs[0] + p.alignment = align + run = p.add_run() + run.text = text + run.font.name = font + run.font.size = Pt(size) + run.font.bold = bold + run.font.italic = italic + run.font.color.rgb = color + return tb + + +def add_line(slide, left, top, width, color=BRAND, weight_pt=1): + line = slide.shapes.add_connector(1, left, top, left + width, top) + line.line.color.rgb = color + line.line.width = Pt(weight_pt) + return line + + +def add_card(slide, left, top, width, height, + fill=IVORY, border=BORDER, border_weight=0.5): + card = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, + left, top, width, height) + card.fill.solid() + card.fill.fore_color.rgb = fill + card.line.color.rgb = border + card.line.width = Pt(border_weight) + card.shadow.inherit = False + return card + + +# ═══════════════════════════════════════════════════════════ +# Slide templates +# ═══════════════════════════════════════════════════════════ + +def cover_slide(prs, title, subtitle, author, date): + s = blank_slide(prs) + add_text(s, title, + Inches(1), Inches(2.5), Inches(11.33), Inches(1.5), + font=SERIF, size=48, color=NEAR_BLACK, + align=PP_ALIGN.CENTER) + add_line(s, Inches(6.17), Inches(4.3), Inches(1), weight_pt=1.5) + add_text(s, subtitle, + Inches(1), Inches(4.6), Inches(11.33), Inches(0.8), + font=SANS, size=18, color=OLIVE, + align=PP_ALIGN.CENTER) + add_text(s, f"{author} · {date}", + Inches(1), Inches(6.5), Inches(11.33), Inches(0.4), + font=SANS, size=13, color=STONE, + align=PP_ALIGN.CENTER) + return s + + +def toc_slide(prs, items): + s = blank_slide(prs) + add_text(s, "Contents", + Inches(1.2), Inches(0.8), Inches(10), Inches(0.8), + font=SERIF, size=34, color=NEAR_BLACK) + add_line(s, Inches(1.2), Inches(1.8), Inches(11), weight_pt=1) + + for i, item in enumerate(items): + y = Inches(2.4 + i * 0.9) + add_text(s, f"0{i+1}", + Inches(1.2), y, Inches(1), Inches(0.6), + font=SERIF, size=28, color=BRAND) + add_text(s, item, + Inches(2.4), y, Inches(9), Inches(0.6), + font=SERIF, size=22, color=NEAR_BLACK, + vanchor=MSO_ANCHOR.MIDDLE) + return s + + +def chapter_slide(prs, number, title): + s = blank_slide(prs, bg_color=BRAND) + add_text(s, f"0{number}", + Inches(0.8), Inches(0.5), Inches(2), Inches(0.8), + font=SERIF, size=28, color=WHITE) + add_text(s, title, + Inches(1), Inches(3), Inches(11.33), Inches(1.5), + font=SERIF, size=60, color=WHITE, + align=PP_ALIGN.CENTER) + return s + + +def content_slide(prs, eyebrow, title, body, page_num=None): + s = blank_slide(prs) + add_text(s, eyebrow.upper(), + Inches(1.2), Inches(0.6), Inches(10), Inches(0.4), + font=SANS, size=11, color=STONE) + add_text(s, title, + Inches(1.2), Inches(1.2), Inches(11.33), Inches(1.2), + font=SERIF, size=34, color=NEAR_BLACK) + add_text(s, body, + Inches(1.2), Inches(3), Inches(11), Inches(3.5), + font=SANS, size=18, color=DARK_WARM) + if page_num is not None: + add_text(s, f" - {page_num:02d}", + Inches(11.5), Inches(6.9), Inches(1.5), Inches(0.3), + font=SANS, size=11, color=STONE, + align=PP_ALIGN.RIGHT) + return s + + +def metrics_slide(prs, title, metrics): + """metrics: [(value, label), ...]""" + s = blank_slide(prs) + add_text(s, title, + Inches(1.2), Inches(0.8), Inches(11), Inches(1), + font=SERIF, size=30, color=NEAR_BLACK, + align=PP_ALIGN.CENTER) + add_line(s, Inches(6.17), Inches(2), Inches(1)) + + n = len(metrics) + card_w = Inches(2.8) + gap = Inches(0.3) + total_w = card_w * n + gap * (n - 1) + start = (SLIDE_W - total_w) / 2 + + for i, (value, label) in enumerate(metrics): + x = start + (card_w + gap) * i + add_text(s, value, + x, Inches(3), card_w, Inches(1.5), + font=SERIF, size=56, color=BRAND, + align=PP_ALIGN.CENTER) + add_text(s, label, + x, Inches(4.8), card_w, Inches(0.6), + font=SANS, size=14, color=OLIVE, + align=PP_ALIGN.CENTER) + return s + + +def quote_slide(prs, quote, source): + s = blank_slide(prs) + add_text(s, f"\u201c{quote}\u201d", + Inches(1.5), Inches(2.8), Inches(10.33), Inches(2.5), + font=SERIF, size=30, color=NEAR_BLACK, + align=PP_ALIGN.CENTER, + vanchor=MSO_ANCHOR.MIDDLE) + add_text(s, f" - {source}", + Inches(1.5), Inches(5.2), Inches(10.33), Inches(0.4), + font=SANS, size=14, color=OLIVE, + align=PP_ALIGN.CENTER) + return s + + +def comparison_slide(prs, eyebrow, left_title, left_items, right_title, right_items, page_num=None): + """Before/After two-column layout. Divider is warm gray. Left column is muted, right is full-weight.""" + s = blank_slide(prs) + add_text(s, eyebrow.upper(), + Inches(1.2), Inches(0.6), Inches(10), Inches(0.4), + font=SANS, size=11, color=STONE) + divider = s.shapes.add_connector(1, + Inches(6.67), Inches(1.0), + Inches(6.67), Inches(6.8)) + divider.line.color.rgb = BORDER + divider.line.width = Pt(1) + add_text(s, left_title, + Inches(1.2), Inches(1.2), Inches(5), Inches(0.8), + font=SERIF, size=22, color=OLIVE) + add_text(s, right_title, + Inches(7.0), Inches(1.2), Inches(5), Inches(0.8), + font=SERIF, size=22, color=NEAR_BLACK) + add_line(s, Inches(1.2), Inches(2.2), Inches(11.5), weight_pt=0.5) + for i, item in enumerate(left_items[:4]): + add_text(s, item, + Inches(1.2), Inches(2.6 + i * 0.9), Inches(4.9), Inches(0.7), + font=SANS, size=17, color=STONE) + for i, item in enumerate(right_items[:4]): + add_text(s, item, + Inches(7.0), Inches(2.6 + i * 0.9), Inches(5.2), Inches(0.7), + font=SANS, size=17, color=DARK_WARM) + if page_num is not None: + add_text(s, f" - {page_num:02d}", + Inches(11.5), Inches(6.9), Inches(1.5), Inches(0.3), + font=SANS, size=11, color=STONE, + align=PP_ALIGN.RIGHT) + return s + + +def pipeline_slide(prs, eyebrow, title, steps, page_num=None): + """Numbered process steps: 01/02/03 serif numerals + step title + description. + steps: list of (step_title, step_desc), max 4 steps. + """ + s = blank_slide(prs) + add_text(s, eyebrow.upper(), + Inches(1.2), Inches(0.6), Inches(10), Inches(0.4), + font=SANS, size=11, color=STONE) + add_text(s, title, + Inches(1.2), Inches(1.1), Inches(11), Inches(0.9), + font=SERIF, size=32, color=NEAR_BLACK) + add_line(s, Inches(1.2), Inches(2.15), Inches(11), weight_pt=0.5) + + n = len(steps[:4]) + step_w = Inches(11.5 / n) + for i, (step_title, step_desc) in enumerate(steps[:4]): + x = Inches(1.0) + step_w * i + add_text(s, f"0{i+1}", + x, Inches(2.5), step_w, Inches(0.8), + font=SERIF, size=42, color=BRAND) + add_text(s, step_title, + x, Inches(3.45), step_w - Inches(0.2), Inches(0.6), + font=SERIF, size=19, color=NEAR_BLACK) + add_text(s, step_desc, + x, Inches(4.15), step_w - Inches(0.2), Inches(2.2), + font=SANS, size=15, color=OLIVE) + if page_num is not None: + add_text(s, f" - {page_num:02d}", + Inches(11.5), Inches(6.9), Inches(1.5), Inches(0.3), + font=SANS, size=11, color=STONE, + align=PP_ALIGN.RIGHT) + return s + + +def ending_slide(prs, message, contact): + s = blank_slide(prs) + add_text(s, message, + Inches(1), Inches(3), Inches(11.33), Inches(1.2), + font=SERIF, size=44, color=NEAR_BLACK, + align=PP_ALIGN.CENTER) + add_line(s, Inches(6.17), Inches(4.5), Inches(1), weight_pt=1.5) + add_text(s, contact, + Inches(1), Inches(4.8), Inches(11.33), Inches(0.6), + font=SANS, size=16, color=OLIVE, + align=PP_ALIGN.CENTER) + return s + + +# ═══════════════════════════════════════════════════════════ +# Main - example deck, replace with your content +# ═══════════════════════════════════════════════════════════ + +def main(): + import argparse + parser = argparse.ArgumentParser() + parser.add_argument("--out", default="output.pptx", + help="Output PPTX path (default: output.pptx in cwd)") + args = parser.parse_args() + + prs = Presentation() + prs.slide_width = SLIDE_W + prs.slide_height = SLIDE_H + + cover_slide(prs, + title="{{DOCUMENT_TITLE}}", + subtitle="{{One-line description.}}", + author="{{AUTHOR}}", + date="2026.04") + + toc_slide(prs, items=[ + "{{Chapter 1}}", + "{{Chapter 2}}", + "{{Chapter 3}}", + "Q&A", + ]) + + chapter_slide(prs, 1, "{{Chapter Title}}") + + content_slide(prs, + eyebrow="{{Chapter · This page}}", + title="{{Core claim as a sentence}}", + body=("{{A short body paragraph, 18pt sans. Keep it under three lines. " + "One slide, one core idea. The reader's attention is the scarce resource.}}"), + page_num=5) + + metrics_slide(prs, + title="Key Results", + metrics=[ + ("+42%", "Conversion lift"), + ("3.8M", "Monthly actives"), + ("99.9%", "Availability SLA"), + ("5,000+", "QPS peak"), + ]) + + quote_slide(prs, + quote="Good design is as little design as possible.", + source="Dieter Rams") + + comparison_slide(prs, + eyebrow="{{Chapter · Comparison}}", + left_title="{{Before}}", + left_items=["{{Point A}}", "{{Point B}}", "{{Point C}}"], + right_title="{{After}}", + right_items=["{{Improvement A}}", "{{Improvement B}}", "{{Improvement C}}"], + page_num=8) + + pipeline_slide(prs, + eyebrow="{{Chapter · Process}}", + title="{{Process headline as a declarative sentence}}", + steps=[ + ("{{Step 1}}", "{{Short description of step 1. Keep to two lines.}}"), + ("{{Step 2}}", "{{Short description of step 2. Keep to two lines.}}"), + ("{{Step 3}}", "{{Short description of step 3. Keep to two lines.}}"), + ], + page_num=9) + + ending_slide(prs, + message="Thank you", + contact="{{EMAIL}} · {{WEBSITE}}") + + prs.save(args.out) + print(f"OK: Saved {args.out}") + + +if __name__ == '__main__': + main() diff --git a/assets/templates/slides-weasy-en.html b/assets/templates/slides-weasy-en.html new file mode 100644 index 0000000..cca32f0 --- /dev/null +++ b/assets/templates/slides-weasy-en.html @@ -0,0 +1,360 @@ + + + + +{{Title}} + + + + + +
+ +

{{Title}}

+
{{Subtitle}}
+
{{Author}} · {{Date}}
+
+ + +
+ 01 · {{Section}} +

{{Page Title}}

+

{{Lead paragraph}}

+ +
+
+

{{Left heading}}

+

{{Left body}}

+
+
+

{{Right heading}}

+

{{Right body}}

+
+
+ +
01
+ +
+ + +
+ 02 · {{Section}} +

{{Page Title}}

+ + + + + + + + + + +
+
A{{Module title}}
+

{{Module body}}

+
+
B{{Module title}}
+

{{Module body}}

+
+
C{{Module title}}
+

{{Module body}}

+
+
D{{Module title}}
+

{{Module body}}

+
+ +
02
+ +
+ + +
+ 03 · {{Section}} +

{{Page Title}}

+ +

{{Body content}}

+ +
{{Key takeaway or bottom-line conclusion}}
+ +
03
+ +
+ + +
+ 04 · {{Section}} +

{{Page Title}}

+ + + + + + + + + + + + +
{{Dimension}}{{Value}}{{Note}}
{{Dimension}}{{Value}}{{Note}}
+ +
04
+ +
+ + + diff --git a/assets/templates/slides-weasy-ko.html b/assets/templates/slides-weasy-ko.html new file mode 100644 index 0000000..17de4ce --- /dev/null +++ b/assets/templates/slides-weasy-ko.html @@ -0,0 +1,368 @@ + + + + + +{{제목}} + + + + + +
+

{{제목}}

+
{{부제목}}
+
{{발표자}} · {{날짜}}
+
+ + +
+ 01 · {{챕터}} +

{{슬라이드 제목}}

+

{{도입 문장}}

+ +
+
+

{{좌측 제목}}

+

{{좌측 내용}}

+
+
+

{{우측 제목}}

+

{{우측 내용}}

+
+
+ +
01
+ +
+ + +
+ 02 · {{챕터}} +

{{슬라이드 제목}}

+ + + + + + + + + + +
+
A{{모듈 제목}}
+

{{모듈 설명}}

+
+
B{{모듈 제목}}
+

{{모듈 설명}}

+
+
C{{모듈 제목}}
+

{{모듈 설명}}

+
+
D{{모듈 제목}}
+

{{모듈 설명}}

+
+ +
02
+ +
+ + +
+ 03 · {{챕터}} +

{{슬라이드 제목}}

+ +

{{본문 내용}}

+ +
{{하단 결론 또는 핵심 판단}}
+ +
03
+ +
+ + +
+ 04 · {{챕터}} +

{{슬라이드 제목}}

+ + + + + + + + + + + + +
{{항목}}{{내용}}{{비고}}
{{항목}}{{내용}}{{비고}}
+ +
04
+ +
+ + + diff --git a/assets/templates/slides-weasy.html b/assets/templates/slides-weasy.html new file mode 100644 index 0000000..961df94 --- /dev/null +++ b/assets/templates/slides-weasy.html @@ -0,0 +1,427 @@ + + + + +{{标题}} + + + + + +
+ +

{{标题}}

+
{{副标题}}
+
{{作者}} · {{日期}}
+
+ + +
+ 01 · {{章节}} +

{{页面标题}}

+

{{引导语}}

+ +
+
+

{{左栏标题}}

+

{{左栏内容}}

+
+
+

{{右栏标题}}

+

{{右栏内容}}

+
+
+ +
01
+ +
+ + +
+ 02 · {{章节}} +

{{页面标题}}

+ + + + + + + + + + +
+
A{{模块标题}}
+

{{模块描述}}

+
+
B{{模块标题}}
+

{{模块描述}}

+
+
C{{模块标题}}
+

{{模块描述}}

+
+
D{{模块标题}}
+

{{模块描述}}

+
+ +
02
+ +
+ + +
+ 03 · {{章节}} +

{{页面标题}}

+ +

{{正文内容}}

+ +
{{底部结论或关键判断}}
+ +
03
+ +
+ + +
+ 04 · {{章节}} +

{{页面标题}}

+ + + + + + + + + + + + +
{{维度}}{{内容}}{{备注}}
{{维度}}{{内容}}{{备注}}
+ +
04
+ +
+ + +
+ 05 · {{章节}} +

{{页面标题}}

+ +
+ + + + + + + + + + + + + + + + 提交请求 + + + + 查询记录 + + + + 返回结果 + + + + 响应数据 + + + + 用户 + + + + API + + + + 数据库 + + +
+ +
05
+ +
+ + + diff --git a/assets/templates/slides.py b/assets/templates/slides.py new file mode 100644 index 0000000..c6868f8 --- /dev/null +++ b/assets/templates/slides.py @@ -0,0 +1,424 @@ +#!/usr/bin/env python3 +""" +gen_slides.py - parchment design system slide deck generator + +用法: + pip install python-pptx --break-system-packages + python3 gen_slides.py + +输出: + output.pptx (16:9 宽屏, parchment 风格) + +这是一个模板脚本 - 填充自己的内容后直接运行。 +""" + +from pptx import Presentation +from pptx.util import Inches, Pt +from pptx.dml.color import RGBColor +from pptx.enum.shapes import MSO_SHAPE +from pptx.enum.text import PP_ALIGN, MSO_ANCHOR + +# ═══════════════════════════════════════════════════════════ +# Design System Constants +# ═══════════════════════════════════════════════════════════ + +# 色板 +PARCHMENT = RGBColor(0xf5, 0xf4, 0xed) +IVORY = RGBColor(0xfa, 0xf9, 0xf5) +BRAND = RGBColor(0x1B, 0x36, 0x5D) +BRAND_DEEP = RGBColor(0x1B, 0x36, 0x5D) +NEAR_BLACK = RGBColor(0x14, 0x14, 0x13) +DARK_WARM = RGBColor(0x3d, 0x3d, 0x3a) +CHARCOAL = RGBColor(0x4d, 0x4c, 0x48) +OLIVE = RGBColor(0x50, 0x4e, 0x49) +STONE = RGBColor(0x6b, 0x6a, 0x64) +BORDER = RGBColor(0xe8, 0xe6, 0xdc) +WHITE = RGBColor(0xff, 0xff, 0xff) + +# Fonts. Single serif per page. PPT falls back on the viewer's system. +# For Japanese best-effort output, set LANG = "ja" before generating. +LANG = "zh" +CN_SERIF = "Source Han Serif SC" +JA_SERIF = "YuMincho" # Windows: Yu Mincho; Linux: Noto Serif CJK JP + +SERIF = JA_SERIF if LANG == "ja" else CN_SERIF +SANS = SERIF + +# 16:9 宽屏 +SLIDE_W = Inches(13.33) +SLIDE_H = Inches(7.5) + + +# ═══════════════════════════════════════════════════════════ +# Helpers +# ═══════════════════════════════════════════════════════════ + +def blank_slide(prs, bg_color=PARCHMENT): + """创建空白幻灯片,指定背景色""" + slide = prs.slides.add_slide(prs.slide_layouts[6]) # 6 = Blank + bg = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, + 0, 0, prs.slide_width, prs.slide_height) + bg.fill.solid() + bg.fill.fore_color.rgb = bg_color + bg.line.fill.background() + bg.shadow.inherit = False + return slide + + +def add_text(slide, text, left, top, width, height, + font=SANS, size=18, bold=False, italic=False, + color=NEAR_BLACK, align=PP_ALIGN.LEFT, + vanchor=MSO_ANCHOR.TOP): + """加一段文字""" + tb = slide.shapes.add_textbox(left, top, width, height) + tf = tb.text_frame + tf.word_wrap = True + tf.margin_left = tf.margin_right = 0 + tf.margin_top = tf.margin_bottom = 0 + tf.vertical_anchor = vanchor + p = tf.paragraphs[0] + p.alignment = align + run = p.add_run() + run.text = text + run.font.name = font + run.font.size = Pt(size) + run.font.bold = bold + run.font.italic = italic + run.font.color.rgb = color + return tb + + +def add_line(slide, left, top, width, color=BRAND, weight_pt=1): + """加水平线""" + line = slide.shapes.add_connector(1, left, top, left + width, top) + line.line.color.rgb = color + line.line.width = Pt(weight_pt) + return line + + +def add_card(slide, left, top, width, height, + fill=IVORY, border=BORDER, border_weight=0.5): + """加卡片背景""" + card = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, + left, top, width, height) + card.fill.solid() + card.fill.fore_color.rgb = fill + card.line.color.rgb = border + card.line.width = Pt(border_weight) + card.shadow.inherit = False + return card + + +# ═══════════════════════════════════════════════════════════ +# Slide Templates +# ═══════════════════════════════════════════════════════════ + +def cover_slide(prs, title, subtitle, author, date): + """封面:大标题 + 副标题 + 作者/日期""" + s = blank_slide(prs) + # 大标题(serif 44pt 居中) + add_text(s, title, + Inches(1), Inches(2.5), Inches(11.33), Inches(1.5), + font=SERIF, size=44, color=NEAR_BLACK, + align=PP_ALIGN.CENTER) + # 品牌色短线 + add_line(s, Inches(6.17), Inches(4.2), Inches(1), weight_pt=1.5) + # 副标题 + add_text(s, subtitle, + Inches(1), Inches(4.5), Inches(11.33), Inches(0.8), + font=SANS, size=18, color=OLIVE, + align=PP_ALIGN.CENTER) + # 作者 + 日期 + add_text(s, f"{author} · {date}", + Inches(1), Inches(6.5), Inches(11.33), Inches(0.4), + font=SANS, size=13, color=STONE, + align=PP_ALIGN.CENTER) + return s + + +def toc_slide(prs, items): + """目录页:01 章节名 列表""" + s = blank_slide(prs) + add_text(s, "目录", + Inches(1.2), Inches(0.8), Inches(10), Inches(0.8), + font=SERIF, size=32, color=NEAR_BLACK) + add_line(s, Inches(1.2), Inches(1.8), Inches(11), weight_pt=1) + + for i, item in enumerate(items): + y = Inches(2.4 + i * 0.9) + add_text(s, f"0{i+1}", + Inches(1.2), y, Inches(1), Inches(0.6), + font=SERIF, size=28, color=BRAND) + add_text(s, item, + Inches(2.4), y, Inches(9), Inches(0.6), + font=SERIF, size=22, color=NEAR_BLACK, + vanchor=MSO_ANCHOR.MIDDLE) + return s + + +def chapter_slide(prs, number, title): + """章节首页:油墨蓝色背景 + 居中大标题""" + s = blank_slide(prs, bg_color=BRAND) + add_text(s, f"0{number}", + Inches(0.8), Inches(0.5), Inches(2), Inches(0.8), + font=SERIF, size=26, color=WHITE) + add_text(s, title, + Inches(1), Inches(3), Inches(11.33), Inches(1.5), + font=SERIF, size=56, color=WHITE, + align=PP_ALIGN.CENTER) + return s + + +def content_slide(prs, eyebrow, title, body, page_num=None): + """内容页:小标题 + 大标题 + 正文""" + s = blank_slide(prs) + # eyebrow + add_text(s, eyebrow, + Inches(1.2), Inches(0.6), Inches(10), Inches(0.4), + font=SANS, size=12, color=STONE) + # title + add_text(s, title, + Inches(1.2), Inches(1.2), Inches(11.33), Inches(1.2), + font=SERIF, size=32, color=NEAR_BLACK) + # body + add_text(s, body, + Inches(1.2), Inches(3), Inches(11), Inches(3.5), + font=SANS, size=18, color=DARK_WARM) + # page number + if page_num is not None: + add_text(s, f" - {page_num:02d}", + Inches(11.5), Inches(6.9), Inches(1.5), Inches(0.3), + font=SANS, size=11, color=STONE, + align=PP_ALIGN.RIGHT) + return s + + +def metrics_slide(prs, title, metrics): + """数据页:标题 + N 张数据卡并排 + metrics: [(value, label), ...] + """ + s = blank_slide(prs) + # 标题 + add_text(s, title, + Inches(1.2), Inches(0.8), Inches(11), Inches(1), + font=SERIF, size=28, color=NEAR_BLACK, + align=PP_ALIGN.CENTER) + add_line(s, Inches(6.17), Inches(2), Inches(1)) + + # 数据卡 + n = len(metrics) + card_w = Inches(2.8) + gap = Inches(0.3) + total_w = card_w * n + gap * (n - 1) + start = (SLIDE_W - total_w) / 2 + + for i, (value, label) in enumerate(metrics): + x = start + (card_w + gap) * i + # 大数字 + add_text(s, value, + x, Inches(3), card_w, Inches(1.5), + font=SERIF, size=52, color=BRAND, + align=PP_ALIGN.CENTER) + # 标签 + add_text(s, label, + x, Inches(4.8), card_w, Inches(0.6), + font=SANS, size=14, color=OLIVE, + align=PP_ALIGN.CENTER) + return s + + +def quote_slide(prs, quote, source): + """引用页:极简,居中引文""" + s = blank_slide(prs) + add_text(s, f"\u201c{quote}\u201d", + Inches(1.5), Inches(2.8), Inches(10.33), Inches(2.5), + font=SERIF, size=28, color=NEAR_BLACK, + align=PP_ALIGN.CENTER, + vanchor=MSO_ANCHOR.MIDDLE) + add_text(s, f" - {source}", + Inches(1.5), Inches(5.2), Inches(10.33), Inches(0.4), + font=SANS, size=14, color=OLIVE, + align=PP_ALIGN.CENTER) + return s + + +def comparison_slide(prs, eyebrow, left_title, left_items, right_title, right_items, page_num=None): + """对比页:左右两栏,竖线分隔,左侧降调,右侧全色 + left_items / right_items: list of str (最多 4 条) + """ + s = blank_slide(prs) + add_text(s, eyebrow, + Inches(1.2), Inches(0.6), Inches(10), Inches(0.4), + font=SANS, size=12, color=STONE) + # 分隔竖线(居中) + divider = s.shapes.add_connector(1, + Inches(6.67), Inches(1.0), + Inches(6.67), Inches(6.8)) + divider.line.color.rgb = BORDER + divider.line.width = Pt(1) + # 左栏标题(降调) + add_text(s, left_title, + Inches(1.2), Inches(1.2), Inches(5), Inches(0.8), + font=SERIF, size=22, color=OLIVE) + # 右栏标题(全色) + add_text(s, right_title, + Inches(7.0), Inches(1.2), Inches(5), Inches(0.8), + font=SERIF, size=22, color=NEAR_BLACK) + # 分隔线 + add_line(s, Inches(1.2), Inches(2.2), Inches(11.5), weight_pt=0.5) + # 左栏条目(降调) + for i, item in enumerate(left_items[:4]): + add_text(s, item, + Inches(1.2), Inches(2.6 + i * 0.9), Inches(4.9), Inches(0.7), + font=SANS, size=17, color=STONE) + # 右栏条目(全色) + for i, item in enumerate(right_items[:4]): + add_text(s, item, + Inches(7.0), Inches(2.6 + i * 0.9), Inches(5.2), Inches(0.7), + font=SANS, size=17, color=DARK_WARM) + if page_num is not None: + add_text(s, f" - {page_num:02d}", + Inches(11.5), Inches(6.9), Inches(1.5), Inches(0.3), + font=SANS, size=11, color=STONE, + align=PP_ALIGN.RIGHT) + return s + + +def pipeline_slide(prs, eyebrow, title, steps, page_num=None): + """流程步骤页:01/02/03 序号 + 步骤标题 + 步骤描述 + steps: list of (step_title, step_desc),最多 4 步 + """ + s = blank_slide(prs) + add_text(s, eyebrow, + Inches(1.2), Inches(0.6), Inches(10), Inches(0.4), + font=SANS, size=12, color=STONE) + add_text(s, title, + Inches(1.2), Inches(1.1), Inches(11), Inches(0.9), + font=SERIF, size=30, color=NEAR_BLACK) + add_line(s, Inches(1.2), Inches(2.15), Inches(11), weight_pt=0.5) + + n = len(steps[:4]) + step_w = Inches(11.5 / n) + for i, (step_title, step_desc) in enumerate(steps[:4]): + x = Inches(1.0) + step_w * i + # 序号 + add_text(s, f"0{i+1}", + x, Inches(2.5), step_w, Inches(0.8), + font=SERIF, size=40, color=BRAND) + # 步骤标题 + add_text(s, step_title, + x, Inches(3.45), step_w - Inches(0.2), Inches(0.6), + font=SERIF, size=19, color=NEAR_BLACK) + # 步骤描述 + add_text(s, step_desc, + x, Inches(4.15), step_w - Inches(0.2), Inches(2.2), + font=SANS, size=15, color=OLIVE) + if page_num is not None: + add_text(s, f" - {page_num:02d}", + Inches(11.5), Inches(6.9), Inches(1.5), Inches(0.3), + font=SANS, size=11, color=STONE, + align=PP_ALIGN.RIGHT) + return s + + +def ending_slide(prs, message, contact): + """结束页""" + s = blank_slide(prs) + add_text(s, message, + Inches(1), Inches(3), Inches(11.33), Inches(1.2), + font=SERIF, size=40, color=NEAR_BLACK, + align=PP_ALIGN.CENTER) + add_line(s, Inches(6.17), Inches(4.5), Inches(1), weight_pt=1.5) + add_text(s, contact, + Inches(1), Inches(4.8), Inches(11.33), Inches(0.6), + font=SANS, size=16, color=OLIVE, + align=PP_ALIGN.CENTER) + return s + + +# ═══════════════════════════════════════════════════════════ +# Main: 示例 deck,按实际需求改 +# ═══════════════════════════════════════════════════════════ + +def main(): + import argparse + parser = argparse.ArgumentParser() + parser.add_argument("--out", default="output.pptx", + help="Output PPTX path (default: output.pptx in cwd)") + args = parser.parse_args() + + prs = Presentation() + prs.slide_width = SLIDE_W + prs.slide_height = SLIDE_H + + # 1. 封面 + cover_slide(prs, + title="{{文档标题}}", + subtitle="{{一句话描述}}", + author="{{作者}}", + date="2026.04") + + # 2. 目录 + toc_slide(prs, items=[ + "{{章节 1}}", + "{{章节 2}}", + "{{章节 3}}", + "{{Q&A}}", + ]) + + # 3. 章节首页 + chapter_slide(prs, 1, "{{章节标题}}") + + # 5. 内容页 + content_slide(prs, + eyebrow="{{章节 · 本页}}", + title="{{核心论点标题}}", + body="{{一段正文,18pt sans 字体。控制在 3 行内,一屏一个核心信息。}}", + page_num=5) + + # 7. 数据页 + metrics_slide(prs, + title="关键结果", + metrics=[ + ("+42%", "转化率提升"), + ("3.8M", "月活用户"), + ("99.9%", "可用性 SLA"), + ("5,000+", "QPS 峰值"), + ]) + + # 6. 引用 + quote_slide(prs, + quote="好的设计是尽可能少的设计。", + source="Dieter Rams") + + # 8. 对比页 + comparison_slide(prs, + eyebrow="{{章节 · 对比}}", + left_title="{{旧方案}}", + left_items=["{{对比点 A}}", "{{对比点 B}}", "{{对比点 C}}"], + right_title="{{新方案}}", + right_items=["{{改善点 A}}", "{{改善点 B}}", "{{改善点 C}}"], + page_num=8) + + # 9. 流程步骤页 + pipeline_slide(prs, + eyebrow="{{章节 · 流程}}", + title="{{核心流程标题}}", + steps=[ + ("{{步骤 1}}", "{{步骤 1 的说明文字,控制在两行内。}}"), + ("{{步骤 2}}", "{{步骤 2 的说明文字,控制在两行内。}}"), + ("{{步骤 3}}", "{{步骤 3 的说明文字,控制在两行内。}}"), + ], + page_num=9) + + # 7. 结束 + ending_slide(prs, + message="Thank you", + contact="{{邮箱}} · {{网站}}") + + prs.save(args.out) + print(f"OK: Saved {args.out}") + + +if __name__ == '__main__': + main() diff --git a/dist/kami.zip b/dist/kami.zip new file mode 100644 index 0000000..5dc1d03 Binary files /dev/null and b/dist/kami.zip differ diff --git a/index-en.html b/index-en.html new file mode 100644 index 0000000..43b5ebb --- /dev/null +++ b/index-en.html @@ -0,0 +1,17 @@ + + + + + Kami · Redirect + + + + + + + +

Redirecting to the homepage. If it does not redirect, open Kami homepage.

+ + diff --git a/index-ja.html b/index-ja.html new file mode 100644 index 0000000..d5b3969 --- /dev/null +++ b/index-ja.html @@ -0,0 +1,703 @@ + + + + + Kami 紙:AIエージェント向けドキュメントデザインシステム + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+ Design System · v1.9.4 · 2026.07 + + + + + + + + + + + + + +
+

Kami

+

よい内容には、よい版面を。Kami は AI 時代のための文書組版デザインシステム。文書を明快で読みやすく、記憶に残る形へ整えます。

+
+ Canvas#f5f4ed + Accent#1B365D + SerifJP Mincho / Tsanger fallback + Sans= Serif (一頁一書体) +
+
+ + +
+
+

00 · See it

+

出力サンプル

+

Claude に要件を渡すだけで、一枚資料、長文、正式書簡、ポートフォリオ、履歴書、スライドまで、少数の明確なルールで整った版面に仕上がります。

+

同じ brief は 画像生成モデル にも渡せます。サンプル画像とコピペできるプロンプトは README に置いています。

+
+
+
+ Tesla Q1 2026 個別株レポート +

Equity Report

+

Tesla Q1 2026 決算レポート

+
+
+ Musk resume +

Resume

+

創業者 CV、2ページ

+
+
+ Kami print one-pager +

One-Pager

+

Kami 紹介、白地印刷、1ページ

+
+
+ Agent slides +

Slides

+

Agent キーノート、6枚

+
+
+
+ + +
+
+

Landing Pages

+

Kami で構築

+

同じランディングページテンプレートを 3 つの異なるプロダクトに適用。ひとつの制約セットから、3 つの異なる目的に。

+
+
+
+ Kami ランディングページ +

Kami

+

デザインシステム ホームページ

+
+
+ Luo ランディングページ +

Luo 落文

+

CJK リーディングフォント

+
+
+ Mole ランディングページ +

Mole 鼹

+

macOS システムユーティリティ

+
+
+
+ + +
+
+

01 · Usage

+

インストールと呼び出し

+

「履歴書を作って」「スタートアップ紹介の一枚資料を作って」「登壇用スライドを作って」のように要件を伝えるだけで、skill が自動起動します。スラッシュコマンドは不要です。

+
+
# Claude Code (v2.1.142+)
+/plugin marketplace add tw93/kami
+/plugin install kami@kami
+
+# Codex plugin marketplace
+codex plugin marketplace add tw93/kami
+codex plugin add kami@kami
+

~/.agents/ を読む汎用エージェント:npx skills add tw93/kami/plugins/kami -a universal -g -y。この plugin パスは生成済みの軽量 skill パッケージを公開します。単に tw93/kami を指定すると SKILL.md しかインストールされません。

+

Claude Desktop の場合はリリースアセットの kami.zip をダウンロードします。GitHub の Source code ZIP ではありません。Customize > Skills > "+" > Create skill からそのままアップロードします。

+
+ + +
+
+

02 · Manifesto

+

設計原則

+
+

暖かい紙色を土台に、強調色は ink blue のみ。階層は serif で作り、強い影や派手な配色は退かせます。印刷文書に向けて、安定し、明快で、読みやすい紙面を保つための設計です。既定は紙色ですが、オプションの白地バリアントを使えば、家庭やオフィスのプリンター向けにあらゆる文書を白い背景で印刷でき、温かみはカードや表に残ります。

+
    +
  1. 1

    ページ背景は parchment #f5f4ed、純白は使わない

  2. +
  3. 2

    強調色は ink blue #1B365D のみ、第二の彩色は入れない

  4. +
  5. 3

    グレーはすべて暖色寄り、冷たいブルーグレーは禁止

  6. +
  7. 4

    英語は見出しと本文を serif、中国語は見出しを serif、本文は読みやすい本文体

  8. +
  9. 5

    serif 本文 400、見出し 500、合成ボールドは使わない

  10. +
  11. 6

    行間は 2 段階、見出し 1.1-1.3 / 本文 1.4-1.45

  12. +
  13. 7

    タグ背景は hex の実色のみ、rgba は使わない。WeasyPrint で二重矩形が出るためです

  14. +
  15. 8

    影は ring か whisper のみ、強い drop shadow は使わない

  16. +
+
+ + +
+
+

03 · Color

+

暖色で抑制する

+

強調色は 1 つ、ニュートラルは暖色のみ、寒色は入れない。Ink blue の使用面積はページ全体の 5% 以内に抑えます。超えると統制ではなく過剰になります。

+
+ +

Canvas

+
+

Parchment

Page background, the emotional foundation

#F5F4ED
+

Ivory

Cards / elevated surfaces

#FAF9F5
+

Warm Sand

Button default / interactive surfaces

#E8E6DC
+

Deep Dark

Dark theme page base, not pure black

#141413
+
+ +

Brand

+
+

Ink Blue

Primary color · CTA · quote bar · section overline

#1B365D
+

Ink Light

Links on dark surfaces · lighter variant

#2D5A8A
+

Dark Surface

Dark theme container · warm charcoal

#30302E
+

Error

Error state, deep warm red

#B53333
+
+ +

Warm Neutrals

+
+

Near Black

#141413
+

Dark Warm

#3D3D3A
+

Olive

#504e49
+

Stone

#6b6a64
+
+ +

Tag Tints: rgba から実色 hex へ

+

下記は ink blue #1B365D を parchment 上に重ねたときの等価な実色 hex です。タグ背景は rgba() を避け、WeasyPrint の二重矩形を防ぎます。

+
+
 Tag sample

0.08

#EEF2F7
+
 Tag sample

0.14

#E4ECF5
+
DefaultTag sample

0.18

#E4ECF5
+
 Tag sample

0.22

#D0DCE9
+
 Tag sample

0.30

#D6E1EE
+
+
+ + +
+
+

04 · Typography

+

タイポグラフィ体系

+

階層は serif、機能要素は sans。serif は本文 400、見出し 500 を基本にします。

+
+ +
+
+

Aa

+

Serif · 見出し + 本文

+

JP Mincho / TsangerJinKai02

+

見出し、本文、引用、数値強調に使用。日本語は明朝体を優先し、TsangerJinKai02 は CJK fallback として扱います。

+
+
+

</>

+

Mono · コード

+

JetBrains Mono

+

コードブロック、バージョン番号、hex 値、等幅数字に使用。

+
+
+ +

スケール(印刷 pt、画面 px = pt x 1.33)

+
+
+
Display
+
よい文書は
明快で、安定し、読みやすい
+
36-48 pt
weight 500
line 1.10
+
+
+
H1 Section
+
Color System · Palette
+
18-22 pt
weight 500
line 1.20
+
+
+
H2 Subsection
+
Canvas · Warm Neutrals
+
14-16 pt
weight 500
line 1.25
+
+
+
H3 Item
+
Tag Tints · Solid Hex
+
12-13 pt
weight 500
line 1.30
+
+
+
Body
+
Ink blue は各ページで 5% 以内。超えると抑制ではなく過剰になります。グレーは黄褐色の下地を持ち、R ≈ G > B を目安にします。
+
9.5-10 pt
weight 400
line 1.55
+
+
+
Caption
+
図注、脚注、補足説明。色は olive または stone へ一段落とします。
+
8.5-9 pt
weight 400
line 1.45
+
+
+
Label
+
Design System · v1.9.4
+
7.5-8 pt
weight 600
line 1.35
+
+
+
+ + +
+
+

05 · Spacing & Shape

+

リズムと形

+

基本単位は 4pt。情報密度が高いほどマージンを詰め、フォーマルな文書ほど広く取ります。

+
+ + + + + + + + + + +
ScaleValueUse
xs2-3 ptInline elements
sm4-5 ptTag padding · tight layout
md8-10 ptComponent internals
lg16-20 ptBetween components · card padding
xl24-32 ptSection title margins
2xl40-60 ptBetween major sections
3xl80-120 ptBetween long-doc chapters
+ +

Radii

+
+
4 pt
Tight
+
6 pt
Code block
+
8 pt
Default card
+
12 pt
Container
+
16 pt
Feature card
+
24 pt
Large container
+
32 pt
Hero
+
+ +

奥行き: 3 つの影の方法

+

Kami は強い影を使いません。奥行きは ring shadow、whisper shadow、明暗切り替えで作ります。

+
+
+

Ring Shadow

+

0 0 0 1pt var(--ring-warm)
 
ボタン・カード hover

+
+
+

Whisper Shadow

+

0 4pt 24pt rgba(0,0,0,0.05)
 
軽い浮き上がり・特集カード

+
+
+

Light ⇌ Dark

+

parchment ⇌ deep-dark
 
セクション単位・最も強い対比

+
+
+
+ + +
+
+

06 · Components

+

モジュール

+

必要最小限の固定セットを使い、文書上の具体的な課題に効く場面だけで使います。

+
+ +
+
+

Buttons

+

強い drop shadow ではなく ring shadow ・角丸 8pt

+
+ 主要 CTA + 副次 CTA + Ghost +
+
+
+

タグ(3 段階)

+

実色 hex ・弱から強へ選択

+
+ Light 0.08 + Standard 0.18 + Brush gradient +
+
+
+

引用

+

左 2pt のブランド実線 + olive テキスト

+
装飾より明快さを優先する。ひとつの Yes のために多くの No を選ぶ。
+
+
+

指標カード

+

serif 数字 + sans ラベル ・ tabular-nums

+
+
8文書タイプ
+
1強調色
+
8基本ルール
+
+
+
+

セクション見出し

+

serif 500 ・ 15pt ・サイズ主導の階層、装飾は不要

+
+ Selected Works · Projects +
+
+
+

Dash List

+

丸点ではなくダッシュ ・ 編集文体

+
    +
  • 紙色は暖色、純白は使わない
  • +
  • 強調色は ink blue だけ、第二色は入れない
  • +
  • 階層は serif で担保する
  • +
+
+
+

コードブロック

+

ivory 背景 + 0.5pt 枠線 + 6pt 角丸

+
// 温かみは色数ではなく、抑制で作る
+canvas   = parchment; accent = ink_blue
+palette  = warm_neutrals − cool_grays
+document = serif × hierarchy + generous_space
+beauty   = constraints × intention ÷ noise
+
+
+

特集カード

+

whisper shadow + 16pt 角丸

+
+

Tesla 企業紹介 · One-Pager

+

A4 1ページ ・ 指標カード 4 枚 + 本文 3 セクション + タイムライン

+
+ 日本語 + A4 x 1 + Ink Blue +
+
+
+
+
+ + +
+
+

07 · Diagrams

+

インライン図表

+

アーキテクチャ、フロー、データ可視化をカバーする 18 種の SVG 図表を用意。必要な種類を Claude に伝えると、Kami の色と書体規則に沿って文書へ直接埋め込みます。

+
+
+ +
+
アーキテクチャ図 · Architecture
+ + + + + + + + + CLIENT + Browser + + + SERVICE + API + + + DATA + Database + +

システム構成と接続関係、焦点ノードを 1 つに絞る

+
+ +
+
フローチャート · Flowchart
+ + + + Start + + + + Valid? + + + Yes + + Process + + + No + + Reject + +

分岐と yes/no の流れ、成功側を主軸に配置

+
+ +
+
棒グラフ · Bar Chart
+ + + + + + + Q1 + Q2 + Q3 + Q4 + 180 + 220 + 195 + 240 + +

四半期売上の比較、単色または段階色で表現

+
+ +
+
ドーナツチャート · Donut Chart
+ + + + + + 60% + Primary + + Core revenue · 60% + + Services · 25% + + Other · 15% + +

売上構成の内訳、3-6 セグメントに対応

+
+
+
+ + +
+
+

08 · Decision Lookup

+

クイック参照

+

どの手法を使うか迷ったらこの表を参照。該当がなければ基本原則に戻って判断します。

+
+ + + + + + + + + + + + + +
場面処方
大きな見出しserif 500、サイズで階層化、行間 1.10-1.30
本文(可読重視)serif 4009.5-10 pt、行間 1.55
数値を強調color: var(--brand) を使い、太字には頼らない
セクション分離2.5pt の brand 実線、または 0.5pt 暖色破線
引用を置く2pt brand 実線 + olive テキスト
コードを載せるivory 背景 + 0.5pt 枠線 + 6pt 角丸 + mono 書体
主ボタンと副ボタン主: brand 塗り + ivory 文字、副: warm-sand + charcoal
特別カードを示すborder: 0.5pt solid var(--brand) または border-left: 3pt
セクション開始serif 50015pt ・サイズ主導の階層、装飾は不要
表紙を作る1 ページ構成、Display サイズ + 右寄せの著者/日付 + 広い余白
データカードivory 背景 + 8pt 角丸 + serif 大数字 + sans 小ラベル
+
+ + +
+
+

09 · Anti-Patterns

+

避けるべきこと

+

例外は許容できますが、理由を明示できる場合に限ります。

+
+ +
+
+

Don't

+

ページ背景に #ffffff の純白や #f3f4f6 の寒色グレーを使わない。紙面が硬く見え、温かい紙質感が崩れます。

+
+
+

Do

+

常に #f5f4ed の parchment を使う。印刷時の白フチを防ぐため、@page background も同色に設定します。

+
+
+

Don't

+

タグに rgba(27,54,93,0.18) を使わない。WeasyPrint で文字領域と余白領域の不透明度がずれ、二重矩形が出ます。

+
+
+

Do

+

tint 対照表の実色 hex #E4ECF5 を使う。描画が安定し、色ぶれを防げます。

+
+
+

Don't

+

見出しに font-weight: 600 以上の合成ボールドを使わない。線が潰れて可読性と品位が落ちます。

+
+
+

Do

+

本文 400、見出し 500(実フォント W05)。強調が必要ならサイズや左バーで対応し、合成ボールドは使わない。

+
+
+

Don't

+

強い drop shadow 0 2px 8px rgba(0,0,0,0.3) を使わない。重く見え、印刷時に黒く潰れやすい。

+
+
+

Do

+

ring shadow 0 0 0 1pt か whisper rgba 0.05 を使う。あるいは明暗の切り替えで階層を作る。

+
+
+
+ + +
+
+

10 · Background

+

設計の背景

+
+
+

米国株投資が好きで、Claude にリサーチレポートを書かせる場面が多いです。出力はいつも既定のドキュメント風で、灰色で平板、セッションごとにレイアウトが変わる。構成は追いづらく書式は古臭く、読み続ける気になれませんでした。書体、配色、余白をひとつずつ直していき、読んでいて心地よいページに仕上げました。

+

その後「あなたの知らない Agent:原理、アーキテクチャとエンジニアリング実践」の発表をすることになりました。すでに原稿があったのでスライドを一から作る気にならず、Claude Design で自分のスタイルのまま組版し、何度も調整を繰り返して、ようやく納得のいく仕上がりになりました。inline SVG 図表、暖色パレットの統一、編集リズムの引き締めを加え、よく使う文書形式をすべてカバーするまで育ったので、このプロセスをさらに抽象化して kami にしました。どのエージェントに渡しても安心して出せる、静かなデザインシステムです。

+
+
+ + +
+
+

11 · よくある質問

+

FAQ

+
+
+
+
Kamiとは?
+
AIエージェント向けのドキュメントデザインシステムです。単一アクセントカラー、serif階層、あたたかなパーチメントキャンバス。LLMエージェントに指示を出すだけで安定したレイアウトが返ってきます。Claude DesignやGPT Canvasなどの画像レンダラーへのビジュアルブリーフとしても使えます。
+
+
+
どんなドキュメントを作れますか?
+
8種類のドキュメントテンプレート:ワンページャー、長文ドキュメント、レター、ポートフォリオ、履歴書、スライド、個別株レポート、変更履歴に加え、ランディングページシステム(EN + CN + KO)。さらに18種のインラインSVG図表で視覚的な説明も可能です。出力はHTMLで、PDF、PNG、編集可能なPPTXスライドに変換できます。
+
+
+
セットアップ方法は?
+
Claude Code(v2.1.142+):/plugin marketplace add tw93/kami の後に /plugin install kami@kami。Codex:codex plugin marketplace add tw93/kami の後に codex plugin add kami@kami~/.agents を読む汎用エージェント:npx skills add tw93/kami/plugins/kami -a universal -g -y。Claude Desktop:GitHub Releases からリリースアセットの kami.zip をダウンロードします。Source code ZIP ではありません。カスタマイズ > スキルでアップロード。
+
+
+
対応言語は?
+
英語、中国語、日本語、韓国語。各言語に専用のserifフォントを使用:英語はCharter、中国語はTsangerJinKai02、日本語はYuMincho、韓国語はSource Han Serif K。字間、行高、フォントサイズは言語ごとに印刷品質レベルで調整されています。
+
+
+
+ + +
+
+ + + + + +
+

Kami · 紙

+

よい内容には、よい版面を。

+
+
+
+
GitHub  ·  One-Pager · Long Doc · Letter · Portfolio · Resume · Slides
+
+ Serif が階層を担い、sans が機能を担い、暖色グレーがリズムを作り、ink blue が焦点を作ります。 +
+
+
+ +
+ + + diff --git a/index-ko.html b/index-ko.html new file mode 100644 index 0000000..5bed452 --- /dev/null +++ b/index-ko.html @@ -0,0 +1,714 @@ + + + + + Kami 紙: AI 에이전트를 위한 문서 디자인 시스템 + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+ Design System · v1.9.4 · 2026.07 + + + + + + + + + + + + + +
+

Kami

+

좋은 콘텐츠는 좋은 종이를 만날 자격이 있습니다. Kami는 AI 시대를 위한 레이아웃 디자인 시스템으로, 문서를 명확하고 읽기 쉽고 기억에 남게 만듭니다.

+
+ Canvas#f5f4ed + Accent#1B365D + SerifCharter / TsangerJinKai + Sans= Serif (페이지당 단일 폰트) +
+
+ + +
+
+

00 · See it

+

출력 샘플

+

Claude에게 브리프를 전달하세요: 원페이저, 긴 문서, 레터, 포트폴리오, 이력서, 슬라이드 모두 하나의 작고 신뢰할 수 있는 규칙 세트에서 정돈된 레이아웃으로 완성됩니다. 동일한 브리프는 이미지 렌더러에도 적용할 수 있으며, README에 복사-붙여넣기 프롬프트와 샘플 출력물이 포함되어 있습니다.

+
+
+
+ Tesla equity report +

Equity Report

+

Tesla 2026년 1분기 실적 분석

+
+
+ Korean resume +

Resume

+

개발자 이력서, 2페이지

+
+
+ Kami print one-pager +

One-Pager

+

Kami 소개, 흰 종이 인쇄, 1페이지

+
+
+ Agent slides +

Slides

+

에이전트 키노트, 6슬라이드

+
+
+
+ + +
+
+

Landing Pages

+

Kami로 제작

+

동일한 랜딩 페이지 템플릿을 세 가지 다른 제품에 적용한 결과. 하나의 제약 세트, 세 가지 다른 목적.

+
+
+
+ Kami landing page +

Kami

+

디자인 시스템 홈페이지

+
+
+ Luo landing page +

Luo

+

CJK 읽기용 폰트, 중국어

+
+
+ Mole landing page +

Mole

+

macOS 시스템 유틸리티

+
+
+
+ + +
+
+

01 · Usage

+

설치 및 사용

+

Claude에게 필요한 것을 말하세요. 예를 들어 "이력서 만들어 줘", "스타트업 원페이저 만들어 줘", "발표용 슬라이드 디자인해 줘". 스킬이 자동으로 실행되며, 슬래시 명령어가 필요 없습니다.

+
+
# Claude Code (v2.1.142+)
+/plugin marketplace add tw93/kami
+/plugin install kami@kami
+
+# Codex plugin marketplace
+codex plugin marketplace add tw93/kami
+codex plugin add kami@kami
+

~/.agents/를 읽는 범용 에이전트: npx skills add tw93/kami/plugins/kami -a universal -g -y. 이 plugin 경로는 생성된 경량 스킬 번들을 제공합니다. 그냥 tw93/kami를 쓰면 SKILL.md만 설치됩니다.

+

Claude Desktop: 릴리스 에셋 kami.zip을 다운로드하세요. GitHub Source code ZIP이 아닙니다. Customize > Skills > "+" > Create skill에서 업로드하세요.

+
+ + +
+
+

02 · Manifesto

+

디자인 원칙

+
+

따뜻한 양피지 캔버스, 잉크 블루를 유일한 강조색으로, 세리프가 계층 구조를 담당하며, 하드 섀도우와 화려한 팔레트는 배제합니다. 이 시스템은 인쇄물을 위해 만들어졌습니다: 안정적이고, 명확하며, 절제되어 있습니다. 양피지가 기본이며, 선택형 흰 종이 변형을 사용하면 가정용이나 사무용 프린터를 위해 어떤 문서든 흰 배경으로 인쇄하면서도 카드와 표에 따뜻함을 남깁니다.

+
    +
  1. 1

    페이지 배경은 양피지 #f5f4ed, 순백은 절대 사용하지 않음

  2. +
  3. 2

    강조색은 잉크 블루 #1B365D 하나만; 두 번째 유채색 없음

  4. +
  5. 3

    모든 회색은 따뜻한 색조, 황갈색 기반; 차가운 청회색 사용 금지

  6. +
  7. 4

    영문은 제목과 본문 모두 세리프; 중문은 제목에 세리프, 본문에 산세리프

  8. +
  9. 5

    세리프 본문은 400, 제목은 500. 합성 볼드 사용 금지

  10. +
  11. 6

    세 가지 행간 밴드: 타이트 제목 1.1-1.3 / 밀집 1.4-1.45 / 읽기 1.5-1.55

  12. +
  13. 7

    태그 배경은 반드시 솔리드 hex; rgba 금지, WeasyPrint 이중 사각형 버그

  14. +
  15. 8

    그림자: 링 또는 위스퍼만 허용, 하드 드롭 섀도우 금지

  16. +
+
+ + +
+
+

03 · Color

+

따뜻한 절제

+

하나의 강조색 + 따뜻한 중성색 + 차가운 색상 제로. 잉크 블루는 어떤 페이지에서든 5% 이하만 차지합니다. 그 이상은 절제가 아니라 혼잡입니다.

+
+ +

Canvas

+
+

Parchment

페이지 배경, 감성의 기초

#F5F4ED
+

Ivory

카드 / 부상된 표면

#FAF9F5
+

Warm Sand

버튼 기본 / 인터랙티브 표면

#E8E6DC
+

Deep Dark

다크 테마 페이지 기본, 순수 블랙 아님

#141413
+
+ +

Brand

+
+

Ink Blue

기본 색상 · CTA · 인용 바 · 섹션 오버라인

#1B365D
+

Ink Light

다크 표면 위 링크 · 밝은 변형

#2D5A8A
+

Dark Surface

다크 테마 컨테이너 · 웜 차콜

#30302E
+

Error

오류 상태, 깊은 따뜻한 레드

#B53333
+
+ +

Warm Neutrals

+
+

Near Black

#141413
+

Dark Warm

#3D3D3A
+

Olive

#504e49
+

Stone

#6b6a64
+
+ +

Tag Tints: rgba에서 솔리드 hex로

+

양피지 위 잉크 블루 #1B365D의 솔리드 hex 등가값. 태그는 WeasyPrint 이중 사각형 렌더링 버그를 피하기 위해 rgba() 대신 솔리드 hex를 사용합니다.

+
+
 Tag sample

0.08

#EEF2F7
+
 Tag sample

0.14

#E4ECF5
+
DefaultTag sample

0.18

#E4ECF5
+
 Tag sample

0.22

#D0DCE9
+
 Tag sample

0.30

#D6E1EE
+
+
+ + +
+
+

04 · Typography

+

타입 시스템

+

세리프는 계층을, 산세리프는 기능을 담당합니다. 세리프 본문은 400, 제목은 500.

+
+ +
+
+

Aa

+

Serif · 제목 + 본문

+

Charter / TsangerJinKai02

+

제목, 본문, 풀 인용, 숫자 강조에 사용됩니다. 영문은 Charter, 중문은 TsangerJinKai02를 사용합니다.

+
+
+

</>

+

Mono · 코드

+

JetBrains Mono

+

코드 블록, 버전 번호, hex 값, 표형 숫자.

+
+
+ +

스케일 (인쇄 pt; 화면 px = pt x 1.33)

+
+
+
Display
+
좋은 문서는 명확하고
안정적이며 절제되어 있다
+
36-48 pt
weight 500
line 1.10
+
+
+
H1 Section
+
Color System · Palette
+
18-22 pt
weight 500
line 1.20
+
+
+
H2 Subsection
+
Canvas · Warm Neutrals
+
14-16 pt
weight 500
line 1.25
+
+
+
H3 Item
+
Tag Tints · Solid Hex
+
12-13 pt
weight 500
line 1.30
+
+
+
Body
+
잉크 블루는 어떤 페이지에서든 5% 이하만 차지합니다. 그 이상은 절제가 아니라 혼잡입니다. 모든 회색은 황갈색 기반입니다: R ≈ G > B가 기본 원칙입니다.
+
9.5-10 pt
weight 400
line 1.55
+
+
+
Caption
+
그림 주석, 각주, 주석 텍스트. 색상은 올리브 또는 스톤으로 낮아집니다.
+
8.5-9 pt
weight 400
line 1.45
+
+
+
Label
+
Design System · v1.9.4
+
7.5-8 pt
weight 600
line 1.35
+
+
+
+ + +
+
+

05 · Spacing & Shape

+

리듬과 형태

+

기본 단위: 4pt. 밀집 레이아웃은 더 작은 여백을, 격식 있는 문서는 더 큰 여백을 사용합니다.

+
+ + + + + + + + + + +
스케일용도
xs2-3 pt인라인 요소
sm4-5 pt태그 패딩 · 타이트 레이아웃
md8-10 pt컴포넌트 내부
lg16-20 pt컴포넌트 간격 · 카드 패딩
xl24-32 pt섹션 제목 마진
2xl40-60 pt주요 섹션 간격
3xl80-120 pt긴 문서 챕터 간격
+ +

Radii

+
+
4 pt
타이트
+
6 pt
코드 블록
+
8 pt
기본 카드
+
12 pt
컨테이너
+
16 pt
피처 카드
+
24 pt
대형 컨테이너
+
32 pt
히어로
+
+ +

깊이: 세 가지 그림자 방식

+

Kami는 전통적인 하드 섀도우를 피합니다. 깊이감은 링 섀도우, 위스퍼 섀도우, 명암 교차를 통해 표현합니다.

+
+
+

링 섀도우

+

0 0 0 1pt var(--ring-warm)
 
버튼 · 카드 호버

+
+
+

위스퍼 섀도우

+

0 4pt 24pt rgba(0,0,0,0.05)
 
부드러운 부상 · 피처 카드

+
+
+

라이트 ⇌ 다크

+

parchment ⇌ deep-dark
 
섹션 레벨 · 가장 강한 대비

+
+
+
+ + +
+
+

06 · Components

+

원자 모듈

+

작은 고정 세트로, 구체적인 문서 문제를 해결하는 곳에만 유지합니다.

+
+ +
+
+

버튼

+

하드 드롭 대신 링 섀도우 · 8pt radius

+
+ Primary CTA + Secondary + Ghost +
+
+
+

태그, 세 단계

+

솔리드 hex · 약에서 강으로 선택

+
+ Light 0.08 + Standard 0.18 + Brush gradient +
+
+
+

인용

+

좌측 2pt brand solid + olive 텍스트

+
천 번의 거절 끝에 하나의 승낙, 장식보다 명확함을 우선합니다.
+
+
+

메트릭

+

세리프 숫자 + 산세리프 레이블 · tabular-nums

+
+
8문서 유형
+
1강조색
+
8규칙
+
+
+
+

섹션 제목

+

serif 500 · 15pt · 크기 주도 계층, 장식 없음

+
+ Selected Works · Projects +
+
+
+

대시 목록

+

불릿 대신 대시 · 에디토리얼 톤

+
    +
  • 따뜻한 양피지, 순백은 절대 아님
  • +
  • 잉크 블루 강조색, 두 번째 색상 없음
  • +
  • 세리프가 권위를 담당
  • +
+
+
+

코드 블록

+

ivory bg + 0.5pt border + 6pt radius

+
// warmth from restraint, not from color
+canvas   = parchment; accent = ink_blue
+palette  = warm_neutrals − cool_grays
+document = serif × hierarchy + generous_space
+beauty   = constraints × intention ÷ noise
+
+
+

피처 카드

+

whisper shadow + 16pt radius

+
+

Tesla Company Profile · One-Pager

+

단일 A4 페이지 · 메트릭 카드 4개 + 본문 섹션 3개 + 타임라인

+
+ English + A4 x 1 + Ink Blue +
+
+
+
+
+ + +
+
+

07 · Diagrams

+

인라인 차트

+

아키텍처, 프로세스, 데이터 차트 시나리오를 아우르는 18가지 인라인 SVG 다이어그램 유형. Claude에게 필요한 유형을 말하면 문서에 직접 삽입되며, 색상과 폰트는 Kami 디자인 언어를 따릅니다.

+
+
+ +
+
Architecture
+ + + + + + + + + CLIENT + Browser + + + SERVICE + API + + + DATA + Database + +

시스템 구성 요소와 연결, 하나의 포컬 노드

+
+ +
+
Flowchart
+ + + + Start + + + + Valid? + + + Yes + + Process + + + No + + Reject + +

분기 결정, 예/아니오 경로, 성공에 포컬

+
+ +
+
Bar Chart
+ + + + + + + Q1 + Q2 + Q3 + Q4 + 180 + 220 + 195 + 240 + +

분기별 매출 비교, 그라데이션 또는 단색

+
+ +
+
Donut Chart
+ + + + + + 60% + Primary + + Core revenue · 60% + + Services · 25% + + Other · 15% + +

매출 구조 분석, 3-6개 세그먼트 지원

+
+
+
+ + +
+
+

08 · Decision Lookup

+

빠른 참조

+

무엇을 사용해야 할지 확신이 없을 때 이 표를 참고하세요. 여기에 없다면, 기본 원칙으로 돌아가세요.

+
+ + + + + + + + + + + + + +
작업방법
큰 제목serif 500, 계층에 따른 크기, line-height 1.10-1.30
읽기 본문serif 400, 9.5-10 pt, line-height 1.55
숫자 강조color: var(--brand), 볼드 아님
두 섹션 구분2.5pt brand 좌측 바, 또는 0.5pt warm-gray 점선
인용좌측 2pt brand solid + olive 텍스트
코드 표시Ivory bg + 0.5pt border + 6pt radius + mono font
기본 vs 보조기본: brand fill + ivory text; 보조: warm-sand + charcoal
특별 카드 표시border: 0.5pt solid var(--brand) 또는 border-left: 3pt
섹션 시작Serif 500 · 15pt · 크기 주도 계층, 장식 불필요
문서 표지단일 페이지, Display 크기 + 우측 정렬 저자/날짜 + 넉넉한 여백
데이터 카드Ivory bg + 8pt radius + serif 큰 숫자, ink blue + sans 작은 레이블
+
+ + +
+
+

09 · Anti-Patterns

+

피해야 할 것

+

예외는 허용되지만, 이유는 명시적이어야 합니다.

+
+ +
+
+

하지 마세요

+

#ffffff 순백이나 #f3f4f6 쿨 그레이를 페이지 배경으로 사용하지 마세요. 페이지가 딱딱해지고 따뜻한 양피지 특성이 약해집니다.

+
+
+

하세요

+

항상 #f5f4ed 양피지를 사용하세요. 인쇄 시 흰 테두리를 방지하기 위해 @page background도 같은 색상으로 설정하세요.

+
+
+

하지 마세요

+

태그에 rgba(27,54,93,0.18)를 사용하지 마세요. WeasyPrint는 패딩과 글리프 영역을 다른 불투명도로 렌더링하여 이중 사각형을 만듭니다.

+
+
+

하세요

+

등가 솔리드 hex #E4ECF5를 위한 틴트 조회 표를 사용하세요. 렌더링이 깔끔하게 유지되고 색상이 안정적입니다.

+
+
+

하지 마세요

+

제목을 font-weight: 600 이상의 합성 볼드로 설정하지 마세요. 합성 볼드는 획을 흐리게 하고 타이포그래피 품질을 떨어뜨립니다.

+
+
+

하세요

+

본문 400, 제목 500 (실제 W05). 더 강한 존재감이 필요하면 크기나 brand 좌측 바를 사용하고, 합성 볼드는 절대 사용하지 마세요.

+
+
+

하지 마세요

+

하드 드롭 섀도우 0 2px 8px rgba(0,0,0,0.3)을 사용하지 마세요. 시각적으로 무겁고 인쇄 시 어두운 얼룩으로 나타날 수 있습니다.

+
+
+

하세요

+

링 섀도우 0 0 0 1pt 또는 위스퍼 rgba 0.05, 아니면 단순히 밝고 어두운 섹션을 교차시키세요.

+
+
+
+ + +
+
+

10 · Background

+

디자인 기원

+
+
+

저는 미국 주식에 투자하며 항상 Claude에게 리서치 리포트를 작성해 달라고 합니다. 매번 출력물은 같은 기본 문서 모양이었습니다: 회색이고, 평면적이며, 세션마다 레이아웃이 달랐습니다. 구조를 파악하기 어렵고, 서식은 구식이었으며, 페이지의 어떤 것도 계속 읽고 싶게 만들지 못했습니다. 그래서 타이포그래피, 팔레트, 간격을 하나의 규칙씩 고쳐나가기 시작했고, 결국 리포트는 제가 실제로 즐길 수 있는 페이지가 되었습니다.

+

나중에 "당신이 모르는 에이전트: 원칙, 아키텍처, 엔지니어링 실무"를 발표해야 했습니다. 이미 문서가 있었고 슬라이드를 처음부터 만들고 싶지 않아서, Claude Design으로 제 스타일에 맞게 레이아웃을 잡고 여러 차례 수정하여 만족스러운 수준에 도달했습니다. 그 과정에서 인라인 SVG 차트, 통일된 웜 팔레트, 더 타이트한 에디토리얼 리듬이 추가되었습니다. 정기적으로 작성하는 모든 문서를 커버할 때까지 계속 성장했고, 추상화 과정을 거쳐 kami가 되었습니다: 어떤 에이전트에게든 넘겨주고 출력물을 신뢰할 수 있는, 하나의 조용한 디자인 시스템입니다.

+
+
+ + +
+
+

11 · Questions

+

FAQ

+
+
+
+
Kami란 무엇인가요?
+
AI 생성 문서를 위한 제약 기반 디자인 시스템입니다. 하나의 강조색, 세리프 주도 계층 구조, 따뜻한 양피지 캔버스. LLM 에이전트에게 브리프를 전달하면 정돈된 레이아웃을 돌려받습니다. Claude Design이나 GPT Canvas 같은 이미지 렌더러에 전달할 수 있는 비주얼 브리프로도 활용됩니다.
+
+
+
무엇을 만들 수 있나요?
+
8가지 문서 템플릿: 원페이저, 긴 문서, 레터, 포트폴리오, 이력서, 슬라이드, 주식 리포트, 변경 로그에 더해 랜딩 페이지 시스템(영문 + 중문 + 한국어). 비주얼 설명을 위한 18가지 인라인 SVG 다이어그램 유형 포함. HTML 출력물을 PDF, PNG 또는 편집 가능한 PPTX 슬라이드 덱으로 내보낼 수 있습니다.
+
+
+
어떻게 설정하나요?
+
Claude Code(v2.1.142+): /plugin marketplace add tw93/kami 실행 후 /plugin install kami@kami. Codex: codex plugin marketplace add tw93/kami 실행 후 codex plugin add kami@kami. ~/.agents를 읽는 범용 에이전트: npx skills add tw93/kami/plugins/kami -a universal -g -y. Claude Desktop: GitHub Releases에서 릴리스 에셋 kami.zip을 다운로드하세요. Source code ZIP이 아닙니다. Customize > Skills에서 업로드하세요. 슬래시 명령어가 필요 없으며, 자연어 요청으로 스킬이 자동 실행됩니다.
+
+
+
어떤 언어를 지원하나요?
+
영어, 중국어, 일본어, 한국어를 지원합니다. 각 언어별 전용 세리프 폰트를 사용합니다: 영어는 Charter, 중국어는 TsangerJinKai02, 일본어는 YuMincho, 한국어는 Source Han Serif K. 자간, 행간, 폰트 크기는 인쇄 품질을 위해 언어별로 튜닝됩니다. 일본어와 한국어는 배포 전 비주얼 QA를 포함하는 최선의 CJK 경로입니다.
+
+
+
+ + +
+
+ + + + + +
+

Kami · 紙

+

좋은 콘텐츠는 좋은 종이를 만날 자격이 있습니다.

+
+
+
+
GitHub  ·  One-Pager · Long Doc · Letter · Portfolio · Resume · Slides
+
+ Serif는 권위를, sans는 기능을, 따뜻한 회색은 리듬을, 잉크 블루는 초점을 담당합니다. +
+
+
+ +
+ + + diff --git a/index-tw.html b/index-tw.html new file mode 100644 index 0000000..de499c9 --- /dev/null +++ b/index-tw.html @@ -0,0 +1,732 @@ + + + + + Kami 紙:面向 AI Agent 的文件設計系統 + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+ Design System · v1.9.4 · 2026.07 + + + + + + + + + + + + + +
+

Kami

+

好內容,值得好版面。Kami 是一套適合 AI 時代的排版設計系統,讓文件更清晰好讀,也容易記住。

+
+ Canvas#f5f4ed + Accent#1B365D + SerifTsangerJinKai / Charter + Sans= Serif (一頁一字體) +
+
+ + +
+
+

00 · See it

+

輸出樣本

+

把文件需求交給 Claude。無論是一頁紙、長文、正式信件、作品集、履歷還是簡報,Kami 都用少量清晰規則,把提示詞變成可交付的排版。同一份 brief 也能 交給繪圖模型,README 裡附了樣圖和可複製的提示詞。

+
+
+
+ Tesla Q1 2026 財報點評 +

Equity Report · 中文

+

Tesla Q1 2026 財報點評

+
+
+ Musk resume +

Resume · English

+

創辦人履歷,2 頁

+
+
+ Kami 列印一頁紙 +

一頁紙 · 中文

+

Kami 介紹,白底列印版,1 頁

+
+
+ Agent slides +

Slides · English

+

Agent keynote, 6 slides

+
+
+
+ + +
+
+

Landing Pages

+

用 Kami 搭建

+

同一套落地頁範本用於三款不同產品,一組約束規則,三種截然不同的用途。

+
+
+
+ Kami 官網 +

Kami

+

設計系統主頁

+
+
+ Luo 落文官網 +

Luo 落文

+

CJK 閱讀字體

+
+
+ Mole 官網 +

Mole 鼴

+

macOS 系統工具

+
+
+
+ + +
+
+

01 · Usage

+

安裝與調用

+

直接告訴 Claude 你要什麼,比如「幫我排版一份白皮書」「做一份履歷」「幫我做一份作品集」,skill 會自動觸發,無需斜線命令。

+
+
# Claude Code (v2.1.142+)
+/plugin marketplace add tw93/kami
+/plugin install kami@kami
+
+# Codex plugin marketplace
+codex plugin marketplace add tw93/kami
+codex plugin add kami@kami
+

讀取 ~/.agents/ 的通用 agent:npx skills add tw93/kami/plugins/kami -a universal -g -y。這個 plugin 路徑暴露生成好的輕量 skill 套件;直接用 tw93/kami 只會裝到 SKILL.md。

+

Claude Desktop:下載 release asset kami.zip,不要下載 GitHub 的 Source code 壓縮包,然後在 Customize > Skills > "+" > Create skill 中直接上傳。

+
+ + +
+
+

02 · Manifesto

+

設計原則

+
+

暖米紙底,油墨藍點綴,serif 承擔層級,硬陰影與花俏配色都退後;這套系統面向印刷文件,負責把內容收束成穩定、清晰、易讀的版面。羊皮紙底為預設,另有可選的白底版本,把任意文件切到白底以適配印表機,暖意則沉入卡片與表格。

+
    +
  1. 1

    頁面背景 parchment #f5f4ed,不用純白

  2. +
  3. 2

    強調色只有油墨藍 #1B365D,不引入第二種彩色

  4. +
  5. 3

    所有灰色暖調 (yellow-brown undertone),禁止冷藍灰

  6. +
  7. 4

    英文 serif 通吃標題和正文,中文標題用 serif、正文用 sans

  8. +
  9. 5

    Serif 正文 400,標題 500,不用合成 bold

  10. +
  11. 6

    行距三檔:緊湊標題 1.1-1.3 / 密排 1.4-1.45 / 閱讀 1.5-1.55

  12. +
  13. 7

    Tag 背景必須實色 hex,禁止 rgba(WeasyPrint 雙層矩形 bug)

  14. +
  15. 8

    陰影只用 ring 或 whisper shadow,不用硬 drop shadow

  16. +
+
+ + +
+
+

03 · Color

+

暖調克制

+

唯一強調色、純暖調中性灰、零冷色;油墨藍全文件不超過 5% 面積,超過就從克制變成堆砌。

+
+ +

畫布 · Canvas

+
+

Parchment

頁面底色,也是整套設計的溫度來源

#F5F4ED
+

Ivory

卡片 · 浮起容器

#FAF9F5
+

Warm Sand

按鈕預設背景 · 互動面

#E8E6DC
+

Deep Dark

深色主題頁面底,不取純黑,保留一點橄欖底色

#141413
+
+ +

主調 · Brand

+
+

Ink Blue

主色 · CTA · quote 豎線 · 章節標題

#1B365D
+

Ink Light

深色底上的連結 · 亮變體

#2D5A8A
+

Dark Surface

深色主題容器 · 暖炭灰

#30302E
+

Error

錯誤,深暖紅,不刺眼

#B53333
+
+ +

中性 · Warm Neutrals

+
+

Near Black

#141413
+

Dark Warm

#3D3D3A
+

Olive

#504e49
+

Stone

#6b6a64
+
+ +

Tag Tints · rgba → 實色對照

+

這裡列的是油墨藍 #1B365D 疊加在 parchment 上的等效實色;Tag 背景不使用 rgba(),避免 WeasyPrint 的雙層矩形問題。

+
+
 Tag sample

0.08

#EEF2F7
+
 Tag sample

0.14

#E4ECF5
+
DefaultTag sample

0.18

#E4ECF5
+
 Tag sample

0.22

#D0DCE9
+
 Tag sample

0.30

#D6E1EE
+
+
+ + +
+
+

04 · Typography

+

字體系統

+

Serif 承擔層級,sans 承擔功能;serif 正文 400,標題 500。

+
+ +
+
+

紙 Aa

+

Serif · 標題

+

TsangerJinKai02 / Charter

+

承擔標題、引語與數字強調,中文取倉耳今楷,英文取 Charter。

+
+
+

</>

+

Mono · 代碼

+

JetBrains Mono

+

用於代碼塊、版本號、hex 值與等寬數字,字號較正文低一檔。

+
+
+ +

層級(印刷品 pt · 螢幕 px ≈ pt × 1.33)

+
+ +
+
Display
+
高品質文件讀起來
清晰、穩定、有餘韻
+
36-48 pt
weight 500
line 1.10
+
+
+
Section H1
+
色彩系統 · Color
+
18-22 pt
weight 500
line 1.20
+
+
+
Subsection H2
+
畫布 · Canvas
+
14-16 pt
weight 500
line 1.25
+
+
+
Item H3
+
Tag Tints · rgba 對照
+
12-13 pt
weight 500
line 1.30
+
+
+
Body Lead
+
面向印刷文件的約束系統,目標是穩定、清晰、易讀。
+
11 pt
weight 400
line 1.55
+
+
+
Body
+
油墨藍全文件不超過 5% 面積,超過就從克制變成堆砌;所有灰色都有暖黃底色,R ≈ G > B 是基本規律。
+
9.5-10 pt
weight 400
line 1.55
+
+
+
Body Dense
+
密排正文用於履歷、一頁紙、名片、索引卡,中文 pt 字號下 1.4-1.45 的行距最舒服,英文網頁的 1.6 放進來會顯得鬆散。
+
9-9.2 pt
weight 400
line 1.40
+
+
+
Caption
+
圖註、腳註、說明文字,顏色用 olive 或 stone 降一級。
+
8.5-9 pt
weight 400
line 1.45
+
+
+
Label
+
Design System · v1.9.4
+
7.5-8 pt
weight 600
line 1.35
+
+ +
+
+ + +
+
+

05 · Spacing & Shape

+

節奏與形

+

基礎單位 4pt,密度越高 margin 越小,場合越正式 margin 越大。

+
+ + + + + + + + + + + + +
ScaleValueUse
xs2-3 pt同行內元素
sm4-5 pttag padding · 緊湊佈局
md8-10 pt元件內部
lg16-20 pt元件之間 · 卡片 padding
xl24-32 ptsection 標題 margin
2xl40-60 pt大 section 之間
3xl80-120 pt長文件章節之間
+ +

圓角尺度 · Radii

+
+
4 pt
極緊
+
6 pt
代碼塊
+
8 pt
預設卡片
+
12 pt
容器
+
16 pt
特色卡片
+
24 pt
大容器
+
32 pt
Hero
+
+ +

深度 · 陰影三法

+

Kami 不用傳統硬陰影,深度交給 ring shadow、whisper shadow 與明暗交替。

+
+
+

Ring Shadow

+

0 0 0 1pt var(--ring-warm)
 
按鈕 · 卡片 hover

+
+
+

Whisper Shadow

+

0 4pt 24pt rgba(0,0,0,0.05)
 
極輕浮起 · 特色卡

+
+
+

Light ⇌ Dark

+

parchment ⇌ deep-dark
 
section 級別 · 對比最明顯

+
+
+
+ + +
+
+

06 · Components

+

原子元件

+

元件不求多,只保留能解決具體問題的一組。

+
+ +
+ +
+

Buttons

+

ring shadow 替代硬投影 · 8pt 圓角

+
+ Primary CTA + Secondary + Ghost +
+
+ +
+

Tags · 三檔

+

實色 hex · 從弱到強選

+
+ 極淡 0.08 + 標準 0.18 + 筆刷漸變 +
+
+ +
+

Quote

+

左 2pt 品牌實線 + olive 色

+
一千個 no 換一個 yes,寧可清淡,不可濃豔。
+
+ +
+

Metric

+

serif 數字 + sans 標籤 · tabular-nums

+
+
6文件類型
+
1強調色
+
8條規則
+
+
+ +
+

Section Title

+

serif 500 · 15pt · 純字號層級,無裝飾

+
+ 核心項目 · Selected Works +
+
+ +
+

Dash List

+

短橫線代替圓點 · 更書卷氣

+
    +
  • 暖米紙底,不用純白
  • +
  • 油墨藍點綴,不引第二色
  • +
  • Serif 承擔權威
  • +
+
+ +
+

Code Block

+

ivory 底 + 0.5pt border + 6pt 圓角 + mono 字體

+
// 暖米紙 · 一種克制的溫度
+canvas   = parchment; accent = ink_blue
+palette  = warm_neutrals − cool_grays
+document = serif × hierarchy + generous_space
+beauty   = constraints × intention ÷ noise
+
+ +
+

Featured Card

+

whisper shadow + 16pt 圓角

+
+

Tesla 公司介紹 · One-Pager

+

單頁 A4 · 中文版 · 4 張指標卡 + 3 段正文 + 時間線

+
+ 中文 + A4 × 1 + Ink Blue +
+
+
+ +
+
+ + +
+
+

07 · Diagrams

+

內聯圖表

+

十八種內聯 SVG 圖表,覆蓋架構、流程、資料三大場景。直接告訴 Claude 需要哪種圖,會自動嵌入文件,配色與字體沿用 Kami 設計語言。

+
+
+ +
+
架構圖 · Architecture
+ + + + + + + + + CLIENT + Browser + + + SERVICE + API + + + DATA + Database + +

系統元件與連接關係,一個焦點節點

+
+ +
+
流程圖 · Flowchart
+ + + + + Start + + + + + Valid? + + + Yes + + + Process + + + No + + + Reject + +

決策分支,Yes / No 路徑,焦點為成功路徑

+
+ +
+
柱狀圖 · Bar Chart
+ + + + + + + Q1 + Q2 + Q3 + Q4 + 180 + 220 + 195 + 240 + +

季度營收對比,可選顏色漸變或單色

+
+ +
+
環形圖 · Donut Chart
+ + + + + + 60% + 主營 + + 主營業務 · 60% + + 服務收入 · 25% + + 其他來源 · 15% + +

營收結構拆解,支援 3-6 個分段

+
+
+
+ + +
+
+

08 · Decision Lookup

+

決策速查

+

遇到「該用什麼」時先看這張表,不在表裡就回到基礎原則。

+
+ + + + + + + + + + + + + +
要做什麼怎麼做
大標題serif 500,字號按層級,line-height 1.10-1.30
正文閱讀sans 4009.5-10 pt,line-height 1.55
強調一個數字color: var(--brand),不要粗體
分隔兩段內容2.5pt 品牌色左側豎線,或 0.5pt 暖灰虛線
引用某人的話2pt 品牌色實線 + olive 色
展示代碼ivory 底 + 0.5pt border + 6pt 圓角 + mono 字體
區分主次按鈕Primary 品牌色填充 + ivory 字;Secondary warm-sand + charcoal
標註某張特殊卡片border: 0.5pt solid var(--brand)border-left: 3pt
章節開始serif 500 · 15pt · 純字號層級,無額外裝飾
文件封面單頁 Display 字號 + 右對齊作者/日期 + 大量留白
資料卡ivory 底 + 8pt 圓角 + serif 大數字(油墨藍) + sans 小標籤
+
+ + +
+
+

09 · Anti-Patterns

+

反面示例

+

破例可以,但理由要足夠清楚。

+
+ +
+
+

Don't

+

頁面背景用 #ffffff 純白或 #f3f4f6 冷灰,會讓版面變硬,也削弱 Kami 的暖米紙氣質。

+
+
+

Do

+

始終使用 #f5f4ed parchment,列印時把 @page background 也設為同色,避免白邊。

+
+ +
+

Don't

+

Tag 用 rgba(201,100,66,0.18),WeasyPrint 會讓 padding 區與字形區透明度疊加,形成雙層矩形。

+
+
+

Do

+

查 tint 對照表,改用等效實色 #E4ECF5,顏色穩定,PDF 也乾淨。

+
+ +
+

Don't

+

標題用 font-weight: 600 或更粗的合成 bold,筆畫容易變糊,版面也會顯得粗重。

+
+
+

Do

+

正文 400,標題 500(真實 W05 字重);需要更強存在感時,用字號或品牌色左側豎線,不靠合成粗體。

+
+ +
+

Don't

+

box-shadow 硬投影 0 2px 8px rgba(0,0,0,0.3) 觀感偏硬,印刷時也容易發黑。

+
+
+

Do

+

用 ring shadow (0 0 0 1pt)、whisper (rgba 0.05),或直接用明暗交替建立層次。

+
+
+
+ + +
+
+

10 · Background

+

設計緣起

+
+
+

我喜歡美股投資,經常讓 Claude 寫研究報告。每次出來的東西都是同一種預設文件的樣子:灰撲撲的,結構不清晰,格式老舊,換個對話就換一套排版,沒有一份讓人想讀下去。於是我開始一條一條地調字體、配色、間距,直到報告變成一份自己真正願意看的頁面。

+

後來要去做《你不知道的 Agent:原理、架構與工程實踐》分享,手上已經有文件,不想再做 PPT,就用 Claude Design 按自己的設計風格來排版,反覆調了很多輪,最後慢慢滿意了。加入了 inline SVG 圖表、統一暖色調、也收緊了編輯節奏,做著做著覆蓋了我常用的文件場景,就把這個過程繼續抽象了一下,成了現在的 kami:一套交給任何 agent 都能放心出活的安靜設計系統。

+
+
+ + +
+
+

11 · 常見問題

+

FAQ

+
+
+
+
Kami 是什麼?
+
一個面向 AI agent 的文件設計系統。一種強調色、襯線層級、暖色羊皮紙畫布。給任何 LLM agent 一段描述,就能得到穩定的排版輸出。也可以作為視覺設計簡報給到 Claude Design 或 GPT Canvas 這類圖像渲染工具。
+
+
+
能生成什麼?
+
八種文件範本:一頁紙、長文件、信件、作品集、履歷、簡報、個股研報、更新日誌,外加一套落地頁系統(中、英、韓文)。另有 18 種內聯 SVG 圖表用於視覺化說明。輸出為 HTML,可匯出為 PDF、PNG 或可編輯的 PPTX 簡報。
+
+
+
怎麼安裝?
+
Claude Code(v2.1.142+):/plugin marketplace add tw93/kami,然後 /plugin install kami@kami。Codex:codex plugin marketplace add tw93/kami,然後 codex plugin add kami@kami。讀取 ~/.agents 的通用 agent:npx skills add tw93/kami/plugins/kami -a universal -g -y。Claude Desktop:從 GitHub Releases 下載 release asset kami.zip,不要下載 Source code 壓縮包,然後在自訂 > Skills 中上傳即可。
+
+
+
支援哪些語言?
+
英文、中文、日文、韓文。每種語言使用專屬襯線字體:英文用 Charter,中文用 TsangerJinKai02,日文用 YuMincho,韓文用 Source Han Serif K。字距、行高、字號都按語言做了印刷級調優。日文與韓文為最佳努力的 CJK 路徑,交付前會做視覺 QA。
+
+
+
+ + +
+
+ + + + + +
+

Kami · 紙

+

好內容,值得好版面。

+
+
+
+
GitHub  ·  One-Pager · Long Doc · Letter · 作品集 · Resume · Slides
+
+ Serif 承擔權威,sans 承擔功能,暖灰承擔節奏,油墨藍承擔焦點。 +
+
+
+ +
+ + + diff --git a/index-zh.html b/index-zh.html new file mode 100644 index 0000000..92078d0 --- /dev/null +++ b/index-zh.html @@ -0,0 +1,732 @@ + + + + + Kami 紙:面向 AI Agent 的文档设计系统 + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+ Design System · v1.9.4 · 2026.07 + + + + + + + + + + + + + +
+

Kami

+

好内容,值得好版面。Kami 是一套适合 AI 时代的排版设计系统,让文档更清晰好读,也容易记住。

+
+ Canvas#f5f4ed + Accent#1B365D + SerifTsangerJinKai / Charter + Sans= Serif (一页一字体) +
+
+ + +
+
+

00 · See it

+

输出样本

+

把文档需求交给 Claude。无论是一页纸、长文、正式信件、作品集、简历还是幻灯片,Kami 都用少量清晰规则,把提示词变成可交付的排版。同一份 brief 也能 交给绘图模型,README 里附了样图和可复制的提示词。

+
+
+
+ Tesla Q1 2026 财报点评 +

Equity Report · 中文

+

Tesla Q1 2026 财报点评

+
+
+ Musk resume +

Resume · English

+

创始人简历,2 页

+
+
+ Kami 打印一页纸 +

一页纸 · 中文

+

Kami 介绍,白底打印版,1 页

+
+
+ Agent slides +

Slides · English

+

Agent keynote, 6 slides

+
+
+
+ + +
+
+

Landing Pages

+

用 Kami 搭建

+

同一套落地页模板用于三款不同产品,一组约束规则,三种截然不同的用途。

+
+
+
+ Kami 官网 +

Kami

+

设计系统主页

+
+
+ Luo 落文官网 +

Luo 落文

+

CJK 阅读字体

+
+
+ Mole 官网 +

Mole 鼹

+

macOS 系统工具

+
+
+
+ + +
+
+

01 · Usage

+

安装与调用

+

直接告诉 Claude 你要什么,比如「帮我排版一份白皮书」「做一份简历」「帮我做一份作品集」,skill 会自动触发,无需斜杠命令。

+
+
# Claude Code (v2.1.142+)
+/plugin marketplace add tw93/kami
+/plugin install kami@kami
+
+# Codex plugin marketplace
+codex plugin marketplace add tw93/kami
+codex plugin add kami@kami
+

读取 ~/.agents/ 的通用 agent:npx skills add tw93/kami/plugins/kami -a universal -g -y。这个 plugin 路径暴露生成好的轻量 skill 包;直接用 tw93/kami 只会装到 SKILL.md。

+

Claude Desktop:下载 release asset kami.zip,不要下载 GitHub 的 Source code 压缩包,然后在 Customize > Skills > "+" > Create skill 中直接上传。

+
+ + +
+
+

02 · Manifesto

+

设计原则

+
+

暖米纸底,油墨蓝点缀,serif 承担层级,硬阴影与花哨配色都退后;这套系统面向印刷文档,负责把内容收束成稳定、清晰、易读的版面。羊皮纸底为默认,另有可选的白底版本,把任意文档切到白底以适配打印机,暖意则沉入卡片与表格。

+
    +
  1. 1

    页面背景 parchment #f5f4ed,不用纯白

  2. +
  3. 2

    强调色只有油墨蓝 #1B365D,不引入第二种彩色

  4. +
  5. 3

    所有灰色暖调 (yellow-brown undertone),禁止冷蓝灰

  6. +
  7. 4

    英文 serif 通吃标题和正文,中文标题用 serif、正文用 sans

  8. +
  9. 5

    Serif 正文 400,标题 500,不用合成 bold

  10. +
  11. 6

    行距三档:紧凑标题 1.1–1.3 / 密排 1.4–1.45 / 阅读 1.5–1.55

  12. +
  13. 7

    Tag 背景必须实色 hex,禁止 rgba(WeasyPrint 双层矩形 bug)

  14. +
  15. 8

    阴影只用 ring 或 whisper shadow,不用硬 drop shadow

  16. +
+
+ + +
+
+

03 · Color

+

暖调克制

+

唯一强调色、纯暖调中性灰、零冷色;油墨蓝全文档不超过 5% 面积,超过就从克制变成堆砌。

+
+ +

画布 · Canvas

+
+

Parchment

页面底色,也是整套设计的温度来源

#F5F4ED
+

Ivory

卡片 · 浮起容器

#FAF9F5
+

Warm Sand

按钮默认背景 · 交互面

#E8E6DC
+

Deep Dark

深色主题页面底,不取纯黑,保留一点橄榄底色

#141413
+
+ +

主调 · Brand

+
+

Ink Blue

主色 · CTA · quote 竖线 · 章节标题

#1B365D
+

Ink Light

深色底上的链接 · 亮变体

#2D5A8A
+

Dark Surface

深色主题容器 · 暖炭灰

#30302E
+

Error

错误,深暖红,不刺眼

#B53333
+
+ +

中性 · Warm Neutrals

+
+

Near Black

#141413
+

Dark Warm

#3D3D3A
+

Olive

#504e49
+

Stone

#6b6a64
+
+ +

Tag Tints · rgba → 实色对照

+

这里列的是油墨蓝 #1B365D 叠加在 parchment 上的等效实色;Tag 背景不使用 rgba(),避免 WeasyPrint 的双层矩形问题。

+
+
 Tag sample

0.08

#EEF2F7
+
 Tag sample

0.14

#E4ECF5
+
DefaultTag sample

0.18

#E4ECF5
+
 Tag sample

0.22

#D0DCE9
+
 Tag sample

0.30

#D6E1EE
+
+
+ + +
+
+

04 · Typography

+

字体系统

+

Serif 承担层级,sans 承担功能;serif 正文 400,标题 500。

+
+ +
+
+

紙 Aa

+

Serif · 标题

+

TsangerJinKai02 / Charter

+

承担标题、引语与数字强调,中文取仓耳今楷,英文取 Charter。

+
+
+

</>

+

Mono · 代码

+

JetBrains Mono

+

用于代码块、版本号、hex 值与等宽数字,字号较正文低一档。

+
+
+ +

层级(印刷品 pt · 屏幕 px ≈ pt × 1.33)

+
+ +
+
Display
+
高质量文档读起来
清晰、稳定、有余韵
+
36–48 pt
weight 500
line 1.10
+
+
+
Section H1
+
色彩系统 · Color
+
18–22 pt
weight 500
line 1.20
+
+
+
Subsection H2
+
画布 · Canvas
+
14–16 pt
weight 500
line 1.25
+
+
+
Item H3
+
Tag Tints · rgba 对照
+
12–13 pt
weight 500
line 1.30
+
+
+
Body Lead
+
面向印刷文档的约束系统,目标是稳定、清晰、易读。
+
11 pt
weight 400
line 1.55
+
+
+
Body
+
油墨蓝全文档不超过 5% 面积,超过就从克制变成堆砌;所有灰色都有暖黄底色,R ≈ G > B 是基本规律。
+
9.5–10 pt
weight 400
line 1.55
+
+
+
Body Dense
+
密排正文用于简历、一页纸、名片、索引卡,中文 pt 字号下 1.4–1.45 的行距最舒服,英文网页的 1.6 放进来会显得松散。
+
9–9.2 pt
weight 400
line 1.40
+
+
+
Caption
+
图注、脚注、说明文字,颜色用 olive 或 stone 降一级。
+
8.5–9 pt
weight 400
line 1.45
+
+
+
Label
+
Design System · v1.9.4
+
7.5–8 pt
weight 600
line 1.35
+
+ +
+
+ + +
+
+

05 · Spacing & Shape

+

节奏与形

+

基础单位 4pt,密度越高 margin 越小,场合越正式 margin 越大。

+
+ + + + + + + + + + + + +
ScaleValueUse
xs2–3 pt同行内元素
sm4–5 pttag padding · 紧凑布局
md8–10 pt组件内部
lg16–20 pt组件之间 · 卡片 padding
xl24–32 ptsection 标题 margin
2xl40–60 pt大 section 之间
3xl80–120 pt长文档章节之间
+ +

圆角尺度 · Radii

+
+
4 pt
极紧
+
6 pt
代码块
+
8 pt
默认卡片
+
12 pt
容器
+
16 pt
特色卡片
+
24 pt
大容器
+
32 pt
Hero
+
+ +

深度 · 阴影三法

+

Kami 不用传统硬阴影,深度交给 ring shadow、whisper shadow 与明暗交替。

+
+
+

Ring Shadow

+

0 0 0 1pt var(--ring-warm)
 
按钮 · 卡片 hover

+
+
+

Whisper Shadow

+

0 4pt 24pt rgba(0,0,0,0.05)
 
极轻浮起 · 特色卡

+
+
+

Light ⇌ Dark

+

parchment ⇌ deep-dark
 
section 级别 · 对比最明显

+
+
+
+ + +
+
+

06 · Components

+

原子组件

+

组件不求多,只保留能解决具体问题的一组。

+
+ +
+ +
+

Buttons

+

ring shadow 替代硬投影 · 8pt 圆角

+
+ Primary CTA + Secondary + Ghost +
+
+ +
+

Tags · 三档

+

实色 hex · 从弱到强选

+
+ 极淡 0.08 + 标准 0.18 + 笔刷渐变 +
+
+ +
+

Quote

+

左 2pt 品牌实线 + olive 色

+
一千个 no 换一个 yes,宁可清淡,不可浓艳。
+
+ +
+

Metric

+

serif 数字 + sans 标签 · tabular-nums

+
+
6文档类型
+
1强调色
+
8条规则
+
+
+ +
+

Section Title

+

serif 500 · 15pt · 纯字号层级,无装饰

+
+ 核心项目 · Selected Works +
+
+ +
+

Dash List

+

短横线代替圆点 · 更书卷气

+
    +
  • 暖米纸底,不用纯白
  • +
  • 油墨蓝点缀,不引第二色
  • +
  • Serif 承担权威
  • +
+
+ +
+

Code Block

+

ivory 底 + 0.5pt border + 6pt 圆角 + mono 字体

+
// 暖米纸 · 一种克制的温度
+canvas   = parchment; accent = ink_blue
+palette  = warm_neutrals − cool_grays
+document = serif × hierarchy + generous_space
+beauty   = constraints × intention ÷ noise
+
+ +
+

Featured Card

+

whisper shadow + 16pt 圆角

+
+

Tesla 公司介绍 · One-Pager

+

单页 A4 · 中文版 · 4 张指标卡 + 3 段正文 + 时间线

+
+ 中文 + A4 × 1 + Ink Blue +
+
+
+ +
+
+ + +
+
+

07 · Diagrams

+

内联图表

+

十八种内联 SVG 图表,覆盖架构、流程、数据三大场景。直接告诉 Claude 需要哪种图,会自动嵌入文档,配色与字体沿用 Kami 设计语言。

+
+
+ +
+
架构图 · Architecture
+ + + + + + + + + CLIENT + Browser + + + SERVICE + API + + + DATA + Database + +

系统组件与连接关系,一个焦点节点

+
+ +
+
流程图 · Flowchart
+ + + + + Start + + + + + Valid? + + + Yes + + + Process + + + No + + + Reject + +

决策分支,Yes / No 路径,焦点为成功路径

+
+ +
+
柱状图 · Bar Chart
+ + + + + + + Q1 + Q2 + Q3 + Q4 + 180 + 220 + 195 + 240 + +

季度营收对比,可选颜色渐变或单色

+
+ +
+
环形图 · Donut Chart
+ + + + + + 60% + 主营 + + 主营业务 · 60% + + 服务收入 · 25% + + 其他来源 · 15% + +

营收结构拆解,支持 3–6 个分段

+
+
+
+ + +
+
+

08 · Decision Lookup

+

决策速查

+

遇到“该用什么”时先看这张表,不在表里就回到基础原则。

+
+ + + + + + + + + + + + + +
要做什么怎么做
大标题serif 500,字号按层级,line-height 1.10–1.30
正文阅读sans 4009.5–10 pt,line-height 1.55
强调一个数字color: var(--brand),不要粗体
分隔两段内容2.5pt 品牌色左侧竖线,或 0.5pt 暖灰虚线
引用某人的话2pt 品牌色实线 + olive 色
展示代码ivory 底 + 0.5pt border + 6pt 圆角 + mono 字体
区分主次按钮Primary 品牌色填充 + ivory 字;Secondary warm-sand + charcoal
标注某张特殊卡片border: 0.5pt solid var(--brand)border-left: 3pt
章节开始serif 500 · 15pt · 纯字号层级,无额外装饰
文档封面单页 Display 字号 + 右对齐作者/日期 + 大量留白
数据卡ivory 底 + 8pt 圆角 + serif 大数字(油墨蓝) + sans 小标签
+
+ + +
+
+

09 · Anti-Patterns

+

反面示例

+

破例可以,但理由要足够清楚。

+
+ +
+
+

Don't

+

页面背景用 #ffffff 纯白或 #f3f4f6 冷灰,会让版面变硬,也削弱 Kami 的暖米纸气质。

+
+
+

Do

+

始终使用 #f5f4ed parchment,打印时把 @page background 也设为同色,避免白边。

+
+ +
+

Don't

+

Tag 用 rgba(201,100,66,0.18),WeasyPrint 会让 padding 区与字形区透明度叠加,形成双层矩形。

+
+
+

Do

+

查 tint 对照表,改用等效实色 #E4ECF5,颜色稳定,PDF 也干净。

+
+ +
+

Don't

+

标题用 font-weight: 600 或更粗的合成 bold,笔画容易变糊,版面也会显得粗重。

+
+
+

Do

+

正文 400,标题 500(真实 W05 字重);需要更强存在感时,用字号或品牌色左侧竖线,不靠合成粗体。

+
+ +
+

Don't

+

box-shadow 硬投影 0 2px 8px rgba(0,0,0,0.3) 观感偏硬,印刷时也容易发黑。

+
+
+

Do

+

用 ring shadow (0 0 0 1pt)、whisper (rgba 0.05),或直接用明暗交替建立层次。

+
+
+
+ + +
+
+

10 · Background

+

设计缘起

+
+
+

我喜欢美股投资,经常让 Claude 写研究报告。每次出来的东西都是同一种默认文档的样子:灰扑扑的,结构不清晰,格式老旧,换个对话就换一套排版,没有一份让人想读下去。于是我开始一条一条地调字体、配色、间距,直到报告变成一份自己真正愿意看的页面。

+

后来要去做《你不知道的 Agent:原理、架构与工程实践》分享,手上已经有文档,不想再做 PPT,就用 Claude Design 按自己的设计风格来排版,反复调了很多轮,最后慢慢满意了。加入了 inline SVG 图表、统一暖色调、也收紧了编辑节奏,做着做着覆盖了我常用的文档场景,就把这个过程继续抽象了一下,成了现在的 kami:一套交给任何 agent 都能放心出活的安静设计系统。

+
+
+ + +
+
+

11 · 常见问题

+

FAQ

+
+
+
+
Kami 是什么?
+
一个面向 AI agent 的文档设计系统。一种强调色、衬线层级、暖色羊皮纸画布。给任何 LLM agent 一段描述,就能得到稳定的排版输出。也可以作为视觉设计简报给到 Claude Design 或 GPT Canvas 这类图像渲染工具。
+
+
+
能生成什么?
+
八种文档模板:一页纸、长文档、信件、作品集、简历、幻灯片、个股研报、更新日志,外加一套落地页系统(中、英、韩文)。另有 18 种内联 SVG 图表用于可视化说明。输出为 HTML,可导出为 PDF、PNG 或可编辑的 PPTX 幻灯片。
+
+
+
怎么安装?
+
Claude Code(v2.1.142+):/plugin marketplace add tw93/kami,然后 /plugin install kami@kami。Codex:codex plugin marketplace add tw93/kami,然后 codex plugin add kami@kami。读取 ~/.agents 的通用 agent:npx skills add tw93/kami/plugins/kami -a universal -g -y。Claude Desktop:从 GitHub Releases 下载 release asset kami.zip,不要下载 Source code 压缩包,然后在自定义 > Skills 中上传即可。
+
+
+
支持哪些语言?
+
英文、中文、日文、韩文。每种语言使用专属衬线字体:英文用 Charter,中文用 TsangerJinKai02,日文用 YuMincho,韩文用 Source Han Serif K。字距、行高、字号都按语言做了印刷级调优。
+
+
+
+ + +
+
+ + + + + +
+

Kami · 紙

+

好内容,值得好版面。

+
+
+
+
GitHub  ·  One-Pager · Long Doc · Letter · 作品集 · Resume · Slides
+
+ Serif 承担权威,sans 承担功能,暖灰承担节奏,油墨蓝承担焦点。 +
+
+
+ +
+ + + diff --git a/index.html b/index.html new file mode 100644 index 0000000..f6751fb --- /dev/null +++ b/index.html @@ -0,0 +1,724 @@ + + + + + + Kami: Document Design System for AI Agents + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+ Design System · v1.9.4 · 2026.07 + + + + + + + + + + + + + +
+

Kami

+

Good content deserves good paper. Kami is a layout design system for the AI era, making documents clear, readable, and memorable.

+
+ Canvas#f5f4ed + Accent#1B365D + SerifCharter / TsangerJinKai + Sans= Serif (single font per page) +
+
+ + +
+
+

00 · See it

+

Output Samples

+

Give Claude the brief: one-pagers, long docs, letters, portfolios, resumes, slides all become polished layouts from one small, reliable rule set. The same brief also travels to image renderers, with the copy-paste prompt and sample outputs in the README.

+
+
+
+ Tesla equity report +

Equity Report

+

Tesla Q1 2026 earnings analysis

+
+
+ Musk resume +

Resume

+

Founder CV, 2 pages

+
+
+ Kami print one-pager +

One-Pager

+

Kami intro, white-paper print, 1 page

+
+
+ Agent slides +

Slides

+

Agent keynote, 6 slides

+
+
+
+ + +
+
+

Landing Pages

+

Built with Kami

+

The same landing-page template applied to three different products. One constraint set, three distinct purposes.

+
+
+
+ Kami landing page +

Kami

+

Design system homepage

+
+
+ Luo landing page +

Luo

+

CJK reading font, Chinese

+
+
+ Mole landing page +

Mole

+

macOS system utility

+
+
+
+ + +
+
+

01 · Usage

+

Install & Invoke

+

Tell Claude what you need, for example "build me a resume", "make a one-pager for my startup", or "design a slide deck for my talk". The skill auto-triggers, no slash command needed.

+
+
# Claude Code (v2.1.142+)
+/plugin marketplace add tw93/kami
+/plugin install kami@kami
+
+# Codex plugin marketplace
+codex plugin marketplace add tw93/kami
+codex plugin add kami@kami
+

Generic agents that read from ~/.agents/: npx skills add tw93/kami/plugins/kami -a universal -g -y. The plugin path exposes the generated lightweight skill bundle; a bare tw93/kami installs only SKILL.md.

+

Claude Desktop: download the release asset kami.zip, not GitHub's Source code ZIP, then upload it in Customize > Skills > "+" > Create skill.

+
+ + +
+
+

02 · Manifesto

+

Design Principles

+
+

Warm parchment canvas, ink blue as the sole accent, serif carries hierarchy, while hard shadows and flashy palettes recede. This system is built for printed matter: stable, clear, and composed. Parchment is the default; an opt-in white-paper variant prints any document on a white background while keeping the warmth in cards and tables.

+
    +
  1. 1

    Page background is parchment #f5f4ed, never pure white

  2. +
  3. 2

    Accent color is ink blue #1B365D only; no second chromatic hue

  4. +
  5. 3

    All grays are warm, yellow-brown undertone; no cool blue-grays

  6. +
  7. 4

    English uses serif for headlines and body; Chinese uses serif headlines and sans body

  8. +
  9. 5

    Serif body at 400, headings at 500. Avoid synthetic bold

  10. +
  11. 6

    Three line-height bands: tight titles 1.1-1.3 / dense 1.4-1.45 / reading 1.5-1.55

  12. +
  13. 7

    Tag backgrounds must be solid hex; no rgba, WeasyPrint double-rectangle bug

  14. +
  15. 8

    Shadows: ring or whisper only, no hard drop shadows

  16. +
+
+ + +
+
+

03 · Color

+

Warm Restraint

+

One accent + warm neutrals + zero cool colors. Ink blue covers no more than 5% of any page. Beyond that is clutter, not restraint.

+
+ +

Canvas

+
+

Parchment

Page background, the emotional foundation

#F5F4ED
+

Ivory

Cards / elevated surfaces

#FAF9F5
+

Warm Sand

Button default / interactive surfaces

#E8E6DC
+

Deep Dark

Dark theme page base, not pure black

#141413
+
+ +

Brand

+
+

Ink Blue

Primary color · CTA · quote bar · section overline

#1B365D
+

Ink Light

Links on dark surfaces · lighter variant

#2D5A8A
+

Dark Surface

Dark theme container · warm charcoal

#30302E
+

Error

Error state, deep warm red

#B53333
+
+ +

Warm Neutrals

+
+

Near Black

#141413
+

Dark Warm

#3D3D3A
+

Olive

#504e49
+

Stone

#6b6a64
+
+ +

Tag Tints: rgba to solid hex

+

Solid hex equivalents of ink blue #1B365D on parchment. Tags use solid hex instead of rgba() to avoid a WeasyPrint double-rectangle rendering bug.

+
+
 Tag sample

0.08

#EEF2F7
+
 Tag sample

0.14

#E4ECF5
+
DefaultTag sample

0.18

#E4ECF5
+
 Tag sample

0.22

#D0DCE9
+
 Tag sample

0.30

#D6E1EE
+
+
+ + +
+
+

04 · Typography

+

Type System

+

Serif carries hierarchy, sans carries function. Serif body at 400, headings at 500.

+
+ +
+
+

Aa

+

Serif · Headlines + Body

+

Charter / TsangerJinKai02

+

Used for headlines, body text, pull quotes, and numeric emphasis. English uses Charter, Chinese uses TsangerJinKai02.

+
+
+

</>

+

Mono · Code

+

JetBrains Mono

+

Code blocks, version numbers, hex values, tabular figures.

+
+
+ +

Scale (print pt; screen px = pt x 1.33)

+
+
+
Display
+
Good docs read
clear, stable, poised
+
36-48 pt
weight 500
line 1.10
+
+
+
H1 Section
+
Color System · Palette
+
18-22 pt
weight 500
line 1.20
+
+
+
H2 Subsection
+
Canvas · Warm Neutrals
+
14-16 pt
weight 500
line 1.25
+
+
+
H3 Item
+
Tag Tints · Solid Hex
+
12-13 pt
weight 500
line 1.30
+
+
+
Body
+
Ink blue covers no more than 5% of any page. Beyond that is clutter, not restraint. All grays have a yellow-brown undertone: R ≈ G > B is the rule of thumb.
+
9.5-10 pt
weight 400
line 1.55
+
+
+
Caption
+
Figure notes, footnotes, annotation text. Color drops to olive or stone.
+
8.5-9 pt
weight 400
line 1.45
+
+
+
Label
+
Design System · v1.9.4
+
7.5-8 pt
weight 600
line 1.35
+
+
+
+ + +
+
+

05 · Spacing & Shape

+

Rhythm & Form

+

Base unit: 4pt. Denser layouts get smaller margins; more formal documents get larger ones.

+
+ + + + + + + + + + +
ScaleValueUse
xs2-3 ptInline elements
sm4-5 ptTag padding · tight layout
md8-10 ptComponent internals
lg16-20 ptBetween components · card padding
xl24-32 ptSection title margins
2xl40-60 ptBetween major sections
3xl80-120 ptBetween long-doc chapters
+ +

Radii

+
+
4 pt
Tight
+
6 pt
Code block
+
8 pt
Default card
+
12 pt
Container
+
16 pt
Feature card
+
24 pt
Large container
+
32 pt
Hero
+
+ +

Depth: Three Shadow Methods

+

Kami avoids traditional hard shadows. Depth comes from ring shadows, whisper shadows, and light-dark alternation.

+
+
+

Ring Shadow

+

0 0 0 1pt var(--ring-warm)
 
Buttons · card hover

+
+
+

Whisper Shadow

+

0 4pt 24pt rgba(0,0,0,0.05)
 
Gentle elevation · feature cards

+
+
+

Light ⇌ Dark

+

parchment ⇌ deep-dark
 
Section-level · strongest contrast

+
+
+
+ + +
+
+

06 · Components

+

Atomic Modules

+

A small fixed set, kept only where it solves a concrete document problem.

+
+ +
+
+

Buttons

+

ring shadow instead of hard drop · 8pt radius

+
+ Primary CTA + Secondary + Ghost +
+
+
+

Tags, three levels

+

solid hex · pick from weak to strong

+
+ Light 0.08 + Standard 0.18 + Brush gradient +
+
+
+

Quote

+

left 2pt brand solid + olive text

+
A thousand no's for every yes, prefer clarity over decoration.
+
+
+

Metric

+

serif number + sans label · tabular-nums

+
+
8Doc types
+
1Accent
+
8Rules
+
+
+
+

Section Title

+

serif 500 · 15pt · size-led hierarchy, no decoration

+
+ Selected Works · Projects +
+
+
+

Dash List

+

dash instead of bullet · editorial tone

+
    +
  • Warm parchment, never pure white
  • +
  • Ink blue accent, no second color
  • +
  • Serif carries authority
  • +
+
+
+

Code Block

+

ivory bg + 0.5pt border + 6pt radius

+
// warmth from restraint, not from color
+canvas   = parchment; accent = ink_blue
+palette  = warm_neutrals − cool_grays
+document = serif × hierarchy + generous_space
+beauty   = constraints × intention ÷ noise
+
+
+

Featured Card

+

whisper shadow + 16pt radius

+
+

Tesla Company Profile · One-Pager

+

Single A4 page · 4 metric cards + 3 body sections + timeline

+
+ English + A4 x 1 + Ink Blue +
+
+
+
+
+ + +
+
+

07 · Diagrams

+

Inline Charts

+

Eighteen inline SVG diagram types covering architecture, process, and data chart scenarios. Tell Claude which type you need and it embeds directly into the document, colors and fonts following the Kami design language.

+
+
+ +
+
Architecture
+ + + + + + + + + CLIENT + Browser + + + SERVICE + API + + + DATA + Database + +

System components and connections, one focal node

+
+ +
+
Flowchart
+ + + + Start + + + + Valid? + + + Yes + + Process + + + No + + Reject + +

Decision branches, yes/no paths, focal on success

+
+ +
+
Bar Chart
+ + + + + + + Q1 + Q2 + Q3 + Q4 + 180 + 220 + 195 + 240 + +

Quarterly revenue comparison, gradient or single color

+
+ +
+
Donut Chart
+ + + + + + 60% + Primary + + Core revenue · 60% + + Services · 25% + + Other · 15% + +

Revenue structure breakdown, supports 3–6 segments

+
+
+
+ + +
+
+

08 · Decision Lookup

+

Quick Reference

+

When in doubt about what to use, consult this table. If it's not here, go back to first principles.

+
+ + + + + + + + + + + + + +
TaskHow
Large headlineserif 500, size by hierarchy, line-height 1.10-1.30
Reading bodyserif 400, 9.5-10 pt, line-height 1.55
Emphasize a numbercolor: var(--brand), not bold
Separate two sections2.5pt brand left bar, or 0.5pt warm-gray dashed
Quote someoneLeft 2pt brand solid + olive text
Show codeIvory bg + 0.5pt border + 6pt radius + mono font
Primary vs secondaryPrimary: brand fill + ivory text; Secondary: warm-sand + charcoal
Mark a special cardborder: 0.5pt solid var(--brand) or border-left: 3pt
Begin a sectionSerif 500 · 15pt · size-led hierarchy, no decoration needed
Document coverSingle page, Display size + right-aligned author/date + generous whitespace
Data cardIvory bg + 8pt radius + serif large number, ink blue + sans small label
+
+ + +
+
+

09 · Anti-Patterns

+

What to Avoid

+

Exceptions are allowed, but the reason should be explicit.

+
+ +
+
+

Don't

+

Use #ffffff pure white or #f3f4f6 cool gray as page background. It makes the page feel brittle and weakens the warm parchment character.

+
+
+

Do

+

Always use #f5f4ed parchment. Set @page background to the same color to avoid white edges when printing.

+
+
+

Don't

+

Use rgba(27,54,93,0.18) for tags. WeasyPrint renders padding and glyph areas at different opacity, creating double rectangles.

+
+
+

Do

+

Use the tint lookup table for equivalent solid hex #E4ECF5. The rendering stays clean and the color remains stable.

+
+
+

Don't

+

Set headlines to font-weight: 600 or heavier synthetic bold. Synthetic bold blurs strokes and degrades typographic quality.

+
+
+

Do

+

Body 400, headings 500 (real W05). For more presence, use size or a brand left bar, never synthetic bold.

+
+
+

Don't

+

Use hard drop shadow 0 2px 8px rgba(0,0,0,0.3). Visually heavy and likely to print as dark blobs.

+
+
+

Do

+

Ring shadow 0 0 0 1pt or whisper rgba 0.05, or simply alternate light and dark sections.

+
+
+
+ + +
+
+

10 · Background

+

Design Origins

+
+
+

I like investing in US equities and ask Claude to write research reports all the time. Every output landed in the same default-doc look: gray, flat, a different layout each session. The structure was hard to scan, the formatting felt dated, and nothing about the page made me want to keep reading. So I started fixing the typography, the palette, the spacing, one rule at a time, until the report became a page I actually enjoyed.

+

Later I needed to present "The Agent You Don't Know: Principles, Architecture and Engineering Practice." I already had the document and didn't want to build slides from scratch, so I used Claude Design to lay it out in my own style, tweaked it round after round, and eventually got it to a place I was happy with. That process added inline SVG charts, a unified warm palette, and a tighter editorial rhythm. It kept growing until it covered every document I regularly ship, so I kept abstracting the process, and it became kami: one quiet design system I can hand to any agent and trust the output.

+
+
+ + +
+
+

11 · Questions

+

FAQ

+
+
+
+
What is Kami?
+
A constraint-based design system for AI-generated documents. One accent color, serif-led hierarchy, warm parchment canvas. Give any LLM agent a brief, get a composed layout back. It also doubles as a visual brief you can hand to image renderers like Claude Design or GPT Canvas.
+
+
+
What can it produce?
+
Eight document templates: one-pagers, long documents, letters, portfolios, resumes, slides, equity reports, and changelogs, plus a landing-page system (EN + CN + KO). Plus 18 inline SVG diagram types for visual explanations. Outputs are HTML that can be exported to PDF, PNG, or editable PPTX slide decks.
+
+
+
How do I set it up?
+
Claude Code (v2.1.142+): /plugin marketplace add tw93/kami then /plugin install kami@kami. Codex: codex plugin marketplace add tw93/kami then codex plugin add kami@kami. Generic agents that read ~/.agents: npx skills add tw93/kami/plugins/kami -a universal -g -y. Claude Desktop: download the release asset kami.zip from GitHub Releases, not the Source code ZIP, and upload it in Customize > Skills. No slash command needed, the skill auto-triggers from natural requests.
+
+
+
What languages does it support?
+
English, Chinese, Japanese, and Korean. Each language uses a dedicated serif font: Charter for English, TsangerJinKai02 for Chinese, YuMincho for Japanese, Source Han Serif K for Korean. Letter-spacing, line-height, and font sizes are tuned per language for print quality. Japanese and Korean are best-effort CJK paths with visual QA before delivery.
+
+
+
+ + +
+
+ + + + + +
+

Kami · 紙

+

Good content deserves good paper.

+
+
+
+
GitHub  ·  One-Pager · Long Doc · Letter · Portfolio · Resume · Slides
+
+ Serif carries authority, sans function, warm grays rhythm, ink blue focus. +
+
+
+ +
+ + + diff --git a/llms.txt b/llms.txt new file mode 100644 index 0000000..80a7e54 --- /dev/null +++ b/llms.txt @@ -0,0 +1,33 @@ +# Kami + +> A warm parchment design system for AI-assisted professional documents. One accent color, serif-led hierarchy, and editorial whitespace. + +## Author +- Name: Tw93 +- Site: https://tw93.fun + +## What is Kami +Kami is a layout design system for the AI era. Give Claude (or any LLM) a brief, and it produces polished documents: one-pagers, resumes, portfolios, slides, long docs, letters, equity reports, and changelogs. The system uses a warm parchment canvas (#f5f4ed), ink-blue accent (#1B365D), and Charter/TsangerJinKai serif fonts. Parchment is the default canvas; an opt-in white-paper variant renders any document on a white background for home or office printers while keeping the warmth in cards and tables. + +## Key Pages +- [English showcase](https://kami.tw93.fun): Design system overview with live demos +- [Chinese showcase](https://kami.tw93.fun/index-zh.html): Chinese version +- [Japanese showcase](https://kami.tw93.fun/index-ja.html): Japanese version (best-effort CJK path) +- [Korean showcase](https://kami.tw93.fun/index-ko.html): Korean version (best-effort CJK path) +- [Traditional Chinese showcase](https://kami.tw93.fun/index-tw.html): Traditional Chinese version +- [GitHub](https://github.com/tw93/kami): Source code and templates + +## Install +- Claude Code (v2.1.142+): `/plugin marketplace add tw93/kami && /plugin install kami@kami` +- Codex plugin marketplace: `codex plugin marketplace add tw93/kami && codex plugin add kami@kami` +- Generic agents (`~/.agents`): `npx skills add tw93/kami/plugins/kami -a universal -g -y` +- Claude Desktop: download the release asset `kami.zip` from GitHub Releases (not the source-code ZIP) and upload it in Skills settings + +## Templates +- 8 document template types: One-pager, Letter, Resume, Long document, Portfolio, Slides, Equity report, Changelog +- 18 diagram types: architecture, architecture board, flowchart, quadrant, bar chart, line chart, donut chart, state machine, timeline, swimlane, tree, layer stack, venn, candlestick, waterfall, sequence, class, ER + +## Links +- GitHub: https://github.com/tw93 +- Blog: https://tw93.fun +- AI Profile: https://tw93.fun/llms-full.txt diff --git a/plugins/kami/.claude-plugin/plugin.json b/plugins/kami/.claude-plugin/plugin.json new file mode 100644 index 0000000..7963fe5 --- /dev/null +++ b/plugins/kami/.claude-plugin/plugin.json @@ -0,0 +1,13 @@ +{ + "name": "kami", + "version": "1.9.4", + "description": "Typeset professional documents and landing pages with the Kami design system.", + "author": { + "name": "Tw93", + "email": "hitw93@gmail.com" + }, + "homepage": "https://github.com/tw93/kami", + "repository": "https://github.com/tw93/kami", + "license": "MIT", + "skills": "./skills/" +} diff --git a/plugins/kami/.codex-plugin/plugin.json b/plugins/kami/.codex-plugin/plugin.json new file mode 100644 index 0000000..b83dcb4 --- /dev/null +++ b/plugins/kami/.codex-plugin/plugin.json @@ -0,0 +1,41 @@ +{ + "name": "kami", + "version": "1.9.4", + "description": "Professional document and landing-page typesetting skill for Codex: resumes, one-pagers, reports, letters, portfolios, slides, and more.", + "author": { + "name": "Tw93", + "email": "hitw93@gmail.com", + "url": "https://github.com/tw93" + }, + "homepage": "https://github.com/tw93/kami", + "repository": "https://github.com/tw93/kami", + "license": "MIT", + "keywords": [ + "codex", + "skills", + "documents", + "typesetting", + "resume", + "slides", + "landing-page" + ], + "skills": "./skills/", + "interface": { + "displayName": "Kami", + "shortDescription": "Typeset polished documents and landing pages", + "longDescription": "Kami packages a warm parchment design system for Codex. Use it to turn briefs and raw material into resumes, one-pagers, long documents, letters, portfolios, slide decks, equity reports, changelogs, and product landing pages.", + "developerName": "Tw93", + "category": "Productivity", + "capabilities": [ + "Interactive", + "Write" + ], + "websiteURL": "https://github.com/tw93/kami", + "defaultPrompt": [ + "Make a polished one-pager from this brief", + "Build a resume using Kami", + "Turn this outline into a slide deck" + ], + "brandColor": "#1B365D" + } +} diff --git a/plugins/kami/skills/kami/CHEATSHEET.md b/plugins/kami/skills/kami/CHEATSHEET.md new file mode 100644 index 0000000..4544aa5 --- /dev/null +++ b/plugins/kami/skills/kami/CHEATSHEET.md @@ -0,0 +1,378 @@ +# kami · Cheatsheet + +One-page quick reference. Scan before filling a template or tweaking a detail. Full spec in `references/design.md`. + +## Ten invariants + +1. Page background `#f5f4ed` (parchment), never pure white +2. Single accent: ink-blue `#1B365D` +3. All grays **warm-toned** (yellow-brown undertone), no cool blue-gray +4. One serif font per page (headings + body). `--sans` is a CSS alias for the same family; introduce a real sans only for genuinely UI-style chrome +5. Serif weight locked at 500, no bold +6. Line-height: headlines 1.1-1.3 / dense 1.4-1.45 / reading 1.5-1.55 +7. Letter-spacing: Chinese body with TsangerJinKai 0.1-0.2pt (dense layouts may push to 0.3pt); English body 0; small labels and all-caps overlines get +0.2-1pt +8. Tag backgrounds solid hex, no rgba (WeasyPrint double-rectangle bug) +9. Depth via ring / whisper shadow, no hard drop shadows +10. No italic in templates or demos + +## Sources and Materials + + +| Trigger | Do first | +| --------------------------------------------------------- | ------------------------------------------------------------------- | +| Latest product / version / launch / funding / market data | Check reliable sources first | +| Company / product / project branded doc | Confirm logo, product image, or UI screenshot | +| Key number or result | Record the source; if unverifiable, write magnitude or mark missing | +| Missing material | Mark the gap or ask the user; do not use unrelated imagery | + + +## Color + + +| Role | Hex | Use | +| ------------ | ------------- | --------------------------------------------------- | +| Parchment | `#f5f4ed` | Page background | +| Ivory | `#faf9f5` | Card / lifted container | +| Warm Sand | `#e8e6dc` | Button / interactive surface | +| Dark Surface | `#30302e` | Dark container | +| Deep Dark | `#141413` | Dark page background | +| **Brand** | **`#1B365D`** | **Accent · CTA · title left bar (≤ 5% of surface)** | +| Ink Light | `#2D5A8A` | Links on dark surfaces | +| Near Black | `#141413` | Primary text | +| Dark Warm | `#3d3d3a` | Secondary text · table headers · links | +| Olive | `#504e49` | Subtext · descriptions | +| Stone | `#6b6a64` | Tertiary · metadata | +| Border | `#e8e6dc` | Primary border · section divider | +| Border Soft | `#e5e3d8` | Secondary border · row separator | + + +**rgba -> solid** (parchment base + ink-blue): + + +| Alpha | Solid | +| -------- | --------------------------- | +| 0.08 | `#EEF2F7` | +| 0.14 | `#E4ECF5` | +| **0.18** | **`#E4ECF5`** ← default tag | +| 0.22 | `#D0DCE9` | +| 0.30 | `#D6E1EE` | + + +## Type (print pt) + + +| Role | Size | Weight | Line-height | +| ---------- | ---- | ------ | ----------- | +| Display | 36 | 500 | 1.10 | +| H1 | 22 | 500 | 1.20 | +| H2 | 16 | 500 | 1.25 | +| H3 | 13 | 500 | 1.30 | +| Body Lead | 11 | 400 | 1.55 | +| Body | 10 | 400 | 1.55 | +| Body Dense | 9.2 | 400 | 1.42 | +| Caption | 9 | 400 | 1.45 | +| Label | 9 | 600 | 1.35 | +| Tiny | 9 | 400 | 1.40 | + + +Screen (px) ≈ pt × 1.33. +Minimum floor: web text >= 12px, PDF text >= 9pt. + +## Font stacks + +Each language uses a single serif for the entire page. `--sans` always equals `var(--serif)`. + +English: + +```css +--serif: Charter, Georgia, Palatino, + "Times New Roman", serif; +--sans: var(--serif); +--mono: "JetBrains Mono", "SF Mono", "Fira Code", + Consolas, Monaco, monospace; +``` + +Chinese: + +```css +--serif: "TsangerJinKai02", "Source Han Serif SC", + "Noto Serif CJK SC", "Songti SC", "STSong", + Georgia, serif; +--sans: var(--serif); +--mono: "JetBrains Mono", "SF Mono", Consolas, + "TsangerJinKai02", "Source Han Serif SC", + monospace; +``` + +Japanese: + +```css +--serif: "YuMincho", "Yu Mincho", "Hiragino Mincho ProN", + "Noto Serif CJK JP", "Source Han Serif JP", + "TsangerJinKai02", Georgia, serif; +--sans: var(--serif); +``` + +Any font-family that may render Chinese or Japanese must include a CJK fallback, including `@page` footer text, `pre`, `code`, and SVG labels. A pure mono stack can render missing glyph boxes in WeasyPrint. + +## Spacing (4pt base) + + +| Tier | Value | Use | +| ---- | -------- | ---------------------- | +| xs | 2-3pt | Inline | +| sm | 4-5pt | Tag padding | +| md | 8-10pt | Component interior | +| lg | 16-20pt | Between components | +| xl | 24-32pt | Section-title margin | +| 2xl | 40-60pt | Between major sections | +| 3xl | 80-120pt | Between chapters | + + +**Page margins (A4)** + + +| Document | T · R · B · L | +| ------------- | -------------------- | +| Resume | 11 · 13 · 11 · 13 mm | +| One-Pager | 15 · 18 · 15 · 18 mm | +| Long Doc | 20 · 22 · 22 · 22 mm | +| Letter | 25 mm all sides | +| Portfolio | 12 · 15 · 12 · 15 mm | +| Equity Report | 16 · 18 · 18 · 18 mm | +| Changelog | 20 · 22 · 22 · 22 mm | +| Landing Page | N/A (screen-first, max-width: 1120px, padding: 88px 64px) | + + +## Radius scale + +`4pt -> 6pt -> 8pt (default) -> 12pt -> 16pt -> 24pt -> 32pt (hero)` + +## Common CSS snippets + +### Card + +```css +.card { + background: var(--ivory); + border: 0.5pt solid var(--border-cream); + border-radius: 8pt; + padding: 16pt 20pt; + transition: box-shadow 0.2s; +} +.card:hover { + box-shadow: 0 4pt 24pt rgba(0, 0, 0, 0.05); /* whisper shadow */ +} +``` + +### Tag (default lightest solid) + +```css +.tag { + background: #EEF2F7; /* 0.08 equivalent */ + color: var(--brand); + font-size: 9pt; font-weight: 600; + padding: 1pt 5pt; + border-radius: 2pt; + letter-spacing: 0.4pt; + text-transform: uppercase; +} +``` + +### Section title (brand left bar is the signature move) + +```css +.section-title { + font-family: var(--serif); + font-size: 14pt; font-weight: 500; + color: var(--near-black); + margin: 24pt 0 10pt 0; + border-left: 2.5pt solid var(--brand); + border-radius: 1.5pt; + padding-left: 8pt; +} +``` + +Resume exception: `resume*.html` uses a quiet bottom rule instead of the brand left bar. Keep project rows borderless so section titles do not create double rules or lonely page-top lines. + +### Table (kami-table) + +Base class works on bare `` or `.kami-table`. Add variant classes for density/alignment: + +```css +/* Base */ +table, .kami-table { + width: 100%; border-collapse: collapse; + font-size: 9.5pt; margin: 12pt 0; break-inside: avoid; +} +table th { text-align: left; font-weight: 500; color: var(--dark-warm); + padding: 6pt 8pt; border-bottom: 1pt solid var(--border); } +table td { padding: 5pt 8pt; border-bottom: 0.3pt solid var(--border-soft); + vertical-align: top; } +``` + + +| Variant | Class | Effect | +| --------- | ------------------ | ---------------------------------------------------- | +| Compact | `.compact` | 8pt font, tight padding (data-dense tables) | +| Financial | `.financial` | Right-align all columns except first, `tabular-nums` | +| Striped | `.striped` | Alternating `var(--ivory)` row background | +| Total row | `.total` on `` | Bold, brand top border, no bottom border | + + +Combine freely: `
`. + +### Metric (data card) + +```css +.metric { display: flex; align-items: baseline; gap: 6pt; } +.metric-value { + font-family: var(--serif); font-size: 16pt; font-weight: 500; + color: var(--brand); + font-variant-numeric: tabular-nums; +} +.metric-label { font-size: 9pt; color: var(--olive); } +``` + +### Quote + +```css +.quote { + border-left: 2pt solid var(--brand); + padding: 4pt 0 4pt 14pt; + color: var(--olive); + line-height: 1.55; +} +``` + +## Diagram components + +Eighteen built-in diagram types (incl. Mermaid-sourced sequence / class / ER; see `references/mermaid.md`). Extract the `` block and embed in a `
` in long-doc / portfolio: + + +| Type | File | Use | +| ------------- | ------------------------------------ | ----------------------------------------------- | +| Architecture | `assets/diagrams/architecture.html` | System components and connections | +| Architecture Board | `assets/diagrams/architecture-board.html` | Report-scale five-layer system board (standalone page) | +| Flowchart | `assets/diagrams/flowchart.html` | Decision branches and flows | +| Quadrant | `assets/diagrams/quadrant.html` | 2×2 positioning | +| Bar Chart | `assets/diagrams/bar-chart.html` | Category comparison (up to 8 groups × 3 series) | +| Line Chart | `assets/diagrams/line-chart.html` | Trends over time (up to 12 points × 3 lines) | +| Donut Chart | `assets/diagrams/donut-chart.html` | Proportional breakdown (up to 6 segments) | +| State Machine | `assets/diagrams/state-machine.html` | Finite states + directed transitions | +| Timeline | `assets/diagrams/timeline.html` | Time axis + milestone events | +| Swimlane | `assets/diagrams/swimlane.html` | Cross-responsibility process flow | +| Tree | `assets/diagrams/tree.html` | Hierarchical relationships | +| Layer Stack | `assets/diagrams/layer-stack.html` | Vertically stacked system layers | +| Venn | `assets/diagrams/venn.html` | Set intersections and overlaps | +| Candlestick | `assets/diagrams/candlestick.html` | OHLC price history (up to 30 days) | +| Waterfall | `assets/diagrams/waterfall.html` | Revenue bridge / decomposition | + + +Usage: extract the `` block from the HTML file and paste into the template's `
` container. + +**Repo-maintained diagram** (README / docs-site figure living in the user's repository): keep the trio consistent, `index.html` source + same-name PNG re-exported after every change + `prompt.md` (must preserve / suggested additions / visual direction / sister boundaries). Evidence pass before drawing; maturity encoding for shipped / in-build / future. See `references/diagrams.md` «Maintained diagram assets». + +**Data chart colors**: primary series `#1B365D` · secondary `#504e49` → `#6b6a64` → `#b8b7b0` → `#d4d3cd` → `#EEF2F7`. + +**Editing data**: only modify elements between `` / ``, leave CSS untouched. All coordinates must be divisible by 4. + +## Dark section + +Alternate light/dark rhythm: add `.sd-alt` to any section container. + +- Background switches to `--deep-dark` (`#141413`) +- Body text switches to `--warm-silver` (`#b0aea5`) +- Headings switch to `--ivory` +- Appropriate for: section-level light/dark alternation in long-doc / portfolio +- Restriction: showcase pages only, never in print templates + +## Verification checks + +`python3 scripts/build.py --verify [target]` checks source templates and slides in sequence: + +1. Source file exists +2. WeasyPrint render to PDF for HTML / diagram targets +3. Page count check for strict targets +4. Font embedding check +5. PPTX generation for `slides` / `slides-en` + +Source templates intentionally keep `{{...}}` fields. Run `python3 scripts/build.py --check-placeholders path/to/filled.html` on completed documents. Run `python3 scripts/build.py --check-density` to warn on pages with >25% trailing whitespace (skips cover). + +Marp variant deck (opt-in): `assets/templates/marp/`. Render with local `marp-cli`. See design.md §8 + production.md Part 2.5. + +## Content quality (one rule per type) + +Full quality bars in `references/writing.md`. The single most important rule for each document type: + + +| Document | Core quality rule | +| ------------- | ------------------------------------------------------------------------------------ | +| Resume | Every bullet: Action + Scope + Measurable Result + Business Outcome | +| Portfolio | Open with the problem and stakes, not the project name | +| Slides | Slide titles are full sentences (assertions), not topic labels | +| Equity Report | Lead with variant perception: what you see that the market doesn't | +| Long Document | Each chapter claim paragraph must survive the "so what?" test | +| One-Pager | Metrics are the headline; if the 4 cards don't tell the story, the metrics are wrong | +| Letter | First paragraph states purpose in one sentence | +| Changelog | One sentence per change, verb-led, user-facing language | + + +## Per-page font size strategy (Resume two-page) + +Page 1 carries the projects section, which is the densest content. Page 2 carries open source, convictions, impact, skills, and education, which has more breathing room. + +| Location | Class | Default | Dense (5 projects) | +|---|---|---|---| +| Project body | `.proj-text` | 9pt / lh 1.40 | 9pt / lh 1.38 | +| Timeline body | `.tl-body` | 9pt / lh 1.40 | 8.5pt (CN) | +| Summary | `.summary` | 9.2pt | 9pt via body | +| Section titles | `.section-title` | margin-top 5mm | 3.5mm | +| OS intro | `.os-intro` | 9.2pt | unchanged | +| Conviction body | `.conv-body` | 9pt | unchanged | +| Skills body | `.skill-body` | 9pt | unchanged | + +**Reference config (5 projects + full page 2)**: + +```html +
+ + + +``` + +Filled resume PDFs should be exactly 2 pages with both pages visually used. Check the rendered result: + +```bash +python3 scripts/build.py --check-resume-balance path/to/resume.pdf +``` + +Page 2 font sizes stay at template defaults. The density variant only tightens page 1 elements. If page 2 has unusually long content, reduce `.os-intro`, `.conv-body`, or `.skill-body` individually, never globally. + +Resume visual rule: header and section titles carry the only structural rules. Top metrics stack value over label so labels stay single-line; project rows separate by padding, not borders. + +## Quick decisions + + +| Need | Use | +| ------------------- | -------------------------------------------------------------- | +| Headline | serif 500, line-height 1.10-1.30 | +| Reading body (EN) | serif 400, 9.5-10pt, 1.55 | +| Reading body (CN) | sans 400, 9.5-10pt, 1.55 | +| Emphasize a number | `color: var(--brand)`, no bold | +| Divide two sections | 2.5pt brand left bar, or 0.5pt warm dotted | +| Quote | 2pt brand left border + olive color | +| Code | ivory bg + 0.5pt border + 6pt radius + mono | +| Primary button | brand fill + ivory text | +| Secondary button | warm-sand + dark-warm | +| Chapter start | serif heading + 2.5pt brand left bar | +| Cover | Display heading + right-aligned author/date + heavy whitespace | +| Figure SVG | `width: 100%; height: auto; max-height: `. Never `max-height` alone (starves width on wide viewBoxes; production.md #17). | +| Metric labels (4-col) | Soft cap 14-18 chars at 9pt Charter; trim context, don't wrap (production.md #18). | +| Multi-column body | Hold lengths within ±10 chars across parallel columns (production.md #19). | +| Image references | Always inside `assets/demos/images/` or `assets/illustrations/`; never `../../sibling-project/...` (production.md #20). | +| Metric row layout | Vertical stack (`flex-direction: column`); horizontal baseline-align breaks when any label wraps (production.md #21). | +| Slide bullets | Numerals `1. 2. 3.` or `•`; en-dash `–` reads informal at slide scale (production.md #22). Print docs keep en-dash. | + + +Not on the table -> first principles: **serif carries authority, sans carries utility, warm gray carries rhythm, ink-blue carries focus**. diff --git a/plugins/kami/skills/kami/LICENSE b/plugins/kami/skills/kami/LICENSE new file mode 100644 index 0000000..503d8bf --- /dev/null +++ b/plugins/kami/skills/kami/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Tw93 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/kami/skills/kami/SKILL.md b/plugins/kami/skills/kami/SKILL.md new file mode 100644 index 0000000..8d3e4b5 --- /dev/null +++ b/plugins/kami/skills/kami/SKILL.md @@ -0,0 +1,569 @@ +--- +name: kami +description: 'Typeset professional documents and product landing pages: resumes, one-pagers, white papers, letters, portfolios, slide decks, landing pages. Warm parchment, ink-blue accent, serif-led hierarchy. CN uses TsangerJinKai02, EN uses Charter, JA uses YuMincho (best-effort). Triggers on "做 PDF / 排版 / 一页纸 / 白皮书 / 作品集 / 简历 / PPT / slides / Marp / markdown slides / マークダウンのスライド / 落地页 / 官网 / landing page / product page", or "build me a resume / make a one-pager / design a slide deck / turn this into a PDF / make this presentable / create a landing page".' +--- + +# kami · 紙 + +**紙 · かみ** - the paper your deliverables land on. + +Good content deserves good paper. One design language across documents and landing pages: warm parchment canvas, ink-blue accent, serif-led hierarchy, tight editorial rhythm. + +Part of `Kaku · Waza · Kami` - Kaku writes code, Waza drills habits, **Kami delivers documents**. + +**Update check (non-blocking).** At the start of a task, run `bash scripts/check-update.sh`. It does a read-only version check at most once per day and prints one line when a newer kami is available; relay that line to the user, then continue. It sends no data, and fails silently when offline, sandboxed, or without `curl`. Never let it block the work. + +## Step 0 · Load brand profile (if exists) + +Check `~/.config/kami/brand.md` (preferred) or `~/.kami/brand.md` (legacy fallback). If found, read `references/brand-profile.md` for the full four-layer application spec (placeholder substitution, session defaults, visual customization, habit notes) and its six guardrails. If no profile exists, continue without interruption. + +Key rule: explicit prompt > editorial judgment > habit notes > frontmatter defaults > built-in defaults. Profile fills gaps silently; it never overrides the current conversation. + +## Step 0.5 · User project style scan (opt-in) + +Run this only when the user explicitly references a sibling project as a visual reference: "like my site", "match the style of ", "use the look from ". Skip silently when no such reference exists. + +When triggered, before generating: + +1. Locate the referenced project's style files: + ```bash + find -maxdepth 4 \( -name "*.css" -o -name "tailwind.config.*" -o -name "theme.*" -o -name "tokens.*" \) | head -20 + ``` +2. Extract: dominant color values (hex / hsl), font stack, spacing scale, border-radius scale. Prefer values declared in CSS variables or design tokens over inline literals. +3. Merge into the in-session brand profile as Layer C (visual customization), not Layer B (session defaults). Do not override an explicit `--brand` flag or values that the user typed in this turn. +4. Report back in one line before continuing: "scanned , extracted N colors / M fonts; using as visual reference." + +Skip and fall back to the brand profile defaults if the referenced path does not exist, no CSS-like files are found, or the extraction would conflict with the user's explicit values in the current message. + +--- + +## Step 1 · Decide the language + +**Match the user's language.** Chinese -> `*.html` / `slides-weasy.html`. English -> `*-en.html` / `slides-weasy-en.html`. Japanese -> CJK path (`.html` / `slides-weasy.html`) as best-effort, JP Mincho first, visual QA before shipping. Korean -> dedicated `*-ko.html` / `slides-weasy-ko.html` family as best-effort, visual QA before shipping. Reference docs are shared English specs. + +When ambiguous (e.g. a one-word command like "resume"), ask a one-liner rather than guess. + +| User language | HTML templates | Slides (PDF default) | Slides (PPTX fallback) | +|---|---|---|---| +| Chinese (primary) | `*.html` | `slides-weasy.html` | `slides.py` | +| English | `*-en.html` | `slides-weasy-en.html` | `slides-en.py` | +| Japanese (best-effort) | `*.html` | `slides-weasy.html` | `slides.py` | +| Korean (best-effort) | `*-ko.html` | `slides-weasy-ko.html` | n/a (use `slides-en.py` only if PPTX is required) | +| Other languages (best-effort) | choose CJK or EN path by script coverage, then verify manually | choose `slides-weasy.html` or `slides-weasy-en.html`, then verify manually | use `slides.py` / `slides-en.py` only if PPTX is required | + +> Default to the WeasyPrint HTML path; fall back to PPTX (`slides*.py`) only when the user explicitly needs an editable deck. + +Always use `CHEATSHEET.md` and `references/*.md` for design, writing, production, and diagram guidance. + +Code blocks with `class="language-*"` are highlighted only when optional `Pygments` is installed in the build environment. Without it, PDFs still render and code blocks stay monochrome. + +## Step 1.5 · Intent extraction (silent checklist) + +Before choosing a template, verify these four dimensions are clear. Do not ask unless 2+ are missing and cannot be inferred from context. + +| Dimension | What to extract | Example | +|---|---|---| +| **Purpose** | Why this document exists | Persuade investor vs. align internal team vs. close a candidate | +| **Audience** | Who reads it, what they already know | Technical CTO (skip basics) vs. non-technical board (explain terms) | +| **Constraint** | Hard limits on length, format, tone, or delivery | "One page max", "formal English", "print-ready A4" | +| **Success** | What outcome counts as success | They schedule a meeting / they approve the budget / they understand the architecture | + +Rules: +- If the conversation already answered a dimension, skip it silently. +- If a dimension can be inferred from the document type (e.g. resume purpose is always "get an interview"), skip it. +- If 2+ dimensions are genuinely unclear, ask in a single compact question (max 2 sub-questions). +- Never ask all four as a checklist. This is a background verification, not a form. + +## Execution contract + +Before creating or modifying an output, lock the contract: language, template, output format, page or length target, visual acceptance check, and verification command. Infer from the user's request when clear; ask only when missing fields materially change the deliverable. + +Use the nearest existing template and verification path. Do not add a new template, shared CSS layer, dependency, script flag, or optional mode unless the current request cannot be satisfied without it. + +If a change touches `SKILL.md`, templates, scripts, references, or package inputs, decide whether `dist/kami.zip` must be refreshed before handoff. Shipped behavior is not ready until the package contains the changed files. + +--- + +## Step 2 · Pick the document type + +| User says | Document | CN template | EN template | KO template | +|---|---|---|---|---| +| "one-pager / 方案 / 执行摘要 / exec summary" | One-Pager | `one-pager.html` | `one-pager-en.html` | `one-pager-ko.html` | +| "white paper / 白皮书 / 长文 / 年度总结 / technical report" | Long Doc | `long-doc.html` | `long-doc-en.html` | `long-doc-ko.html` | +| "formal letter / 信件 / 辞职信 / 推荐信 / memo" | Letter | `letter.html` | `letter-en.html` | `letter-ko.html` | +| "portfolio / 作品集 / case studies" | Portfolio | `portfolio.html` | `portfolio-en.html` | `portfolio-ko.html` | +| "resume / CV / 简历 / 履歴書" | Resume | `resume.html` | `resume-en.html` | `resume-ko.html` | +| "slides / PPT / deck / 演示" | Slides | `slides-weasy.html` | `slides-weasy-en.html` | `slides-weasy-ko.html` | +| "个股研报 / equity report / 估值分析 / investment memo / 股票分析" | Equity Report | `equity-report.html` | `equity-report-en.html` | `equity-report-ko.html` | +| "更新日志 / changelog / release notes / 版本记录" | Changelog | `changelog.html` | `changelog-en.html` | `changelog-ko.html` | +| "landing page / 落地页 / 官网 / product page / 产品页" | Landing Page | `landing-page.html` | `landing-page-en.html` | `landing-page-ko.html` | + +> **Changelog vs. release notes**: The changelog template above is for styled document output. GitHub release notes are a separate deliverable; use `/write` with Release Note Template Mode. + +> **Landing Page**: Screen-first interactive template. No PDF output. Includes gallery carousel with auto-rotate, hero entrance animation, responsive breakpoints (880px / 480px), and prefers-reduced-motion support. Deploy as static HTML to Vercel / Netlify / any host. The agent fills {{PLACEHOLDER}} values and HTML comment blocks, then saves as a ready-to-serve `.html` file. + +> **Landing Page companion files**: For a production multilingual deploy, copy the five `landing-page-*.example` files alongside the main HTML, remove the `.example` suffix, and fill the placeholders. They cover Vercel rewrites and headers, sitemap hreflang, robots AI allowlist, and llms.txt + llms-full.txt for AI assistants. The main HTML already ships matching hreflang and og:locale in ``; an Accept-Language redirect at the end of `landing-page-en.html` is commented out for opt-in. `{{SITE_ORIGIN}}` is the scheme + host of your `{{CANONICAL_URL}}` (e.g. `https://example.com`). See `references/design.md` Section 11 «Companion assets». + +> **Production product site mode**: If the user needs docs, help, releases, changelog, roadmap, legal pages, or more than two locales, treat it as a site system. Lock product category, real screenshot slots, locale list, companion files, long-content pages, and generator/check needs before filling templates. Keep project-specific release artifacts, payment providers, appcast rules, and private local paths out of Kami. See `references/design.md` Section 11 «Product site system». + +> **Documentation pages**: When a landing page grows into a docs or help site, use the doc shell in `references/design.md` Section 11 «Documentation site»: a sticky sidebar nav with a 2px brand rail (not a dark underline), an on-this-page TOC hidden below the tablet breakpoint, a constrained prose measure, and a quiet borderless prev/next pager (text links, not bordered cards). Highlight code at build time with zero runtime JS on a dark code surface; plain code stays the source of truth. + +> Slides: default to `slides-weasy.html` / `slides-weasy-en.html` / `slides-weasy-ko.html` (WeasyPrint HTML → PDF). Use `slides.py` / `slides-en.py` only when the user explicitly requires an editable PPTX file. Use `assets/templates/marp/slides-marp(.md|.css)` only when the user explicitly asks for Marp / markdown slides / a deck that lives in a `.md` file. + +> Deck recipe: read design.md Section 8 before drafting slides. Sketch title sequence, evidence shape, and image slot before generating or cropping visuals. Keep audience copy separate from visual briefs. Marp-specific constraints live in design.md §8 «Marp variant». + +### Decision tree (use before asking) + +Walk this tree before reaching for a one-liner question. Ask only when two cells genuinely both fit. + +| Signal | Document | +|---|---| +| Length target unknown | Ask "how many pages" before classifying | +| ≤ 1 page + investor / recruiter / exec summary audience | one-pager | +| ≤ 1 page + formal correspondence (sales, hiring, resignation, memo) | letter | +| 1.5-2 pages + career narrative + project bullets | resume | +| 3-6 pages + project showcase + visual heavy | portfolio | +| 6-15 pages + sustained argument + low visual density | long-doc | +| Presentation flow + speaker support + per-slide assertion | slides | +| Financial / metrics dashboard + thesis + price or risk view | equity-report | +| Version-by-version log + release facts | changelog | +| Product showcase + pricing + screenshots + FAQ for browser | landing-page | + +Ambiguity examples that justify a one-liner: +- "1.5 page career story with heavy visuals" -> ask "resume or portfolio?" +- "2 page exec summary with metric tiles" -> ask "one-pager or equity-report?" +- "5 page argument with several charts" -> ask "long-doc or portfolio?" + +Pick from the tree first. Ask only when the tree is genuinely silent. + +### Diagrams (primitives, not a separate template type) + +When the user asks for **a diagram inside** a long-doc / portfolio / slide (not a standalone document), route to `assets/diagrams/` rather than a template: + +| User says | Diagram | Template | +|---|---|---| +| "架构图 / architecture / 系统图 / components diagram" | Architecture | `assets/diagrams/architecture.html` | +| "架构全景 / architecture board / 平台全景 / 系统大图 / five-layer panorama" | Architecture Board | `assets/diagrams/architecture-board.html` | +| "流程图 / flowchart / 决策流 / branching logic" | Flowchart | `assets/diagrams/flowchart.html` | +| "象限图 / quadrant / 优先级矩阵 / 2×2 matrix" | Quadrant | `assets/diagrams/quadrant.html` | +| "柱状图 / bar chart / 分类对比 / grouped bars" | Bar Chart | `assets/diagrams/bar-chart.html` | +| "折线图 / line chart / 趋势 / 股价 / time series" | Line Chart | `assets/diagrams/line-chart.html` | +| "环形图 / donut / pie / 占比 / 分布结构" | Donut Chart | `assets/diagrams/donut-chart.html` | +| "状态机 / state machine / 状态图 / lifecycle" | State Machine | `assets/diagrams/state-machine.html` | +| "时间线 / timeline / 里程碑 / milestones / roadmap" | Timeline | `assets/diagrams/timeline.html` | +| "泳道图 / swimlane / 跨角色流程 / cross-team flow" | Swimlane | `assets/diagrams/swimlane.html` | +| "树状图 / tree / hierarchy / 层级 / 组织架构" | Tree | `assets/diagrams/tree.html` | +| "分层图 / layer stack / 分层架构 / OSI / stack" | Layer Stack | `assets/diagrams/layer-stack.html` | +| "维恩图 / venn / 交集 / overlap / 集合关系" | Venn | `assets/diagrams/venn.html` | +| "K 线 / candlestick / OHLC / 股价走势 / price history" | Candlestick | `assets/diagrams/candlestick.html` | +| "瀑布图 / waterfall / 收入桥 / revenue bridge / decomposition" | Waterfall | `assets/diagrams/waterfall.html` | + +Read `references/diagrams.md` before drawing - it has the selection guide, kami token map, and the AI-slop anti-pattern table. Extract the `` block from the template and drop it into a `
` inside long-doc / portfolio. + +For a **full-system architecture board** (platform panorama, control plane, roadmap, or owner map in one artifact), do not inflate the single architecture figure past its node budget. Start from `assets/diagrams/architecture-board.html` and follow the «Architecture boards» section in `references/diagrams.md`: five fixed information layers, bands over cards, lines never on module edges, and a structure outline before any rendering. + +For a **repo-maintained diagram** (README or docs-site architecture figure, "给项目画张架构图", or updating a diagram that already lives in the user's repository), follow «Maintained diagram assets» in `references/diagrams.md`: run the evidence pass first (existing `prompt.md`, `index.html`, current PNG, then the facts that define objects and boundaries), keep the trio (`index.html` + same-name PNG + `prompt.md`) consistent, encode shipped / in-build / future maturity, and re-export the PNG after every HTML change. Never redraw an existing diagram from memory, and never hand-edit the PNG. + +Before drawing, always ask: **would a well-written paragraph teach the reader less than this diagram?** If no, don't draw. + +**Auto-select charts from data.** When content contains numerical data, choose the chart type and embed it without waiting for the user to specify. Decision tree (first match wins): + +| Data shape | Chart | +|---|---| +| Has open/high/low/close fields, or per-day price | Candlestick | +| Has + and - contributions that sum to a total (bridge, waterfall, P&L) | Waterfall | +| One series, values sum to ~100%, items ≤ 6 | Donut | +| One series, values sum to ~100%, items ≥ 7 | Horizontal bar | +| Two or more series across time (months, quarters, years) | Line | +| One series across time, large count changes dominate (not rate) | Bar | +| Multiple categories, same time snapshot, 2+ series | Grouped bar | +| 2×2 strategic or priority positioning | Quadrant | +| Hierarchical data with depth ≥ 2 | Tree | +| Process with decision branches | Flowchart | +| Cross-team or cross-role process with ≥ 3 actors | Swimlane | +| Set overlaps or shared attributes between 2-3 groups | Venn | +| Category comparison, single series, no time axis | Bar | + +When data fits multiple types, prefer the one that shows variance most clearly. Always embed inside a `
` with a caption that states the insight, not just the data range. + +### Illustrations (host image model, not inline SVG) + +Inline diagrams above are vector SVGs you assemble by hand. For a standalone raster illustration, or a redraw of a figure, photo, or screenshot in the Kami look, delegate the drawing to the host's own image generation. Never call an external image API or require a key; rendering is the host's job. + +- If the running host can generate images (for example ChatGPT), apply the brief below and render the image directly. +- If it cannot (Claude, Codex, most coding agents), output the brief as text so the user can paste it into any image model. + +Brief: warm parchment (`#f5f4ed`) background, never pure white; one accent only, ink blue (`#1B365D`); all else warm gray with a yellow-brown undertone, no other colors; thin single-line geometric strokes and simple flat icons; no gradients, drop shadows, or 3D; serif labels; generous whitespace, composed like a figure in a well-typeset report. + +## Step 2.1 · Source and material pass + +Run this before distilling or filling content when the document depends on facts or materials outside the user's draft. Skip it only for personal drafts where the user already supplied everything needed. + +### Source check + +Trigger when the document mentions a specific company, product, person, release date, version, funding round, metric, market fact, technical spec, or any current fact likely to change. + +- Use primary sources before writing: user-provided material, official site, docs, filings, press release, app store page, or repo release +- Keep a short note of source names and dates for facts that drive the document +- If sources conflict or a fact cannot be checked quickly, ask the user instead of choosing silently +- Avoid current-sounding claims such as "latest", "recent", "new", version numbers, launch dates, or financial figures unless they are checked + +### Material check + +Trigger when the document is about a company, product, project, venue, or personal brand. + +Confirm the materials that make the subject recognizable before layout: + +| Need | Required when | Accept | +|---|---|---| +| Logo | Any branded document | User file or official SVG/PNG | +| Product image | Physical product / venue / object | Official image, user image, or marked gap | +| UI screenshot | App / SaaS / website / tool | Current screenshot, official product image, or user capture | +| Brand colors | Branded one-pager / portfolio / deck | Official value, extracted asset value, or keep kami ink-blue | +| Fonts | Only if brand typography matters | Official font, close system fallback, or kami default | + +If a required item is missing, use a compact gap table and ask once. Do not replace missing material with generic imagery, approximate logo drawings, or invented values. + +Logo fallback: when the request names no logo but the brand profile has a `logo` path, fill the commented `.brand-logo` slot in `one-pager` / `portfolio` / `slides-weasy` per `references/brand-profile.md` Layer C. Expand `~` to an absolute path, and if the file is missing or the template has no slot, leave it commented and render without a logo (never insert a broken image). An explicit logo in the current request always wins. + +### Materials status block + +After the material check, output a structured status block before continuing. This is a one-shot transparency display, not a question: + +``` +Materials status: +- Logo: OK assets/client-logo.svg +- Brand colors: OK #1B365D mapped to --brand +- Product screenshot: MISSING (proceeding with kami default placeholder) +- UI screenshot: not required for this doc type +``` + +Use `OK`, `MISSING`, or `not required`. If a required item is missing and no user input arrived, ask once with the gap table; otherwise continue silently. + +## Step 2.5 · Distill raw content (if applicable) + +**Auto-detect whether to distill.** Do not ask the user; judge from the input: + +| Skip distill (fill directly) | Run distill | +|---|---| +| Content has explicit section labels matching template structure | Raw prose without section structure | +| Metrics already quantified with units in place | Numbers scattered or implied, not extracted | +| User wrote "use this as-is" / "直接用这个" / "原封不动" | User pasted multi-source dump (chat / email thread / multiple docs) | +| Content count matches template (e.g. 4 metrics for 4 metric cards) | Content count mismatches template (too many or too few items) | +| One coherent voice with consistent claims | Conflicting claims or duplicate facts across sources | + +When in doubt, run distill. Distill is cheap; rebuilding a misaligned doc is not. + +When the user hands over **raw material** (meeting notes, brain dump, existing doc in different format, chat transcript, scattered points): + +1. **Extract**: pull out every factual claim, number, date, name, source, material reference, and action item +2. **Classify**: map each extract to the target template's sections (see `references/writing.md` for section structure per doc type) +3. **Gap-check**: list what the template needs but the raw content doesn't have - include missing facts, missing proof, and missing materials +4. **Ask once**: share the gap table with the user. Do not guess to fill gaps. + +Example gap-check: + +| Template needs | Found | Missing | +|---|---|---| +| 4 metric cards | "8 years", "50-person team" | 2 more quantifiable results | +| 3-5 core projects | 2 mentioned | at least 1 more with outcome | +| Materials | logo file provided | product screenshot source | + +Then proceed to Step 2.6 (slides) or the layout note (all other doc types) with structured, distilled content. + +## Step 2.6 · Deck pre-flight (slides only) + +Skip this step for every doc type except slides. + +### Path selection + +Default to the WeasyPrint HTML path. Switch to pptx only if the user explicitly requires an editable PPTX file. Switch to Marp only when the user explicitly asks for Marp / markdown slides. + +| Path | Template | When | +|---|---|---| +| WeasyPrint HTML → PDF (default) | `slides-weasy.html` / `slides-weasy-en.html` / `slides-weasy-ko.html` | All cases unless PPTX or Marp is required | +| python-pptx → PPTX (fallback) | `slides.py` / `slides-en.py` | User explicitly requires editable PPTX | +| Marp Markdown (variant) | `assets/templates/marp/slides-marp.md` (+ `slides-marp.css`) / `slides-marp-en.md` (+ `slides-marp-en.css`) | User explicitly asks for Marp, "markdown slides", or a `.md` deck. Shipped `.md` is a working demo of Kami Marp itself; copy it, swap content, keep the structure. Renders via local `marp` CLI; not bundled. | + +### Page size + +Default is `280mm 158mm`. Ask only if the user has mentioned length or density constraints. + +| Size | When | +|---|---| +| `280mm 158mm` | Default; fits most decks | +| `297mm 167mm` | User wants a bit more room | +| `338mm 190mm` | Heavy content slide or many data points per page | + +### Content pre-flight + +Before drafting any slide, confirm these points with the user. Ask all at once, skip any already answered: + +| # | Question | +|---|---| +| 1 | **Audience + venue** - who is in the room, and is it live keynote, investor 1:1, or async share link? | +| 2 | **Length target** - presentation time or slide count? (15 min: ~10 slides / 30 min: ~20 slides / 45 min: ~25-30 slides) | +| 3 | **Source material** - what content is already ready: outline, doc, notes, data? | +| 4 | **Images** - are screenshots, charts, logos, or product images available; which slides need real evidence slots; and is a separate visual brief needed? | +| 5 | **Hard constraints** - brand colors, required logo, PPTX required, any slides that must exist? | +| 6 | **Format confirmation** - slides deck, or a one-pager that looks like a deck? | + +Before drafting any landing page or product site, lock these points from the source material. Ask once only when a missing item would change the deliverable: + +| # | Lock | +|---|---| +| 1 | **Product category** - first-viewport category: app, CLI, terminal, utility, skill, template system, or another user-provided label. | +| 2 | **Real assets** - available product screenshots, logo, icon, or UI captures, mapped to hero/gallery/feature/social slots. Missing assets must stay marked, not replaced with stock imagery. | +| 3 | **Site shape** - single page, or home plus docs/help/releases/changelog/roadmap/legal pages? | +| 4 | **Locales** - exact locale list, canonical paths, and whether a generator/check mode is needed. | +| 5 | **Truth surfaces** - install path, price, version, support route, FAQ, `llms.txt`, and `llms-full.txt` that must stay synchronized. | + +### Content rules for slides + +- Ghost deck test: read only the slide titles in order. They must tell the argument; if not, fix titles or structure before styling +- One evidence shape per slide: chart, table, screenshot, code, quote, or conclusion. Split mixed evidence instead of crowding one slide +- Audience copy stays clean: titles, body, and captions never contain image prompts, crop instructions, or generation notes +- No section divider slides: use `.eyebrow` for section numbering, not a dedicated blue-background page +- No CJK parentheses: replace `(...)` with `·` or `,` +- Each bullet fits one line: trim until it does +- 2×2 layouts: use `table.t2x2`, not CSS Grid +- Pinned conclusions: use `.co` at `position: absolute; bottom: 12mm` + +These rules apply identically to Marp decks. Marp-specific syntax: see `references/design.md` §8 «Marp variant». + +## Step 2.7 · Layout note (transparent, non-blocking) + +Before loading specs and filling the template, write a short editor-style note stating the layout intent: template choice, length target, narrative arc, embedded diagrams, material status, and output formats. Match the document's language. Keep it under 80 words, written as prose, not a status panel. Continue immediately after; do not wait. + +Example (CN): + +> 排版意图:Equity Report 中文版,2 页 A4。先立论与目标价,进入估值 (DCF 与可比公司),落于催化剂与风险。中段嵌一张营收趋势折线和 FY26 收入桥瀑布。Logo 已就位,产品图暂缺,header 改走纯文字。输出 HTML 与 PDF。 + +Example (EN): + +> Layout intent: Equity Report (EN), two pages A4. Open with thesis and price target, run through valuation (DCF and comparables), close on catalysts and risks. A revenue line chart and an FY26 waterfall sit mid-doc. Logo is in hand; product image is absent, so the header stays text-only. Output: HTML and PDF. + +The note is for transparency, not approval. If the user pushes back, adjust; otherwise proceed to Step 3. + +--- + +## Step 3 · Load the right amount of spec + +Pick the tier that matches the task. Default to the lowest tier that covers the work. + +| Tier | When | Read | +|---|---|---| +| **Content-only** | Updating text, swapping bullets, translating an existing doc. CSS stays untouched. | `CHEATSHEET.md` only | +| **Layout tweak** | Adjusting spacing, moving sections, changing font size within spec. CSS touched. | `CHEATSHEET.md` + template (tokens already inline) | +| **New document** | Building from scratch or from raw content. | Full design spec + writing spec + template | +| **Resume content** | Resume-specific bullet structure, project framing, scope-result-outcome rules. | `resume-writing.md` + template | +| **Sources / materials** | Company, product, market, launch, funding, specs, or branded subject. | `writing.md` source rules + user/source material | +| **Deck (>20 slides)** | Long presentation needing Part Divider, Code Cards, section headers. | Full design spec + Deck Recipe (design.md section 8) | +| **Troubleshoot** | Rendering bug, font issue, page overflow. | `production.md` (+ design spec if CSS is the cause) | +| **Anti-patterns** | Reviewing AI-generated drafts before shipping. | `anti-patterns.md` (six-category checklist) | +| **Diagram** | Embedding SVG in a doc, or maintaining a repo-owned diagram (trio: HTML + PNG + prompt.md). | `diagrams.md` only (has its own token map) | + +You can always escalate mid-task if the work turns out to need more than the initial tier. + +The full spec files for reference: +- Design: `references/design.md` +- Writing (general): `references/writing.md` +- Writing (resume-specific): `references/resume-writing.md` +- Production: `references/production.md` +- Diagrams: `references/diagrams.md` +- Anti-patterns: `references/anti-patterns.md` + +## Step 4 · Fill content into the template + +- Copy the template into your working directory; don't write HTML from scratch +- **CSS stays untouched**, only edit the body +- Content follows `writing.md`: data over adjectives, distinctive phrasing over industry clichés +- Avoid patterns listed in `references/anti-patterns.md`: emptiness, fabrication, mimicry, excess, source gaps, tone contamination +- **Before filling, read the quality bar for your document type** in `writing.md` section "Quality bars by document type". Structure is necessary but not sufficient: a resume bullet needs Action + Scope + Result + Business Outcome; an equity report needs variant perception + quantified catalysts; slides need assertion-evidence titles. Meeting the quality bar is as important as filling every placeholder. + +### Do not generate + +These are the most common AI document failures. Cross-reference `references/anti-patterns.md` for the full list. + +- Do not leave placeholder text in the final document ("Lorem ipsum", "[Insert here]", "TBD") +- Do not invent metrics, financial data, or statistics; mark gaps with `[DATA NEEDED: description]` +- Do not use stock-image descriptions as image placeholders ("A diverse team collaborating in a modern office") +- Do not pad content to fill template slots (a resume with 3 real projects does not need 5 fabricated ones) +- Do not write a paragraph that merely restates its own heading in sentence form + +### Fill PDF metadata (WeasyPrint reads these into the PDF) + +Every template has meta placeholders in `
`. Fill all four before building: + +| Placeholder (CN) | Placeholder (EN) | Rule | +|---|---|---| +| `{{作者}}` | `{{AUTHOR}}` | Resume/letter/portfolio: use the person's name from the doc. All others: leave as-is (build script infers from git config or env) | +| `{{摘要}}` | `{{DESCRIPTION}}` | Extract one sentence (≤150 chars) from the first 2 paragraphs | +| `{{关键词}}` | `{{KEYWORDS}}` | 3-5 keywords from the title + section headings, comma-separated | +| `{{文档标题}}` / `{{信件主题}}` etc. | `{{DOC_TITLE}}` / `{{LETTER_SUBJECT}}` etc. | Infer from the H1 or `.header .title` text | + +`` is already fixed in the template; do not change it. + +**Author inference**: `build.py` automatically sets PDF `/Author` metadata from: +1. `git config user.name` (primary) +2. `KAMI_AUTHOR` environment variable (fallback) +3. `"Kami"` (final fallback) + +For personal documents (resume/letter/portfolio), the HTML `` should match the person's name in the content. For non-personal documents (one-pager/long-doc), leave the placeholder as-is and let the build script infer it. + +## Step 4.1 · Per-page density target (multi-page templates only) + +适用:slides-weasy / long-doc / portfolio / equity-report / changelog。不适用 resume / one-pager / letter(这些有独立的长度合约)。 + +正文页填充率目标 60-80%。封面 / 目录 / 末尾署名页豁免。这条规则解决的是 AI 生成多页文档时最常见的 draft 缺陷:把内容拆得太散,结果几页都填不满。 + +### Items-per-page contract + +| Template | Typical body page | Hard floor (merge if below) | +|---|---|---| +| slides-weasy | 1 assertion title + 3-5 supporting items, or 1 chart + 2-3 callouts | <3 items and no chart → merge into adjacent slide | +| long-doc | 1 chapter heading + 2-4 paragraphs + at most 1 figure | Chapter renders to <40% page → merge into neighbor chapter | +| portfolio | 1 project header + 1 hero image + 3-5 outcome bullets | No image and <3 outcomes → merge with adjacent project | +| equity-report | 1 section + 1 table/chart + supporting prose | Only a 2-row table on the page → combine sections | +| changelog | 1 version block + 4-8 entries | Version has <4 entries → place on the same page as the prior version | + +### Sparse-page merge rule + +Before finalizing, scan the draft. Any body page that would render under 50% full → apply one of, in order: + +1. Merge upward into the previous section. +2. Merge downward into the next section. +3. Promote a list to a small diagram or table that earns the space. +4. Pin a `.co` callout to bottom (slides-weasy only). Whitespace above a pinned callout is intentional, not sparse. + +Forbidden ways to "fill" a sparse page: padding with filler prose, repeating the heading as a sentence, inventing statistics, restating the prior page in different words. If the merge options don't apply, the page itself shouldn't exist. + +### Last-page exemption + +The last body page is allowed to run 40-60% fill. Forcing balance on the last page usually means padding. The colophon / closing slide may have any fill level. + +### Verify after build + +```bash +python3 scripts/build.py --check-density # flags >25% (WARN) / >50% (SPARSE) trailing whitespace +``` + +If a body page (not cover, not last page) gets a SPARSE warning, treat it as a draft defect and re-author with the merge rule. + +## Step 4.2 · Resume recruiter pass (resume only) + +Mechanical checks (`--check-placeholders`, `--check-resume-balance`, `--check-density`) validate structure and layout, not prose. A resume can pass all of them and still read broken. After filling and before building, reread every project card the way a recruiter would, against the row definitions in `references/resume-writing.md` ("What goes in each row"): Role carries your position in the project, not background alone; Actions are verb-led, one concrete approach per sentence; Impact reads as an outcome, not a restatement of the process. One cross-row check on top: no row repeats another row's information. + +Fix a failing row by rewriting from the source material. If the source cannot support a row (for example, no outcome fact exists), ask the user for the missing fact. Do not pad, and do not fall back to generic claims ("保障稳定运行", "improved efficiency"). + +This pass is internal: run it silently; surface it only when a row cannot be fixed without new information from the user. + +## Step 4.5 · Auto-select output format + +Do not ask the user which format to export. Decide from context: + +| Signal | Output | Why | +|---|---|---| +| Any document request | HTML + PDF | PDF is the default deliverable, HTML is the source | +| Slides / PPT / deck | HTML + PDF + PPTX | Presentations need a projectable format | +| "分享" / "发朋友圈" / "share" / "post" / "preview" | + PNG | Social platforms and messaging need images | +| "嵌入" / "插图" / "embed in another doc" | PNG only | Used as material inside other documents | +| User explicitly says a format | Follow the user | Explicit request overrides auto-selection | + +PDF always ships for document templates. Landing pages ship as a ready-to-serve static HTML file. PPTX follows slides. PNG follows sharing context. The user should never need to think about formats. + +## Step 5 · Build & verify + +```bash +python3 scripts/build.py --verify # build all templates + page count + font check + slides +python3 scripts/build.py --verify resume-en # single target full verification +python3 scripts/build.py landing-page # screen-first static HTML template check +python3 scripts/build.py --verify slides # single slide deck verification +python3 scripts/build.py --check-placeholders path/to/filled.html +python3 scripts/build.py --check-markdown path/to/filled.pdf +python3 scripts/build.py --check-resume-balance path/to/resume.pdf +python3 scripts/build.py --check-density # page whitespace scanner (skips cover) +python3 scripts/build.py --check # lint + token/theme + public-site fact checks +python3 scripts/build_metadata.py --check # Claude/Codex plugin mirror + marketplace drift check +``` + +> **Screen verify**: `--check-density` is a print gate. For screen output (landing or docs pages) instead screenshot the rendered page at 375px and 1280px in every locale and scan for line widows before shipping. See `references/design.md` Section 11 «Responsive screenshot verification». + +Source templates intentionally keep `{{...}}` fields. Run placeholder checks on completed documents, not on the template library. + +For Markdown-sourced long documents, also run `--check-markdown` on the rendered PDF. It catches visible raw `---`, `**bold**`, and inline-code backticks that should have been converted or removed before delivery. + +Visual anomalies (tag double rectangle, font fallback, page break issues) -> `production.md` Part 4. + +### Maintainer-mode checks + +Use these only when maintaining this repository or release package, not for ordinary document generation. + +- If marketplace metadata, generated plugin mirrors, version selection, or install paths change, run `python3 scripts/build_metadata.py --check`; for Claude Code install behavior, smoke with an isolated `HOME=/tmp/...` using `claude plugin marketplace add `, `claude plugin install kami@kami`, and `claude plugin details kami@kami`; for Codex install behavior, smoke with an isolated `CODEX_HOME=/tmp/...` using `codex plugin marketplace add `, `codex plugin add kami@kami`, and `codex plugin list`. +- If `SKILL.md`, templates, scripts, references, or other package inputs change and the behavior ships through the skill package, run `bash scripts/package-skill.sh` and inspect `dist/kami.zip` before handoff. +- If a GitHub release asset is refreshed, download the uploaded `kami.zip` and compare ZIP entry names plus per-entry SHA-256 digests against local `dist/kami.zip`; page text, file size, and the container hash are not enough. + +## Fonts + +**Chinese** +- Main serif: TsangerJinKai02-W04.ttf (400 weight) + TsangerJinKai02-W05.ttf (500 weight, real bold) +- Templates use dual @font-face declarations: W04 for body text, W05 for headings +- Both files are commercial fonts. Keep them available in the repository for local preview and CDN fallback, but do not bundle them inside Claude Desktop skill ZIPs +- Fallback chain baked into templates: Source Han Serif SC -> Noto Serif CJK SC -> Songti SC -> STSong -> Georgia + +**Japanese (best-effort)** +- Uses CJK template path, no dedicated `-ja` templates yet +- JP Mincho-first stack: YuMincho -> Hiragino Mincho ProN -> Noto Serif CJK JP -> Source Han Serif JP -> TsangerJinKai02 -> serif +- Visually verify line breaks, punctuation rhythm, and emphasis weight before shipping + +**Korean (best-effort)** +- Dedicated `-ko` templates use Source Han Serif K Regular / Medium, with the real OTF family name `Source Han Serif KR` kept in every fallback stack +- Fallback: Noto Serif KR / Apple SD Gothic Neo / AppleMyungjo / Charter / Georgia +- The OTFs are OFL-licensed and tracked for local preview / CDN fallback, but excluded from Claude Desktop skill ZIPs to keep the package small + +**English** +- Single serif: Charter (system-bundled, macOS/iOS), used for both headlines and body +- No separate sans: `--sans: var(--serif)`, one font per page +- Fallback: Georgia (cross-platform) / Palatino / Times New Roman + +Font files next to HTML with relative `@font-face` paths is the most stable setup. `scripts/package-skill.sh` excludes large CJK font files from the Claude Desktop ZIP, so the uploaded package stays under the 6MB package ceiling and contains a top-level `kami/` skill folder. Always upload that `package-skill.sh` output, never a hand-zipped checkout (the tracked CJK fonts make it too large and Claude Desktop rejects the upload). + +**Font auto-recovery (Claude Desktop)** + +Before building Chinese or Korean documents, ensure fonts are present. The script tries multiple CDN sources with retry and size validation: + +```bash +bash scripts/ensure-fonts.sh +``` + +It downloads to the XDG user font dir (`${XDG_DATA_HOME:-~/.local/share}/fonts/kami`, override with `KAMI_FONT_DIR`), **not** into the skill's `assets/fonts` -- that keeps the installed skill small so Claude Desktop never trips its size limit. fontconfig scans that dir by default, so WeasyPrint finds `TsangerJinKai02` and `Source Han Serif K` there; online renders fall back to the jsDelivr `@font-face` URL. Run once before building. If all sources fail, the script prints per-language alternatives. + +## Feedback protocol + +When the user gives **vague visual feedback** ("looks off", "太挤了", "not elegant"), do not guess. Ask back with current values: + +| User says | Ask about | +|---|---| +| "太挤了" / "too cramped" | Which element? Line-height (current: X)? Padding (current: Y)? Page margin? | +| "太松了" / "too loose" | Same direction, reversed | +| "颜色不对" / "color feels wrong" | Which element? Brand blue overused? A gray reading too cool? | +| "不够好看" / "not polished" | Font rendering? Alignment? Whitespace distribution? Hierarchy unclear? | +| "看着不专业" / "unprofessional" | Content wording? Or layout (alignment, consistency)? | + +Template response: "X is currently set to Y. Would you like (a) [specific alternative within spec] or (b) [another option]?" + +Never say "I'll adjust the spacing" without naming the exact property and its new value. + +--- + +## When not to use this skill + +- User explicitly wants Material / Fluent / Tailwind default - different design language +- Need dark / cyberpunk / futurist aesthetic (this is deliberately anti-future) +- Need saturated multi-color (this has one accent) +- Need cartoon / animation / illustration style (this is editorial) +- Web dynamic app UI (this is for print / static documents) + +--- + +Next: **apply Step 3's tier table to decide what to read**, then copy the matching template and start filling. diff --git a/plugins/kami/skills/kami/VERSION b/plugins/kami/skills/kami/VERSION new file mode 100644 index 0000000..d615fd0 --- /dev/null +++ b/plugins/kami/skills/kami/VERSION @@ -0,0 +1 @@ +1.9.4 diff --git a/plugins/kami/skills/kami/assets/diagrams/architecture-board.html b/plugins/kami/skills/kami/assets/diagrams/architecture-board.html new file mode 100644 index 0000000..b2b30ed --- /dev/null +++ b/plugins/kami/skills/kami/assets/diagrams/architecture-board.html @@ -0,0 +1,271 @@ + + + + + +Architecture Board · kami diagram + + + +
+

Architecture Board · kami diagram

+

{{System name}} architecture board

+ + + + + + + + + + + + + + Kaku: a WezTerm core, rewired for AI coding + WORKSPACE VIEW · 2026-07 + Defaults ship on day one, Lua customization stays, and the AI engine is the fork's real addition. + + + + 01 · PLATFORM SURFACE + WHO KAKU SERVES + + + + + + AI coding sessions + in-terminal assistant + ai_chat_engine · ai_tools + + Daily terminal work + panes · tabs · workspaces + local, ssh, mux domains + + macOS desktop + theme follows the system + notarized DMG app + + Shell suite + curated zsh plugins + prompt · diff · navigation + + + 02 · GUI SHELL · KAKU-GUI + GPU FRONT END + + + + + Window frontend + frontend.rs · macos + events · commands + + GPU glyph pipeline + glyphcache · shaders + custom glyphs + + Input map + inputmap.rs · commands.rs + keys to actions + + + AI chat engine + ai_client · conversations + auth · state · remote + + + 03 · TERMINAL CORE + INHERITED FROM WEZTERM · TRIMMED + + + + + + Multiplexer + pane · tab · domain + mux crate + + PTY layer + child shell processes + pty crate + + Escape parsing + vtparse · escape-parser + bytes to actions + + Cell model + surface · cell · termwiz + grid state · attributes + + + 04 · MAIN AXIS · INPUT TO PIXELS + THE PATH EVERY KEYSTROKE TAKES + + + + + + + + + + + + + + + Keystroke + user intent + + + Input map + kaku-gui + + + PTY · shell + pty crate + + + Escape parse + vtparse + + + Cell grid + surface + + + GPU frame + glyphcache + + + AUX · AI PATH: PROMPT → AI_CHAT_ENGINE → AI_CLIENT → PROVIDER API + + + 05 · CONTROL PLANE & GOVERNANCE + HOW THE FORK STAYS MAINTAINABLE + + CONFIG + THEME + DISTRIBUTION + UPSTREAM + + + + + + Lua end to end + config · lua-api-crates + config_tui · doctor + + Follows macOS + kaku_theme · color-funcs + dark and light tuned + + Notarized DMG + GitHub releases + brew tw93/tap/kakuku + + WezTerm fork + core crates tracked + 40% smaller binary + + + + LEGEND + + + Focal · the fork's addition + + + + Band · peers share one shell + + + + Main axis + + Crate-level detail lives in the workspace docs; this board carries the spine. + + +

One focal per board: the AI chat engine, the piece this fork actually adds. Bands group peers behind thin dividers, governance sits in a table shell, and the only brand-colored line on the page is the main axis.

+
+ + diff --git a/plugins/kami/skills/kami/assets/diagrams/architecture.html b/plugins/kami/skills/kami/assets/diagrams/architecture.html new file mode 100644 index 0000000..606f17a --- /dev/null +++ b/plugins/kami/skills/kami/assets/diagrams/architecture.html @@ -0,0 +1,180 @@ + + + + + +Architecture · kami diagram + + + +
+

Architecture · kami diagram

+

{{System name}} in production

+ + + + + + + + + + + + + + + + + + + + + + + + + + HTTPS + + + SSR + + + READ + + + QUERY + + + + + USER + Reader + Browser + + + + + EDGE + CDN + cache · SSL + + + + + ORIGIN + App Server + render · route + + + + + BUNDLE + Content + *.mdx · assets + + + + + STORE + Database + postgres · vectors + + + + LEGEND + + + Focal · origin + + + Backend · bundle + + + Store + + + Cloud + + + External + + + Standard flow + + + Primary path + + +

Focal rule: one ink-blue node per diagram, marking the component the reader should look at first. Every other box stays in warm neutrals so the accent actually means something.

+
+ + diff --git a/plugins/kami/skills/kami/assets/diagrams/bar-chart.html b/plugins/kami/skills/kami/assets/diagrams/bar-chart.html new file mode 100644 index 0000000..80ddbaf --- /dev/null +++ b/plugins/kami/skills/kami/assets/diagrams/bar-chart.html @@ -0,0 +1,187 @@ + + + + + +Bar Chart · kami diagram + + + +
+

Bar Chart · kami diagram

+

{{Chart Title}}

+ + + + + + + + + + + + + + + + + + + 0 + 20 + 40 + 60 + 80 + 100 + 120 + 140 + + + UNIT + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 44 + 32 + + 60 + 48 + + 72 + 64 + + 88 + 76 + + + 2021 + 2022 + 2023 + 2024 + + + + + + + + {{Series A label}} + + + {{Series B label}} + + + +

{{Caption text. The focal series in ink-blue carries the primary argument. State the takeaway here, not a description of what is plotted.}}

+
+ + diff --git a/plugins/kami/skills/kami/assets/diagrams/candlestick.html b/plugins/kami/skills/kami/assets/diagrams/candlestick.html new file mode 100644 index 0000000..1ba1428 --- /dev/null +++ b/plugins/kami/skills/kami/assets/diagrams/candlestick.html @@ -0,0 +1,223 @@ + + + + + +Candlestick Chart · kami diagram + + + +
+

Candlestick · kami diagram

+

{{Chart Title}}

+ + + + + + + + + + + + + + + + + + 100 + 110 + 120 + 130 + 140 + 150 + 160 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{D1}} + {{D6}} + {{D11}} + {{D16}} + {{D20}} + + + + + + Up (close > open) + + + Down (close < open) + + + +

{{Caption: e.g. "20-day price action showing accumulation phase."}}

+
+ + diff --git a/plugins/kami/skills/kami/assets/diagrams/class.html b/plugins/kami/skills/kami/assets/diagrams/class.html new file mode 100644 index 0000000..13df29c --- /dev/null +++ b/plugins/kami/skills/kami/assets/diagrams/class.html @@ -0,0 +1,158 @@ + + + + + +Class · kami diagram + + + +
+

Class · kami diagram

+

{{What structure does this model}}

+ + + + + + + + + + + + + + + + + + + + + + + Order + + + id: String + + createdAt: Date + + + total(): Money + + + + + Customer + + + name: String + + + + + + LineItem + + + qty: int + + + places + 1 + * + contains + 1 + * + + +

Boxes are types; compartments list fields and methods. Arrows carry association and composition.

+
+ + diff --git a/plugins/kami/skills/kami/assets/diagrams/donut-chart.html b/plugins/kami/skills/kami/assets/diagrams/donut-chart.html new file mode 100644 index 0000000..14fc7a4 --- /dev/null +++ b/plugins/kami/skills/kami/assets/diagrams/donut-chart.html @@ -0,0 +1,194 @@ + + + + + +Donut Chart · kami diagram + + + +
+

Donut Chart · kami diagram

+

{{Chart Title}}

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 32% + {{CENTER LABEL}} + + + + + + + + + 32% + {{Category A}} + + + + 24% + {{Category B}} + + + + 18% + {{Category C}} + + + + 12% + {{Category D}} + + + + 8% + {{Category E}} + + + + 6% + {{Category F}} + + + + TOTAL · 100% + {{Source / period}} + + + +

{{Caption text. The ink-blue segment is the focal category. Lead with the insight, not the breakdown.}}

+
+ + diff --git a/plugins/kami/skills/kami/assets/diagrams/er.html b/plugins/kami/skills/kami/assets/diagrams/er.html new file mode 100644 index 0000000..863ea93 --- /dev/null +++ b/plugins/kami/skills/kami/assets/diagrams/er.html @@ -0,0 +1,166 @@ + + + + + +ER · kami diagram + + + +
+

Entity-Relationship · kami diagram

+

{{What schema does this map}}

+ + + + + + + + + + + + CUSTOMER + + (no attributes) + + + + + ORDER + + (no attributes) + + + + + LINE_ITEM + + (no attributes) + + + + + PRODUCT + + (no attributes) + + + + + + + + + + + + + + + + + + + + places + + contains + + appears in + + +

Crow's-foot notation: each connector's ends mark cardinality between entities.

+
+ + diff --git a/plugins/kami/skills/kami/assets/diagrams/flowchart.html b/plugins/kami/skills/kami/assets/diagrams/flowchart.html new file mode 100644 index 0000000..49c2d26 --- /dev/null +++ b/plugins/kami/skills/kami/assets/diagrams/flowchart.html @@ -0,0 +1,170 @@ + + + + + +Flowchart · kami diagram + + + +
+

Flowchart · kami diagram

+

{{Question the flow answers}}

+ + + + + + + + + + + + + + + + + + + + + + + + YES + + + + + NO + + + + + + + + + + + + Start + + + + STEP 01 + {{Describe input}} + one short line + + + + {{Decision}} + BRANCH + + + + OUTCOME A + {{Path taken}} + what happens next + + + + OUTCOME B + {{Alt path}} + skipped / deferred + + + + End + + + + LEGEND + + + Focal decision + + + Step · outcome + + + Start · end + + + Deferred branch + + +

The decision diamond carries the accent. Its two branches diverge into equal weights so the reader sees the question, not an implied answer.

+
+ + diff --git a/plugins/kami/skills/kami/assets/diagrams/layer-stack.html b/plugins/kami/skills/kami/assets/diagrams/layer-stack.html new file mode 100644 index 0000000..de2bd91 --- /dev/null +++ b/plugins/kami/skills/kami/assets/diagrams/layer-stack.html @@ -0,0 +1,184 @@ + + + + + +Layer Stack · kami diagram + + + +
+

Layer Stack · kami diagram

+

{{System name}} architecture layers

+ + + + + + + + + + + + + + + + 01 + Presentation + + UI · views · routing + + + + 02 · FOCAL + Business Logic + + rules · validation · orchestration + + + + 03 + Data Access + + repositories · queries · cache + + + + 04 + Storage + + database · object store · search index + + + + 05 + Infrastructure + + cloud · network · runtime + + + + + + REQUEST + + + + + + RESPONSE + + + + LEGEND + + + Focal layer + + + Top layer + + + Foundation + + + + Request direction + + + + Response direction + + +

Focal rule: one layer in ink-blue marks where the most domain-specific logic lives. Layers above carry user-facing concerns; layers below carry infrastructure. Side arrows orient the reader to flow direction without cluttering the bands.

+
+ + diff --git a/plugins/kami/skills/kami/assets/diagrams/line-chart.html b/plugins/kami/skills/kami/assets/diagrams/line-chart.html new file mode 100644 index 0000000..e33bf6f --- /dev/null +++ b/plugins/kami/skills/kami/assets/diagrams/line-chart.html @@ -0,0 +1,207 @@ + + + + + +Line Chart · kami diagram + + + +
+

Line Chart · kami diagram

+

{{Chart Title}}

+ + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 20 + 40 + 60 + 80 + 100 + 120 + 140 + + + UNIT + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 96 + 76 + + + Jan + Feb + Mar + Apr + May + Jun + Jul + Aug + + + + + + + + + + {{Line 1 label}} + + + + + {{Line 2 label}} + + + +

{{Caption text. The ink-blue line carries the primary trend argument. State what the trend means, not what was plotted.}}

+
+ + diff --git a/plugins/kami/skills/kami/assets/diagrams/quadrant.html b/plugins/kami/skills/kami/assets/diagrams/quadrant.html new file mode 100644 index 0000000..80e6be0 --- /dev/null +++ b/plugins/kami/skills/kami/assets/diagrams/quadrant.html @@ -0,0 +1,173 @@ + + + + + +Quadrant · kami diagram + + + +
+

Quadrant · kami diagram

+

{{X axis}} × {{Y axis}}

+ + + + + + + + + + + + + + + + + + + + + + + {{X axis · low to high}} + {{Y axis · low to high}} + + + HIGH-Y · LOW-X + incubate + + HIGH-Y · HIGH-X + do first + + LOW-Y · LOW-X + drop + + LOW-Y · HIGH-X + delegate + + + + + {{Item A}} + + + + {{Item B · focal}} + + + + {{Item C}} + + + + {{Item D}} + + + + {{Item E}} + + + + {{Item F}} + + + + {{Item G}} + + + + {{Item H}} + + + + LEGEND + + + Focal · do first + + + Near-focal + + + Standard + + + Secondary + + + Drop + + +

The ink-blue point is where the reader's eye lands. The tinted upper-right quadrant is the preferred region. Everything else recedes so the judgment reads instantly.

+
+ + diff --git a/plugins/kami/skills/kami/assets/diagrams/sequence.html b/plugins/kami/skills/kami/assets/diagrams/sequence.html new file mode 100644 index 0000000..aea8ef0 --- /dev/null +++ b/plugins/kami/skills/kami/assets/diagrams/sequence.html @@ -0,0 +1,148 @@ + + + + + +Sequence · kami diagram + + + +
+

Sequence · kami diagram

+

{{What interaction does this trace}}

+ + + + + + + + + + + + + + + + + 提交请求 + + + + 查询记录 + + + + 返回结果 + + + + 响应数据 + + + + 用户 + + + + API + + + + 数据库 + + + +

Lifelines drop from each participant; messages read top to bottom. Dashed returns distinguish responses from calls.

+
+ + diff --git a/plugins/kami/skills/kami/assets/diagrams/src/class.mmd b/plugins/kami/skills/kami/assets/diagrams/src/class.mmd new file mode 100644 index 0000000..fff388c --- /dev/null +++ b/plugins/kami/skills/kami/assets/diagrams/src/class.mmd @@ -0,0 +1,14 @@ +classDiagram + class Order { + +String id + +Date createdAt + +total() Money + } + class Customer { + +String name + } + class LineItem { + +int qty + } + Customer "1" --> "*" Order : places + Order "1" *-- "*" LineItem : contains diff --git a/plugins/kami/skills/kami/assets/diagrams/src/er.mmd b/plugins/kami/skills/kami/assets/diagrams/src/er.mmd new file mode 100644 index 0000000..45774de --- /dev/null +++ b/plugins/kami/skills/kami/assets/diagrams/src/er.mmd @@ -0,0 +1,4 @@ +erDiagram + CUSTOMER ||--o{ ORDER : places + ORDER ||--|{ LINE_ITEM : contains + PRODUCT ||--o{ LINE_ITEM : "appears in" diff --git a/plugins/kami/skills/kami/assets/diagrams/src/sequence.mmd b/plugins/kami/skills/kami/assets/diagrams/src/sequence.mmd new file mode 100644 index 0000000..96b3512 --- /dev/null +++ b/plugins/kami/skills/kami/assets/diagrams/src/sequence.mmd @@ -0,0 +1,8 @@ +sequenceDiagram + participant U as 用户 + participant A as API + participant D as 数据库 + U->>A: 提交请求 + A->>D: 查询记录 + D-->>A: 返回结果 + A-->>U: 响应数据 diff --git a/plugins/kami/skills/kami/assets/diagrams/state-machine.html b/plugins/kami/skills/kami/assets/diagrams/state-machine.html new file mode 100644 index 0000000..04a565d --- /dev/null +++ b/plugins/kami/skills/kami/assets/diagrams/state-machine.html @@ -0,0 +1,217 @@ + + + + + +State Machine · kami diagram + + + +
+

State Machine · kami diagram

+

{{Component name}} lifecycle

+ + + + + + + + + + + + + + + RESET + + + + + + + + + + + + TRIGGER + + + + + + SUBMIT + + + + + + COMPLETE + + + + + + + + + + + + + + + STATE + Idle + waiting + + + + + FOCAL + Active + editing + + + + + STATE + Processing + running + + + + + TERMINAL + Done + resolved + + + + + + + + LEGEND + + + Start + + + Focal state + + + Terminal + + + + Forward transition + + + + Reset / timeout + + +

Focal rule: one ink-blue state per diagram, marking where the user spends most time. Start and terminal markers follow UML convention; the dashed arc shows the exceptional path.

+
+ + diff --git a/plugins/kami/skills/kami/assets/diagrams/swimlane.html b/plugins/kami/skills/kami/assets/diagrams/swimlane.html new file mode 100644 index 0000000..b52ddc5 --- /dev/null +++ b/plugins/kami/skills/kami/assets/diagrams/swimlane.html @@ -0,0 +1,202 @@ + + + + + +Swimlane · kami diagram + + + +
+

Swimlane · kami diagram

+

{{System name}} request flow

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + CLIENT + SERVER + DATABASE + + + + + + + + + + + + + + + + + + + + + + + + + USER + Request + + + + + FOCAL + Validate + + + + + HANDLER + Route + + + + + STORE + Query + + + + + USER + Respond + + + + LEGEND + + Focal lane / node + + Store + + Client / external + + +

Focal rule: the Server lane carries the business logic, so it gets the ink-blue tint. Client and Database lanes stay in warm neutral to keep the eye on what actually decides the outcome.

+
+ + diff --git a/plugins/kami/skills/kami/assets/diagrams/timeline.html b/plugins/kami/skills/kami/assets/diagrams/timeline.html new file mode 100644 index 0000000..0eff2d4 --- /dev/null +++ b/plugins/kami/skills/kami/assets/diagrams/timeline.html @@ -0,0 +1,170 @@ + + + + + +Timeline · kami diagram + + + +
+

Timeline · kami diagram

+

{{Project name}} milestones

+ + + + + + + + + + + + + + + + + + + + + + + 2019 + Concept + + + + + + 2020 + Prototype + + + + + + 2021 · FOCAL + Launch + + + + + + 2022 + Growth + + + + + + 2023 + Scale + + + + LEGEND + + + Focal milestone + + + Standard milestone + + + Connector + + +

Focal rule: ink-blue marks the single most important milestone, the moment the reader should remember. Every other event stays in warm stone so the contrast does the work.

+
+ + diff --git a/plugins/kami/skills/kami/assets/diagrams/tree.html b/plugins/kami/skills/kami/assets/diagrams/tree.html new file mode 100644 index 0000000..424979c --- /dev/null +++ b/plugins/kami/skills/kami/assets/diagrams/tree.html @@ -0,0 +1,227 @@ + + + + + +Tree · kami diagram + + + +
+

Tree · kami diagram

+

{{System name}} hierarchy

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ROOT + {{System}} + + + + + + + MODULE + {{Module A}} + + + + + FOCAL + {{Module B}} + + + + + + + {{Leaf 1}} + service + + + + + {{Leaf 2}} + service + + + + + + + {{Leaf 3}} + component + + + + + {{Leaf 4}} + component + + + + + {{Leaf 5}} + component + + + + LEGEND + + + Focal sub-tree + + + Root + + + Secondary branch + + +

Focal rule: one sub-tree in ink-blue marks the branch where complexity lives, or the path the reader needs to understand first. Orthogonal elbows make parent-child relationships unambiguous.

+
+ + diff --git a/plugins/kami/skills/kami/assets/diagrams/venn.html b/plugins/kami/skills/kami/assets/diagrams/venn.html new file mode 100644 index 0000000..cbe3e6d --- /dev/null +++ b/plugins/kami/skills/kami/assets/diagrams/venn.html @@ -0,0 +1,154 @@ + + + + + +Venn · kami diagram + + + +
+

Venn · kami diagram

+

{{Topic A}} and {{Topic B}} intersection

+ + + + + + + + + + + + + + + + + + + + + + {{Set A only}} + unique to A + + + {{Shared}} + overlap + + + {{Set B only}} + unique to B + + + SET A + SET B + + + item · item · item + item · item + item · item · item + + + + LEGEND + + + Focal set (A) + + + Secondary set (B) + + Overlap + Intersection label (serif callout) + + +

Focal rule: the circle whose contents matter most to the argument gets the ink-blue stroke. The intersection label uses a serif italic callout to draw the eye to the overlap, which is usually the point of the diagram.

+
+ + diff --git a/plugins/kami/skills/kami/assets/diagrams/waterfall.html b/plugins/kami/skills/kami/assets/diagrams/waterfall.html new file mode 100644 index 0000000..5ca82e3 --- /dev/null +++ b/plugins/kami/skills/kami/assets/diagrams/waterfall.html @@ -0,0 +1,192 @@ + + + + + +Waterfall Chart · kami diagram + + + +
+

Waterfall · kami diagram

+

{{Chart Title}}

+ + + + + + + + + + + + + + + + + 0 + 40 + 80 + 120 + 160 + 200 + + + + + 120 + + + + + + + + +30 + + + + + + + + +20 + + + + + + + + -40 + + + + + + + + +10 + + + + + + + + -20 + + + + 120 + + + + {{Start}} + {{Cat A}} + {{Cat B}} + {{Cat C}} + {{Cat D}} + {{Cat E}} + {{End}} + + + + + + Increase + + + Decrease + + + Total + + + +

{{Caption: e.g. "Revenue bridge from FY2024 to FY2025, showing growth drivers and headwinds."}}

+
+ + diff --git a/plugins/kami/skills/kami/assets/fonts/JetBrainsMono.woff2 b/plugins/kami/skills/kami/assets/fonts/JetBrainsMono.woff2 new file mode 100644 index 0000000..256ca93 Binary files /dev/null and b/plugins/kami/skills/kami/assets/fonts/JetBrainsMono.woff2 differ diff --git a/plugins/kami/skills/kami/assets/fonts/LICENSE-SourceHanSerifK.txt b/plugins/kami/skills/kami/assets/fonts/LICENSE-SourceHanSerifK.txt new file mode 100644 index 0000000..ddf9d29 --- /dev/null +++ b/plugins/kami/skills/kami/assets/fonts/LICENSE-SourceHanSerifK.txt @@ -0,0 +1,107 @@ +Source Han Serif K (also distributed as Noto Serif KR) +Copyright 2017-2022 Adobe (http://www.adobe.com/), with Reserved Font +Name 'Source'. Source is a trademark of Adobe in the United States +and/or other countries. + +This Font Software is licensed under the SIL Open Font License, +Version 1.1. + +Source: + https://github.com/adobe-fonts/source-han-serif (Adobe official) + https://fonts.google.com/noto/specimen/Noto+Serif+KR (Google Fonts mirror) + +The Adobe and Google distributions are the same font under two names. +Kami's templates reference three names in the `--serif` chain +("Source Han Serif K", "Source Han Serif KR", "Noto Serif KR"): +"Source Han Serif K" is the `@font-face` declared alias (loads via the +bundled file or CDN), "Source Han Serif KR" is the actual family name +inside the OTFs (so fontconfig resolves the ensure-fonts.sh download by +name on an offline Linux box), and "Noto Serif KR" covers the Google +Fonts install. The font therefore resolves regardless of source. + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font +creation efforts of academic and linguistic communities, and to +provide a free and open framework in which fonts may be shared and +improved in partnership with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply to +any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software +components as distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, +deleting, or substituting -- in part or in whole -- any of the +components of the Original Version, by changing formats or by porting +the Font Software to a new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, +modify, redistribute, and sell modified and unmodified copies of the +Font Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, in +Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the +corresponding Copyright Holder. This restriction only applies to the +primary font name as presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created using +the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/plugins/kami/skills/kami/assets/images/logo.svg b/plugins/kami/skills/kami/assets/images/logo.svg new file mode 100644 index 0000000..cf07a2e --- /dev/null +++ b/plugins/kami/skills/kami/assets/images/logo.svg @@ -0,0 +1 @@ + diff --git a/plugins/kami/skills/kami/assets/templates/changelog-en.html b/plugins/kami/skills/kami/assets/templates/changelog-en.html new file mode 100644 index 0000000..aa479da --- /dev/null +++ b/plugins/kami/skills/kami/assets/templates/changelog-en.html @@ -0,0 +1,249 @@ + + + + + +{{PROJECT_NAME}} · {{VERSION}} Release Notes + + + + + + + + + +
+
+
Release Notes
+

{{PROJECT_NAME}} {{VERSION}}

+
{{One-line release highlight.}}
+
+
{{RELEASE_DATE}}
+
+ + +

Breaking Changes

+
    +
  1. Breaking {{What changed}}: {{what readers must do about it.}}
  2. +
+ +

Features

+
    +
  1. {{Feature}}: {{one-line description.}}
  2. +
  3. {{Feature}}: {{one-line description.}}
  4. +
  5. {{Feature}}: {{one-line description.}}
  6. +
+ +

Fixes

+
    +
  1. {{Area}}: {{what was fixed.}}
  2. +
  3. {{Area}}: {{what was fixed.}}
  4. +
  5. {{Area}}: {{what was fixed.}}
  6. +
+ + +
+
Acknowledgements
+ {{Thank contributors, e.g. "Thanks to @user1 and @user2 for their help."}} +
+ + + + + + diff --git a/plugins/kami/skills/kami/assets/templates/changelog-ko.html b/plugins/kami/skills/kami/assets/templates/changelog-ko.html new file mode 100644 index 0000000..a454b30 --- /dev/null +++ b/plugins/kami/skills/kami/assets/templates/changelog-ko.html @@ -0,0 +1,259 @@ + + + + + +{{프로젝트명}} · {{버전}} 업데이트 로그 + + + + + + + + + + +
+
+
업데이트 로그
+

{{프로젝트명}} {{버전}}

+
{{이번 버전의 핵심 한 문장}}
+
+
{{출시 날짜}}
+
+ + +

호환성 변경 사항

+
    +
  1. Breaking {{변경된 점}}: {{사용자가 해야 할 조치.}}
  2. +
+ +

신기능

+
    +
  1. {{기능}}: {{한 문장 설명.}}
  2. +
  3. {{기능}}: {{한 문장 설명.}}
  4. +
  5. {{기능}}: {{한 문장 설명.}}
  6. +
+ +

수정 사항

+
    +
  1. {{영역}}: {{무엇을 고쳤는지.}}
  2. +
  3. {{영역}}: {{무엇을 고쳤는지.}}
  4. +
  5. {{영역}}: {{무엇을 고쳤는지.}}
  6. +
+ + +
+
감사의 말
+ {{기여자에게 감사. 예: "기여해 주신 @user1, @user2 님께 감사드립니다."}} +
+ + + + + + diff --git a/plugins/kami/skills/kami/assets/templates/changelog.html b/plugins/kami/skills/kami/assets/templates/changelog.html new file mode 100644 index 0000000..821ce60 --- /dev/null +++ b/plugins/kami/skills/kami/assets/templates/changelog.html @@ -0,0 +1,254 @@ + + + + + +{{项目名}} · {{版本号}} 更新日志 + + + + + + + + + +
+
+
更新日志
+

{{项目名}} {{版本号}}

+
{{一句话版本亮点}}
+
+
{{发布日期}}
+
+ + +

破坏性变更

+
    +
  1. Breaking {{变更点}}:{{读者需要做的处理}}
  2. +
+ +

新功能

+
    +
  1. {{功能}}:{{一句话描述}}
  2. +
  3. {{功能}}:{{一句话描述}}
  4. +
  5. {{功能}}:{{一句话描述}}
  6. +
+ +

修复

+
    +
  1. {{范围}}:{{修复了什么}}
  2. +
  3. {{范围}}:{{修复了什么}}
  4. +
  5. {{范围}}:{{修复了什么}}
  6. +
+ + +
+
致谢
+ {{感谢贡献者,如 "感谢 @user1、@user2 的贡献。"}} +
+ + + + + + diff --git a/plugins/kami/skills/kami/assets/templates/equity-report-en.html b/plugins/kami/skills/kami/assets/templates/equity-report-en.html new file mode 100644 index 0000000..e09657a --- /dev/null +++ b/plugins/kami/skills/kami/assets/templates/equity-report-en.html @@ -0,0 +1,498 @@ + + + + + +{{COMPANY_NAME}} · Equity Report + + + + + + + + + +
+
+
{{EYEBROW - e.g. Equity Research / Valuation / Investment Memo}}
+
{{COMPANY_NAME}} {{TICKER}}
+
{{SECTOR}} · {{EXCHANGE}} · {{One-line thesis.}}
+
+
+
{{CURRENT_PRICE}}
+
{{CHANGE - e.g. "+12.3%"}}
+
{{DATE}}
+
+
+ + +
+
+
{{NUMBER}}
+
Market Cap
+
+
+
{{NUMBER}}
+
P/E Ratio
+
+
+
{{NUMBER}}
+
Revenue
+
+
+
{{NUMBER}}
+
Margin
+
+
+ + +

Investment Thesis

+

{{Two or three sentences laying out the core thesis. Use key figures to support the argument. This paragraph is the soul of the report.}}

+ +
+ {{One-line verdict: buy / hold / watch, and the single strongest reason.}} +
+ + +

Price Action

+
+
+ [Candlestick / price chart placeholder - extract SVG from diagrams/] +
+
{{Chart title, e.g. "20-day price action showing accumulation phase."}}
+
+ + +

Financial Overview

+
+ + + + + + + + + + + + + + + +
MetricFY2023FY2024FY2025E
Revenue{{VAL}}{{VAL}}{{VAL}}
Net Income{{VAL}}{{VAL}}{{VAL}}
EPS{{VAL}}{{VAL}}{{VAL}}
Gross Margin{{VAL}}{{VAL}}{{VAL}}
ROE{{VAL}}{{VAL}}{{VAL}}
+ + +

Revenue Breakdown

+
+
+ [Revenue breakdown chart placeholder - donut or waterfall] +
+
{{Chart title.}}
+
+ + +
+

Competitive Landscape

+

{{Industry landscape overview, one or two paragraphs.}}

+ + + + + + + + + + + + + + + + + +
CompanyMkt CapP/ERev GrowthMargin
{{TARGET}}{{VAL}}{{VAL}}{{VAL}}{{VAL}}
{{COMP_A}}{{VAL}}{{VAL}}{{VAL}}{{VAL}}
{{COMP_B}}{{VAL}}{{VAL}}{{VAL}}{{VAL}}
{{COMP_C}}{{VAL}}{{VAL}}{{VAL}}{{VAL}}
+ + +

Risk Factors

+
+
+
{{Risk Type 1}}
+
{{Risk description.}}
+
+
+
{{Risk Type 2}}
+
{{Risk description.}}
+
+
+
{{Risk Type 3}}
+
{{Risk description.}}
+
+
+
{{Risk Type 4}}
+
{{Risk description.}}
+
+
+ + +
+
Analyst Summary
+ {{Three to five sentences summarizing the position. Include target price (if applicable), rating, key catalysts, and core assumptions.}} +
+ + + + + + diff --git a/plugins/kami/skills/kami/assets/templates/equity-report-ko.html b/plugins/kami/skills/kami/assets/templates/equity-report-ko.html new file mode 100644 index 0000000..54cf5d6 --- /dev/null +++ b/plugins/kami/skills/kami/assets/templates/equity-report-ko.html @@ -0,0 +1,565 @@ + + + + + +{{종목명}} · 종목 리서치 + + + + + + + + + +
+
+
{{EYEBROW · 예: "종목 리서치" / "밸류에이션 분석" / "투자 메모"}}
+
{{회사명}} {{종목 코드}}
+
{{섹터}} · {{거래소}} · {{핵심 투자 판단 한 문장}}
+
+
+
{{현재가}}
+
{{등락률 예: "+12.3%"}}
+
{{날짜}}
+
+
+ + +
+
+
{{수치}}
+
시가총액
+
+
+
{{수치}}
+
P/E
+
+
+
{{수치}}
+
매출
+
+
+
{{수치}}
+
이익률
+
+
+ + +

투자 논거

+

{{2-3문장의 핵심 투자 포인트. 핵심 데이터로 판단을 뒷받침한다. 이 단락이 리서치 전체의 핵심이다.}}

+ +
+ {{한 문장 요약: 매수/보유/관망의 핵심 이유.}} +
+ + +

가격 추이

+
+ +
+ [캔들스틱 / 가격 추이 차트 자리 · diagrams/에서 SVG를 추출해 삽입] +
+
{{차트 제목, 예: "최근 20 거래일 가격 추이"}}
+
+ + +

재무 개요

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
지표FY2023FY2024FY2025E
매출{{데이터}}{{데이터}}{{데이터}}
순이익{{데이터}}{{데이터}}{{데이터}}
EPS{{데이터}}{{데이터}}{{데이터}}
매출총이익률{{데이터}}{{데이터}}{{데이터}}
ROE{{데이터}}{{데이터}}{{데이터}}
+ + +

매출 구조

+
+
+ [매출 분해 차트 자리 · 도넛 차트 또는 폭포 차트] +
+
{{차트 제목}}
+
+ + +
+

경쟁 구도

+

{{업계 구도 개요, 1-2 단락.}}

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
회사시가총액P/E매출 성장률이익률
{{대상 회사}}{{데이터}}{{데이터}}{{데이터}}{{데이터}}
{{경쟁사 A}}{{데이터}}{{데이터}}{{데이터}}{{데이터}}
{{경쟁사 B}}{{데이터}}{{데이터}}{{데이터}}{{데이터}}
{{경쟁사 C}}{{데이터}}{{데이터}}{{데이터}}{{데이터}}
+ + +

리스크 고지

+
+
+
{{리스크 유형 1}}
+
{{리스크 설명}}
+
+
+
{{리스크 유형 2}}
+
{{리스크 설명}}
+
+
+
{{리스크 유형 3}}
+
{{리스크 설명}}
+
+
+
{{리스크 유형 4}}
+
{{리스크 설명}}
+
+
+ + +
+
애널리스트 총평
+ {{3-5문장 총평. 목표 주가(해당 시), 투자 의견, 핵심 촉매제, 주요 가정을 포함한다.}} +
+ + + + + + diff --git a/plugins/kami/skills/kami/assets/templates/equity-report.html b/plugins/kami/skills/kami/assets/templates/equity-report.html new file mode 100644 index 0000000..7fb1705 --- /dev/null +++ b/plugins/kami/skills/kami/assets/templates/equity-report.html @@ -0,0 +1,559 @@ + + + + + +{{股票名称}} · 个股研报 + + + + + + + + + +
+
+
{{EYEBROW · 如 "个股研报" / "估值分析" / "投资备忘"}}
+
{{公司名称}} {{股票代码}}
+
{{行业}} · {{交易所}} · {{一句核心判断}}
+
+
+
{{当前价格}}
+
{{涨跌幅 如 "+12.3%"}}
+
{{日期}}
+
+
+ + +
+
+
{{数字}}
+
市值
+
+
+
{{数字}}
+
P/E
+
+
+
{{数字}}
+
营收
+
+
+
{{数字}}
+
利润率
+
+
+ + +

投资逻辑

+

{{2-3 句核心投资论点。用 关键数据 支撑判断。这段是整份研报的灵魂。}}

+ +
+ {{一句话总结:买入/持有/观望的核心理由。}} +
+ + +

价格走势

+
+ +
+ [K 线图 / 价格走势图占位 · 从 diagrams/ 提取 SVG 嵌入] +
+
{{图表标题,如 "近 20 个交易日价格走势"}}
+
+ + +

财务概览

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
指标FY2023FY2024FY2025E
营收{{数据}}{{数据}}{{数据}}
净利润{{数据}}{{数据}}{{数据}}
EPS{{数据}}{{数据}}{{数据}}
毛利率{{数据}}{{数据}}{{数据}}
ROE{{数据}}{{数据}}{{数据}}
+ + +

收入结构

+
+
+ [营收分解图占位 · 环形图或瀑布图] +
+
{{图表标题}}
+
+ + +
+

竞争格局

+

{{行业格局概述,1-2 段。}}

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
公司市值P/E营收增速利润率
{{目标公司}}{{数据}}{{数据}}{{数据}}{{数据}}
{{竞品 A}}{{数据}}{{数据}}{{数据}}{{数据}}
{{竞品 B}}{{数据}}{{数据}}{{数据}}{{数据}}
{{竞品 C}}{{数据}}{{数据}}{{数据}}{{数据}}
+ + +

风险提示

+
+
+
{{风险类型 1}}
+
{{风险描述}}
+
+
+
{{风险类型 2}}
+
{{风险描述}}
+
+
+
{{风险类型 3}}
+
{{风险描述}}
+
+
+
{{风险类型 4}}
+
{{风险描述}}
+
+
+ + +
+
分析师总结
+ {{3-5 句总结。包含目标价(如有)、评级建议、核心催化剂、关键假设。}} +
+ + + + + + diff --git a/plugins/kami/skills/kami/assets/templates/landing-page-en.html b/plugins/kami/skills/kami/assets/templates/landing-page-en.html new file mode 100644 index 0000000..c10771e --- /dev/null +++ b/plugins/kami/skills/kami/assets/templates/landing-page-en.html @@ -0,0 +1,1163 @@ + + + + + +{{PRODUCT_NAME}} · {{TAGLINE_SHORT}} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+ {{EYEBROW_TEXT}} · {{VERSION}} + + + +
+ +

{{PRODUCT_NAME}}

+ +

{{TAGLINE}}

+ +
+ +
+ + +
+ + +
+
+

00 · {{GALLERY_SECTION_NUM}}

+

{{GALLERY_TITLE}}

+

{{GALLERY_LEDE}}

+ +
+ + +
+ + +
+
+

01 · {{FEATURES_SECTION_NUM}}

+

{{FEATURES_TITLE}}

+
+ +
    + +
+
+ + +
+
+

02 · {{PRINCIPLES_SECTION_NUM}}

+

{{PRINCIPLES_TITLE}}

+
+ +
    + +
+
+ + +
+
+

03 · {{PRICING_SECTION_NUM}}

+

{{PRICING_TITLE}}

+
+ +
+

{{PRICE_DISPLAY}}

+

{{PRICE_COMPARISON}}

+ {{CTA_PRIMARY_LABEL}} +

{{PRICE_TRIAL_TEXT}}

+

{{PRICE_TERMS}}

+
+
+ + +
+
+

04 · {{FAQ_SECTION_NUM}}

+

{{FAQ_TITLE}}

+
+ +
+ +
+ +
+ + +
+
+ {{PRODUCT_NAME}} + {{PRODUCT_NAME}} + {{FOOTER_TAGLINE}} +
+
+ +

{{FOOTER_ETHOS}}

+
+
+ +
+ + + + + diff --git a/plugins/kami/skills/kami/assets/templates/landing-page-ko.html b/plugins/kami/skills/kami/assets/templates/landing-page-ko.html new file mode 100644 index 0000000..3e60120 --- /dev/null +++ b/plugins/kami/skills/kami/assets/templates/landing-page-ko.html @@ -0,0 +1,1148 @@ + + + + + +{{PRODUCT_NAME}} · {{TAGLINE_SHORT}} + + + + + + + + + + + + + + + + + +
+ + +
+
+ {{EYEBROW_TEXT}} · {{VERSION}} + + + +
+ +

{{PRODUCT_NAME}}

+ +

{{TAGLINE}}

+ +
+ +
+ + +
+ + +
+
+

00 · {{GALLERY_SECTION_NUM}}

+

{{GALLERY_TITLE}}

+

{{GALLERY_LEDE}}

+ +
+ + +
+ + +
+
+

01 · {{FEATURES_SECTION_NUM}}

+

{{FEATURES_TITLE}}

+
+ +
    + +
+
+ + +
+
+

02 · {{PRINCIPLES_SECTION_NUM}}

+

{{PRINCIPLES_TITLE}}

+
+ +
    + +
+
+ + +
+
+

03 · {{PRICING_SECTION_NUM}}

+

{{PRICING_TITLE}}

+
+ +
+

{{PRICE_DISPLAY}}

+

{{PRICE_COMPARISON}}

+ {{CTA_PRIMARY_LABEL}} +

{{PRICE_TRIAL_TEXT}}

+

{{PRICE_TERMS}}

+
+
+ + +
+
+

04 · {{FAQ_SECTION_NUM}}

+

{{FAQ_TITLE}}

+
+ +
+ +
+ +
+ + +
+
+ {{PRODUCT_NAME}} + {{PRODUCT_NAME}} + {{FOOTER_TAGLINE}} +
+
+ +

{{FOOTER_ETHOS}}

+
+
+ +
+ + + + diff --git a/plugins/kami/skills/kami/assets/templates/landing-page-llms-full.txt.example b/plugins/kami/skills/kami/assets/templates/landing-page-llms-full.txt.example new file mode 100644 index 0000000..61daf3c --- /dev/null +++ b/plugins/kami/skills/kami/assets/templates/landing-page-llms-full.txt.example @@ -0,0 +1,76 @@ +# {{PRODUCT_NAME}} - Full Knowledge Base + + + +> {{TAGLINE_LONG_ONE_SENTENCE}} + +This is the long-form companion to llms.txt. Keep llms.txt as the short summary; put feature details, FAQ, and comparison here so AI assistants get accurate answers without having to scrape the site. + +--- + +## Overview + +{{PRODUCT_NAME}} is {{ONE_PARAGRAPH_POSITIONING}}. + +Author: {{AUTHOR_NAME}} ({{AUTHOR_URL}}). +Source: {{SOURCE_REPO_URL}}. +Platform: {{OS_REQUIREMENT}}. + +## Pricing + +{{ONE_PARAGRAPH_PRICE_TERMS_REFUND_LICENSE_SEATS}} + +## Features + +### {{FEATURE_1_NAME}} +{{ONE_PARAGRAPH_FEATURE_1}}. Cover: what it does, when to use it, the most common output it produces, and any limit the user should know. + +### {{FEATURE_2_NAME}} +{{ONE_PARAGRAPH_FEATURE_2}} + +### {{FEATURE_3_NAME}} +{{ONE_PARAGRAPH_FEATURE_3}} + +Repeat one subsection per feature. Aim for 3-7 features. Skip marketing adjectives - LLMs index facts, not enthusiasm. + +## How {{PRODUCT_NAME}} differs + +### vs {{COMPETITOR_A}} +{{TWO_OR_THREE_SENTENCES}}. State the concrete differentiator (workflow, output, distribution model, price). Avoid "we are better" framing. + +### vs {{COMPETITOR_B}} +{{TWO_OR_THREE_SENTENCES}}. + +### vs general-purpose tools (e.g. {{GENERIC_TOOL}}) +{{TWO_OR_THREE_SENTENCES}}. + +## FAQ + +### What is {{PRODUCT_NAME}} for? +{{ANSWER_3_SENTENCES}}. + +### Who is it not for? +{{ANSWER_2_SENTENCES}}. Be specific about workflows that are out of scope, so AI assistants do not over-recommend. + +### How does it integrate with {{COMMON_TOOL_USERS_ALREADY_USE}}? +{{ANSWER_2_SENTENCES}}. + +### What does the {{PRICE_AMOUNT}} buy? +{{ANSWER_2_SENTENCES}} including seat count, updates, and refund window. + +### How do I get support? +{{ANSWER_1_SENTENCE}} with a real contact channel. + +## Links + +- Website: {{SITE_ORIGIN}} +- Website (Chinese): {{SITE_ORIGIN}}/zh/ +- Documentation: {{SITE_ORIGIN}}/docs +- Help: {{SITE_ORIGIN}}/help +- Source: {{SOURCE_REPO_URL}} +- Releases: {{SITE_ORIGIN}}/releases diff --git a/plugins/kami/skills/kami/assets/templates/landing-page-llms.txt.example b/plugins/kami/skills/kami/assets/templates/landing-page-llms.txt.example new file mode 100644 index 0000000..cffa8c4 --- /dev/null +++ b/plugins/kami/skills/kami/assets/templates/landing-page-llms.txt.example @@ -0,0 +1,34 @@ +# {{PRODUCT_NAME}} + + + +> {{TAGLINE_LONG_ONE_SENTENCE}} + +## Author +- Name: {{AUTHOR_NAME}} +- Site: {{AUTHOR_URL}} + +## What is {{PRODUCT_NAME}} +{{PRODUCT_NAME}} is {{ONE_PARAGRAPH_POSITIONING}}. Include who it is for, the core job it does, and the one differentiator that separates it from common alternatives (name the alternatives by their real names; do not write "competitors"). Mention platform requirements at the end of this paragraph. + +## How it differs +- vs {{COMPETITOR_A}}: {{ONE_LINE_CONTRAST}} +- vs {{COMPETITOR_B}}: {{ONE_LINE_CONTRAST}} +- vs general-purpose tools: {{ONE_LINE_CONTRAST}} + +## Pricing +{{ONE_LINE_PRICE_AND_TERMS}}. If free, write "Free. Open source under the {{LICENSE}} license." Mention any trial scope explicitly. + +## Key pages +- [Documentation]({{SITE_ORIGIN}}/docs): {{ONE_LINE}} +- [Help]({{SITE_ORIGIN}}/help): {{ONE_LINE}} +- [Full knowledge base]({{SITE_ORIGIN}}/llms-full.txt): comprehensive feature details for AI assistants +- [Releases]({{SITE_ORIGIN}}/releases): version history and changelogs + +## Links +- Website: {{SITE_ORIGIN}} +- Website (Chinese): {{SITE_ORIGIN}}/zh/ +- Source: {{SOURCE_REPO_URL}} diff --git a/plugins/kami/skills/kami/assets/templates/landing-page-robots.txt.example b/plugins/kami/skills/kami/assets/templates/landing-page-robots.txt.example new file mode 100644 index 0000000..4e62a32 --- /dev/null +++ b/plugins/kami/skills/kami/assets/templates/landing-page-robots.txt.example @@ -0,0 +1,63 @@ +# Companion to landing-page.html / landing-page-en.html. +# Drop into your repo root as robots.txt (without the .example suffix) and replace example.com with your domain. +# AI-crawler allowlist mirrors what Kami's own site already ships - keep these open so Claude, ChatGPT, Perplexity, and Apple Intelligence can index your product. + +User-agent: * +Allow: / + +# Add Disallow lines for paths that should not be indexed, e.g. +# Disallow: /api/ +# Disallow: /admin/ + +Sitemap: https://example.com/sitemap.xml + +# Search engines +User-agent: Googlebot +Allow: / + +User-agent: Bingbot +Allow: / + +User-agent: Baiduspider +Allow: / + +User-agent: Applebot +Allow: / + +# AI training crawlers +User-agent: GPTBot +Allow: / + +User-agent: ChatGPT-User +Allow: / + +User-agent: ClaudeBot +Allow: / + +User-agent: anthropic-ai +Allow: / + +User-agent: PerplexityBot +Allow: / + +User-agent: Google-Extended +Allow: / + +User-agent: CCBot +Allow: / + +# AI search bots (retrieval, not training) +User-agent: OAI-SearchBot +Allow: / + +User-agent: Claude-SearchBot +Allow: / + +User-agent: Claude-User +Allow: / + +User-agent: Perplexity-User +Allow: / + +User-agent: DuckAssistBot +Allow: / diff --git a/plugins/kami/skills/kami/assets/templates/landing-page-sitemap.xml.example b/plugins/kami/skills/kami/assets/templates/landing-page-sitemap.xml.example new file mode 100644 index 0000000..7078cde --- /dev/null +++ b/plugins/kami/skills/kami/assets/templates/landing-page-sitemap.xml.example @@ -0,0 +1,53 @@ + + + + + https://example.com/ + + + + + + + 2026-01-01 + monthly + 1.0 + + + https://example.com/zh/ + + + 2026-01-01 + monthly + 0.9 + + + https://example.com/tw/ + + + 2026-01-01 + monthly + 0.8 + + + https://example.com/ja/ + + + 2026-01-01 + monthly + 0.8 + + + https://example.com/ko/ + + + 2026-01-01 + monthly + 0.8 + + diff --git a/plugins/kami/skills/kami/assets/templates/landing-page-vercel.json.example b/plugins/kami/skills/kami/assets/templates/landing-page-vercel.json.example new file mode 100644 index 0000000..efa185f --- /dev/null +++ b/plugins/kami/skills/kami/assets/templates/landing-page-vercel.json.example @@ -0,0 +1,37 @@ +{ + "rewrites": [ + { "source": "/zh", "destination": "/index-zh.html" }, + { "source": "/zh/", "destination": "/index-zh.html" }, + { "source": "/tw", "destination": "/index-tw.html" }, + { "source": "/tw/", "destination": "/index-tw.html" }, + { "source": "/ja", "destination": "/index-ja.html" }, + { "source": "/ja/", "destination": "/index-ja.html" }, + { "source": "/ko", "destination": "/index-ko.html" }, + { "source": "/ko/", "destination": "/index-ko.html" } + ], + + "redirects": [ + { + "source": "/(.*)", + "has": [{ "type": "host", "value": "www.example.com" }], + "destination": "https://example.com/$1", + "permanent": true + } + ], + + "headers": [ + { + "source": "/(.*)", + "headers": [ + { "key": "X-Content-Type-Options", "value": "nosniff" }, + { "key": "X-Frame-Options", "value": "DENY" } + ] + }, + { + "source": "/:path*.(png|jpg|gif|ico|svg|webp)", + "headers": [ + { "key": "Cache-Control", "value": "public, max-age=31536000, immutable" } + ] + } + ] +} diff --git a/plugins/kami/skills/kami/assets/templates/landing-page.html b/plugins/kami/skills/kami/assets/templates/landing-page.html new file mode 100644 index 0000000..9337219 --- /dev/null +++ b/plugins/kami/skills/kami/assets/templates/landing-page.html @@ -0,0 +1,1149 @@ + + + + + +{{PRODUCT_NAME}} · {{TAGLINE_SHORT}} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+ {{EYEBROW_TEXT}} · {{VERSION}} + + + +
+ +

{{PRODUCT_NAME}}

+ +

{{TAGLINE}}

+ +
+ +
+ + +
+ + +
+
+

00 · {{GALLERY_SECTION_NUM}}

+

{{GALLERY_TITLE}}

+

{{GALLERY_LEDE}}

+
+ + +
+ + +
+
+

01 · {{FEATURES_SECTION_NUM}}

+

{{FEATURES_TITLE}}

+
+ +
    + +
+
+ + +
+
+

02 · {{PRINCIPLES_SECTION_NUM}}

+

{{PRINCIPLES_TITLE}}

+
+ +
    + +
+
+ + +
+
+

03 · {{PRICING_SECTION_NUM}}

+

{{PRICING_TITLE}}

+
+ +
+

{{PRICE_DISPLAY}}

+

{{PRICE_COMPARISON}}

+ {{CTA_PRIMARY_LABEL}} +

{{PRICE_TRIAL_TEXT}}

+

{{PRICE_TERMS}}

+
+
+ + +
+
+

04 · {{FAQ_SECTION_NUM}}

+

{{FAQ_TITLE}}

+
+ +
+ +
+ +
+ + +
+
+ {{PRODUCT_NAME}} + {{PRODUCT_NAME}} + {{FOOTER_TAGLINE}} +
+
+ +

{{FOOTER_ETHOS}}

+
+
+ +
+ + + + diff --git a/plugins/kami/skills/kami/assets/templates/letter-en.html b/plugins/kami/skills/kami/assets/templates/letter-en.html new file mode 100644 index 0000000..9a7a8fa --- /dev/null +++ b/plugins/kami/skills/kami/assets/templates/letter-en.html @@ -0,0 +1,289 @@ + + + + + +{{LETTER_SUBJECT}} + + + + + + + + +
+
{{SENDER_NAME}}
+
{{SENDER_ADDRESS / DEPARTMENT / ORGANIZATION}}
+
{{PHONE}} · {{EMAIL}}
+
+ +
{{DATE, e.g. April 18, 2026}}
+ +
+
To
+
{{RECIPIENT_NAME_OR_TITLE}}
+
{{RECIPIENT_ORG / DEPARTMENT}}
+
+ +
+
Re
+
{{One-sentence subject line: what this letter is about.}}
+
+ +
{{Dear {{NAME}},}}
+ +
+ +

+{{Paragraph 1: state the purpose. Why you are writing and the core intent. +No preamble. Two sentences at most so the reader knows what you want from this letter.}} +

+ +

+{{Paragraph 2: elaborate. Background, reasoning, evidence. Use +brand-color emphasis on the single most important point.}} +

+ +

+{{Paragraph 3: be specific. What you want the reader to do, by when, +and how to reach you. Give the action a clear exit.}} +

+ +

+{{Paragraph 4 (optional): close. Express anticipation, gratitude, or a courteous sign-off line.}} +

+ +
+ +
+
+ {{Closing - "Best regards," / "Sincerely," / "Warm regards,"}} +
+ +
{{HANDWRITTEN_NAME_OR_SIGNATURE}}
+
+ {{TITLE · DEPARTMENT}}
+ {{DATE (optional restatement)}} +
+
+ +
+ Enclosures + {{List - ① Attachment 1 · ② Attachment 2}} +
+ + + diff --git a/plugins/kami/skills/kami/assets/templates/letter-ko.html b/plugins/kami/skills/kami/assets/templates/letter-ko.html new file mode 100644 index 0000000..5551315 --- /dev/null +++ b/plugins/kami/skills/kami/assets/templates/letter-ko.html @@ -0,0 +1,317 @@ + + + + + +{{서한 주제}} + + + + + + + + + +
+
{{발신인 성명}}
+
{{발신인 주소 / 부서 / 기관}}
+
{{전화번호}} · {{EMAIL}}
+
+ + +
{{날짜 예: 2026년 4월 18일}}
+ + +
+
수신
+
{{수신인 성명 / 직함}}
+
{{수신인 기관 / 부서}}
+
+ + +
+
제목
+
{{서한 주제를 한 문장으로 명확하게}}
+
+ + +
{{인사말 예: "안녕하세요, XX 님," / "존경하는 XX 귀중,"}}
+ + +
+ +

+{{첫 번째 단락: 서두. 이 서한을 쓰게 된 배경과 핵심 의도를 밝힌다. +돌려 말하지 않고 1-2문장으로 독자가 무엇에 관한 편지인지 파악하게 한다.}} +

+ +

+{{두 번째 단락: 전개. 배경, 이유, 근거를 제시한다. +핵심 정보는 하이라이트로 강조할 수 있다.}} +

+ +

+{{세 번째 단락: 구체적 요청. 상대방이 무엇을, 언제, 어떻게 해야 하는지 명확히 기술한다. +행동으로 이어질 수 있는 출구를 열어 둔다.}} +

+ +

+{{네 번째 단락 (선택): 마무리. 기대감 표명, 감사 인사, 또는 정중한 맺음말.}} +

+ +
+ + +
+
+ {{맺음말. 예: "감사합니다." / "부탁드립니다." / "Best regards,"}} +
+ +
{{서명 / 성명}}
+
+ {{직함 · 부서}}
+ {{날짜 재기입 · 선택}} +
+
+ + +
+ 첨부 + {{첨부 목록: ① 첨부 파일명 1 · ② 첨부 파일명 2}} +
+ + + diff --git a/plugins/kami/skills/kami/assets/templates/letter.html b/plugins/kami/skills/kami/assets/templates/letter.html new file mode 100644 index 0000000..209b820 --- /dev/null +++ b/plugins/kami/skills/kami/assets/templates/letter.html @@ -0,0 +1,317 @@ + + + + + +{{信件主题}} + + + + + + + + + +
+
{{寄件人姓名}}
+
{{寄件人地址 / 部门 / 机构}}
+
{{电话}} · {{EMAIL}}
+
+ + +
{{日期 如 2026 年 4 月 18 日}}
+ + +
+
+
{{收件人姓名 / 称谓}}
+
{{收件人机构 / 部门}}
+
+ + +
+
关于
+
{{信件主题,一句话说清}}
+
+ + +
{{称呼 如 "尊敬的 XX 先生:" / "Dear XX,"}}
+ + +
+ +

+{{第一段:破题。说明写这封信的缘由和核心意图。 +不要绕弯,1-2 句话让读者知道你要说什么。}} +

+ +

+{{第二段:展开。给出背景、理由、论据。 +如有 关键信息 可用高亮突出。}} +

+ +

+{{第三段:具体。说清楚你希望对方做什么、 +何时做、怎么联系。让行动有明确出口。}} +

+ +

+{{第四段(可选):收尾。表达期待、致谢或礼貌性结束语。}} +

+ +
+ + +
+
+ {{敬语。中文常用 "此致 / 敬礼!"、"顺颂商祺"。英文 "Best regards," / "Sincerely,"}} +
+ +
{{签名 / 亲笔姓名}}
+
+ {{职位 · 部门}}
+ {{日期复写 · 可选}} +
+
+ + +
+ 附件 + {{附件清单:① 附件名 1 · ② 附件名 2}} +
+ + + diff --git a/plugins/kami/skills/kami/assets/templates/long-doc-en.html b/plugins/kami/skills/kami/assets/templates/long-doc-en.html new file mode 100644 index 0000000..b4e8549 --- /dev/null +++ b/plugins/kami/skills/kami/assets/templates/long-doc-en.html @@ -0,0 +1,542 @@ + + + + + +{{DOC_TITLE}} + + + + + + + + + +
+
+
{{EYEBROW - e.g. Technical Report / Annual Review / White Paper}}
+
{{Document title
can span two lines}}
+
{{One-line subtitle - what this is and who it's for.}}
+
+
+ {{AUTHOR / TEAM}}
+ {{Version 1.0}} · {{YYYY.MM}}
+ {{PUBLISHER / ORGANIZATION}} +
+
+ + +
+

Contents

+ + + + + +
+ + +
+
01 · Executive Summary
+

Executive Summary

+ +

+ {{Two or three sentences opening the whole thesis. Use brand-color emphasis to grab attention on the sharpest claim. A reader of only this paragraph should understand what the document argues.}} +

+ +

Key Takeaways

+
    +
  • {{Takeaway 1 - a quantified conclusion in one line.}}
  • +
  • {{Takeaway 2 - an insight backed by data.}}
  • +
  • {{Takeaway 3 - a forward-looking judgment.}}
  • +
+ +
+
Questions this document answers
+ {{List the three core questions as actual questions - so the reader can decide in ten seconds whether to read on.}} +
+
+ + +
+
02 · Background
+

Background & Problem Statement

+ +

+ {{Chapter intro - what this chapter is solving, why it matters. One or two sentences.}} +

+ +

Current State

+

{{Three to five lines describing the status quo. Use specific figures rather than adjectives.}}

+ +

The Core Problem

+

{{State the problem specifically. Use a callout to emphasize a key observation:}}

+ +
+ {{A short quoted line or key observation. Different in tone from the body so the reader gets a breath.}} +
+ +

Metrics of Success

+ + + + + + + + + + + + + +
DimensionCurrentTargetGap
{{DIMENSION_1}}{{VAL}}{{VAL}}{{GAP}}
{{DIMENSION_2}}{{VAL}}{{VAL}}{{GAP}}
+
+ + +
+
03 · Methodology
+

Methodology & Findings

+ +

{{Chapter intro.}}

+ +

Approach

+

{{Describe the methodology. Code or formula examples welcome:}}

+ +
def analyze(data):
+    """Transform raw data."""
+    return transform(data)
+ +

Key Findings

+ +

Finding 1 - {{TITLE}}

+

{{A paragraph with data: specific numbers or ratios.}}

+ +

Finding 2 - {{TITLE}}

+

{{A paragraph with data: specific numbers or ratios.}}

+ +
+ {{A quoted passage - user interview, expert perspective, or cited source.}} + - {{SOURCE / PERSON}}, {{DATE}} +
+
+ + +
+
04 · Conclusions
+

Conclusions & Recommendations

+ +

{{Chapter intro - a one-line summary of the conclusion, then the recommendations below.}}

+ +

Core Conclusions

+
    +
  1. {{Conclusion 1.}}
  2. +
  3. {{Conclusion 2.}}
  4. +
  5. {{Conclusion 3.}}
  6. +
+ +

Recommended Next Steps

+

{{Concrete, executable recommendations tied to the conclusions.}}

+ +
+
Call to Action
+ {{If the reader does one thing, what is it? Specific enough to start Monday morning.}} +
+
+ + +
+
05 · Appendix
+

Appendix

+ +

A. References

+
    +
  • {{Reference 1}}
  • +
  • {{Reference 2}}
  • +
+ +

B. Glossary

+

{{TERM}} - {{definition}}

+

{{TERM}} - {{definition}}

+ +

C. Acknowledgements

+

{{Acknowledgement paragraph.}}

+
+ + + diff --git a/plugins/kami/skills/kami/assets/templates/long-doc-ko.html b/plugins/kami/skills/kami/assets/templates/long-doc-ko.html new file mode 100644 index 0000000..9a2ec57 --- /dev/null +++ b/plugins/kami/skills/kami/assets/templates/long-doc-ko.html @@ -0,0 +1,615 @@ + + + + + +{{문서 제목}} + + + + + + + + + +
+
+
{{EYEBROW · 예: "기술 보고서" / "연간 결산" / "백서"}}
+
{{문서 주제목
두 줄도 가능}}
+
{{부제목: 이 문서가 무엇인지, 누구를 위한 것인지 한 문장으로}}
+
+
+ {{작성자 / 팀}}
+ {{버전 V1.0}} · {{날짜 2026.04}}
+ {{발행 기관}} +
+
+ + +
+

목차

+
+ 01 + 요약 +
+ + + +
+ 05 + 부록 +
+
+ + +
+
01 · Executive Summary
+

요약

+ +

+ {{2-3문장의 핵심 논지 도입부. 키워드 하이라이트로 독자의 주의를 끈다. + 이 단락만 읽어도 문서 전체가 무엇을 말하는지 파악할 수 있어야 한다.}} +

+ +

핵심 요점

+
    +
  • {{요점 1: 한 문장, 수치화 가능한 결론}}
  • +
  • {{요점 2: 데이터 기반의 인사이트}}
  • +
  • {{요점 3: 향후 방향에 대한 판단}}
  • +
+ +
+
이 문서가 답하는 질문
+ {{이 문서의 핵심 질문 3가지를 의문문으로 나열. 독자가 계속 읽을 필요가 있는지 바로 판단하게 한다.}} +
+
+ + +
+
02 · Background
+

배경 및 문제 정의

+ +

+ {{챕터 도입부: 이 챕터가 다루는 문제와 그 중요성. 1-2문장.}} +

+ +

현황

+

{{3-5줄 단락, 현재 상황을 서술한다. 구체적인 수치를 형용사 대신 사용한다.}}

+ +

핵심 문제

+

{{구체적인 핵심 문제를 기술한다. 아래 callout으로 핵심 관찰을 강조할 수 있다:}}

+ +
+ {{중요한 인용 또는 핵심 관찰. 본문과 약간 다른 어조로, 독자에게 호흡 공간을 준다.}} +
+ +

측정 기준

+ + + + + + + + + + + + + + + + + + + + + + + +
항목현재 수준목표 수준격차
{{항목 1}}{{데이터}}{{데이터}}{{격차}}
{{항목 2}}{{데이터}}{{데이터}}{{격차}}
+
+ + +
+
03 · Methodology
+

방법론 및 주요 발견

+ +

{{챕터 도입부}}

+ +

연구 방법

+

{{방법론을 기술한다. 코드 / 수식 예시를 포함할 수 있다:}}

+ +
def analyze(data):
+    """Transform raw data."""
+    return transform(data)
+ +

주요 발견

+ +

발견 1: {{제목}}

+

{{한 단락 논술. 구체적인 수치 / 비율을 포함한다.}}

+ +

발견 2: {{제목}}

+

{{한 단락 논술. 구체적인 수치 / 비율을 포함한다.}}

+ +
+ {{인용: 사용자 인터뷰, 전문가 의견, 또는 문헌 인용}} + - {{출처 / 인물}}, {{날짜}} +
+
+ + +
+
04 · Conclusions
+

결론 및 제언

+ +

{{챕터 도입부: 결론을 한 문장으로 요약하고, 아래에서 제언을 전개한다.}}

+ +

핵심 결론

+
    +
  1. {{결론 1}}
  2. +
  3. {{결론 2}}
  4. +
  5. {{결론 3}}
  6. +
+ +

다음 단계 제언

+

{{결론에 기반한 구체적이고 실행 가능한 제언.}}

+ +
+
Call to Action
+ {{독자가 한 가지만 실행한다면 무엇인가? 월요일 아침에 바로 시작할 수 있을 만큼 구체적으로.}} +
+
+ + +
+
05 · Appendix
+

부록

+ +

A. 참고 자료

+
    +
  • {{참고 문헌 1}}
  • +
  • {{참고 문헌 2}}
  • +
+ +

B. 용어 설명

+

{{용어}}: {{정의}}

+

{{용어}}: {{정의}}

+ +

C. 감사의 말

+

{{감사 단락}}

+
+ + + diff --git a/plugins/kami/skills/kami/assets/templates/long-doc.html b/plugins/kami/skills/kami/assets/templates/long-doc.html new file mode 100644 index 0000000..58ce400 --- /dev/null +++ b/plugins/kami/skills/kami/assets/templates/long-doc.html @@ -0,0 +1,672 @@ + + + + + +{{文档标题}} + + + + + + + + + +
+
+
{{EYEBROW · 如 "技术报告" / "年度总结" / "白皮书"}}
+
{{文档主标题
可以两行}}
+
{{副标题,一句话说清这份文档是什么 / 为谁而写}}
+
+
+ {{作者 / 团队}}
+ {{版本 V1.0}} · {{日期 2026.04}}
+ {{发布方 / 机构}} +
+
+ + +
+

目录

+
+ 01 + 执行摘要 +
+ +
+ 03 + 方法与发现 +
+
+ 04 + 结论与建议 +
+
+ 05 + 附录 +
+
+ + +
+
01 · Executive Summary
+

执行摘要

+ +

+ {{一段 2-3 句话的大论点开场。用 关键词高亮 抓住读者注意力。 + 让读者读这段就能理解整份文档想表达什么。}} +

+ +

核心 Takeaways

+
    +
  • {{Takeaway 1:一句话,可量化的结论}}
  • +
  • {{Takeaway 2:有数据的洞察}}
  • +
  • {{Takeaway 3:对未来的判断}}
  • +
+ +
+
本文档回答的问题
+ {{用疑问句列出 3 个本文档的核心问题。让读者立刻 get 到是否需要读完。}} +
+
+ + +
+
02 · Background
+

背景与问题定义

+ +

+ {{章节导语:这一章要解决什么问题,为什么重要。1-2 句。}} +

+ +

当前现状

+

{{3-5 行段落,铺陈当前状况。用 具体数据 而不是形容词。}}

+ +

核心问题

+

{{陈述具体的核心问题。可以用 callout 突出关键观察:}}

+ +
+ {{一段重要引用或核心观察。和正文语气略有不同,给读者呼吸节奏。}} +
+ +

衡量标准

+ + + + + + + + + + + + + + + + + + + + + + + +
维度当前水平目标水平差距
{{维度 1}}{{数据}}{{数据}}{{差距}}
{{维度 2}}{{数据}}{{数据}}{{差距}}
+
+ + +
+
03 · Methodology
+

方法与发现

+ +

{{章节导语}}

+ +

研究方法

+

{{描述方法论。可以用代码 / 公式示例:}}

+ +
def analyze(data):
+    """Transform raw data."""
+    return transform(data)
+ +
+ + + + + + + + + + + + + + + + 提交请求 + + + + 查询记录 + + + + 返回结果 + + + + 响应数据 + + + + 用户 + + + + API + + + + 数据库 + + +
图 1:用户请求的调用时序
+
+ +

关键发现

+ +

发现 1:{{标题}}

+

{{一段论述。包含数据:具体数字 / 具体比例。}}

+ +

发现 2:{{标题}}

+

{{一段论述。包含数据:具体数字 / 具体比例。}}

+ +
+ {{一段引用,可以是用户访谈、专家观点、文献引用}} + - {{来源 / 人物}},{{日期}} +
+
+ + +
+
04 · Conclusions
+

结论与建议

+ +

{{章节导语:一句话总结结论,下面展开建议。}}

+ +

核心结论

+
    +
  1. {{结论 1}}
  2. +
  3. {{结论 2}}
  4. +
  5. {{结论 3}}
  6. +
+ +

下一步建议

+

{{基于结论的具体可执行建议。}}

+ +
+
Call to Action
+ {{如果读者要做一件事,是什么?具体到可以周一早上就开始行动。}} +
+
+ + +
+
05 · Appendix
+

附录

+ +

A. 参考资料

+
    +
  • {{参考文献 1}}
  • +
  • {{参考文献 2}}
  • +
+ +

B. 术语表

+

{{术语}}:{{定义}}

+

{{术语}}:{{定义}}

+ +

C. 致谢

+

{{致谢段落}}

+
+ + + diff --git a/plugins/kami/skills/kami/assets/templates/marp/slides-marp-en.css b/plugins/kami/skills/kami/assets/templates/marp/slides-marp-en.css new file mode 100644 index 0000000..aa65fab --- /dev/null +++ b/plugins/kami/skills/kami/assets/templates/marp/slides-marp-en.css @@ -0,0 +1,254 @@ +/* @theme kami-en */ +/* Kami Marp theme (EN). Mirrors slides-weasy-en.html tokens. */ + +@font-face { + font-family: "JetBrains Mono"; + src: url("../../fonts/JetBrainsMono.woff2") format("woff2"); + font-weight: 400; +} + +:root { + --parchment: #f5f4ed; + --ivory: #faf9f5; + --near-black: #141413; + --dark-warm: #3d3d3a; + --olive: #504e49; + --stone: #6b6a64; + --brand: #1B365D; + --brand-tint: #EEF2F7; + --border: #e8e6dc; + --border-soft:#e5e3d8; + + --serif: Charter, Georgia, Palatino, serif; + --sans: var(--serif); + --mono: "JetBrains Mono", "SF Mono", Consolas, monospace; + + --rhythm-module: 14pt; + --rhythm-section: 18pt; +} + +section { + width: 280mm; + height: 158mm; + padding: 16mm 20mm; + background: var(--parchment); + color: var(--near-black); + font-family: var(--serif); + font-size: 13pt; + line-height: 1.55; + position: relative; + display: flex; + flex-direction: column; +} + +section * { box-sizing: border-box; } +section h1, section h2, section h3, +section p, section ul, section ol, section table { + margin: 0; + padding: 0; +} + +.eyebrow { + font-family: var(--mono); + font-size: 9.5pt; + letter-spacing: 2pt; + text-transform: uppercase; + color: var(--stone); + margin-bottom: 2pt; +} + +section h2 { + font-family: var(--serif); + font-size: 24pt; + font-weight: 500; + line-height: 1.2; + letter-spacing: -0.3pt; + color: var(--near-black); + margin-bottom: var(--rhythm-module); + text-wrap: balance; +} + +section h3 { + font-family: var(--serif); + font-size: 15pt; + font-weight: 500; + line-height: 1.3; + color: var(--brand); + margin-bottom: 8pt; +} + +.lead { + font-family: var(--serif); + font-size: 12pt; + font-weight: 400; + line-height: 1.5; + color: var(--olive); + margin-bottom: 8pt; +} + +.mt { + font-family: var(--serif); + font-size: 16pt; + font-weight: 500; + line-height: 1.3; + color: var(--near-black); + margin-bottom: 8pt; +} + +.ml { + font-family: var(--serif); + font-size: 24pt; + font-weight: 500; + color: var(--brand); + margin-right: 6pt; +} + +.ms { + font-family: var(--mono); + font-size: 7.5pt; + letter-spacing: 1.5pt; + text-transform: uppercase; + color: var(--stone); + border-bottom: 0.3pt solid var(--border); + padding-bottom: 4pt; + margin-bottom: var(--rhythm-module); +} + +.mb, +section p { + font-family: var(--serif); + font-size: 11pt; + line-height: 1.55; + color: var(--dark-warm); + margin-bottom: var(--rhythm-section); +} + +.mi { + font-family: var(--serif); + font-size: 11pt; + line-height: 1.55; + color: var(--dark-warm); + padding: 8pt 0; +} + +.mc { + font-family: var(--serif); + font-size: 9.5pt; + line-height: 1.5; + color: var(--olive); + border-top: 0.3pt solid var(--border); + padding-top: 8pt; + margin-top: var(--rhythm-section); +} + +.co { + font-family: var(--serif); + font-size: 11pt; + font-weight: 500; + line-height: 1.55; + color: var(--brand); + position: absolute; + bottom: 18mm; + left: 20mm; + right: 20mm; +} + +.c2 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 22pt; +} + +table.t2x2 { + width: 100%; + border-collapse: separate; + border-spacing: 22pt 18pt; + margin: -18pt -22pt; +} +table.t2x2 td { + vertical-align: top; + width: 50%; +} + +table.data { + width: 100%; + border-collapse: collapse; +} +table.data td { + padding: 8pt; + border-bottom: 0.3pt solid var(--border); + font-size: 11pt; + line-height: 1.55; +} +table.data td:first-child { + font-weight: 500; + color: var(--brand); +} + +section ul, section ol { + margin: 0 0 var(--rhythm-section) 0; + padding-left: 1.4em; + font-size: 11pt; + line-height: 1.55; + color: var(--dark-warm); +} +section li { margin-bottom: 4pt; } +section li::marker { color: var(--brand); } + +section svg { + max-height: 105mm; + width: auto; +} + +/* Cover layout: */ +section.cover { + align-items: center; + justify-content: center; + text-align: center; +} +section.cover h1 { + font-family: var(--serif); + font-size: 38pt; + font-weight: 500; + line-height: 1.05; + letter-spacing: -0.5pt; + color: var(--near-black); + margin-bottom: 12pt; +} +section.cover .sub { + font-family: var(--serif); + font-size: 14pt; + line-height: 1.45; + color: var(--olive); + max-width: 50ch; + margin-bottom: 32pt; +} +section.cover .meta { + font-family: var(--mono); + font-size: 9.5pt; + color: var(--stone); + letter-spacing: 1.5pt; + text-transform: uppercase; +} + +/* Pagination (enable with `paginate: true` in deck frontmatter) */ +section::after { + font-family: var(--mono); + font-size: 9pt; + color: var(--stone); + letter-spacing: 0.5pt; + font-variant-numeric: tabular-nums; + bottom: 10mm; + right: 20mm; +} + +/* Footer mark (enable with ``) */ +footer { + font-family: var(--mono); + font-size: 9pt; + color: var(--stone); + letter-spacing: 1.5pt; + position: absolute; + bottom: 10mm; + left: 20mm; +} diff --git a/plugins/kami/skills/kami/assets/templates/marp/slides-marp-en.md b/plugins/kami/skills/kami/assets/templates/marp/slides-marp-en.md new file mode 100644 index 0000000..55dcb4a --- /dev/null +++ b/plugins/kami/skills/kami/assets/templates/marp/slides-marp-en.md @@ -0,0 +1,129 @@ +--- +marp: true +theme: kami-en +size: 280mm 158mm +paginate: true +footer: "Kami · Marp" +--- + + + + + +# Decks that read like paper + +
Kami Marp deck · editorial rhythm in Markdown
+
Kami · 2026
+ +--- + +01 · Origin + +## Kami WeasyPrint deck, ported to Markdown + +

Same palette, fonts, layout tokens. Only the file format and editing posture change.

+ +
+ +
+ +### Shared with WeasyPrint slides + +- `--parchment` `#f5f4ed` warm cream canvas +- `--brand` `#1B365D` the only chromatic accent +- `--serif` Charter on English, Tsanger on Chinese +- 280×158mm 16:9 page +- `.eyebrow` `.lead` `.co` `.c2` `.t2x2` carry over + +
+ +
+ +### What Marp changes + +- Page unit is `section`, not `.slide` +- Page break is `---`, not `break-after: page` +- Pagination via `paginate: true` injects automatically +- Per-slide class via `` +- Renders through local `marp-cli`, not `build.py` + +
+ +
+ +--- + +02 · Four pillars + +## Four decisions to lock before writing + + + + + + + +
+ +
APalette
+ +One ink-blue accent, never above 5% of surface area. Warm neutrals carry the rest. No cool gray, no pure white. + +
+ +
BType
+ +One serif per page. Body 400, headings 500. No synthetic bold. Charter for EN, Tsanger W04 / W05 for CN. + +
+ +
CLayout
+ +`.c2` two-column via CSS Grid. `.t2x2` four-quadrant via HTML ``. Grid will not align row heights in a 2×2. + + + + +
+ +
DRhythm
+ +`--rhythm-module: 14pt` and `--rhythm-section: 18pt`. Two tokens govern all spacing. Do not sprinkle ad-hoc margins. + +
+ +--- + +03 · Title rule + +## Slide titles are claims, not labels + +

"Q3 results" is a topic. "Q3 revenue beat by 12%" is a claim.

+ +Avoid noun phrases like "Q3 results" or "Team intro". Rewrite to "Q3 revenue beat guidance by 12 percent" or "The team has only built retrieval for five years". A reader scanning titles should leave with the takeaway; the body just supplies evidence. + +
Title carries the claim. Body grounds it. The deck gains a spine.
+ +--- + +04 · Render matrix + +## One Markdown source, three export targets + + + + + + +
HTML preview0 MB extra downloadOpen in any browser. Lightest path.
PDF export~150 MB Chromium, or reuse a local ChromeSee production.md «Browser strategy»
PPTX exportSame dependency as PDFSlide-image dump; not an editable deck
VS Code preview0 MB (VS Code bundles Chromium)Install the Marp for VS Code extension
+ +--- + + + + + +# Copy it, swap in your story + +
Replace the content. Leave the structure alone.
+
github.com/tw93/Kami
diff --git a/plugins/kami/skills/kami/assets/templates/marp/slides-marp.css b/plugins/kami/skills/kami/assets/templates/marp/slides-marp.css new file mode 100644 index 0000000..69f3997 --- /dev/null +++ b/plugins/kami/skills/kami/assets/templates/marp/slides-marp.css @@ -0,0 +1,276 @@ +/* @theme kami */ +/* Kami Marp theme (CN). Mirrors slides-weasy.html tokens. */ + +@font-face { + font-family: "TsangerJinKai02"; + src: url("../../fonts/TsangerJinKai02-W04.ttf") format("truetype"), + url("https://cdn.jsdelivr.net/gh/tw93/Kami@main/assets/fonts/TsangerJinKai02-W04.ttf") format("truetype"); + font-weight: 400; +} +@font-face { + font-family: "TsangerJinKai02"; + src: url("../../fonts/TsangerJinKai02-W05.ttf") format("truetype"), + url("https://cdn.jsdelivr.net/gh/tw93/Kami@main/assets/fonts/TsangerJinKai02-W05.ttf") format("truetype"); + font-weight: 500; +} +@font-face { + font-family: "JetBrains Mono"; + src: url("../../fonts/JetBrainsMono.woff2") format("woff2"); + font-weight: 400; +} + +:root { + --parchment: #f5f4ed; + --ivory: #faf9f5; + --near-black: #141413; + --dark-warm: #3d3d3a; + --olive: #504e49; + --stone: #6b6a64; + --brand: #1B365D; + --brand-tint: #EEF2F7; + --border: #e8e6dc; + --border-soft:#e5e3d8; + + --serif: "TsangerJinKai02", "Source Han Serif SC", "Noto Serif CJK SC", "Songti SC", "STSong", Georgia, serif; + --sans: var(--serif); + --mono: "JetBrains Mono", "SF Mono", "Fira Code", Consolas, Monaco, "TsangerJinKai02", "Source Han Serif SC", monospace; + + --rhythm-module: 14pt; + --rhythm-section: 18pt; +} + +section { + width: 280mm; + height: 158mm; + padding: 16mm 20mm; + background: var(--parchment); + color: var(--near-black); + font-family: var(--sans); + font-size: 13pt; + line-height: 1.55; + letter-spacing: 0.3pt; + position: relative; + display: flex; + flex-direction: column; +} + +section * { box-sizing: border-box; } +section h1, section h2, section h3, +section p, section ul, section ol, section table { + margin: 0; + padding: 0; +} + +.eyebrow { + font-family: var(--mono); + font-size: 9.5pt; + letter-spacing: 2pt; + text-transform: uppercase; + color: var(--stone); + margin-bottom: 2pt; +} + +section h2 { + font-family: var(--serif); + font-size: 24pt; + font-weight: 500; + line-height: 1.2; + letter-spacing: 0.3pt; + color: var(--near-black); + margin-bottom: var(--rhythm-module); + text-wrap: balance; +} + +section h3 { + font-family: var(--serif); + font-size: 15pt; + font-weight: 500; + line-height: 1.3; + letter-spacing: 0.3pt; + color: var(--brand); + margin-bottom: 8pt; +} + +.lead { + font-family: var(--sans); + font-size: 12pt; + font-weight: 400; + line-height: 1.55; + letter-spacing: 0.3pt; + color: var(--olive); + margin-bottom: 8pt; +} + +.mt { + font-family: var(--serif); + font-size: 16pt; + font-weight: 500; + line-height: 1.3; + letter-spacing: 0.3pt; + color: var(--near-black); + margin-bottom: 8pt; +} + +.ml { + font-family: var(--serif); + font-size: 24pt; + font-weight: 500; + color: var(--brand); + margin-right: 6pt; +} + +.ms { + font-family: var(--mono); + font-size: 7.5pt; + letter-spacing: 1.5pt; + text-transform: uppercase; + color: var(--stone); + border-bottom: 0.3pt solid var(--border); + padding-bottom: 4pt; + margin-bottom: var(--rhythm-module); +} + +.mb, +section p { + font-family: var(--sans); + font-size: 11pt; + line-height: 1.55; + letter-spacing: 0.3pt; + color: var(--dark-warm); + margin-bottom: var(--rhythm-section); +} + +.mi { + font-family: var(--sans); + font-size: 11pt; + line-height: 1.55; + letter-spacing: 0.3pt; + color: var(--dark-warm); + padding: 8pt 0; +} + +.mc { + font-family: var(--sans); + font-size: 9.5pt; + line-height: 1.5; + letter-spacing: 0.3pt; + color: var(--olive); + border-top: 0.3pt solid var(--border); + padding-top: 8pt; + margin-top: var(--rhythm-section); +} + +.co { + font-family: var(--sans); + font-size: 11pt; + font-weight: 500; + line-height: 1.55; + letter-spacing: 0.3pt; + color: var(--brand); + position: absolute; + bottom: 18mm; + left: 20mm; + right: 20mm; +} + +.c2 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 22pt; +} + +table.t2x2 { + width: 100%; + border-collapse: separate; + border-spacing: 22pt 18pt; + margin: -18pt -22pt; +} +table.t2x2 td { + vertical-align: top; + width: 50%; +} + +table.data { + width: 100%; + border-collapse: collapse; +} +table.data td { + padding: 8pt; + border-bottom: 0.3pt solid var(--border); + font-size: 11pt; + line-height: 1.55; + letter-spacing: 0.3pt; +} +table.data td:first-child { + font-weight: 500; + color: var(--brand); +} + +section ul, section ol { + margin: 0 0 var(--rhythm-section) 0; + padding-left: 1.4em; + font-size: 11pt; + line-height: 1.55; + letter-spacing: 0.3pt; + color: var(--dark-warm); +} +section li { margin-bottom: 4pt; } +section li::marker { color: var(--brand); } + +section svg { + max-height: 105mm; + width: auto; +} + +/* Cover layout: */ +section.cover { + align-items: center; + justify-content: center; + text-align: center; +} +section.cover h1 { + font-family: var(--serif); + font-size: 38pt; + font-weight: 500; + line-height: 1.1; + letter-spacing: 0.3pt; + color: var(--near-black); + margin-bottom: 12pt; +} +section.cover .sub { + font-family: var(--sans); + font-size: 14pt; + line-height: 1.5; + letter-spacing: 0.3pt; + color: var(--olive); + max-width: 50ch; + margin-bottom: 32pt; +} +section.cover .meta { + font-family: var(--sans); + font-size: 10pt; + color: var(--stone); + letter-spacing: 0.3pt; +} + +/* Pagination (enable with `paginate: true` in deck frontmatter) */ +section::after { + font-family: var(--mono); + font-size: 9pt; + color: var(--stone); + letter-spacing: 0.5pt; + font-variant-numeric: tabular-nums; + bottom: 10mm; + right: 20mm; +} + +/* Footer mark (enable with ``) */ +footer { + font-family: var(--mono); + font-size: 9pt; + color: var(--stone); + letter-spacing: 1.5pt; + position: absolute; + bottom: 10mm; + left: 20mm; +} diff --git a/plugins/kami/skills/kami/assets/templates/marp/slides-marp.md b/plugins/kami/skills/kami/assets/templates/marp/slides-marp.md new file mode 100644 index 0000000..45c9211 --- /dev/null +++ b/plugins/kami/skills/kami/assets/templates/marp/slides-marp.md @@ -0,0 +1,129 @@ +--- +marp: true +theme: kami +size: 280mm 158mm +paginate: true +footer: "Kami · Marp" +--- + + + + + +# 把幻灯片做成纸 + +
Kami Marp deck · 用 Markdown 写出版面
+
Kami · 2026
+ +--- + +01 · 出处 + +## 把 Kami slides 搬到 Markdown 上 + +

同一套色板、字体、布局 token。换的是文件格式与编辑姿势。

+ +
+ +
+ +### 共享的部分 + +- `--parchment` `#f5f4ed` 暖纸底色 +- `--brand` `#1B365D` 单一墨蓝 +- `--serif` 中文 TsangerJinKai02 / 英文 Charter +- 280×158mm 16:9 页面 +- `.eyebrow` `.lead` `.co` `.c2` `.t2x2` 一致 + +
+ +
+ +### Marp 这边的差异 + +- 页面单元用 `section`,不是 `.slide` +- 分页用 `---`,不是 `break-after: page` +- 页码用 `paginate: true` 自动注入 +- 单页类名用 `` +- 渲染走本地 `marp-cli`,不进 build.py + +
+ +
+ +--- + +02 · 四个支柱 + +## 一张 deck 立不立得住,看这四件事 + + + + + + + + + + +
+ +
A色板
+ +单一墨蓝做强调色,全场 ≤ 5% 面积。其余靠暖中性灰承托。绝对不要冷色、不要纯白底。 + +
+ +
B字体
+ +一页一种衬线,body 400、heading 500,禁止合成粗体。中文 W04/W05 双字重,英文 Charter 一套通吃。 + +
+ +
C布局
+ +`.c2` 两栏走 grid,`.t2x2` 四象限必须用 table,Grid 在四象限里行高对不齐。 + +
+ +
D节奏
+ +`--rhythm-module: 14pt`、`--rhythm-section: 18pt`。两个数管整套间距,不要再随手加 margin。 + +
+ +--- + +03 · 标题原则 + +## 标题写完整断言,不是话题标签 + +

「Q3 业绩」是话题,「Q3 营收高出 12 个点」是断言。

+ +避免 "Q3 业绩 / 团队介绍 / 下一步规划" 这种 noun phrase。改写成「Q3 营收比 guidance 高出 12 个百分点」「团队五年只做检索一件事」「下一季度把延迟从 800ms 压到 120ms」这种带结论的句子。读者扫标题就能拿到 takeaway,正文是支撑证据。 + +
标题先有立场,正文再补证据,整套 deck 就有了主线。
+ +--- + +04 · 渲染矩阵 + +## 一份 Markdown,三种导出口径 + + + + + + +
HTML 预览0 MB 额外下载浏览器打开即可,最轻
PDF 导出~150 MB Chromium 或复用本地 Chrome看 production.md «Browser strategy»
PPTX 导出同 PDF,依赖浏览器幻灯片图像化,非可编辑 deck
VS Code 预览0 MB(VS Code 内置 Chromium)装 Marp for VS Code 插件即可
+ +--- + + + + + +# 复制走,开始你自己的 deck + +
把内容换成你的故事,结构留下不动
+
github.com/tw93/Kami
diff --git a/plugins/kami/skills/kami/assets/templates/one-pager-en.html b/plugins/kami/skills/kami/assets/templates/one-pager-en.html new file mode 100644 index 0000000..4675c59 --- /dev/null +++ b/plugins/kami/skills/kami/assets/templates/one-pager-en.html @@ -0,0 +1,421 @@ + + + + + +{{DOC_TITLE}} + + + + + + + + +
+
+
{{EYEBROW - e.g. Proposal / Report / Exec Summary}}
+

{{Document headline - verb-led, fits in two lines, bookish.}}

+
{{One-line subtitle or the single sharpest claim.}}
+
+
+ {{AUTHOR}}
+ {{YYYY.MM.DD}}
+ {{VERSION / STATUS}} +
+ +
+ +
+
+
{{NUMBER}}
+
{{LABEL}}
+
+
+
{{NUMBER}}
+
{{LABEL}}
+
+
+
{{NUMBER}}
+
{{LABEL}}
+
+
+
{{NUMBER}}
+
{{LABEL}}
+
+
+ +

+ {{~30-40 words. The one paragraph that sets the whole document's tone. Use brand-color emphasis on the sharpest claim or number. Everything below is in service of this.}} +

+ + + +
+
+

{{Section one}}

+

{{One or two sentences expanding the claim.}}

+
    +
  • {{Short bullet: a data point, observation, or judgment.}}
  • +
  • {{Short bullet with key figure.}}
  • +
  • {{Short bullet: one point per line.}}
  • +
+
+ +
+

{{Section two}}

+

{{One or two sentences expanding the claim.}}

+
    +
  • {{Short bullet: a data point, observation, or judgment.}}
  • +
  • {{Short bullet with key figure.}}
  • +
  • {{Short bullet: one point per line.}}
  • +
+
+
+ +
+

Roadmapthree-step arc for proposals

+
+
+
Phase 1
+
{{STAGE_TITLE}}
+
{{One-line explanation.}}
+
+
+
Phase 2
+
{{STAGE_TITLE}}
+
{{One-line explanation.}}
+
+
+
Phase 3
+
{{STAGE_TITLE}}
+
{{One-line explanation.}}
+
+
+
+ +
+ {{Key quote / critical note / the single takeaway that must not be missed.}} +
+ + + + + diff --git a/plugins/kami/skills/kami/assets/templates/one-pager-ko.html b/plugins/kami/skills/kami/assets/templates/one-pager-ko.html new file mode 100644 index 0000000..3e3221a --- /dev/null +++ b/plugins/kami/skills/kami/assets/templates/one-pager-ko.html @@ -0,0 +1,448 @@ + + + + + +{{문서 제목}} + + + + + + + + + +
+
+
{{EYEBROW · 예: "제품 제안서" / "프로젝트 제안" / "요약 보고"}}
+

{{문서 주제목 (serif, 2줄 이내, 동사+명사 구조 권장)}}

+
{{한 줄 부제목 / 핵심 주장 한 문장}}
+
+
+ {{작성자명}}
+ {{날짜 YYYY.MM.DD}}
+ {{버전 / 상태}} +
+
+ + +
+
+
{{수치}}
+
{{라벨 설명}}
+
+
+
{{수치}}
+
{{라벨 설명}}
+
+
+
{{수치}}
+
{{라벨 설명}}
+
+
+
{{수치}}
+
{{라벨 설명}}
+
+
+ + +

+ {{40~60자 분량의 핵심 주장 도입부. 키워드로 핵심을 강조. 이 단락이 전체 문서의 톤을 결정.}} +

+ + + + +
+
+

{{섹션 제목 1}}

+

{{여기에 1~2문장의 단락을 작성해 주장을 전개.}}

+
    +
  • {{짧은 bullet: 데이터 / 관찰 / 판단}}
  • +
  • {{짧은 bullet: 핵심 수치를 포함한 근거}}
  • +
  • {{짧은 bullet: 한 줄에 한 논점}}
  • +
+
+ +
+

{{섹션 제목 2}}

+

{{여기에 1~2문장의 단락을 작성해 주장을 전개.}}

+
    +
  • {{짧은 bullet: 데이터 / 관찰 / 판단}}
  • +
  • {{짧은 bullet: 핵심 수치를 포함한 근거}}
  • +
  • {{짧은 bullet: 한 줄에 한 논점}}
  • +
+
+
+ + +
+

타임라인 / 로드맵(제안 문서에 적합)

+
+
+
단계 1
+
{{단계 제목}}
+
{{한 문장 설명}}
+
+
+
단계 2
+
{{단계 제목}}
+
{{한 문장 설명}}
+
+
+
단계 3
+
{{단계 제목}}
+
{{한 문장 설명}}
+
+
+
+ + +
+ {{핵심 인용 / 중요 알림 / 주요 takeaway. 본문보다 더 주목을 끌어야 하는 내용을 여기에.}} +
+ + + + + + diff --git a/plugins/kami/skills/kami/assets/templates/one-pager.html b/plugins/kami/skills/kami/assets/templates/one-pager.html new file mode 100644 index 0000000..cc35830 --- /dev/null +++ b/plugins/kami/skills/kami/assets/templates/one-pager.html @@ -0,0 +1,457 @@ + + + + + +{{文档标题}} + + + + + + + + + +
+
+
{{EYEBROW · 如 "产品方案" / "项目提案" / "执行摘要"}}
+

{{文档主标题(serif,2 行内,动词+名词结构最好)}}

+
{{一行副标题 / 一句核心论点}}
+
+
+ {{作者名}}
+ {{日期 YYYY.MM.DD}}
+ {{版本号 / 状态}} +
+ +
+ + +
+
+
{{数字}}
+
{{标签描述}}
+
+
+
{{数字}}
+
{{标签描述}}
+
+
+
{{数字}}
+
{{标签描述}}
+
+
+
{{数字}}
+
{{标签描述}}
+
+
+ + +

+ {{一段 40-60 字的核心论点导语。用「关键词」高亮重点。这段定调整个文档。}} +

+ + + + +
+
+

{{section 标题 1}}

+

{{这里写 1-2 句段落,展开论点。}}

+
    +
  • {{短 bullet:数据/观察/判断}}
  • +
  • {{短 bullet:带 关键数字 的论据}}
  • +
  • {{短 bullet:一句一个论点}}
  • +
+
+ +
+

{{section 标题 2}}

+

{{这里写 1-2 句段落,展开论点。}}

+
    +
  • {{短 bullet:数据/观察/判断}}
  • +
  • {{短 bullet:带 关键数字 的论据}}
  • +
  • {{短 bullet:一句一个论点}}
  • +
+
+
+ + +
+

时间线 / 路线图(适用于方案类文档)

+
+
+
阶段 1
+
{{阶段标题}}
+
{{一句解释}}
+
+
+
阶段 2
+
{{阶段标题}}
+
{{一句解释}}
+
+
+
阶段 3
+
{{阶段标题}}
+
{{一句解释}}
+
+
+
+ + +
+ {{关键引用 / 重要提示 / 核心 takeaway。比正文更需要引起注意的内容放这里。}} +
+ + + + + + diff --git a/plugins/kami/skills/kami/assets/templates/portfolio-en.html b/plugins/kami/skills/kami/assets/templates/portfolio-en.html new file mode 100644 index 0000000..e323da5 --- /dev/null +++ b/plugins/kami/skills/kami/assets/templates/portfolio-en.html @@ -0,0 +1,653 @@ + + + + + +{{NAME}} · Portfolio + + + + + + + + + +
+
+ +
{{YEAR_RANGE - e.g. Selected Works 2023–2026}}
+
+ +
+
{{NAME}}
Portfolio
+
{{One-line self-description or portfolio theme.}}
+
+
+ +
+
+ {{DISCIPLINE / ROLE}}
+ {{LOCATION}} +
+
+ {{EMAIL}}
+ {{WEBSITE / SOCIAL}} +
+
+
+ + +
+
About
+
{{Single-line positioning headline.}}
+
+ {{Two or three lines of introduction in serif. Not sales-y. Describe in your own words what you care about and what you do well.}} +
+
+
+

Background

+

{{A paragraph on your past experience.}}

+
+
+

Focus

+

{{A paragraph on your current focus or methodology.}}

+
+
+
+ + +
+
+
01
+
+
{{PROJECT_TYPE - e.g. Product Design / Open Source}}
+
{{PROJECT_NAME}}
+
{{One-line description of what this project did.}}
+
+
{{DATE - e.g. 2025.04 - 2026.02}}
+
+ +
+ {{Tag 1}} + {{Tag 2}} + {{Tag 3}} +
+ +
+ + [hero image placeholder - replace with <img>] +
+ +
+
+

Context

+

{{Why this project? What problem does it solve? Who is the user?}}

+
+
+

Approach

+

{{How it was done - key decisions, design rationale, technical approach.}}

+
+
+

Outcome

+

{{Results - figures, feedback, impact.}}

+
+
+ +
+
+
{{NUMBER}}
+
{{LABEL}}
+
+
+
{{NUMBER}}
+
{{LABEL}}
+
+
+
{{NUMBER}}
+
{{LABEL}}
+
+
+
+ + +
+
+
02
+
+
{{PROJECT_TYPE - e.g. Product Design / Open Source}}
+
{{PROJECT_NAME}}
+
{{One-line description.}}
+
+
{{DATE - e.g. 2025.04 - 2026.02}}
+
+ +
+ {{Tag}} + {{Tag}} +
+ +
+
[left image]
+
[right image]
+
+ +
+
+

Context

+

{{Why this project? What problem does it solve? Who is the user?}}

+
+
+

Approach

+

{{How it was done - key decisions, design rationale, technical approach.}}

+
+
+

Outcome

+

{{Results - figures, feedback, impact.}}

+
+
+
+ + +
+
Selected Works
+ +
+ 2025 +
+
{{WORK_TITLE}}
+
{{One-line description.}}
+
+ +
+ +
+ 2024 +
+
{{WORK_TITLE}}
+
{{One-line description.}}
+
+ +
+ +
+ 2023 +
+
{{WORK_TITLE}}
+
{{One-line description.}}
+
+ +
+
+ + +
+
Let's Talk
+
{{One-line opening - e.g. "Open to new collaborations."}}
+
+
+ +
Phone{{PHONE}}
+
Website{{URL}}
+
{{PLATFORM}}@{{HANDLE}}
+
+
+ + + diff --git a/plugins/kami/skills/kami/assets/templates/portfolio-ko.html b/plugins/kami/skills/kami/assets/templates/portfolio-ko.html new file mode 100644 index 0000000..325623f --- /dev/null +++ b/plugins/kami/skills/kami/assets/templates/portfolio-ko.html @@ -0,0 +1,662 @@ + + + + + +{{이름}} · 포트폴리오 + + + + + + + + + +
+
{{연도 또는 분야 태그 · 예: "Selected Works 2023–2026"}}
+ +
+
{{이름
포트폴리오}}
+
{{한 줄 자기 설명 / 포트폴리오 주제}}
+
+
+ +
+
+ {{전문 분야 / 역할}}
+ {{소재지}} +
+
+ {{EMAIL}}
+ {{웹사이트 / 소셜 링크}} +
+
+
+ + +
+
About
+
{{자기 정의를 담은 한 문장 headline}}
+
+ {{2-3줄의 자기소개 인트로. serif 서체, 너무 홍보성이 되지 않게. + 자신이 무엇에 관심을 갖고 무엇을 잘하는지 자신의 언어로 기술한다.}} +
+
+
+

경력

+

{{과거 경력에 대한 개요.}}

+
+
+

관심사

+

{{현재의 관심사 / 방법론 / 접근 방식.}}

+
+
+
+ + +
+
+
01
+
+
{{프로젝트 유형 · 예: "Product Design" / "Open Source"}}
+
{{프로젝트 이름}}
+
{{이 프로젝트가 무엇을 했는지 한 문장으로}}
+
+
{{기간 · 예: "2025.04 - 2026.02"}}
+
+ +
+ {{태그 1}} + {{태그 2}} + {{태그 3}} +
+ + +
+ + [프로젝트 메인 이미지 자리 · <img src="xxx.png">로 교체] +
+ + +
+
+

Context

+

{{왜 이 프로젝트를 했는가? 어떤 문제를 해결하려 했는가? 누가 사용자인가?}}

+
+
+

Approach

+

{{어떻게 접근했는가? 핵심 의사결정, 설계 고려사항, 기술 방안.}}

+
+
+

Outcome

+

{{결과는 무엇인가? 수치, 피드백, 영향.}}

+
+
+ + +
+
+
{{수치}}
+
{{라벨}}
+
+
+
{{수치}}
+
{{라벨}}
+
+
+
{{수치}}
+
{{라벨}}
+
+
+
+ + +
+
+
02
+
+
{{프로젝트 유형 · 예: "Product Design" / "Open Source"}}
+
{{프로젝트 이름}}
+
{{한 문장 설명}}
+
+
{{기간 · 예: "2025.04 - 2026.02"}}
+
+ +
+ {{태그}} + {{태그}} +
+ + +
+
[좌측 이미지]
+
[우측 이미지]
+
+ +
+
+

Context

+

{{왜 이 프로젝트를 했는가? 어떤 문제를 해결하려 했는가? 누가 사용자인가?}}

+
+
+

Approach

+

{{어떻게 접근했는가? 핵심 의사결정, 설계 고려사항, 기술 방안.}}

+
+
+

Outcome

+

{{결과는 무엇인가? 수치, 피드백, 영향.}}

+
+
+
+ + +
+
추가 작품
+ +
+ 2025 +
+
{{작품 제목}}
+
{{한 줄 설명}}
+
+ +
+ +
+ 2024 +
+
{{작품 제목}}
+
{{한 줄 설명}}
+
+ +
+ +
+ 2023 +
+
{{작품 제목}}
+
{{한 줄 설명}}
+
+ +
+
+ + +
+
Let's Talk
+
{{연락을 환영하는 한 문장 · 예: "새로운 협업을 기대합니다"}}
+
+
+ +
Phone{{전화번호}}
+
Website{{URL}}
+
{{기타 플랫폼}}@{{ID}}
+
+
+ + + diff --git a/plugins/kami/skills/kami/assets/templates/portfolio.html b/plugins/kami/skills/kami/assets/templates/portfolio.html new file mode 100644 index 0000000..a67c747 --- /dev/null +++ b/plugins/kami/skills/kami/assets/templates/portfolio.html @@ -0,0 +1,715 @@ + + + + + +{{名字}} · 作品集 + + + + + + + + + +
+
+ +
{{年份 或 领域标签 · 如 "Selected Works 2023–2026"}}
+
+ +
+
{{名字
作品集}}
+
{{一句自我描述 / 作品集主题}}
+
+
+ +
+
+ {{专业 / 角色}}
+ {{所在地}} +
+
+ {{EMAIL}}
+ {{网站 / 社交链接}} +
+
+
+ + +
+
About
+
{{一句自我定位的 headline}}
+
+ {{2-3 行的自我介绍引言。serif 字体,斜体感,不写太 sales。 + 用自己的语言描述你关心什么、擅长什么。}} +
+
+
+

经历

+

{{一段关于你过往经历的概述。}}

+
+
+

关注

+

{{一段关于你当前的关注点 / 方法论。}}

+
+
+
+ + +
+
+
01
+
+
{{项目类型 · 如 "Product Design" / "Open Source"}}
+
{{项目名称}}
+
{{一句话描述这个项目做了什么}}
+
+
{{时间 · 如 "2025.04 - 2026.02"}}
+
+ +
+ {{标签 1}} + {{标签 2}} + {{标签 3}} +
+ + +
+ + + + + + + + + + + + + + + + 提交请求 + + + + 查询记录 + + + + 返回结果 + + + + 响应数据 + + + + 用户 + + + + API + + + + 数据库 + + +
+ + +
+
+

Context

+

{{为什么做这个项目?要解决什么问题?谁是用户?}}

+
+
+

Approach

+

{{怎么做的?关键决策、设计考量、技术方案。}}

+
+
+

Outcome

+

{{结果是什么?数据、反馈、影响。}}

+
+
+ + +
+
+
{{数字}}
+
{{标签}}
+
+
+
{{数字}}
+
{{标签}}
+
+
+
{{数字}}
+
{{标签}}
+
+
+
+ + +
+
+
02
+
+
{{项目类型 · 如 "Product Design" / "Open Source"}}
+
{{项目名称}}
+
{{一句话描述}}
+
+
{{时间 · 如 "2025.04 - 2026.02"}}
+
+ +
+ {{标签}} + {{标签}} +
+ + +
+
[左图]
+
[右图]
+
+ +
+
+

Context

+

{{为什么做这个项目?要解决什么问题?谁是用户?}}

+
+
+

Approach

+

{{怎么做的?关键决策、设计考量、技术方案。}}

+
+
+

Outcome

+

{{结果是什么?数据、反馈、影响。}}

+
+
+
+ + +
+
更多作品
+ +
+ 2025 +
+
{{作品标题}}
+
{{一句描述}}
+
+ +
+ +
+ 2024 +
+
{{作品标题}}
+
{{一句描述}}
+
+ +
+ +
+ 2023 +
+
{{作品标题}}
+
{{一句描述}}
+
+ +
+
+ + +
+
Let's Talk
+
{{欢迎联系的一句话 · 如 "期待新的合作机会"}}
+
+
+ +
Phone{{电话}}
+
Website{{URL}}
+
{{其他平台}}@{{ID}}
+
+
+ + + diff --git a/plugins/kami/skills/kami/assets/templates/resume-en.html b/plugins/kami/skills/kami/assets/templates/resume-en.html new file mode 100644 index 0000000..f7377f8 --- /dev/null +++ b/plugins/kami/skills/kami/assets/templates/resume-en.html @@ -0,0 +1,745 @@ + + + + +{{NAME}} · Resume + + + + + + + + + + + + + +
+
+
{{NAME}}{{ALIAS_OR_PREFERRED_NAME}}
+
+
+ {{ROLE_TITLE - e.g. AI / Agent Engineer}} +
+ github.com/{{GITHUB_ID}} + · + x.com/{{X_ID}} + · + {{EMAIL}} + · + {{CITY}}, {{COUNTRY_OR_REGION}} +
+
+ + +
+
{{NUMBER}}{{LABEL}}
+
{{NUMBER}}{{LABEL}}
+
{{NUMBER}}{{LABEL}}
+
{{NUMBER}}{{LABEL}}
+
+ +
+
Summary
+
+ {{~50 words. Structure: current role + seniority + tenure. Team composition (size, seniority mix, cross-functional collaborators). Long-term direction. Three to five core areas of depth.}} +
+
+ +
+
Experience{{START_DATE}} - Present · {{KEY_MILESTONE}}
+ + +
+
+
{{YEAR}}
{{STAGE_TITLE}}
+
{{One sentence on what this stage meant.}}
+
+
+
{{YEAR}}
{{STAGE_TITLE}}
+
{{One sentence on what this stage meant.}}
+
+
+
{{YEAR}}
{{STAGE_TITLE}}
+
{{One sentence on what this stage meant.}}
+
+
+ + +
+
+ {{PROJECT_NAME}} + · {{PROJECT_TYPE}} + {{ROLE - e.g. tech lead}} +
+
+
+
Role
+
{{~40 words: what the project is, why it existed, what your seat at the table was.}}
+
+
+
Actions
+
{{~55 words: technical approach, key decisions, execution path.}}
+
+
+
Impact
+
{{~65 words: numbers-first. Highlight 1-2 key figures.}}
+
+
+
+ +
+
+ {{PROJECT_NAME}} + · {{PROJECT_TYPE}} + {{ROLE - e.g. tech lead}} +
+
+
+
Role
+
{{~40 words: what the project is, why it existed, what your seat at the table was.}}
+
+
+
Actions
+
{{~55 words: technical approach, key decisions, execution path.}}
+
+
+
Impact
+
{{~65 words: numbers-first. Highlight 1-2 key figures.}}
+
+
+
+ +
+
+ {{PROJECT_NAME}} + · {{PROJECT_TYPE}} + {{ROLE - e.g. tech lead}} +
+
+
+
Role
+
{{~40 words: what the project is, why it existed, what your seat at the table was.}}
+
+
+
Actions
+
{{~55 words: technical approach, key decisions, execution path.}}
+
+
+
Impact
+
{{~65 words: numbers-first. Highlight 1-2 key figures.}}
+
+
+
+
+ + + + +
+
Open Source & Indie Work{{TIME_RANGE}} · {{SUBTITLE}}
+ +
+ {{One-line positioning statement.}} {{Short sketch of your indie developer identity: design instinct, solo end-to-end delivery, cross-language range, user reception.}} Cumulative GitHub: {{STARS_TOTAL}} stars · {{FORKS_TOTAL}} forks · {{FOLLOWERS_TOTAL}} followers. +
+ +
+
+ {{PROJECT}} + {{Stack + core positioning + platform.}} + ★ {{STARS}} +
+
+ {{PROJECT}} + {{Stack + core positioning + platform.}} + ★ {{STARS}} +
+
+ {{PROJECT}} + {{Stack + core positioning + platform.}} + ★ {{STARS}} +
+
+ {{PROJECT}} + {{Stack + core positioning + platform.}} + ★ {{STARS}} +
+
+ {{PROJECT}} + {{Stack + core positioning + platform.}} + ★ {{STARS}} +
+
+ {{PROJECT}} + {{Stack + core positioning + platform.}} + ★ {{STARS}} +
+
+ +
+ {{HIGHLIGHT}}{{A single anecdote that sets your work apart: the moment it went viral, the notable person who shared it, the community that formed around it.}} +
+
+ +
+
Judgment & Conviction
+
+
+
{{DATE}}{{EVENT_TITLE}}
+
+ {{Specific call you made or action you took, and the downstream evidence that the judgment was right.}} +
+
+
+
{{DATE}}{{EVENT_TITLE}}
+
+ {{Specific call you made or action you took, and the downstream evidence that the judgment was right.}} +
+
+
+
{{DATE}}{{EVENT_TITLE}}
+
+ {{Specific call you made or action you took, and the downstream evidence that the judgment was right.}} +
+
+
+
+ +
+
Public Impact
+ +
+ {{PLATFORM}} · @{{HANDLE}} + {{FOLLOWERS}} followers + {{Blog / newsletter / other content product.}} +
+ +
+
+
Selected writing{{SUBTITLE}}
+
+
+ {{ARTICLE_TITLE}} + {{DATE}} +
+
{{Views / likes / impact metric.}}
+
+
+
+ {{ARTICLE_TITLE}} + {{DATE}} +
+
{{Views / likes / impact metric.}}
+
+
+ +
+
Invited talks{{SUBTITLE}}
+
+
+
{{TALK_TITLE}}
+
{{HOST / VENUE}}
+
+
{{DATE}}
+
+
+
+
{{TALK_TITLE}}
+
{{HOST / VENUE}}
+
+
{{DATE}}
+
+
+
+
+ +
+
Core Skills
+ +
+
{{SKILL_LABEL_1}}
+
{{Description with at least one emphasis.}}
+
+
+
{{SKILL_LABEL_2}}
+
{{Description with at least one emphasis.}}
+
+
+
{{SKILL_LABEL_3}}
+
{{Description with at least one emphasis.}}
+
+
+
{{SKILL_LABEL_4}}
+
{{Description with at least one emphasis.}}
+
+
+
{{SKILL_LABEL_5}}
+
{{Description with at least one emphasis.}}
+
+
+ +
+
Education
+
+
+ {{SCHOOL}} + · {{COLLEGE}} · {{MAJOR}} · {{One-line judgment-flavored note, e.g. "declined grad school, went straight to industry".}} +
+
{{DATE_RANGE}}
+
+
+ + + diff --git a/plugins/kami/skills/kami/assets/templates/resume-ko.html b/plugins/kami/skills/kami/assets/templates/resume-ko.html new file mode 100644 index 0000000..1bc9eed --- /dev/null +++ b/plugins/kami/skills/kami/assets/templates/resume-ko.html @@ -0,0 +1,802 @@ + + + + + +{{성명}} · 이력서 + + + + + + + + + + + + + +
+
+
{{성명}}{{별명/영문명}}
+
+
+ {{포지션, 예: "AI / 에이전트 엔지니어링"}} +
+ GitHub @{{GITHUB_ID}} + · + X @{{X_ID}} + · + {{PHONE}} + · + {{EMAIL}} + · + {{도시}} + +
+
+ + +
+
{{수치}}{{단위}}{{라벨}}
+
{{수치}}{{단위}}{{라벨}}
+
{{수치}}{{단위}}{{라벨}}
+
{{수치}}{{단위}}{{라벨}}
+
+ +
+
자기 소개
+
+ {{80자 이내. 권장 구조: 현재 직책 + 직급 + 재직 기간. 팀 구성(인원, 협업 방식). 장기 발전 방향. 핵심 역량 영역(4-6개).}} +
+
+ +
+
경력{{시작 연도}} - 현재 ({{핵심 이정표}})
+ + +
+
+
{{연도}}
{{단계 제목}}
+
{{이 단계의 의미를 한 문장으로}}
+
+
+
{{연도}}
{{단계 제목}}
+
{{이 단계의 의미를 한 문장으로}}
+
+
+
{{연도}}
{{단계 제목}}
+
{{이 단계의 의미를 한 문장으로}}
+
+
+ + +
+
+ {{프로젝트명}} + · {{프로젝트 유형}} + {{역할, 예: "방향 주도"}} +
+
+
+
역할
+
{{~60자: 프로젝트 개요 + 배경 + 본인 역할}}
+
+
+
행동
+
{{~80자: 기술 방안 / 핵심 의사결정 / 실행 경로}}
+
+
+
결과
+
{{~100자: 수치 중심. 핵심 수치 1-2곳 하이라이트.}}
+
+
+
+ +
+
+ {{프로젝트명}} + · {{프로젝트 유형}} + {{역할, 예: "방향 주도"}} +
+
+
+
역할
+
{{~60자: 프로젝트 개요 + 배경 + 본인 역할}}
+
+
+
행동
+
{{~80자: 기술 방안 / 핵심 의사결정 / 실행 경로}}
+
+
+
결과
+
{{~100자: 수치 중심. 핵심 수치 1-2곳 하이라이트.}}
+
+
+
+ +
+
+ {{프로젝트명}} + · {{프로젝트 유형}} + {{역할, 예: "방향 주도"}} +
+
+
+
역할
+
{{~60자: 프로젝트 개요 + 배경 + 본인 역할}}
+
+
+
행동
+
{{~80자: 기술 방안 / 핵심 의사결정 / 실행 경로}}
+
+
+
결과
+
{{~100자: 수치 중심. 핵심 수치 1-2곳 하이라이트.}}
+
+
+
+
+ + + + +
+
오픈소스 & 독립 개발{{기간}} · {{부제목 한 문장}}
+ +
+ {{자기 정의 한 문장}},{{개발자 정체성 간략 서술: 디자인 감각 / 독립 완성 프로세스 / 다국어 실전 / 사용자 피드백}}. GitHub 누적 {{STARS_TOTAL}} stars · {{FORKS_TOTAL}} forks · {{FOLLOWERS_TOTAL}} followers. +
+ +
+
+ {{프로젝트명}} + {{언어 + 핵심 정의 + 플랫폼}} + ★ {{STARS}} +
+
+ {{프로젝트명}} + {{언어 + 핵심 정의 + 플랫폼}} + ★ {{STARS}} +
+
+ {{프로젝트명}} + {{언어 + 핵심 정의 + 플랫폼}} + ★ {{STARS}} +
+
+ {{프로젝트명}} + {{언어 + 핵심 정의 + 플랫폼}} + ★ {{STARS}} +
+
+ {{프로젝트명}} + {{언어 + 핵심 정의 + 플랫폼}} + ★ {{STARS}} +
+
+ {{프로젝트명}} + {{언어 + 핵심 정의 + 플랫폼}} + ★ {{STARS}} +
+
+ +
+ {{하이라이트 TAG}}{{이 프로젝트의 독특한 스토리: 오픈소스 시점 / 전파 범위 / 유명인 추천 등}} +
+
+ +
+
AI 판단과 행동
+
+
+
{{시기}}{{사건 제목}}
+
+ {{구체적으로 어떤 판단이나 행동을 했는지, 왜 그것이 판단력을 증명하는지}} +
+
+
+
{{시기}}{{사건 제목}}
+
+ {{구체적으로 어떤 판단이나 행동을 했는지, 왜 그것이 판단력을 증명하는지}} +
+
+
+
{{시기}}{{사건 제목}}
+
+ {{구체적으로 어떤 판단이나 행동을 했는지, 왜 그것이 판단력을 증명하는지}} +
+
+
+
+ +
+
외부 영향력
+ +
+ {{플랫폼}} · @{{HANDLE}} + {{팔로워 수}} 팔로워 + {{블로그 / 뉴스레터 / 기타 콘텐츠 소개}} +
+ +
+
+
대표 기술 장문{{부제목}}
+
+
+ 《{{글 제목}}》 + {{날짜}} +
+
{{조회수 / 좋아요 / 영향력 지표}}
+
+
+
+ 《{{글 제목}}》 + {{날짜}} +
+
{{조회수 / 좋아요 / 영향력 지표}}
+
+
+ +
+
초청 강연{{부제목}}
+
+
+
《{{강연 제목}}》
+
{{주최 / 장소}}
+
+
{{날짜}}
+
+
+
+
《{{강연 제목}}》
+
{{주최 / 장소}}
+
+
{{날짜}}
+
+
+
+
+ +
+
핵심 역량
+ +
+
{{역량 1
라벨}}
+
{{설명. 최소 1곳 강조.}}
+
+
+
{{역량 2
라벨}}
+
{{설명. 최소 1곳 강조.}}
+
+
+
{{역량 3
라벨}}
+
{{설명. 최소 1곳 강조.}}
+
+
+
{{역량 4
라벨}}
+
{{설명. 최소 1곳 강조.}}
+
+
+
{{역량 5
라벨}}
+
{{설명. 최소 1곳 강조.}}
+
+
+ +
+
학력
+
+
+ {{학교}} +  · {{단과대학}} · {{전공}} · {{한 줄 판단 설명, 예: "대학원 포기 후 바로 취업"}} +
+
{{재학 기간}}
+
+
+ + + diff --git a/plugins/kami/skills/kami/assets/templates/resume.html b/plugins/kami/skills/kami/assets/templates/resume.html new file mode 100644 index 0000000..beba50e --- /dev/null +++ b/plugins/kami/skills/kami/assets/templates/resume.html @@ -0,0 +1,804 @@ + + + + +{{姓名}} · 简历 + + + + + + + + + + + + + +
+
+
{{姓名}}{{别名/英文名}}
+
+
+ {{岗位定位,如"AI / Agent 工程"}} +
+ GitHub @{{GITHUB_ID}} + · + X @{{X_ID}} + · + {{PHONE}} + · + {{EMAIL}} + · + {{城市}} + +
+
+ + +
+
{{数字}}{{单位}}{{标签}}
+
{{数字}}{{单位}}{{标签}}
+
{{数字}}{{单位}}{{标签}}
+
{{数字}}{{单位}}{{标签}}
+
+ +
+
个人简介
+
+ {{80 字以内。建议结构:现任职位 + 级别 + 时长。团队构成:人数、梯队、协作方。长期演进方向。核心沉淀领域:4-6 个方向。}} +
+
+ +
+
工作经历{{起始时间}} - 至今 · {{关键里程碑}}
+ + +
+
+
{{年份}}
{{阶段标题}}
+
{{一句解释这一步的意义}}
+
+
+
{{年份}}
{{阶段标题}}
+
{{一句解释这一步的意义}}
+
+
+
{{年份}}
{{阶段标题}}
+
{{一句解释这一步的意义}}
+
+
+ + +
+
+ {{项目名}} + · {{项目类型}} + {{角色定位,如"方向主导"}} +
+
+
+
角色
+
{{~60 字:项目是什么 + 为什么做 + 你的位置}}
+
+
+
动作
+
{{~80 字:技术方案 / 关键决策 / 执行路径}}
+
+
+
结果
+
{{~100 字:数据为王。关键数字 高亮 1-2 处。}}
+
+
+
+ +
+
+ {{项目名}} + · {{项目类型}} + {{角色定位,如"方向主导"}} +
+
+
+
角色
+
{{~60 字:项目是什么 + 为什么做 + 你的位置}}
+
+
+
动作
+
{{~80 字:技术方案 / 关键决策 / 执行路径}}
+
+
+
结果
+
{{~100 字:数据为王。关键数字 高亮 1-2 处。}}
+
+
+
+ +
+
+ {{项目名}} + · {{项目类型}} + {{角色定位,如"方向主导"}} +
+
+
+
角色
+
{{~60 字:项目是什么 + 为什么做 + 你的位置}}
+
+
+
动作
+
{{~80 字:技术方案 / 关键决策 / 执行路径}}
+
+
+
结果
+
{{~100 字:数据为王。关键数字 高亮 1-2 处。}}
+
+
+
+
+ + + + +
+
开源项目 & 独立开发者{{时间跨度}} · {{一句副标题}}
+ +
+ {{一句自我定位}},{{简述开发者身份:设计审美 / 独立完成流程 / 跨语言实战 / 用户反馈}}。GitHub 累计 {{STARS_TOTAL}} stars · {{FORKS_TOTAL}} forks · {{FOLLOWERS_TOTAL}} followers。 +
+ +
+
+ {{项目名}} + {{语言 + 核心定位 + 平台}} + ★ {{STARS}} +
+
+ {{项目名}} + {{语言 + 核心定位 + 平台}} + ★ {{STARS}} +
+
+ {{项目名}} + {{语言 + 核心定位 + 平台}} + ★ {{STARS}} +
+
+ {{项目名}} + {{语言 + 核心定位 + 平台}} + ★ {{STARS}} +
+
+ {{项目名}} + {{语言 + 核心定位 + 平台}} + ★ {{STARS}} +
+
+ {{项目名}} + {{语言 + 核心定位 + 平台}} + ★ {{STARS}} +
+
+ +
+ {{亮点 TAG}}{{这个项目的独特故事:开源时机 / 传播范围 / 知名人物推荐等}} +
+
+ +
+
AI 判断与行动
+
+
+
{{时间}}{{事件标题}}
+
+ {{具体做了什么判断或行动,为什么证明判断力}} +
+
+
+
{{时间}}{{事件标题}}
+
+ {{具体做了什么判断或行动,为什么证明判断力}} +
+
+
+
{{时间}}{{事件标题}}
+
+ {{具体做了什么判断或行动,为什么证明判断力}} +
+
+
+
+ +
+
对外影响力
+ +
+ {{平台}} · @{{HANDLE}} + {{粉丝数}} 粉丝 + {{博客 / 周刊 / 其他内容产品简介}} +
+ +
+
+
代表性技术长文{{副标题}}
+
+
+ 《{{文章标题}}》 + {{日期}} +
+
{{浏览量 / 赞数 / 影响力指标}}
+
+
+
+ 《{{文章标题}}》 + {{日期}} +
+
{{浏览量 / 赞数 / 影响力指标}}
+
+
+ +
+
受邀演讲{{副标题}}
+
+
+
《{{演讲标题}}》
+
{{主办方 / 地点}}
+
+
{{日期}}
+
+
+
+
《{{演讲标题}}》
+
{{主办方 / 地点}}
+
+
{{日期}}
+
+
+
+
+ +
+
核心能力
+ +
+
{{能力 1
标签}}
+
{{描述。至少 1 处强调}}
+
+
+
{{能力 2
标签}}
+
{{描述。至少 1 处强调}}
+
+
+
{{能力 3
标签}}
+
{{描述。至少 1 处强调}}
+
+
+
{{能力 4
标签}}
+
{{描述。至少 1 处强调}}
+
+
+
{{能力 5
标签}}
+
{{描述。至少 1 处强调}}
+
+
+ +
+
教育背景
+
+
+ {{学校}} +  · {{学院}} · {{专业}} · {{一句判断性描述,如"放弃保研直接就业"}} +
+
{{起止时间}}
+
+
+ + + diff --git a/plugins/kami/skills/kami/assets/templates/slides-en.py b/plugins/kami/skills/kami/assets/templates/slides-en.py new file mode 100644 index 0000000..22513c3 --- /dev/null +++ b/plugins/kami/skills/kami/assets/templates/slides-en.py @@ -0,0 +1,375 @@ +#!/usr/bin/env python3 +""" +slides-en.py - parchment design system, English slide deck generator. + +Usage: + pip install python-pptx --break-system-packages + python3 slides-en.py + +Output: + output.pptx (16:9, parchment aesthetic, Charter serif) + +This is a template. Fill in your content and run it directly. +""" + +from pptx import Presentation +from pptx.util import Inches, Pt +from pptx.dml.color import RGBColor +from pptx.enum.shapes import MSO_SHAPE +from pptx.enum.text import PP_ALIGN, MSO_ANCHOR + +# ═══════════════════════════════════════════════════════════ +# Design system constants +# ═══════════════════════════════════════════════════════════ + +PARCHMENT = RGBColor(0xf5, 0xf4, 0xed) +IVORY = RGBColor(0xfa, 0xf9, 0xf5) +BRAND = RGBColor(0x1B, 0x36, 0x5D) +BRAND_DEEP = RGBColor(0x1B, 0x36, 0x5D) +NEAR_BLACK = RGBColor(0x14, 0x14, 0x13) +DARK_WARM = RGBColor(0x3d, 0x3d, 0x3a) +CHARCOAL = RGBColor(0x4d, 0x4c, 0x48) +OLIVE = RGBColor(0x50, 0x4e, 0x49) +STONE = RGBColor(0x6b, 0x6a, 0x64) +BORDER = RGBColor(0xe8, 0xe6, 0xdc) +WHITE = RGBColor(0xff, 0xff, 0xff) + +# English Silicon Valley stack. PowerPoint falls back silently if the +# primary face is not installed on the viewing machine. +SERIF = "Charter" +SANS = SERIF + +SLIDE_W = Inches(13.33) +SLIDE_H = Inches(7.5) + + +# ═══════════════════════════════════════════════════════════ +# Helpers +# ═══════════════════════════════════════════════════════════ + +def blank_slide(prs, bg_color=PARCHMENT): + slide = prs.slides.add_slide(prs.slide_layouts[6]) + bg = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, + 0, 0, prs.slide_width, prs.slide_height) + bg.fill.solid() + bg.fill.fore_color.rgb = bg_color + bg.line.fill.background() + bg.shadow.inherit = False + return slide + + +def add_text(slide, text, left, top, width, height, + font=SANS, size=18, bold=False, italic=False, + color=NEAR_BLACK, align=PP_ALIGN.LEFT, + vanchor=MSO_ANCHOR.TOP): + tb = slide.shapes.add_textbox(left, top, width, height) + tf = tb.text_frame + tf.word_wrap = True + tf.margin_left = tf.margin_right = 0 + tf.margin_top = tf.margin_bottom = 0 + tf.vertical_anchor = vanchor + p = tf.paragraphs[0] + p.alignment = align + run = p.add_run() + run.text = text + run.font.name = font + run.font.size = Pt(size) + run.font.bold = bold + run.font.italic = italic + run.font.color.rgb = color + return tb + + +def add_line(slide, left, top, width, color=BRAND, weight_pt=1): + line = slide.shapes.add_connector(1, left, top, left + width, top) + line.line.color.rgb = color + line.line.width = Pt(weight_pt) + return line + + +def add_card(slide, left, top, width, height, + fill=IVORY, border=BORDER, border_weight=0.5): + card = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, + left, top, width, height) + card.fill.solid() + card.fill.fore_color.rgb = fill + card.line.color.rgb = border + card.line.width = Pt(border_weight) + card.shadow.inherit = False + return card + + +# ═══════════════════════════════════════════════════════════ +# Slide templates +# ═══════════════════════════════════════════════════════════ + +def cover_slide(prs, title, subtitle, author, date): + s = blank_slide(prs) + add_text(s, title, + Inches(1), Inches(2.5), Inches(11.33), Inches(1.5), + font=SERIF, size=48, color=NEAR_BLACK, + align=PP_ALIGN.CENTER) + add_line(s, Inches(6.17), Inches(4.3), Inches(1), weight_pt=1.5) + add_text(s, subtitle, + Inches(1), Inches(4.6), Inches(11.33), Inches(0.8), + font=SANS, size=18, color=OLIVE, + align=PP_ALIGN.CENTER) + add_text(s, f"{author} · {date}", + Inches(1), Inches(6.5), Inches(11.33), Inches(0.4), + font=SANS, size=13, color=STONE, + align=PP_ALIGN.CENTER) + return s + + +def toc_slide(prs, items): + s = blank_slide(prs) + add_text(s, "Contents", + Inches(1.2), Inches(0.8), Inches(10), Inches(0.8), + font=SERIF, size=34, color=NEAR_BLACK) + add_line(s, Inches(1.2), Inches(1.8), Inches(11), weight_pt=1) + + for i, item in enumerate(items): + y = Inches(2.4 + i * 0.9) + add_text(s, f"0{i+1}", + Inches(1.2), y, Inches(1), Inches(0.6), + font=SERIF, size=28, color=BRAND) + add_text(s, item, + Inches(2.4), y, Inches(9), Inches(0.6), + font=SERIF, size=22, color=NEAR_BLACK, + vanchor=MSO_ANCHOR.MIDDLE) + return s + + +def chapter_slide(prs, number, title): + s = blank_slide(prs, bg_color=BRAND) + add_text(s, f"0{number}", + Inches(0.8), Inches(0.5), Inches(2), Inches(0.8), + font=SERIF, size=28, color=WHITE) + add_text(s, title, + Inches(1), Inches(3), Inches(11.33), Inches(1.5), + font=SERIF, size=60, color=WHITE, + align=PP_ALIGN.CENTER) + return s + + +def content_slide(prs, eyebrow, title, body, page_num=None): + s = blank_slide(prs) + add_text(s, eyebrow.upper(), + Inches(1.2), Inches(0.6), Inches(10), Inches(0.4), + font=SANS, size=11, color=STONE) + add_text(s, title, + Inches(1.2), Inches(1.2), Inches(11.33), Inches(1.2), + font=SERIF, size=34, color=NEAR_BLACK) + add_text(s, body, + Inches(1.2), Inches(3), Inches(11), Inches(3.5), + font=SANS, size=18, color=DARK_WARM) + if page_num is not None: + add_text(s, f" - {page_num:02d}", + Inches(11.5), Inches(6.9), Inches(1.5), Inches(0.3), + font=SANS, size=11, color=STONE, + align=PP_ALIGN.RIGHT) + return s + + +def metrics_slide(prs, title, metrics): + """metrics: [(value, label), ...]""" + s = blank_slide(prs) + add_text(s, title, + Inches(1.2), Inches(0.8), Inches(11), Inches(1), + font=SERIF, size=30, color=NEAR_BLACK, + align=PP_ALIGN.CENTER) + add_line(s, Inches(6.17), Inches(2), Inches(1)) + + n = len(metrics) + card_w = Inches(2.8) + gap = Inches(0.3) + total_w = card_w * n + gap * (n - 1) + start = (SLIDE_W - total_w) / 2 + + for i, (value, label) in enumerate(metrics): + x = start + (card_w + gap) * i + add_text(s, value, + x, Inches(3), card_w, Inches(1.5), + font=SERIF, size=56, color=BRAND, + align=PP_ALIGN.CENTER) + add_text(s, label, + x, Inches(4.8), card_w, Inches(0.6), + font=SANS, size=14, color=OLIVE, + align=PP_ALIGN.CENTER) + return s + + +def quote_slide(prs, quote, source): + s = blank_slide(prs) + add_text(s, f"\u201c{quote}\u201d", + Inches(1.5), Inches(2.8), Inches(10.33), Inches(2.5), + font=SERIF, size=30, color=NEAR_BLACK, + align=PP_ALIGN.CENTER, + vanchor=MSO_ANCHOR.MIDDLE) + add_text(s, f" - {source}", + Inches(1.5), Inches(5.2), Inches(10.33), Inches(0.4), + font=SANS, size=14, color=OLIVE, + align=PP_ALIGN.CENTER) + return s + + +def comparison_slide(prs, eyebrow, left_title, left_items, right_title, right_items, page_num=None): + """Before/After two-column layout. Divider is warm gray. Left column is muted, right is full-weight.""" + s = blank_slide(prs) + add_text(s, eyebrow.upper(), + Inches(1.2), Inches(0.6), Inches(10), Inches(0.4), + font=SANS, size=11, color=STONE) + divider = s.shapes.add_connector(1, + Inches(6.67), Inches(1.0), + Inches(6.67), Inches(6.8)) + divider.line.color.rgb = BORDER + divider.line.width = Pt(1) + add_text(s, left_title, + Inches(1.2), Inches(1.2), Inches(5), Inches(0.8), + font=SERIF, size=22, color=OLIVE) + add_text(s, right_title, + Inches(7.0), Inches(1.2), Inches(5), Inches(0.8), + font=SERIF, size=22, color=NEAR_BLACK) + add_line(s, Inches(1.2), Inches(2.2), Inches(11.5), weight_pt=0.5) + for i, item in enumerate(left_items[:4]): + add_text(s, item, + Inches(1.2), Inches(2.6 + i * 0.9), Inches(4.9), Inches(0.7), + font=SANS, size=17, color=STONE) + for i, item in enumerate(right_items[:4]): + add_text(s, item, + Inches(7.0), Inches(2.6 + i * 0.9), Inches(5.2), Inches(0.7), + font=SANS, size=17, color=DARK_WARM) + if page_num is not None: + add_text(s, f" - {page_num:02d}", + Inches(11.5), Inches(6.9), Inches(1.5), Inches(0.3), + font=SANS, size=11, color=STONE, + align=PP_ALIGN.RIGHT) + return s + + +def pipeline_slide(prs, eyebrow, title, steps, page_num=None): + """Numbered process steps: 01/02/03 serif numerals + step title + description. + steps: list of (step_title, step_desc), max 4 steps. + """ + s = blank_slide(prs) + add_text(s, eyebrow.upper(), + Inches(1.2), Inches(0.6), Inches(10), Inches(0.4), + font=SANS, size=11, color=STONE) + add_text(s, title, + Inches(1.2), Inches(1.1), Inches(11), Inches(0.9), + font=SERIF, size=32, color=NEAR_BLACK) + add_line(s, Inches(1.2), Inches(2.15), Inches(11), weight_pt=0.5) + + n = len(steps[:4]) + step_w = Inches(11.5 / n) + for i, (step_title, step_desc) in enumerate(steps[:4]): + x = Inches(1.0) + step_w * i + add_text(s, f"0{i+1}", + x, Inches(2.5), step_w, Inches(0.8), + font=SERIF, size=42, color=BRAND) + add_text(s, step_title, + x, Inches(3.45), step_w - Inches(0.2), Inches(0.6), + font=SERIF, size=19, color=NEAR_BLACK) + add_text(s, step_desc, + x, Inches(4.15), step_w - Inches(0.2), Inches(2.2), + font=SANS, size=15, color=OLIVE) + if page_num is not None: + add_text(s, f" - {page_num:02d}", + Inches(11.5), Inches(6.9), Inches(1.5), Inches(0.3), + font=SANS, size=11, color=STONE, + align=PP_ALIGN.RIGHT) + return s + + +def ending_slide(prs, message, contact): + s = blank_slide(prs) + add_text(s, message, + Inches(1), Inches(3), Inches(11.33), Inches(1.2), + font=SERIF, size=44, color=NEAR_BLACK, + align=PP_ALIGN.CENTER) + add_line(s, Inches(6.17), Inches(4.5), Inches(1), weight_pt=1.5) + add_text(s, contact, + Inches(1), Inches(4.8), Inches(11.33), Inches(0.6), + font=SANS, size=16, color=OLIVE, + align=PP_ALIGN.CENTER) + return s + + +# ═══════════════════════════════════════════════════════════ +# Main - example deck, replace with your content +# ═══════════════════════════════════════════════════════════ + +def main(): + import argparse + parser = argparse.ArgumentParser() + parser.add_argument("--out", default="output.pptx", + help="Output PPTX path (default: output.pptx in cwd)") + args = parser.parse_args() + + prs = Presentation() + prs.slide_width = SLIDE_W + prs.slide_height = SLIDE_H + + cover_slide(prs, + title="{{DOCUMENT_TITLE}}", + subtitle="{{One-line description.}}", + author="{{AUTHOR}}", + date="2026.04") + + toc_slide(prs, items=[ + "{{Chapter 1}}", + "{{Chapter 2}}", + "{{Chapter 3}}", + "Q&A", + ]) + + chapter_slide(prs, 1, "{{Chapter Title}}") + + content_slide(prs, + eyebrow="{{Chapter · This page}}", + title="{{Core claim as a sentence}}", + body=("{{A short body paragraph, 18pt sans. Keep it under three lines. " + "One slide, one core idea. The reader's attention is the scarce resource.}}"), + page_num=5) + + metrics_slide(prs, + title="Key Results", + metrics=[ + ("+42%", "Conversion lift"), + ("3.8M", "Monthly actives"), + ("99.9%", "Availability SLA"), + ("5,000+", "QPS peak"), + ]) + + quote_slide(prs, + quote="Good design is as little design as possible.", + source="Dieter Rams") + + comparison_slide(prs, + eyebrow="{{Chapter · Comparison}}", + left_title="{{Before}}", + left_items=["{{Point A}}", "{{Point B}}", "{{Point C}}"], + right_title="{{After}}", + right_items=["{{Improvement A}}", "{{Improvement B}}", "{{Improvement C}}"], + page_num=8) + + pipeline_slide(prs, + eyebrow="{{Chapter · Process}}", + title="{{Process headline as a declarative sentence}}", + steps=[ + ("{{Step 1}}", "{{Short description of step 1. Keep to two lines.}}"), + ("{{Step 2}}", "{{Short description of step 2. Keep to two lines.}}"), + ("{{Step 3}}", "{{Short description of step 3. Keep to two lines.}}"), + ], + page_num=9) + + ending_slide(prs, + message="Thank you", + contact="{{EMAIL}} · {{WEBSITE}}") + + prs.save(args.out) + print(f"OK: Saved {args.out}") + + +if __name__ == '__main__': + main() diff --git a/plugins/kami/skills/kami/assets/templates/slides-weasy-en.html b/plugins/kami/skills/kami/assets/templates/slides-weasy-en.html new file mode 100644 index 0000000..cca32f0 --- /dev/null +++ b/plugins/kami/skills/kami/assets/templates/slides-weasy-en.html @@ -0,0 +1,360 @@ + + + + +{{Title}} + + + + + +
+ +

{{Title}}

+
{{Subtitle}}
+
{{Author}} · {{Date}}
+
+ + +
+ 01 · {{Section}} +

{{Page Title}}

+

{{Lead paragraph}}

+ +
+
+

{{Left heading}}

+

{{Left body}}

+
+
+

{{Right heading}}

+

{{Right body}}

+
+
+ +
01
+ +
+ + +
+ 02 · {{Section}} +

{{Page Title}}

+ + + + + + + + + + +
+
A{{Module title}}
+

{{Module body}}

+
+
B{{Module title}}
+

{{Module body}}

+
+
C{{Module title}}
+

{{Module body}}

+
+
D{{Module title}}
+

{{Module body}}

+
+ +
02
+ +
+ + +
+ 03 · {{Section}} +

{{Page Title}}

+ +

{{Body content}}

+ +
{{Key takeaway or bottom-line conclusion}}
+ +
03
+ +
+ + +
+ 04 · {{Section}} +

{{Page Title}}

+ + + + + + + + + + + + +
{{Dimension}}{{Value}}{{Note}}
{{Dimension}}{{Value}}{{Note}}
+ +
04
+ +
+ + + diff --git a/plugins/kami/skills/kami/assets/templates/slides-weasy-ko.html b/plugins/kami/skills/kami/assets/templates/slides-weasy-ko.html new file mode 100644 index 0000000..17de4ce --- /dev/null +++ b/plugins/kami/skills/kami/assets/templates/slides-weasy-ko.html @@ -0,0 +1,368 @@ + + + + + +{{제목}} + + + + + +
+

{{제목}}

+
{{부제목}}
+
{{발표자}} · {{날짜}}
+
+ + +
+ 01 · {{챕터}} +

{{슬라이드 제목}}

+

{{도입 문장}}

+ +
+
+

{{좌측 제목}}

+

{{좌측 내용}}

+
+
+

{{우측 제목}}

+

{{우측 내용}}

+
+
+ +
01
+ +
+ + +
+ 02 · {{챕터}} +

{{슬라이드 제목}}

+ + + + + + + + + + +
+
A{{모듈 제목}}
+

{{모듈 설명}}

+
+
B{{모듈 제목}}
+

{{모듈 설명}}

+
+
C{{모듈 제목}}
+

{{모듈 설명}}

+
+
D{{모듈 제목}}
+

{{모듈 설명}}

+
+ +
02
+ +
+ + +
+ 03 · {{챕터}} +

{{슬라이드 제목}}

+ +

{{본문 내용}}

+ +
{{하단 결론 또는 핵심 판단}}
+ +
03
+ +
+ + +
+ 04 · {{챕터}} +

{{슬라이드 제목}}

+ + + + + + + + + + + + +
{{항목}}{{내용}}{{비고}}
{{항목}}{{내용}}{{비고}}
+ +
04
+ +
+ + + diff --git a/plugins/kami/skills/kami/assets/templates/slides-weasy.html b/plugins/kami/skills/kami/assets/templates/slides-weasy.html new file mode 100644 index 0000000..961df94 --- /dev/null +++ b/plugins/kami/skills/kami/assets/templates/slides-weasy.html @@ -0,0 +1,427 @@ + + + + +{{标题}} + + + + + +
+ +

{{标题}}

+
{{副标题}}
+
{{作者}} · {{日期}}
+
+ + +
+ 01 · {{章节}} +

{{页面标题}}

+

{{引导语}}

+ +
+
+

{{左栏标题}}

+

{{左栏内容}}

+
+
+

{{右栏标题}}

+

{{右栏内容}}

+
+
+ +
01
+ +
+ + +
+ 02 · {{章节}} +

{{页面标题}}

+ + + + + + + + + + +
+
A{{模块标题}}
+

{{模块描述}}

+
+
B{{模块标题}}
+

{{模块描述}}

+
+
C{{模块标题}}
+

{{模块描述}}

+
+
D{{模块标题}}
+

{{模块描述}}

+
+ +
02
+ +
+ + +
+ 03 · {{章节}} +

{{页面标题}}

+ +

{{正文内容}}

+ +
{{底部结论或关键判断}}
+ +
03
+ +
+ + +
+ 04 · {{章节}} +

{{页面标题}}

+ + + + + + + + + + + + +
{{维度}}{{内容}}{{备注}}
{{维度}}{{内容}}{{备注}}
+ +
04
+ +
+ + +
+ 05 · {{章节}} +

{{页面标题}}

+ +
+ + + + + + + + + + + + + + + + 提交请求 + + + + 查询记录 + + + + 返回结果 + + + + 响应数据 + + + + 用户 + + + + API + + + + 数据库 + + +
+ +
05
+ +
+ + + diff --git a/plugins/kami/skills/kami/assets/templates/slides.py b/plugins/kami/skills/kami/assets/templates/slides.py new file mode 100644 index 0000000..c6868f8 --- /dev/null +++ b/plugins/kami/skills/kami/assets/templates/slides.py @@ -0,0 +1,424 @@ +#!/usr/bin/env python3 +""" +gen_slides.py - parchment design system slide deck generator + +用法: + pip install python-pptx --break-system-packages + python3 gen_slides.py + +输出: + output.pptx (16:9 宽屏, parchment 风格) + +这是一个模板脚本 - 填充自己的内容后直接运行。 +""" + +from pptx import Presentation +from pptx.util import Inches, Pt +from pptx.dml.color import RGBColor +from pptx.enum.shapes import MSO_SHAPE +from pptx.enum.text import PP_ALIGN, MSO_ANCHOR + +# ═══════════════════════════════════════════════════════════ +# Design System Constants +# ═══════════════════════════════════════════════════════════ + +# 色板 +PARCHMENT = RGBColor(0xf5, 0xf4, 0xed) +IVORY = RGBColor(0xfa, 0xf9, 0xf5) +BRAND = RGBColor(0x1B, 0x36, 0x5D) +BRAND_DEEP = RGBColor(0x1B, 0x36, 0x5D) +NEAR_BLACK = RGBColor(0x14, 0x14, 0x13) +DARK_WARM = RGBColor(0x3d, 0x3d, 0x3a) +CHARCOAL = RGBColor(0x4d, 0x4c, 0x48) +OLIVE = RGBColor(0x50, 0x4e, 0x49) +STONE = RGBColor(0x6b, 0x6a, 0x64) +BORDER = RGBColor(0xe8, 0xe6, 0xdc) +WHITE = RGBColor(0xff, 0xff, 0xff) + +# Fonts. Single serif per page. PPT falls back on the viewer's system. +# For Japanese best-effort output, set LANG = "ja" before generating. +LANG = "zh" +CN_SERIF = "Source Han Serif SC" +JA_SERIF = "YuMincho" # Windows: Yu Mincho; Linux: Noto Serif CJK JP + +SERIF = JA_SERIF if LANG == "ja" else CN_SERIF +SANS = SERIF + +# 16:9 宽屏 +SLIDE_W = Inches(13.33) +SLIDE_H = Inches(7.5) + + +# ═══════════════════════════════════════════════════════════ +# Helpers +# ═══════════════════════════════════════════════════════════ + +def blank_slide(prs, bg_color=PARCHMENT): + """创建空白幻灯片,指定背景色""" + slide = prs.slides.add_slide(prs.slide_layouts[6]) # 6 = Blank + bg = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, + 0, 0, prs.slide_width, prs.slide_height) + bg.fill.solid() + bg.fill.fore_color.rgb = bg_color + bg.line.fill.background() + bg.shadow.inherit = False + return slide + + +def add_text(slide, text, left, top, width, height, + font=SANS, size=18, bold=False, italic=False, + color=NEAR_BLACK, align=PP_ALIGN.LEFT, + vanchor=MSO_ANCHOR.TOP): + """加一段文字""" + tb = slide.shapes.add_textbox(left, top, width, height) + tf = tb.text_frame + tf.word_wrap = True + tf.margin_left = tf.margin_right = 0 + tf.margin_top = tf.margin_bottom = 0 + tf.vertical_anchor = vanchor + p = tf.paragraphs[0] + p.alignment = align + run = p.add_run() + run.text = text + run.font.name = font + run.font.size = Pt(size) + run.font.bold = bold + run.font.italic = italic + run.font.color.rgb = color + return tb + + +def add_line(slide, left, top, width, color=BRAND, weight_pt=1): + """加水平线""" + line = slide.shapes.add_connector(1, left, top, left + width, top) + line.line.color.rgb = color + line.line.width = Pt(weight_pt) + return line + + +def add_card(slide, left, top, width, height, + fill=IVORY, border=BORDER, border_weight=0.5): + """加卡片背景""" + card = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, + left, top, width, height) + card.fill.solid() + card.fill.fore_color.rgb = fill + card.line.color.rgb = border + card.line.width = Pt(border_weight) + card.shadow.inherit = False + return card + + +# ═══════════════════════════════════════════════════════════ +# Slide Templates +# ═══════════════════════════════════════════════════════════ + +def cover_slide(prs, title, subtitle, author, date): + """封面:大标题 + 副标题 + 作者/日期""" + s = blank_slide(prs) + # 大标题(serif 44pt 居中) + add_text(s, title, + Inches(1), Inches(2.5), Inches(11.33), Inches(1.5), + font=SERIF, size=44, color=NEAR_BLACK, + align=PP_ALIGN.CENTER) + # 品牌色短线 + add_line(s, Inches(6.17), Inches(4.2), Inches(1), weight_pt=1.5) + # 副标题 + add_text(s, subtitle, + Inches(1), Inches(4.5), Inches(11.33), Inches(0.8), + font=SANS, size=18, color=OLIVE, + align=PP_ALIGN.CENTER) + # 作者 + 日期 + add_text(s, f"{author} · {date}", + Inches(1), Inches(6.5), Inches(11.33), Inches(0.4), + font=SANS, size=13, color=STONE, + align=PP_ALIGN.CENTER) + return s + + +def toc_slide(prs, items): + """目录页:01 章节名 列表""" + s = blank_slide(prs) + add_text(s, "目录", + Inches(1.2), Inches(0.8), Inches(10), Inches(0.8), + font=SERIF, size=32, color=NEAR_BLACK) + add_line(s, Inches(1.2), Inches(1.8), Inches(11), weight_pt=1) + + for i, item in enumerate(items): + y = Inches(2.4 + i * 0.9) + add_text(s, f"0{i+1}", + Inches(1.2), y, Inches(1), Inches(0.6), + font=SERIF, size=28, color=BRAND) + add_text(s, item, + Inches(2.4), y, Inches(9), Inches(0.6), + font=SERIF, size=22, color=NEAR_BLACK, + vanchor=MSO_ANCHOR.MIDDLE) + return s + + +def chapter_slide(prs, number, title): + """章节首页:油墨蓝色背景 + 居中大标题""" + s = blank_slide(prs, bg_color=BRAND) + add_text(s, f"0{number}", + Inches(0.8), Inches(0.5), Inches(2), Inches(0.8), + font=SERIF, size=26, color=WHITE) + add_text(s, title, + Inches(1), Inches(3), Inches(11.33), Inches(1.5), + font=SERIF, size=56, color=WHITE, + align=PP_ALIGN.CENTER) + return s + + +def content_slide(prs, eyebrow, title, body, page_num=None): + """内容页:小标题 + 大标题 + 正文""" + s = blank_slide(prs) + # eyebrow + add_text(s, eyebrow, + Inches(1.2), Inches(0.6), Inches(10), Inches(0.4), + font=SANS, size=12, color=STONE) + # title + add_text(s, title, + Inches(1.2), Inches(1.2), Inches(11.33), Inches(1.2), + font=SERIF, size=32, color=NEAR_BLACK) + # body + add_text(s, body, + Inches(1.2), Inches(3), Inches(11), Inches(3.5), + font=SANS, size=18, color=DARK_WARM) + # page number + if page_num is not None: + add_text(s, f" - {page_num:02d}", + Inches(11.5), Inches(6.9), Inches(1.5), Inches(0.3), + font=SANS, size=11, color=STONE, + align=PP_ALIGN.RIGHT) + return s + + +def metrics_slide(prs, title, metrics): + """数据页:标题 + N 张数据卡并排 + metrics: [(value, label), ...] + """ + s = blank_slide(prs) + # 标题 + add_text(s, title, + Inches(1.2), Inches(0.8), Inches(11), Inches(1), + font=SERIF, size=28, color=NEAR_BLACK, + align=PP_ALIGN.CENTER) + add_line(s, Inches(6.17), Inches(2), Inches(1)) + + # 数据卡 + n = len(metrics) + card_w = Inches(2.8) + gap = Inches(0.3) + total_w = card_w * n + gap * (n - 1) + start = (SLIDE_W - total_w) / 2 + + for i, (value, label) in enumerate(metrics): + x = start + (card_w + gap) * i + # 大数字 + add_text(s, value, + x, Inches(3), card_w, Inches(1.5), + font=SERIF, size=52, color=BRAND, + align=PP_ALIGN.CENTER) + # 标签 + add_text(s, label, + x, Inches(4.8), card_w, Inches(0.6), + font=SANS, size=14, color=OLIVE, + align=PP_ALIGN.CENTER) + return s + + +def quote_slide(prs, quote, source): + """引用页:极简,居中引文""" + s = blank_slide(prs) + add_text(s, f"\u201c{quote}\u201d", + Inches(1.5), Inches(2.8), Inches(10.33), Inches(2.5), + font=SERIF, size=28, color=NEAR_BLACK, + align=PP_ALIGN.CENTER, + vanchor=MSO_ANCHOR.MIDDLE) + add_text(s, f" - {source}", + Inches(1.5), Inches(5.2), Inches(10.33), Inches(0.4), + font=SANS, size=14, color=OLIVE, + align=PP_ALIGN.CENTER) + return s + + +def comparison_slide(prs, eyebrow, left_title, left_items, right_title, right_items, page_num=None): + """对比页:左右两栏,竖线分隔,左侧降调,右侧全色 + left_items / right_items: list of str (最多 4 条) + """ + s = blank_slide(prs) + add_text(s, eyebrow, + Inches(1.2), Inches(0.6), Inches(10), Inches(0.4), + font=SANS, size=12, color=STONE) + # 分隔竖线(居中) + divider = s.shapes.add_connector(1, + Inches(6.67), Inches(1.0), + Inches(6.67), Inches(6.8)) + divider.line.color.rgb = BORDER + divider.line.width = Pt(1) + # 左栏标题(降调) + add_text(s, left_title, + Inches(1.2), Inches(1.2), Inches(5), Inches(0.8), + font=SERIF, size=22, color=OLIVE) + # 右栏标题(全色) + add_text(s, right_title, + Inches(7.0), Inches(1.2), Inches(5), Inches(0.8), + font=SERIF, size=22, color=NEAR_BLACK) + # 分隔线 + add_line(s, Inches(1.2), Inches(2.2), Inches(11.5), weight_pt=0.5) + # 左栏条目(降调) + for i, item in enumerate(left_items[:4]): + add_text(s, item, + Inches(1.2), Inches(2.6 + i * 0.9), Inches(4.9), Inches(0.7), + font=SANS, size=17, color=STONE) + # 右栏条目(全色) + for i, item in enumerate(right_items[:4]): + add_text(s, item, + Inches(7.0), Inches(2.6 + i * 0.9), Inches(5.2), Inches(0.7), + font=SANS, size=17, color=DARK_WARM) + if page_num is not None: + add_text(s, f" - {page_num:02d}", + Inches(11.5), Inches(6.9), Inches(1.5), Inches(0.3), + font=SANS, size=11, color=STONE, + align=PP_ALIGN.RIGHT) + return s + + +def pipeline_slide(prs, eyebrow, title, steps, page_num=None): + """流程步骤页:01/02/03 序号 + 步骤标题 + 步骤描述 + steps: list of (step_title, step_desc),最多 4 步 + """ + s = blank_slide(prs) + add_text(s, eyebrow, + Inches(1.2), Inches(0.6), Inches(10), Inches(0.4), + font=SANS, size=12, color=STONE) + add_text(s, title, + Inches(1.2), Inches(1.1), Inches(11), Inches(0.9), + font=SERIF, size=30, color=NEAR_BLACK) + add_line(s, Inches(1.2), Inches(2.15), Inches(11), weight_pt=0.5) + + n = len(steps[:4]) + step_w = Inches(11.5 / n) + for i, (step_title, step_desc) in enumerate(steps[:4]): + x = Inches(1.0) + step_w * i + # 序号 + add_text(s, f"0{i+1}", + x, Inches(2.5), step_w, Inches(0.8), + font=SERIF, size=40, color=BRAND) + # 步骤标题 + add_text(s, step_title, + x, Inches(3.45), step_w - Inches(0.2), Inches(0.6), + font=SERIF, size=19, color=NEAR_BLACK) + # 步骤描述 + add_text(s, step_desc, + x, Inches(4.15), step_w - Inches(0.2), Inches(2.2), + font=SANS, size=15, color=OLIVE) + if page_num is not None: + add_text(s, f" - {page_num:02d}", + Inches(11.5), Inches(6.9), Inches(1.5), Inches(0.3), + font=SANS, size=11, color=STONE, + align=PP_ALIGN.RIGHT) + return s + + +def ending_slide(prs, message, contact): + """结束页""" + s = blank_slide(prs) + add_text(s, message, + Inches(1), Inches(3), Inches(11.33), Inches(1.2), + font=SERIF, size=40, color=NEAR_BLACK, + align=PP_ALIGN.CENTER) + add_line(s, Inches(6.17), Inches(4.5), Inches(1), weight_pt=1.5) + add_text(s, contact, + Inches(1), Inches(4.8), Inches(11.33), Inches(0.6), + font=SANS, size=16, color=OLIVE, + align=PP_ALIGN.CENTER) + return s + + +# ═══════════════════════════════════════════════════════════ +# Main: 示例 deck,按实际需求改 +# ═══════════════════════════════════════════════════════════ + +def main(): + import argparse + parser = argparse.ArgumentParser() + parser.add_argument("--out", default="output.pptx", + help="Output PPTX path (default: output.pptx in cwd)") + args = parser.parse_args() + + prs = Presentation() + prs.slide_width = SLIDE_W + prs.slide_height = SLIDE_H + + # 1. 封面 + cover_slide(prs, + title="{{文档标题}}", + subtitle="{{一句话描述}}", + author="{{作者}}", + date="2026.04") + + # 2. 目录 + toc_slide(prs, items=[ + "{{章节 1}}", + "{{章节 2}}", + "{{章节 3}}", + "{{Q&A}}", + ]) + + # 3. 章节首页 + chapter_slide(prs, 1, "{{章节标题}}") + + # 5. 内容页 + content_slide(prs, + eyebrow="{{章节 · 本页}}", + title="{{核心论点标题}}", + body="{{一段正文,18pt sans 字体。控制在 3 行内,一屏一个核心信息。}}", + page_num=5) + + # 7. 数据页 + metrics_slide(prs, + title="关键结果", + metrics=[ + ("+42%", "转化率提升"), + ("3.8M", "月活用户"), + ("99.9%", "可用性 SLA"), + ("5,000+", "QPS 峰值"), + ]) + + # 6. 引用 + quote_slide(prs, + quote="好的设计是尽可能少的设计。", + source="Dieter Rams") + + # 8. 对比页 + comparison_slide(prs, + eyebrow="{{章节 · 对比}}", + left_title="{{旧方案}}", + left_items=["{{对比点 A}}", "{{对比点 B}}", "{{对比点 C}}"], + right_title="{{新方案}}", + right_items=["{{改善点 A}}", "{{改善点 B}}", "{{改善点 C}}"], + page_num=8) + + # 9. 流程步骤页 + pipeline_slide(prs, + eyebrow="{{章节 · 流程}}", + title="{{核心流程标题}}", + steps=[ + ("{{步骤 1}}", "{{步骤 1 的说明文字,控制在两行内。}}"), + ("{{步骤 2}}", "{{步骤 2 的说明文字,控制在两行内。}}"), + ("{{步骤 3}}", "{{步骤 3 的说明文字,控制在两行内。}}"), + ], + page_num=9) + + # 7. 结束 + ending_slide(prs, + message="Thank you", + contact="{{邮箱}} · {{网站}}") + + prs.save(args.out) + print(f"OK: Saved {args.out}") + + +if __name__ == '__main__': + main() diff --git a/plugins/kami/skills/kami/references/anti-patterns.md b/plugins/kami/skills/kami/references/anti-patterns.md new file mode 100644 index 0000000..279ed92 --- /dev/null +++ b/plugins/kami/skills/kami/references/anti-patterns.md @@ -0,0 +1,99 @@ +# Anti-Patterns: AI Document Quality + +Common failures when AI generates professional documents. Organized by failure type, each with a bad example and the fix. Use alongside `writing.md` quality bars. + +## Content Emptiness + +| # | Pattern | Bad | Fix | +|---|---------|-----|-----| +| 1 | Adjective pile-up, no numbers | "Achieved significant growth across key metrics" | State the number: "Revenue grew 34% YoY to $12M" | +| 2 | Opening-paragraph filler | "In today's rapidly evolving landscape..." | Delete the opener. Start with the first real claim. | +| 3 | Restating the heading as a sentence | Heading "Revenue Growth", body "Our revenue growth has been notable" | Body must add information the heading does not carry | +| 4 | Vague time references | "Recently launched", "in the near future" | Pin to a date or quarter: "Launched Q1 2026" | +| 5 | Synonyms masking repetition | Three paragraphs saying "we are growing" in different words | One claim, one proof, move on | + +## Metric Fabrication + +| # | Pattern | Bad | Fix | +|---|---------|-----|-----| +| 6 | Round numbers implying precision | "Exactly 10,000 users" when the source says "around 10K" | Match the source's precision: "approximately 10,000" | +| 7 | Fake decimal precision | "Market share: 23.7%" with no cited source | Either cite the source or round to "roughly 24%" | +| 8 | Metric-narrative disconnect | Chart shows flat revenue, text says "strong momentum" | Text must match what the chart shows | +| 9 | Invented comparison baselines | "3x faster than alternatives" with no benchmark | Name the alternative and the benchmark method, or remove | +| 10 | Mixing time periods | YoY growth next to QoQ growth as if comparable | Label every comparison window explicitly | + +## Structure Mimicry + +| # | Pattern | Bad | Fix | +|---|---------|-----|-----| +| 11 | Resume bullet without result | "Managed a cross-functional team" | Action + Scope + Result: "Led 8-person team to ship v2.0, reducing churn 15%" | +| 12 | Template slots filled with filler | Skills section listing "Communication, Teamwork, Problem-solving" | Name the specific skill and where it was applied, or cut the section | +| 13 | Equity report without variant perception | "Company is well-positioned for growth" | State what the market gets wrong and why your thesis differs | +| 14 | One-pager without a clear ask | Three sections of context, no "what we need from you" | The ask belongs above the fold, not implied | +| 15 | Slide title as label, not assertion | "Q3 Results" | Assertion-evidence: "Q3 revenue beat guidance by 12%" | + +## Visual Excess + +| # | Pattern | Bad | Fix | +|---|---------|-----|-----| +| 16 | More than 3 brand-color accents per page | Four different colored highlights on the same page | One accent color for emphasis; use weight or size for hierarchy | +| 17 | Chart with no insight title | Chart titled "Revenue by Quarter" | Title states the insight: "Revenue accelerated after Q2 price change" | +| 18 | Decorative chart that restates the text | Bar chart showing the same three numbers the paragraph just listed | If the text already communicates it, the chart must add a dimension (comparison, trend, distribution) | +| 19 | Icon or emoji as section marker | Sections led by decorative icons with no semantic value | Use typographic hierarchy (size, weight, spacing) instead | + +## Source Gaps + +| # | Pattern | Bad | Fix | +|---|---------|-----|-----| +| 20 | Unverified version numbers | "Compatible with v4.2" when latest is v5.1 | Check the official source before citing any version | +| 21 | "Latest" without a date | "Uses the latest framework" | "Uses Next.js 15 (as of 2026-04)" | +| 22 | Competitor comparison without market data | "Leading solution in the market" | Cite the ranking source, or use "one of the established solutions" | +| 23 | Assumed availability | "Available on all major platforms" | List the actual platforms verified | + +## Tone Contamination + +| # | Pattern | Bad | Fix | +|---|---------|-----|-----| +| 24 | Chinese AI corporate speak | "赋能企业数字化转型", "打造一站式解决方案" | Say what it does: "帮公司把纸质流程搬到线上" | +| 25 | English AI corporate speak | "Leverage our platform to unlock synergies" | "Use the platform to share data between teams" | +| 26 | Caption restates the flow diagram | "六类来源 → 六道过滤 → 配比设计 → 训练分片,四步串联" | Cap 给出图意以外的判断:"来源决定知识边界,过滤决定干净程度,配比决定能力侧重" | +| 27 | AI tone cliches (CN dashes and connectors) | "本质上是模型在做预测——这意味着..." / 大量破折号 | 删元评论框架,直接说结论。破折号换冒号或句号。自检: `grep -nE '本质是\|这意味着\|值得注意的是\|不仅.*而且\|[——–]'` | +| 28 | Sans font stack missing CJK fallback | `font-family: Inter` 用在含中文的 th / h3 | CJK 回退到系统 sans (PingFang) 跟 serif 主调冲突。任何可能渲染 CJK 的元素用 `var(--serif)` | +| 29 | Caption restates the slide title | Title: "ORM vs PRM 对比" / Cap: "ORM 跟 PRM 的对比" | Cap 必须给出标题之外的信息:取舍判据、适用场景、或讲到这里要带出的下一步。同义重写浪费了 cap 的注意力位 | + +## Landing Page + +The main `landing-page.html` template does not ship a price card or language switcher by default. Rules #30 and #31 below apply when you add either component (Kami's own `styles.css` L67-151 has a working `.lang-switch` reference). + +| # | Pattern | Bad | Fix | +|---|---------|-----|-----| +| 30 | Currency glyph shrunk and raised | `.price-currency { font-size: 0.5em; vertical-align: super }` floats `$` above the digit and breaks baseline | `font-size: 0.74em; line-height: 1; transform: translateY(0.015em)` keeps `$` and the digit on a shared baseline | +| 31 | Language menu clips descenders | `.lang-menu a { line-height: 1 }` cuts the bottom of 'g' / 'y' / 'p' | `min-height: 32px; padding: 6px 10px; line-height: 1.35` plus an invisible `::before` bridge so the cursor can travel from trigger to menu without dismissal | +| 32 | Sitemap lists locales without hreflang | `example.com/zh/` standing alone | Add `` for every shipped locale plus `hreflang="x-default"` inside the same `` entry | +| 33 | Mixed CJK and Latin digits drift on baseline | `Mac/22/$19` shows digits at different heights because the serif falls back to oldstyle nums | `font-variant-numeric: lining-nums tabular-nums` on every node that displays numbers | +| 34 | Hardcoded business data in the template | `` checked into the template file | Keep `{{PLACEHOLDER}}` slots so the user fills them at copy time. Templates ship with no real product data | +| 35 | Multilingual site without `og:locale:alternate` | Single `og:locale` and no alternates listed | Self-reference the current page locale and list the others on `og:locale:alternate` so social previews pick the right thumbnail per region | +| 36 | Stale product category | Hero still uses an old single-tool label after the product became a broader utility | Rewrite title, meta, hero, FAQ, JSON-LD, `llms.txt`, and `llms-full.txt` around the current category before adding feature bullets | +| 37 | Generated locale pages without drift check | `template.html` and `i18n/*.json` generate committed pages, but no `--check` catches stale output | Add a check mode that fails on missing keys and generated-output drift before release or package work | +| 38 | FAQ and `llms-full.txt` diverge | FAQ says one install path or price, AI-facing docs say another | Treat FAQ, JSON-LD, `llms.txt`, and `llms-full.txt` as one public fact set and update them together | +| 39 | Screenshot gallery uses private local assets | `` or `../other-repo/shots/app.webp` | Copy the image into the site repo or use a stable public URL; packaged templates must not depend on sibling checkouts | +| 40 | Locale copy updated in only one place | One locale changes price, version, or install path while other locale pages still show the old facts | Search all locale pages plus structured data for the old value and update every matching surface | +| 41 | Feature rows alternated to escape the product-list look | A visual + copy row mirrored every other row, a fixed visual track against a `1fr` copy track: the copy never fills its column, so the surplus lands between the copy and the visual as a hole, and the copy's left edge jumps every other row | Run every row the same way so the copy keeps one left edge and the surplus always falls on the outer trim. Mirror a paragraph-sized text mass, never a short bullet list. Get rhythm from unequal weight (a lead row with a larger visual, fewer points on supporting rows), not from alternation | + +## Image Slots + +| # | Pattern | Bad | Fix | +|---|---------|-----|-----| +| 41 | Image generated before slot | A polished 16:9 concept image is forced into a 3:2 feature panel and loses its focal point | Decide the slot first, then crop, pad, or generate to that ratio | +| 42 | Real screenshot redrawn as fake UI | A product screenshot is AI-redesigned and no longer matches the shipped app | Preserve real screenshots; use programmatic padding or split panels before redrawing | +| 43 | Mixed screenshot ratios in one group | Three gallery panels jump between tall, wide, and square crops | Normalize the frame and padding; keep the screenshot content intact inside it | +| 44 | Missing product image filled with atmosphere | A product page uses abstract texture because no screenshot was provided | Mark the material gap or omit the panel; do not substitute unrelated imagery | + +## Slides + +| # | Pattern | Bad | Fix | +|---|---------|-----|-----| +| 45 | Ghost deck fails | Reading only slide titles produces a pile of topics, not an argument | Rewrite titles and order before touching layout | +| 46 | Multiple evidence shapes on one slide | One slide contains a chart, screenshot, code block, and quote | Pick the primary proof, then split the rest into adjacent slides or appendix | +| 47 | Visual brief leaks into audience copy | Caption says "21:9 ultra-wide screenshot with quiet background" | Keep image prompts and crop notes in the slot map; captions state the insight | +| 48 | Template inventory without a template | Default Kami deck gets a fake layout-mapping phase | Only inventory a real user-provided PPTX or brand template | diff --git a/plugins/kami/skills/kami/references/brand-profile.md b/plugins/kami/skills/kami/references/brand-profile.md new file mode 100644 index 0000000..5048beb --- /dev/null +++ b/plugins/kami/skills/kami/references/brand-profile.md @@ -0,0 +1,76 @@ +# Brand Profile Reference + +Full specification for loading and applying user brand profiles. Referenced from SKILL.md Step 0. + +## Profile locations + +1. `~/.config/kami/brand.md` (global user config, XDG-compliant, preferred) +2. `~/.kami/brand.md` (legacy fallback) + +If found, parse the YAML frontmatter for structured fields and treat the Markdown body below the frontmatter as freeform habit notes. If no profile exists, continue without interruption. + +## Layer A: Placeholder substitution + +Apply at template edit time: replace matching `{{...}}` placeholders in the HTML body with profile values before building. + +| Profile field | Template placeholder(s) | +|---|---| +| `name` | `{{NAME}}`, `{{作者}}`, `{{AUTHOR}}` | +| `role_title` | `{{ROLE_TITLE}}` | +| `email` | `{{EMAIL}}` | +| `website` | `{{WEBSITE}}`, `{{PERSONAL_SITE}}` | +| `github` | `{{GITHUB_URL}}` (expand to full URL: `https://github.com/`) | +| `x` | `{{X_URL}}` (expand to `https://x.com/`) | +| `city` | `{{CITY}}` | +| `country` | `{{COUNTRY}}` | +| `company` | `{{COMPANY}}` | +| `tagline` | `{{TAGLINE}}` | + +Rule: explicit instructions in the current conversation always override profile values (e.g. "sign it as Alex" beats `name: Tang`). Profile fills slots only; if the template has no matching placeholder, do not insert the field. + +## Layer B: Session defaults + +Apply when the current request is ambiguous. Use profile fields to fill in missing signal silently; do not skip clarification questions for genuinely unclear intent (e.g. "make a document" still warrants asking which type). + +| Ambiguous signal | Profile field | Behavior | +|---|---|---| +| No language stated | `language` | Use `cn` / `en` / `ja` path | +| No doc type stated | `default_doc_type` | Use as fallback, still ask if truly ambiguous | +| No output format stated | `output_format` | `pdf` / `pptx` / `both` | +| No page size stated | `page_size` | `a4` or `letter` | +| Currency context unclear | `currency_locale` | `USD` -> $, M/B; `CNY` -> 元, 万/亿 | +| Tone unclear | `tone` | `formal` -> deferential register; `casual` -> relaxed; `balanced` -> default | +| Footer unclear | `footer_note` | Append standing disclaimer if present | +| TOC decision unclear | `always_include_toc` | `true` -> auto-add TOC in long-doc / portfolio | + +## Layer C: Visual customization + +Apply after template content is filled, before calling `build.py`: + +- `brand_color`: edit the `--brand` CSS variable in the template ` +``` + +**No commercial font available**: fallback chains are embedded in every template. + +```css +/* English */ +font-family: Charter, Georgia, Palatino, + "Times New Roman", serif; + +/* Chinese */ +font-family: "TsangerJinKai02", "Source Han Serif SC", + "Noto Serif CJK SC", "Songti SC", Georgia, serif; + +/* Japanese */ +font-family: "YuMincho", "Yu Mincho", "Hiragino Mincho ProN", + "Noto Serif CJK JP", "Source Han Serif JP", + "TsangerJinKai02", Georgia, serif; + +/* Korean */ +font-family: "Source Han Serif K", "Source Han Serif KR", + "Noto Serif KR", "Apple SD Gothic Neo", AppleMyungjo, + Charter, Georgia, serif; +``` + +**Font fallback affects page count**. Any font swap requires re-running the page-count check. If it overflows: lower `font-size` first, then tighten margins, then cut content. + +**Claude Desktop skill ZIPs do not bundle large CJK font files**: `TsangerJinKai02-W04.ttf`, `TsangerJinKai02-W05.ttf`, `SourceHanSerifKR-Regular.otf`, and `SourceHanSerifKR-Medium.otf` can make Claude.ai / Desktop skill upload or execution time out. The ZIP you upload must be the `scripts/package-skill.sh` output under the 6MB package ceiling, never a hand-zipped checkout. `package-skill.sh` excludes those large font files. Templates still keep local-first and jsDelivr fallback `@font-face` paths. + +When Chinese or Korean fonts are missing (the skill case), `scripts/ensure-fonts.sh` downloads them to the XDG user font dir (`${XDG_DATA_HOME:-~/.local/share}/fonts/kami`, override with `KAMI_FONT_DIR`), **not** into the skill's `assets/fonts`. fontconfig scans that dir by default on macOS and Linux, so WeasyPrint resolves `TsangerJinKai02` and `Source Han Serif K` from there while the installed skill stays small; online renders still use the jsDelivr `@font-face` URL. + +**Standalone HTML export** (sending a filled HTML file to someone else): this is not guaranteed to work outside the project tree. If the recipient cannot set up the font environment, use the PDF output instead. + +If you do need to share HTML: the font file and the HTML must live in the same directory, and the `@font-face src` must use a bare filename with no path prefix: + +```css +@font-face { + font-family: "TsangerJinKai02"; + src: url("TsangerJinKai02-W04.ttf") format("truetype"); +} +``` + +Remove the `../fonts/` prefix that templates use when fonts are in the project tree. The recipient must place the `.ttf` file alongside the `.html` file before running WeasyPrint. When in doubt, deliver the PDF. + +### Page spec + +```css +@page { + size: A4; /* or 210mm 297mm / A4 landscape / 13in 10in */ + margin: 20mm 22mm; + background: #f5f4ed; /* extend past margins to avoid white printed edge */ +} +``` + +### Print / white-paper variant (opt-in) + +Parchment is the default and keeps shipping. Override to white only when a single +document is **headed for a home / office printer**: a full-page `#f5f4ed` tint +bands unevenly and burns toner, where white paper prints clean. This is the one +sanctioned exception to design.md invariant #1 ("never pure white"), and it is +opt-in per document, never the default render. + +White is not a one-line background swap. Parchment also serves as the surface that +`--ivory` cards, code blocks, and striped rows lift *off* of (they are "brighter +than parchment"). Flip the page to white and those surfaces, only 1.5% lighter than +white, vanish. So relocate the warmth instead of deleting it: sink parchment down +into the lift surface. Three edits on the copied, filled template: + +```css +@page { background: #ffffff; } /* was #f5f4ed */ +html, body { background: #ffffff; } /* was var(--parchment) */ +:root { --ivory: #f5f4ed; } /* parchment becomes the card/code/table lift */ +``` + +Everything else is unchanged: ink-blue accent, warm text grays, and `--border` +hairlines all read fine on white. Leave the `--parchment` token itself alone (any +`var(--parchment)` section fill then reads as an intentional warm band on white). +A lifted surface that separated from parchment by fill *alone* (rare, most already +carry a `0.5pt var(--border)` edge) wants that border added so it holds an edge on +white. + +Notes: +- Lint-safe by construction: `scripts/lint.py` off-palette and token-sync guards + scan only registered templates, not generated documents, so `#ffffff` in a + filled output never trips them. +- Page-count and `--check-density` contracts are unaffected; still inspect that + cards, code, and tables read on white before shipping. +- If white-paper output ever becomes a first-class, frequent ask, promote this from + a recipe to a `--page-bg` / `--surface` token pair in every template `:root` + (ships commented, like the `.brand-logo` slot). `@page { background: var(--page-bg) }` + is verified to resolve in WeasyPrint (68.1), so the token path is viable. Until + then, keep it a recipe: the project's contract is "no new mode unless a request + can't be met otherwise", and this recipe already meets the request. + +### Headers & footers + +Long-doc running headers use `string(section-title)`. The default templates set +that string on chapter `h1` elements, not on every `h2`, so a table of contents +heading cannot pin the header to "Contents" / "目录". If a filled document uses a +different element for chapter titles, add `.running-title` to that element. + +```css +@page { + @top-right { + content: counter(page); + font-family: serif; font-size: 9pt; color: #6b6a64; + } + @bottom-center { + content: "{{DOC_NAME}} · {{AUTHOR}}"; + font-size: 9pt; color: #6b6a64; + } +} + +@page:first { + @top-right { content: ""; } + @bottom-center { content: ""; } +} +``` + +### WeasyPrint support matrix + +| Solid | Partial | Unsupported | +|---|---|---| +| CSS Grid / Flexbox | CSS filter / transform (partial) | JavaScript | +| `@page` rules | inline SVG (some attrs) | `position: sticky` | +| `@font-face` | gradients (slow, use sparingly) | CSS animations / transitions | +| `break-before` / `break-inside: avoid` | | | +| CSS variables `var(--name)` | | | +| `target-counter(attr(href), page)` for rendered TOC page numbers | | | +| `::before` / `::after` | | | + +### PDF metadata + +WeasyPrint reads standard meta tags in `` and writes them into the PDF (Title / Author / Subject / Keywords). All templates have pre-built placeholders: + +```html + + {{DOC_TITLE}} + + + + + +``` + +**Auto-inference rules** (Claude fills these from the document content without asking): + +| Field | Source | +|---|---| +| `` | H1 heading or `.header .title` text | +| `author` | Resume / letter / portfolio: person's name from the document; everything else: `"Kami"` | +| `description` | One sentence extracted from the first 2 paragraphs, ≤150 characters | +| `keywords` | 3-5 keywords from title + section headings, comma-separated | +| `generator` | Fixed `"Kami"`, already set in template, do not change | + +**Verify**: + +```bash +pdfinfo assets/examples/one-pager-en.pdf # shows Title / Author / Subject +``` + +--- + +## Part 2 · Python -> PPTX (python-pptx) + +PPT shares the same design language but the medium (screen, 16:9, one-idea-per-slide) changes the details: fonts larger, layouts more rigid. + +### Install + +```bash +pip install python-pptx --break-system-packages --quiet +``` + +### Dimensions + +- **16:9 widescreen** (preferred): 13.33 × 7.5 inch +- **4:3 traditional**: 10 × 7.5 inch +- **Safe zone**: 0.5 inch margin on all sides (projector crop), plus 0.3 inch at bottom for page number + +### Palette (1:1 with design.md) + +```python +from pptx.dml.color import RGBColor + +PARCHMENT = RGBColor(0xf5, 0xf4, 0xed) +IVORY = RGBColor(0xfa, 0xf9, 0xf5) +BRAND = RGBColor(0x1B, 0x36, 0x5D) +NEAR_BLACK = RGBColor(0x14, 0x14, 0x13) +DARK_WARM = RGBColor(0x3d, 0x3d, 0x3a) +OLIVE = RGBColor(0x5e, 0x5d, 0x59) +STONE = RGBColor(0x87, 0x86, 0x7f) +BORDER_WARM = RGBColor(0xe8, 0xe6, 0xdc) +TAG_BG = RGBColor(0xee, 0xf2, 0xf7) +``` + +### Type (bigger than print, optimized for projection) + +| Role | Size | Font | +|---|---|---| +| Title | 48pt | Serif 500 | +| Subtitle | 24pt | Serif 400 | +| H2 chapter | 32pt | Serif 500 | +| H3 subtitle | 20pt | Serif 500 | +| Body | 18pt | Serif 400 | +| Caption | 14pt | Serif 400 | +| Footer | 12pt | Serif 400 | + +English stack on PowerPoint: +- Serif: `Charter` -> `Georgia` -> `Palatino` +- Sans: same as serif (single-font-per-page rule) + +### Nine standard layouts + +1. **Cover**: parchment background, centered display title + brand-colored short line + subtitle / author / date +2. **Contents**: parchment, left-aligned `01 Chapter title` (number serif brand-colored) +3. **Chapter divider**: full brand ink-blue background, centered white title - the **only** fully chromatic slide in the deck +4. **Content slide**: eyebrow (serif stone) + core claim (serif near-black) + brand line + body (serif dark-warm) +5. **Data slide**: top takeaway + 2-4 metric cards (big number serif brand + small label serif olive) +6. **Comparison**: eyebrow + left column (muted, OLIVE/STONE) vs. right column (full-weight, DARK_WARM/NEAR_BLACK), separated by a 1pt BORDER warm-gray vertical divider. Left = "Before/Old/Problem"; right = "After/New/Solution". Use `comparison_slide()`. +7. **Pipeline**: eyebrow + title + serif numerals 01/02/03 + step title + step description, laid out in equal-width columns. All steps visible at once (no click-reveal). Use `pipeline_slide()`. +8. **Quote**: parchment, minimal, centered serif quote + `- Source` +9. **Closing**: parchment, centered "Thank you / Q&A / Contact" + +### Template-bound PPTX inventory + +Use this only when the user provides a real PPTX or brand template and explicitly asks to preserve its layout system. First inspect the template visually, identify the few reusable layout families, and map each planned section to one existing slide family. Then edit content while preserving the template's shape structure. Do not run this inventory step for Kami's default WeasyPrint or Marp paths; those already have fixed template contracts. + +### Script skeleton + +Full working example in `assets/templates/slides-en.py`. Key bits: + +```python +from pptx import Presentation +from pptx.util import Inches, Pt +from pptx.dml.color import RGBColor +from pptx.enum.shapes import MSO_SHAPE +from pptx.enum.text import PP_ALIGN + +PARCHMENT = RGBColor(0xf5, 0xf4, 0xed) +BRAND = RGBColor(0x1B, 0x36, 0x5D) +SERIF = "Charter" +SANS = SERIF + +prs = Presentation() +prs.slide_width = Inches(13.33) +prs.slide_height = Inches(7.5) + +def blank_slide(): + slide = prs.slides.add_slide(prs.slide_layouts[6]) + bg = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, + 0, 0, prs.slide_width, prs.slide_height) + bg.fill.solid(); bg.fill.fore_color.rgb = PARCHMENT + bg.line.fill.background(); bg.shadow.inherit = False + return slide +``` + +### PPT notes + +1. **One idea per slide** - if it runs over three lines, split it +2. **No default PowerPoint template** - it's cool-blue-gray, clashes with parchment +3. **Animations**: don't. Parchment is a print aesthetic, not a SaaS demo. At most `fade` +4. **Export to PDF** for sharing - cross-machine consistency is better than .pptx + - macOS: Keynote -> Export to PDF + - Linux: `libreoffice --headless --convert-to pdf output.pptx` + +### Slide scale rules (from production decks) + +Print and slide share the same palette and serif, but sizing is different. +One rule covers most adjustments: **macro spacing x1.6, micro details x0.5** (letter-spacing, border weight, border-radius). + +| Item | Print | Slide | Why | +|---|---|---|---| +| Container | A4 portrait | 1920x1080 or A4 landscape | Fixed ratio, never `100vw x 100vh` | +| Title size | 30-34pt | 48-64pt | Projection needs larger display | +| Slide padding | N/A | 72-80px top, 80px sides | Less than 72px top feels cramped | +| Eyebrow tracking | 0.5-1pt | 3px max | Print tracking looks scattered at slide scale | +| Display tracking | 0 to -0.2pt | -0.5pt | Tighten large titles to prevent letter gaps | +| Header gap | 8-14pt | 36px+ between rule and H1 | Below 36px the rule looks like an underline | +| Line-height titles | 1.1-1.3 | 1.3 minimum | CJK characters collide below 1.3 at slide sizes | +| Code blocks | Full runnable code | Pseudocode + `.hl` keywords | Slide code is for structure, not execution | +| Images | Inline with text | `width:100%; object-fit:contain; max-height:780px` | Avoid fixed height that clips different ratios | +| Footer | Per-template | Single component, CSS-injected text | Prevents drift across 50+ slides | +| Punctuation | Standard | Use ` - ` for short joins, `<ol>` for parallel items | CJK commas break visual rhythm at slide scale | + +--- + +## Part 2.5 · Markdown -> Marp (variant deck path) + +Marp is the third rendering path, used only when the user explicitly asks for Marp / markdown slides / a deck that lives in a `.md` file. The repo does **not** declare `marp-cli` as a dependency; install it on the user's machine. + +### Install + +Use the `npx @marp-team/marp-cli@latest ...` form below for zero-install. For repeat use, install via `npm i -g @marp-team/marp-cli` or `brew install marp-cli` (see [marp-cli docs](https://github.com/marp-team/marp-cli)). Kami's build pipeline (`build.py` / `package-skill.sh`) does not call `marp`. + +### Files + +| Asset | Path | +|---|---| +| CN theme | `assets/templates/marp/slides-marp.css` (theme name: `kami`) | +| EN theme | `assets/templates/marp/slides-marp-en.css` (theme name: `kami-en`) | +| CN sample deck | `assets/templates/marp/slides-marp.md` | +| EN sample deck | `assets/templates/marp/slides-marp-en.md` | + +### Render commands + +Run from the repo root so input paths resolve. **Input file must come before `--theme-set`**; `--theme-set` is a yargs array option and will swallow any positional arg that follows it. + +**Font path caveat**: Marp inlines the theme CSS into the output HTML verbatim. The `@font-face` `url("../../fonts/...")` paths in the theme therefore resolve relative to the *output file location*, not the theme CSS location. When the output sits inside the repo (e.g. `-o assets/examples/kami.html`), the relative path matches and local Tsanger / Charter loads. When the output sits elsewhere (e.g. `-o /tmp/kami.html`), the relative path misses and the browser falls back to the jsDelivr CDN URL declared alongside each local one. This needs network. This differs from WeasyPrint, where CSS paths resolve relative to the input HTML. + +```bash +# HTML preview (no Chromium needed; zero external download) +npx @marp-team/marp-cli@latest \ + assets/templates/marp/slides-marp.md \ + --theme-set assets/templates/marp \ + -o /tmp/kami-cn.html + +# PDF (needs Chromium; see "Browser strategy" below) +npx @marp-team/marp-cli@latest \ + assets/templates/marp/slides-marp.md \ + --theme-set assets/templates/marp \ + --pdf --allow-local-files \ + -o /tmp/kami-cn.pdf + +# PPTX (Chromium-rendered; not a python-pptx editable deck) +npx @marp-team/marp-cli@latest \ + assets/templates/marp/slides-marp.md \ + --theme-set assets/templates/marp \ + --pptx --allow-local-files \ + -o /tmp/kami-cn.pptx +``` + +`--theme-set` points at the directory; Marp picks up every `.css` in there. `--allow-local-files` is required for PDF / PPTX so the renderer may read the local font files referenced by `@font-face` URLs. + +### Browser strategy (only for PDF / PPTX) + +HTML output is pure Node and needs no browser. PDF / PPTX rendering goes through a headless browser. Three options, lightest first: + +| Strategy | Setup | Cost | +|---|---|---| +| **HTML only, view in browser** | Use the HTML command above; open in any browser; export to PDF from the browser's print dialog if needed | 0 MB | +| **Reuse a local Chromium-family browser** | Set `PUPPETEER_EXECUTABLE_PATH` to the absolute path of an installed Chrome, Edge, Brave, Arc, or Chromium binary. Marp also honours `--browser chrome` / `edge` / `firefox` with `--browser-path`. | 0 MB (assumes the browser is already installed) | +| **Let Marp download its own Chromium** | First run of `--pdf` / `--pptx` triggers Puppeteer to fetch a pinned Chromium build (~150 MB to `~/.cache/puppeteer/`) | ~150 MB, one-time | + +For light verification of the theme and sample deck, prefer the HTML path. + +### Marp gotchas + +| Symptom | Cause | Fix | +|---|---|---| +| Two page numbers per slide | Deck pinned a `.page-num` element and also set `paginate: true` | Pick one; the theme injects pagination via `section::after` | +| `position: absolute` does not pin `.co` | A child `<div>` overrode `position: relative` on the section | Marp themes already set `section { position: relative }`; do not override it per slide | +| PDF / PPTX export hangs on first run | Marp is downloading Chromium | Network-restricted environments need `PUPPETEER_EXECUTABLE_PATH` to a pre-installed Chrome | +| Markdown inside `<div class="c2">` renders as a literal HTML block | Missing blank line between the `<div>` and the Markdown body | Always leave a blank line above and below Markdown inside an HTML wrapper | + +--- + +## Part 3 · Verify & Debug + +### The three-step loop (mandatory after every change) + +```bash +# 1. Generate +python3 -c "from weasyprint import HTML; HTML('doc.html').write_pdf('out.pdf')" + +# 2. Page count +python3 -c "from pypdf import PdfReader; print(len(PdfReader('out.pdf').pages))" + +# 3. Visual inspect (when in doubt) +pdftoppm -png -r 300 out.pdf inspect +``` + +**Not verified = not done.** + +### Did the font actually load? + +```bash +pdffonts output.pdf +``` + +If the output shows `DejaVuSerif` / `Bitstream Vera` - your specified font didn't load, fell through to system ultimate fallback. Expected: `Charter`, `Georgia`, `TsangerJinKai02`, or a Japanese Mincho face such as `YuMincho`, `Hiragino-Mincho`, `Noto-Serif-CJK-JP`, or `Source-Han-Serif-JP`. + +### One-step build + validate + +Project script `scripts/build.py` is the productized version of the three-step loop: + +```bash +python3 scripts/build.py # all 12 examples +python3 scripts/build.py resume-en # one target + page count + fonts +python3 scripts/build.py --check # scan for CSS rule violations +python3 scripts/build.py --check-density # warn on pages with >25% trailing whitespace +python3 scripts/build.py --check-markdown output.pdf # scan for raw Markdown markers +``` + +For long-doc templates, keep TOC entries as links to stable chapter ids. The page +number is generated with CSS `target-counter()` at render time, so content edits +do not require hand-updating page numbers. + +### Page overflow (constrained templates) + +When a constrained template (one-pager, letter, resume, and their variants) runs over its page ceiling, fix it by editing content, not by shrinking type: cut or merge body copy until it fits, since tiny font / line-height / margin changes break the layout (see High-Risk Pitfalls). Then verify: + +```bash +python3 scripts/build.py --check +python3 scripts/build.py --verify +``` + +### Hi-res visual inspection + +```bash +pdftoppm -png -r 160 output.pdf preview # standard +pdftoppm -png -r 300 output.pdf preview # detail bugs +pdftoppm -png -r 400 output.pdf preview # extreme detail (tag double-rect check) +``` + +### 5-point pre-ship review + +A successful render is not enough. Scan these before delivery: + +| Dimension | Pass standard | +|---|---| +| Fact accuracy | Numbers, dates, versions, funding, specs, and market facts have sources; uncertainty is written as magnitude or marked as missing | +| Content structure | Headlines read as a summary; each paragraph opens with a claim; no ceremonial filler | +| Material coverage | Branded docs include logo, product image, or UI screenshot coverage; missing materials are clearly marked | +| Typographic detail | Fonts load correctly, line-height stays in spec, emphasis only marks numbers or distinctive phrases, tag backgrounds are solid hex | +| PDF readiness | Page count fits, placeholders are replaced, visual inspection shows no overflow, overlap, or broken page breaks | + +If any row fails, fix it before delivery. + +### Static product site pre-ship review + +Run this when the deliverable is a landing page, product site, or hosted showcase rather than a PDF: + +| Dimension | Pass standard | +|---|---| +| Runtime preview | Serve locally and inspect desktop plus 375px mobile. The first viewport shows the product category, real asset, CTA, and a hint of the next section. | +| Generated output | If pages come from `template + i18n + content`, run the generator's check mode. It must fail on missing keys and committed output drift. | +| Public metadata | Canonical, hreflang, `og:locale`, social image, JSON-LD, robots, sitemap, `llms.txt`, and `llms-full.txt` all reflect the shipped locale set. | +| Copy sync | Product positioning, price, version, install path, support link, FAQ, `llms.txt`, and `llms-full.txt` carry the same factual claims in every locale. | +| Asset reality | Screenshots are real product surfaces and every image path resolves from the repo or a public URL. No `/Users`, `file://`, or sibling-repo relative paths. | +| Screenshot fit | Hero, gallery, feature, and social-image slots use stable ratios. UI text, numbers, prompts, and controls are not cropped away for aesthetics. | +| Motion fallback | Gallery rotation, entrance animation, or custom transitions respect `prefers-reduced-motion`; a still page remains readable with motion disabled. | +| Link surface | Primary CTA, download, releases, docs, help, social links, and internal locale links resolve. Any named release or download artifact exists. | + +If the site has only one or two locales, hand-maintained static pages are acceptable. For three or more locales, prefer a generator with a drift check over repeated manual edits. + +--- + +## Part 4 · 22 known pitfalls + +Every entry below came from a real failure. Check here first when something looks wrong. + +Severity scale: **(P0)** render-breaking, must fix before delivery. **(P1)** breaks the design contract (rhythm, spec). **(P2)** visible to a careful reader, but not blocking. **(P3)** operational: affects workflow, not visual output. + +### 1. (P0) Tag / Badge double-rectangle bug (the worst) + +**Symptom**: PDFs show two concentric rectangles on tag backgrounds at zoom - an outer softer one and an inner tighter one. Especially visible on mobile PDF viewers. + +**Root cause**: WeasyPrint renders `rgba(..., 0.xx)` by compositing the **padding area** and the **glyph pixel area** independently. Glyph anti-aliasing stacks alpha differently, creating the second visible edge. + +**Fix**: Tag backgrounds must be solid hex. No rgba. + +```css +/* avoid */ .tag { background: rgba(201, 100, 66, 0.18); } +/* use */ .tag { background: #E4ECF5; } +``` + +**rgba -> solid conversion** (parchment `#f5f4ed` base + ink-blue `#1B365D`): + +| rgba alpha | Solid hex | +|---|---| +| 0.08 | `#EEF2F7` | +| 0.14 | `#E4ECF5` | +| **0.18** | **`#E4ECF5`** ← default | +| 0.22 | `#D0DCE9` | +| 0.30 | `#D6E1EE` | + +Formula: `solid_channel = base + (foreground - base) × alpha`. Different base colors (e.g. ivory) need re-computing. + +**Want "breathing" texture?** Use `linear-gradient` - the whole tag rasterizes as one bitmap, no alpha compositing: + +```css +.tag { background: linear-gradient(to right, #D6E1EE, #E4ECF5 70%, #EEF2F7); } +``` + +**Aesthetic warning**: gradients work engineering-wise but usually oversell the tag. Priority order: lightest solid (`#EEF2F7`) > standard solid (`#E4ECF5`) > gradient (rarely). If the reader's eye lands on the tag background shape before the text inside - you went too far. + +### 2. (P0) Thin border + radius = double circle + +**Symptom**: `border: 0.4pt solid ...` + `border-radius: 2pt` shows two parallel arcs on zoom. + +**Root cause**: WeasyPrint strokes border inner and outer paths separately when `< 1pt` + rounded corners - at thin widths they can't overlap. + +**Fix (pick one)**: +1. Use background fill instead (preferred, design-consistent) +2. Border ≥ 1pt +3. Drop `border-radius` + +### 3. (P1) 2-page hard-limit overflow + +For resume, one-pager, and other length-capped docs. + +**Common causes**: font fallback, content added, font-size bumped by accident, line-height pushed from 1.4 to 1.6. + +**Diagnose**: `pdffonts output.pdf` to verify what actually loaded. + +**Fix (priority)**: +1. Cut redundant qualifiers ("deeply researched" -> "researched") +2. Merge related data points in the same section +3. Drop non-essential items whole (not piecemeal) +4. Reduce section spacing (use sparingly - affects global rhythm) +5. Last resort: shrink font by 0.1-0.2pt + +**Don't**: cut cover / education / timeline structural blocks; cut emphasis (resume becomes flat). + +**5-project high-density layout** (when content legitimately needs 5 projects on page 1): add `class="resume--dense"` to `<body>`. This activates a built-in CSS variant that applies the following adjustments without touching any content: + +| Property | Default | Dense | +|---|---|---| +| `body font-size` | 9.2pt | 9pt | +| `.proj-text line-height` | 1.40 | 1.38 (CN) / 1.40 (EN) | +| `.tl-body font-size` | 9pt | 8.5pt (CN only) | +| `.section-title margin-top` | 5mm | 3.5mm | + +Apply dense mode only when the 4-project layout already overflows. Do not use it as a default; the visual rhythm is noticeably tighter. + +Resume templates use section-title bottom rules and borderless project rows. Do not reintroduce project `border-top` as a density aid; it creates double rules below headings and orphan rules at page breaks. + +### 4. (P1) Font fallback causes page count inconsistency + +**Symptom**: 2 pages locally, 4 pages in CI / on server. + +**Root cause**: font file neither alongside HTML nor system-installed. + +**Fix**: + +```bash +# Preferred: multi-source download script (retries, size validation). +# Lands fonts in ${XDG_DATA_HOME:-~/.local/share}/fonts/kami (fontconfig-scanned, +# outside the skill dir), then runs fc-cache. Inside a repo checkout it is a +# no-op because the committed TTFs already satisfy the templates' relative path. +bash scripts/ensure-fonts.sh + +# Or put .ttf alongside the HTML +cp TsangerJinKai02-W04.ttf workspace/ + +# macOS fallback font +brew install --cask font-source-han-serif-sc + +# Linux system install +apt install fonts-noto-cjk +mkdir -p ~/.fonts && cp *.ttf ~/.fonts/ && fc-cache -f +``` + +### 5. (P2) CJK and Latin crowding (Chinese mode only) + +**Symptom**: "125.4k GitHub Stars" - k and G feel glued. + +**Wrong fixes**: hand-added ` ` / `margin-left: 2mm` (misaligns adjacent elements). + +**Right fix**: separate spans with flex gap: + +```html +<div class="metric"> + <span class="metric-value">125.4k</span> + <span class="metric-label">GitHub Stars</span> +</div> +``` +```css +.metric { display: flex; align-items: baseline; gap: 6pt; } +``` + +### 6. (P2) Full-width vs half-width spaces (Chinese mode) + +- **Between Chinese characters**: U+3000 full-width space + `·` + space +- **Between Latin words**: half-width space + `·` + space +- **Mixed**: prefer flex gap, don't hand-type spaces + +### 7. (P2) Thousands / percent / arrows - be consistent + +| Use | Avoid | +|---|---| +| `5,000+` | `5000+` | +| `90%` | `90 %` (pre-space) | +| `->` | `->` / `->` | + +Self-check: +```bash +grep -oE '->|->|⟶|⇒' doc.html | sort | uniq -c +grep -oE '[0-9]{4,}' doc.html | sort -u +``` + +### 8. (P2) Too much / too little emphasis + +- Four or five ink-blue runs in one line -> visual fatigue, no focal point +- Entire section with none -> flat, no scan handles + +**Rule**: ≤ 2 emphases per line, ≥ 1 per section, only **quantifiable numbers or distinctive phrases** get highlighted - never adjectives. + +Healthy ratio: one emphasis per 80-150 words. + +### 9. (P0) `height: 100vh` doesn't work + +**Symptom**: full-bleed cover using `height: 100vh` renders empty. + +**Root cause**: viewport units are undefined in WeasyPrint's `@page` context. + +**Fix**: + +```css +.cover { + min-height: 257mm; /* A4 height 297 - 40mm margins */ + display: flex; + flex-direction: column; + justify-content: center; +} +``` + +### 10. (P1) `break-inside` fails inside flex + +**Symptom**: `.card { break-inside: avoid }` still splits across pages. + +**Root cause**: WeasyPrint's flex/grid `break-inside` support on direct children is incomplete. + +**Fix**: wrap the flex item in an extra block: + +```html +<div class="row"> + <div class="card-wrapper"><div class="card">...</div></div> +</div> +``` +```css +.row { display: flex; } +.card-wrapper { break-inside: avoid; } +``` + +### 11. (P3) Hide page number on the first page + +```css +@page:first { + @top-right { content: ""; } +} +``` + +### 12. (P2) Printed white margin around the page + +**Symptom**: printing produces a white border even though `background` is set. + +**Root cause**: default `@page background` only covers the content area, not the margin. + +**Fix**: + +```css +@page { + size: A4; margin: 20mm; + background: #f5f4ed; /* extends past margins */ +} +``` + +### 13. (P2) Blurry images + +**Symptom**: images in PDF look soft. + +**Root cause**: WeasyPrint renders at source pixel density. A4 @ 300 dpi = 2480 × 3508 pixels. + +**Fix**: source images at 2x or 3x. + +### 14. (P3) Verification loop (catch-all) + +```bash +python3 -c "from weasyprint import HTML; HTML('doc.html').write_pdf('out.pdf')" +python3 -c "from pypdf import PdfReader; print(len(PdfReader('out.pdf').pages))" +pdftoppm -png -r 300 out.pdf inspect # when in doubt +``` + +**Not verified = not done.** + +### 15. (P0) SVG marker `orient="auto"` ignored + +**Symptom**: SVG arrows using `<marker orient="auto">` or `orient="auto-start-reverse"` all point right (the marker's default drawing direction), regardless of the path's tangent angle. + +**Root cause**: WeasyPrint's SVG renderer does not support the `orient="auto"` attribute on markers. The marker is always drawn at 0°. + +**Fix**: skip `<marker>` entirely. Draw each arrowhead as a manual chevron `<path>` at the endpoint, with the direction hardcoded. + +```xml +<!-- Bad: marker arrow, WeasyPrint renders all pointing right --> +<defs> + <marker id="a" orient="auto" ...> + <path d="M2 1L8 5L2 9" .../> + </marker> +</defs> +<path d="M 440 52 Q 568 52 568 244" marker-end="url(#a)"/> + +<!-- Good: manual chevron, direction per endpoint --> +<path d="M 440 52 Q 568 52 568 244" fill="none" stroke="#504e49" stroke-width="1.5"/> +<path d="M 560 236 L 568 244 L 576 236" fill="none" stroke="#504e49" stroke-width="1.5" + stroke-linecap="round" stroke-linejoin="round"/> +``` + +Chevron templates (tip at endpoint, 8px arm length): + +| Direction | chevron path | +|---|---| +| down | `M (x-8) (y-8) L x y L (x+8) (y-8)` | +| left | `M (x+8) (y-8) L x y L (x+8) (y+8)` | +| up | `M (x-8) (y+8) L x y L (x+8) (y+8)` | +| right | `M (x-8) (y-8) L x y L (x-8) (y+8)` | + +### 16. (P1) Slide letter-spacing must be halved + +**Symptom**: Slide text looks "scattered" or over-spaced when print letter-spacing values (e.g. `letter-spacing: 8px`) are used directly. + +**Root cause**: Print letter-spacing values are tuned for small sizes (8-12pt). At slide sizes (48-64px), the same absolute value gets multiplied out of control. + +**Fix**: Slide letter-spacing = print value / 2. Mono fonts are exempt (fixed-width by nature, no extra tracking needed). + +```css +/* Print eyebrow */ +.eyebrow { letter-spacing: 6px; } + +/* Slide eyebrow */ +.slide .eyebrow { letter-spacing: 3px; } /* halved */ +``` + +### 17. (P1) Figure SVG `max-height` starves width + +**Symptom**: An inline `<svg>` inside `<figure>` sits at less than the page content width, leaving a visible parchment gap on the right while the surrounding title and table run full-width. + +**Root cause**: When a figure SVG declares `max-height` without an explicit `width: 100%`, browsers and WeasyPrint preserve the viewBox aspect ratio and shrink width to honor the height cap. For wide viewBoxes (aspect > 1.5) the height cap becomes the binding constraint and width starves. + +**Fix**: Always set both width and height behavior. Use `max-height` only as a safety ceiling, never as the primary sizing rule. + +```css +/* avoid */ +figure svg { max-height: 45mm; } + +/* use */ +figure svg { width: 100%; height: auto; max-height: 70mm; } +``` + +Quick check: if `viewBox` aspect ratio × current `max-height` < page content width, the chart is starved. Bump `max-height` until `aspect × max-height >= content width` or remove the cap. + +### 18. (P1) Multi-column metric labels need word-budget discipline + +**Symptom**: One or more labels in a 3-4 column metric row wrap to two lines while siblings stay on one line, breaking the baseline rhythm and pushing the value/label out of alignment. + +**Root cause**: Equal-flex columns at gap `G` and content width `W` give each column `(W - G·(N-1)) / N` total. After the metric value (typically 12-18mm at 14pt Charter), the label has only what remains - usually 22-28mm for 4 columns at 184mm width. + +**Fix**: Plan label text against the available budget before layout. Approximate budget at 9pt Charter: + +| Layout | Per-column total | Label budget after value | Soft char limit | +|---|---|---|---| +| 4 columns, gap 7mm, content 184mm | ~40.7mm | ~22-26mm | 14-18 chars | +| 3 columns, gap 7mm, content 184mm | ~56.7mm | ~38-42mm | 24-28 chars | +| 4 columns, gap 5mm, content 184mm | ~42.7mm | ~24-28mm | 16-20 chars | + +When the natural label is longer (e.g. "Falcon launches, lifetime"), trim to the data essential ("Falcon launches"); supporting context belongs in nearby body copy, not in a metric chip. + +### 19. (P2) Multi-column body density imbalance + +**Symptom**: A row of N parallel body columns (timeline cards, conviction cards, feature blurbs) renders with one column wrapping to one extra line while the others wrap evenly. The rhythm reads broken even when each individual cell looks fine. + +**Root cause**: Equal-width columns wrap based on character count, not "ideas". A column with 88 chars next to siblings at 67-81 chars at the same width will spill to one extra line. + +**Fix**: Hold body length within ±10 chars across parallel columns of the same width. Rewrite the longest column tighter rather than padding the shorter ones. + +```text +col 1: 67 chars (2 lines) +col 2: 81 chars (2 lines) +col 3: 88 chars (3 lines) <- breaks rhythm +col 3': 66 chars (2 lines) <- fixed by trimming "general intelligence" -> "AGI" +``` + +### 20. (P0) Demo / template HTML must reference assets inside the kami repo + +**Symptom**: Image slot renders as a missing-image placeholder in the PDF; rendered demo PNG looks empty where a screenshot should be. + +**Root cause**: An `<img src="../../../sibling-project/asset.jpg">` reaches outside the kami repo. The path resolves on the maintainer's laptop where the sibling project happens to be checked out, but breaks for every other user, breaks the packaged skill ZIP, and breaks any CI that doesn't recreate the maintainer's working tree. + +**Fix**: Every image referenced by a demo or template must live under `assets/demos/images/` or `assets/illustrations/`. Copy the source into the kami repo, then reference it with a relative path inside the repo. + +```html +<!-- avoid --> +<img src="../../../kaku/website/public/shots/kaku-light.webp" alt="..."> + +<!-- use --> +<img src="images/kaku-hero.jpg" alt="..."> +``` + +Quick check before building any demo: `rg 'src="(\.\./|/Users/|file://)' assets/demos/` should return zero matches. + +### 21. (P1) Metric row baseline-align breaks when labels wrap + +**Symptom**: A horizontal metric row with `display: flex; align-items: baseline` looks fine when every label is one line, but ugly when one label wraps. The big number "10×" sits at the visual top of its column while the multi-line label flows downward; sibling columns with one-line labels look balanced but the wrapped column reads broken. + +**Root cause**: `align-items: baseline` aligns each metric to the **first line** of its label. When labels have different line counts, the visible heights differ but the numbers all sit at the same baseline (= top), making the row look uneven. + +**Fix**: Stack vertically (`flex-direction: column`). All numbers sit on the same top edge, all labels start at the same y below the numbers, and label wrap only extends each column's bottom, which is invisible on a slide / page. + +```css +/* avoid: breaks visually when one label wraps */ +.metric { display: flex; align-items: baseline; gap: 8pt; } + +/* use: vertical stack, number above label */ +.metric { display: flex; flex-direction: column; gap: 6pt; } +``` + +This is especially important on slides where metrics often sit on a baseline strip at the bottom of the page; even a single multi-line label among 3 columns breaks the rhythm. + +### 22. (P2) Slide bullets: prefer short numerals or `•` over en-dash + +**Symptom**: A bulleted list on a slide with `–` (en-dash, U+2013) markers reads heavy and informal, especially at large slide font sizes (12-14pt body). The en-dash is wide and creates a visible gap between marker and text. + +**Root cause**: En-dash is a typographic primitive, originally meant for ranges ("1995–1997"), not list markers. At slide scale it looks elongated and informal. + +**Fix**: Use either small numerals (`1.`, `2.`, `3.`) or a standard bullet (`•`) in brand color. Numerals are tighter horizontally and signal sequence; bullets are tightest visually and signal "items in any order". + +```css +/* avoid on slides: en-dash reads informal at large font sizes */ +ul.pts li::before { content: "\2013"; } + +/* use: numbered, mono digit, brand color */ +ul.pts { counter-reset: pts; } +ul.pts li { counter-increment: pts; padding-left: 18pt; } +ul.pts li::before { + content: counter(pts) "."; + color: var(--brand); + font-weight: 500; + font-variant-numeric: tabular-nums; +} +``` + +Print docs (long-doc, equity-report) keep the editorial en-dash style; slides switch to numerals. + +--- + +## Part 5 · HTML -> DOCX (pandoc + SVG-to-PNG) + +PDF is the delivery format; DOCX is the collaboration format. For proposal / report scenarios where the recipient needs to edit, comment, or forward to their team, ship a `.docx` alongside the PDF. + +### Why two-step + +`pandoc input.html -o output.docx` looks straightforward, but inline `<svg>` blocks fail Word's OOXML validation. Word treats them as unknown content and either drops them or refuses to open the file. The fix is to bake every SVG to PNG first, swap the `<svg>` blocks for `<img>` tags, then run pandoc. + +### Install + +```bash +# macOS +brew install pandoc librsvg + +# Linux +apt install pandoc librsvg2-bin +``` + +### Workflow + +```bash +# 1. Extract every SVG to its own file, ensuring xmlns is set +python3 - << 'EOF' +import re +src = open('input.html').read() +for i, m in enumerate(re.finditer(r'<svg[^>]*>.*?</svg>', src, re.DOTALL)): + svg = m.group(0) + if 'xmlns=' not in svg[:200]: + svg = svg.replace('<svg ', '<svg xmlns="http://www.w3.org/2000/svg" ', 1) + with open(f'/tmp/diagram-{i}.svg', 'w') as f: + f.write('<?xml version="1.0"?>\n' + svg) +EOF + +# 2. Rasterize each SVG to a wide PNG +for f in /tmp/diagram-*.svg; do + rsvg-convert "$f" -o "${f%.svg}.png" -w 1600 +done + +# 3. Replace each <svg>...</svg> in the HTML with <img src="/tmp/diagram-N.png"> +# (do this with the same regex iteration that produced the indices above) + +# 4. Convert +pandoc input-with-png.html -o output.docx +``` + +### What carries over + +| Carries over | Lost | +|---|---| +| Headings, body, lists, tables | Grid layouts, custom positioning | +| Brand color on headings, `<strong>`, `.hl` | Subtle borders and shadows | +| PNG figures at full width | SVG editability | +| Page breaks via `<hr class="page-break">` | `@page` margins | + +The recipient gets a Word document they can edit text in and replace figures in (drag a new PNG over the existing one). Complex layout differences are expected; the goal is editability, not visual fidelity. + +### When not to ship a DOCX + +Skip this for resume, one-pager, slides, and portfolio. They are visual artifacts; converting them produces a degraded version with no editability gain. Long-doc / proposal / equity-report are the document types where a DOCX companion has a real audience. diff --git a/plugins/kami/skills/kami/references/resume-writing.md b/plugins/kami/skills/kami/references/resume-writing.md new file mode 100644 index 0000000..49f2461 --- /dev/null +++ b/plugins/kami/skills/kami/references/resume-writing.md @@ -0,0 +1,268 @@ +# Resume Writing Guide + +Content strategy for kami resume templates (CN and EN). Covers bullet structure, emphasis density, timeline narrative, and open source entries. For general writing quality bars, see `references/writing.md`. + +--- + +## Three-part bullet: Role / Actions / Impact + +Each project row uses a fixed three-part structure. The word and character targets below are maximums, not targets; shorter is better when the meaning is complete. + +**Chinese (CN)** + +| Row | Label | Target | Hard limit | +|---|---|---|---| +| 1 | 角色 | 50 characters | 60 characters | +| 2 | 动作 | 70 characters | 80 characters | +| 3 | 结果 | 90 characters | 100 characters | + +**English (EN)** + +| Row | Label | Target | Hard limit | +|---|---|---|---| +| 1 | Role | 35 words | 40 words | +| 2 | Actions | 45 words | 55 words | +| 3 | Impact | 55 words | 65 words | + +**What goes in each row** + +- Role: what the project was, why it existed, and your position in it. No verbs yet. +- Actions: the decisions and techniques you applied. One concrete approach per sentence. +- Impact: quantified outcomes only. If no number exists, write the magnitude or scope (team size, user count, traffic tier). + +**Self-check**: read Impact aloud. If it sounds like a process description ("improved the pipeline") rather than a result ("reduced p95 latency from 800ms to 120ms"), rewrite. + +--- + +## Source and truth pass + +Run this pass before rewriting when the user provides more than one source, such as an old resume plus a self-review, annual review, promotion packet, or project notes. + +1. Extract every claim, number, project name, role label, and result from each source. +2. Use the richer source for its best material, not as a replacement for the other source. Annual reviews often carry the strongest metrics; old resumes often carry the clearest project narrative. +3. If two sources conflict on scope, owner, unit, or number, ask the user. Do not silently choose the larger or more impressive claim. +4. Drop numbers whose unit or measurement basis is unclear. A precise, traceable number beats a bigger rounded estimate. + +Use precise before/after values when available: `S1 68% / S2 93%` reads more credible than "about 70%". If a number looks like a typo or a unit mismatch, leave it out or mark it as a question. + +--- + +## Personal information hygiene + +Age and gender are optional resume metadata, not evidence. In CN / KO recruiting contexts, include them only when the user supplied them and the target channel expects them. Place them in the contact line after location; never promote them into metric cards, summary, or project copy. + +Do not invent missing personal details. Do not include expected salary in the resume body. Salary expectations, availability, and negotiation constraints belong in the recruiter message or a separate candidate note unless the user explicitly asks for a form-style resume. + +Use role positioning instead of old-style job intention: `AI / Agent 工程` is useful; `求职意向:前端工程师` usually wastes space. + +--- + +## Ownership and title calibration + +Role words carry different promises. Pick the lowest truthful word that still reflects the contribution. + +| Role word | Boundary | Use when | +|---|---|---| +| 方向负责人 / owner / lead | Defined the direction, owned the architecture or outcome, and was the clear accountable person | You can defend the strategy, tradeoffs, and final result | +| 负责 / drove / led | Independently carried a line or module to production | You owned delivery, but not necessarily the whole direction | +| 牵头 / coordinated | Pulled multiple people or systems together around a bounded goal | The value was orchestration and delivery pressure | +| 模块负责人 / module owner | Owned a specific module, industry slice, or workflow | The larger business line had other owners | +| 共建 / contributed / core contributor | Delivered a distinct piece inside a shared project | Another person or team owns the larger project | +| 参与 / implemented | Solid execution without ownership | The result is real, but scope should stay modest | + +Do not let every project say "主导". A strong resume usually mixes owner-level projects with narrower but concrete contributions. This reads more credible than inflating every line. + +--- + +## Team resume mode + +When optimizing multiple resumes from the same team, run a project ownership pass before polishing any single resume. + +1. List each person's project names, aliases, role labels, and headline metrics. +2. Mark shared projects, near-duplicate names, and reused metrics. +3. Ask the user to confirm the unique owner for any project using owner-level language. +4. For non-owners, downgrade the role word and switch to the person's distinct contribution or metric dimension. + +The goal is not to hide collaboration. The goal is to avoid five resumes all claiming the same project as personally owned. Shared work is credible when each person's scope is different and defensible. + +--- + +## Metrics: horizontal vs vertical layout + +Each project card has a `.metrics` row that shows key numbers. Two layout modes are available. + +**Horizontal (default, `flex-direction: row`)**: numbers side by side, good for 2-3 short metrics. + +**Vertical (`flex-direction: column`)**: metrics stack one per line. Use when: +- Any single metric label exceeds 18 characters +- Three or more metrics are present and labels are of unequal length +- The project card already has long Role / Actions / Impact rows that crowd the line + +Use | Avoid +---|--- +Horizontal for two short metrics: `38% reduce latency · 12k daily users` | Horizontal for long labels: `reduced p95 latency from 820ms to 110ms · 12k active users` +Vertical when three or more metrics exist | Vertical for exactly two short metrics (wastes space) + +To switch to vertical: +```css +.metrics { flex-direction: column; gap: 0.8mm; } +``` + +Do not add `flex-wrap: wrap`; it causes uneven line splits that look like layout errors. + +--- + +## Key-number highlight density + +`.hl` applies brand-blue color to mark the single most important figure or phrase in a line. It is not a general emphasis tool. + +**Use:** +- One `.hl` per project row, maximum two across all three rows of one project +- Numbers with units: `<span class="hl">120ms</span>`, `<span class="hl">38%</span>` +- A distinctive noun when no number exists: `<span class="hl">production-first rollout</span>` + +**Avoid:** +- Highlighting adjectives or qualifiers: "significantly", "dramatically", "key" +- Two `.hl` spans on the same row +- Highlighting an entire clause + +Healthy ratio: one emphasis per 80 to 150 characters of body text. + +--- + +## Timeline: three-step evolution arc + +The `.timeline` section shows career judgment, not a job chronology. Three steps, each one sentence. + +**Structuring the arc** + +Each step should answer: what shifted in how you thought or operated, and why does it matter for the reader? + +Use | Avoid +---|--- +"Shifted from feature delivery to owning the model quality loop end-to-end" | "Joined the AI team as senior engineer" +"Moved budget approval to the team level, cutting decision lag from weeks to hours" | "Led a team of 8 engineers" +"Started shipping open-source tools to validate ideas before internal roadmap commits" | "Side projects and open source work" + +**Three-step pattern** + +1. Foundation: the constraint or context that shaped your thinking in the early phase +2. Inflection: the moment or decision that changed what you were optimizing for +3. Present: the operating mode you've settled into and what it produces + +Do not list events. List shifts in judgment and scope. + +--- + +## Open source entries + +Each `.os-item` has three parts: name, one-line description, and star count. No README summaries, no feature lists. + +**Description formula**: `[language or stack] · [what it does] · [platform or audience]` + +Use | Avoid +---|--- +"Rust + Tauri · turns any URL into a lightweight desktop app · macOS / Windows / Linux" | "A lightweight desktop app builder based on Tauri that supports packaging web apps as native applications across multiple platforms" +"Swift · native Markdown notes · macOS AppKit" | "A beautiful and fast Markdown note-taking app for macOS with syntax highlighting and live preview" + +**Star count**: use the `.big` class on the top two entries (your flagship projects). Plain `.os-star` for the rest. + +**`.os-highlight`**: one short paragraph, one anecdote. Focus on a specific external validation moment: a notable person who shared it, a community that formed around it, a moment when it reached an audience you didn't target. Not a feature summary. + +--- + +## Density tuning by project count + +When page 1 carries 3-6 projects, adjust these three CSS properties. Do not change line-heights on page 2 or touch header typography. + +| Projects on page 1 | `body font-size` | `.proj-text line-height` | `.section-title margin-top` | +|---|---|---|---| +| 3 | 9.4pt | 1.42 | 6mm | +| 4 (default) | 9.2pt | 1.40 | 5mm | +| 5 (dense) | 9pt | 1.38 (CN) / 1.40 (EN) | 3.5mm | +| 6 | 9pt | 1.36 (CN) / 1.38 (EN) | 3mm | + +For 5-project layouts, add `class="resume--dense"` to `<body>` instead of manually adjusting CSS. The `resume--dense` class applies the row-5 values above. + +For 6 projects there is no pre-built variant; apply the row-6 values directly and run `--verify` after. Six projects on one page is a strong signal the project list needs editing, not just tightening. + +--- + +## Two-page balance + +Resume output has a stricter contract than general multi-page documents: + +- Exactly 2 pages. +- Each page fill should land around 83-95%. +- The two pages should differ by less than 12 percentage points. + +Verify filled resume PDFs with: + +```bash +python3 scripts/build.py --check-resume-balance path/to/resume.pdf +``` + +If page 2 is too empty, fix content before touching typography: + +1. Split one mixed project only when it honestly contains two different systems or result dimensions. +2. Add one real skills row only when the source material already supports it. +3. Cut filler and repeated metrics before shrinking fonts. + +Do not fill space by padding, duplicating a number, or splitting one sentence into two lines. A sparse second page is acceptable only when the source material is genuinely thin and the user prefers restraint over padding. + +--- + +## Visual rhythm for resume templates + +Resume uses a quieter divider pattern than long-form templates because it has more repeated headings in two pages. + +- Header and section titles use a single warm bottom rule, not a brand-color left bar. +- Project rows have no top or bottom border; separate them with padding and project-name weight. +- Top metrics stack the value over the label inside each of the four columns. Metric labels stay on one line; if a label wraps, shorten it before changing the layout. +- In English resumes, include the unit inside the value (`8 yrs`, `120ms`, `$280K`) rather than as a separate placeholder. +- Highlight boxes such as open-source validation use tint and radius only, without a vertical brand rail. + +This keeps the page editorial and avoids double rules when a project sits directly below a section title or lands at the top of page 2. + +--- + +## Page 2 rhythm + +Page 2 has more space than page 1. Do not compress it to match page 1 density. + +- OS intro: one sentence positioning + one sentence with the aggregate GitHub numbers. No more. +- Convictions: each card is one judgment call + one piece of downstream evidence. Not a project summary. +- Skills: each row names one capability area and demonstrates it with one concrete example. No abstract claims. +- Education: one line. If there is a judgment-flavored note (declined grad school, switched majors), include it. That note signals self-direction better than GPA. + +**Font size and spacing reference** (5 projects on page 1 + complete page 2): + +| Property | Value | +|---|---| +| `body font-size` | 9pt (dense) / 9.2pt (default) | +| `.os-intro` line-height | 1.55 | +| `.conv-body` line-height | 1.40 | +| Page 2 top buffer | 4mm minimum between header and first section | + +This configuration fits 2 pages when TsangerJinKai02 is available. Font fallback to Source Han Serif adds roughly 0.3pt per line; run `--verify` after any font environment change. + +Do not scale page 2 font below 9pt to save space. If page 2 still overflows, cut one Convictions card or reduce Skills to 2 rows. + +--- + +## AI engineering resumes + +AI-facing resumes should show that the candidate treats AI as an engineered system, not a magic tool or a list of model names. + +Use: +- Mechanisms that turn generation into delivery: evaluation gates, feedback loops, harnesses, regression checks, context boundaries, rollback paths. +- Clear human / AI division of labor: humans define scope, tradeoffs, and risk; AI may implement, repair, classify, or generate under constraints. +- Metrics tied to the mechanism: pass rate, failure rate, iteration cycle, case coverage, retrieval precision, hallucination reduction, throughput, review load. +- Evolution in operating model: prompt craft -> context engineering -> tool orchestration -> automated validation. + +Avoid: +- Listing model or tool names as a skill by itself. +- Writing "AI improved efficiency by 50%" without saying what baseline, sample, or workflow was measured. +- Packaging an API demo as an agent platform. +- Claiming a team or platform result as an individual result. +- Using filler such as `赋能`, `抓手`, `组合拳`, or `规模化` without a concrete mechanism and metric. diff --git a/plugins/kami/skills/kami/references/tokens.json b/plugins/kami/skills/kami/references/tokens.json new file mode 100644 index 0000000..38c0c68 --- /dev/null +++ b/plugins/kami/skills/kami/references/tokens.json @@ -0,0 +1,16 @@ +{ + "--parchment": "#f5f4ed", + "--ivory": "#faf9f5", + "--border": "#e8e6dc", + "--border-soft": "#e5e3d8", + "--brand": "#1B365D", + "--brand-tint": "#EEF2F7", + "--tag-bg": "#E4ECF5", + "--near-black": "#141413", + "--dark-warm": "#3d3d3a", + "--charcoal": "#4d4c48", + "--olive": "#504e49", + "--stone": "#6b6a64", + "--breaking-bg": "#f0e0d8", + "--breaking-fg": "#8b4513" +} diff --git a/plugins/kami/skills/kami/references/writing.md b/plugins/kami/skills/kami/references/writing.md new file mode 100644 index 0000000..1174ae5 --- /dev/null +++ b/plugins/kami/skills/kami/references/writing.md @@ -0,0 +1,525 @@ +# Content Strategy + +How to write, not how to lay out. Good typography with bad content is just "polished mediocrity". This document covers the writing principles for both Chinese and English output. Shared rules come first; language-specific details are called out where they matter. + +--- + +## Core principles (all documents) + +### 1. Data over adjectives + +- Avoid: "Delivered significant business growth" +- Use: write the specific numbers and deltas + +Every sentence should survive the follow-up question "how much, specifically?". If you can't answer, don't write it. + +### 2. Judgment over execution + +Junior writes "what they did". Mid writes "how they did it". **Senior writes "why they made that call, and what they predicted correctly"**. + +- Avoid: "Led the platform build-out" +- Use: write what judgment you made and how it was proven right + +### 3. Distinctive phrasing over industry clichés + +- Avoid: "Embrace the AI era, pioneer digital transformation paradigms" +- Use: say it in your own words, skip the industry vocabulary + +**Distinctive phrasing is memorable**. A line you invented beats a line borrowed from an earnings call. It sounds like a person thinking, not a deck regurgitating. + +### 4. Honest boundaries + +- If you didn't do it, don't claim it +- If you don't know the exact number, don't invent one. Write a vague but honest magnitude +- Attribute collaborators + +### 5. Sources before phrasing + +For companies, products, people, launch dates, versions, funding, financials, market data, or technical specs, verify the source before writing. Priority: user-provided source material > official pages / docs / press releases > filings / app stores / repo releases > credible media. + +- Do not write "latest", "new", version numbers, or market figures before checking them +- If sources conflict, list the conflict and ask the user instead of choosing one +- If only the magnitude is known, write the magnitude instead of false precision + +### 6. Materials serve recognition + +Branded documents should first make the subject recognizable, then use decoration and atmosphere with restraint. + +- Company / product / project docs should confirm logo, product image, UI screenshot, and brand color before layout +- If a key material is missing, mark the gap or ask the user. Do not fill the page with unrelated imagery +- Physical products prefer official product images; digital products prefer real UI screenshots +- If brand color is unknown, keep kami ink-blue rather than inventing a new color +- **Third-party figures**: when redrawing a paper figure, patent illustration, or official architecture diagram for visual consistency, mark the redraw as `Schematic redrawn` /「示意重绘」in the caption. Do not style a redrawn version to look like the original screenshot. If the figure carries primary evidentiary value (patent, official spec), embed the original with attribution rather than redrawing it + +### 7. Term annotation half-life + +术语首次出现时注解。注解在 8-10 页或 10 张 slide 后过期。超出半衰期再次使用时,用更短的提示重标,不要假设读者还记得。Cap、卡片副标题、章节摘要这类快速阅读位置尤其严格。 + +- 首次: "LTV (Lifetime Value, 用户在流失前贡献的总收入)" +- 超过半衰期后重现: "LTV (用户终身价值)" +- 半衰期内不要重标,读起来像在解释已知概念 +- 适用于 slides、long-doc、equity report 等超过 8 页的文档。Resume 和 one-pager 太短,不触发 + +### 8. English term density in CJK text + +中日文正文里,单句未注解英文术语 ≤ 1 个。超过就拆句或加 inline 注解。注解优先用动作词 (warmup → 升起来, rollout → 跑一遍生成, credit assignment → 把功劳分到具体哪一步),不用概念词 (预热, 轨迹生成, 信用分配)。动作词描述发生了什么,概念词只是换个外壳。 + +--- + +## Per-document strategies + +### One-Pager + +**Single purpose**: the reader grasps the point in 30 seconds. + +**Structure**: +1. **Headline** (serif display) + one-line subtitle (sans body) +2. **Metrics** - 3-4 cards, numbers first +3. **Core argument** (1-2 paragraphs) +4. **Key evidence / roadmap** (3-5 short bullets) +5. **Next step / contact** (footer) + +**Rules**: +- Length target: English 200-350 words; Chinese 400-600 characters +- All section headlines should work as a standalone outline - reading just the headlines should deliver the gist +- Data must fill 30%+ of the body +- Company / product one-pagers must confirm logo, core screenshot or product image, and source for key metrics +- No opening ceremony ("In recent years, as technology has rapidly evolved...") + +### Long Document + +**Structure**: +1. **Cover** - big title + subtitle + author + date +2. **Contents** (auto-generated or hand-written TOC) +3. **Executive Summary** (≤ 1 page + 3-5 takeaways) +4. **Body** - chapters that each stand alone as an essay +5. **Appendix / references** (if applicable) + +**Rules**: +- Every chapter opens with a "claim paragraph" (2-3 sentences summarizing the argument) +- After long paragraphs (>5 lines), intersperse callouts / quotes / figures to relieve eye fatigue +- Highlight key data / conclusions with `<span class="hl">` +- Chapters with external facts must preserve source cues so readers can distinguish fact, judgment, and inference +- Use "chapter breaks" (blank page + chapter number) between major sections + +### Letter + +**Structure**: +1. Letterhead (sender info, top right or centered) +2. Date (right-aligned) +3. Recipient salutation (left-aligned) +4. Body (3-5 paragraphs) +5. Sign-off ("Sincerely," / "Best regards,") +6. Signature (serif 500) +7. Enclosures (if any) + +**Rules**: +- Minimal - no decorative elements +- Body prefers serif (editorial feel) +- Slightly larger type (11-12pt body) - this will be read, not scanned +- Paragraph spacing ≥ 10pt + +**Common use cases**: +- Resignation / notice +- Recommendation letter +- Formal collaboration proposal +- Personal statement + +**Language notes**: +- Chinese sign-offs can use "此致 / 敬礼", "顺颂商祺", or a context-appropriate formal closing +- English sign-offs should stay simple: "Best regards," / "Sincerely," / "Warm regards," + +### Portfolio + +**Structure**: +1. **Cover** (name + one-line positioning + contact) +2. **About** (half-page introduction) +3. **Per-project 1-2 pages**: + - Project title + type tag + date range + - One-line description + - 2-3 hero images (if applicable) + - Role + challenge + outcome +4. **Selected works list** (additional projects as a short list) +5. **Contact** (return to contact details) + +**Rules**: +- Visuals first, text supports +- Every project's outcome must be quantifiable +- Final product screenshots / real photos > design mockups > code screenshots +- If project images are missing, mark the gap. Do not fill the layout with unrelated imagery +- Don't list every tech stack - a mono tag row is enough + +### Proposal (long-doc variant) + +A proposal is a long-doc whose body argues for a specific commercial engagement. Three voice rules are unique to this variant: + +#### 1. Work-volume frame vs attention frame + +A pricing page can be written two ways. The work-volume frame lists deliverables, hours, or session counts; readers price-anchor against market hourly rates. The attention frame describes a senior advisor's judgment allocated across a few directions; readers price-anchor against the value of the directions. Pick one frame and stay consistent. + +| Dimension | Work-volume frame | Attention frame | +|---|---|---| +| What is sold | Execution of N items | Allocation of judgment | +| Pricing basis | Hours / deliverables | Total engagement | +| Workload density | Fixed commitment | Flexes with the buyer's cadence | +| Reader posture | Procurement | Hiring an advisor | +| Signals | "I can do these tasks" | "I bet on these directions" | +| Fits | Outsourced delivery | Senior advisory work | + +The work-volume frame backfires for advisor pricing: the more itemized the breakdown, the more the reader divides total fee by hours and decides "this is a vendor, can it cut corners?" Avoid these patterns when writing in the attention frame: + +- Rigid monthly volume: `每月 N 份 / 节 / 篇`. Replace with "by milestone" or "as the cadence requires". +- Exhaustive coverage: `每个发布节点全程参与`. Replace with "deep involvement at major milestones". +- Specific session counts: `20 节课程`. Replace with "depth of content" anchors and let scheduling adjust. +- "Full-X" totalizers: `全方位 / 全链路 / 全程`. Replace with "key X" or "core X". + +#### 2. With-price vs without-price modes + +Whether to print the number depends on the reader. Use these defaults and switch by audience: + +| Audience | Mode | Reasoning | +|---|---|---| +| Operating founder, direct buyer | **With price** | Direct decision-makers want the number to evaluate fit | +| Intermediary, business contact | **Without price** | Leave room for the intermediary's own commercial layer | +| Investor, board reader | **Without price** | Strategy first, money second; a number breaks the register | + +The without-price variant drops the large-figure hero and keeps the value anchors. Rename the chapter from "合作方案与投入" (Plan & investment) to "合作方向与价值" (Directions & value) so the title matches the content. + +#### 3. Kickoff-period emphasis pattern + +Twelve-month timelines fail when every month is described at equal weight: the reader cannot tell what is urgent. Surface the first 60 days as a separate beat ahead of the timeline table: + +- One paragraph on time scarcity ("this window closes; recovering it later costs several times more"). +- One paragraph per month: bold the date, name the module, list 2-3 concrete actions, state the outcome. +- Then the full month-by-month table, which reads as detail rather than the story. + +The pattern is "story before grid": the reader leaves the timeline section knowing one thing (the kickoff is dense), not twelve things (every month). + +### Resume + +The most constrained document type in kami. + +**Hard constraints**: +- Strictly 2 A4 pages +- Every project follows three-part: Role / Actions / Impact +- 5 core skills, each with at least one brand-color emphasis +- Team size, tech stack, narrative voice must stay consistent throughout + +**Key sections**: +- Header + 4 metric cards +- Summary (~50 English words or ~80 Chinese characters) +- Timeline (3 steps - long-range evolution signal) +- 3-5 core projects +- Public work / impact (optional) +- 5 core skills +- Education + +**Metric card selection rule**: +- 1 card on **time** (years, consistency) +- 1 card on **scale** (team, users, projects, or other quantifiable scope) +- 2 cards on **results** (quantifiable external proof) + +--- + +## Quality bars by document type + +Structure is necessary but not sufficient. These bars define what separates compelling content from template-filling. + +### Resume + +**Impact formula**: Action + Scope + Measurable Result + Business Outcome. Every bullet must answer "what did I do, at what scale, with what result, and why did it matter?" + +| Avoid | Use | +|---|---| +| "Worked on backend services" | "Redesigned order pipeline serving 2M daily txns, cut p99 latency from 800ms to 120ms, saved $340K/yr in infra costs" | +| "Led a team to deliver features" | "Led 5-engineer squad that shipped real-time collaboration (3-month timeline), adopted by 40% of enterprise accounts within one quarter" | +| "Improved performance" | "Reduced cold-start time 62% across 14 Lambda functions by replacing runtime init with pre-baked layers, cutting median API response from 1.2s to 0.45s" | + +**Rules**: +1. Start every bullet with a strong past-tense verb (designed, led, reduced, migrated). Never "Responsible for" or "Helped with" +2. Every bullet needs at least one number. If no hard metric exists, use scope (team size, user count, codebase size) +3. Connect technical work to business outcomes: revenue, cost, reliability, user retention, time-to-market +4. Include before/after pairs when possible: "from X to Y" is more credible than "improved by Z%" +5. Use precise numbers over round ones: "$280K" reads as measured, "$300K" reads as estimated +6. Distinguish ownership: "owned" vs "contributed to" vs "coordinated". Inflating scope is the fastest way to lose credibility in an interview + +**Senior vs junior**: junior resumes show execution ("built X"). Senior resumes show judgment ("evaluated 3 approaches, chose Y because of tradeoff Z") and multiplier effect ("mentored 4 engineers, 2 promoted within 12 months") + +### Portfolio + +**Core rule**: open every case study with the problem and its stakes, not with your role or the project name. + +**Density bar**: each project page reads as a complete case study. At target font size, a body page that renders under half-full is a draft defect, not a design choice. Merge upward into the previous project or downward into the next; do not pad with filler prose. See SKILL.md Step 4.1 for the items-per-page contract. + +| Avoid | Use | +|---|---| +| "I redesigned the dashboard" | "Enterprise users abandoned the analytics dashboard at 73% rate within the first session. I led the redesign that cut abandonment to 31%." | +| "The client was happy" | "Task completion time dropped from 4.2 min to 1.8 min. NPS increased from 22 to 51 over 3 months." | + +**Rules**: +1. Show 2-3 decision points where you chose between alternatives. Explain the tradeoff, not just the winner +2. Three-layer outcomes: quantitative metric (conversion rate +80%) + qualitative evidence (user quote) + business context ($1.2M additional annual revenue) +3. State your exact role and scope: "I designed" vs "I led" vs "I contributed to" are very different signals +4. 3-5 deep case studies beats 12 shallow ones. Depth is credibility +5. Always close the loop: every problem introduced must have a measured resolution +6. Prefer final product screenshots over mockups. If product images are missing, mark the gap rather than filling with unrelated imagery + +### Slides + +**Core rule**: every slide title should be a full declarative sentence (an assertion), not a topic label. The body provides one piece of evidence supporting the assertion. + +**Density bar**: each body slide carries one assertion + 3-5 supporting items (or 1 chart + 2-3 callouts). Slides with fewer than 3 items and no chart must merge into an adjacent slide. Pinned `.co` callouts at bottom are intentional; bare trailing whitespace on a slide is a draft defect. See SKILL.md Step 4.1. + +| Avoid | Use | +|---|---| +| Title: "Q3 Performance" | Title: "Q3 revenue grew 23% because enterprise deals closed 2x faster" | +| 7 bullet fragments per slide | One chart proving the assertion | +| "Key Takeaways" slide with 8 points | One clear ask or recommendation | + +**Rules**: +1. 20-40 words per slide maximum. If a slide has more than 40 words, split it or convert text to a visual +2. 5 items per list maximum (working memory capacity) +3. Three-act structure: Setup (slides 1-4, establish stakes) -> Evidence (slides 5-12, build the case) -> Resolution (slides 13-16, deliver the payoff) +4. Reading just the slide titles in sequence should tell the full argument +5. Include a "so what" moment every 3-4 slides to re-anchor the audience +6. End with one clear ask, not a bullet list of "key points" + +**Eyebrow vs title non-duplication**: the eyebrow is a stable, cross-slide section label ("Growth / Q3 Results"). The title is a page-unique declarative claim ("Revenue grew 23% because enterprise deals closed 2x faster"). They must never say the same thing in different words. If removing the eyebrow would make the title ambiguous, the title is too weak. If reading the title makes the eyebrow redundant, the eyebrow is a topic label masquerading as context. + +**Deck rhythm (>=12 slides)**: before writing any slide, sketch a layout-type sequence. Rules: every 4-6 slides must include a `chapter_slide` (ink-blue full-bleed divider); never run more than 5 consecutive `content_slides` without a divider; the deck must include at least one `quote_slide` or `metrics_slide` to vary density. Monotony is a structure failure, not a content one. + +**Term consistency self-check**: after drafting, list every domain term that appears 3 or more times (product names, feature names, roles, metrics). Confirm there is exactly one spelling and capitalization for each. Inconsistent casing ("LLM" vs "llm" vs "large language model") signals an unreviewed draft. + +**Caption quality bar**: every cap must answer "why does this slide matter": give a tradeoff, an applicability boundary, a next step, or the insight the diagram alone cannot say. Two failure modes both waste the cap's attention slot: restating the slide title in different words (anti-pattern #29), or restating the flow diagram in prose (anti-pattern #26). If removing the cap would make the slide weaker, it is doing its job; if removing it changes nothing, rewrite it. + +**Term annotation half-life**: decks 超过 10 张时,跨越 10-slide 窗口再出现的术语需重标。见 core principle #7。 + +### Equity Report + +**Core rule**: lead with the variant perception (what you see that the market doesn't) and tie every thesis driver to a measurable financial impact. + +**Density bar**: a body page with only a 2-row table and a sentence is too thin. Each page should carry one section + one table/chart + supporting prose. Combine sections rather than leaving a page half-empty. See SKILL.md Step 4.1. + +| Avoid | Use | +|---|---| +| "Strong management team" | "Management delivered 23% revenue CAGR over 5 years while keeping debt-to-equity below 0.4" | +| "Massive opportunity" | "We estimate 25% upside to $X based on DCF with 12% WACC and 3% terminal growth" | +| Vague "risks include competition" | "BYD's 35% unit cost advantage in the $20-30K segment threatens 15% of addressable volume by 2027" | + +**Rules**: +1. Investment thesis on page 1, above the fold. Rating + price target (if applicable) + 3-5 bullet thesis drivers +2. Every claim backed by a number or a source. No unquantified superlatives +3. At least two valuation methods with sensitivity ranges. Single-method valuation is amateur +4. Catalysts must have dates and expected magnitude: "Robotaxi launch in Dallas, June 2025, adding estimated $X to revenue run-rate by Q4" +5. Competitive positioning with market share numbers, not narrative: "23% share of the $45B market, up from 18% in 2022" +6. Risk factors quantified and connected to the financial model, not generic disclaimers +7. Professional tone: "we estimate" / "our base case" / "we see upside to". Never "this stock will moon" or "buy the dip" +8. Acknowledge counter-arguments before dismissing them. One-sided analysis signals bias, not conviction +9. Separate GAAP from non-GAAP clearly. Flag one-time items (warranty reserves, tax benefits, restructuring charges) + +### Long Document + +**Core rule**: each chapter's claim paragraph must survive the "so what?" test. If the reader asks "why should I care?", the first paragraph must have the answer. + +**Density bar**: each body page carries 1 chapter heading + 2-4 paragraphs + at most 1 figure. A chapter that fits in under 40% of a page must merge into the next chapter rather than claiming its own page. Trailing whitespace at the bottom of a body page is a draft defect. See SKILL.md Step 4.1. + +After converting from Markdown, remove or convert thematic breaks, bold markers, +and inline-code backticks before delivery, then run `python3 scripts/build.py +--check-markdown path/to/filled.pdf` on the rendered PDF. + +**Rules**: +1. Evidence density: at least one data point per paragraph. A paragraph with zero numbers is an opinion paragraph and should be rare +2. Callout or figure after every 3-4 paragraphs of dense text. Long unbroken prose causes eye fatigue in print +3. Counter-arguments addressed before they become reader objections. If you can predict the pushback, address it proactively +4. Source cues preserved inline: "(Gartner, 2025)" or "according to the company's 10-K" so readers can distinguish fact from inference +5. Each chapter should stand alone as a mini-essay with its own arc: claim -> evidence -> conclusion + +### One-Pager + +**Core rule**: the reader grasps the point in 30 seconds. Every element that doesn't serve 30-second comprehension is bloat. + +**Rules**: +1. Metrics are the headline, not supporting evidence. If your 4 metric cards don't tell the story, the metrics are wrong +2. The lead paragraph must contain the single sharpest claim, not context-setting +3. Bullet points should be evidence, not restated arguments. Each bullet: fact + number + "so what" +4. Footer is for contact and classification, not for squeezing in one more argument + +### Letter + +**Core rule**: first paragraph states purpose in one sentence. Last paragraph states the specific ask or next step. Everything in between is evidence. + +**Rules**: +1. One point per middle paragraph, each with its own evidence +2. Tone calibration per use case: resignation (grateful + clear), recommendation (specific + enthusiastic), proposal (value-first + concrete), personal statement (authentic + structured) +3. Sign-off matches formality: "Sincerely" for formal, "Best regards" for professional-warm, "Warm regards" for personal +4. Under no circumstances exceed one page. If you need two pages, it's a memo or a proposal, not a letter + +### Changelog + +**Core rule**: one sentence per change, verb-led, user-facing language. If the user cannot understand the change from the sentence alone, rewrite it. + +**Density bar**: each version block carries 4-8 entries. A version with fewer than 4 entries should sit on the same page as the prior version rather than triggering a near-empty page. See SKILL.md Step 4.1. + +| Avoid | Use | +|---|---| +| "Refactored internal state management module" | "Fix crash when switching tabs rapidly on iPad" | +| "Updated dependencies" | "Upgrade OpenSSL to 3.2.1 (patches CVE-2026-1234)" | + +**Rules**: +1. Breaking changes always first, with migration path ("Replace `config.old` with `config.new`; run `migrate.sh` to convert") +2. 5-8 items per section. If more, this is probably 2 releases +3. Group by user impact (Breaking / Features / Fixes), not by component or file +4. No internal jargon. "Fix memory leak in image decoder" is clear. "Fix retain cycle in UIImageDecoderBridge" is not + +--- + +## Diagrams and infographics + +Words inside a diagram or infographic (title, eyebrow, node label, summary line) follow tighter rules than body prose. Readers process them in seconds, so rhetorical flourish reads as noise. This applies whether the figure is a Kami SVG embedded in a long-doc or an external image rendered elsewhere. + +### Avoid colloquial slogan-words + +| Avoid | Why it fails | +|---|---| +| 白搭 / 立住 / 才顺 / 回炉 / 闸 | Slang verbs; rewrite as a literal claim | +| 必看 / 一图看懂 / 彻底搞懂 | Bait phrasing; state the figure's actual content | +| 爆款 / 神器 | Marketing tone in an engineering figure | +| 飞轮 / 闭环 (unless audience is fluent) | Use 数据循环 / 持续改进 / 四类入口 | + +### Avoid product-specific judgments that date fast + +Pinning a category to a current product name ages the figure within a quarter, because tools evolve faster than diagrams ship. Frame the paradigm and let the reader map products themselves: prefer 「上一代工具范式 / 新一代执行范式」over 「Cursor 是副驾驶 / Claude Code 是自动驾驶」. + +### Slogan to neutral rewrites + +| Before (slogan) | After (neutral) | +|---|---| +| 没对完,不算完成 | 交付前,过三遍 | +| 任一不过则回炉 | 任一步不通过,回到修改 | +| 交付前最后一道闸 | 交付前最后检查 | +| 订阅前先把这些习惯立住 | 订阅前的基础检查 | + +The principle: a diagram caption is engineering documentation, not marketing copy. Restraint reads as competence; bravado reads as filler. + +--- + +## Coupling rules (layout × content) + +### Emphasis rhythm + +Across any document: +- ≤ 2 emphasized items per line +- Emphasis must be a **quantifiable number** or a **distinctive phrase** +- Do not emphasize adjectives + +### Number formatting + +| Use | Avoid | +|---|---| +| 5,000+ | 5000+ (missing thousands separator) | +| 5,000+ | 5,000+ (full-width comma in a metric) | +| 90% | 90 % (space before percent) | +| ~$10M | $9,876,543 (false precision reads fake) | +| 2026.04 | 2026年4月 / April 2026 (when horizontal space is tight) | +| -> | → | + +### Language-specific punctuation + +Chinese documents: +- Prefer `「」` for quoted prose, not straight double quotes +- Keep numbers, commas, percent signs, and dates half-width in metric-heavy areas +- Add spaces between Chinese text and Latin product names when it improves readability + +English documents: +- Use straight quotes in source text unless the document already has a typographic quote convention +- Prefer compact date forms (`2026.04`) in dense layouts and natural dates (`April 2026`) in prose + +### Emphasis is not bold + +Use `color: var(--brand)` alone - don't also add `font-weight: bold`. Bold breaks the single-weight design language. + +--- + +## Pre-ship checklist + +Run through before every draft: + +- [ ] Any jargon like "leverage / unlock / embrace / pioneer"? Cut. +- [ ] Any Chinese filler like "拥抱 / 打造 / 赋能 / 重构"? Rewrite in plain language. +- [ ] Does every paragraph's first sentence stand alone? If not, that paragraph has no claim. +- [ ] Are all numbers verifiable? If asked "where did this come from", can you answer? +- [ ] Are current facts, versions, launch dates, funding, financials, and specs backed by reliable sources? +- [ ] Does every branded document have logo, product image, or UI screenshot coverage? Are missing materials clearly marked? +- [ ] At least one **distinctive phrase** (not industry boilerplate)? +- [ ] Every emphasized (brand-colored) span is either a number or a distinctive phrase? If not, remove the emphasis. +- [ ] Paragraph lengths even? No paragraph over 5 lines? +- [ ] Number format consistent (commas, percent signs, arrows)? +- [ ] Chinese punctuation and Chinese / Latin spacing consistent where applicable? +- [ ] Page count within the document's constraint (resume 2, one-pager 1, letter 1)? +- [ ] Any AI writing cliches? CN: 本质是 / 这意味着 / 值得注意的是 / 不仅...而且 / 破折号堆砌。EN: em dashes, "It's worth noting", "This means that". See anti-patterns #27. +- [ ] Multi-page docs (>8 pages / >10 slides): domain terms re-annotated beyond the half-life window? See principle #7. + +--- + +## Landing Page + +A landing page is not a brochure. It is a conversion surface. Every element either builds trust or wastes attention. + +### Global: screen-only italic exception + +Invariant #10 bans italic in print templates. Landing pages are screen-only, so gallery captions, feature subtitles, and footer ethos may use the limited italic treatment defined in `references/design.md`. Do not add new italic uses beyond those roles. + +### Hero rules + +- **Positioning comes before feature count.** Name the real product category in the first viewport. If the product has grown beyond its old anchor, rewrite the category instead of adding more feature bullets under the old one. +- **Tagline is one sentence, not a paragraph.** If it needs a comma, it is too long. The user decides in 3 seconds whether to scroll. +- **Tokens (key facts) are scannable proof.** Price, platform, refund policy, compatibility. No adjectives. `$9 lifetime` beats `Affordable pricing for everyone`. +- **CTA pair: secondary (try) + primary (buy).** Ghost button for low-commitment action, filled button for revenue action. Never three buttons. + +### Gallery rules + +- **Show, don't describe.** Real screenshots replace feature paragraphs. Each panel is one shipped tool, one workflow, or one state users can actually reach. +- **Technical products can show the workflow itself.** A terminal transcript, command draft, or error recovery panel is a product screenshot when it shows the actual review/confirm boundary. +- **Poetic captions, not marketing copy.** The line under each screenshot should evoke, not explain. `Rainwater clears the soil` over `Efficiently clean your system caches`. +- **3-6 panels maximum.** More than 6 and the auto-rotate becomes noise. Users remember 4. + +### Features list rules + +- **Name is the tool, subtitle is the metaphor.** Feature name in brand color, subtitle in small muted text. +- **Description answers "so what?"** Not what it does, but why the user should care. One paragraph, 2-3 sentences. + +### Principles rules + +- **Title is the commitment, description is the proof.** "Nothing leaves your Mac" is the title. How you enforce it is the description. +- **4-6 principles.** More than 6 dilutes the message. If you have 8, two are redundant. + +### Pricing rules + +- **Price is the headline.** 112px, not buried in a paragraph. Users look for the number. +- **Compare honestly.** Name the competitors, show their subscription price with `<s>`, then your one-time price. No vague "other tools charge more". +- **Terms at the bottom.** Payment methods, refund policy, device limit. Factual, not promotional. + +### FAQ rules + +- **First question is the positioning question.** Before "is it free" or "how do I install", users want to know what category this product is in. Lead with the comparison: "How is this different from {{CLI_NAME}} / {{MAC_APP_NAME}} / {{COMMON_TOOL}}?" or "Who is this not for?". This single question removes the misframing that AI assistants and first-time visitors do most often. +- **Compare against the tool users already know.** Use named alternatives from the source material, not generic "other apps" language. +- **Lead with the question the user is actually thinking.** "Is it free?" before "What's the refund policy?". +- **Answers in 1-2 sentences.** A FAQ answer longer than 3 sentences belongs in the docs page, not here. +- **6-8 questions maximum.** Cover: positioning, free tier, comparison, permissions/privacy, data collection, purchase flow, licensing. +- **llms-full.txt mirrors the FAQ.** Whatever you answer here, restate in `landing-page-llms-full.txt.example` so AI assistants summarizing the product give the same answer the visitor reads on the page. Divergence between FAQ and llms-full.txt is the most common AI-misrecommendation source. + +### Footer rules + +- **Brand mark + closing ethos.** The footer is the last impression. A poetic closing line beats a copyright notice. +- **Links are navigation, not decoration.** Only link to pages that exist. Dead links destroy trust faster than missing links. + +--- + +## Writing references + +- **Paul Graham's essays** - short, direct, judgmental. The gold standard for essayistic writing. +- **Stripe Press books** - print-grade typography paired with deep content. Where to learn the craft of the single sentence. +- **Minto's Pyramid Principle** - conclusion first, evidence below. The shape of every one-pager and exec summary. +- **Ben Horowitz's blog** - how to write technical and business judgment in prose ordinary people can read. The template for long-doc voice. + +None are required, but reading any one of them will move the dial on both your writing and your judgment. diff --git a/plugins/kami/skills/kami/scripts/build.py b/plugins/kami/skills/kami/scripts/build.py new file mode 100644 index 0000000..1851db9 --- /dev/null +++ b/plugins/kami/skills/kami/scripts/build.py @@ -0,0 +1,395 @@ +#!/usr/bin/env python3 +"""kami build & check + +Thin CLI shell. Implementation lives in: + - lint.py (scan_file, check_all, check_cross_template_consistency) + - tokens.py (sync_check) + - site_facts.py (check_site_facts) + - verify.py (verify_target, verify_all, show_fonts, font checks) + - checks.py (check_placeholders, check_markdown_residue, check_orphans, check_density, check_resume_balance, check_rhythm) + +Usage: + python3 scripts/build.py # build all examples (HTML + diagrams + PPTX) + python3 scripts/build.py resume # build one template, print pages + fonts + python3 scripts/build.py landing-page # check one browser-only static template + python3 scripts/build.py --check # lint + token/theme + public-site fact checks + python3 scripts/build.py --check -v # verbose (show each scanned file) + python3 scripts/build.py --sync # check CSS token drift across templates + python3 scripts/build.py --verify # build all + page count + font checks + python3 scripts/build.py --verify resume-en # single target full verification + python3 scripts/build.py --check-placeholders path/to/doc.html + python3 scripts/build.py --check-markdown path/to/doc.pdf + python3 scripts/build.py --check-orphans # scan example PDFs for orphan text + python3 scripts/build.py --check-orphans path/to/doc.pdf + python3 scripts/build.py --check-density # warn on pages with >25% trailing whitespace + python3 scripts/build.py --check-density path/to/doc.pdf + python3 scripts/build.py --check-resume-balance path/to/resume.pdf + python3 scripts/build.py --check-rhythm # warn on monotonous slide sequences + python3 scripts/build.py --check-rhythm slides slides-en +""" +from __future__ import annotations + +import functools +import os +import subprocess +import sys +from pathlib import Path + +from highlight import highlight_code_blocks +from optional_deps import ( + MissingDepError, + require_pypdf_reader, + require_pypdf_writer, + require_weasyprint_html, +) +from shared import ( + DIAGRAMS, + EXAMPLES, + TEMPLATES, + build_targets, + diagram_targets, + screen_targets, +) + +# Implementation modules (also re-exported for tests / external callers). +from checks import ( # noqa: F401 re-exported for test_build.py + _BG_B, + _BG_G, + _BG_R, + _density_bucket, + _last_content_y, + _markdown_residue_issues, + _orphan_last_line, + _parse_slide_sequence, + _resume_balance_issues, + _rhythm_issues, + _scan_density, + check_density, + check_markdown_residue, + check_orphans, + check_placeholders, + check_resume_balance, + check_rhythm, +) +from lint import ( # noqa: F401 re-exported for test_build.py + _extract_root_vars, + _off_palette_findings, + _pair_names, + _root_token_findings, + check_all, + check_cross_template_consistency, + check_off_palette, + scan_file, +) +from site_facts import check_site_facts +from tokens import sync_check +from verify import ( + show_fonts, + verify_all, +) + +# name -> (source, max_pages). max_pages=0 means no hard check. +# Sourced from shared.HTML_TEMPLATES (single source of truth for targets). +HTML_TARGETS: dict[str, tuple[str, int]] = build_targets() +SCREEN_TARGETS: dict[str, str] = screen_targets() +PPTX_TARGETS: dict[str, str] = { + "slides": "slides.py", + "slides-en": "slides-en.py", +} + +# Diagram HTMLs live in a separate directory and have no page-count contract. +# Registry lives in shared.DIAGRAM_TEMPLATES (single home for all template lists). +DIAGRAM_TARGETS: dict[str, str] = diagram_targets() + + +# ------------------------- build helpers ------------------------- + +@functools.lru_cache(maxsize=1) +def infer_author() -> str: + """Infer author name from git config or environment. + + Priority: + 1. git config user.name + 2. KAMI_AUTHOR env var + 3. fallback to "Kami" + + Cached so a full build doesn't shell out for every PDF target. + """ + try: + result = subprocess.run( + ["git", "config", "user.name"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode == 0 and result.stdout.strip(): + return result.stdout.strip() + except FileNotFoundError: + pass + + if env_author := os.environ.get("KAMI_AUTHOR"): + return env_author + + return "Kami" + + +def set_pdf_metadata(pdf_path: Path, author: str | None = None) -> None: + """Set PDF metadata using pypdf, only if placeholders are still present.""" + try: + PdfReader = require_pypdf_reader() + PdfWriter = require_pypdf_writer() + except MissingDepError: + return + + if not pdf_path.exists(): + return + + reader = PdfReader(str(pdf_path)) + + existing = reader.metadata or {} + needs_update = False + metadata = dict(existing) + + if author and existing.get("/Author"): + author_value = str(existing["/Author"]) + if "{{" in author_value and "}}" in author_value: + metadata["/Author"] = author + needs_update = True + + if metadata.get("/Producer") != "Kami": + metadata["/Producer"] = "Kami" + needs_update = True + if metadata.get("/Creator") != "Kami": + metadata["/Creator"] = "Kami" + needs_update = True + + if not needs_update: + return + + writer = PdfWriter() + for page in reader.pages: + writer.add_page(page) + + writer.add_metadata(metadata) + + with open(pdf_path, "wb") as f: + writer.write(f) + + +# ------------------------- build ------------------------- + +def build_html(name: str, source: str, max_pages: int, + src_dir: Path = TEMPLATES) -> bool: + try: + HTML = require_weasyprint_html() + PdfReader = require_pypdf_reader() + except MissingDepError as exc: + print(f"ERROR: {exc}") + return False + + src = src_dir / source + if not src.exists(): + print(f"ERROR: {name}: source not found ({src})") + return False + + EXAMPLES.mkdir(parents=True, exist_ok=True) + out = EXAMPLES / f"{name}.pdf" + + html_text = src.read_text(encoding="utf-8") + html_text = highlight_code_blocks(html_text) + HTML(string=html_text, base_url=str(src.parent)).write_pdf(str(out)) + + set_pdf_metadata(out, author=infer_author()) + + n = len(PdfReader(str(out)).pages) + msg = f"OK: {name}: {n} pages" + if max_pages and n > max_pages: + msg = f"ERROR: {name}: {n} pages (limit {max_pages})" + print(msg) + return False + print(msg) + return True + + +def build_slides(name: str = "slides") -> bool: + source = PPTX_TARGETS.get(name) + if source is None: + print(f"ERROR: {name}: unknown slides target") + return False + src = TEMPLATES / source + if not src.exists(): + print(f"ERROR: {name}: source not found ({src})") + return False + + EXAMPLES.mkdir(parents=True, exist_ok=True) + out = EXAMPLES / f"{name}.pptx" + # Pass --out so the slides script writes directly to the target path. Older + # slides.py defaults to 'output.pptx' in cwd; new copies accept --out. + result = subprocess.run( + [sys.executable, str(src), "--out", str(out)], + cwd=str(src.parent), + capture_output=True, + text=True, + ) + if result.returncode != 0: + print(f"ERROR: {name}: {result.stderr.strip() or 'script failed'}") + return False + if out.exists(): + print(f"OK: {name}: generated {out.name}") + return True + print(f"ERROR: {name}: {out.name} not produced") + return False + + +def build_screen_template(name: str, source: str) -> bool: + src = TEMPLATES / source + if not src.exists(): + print(f"ERROR: {name}: source not found ({src})") + return False + + findings = scan_file(src) + if findings: + print(f"ERROR: {name}: {len(findings)} template violation(s)") + return False + + print(f"OK: {name}: static HTML template") + return True + + +def build_all() -> int: + failures = 0 + for name, (source, max_pages) in HTML_TARGETS.items(): + if not build_html(name, source, max_pages): + failures += 1 + for name, source in SCREEN_TARGETS.items(): + if not build_screen_template(name, source): + failures += 1 + for name, source in DIAGRAM_TARGETS.items(): + if not build_html(name, source, 0, src_dir=DIAGRAMS): + failures += 1 + for name in PPTX_TARGETS: + if not build_slides(name): + failures += 1 + return failures + + +def build_single(name: str) -> int: + if name in HTML_TARGETS: + source, max_pages = HTML_TARGETS[name] + ok = build_html(name, source, max_pages) + if ok: + show_fonts(EXAMPLES / f"{name}.pdf") + return 0 if ok else 1 + if name in SCREEN_TARGETS: + ok = build_screen_template(name, SCREEN_TARGETS[name]) + return 0 if ok else 1 + if name in DIAGRAM_TARGETS: + source = DIAGRAM_TARGETS[name] + ok = build_html(name, source, 0, src_dir=DIAGRAMS) + return 0 if ok else 1 + if name in PPTX_TARGETS: + return 0 if build_slides(name) else 1 + known = list(HTML_TARGETS) + list(SCREEN_TARGETS) + list(DIAGRAM_TARGETS) + list(PPTX_TARGETS) + print(f"ERROR: unknown target: {name}. Known: {', '.join(known)}") + return 2 + + +# ------------------------- verify glue ------------------------- + +def verify_slides_target(name: str) -> list[str]: + return [] if build_slides(name) else ["slides build failed"] + + +def _verify_all(target: str | None) -> int: + return verify_all( + target, + html_targets=HTML_TARGETS, + screen_targets=SCREEN_TARGETS, + diagram_targets=DIAGRAM_TARGETS, + pptx_targets=PPTX_TARGETS, + verify_slides_fn=verify_slides_target, + scan_file_fn=scan_file, + scan_density_fn=_scan_density, + infer_author_fn=infer_author, + set_pdf_metadata_fn=set_pdf_metadata, + ) + + +# ------------------------- entry ------------------------- + +def _unexpected_arg(args: list[str], allowed: set[str] | None = None) -> str | None: + for arg in args: + if allowed is not None: + if arg not in allowed: + return arg + elif arg.startswith("-"): + return arg + return None + + +def _error_unexpected(arg: str) -> int: + print(f"ERROR: unexpected argument: {arg}") + return 2 + + +def main(argv: list[str]) -> int: + args = argv[1:] + if not args: + return build_all() + if args[0] in ("-h", "--help"): + print(__doc__) + return 0 + if args[0] == "--check": + unexpected = _unexpected_arg(args[1:], {"-v", "--verbose"}) + if unexpected: + return _error_unexpected(unexpected) + verbose = "-v" in args[1:] or "--verbose" in args[1:] + css_result = check_all(verbose) + sync_result = sync_check(verbose) + cross_result = check_cross_template_consistency(verbose) + palette_result = check_off_palette(verbose) + site_result = check_site_facts(verbose) + return max(css_result, sync_result, cross_result, palette_result, site_result) + if args[0] == "--sync": + unexpected = _unexpected_arg(args[1:], {"-v", "--verbose"}) + if unexpected: + return _error_unexpected(unexpected) + verbose = "-v" in args[1:] or "--verbose" in args[1:] + return sync_check(verbose) + if args[0] == "--verify": + if len(args) > 2: + return _error_unexpected(args[2]) + if len(args) == 2 and args[1].startswith("-"): + return _error_unexpected(args[1]) + target = args[1] if len(args) > 1 else None + return _verify_all(target) + # Path-taking check subcommands share one guard + dispatch table. + path_checks = { + "--check-orphans": check_orphans, + "--check-density": check_density, + "--check-resume-balance": check_resume_balance, + "--check-placeholders": check_placeholders, + "--check-markdown": check_markdown_residue, + } + handler = path_checks.get(args[0]) + if handler is not None: + unexpected = _unexpected_arg(args[1:]) + if unexpected: + return _error_unexpected(unexpected) + return handler(args[1:]) + if args[0] == "--check-rhythm": + unexpected = _unexpected_arg(args[1:]) + if unexpected: + return _error_unexpected(unexpected) + slide_targets = [a for a in args[1:] if not a.startswith("-")] + return check_rhythm(slide_targets, PPTX_TARGETS, TEMPLATES) + if args[0].startswith("-"): + print(f"ERROR: unknown option: {args[0]}") + return 2 + if len(args) > 1: + return _error_unexpected(args[1]) + return build_single(args[0]) + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/plugins/kami/skills/kami/scripts/build_metadata.py b/plugins/kami/skills/kami/scripts/build_metadata.py new file mode 100644 index 0000000..1484a23 --- /dev/null +++ b/plugins/kami/skills/kami/scripts/build_metadata.py @@ -0,0 +1,393 @@ +#!/usr/bin/env python3 +"""Generate Kami plugin metadata and mirror files. + +Source of truth: + - VERSION + - root SKILL.md + - CHEATSHEET.md + - references/ + - scripts/ + - assets/templates/ + - assets/diagrams/ + - selected lightweight assets + +Generated files: + - .claude-plugin/marketplace.json + - .agents/plugins/marketplace.json + - plugins/kami/.claude-plugin/plugin.json + - plugins/kami/.codex-plugin/plugin.json + - plugins/kami/skills/kami/ + +Modes: + --write (default) regenerate plugin files from source + --check compare generated bytes against committed files + +Run as: python3 scripts/build_metadata.py [--check] [--root PATH] +""" + +from __future__ import annotations + +import argparse +import difflib +import json +import shutil +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + +PLUGIN_NAME = "kami" +CODEX_CATEGORY = "Productivity" +HOMEPAGE = "https://github.com/tw93/kami" +REPOSITORY = "https://github.com/tw93/kami" + +AUTHOR = { + "name": "Tw93", + "email": "hitw93@gmail.com", + "url": "https://github.com/tw93", +} + +CODEX_DESCRIPTION = ( + "Professional document and landing-page typesetting skill for Codex: " + "resumes, one-pagers, reports, letters, portfolios, slides, and more." +) +CLAUDE_MARKETPLACE_DESCRIPTION = "Document typesetting skill for Claude Code." +CLAUDE_PLUGIN_DESCRIPTION = ( + "Typeset professional documents and landing pages with the Kami design system." +) + +SKILL_MIRROR_ROOT = Path("plugins/kami/skills/kami") +SKILL_MIRROR_FILES = ( + "CHEATSHEET.md", + "LICENSE", + "SKILL.md", + "VERSION", +) +SKILL_MIRROR_DIRS = ( + "assets/diagrams", + "assets/fonts", + "assets/images", + "assets/templates", + "references", + "scripts", +) +SKILL_MIRROR_ALLOWED_FONT_FILES = { + "JetBrainsMono.woff2", + "LICENSE-SourceHanSerifK.txt", +} +SKILL_MIRROR_IGNORED_DIRS = { + "__pycache__", + ".mypy_cache", + ".pytest_cache", + ".ruff_cache", +} +SKILL_MIRROR_IGNORED_NAMES = { + ".DS_Store", +} +SKILL_MIRROR_IGNORED_SUFFIXES = { + ".pyc", + ".pyo", +} + + +def read_version(root: Path) -> str: + version_file = root / "VERSION" + if not version_file.exists(): + raise SystemExit(f"ERROR: missing VERSION file at {version_file}") + version = version_file.read_text().strip() + if not version: + raise SystemExit("ERROR: VERSION file is empty") + return version + + +def read_token_value(root: Path, name: str) -> str: + key = name if name.startswith("--") else f"--{name}" + token_file = root / "references" / "tokens.json" + try: + tokens = json.loads(token_file.read_text(encoding="utf-8")) + except OSError as exc: + raise SystemExit(f"ERROR: missing token file at {token_file}: {exc}") from exc + except json.JSONDecodeError as exc: + raise SystemExit(f"ERROR: tokens.json is malformed: {exc}") from exc + try: + return tokens[key] + except KeyError as exc: + raise SystemExit(f"ERROR: missing token {key} in {token_file}") from exc + + +def render_json(data: dict) -> str: + return json.dumps(data, indent=2, ensure_ascii=False) + "\n" + + +def build_codex_plugin(version: str, brand_color: str) -> dict: + return { + "name": PLUGIN_NAME, + "version": version, + "description": CODEX_DESCRIPTION, + "author": AUTHOR, + "homepage": HOMEPAGE, + "repository": REPOSITORY, + "license": "MIT", + "keywords": [ + "codex", + "skills", + "documents", + "typesetting", + "resume", + "slides", + "landing-page", + ], + "skills": "./skills/", + "interface": { + "displayName": "Kami", + "shortDescription": "Typeset polished documents and landing pages", + "longDescription": ( + "Kami packages a warm parchment design system for Codex. Use it " + "to turn briefs and raw material into resumes, one-pagers, long " + "documents, letters, portfolios, slide decks, equity reports, " + "changelogs, and product landing pages." + ), + "developerName": "Tw93", + "category": CODEX_CATEGORY, + "capabilities": [ + "Interactive", + "Write", + ], + "websiteURL": HOMEPAGE, + "defaultPrompt": [ + "Make a polished one-pager from this brief", + "Build a resume using Kami", + "Turn this outline into a slide deck", + ], + "brandColor": brand_color, + }, + } + + +def build_codex_marketplace() -> dict: + return { + "name": PLUGIN_NAME, + "interface": { + "displayName": "Kami", + }, + "plugins": [ + { + "name": PLUGIN_NAME, + "source": { + "source": "local", + "path": "./plugins/kami", + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL", + }, + "category": CODEX_CATEGORY, + } + ], + } + + +def build_claude_plugin(version: str) -> dict: + return { + "name": PLUGIN_NAME, + "version": version, + "description": CLAUDE_PLUGIN_DESCRIPTION, + "author": { + "name": AUTHOR["name"], + "email": AUTHOR["email"], + }, + "homepage": HOMEPAGE, + "repository": REPOSITORY, + "license": "MIT", + "skills": "./skills/", + } + + +def build_claude_marketplace(version: str) -> dict: + return { + "name": PLUGIN_NAME, + "description": CLAUDE_MARKETPLACE_DESCRIPTION, + "owner": { + "name": AUTHOR["name"], + "email": AUTHOR["email"], + }, + "plugins": [ + { + "name": PLUGIN_NAME, + "version": version, + "description": CLAUDE_PLUGIN_DESCRIPTION, + "category": "documents", + "source": "./plugins/kami", + "homepage": HOMEPAGE, + } + ], + } + + +def should_include_skill_mirror_file(path: Path) -> bool: + if any(part in SKILL_MIRROR_IGNORED_DIRS for part in path.parts): + return False + if path.name in SKILL_MIRROR_IGNORED_NAMES: + return False + if path.suffix in SKILL_MIRROR_IGNORED_SUFFIXES: + return False + if path.parts[:2] == ("assets", "fonts"): + return path.name in SKILL_MIRROR_ALLOWED_FONT_FILES + return True + + +def collect_plugin_tree(root: Path, codex_manifest_rendered: str, claude_manifest_rendered: str) -> dict[str, bytes]: + """Build the generated file set for the shared plugin directory. + + Claude Code and Codex install only the directory referenced by their + marketplace source path. Kami's source skill lives at the repository root, + so the plugin mirrors a lightweight skill root under plugins/kami/skills/kami + and keeps all paths referenced by SKILL.md relative to that generated skill + directory. + """ + generated = { + "plugins/kami/.claude-plugin/plugin.json": claude_manifest_rendered.encode(), + "plugins/kami/.codex-plugin/plugin.json": codex_manifest_rendered.encode(), + } + + for source in SKILL_MIRROR_FILES: + path = root / source + if not path.exists(): + raise SystemExit(f"ERROR: missing required plugin source file {path}") + generated[(SKILL_MIRROR_ROOT / source).as_posix()] = path.read_bytes() + + for source in SKILL_MIRROR_DIRS: + source_root = root / source + if not source_root.exists(): + raise SystemExit(f"ERROR: missing required plugin source tree {source_root}") + for path in sorted(source_root.rglob("*")): + if not path.is_file(): + continue + source_rel = path.relative_to(root) + if not should_include_skill_mirror_file(source_rel): + continue + generated[(SKILL_MIRROR_ROOT / source_rel).as_posix()] = path.read_bytes() + + return generated + + +def diff(label: str, expected: str, actual: str) -> str: + return "".join( + difflib.unified_diff( + actual.splitlines(keepends=True), + expected.splitlines(keepends=True), + fromfile=f"committed:{label}", + tofile=f"generated:{label}", + ) + ) + + +def bytes_diff(label: str, expected: bytes, actual: bytes) -> str: + return diff( + label, + expected.decode("utf-8", errors="replace"), + actual.decode("utf-8", errors="replace"), + ) + + +def check_generated(root: Path, generated_json_files: list[tuple[Path, str]], plugin_tree: dict[str, bytes]) -> int: + drift = False + + for generated_path, expected in generated_json_files: + actual = generated_path.read_text() if generated_path.exists() else "" + if actual != expected: + rel = generated_path.relative_to(root).as_posix() + print( + f"DRIFT: {rel} is out of sync with plugin metadata.\n" + "Run scripts/build_metadata.py (no flags) to regenerate.", + ) + print(diff(rel, expected, actual), end="") + drift = True + + for rel, expected in plugin_tree.items(): + path = root / rel + actual = path.read_bytes() if path.exists() else b"" + if actual != expected: + print( + f"DRIFT: {rel} is out of sync with Kami source files.\n" + "Run scripts/build_metadata.py (no flags) to regenerate.", + ) + print(bytes_diff(rel, expected, actual), end="") + drift = True + + codex_plugin_root = root / "plugins" / "kami" + if codex_plugin_root.exists(): + expected_paths = set(plugin_tree) + for path in sorted(codex_plugin_root.rglob("*")): + if not path.is_file(): + continue + rel_path = path.relative_to(root) + plugin_rel = path.relative_to(codex_plugin_root) + if not should_include_skill_mirror_file(plugin_rel): + continue + rel = rel_path.as_posix() + if rel not in expected_paths: + print( + f"DRIFT: {rel} is an extra file in the generated plugin tree.\n" + "Run scripts/build_metadata.py (no flags) to regenerate.", + ) + drift = True + + if drift: + return 1 + + for generated_path, _ in generated_json_files: + print(f"OK: {generated_path.relative_to(root)} matches generator") + print("OK: plugins/kami plugin tree matches generator") + return 0 + + +def write_generated(root: Path, generated_json_files: list[tuple[Path, str]], plugin_tree: dict[str, bytes]) -> int: + for generated_path, expected in generated_json_files: + generated_path.parent.mkdir(parents=True, exist_ok=True) + generated_path.write_text(expected) + print(f"OK: wrote {generated_path.relative_to(root)} ({len(expected)} bytes)") + + codex_plugin_root = root / "plugins" / "kami" + shutil.rmtree(codex_plugin_root, ignore_errors=True) + for rel, expected in plugin_tree.items(): + path = root / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(expected) + print(f"OK: wrote plugins/kami ({len(plugin_tree)} generated files)") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--root", + type=Path, + default=ROOT, + help="Repository root (default: parent of scripts/)", + ) + parser.add_argument( + "--check", + action="store_true", + help="Compare generated bytes to committed files; exit non-zero on drift.", + ) + args = parser.parse_args() + root = args.root.resolve() + + version = read_version(root) + codex_plugin_rendered = render_json(build_codex_plugin(version, read_token_value(root, "brand"))) + codex_marketplace_rendered = render_json(build_codex_marketplace()) + claude_plugin_rendered = render_json(build_claude_plugin(version)) + claude_marketplace_rendered = render_json(build_claude_marketplace(version)) + plugin_tree = collect_plugin_tree(root, codex_plugin_rendered, claude_plugin_rendered) + generated_json_files = [ + (root / ".claude-plugin" / "marketplace.json", claude_marketplace_rendered), + (root / ".agents" / "plugins" / "marketplace.json", codex_marketplace_rendered), + ] + + if args.check: + return check_generated(root, generated_json_files, plugin_tree) + return write_generated(root, generated_json_files, plugin_tree) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/kami/skills/kami/scripts/check-update.sh b/plugins/kami/skills/kami/scripts/check-update.sh new file mode 100644 index 0000000..4b3eac6 --- /dev/null +++ b/plugins/kami/skills/kami/scripts/check-update.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Quiet daily update check for the installed kami skill. +# +# Reads the public VERSION file on the default branch and compares it to the +# bundled VERSION. If a newer version exists, prints one line so the agent can +# relay it. No data is ever sent (a plain read-only GET); any failure is silent; +# the check runs at most once per day via a cache marker, so it never blocks work. +set -u + +SKILL="kami" +REPO="tw93/Kami" +DEFAULT_UPDATE_CMD="npx skills add tw93/kami/plugins/kami -a universal -g -y" +# KAMI_UPDATE_URL overrides the source (used by tests); defaults to the public VERSION. +REMOTE_URL="${KAMI_UPDATE_URL:-https://raw.githubusercontent.com/${REPO}/main/VERSION}" + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +local_ver="$(tr -d '[:space:]' < "${root}/VERSION" 2>/dev/null)" +[ -n "${local_ver}" ] || exit 0 + +case "${root}" in + */.claude/plugins/cache/kami/kami/*/skills/kami) + UPDATE_CMD="claude plugin update kami" + ;; + */plugins/cache/kami/kami/*/skills/kami) + UPDATE_CMD="codex plugin marketplace upgrade kami && codex plugin add kami@kami" + ;; + *) + UPDATE_CMD="${DEFAULT_UPDATE_CMD}" + ;; +esac + +# Throttle: at most one check per calendar day, regardless of outcome. One +# dated marker file rewritten in place, so the cache dir does not accumulate +# a new empty update-checked-YYYY-MM-DD file every day. +day="$(date +%F 2>/dev/null)" || exit 0 +cache_dir="${XDG_CACHE_HOME:-${HOME}/.cache}/${SKILL}" +marker="${cache_dir}/update-checked" +[ "$(cat "${marker}" 2>/dev/null)" = "${day}" ] && exit 0 +mkdir -p "${cache_dir}" 2>/dev/null +printf '%s' "${day}" > "${marker}" 2>/dev/null # write first so an offline run does not retry all day +rm -f "${cache_dir}"/update-checked-2* 2>/dev/null # sweep legacy per-day markers + +command -v curl >/dev/null 2>&1 || exit 0 +remote_ver="$(curl -fsSL --max-time 3 "${REMOTE_URL}" 2>/dev/null | tr -d '[:space:]')" +[ -n "${remote_ver}" ] || exit 0 +[ "${remote_ver}" = "${local_ver}" ] && exit 0 + +# Only notify when the remote version sorts strictly higher. Numeric-field +# sort instead of `sort -V`: on a sort without -V support the old pipeline +# yielded an empty string and silently never notified again. +highest="$(printf '%s\n%s\n' "${local_ver}" "${remote_ver}" | sort -t. -k1,1n -k2,2n -k3,3n 2>/dev/null | tail -1)" +[ -n "${highest}" ] || exit 0 +[ "${highest}" = "${remote_ver}" ] || exit 0 + +echo "Kami ${remote_ver} is available (you have ${local_ver}). Update: ${UPDATE_CMD}" +exit 0 diff --git a/plugins/kami/skills/kami/scripts/checks.py b/plugins/kami/skills/kami/scripts/checks.py new file mode 100644 index 0000000..a5d41a4 --- /dev/null +++ b/plugins/kami/skills/kami/scripts/checks.py @@ -0,0 +1,614 @@ +"""PDF-side and content-shape checks for kami documents. + +Splits out from build.py: + - check_placeholders: scan filled HTML for unreplaced `{{...}}` tokens. + - check_orphans: scan rendered PDFs for short trailing lines (typographic orphans). + - check_density: scan rendered PDFs for pages with too much trailing whitespace. + - check_resume_balance: scan resume PDFs for exact two-page balance. + - check_rhythm: scan slides Python source for monotonous deck sequences. + +Density scanning uses a parchment-aware pixel sweep. The hot path is +vectorized with NumPy when available and falls back to a pure-Python loop. +Thresholds and DPI live in `references/checks_thresholds.json`. +""" +from __future__ import annotations + +import re +from html.parser import HTMLParser +from pathlib import Path + +from optional_deps import MissingDepError, require_pymupdf, require_pypdf_reader +from shared import ( + PARCHMENT_RGB, + ROOT, + default_example_pdfs, + load_checks_thresholds, + rel_to_root, +) + +PLACEHOLDER = re.compile(r"\{\{[^}]+\}\}") +MARKDOWN_THEMATIC_BREAK = re.compile(r"^\s*[-*_]{3,}\s*$") +MARKDOWN_RESIDUE_MARKERS = ( + ("markdown thematic break", MARKDOWN_THEMATIC_BREAK), + ("unconverted bold marker", re.compile(r"\*\*")), + ("unconverted inline-code marker", re.compile(r"`")), +) + +# Parchment background RGB for pixel comparison (sourced from shared.PARCHMENT_RGB). +_BG_R, _BG_G, _BG_B = PARCHMENT_RGB +_BG_TOLERANCE = 10 + + +def check_placeholders(paths: list[str]) -> int: + if not paths: + print("ERROR: provide at least one HTML file to scan") + return 2 + + failures = 0 + for raw in paths: + path = Path(raw) + if not path.is_absolute(): + path = ROOT / path + if not path.exists(): + print(f"ERROR: {raw}: file not found") + failures += 1 + continue + text = path.read_text(encoding="utf-8", errors="replace") + hits = list(dict.fromkeys(PLACEHOLDER.findall(text))) + rel = rel_to_root(path) + if hits: + print(f"ERROR: {rel}: unfilled placeholder(s): {', '.join(hits)}") + failures += 1 + else: + print(f"OK: {rel}: no placeholders") + + return 0 if failures == 0 else 1 + + +# ---------- markdown residue check ---------- + +class _VisibleTextParser(HTMLParser): + """Extract visible text from filled HTML while skipping code-like blocks.""" + + _SKIP_TAGS = {"code", "pre", "script", "style"} + + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self._skip_depth = 0 + self.parts: list[str] = [] + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + if tag in self._SKIP_TAGS: + self._skip_depth += 1 + + def handle_endtag(self, tag: str) -> None: + if tag in self._SKIP_TAGS and self._skip_depth > 0: + self._skip_depth -= 1 + + def handle_data(self, data: str) -> None: + if self._skip_depth == 0: + self.parts.append(data) + + +def _visible_html_text(raw: str) -> str: + parser = _VisibleTextParser() + parser.feed(raw) + return "\n".join(parser.parts) + + +def _markdown_residue_issues(text: str, *, page: int | None = None) -> list[str]: + issues: list[str] = [] + for line_no, line in enumerate(text.splitlines(), start=1): + for label, pattern in MARKDOWN_RESIDUE_MARKERS: + if pattern.search(line): + where = f"p{page}" if page is not None else f"line {line_no}" + sample = " ".join(line.strip().split())[:80] + issues.append(f"{where}: {label}: {sample!r}") + return issues + + +def _markdown_text_chunks(path: Path) -> tuple[list[tuple[int | None, str]], str | None]: + suffix = path.suffix.lower() + if suffix == ".pdf": + try: + PdfReader = require_pypdf_reader() + except MissingDepError as exc: + return [], str(exc) + try: + reader = PdfReader(str(path)) + except Exception as exc: + return [], f"could not read PDF text: {exc}" + return [ + (index, page.extract_text() or "") + for index, page in enumerate(reader.pages, start=1) + ], None + + raw = path.read_text(encoding="utf-8", errors="replace") + if suffix in {".html", ".htm"}: + return [(None, _visible_html_text(raw))], None + return [(None, raw)], None + + +def check_markdown_residue(paths: list[str]) -> int: + """Scan filled HTML/PDF outputs for visible raw Markdown markers. + + This catches common hand-conversion misses such as literal `---`, `**bold**`, + and inline-code backticks leaking into the final PDF. + """ + if not paths: + paths = default_example_pdfs() + if not paths: + print("ERROR: no files to scan") + return 2 + + failures = 0 + scanned = 0 + for raw in paths: + path = Path(raw) + if not path.is_absolute(): + path = ROOT / path + rel = rel_to_root(path) + if not path.exists(): + print(f"ERROR: {raw}: file not found") + failures += 1 + continue + + chunks, error = _markdown_text_chunks(path) + if error: + print(f"ERROR: {rel}: {error}") + failures += 1 + continue + + scanned += 1 + issues: list[str] = [] + for page, text in chunks: + issues.extend(_markdown_residue_issues(text, page=page)) + if issues: + failures += 1 + print(f"ERROR: {rel}: markdown residue found") + for issue in issues: + print(f" {issue}") + else: + print(f"OK: {rel}: no markdown residue") + + if scanned == 0: + print("ERROR: no files scanned") + return 2 + return 0 if failures == 0 else 1 + + +# ---------- orphan check ---------- + +def _orphan_last_line(text: str, max_words: int, max_chars: int) -> str | None: + """Return a block's last line if it is an orphan, else None. + + A block orphans when it has 2+ lines and the trailing line is short by + both word count (<= max_words) and length (< max_chars). Pure so the + predicate is unit-testable without a rendered PDF. + """ + lines = text.strip().splitlines() + if len(lines) < 2: + return None + last = lines[-1].strip() + if len(last.split()) <= max_words and len(last) < max_chars: + return last + return None + + +def check_orphans(paths: list[str]) -> int: + """Scan PDF for text blocks whose last line has <= max_words and < max_chars.""" + try: + fitz = require_pymupdf() + except MissingDepError as exc: + print(f"ERROR: {exc}") + return 2 + + if not paths: + paths = default_example_pdfs() + if not paths: + print("ERROR: no PDF files to scan") + return 2 + + orphan_cfg = load_checks_thresholds()["orphan"] + max_words = int(orphan_cfg["max_words"]) + max_chars = int(orphan_cfg["max_chars"]) + + total = 0 + missing = 0 + scanned = 0 + for raw in paths: + path = Path(raw) + if not path.exists(): + print(f"ERROR: {raw}: not found") + missing += 1 + continue + try: + doc = fitz.open(str(path)) + except Exception as exc: + print(f"ERROR: {raw}: could not read PDF: {exc}") + missing += 1 + continue + scanned += 1 + rel = rel_to_root(path) + for page_num in range(len(doc)): + page = doc[page_num] + blocks = page.get_text("blocks") + for bx0, by0, bx1, by1, text, block_no, block_type in blocks: + if block_type != 0: # text blocks only + continue + last = _orphan_last_line(text, max_words, max_chars) + if last is not None: + total += 1 + print(f" {rel} p{page_num + 1}: orphan: \"{last}\" ({len(last.split())} word(s), {len(last)} chars)") + doc.close() + + if scanned == 0: + print(f"ERROR: no PDFs scanned ({missing} missing)") + return 2 + + if total == 0 and missing == 0: + print(f"OK: no orphans found across {scanned} PDF(s)") + return 0 + + if total: + print(f"\n{total} orphan(s) found across {scanned} PDF(s)") + if missing: + print(f"{missing} input(s) missing") + return 1 + + +# ---------- density check ---------- + +def _last_content_y(samples: bytes, w: int, h: int, stride: int, n: int) -> int: + """Return the highest y row index that contains non-parchment content. + + Uses numpy when available (vectorized scan, ~50-100x faster on multi-page + PDFs); falls back to a pure Python loop otherwise. Both paths sample every + fourth column for parity, so the result is identical. + """ + try: + import numpy as np + except ImportError: + last_y = 0 + for y in range(h - 1, -1, -1): + row_start = y * stride + is_bg = True + for x in range(0, w, 4): + offset = row_start + x * n + if (abs(samples[offset] - _BG_R) > _BG_TOLERANCE + or abs(samples[offset + 1] - _BG_G) > _BG_TOLERANCE + or abs(samples[offset + 2] - _BG_B) > _BG_TOLERANCE): + is_bg = False + break + if not is_bg: + last_y = y + break + return last_y + + arr = np.frombuffer(samples, dtype=np.uint8).reshape((h, stride)) + pixels = arr[:, : w * n].reshape((h, w, n)) + rgb = pixels[:, ::4, :3].astype(np.int16) + bg = np.array([_BG_R, _BG_G, _BG_B], dtype=np.int16) + row_is_bg = (np.abs(rgb - bg).max(axis=2) <= _BG_TOLERANCE).all(axis=1) + non_bg = np.where(~row_is_bg)[0] + return int(non_bg[-1]) if non_bg.size else 0 + + +def _density_bucket(empty: float, warn_pct: float, sparse_pct: float) -> str: + """Categorize a page by its trailing-whitespace fraction. + + Pure so `_scan_density` and its tests share one decision. A test that + reimplements these comparisons would stay green if the real operators + drifted (`>` to `>=`, or warn/sparse swapped); calling this keeps the + assertion anchored to production logic. + """ + if empty > sparse_pct: + return "SPARSE" + if empty > warn_pct: + return "WARN" + return "OK" + + +def _scan_density(paths: list[str]) -> tuple[int, int, int, int] | None: + """Scan PDFs and print SPARSE/WARN lines. + + Returns (sparse, warn, missing, scanned), or None if PyMuPDF is missing. + Thresholds (warn_pct, sparse_pct, dpi) come from + references/checks_thresholds.json. + """ + try: + fitz = require_pymupdf() + except MissingDepError as exc: + print(f"ERROR: {exc}") + return None + + density_cfg = load_checks_thresholds()["density"] + warn_pct = float(density_cfg["warn_pct"]) + sparse_pct = float(density_cfg["sparse_pct"]) + dpi = int(density_cfg["dpi"]) + + sparse = 0 + warn = 0 + missing = 0 + scanned = 0 + for raw in paths: + path = Path(raw) + if not path.exists(): + print(f"ERROR: {raw}: not found") + missing += 1 + continue + try: + doc = fitz.open(str(path)) + except Exception as exc: + print(f"ERROR: {raw}: could not read PDF: {exc}") + missing += 1 + continue + scanned += 1 + rel = rel_to_root(path) + for page_num in range(len(doc)): + if page_num == 0: + continue + page = doc[page_num] + pix = page.get_pixmap(dpi=dpi) + w, h = pix.width, pix.height + if h == 0: + continue + last_content_y = _last_content_y(pix.samples, w, h, pix.stride, pix.n) + + empty = (h - last_content_y) / h + bucket = _density_bucket(empty, warn_pct, sparse_pct) + if bucket == "SPARSE": + print(f" SPARSE: {rel} p{page_num + 1}: {empty:.0%} trailing whitespace") + sparse += 1 + elif bucket == "WARN": + print(f" WARN: {rel} p{page_num + 1}: {empty:.0%} trailing whitespace") + warn += 1 + doc.close() + return sparse, warn, missing, scanned + + +def check_density(paths: list[str]) -> int: + """Scan PDF pages for sparse content (large trailing whitespace from + break-inside:avoid pushing content to the next page).""" + if not paths: + paths = default_example_pdfs() + if not paths: + print("ERROR: no PDF files to scan") + return 2 + + result = _scan_density(paths) + if result is None: + return 2 + sparse, warn, missing, scanned = result + + if scanned == 0: + print(f"ERROR: no PDFs scanned ({missing} missing)") + return 2 + + total = sparse + warn + if total == 0 and missing == 0: + print(f"OK: no density issues across {scanned} PDF(s)") + return 0 + + if total: + print(f"\n{total} density warning(s) across {scanned} PDF(s)") + if missing: + print(f"{missing} input(s) missing") + return 1 + + +# ---------- resume balance check ---------- + +def _resume_balance_issues( + fills: list[float], + page_count: int, + min_fill: float, + max_fill: float, + max_gap: float, +) -> list[str]: + """Return human-readable resume balance failures for unit tests and CLI.""" + issues: list[str] = [] + if page_count != 2: + issues.append(f"{page_count} pages (expected 2)") + + for index, fill in enumerate(fills[:2], start=1): + if fill < min_fill: + issues.append(f"p{index} fill {fill:.0%} below {min_fill:.0%}") + elif fill > max_fill: + issues.append(f"p{index} fill {fill:.0%} above {max_fill:.0%}") + + if len(fills) >= 2: + gap = abs(fills[0] - fills[1]) + if gap > max_gap: + issues.append(f"page fill gap {gap:.0%} above {max_gap:.0%}") + + return issues + + +def _resume_page_fills(path: Path, dpi: int) -> tuple[list[float], int] | None: + try: + fitz = require_pymupdf() + except MissingDepError as exc: + print(f"ERROR: {exc}") + return None + + try: + doc = fitz.open(str(path)) + except Exception as exc: + print(f"ERROR: {path}: could not read PDF: {exc}") + return None + fills: list[float] = [] + for page in doc: + pix = page.get_pixmap(dpi=dpi) + if pix.height == 0: + fills.append(0.0) + continue + last_content_y = _last_content_y(pix.samples, pix.width, pix.height, pix.stride, pix.n) + fills.append((last_content_y + 1) / pix.height) + page_count = len(doc) + doc.close() + return fills, page_count + + +def check_resume_balance(paths: list[str]) -> int: + """Require resume PDFs to be exactly 2 pages with balanced content fill.""" + if not paths: + print("ERROR: provide at least one filled resume PDF to scan") + return 2 + + # Resolve the dependency once up front so a missing PyMuPDF stays a + # tooling error (exit 2) while a single unreadable PDF inside the loop + # tallies as missing and lets the remaining files still get scanned. + try: + require_pymupdf() + except MissingDepError as exc: + print(f"ERROR: {exc}") + return 2 + + cfg = load_checks_thresholds()["resume_balance"] + min_fill = float(cfg["min_fill_pct"]) + max_fill = float(cfg["max_fill_pct"]) + max_gap = float(cfg["max_gap_pct"]) + dpi = int(cfg["dpi"]) + + failures = 0 + missing = 0 + scanned = 0 + for raw in paths: + path = Path(raw) + if not path.exists(): + print(f"ERROR: {raw}: not found") + missing += 1 + continue + + result = _resume_page_fills(path, dpi) + if result is None: + missing += 1 + continue + + fills, page_count = result + scanned += 1 + rel = rel_to_root(path) + fill_text = " / ".join(f"{fill:.0%}" for fill in fills) + issues = _resume_balance_issues(fills, page_count, min_fill, max_fill, max_gap) + if issues: + failures += 1 + print(f"ERROR: {rel}: {fill_text} ({'; '.join(issues)})") + else: + gap = abs(fills[0] - fills[1]) + print(f"OK: {rel}: 2 pages, fill {fill_text}, gap {gap:.0%}") + + if scanned == 0: + print(f"ERROR: no PDFs scanned ({missing} missing)") + return 2 + if missing: + print(f"{missing} input(s) missing") + return 0 if failures == 0 and missing == 0 else 1 + + +# ---------- rhythm check ---------- + +# Layout functions that count as "divider" slides (break monotony). +_DIVIDER_FUNCS = {"chapter_slide"} +# Layout functions that count as "density variation" slides. +_DENSITY_VARIATION_FUNCS = {"quote_slide", "metrics_slide"} +# Layout function call pattern in slides.py source. +_SLIDE_CALL = re.compile(r"^\s*(\w+_slide)\s*\(") + + +def _parse_slide_sequence(src: Path) -> list[str]: + """Return the ordered list of slide-function names called in main().""" + text = src.read_text(encoding="utf-8", errors="replace") + in_main = False + sequence: list[str] = [] + for line in text.splitlines(): + if re.match(r"^def main\s*\(", line): + in_main = True + continue + if in_main and re.match(r"^def \w", line): + break + if in_main: + m = _SLIDE_CALL.match(line) + if m: + sequence.append(m.group(1)) + return sequence + + +def _rhythm_issues(seq: list[str], max_content_run: int, divider_min_deck_size: int) -> list[str]: + """Return the rhythm warnings for one parsed slide sequence. + + Pure so the three monotony rules are unit-testable without rendering a + deck, matching the `_resume_balance_issues` seam. + """ + issues: list[str] = [] + + # Rule 1: no run of more than `max_content_run` consecutive content_slides. + run = 0 + max_run = 0 + for fn in seq: + if fn == "content_slide": + run += 1 + max_run = max(max_run, run) + else: + run = 0 + if max_run > max_content_run: + issues.append(f"longest content_slide run is {max_run} (limit {max_content_run})") + + # Rule 2: large decks need at least one chapter_slide divider. + if len(seq) >= divider_min_deck_size and not any(fn in _DIVIDER_FUNCS for fn in seq): + issues.append(f"{len(seq)} slides with no chapter_slide divider") + + # Rule 3: deck must contain at least one density-variation slide. + if not any(fn in _DENSITY_VARIATION_FUNCS for fn in seq): + issues.append("no quote_slide or metrics_slide for density variation") + + return issues + + +def check_rhythm(targets: list[str], pptx_targets: dict[str, str], templates_dir: Path) -> int: + """Scan slide templates for monotony: too many consecutive content_slides, + missing dividers, and missing density variation. + + Thresholds come from references/checks_thresholds.json. + """ + names = targets if targets else list(pptx_targets.keys()) + failures = 0 + rhythm_cfg = load_checks_thresholds()["rhythm"] + max_content_run = int(rhythm_cfg["max_content_run"]) + divider_min_deck_size = int(rhythm_cfg["divider_min_deck_size"]) + + for name in names: + source = pptx_targets.get(name) + if source is None: + print(f"ERROR: {name}: not a known slides target") + failures += 1 + continue + src = templates_dir / source + if not src.exists(): + print(f"ERROR: {name}: source not found ({src})") + failures += 1 + continue + + seq = _parse_slide_sequence(src) + if not seq: + print(f"ERROR: {name}: no slide calls found in main() (deck unparseable)") + failures += 1 + continue + + issues = _rhythm_issues(seq, max_content_run, divider_min_deck_size) + + if issues: + # These fail the run (exit 1), so label them ERROR; WARN is + # reserved for advisory output that does not gate the build. + for issue in issues: + print(f"ERROR: {name}: {issue}") + failures += 1 + else: + content_run = 0 + max_run = 0 + for fn in seq: + content_run = content_run + 1 if fn == "content_slide" else 0 + max_run = max(max_run, content_run) + print(f"OK: {name}: rhythm ok ({len(seq)} slides, max run {max_run})") + + return 0 if failures == 0 else 1 diff --git a/plugins/kami/skills/kami/scripts/draft-release-notes.py b/plugins/kami/skills/kami/scripts/draft-release-notes.py new file mode 100644 index 0000000..2fc78cc --- /dev/null +++ b/plugins/kami/skills/kami/scripts/draft-release-notes.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +"""Draft the next Kami release notes from git log. + +Pulls commit subjects in a rev range and pours them into the V1.4.0-style +template (centered logo + bilingual changelog). The output is a starting +point: regroup the commits into 5-8 product-themed bullets and translate +each to Chinese before publishing. + +Usage: + python3 scripts/draft-release-notes.py + python3 scripts/draft-release-notes.py V1.4.0..HEAD + python3 scripts/draft-release-notes.py \\ + --version V1.5.0 \\ + --title "Steadier Hand" \\ + --subtitle-en "Plugin install fix and audit cleanup." \\ + --subtitle-cn "插件安装修复,审计清理沉淀。" + +The default rev range is `<latest tag>..HEAD`. Output goes to stdout; pipe +to a file or `pbcopy` to feed `gh release create --notes-file`. +""" +from __future__ import annotations + +import argparse +import subprocess +import sys +from textwrap import dedent + +_HEADER = dedent("""\ + <div align="center"> + <img src="https://gw.alipayobjects.com/zos/k/vl/logo.svg" alt="Kami Logo" width="120" /> + <h1 style="margin: 12px 0 6px;">Kami {version}</h1> + <p><em>{subtitle_en}</em></p> + </div> +""") + +_FOOTER = dedent("""\ + > Kami is a quiet design system for professional documents, one constraint set that any agent can trust. https://github.com/tw93/Kami +""") + +# Conventional-commit prefix to a short product label, used as a hint when +# the user reorganizes the auto-listed commits into themed bullets. +_PREFIX_HINT = { + "build": "build", + "ci": "ci", + "feat": "feature", + "fix": "fix", + "docs": "docs", + "chore": "chore", + "refactor": "refactor", + "test": "test", + "perf": "perf", + "revert": "revert", +} + + +def _run(cmd: list[str]) -> str: + result = subprocess.run(cmd, capture_output=True, text=True, check=False) + if result.returncode != 0: + raise RuntimeError(f"{' '.join(cmd)}: {result.stderr.strip() or 'failed'}") + return result.stdout + + +def latest_tag() -> str | None: + try: + out = _run(["git", "describe", "--tags", "--abbrev=0"]).strip() + except RuntimeError: + return None + return out or None + + +def commits_in(rev_range: str) -> list[tuple[str, str]]: + """Return [(short_sha, subject), ...] in chronological order.""" + out = _run(["git", "log", rev_range, "--format=%h\t%s", "--reverse"]) + rows: list[tuple[str, str]] = [] + for line in out.splitlines(): + if "\t" in line: + sha, subject = line.split("\t", 1) + rows.append((sha.strip(), subject.strip())) + return rows + + +def classify(subject: str) -> str: + """Return a one-word commit category derived from the conventional-commit prefix.""" + head = subject.split(":", 1)[0].split("(", 1)[0].strip().lower() + return _PREFIX_HINT.get(head, "other") + + +def render( + version: str, + title: str, + subtitle_en: str, + subtitle_cn: str, + rev_range: str, + commits: list[tuple[str, str]], +) -> str: + out: list[str] = [] + out.append(_HEADER.format(version=version, subtitle_en=subtitle_en)) + out.append(f"<!-- title: {version} {title} -->") + out.append(f"<!-- range: {rev_range} ({len(commits)} commits) -->") + out.append("<!-- regroup the bullets below into 5-8 product-themed items -->") + out.append("") + out.append("### Changelog") + out.append("") + for i, (sha, subject) in enumerate(commits, 1): + out.append(f"{i}. **TODO**: {subject} <!-- {sha} {classify(subject)} -->") + out.append("") + out.append("### 更新日志") + out.append("") + out.append(f"<!-- 副标题: {subtitle_cn} -->") + out.append("<!-- 翻译并对齐到上面英文条目,保持一一对应 -->") + out.append("") + for i in range(1, len(commits) + 1): + out.append(f"{i}. **TODO**:(对应英文第 {i} 条)") + out.append("") + out.append(_FOOTER) + return "\n".join(out) + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "rev_range", + nargs="?", + default=None, + help="Git rev range (default: <latest tag>..HEAD)", + ) + parser.add_argument("--version", default="VX.Y.Z", help="Version label, e.g. V1.5.0") + parser.add_argument("--title", default="<title>", help="Release title, e.g. 'Steadier Hand'") + parser.add_argument( + "--subtitle-en", + default="<one-line subtitle>", + help="Short English subtitle for the centered hero block", + ) + parser.add_argument( + "--subtitle-cn", + default="<一句话中文副标题>", + help="Short Chinese subtitle, kept as a comment hint for translators", + ) + return parser.parse_args(argv[1:]) + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + + rev_range = args.rev_range + if rev_range is None: + tag = latest_tag() + if not tag: + print("ERROR: no tag found; pass an explicit rev range like V1.0.0..HEAD", + file=sys.stderr) + return 2 + rev_range = f"{tag}..HEAD" + + try: + commits = commits_in(rev_range) + except RuntimeError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 2 + + if not commits: + print(f"ERROR: no commits in {rev_range}", file=sys.stderr) + return 1 + + out = render( + version=args.version, + title=args.title, + subtitle_en=args.subtitle_en, + subtitle_cn=args.subtitle_cn, + rev_range=rev_range, + commits=commits, + ) + print(out) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/plugins/kami/skills/kami/scripts/ensure-fonts.sh b/plugins/kami/skills/kami/scripts/ensure-fonts.sh new file mode 100644 index 0000000..f2b7d12 --- /dev/null +++ b/plugins/kami/skills/kami/scripts/ensure-fonts.sh @@ -0,0 +1,220 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Portable across bash 3.2+ (macOS stock /bin/bash) and bash 4+ (Linux, Homebrew). +# Avoids `declare -A` so the script runs on a fresh macOS without `brew install bash`. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_FONT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)/assets/fonts" + +# Download target lives OUTSIDE the skill directory on purpose. +# +# Claude Desktop skill ZIPs exclude the large bundled fonts (TsangerJinKai TTFs, +# Source Han Serif K OTFs). The old code downloaded them back into the skill's +# own assets/fonts, which pushed the installed skill past Claude Desktop's size +# limit ("upload/execution too big"). We instead drop them in the XDG user font +# dir, which fontconfig scans by default on both macOS (Homebrew) and Linux, yet +# does NOT show up in macOS Font Book. WeasyPrint then resolves "TsangerJinKai02" +# / "Source Han Serif K" from here when the template's relative @font-face path +# is absent; online renders still fall back to the jsDelivr URL baked alongside +# each @font-face declaration. +FONT_DIR="${KAMI_FONT_DIR:-${XDG_DATA_HOME:-$HOME/.local/share}/fonts/kami}" + +# Partial downloads from an interrupted run must not linger in FONT_DIR, and +# two concurrent runs must not fight over one temp path: temp files carry this +# run's PID and are swept on exit. +TMP_SUFFIX="tmp.$$" +cleanup_tmp() { rm -f "$FONT_DIR"/*."$TMP_SUFFIX" 2>/dev/null || true; } +trap cleanup_tmp EXIT + +MIN_SIZE_CN=10000000 # 10MB for TsangerJinKai (large CJK glyph set) +MIN_SIZE_KO=6500000 # 6.5MB for Source Han Serif K (Adobe full subset) + +# TsangerJinKai (CN): index N pairs CN_NAMES[N] with CN_LOCAL_NAMES[N]. +CN_NAMES=("仓耳今楷02-W04.ttf" "仓耳今楷02-W05.ttf") +CN_LOCAL_NAMES=("TsangerJinKai02-W04.ttf" "TsangerJinKai02-W05.ttf") + +# Source Han Serif K (KO): mirror filenames match the repo filenames, so there +# is no rename step (unlike Tsanger's Chinese-named official downloads). +KO_NAMES=("SourceHanSerifKR-Regular.otf" "SourceHanSerifKR-Medium.otf") + +# Mirror order is intentionally jsdmirror-first here, opposite of the +# templates' @font-face fallback (which lists jsdelivr first). Reasoning: +# this script runs interactively when fonts are missing locally, often from +# China where jsdmirror is reachable and faster than jsdelivr; templates run +# anywhere and prioritize jsdelivr's broader global coverage. +MIRROR_SOURCES=( + "https://cdn.jsdmirror.com/gh/tw93/Kami@main/assets/fonts" + "https://cdn.jsdelivr.net/gh/tw93/Kami@main/assets/fonts" +) + +check_size() { + local file="$1" + local min_size="$2" + [[ -f "$file" ]] || return 1 + local size + size=$(wc -c < "$file" | tr -d ' ') + [[ "$size" -ge "$min_size" ]] +} + +cn_present_in() { + local dir="$1" name + for name in "${CN_LOCAL_NAMES[@]}"; do + check_size "$dir/$name" "$MIN_SIZE_CN" || return 1 + done + return 0 +} + +ko_present_in() { + local dir="$1" name + for name in "${KO_NAMES[@]}"; do + check_size "$dir/$name" "$MIN_SIZE_KO" || return 1 + done + return 0 +} + +refresh_fontconfig() { + # The XDG font dir is already on fontconfig's default scan path, so a cache + # refresh is all that is needed for WeasyPrint to pick the fonts up. Optional: + # absence of fc-cache (e.g. minimal sandbox) is non-fatal, fontconfig rescans + # the directory lazily on next use. + if command -v fc-cache >/dev/null 2>&1; then + fc-cache -f "$FONT_DIR" >/dev/null 2>&1 || true + fi +} + +download_tsanger() { + local cn_name="$1" + local local_name="$2" + local target="$FONT_DIR/$local_name" + + # Source 1: official tsanger.cn + local official_url="https://tsanger.cn/download/${cn_name}" + echo " Trying: tsanger.cn (official)" + if curl --retry 2 --connect-timeout 15 --max-time 300 -fSL "$official_url" -o "$target.$TMP_SUFFIX" 2>/dev/null; then + if check_size "$target.$TMP_SUFFIX" "$MIN_SIZE_CN"; then + mv "$target.$TMP_SUFFIX" "$target" + echo " OK: $local_name downloaded ($(du -h "$target" | cut -f1))" + return 0 + else + rm -f "$target.$TMP_SUFFIX" + fi + else + rm -f "$target.$TMP_SUFFIX" + fi + + # Source 2+: CDN mirrors (already named TsangerJinKai02-W0x.ttf) + for src in "${MIRROR_SOURCES[@]}"; do + local url="$src/$local_name" + echo " Trying: $url" + if curl --retry 2 --connect-timeout 15 --max-time 300 -fSL "$url" -o "$target.$TMP_SUFFIX" 2>/dev/null; then + if check_size "$target.$TMP_SUFFIX" "$MIN_SIZE_CN"; then + mv "$target.$TMP_SUFFIX" "$target" + echo " OK: $local_name downloaded ($(du -h "$target" | cut -f1))" + return 0 + else + rm -f "$target.$TMP_SUFFIX" + fi + else + rm -f "$target.$TMP_SUFFIX" + fi + done + + echo " ERROR: all sources failed for $local_name" + return 1 +} + +download_ko_serif() { + local local_name="$1" + local target="$FONT_DIR/$local_name" + + # CDN mirrors only: Source Han Serif K has no single official direct-download + # URL, so we serve the committed OTFs from the same jsDelivr/jsdmirror gh path. + for src in "${MIRROR_SOURCES[@]}"; do + local url="$src/$local_name" + echo " Trying: $url" + if curl --retry 2 --connect-timeout 15 --max-time 300 -fSL "$url" -o "$target.$TMP_SUFFIX" 2>/dev/null; then + if check_size "$target.$TMP_SUFFIX" "$MIN_SIZE_KO"; then + mv "$target.$TMP_SUFFIX" "$target" + echo " OK: $local_name downloaded ($(du -h "$target" | cut -f1))" + return 0 + else + rm -f "$target.$TMP_SUFFIX" + fi + else + rm -f "$target.$TMP_SUFFIX" + fi + done + + echo " ERROR: all sources failed for $local_name" + return 1 +} + +# A repo checkout ships the committed font files. Templates resolve their +# relative `../fonts/*` @font-face path against them directly, so there is +# nothing to download or register. These branches are skipped inside a Claude +# Desktop skill, whose assets/fonts has the large fonts stripped out. + +cn_failed=0 +if cn_present_in "$REPO_FONT_DIR"; then + echo "OK: TsangerJinKai fonts present in repo checkout ($REPO_FONT_DIR)" +else + mkdir -p "$FONT_DIR" + if cn_present_in "$FONT_DIR"; then + echo "OK: TsangerJinKai fonts present ($FONT_DIR)" + else + echo "Downloading TsangerJinKai fonts to $FONT_DIR ..." + for i in "${!CN_NAMES[@]}"; do + cn_name="${CN_NAMES[$i]}" + local_name="${CN_LOCAL_NAMES[$i]}" + if check_size "$FONT_DIR/$local_name" "$MIN_SIZE_CN"; then + echo " OK: $local_name already present" + continue + fi + if ! download_tsanger "$cn_name" "$local_name"; then + cn_failed=$((cn_failed + 1)) + fi + done + if [[ "$cn_failed" -gt 0 ]]; then + echo "" + echo "Some TsangerJinKai files could not be downloaded. Alternatives:" + echo " 1. Install Source Han Serif SC: brew install --cask font-source-han-serif-sc" + echo " 2. Copy TsangerJinKai02-W04.ttf and W05.ttf manually into $FONT_DIR" + # Don't exit yet, try the KO recovery too so a Korean-only user still gets KO fonts. + fi + fi +fi + +ko_failed=0 +if ko_present_in "$REPO_FONT_DIR"; then + echo "OK: Source Han Serif K fonts present in repo checkout ($REPO_FONT_DIR)" +else + mkdir -p "$FONT_DIR" + if ko_present_in "$FONT_DIR"; then + echo "OK: Source Han Serif K fonts present ($FONT_DIR)" + else + echo "Downloading Source Han Serif K fonts to $FONT_DIR ..." + for local_name in "${KO_NAMES[@]}"; do + if check_size "$FONT_DIR/$local_name" "$MIN_SIZE_KO"; then + echo " OK: $local_name already present" + continue + fi + if ! download_ko_serif "$local_name"; then + ko_failed=$((ko_failed + 1)) + fi + done + if [[ "$ko_failed" -gt 0 ]]; then + echo "" + echo "Some Source Han Serif K files could not be downloaded. Alternatives:" + echo " 1. Download from https://github.com/adobe-fonts/source-han-serif/releases" + echo " 2. Copy SourceHanSerifKR-Regular.otf and -Medium.otf manually into $FONT_DIR" + fi + fi +fi + +if [[ "$cn_failed" -gt 0 || "$ko_failed" -gt 0 ]]; then + exit 1 +fi + +refresh_fontconfig +echo "OK: all fonts ready" diff --git a/plugins/kami/skills/kami/scripts/highlight.py b/plugins/kami/skills/kami/scripts/highlight.py new file mode 100644 index 0000000..335d860 --- /dev/null +++ b/plugins/kami/skills/kami/scripts/highlight.py @@ -0,0 +1,141 @@ +"""Lightweight syntax highlighting for Kami HTML templates. + +Scans HTML for <pre><code class="language-*"> blocks and applies +Pygments-based inline-style highlighting using Kami design tokens. +Blocks without a language- class pass through unchanged. +""" +from __future__ import annotations + +import html as html_mod +import re +import sys + +from shared import token_value + +CODE_BLOCK_RE = re.compile( + r'(<pre[^>]*>\s*<code\s+class="language-([\w+-]+)"[^>]*>)' + r'(.*?)' + r'(</code>\s*</pre>)', + re.DOTALL, +) + +_KAMI_PALETTE: dict[str, str] | None = None + + +def _kami_palette() -> dict[str, str]: + """Resolve design-token colors lazily. + + Kept out of module scope so importing this module (e.g. by build.py for a + command that never highlights code) does not read tokens.json. That keeps + the import resilient on a half-installed checkout, matching shared.py's + baked-in fallbacks. + """ + global _KAMI_PALETTE + if _KAMI_PALETTE is None: + _KAMI_PALETTE = { + "brand": token_value("brand"), + "stone": token_value("stone"), + "olive": token_value("olive"), + "dark_warm": token_value("dark-warm"), + "near_black": token_value("near-black"), + } + return _KAMI_PALETTE + + +_WARNED_MISSING_PYGMENTS = False + + +def _warn_missing_pygments() -> None: + global _WARNED_MISSING_PYGMENTS + if _WARNED_MISSING_PYGMENTS: + return + print( + "WARN: Pygments is not installed; language-tagged code blocks will render monochrome. " + "Install with `python3 -m pip install Pygments` to enable syntax highlighting.", + file=sys.stderr, + ) + _WARNED_MISSING_PYGMENTS = True + + +def _build_kami_style(): + from pygments.style import Style + from pygments.token import ( + Comment, Keyword, Literal, Name, Number, Operator, + Punctuation, String, Token, + ) + + palette = _kami_palette() + + class KamiStyle(Style): + background_color = "" + default_style = "" + styles = { + Token: "", + Comment: palette["stone"], + Comment.Single: palette["stone"], + Comment.Multiline: palette["stone"], + Comment.Preproc: palette["stone"], + Keyword: palette["brand"], + Keyword.Constant: palette["brand"], + Keyword.Namespace: palette["brand"], + Keyword.Type: palette["brand"], + Name.Builtin: palette["brand"], + Name.Function: palette["near_black"], + Name.Class: palette["near_black"], + Name.Decorator: palette["olive"], + String: palette["olive"], + String.Doc: palette["stone"], + Number: palette["dark_warm"], + Number.Float: palette["dark_warm"], + Number.Integer: palette["dark_warm"], + Literal: palette["dark_warm"], + Operator: "", + Punctuation: "", + } + + return KamiStyle + + +def _highlight_block(match: re.Match[str]) -> str: + from pygments import highlight as pyg_highlight + from pygments.formatters import HtmlFormatter + from pygments.lexers import get_lexer_by_name + + open_tag = match.group(1) + language = match.group(2) + code = match.group(3) + close_tag = match.group(4) + + code_text = html_mod.unescape(code) + + try: + lexer = get_lexer_by_name(language, stripall=False) + except Exception: + return match.group(0) + + formatter = HtmlFormatter( + style=_build_kami_style(), + noclasses=True, + nowrap=True, + ) + + highlighted = pyg_highlight(code_text, lexer, formatter) + return f'{open_tag}{highlighted}{close_tag}' + + +def highlight_code_blocks(html_text: str) -> str: + """Apply syntax highlighting to language-tagged code blocks. + + Returns HTML unchanged if Pygments is not installed or no + language-tagged blocks are found. + """ + if not CODE_BLOCK_RE.search(html_text): + return html_text + + try: + import pygments # noqa: F401 + except ImportError: + _warn_missing_pygments() + return html_text + + return CODE_BLOCK_RE.sub(_highlight_block, html_text) diff --git a/plugins/kami/skills/kami/scripts/lint.py b/plugins/kami/skills/kami/scripts/lint.py new file mode 100644 index 0000000..6c44e68 --- /dev/null +++ b/plugins/kami/skills/kami/scripts/lint.py @@ -0,0 +1,425 @@ +"""Template lint rules and cross-template consistency checks. + +Splits out from build.py: + - scan_file: per-line + per-block lint for HTML/CSS/PPTX templates. + - check_all: scan every template and aggregate findings by rule. + - check_cross_template_consistency: pair CN/EN templates and report :root + variable drift outside the allowlist. + +Each `Finding` is anchored to a file path + line number so editors can jump +straight to the violation. Rules encode real WeasyPrint pitfalls (rgba on +background, thin border with border-radius, etc.), not style preferences. +""" +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from pathlib import Path + +from shared import ( + COOL_GRAY_BLOCKLIST, + HTML_TEMPLATES, + ROOT, + SCREEN_TEMPLATES, + TEMPLATES, + TOKENS_FILE, + iter_template_files, +) +from tokens import ROOT_BLOCK, parse_root_vars + +# Font-stack vars legitimately differ between a base template and its locale +# variants (-en, -ko); every other :root var must match across the pair. +CROSS_TEMPLATE_ALLOWED_VARS = {"--serif", "--sans", "--mono", "--latin-ui"} + +RGBA_BG_DIRECT = re.compile(r"background(?:-color)?\s*:\s*[^;]*rgba\s*\(", re.IGNORECASE) +RGBA_VAR_DEF = re.compile(r"--([\w-]+)\s*:\s*[^;]*rgba\s*\(", re.IGNORECASE) +BG_VAR_USE = re.compile(r"background(?:-color)?\s*:\s*[^;]*var\s*\(\s*--([\w-]+)", re.IGNORECASE) +RGBA_BORDER_DIRECT = re.compile(r"border(?:-\w+)?\s*:\s*[^;]*rgba\s*\(", re.IGNORECASE) +BORDER_VAR_USE = re.compile(r"border(?:-\w+)?\s*:\s*[^;]*var\s*\(\s*--([\w-]+)", re.IGNORECASE) +LINE_HEIGHT_LOOSE = re.compile(r"line-height\s*:\s*1\.[6-9]\d*", re.IGNORECASE) +UNICODE_ARROW = re.compile(r"→") # U+2192; should not appear in EN template body +HEX_ANY = re.compile(r"#[0-9a-fA-F]{3,6}\b") +# Thin closed border: border shorthand (not single-side) with sub-1pt width -- pitfall #2 +THIN_CLOSED_BORDER = re.compile( + r"border(?!-(?:left|right|top|bottom))\s*:\s*[^;]*0\.\d+pt", + re.IGNORECASE, +) +BORDER_RADIUS_PROP = re.compile(r"border-radius\s*:", re.IGNORECASE) +CSS_BLOCK_COMMENT_RE = re.compile(r"/\*.*?\*/", re.DOTALL) +SVG_BLOCK_RE = re.compile(r"<svg\b.*?</svg>", re.DOTALL | re.IGNORECASE) + +# WeasyPrint-unsafe artifacts of un-normalized beautiful-mermaid SVG. These must +# never reach a PDF-bound template/diagram: WeasyPrint does not resolve +# color-mix(), render <foreignObject>, or fetch a runtime web font. The author +# must pipe Mermaid output through scripts/mermaid_normalize.py first. Screen-only +# landing pages are exempt (color-mix in CSS is fine in a real browser). +MERMAID_UNSAFE = { + "mermaid-color-mix": re.compile(r"color-mix\s*\(", re.IGNORECASE), + "mermaid-foreignobject": re.compile(r"<foreignObject\b", re.IGNORECASE), + "mermaid-webfont-import": re.compile(r"fonts\.googleapis\.com", re.IGNORECASE), +} + + +@dataclass +class Finding: + file: Path + line: int + rule: str + excerpt: str + + +def _strip_css_block_comments(text: str) -> str: + """Replace `/* ... */` with spaces of the same length so commented-out + rgba()/cool-gray literals don't trip the per-line scan. Length-preserving + so line numbers and per-line search offsets remain correct. + """ + def repl(m: re.Match[str]) -> str: + return "".join(ch if ch == "\n" else " " for ch in m.group(0)) + return CSS_BLOCK_COMMENT_RE.sub(repl, text) + + +def scan_file(path: Path) -> list[Finding]: + findings: list[Finding] = [] + raw_text = path.read_text(encoding="utf-8", errors="replace") + text = _strip_css_block_comments(raw_text) + lines = text.splitlines() + + # Pass 1: collect variable names that hold rgba(...) so the tag-background + # bug can be detected through one level of indirection. + rgba_vars: set[str] = set() + for raw in lines: + m = RGBA_VAR_DEF.search(raw) + if m: + rgba_vars.add(m.group(1)) + + is_en = path.name.endswith("-en.html") + # Screen-only templates (landing pages) never go through WeasyPrint, so the + # Mermaid-unsafe-SVG rule does not apply to them. + is_screen = path.name in set(SCREEN_TEMPLATES.values()) + + # Pass 2: per-line rule checks + is_python = path.suffix == ".py" + for i, raw in enumerate(lines, start=1): + line = raw.strip() + if not line: + continue + # Skip comment lines. Note: '#' alone is NOT a CSS or HTML comment; it + # is the start of a CSS id selector (e.g. `#hero-bg { ... }`) or part of + # a hex literal. Only treat '#' as a comment when scanning Python. + if line.startswith("//"): + continue + if line.startswith("<!--"): + continue + if is_python and line.startswith("#"): + continue + + if RGBA_BG_DIRECT.search(raw): + findings.append(Finding(path, i, "rgba-background", + "rgba() used directly on background (tag double-rectangle bug)")) + + bg_var = BG_VAR_USE.search(raw) + if bg_var and bg_var.group(1) in rgba_vars: + findings.append(Finding(path, i, "rgba-background", + f"background: var(--{bg_var.group(1)}) resolves to rgba() (tag double-rectangle bug)")) + + if RGBA_BORDER_DIRECT.search(raw): + findings.append(Finding(path, i, "rgba-border", + "rgba() used on border (violates solid-color invariant)")) + + border_var = BORDER_VAR_USE.search(raw) + if border_var and border_var.group(1) in rgba_vars: + findings.append(Finding(path, i, "rgba-border", + f"border: var(--{border_var.group(1)}) resolves to rgba() (solid-color invariant)")) + + if is_en and UNICODE_ARROW.search(raw): + # skip CSS comment lines (/* ... */) and the arrow-in-CSS-content patterns + stripped = raw.lstrip() + if not stripped.startswith("/*") and not stripped.startswith("*") and "content:" not in raw: + findings.append(Finding(path, i, "arrow-unicode-in-en", + "to (U+2192) in English template; use 'to' or '->' per patterns Section 2")) + + m = LINE_HEIGHT_LOOSE.search(raw) + if m: + findings.append(Finding(path, i, "line-height-too-loose", + f"{m.group(0)} exceeds 1.55 ceiling")) + + for hex_match in HEX_ANY.finditer(raw): + h = hex_match.group(0).lower() + if h in COOL_GRAY_BLOCKLIST: + findings.append(Finding(path, i, "cool-gray", + f"{h} is a cool / neutral gray, use warm undertone")) + + if not is_screen and not is_python: + for rule, pattern in MERMAID_UNSAFE.items(): + if pattern.search(raw): + findings.append(Finding(path, i, rule, + "un-normalized Mermaid SVG (run scripts/mermaid_normalize.py before embedding)")) + + # Pass 3: thin-border-radius block scan (pitfall #2 double-ring). + # For each thin closed border line, scan backward to the block open and + # forward to the block close, checking for border-radius in the same block. + for i, raw in enumerate(lines): + if not THIN_CLOSED_BORDER.search(raw): + continue + if "skip-thin-border-radius" in raw: + continue + found = False + # Scan backward; stop at { or } (entering/leaving a block). + for j in range(i - 1, max(0, i - 6) - 1, -1): + if "{" in lines[j] or "}" in lines[j]: + break + if BORDER_RADIUS_PROP.search(lines[j]): + found = True + break + # Scan forward; stop at } (leaving the block). + if not found: + for j in range(i + 1, min(len(lines), i + 6)): + if "}" in lines[j]: + break + if BORDER_RADIUS_PROP.search(lines[j]): + found = True + break + if found: + findings.append(Finding(path, i + 1, "thin-border-radius", + "thin border (<1pt) with border-radius -- pitfall #2 double-ring risk")) + return findings + + +def check_all(verbose: bool) -> int: + targets = iter_template_files(include_py=True, include_diagrams=True, include_marp_css=True) + if not targets: + print("ERROR: no templates found to lint (bad checkout?)") + return 2 + + findings: list[Finding] = [] + for p in targets: + file_findings = scan_file(p) + findings.extend(file_findings) + if verbose: + print(f"scanned {p.relative_to(ROOT)}: {len(file_findings)} finding(s)") + + if not findings: + print(f"OK: no violations across {len(targets)} templates") + return 0 + + by_rule: dict[str, list[Finding]] = {} + for f in findings: + by_rule.setdefault(f.rule, []).append(f) + + print(f"ERROR: {len(findings)} violation(s) across {len({f.file for f in findings})} file(s)") + for rule, items in by_rule.items(): + print(f"\n[{rule}] {len(items)}") + for f in items: + rel = f.file.relative_to(ROOT) + print(f" {rel}:{f.line} {f.excerpt}") + return 1 + + +# ---------- off-palette color guard ---------- +# +# design.md core invariant: a single chromatic accent (ink-blue) plus warm +# neutrals, zero cool tones. The salmon-border regression slipped past the +# token-drift guard because it was a hardcoded hex inside a component rule, not +# a :root token. This guard mechanizes the invariant: any hex literal in an +# editorial template that is neither a registered token value nor a cool-gray +# (those have their own rule) is an off-palette color. The single sanctioned +# semantic exception (the changelog breaking-change badge) is registered as the +# --breaking-* tokens, so it lands in `allowed` and passes. +# +# Scope is deliberately narrow: editorial TEMPLATES/*.html only. Diagrams use +# warm-gray chart ramps that are intentionally not tokens, and inline <svg> +# charts carry their own fills -- both are skipped (diagrams by directory, svg +# by block). :root blocks define the tokens themselves, so they are skipped too. + + +def _blank_block(text: str, regex: re.Pattern[str]) -> str: + """Replace each match with same-length whitespace (newlines preserved) so + line numbers stay accurate after a block is masked out.""" + def repl(m: re.Match[str]) -> str: + return "".join(ch if ch == "\n" else " " for ch in m.group(0)) + return regex.sub(repl, text) + + +def _load_token_values() -> set[str]: + """Return the set of canonical token hex values (lowercased).""" + if not TOKENS_FILE.exists(): + return set() + try: + data = json.loads(TOKENS_FILE.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return set() + return {v.lower() for v in data.values() if isinstance(v, str) and v.startswith("#")} + + +def _off_palette_findings(path: Path, allowed: set[str]) -> list[Finding]: + raw = path.read_text(encoding="utf-8", errors="replace") + text = _strip_css_block_comments(raw) + text = _blank_block(text, ROOT_BLOCK) + text = _blank_block(text, SVG_BLOCK_RE) + findings: list[Finding] = [] + for i, line in enumerate(text.splitlines(), start=1): + for m in HEX_ANY.finditer(line): + h = m.group(0).lower() + if h in allowed: + continue + if h in COOL_GRAY_BLOCKLIST: + continue # reported by the cool-gray rule in scan_file + findings.append(Finding(path, i, "off-palette", + f"{h} is not a registered token; single-accent palette violated")) + return findings + + +ROOT_TOKEN_DEF = re.compile(r"(--[\w-]+)\s*:\s*(#[0-9a-fA-F]{3,6})\b") + + +def _root_token_findings(path: Path, allowed: set[str]) -> list[Finding]: + """Flag `:root` token definitions whose hex is off the registered palette. + + `_off_palette_findings` blanks the `:root` block before scanning property + values, so a dead or off-palette token *defined* in `:root` but never written + as a literal hex in a property escapes every guard (this is how a stray + `--brand-deep: #a64f33` second accent hid in portfolio.html). This closes that + gap for print templates: every `:root` chromatic token must resolve to a + registered tokens.json value. Screen templates (landing pages) keep their own + local tokens outside the print palette, so callers exempt them. + """ + raw = path.read_text(encoding="utf-8", errors="replace") + text = _strip_css_block_comments(raw) + findings: list[Finding] = [] + for block in ROOT_BLOCK.finditer(text): + body_start = block.start(1) + for vm in ROOT_TOKEN_DEF.finditer(block.group(1)): + h = vm.group(2).lower() + if h in allowed: + continue + if h in COOL_GRAY_BLOCKLIST: + continue # reported by the cool-gray rule in scan_file + line = text.count("\n", 0, body_start + vm.start(2)) + 1 + findings.append(Finding(path, line, "off-palette-token", + f"{vm.group(1)}: {h} is a :root token off the registered palette " + "(single-accent invariant; register in tokens.json or remove)")) + return findings + + +def check_off_palette(verbose: bool = False) -> int: + allowed = _load_token_values() + screen_names = set(SCREEN_TEMPLATES.values()) + targets = sorted(TEMPLATES.glob("*.html")) + if not targets: + print("ERROR: no templates found for off-palette scan (bad checkout?)") + return 2 + findings: list[Finding] = [] + for p in targets: + file_findings = _off_palette_findings(p, allowed) + if p.name not in screen_names: + file_findings.extend(_root_token_findings(p, allowed)) + findings.extend(file_findings) + if verbose: + print(f"scanned {p.relative_to(ROOT)}: {len(file_findings)} off-palette finding(s)") + + if not findings: + print(f"OK: no off-palette colors across {len(targets)} template(s)") + return 0 + + print(f"\nERROR: [off-palette] {len(findings)}") + for f in findings: + print(f" {f.file.relative_to(ROOT)}:{f.line} {f.excerpt}") + return 1 + + +# ---------- cross-template consistency ---------- +# +# The project intentionally ships CN/EN templates as forked single-file HTML +# (no shared partials). The price of that decision is drift: a maintainer +# updating one side of a pair can silently leave the other behind. This check +# pairs each base template (e.g. `foo.html`) with every recognized locale +# variant (`foo-en.html`, `foo-ko.html`), parses the `:root { ... }` block of +# each, and flags variables that differ. Font-stack variables (`--serif`, +# `--sans`, `--mono`, `--latin-ui`) are allowlisted because each locale +# deliberately uses different fonts. + +_VARIANT_SUFFIXES: tuple[str, ...] = ("-en", "-ko") + + +def _pair_names() -> list[tuple[str, str]]: + """Return [(base_name, variant_name), ...] for every base template that has + one of the recognized locale-variant siblings (`-en`, `-ko`). + + A base template is any registered name that does not itself end in a + recognized variant suffix. + """ + pairs: list[tuple[str, str]] = [] + seen = set(HTML_TEMPLATES) | set(SCREEN_TEMPLATES) + for name in sorted(seen): + if any(name.endswith(s) for s in _VARIANT_SUFFIXES): + continue + for suffix in _VARIANT_SUFFIXES: + variant = f"{name}{suffix}" + if variant in seen: + pairs.append((name, variant)) + return pairs + + +def _source_for(name: str) -> tuple[Path, Path]: + """Return (source path, directory) for a template name across registries.""" + if name in HTML_TEMPLATES: + return TEMPLATES / HTML_TEMPLATES[name].source, TEMPLATES + if name in SCREEN_TEMPLATES: + return TEMPLATES / SCREEN_TEMPLATES[name], TEMPLATES + raise KeyError(f"unknown template name: {name}") + + +def _extract_root_vars(html_path: Path) -> dict[str, str]: + """Return {var_name: value} merged across every `:root { ... }` block.""" + text = html_path.read_text(encoding="utf-8", errors="replace") + return parse_root_vars(text) + + +def check_cross_template_consistency(verbose: bool = False) -> int: + pairs = _pair_names() + if not pairs: + print("ERROR: no base-variant template pairs found (bad checkout?)") + return 2 + drift: list[tuple[str, str, str, str]] = [] # (pair, var, base_value, variant_value) + + for base_name, variant_name in pairs: + try: + base_path, _ = _source_for(base_name) + variant_path, _ = _source_for(variant_name) + except KeyError: + continue + if not base_path.exists() or not variant_path.exists(): + continue + + base_vars = _extract_root_vars(base_path) + variant_vars = _extract_root_vars(variant_path) + + shared_keys = set(base_vars) & set(variant_vars) + for key in sorted(shared_keys): + if key in CROSS_TEMPLATE_ALLOWED_VARS: + continue + if base_vars[key].lower() != variant_vars[key].lower(): + drift.append((base_name, key, base_vars[key], variant_vars[key])) + + # A var defined on only one side is drift too: it usually means a + # maintainer added or dropped a token on one side of the fork. That is + # exactly the "left the other side behind" failure this check exists + # to catch, so report it instead of silently comparing the overlap. + for key in sorted((set(base_vars) ^ set(variant_vars)) - CROSS_TEMPLATE_ALLOWED_VARS): + if key in base_vars: + drift.append((base_name, key, base_vars[key], f"missing from {variant_name}")) + else: + drift.append((base_name, key, f"missing from {base_name}", variant_vars[key])) + + if verbose: + print(f" pair {base_name}/{variant_name}: checked {len(shared_keys)} shared vars") + + if not drift: + print(f"OK: cross-template :root vars in sync across {len(pairs)} base-variant pair(s)") + return 0 + + print(f"\nERROR: [cross-template-drift] {len(drift)}") + for pair, var, base_val, variant_val in drift: + print(f" {pair}: {var} base={base_val} variant={variant_val}") + return 1 diff --git a/plugins/kami/skills/kami/scripts/mermaid_normalize.py b/plugins/kami/skills/kami/scripts/mermaid_normalize.py new file mode 100644 index 0000000..6d942a1 --- /dev/null +++ b/plugins/kami/skills/kami/scripts/mermaid_normalize.py @@ -0,0 +1,343 @@ +#!/usr/bin/env python3 +"""Re-theme and normalize a beautiful-mermaid SVG into a Kami-styled, WeasyPrint-safe SVG. + +beautiful-mermaid (https://github.com/lukilabs/beautiful-mermaid) hangs its seven +color roles on CSS custom properties on the root ``<svg>`` and derives the rest with +``color-mix(in srgb, ...)``. WeasyPrint's inline-SVG renderer does not resolve +``color-mix()``, does not reliably cascade SVG ``<style>`` custom properties, and +should not fetch a runtime web font. This script: + + 1. **Re-themes** the SVG to the Kami palette by overriding the seven root color + roles with ``references/mermaid-theme.json`` values, so the diagram looks like + Kami no matter which theme it was generated with. + 2. **Resolves** every ``var()`` and ``color-mix(in srgb, ...)`` to a static hex, so + colors land on inline presentation attributes WeasyPrint renders directly. + 3. **Fixes fonts**: strips beautiful-mermaid's Google-Fonts ``@import`` and rewrites + the (mis-quoted) ``font-family`` to the Kami serif stack (with CJK fallback). + +No Node, no network, pure stdlib. Generate the SVG anywhere beautiful-mermaid runs +(e.g. https://agents.craft.do/mermaid or your own one-off script), then run this. + +Scope: graph-type diagrams (flowchart / state / sequence / class / ER) whose colors +flow from the seven root roles. xychart-beta styles via ``<style>`` class selectors, +which WeasyPrint will not apply to inline SVG, so charts stay browser-only; use +Kami's hand-drawn bar/line/donut/candlestick/waterfall diagrams for PDF. See +references/mermaid.md. + +Usage: + python3 scripts/mermaid_normalize.py raw.svg # cleaned SVG to stdout + python3 scripts/mermaid_normalize.py raw.svg -o out.svg + cat raw.svg | python3 scripts/mermaid_normalize.py - # read from stdin +""" +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path + +_ROOT = Path(__file__).resolve().parent.parent +_THEME_FILE = _ROOT / "references" / "mermaid-theme.json" + +# Fallbacks if references/mermaid-theme.json is missing. Mirror that file and +# references/design.md. +_DEFAULT_FONT_STACK = ( + 'Charter, Georgia, "TsangerJinKai02", "Source Han Serif SC", ' + '"Noto Serif CJK SC", serif' +) +_DEFAULT_COLORS = { + "--bg": "#f5f4ed", "--fg": "#141413", "--line": "#504e49", + "--accent": "#1B365D", "--muted": "#6b6a64", "--surface": "#faf9f5", + "--border": "#e8e6dc", +} + +# A handful of CSS named colors beautiful-mermaid may emit. Hex is preferred. +_NAMED = {"white": (255, 255, 255), "black": (0, 0, 0), "transparent": None} + + +def _load_theme() -> tuple[dict[str, str], str]: + """Return (kami color-role overrides, font stack) from the theme file.""" + try: + data = json.loads(_THEME_FILE.read_text(encoding="utf-8")) + colors = {f"--{k}": v for k, v in data.get("colors", {}).items()} + font = data.get("cssFontStack") or _DEFAULT_FONT_STACK + if colors: + return colors, font + except (OSError, ValueError): + pass + return dict(_DEFAULT_COLORS), _DEFAULT_FONT_STACK + + +# --------------------------------------------------------------------------- +# color helpers +# --------------------------------------------------------------------------- + +def _parse_hex(value: str) -> tuple[int, int, int]: + h = value.strip().lstrip("#") + if len(h) == 3: + h = "".join(c * 2 for c in h) + if len(h) != 6: + raise ValueError(f"not a hex color: {value!r}") + return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16) + + +def _to_hex(rgb: tuple[float, float, float]) -> str: + return "#" + "".join(f"{max(0, min(255, round(c))):02x}" for c in rgb) + + +def _mix_srgb(c1: tuple[int, int, int], p1: float, + c2: tuple[int, int, int], p2: float) -> tuple[float, float, float]: + """color-mix(in srgb, c1 p1%, c2 p2%): linear blend in gamma sRGB. + + Percentages are normalized to sum to 100 (per CSS Color 4). + """ + total = p1 + p2 or 1.0 + w1, w2 = p1 / total, p2 / total + return tuple(a * w1 + b * w2 for a, b in zip(c1, c2)) + + +# --------------------------------------------------------------------------- +# balanced-paren parsing +# --------------------------------------------------------------------------- + +def _match_paren(s: str, open_idx: int) -> int: + """Given index of a '(', return index of its matching ')'.""" + depth = 0 + for i in range(open_idx, len(s)): + if s[i] == "(": + depth += 1 + elif s[i] == ")": + depth -= 1 + if depth == 0: + return i + raise ValueError("unbalanced parentheses") + + +def _split_top(s: str, sep: str = ",") -> list[str]: + """Split on `sep` at paren depth 0 only.""" + parts, depth, start = [], 0, 0 + for i, ch in enumerate(s): + if ch == "(": + depth += 1 + elif ch == ")": + depth -= 1 + elif ch == sep and depth == 0: + parts.append(s[start:i]) + start = i + 1 + parts.append(s[start:]) + return [p.strip() for p in parts] + + +class _Resolver: + """Resolves a CSS color value to a static hex using a custom-property map.""" + + def __init__(self, raw_defs: dict[str, str]): + self._raw = raw_defs + + def hex_of(self, value: str) -> str | None: + rgb = self._rgb(value) + return None if rgb is None else _to_hex(rgb) + + def var_map(self) -> dict[str, str]: + out: dict[str, str] = {} + for name in self._raw: + h = self.hex_of(f"var({name})") + if h is not None: + out[name] = h + return out + + def _rgb(self, value: str, _seen: frozenset[str] = frozenset()) -> tuple[int, int, int] | None: + value = value.strip().rstrip(";").strip() + if not value: + return None + if value.startswith("#"): + return _parse_hex(value) + low = value.lower() + if low in _NAMED: + return _NAMED[low] + if value.startswith("var(") and value.endswith(")"): + inner = value[4:_match_paren(value, 3)] + args = _split_top(inner) + name = args[0].strip() + fallback = args[1] if len(args) > 1 else None + if name in _seen: + return None # cycle guard + if name in self._raw: + return self._rgb(self._raw[name], _seen | {name}) + if fallback is not None: + return self._rgb(fallback, _seen | {name}) + return None + if value.startswith("color-mix(") and value.endswith(")"): + inner = value[len("color-mix("):_match_paren(value, len("color-mix"))] + args = _split_top(inner) + # args[0] is the color space, e.g. "in srgb" + if not args or "srgb" not in args[0]: + raise ValueError(f"unsupported color-mix space: {args[0] if args else '?'}") + ops = [self._parse_operand(a, _seen) for a in args[1:3]] + (c1, p1), (c2, p2) = ops[0], ops[1] + if p1 is None and p2 is None: + p1 = p2 = 50.0 + elif p1 is None: + p1 = max(0.0, 100.0 - p2) + elif p2 is None: + p2 = max(0.0, 100.0 - p1) + if c1 is None or c2 is None: + return None + return tuple(round(x) for x in _mix_srgb(c1, p1, c2, p2)) + return None + + def _parse_operand(self, token: str, + _seen: frozenset[str]) -> tuple[tuple[int, int, int] | None, float | None]: + """Parse a color-mix operand 'COLOR' or 'COLOR P%'.""" + token = token.strip() + pct = None + m = re.search(r"\s(\d+(?:\.\d+)?)%\s*$", " " + token) + if m: + pct = float(m.group(1)) + token = token[: token.rfind(m.group(1) + "%")].strip() + return self._rgb(token, _seen), pct + + +# --------------------------------------------------------------------------- +# SVG transforms +# --------------------------------------------------------------------------- + +_STYLE_RE = re.compile(r"<style[^>]*>(.*?)</style>", re.DOTALL) +_CUSTOM_PROP_RE = re.compile(r"(--[\w-]+)\s*:\s*([^;]*?(?:\([^)]*\)[^;]*?)*);") +_IMPORT_RE = re.compile(r"@import\s+url\([^)]*\)\s*;", re.IGNORECASE) +_FONT_FAMILY_RE = re.compile(r"font-family\s*:\s*[^;}]+") + + +def _collect_raw_defs(svg: str, overrides: dict[str, str]) -> dict[str, str]: + """Gather custom-property defs from the root <svg style> and <style> svg{} rules. + + `overrides` (the Kami color roles) replace any same-named root role, re-theming + the diagram regardless of the theme it was generated with. + """ + raw: dict[str, str] = {} + m = re.search(r"<svg\b[^>]*\bstyle=\"([^\"]*)\"", svg) + if m: + for prop in m.group(1).split(";"): + if ":" in prop: + k, v = prop.split(":", 1) + k = k.strip() + if k.startswith("--"): + raw[k] = v.strip() + for sm in _STYLE_RE.finditer(svg): + for pm in _CUSTOM_PROP_RE.finditer(sm.group(1)): + raw[pm.group(1)] = pm.group(2).strip() + raw.update(overrides) # Kami palette wins + return raw + + +def _resolve_functions(text: str, resolver: _Resolver) -> str: + """Replace every var()/color-mix() in `text` with a static hex, innermost-first. + + Raises if any function cannot be resolved to a color: in well-formed + beautiful-mermaid output every var()/color-mix() resolves, so an unresolved + one means the input structure changed. Failing loudly beats silently emitting + an invalid color that renders as a black or invisible diagram. + """ + while True: + starts = [m.start() for m in re.finditer(r"\b(?:var|color-mix)\(", text)] + if not starts: + break + # rightmost opening = guaranteed leaf (no nested var/color-mix after it) + start = max(starts) + open_paren = text.index("(", start) + close = _match_paren(text, open_paren) + expr = text[start:close + 1] + hexval = resolver.hex_of(expr) + if hexval is None: + raise ValueError( + f"could not resolve {expr!r} to a color; the input may not be " + "beautiful-mermaid output or uses an unsupported structure" + ) + text = text[:start] + hexval + text[close + 1:] + return text + + +def _assert_beautiful_mermaid(svg: str) -> None: + """Raise unless the SVG carries beautiful-mermaid's root color-role props. + + beautiful-mermaid v1.x defines --bg / --fg (plus the optional roles) as inline + custom properties on the root <svg>. Their absence means the input came from a + different renderer whose structure this normalizer does not understand, so we + fail loudly here instead of silently producing unresolved colors downstream. + Verified against beautiful-mermaid v1.1.3; see references/mermaid.md. + """ + m = re.search(r"<svg\b[^>]*\bstyle=\"([^\"]*)\"", svg) + style = m.group(1) if m else "" + if "--bg" not in style or "--fg" not in style: + raise ValueError( + "input does not look like beautiful-mermaid output: the root <svg> is " + "missing the --bg/--fg color roles (verified against v1.1.3)" + ) + + +def normalize(svg: str, theme: dict[str, str] | None = None, + font_stack: str | None = None) -> str: + """Return a Kami-re-themed, WeasyPrint-safe copy of a beautiful-mermaid SVG.""" + if theme is None or font_stack is None: + default_colors, default_font = _load_theme() + theme = theme or default_colors + font_stack = font_stack or default_font + + _assert_beautiful_mermaid(svg) + raw_defs = _collect_raw_defs(svg, theme) + resolver = _Resolver(raw_defs) + + out = _resolve_functions(svg, resolver) + out = _IMPORT_RE.sub("", out) + out = _FONT_FAMILY_RE.sub(f"font-family: {font_stack}", out) + + # The derived custom props were inlined into presentation attributes, so the + # <style> svg{} rules and the root role decls are now dead. Strip them, keeping + # only live rules (e.g. the rewritten font-family). + def _clean_style(m: re.Match[str]) -> str: + body = m.group(1) + body = re.sub(r"--[\w-]+\s*:\s*#[0-9a-fA-F]{3,8}\s*;", "", body) + body = re.sub(r"[\w*.#-]+\s*\{\s*(?:/\*.*?\*/\s*)?\}", "", body, flags=re.DOTALL) + body = re.sub(r"\n\s*\n+", "\n", body).strip() + return f"<style>\n {body}\n</style>" if body else "" + + out = _STYLE_RE.sub(_clean_style, out) + + def _clean_root_style(m: re.Match[str]) -> str: + kept = [d.strip() for d in m.group(1).split(";") + if d.strip() and not d.strip().startswith("--")] + return f' style="{";".join(kept)}"' if kept else "" + + out = re.sub(r'\s+style="([^"]*)"', _clean_root_style, out, count=1) + return out + + +def main(argv: list[str]) -> int: + if len(argv) == 1: + print(__doc__) + return 0 + + parser = argparse.ArgumentParser( + description="Re-theme a beautiful-mermaid SVG into a Kami, WeasyPrint-safe SVG.", + ) + parser.add_argument("src", help="Input SVG path, or '-' to read from stdin") + parser.add_argument("-o", "--output", help="Output SVG path (default: stdout)") + args = parser.parse_args(argv[1:]) + + try: + raw = sys.stdin.read() if args.src == "-" else Path(args.src).read_text(encoding="utf-8") + result = normalize(raw) + if args.output: + Path(args.output).write_text(result, encoding="utf-8") + print(f"OK: wrote {args.output}") + else: + sys.stdout.write(result) + except (OSError, ValueError) as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/plugins/kami/skills/kami/scripts/optional_deps.py b/plugins/kami/skills/kami/scripts/optional_deps.py new file mode 100644 index 0000000..f3c1efa --- /dev/null +++ b/plugins/kami/skills/kami/scripts/optional_deps.py @@ -0,0 +1,74 @@ +"""Centralized loader for optional third-party deps (weasyprint, pypdf, PyMuPDF). + +build.py previously had inline `from weasyprint import HTML` try/except blocks +with duplicated install hints; this module collapses those into one resolver +each so the import error message stays consistent and the WeasyPrint runtime +is configured once at the import call site. +""" +from __future__ import annotations + +import sys + +from shared import configure_weasyprint_runtime + +# On Linux, WeasyPrint links against cairo / pango / harfbuzz at runtime; a bare +# `pip install weasyprint` succeeds but then fails to load with a cryptic +# "cannot load library 'libgobject-2.0'" until the native libs are present. Spell +# them out so the error message is actionable on a fresh Linux box. +_LINUX_NATIVE_LIBS = ( + "Linux also needs native libs: " + "sudo apt-get install -y libcairo2 libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz0b " + "(Debian/Ubuntu), or `sudo dnf install cairo pango harfbuzz` (Fedora/RHEL)" +) + +WEASYPRINT_INSTALL_HINT = "pip install weasyprint pypdf --break-system-packages" +if sys.platform.startswith("linux"): + WEASYPRINT_INSTALL_HINT = f"{WEASYPRINT_INSTALL_HINT}. {_LINUX_NATIVE_LIBS}" +PYMUPDF_INSTALL_HINT = "pip install pymupdf --break-system-packages" + + +class MissingDepError(RuntimeError): + """Raised when an optional dependency is requested but not installed.""" + + +def require_weasyprint_html(): + """Return the weasyprint.HTML class, configuring native libs first.""" + configure_weasyprint_runtime() + try: + from weasyprint import HTML + return HTML + except ImportError as exc: + raise MissingDepError( + f"missing weasyprint. {WEASYPRINT_INSTALL_HINT}" + ) from exc + + +def _require_pypdf_attr(name: str): + try: + import pypdf + except ImportError as exc: + raise MissingDepError( + f"missing pypdf. {WEASYPRINT_INSTALL_HINT}" + ) from exc + return getattr(pypdf, name) + + +def require_pypdf_reader(): + """Return the pypdf.PdfReader class.""" + return _require_pypdf_attr("PdfReader") + + +def require_pypdf_writer(): + """Return the pypdf.PdfWriter class.""" + return _require_pypdf_attr("PdfWriter") + + +def require_pymupdf(): + """Return the PyMuPDF module (imported as fitz).""" + try: + import fitz + return fitz + except ImportError as exc: + raise MissingDepError( + f"missing PyMuPDF. {PYMUPDF_INSTALL_HINT}" + ) from exc diff --git a/plugins/kami/skills/kami/scripts/package-skill.sh b/plugins/kami/skills/kami/scripts/package-skill.sh new file mode 100644 index 0000000..b2820fe --- /dev/null +++ b/plugins/kami/skills/kami/scripts/package-skill.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +OUT="${1:-"$ROOT/dist/kami.zip"}" +case "$OUT" in + /*) ;; + *) OUT="$ROOT/$OUT" ;; +esac +PACKAGE_ROOT_NAME="${KAMI_PACKAGE_ROOT_NAME:-kami}" +PACKAGE_MAX_BYTES="${KAMI_PACKAGE_MAX_BYTES:-6000000}" +PACKAGE_FORBIDDEN_RE='^(\.agents/|\.claude/|\.claude-plugin/|\.github/|plugins/|assets/(showcase|demos|examples|illustrations)/|assets/images/[123]\.png$|assets/fonts/TsangerJinKai02-W0[45]\.ttf$|assets/fonts/SourceHanSerifKR-(Regular|Medium)\.otf$|dist/|index(-[^/]+)?\.html$|styles\.css$|llms\.txt$|robots\.txt$|sitemap\.xml$|vercel\.json$|AGENTS\.md$|CLAUDE\.md$|README\.md$|\.gitignore$|scripts/(build_metadata|draft-release-notes|package-skill)\.py$|scripts/package-skill\.sh$|scripts/tests/)' +PACKAGE_REQUIRED_ENTRIES=( + "SKILL.md" + "CHEATSHEET.md" + "VERSION" + "LICENSE" + "assets/images/logo.svg" + "assets/fonts/JetBrainsMono.woff2" + "assets/templates/resume.html" + "assets/templates/landing-page.html" + "assets/diagrams/sequence.html" + "references/design.md" + "scripts/build.py" + "scripts/ensure-fonts.sh" + "scripts/site_facts.py" +) + +mkdir -p "$(dirname "$OUT")" +rm -f "$OUT" + +cd "$ROOT" + +MANIFEST="$(mktemp)" +FILTERED_MANIFEST="$(mktemp)" +ZIP_MANIFEST="$(mktemp)" +STAGING="$(mktemp -d)" +trap 'rm -f "$MANIFEST" "$FILTERED_MANIFEST" "$ZIP_MANIFEST"; rm -rf "$STAGING"' EXIT + +git ls-files > "$MANIFEST" +awk ' + /(^|\/)__pycache__\// { next } + /\.pyc$/ { next } + /(^|\/)\.DS_Store$/ { next } + /^(SKILL\.md|CHEATSHEET\.md|VERSION|LICENSE)$/ { print; next } + /^assets\/templates\// { print; next } + /^assets\/diagrams\// { print; next } + /^assets\/images\/logo\.svg$/ { print; next } + /^assets\/fonts\/JetBrainsMono\.woff2$/ { print; next } + /^assets\/fonts\/LICENSE-SourceHanSerifK\.txt$/ { print; next } + /^references\// { print; next } + /^scripts\/(build|check-update|checks|ensure-fonts|highlight|lint|mermaid_normalize|optional_deps|shared|site_facts|tokens|verify)\.(py|sh)$/ { print; next } +' "$MANIFEST" > "$FILTERED_MANIFEST" + +# Coverage gate: every tracked scripts/ file must be either packaged by the +# allowlist above or named in the repo-only exclusion below. Without this, a +# new runtime module that build.py imports would silently miss the zip and +# the installed skill would ImportError while every local check stays green. +SCRIPTS_REPO_ONLY_RE='^scripts/(build_metadata\.py|draft-release-notes\.py|package-skill\.sh|tests/)' +unaccounted="$(grep '^scripts/' "$MANIFEST" \ + | grep -Ev "$SCRIPTS_REPO_ONLY_RE" \ + | grep -Fvx -f <(grep '^scripts/' "$FILTERED_MANIFEST" || true) || true)" +if [ -n "$unaccounted" ]; then + echo "ERROR: tracked scripts neither packaged nor listed as repo-only:" >&2 + printf '%s\n' "$unaccounted" >&2 + echo "Add them to the packaging allowlist or to SCRIPTS_REPO_ONLY_RE." >&2 + exit 1 +fi + +while IFS= read -r entry; do + dest="$STAGING/$PACKAGE_ROOT_NAME/$entry" + mkdir -p "$(dirname "$dest")" + cp -p "$entry" "$dest" +done < "$FILTERED_MANIFEST" + +( + cd "$STAGING" + find "$PACKAGE_ROOT_NAME" -type f | sort > "$ZIP_MANIFEST" + zip -X -q "$OUT" -@ < "$ZIP_MANIFEST" +) + +entries="$(zipinfo -1 "$OUT")" +bad_root="$(printf '%s\n' "$entries" | awk -v prefix="${PACKAGE_ROOT_NAME}/" 'index($0, prefix) != 1 { print }')" +if [ -n "$bad_root" ]; then + echo "ERROR: package entries must live under ${PACKAGE_ROOT_NAME}/:" >&2 + printf '%s\n' "$bad_root" >&2 + exit 1 +fi + +stripped_entries="$(printf '%s\n' "$entries" | sed "s#^${PACKAGE_ROOT_NAME}/##")" +if forbidden_entries="$(printf '%s\n' "$stripped_entries" | grep -E "$PACKAGE_FORBIDDEN_RE")"; then + echo "ERROR: disallowed package entry found in $OUT:" >&2 + printf '%s\n' "$forbidden_entries" >&2 + exit 1 +fi + +for required in "${PACKAGE_REQUIRED_ENTRIES[@]}"; do + if ! printf '%s\n' "$entries" | grep -Fxq "${PACKAGE_ROOT_NAME}/${required}"; then + echo "ERROR: required package entry missing from $OUT: ${PACKAGE_ROOT_NAME}/${required}" >&2 + exit 1 + fi +done + +size_bytes="$(wc -c < "$OUT" | tr -d '[:space:]')" +if (( size_bytes > PACKAGE_MAX_BYTES )); then + echo "ERROR: package exceeds ${PACKAGE_MAX_BYTES} bytes: ${size_bytes} bytes" >&2 + exit 1 +fi + +echo "OK: package audit passed (${size_bytes} bytes, limit ${PACKAGE_MAX_BYTES})" +echo "OK: wrote $OUT" diff --git a/plugins/kami/skills/kami/scripts/shared.py b/plugins/kami/skills/kami/scripts/shared.py new file mode 100644 index 0000000..304c1f6 --- /dev/null +++ b/plugins/kami/skills/kami/scripts/shared.py @@ -0,0 +1,283 @@ +"""Shared constants and helpers for kami build scripts.""" +from __future__ import annotations + +import functools +import json +import os +import sys +from pathlib import Path +from typing import Any, NamedTuple + + +class TemplateSpec(NamedTuple): + """Per-template configuration. + + build_max_pages: hard ceiling enforced by `build.py --verify`. 0 = no limit. + """ + source: str + build_max_pages: int + +ROOT = Path(__file__).resolve().parent.parent +TEMPLATES = ROOT / "assets" / "templates" +DIAGRAMS = ROOT / "assets" / "diagrams" +EXAMPLES = ROOT / "assets" / "examples" +TOKENS_FILE = ROOT / "references" / "tokens.json" +CHECKS_THRESHOLDS_FILE = ROOT / "references" / "checks_thresholds.json" + +PUBLIC_REPO = "tw93/kami" +CLAUDE_CODE_MIN_VERSION = "2.1.142" +CLAUDE_CODE_INSTALL_COMMANDS = ( + f"/plugin marketplace add {PUBLIC_REPO}", + "/plugin install kami@kami", +) +CODEX_PLUGIN_INSTALL_COMMANDS = ( + f"codex plugin marketplace add {PUBLIC_REPO}", + "codex plugin add kami@kami", +) +GENERIC_AGENT_INSTALL_COMMAND = f"npx skills add {PUBLIC_REPO}/plugins/kami -a universal -g -y" +CLAUDE_DESKTOP_PACKAGE_URL = "https://github.com/tw93/kami/releases/latest/download/kami.zip" + +# Canonical parchment background color, kept here so build/density +# checks share one source of truth instead of redefining the RGB triple. +PARCHMENT_RGB = (0xF5, 0xF4, 0xED) + +_HOMEBREW_PREFIXES = (Path("/opt/homebrew"), Path("/usr/local")) + + +def _default_cache_dir() -> Path: + """Return a sensible per-platform fontconfig cache directory.""" + if sys.platform == "darwin": + return Path("/private/tmp/kami-fontconfig-cache") + xdg = os.environ.get("XDG_CACHE_HOME") + if xdg: + return Path(xdg) / "kami" + return Path.home() / ".cache" / "kami" + + +def configure_weasyprint_runtime() -> None: + """Make platform-native libraries discoverable before importing WeasyPrint. + + On macOS, also surface Homebrew's gobject lib so cairo/pango can load. + On Linux/other, only the fontconfig cache hint is set; the system loader + is expected to find the libraries. + """ + os.environ.setdefault("XDG_CACHE_HOME", str(_default_cache_dir())) + + if sys.platform != "darwin": + return + + brew_lib = next( + (p / "lib" for p in _HOMEBREW_PREFIXES if (p / "lib" / "libgobject-2.0.dylib").exists()), + None, + ) + if brew_lib is None: + return + + existing = os.environ.get("DYLD_FALLBACK_LIBRARY_PATH", "") + paths = [path for path in existing.split(":") if path] + brew_lib_str = str(brew_lib) + if brew_lib_str in paths: + return + + os.environ["DYLD_FALLBACK_LIBRARY_PATH"] = ":".join([brew_lib_str, *paths]) + +# Cool / neutral gray hex values that violate the "warm undertone only" rule. +COOL_GRAY_BLOCKLIST = { + "#888", "#888888", "#666", "#666666", "#999", "#999999", + "#ccc", "#cccccc", "#ddd", "#dddddd", "#eee", "#eeeeee", + "#111", "#111111", "#222", "#222222", "#333", "#333333", + "#444", "#444444", "#555", "#555555", "#777", "#777777", + "#aaa", "#aaaaaa", "#bbb", "#bbbbbb", + # Tailwind cool grays + "#6b7280", "#9ca3af", "#d1d5db", "#e5e7eb", "#f3f4f6", + "#4b5563", "#374151", "#1f2937", "#111827", + # Bootstrap-like neutrals + "#f8f9fa", "#e9ecef", "#dee2e6", "#ced4da", "#adb5bd", + "#6c757d", "#495057", "#343a40", "#212529", +} + + +# --------------------------------------------------------------------------- +# Template registry +# +# Single source of truth for HTML targets used by build.py. +# See TemplateSpec for field meanings. +# --------------------------------------------------------------------------- +HTML_TEMPLATES: dict[str, TemplateSpec] = { + # Core six + "one-pager": TemplateSpec("one-pager.html", 1), + "letter": TemplateSpec("letter.html", 1), + "long-doc": TemplateSpec("long-doc.html", 0), + "portfolio": TemplateSpec("portfolio.html", 0), + "resume": TemplateSpec("resume.html", 2), + "one-pager-en": TemplateSpec("one-pager-en.html", 1), + "letter-en": TemplateSpec("letter-en.html", 1), + "long-doc-en": TemplateSpec("long-doc-en.html", 0), + "portfolio-en": TemplateSpec("portfolio-en.html", 0), + "resume-en": TemplateSpec("resume-en.html", 2), + # Korean + "one-pager-ko": TemplateSpec("one-pager-ko.html", 1), + "letter-ko": TemplateSpec("letter-ko.html", 1), + "long-doc-ko": TemplateSpec("long-doc-ko.html", 0), + "portfolio-ko": TemplateSpec("portfolio-ko.html", 0), + "resume-ko": TemplateSpec("resume-ko.html", 2), + "equity-report-ko": TemplateSpec("equity-report-ko.html", 3), + "changelog-ko": TemplateSpec("changelog-ko.html", 2), + "slides-weasy-ko": TemplateSpec("slides-weasy-ko.html", 0), + # Equity report + "equity-report": TemplateSpec("equity-report.html", 3), + "equity-report-en": TemplateSpec("equity-report-en.html", 3), + # Changelog + "changelog": TemplateSpec("changelog.html", 2), + "changelog-en": TemplateSpec("changelog-en.html", 2), + # Slides (WeasyPrint default) + "slides-weasy": TemplateSpec("slides-weasy.html", 0), + "slides-weasy-en": TemplateSpec("slides-weasy-en.html", 0), +} + +SCREEN_TEMPLATES: dict[str, str] = { + "landing-page": "landing-page.html", + "landing-page-en": "landing-page-en.html", + "landing-page-ko": "landing-page-ko.html", +} + +# Diagram HTMLs live in assets/diagrams and have no page-count contract. +# Registered here (not in build.py) so all template registries share one home. +# The Mermaid-sourced ones are produced via scripts/mermaid_normalize.py. +DIAGRAM_TEMPLATES: dict[str, str] = { + "diagram-architecture": "architecture.html", + "diagram-architecture-board": "architecture-board.html", + "diagram-flowchart": "flowchart.html", + "diagram-quadrant": "quadrant.html", + "diagram-bar-chart": "bar-chart.html", + "diagram-line-chart": "line-chart.html", + "diagram-donut-chart": "donut-chart.html", + "diagram-state-machine": "state-machine.html", + "diagram-timeline": "timeline.html", + "diagram-swimlane": "swimlane.html", + "diagram-tree": "tree.html", + "diagram-layer-stack": "layer-stack.html", + "diagram-venn": "venn.html", + "diagram-candlestick": "candlestick.html", + "diagram-waterfall": "waterfall.html", + # Mermaid-sourced (beautiful-mermaid + scripts/mermaid_normalize.py) + "diagram-sequence": "sequence.html", + "diagram-class": "class.html", + "diagram-er": "er.html", +} + + +PUBLIC_DOCUMENT_TEMPLATE_KINDS = { + "one-pager", + "letter", + "long-doc", + "portfolio", + "resume", + "slides", + "equity-report", + "changelog", +} + + +def _public_template_kind(name: str) -> str: + for suffix in ("-en", "-ko"): + if name.endswith(suffix): + name = name[: -len(suffix)] + break + if name.startswith("slides-weasy"): + return "slides" + return name + + +def public_document_template_kinds() -> set[str]: + """Return public document-template kinds represented by HTML_TEMPLATES.""" + return { + _public_template_kind(name) + for name in HTML_TEMPLATES + if _public_template_kind(name) in PUBLIC_DOCUMENT_TEMPLATE_KINDS + } + + +def public_document_template_count() -> int: + return len(public_document_template_kinds()) + + +def rel_to_root(path: Path) -> Path: + """Return `path` relative to ROOT when possible, else the path unchanged.""" + return path.relative_to(ROOT) if path.is_relative_to(ROOT) else path + + +def default_example_pdfs() -> list[str]: + """Return every rendered example PDF, the default scan set for PDF checks.""" + return [str(p) for p in sorted(EXAMPLES.glob("*.pdf"))] + + +def iter_template_files( + *, + include_py: bool = False, + include_diagrams: bool = False, + include_marp_css: bool = False, +) -> list[Path]: + """Collect template-family files for scanning. + + One shared walker so lint, token-sync, and future checks cannot silently + diverge in coverage (a divergence is how the Marp CSS family once slipped + out of the lint scan while staying in the token scan). + """ + targets: list[Path] = list(TEMPLATES.glob("*.html")) + if include_py: + targets.extend(TEMPLATES.glob("*.py")) + if include_diagrams and DIAGRAMS.exists(): + targets.extend(DIAGRAMS.glob("*.html")) + if include_marp_css: + marp_dir = TEMPLATES / "marp" + if marp_dir.exists(): + targets.extend(marp_dir.glob("*.css")) + return sorted(targets) + + +@functools.lru_cache(maxsize=1) +def kami_version() -> str: + """Return the canonical Kami version from the tracked VERSION file.""" + return (ROOT / "VERSION").read_text(encoding="utf-8").strip() + + +@functools.lru_cache(maxsize=1) +def load_tokens() -> dict[str, str]: + return json.loads(TOKENS_FILE.read_text(encoding="utf-8")) + + +def token_value(name: str) -> str: + key = name if name.startswith("--") else f"--{name}" + return load_tokens()[key] + + +def build_targets() -> dict[str, tuple[str, int]]: + """Return target -> (source, max_pages) mapping for build.py.""" + return {name: (spec.source, spec.build_max_pages) for name, spec in HTML_TEMPLATES.items()} + + +def screen_targets() -> dict[str, str]: + """Return target -> source mapping for browser-only HTML templates.""" + return dict(SCREEN_TEMPLATES) + + +def diagram_targets() -> dict[str, str]: + """Return target -> source mapping for assets/diagrams HTML templates.""" + return dict(DIAGRAM_TEMPLATES) + + +@functools.lru_cache(maxsize=1) +def load_checks_thresholds() -> dict[str, Any]: + """Return rhythm / density / orphan thresholds. + + Falls back to baked-in defaults if the JSON is missing so build.py works + on a half-installed checkout. + """ + if CHECKS_THRESHOLDS_FILE.exists(): + return json.loads(CHECKS_THRESHOLDS_FILE.read_text(encoding="utf-8")) + return { + "rhythm": {"max_content_run": 5, "divider_min_deck_size": 12}, + "density": {"warn_pct": 0.25, "sparse_pct": 0.50, "dpi": 36}, + "orphan": {"max_words": 2, "max_chars": 15}, + } diff --git a/plugins/kami/skills/kami/scripts/site_facts.py b/plugins/kami/skills/kami/scripts/site_facts.py new file mode 100644 index 0000000..e116f4b --- /dev/null +++ b/plugins/kami/skills/kami/scripts/site_facts.py @@ -0,0 +1,282 @@ +"""Public-site fact drift checks for Kami. + +The hosted pages, README, and llms.txt intentionally repeat install and product +facts in multiple languages. This module keeps those facts tied to the shared +registry and public constants so `build.py --check` catches drift before CI. +""" +from __future__ import annotations + +import difflib +import html +import re +from collections.abc import Mapping +from html.parser import HTMLParser + +from shared import ( + CLAUDE_CODE_INSTALL_COMMANDS, + CLAUDE_CODE_MIN_VERSION, + CLAUDE_DESKTOP_PACKAGE_URL, + CODEX_PLUGIN_INSTALL_COMMANDS, + DIAGRAM_TEMPLATES, + GENERIC_AGENT_INSTALL_COMMAND, + PUBLIC_DOCUMENT_TEMPLATE_KINDS, + ROOT, + kami_version, + public_document_template_count, + public_document_template_kinds, +) + +# Locale pages are hand-maintained forks of index.html. Their DOM skeletons +# must stay identical; the only allowed divergence is the language-redirect +# <script> that exists solely on the default page. +SITE_BASE_PAGE = "index.html" +SITE_LOCALE_PAGES = ( + "index-zh.html", + "index-ja.html", + "index-ko.html", + "index-tw.html", +) + +# Every surface that must carry the full public fact set. Derived from the +# locale-page tuple so adding a locale automatically joins both checks. +FULL_PUBLIC_FACT_FILES = ( + "README.md", + "llms.txt", + SITE_BASE_PAGE, + *SITE_LOCALE_PAGES, +) +REDIRECT_SITE_FILE = "index-en.html" +SITE_SURFACE_ABSENT = "__site_surface_absent__" + +_SKELETON_TAGS = frozenset({ + "article", "aside", "dl", "figure", "footer", "form", "header", + "h1", "h2", "h3", "h4", "h5", "h6", + "main", "nav", "ol", "section", "script", "svg", "table", "ul", +}) + +# Spelled-out numerals per template count. Digit patterns below are derived +# from the live registry count, so a registry change keeps the check honest; +# only the localized number words need a new entry here when the count moves. +_TEMPLATE_COUNT_WORDS = { + 8: ( + r"\bEight document template", + r"八种文档模板", + r"八種文件範本", + ), +} + +def _normalize(text: str) -> str: + return html.unescape(text) + + +def _contains_template_count(text: str, expected: int) -> bool: + patterns = [ + rf"\b{expected} document template", + rf"{expected}种文档模板", + rf"{expected}種文件範本", + rf"{expected}種類のドキュメントテンプレート", + rf"{expected}가지 문서 템플릿", + *_TEMPLATE_COUNT_WORDS.get(expected, ()), + ] + return any(re.search(pattern, text, re.IGNORECASE) for pattern in patterns) + + +def _contains_diagram_count(text: str, expected: int) -> bool: + patterns = [ + rf"\b{expected}\s+(?:inline\s+SVG\s+)?diagram", + rf"{expected}\s*(?:种|種).*?(?:图表|圖表)", + rf"{expected}種.*?図表", + rf"{expected}가지.*?다이어그램", + ] + if expected == 18: + patterns.append(r"\bEighteen\s+inline\s+SVG") + return any(re.search(pattern, text, re.IGNORECASE) for pattern in patterns) + + +def _file_texts(files: Mapping[str, str] | None) -> tuple[dict[str, str], list[str]]: + if files is not None: + return dict(files), [] + + texts: dict[str, str] = {} + issues: list[str] = [] + site_files = (*FULL_PUBLIC_FACT_FILES, REDIRECT_SITE_FILE) + if not any((ROOT / rel).exists() for rel in site_files): + return {SITE_SURFACE_ABSENT: ""}, [] + for rel in site_files: + path = ROOT / rel + if not path.exists(): + issues.append(f"{rel}: missing public fact file") + continue + texts[rel] = path.read_text(encoding="utf-8", errors="replace") + return texts, issues + + +class _SkeletonParser(HTMLParser): + """Collect the structural tag sequence of a page, annotated enough to + catch real drift (class identity, script type) without tracking text.""" + + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.rows: list[str] = [] + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + if tag not in _SKELETON_TAGS: + return + attr_map = dict(attrs) + row = tag + css_class = (attr_map.get("class") or "").strip() + if css_class: + row += f".{css_class}" + if tag == "script": + script_type = (attr_map.get("type") or "").strip() + if script_type: + row += f"[{script_type}]" + self.rows.append(row) + + +def _page_skeleton(text: str) -> list[str]: + parser = _SkeletonParser() + parser.feed(text) + return parser.rows + + +def _drop_language_redirect_script(rows: list[str]) -> list[str]: + """Return rows minus the first bare <script> (the default page's + language redirect), which locale pages intentionally omit.""" + for index, row in enumerate(rows): + if row == "script": + return rows[:index] + rows[index + 1:] + return rows + + +def site_structure_issues(files: Mapping[str, str] | None = None) -> list[str]: + """Compare each locale page's DOM skeleton against index.html.""" + texts, issues = _file_texts(files) + if SITE_SURFACE_ABSENT in texts: + return [] + + base_raw = texts.get(SITE_BASE_PAGE) + if base_raw is None: + return issues + expected = _drop_language_redirect_script(_page_skeleton(base_raw)) + + for rel in SITE_LOCALE_PAGES: + raw = texts.get(rel) + if raw is None: + continue + rows = _page_skeleton(raw) + if rows == expected: + continue + delta = [ + line for line in difflib.unified_diff(expected, rows, lineterm="", n=0) + if line[:1] in "+-" and line[:3] not in ("+++", "---") + ] + preview = "; ".join(delta[:6]) + (" ..." if len(delta) > 6 else "") + issues.append( + f"{rel}: DOM skeleton drifted from {SITE_BASE_PAGE} " + f"({len(delta)} row(s): {preview})" + ) + + return issues + + +def site_fact_issues(files: Mapping[str, str] | None = None) -> list[str]: + texts, issues = _file_texts(files) + if SITE_SURFACE_ABSENT in texts: + return [] + + kinds = public_document_template_kinds() + if kinds != PUBLIC_DOCUMENT_TEMPLATE_KINDS: + missing = sorted(PUBLIC_DOCUMENT_TEMPLATE_KINDS - kinds) + extra = sorted(kinds - PUBLIC_DOCUMENT_TEMPLATE_KINDS) + detail = [] + if missing: + detail.append(f"missing public kinds: {', '.join(missing)}") + if extra: + detail.append(f"extra public kinds: {', '.join(extra)}") + issues.append("registry: public document template kinds drifted" + (f" ({'; '.join(detail)})" if detail else "")) + + template_count = public_document_template_count() + diagram_count = len(DIAGRAM_TEMPLATES) + + for rel in FULL_PUBLIC_FACT_FILES: + raw = texts.get(rel) + if raw is None: + if files is not None: + issues.append(f"{rel}: missing public fact file") + continue + text = _normalize(raw) + + if CLAUDE_CODE_MIN_VERSION not in text: + issues.append(f"{rel}: missing Claude Code minimum version {CLAUDE_CODE_MIN_VERSION}") + for command in CLAUDE_CODE_INSTALL_COMMANDS: + if command not in text: + issues.append(f"{rel}: missing Claude Code install command `{command}`") + for command in CODEX_PLUGIN_INSTALL_COMMANDS: + if command not in text: + issues.append(f"{rel}: missing Codex install command `{command}`") + if GENERIC_AGENT_INSTALL_COMMAND not in text: + issues.append(f"{rel}: missing generic agent install command `{GENERIC_AGENT_INSTALL_COMMAND}`") + + if "kami.zip" not in text: + issues.append(f"{rel}: missing Claude Desktop package name kami.zip") + if rel != "llms.txt" and CLAUDE_DESKTOP_PACKAGE_URL not in text: + issues.append(f"{rel}: missing Claude Desktop package URL {CLAUDE_DESKTOP_PACKAGE_URL}") + + # The site pages carry a hand-written Kami version badge; tie it to the + # tracked VERSION file so a release bump cannot leave a page behind. + # README and llms.txt intentionally carry no version string. + if rel.endswith(".html") and f"v{kami_version()}" not in text: + issues.append(f"{rel}: missing Kami version badge v{kami_version()}") + + if not _contains_template_count(text, template_count): + issues.append(f"{rel}: missing public document template count {template_count}") + if not _contains_diagram_count(text, diagram_count): + issues.append(f"{rel}: missing diagram count {diagram_count}") + + redirect = texts.get(REDIRECT_SITE_FILE) + if redirect is None: + if files is not None: + issues.append(f"{REDIRECT_SITE_FILE}: missing redirect page") + else: + text = _normalize(redirect) + for required in ('http-equiv="refresh"', "url=./", 'content="noindex"', 'rel="canonical"'): + if required not in text: + issues.append(f"{REDIRECT_SITE_FILE}: missing redirect marker `{required}`") + if CLAUDE_CODE_MIN_VERSION in text or "kami.zip" in text: + issues.append(f"{REDIRECT_SITE_FILE}: redirect page should not carry product fact copy") + + return issues + + +def check_site_facts(verbose: bool = False) -> int: + if not any((ROOT / rel).exists() for rel in (*FULL_PUBLIC_FACT_FILES, REDIRECT_SITE_FILE)): + print("OK: public site facts skipped (site files absent)") + return 0 + + result = 0 + + issues = site_fact_issues() + if not issues: + scanned = len(FULL_PUBLIC_FACT_FILES) + 1 + print(f"OK: public site facts in sync across {scanned} file(s)") + else: + print(f"\nERROR: [site-fact-drift] {len(issues)}") + for issue in issues: + print(f" {issue}") + if verbose: + print(" source: shared public constants and template registries") + result = 1 + + structure_issues = site_structure_issues() + if not structure_issues: + print(f"OK: locale page structure matches {SITE_BASE_PAGE} across {len(SITE_LOCALE_PAGES)} page(s)") + else: + print(f"\nERROR: [site-structure-drift] {len(structure_issues)}") + for issue in structure_issues: + print(f" {issue}") + if verbose: + print(f" source: DOM skeleton comparison against {SITE_BASE_PAGE}") + result = 1 + + return result diff --git a/plugins/kami/skills/kami/scripts/tests/test_build.py b/plugins/kami/skills/kami/scripts/tests/test_build.py new file mode 100644 index 0000000..13eb421 --- /dev/null +++ b/plugins/kami/skills/kami/scripts/tests/test_build.py @@ -0,0 +1,1473 @@ +#!/usr/bin/env python3 +"""Lightweight tests for scripts/build.py and scripts/shared.py. + +Run with: python3 scripts/tests/test_build.py +The harness uses plain assertions and a tiny runner so it has no third-party +dependency (matching the rest of the repo's lean tooling). +""" +from __future__ import annotations + +import contextlib +import builtins +import importlib.util +import inspect +import io +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +import zipfile +from pathlib import Path + +# Make scripts/ importable when running this file directly. +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) + +from build import ( # noqa: E402 + DIAGRAM_TARGETS, + HTML_TARGETS, + PPTX_TARGETS, + SCREEN_TARGETS, + _BG_B, + _BG_G, + _BG_R, + _density_bucket, + _extract_root_vars, + _last_content_y, + _markdown_residue_issues, + _off_palette_findings, + _orphan_last_line, + _pair_names, + _parse_slide_sequence, + _resume_balance_issues, + _rhythm_issues, + _root_token_findings, + check_all, + check_cross_template_consistency, + check_markdown_residue, + check_off_palette, + check_placeholders, + main as build_main, + scan_file, +) +from shared import ( # noqa: E402 + DIAGRAM_TEMPLATES, + HTML_TEMPLATES, + PARCHMENT_RGB, + ROOT as REPO_ROOT, + SCREEN_TEMPLATES, + TEMPLATES, + build_targets, + diagram_targets, + load_checks_thresholds, + screen_targets, +) +import highlight as highlight_mod # noqa: E402 +from highlight import highlight_code_blocks # noqa: E402 +from site_facts import ( # noqa: E402 + FULL_PUBLIC_FACT_FILES, + REDIRECT_SITE_FILE, + check_site_facts, + site_fact_issues, + site_structure_issues, +) +from tokens import _mermaid_theme_drift # noqa: E402 +from verify import RECOGNIZABLE_FALLBACK_FONT_MARKERS # noqa: E402 + + +# --------------------------- helpers --------------------------- + +_PASS = 0 +_FAIL = 0 + + +def check(name: str, predicate: bool, detail: str = "") -> None: + global _PASS, _FAIL + if predicate: + _PASS += 1 + print(f"OK: {name}") + else: + _FAIL += 1 + print(f"ERROR: {name}{(' - ' + detail) if detail else ''}") + + +def write_temp_html(body: str, suffix: str = "-en.html") -> Path: + f = tempfile.NamedTemporaryFile(mode="w", suffix=suffix, delete=False, encoding="utf-8") + f.write(body) + f.close() + return Path(f.name) + + +def silently(callable_, *args, **kwargs): + """Run a function with stdout suppressed, return its result.""" + sink = io.StringIO() + with contextlib.redirect_stdout(sink): + return callable_(*args, **kwargs) + + +def run_build_args(args: list[str]) -> tuple[int, str]: + sink = io.StringIO() + with contextlib.redirect_stdout(sink): + rc = build_main(["build.py", *args]) + return rc, sink.getvalue() + + +def site_fact_file_map() -> dict[str, str]: + rels = (*FULL_PUBLIC_FACT_FILES, REDIRECT_SITE_FILE) + return { + rel: (REPO_ROOT / rel).read_text(encoding="utf-8", errors="replace") + for rel in rels + } + + +# --------------------------- package archive --------------------------- + +PACKAGE_MAX_BYTES = 6_000_000 +PACKAGE_ROOT_NAME = "kami" +PACKAGE_FORBIDDEN_EXACT = { + ".claude-plugin/marketplace.json", + ".gitignore", + "AGENTS.md", + "CLAUDE.md", + "README.md", + "assets/images/1.png", + "assets/images/2.png", + "assets/images/3.png", + "assets/fonts/TsangerJinKai02-W04.ttf", + "assets/fonts/TsangerJinKai02-W05.ttf", + "assets/fonts/SourceHanSerifKR-Regular.otf", + "assets/fonts/SourceHanSerifKR-Medium.otf", + "index.html", + "index-en.html", + "index-ja.html", + "index-ko.html", + "index-tw.html", + "index-zh.html", + "llms.txt", + "robots.txt", + "scripts/build_metadata.py", + "scripts/draft-release-notes.py", + "scripts/package-skill.sh", + "sitemap.xml", + "styles.css", + "vercel.json", +} +PACKAGE_FORBIDDEN_PREFIXES = ( + ".agents/", + ".claude/", + ".github/", + "assets/demos/", + "assets/examples/", + "assets/illustrations/", + "assets/showcase/", + "plugins/", + "scripts/tests/", +) +PACKAGE_REQUIRED_ENTRIES = { + "SKILL.md", + "CHEATSHEET.md", + "VERSION", + "LICENSE", + "assets/images/logo.svg", + "assets/fonts/JetBrainsMono.woff2", + "assets/templates/resume.html", + "assets/templates/landing-page.html", + "assets/diagrams/sequence.html", + "references/design.md", + "scripts/build.py", + "scripts/ensure-fonts.sh", + "scripts/site_facts.py", +} + + +def test_dist_package_contents() -> None: + archive = REPO_ROOT / "dist" / "kami.zip" + check("dist/kami.zip exists", archive.exists(), f"missing {archive}") + if not archive.exists(): + return + + size_bytes = archive.stat().st_size + check("dist/kami.zip stays below 6MB", + size_bytes <= PACKAGE_MAX_BYTES, + f"{size_bytes} bytes > {PACKAGE_MAX_BYTES} bytes") + + with zipfile.ZipFile(archive) as zf: + names = set(zf.namelist()) + + bad_root = sorted(name for name in names if not name.startswith(f"{PACKAGE_ROOT_NAME}/")) + check("dist/kami.zip uses a Claude-friendly top-level skill folder", + not bad_root, + f"entries outside {PACKAGE_ROOT_NAME}/: {', '.join(bad_root)}") + + payload_names = { + name.removeprefix(f"{PACKAGE_ROOT_NAME}/") + for name in names + if name.startswith(f"{PACKAGE_ROOT_NAME}/") + } + forbidden = sorted( + name for name in payload_names + if name.startswith(PACKAGE_FORBIDDEN_PREFIXES) + or name in PACKAGE_FORBIDDEN_EXACT + ) + check("dist/kami.zip excludes site, CI, tests, demos, generated mirrors, and large bundled fonts", + not forbidden, + f"forbidden entries: {', '.join(forbidden)}") + missing_required = sorted(PACKAGE_REQUIRED_ENTRIES - payload_names) + check("dist/kami.zip keeps required runtime skill files", + not missing_required, + f"missing entries: {', '.join(missing_required)}") + + +def test_plugin_metadata_generated() -> None: + """Claude Code / Codex marketplaces and plugin mirrors must stay generated.""" + script = REPO_ROOT / "scripts" / "build_metadata.py" + check("build_metadata.py exists", script.exists(), f"missing {script}") + if not script.exists(): + return + + result = subprocess.run( + [sys.executable, str(script), "--check"], + cwd=REPO_ROOT, + capture_output=True, + text=True, + ) + detail = (result.stdout + result.stderr).strip() + check("plugin metadata matches generator", result.returncode == 0, detail) + + +def test_claude_plugin_marketplace_version_matches_version_file() -> None: + """Claude Code uses this version instead of falling back to a commit hash.""" + version = (REPO_ROOT / "VERSION").read_text(encoding="utf-8").strip() + marketplace_file = REPO_ROOT / ".claude-plugin" / "marketplace.json" + check("Claude plugin marketplace metadata exists", marketplace_file.exists()) + if not marketplace_file.exists(): + return + + marketplace = json.loads(marketplace_file.read_text(encoding="utf-8")) + plugins = marketplace.get("plugins", []) + kami_plugin = next((plugin for plugin in plugins if plugin.get("name") == "kami"), None) + check("Claude plugin marketplace includes kami", kami_plugin is not None) + if not kami_plugin: + return + + check("Claude plugin marketplace version matches VERSION", + kami_plugin.get("version") == version, + f"marketplace={kami_plugin.get('version')!r}, VERSION={version!r}") + check("Claude plugin marketplace installs the lightweight plugin directory", + kami_plugin.get("source") == "./plugins/kami", + f"source={kami_plugin.get('source')!r}") + + plugin_file = REPO_ROOT / "plugins" / "kami" / ".claude-plugin" / "plugin.json" + check("Claude plugin manifest exists in generated plugin tree", plugin_file.exists()) + if not plugin_file.exists(): + return + + plugin = json.loads(plugin_file.read_text(encoding="utf-8")) + check("Claude plugin manifest version matches VERSION", + plugin.get("version") == version, + f"plugin={plugin.get('version')!r}, VERSION={version!r}") + check("Claude plugin manifest exposes skills directory", + plugin.get("skills") == "./skills/", + f"skills={plugin.get('skills')!r}") + + +def test_build_metadata_reads_tokens_from_root_argument() -> None: + from build_metadata import build_codex_plugin, read_token_value + + with tempfile.TemporaryDirectory() as d: + root = Path(d) + (root / "references").mkdir() + (root / "references" / "tokens.json").write_text('{"--brand":"#123456"}\n', encoding="utf-8") + + brand_color = read_token_value(root, "brand") + plugin = build_codex_plugin("9.9.9", brand_color) + check("build_metadata reads brand token from provided root", + plugin["interface"]["brandColor"] == "#123456", + f"brandColor={plugin['interface']['brandColor']}") + + +# --------------------------- shared registry --------------------------- + +def test_registry_consistency() -> None: + check("HTML_TEMPLATES has 24 entries", len(HTML_TEMPLATES) == 24, + f"got {len(HTML_TEMPLATES)}") + check("SCREEN_TARGETS has 3 entries", len(SCREEN_TARGETS) == 3, + f"got {len(SCREEN_TARGETS)}") + check("build_targets matches HTML_TEMPLATES key set", + set(build_targets()) == set(HTML_TEMPLATES)) + check("screen_targets matches SCREEN_TARGETS key set", + set(screen_targets()) == set(SCREEN_TARGETS)) + check("HTML_TARGETS in build.py matches build_targets()", + dict(HTML_TARGETS) == build_targets()) + check("DIAGRAM_TARGETS has 18 entries", len(DIAGRAM_TARGETS) == 18, + f"got {len(DIAGRAM_TARGETS)}") + check("DIAGRAM_TARGETS in build.py matches shared.diagram_targets()", + dict(DIAGRAM_TARGETS) == diagram_targets() == dict(DIAGRAM_TEMPLATES)) + check("PPTX_TARGETS has 2 entries", len(PPTX_TARGETS) == 2, + f"got {len(PPTX_TARGETS)}") + check("PARCHMENT_RGB is canonical", PARCHMENT_RGB == (0xF5, 0xF4, 0xED)) + + +def test_runner_auto_discovers_tests() -> None: + names = [name for name, _ in _test_functions()] + check("test runner auto-discovers Codex update command test", + "test_check_update_uses_codex_plugin_update_command" in names) + check("test runner auto-discovers this test", + "test_runner_auto_discovers_tests" in names) + + +def test_build_cli_rejects_unexpected_flags() -> None: + rc, out = run_build_args(["resume", "--verify"]) + check("build.py rejects flags after target", + rc == 2 and "ERROR: unexpected argument: --verify" in out, + out.strip()) + + rc, out = run_build_args(["--check-density", "-v"]) + check("build.py rejects unknown flags for path-based checks", + rc == 2 and "ERROR: unexpected argument: -v" in out, + out.strip()) + + rc, out = run_build_args(["--verify", "-v"]) + check("build.py rejects unknown --verify flags", + rc == 2 and "ERROR: unexpected argument: -v" in out, + out.strip()) + + rc, out = run_build_args(["--check-markdown", "-v"]) + check("build.py rejects unknown --check-markdown flags", + rc == 2 and "ERROR: unexpected argument: -v" in out, + out.strip()) + + +def test_long_doc_templates_use_rendered_toc_pages_and_chapter_headers() -> None: + """Long-doc TOCs must use WeasyPrint target-counter, and running headers + must follow chapter h1 titles instead of getting stuck on the TOC h2. + """ + sources = ("long-doc.html", "long-doc-en.html", "long-doc-ko.html") + required_ids = { + "#ch-executive-summary", + "#ch-background", + "#ch-methodology", + "#ch-conclusions", + "#ch-appendix", + } + offenders: list[str] = [] + for source in sources: + text = (TEMPLATES / source).read_text(encoding="utf-8") + if "target-counter(attr(href), page)" not in text: + offenders.append(f"{source}: missing target-counter") + if ".toc-page" in text: + offenders.append(f"{source}: still has obsolete toc-page wiring") + missing_ids = sorted(href for href in required_ids if f'href="{href}"' not in text or f'id="{href[1:]}"' not in text) + if missing_ids: + offenders.append(f"{source}: missing TOC href/id pairs {missing_ids}") + h1_block = re.search(r"(?m)^ h1\s*\{(?P<body>.*?)^ \}", text, re.S) + if not h1_block or "string-set: section-title content();" not in h1_block.group("body"): + offenders.append(f"{source}: h1 does not set running header") + h2_block = re.search(r"(?m)^ h2\s*\{(?P<body>.*?)^ \}", text, re.S) + if h2_block and "string-set:" in h2_block.group("body"): + offenders.append(f"{source}: h2 still sets running header") + + check("long-doc templates use rendered TOC pages and chapter headers", + not offenders, + "; ".join(offenders)) + + +def test_site_facts_repo_clean() -> None: + rc = silently(check_site_facts, False) + check("public site facts match shared constants and registries", rc == 0, + f"check_site_facts returned {rc}") + + +def test_site_facts_flags_bad_diagram_count() -> None: + files = site_fact_file_map() + bad = files["index.html"] + bad = bad.replace("18 inline SVG diagram types", "17 inline SVG diagram types") + bad = bad.replace("Eighteen inline SVG diagram types", "Seventeen inline SVG diagram types") + files["index.html"] = bad + + issues = site_fact_issues(files) + check("public site facts flag stale diagram counts", + any("index.html: missing diagram count 18" in issue for issue in issues), + f"issues: {issues}") + + +def test_site_structure_repo_clean() -> None: + """Locale pages match index.html's DOM skeleton (redirect script exempt).""" + issues = site_structure_issues() + check("locale page structure matches index.html", not issues, + f"issues: {issues}") + + +def test_site_structure_flags_locale_drift() -> None: + files = site_fact_file_map() + files["index-zh.html"] = files["index-zh.html"].replace( + '<h2 class="section-title">', '<h3 class="section-title">', 1) + + issues = site_structure_issues(files) + check("locale structure check flags a drifted heading", + any("index-zh.html: DOM skeleton drifted" in issue for issue in issues), + f"issues: {issues}") + + +def test_chinese_html_templates_keep_single_serif_stack() -> None: + """Chinese templates must keep --sans pinned to --serif for PDF glyph safety.""" + offenders: list[str] = [] + for name, spec in HTML_TEMPLATES.items(): + source = spec.source + if name.endswith("-en"): + continue + text = (TEMPLATES / source).read_text(encoding="utf-8") + if "--sans: var(--serif)" not in text and "--sans: var(--serif)" not in text: + offenders.append(source) + + check("Chinese HTML templates keep --sans: var(--serif)", + not offenders, + f"offenders: {', '.join(offenders)}") + + +def _ko_stack_offenders(text: str) -> list[str]: + """Return CSS declarations that reference the bare `"Source Han Serif K"` + family inside a multi-name fallback stack but omit the real OTF family + name `"Source Han Serif KR"`. + + The bare name `"Source Han Serif K"` is legitimate on its own only as the + `@font-face` declared alias (a single-name `font-family: "Source Han Serif K";` + with no comma, which loads via the file/CDN `src`). Anywhere it appears as a + fallback item in a comma-separated stack (`--serif`, `--mono`, `@page` + margin boxes, `code`/`pre`, ...), `"Source Han Serif KR"` MUST sit alongside + it, or an offline Linux skill install cannot resolve the + ensure-fonts.sh-downloaded font by name. + + Detection: scan only `font-family` / `--serif` / `--sans` / `--mono` + declaration values (up to the next `;`, never crossing `{`/`}`). The token + `"Source Han Serif K"` (closing quote after `K`) never matches + `"Source Han Serif KR"`, so a value that contains the bare token AND a comma + (i.e. a fallback stack, not a bare `@font-face` alias) must also contain KR. + """ + bare = '"Source Han Serif K"' + kr = '"Source Han Serif KR"' + decl_re = re.compile(r"(?:font-family|--serif|--sans|--mono)\s*:\s*([^;{}]*)", re.IGNORECASE) + offenders: list[str] = [] + for m in decl_re.finditer(text): + value = m.group(1) + if bare in value and "," in value and kr not in value: + offenders.append(" ".join(value.split())) + return offenders + + +def test_korean_templates_carry_resolvable_serif_name() -> None: + """Every KO fallback stack that names `Source Han Serif K` must also name + `Source Han Serif KR` (the actual family of the bundled OTFs), so the font + resolves by name on an offline Linux skill install. Checks per-declaration, + not just per-file, so a complete `--serif` cannot mask an incomplete local + stack (page-margin header/footer, code/pre, mono). + """ + offenders: list[str] = [] + ko_sources = [spec.source for name, spec in HTML_TEMPLATES.items() if name.endswith("-ko")] + ko_sources += [source for name, source in SCREEN_TEMPLATES.items() if name.endswith("-ko")] + # Guard against vacuous green: with zero -ko templates the offender loop + # never runs and the check below would pass while enforcing nothing. + check("Korean template set is non-empty", bool(ko_sources), + "no -ko templates found in the registries") + for source in ko_sources: + text = (TEMPLATES / source).read_text(encoding="utf-8") + for bad in _ko_stack_offenders(text): + offenders.append(f"{source}: {bad}") + + check("Korean fallback stacks all carry Source Han Serif KR", + not offenders, + f"offenders: {'; '.join(offenders)}") + + +# ---------- sibling placeholder parity (issue #38 class) ---------- + +# Repeated template structures whose placeholder hints must repeat the first +# block verbatim. Hint richness degrading from block 1 to later siblings makes +# fillers (human or agent) produce degraded copy; see issue #38. Cycle length N +# means placeholders repeat in groups of N (e.g. Role/Actions/Impact rows). +_SIBLING_PARITY_SPECS = ( + ("resume*.html", r'class="proj-text">(\{\{.*?\}\})', 3), + ("resume*.html", r'class="proj-role">(\{\{.*?\}\})', 1), + ("resume*.html", r'class="conv-body">\s*(\{\{.*?\}\})', 1), + ("resume*.html", r'class="os-desc">(\{\{.*?\}\})', 1), + ("resume*.html", r'class="art-stats">(\{\{.*?\}\})', 1), + ("portfolio*.html", r'class="project-block">\s*<h3>[^<]*</h3>\s*<p>(\{\{.*?\}\})</p>', 3), + ("portfolio*.html", r'class="project-type">(\{\{.*?\}\})', 1), + ("portfolio*.html", r'class="project-date">(\{\{.*?\}\})', 1), + ("one-pager*.html", r'<li>(\{\{(?:短 bullet|Short bullet|짧은 bullet).*?\}\})</li>', 3), + ("long-doc*.html", r'(\{\{(?:一段论述|A paragraph|한 단락 논술).*?\}\})', 1), +) + + +def test_sibling_placeholder_hints_stay_in_parity() -> None: + """Same-structure sibling blocks must carry identical placeholder hints.""" + matched = 0 + offenders: list[str] = [] + for glob_pattern, regex, cycle in _SIBLING_PARITY_SPECS: + rx = re.compile(regex, re.DOTALL) + for path in sorted(TEMPLATES.glob(glob_pattern)): + hits = rx.findall(path.read_text(encoding="utf-8")) + if not hits: + offenders.append(f"{path.name}: no match for {regex[:40]!r} (stale spec?)") + continue + matched += 1 + if len(hits) % cycle != 0: + offenders.append(f"{path.name}: {len(hits)} hint(s) not divisible by cycle {cycle}") + continue + first = hits[:cycle] + for start in range(cycle, len(hits), cycle): + block = hits[start:start + cycle] + if block != first: + offenders.append( + f"{path.name}: block {start // cycle + 1} diverges from block 1: " + f"{block} != {first}") + check("sibling parity specs matched across template families", matched >= 30, + f"only {matched} template/spec matches; spec table may be stale") + check("repeated blocks carry identical placeholder hints", not offenders, + "; ".join(offenders[:6])) + + +def test_font_fallback_markers_recognize_pt_serif() -> None: + """macOS without Charter may render English fallbacks as PT Serif.""" + embedded = {"DROIWJ+PT-Serif", "ZBEAAE+JetBrains-Mono"} + fallback_present = any( + marker in font for font in embedded + for marker in RECOGNIZABLE_FALLBACK_FONT_MARKERS + ) + check("font fallback markers recognize PT-Serif", + fallback_present, + f"markers: {RECOGNIZABLE_FALLBACK_FONT_MARKERS}") + + +def test_chinese_slides_mono_has_cjk_fallback() -> None: + """Slide labels may mix mono Latin and CJK; the mono stack needs CJK fallback.""" + text = (TEMPLATES / "slides-weasy.html").read_text(encoding="utf-8") + check("slides-weasy mono stack includes TsangerJinKai02 fallback", + '"TsangerJinKai02"' in text and '"Source Han Serif SC"' in text) + + +# --------------------------- scan_file --------------------------- + +def test_scan_file_skip_bug() -> None: + """Lines starting with '#' (CSS id selectors) must NOT be skipped.""" + fixture = """<!doctype html> +<html><head><style> +#card { background: rgba(0,0,0,0.5); } +</style></head><body></body></html> +""" + p = write_temp_html(fixture) + try: + findings = scan_file(p) + rules = {f.rule for f in findings} + check("scan_file flags rgba on #id-prefixed CSS line", + "rgba-background" in rules, + f"rules found: {rules or '(none)'}") + finally: + p.unlink(missing_ok=True) + + +def test_scan_file_arrow_in_en() -> None: + """`→` in -en.html body should trigger arrow-unicode-in-en.""" + fixture = """<!doctype html> +<html lang="en"><head><style> +.tag { color: #1B365D; } +</style></head><body> +<p>Step 1 → Step 2</p> +</body></html> +""" + p = write_temp_html(fixture, suffix="-en.html") + try: + findings = scan_file(p) + rules = {f.rule for f in findings} + check("scan_file flags U+2192 arrow in -en.html", + "arrow-unicode-in-en" in rules, + f"rules found: {rules or '(none)'}") + finally: + p.unlink(missing_ok=True) + + +def test_scan_file_clean_template() -> None: + """A clean template should produce zero findings.""" + fixture = """<!doctype html> +<html><head><style> +:root { --brand: #1B365D; } +.card { background: var(--ivory); } +.tag { background: #EEF2F7; color: var(--brand); } +</style></head><body></body></html> +""" + p = write_temp_html(fixture) + try: + findings = scan_file(p) + check("scan_file produces no findings on clean template", + len(findings) == 0, + f"got {len(findings)} finding(s): {[f.rule for f in findings]}") + finally: + p.unlink(missing_ok=True) + + +# --------------------------- slide sequence --------------------------- + +def test_parse_slide_sequence_empty() -> None: + fixture = """def main(): + pass +""" + p = write_temp_html(fixture, suffix=".py") + try: + seq = _parse_slide_sequence(p) + check("_parse_slide_sequence returns [] for empty main()", + seq == [], f"got {seq}") + finally: + p.unlink(missing_ok=True) + + +def test_parse_slide_sequence_basic() -> None: + fixture = """def main(): + cover_slide() + content_slide() + content_slide() + chapter_slide() + metrics_slide() + +def helper(): + other_call() +""" + p = write_temp_html(fixture, suffix=".py") + try: + seq = _parse_slide_sequence(p) + expected = ["cover_slide", "content_slide", "content_slide", "chapter_slide", "metrics_slide"] + check("_parse_slide_sequence parses ordered slide calls", + seq == expected, f"got {seq}") + finally: + p.unlink(missing_ok=True) + + +# --------------------------- scan_file extra rules --------------------------- + +def test_scan_file_line_height_too_loose() -> None: + """line-height >= 1.6 should trigger line-height-too-loose.""" + fixture = """<!doctype html> +<html><head><style> +p { line-height: 1.8; } +</style></head><body></body></html> +""" + p = write_temp_html(fixture) + try: + findings = scan_file(p) + rules = {f.rule for f in findings} + check("scan_file flags line-height 1.8 (too loose)", + "line-height-too-loose" in rules, + f"rules found: {rules or '(none)'}") + finally: + p.unlink(missing_ok=True) + + +def test_scan_file_cool_gray() -> None: + """Cool-gray hex literals should be flagged.""" + fixture = """<!doctype html> +<html><head><style> +.muted { color: #888; } +</style></head><body></body></html> +""" + p = write_temp_html(fixture) + try: + findings = scan_file(p) + rules = {f.rule for f in findings} + check("scan_file flags cool gray #888", + "cool-gray" in rules, + f"rules found: {rules or '(none)'}") + finally: + p.unlink(missing_ok=True) + + +def test_off_palette_flags_non_token_hex() -> None: + """A non-token, non-cool-gray hex in a component rule is off-palette.""" + fixture = """<!doctype html> +<html><head><style> +.x { color: #ff00aa; } +</style></head><body></body></html> +""" + p = write_temp_html(fixture) + try: + findings = _off_palette_findings(p, {"#1b365d"}) + rules = {f.rule for f in findings} + check("_off_palette_findings flags non-token hex #ff00aa", + "off-palette" in rules, + f"rules found: {rules or '(none)'}") + finally: + p.unlink(missing_ok=True) + + +def test_off_palette_ignores_root_and_svg() -> None: + """Hex inside :root token defs and inside <svg> blocks must be skipped.""" + fixture = """<!doctype html> +<html><head><style> +:root { --brand: #1B365D; --accent: #ff00aa; } +</style></head><body> +<svg viewBox="0 0 10 10"><rect fill="#ff0000" /></svg> +</body></html> +""" + p = write_temp_html(fixture) + try: + findings = _off_palette_findings(p, {"#1b365d"}) + check("_off_palette_findings skips :root defs and <svg> fills", + findings == [], + f"unexpected findings: {[(f.line, f.excerpt) for f in findings]}") + finally: + p.unlink(missing_ok=True) + + +def test_root_token_findings_flags_off_palette_definition() -> None: + """An off-palette hex *defined* in :root (never used as a literal property + hex) escapes _off_palette_findings, which blanks :root. _root_token_findings + closes that gap: a stray `--brand-deep: #a64f33` second accent is flagged, + while a registered token and cool-gray (reported elsewhere) are not.""" + fixture = """<!doctype html> +<html><head><style> +:root { + --brand: #1B365D; + --brand-deep: #a64f33; +} +</style></head><body></body></html> +""" + p = write_temp_html(fixture) + try: + findings = _root_token_findings(p, {"#1b365d"}) + rules = {f.rule for f in findings} + excerpts = " ".join(f.excerpt for f in findings) + check("_root_token_findings flags off-palette :root token #a64f33", + "off-palette-token" in rules and "#a64f33" in excerpts, + f"rules={rules or '(none)'} excerpts={excerpts or '(none)'}") + check("_root_token_findings does not flag the registered --brand token", + "#1b365d" not in excerpts, + f"unexpectedly flagged brand token: {excerpts}") + finally: + p.unlink(missing_ok=True) + + +def test_off_palette_repo_clean() -> None: + """The real editorial templates must carry no off-palette colors.""" + rc = silently(check_off_palette) + check("check_off_palette passes on the real templates", + rc == 0, + f"check_off_palette returned {rc}") + + +def test_check_update_script() -> None: + """check-update.sh notifies on a newer remote, stays silent when current, + throttles to once per day, and fails silently offline. It only reads a + version file and sends no data; KAMI_UPDATE_URL points it at a fixture.""" + script = REPO_ROOT / "scripts" / "check-update.sh" + check("check-update.sh exists", script.exists()) + if not script.exists(): + return + if shutil.which("bash") is None or shutil.which("curl") is None: + check("check-update.sh behavior (skipped: bash/curl unavailable)", True) + return + local_ver = (REPO_ROOT / "VERSION").read_text(encoding="utf-8").strip() + + def run(cache: str, url: str) -> tuple[int, str]: + env = dict(os.environ, XDG_CACHE_HOME=cache, KAMI_UPDATE_URL=url) + r = subprocess.run(["bash", str(script)], capture_output=True, text=True, env=env) + return r.returncode, r.stdout.strip() + + with tempfile.TemporaryDirectory() as d: + dp = Path(d) + newer = dp / "newer"; newer.write_text("9.9.9\n") + same = dp / "same"; same.write_text(local_ver + "\n") + + rc, out = run(str(dp / "c1"), newer.as_uri()) + check("check-update notifies on a newer remote", rc == 0 and "9.9.9" in out, out) + check("check-update default command uses plugin bundle path", + "npx skills add tw93/kami/plugins/kami -a universal -g -y" in out and "skills update" not in out, + out) + + rc, out = run(str(dp / "c2"), same.as_uri()) + check("check-update is silent when current", rc == 0 and out == "", out) + + c3 = str(dp / "c3") + run(c3, newer.as_uri()) + _, out2 = run(c3, newer.as_uri()) + check("check-update throttles to once per day", out2 == "", out2) + + rc, out = run(str(dp / "c4"), (dp / "nope").as_uri()) + check("check-update fails silently when offline", rc == 0 and out == "", out) + + +def test_check_update_uses_codex_plugin_update_command() -> None: + """When installed through Codex plugin cache, the update hint should use + plugin marketplace refresh commands instead of the legacy npx skill update. + """ + script = REPO_ROOT / "scripts" / "check-update.sh" + check("check-update.sh exists for Codex command test", script.exists()) + if not script.exists(): + return + if shutil.which("bash") is None or shutil.which("curl") is None: + check("check-update Codex command (skipped: bash/curl unavailable)", True) + return + + with tempfile.TemporaryDirectory() as d: + dp = Path(d) + newer = dp / "newer" + newer.write_text("9.9.9\n") + + roots = [ + dp / ".codex" / "plugins" / "cache" / "kami" / "kami" / "1.7.4" / "skills" / "kami", + dp / "custom-codex-home" / "plugins" / "cache" / "kami" / "kami" / "1.7.4" / "skills" / "kami", + ] + for index, install_root in enumerate(roots, start=1): + (install_root / "scripts").mkdir(parents=True) + shutil.copy2(script, install_root / "scripts" / "check-update.sh") + (install_root / "VERSION").write_text("1.7.4\n") + + env = dict(os.environ, XDG_CACHE_HOME=str(dp / f"cache-{index}"), KAMI_UPDATE_URL=newer.as_uri()) + result = subprocess.run( + ["bash", str(install_root / "scripts" / "check-update.sh")], + capture_output=True, + text=True, + env=env, + ) + out = result.stdout.strip() + check(f"check-update uses Codex plugin update command ({install_root.parent.parent.parent.name})", + result.returncode == 0 and "codex plugin marketplace upgrade kami" in out, + out) + + +def test_check_update_uses_claude_plugin_update_command() -> None: + """When installed through Claude Code's plugin cache, the update hint should + use Claude's plugin updater instead of generic npx skill install. + """ + script = REPO_ROOT / "scripts" / "check-update.sh" + check("check-update.sh exists for Claude command test", script.exists()) + if not script.exists(): + return + if shutil.which("bash") is None or shutil.which("curl") is None: + check("check-update Claude command (skipped: bash/curl unavailable)", True) + return + + with tempfile.TemporaryDirectory() as d: + dp = Path(d) + newer = dp / "newer" + newer.write_text("9.9.9\n") + install_root = dp / ".claude" / "plugins" / "cache" / "kami" / "kami" / "1.9.1" / "skills" / "kami" + (install_root / "scripts").mkdir(parents=True) + shutil.copy2(script, install_root / "scripts" / "check-update.sh") + (install_root / "VERSION").write_text("1.9.1\n") + + env = dict(os.environ, XDG_CACHE_HOME=str(dp / "cache"), KAMI_UPDATE_URL=newer.as_uri()) + result = subprocess.run( + ["bash", str(install_root / "scripts" / "check-update.sh")], + capture_output=True, + text=True, + env=env, + ) + out = result.stdout.strip() + check("check-update uses Claude plugin update command", + result.returncode == 0 and "claude plugin update kami" in out and "npx skills" not in out, + out) + + +def test_lint_repo_clean() -> None: + """The full CSS lint (scan_file across every template) must pass. This is + what `build.py --check` runs; covering it here means a rule violation such + as thin-border-radius cannot reach main behind an otherwise green suite.""" + rc = silently(check_all, False) + check("check_all (full CSS lint) passes on the real templates", + rc == 0, + f"check_all returned {rc}") + + +def test_scan_file_ignores_block_comment_rgba() -> None: + """rgba() inside a /* ... */ CSS block comment must not trigger findings.""" + fixture = """<!doctype html> +<html><head><style> +/* historical note: we used to write + background: rgba(0,0,0,0.5); + here, but switched to solid hex. */ +.card { background: #EEF2F7; } +</style></head><body></body></html> +""" + p = write_temp_html(fixture) + try: + findings = scan_file(p) + rules = {f.rule for f in findings} + check("scan_file ignores rgba inside /* */ comment", + "rgba-background" not in rules, + f"rules found: {rules or '(none)'}") + finally: + p.unlink(missing_ok=True) + + +def test_scan_file_thin_border_with_radius() -> None: + """Sub-1pt closed border in a block with border-radius should fire pitfall #2.""" + fixture = """<!doctype html> +<html><head><style> +.tag { + border: 0.5pt solid #1B365D; + border-radius: 3pt; + background: #EEF2F7; +} +</style></head><body></body></html> +""" + p = write_temp_html(fixture) + try: + findings = scan_file(p) + rules = {f.rule for f in findings} + check("scan_file flags thin border with border-radius", + "thin-border-radius" in rules, + f"rules found: {rules or '(none)'}") + finally: + p.unlink(missing_ok=True) + + +# --------------------------- check_placeholders --------------------------- + +def test_check_placeholders_flags_unfilled() -> None: + """A doc with `{{ name }}` left over should fail the check.""" + p = write_temp_html("<html><body><h1>{{ name }}</h1><p>{{ role }}</p></body></html>") + try: + rc = silently(check_placeholders, [str(p)]) + check("check_placeholders fails on {{ name }}", rc == 1, f"rc={rc}") + finally: + p.unlink(missing_ok=True) + + +def test_check_placeholders_passes_clean() -> None: + """A doc with no placeholder syntax should pass.""" + p = write_temp_html("<html><body><h1>Real Name</h1><p>Real role</p></body></html>") + try: + rc = silently(check_placeholders, [str(p)]) + check("check_placeholders passes clean file", rc == 0, f"rc={rc}") + finally: + p.unlink(missing_ok=True) + + +def test_markdown_residue_flags_raw_markers() -> None: + issues = _markdown_residue_issues("Intro\n---\nThis has **raw bold** and `raw code`.") + check("markdown residue flags thematic breaks", + any("thematic break" in issue for issue in issues), + f"issues={issues}") + check("markdown residue flags raw bold markers", + any("bold marker" in issue for issue in issues), + f"issues={issues}") + check("markdown residue flags raw inline-code markers", + any("inline-code marker" in issue for issue in issues), + f"issues={issues}") + + check("markdown residue ignores clean text", + _markdown_residue_issues("Clean paragraph with converted emphasis.") == []) + + +def test_check_markdown_residue_skips_html_code_blocks() -> None: + dirty = write_temp_html("<html><body><p>Visible **raw bold**</p></body></html>", suffix=".html") + clean_code = write_temp_html( + "<html><body><p>Visible text</p><pre><code>**example** `cmd`</code></pre></body></html>", + suffix=".html", + ) + try: + rc = silently(check_markdown_residue, [str(dirty)]) + check("check_markdown_residue fails visible raw markdown", rc == 1, f"rc={rc}") + rc = silently(check_markdown_residue, [str(clean_code)]) + check("check_markdown_residue skips code/pre blocks", rc == 0, f"rc={rc}") + finally: + dirty.unlink(missing_ok=True) + clean_code.unlink(missing_ok=True) + + +# --------------------------- cross-template consistency --------------------------- + +def test_pair_names_includes_known_pairs() -> None: + captured = list(_pair_names()) + check("pair_names includes one-pager", + ("one-pager", "one-pager-en") in captured, + f"got {[v for b, v in captured if b == 'one-pager']!r}") + check("pair_names includes landing-page (CN/EN)", + ("landing-page", "landing-page-en") in captured, + f"got {[v for b, v in captured if b == 'landing-page']!r}") + check("pair_names omits lone -en entries", + not any(name.endswith("-en") for name, _ in _pair_names())) + + +def test_pair_names_includes_ko_variants_when_present() -> None: + """`_pair_names` must yield (base, base-ko) pairs in addition to (base, base-en).""" + captured = list(_pair_names()) + # Sanity: existing CN/EN and CN/KO pairs still detected, so the sweep + # below cannot pass vacuously on an empty or EN-only pair list. + check("CN/EN pair still detected", ("one-pager", "one-pager-en") in captured) + check("CN/KO pair still detected", ("one-pager", "one-pager-ko") in captured) + # Any base whose `-ko` sibling is registered must appear as a (base, base-ko) pair. + bases = {base for base, _ in captured} + seen = set(HTML_TEMPLATES) | set(SCREEN_TEMPLATES) + missing = [ + base for base in bases + if f"{base}-ko" in seen and (base, f"{base}-ko") not in captured + ] + check("pair_names includes ko variants when present", not missing, + f"unpaired KO bases: {missing}") + + +def test_cross_template_consistency_clean() -> None: + """The current repo should pass cross-template consistency.""" + rc = silently(check_cross_template_consistency, False) + check("cross-template returns 0 on current repo", rc == 0, f"rc={rc}") + + +def test_extract_root_vars_picks_up_definitions() -> None: + fixture = """<!doctype html> +<html><head><style> +:root { + --brand: #1B365D; + --parchment: #F5F4ED; + --serif: Charter, Georgia, serif; +} +</style></head><body></body></html> +""" + p = write_temp_html(fixture) + try: + vars_ = _extract_root_vars(p) + check("extract_root_vars finds --brand", vars_.get("--brand") == "#1B365D", + f"got {vars_.get('--brand')!r}") + check("extract_root_vars finds --parchment", vars_.get("--parchment") == "#F5F4ED", + f"got {vars_.get('--parchment')!r}") + finally: + p.unlink(missing_ok=True) + + +# --------------------------- _last_content_y --------------------------- + +def _make_samples(rows_with_content: int, w: int, h: int, n: int = 3) -> bytes: + """Build a flat RGB buffer: parchment everywhere, ink in the top N rows. + + Returns bytes matching the layout PyMuPDF's Pixmap uses, so we can drive + _last_content_y without depending on a real PDF or numpy. + """ + parchment_row = bytes((_BG_R, _BG_G, _BG_B)) * w + ink_row = bytes((27, 54, 93)) * w + out = bytearray() + for y in range(h): + out.extend(ink_row if y < rows_with_content else parchment_row) + return bytes(out) + + +def test_last_content_y_dense_page() -> None: + """Page with content all the way to the bottom: returns h-1.""" + w, h, n = 80, 100, 3 + samples = _make_samples(rows_with_content=h, w=w, h=h, n=n) + y = _last_content_y(samples, w, h, w * n, n) + check("_last_content_y dense page returns last row", y == h - 1, f"got {y}") + + +def test_last_content_y_sparse_page() -> None: + """Page with content only in top 10 rows: returns 9.""" + w, h, n = 80, 100, 3 + samples = _make_samples(rows_with_content=10, w=w, h=h, n=n) + y = _last_content_y(samples, w, h, w * n, n) + check("_last_content_y sparse page returns last content row", + y == 9, f"got {y}") + + +def test_last_content_y_blank_page() -> None: + """Page with no content at all: returns 0.""" + w, h, n = 80, 100, 3 + samples = _make_samples(rows_with_content=0, w=w, h=h, n=n) + y = _last_content_y(samples, w, h, w * n, n) + check("_last_content_y blank page returns 0", y == 0, f"got {y}") + + +def test_density_threshold_buckets() -> None: + """Drive the real `_density_bucket` seam so the SPARSE (>50%) / WARN (>25%) + / OK categorization is asserted against production logic, not a reimplemented + copy. A `>`->`>=` slip or a warn/sparse swap in checks.py fails here.""" + density_cfg = load_checks_thresholds()["density"] + warn_pct = float(density_cfg["warn_pct"]) + sparse_pct = float(density_cfg["sparse_pct"]) + cases = [ + (0.0, "OK"), # full page + (warn_pct, "OK"), # exactly at warn threshold -> not yet WARN (strict >) + (0.30, "WARN"), # 30% trailing + (sparse_pct, "WARN"), # exactly at sparse threshold -> still WARN (strict >) + (0.51, "SPARSE"), # 51% trailing + (1.0, "SPARSE"), # blank page + ] + for empty, expected_bucket in cases: + bucket = _density_bucket(empty, warn_pct, sparse_pct) + check( + f"_density_bucket empty={empty:.2f} -> {expected_bucket}", + bucket == expected_bucket, + f"got {bucket}", + ) + + +def test_rhythm_issues_rules() -> None: + """Drive the three monotony rules in `_rhythm_issues` directly, without + rendering a deck. Covers content-run limit, missing divider, and missing + density-variation slide, plus the clean case.""" + max_run, min_deck = 4, 8 + + healthy = [ + "title_slide", "content_slide", "content_slide", "quote_slide", + "chapter_slide", "content_slide", "metrics_slide", "closing_slide", + ] + check("rhythm: balanced deck has no issues", + _rhythm_issues(healthy, max_run, min_deck) == [], + f"got {_rhythm_issues(healthy, max_run, min_deck)}") + + long_run = ["quote_slide"] + ["content_slide"] * (max_run + 1) + issues = _rhythm_issues(long_run, max_run, min_deck) + check("rhythm: over-long content run flagged", + any("content_slide run" in i for i in issues), f"got {issues}") + + no_divider = ["title_slide"] + ["content_slide", "quote_slide"] * 5 + issues = _rhythm_issues(no_divider, max_run, min_deck) + check("rhythm: large deck without divider flagged", + any("no chapter_slide divider" in i for i in issues), f"got {issues}") + + no_variation = ["title_slide", "content_slide", "chapter_slide", "content_slide"] + issues = _rhythm_issues(no_variation, max_run, min_deck) + check("rhythm: deck without quote/metrics flagged", + any("density variation" in i for i in issues), f"got {issues}") + + +def test_orphan_last_line_predicate() -> None: + """Drive `_orphan_last_line`: a short trailing line on a multi-line block + is an orphan; single-line blocks and long trailing lines are not.""" + max_words, max_chars = 3, 30 + + orphan = "This is a full sentence that wraps\nword" + check("orphan: short trailing line detected", + _orphan_last_line(orphan, max_words, max_chars) == "word", + f"got {_orphan_last_line(orphan, max_words, max_chars)!r}") + + single = "Only one line here" + check("orphan: single-line block is not an orphan", + _orphan_last_line(single, max_words, max_chars) is None, + "single line flagged") + + long_tail = "First line of the block\n" + "x" * (max_chars + 5) + check("orphan: long trailing line is not an orphan", + _orphan_last_line(long_tail, max_words, max_chars) is None, + "long tail flagged") + + many_words = "First line here\none two three four five" + check("orphan: wordy trailing line is not an orphan", + _orphan_last_line(many_words, max_words, max_chars) is None, + "wordy tail flagged") + + +def test_resume_balance_issues() -> None: + min_fill, max_fill, max_gap = 0.83, 0.95, 0.12 + check("resume balance accepts two filled pages", + _resume_balance_issues([0.88, 0.92], 2, min_fill, max_fill, max_gap) == []) + + issues = _resume_balance_issues([0.92, 0.74], 2, min_fill, max_fill, max_gap) + check("resume balance flags low second page", + any("p2 fill" in issue for issue in issues), + f"issues={issues}") + check("resume balance flags page gap", + any("gap" in issue for issue in issues), + f"issues={issues}") + + issues = _resume_balance_issues([0.90, 0.89, 0.50], 3, min_fill, max_fill, max_gap) + check("resume balance requires two pages", + any("expected 2" in issue for issue in issues), + f"issues={issues}") + + +# --------------------------- runner --------------------------- + +def test_highlight_with_language() -> None: + html = '<pre><code class="language-python">def foo():\n pass</code></pre>' + out = highlight_code_blocks(html) + if importlib.util.find_spec("pygments") is None: + check("highlight skips styled output when Pygments is absent", + out == html, + f"out differs: {out[:200]}") + return + + check("highlight adds style spans to language-tagged block", + "<span" in out and "style=" in out, + f"out: {out[:200]}") + check("highlight avoids synthetic bold", + "font-weight" not in out.lower(), + f"out: {out[:200]}") + check("highlight preserves pre/code wrapper", + "<pre" in out and "</code>" in out) + + +def test_highlight_without_language() -> None: + html = '<pre><code>def foo():\n pass</code></pre>' + out = highlight_code_blocks(html) + check("highlight does not modify plain code block", + out == html, + f"out differs: {out[:200]}") + + +def test_highlight_without_pygments_dependency() -> None: + html = '<pre><code class="language-python">def foo():\n pass</code></pre>' + original_import = builtins.__import__ + original_warned = highlight_mod._WARNED_MISSING_PYGMENTS + + def fake_import(name, *args, **kwargs): + if name == "pygments" or name.startswith("pygments."): + raise ImportError("blocked for fallback test") + return original_import(name, *args, **kwargs) + + try: + highlight_mod._WARNED_MISSING_PYGMENTS = False + builtins.__import__ = fake_import + warning = io.StringIO() + with contextlib.redirect_stderr(warning): + out = highlight_code_blocks(html) + finally: + builtins.__import__ = original_import + highlight_mod._WARNED_MISSING_PYGMENTS = original_warned + + check("highlight falls back unchanged without Pygments", + out == html, + f"out differs: {out[:200]}") + check("highlight warns when Pygments is missing", + "WARN: Pygments is not installed" in warning.getvalue(), + f"warning: {warning.getvalue()}") + + +def test_marp_themes_token_synced() -> None: + """Marp theme CSS keeps its :root tokens in sync with tokens.json. + + Locks the invariant AGENTS.md documents (tokens.py globs marp/*.css), so the + Marp decks cannot silently drift even if that glob is later refactored away. + """ + from shared import TOKENS_FILE + from tokens import CSS_VAR, ROOT_BLOCK + + canonical = {k.lstrip("-"): v.strip().lower() + for k, v in json.loads(TOKENS_FILE.read_text(encoding="utf-8")).items()} + marp_files = sorted((TEMPLATES / "marp").glob("*.css")) + check("marp theme CSS present", len(marp_files) >= 1, f"found {len(marp_files)} file(s)") + + drift: list[str] = [] + checked = 0 + for path in marp_files: + block = ROOT_BLOCK.search(path.read_text(encoding="utf-8", errors="replace")) + if not block: + continue + checked += 1 + found = {m.group(1): m.group(2).strip().lower() + for m in CSS_VAR.finditer(block.group(1))} + for name, expected in canonical.items(): + actual = found.get(name) + if actual is not None and actual != expected: + drift.append(f"{path.name}: --{name} expected {expected}, got {actual}") + check("marp theme :root tokens match tokens.json", + checked >= 1 and not drift, + "; ".join(drift) if drift else f"checked {checked}, no :root block found") + + +def test_mermaid_theme_matches_tokens() -> None: + from shared import TOKENS_FILE + canonical = json.loads(TOKENS_FILE.read_text(encoding="utf-8")) + issues = _mermaid_theme_drift(canonical) + check("mermaid theme colors and role docs match tokens.json", + issues == [], + f"issues: {issues}") + + +def test_mermaid_theme_drift_flags_token_mismatch() -> None: + from shared import TOKENS_FILE + canonical = json.loads(TOKENS_FILE.read_text(encoding="utf-8")) + canonical["--brand"] = "#000000" + issues = _mermaid_theme_drift(canonical) + check("mermaid theme drift flags accent token mismatch", + any("accent" in issue and "--brand" in issue for issue in issues), + f"issues: {issues}") + + +def test_mermaid_normalize_defaults_match_theme() -> None: + import mermaid_normalize as mermaid_mod + theme = json.loads((REPO_ROOT / "references" / "mermaid-theme.json").read_text(encoding="utf-8")) + expected_colors = {f"--{key}": value for key, value in theme["colors"].items()} + check("mermaid normalizer fallback colors mirror mermaid-theme.json", + mermaid_mod._DEFAULT_COLORS == expected_colors, + f"default={mermaid_mod._DEFAULT_COLORS}, theme={expected_colors}") + check("mermaid normalizer fallback font mirrors mermaid-theme.json", + mermaid_mod._DEFAULT_FONT_STACK == theme["cssFontStack"], + f"default={mermaid_mod._DEFAULT_FONT_STACK}, theme={theme['cssFontStack']}") + + +# --------------------------- mermaid normalize --------------------------- + +def test_mermaid_color_mix_srgb_single_pct() -> None: + from mermaid_normalize import _Resolver + r = _Resolver({"--fg": "#141413", "--bg": "#f5f4ed"}) + # color-mix(in srgb, fg 12%, bg) == 0.12*fg + 0.88*bg + got = r.hex_of("color-mix(in srgb, var(--fg) 12%, var(--bg))") + check("color-mix(in srgb, fg 12%, bg) resolves to warm gray", + got == "#dad9d3", f"got {got}") + + +def test_mermaid_color_mix_both_pct() -> None: + from mermaid_normalize import _Resolver + r = _Resolver({"--bg": "#ffffff", "--c": "#000000"}) + got = r.hex_of("color-mix(in srgb, var(--bg) 75%, var(--c) 25%)") + check("color-mix honors both explicit percentages", got == "#bfbfbf", f"got {got}") + + +def test_mermaid_normalize_strips_unsafe_features() -> None: + from mermaid_normalize import normalize + # Root carries a deliberately NON-Kami theme (red accent, white bg) to prove + # the normalizer re-themes to the Kami palette regardless of source theme. + raw = ( + '<svg xmlns="http://www.w3.org/2000/svg" ' + 'style="--bg:#ffffff;--fg:#000000;--accent:#ff0000;background:var(--bg)">' + "<style>@import url('https://fonts.googleapis.com/css2?family=Charter');\n" + " text { font-family: 'Charter', system-ui, sans-serif; }\n" + " svg { --_t: color-mix(in srgb, var(--fg) 25%, var(--bg)); }</style>" + '<rect fill="var(--accent)" stroke="var(--fg)"/></svg>' + ) + out = normalize(raw) + check("normalize removes color-mix()", "color-mix(" not in out, out) + check("normalize removes var()", "var(" not in out, out) + check("normalize removes google-fonts import", "googleapis" not in out, out) + check("normalize drops the quoted single-family bug", "'Charter'" not in out, out) + check("normalize keeps the Kami CJK serif stack", "TsangerJinKai02" in out, out) + check("normalize resolves fill to a static hex", 'fill="#' in out, out) + check("normalize re-themes accent to Kami ink-blue", + "#1b365d" in out.lower(), out) + check("normalize drops the source theme's red accent", + "#ff0000" not in out.lower(), out) + + +def test_mermaid_lint_flags_unnormalized_svg() -> None: + body = '<svg><rect fill="color-mix(in srgb, #000000 50%, #ffffff)"/></svg>' + path = write_temp_html(body, suffix=".html") # not a screen-template name + try: + rules = {f.rule for f in scan_file(path)} + check("scan_file flags un-normalized mermaid color-mix", + "mermaid-color-mix" in rules, f"rules={rules}") + finally: + path.unlink(missing_ok=True) + + +def test_mermaid_diagram_templates_normalized() -> None: + for name in ("sequence.html", "class.html", "er.html"): + path = REPO_ROOT / "assets" / "diagrams" / name + check(f"diagram {name} exists", path.exists(), f"missing {path}") + if not path.exists(): + continue + text = path.read_text(encoding="utf-8") + check(f"{name} carries no color-mix()", "color-mix(" not in text) + check(f"{name} carries no <foreignObject>", "<foreignObject" not in text) + check(f"{name} carries no runtime web-font import", "googleapis" not in text) + + +def test_mermaid_diagrams_match_their_mmd_sources() -> None: + """The committed diagram HTML must still carry every node/participant/entity + label from its .mmd source. No Node regenerates these, so this guards against + a .mmd edit that silently leaves the committed SVG stale.""" + src_dir = REPO_ROOT / "assets" / "diagrams" / "src" + sources = sorted(src_dir.glob("*.mmd")) + check("diagram .mmd sources present", len(sources) >= 1, f"found {len(sources)}") + for mmd in sources: + html_path = REPO_ROOT / "assets" / "diagrams" / f"{mmd.stem}.html" + check(f"{mmd.stem}.html exists for {mmd.name}", html_path.exists()) + if not html_path.exists(): + continue + labels: set[str] = set() + for line in mmd.read_text(encoding="utf-8").splitlines(): + line = line.strip() + m = re.match(r"participant\s+\S+\s+as\s+(.+)", line) + if m: + labels.add(m.group(1).strip()) + m = re.match(r"class\s+(\w+)", line) + if m: + labels.add(m.group(1)) + labels.update(re.findall(r"\b([A-Z][A-Z_]{2,})\b", line)) # ER entities + body = html_path.read_text(encoding="utf-8") + missing = sorted(label for label in labels if label not in body) + check(f"{mmd.stem}.html carries all {mmd.name} labels", + not missing, f"missing labels (regenerate the diagram): {missing}") + + +def test_mermaid_normalize_rejects_non_beautiful_mermaid() -> None: + """A non-beautiful-mermaid SVG (no --bg/--fg roles) must raise, not silently + emit unresolved colors.""" + from mermaid_normalize import normalize + raised = False + try: + normalize('<svg xmlns="http://www.w3.org/2000/svg"><rect fill="#000"/></svg>') + except ValueError: + raised = True + check("normalize rejects input lacking --bg/--fg color roles", raised) + + +def test_mermaid_normalize_cli_accepts_output_before_input() -> None: + """CLI parsing should accept both `input -o out` and `-o out input`.""" + script = REPO_ROOT / "scripts" / "mermaid_normalize.py" + raw = ( + '<svg xmlns="http://www.w3.org/2000/svg" ' + 'style="--bg:#ffffff;--fg:#000000;--accent:#ff0000">' + '<rect fill="var(--accent)" /></svg>' + ) + with tempfile.TemporaryDirectory() as d: + dp = Path(d) + src = dp / "raw.svg" + out = dp / "clean.svg" + src.write_text(raw, encoding="utf-8") + result = subprocess.run( + [sys.executable, str(script), "-o", str(out), str(src)], + cwd=REPO_ROOT, + capture_output=True, + text=True, + ) + body = out.read_text(encoding="utf-8") if out.exists() else "" + check("mermaid_normalize CLI supports -o before input", + result.returncode == 0 and out.exists() and "color-mix(" not in body and "var(" not in body, + (result.stdout + result.stderr).strip()) + + +def test_mermaid_normalize_cli_reports_missing_input() -> None: + """Missing input should be a concise ERROR, not a Python traceback.""" + script = REPO_ROOT / "scripts" / "mermaid_normalize.py" + with tempfile.TemporaryDirectory() as d: + result = subprocess.run( + [sys.executable, str(script), str(Path(d) / "missing.svg")], + cwd=REPO_ROOT, + capture_output=True, + text=True, + ) + combined = result.stdout + result.stderr + check("mermaid_normalize CLI reports missing input without traceback", + result.returncode == 1 and "ERROR:" in combined and "Traceback" not in combined, + combined.strip()) + + +def _test_functions(): + tests = [] + for name, func in globals().items(): + if not name.startswith("test_") or not callable(func): + continue + if getattr(func, "__module__", None) != __name__: + continue + code = getattr(func, "__code__", None) + if code is None: + continue + tests.append((code.co_firstlineno, name, func)) + return [(name, func) for _, name, func in sorted(tests)] + + +def main() -> int: + for name, func in _test_functions(): + signature = inspect.signature(func) + if signature.parameters: + params = ", ".join(signature.parameters) + check(f"{name} has no parameters", False, f"parameters: {params}") + continue + func() + print() + print(f"Passed: {_PASS} | Failed: {_FAIL}") + return 0 if _FAIL == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/kami/skills/kami/scripts/tokens.py b/plugins/kami/skills/kami/scripts/tokens.py new file mode 100644 index 0000000..aa30c76 --- /dev/null +++ b/plugins/kami/skills/kami/scripts/tokens.py @@ -0,0 +1,153 @@ +"""Token-sync checker for kami templates. + +Splits out from build.py: scans `:root { ... }` blocks across HTML templates +and `RGBColor(0xXX, 0xXX, 0xXX)` constants in the PPTX slide scripts, and +reports drift from `references/tokens.json` (the canonical color tokens). +""" +from __future__ import annotations + +import json +import re +from pathlib import Path + +from shared import ROOT, TEMPLATES, TOKENS_FILE, iter_template_files + +ROOT_BLOCK = re.compile(r":root\s*\{([^}]*)\}", re.DOTALL) +CSS_VAR = re.compile(r"--([\w-]+)\s*:\s*([^;]+);") + + +def parse_root_vars(text: str) -> dict[str, str]: + """Return {'--name': value} merged across every `:root { ... }` block. + + Scanning all blocks (not just the first) keeps a future dark-mode or print + override inside a second `:root` from silently escaping the drift checks. + """ + found: dict[str, str] = {} + for block in ROOT_BLOCK.finditer(text): + for m in CSS_VAR.finditer(block.group(1)): + found[f"--{m.group(1)}"] = m.group(2).strip() + return found +PY_RGB = re.compile( + r"^([A-Z][A-Z_]+)\s*=\s*RGBColor\(\s*0x([0-9a-fA-F]{2})\s*," + r"\s*0x([0-9a-fA-F]{2})\s*,\s*0x([0-9a-fA-F]{2})\s*\)", + re.MULTILINE, +) +# Python const name -> tokens.json key. Only constants that mirror a CSS token. +PY_TOKEN_MAP = { + "PARCHMENT": "--parchment", + "IVORY": "--ivory", + "BRAND": "--brand", + "NEAR_BLACK": "--near-black", + "DARK_WARM": "--dark-warm", + "CHARCOAL": "--charcoal", + "OLIVE": "--olive", + "STONE": "--stone", +} +MERMAID_THEME_FILE = ROOT / "references" / "mermaid-theme.json" +MERMAID_THEME_TOKEN_MAP = { + "bg": "--parchment", + "fg": "--near-black", + "line": "--olive", + "accent": "--brand", + "muted": "--stone", + "surface": "--ivory", + "border": "--border", +} + + +def _mermaid_theme_drift(canonical: dict[str, str]) -> list[str]: + if not MERMAID_THEME_FILE.exists(): + return [f"mermaid-theme.json not found at {MERMAID_THEME_FILE.relative_to(ROOT)}"] + + try: + theme = json.loads(MERMAID_THEME_FILE.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + return [f"mermaid-theme.json is malformed: {exc}"] + + colors = theme.get("colors", {}) + roles = theme.get("roles", {}) + drift: list[str] = [] + for role, token in MERMAID_THEME_TOKEN_MAP.items(): + expected = canonical.get(token) + actual = colors.get(role) + if expected is None: + drift.append(f"{role}: canonical token {token} missing") + continue + if actual is None: + drift.append(f"{role}: color missing, expected {expected} from {token}") + elif actual.lower() != expected.lower(): + drift.append(f"{role}: expected {expected} from {token}, got {actual}") + + role_doc = str(roles.get(role, "")) + if token not in role_doc: + drift.append(f"{role}: role doc should mention {token}") + + return drift + + +def sync_check(verbose: bool = False) -> int: + if not TOKENS_FILE.exists(): + print(f"ERROR: tokens.json not found at {TOKENS_FILE.relative_to(ROOT)}") + return 2 + + try: + canonical: dict[str, str] = json.loads(TOKENS_FILE.read_text()) + except json.JSONDecodeError as exc: + print(f"ERROR: tokens.json is malformed: {exc}") + return 2 + + targets = iter_template_files(include_diagrams=True, include_marp_css=True) + py_targets: list[Path] = list(TEMPLATES.glob("*.py")) + if not targets and not py_targets: + print("ERROR: no templates found to token-check (bad checkout?)") + return 2 + + drift: list[tuple[str, str, str, str]] = [] # (file, token, expected, actual) + + for path in targets: + text = path.read_text(encoding="utf-8", errors="replace") + found = parse_root_vars(text) + if not found: + if verbose: + print(f" (skip {path.name}: no :root block)") + continue + rel = path.relative_to(ROOT) + for token, expected in canonical.items(): + actual = found.get(token) + # Only flag if the template defines the token but with a wrong value. + # Templates that don't use a token don't need to define it. + if actual is not None and actual.lower() != expected.lower(): + drift.append((str(rel), token, expected, actual)) + + for path in sorted(py_targets): + text = path.read_text(encoding="utf-8", errors="replace") + rel = path.relative_to(ROOT) + for m in PY_RGB.finditer(text): + name = m.group(1) + token = PY_TOKEN_MAP.get(name) + if token is None: + continue + expected = canonical.get(token) + if expected is None: + continue + actual = f"#{m.group(2)}{m.group(3)}{m.group(4)}" + if actual.lower() != expected.lower(): + drift.append((str(rel), token, expected, actual)) + + theme_drift = _mermaid_theme_drift(canonical) + + if not drift and not theme_drift: + scanned = len(targets) + len(py_targets) + print(f"OK: tokens in sync across {scanned} template(s) and mermaid-theme.json") + return 0 + + if drift: + print(f"\nERROR: [token-drift] {len(drift)}") + for file, token, expected, actual in drift: + print(f" {file}: {token} expected {expected}, got {actual}") + if theme_drift: + print(f"\nERROR: [mermaid-theme-drift] {len(theme_drift)}") + for issue in theme_drift: + print(f" {issue}") + + return 1 diff --git a/plugins/kami/skills/kami/scripts/verify.py b/plugins/kami/skills/kami/scripts/verify.py new file mode 100644 index 0000000..e2fd02a --- /dev/null +++ b/plugins/kami/skills/kami/scripts/verify.py @@ -0,0 +1,293 @@ +"""End-to-end render verification for kami templates. + +Splits out from build.py: renders each template via WeasyPrint, validates +page-count against per-template ceilings, inspects embedded PDF fonts to +warn when only a fallback is used, and runs an advisory density scan over +the rendered examples when invoked for the full suite. +""" +from __future__ import annotations + +import os +import re +import subprocess +from pathlib import Path + +from highlight import highlight_code_blocks +from optional_deps import ( + MissingDepError, + require_pypdf_reader, + require_weasyprint_html, +) +from shared import DIAGRAMS, EXAMPLES, TEMPLATES, default_example_pdfs, load_checks_thresholds + +# Primary fonts expected in embedded PDF font names +CN_PRIMARY_FONTS = {"TsangerJinKai02"} +EN_PRIMARY_FONTS = {"Charter"} +KO_PRIMARY_FONTS = {"Source-Han-Serif-K", "SourceHanSerifK"} +RECOGNIZABLE_FALLBACK_FONT_MARKERS = ( + "Georgia", + "Palatino", + "PT-Serif", + "PTSerif", + "TsangerJinKai", + "YuMincho", + "Hiragino", + "SourceHan", + "Noto", + "Charter", + "Songti", + "DejaVu", + "Liberation", +) + + +def show_fonts(pdf: Path) -> None: + if not pdf.exists(): + return + try: + out = subprocess.run(["pdffonts", str(pdf)], capture_output=True, text=True, check=False) + if out.returncode == 0: + print("--- pdffonts ---") + print(out.stdout.rstrip()) + except FileNotFoundError: + pass # pdffonts not installed; silent + + +def _pdf_font_names(pdf_path: Path) -> set[str]: + def _resolve_pdf_obj(obj): + if obj is None: + return None + try: + return obj.get_object() if hasattr(obj, "get_object") else obj + except Exception: + return obj + + try: + PdfReader = require_pypdf_reader() + reader = PdfReader(str(pdf_path)) + fonts: set[str] = set() + for page in reader.pages: + resources = _resolve_pdf_obj(page.get("/Resources")) + if resources is None or not hasattr(resources, "get"): + continue + font_dict = _resolve_pdf_obj(resources.get("/Font")) + if font_dict is None or not hasattr(font_dict, "values"): + continue + for obj in font_dict.values(): + resolved = _resolve_pdf_obj(obj) + if resolved is None or not hasattr(resolved, "get"): + continue + base = resolved.get("/BaseFont") + if base: + fonts.add(str(base).lstrip("/")) + return fonts + except Exception as exc: + print(f" WARN: could not read font names from PDF: {exc}") + return set() + + +def _check_font_sources(html_path: Path) -> list[str]: + """Return list of local @font-face src files that are missing on disk.""" + text = html_path.read_text(encoding="utf-8", errors="replace") + missing: list[str] = [] + for url in re.findall(r"""url\(["']?([^"')]+)["']?\)""", text): + if url.startswith(("http://", "https://", "data:", "#")): + continue + resolved = (html_path.parent / url).resolve() + if not resolved.exists(): + missing.append(url) + return missing + + +def verify_target( + name: str, + source: str, + max_pages: int, + src_dir: Path, + *, + infer_author_fn, + set_pdf_metadata_fn, +) -> list[str]: + """Render `source` to a PDF, then run page-count and font checks. + + `infer_author_fn` and `set_pdf_metadata_fn` are passed in by the caller + (build.py) so this module avoids a circular import on those helpers. + """ + issues: list[str] = [] + src = src_dir / source + if not src.exists(): + issues.append(f"source not found: {src}") + return issues + + try: + HTML = require_weasyprint_html() + PdfReader = require_pypdf_reader() + except MissingDepError as exc: + issues.append(str(exc)) + return issues + + EXAMPLES.mkdir(parents=True, exist_ok=True) + out = EXAMPLES / f"{name}.pdf" + + # Warn about missing local font files before rendering + missing_fonts = _check_font_sources(src) + if missing_fonts: + for mf in missing_fonts: + print(f" [FONT MISS] {name}: {mf} not found") + print(f" [FONT MISS] Repo fix: git checkout -- assets/fonts (commercial TTFs are tracked)") + print(f" [FONT MISS] Skill recovery (downloads to the user font dir, not the skill): bash scripts/ensure-fonts.sh") + print(f" [FONT MISS] Fallback: brew install --cask font-source-han-serif-sc") + + html_text = src.read_text(encoding="utf-8") + html_text = highlight_code_blocks(html_text) + HTML(string=html_text, base_url=str(src.parent)).write_pdf(str(out)) + + # Set PDF metadata (only replaces placeholders, preserves filled values) + set_pdf_metadata_fn(out, author=infer_author_fn()) + + # page count check + n = len(PdfReader(str(out)).pages) + if max_pages and n > max_pages: + over = n - max_pages + hint = "" + if "resume" in name and over == 1: + hint = '; add class="resume--dense" to <body> or tighten .proj-text line-height to 1.38' + issues.append(f"page overflow: {n} pages (limit {max_pages}){hint}") + + # font check + embedded = _pdf_font_names(out) + fallback_present = any( + kw in font for font in embedded + for kw in RECOGNIZABLE_FALLBACK_FONT_MARKERS + ) + + # Diagram templates are language-neutral and often rely on fallback stacks, + # so only enforce that at least one recognizable serif/sans fallback exists. + is_diagram = src_dir == DIAGRAMS + if is_diagram: + if not fallback_present: + issues.append(f"no recognizable font embedded in {out.name}") + return issues + + is_en = name.endswith("-en") + is_ko = name.endswith("-ko") + expected = EN_PRIMARY_FONTS if is_en else (KO_PRIMARY_FONTS if is_ko else CN_PRIMARY_FONTS) + if not any(exp in font_name for exp in expected for font_name in embedded): + primary = next(iter(expected)) + if not fallback_present: + issues.append(f"no recognizable font embedded in {out.name}") + elif os.environ.get("KAMI_ALLOW_FALLBACK_ONLY"): + # CI / headless boxes never have commercial fonts (TsangerJinKai02, + # Charter). Treat "primary missing, fallback present" as a warning + # there so CI can still gate page-count regressions. + print(f" WARN: {name}: primary font ({primary}) not embedded; using fallback") + else: + issues.append(f"primary font ({primary}) not embedded; using fallback") + + return issues + + +def verify_screen_target(name: str, source: str, scan_file_fn) -> list[str]: + """Lint a browser-only template via the caller-provided scan_file.""" + src = TEMPLATES / source + if not src.exists(): + return [f"source not found: {src}"] + findings = scan_file_fn(src) + if findings: + return [f"{len(findings)} template violation(s)"] + return [] + + +def verify_all( + target: str | None, + *, + html_targets, + screen_targets, + diagram_targets, + pptx_targets, + verify_slides_fn, + scan_file_fn, + scan_density_fn, + infer_author_fn, + set_pdf_metadata_fn, +) -> int: + """Drive verification across the requested target set. + + All registries and helper callbacks are passed in to keep this module free + of circular imports back into build.py. + """ + targets_to_run: dict[str, tuple[str, int, Path] | None] = {} + screen_targets_to_run: dict[str, str] = {} + if target: + if target in html_targets: + src, mp = html_targets[target] + targets_to_run[target] = (src, mp, TEMPLATES) + elif target in screen_targets: + screen_targets_to_run[target] = screen_targets[target] + elif target in diagram_targets: + targets_to_run[target] = (diagram_targets[target], 0, DIAGRAMS) + elif target in pptx_targets: + targets_to_run[target] = None + else: + print(f"ERROR: unknown target: {target}") + return 2 + else: + for name, (src, mp) in html_targets.items(): + targets_to_run[name] = (src, mp, TEMPLATES) + for name, src in screen_targets.items(): + screen_targets_to_run[name] = src + for name, src in diagram_targets.items(): + targets_to_run[name] = (src, 0, DIAGRAMS) + for name in pptx_targets: + targets_to_run[name] = None + + failures = 0 + rows: list[tuple[str, str]] = [] + for name, config in targets_to_run.items(): + if config is None: + issues = verify_slides_fn(name) + else: + source, max_pages, src_dir = config + issues = verify_target( + name, source, max_pages, src_dir, + infer_author_fn=infer_author_fn, + set_pdf_metadata_fn=set_pdf_metadata_fn, + ) + if issues: + rows.append((f"ERROR: {name}", "; ".join(issues))) + failures += 1 + else: + rows.append((f"OK: {name}", "ok")) + + for name, source in screen_targets_to_run.items(): + issues = verify_screen_target(name, source, scan_file_fn) + if issues: + rows.append((f"ERROR: {name}", "; ".join(issues))) + failures += 1 + else: + rows.append((f"OK: {name}", "static HTML template")) + + for status, detail in rows: + print(f"{status}: {detail}") + + if target is None: + pdfs = default_example_pdfs() + if pdfs: + print() + print("Density scan (advisory):") + scan = scan_density_fn(pdfs) + if scan is not None: + sparse, warn, _, scanned = scan + if sparse + warn == 0: + print(f" OK: no density issues across {scanned} PDF(s)") + else: + density_cfg = load_checks_thresholds()["density"] + sparse_pct_disp = int(round(float(density_cfg["sparse_pct"]) * 100)) + warn_pct_disp = int(round(float(density_cfg["warn_pct"]) * 100)) + if sparse: + print(f" {sparse} SPARSE page(s) (>{sparse_pct_disp}% trailing whitespace) across {scanned} PDF(s)") + if warn: + print(f" {warn} WARN page(s) (>{warn_pct_disp}%) across {scanned} PDF(s)") + print(" (advisory: re-author with SKILL.md Step 4.1 merge rule. Does not fail --verify.)") + + return 0 if failures == 0 else 1 diff --git a/references/anti-patterns.md b/references/anti-patterns.md new file mode 100644 index 0000000..279ed92 --- /dev/null +++ b/references/anti-patterns.md @@ -0,0 +1,99 @@ +# Anti-Patterns: AI Document Quality + +Common failures when AI generates professional documents. Organized by failure type, each with a bad example and the fix. Use alongside `writing.md` quality bars. + +## Content Emptiness + +| # | Pattern | Bad | Fix | +|---|---------|-----|-----| +| 1 | Adjective pile-up, no numbers | "Achieved significant growth across key metrics" | State the number: "Revenue grew 34% YoY to $12M" | +| 2 | Opening-paragraph filler | "In today's rapidly evolving landscape..." | Delete the opener. Start with the first real claim. | +| 3 | Restating the heading as a sentence | Heading "Revenue Growth", body "Our revenue growth has been notable" | Body must add information the heading does not carry | +| 4 | Vague time references | "Recently launched", "in the near future" | Pin to a date or quarter: "Launched Q1 2026" | +| 5 | Synonyms masking repetition | Three paragraphs saying "we are growing" in different words | One claim, one proof, move on | + +## Metric Fabrication + +| # | Pattern | Bad | Fix | +|---|---------|-----|-----| +| 6 | Round numbers implying precision | "Exactly 10,000 users" when the source says "around 10K" | Match the source's precision: "approximately 10,000" | +| 7 | Fake decimal precision | "Market share: 23.7%" with no cited source | Either cite the source or round to "roughly 24%" | +| 8 | Metric-narrative disconnect | Chart shows flat revenue, text says "strong momentum" | Text must match what the chart shows | +| 9 | Invented comparison baselines | "3x faster than alternatives" with no benchmark | Name the alternative and the benchmark method, or remove | +| 10 | Mixing time periods | YoY growth next to QoQ growth as if comparable | Label every comparison window explicitly | + +## Structure Mimicry + +| # | Pattern | Bad | Fix | +|---|---------|-----|-----| +| 11 | Resume bullet without result | "Managed a cross-functional team" | Action + Scope + Result: "Led 8-person team to ship v2.0, reducing churn 15%" | +| 12 | Template slots filled with filler | Skills section listing "Communication, Teamwork, Problem-solving" | Name the specific skill and where it was applied, or cut the section | +| 13 | Equity report without variant perception | "Company is well-positioned for growth" | State what the market gets wrong and why your thesis differs | +| 14 | One-pager without a clear ask | Three sections of context, no "what we need from you" | The ask belongs above the fold, not implied | +| 15 | Slide title as label, not assertion | "Q3 Results" | Assertion-evidence: "Q3 revenue beat guidance by 12%" | + +## Visual Excess + +| # | Pattern | Bad | Fix | +|---|---------|-----|-----| +| 16 | More than 3 brand-color accents per page | Four different colored highlights on the same page | One accent color for emphasis; use weight or size for hierarchy | +| 17 | Chart with no insight title | Chart titled "Revenue by Quarter" | Title states the insight: "Revenue accelerated after Q2 price change" | +| 18 | Decorative chart that restates the text | Bar chart showing the same three numbers the paragraph just listed | If the text already communicates it, the chart must add a dimension (comparison, trend, distribution) | +| 19 | Icon or emoji as section marker | Sections led by decorative icons with no semantic value | Use typographic hierarchy (size, weight, spacing) instead | + +## Source Gaps + +| # | Pattern | Bad | Fix | +|---|---------|-----|-----| +| 20 | Unverified version numbers | "Compatible with v4.2" when latest is v5.1 | Check the official source before citing any version | +| 21 | "Latest" without a date | "Uses the latest framework" | "Uses Next.js 15 (as of 2026-04)" | +| 22 | Competitor comparison without market data | "Leading solution in the market" | Cite the ranking source, or use "one of the established solutions" | +| 23 | Assumed availability | "Available on all major platforms" | List the actual platforms verified | + +## Tone Contamination + +| # | Pattern | Bad | Fix | +|---|---------|-----|-----| +| 24 | Chinese AI corporate speak | "赋能企业数字化转型", "打造一站式解决方案" | Say what it does: "帮公司把纸质流程搬到线上" | +| 25 | English AI corporate speak | "Leverage our platform to unlock synergies" | "Use the platform to share data between teams" | +| 26 | Caption restates the flow diagram | "六类来源 → 六道过滤 → 配比设计 → 训练分片,四步串联" | Cap 给出图意以外的判断:"来源决定知识边界,过滤决定干净程度,配比决定能力侧重" | +| 27 | AI tone cliches (CN dashes and connectors) | "本质上是模型在做预测——这意味着..." / 大量破折号 | 删元评论框架,直接说结论。破折号换冒号或句号。自检: `grep -nE '本质是\|这意味着\|值得注意的是\|不仅.*而且\|[——–]'` | +| 28 | Sans font stack missing CJK fallback | `font-family: Inter` 用在含中文的 th / h3 | CJK 回退到系统 sans (PingFang) 跟 serif 主调冲突。任何可能渲染 CJK 的元素用 `var(--serif)` | +| 29 | Caption restates the slide title | Title: "ORM vs PRM 对比" / Cap: "ORM 跟 PRM 的对比" | Cap 必须给出标题之外的信息:取舍判据、适用场景、或讲到这里要带出的下一步。同义重写浪费了 cap 的注意力位 | + +## Landing Page + +The main `landing-page.html` template does not ship a price card or language switcher by default. Rules #30 and #31 below apply when you add either component (Kami's own `styles.css` L67-151 has a working `.lang-switch` reference). + +| # | Pattern | Bad | Fix | +|---|---------|-----|-----| +| 30 | Currency glyph shrunk and raised | `.price-currency { font-size: 0.5em; vertical-align: super }` floats `$` above the digit and breaks baseline | `font-size: 0.74em; line-height: 1; transform: translateY(0.015em)` keeps `$` and the digit on a shared baseline | +| 31 | Language menu clips descenders | `.lang-menu a { line-height: 1 }` cuts the bottom of 'g' / 'y' / 'p' | `min-height: 32px; padding: 6px 10px; line-height: 1.35` plus an invisible `::before` bridge so the cursor can travel from trigger to menu without dismissal | +| 32 | Sitemap lists locales without hreflang | `<url><loc>example.com/zh/</loc></url>` standing alone | Add `<xhtml:link rel="alternate" hreflang>` for every shipped locale plus `hreflang="x-default"` inside the same `<url>` entry | +| 33 | Mixed CJK and Latin digits drift on baseline | `Mac/22/$19` shows digits at different heights because the serif falls back to oldstyle nums | `font-variant-numeric: lining-nums tabular-nums` on every node that displays numbers | +| 34 | Hardcoded business data in the template | `<meta name="description" content="Acme is a $9 cleaner">` checked into the template file | Keep `{{PLACEHOLDER}}` slots so the user fills them at copy time. Templates ship with no real product data | +| 35 | Multilingual site without `og:locale:alternate` | Single `og:locale` and no alternates listed | Self-reference the current page locale and list the others on `og:locale:alternate` so social previews pick the right thumbnail per region | +| 36 | Stale product category | Hero still uses an old single-tool label after the product became a broader utility | Rewrite title, meta, hero, FAQ, JSON-LD, `llms.txt`, and `llms-full.txt` around the current category before adding feature bullets | +| 37 | Generated locale pages without drift check | `template.html` and `i18n/*.json` generate committed pages, but no `--check` catches stale output | Add a check mode that fails on missing keys and generated-output drift before release or package work | +| 38 | FAQ and `llms-full.txt` diverge | FAQ says one install path or price, AI-facing docs say another | Treat FAQ, JSON-LD, `llms.txt`, and `llms-full.txt` as one public fact set and update them together | +| 39 | Screenshot gallery uses private local assets | `<img src="/Users/me/project/shot.png">` or `../other-repo/shots/app.webp` | Copy the image into the site repo or use a stable public URL; packaged templates must not depend on sibling checkouts | +| 40 | Locale copy updated in only one place | One locale changes price, version, or install path while other locale pages still show the old facts | Search all locale pages plus structured data for the old value and update every matching surface | +| 41 | Feature rows alternated to escape the product-list look | A visual + copy row mirrored every other row, a fixed visual track against a `1fr` copy track: the copy never fills its column, so the surplus lands between the copy and the visual as a hole, and the copy's left edge jumps every other row | Run every row the same way so the copy keeps one left edge and the surplus always falls on the outer trim. Mirror a paragraph-sized text mass, never a short bullet list. Get rhythm from unequal weight (a lead row with a larger visual, fewer points on supporting rows), not from alternation | + +## Image Slots + +| # | Pattern | Bad | Fix | +|---|---------|-----|-----| +| 41 | Image generated before slot | A polished 16:9 concept image is forced into a 3:2 feature panel and loses its focal point | Decide the slot first, then crop, pad, or generate to that ratio | +| 42 | Real screenshot redrawn as fake UI | A product screenshot is AI-redesigned and no longer matches the shipped app | Preserve real screenshots; use programmatic padding or split panels before redrawing | +| 43 | Mixed screenshot ratios in one group | Three gallery panels jump between tall, wide, and square crops | Normalize the frame and padding; keep the screenshot content intact inside it | +| 44 | Missing product image filled with atmosphere | A product page uses abstract texture because no screenshot was provided | Mark the material gap or omit the panel; do not substitute unrelated imagery | + +## Slides + +| # | Pattern | Bad | Fix | +|---|---------|-----|-----| +| 45 | Ghost deck fails | Reading only slide titles produces a pile of topics, not an argument | Rewrite titles and order before touching layout | +| 46 | Multiple evidence shapes on one slide | One slide contains a chart, screenshot, code block, and quote | Pick the primary proof, then split the rest into adjacent slides or appendix | +| 47 | Visual brief leaks into audience copy | Caption says "21:9 ultra-wide screenshot with quiet background" | Keep image prompts and crop notes in the slot map; captions state the insight | +| 48 | Template inventory without a template | Default Kami deck gets a fake layout-mapping phase | Only inventory a real user-provided PPTX or brand template | diff --git a/references/brand-profile.md b/references/brand-profile.md new file mode 100644 index 0000000..5048beb --- /dev/null +++ b/references/brand-profile.md @@ -0,0 +1,76 @@ +# Brand Profile Reference + +Full specification for loading and applying user brand profiles. Referenced from SKILL.md Step 0. + +## Profile locations + +1. `~/.config/kami/brand.md` (global user config, XDG-compliant, preferred) +2. `~/.kami/brand.md` (legacy fallback) + +If found, parse the YAML frontmatter for structured fields and treat the Markdown body below the frontmatter as freeform habit notes. If no profile exists, continue without interruption. + +## Layer A: Placeholder substitution + +Apply at template edit time: replace matching `{{...}}` placeholders in the HTML body with profile values before building. + +| Profile field | Template placeholder(s) | +|---|---| +| `name` | `{{NAME}}`, `{{作者}}`, `{{AUTHOR}}` | +| `role_title` | `{{ROLE_TITLE}}` | +| `email` | `{{EMAIL}}` | +| `website` | `{{WEBSITE}}`, `{{PERSONAL_SITE}}` | +| `github` | `{{GITHUB_URL}}` (expand to full URL: `https://github.com/<value>`) | +| `x` | `{{X_URL}}` (expand to `https://x.com/<value>`) | +| `city` | `{{CITY}}` | +| `country` | `{{COUNTRY}}` | +| `company` | `{{COMPANY}}` | +| `tagline` | `{{TAGLINE}}` | + +Rule: explicit instructions in the current conversation always override profile values (e.g. "sign it as Alex" beats `name: Tang`). Profile fills slots only; if the template has no matching placeholder, do not insert the field. + +## Layer B: Session defaults + +Apply when the current request is ambiguous. Use profile fields to fill in missing signal silently; do not skip clarification questions for genuinely unclear intent (e.g. "make a document" still warrants asking which type). + +| Ambiguous signal | Profile field | Behavior | +|---|---|---| +| No language stated | `language` | Use `cn` / `en` / `ja` path | +| No doc type stated | `default_doc_type` | Use as fallback, still ask if truly ambiguous | +| No output format stated | `output_format` | `pdf` / `pptx` / `both` | +| No page size stated | `page_size` | `a4` or `letter` | +| Currency context unclear | `currency_locale` | `USD` -> $, M/B; `CNY` -> 元, 万/亿 | +| Tone unclear | `tone` | `formal` -> deferential register; `casual` -> relaxed; `balanced` -> default | +| Footer unclear | `footer_note` | Append standing disclaimer if present | +| TOC decision unclear | `always_include_toc` | `true` -> auto-add TOC in long-doc / portfolio | + +## Layer C: Visual customization + +Apply after template content is filled, before calling `build.py`: + +- `brand_color`: edit the `--brand` CSS variable in the template `<style>` block. Warn if the hue departs significantly from ink-blue; the warm palette constraint (parchment + neutrals) remains in force regardless. +- `logo`: the templates with a logo slot are `one-pager`, `portfolio`, and `slides-weasy` (their `-en` variants too). Each ships a commented-out `<img class="brand-logo">` slot. To apply the profile logo, uncomment that line and set `src` to the resolved path. Rules: + - **Resolve the path first.** Expand `~` and environment variables (`os.path.expanduser` / `os.path.expandvars`) to an absolute path before inserting it; WeasyPrint does not expand `~` itself. SVG, PNG, and JPG are all fine. + - **Explicit request wins.** A logo or visual asset named in the current request always takes priority over `profile.logo`; the profile value fills the slot only when the request specifies none. + - **Fail silently.** If the file does not exist, the path is unreadable, or the active template has no logo slot, leave the slot commented and render without a logo. Never insert a broken-image reference, and never announce the omission. + - **Do not homogenize.** Reusing the same logo across documents does not mean reusing the same cover. Per guardrail 3, choose each document's composition, density, and section order fresh for its content. + +## Layer D: Habit notes + +Treat the Markdown body below the YAML frontmatter as additional voice and style context, merged with `references/writing.md` guidance. When a note names a specific document type ("equity reports: dense, cite inline"), apply it when that doc type is active. Habit notes are advisory: if the current document's content does not fit a stated preference, follow the content. + +## Guardrails (preserve editorial judgment) + +These six rules apply to every layer above. Weight them equally with the field table. + +1. **Profile is a fallback, not a first resort.** When the current request already carries tone, urgency, or subject-specific signal, use that signal. Profile fills gaps only. +2. **Editorial judgment can override any layer silently.** If a profile field would harm this specific document, skip it. No explanation needed. Example: `tone: formal` in profile, but the user hands over a playful changelog; follow the changelog's voice. +3. **Vary surface details across documents.** Even when author, brand_color, and company all come from profile, each document's opening hook, chart selection, section order, and density must be chosen fresh for the current content. Two equity reports should not look like clones. +4. **Profile fills slots, never introduces new content.** If the template has no `{{EMAIL}}` placeholder, do not insert a contact line because `email` exists in profile. +5. **Apply silently, do not announce.** Never write "applied your profile defaults" or any similar note in the output. Profile disappears into the background. +6. **Habit notes are advisory, not mandatory.** They describe tendencies ("I prefer dense reports"), not hard rules. When content does not fit, follow the content. + +## Precedence + +``` +explicit prompt > editorial judgment for this document > habit notes > frontmatter defaults > built-in defaults +``` diff --git a/references/brand.example.md b/references/brand.example.md new file mode 100644 index 0000000..9edd05a --- /dev/null +++ b/references/brand.example.md @@ -0,0 +1,54 @@ +# Brand Profile (Optional) +# +# Kami treats this as the lowest-resolution context: a fallback when the current +# request is ambiguous. The current document's needs always come first. Leave +# fields blank when you don't have a strong preference. Silence is fine. +# +# To activate: cp references/brand.example.md ~/.config/kami/brand.md +# Then edit the values below. + +--- +# ─── Identity & contact ─── +name: "" # Your name. Used in author metadata and {{NAME}} placeholders. +role_title: "" # e.g. "Software Engineer", "Partner", "Founder" +email: "" # e.g. "you@example.com" +website: "" # e.g. "yoursite.com" (no protocol needed) +github: "" # GitHub handle only, e.g. "tw93" (not the full URL) +x: "" # X (Twitter) handle only, e.g. "HiTw93" +city: "" # e.g. "San Francisco" +country: "" # e.g. "USA" + +# ─── Brand identity ─── +company: "" # Company or project name for headers and footers +tagline: "" # One-liner used in one-pager / long-doc footer +logo: "" # Optional fallback logo path (SVG/PNG/JPG), e.g. "~/Downloads/logo.svg". Used in one-pager/portfolio/slides covers only when a request names none. +brand_color: "" # Hex to override --brand, e.g. "#1B365D". Warm palette still applies. + +# ─── Document defaults ─── +language: "" # cn / en / ja. Used when the request language is ambiguous. +default_doc_type: "" # one-pager / long-doc / letter / portfolio / resume / slides / equity-report / changelog +output_format: pdf # pdf / pptx / both +page_size: a4 # a4 / letter +always_include_toc: false # true to auto-add TOC in long-doc and portfolio + +# ─── Content conventions ─── +date_format: "YYYY-MM-DD" # Date format used in content +currency_locale: "" # USD → $, M/B notation; CNY → 元, 万/亿 notation +footer_note: "" # Standing disclaimer appended to footers, e.g. "Confidential" +signature_block: "" # Closing signature text for letters +tone: balanced # formal / balanced / casual +--- + +# Habits + +Freeform notes Kami should respect. Write in plain prose or bullets. Be specific +about document types when a preference only applies to one ("equity reports: ..."). +These are advisory: Kami applies them when they fit the content, not mechanically. + +Examples to replace with your own: + +- Equity reports: dense, evidence-led, cite sources inline. Avoid adjective-heavy commentary. +- Letters: formal opening, casual closing. Keep to one page unless content demands two. +- Slides: prefer 5-7 slides, avoid bulleted walls, one strong assertion per slide title. +- Always include a one-line confidentiality disclaimer on internal documents. +- Long docs: open with the conclusion, then support it. Do not bury the thesis. diff --git a/references/checks_thresholds.json b/references/checks_thresholds.json new file mode 100644 index 0000000..aaf40de --- /dev/null +++ b/references/checks_thresholds.json @@ -0,0 +1,21 @@ +{ + "rhythm": { + "max_content_run": 5, + "divider_min_deck_size": 12 + }, + "density": { + "warn_pct": 0.25, + "sparse_pct": 0.50, + "dpi": 36 + }, + "resume_balance": { + "min_fill_pct": 0.83, + "max_fill_pct": 0.95, + "max_gap_pct": 0.12, + "dpi": 36 + }, + "orphan": { + "max_words": 2, + "max_chars": 15 + } +} diff --git a/references/design.md b/references/design.md new file mode 100644 index 0000000..85d3349 --- /dev/null +++ b/references/design.md @@ -0,0 +1,1404 @@ +# Design System + +## Principles + +kami's aesthetic compresses into one sentence: **warm parchment canvas, ink-blue accent, serif carries hierarchy, avoid cool grays and hard shadows**. + +This is not a UI framework. It is a constraint system for print, designed to keep pages stable, clear, and readable. + +**The ten invariants** (each has a real cost, think before overriding): + +1. Page background parchment `#f5f4ed`, never pure white +2. Single accent: ink-blue `#1B365D`, no second chromatic color +3. All grays warm-toned (yellow-brown undertone), no cool blue-grays +4. English: serif for everything (headlines and body). Chinese: serif headlines, sans body. Sans only for UI elements (labels, eyebrows, meta) in both +5. Serif weight locked at 500, no bold +6. Line-heights: tight headlines 1.1-1.3, dense body 1.4-1.45, reading body 1.5-1.55 +7. Letter-spacing: Chinese body 0.3pt for comfortable reading; English body 0; tracking only for short labels and overlines +8. Tag backgrounds must be solid hex, never rgba (WeasyPrint renders a double rectangle) +9. Depth via ring shadow or whisper shadow, never hard drop shadows +10. **No italic in print templates**. No `font-style: italic` in any PDF template or demo. Exception: landing page (screen-only) uses italic for poetic lines (gallery captions, feature subtitles, footer ethos) + +This system is a fusion of Anthropic's visual language and real Chinese / English resume iteration. Details below. + +--- + +## 1. Color + +**Single accent, warm neutrals only, zero cool tones** - this is the core. + +### Brand + +```css +--brand: #1B365D; /* Ink Blue - the only chromatic color. CTAs, accents, section-title left bar. */ +--brand-light: #2D5A8A; /* Ink Light - brighter variant, for links on dark surfaces. */ +``` + +**Rule**: ink-blue covers ≤ **5% of document surface area**. More than that is ornament, not restraint. + +### Surface + +```css +--parchment: #f5f4ed; /* Page background - warm cream, the emotional foundation */ +--ivory: #faf9f5; /* Card / lifted container - brighter than parchment */ +--warm-sand: #e8e6dc; /* Button default / interactive surface */ +--dark-surface: #30302e; /* Dark-theme container - warm charcoal */ +--deep-dark: #141413; /* Dark-theme page background - not pure black, slight olive undertone */ +``` + +**Never**: `#ffffff` pure white as page background. `#f8f9fa` / `#f3f4f6` or any cool-gray surface. + +### Text + +```css +--near-black: #141413; /* Primary text - deepest but not pure black, warm olive undertone */ +--dark-warm: #3d3d3a; /* Secondary text, table headers, links */ +--olive: #504e49; /* Subtext - descriptions, captions. zh-CN TsangerJinKai02 不需要 override. JA override: #4d4c48 (YuMincho thin strokes need darker text) */ +--stone: #6b6a64; /* Tertiary - dates, metadata */ +``` + +Four levels: near-black (primary) > dark-warm (secondary) > olive (subtext) > stone (tertiary). No fifth level needed. + +**Mnemonic**: every gray has a **yellow-brown undertone**. In `rgb()`, warm gray is R ≈ G > B (or R > G > B with small gaps). Cool gray is R < G < B or R = G = B (neutral). + +### Border + +```css +--border: #e8e6dc; /* Primary border - section dividers, table headers, card borders */ +--border-soft: #e5e3d8; /* Secondary border - row separators, subtle dividers */ +``` + +### Semantic warm accent (the one sanctioned exception) + +```css +--breaking-bg: #f0e0d8; /* Changelog .tag.breaking background - muted warm peach */ +--breaking-fg: #8b4513; /* Changelog .tag.breaking text - warm brown */ +``` + +The "no second chromatic color" rule has exactly one approved exception: the breaking-change badge in the changelog template needs a warm warning tint to read as "caution" without importing a red. Both values are warm-toned (R > G > B), registered as `--breaking-*` tokens in `tokens.json`, and enforced by the off-palette lint guard. Any other off-token color is a violation. Do not add a second semantic accent without registering it here and in `tokens.json`. + +### Translucent -> Solid conversion (TAGS MUST BE SOLID) + +**Why**: WeasyPrint's alpha compositing for padding vs glyph areas produces a visible double rectangle on zoom. See `production.md` Part 4 Pitfall #1. + +Ink Blue `#1B365D` over parchment `#f5f4ed`: + +| rgba alpha | Solid hex | +|---|---| +| 0.08 | `#EEF2F7` | +| 0.14 | `#E4ECF5` | +| **0.18** | **`#E4ECF5`** ← default tag | +| 0.22 | `#D0DCE9` | +| 0.30 | `#D6E1EE` | + +The default tag swatch `#E4ECF5` is registered as the `--tag-bg` token in `tokens.json`, so its 15 template definitions stay under the sync guard. + +--- + +## 2. Typography + +### Stacks + +```css +/* Single serif per page. --sans always equals var(--serif). */ + +/* English */ +font-family: Charter, Georgia, Palatino, + "Times New Roman", serif; + +/* Chinese */ +font-family: "TsangerJinKai02", + "Source Han Serif SC", "Noto Serif CJK SC", + "Songti SC", "STSong", + Georgia, serif; + +/* Japanese */ +font-family: "YuMincho", "Yu Mincho", + "Hiragino Mincho ProN", + "Noto Serif CJK JP", "Source Han Serif JP", + "TsangerJinKai02", + Georgia, serif; + +/* Mono, with CJK fallback for comments and labels */ +font-family: "JetBrains Mono", "SF Mono", "Fira Code", + Consolas, Monaco, + "TsangerJinKai02", "Source Han Serif SC", + monospace; +``` + +Any font-family that may render Chinese or Japanese must include a CJK fallback, including `@page` footer text, `pre`, `code`, and SVG labels. A pure mono stack can render missing glyph boxes in WeasyPrint. + +### Size scale (pt for print A4, px for screen) + +**Print:** + +| Role | Size | Weight | Line-height | Use | +|---|---|---|---|---| +| Display | 36pt | 500 | 1.10 | Cover title, one-pager hero | +| H1 Section | 22pt | 500 | 1.20 | Chapter titles | +| H2 | 16pt | 500 | 1.25 | Subsection | +| H3 | 13pt | 500 | 1.30 | Item titles | +| Body Lead | 11pt | 400 | 1.55 | Intro paragraphs | +| Body | 10pt | 400 | 1.55 | Reading body | +| Body Dense | 9.2pt | 400 | 1.42 | Dense body (resume, one-pager) | +| Caption | 9pt | 400 | 1.45 | Notes, figure captions | +| Label | 9pt | 600 | 1.35 | Small labels, corner tags | +| Tiny | 9pt | 400 | 1.40 | Footer, minor metadata | + +**Screen (px)** ≈ pt × 1.33 (9pt ≈ 12px, 18pt ≈ 24px). +**Minimum floor**: web text >= 12px, PDF text >= 9pt. +**Slide caption floor**: slides 上 caption 至少 24px (不是 12px)。Print 9pt 在投影距离不可读,slide caption 用 pt x 2.67。 + +### Weight + +- **Serif body**: 400 (W04 font file) +- **Serif headings**: 500 (W05 font file, real bold, not synthetic) +- **Sans body**: 400 default +- **Sans labels / small titles**: 500 or 600 +- **Forbidden**: 900 black, 100 thin + +**Design principle**: Serif uses only two weights (400/500), no synthetic bold (600/700), maintaining restrained typography. + +- `strong { font-weight: 500 }` in long-doc templates locks bold to W05, preventing browsers from synthesizing 700 on top of W05 +- **Web only**: W04 covers weight 400-500 (single `font-weight: 400 500` declaration); W05 is PDF-only because WeasyPrint cannot synthesize bold + +### Line-height + +Print documents are **tighter** than English web body. English web typically runs 1.6-1.75; in print at pt sizes that feels loose and floats. + +| Tier | Value | Use | +|---|---|---| +| Tight headline | 1.10-1.30 | Display, H1, H2 | +| Dense body | 1.40-1.45 | Resume, one-pager, dense information | +| Reading body | 1.50-1.55 | Long-doc chapters, letters | +| Label / caption | 1.30-1.40 | Small labels, multi-line metadata | +| CJK screen body | 1.55-1.65 | 中日文 serif 在 slide scale (27-33px) 下比 print x1.33 更需松行高 | + +**Forbidden**: +- 1.60+ - loose feel, web rhythm, not print +- 1.00-1.05 - lines collide except at extreme display sizes + +### Letter-spacing + +- Body text: **0** +- Chinese and Japanese body text with TsangerJinKai02: **0.1–0.2pt** to compensate for the font's natural density; section titles and Mincho samples: **0** +- Chinese lede text (14–22pt) with TsangerJinKai02: **0.03–0.06em** to open up large-body paragraphs without breaking density; EN and JA lede: **0** (only TsangerJinKai02 needs density compensation) +- Chinese and Japanese display text (24pt+): **0.2–1pt** optical spacing for visual breathing room at large sizes; scale with font size +- English headings may use subtle optical tightening when needed; keep it localized, never inherited by body copy +- Small labels (< 10pt): +0.2 to +0.5pt for readability +- All-caps overlines: +0.5 to +1pt mandatory +- **Slide-specific**: print tracking x0.5 at slide scale. Eyebrow max 3px (not 8px), display titles -0.5pt. Large type at 40pt+ will look scattered at print tracking values + +--- + +## 3. Spacing + +### Base unit: 4pt (4px on screen) + +| Tier | Value | Use | +|---|---|---| +| xs | 2-3pt | Inline adjacent elements | +| sm | 4-5pt | Tag padding, dense layout | +| md | 8-10pt | Component interior | +| lg | 16-20pt | Between components / card padding | +| xl | 24-32pt | Section-title margins | +| 2xl | 40-60pt | Between major sections | +| 3xl | 80-120pt | Between chapters (long docs) | + +### Page margins (A4) + +| Document | Top | Right | Bottom | Left | +|---|---|---|---|---| +| Resume (dense) | 11mm | 13mm | 11mm | 13mm | +| One-Pager | 15mm | 18mm | 15mm | 18mm | +| Long Doc | 20mm | 22mm | 22mm | 22mm | +| Letter | 25mm | 25mm | 25mm | 25mm | +| Portfolio | 12mm | 15mm | 12mm | 15mm | + +**Rule**: denser = smaller margins, more formal (letter) = larger margins. + +### Slide-scale spacing + +Print uses mm/pt; slides (screen) use px. The scale relationships differ: + +```css +--slide-pad: 80px; /* slide four-side padding baseline */ +``` + +**Key rules**: +- Slide padding-top: 72-80px (print is 96-120px; slides are more compact) +- Slide letter-spacing = print value / 2 (8px tracking "falls apart" on screen; halve it) +- Macro scale (font size, padding): multiply print pt values by ~1.6 +- Micro scale (letter-spacing, border, radius): multiply by ~0.6 + +--- + +## 4. Components + +### Cards / Containers + +```css +.card { + background: var(--ivory); + border: 0.5pt solid var(--border-cream); + border-radius: 8pt; + padding: 16pt 20pt; +} + +.card-featured { + border-radius: 16pt; + box-shadow: 0 4pt 24pt rgba(0,0,0,0.05); /* whisper shadow */ +} +``` + +Radius scale: 4pt -> 6pt -> 8pt (default) -> 12pt -> 16pt -> 24pt -> 32pt (hero containers). + +### Buttons + +```css +/* Primary - brand-colored */ +.btn-primary { + background: var(--brand); + color: var(--ivory); + padding: 8pt 16pt; + border-radius: 8pt; + box-shadow: 0 0 0 1pt var(--brand); /* ring shadow */ +} + +/* Secondary - warm-sand */ +.btn-secondary { + background: var(--warm-sand); + color: var(--dark-warm); + padding: 8pt 16pt; + border-radius: 8pt; + box-shadow: 0 0 0 1pt var(--border); +} +``` + +### Tags + +Three tiers from weak to strong visual weight: + +**Lightest solid** (default, most restrained): +```css +.tag { + background: #EEF2F7; /* 0.08 solid equivalent */ + color: var(--brand); + font-size: 9pt; + font-weight: 600; + padding: 1pt 5pt; + border-radius: 2pt; + letter-spacing: 0.4pt; + text-transform: uppercase; +} +``` + +**Standard solid** (when more contrast needed): +```css +.tag { + background: #E4ECF5; /* 0.18 solid equivalent */ + color: var(--brand); + padding: 1pt 6pt; + border-radius: 4pt; +} +``` + +**Gradient brush** (only when "hand-painted" feel is required - use sparingly): +```css +.tag { + background: linear-gradient(to right, #D6E1EE, #E4ECF5 70%, #EEF2F7); + color: var(--brand); + padding: 1pt 5pt; + border-radius: 2pt; +} +``` + +**Philosophy**: tint depth should be one step lighter than what decoration wants. Prefer pale over saturated. In iteration, "gradient brush" often steals focus - lightest solid wins most of the time. + +**Never**: `background: rgba(201, 100, 66, 0.18)` - WeasyPrint double-rectangle bug. + +### Lists + +Use native list markers, brand-colored: ordered lists carry numbers, unordered lists carry a disc. Do not fake a bullet with a `::before` en-dash; a dash marker reads like AI default output, not editorial typesetting. The `ul.dash` class is an alias for the same native rendering, kept only so existing markup keeps working. + +```css +ul, ol { + padding-left: 16pt; + line-height: 1.55; +} +ul li::marker { color: var(--brand); } +ol li::marker { color: var(--brand); font-weight: 500; } +ul.dash { padding-left: 16pt; } /* native disc, no en-dash hack */ +ul.dash li::marker { color: var(--brand); } +``` + +### Quote + +```css +.quote { + border-left: 2pt solid var(--brand); + padding: 4pt 0 4pt 14pt; + color: var(--olive); + line-height: 1.55; +} +``` + +### Code + +```css +.code-block { + background: var(--ivory); + border: 0.5pt solid var(--border-cream); + border-radius: 6pt; + padding: 10pt 14pt; + font-family: var(--mono); + font-size: 9pt; + line-height: 1.5; +} +``` + +### Section Title + +```css +.section-title { + font-family: var(--serif); + font-size: 14pt; + font-weight: 500; + color: var(--near-black); + margin: 24pt 0 10pt 0; + border-left: 2.5pt solid var(--brand); + border-radius: 1.5pt; + padding-left: 8pt; +} +``` + +Resume exception: resume templates use a quiet bottom rule instead of the brand left bar, and project rows stay borderless to avoid double rules and page-top orphan lines. + +Document header signature: across the document templates (one-pager, changelog, equity-report, long-doc cover) the page header opens with an uppercase eyebrow led by the 8pt x 1.5pt brand tick (a short horizontal bar, not a round bullet, which reads juvenile next to CJK), then the serif title, with any meta right-aligned and a 0.5pt hairline rule closing the block. This is the shared opener: no centered version block, no full-height left bar. The full-height brand left bar reads heavy and crude as a page-top frame, so keep it a section-level and pull-quote device only. Resume (name header) and letter (letterhead) keep their purpose-built headers and are exempt. + +Hero product shot (one-pager): a product brief earns one real screenshot as its visual anchor, not a decorative texture. Frame it in a wrapper with `overflow: hidden`, `border-radius`, and a soft shadow (no closed sub-1pt border, which trips the `thin-border-radius` lint and risks a double ring); size the wrapper by `height` with the image set to `object-fit: cover` so dead background (wallpaper, chrome margins) is trimmed evenly while the app window stays whole. Give it a single caption that adds a fact, not a restatement. Adjust the wrapper height to fill the page rather than stretching text or leaving bottom whitespace. + +Changelog best practice: a release-notes doc uses the same editorial language as the one-pager, not a centered version block. Open with the left-aligned header (eyebrow tick + "Project Version" serif title + date on the right + hairline rule). Group entries under h2 section heads carrying the brand left bar (Breaking / Features / Fixes, or Highlights / Fixes), and drop any section that does not apply. Each entry is a numbered item with a bold lead-in, then a colon and the detail; the numbers carry sequence and restart per section, so no per-item bullet glyph is added (one repeated mark per row reads as clutter). Keep acknowledgements a quiet labelled note, never a filled card, and split the footer into left description and right URL. One locale per file, no bilingual stacking. A breaking entry may carry an inline `.tag.breaking` chip; that is the only inline tag worth keeping. + +### Table (kami-table) + +Unified table component across all templates. Base class applies to bare `<table>` or `.kami-table`. + +```css +table, .kami-table { + width: 100%; border-collapse: collapse; + font-size: 9.5pt; margin: 12pt 0; break-inside: avoid; +} +table th, .kami-table th { + text-align: left; font-weight: 500; color: var(--dark-warm); + padding: 6pt 8pt; border-bottom: 1pt solid var(--border); +} +table td, .kami-table td { + padding: 5pt 8pt; border-bottom: 0.3pt solid var(--border-soft); + vertical-align: top; +} +``` + +**Variants** (combine freely on the same element): + +| Class | Purpose | +|---|---| +| `.compact` | 8pt font, tighter padding. For data-dense tables in resume/one-pager. | +| `.financial` | Right-align all columns except the first, enable `tabular-nums`. For revenue, pricing, metrics. | +| `.striped` | Alternating `var(--ivory)` background on even rows. Improves scanability for wide tables. | + +**Total row**: add `.total` to the final `<tr>` for a bold summary row with a `1pt` brand top border. + +```html +<table class="kami-table financial striped"> + <thead><tr><th>Category</th><th>Q1</th><th>Q2</th></tr></thead> + <tbody> + <tr><td>Revenue</td><td>$12.4M</td><td>$14.1M</td></tr> + <tr class="total"><td>Total</td><td>$12.4M</td><td>$14.1M</td></tr> + </tbody> +</table> +``` + +### Metric + +Key numbers side-by-side (one-pager header, resume top, portfolio cover): + +```css +.metrics { display: flex; gap: 24pt; } +.metric { flex: 1; display: flex; align-items: baseline; gap: 6pt; } +.metric-value { + font-family: var(--serif); + font-size: 16pt; + font-weight: 500; + color: var(--brand); + font-variant-numeric: tabular-nums; /* align digits in columns */ +} +.metric-label { font-size: 9pt; color: var(--olive); white-space: nowrap; } +``` + +Metric labels never wrap. The value and label share a baseline (`align-items: baseline`); a label that wraps to a second line dangles below that baseline and reads broken. Keep every label short enough for one line and set `white-space: nowrap`, so an over-long label is caught as overflow during QA instead of silently wrapping. Fix by shortening the words, not by letting it wrap. + +### Section Header (`.kami-section-header`) + +Lightweight section opener for content slides. Has an eyebrow and a horizontal rule. + +```css +.kami-section-header { + margin-bottom: 36px; +} +.kami-section-header .eyebrow { + display: flex; + align-items: center; /* dot is geometric, center beats baseline */ + gap: 8px; + font-family: var(--sans); + font-size: 12px; + font-weight: 500; + letter-spacing: 1.5px; + text-transform: uppercase; + color: var(--stone); + margin-bottom: 14px; +} +.kami-section-header .eyebrow::before { + content: ""; + display: inline-block; + width: 6px; height: 6px; + border-radius: 50%; + background: var(--brand); + flex-shrink: 0; +} +.kami-section-header .rule { + height: 1px; + background: var(--border-warm); + margin-bottom: 36px; /* gap below rule >= 36px (>= 2x the gap above) */ +} +.kami-section-header h1 { + font-family: var(--serif); + font-size: 38px; + font-weight: 500; + line-height: 1.1; + color: var(--near-black); +} +``` + +**Spacing rule**: eyebrow to rule: 14px; rule to H1: **≥ 36px** (the gap below must be at least double the gap above, creating a visual anchor). + +### Code Card (`.kami-code-card`) + +For displaying pseudocode or code snippets in slides. More structured than a plain code block. + +```css +.kami-code-card { + background: var(--ivory); + border: 1px solid var(--border-cream); + border-radius: 8px; + padding: 20px 24px; + overflow: hidden; +} +.kami-code-card pre { + font-family: var(--mono); + font-size: 13px; /* 14px for larger slides */ + line-height: 1.55; + color: var(--near-black); + margin: 0; + white-space: pre; +} +/* Syntax colors: existing tokens only, no new colors */ +.kami-code-card .k { color: var(--brand); } /* keyword / string */ +.kami-code-card .c { color: var(--stone); } /* comment */ + +/* Optional line numbers: 1px left divider */ +.kami-code-card.numbered { + display: grid; + grid-template-columns: auto 1fr; + gap: 0 16px; +} +.kami-code-card .line-nums { + font-family: var(--mono); + font-size: 13px; + line-height: 1.55; + color: var(--stone); + text-align: right; + border-right: 1px solid var(--border-soft); + padding-right: 12px; + user-select: none; +} +``` + +**Content philosophy**: use pseudocode style. Comments should outnumber code lines. The reader sees logic, not syntax. + +### Syntax Highlighting + +Code blocks with `class="language-*"` on the `<code>` element get Pygments-based highlighting at build time. The palette uses existing tokens only: + +| Token | Hex | Token var | +|---|---|---| +| Keyword | `#1B365D` | `--brand` | +| Comment | `#6b6a64` | `--stone` | +| String | `#504e49` | `--olive` | +| Number | `#3d3d3a` | `--dark-warm` | +| Function/Class | `#141413` | `--near-black` | + +```html +<pre><code class="language-python">def analyze(data): + """Transform raw data.""" + return transform(data)</code></pre> +``` + +Blocks without `class="language-*"` stay monochrome. Requires `pip install Pygments`; without it, blocks pass through unstyled. + +### Glance Grid + +Four key-number cells, placed after the TOC or on a chapter-opening page of a long-doc / proposal. + +```html +<div class="glance-grid"> + <div class="glance-cell"> + <div class="glance-label">REPORTING PERIOD</div> + <div class="glance-value">Q1 2026</div> + <div class="glance-note">Three core themes</div> + </div> + <!-- 4 cells total --> +</div> +``` + +```css +.glance-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14pt; + margin: 18pt 0; +} +.glance-cell { + padding: 12pt 0 10pt 14pt; + border-left: 2pt solid var(--brand); + border-radius: 1.5pt; +} +.glance-label { + font-family: var(--mono); + font-size: 8.5pt; + color: var(--brand); + letter-spacing: 1pt; + text-transform: uppercase; + font-weight: 500; +} +.glance-value { + font-size: 18pt; + font-weight: 500; + color: var(--near-black); + font-variant-numeric: tabular-nums; + letter-spacing: 0.5pt; +} +.glance-note { + font-size: 9pt; + color: var(--olive); + line-height: 1.4; +} +``` + +### Module Block + +Proposal A / B / C structure: each module gets a brand-colored letter, a Chinese title, and an uppercase English subtitle. + +```html +<div class="module"> + <div class="module-head"> + <div class="module-letter">A</div> + <div class="module-title">模块标题</div> + <div class="module-sub">MODULE SUBTITLE</div> + </div> + <p>...</p> + <ul>...</ul> +</div> +``` + +Visual recipe: letter at 28pt brand, title at 17pt, English subtitle at 10pt mono brand `letter-spacing: 2pt`, head separated from body by a 0.3pt warm-gray hairline (not brand color). + +### Module Note (group explanation) + +A short note that explains the relationship between two or more modules. Same family as `.callout`, lighter weight, no decorative bar. + +```html +<div class="module-note"> + <div class="module-note-label">ABOUT B + C</div> + <p>B 是上游能力建设,C 是下游验证。两者构成一个最小闭环。</p> +</div> +``` + +ivory background + 4pt radius + `module-note-label` at 8.5pt brand uppercase mono. + +### Position Table + +Three-column industry-comparison table whose final row highlights the current project / subject. + +```html +<table class="position-table"> + <thead> + <tr><th>Direction</th><th>Reference project</th><th>Approach</th></tr> + </thead> + <tbody> + <tr><td>...</td><td>...</td><td>...</td></tr> + <tr class="highlight"><td><strong>本项目</strong></td>...</tr> + </tbody> +</table> +``` + +`.highlight` row: ivory fill + brand text. Do not bold the entire row; let the `<strong>` carry the emphasis. + +### Pricing Card + +Headline-figure price block. Eyebrow + price + short note. + +```html +<div class="pricing-card"> + <div class="pricing-eyebrow">PROJECT TERM</div> + <div class="pricing-price"> + <span class="currency">¥</span> + <span class="amount">XXX,XXX</span> + <span class="unit">/ term</span> + </div> + <div class="pricing-note">Paid by milestone</div> +</div> +``` + +Digits: serif 500, 44pt, `tabular-nums`, `letter-spacing: 0.5pt`. Without the letter-spacing, large digits crowd each other and read as too dense. + +For the without-price variant (see writing.md "Proposal voice"), drop the `.pricing-price` block and follow the eyebrow with the Value Anchors list below. + +### Value Anchors + +Replaces pricing line-item breakdowns with a short list of capability anchors. Pairs with Pricing Card or stands alone in the without-price variant. + +```html +<ul class="value-anchors"> + <li><strong>能力锚点 A</strong>:一句具体说明能力来源的事实陈述</li> + <li><strong>能力锚点 B</strong>:一句具体说明锚点稀缺性的事实陈述</li> + <!-- 4-6 items --> +</ul> +``` + +```css +.value-anchors { + list-style: none; + padding: 0; + margin: 12pt 0 18pt 0; +} +.value-anchors li { + position: relative; + padding: 9pt 0 9pt 18pt; + border-bottom: 0.3pt solid var(--border-soft); + line-height: 1.55; + font-size: 10.5pt; +} +.value-anchors li:last-child { border-bottom: none; } +.value-anchors li::before { + content: ""; + position: absolute; + left: 0; + top: 17pt; + width: 8pt; + height: 1.5pt; + background: var(--brand); +} +.value-anchors li strong { + color: var(--brand); + font-weight: 500; + margin-right: 6pt; +} +``` + +The 8pt × 1.5pt brand bar (`::before`) replaces the round `<ul>` bullet. A round bullet next to CJK body reads juvenile; the bar reads editorial. + +### Decoration density: editorial vs structured + +Long-doc / proposal layouts have two acceptable decoration densities. Pick one and stay consistent across the whole document. + +| Context | Mode | Pattern | +|---|---|---| +| Data report, white paper, technical brief | **Structured** | Top hairlines (0.6-0.8pt brand) on callouts, glance cells, and pricing blocks. Roughly 5-8 brand lines per page. | +| Proposal, advisory pitch, founder-facing brief | **Editorial** (default) | No decorative lines. Brand color appears only in text (chapter number, `.hl`, `<strong>`, digits, labels). Containers use ivory fill + 4pt radius. | + +The editorial mode reads as "content speaks"; the structured mode reads as "structure helps". The wrong mode is the third one: brand lines plus ivory plus radius plus borders, which signals over-packaging. When unsure, default to editorial. + +--- + +## 5. Depth & Shadow + +**Core rule**: do not use traditional hard shadows. Depth comes from three sources: + +### 1. Ring shadow (border-like) + +For **button** hover/focus states. + +```css +/* Button default */ +box-shadow: 0 0 0 1pt var(--ring-warm); + +/* Button hover/active */ +box-shadow: 0 0 0 1pt var(--ring-deep); +``` + +**Do not use for card hover**: ring shadow is a border replacement. Layering it over an existing border creates three-layer visual stacking (border + ring + offset), which feels digital, not paper-like. + +### 2. Whisper shadow (barely visible lift) + +For **card hover** and **featured card** elevation. + +```css +/* Card hover - mimics paper lifting slightly */ +.card { + transition: box-shadow 0.2s; +} +.card:hover { + box-shadow: 0 4pt 24pt rgba(0, 0, 0, 0.05); +} + +/* Featured card default state */ +.featured-card { + box-shadow: 0 4pt 24pt rgba(0, 0, 0, 0.05); +} +``` + +**Why whisper, not ring**: paper elevation is depth change, not outline change. Whisper shadow is singular, soft, outline-free, matching the paper-like tone. + +### 3. Section-level light/dark alternation + +Long docs alternate parchment `#f5f4ed` and `#141413` dark sections. This section-level light change creates the strongest contrast. + +**Forbidden**: `box-shadow: 0 2px 8px rgba(0,0,0,0.3)` and relatives. + +--- + +## 6. Print & Pagination + +### break-inside protection + +```css +.card, .metric, .project-item, .quote, .code-block, figure, .callout, +.takeaway, .module, .module-note, .glance-grid, .pricing-card, +table.compact { + break-inside: avoid; +} + +/* Headings should never sit alone at the bottom of a page */ +h1, h2, h3 { break-after: avoid; } + +/* Widow / orphan minimums for body text */ +body { widows: 3; orphans: 3; } +p { widows: 2; orphans: 2; } +``` + +CSS alone cannot prevent "the last two lines of a chapter pushed onto a fresh page". For long-doc / proposal output, follow up with a render-time density check (see production.md "Verify & Debug"). + +Long-doc table-of-contents rows should link to stable chapter ids and use +WeasyPrint `target-counter(attr(href), page)` for rendered page numbers. Do not +hand-fill page numerals; any pagination-affecting edit will make them drift. + +**Cascading break-inside**: when two `break-inside: avoid` blocks sit next to each other and the first would split, both get pushed to the next page together. A chapter with more than two `break-inside: avoid` blocks (quote + table + callout, etc.) near a page boundary is at high risk of leaving 40-80mm of trailing whitespace on the previous page. Fix by splitting the chapter, or downgrade one block (allow the table to break with a repeating header `<thead>`). + +### Force break + +```css +.page-break { break-before: page; } +``` + +### Page background extending past margins + +```css +@page { + size: A4; + margin: 20mm 22mm; + background: #f5f4ed; /* extends past margin area, prevents printed white edges */ +} +``` + +--- + +## 7. Quick decisions + +When you're not sure "what should I use": + +| Need | Use | +|---|---| +| Big headline | serif 500, size by level, line-height 1.10-1.30 | +| Reading body (EN) | serif 400, 9.5-10pt, line-height 1.55 | +| Reading body (CN) | sans 400, 9.5-10pt, line-height 1.55 | +| Emphasize a number | `color: var(--brand)`, no bold | +| Divide two sections | 2.5pt brand left bar, or 0.5pt warm-gray dotted | +| Quote someone | 2pt brand left border + olive color | +| Show code | ivory background + 0.5pt border + 6pt radius + mono | +| Primary vs secondary button | Primary = brand fill + ivory text; Secondary = warm-sand + dark-warm | +| Highlight one card in a list | `border: 0.5pt solid var(--brand)` or `border-left: 3pt solid var(--brand)` | +| Start a chapter | serif heading + 2.5pt brand left bar | +| Cover page | Display-size heading + right-aligned author/date + heavy whitespace | +| Data card | ivory background + 8pt radius + serif big number + sans small label | + +Not on this table -> return to first principles: **serif carries authority, sans carries utility, warm gray carries rhythm, ink-blue carries focus**. + +--- + +## 8. Deck Recipe + +Slides in kami use WeasyPrint HTML to PDF as the primary rendering path. The pptx path (`slides.py`) is available as a fallback when the user explicitly requires an editable PPTX file. + +### Architecture + +**Why WeasyPrint over python-pptx:** pptx output passed through LibreOffice loses CJK font weight, tracking, and glyph spacing. WeasyPrint embeds fonts exactly, giving pixel-level CSS control. + +Use `assets/templates/slides-weasy.html` (CN) or `assets/templates/slides-weasy-en.html` (EN) as the starting point. + +### Page size + +Default `280mm 158mm`. Change in `@page` and `.slide` together. + +| Size | `@page` | Use when | +|---|---|---| +| Compact (default) | `280mm 158mm` | Standard density, fits most content | +| Standard | `297mm 167mm` | Slightly more room per slide | +| Wide | `338mm 190mm` | Heavy content, many data points | + +### Typography + +Global parameters for the slide body: + +```css +body { + font-size: 13pt; + line-height: 1.65; + letter-spacing: 0.3pt; /* CJK: critical for breathing room */ +} +``` + +Heading scale: + +| Element | Size | Weight | Notes | +|---|---|---|---| +| `h2` | 24pt | 500 | Page title; `margin-bottom: 14pt` | +| `h3` | 15pt | 500 | Section heading; `color: var(--brand)` | +| `.eyebrow` | 9.5pt | 400 | Mono, `letter-spacing: 2pt`, `color: var(--stone)` | +| `.lead` | 12pt | 400 | Below `h2`; `color: var(--olive)` | + +Content element scale: + +| Class | Size | Notes | +|---|---|---| +| `.mt` | 16pt | Module title, used with `.ml` | +| `.ml` | 24pt | Large letter prefix in `var(--brand)`, paired with `.mt` | +| `.ms` | 7.5pt mono | Module sub-label; `border-bottom` separator | +| `.mb` | 11pt | Module body description | +| `.mi` | 11pt | Module line item; `padding: 8pt 0` | +| `.mc` | 9.5pt | Delivery rhythm or cadence note; `border-top` | +| `.co` | 11pt bold | Bottom callout; `position: absolute; bottom: 12mm` | + +### Layout patterns + +**Two-column (`.c2`)**: CSS Grid, `grid-template-columns: 1fr 1fr; gap: 22pt`. Use for side-by-side modules with independent heights. + +**2×2 aligned (`.t2x2`)**: HTML `<table>`, not CSS Grid. Grid does not guarantee row alignment across cells; table rows share height naturally. + +```html +<table class="t2x2"> + <tr> + <td> <!-- top-left --> </td> + <td> <!-- top-right --> </td> + </tr> + <tr> + <td> <!-- bottom-left --> </td> + <td> <!-- bottom-right --> </td> + </tr> +</table> +``` + +**Pinned callout (`.co`)**: `position: absolute; bottom: 12mm; left: 20mm; right: 20mm`. The whitespace above it is intentional, not empty. + +### Table styles + +```css +table.data td { + padding: 8pt; + border-bottom: 0.3pt solid var(--border); + font-size: 11pt; +} +table.data td:first-child { + font-weight: 500; + color: var(--brand); /* first column: brand blue bold */ +} +``` + +### SVG constraints + +- `viewBox` width fixed at `920`; adjust height to content +- `max-height: 105mm` on `svg` element to prevent overflow +- WeasyPrint does not support `fill="url(#gradient)"` or CSS Grid inside SVG +- Draw arrowheads as explicit `<path>` elements; `marker-end` with `orient="auto"` does not rotate in WeasyPrint + +### Content rules + +| Rule | Detail | +|---|---| +| No section divider slides | Use `.eyebrow` for section numbering instead; saves one slide per section | +| No CJK parentheses | Replace `(...)` with `·` or `,` | +| Ghost deck test | Read only slide titles in order. They must tell the argument; disconnected titles mean the structure is not ready | +| One evidence shape | Each slide has one primary proof form: chart, table, screenshot, code, quote, or conclusion. Split mixed evidence | +| One line per bullet | Trim until each item fits on one line; never let it wrap | +| Empty space ≥50% | Draft defect. Order: merge with neighbor slide > pin `.co` callout > add a chart that earns the space. Shrinking page size is a last resort and must apply to the whole deck, not per slide. | +| Empty space 25-50% | Acceptable if the slide has a pinned `.co` callout. Otherwise add one supporting bullet or a small inline figure. Never pad with filler prose. | +| Cover | No horizontal rule; title centered `38pt`; subtitle on one line; bottom meta centered | + +Before drafting an image-heavy deck, sketch a short slot map: `page -> slide title -> evidence shape -> image slot -> visual brief`. Use broad types only: cover, assertion, comparison, metric, quote, image evidence, closing. This is a rhythm check, not a locked layout registry. The `visual brief` is internal working material for image selection, crop, or generation; it must not leak into slide titles, body copy, or captions. Keep Kami's default simple: use the existing `.c2`, `table.t2x2`, `.co`, data table, and inline figure patterns unless the source material clearly needs something else. + +If the user provides a real PPTX or brand template and explicitly asks to preserve it, do a template inventory before content editing: thumbnail the source deck, identify reusable layout families, then map each section to an existing layout. Do not do this for the default WeasyPrint or Marp paths; Kami templates are already the inventory. + +### Troubleshooting + +| Symptom | Fix | +|---|---| +| Content overflows to next page | Add `max-height` or trim content | +| 2×2 columns misaligned | Switch from CSS Grid to `table.t2x2` | +| Large blank at slide bottom | First check item count (target 3-5 items per slide). If content is genuinely short, pin a `.co` callout. Only reduce page size when the entire deck is uniformly sparse. | +| CJK text looks tight | Add `letter-spacing: 0.3pt` | + +### Core principles + +1. `letter-spacing` matters more than `font-size` for CJK density +2. 2×2 layouts use `table`, not grid +3. No section divider slides +4. No white card panels on parchment; use border lines to divide +5. Callout pins to bottom; whitespace above is the design +6. Each bullet fits one line +7. Shrink page first before adding more content + +### Marp variant + +Marp is an optional third path, alongside WeasyPrint HTML and python-pptx. Use it only when the user explicitly asks for Marp, "markdown slides", or a deck that lives in a `.md` file. The repo does not bundle `marp-cli`; rendering happens with the user's local install. + +Files: + +| Asset | Path | +|---|---| +| CN theme CSS | `assets/templates/marp/slides-marp.css` | +| EN theme CSS | `assets/templates/marp/slides-marp-en.css` | +| CN sample deck | `assets/templates/marp/slides-marp.md` | +| EN sample deck | `assets/templates/marp/slides-marp-en.md` | + +Shared with WeasyPrint slides: every design token (`--parchment`, `--brand`, `--serif`, `--mono`), the Kami class scale (`.eyebrow`, `.lead`, `.mt`, `.ml`, `.mb`, `.mc`, `.co`, `.c2`, `table.t2x2`, `table.data`, `section.cover`), and the 280×158mm page size. The Marp theme is a port, not a redesign. + +Marp-specific additions on top of that port: the theme styles bare `<p>`, `<ul>`, `<ol>`, `<li>` so that plain Markdown body content picks up Kami rhythm without explicit class attributes. These rules do not exist in `slides-weasy.html` because the WeasyPrint deck never has unclassed Markdown. They are required here because Marpit's defaults would otherwise leak through. + +`.co` is pinned at `bottom: 18mm` in the Marp theme, not `12mm` like in `slides-weasy.html`. Reason: the WeasyPrint deck's footer is two narrow corner labels (`.page-num` right, `.footer-mark` left) that never sit under a centered `.co`. Marp's built-in footer spans the full width at `bottom: 10mm`, so `.co` needs a wider vertical buffer to avoid stacking on top of it. + +Brand color and logo follow the same `brand-profile.md` Layer C rules: edit `--brand` in the theme CSS; insert a logo with `<img src="../../images/logo.svg" width="80">` on the cover slide. The output-path caveat in `production.md` Part 2.5 applies to logo URLs the same way it applies to fonts. + +Marp-specific syntax to know: + +| Need | Marp syntax | +|---|---| +| Page break | One blank line, then `---`, then one blank line | +| Per-slide class (e.g. cover) | `<!-- _class: cover -->` at the top of the section | +| Per-slide pagination off | `<!-- _paginate: false -->` (use it on the cover and the closing slide) | +| Per-slide footer override | `<!-- _footer: "..." -->` | +| Global header / footer / pagination | Set in the deck's YAML front-matter (`paginate: true`, `footer: "Project"`) | +| Background image | `![bg]\(path.jpg\)` | + +Constraints that bite if you arrive from the WeasyPrint slides: + +- The page unit is `section`, not `.slide`. CSS that targets `.slide` will not match. The theme already declares `section { width: 280mm; height: 158mm; position: relative; }`, so `.co { position: absolute; bottom: 12mm }` still pins to the bottom of the current slide. +- Markdown blocks inside `<div>` wrappers need surrounding blank lines for Marp to parse them as Markdown. The sample deck shows the pattern for `.c2` and `table.t2x2`. +- `paginate: true` injects a page number via the `section::after` pseudo-element. Do not also place a `.page-num` element by hand; you will get two numbers. + +Render commands and CLI flags live in `references/production.md` Part 2.5. + +--- + +## 9. Horizontal Funnel / Progress Bar Pattern + +No dedicated `funnel.html` diagram prototype exists. When generating a horizontal bar chart with external percentage labels (conversion funnel, progress tracker, ranked list), use a three-column grid so the label column is fixed-width and never drifts with bar length. + +```css +.funnel-row { + display: grid; + grid-template-columns: 80pt 1fr 40pt; /* label | track | external % */ + align-items: center; + gap: 8pt; + margin: 4pt 0; +} +.funnel-track { + position: relative; + height: 18pt; + background: var(--border-soft); + border-radius: 2pt; +} +.funnel-fill { + position: absolute; + inset: 0 auto 0 0; + background: var(--brand); + border-radius: 2pt; +} +.funnel-pct { + font-variant-numeric: tabular-nums; + text-align: right; + color: var(--stone); + font-size: 9pt; +} +``` + +Example row (77.8% fill): + +```html +<div class="funnel-row"> + <span>Stage Name</span> + <div class="funnel-track"> + <div class="funnel-fill" style="width:77.8%"></div> + </div> + <span class="funnel-pct">77.8%</span> +</div> +``` + +Key rules: + +- Use the three-column grid, not flex. The third column is always 40pt wide; the percentage never moves regardless of bar length. +- Color: single `--brand` fill only. No color gradients or per-row hues. Vary opacity (e.g. `opacity: 0.7`) if a visual ranking is needed, not hue. +- Inline labels inside the bar (count, name) go in an absolutely positioned child inside `.funnel-track`, not in the grid's third column. + +--- + +## 10. Image Aspect Ratios and Cropping + +Use this table when placing images in any Kami template. Pick the content slot first, then decide whether the source should be preserved, padded, cropped, or regenerated. The ratios are defaults, not constraints; adjust by one step if the source image differs significantly. + +| Context | Preferred ratio | Notes | +|---|---|---| +| Hero full-bleed (slides / cover) | 16:9 | One image fills the slide; use `object-fit: cover` | +| Main document image (long-doc / portfolio spread) | 4:3 or 16:10 | Standard editorial proportion | +| Side-by-side grid (two images per row) | 3:2 | Even weight, comfortable scanning | +| Magazine inset (text wraps around) | 3:2 or 3:4 | Portrait works when the subject is a person | +| Square thumbnail (icon grid, avatar, logo) | 1:1 | Enforced with `aspect-ratio: 1/1` | +| Slide image grid (26vh fixed-height row) | Fixed height, free width | Grid items share a row height; clip width to fit | + +**Slot-first rule**: do not generate an image and then hunt for a place to put it. Decide the image job first: proof screenshot, product surface, person/place photo, diagram, logo, or decorative texture. Proof screenshots and product UI keep fidelity; only diagrams and concept images may be redrawn for style. + +**Audience copy vs visual brief**: visible copy says what the reader should believe. A visual brief says what the image should contain, crop, preserve, or avoid. Keep the brief in the layout note, comments, or temporary slot map. Never paste prompt fragments such as "16:9 cinematic UI mockup" or crop instructions into captions, bullets, alt text, FAQ, or metadata. + +**Screenshot handling**: product screenshots default to `object-fit: contain` inside a stable frame, or to a programmatic canvas with quiet padding when the target ratio differs. Do not crop away UI text, numbers, window chrome, terminal prompts, or controls just to fill a frame. If the screenshot is too tall, too narrow, or too dense, split it into 2-3 panels before considering an AI redraw. When a redraw is unavoidable, label it as a schematic or concept image, not a real screenshot. + +**Cropping rule**: choose `object-position` by subject, not as a universal default. UI screenshots use `contain` or centered padding. Photos of people or products use `cover` with the subject in the safe center area (`center center` or `center 35%`). Document scans and pages often prefer `top center` because titles live at the top. + +```css +.frame-img, +figure img, +.hero-img { + width: 100%; + height: 100%; + object-fit: cover; + object-position: center center; +} +``` + +Apply this to fixed-size containers that intentionally crop photos or illustrations. For `object-fit: contain` (screenshots, slides, logos), `object-position` has little visible effect; use frame padding and alignment instead. + +### Brand logo slot + +`one-pager`, `portfolio`, and `slides-weasy` (and their `-en` variants) carry an optional brand logo slot, supplied by the brand profile `logo` field (see `references/brand-profile.md` Layer C). It ships commented out, so the default render is unchanged. The `.brand-logo` rule is fixed-height, `width: auto`, `object-fit: contain`, so any aspect ratio scales cleanly without distortion. Sizes are deliberately per template, not a shared token: + +| Template | Height | Placement | +|---|---|---| +| one-pager | `44px` | top of the header flex row, `align-self: flex-start` | +| portfolio | `72px` | above the cover eyebrow, inside `.cover-head` | +| slides-weasy | `72px` | centered above the cover `h1` | + +Keep the base and `-en` `.brand-logo` rules identical; the cross-template lint pair check (`scripts/lint.py`) flags drift. Do not add `object-position` (no effect under `object-fit: contain`). + +--- + +## 11. Landing Page (screen-first) + +The landing-page template is the only kami template designed for browser delivery, not PDF. It inherits the full parchment design system but adds interactive and responsive patterns. + +### Product site system + +- Use `landing-page*.html` for a single ready-to-serve product page. Keep CSS and JS inline so the file can be copied without a build step. +- If the deliverable needs docs, help, releases, changelog, roadmap, legal pages, or more than two locales, treat it as a production product site. +- For a production product site, prefer one structural template, locale string files, and long-content files. The generator must have a check mode that fails on missing keys and generated-output drift. +- Product positioning must be checked against current product surfaces before rewriting. Stale category language is worse than a missing feature detail. +- Locale pages, FAQ, JSON-LD, `llms.txt`, `llms-full.txt`, screenshots, install copy, pricing, version, and support links are one public fact set. Keep the factual claims aligned across them. +- Do not promote project-specific release artifacts, appcast rules, payment providers, or private local paths into the generic template. + +### Product screenshot slots + +Use a small slot matrix before filling a landing page or product site. This keeps the default beautiful without adding a new template system: + +| Slot | Preferred asset | Fit | +|---|---|---| +| Hero signal | real product surface, terminal state, or app window | 16:10 or 16:9, preserve recognition over full-bleed drama | +| Gallery panel | one shipped workflow or reachable UI state | stable frame, real screenshot first, no unrelated filler | +| Feature panel | focused crop or two-step before/after proof | preserve labels and controls; split dense screenshots | +| Social image | product name + one recognizable surface | 1200x630 crop, repo path or public URL | + +Every screenshot path must resolve from the repo or a stable public URL. Never reference `/Users`, `file://`, or sibling checkouts. Missing visuals remain material gaps or omitted panels; they are not replaced with stock atmosphere. + +### Layout + +- `max-width: 1120px` centered, padding `88px 64px 120px` +- Sections numbered `00 · Label` through `04 · Label` with `section-num` / `section-title` / `section-lede` pattern +- Two responsive breakpoints: `880px` (tablet) and `480px` (phone) +- Section rhythm is a system, not per-gap. Run section spacing as one responsive ladder (e.g. desktop 96/72, tablet 72/54, phone 56/42). When a page reads too airy or too tight, scale the *whole* set by a single factor (about 0.75) across all breakpoints at once; nudging one gap leaves asymmetry, and asymmetry that survives tuning is structural. At the phone breakpoint step gutters down (64px to 16px) and shrink display sizes (hero title, price amount) in the same pass. + +### Eyebrow + +- Flex row: `space-between`, left side = product category text + version link, right side = hero-links (lang switch, social icons) +- Version link: brand color, weight 600, clickable to releases page. Slot: `{{VERSION}}` +- `--latin-ui` 12px, weight 500, letter-spacing 0.4px, uppercase, stone color + +### Hero + +- Title: 96px (EN) / 88px (CN), weight 500, letter-spacing 0 +- Entrance animation: `translateY(10px) + blur(6px)` fading in over 900ms with 120ms delay +- Tagline: 21px (EN) / 20px (CN), olive color, letter-spacing 0.2px (EN) / 0.4px (CN), max-width 820px +- Tokens row: a few small chips as `<span>quality</span>`, 13px stone, `--latin-ui` font +- CTA: pill buttons (border-radius 999px), primary filled + ghost outlined, 15px, 13px 28px padding +- Quality chips, not a facts list. The tokens row should carry product *qualities* (good-looking, lightweight, AI-friendly), not an inventory (license, package manager, OS version). Push every hard fact to the footer or docs where it is referenceable. Pick about three. +- No chip may repeat the tagline. Read tagline and chips together and cut any concept stated twice. If trimming a chip leaves an orphaned separator, the row should collapse to one clean line, not a dangling dot. +- Wrap-safe chip separator. Put the middot on `span:not(:last-child)::after`, never on `::before` of the following item, so a chip that wraps to the next line never carries a leading dot. Use `color-mix(... 58%, transparent)` so the dot stays quieter than the text. +- Line-widow discipline (title + tagline). Eliminate 1-2 word last lines by trimming the copy so the block rebalances, not by adding a `max-width` cap (a cap narrower than its container wraps early and leaves empty space on the right, which reads as a premature break). `text-wrap: balance` on the title and `pretty` on the tagline help only as a backstop; do not rely on them. Leave inherently-two-line notes alone. + +### Gallery + +- Grid: `minmax(0, 1fr) auto`, frame spans full width, caption and tabs on row 2 +- Frame: dark background `--shot-bg: #141318`, rounded 8px, 1px border +- Screenshots are final product surfaces first. Use real app/site captures over mockups; if the asset is missing, record a material gap or omit that panel rather than substituting unrelated imagery. +- Transition: direction-aware slide + scale(0.985), 620-880ms cubic-bezier(0.22, 1, 0.36, 1) +- Sweep overlay: diagonal light gradient that slides across on switch (540-920ms) +- Auto-rotate: 4500ms interval, pauses on hover/focus, respects prefers-reduced-motion +- Empty gallery: script exits cleanly; single-image gallery initializes caption/tab state without starting auto-rotate +- Tabs: pill buttons 12px `--latin-ui`, active state uses brand-tint background +- Click navigation: left half = previous, right half = next +- Caption `.line`: italic serif, 14px olive. Poetic one-liners describing each screenshot + +### Buttons + +Two variants only: + +| Variant | Background | Border | Text | Hover | +|---|---|---|---|---| +| `.btn-primary` | `--brand` | `--brand` | `--ivory` | `--brand-light`, translateY(-1px) | +| `.btn-ghost` | transparent | `--brand` | `--brand` | `--brand-tint` bg, translateY(-1px) | + +Both: pill shape (999px radius), 15px `--latin-ui`, weight 500, 1.5px border, min-width 158px. + +Mobile resting state: natural width, left-aligned to the hero text edge, height unchanged. Do not center them (reads as floating), do not stretch edge-to-edge with `flex: 1` (reads heavy), and never drop button height to relieve a "too full" feel; fullness is a width and spacing problem, not a height one. A left edge that does not line up with the text and chips reads as an accidental gap. Verify at 320px and 375px with no overflow. + +### Pricing + +- Amount: 112px serif, letter-spacing 0 +- Comparison: 18px, use `<s>` for competitor prices (stone color, 1px underline) +- Highlight: `.hl` class for brand-colored emphasis +- Terms: 13.5px olive, centered, max-width 640px, line-height 1.5 + +### Manifesto + +- Brand philosophy paragraph: 20px, weight 400, line-height 1.55, letter-spacing 0.05em +- `<em>` renders in brand color with `font-style: normal` (brand emphasis, not typographic italic) + +### Code Block + +- `pre.code`: ivory background, 1px border, 6px radius, 18px 22px padding +- Font: `--mono` 13.5px, tabular-nums, line-height 1.55; reduce to 11.5px at the phone breakpoint (480px) so wide lines stay legible without horizontal scroll. `code { min-width: max-content }` lets long lines scroll instead of wrapping. +- Inline `code` is a distinct style, not the block palette: brand-tint background, brand text, 1px hairline, `0.9em`. + +Screen code blocks may use a dark surface (`--shot-bg: #141318`, the same frame as the gallery) instead of ivory. Highlight at build time with zero runtime JS: a script bakes static `<span class>` markup (e.g. Pygments) and is idempotent, so re-running it after any doc edit refreshes the output; merge adjacent same-class spans so the markup stays small. Plain code stays the source of truth; the spans are generated, never hand-authored. Keep the token palette restrained on the dark surface: + +| Token | Hex | Role | +|---|---|---| +| Comment | `#79756a` | faint, italic | +| Keyword | `#84aad6` | soft blue | +| String | `#8cbb91` | muted green | +| Number | `#cbab86` | sand | +| Function/Class | `#d6c78c` | sand-gold | +| Builtin/Constant | `#b59ccd` | muted violet | + +Blocks without `class="language-*"` stay monochrome. + +### Metrics + +- Flex row with 32px gap, each metric is value (36px serif 500) + label (13px `--latin-ui` stone) +- `font-variant-numeric: tabular-nums` on values + +### Demo Card Grid + +- `auto-fill, minmax(240px, 1fr)` grid, 18px gap +- Cards: ivory bg, 1px border, 8px radius, whisper shadow on hover +- Image fills top, title 15px weight 500 + desc 12px olive below + +### Features + +- Two-column grid: 200px name + 1fr description, 36px gap, separated by border-soft hairlines +- Feature name: 22px brand, weight 500 +- Poetic subtitle: `<small>` below name, 13px olive, italic. One short line evoking the feature's character +- Description: 15px dark-warm, line-height 1.55 +- Tables stay editorial: no framed box, no tinted header bar, no vertical rules, no empty right gap. Content-sized columns, hairline row rules, a muted `--latin-ui` uppercase header. On phone, `display: block; overflow-x: auto` rather than cramming columns. A framed, tinted table adds weight without adding information. + +### Feature rows (a visual beside the copy) + +The `.features` list above is the shipped default. A feature *row* (a visual on one side, the points on the other, repeated down the section) is a different component and no template ships it. It is also where a feature section most often goes wrong, because the instinct that produces it ("alternate the sides so it does not look like a product list") is the same thing that breaks it. + +- **Compute the slack before building the row.** `slack = content width - (visual + gutter + the copy's natural width)`. The copy almost never fills a `1fr` track, so that number is positive on every row. Sizing the two tracks independently (a fixed visual track against a `1fr` copy track) does not remove it; it only decides which side it lands on. +- **Slack belongs on the outer trim, never in the gutter.** At the page edge it reads as a margin and disappears. Between the copy and the visual it reads as a hole, and it leaves the copy nearer the page edge than the thing it describes, which breaks the one association the row exists to make. +- **Mirror a text mass, never a short list.** A paragraph block has edges that read as a shape, so flipping which side it sits on costs nothing; that is why magazine spreads mirror. Four one-line points are not a mass. Their left edges are the only structure holding them together, and alternating rows move that edge every other row. Left edges are the strongest alignment cue on a page. Do not spend them on rhythm. +- **Three rescues that do not work.** Recorded so they are not retried. Pinning a fixed measure to the gutter: the slack turns into an unexplained indent on the mirrored rows. Right-aligning the mirrored copy so the bullet dots move to the right: flush against the visual, but the dots read as a mistake. Centering every seam on one shared axis: the visual leaves the page edge, the section loses its only anchor, and a third left edge appears. +- **Run every row the same way.** One visual edge flush with the section's left margin, one copy left edge, slack always at the outer trim. Nothing jumps, and the row that already read as fine is the row you keep. +- **Buy variety with weight, not with alternation.** A-B-A-B is still a pattern, and it produces a zigzagging list rather than editorial rhythm. Rhythm comes from unequal weight: a lead row with a larger visual or an opening sentence above its points, supporting rows with fewer and shorter points. If every row carries the same structure and near-identical copy length, no layout move will make the section feel unlike a list. The fix is in the content. +- **Measure the copy in every locale before capping the column.** The longest point's natural width swings hard by language (a CJK line can run 30% past its English source), and a system-font stack widens it again wherever the primary face is missing and a broader fallback takes over. No measure prevents wrapping everywhere, so do not chase one: pick the measure the layout needs, let long locales wrap, and follow «Cross-lang typography hardening». + +### FAQ + +- Wrap each dt/dd pair in `<div class="faq-pair">` for spacing (24px margin-bottom) +- `<dt>` question: 16px, weight 500, no top margin +- `<dd>` answer: 14px olive +- Code spans: mono 12px on brand-tint background, 3px radius +- Tail paragraph: `.faq-tail` after `</dl>`, 13px stone, links to help page. Closes the FAQ without another section + +### Footer + +- Two-column flex: brand mark (icon + name + tagline) left, colophon (links + ethos) right +- Mark icon: 56px rounded 8px +- Links: inline with middot (`·`) separators between items, dark-warm color. Editorial pattern, not flex-gap +- Ethos: closing italic serif line, olive color, max-width 360px. The italic voice signals a personal sign-off +- Tech credit, once. If the product builds on an upstream project or framework, credit it exactly once as a quiet footer line, never as a repeated selling point and never in the hero tagline. Grep the whole site for the upstream name and collapse it to this single instance; rewrite the hero around the product's own positioning. Hard facts that are not the credit (license, version) belong here too. +- Collapses to single column below 880px + +### Cross-lang typography hardening + +- **Numeric alignment across CJK and Latin runs.** Use `font-variant-numeric: lining-nums tabular-nums` on every node that displays numbers (prices, metrics, version strings, tabular data). Lining keeps digit height uniform; tabular keeps digit width uniform. Without lining-nums, oldstyle fonts drop descenders on 3, 4, 5, 7, 9 and break vertical rhythm. +- **Latin fallback before CJK in the serif stack on Chinese pages.** Charter or Georgia first in `html[lang="zh-CN"] { --serif: ... }`, so mixed runs like "Mac/22/$19" share baseline with the Chinese body. +- **Avoid scaling the currency glyph with super.** Do not write `.price-currency { font-size: 0.5em; vertical-align: super }`. That trick makes `$` and the digit visually unequal. Prefer `font-size: 0.74em; line-height: 1; transform: translateY(0.015em);`. +- **Language menu items need vertical room for descenders.** When `<a>` inside `.lang-menu` has `line-height: 1`, the descender of 'g' / 'y' / 'p' is clipped. Use `min-height: 32px; padding: 6px 10px; line-height: 1.35;`. Add an invisible `::before` bridge between trigger and menu so the cursor can cross the gap without dismissing the menu. + +> The main landing-page template does not ship a language switcher or a price card by default; the `{{HERO_LINKS}}` slot is where one would go. Kami's own site at `styles.css` L67-151 ships a tested `.lang-switch` + `.lang-menu` implementation (hover bridge, descender padding, focus-within fallback). Copy it when you add multi-locale links to a landing page. + +### Multilingual SEO scaffolding + +- **hreflang link block in `<head>`.** One `<link rel="alternate" hreflang>` per shipped locale plus one `hreflang="x-default"`. Drop locales that have no actual page. Add `<link rel="alternate" type="text/plain" href="/llms.txt">` so AI assistants find the summary file. +- **og:locale + og:locale:alternate.** Self-reference the current page locale on `og:locale`, list the others on `og:locale:alternate`. Social previews on Facebook, LinkedIn, Telegram use this to pick the right thumbnail. +- **Canonical points at the per-locale URL.** Each locale should have a canonical that matches its own URL, not a single canonical pointing to `/`. + +### Companion assets + +The landing-page template alone is one HTML file. To deploy a production multilingual site you ship five companion files in the same folder; each is provided as `.example` and you remove the suffix before deploying: + +- `landing-page-vercel.json.example`: path-based rewrites for `/zh`, `/tw`, `/ja`, `/ko`, host-canonical redirect, security headers, immutable cache for static assets. +- `landing-page-sitemap.xml.example`: one `<url>` per locale with `<xhtml:link>` cross-references; mirrors the hreflang block in `<head>`. +- `landing-page-robots.txt.example`: AI crawler allowlist (GPTBot, ClaudeBot, PerplexityBot, Applebot, OAI-SearchBot, Claude-SearchBot). +- `landing-page-llms.txt.example`: short brand summary; positioning, one-line competitor contrast, pricing, key links. +- `landing-page-llms-full.txt.example`: long-form companion AI assistants pull for accurate feature-level answers. Has Overview, Pricing, Features, Comparison, FAQ. + +The optional Accept-Language redirect at the end of `landing-page-en.html` is commented out by default. Uncomment only after confirming `/zh/`, `/tw/`, `/ja/`, `/ko/` actually resolve on the host. + +When a site uses generated locale pages, add a local drift check next to the generator. It should compare generated HTML to committed output, report missing placeholders by key, and fail before package or release work continues. + +### Documentation site + +When the product site grows docs, help, or guide pages (see «Product site system»), they need a layout the single landing page does not provide. All of this is screen-only. + +- Two-column shell: a sticky sidebar nav plus a prose column. Sidebar around 178px, `position: sticky; top: 84px; max-height: calc(100svh - 108px); overflow: auto`. Constrain the prose column to a reading measure (about 720px) even though the page frame is wider; long doc lines hurt readability. +- Sidebar active state is a rail, not a fill. `border-left: 2px solid transparent` that fills brand on `[aria-current="page"]`, with brand text. No full-width dark underline or background block; that reads as a heavy dark bar against the warm paper. +- Multi-page topic structure: one file per topic, grouped under sidebar sections. Mark the current page with `aria-current="page"` so the rail and screen readers agree. +- On-this-page TOC: a thin in-flow list under a hairline top border, with a `--latin-ui` uppercase 11px "On this page" heading and depth-3 entries indented about 12px. Hide it entirely below the tablet breakpoint; it is an aid, not content. +- Prev/next pager: quiet borderless text links, not bordered cards. A 2-column grid with one thin top divider; each link is a `--latin-ui` uppercase "Previous"/"Next" eyebrow over a brand serif title, `border: 0; background: none`. The next link aligns right (resets left on phone). Press feedback via `:active { opacity: 0.6 }`. A bordered card here reads heavy on mobile. +- Mobile (tablet breakpoint): the sidebar un-sticks (`position: static`) and collapses to a horizontal scroll strip (`display: flex; overflow-x: auto; scrollbar-width: none`) with the active rail moved to `border-bottom`; the TOC is hidden. Reuse the landing page's existing breakpoints; do not invent a new ladder. + +## 12. Mermaid diagrams + +Mermaid text is turned into Kami-styled diagrams via beautiful-mermaid plus +`scripts/mermaid_normalize.py`. The theme maps beautiful-mermaid's seven color +roles onto the canonical palette (single source: `references/mermaid-theme.json`, +kept in sync with `tokens.json`): + +| role | token | hex | +|------|-------|-----| +| `bg` | `--parchment` | `#f5f4ed` | +| `fg` | `--near-black` | `#141413` | +| `line` | `--olive` | `#504e49` | +| `accent` | `--brand` | `#1B365D` | +| `muted` | `--stone` | `#6b6a64` | +| `surface` | `--ivory` | `#faf9f5` | +| `border` | `--border` | `#e8e6dc` | + +Same invariants as every other surface: parchment canvas, one chromatic accent +(ink-blue marks the focal element only), warm neutrals for everything else, serif +text with CJK fallback. The normalizer resolves beautiful-mermaid's `color-mix()` +derivations to static hex, so derived shades (e.g. `#dad9d3`) stay warm and never +introduce cool grays. PDF supports flowchart / state / sequence / class / ER; +`xychart-beta` is browser-only (it uses `<style>` class selectors WeasyPrint will +not apply). Full pipeline and rationale in `references/mermaid.md`. + +### Responsive screenshot verification + +Before declaring any screen change done, screenshot the real rendered surface; a type check or CSS-balance read is not enough. Several regressions (early wraps, orphaned separator dots, table overflow, missed pages) are invisible in source and only show in the render. + +- Capture at phone (375px, plus 320px for CTAs) and desktop (1280px), in every shipped locale. +- Scan for line widows objectively: measure each text block's last-line width against its widest line and flag anything below about 13%. Eyeballing misses pages, and nested `<code>` hides widows from greps. Accept "0 widows" only after the check confirms it. +- Confirm CTAs reach their natural-width left-aligned resting state with no overflow, code is legible at the reduced mobile font, the gallery and any multi-column grids collapse to a single column, and total page overflow is zero. +- Long pages do not fit one viewport; use a capture helper that can scroll to a specific element (first code block, pager) before shooting. + +## KO locale tuning + +Korean templates use Source Han Serif K (Adobe, also distributed by Google +as Noto Serif KR) as the primary serif. The font's hangul metrics are close +enough to TsangerJinKai (CN) that the CN per-component values render +naturally in Korean without per-template re-tuning. The `one-pager-ko` +pilot confirmed that the CN baseline values flow through cleanly: every +numeric value below matches the CN one-pager (and the rest of the CN +doc-style templates by extension), with one KO-specific micro-adjustment: +`.metric-label` font-size drops from 9pt to 7pt to accommodate the longer +mixed hangul + parenthesised-metadata labels typical of Korean editorial +content (see one-pager-ko `.metric-label` rule). + +Canonical values (verified during the `one-pager-ko` pilot, 2026-05-28): + +- Body `font-size`: 10pt (matches CN baseline) +- Body `line-height`: 1.45 (matches CN baseline) +- Body `letter-spacing`: 0.3pt (matches CN baseline) +- H1 `font-size`: 24pt (matches CN baseline) +- H1 `font-weight`: 500. CN templates use 500 (TsangerJinKai W05, a + Medium-Bold) for every emphasis (body bold, headings, tags, metric + values) and never reach for 700. Source Han Serif K exposes the full + weight range (ExtraLight through Heavy), so KO bundles Regular (400) + + Medium (500) to mirror CN's W04/W05 two-weight discipline. The result: + KO emphasis reads at the same Medium tier as CN, rather than the heavier + Bold the early Nanum-era forks settled on. +- H1 `letter-spacing`: matches CN per-template setting (typically 0 to −0.2pt + on display H1s; copied from the CN sibling). +- H1 `line-height`: 1.15 (matches CN baseline) +- `.metric-label` `font-size`: 7pt (one-pager-ko only: KO labels read wider + than CN/EN, so the baseline-flex metric strip needs a smaller label to + avoid wrapping inside the card column). +- `font-synthesis: none;` MUST be applied to the body rule. WeasyPrint can + synthesize fake bold when Bold weight resolution fails through fallbacks, + and disabling synthesis keeps the editorial tone honest (real glyph + shapes only). + +Fallback chain (consistent across all KO templates): + +```css +--serif: "Source Han Serif K", "Source Han Serif KR", "Noto Serif KR", "Apple SD Gothic Neo", + "AppleMyungjo", Charter, Georgia, serif; +--sans: var(--serif); +--mono: "JetBrains Mono", "D2Coding", "SF Mono", "Fira Code", + Consolas, Monaco, monospace; +--latin-ui: "Inter", -apple-system, "Segoe UI", Helvetica, Arial, sans-serif; +``` + +`"Source Han Serif K"` is the Adobe distribution name and the `@font-face` +declared name (so file/CDN loads resolve in a repo checkout or online). +`"Source Han Serif KR"` is the actual family name baked into the bundled OTFs +(nameID 1/16 = `Source Han Serif KR`, Korean `본명조 KR`); it MUST stay in the +chain so that on an offline Linux skill install -- where the relative +`@font-face` file is stripped and jsDelivr is unreachable -- fontconfig can +still resolve the `ensure-fonts.sh`-downloaded OTF by name (the bare +`Source Han Serif K` matches nothing). `"Noto Serif KR"` is the Google Fonts +name for the same Adobe source, covering boxes that installed it via +`fonts-noto-cjk`. Listing all three keeps the chain agnostic to which +installer the user used. + +Subsequent KO templates (letter-ko, long-doc-ko, etc.) should adopt the +font variables and `font-synthesis` rule verbatim and leave all numeric +values at their CN sibling's baseline. diff --git a/references/diagrams.md b/references/diagrams.md new file mode 100644 index 0000000..c75db6c --- /dev/null +++ b/references/diagrams.md @@ -0,0 +1,647 @@ +# Diagrams + +kami's drawing capability. **18 diagram types** covering structural, process, data chart, and interaction scenarios. All wear kami's skin (parchment + ink-blue + warm grays). No second design system. + +Every diagram is a **self-contained HTML + inline SVG**: no JS and no build step to use one. Fifteen are hand-drawn; `sequence`, `class`, and `er` are authored from Mermaid text and re-themed to the Kami palette by `scripts/mermaid_normalize.py` (see `references/mermaid.md`). Browse them as standalone pages, or copy the `<svg>...</svg>` block into a long-doc `<figure>` to embed. + +--- + +## 1. Selection + +| Showing… | Use | Template | +|---|---|---| +| System components + connections | **Architecture** | `assets/diagrams/architecture.html` | +| Full-system panorama: five layers, control plane, roadmap, owners | **Architecture Board** | `assets/diagrams/architecture-board.html` | +| Decision branches, "if A then B else C" | **Flowchart** | `assets/diagrams/flowchart.html` | +| Two-axis positioning / prioritization | **Quadrant** | `assets/diagrams/quadrant.html` | +| Category comparison (revenue, market share, quarterly) | **Bar Chart** | `assets/diagrams/bar-chart.html` | +| Trend over time (stock price, growth rate, time series) | **Line Chart** | `assets/diagrams/line-chart.html` | +| Proportional breakdown (spend, user segments, share) | **Donut Chart** | `assets/diagrams/donut-chart.html` | +| Finite states + directed transitions (lifecycle, state machine) | **State Machine** | `assets/diagrams/state-machine.html` | +| Time axis + milestone events (roadmap, project progress) | **Timeline** | `assets/diagrams/timeline.html` | +| Cross-responsibility process (multi-role, API request path) | **Swimlane** | `assets/diagrams/swimlane.html` | +| Hierarchical relationships (org chart, module deps, directory tree) | **Tree** | `assets/diagrams/tree.html` | +| Vertically stacked system layers (OSI, application stack) | **Layer Stack** | `assets/diagrams/layer-stack.html` | +| Set intersections (feature overlap, audience comparison, capability map) | **Venn** | `assets/diagrams/venn.html` | +| OHLC price action (stock price, trading days, up/down candles) | **Candlestick** | `assets/diagrams/candlestick.html` | +| Revenue bridge, valuation decomposition, cash flow breakdown | **Waterfall** | `assets/diagrams/waterfall.html` | + +Not on the list: +- **Compare two things**: use a table. A three-column table beats any diagram of a binary contrast. +- **One box with a label**: delete the box, write the sentence. + +Scale check: the **Architecture** row above is a single embeddable figure and follows the 9-node budget below. A full-system panorama (platform map, control plane, roadmap, owner map) is a different artifact: an **architecture board**, covered in section 3. Do not inflate one figure to carry it. + +### The question before drawing + +> Would a well-written paragraph teach the reader less than this diagram? + +If "no", don't draw. Diagrams add signal to hierarchy, direction, and magnitude. They don't decorate prose. + +--- + +## 2. Complexity budget + +**Target density: 4/10**. Enough to be technically complete, not so dense the reader needs a guide. + +- Nodes > 9 -> this is two diagrams, not one +- Two nodes that always travel together -> they're one node +- A line whose meaning is obvious from layout -> remove the line +- 5 nodes in ink-blue -> you haven't decided what's focal + +**Focal rule**: 1-2 focal elements per diagram (`#1B365D` stroke + `#EEF2F7` fill). Everything else goes neutral. Focal signal comes from contrast, not count. + +These budgets govern single embeddable figures. A report-scale architecture board carries more blocks under its own budget (section 3). + +--- + +## 3. Architecture boards (report scale) + +The **Architecture** template in section 1 is one embeddable figure: at most 9 nodes, one focal, dropped into a `<figure>`. Some asks are bigger: a whole-platform panorama, a control-plane map, a target architecture with roadmap and owners. That artifact is an **architecture board**: a standalone HTML page with inline SVG, same tokens, more structure. Never answer it by inflating a single figure past its node budget. + +Start from `assets/diagrams/architecture-board.html`. It ships with a real five-layer demo (a terminal emulator fork), an authoring outline in the HTML comment, and a poster-size `@page` so WeasyPrint exports the whole board on one sheet. Replace the demo content; keep the skeleton. + +A board is a reading instrument, not an illustration. The reader must get three things, in order: what the parts are, how they flow or depend, and where the next piece of work should intervene. Any element that does not help one of those judgments gets deleted. The denser the system, the more restrained the board. + +### Canvas follows reading path + +Decide how much the board must carry before deciding the canvas: + +| Board carries | Canvas | +|---|---| +| One-screen product or system relationship | 16:9, slide-sized | +| Whole-platform panorama | Wide canvas, light vertical scroll | +| Roadmap + owners + governance loop | Report page (the board as the spine of an A4 flow) | + +The canvas may grow taller, but never into an endless page. One scan should build the whole picture. + +### Five fixed information layers + +Complex boards keep a fixed five-layer skeleton instead of free-form scatter. Each layer answers exactly one question: + +1. **Title**: one sentence stating the subject and the judgment. +2. **Business**: roles, capability domains, external consumers. +3. **System**: platform modules and the control plane. +4. **Runtime**: key paths: data flow, event flow, permission flow. +5. **Governance**: monitoring, audit, lifecycle, roadmap, owners. + +Do not explain protocol detail in the business layer; do not dump the domain catalog into governance. + +### Bands over cards + +The fastest way a board turns crude is drawing every fact as its own small card. + +- Parallel peers share **one band** with thin vertical dividers, not N cards. +- Tabular facts get a **table shell**, not five boxes. +- Focal fill (`--brand-tint`) marks only the genuinely core nodes; the 1-2 focal rule from section 2 still holds. +- Never nest a card inside a card. +- Budget: **10-25 major blocks** per board. Past that, merge blocks into domains; do not keep stacking nodes. + +### Node anatomy + +A node holds three things: optional icon, title, then two or three short lines. No paragraphs, no noun trains. + +Good: + +```text +Foundation: event push +PublishEvent v2, EventBus, webhook subscription +``` + +Bad: the same title followed by eight comma-separated technical nouns on one line. + +### Copy reads as judgment, not summary + +Generated boards fail on copy before they fail on layout: text that is correct but decides nothing. Avoid saturated abstractions, long parallel noun phrases, and adjectives with no action attached. Prefer sentence shapes that commit: + +- from X to Y +- inserted before X +- unifies X across Y +- owned by X +- measured by X + +Board text is short, hard, and executable. `writing.md` still applies. + +### Line discipline + +- Orthogonal lines only. No curves, no passing through modules, no crossing text, no decorative junctions. +- Main path in `--brand`, auxiliary lines in border tone, light open chevron heads (arrow rules in section 5; manual chevrons for PDF output, see production.md). +- **Connector standoff: 4px.** On a board, start the shaft 4px after the source edge and land the chevron tip 4px before the target edge. Both offsets are computed from the node edge (keep them divisible by 4), so this is a deliberate standoff, not the sloppy floating gap the embedding rules warn about. Welded-on arrows read cramped at board scale. +- **Never run a line along a module's top edge.** It reads as a broken border or a squashed module, worst when a brand-colored line crosses a light card. Route the line below or beside the module with 16-24px of air, and attach it with a short stub to the outer edge, never into the text area. +- A relation that is not core information becomes a caption or a small label, not a line. +- A line the reader cannot parse gets deleted, not explained. + +### English anchors on CN boards + +Uppercase mono anchors (`MAIN AXIS`, `CONTROL PLANE`, `PUBLIC INFRA`, `OWNER MAP`, `ROADMAP`) help scanning on a Chinese board. Do not translate full sentences: the reader should never switch languages mid-thought. + +### No viewpoint captions + +Notes like "from the platform team's perspective" or "working draft for X" do not belong on the board; structure carries the viewpoint. Corner text holds only the date basis, version, or data scope. If deleting a caption changes nothing, delete it. + +### Whiteboard to board + +Whiteboards explore; boards communicate. Never reuse the whiteboard drawing style. Convert: + +1. Identify the core objects. +2. Merge repeated objects into domains. +3. Collapse free-form connections into one or two main paths. +4. Rewrite sticky-note phrasing into short labels. +5. Push detail into a bottom note or a companion doc, not the main drawing. + +### Structure before pixels + +Do not draw straight into SVG. Outline the board first with a fixed vocabulary, then render with the section 5 token map, so consecutive boards look like one system: + +```text +Section: Target architecture +Band: Platform surface +Node: Collaboration +Node: Execution +Band: Core runtime +Node: Sensing +Node: Identity +Node: Control plane +Flow: Foundation -> Spine -> Pillars +Note: Does not replace per-business implementations +``` + +Content fills the structure; the token map styles it. + +### Board type scale + +Standalone board pages run larger than embedded figures (for embedded sizing use the calibration table in section 5): + +| Role | Size | +|---|---| +| Page title | 36-40px | +| Section title | 22-24px | +| Block title | 17-19px | +| Body / node description | 13-15px | +| Caption | 11-12px | + +Fixed pixel sizes: no viewport-scaled type, no negative letter-spacing on body sizes. Fonts and colors come from the existing kami stacks and token map; a board introduces zero new colors and zero new fonts. + +### Module-level pass + +When the macro structure is right but the board still reads crowded, the fault is usually inside modules, not the canvas. Global fixes (enlarge the canvas, shrink type, recolor) do not touch it. Check per module: + +- title sitting too close to its description +- CJK line breaks landing mid-phrase or orphaning one character +- an icon eating the text column +- padding thinner than the module's siblings +- baselines unaligned across one row +- a table sitting off-center inside its section + +Fix modules one at a time, re-render, and only then judge whether the canvas itself needs to change. + +### Board pre-ship scan + +Content: + +1. The reader can state the main path within 30 seconds. +2. Each section answers exactly one question. +3. Current state, target, intervention points, governance, and roadmap are all explicit. +4. Everything deletable has been deleted. + +Visual: + +1. Parchment background, never pure white. +2. One accent color. +3. No gradient, shadow, bitmap, external fetch, or script. +4. No overlapping text; no line over module content. +5. Icon stroke and size uniform. +6. Right-side captions right-aligned, with at least 56px of outer margin. + +File: grep the HTML for `#fff`, `gradient`, `shadow`, `<script`, `<img`, and the em dash character; every hex value must exist in the token map. + +--- + +## 4. Maintained diagram assets (repo scale) + +The third scale. A **figure** embeds in a kami document; a **board** ships as a report page; a **maintained asset** lives in someone's repository (README hero, docs-site figure, `docs/architecture/`) and gets redrawn for months by different hands. Triggers: "给项目画张架构图", "README 配图", "更新这张架构图", or any task that starts from an existing diagram directory. + +Everything above still applies (tokens, budgets, line discipline). What changes is lifecycle: the diagram is no longer a one-shot render but a source file with a contract. + +### The trio contract + +| File | Role | Rules | +|---|---|---| +| `index.html` | Source of truth | Self-contained HTML + inline SVG + inline CSS. No external image, script, or font fetch. SVG carries `role="img"`, `<title>`, `<desc>` | +| Same-name `.png` | What readers see | Re-exported from the HTML after every content change. Never edited directly, never patched to hide a source problem | +| `prompt.md` | Redraw context | The intent that would otherwise die in a chat log. Missing or stale prompt.md gets rebuilt as part of the task, not skipped | + +Deliver all three or say which is missing. A diagram whose latest intent lives only in conversation history will be redrawn wrong next quarter. + +### prompt.md: four fixed blocks + +| Block | Holds | Never holds | +|---|---|---| +| Must preserve | What the current diagram already states correctly | New ideas | +| Suggested additions | Facts from the sources the diagram does not show yet | Anything phrased as if already drawn | +| Visual direction | Hierarchy, whitespace, line, and boundary fixes to try next | A full palette dump (tokens live in this file) | +| Sister boundaries | What belongs to companion diagrams, with their paths | Content that should move back in | + +The block separation prevents the two classic redraw failures: treating a suggestion as if it were already drawn, and doing a visual pass that silently grows scope. + +### Evidence pass before drawing + +Read, in order, before any drawing: + +1. `prompt.md`, if present. +2. `index.html` as it is now. +3. The current PNG, at real size. +4. The facts: README, design doc, or the source files that define the objects and boundaries the diagram names. Read only what affects terminology and edges. + +Current facts override prompt.md; prompt.md overrides memory; never redraw from memory alone. If the facts contradict the prompt, update the prompt in the same change. + +### Reading path before canvas + +Decide how the reader's eye moves first; ratio and canvas follow (boards already obey this, section 3): + +| Path | Fits | Skeleton | +|---|---|---| +| Left to right | Mechanism, request path, task flow | input, decision or merge, output | +| Top to bottom | Platform overview, runtime architecture | access layer, runtime layer, governance layer | +| Current to target | Evolution, refactor | today, intervention point, target | +| Hub with edges | Plugin system, context boundary | center object, input boundary, output boundary | + +If the reader has no entry point, no amount of color or radius tuning helps. Fix the path, then the pixels. + +### Maturity encoding + +Repo diagrams mix what exists, what is being built, and what is only a direction. Encode maturity with stroke and opacity, not new colors: + +| State | Encoding | Reads as | +|---|---|---| +| Shipped | Standard node (ivory fill, near-black stroke) | Exists today | +| In build | Focal (brand stroke, `--brand-tint` fill) | The current work, the diagram's point | +| Future | Dashed `--stone` stroke, node content at 55% opacity | Direction, not commitment | + +This collapses two rules into one: the 1-2 focal budget and "what is under construction" are the same slots. Consequences: + +- Future nodes never take focal color, and never sit on the main path as if load-bearing. +- An undecided boundary gets a `TO VERIFY` mono label, not a drawn-through line. +- No dates, owners, phases, or milestones in an architecture diagram unless the user asked for a board with a governance layer. A diagram radiates certainty; do not let it promise what the roadmap has not decided. + +### Naming and copy + +Node titles carry function first, protocol noun second. A bare protocol noun outsources the reading cost to the reader: + +| Weak | Strong | +|---|---| +| Registry | 插件注册表 Registry | +| Queue | 任务队列 Queue | +| Policy Hook | 写动作准入 Policy Hook | +| Inbox | 任务收件箱 Inbox | + +In-diagram copy holds objects, boundaries, and actions only; argument stays in prose. CJK copy inside nodes uses short labels with commas, slashes, and semicolons, never the CJK full stop (。). If a line needs a full stop, it is a sentence, and sentences live in the document, not the diagram. + +### Terminology sync + +The diagram and its host document are one vocabulary. When prose renames an object, the same change updates: SVG `<text>` labels, `<title>` and `<desc>`, `prompt.md`, the re-exported PNG, and any cross-references. A diagram that still shows the old name is a bug, not a style issue. + +### PNG export + +| Destination | Export | +|---|---| +| README, docs site | 2400-3200px wide PNG | +| Local markdown preview | Same-directory relative path | +| Social or chat preview | Separate lightweight copy; never overwrite the main PNG | + +- Capture the content bounding box (the `.diagram` element or the SVG), not the full page. Add a fixed safe margin of 96-120px, default 112 (keep it divisible by 4). +- Export from the HTML, headless: `chrome --headless --screenshot` against the element, or `rsvg-convert -w 3200` on an extracted SVG. +- When export fails or clips, fix the export chain (parse the HTML, confirm the element exists, re-run). Never resize, crop, or hand-edit the PNG to route around a tool problem, and never change diagram content to appease the exporter. + +### Acceptance: three surfaces + +A repo diagram is not done until all three surfaces pass: + +1. **HTML in a browser**: structure, overlap, arrows, whitespace. +2. **The exported PNG in an image viewer** at 100%: clipping, blank bands, HTML-to-PNG drift. +3. **The published context**: the image fills the prose column, sits at the right heading level, and is not half-width or double-margined in the README or docs site. + +Mechanical scan before handoff, same spirit as the board pre-ship scan: grep the HTML for `#fff`, `gradient`, `shadow`, `<script`, `<img`, and the em dash character; every hex exists in the token map; the type floor holds (the caption tier is the smallest type on the page, nothing below it); the PNG is fresher than the HTML; `prompt.md` reflects what was just drawn. + +Crowding is solved by cutting content, banding peers, or splitting out a sister diagram, never by adding a smaller type tier or shrinking the export. + +--- + +## 5. Embedding in long-doc / portfolio + +### Standalone preview + +Open `assets/diagrams/architecture.html` (or `flowchart.html`, `quadrant.html`) directly. Each file is a complete HTML page with title, SVG, and caption. + +### Embed in a kami document + +Extract **only the `<svg>...</svg>` block** from the template (leave the frame / h1 / eyebrow behind). Drop it into a long-doc `<figure>`: + +```html +<figure> + <svg viewBox="0 0 960 460" xmlns="http://www.w3.org/2000/svg"> + <!-- svg content copied from architecture.html --> + </svg> + <figcaption>Figure 1. {{Short editorial caption in serif.}}</figcaption> +</figure> +``` + +`long-doc.html` already styles `figure` and `figcaption`. No extra CSS required. + +### Editing nodes / text + +Edit the `<text>` and `<rect>` values directly. Rules: + +- **All coordinates, widths, and gaps must be divisible by 4.** This is the anti-AI-slop floor. Break it once and the diagram starts looking "close enough". +- Node widths: 128 / 144 / 160 (three tiers, don't add more). Small diagrams (viewBox width < 360) may compress to 2 tiers, but still keep it 2 - don't tailor each node. +- Node heights: 32 (pill) / 64 (standard) +- Font sizes: 7 (small mono label) / 9 (sublabel mono) / 12 (name sans) +- **Arrow endpoints land exactly on node edges**: start `(box.x + box.w, box.y + box.h/2)`, end `(box.x, box.y + box.h/2)`, not "close enough". A 10px gap is visible to the eye. +- **SVG top padding**: the `y` in `<text y="…">` is the baseline. `y` must be ≥ font-size × 1.2, otherwise the tops of capital letters extend above the viewBox and get clipped (classic symptom: "TOOLS" renders as "TOULS"). Either pad the viewBox at the top or move `y` into the safe zone. +- **Loop arc control points**: for a four-cardinal-node ring, each arc is a Q-curve whose control point sits at the **outer intersection of the two adjacent tangent axes**, not at a node corner. Example for PLAN (top) → ACT (right): start = PLAN's right-edge midpoint, end = ACT's top-edge midpoint, control = `(ACT.x + ACT.w/2, PLAN.y + PLAN.h/2)`. This gives a pure horizontal tangent at departure and pure vertical at arrival, reading as a clean quarter-circle. Control at the node corner produces a squashed arc. +- **Closed loops need a dashed framing ring**: four directed arcs alone force the reader to mentally connect them into a loop. A dashed circle centered on the visual center (radius slightly larger than center-to-inner-edge distance) makes the loop immediately readable. Draw the ring below the nodes; solid node fills mask where the ring crosses each node; the ring shows only between nodes. +- **Chevron arrows, not filled triangles**: use `<path d="M2 1 L8 5 L2 9" fill="none" stroke=... stroke-width="1.5" stroke-linecap="round"/>`. A filled triangle reads as technical UI; an open two-stroke chevron reads as editorial schematic. kami defaults to chevron. **WeasyPrint does not support `<marker orient="auto">`**: all markers render at 0° (pointing right). The fix is to skip `<marker>` and draw each arrowhead as a manual chevron `<path>` with hardcoded direction (see production.md #15). + +### Color token map + +Shared tokens across kami's diagram set, mapping directly to the design system. All fills are solid hex values pre-blended on parchment; never use `rgba()` in SVG fills or strokes (it disagrees with the warm-tone palette and complicates WeasyPrint output). + +| SVG role | kami token | Value | +|---|---|---| +| Canvas | `--parchment` | `#f5f4ed` | +| Standard node fill | `--ivory` | `#faf9f5` | +| Standard node stroke | `--near-black` | `#141413` | +| Store node fill | near-black 5% (solid) | `#EAE9E2` | +| Store node stroke | `--olive` | `#504e49` | +| Cloud node fill | near-black 3% (solid) | `#EEEDE6` | +| Cloud node stroke | near-black 30% (solid) | `#B2B1AC` | +| External node fill | olive 8% (solid) | `#E9E8E1` | +| External node stroke | `--stone` | `#6b6a64` | +| **Focal fill** | `--brand-tint` | `#EEF2F7` | +| **Focal stroke** | `--brand` | `#1B365D` | +| Standard arrow | `--olive` | `#504e49` | +| Focal arrow | `--brand` | `#1B365D` | +| Primary text | `--near-black` | `#141413` | +| Secondary text | `--olive` | `#504e49` | +| Tertiary text / small mono label | `--stone` | `#6b6a64` | + +Don't add a fourth state ("warning amber", "success green"). kami has one accent. + +### Shared `<defs>` fragment + +Every diagram opens with the same parchment + dotted-noise overlay. Copy this block verbatim into new diagrams so the texture stays uniform: + +```html +<defs> + <pattern id="dots" width="22" height="22" patternUnits="userSpaceOnUse"> + <circle cx="1" cy="1" r="0.9" fill="#E3E2DC"/> + </pattern> +</defs> + +<rect width="100%" height="100%" fill="#f5f4ed"/> +<rect width="100%" height="100%" fill="url(#dots)" opacity="0.55"/> +``` + +`#E3E2DC` is the parchment-blended solid for `rgba(20,20,19,0.08)`; the `opacity="0.55"` on the overlay rect is a deliberate decoration, not a violation of the no-rgba-on-tag-backgrounds rule (which targets CSS tag fills, not SVG dot textures). + +### Embedded font calibration (override standalone sizes) + +Standalone diagram sizes (`7 / 9 / 12`) are too small once embedded in A4 long-doc / portfolio. The render width drops to about 470pt while the viewBox stays at 1000, so the scale factor is roughly `0.47`. To keep diagram text aligned with the 11pt body baseline, raise the SVG `font-size` values when embedding: + +| Visual target | Visual weight | SVG `font-size` | +|---|---|---| +| Same as h2 / focal node name | 11pt | **24** | +| Same as body | 11pt | **22-24** | +| Same as h3 / sub-label | 9-10pt | **18-20** | +| Same as caption | 8pt | **15-16** | +| Mono uppercase tag (letter-spacing 2.5) | 7pt | **14** | + +Compensation factor is roughly `1.8-2.0x` over standalone. `font-size: 24` with `font-weight: 600` and the body serif renders at about 1.05x the body, which reads as h2-equivalent without dominating the page. + +For tall diagrams (e.g. 5-layer stack), a working layout is `viewBox: 0 0 1000 560`, layer height `88`, gap `8`, and inside each layer: + +- Tag baseline `y+24`, font-size `14`, mono, letter-spacing `2.5` +- Name baseline `y+54`, font-size `24`, serif weight `600` +- Description baseline `y+76`, font-size `14`, mono, normal +- Right-side role tag `x=900`, `text-anchor=end`, font-size `13` + +### In-SVG header line (figure number + title) + +For embedded diagrams, put the "FIGURE N · TITLE" header inside the SVG instead of using `<figcaption>`. The diagram becomes a self-contained editorial unit, and the brand-colored header doubles as a section anchor. + +```svg +<text x="80" y="38" fill="#1B365D" font-size="13" font-weight="600" + font-family="mono" letter-spacing="3">FIGURE 1</text> +<text x="195" y="38" fill="#504e49" font-size="13" + font-family="mono" letter-spacing="3">DIAGRAM TITLE GOES HERE</text> +<line x1="80" y1="52" x2="920" y2="52" + stroke="#1B365D" stroke-width="0.8"/> +``` + +Two spaces between `FIGURE` and the number. With `letter-spacing: 3`, a single space lets the digit collide with the preceding letter. + +--- + +## 6. Icon style + +Icons live inside `<svg>` blocks alongside diagram nodes. Draw them with the same primitives (`rect`, `circle`, `line`, `path`) used for nodes - no imported icon fonts, no SVG sprites. + +**Rules**: +- Single line, stroke 1pt-1.5pt, no fill +- Stroke weight stays consistent within one diagram. Never mix 1pt and 1.5pt icons in the same figure +- No drop shadow, gradient, 3D, or glassmorphism +- No emoji-style faces, mascots, or expressive characters - this is editorial schematic, not playful +- Focal icons may use `--brand` stroke or fill, but the figure's total ink-blue area still respects the 5% cap + +### Canonical shapes + +When an icon represents a recurring concept, use the canonical form rather than inventing a new one: + +| Concept | Shape | +|---|---| +| Terminal / CLI | rounded rectangle, three dots top-left | +| Document / spec | rectangle, three short horizontal lines | +| Checklist / verification | rectangle, two check marks | +| Gear / system | 8-tooth gear outline | +| Magnifier / inspect | circle with 45° handle | +| Shield / safety | shield silhouette | +| Cloud / hosted service | three-arc cloud outline | +| Chip / hardware | square with leg lines on four sides | +| GPU / compute rack | rectangular stack with port indicators | + +### Human and robot figures + +Avoid human figures and anthropomorphic AI in editorial diagrams. If a person must appear, use a minimal line drawing without facial detail. Industrial robots may be line-art mechanical structures, but stop short of patent-illustration density. + +When in doubt, omit the icon entirely. A clean text label beats a cute icon in editorial schematic style. Add an icon only when it carries information the label cannot (e.g. distinguishing "cloud service" from "on-device compute" at a glance). + +--- + +## 7. AI-slop anti-patterns + +Scan for these when drawing or reviewing: + +| Anti-pattern | Why it fails | +|---|---| +| Dark mode + cyan / purple glow | Cheap "technical" signifier with no design decision | +| All nodes identical size | Destroys hierarchy | +| JetBrains Mono as the universal "dev" font | Mono is for technical content (ports, URLs, fields). Names go in sans. | +| Legend floating inside the diagram area | Collides with nodes | +| Arrow labels without a masking rect | Line bleeds through the text | +| Vertical `writing-mode` text on arrows | Unreadable | +| Three equal-width summary cards as a default | Template feel. Vary widths. | +| `box-shadow` on anything | kami only permits ring / whisper | +| `rounded-2xl` / border-radius above 10px | Max 6-10px. Beyond, it starts to look like App Store chrome. | +| Ink Blue on every "important" node | Focal rule is 1-2, not a signaling system | +| Decorative icons | Disaster | +| Gradient backgrounds | kami forbids them | +| Focal color contradicts the caption's claim | Caption says "Simple **core**", but the ACT node is painted ink-blue - two focals competing. Focal color must match the word emphasized (`<span class="hl">`) in the caption | +| Cycle diagram with a dashed ring AND four directed arcs | Same loop drawn twice; reader thinks there are two flows | +| SVG text clipped at the viewBox top | `text` y is the baseline; cap letters extend above y=0. Pad the top by font-size × 1.2 or adjust the viewBox | +| 5-10px gap between arrow endpoint and node edge | Reads as "arrow floating in space". Anchor endpoints to exact `box.x / box.x+w / box.y / box.y+h` | +| Per-node custom widths within one diagram | Four steps at widths 60 / 76 / 80 / 100 feel hand-patched. Small diagram: 2 tiers. Large: 3 tiers. That's the full budget | +| Porting an external diagram with one accent color per node type (purple/amber/green/red) | kami has one accent. When adapting external diagrams, migrate the focal to whichever element the caption's `<span class="hl">` emphasizes; concentrate color there, keep all other nodes neutral | +| Ring diagram: every node is a single word, center is empty | Four labeled boxes looping with no anchor. Either add a subtitle to each node or place one line of text at the center (exit condition, LOC count, etc.). Pick one. | +| Connector hugging a module's top edge | Reads as a broken border; the module looks pressed. Drop the line below the module with 16-24px of air and attach short stubs to the outer edge (section 3, Line discipline) | +| Viewpoint caption ("from the X perspective", "working draft for Y") | Structure carries the viewpoint. Corner text holds only date basis, version, or data scope | +| Paragraph inside a node | Node = optional icon + title + 2-3 short lines. Prose goes to a bottom note or companion doc | +| Full-sentence English translation on a CN board | English is a scan anchor (`CONTROL PLANE`, `OWNER MAP`), not a second copy of the text | +| Every fact drawn as its own small card | Peers share one band with vertical dividers; tabular facts get a table shell (section 3, Bands over cards) | +| Roadmap furniture (30/60/90, owner map, milestones) in an architecture diagram | That is a plan, not an architecture. Objects, relations, boundaries, intervention points only; schedule belongs to a timeline or a board's governance layer, and only when asked | +| Future capability drawn at the same weight as shipped | The reader assumes it exists. Encode maturity: shipped solid, in-build focal, future dashed at reduced opacity (section 4, Maturity encoding) | +| PNG edited or resized instead of re-exported from the HTML | The trio breaks silently; the next redraw starts from a lie. Fix the HTML or the export chain, then re-export (section 4) | +| HTML previewed, exported PNG never opened | Export clipping, blank bands, and scale bugs live only on the PNG surface (section 4, Acceptance) | +| Prose renamed an object, diagram still shows the old name | One vocabulary. Rename SVG text, `<title>`/`<desc>`, prompt.md, and re-export the PNG in the same change | +| Bare protocol noun as a node title (Registry, Queue, Inbox) | Function first, protocol second: 插件注册表 Registry (section 4, Naming and copy) | +| CJK full stop (。) inside node copy | Node copy is labels, not sentences. Commas, slashes, semicolons | +| Crowded board "fixed" by global scaling | The fault is module-level: padding, line breaks, baselines (section 3, Module-level pass) | + +--- + +## 8. Common pairings + +### Technical white paper +- Architecture (system overview) + built-in timeline (from long-doc) +- One architecture diagram per chapter, maximum. If you want two, the chapter is covering two topics and should split. + +### Portfolio project page +- Quadrant (competitive positioning) or architecture (the layer you owned) +- **Not every project needs a diagram.** Only when the diagram says something prose can't. + +### One-pager +- Quadrant (priority) or flowchart (decision path) +- One diagram only. If you're tempted to add a second, kill the weaker one. + +### Resume +- **No diagrams.** Resume real-estate costs more than diagrams. Rare exception: a URL to a portfolio diagram when showing system-level capability. + +### Slides +- One diagram per slide, max. The diagram is the body. Text is caption, not a sidebar. At slide scale (1920x1080), scale the SVG to fill >=65% of the slide area; print-sized diagram on screen slide leaves ~35% dead space. +- **Alternative when the diagram cannot grow** (already at semantic max width, e.g. flow charts or quadrant maps): insert a 70-100 char olive paragraph (`color: var(--olive)`, `font-size: 28px`, `line-height: 1.55`) between figure and caption. The paragraph carries the editorial reading; the caption stays one line as the takeaway. Keeps vertical fill above 60% without forcing the SVG larger than its information density supports. + +--- + +## 9. Data charts (bar / line / donut) + +Five data-driven chart types for investment reports, financial comparisons, and market-share breakdowns. Like the first three diagram types, all are self-contained HTML + inline SVG, embeddable in any kami document. + +### Color palette (derived from kami warm palette) + +| Role | Value | Use | +|---|---|---| +| Primary series | `#1B365D` ink-blue | First group / focal data | +| Series 2 | `#504e49` olive | Second group | +| Series 3 | `#6b6a64` stone | Third group | +| Series 4 | `#b8b7b0` light-stone | Fourth group | +| Series 5 | `#d4d3cd` mist | Fifth group | +| Series 6 | `#EEF2F7` brand-tint | Sixth group | +| Grid lines | `#e8e7e1` | Axes / reference lines | +| Data labels | `#141413` near-black | Numeric text | + +### Data limits + +| Chart | Max categories | Max series | Template | +|---|---|---|---| +| Bar chart | 8 groups | 3 series | `assets/diagrams/bar-chart.html` | +| Line chart | 12 points | 3 lines | `assets/diagrams/line-chart.html` | +| Donut chart | 6 segments | n/a | `assets/diagrams/donut-chart.html` | +| Candlestick | 30 days | n/a | `assets/diagrams/candlestick.html` | +| Waterfall | 8 segments | n/a | `assets/diagrams/waterfall.html` | + +### Editing data + +Each file has `<!-- DATA START -->` / `<!-- DATA END -->` comments. Only change SVG elements between those markers (`<rect>` coordinates, `<polyline>` points, `<path>` arcs, `<text>` values). Leave surrounding structure and styles untouched. + +**Coordinate rules (same as the first three diagram types)**: +- All coordinates divisible by 4 +- Bar chart corner radius `rx=2` (distinct from node radius 6-10) +- Line chart: `<polyline>` points format `"x1,y1 x2,y2 ..."`, data points marked with `<circle>` +- Donut chart: `<path>` arcs use `A R R 0 large-arc sweep_flag x y`; `large-arc=1` only when segment > 180° + +**Bar / line chart Y-axis formula** (default scale: max=140, chart-height=280, scale=2): +``` +bar_height = value × 2 +bar_top_y = 320 - bar_height (baseline y = 320) +dot_y = 320 - value × 2 +``` + +**Donut arc coordinates** (cx=300 cy=200 R=136 r=76, clockwise from top at -90°): +``` +angle_start = -90 + sum_of_previous_percentages × 3.6 +angle_end = angle_start + this_percentage × 3.6 +outer_x = 300 + 136 × cos(angle_deg × π/180) +outer_y = 200 + 136 × sin(angle_deg × π/180) +inner_x = 300 + 76 × cos(angle_deg × π/180) +inner_y = 200 + 76 × sin(angle_deg × π/180) +``` + +**Candlestick Y-axis formula** (default: price range 100-160, chart-height=280, scale=4.67): +``` +candle_y = 320 - (price - 100) * 4.67 +Up candle: fill=#1B365D (close > open), body from open_y to close_y +Down candle: fill=#6b6a64 (close < open), body from close_y to open_y +Wick: 1.2px stroke from high_y to low_y, centered on candle +``` + +**Waterfall formula** (default: max=200, chart-height=280, scale=1.4): +``` +bar_y = 320 - value * 1.4 +Floating bars: top = running_total_y, height = abs(delta) * 1.4 +Positive: fill=#1B365D · Negative: fill=#6b6a64 · Total: fill=#4d4c48 +Connector: dashed 0.8px #b8b7b0 between adjacent bar edges +``` + +--- + +## 10. Build / preview + +```bash +python3 scripts/build.py diagram-architecture +python3 scripts/build.py diagram-architecture-board +python3 scripts/build.py diagram-flowchart +python3 scripts/build.py diagram-quadrant +python3 scripts/build.py diagram-bar-chart +python3 scripts/build.py diagram-line-chart +python3 scripts/build.py diagram-donut-chart +python3 scripts/build.py diagram-state-machine +python3 scripts/build.py diagram-timeline +python3 scripts/build.py diagram-swimlane +python3 scripts/build.py diagram-tree +python3 scripts/build.py diagram-layer-stack +python3 scripts/build.py diagram-venn +python3 scripts/build.py diagram-candlestick +python3 scripts/build.py diagram-waterfall + +# or all +python3 scripts/build.py +``` + +Or just open `assets/diagrams/*.html` in a browser. + +Every diagram template carries a poster-size `@page` sized to its own frame and viewBox, so the WeasyPrint build exports one uncropped sheet instead of clipping at A4. Browsers ignore `@page`; only the PDF export path sees it. + +--- + +## 11. Credit + +This capability is inspired by Cathryn Lavery's [diagram-design](https://github.com/cathrynlavery/diagram-design) (a Claude Code skill with 13 editorial diagram types). kami borrowed the **approach** (inline SVG, semantic tokens, complexity budget, anti-slop table). Not the full catalog. diff --git a/references/mermaid-theme.json b/references/mermaid-theme.json new file mode 100644 index 0000000..2814d81 --- /dev/null +++ b/references/mermaid-theme.json @@ -0,0 +1,23 @@ +{ + "_comment": "Kami Mermaid theme. Single source of truth for mapping beautiful-mermaid's color roles onto the Kami palette (references/tokens.json). Consumed by scripts/mermaid_normalize.py (Python). Keep hex values in sync with references/tokens.json.", + "colors": { + "bg": "#f5f4ed", + "fg": "#141413", + "line": "#504e49", + "accent": "#1B365D", + "muted": "#6b6a64", + "surface": "#faf9f5", + "border": "#e8e6dc" + }, + "roles": { + "bg": "--parchment · diagram canvas, edge-label masks", + "fg": "--near-black · node text, primary strokes", + "line": "--olive · edge connectors (warm, not pure black)", + "accent": "--brand · arrow heads / focal emphasis (the only chromatic color)", + "muted": "--stone · secondary text, edge labels", + "surface": "--ivory · node fill (lifted surface)", + "border": "--border · node / group stroke" + }, + "renderFont": "Charter", + "cssFontStack": "Charter, Georgia, \"TsangerJinKai02\", \"Source Han Serif SC\", \"Noto Serif CJK SC\", serif" +} diff --git a/references/mermaid.md b/references/mermaid.md new file mode 100644 index 0000000..7b5c2bb --- /dev/null +++ b/references/mermaid.md @@ -0,0 +1,93 @@ +# Mermaid Diagrams + +Kami turns Mermaid text into editorial, Kami-styled diagrams using +[beautiful-mermaid](https://github.com/lukilabs/beautiful-mermaid) (MIT). Instead +of hand-tuning SVG coordinates, describe a diagram in Mermaid and let the +pipeline produce a parchment / ink-blue diagram that drops into any document. + +## Two paths + +| Path | Surface | Renderer | Diagram types | +|------|---------|----------|---------------| +| **PDF** | one-pager / long-doc / equity-report etc. | WeasyPrint (no JS) | flowchart, state, sequence, class, ER | +| **Browser** | any page running beautiful-mermaid (e.g. <https://agents.craft.do/mermaid>) | beautiful-mermaid in the browser | all of the above + xychart | + +The split exists because WeasyPrint does not execute JavaScript and applies inline +SVG only partially. The graph types carry their colors on inline presentation +attributes, which survive normalization to static hex. `xychart-beta` drives its +colors through `<style>` class selectors instead, which WeasyPrint does not apply +to inline SVG, so charts stay on the browser path. Kami already ships hand-drawn +`bar-chart` / `line-chart` / `donut-chart` / `candlestick` / `waterfall` diagrams +for PDF, so this is no loss of capability. + +## The Kami theme + +`references/mermaid-theme.json` is the single source of truth mapping +beautiful-mermaid's seven color roles onto the Kami palette (`references/tokens.json`): + +| role | token | hex | +|------|-------|-----| +| `bg` | `--parchment` | `#f5f4ed` | +| `fg` | `--near-black` | `#141413` | +| `line` | `--olive` | `#504e49` | +| `accent` | `--brand` | `#1B365D` | +| `muted` | `--stone` | `#6b6a64` | +| `surface` | `--ivory` | `#faf9f5` | +| `border` | `--border` | `#e8e6dc` | + +Font resolves to the Kami serif stack (`Charter ... TsangerJinKai02 ...`), so CJK +labels render and embed correctly. Keep these hex values in sync with `tokens.json`. + +## Most cases: edit a ready template + +Kami ships `sequence`, `class`, and `er` as static Kami-styled diagrams (joining the +14 hand-drawn ones). The common case needs **no tooling at all**: copy the nearest +`assets/diagrams/*.html`, edit the text labels, embed the `<svg>` into your document. +beautiful-mermaid is the *source* of these diagrams; you do not need it to use them. + +## New diagram from Mermaid text (no Node) + +Kami's build stays pure Python; beautiful-mermaid (a Node package) is never bundled. +To make a new diagram from Mermaid text: + +```bash +# 1. Render the Mermaid to SVG in any browser running beautiful-mermaid +# (e.g. https://agents.craft.do/mermaid). Theme choice does not matter -- +# the normalizer re-themes to the Kami palette. Save the SVG. +# 2. Re-theme + make it WeasyPrint-safe (pure Python, no Node, no network): +python3 scripts/mermaid_normalize.py raw.svg -o clean.svg +# 3. Paste <svg>...</svg> into a diagram shell (copy assets/diagrams/sequence.html), +# register it in DIAGRAM_TARGETS (scripts/build.py), then verify: +python3 scripts/build.py diagram-<name> # renders to assets/examples/*.pdf +``` + +The `.mmd` files in `assets/diagrams/src/` record the Mermaid source of the shipped +diagrams. Do not hand-edit the SVG coordinates in the committed HTML. + +## What the normalizer does + +`scripts/mermaid_normalize.py` turns any beautiful-mermaid SVG into a Kami diagram: + +- **Re-themes** it to the Kami palette by overriding the seven root color roles from + `references/mermaid-theme.json`, so the source theme is irrelevant. +- **Resolves** every `var()` and `color-mix(in srgb, ...)` to a static hex (WeasyPrint + does not compute `color-mix()` or reliably cascade SVG `<style>` custom props). +- **Fixes fonts**: strips beautiful-mermaid's Google-Fonts `@import` and rewrites the + (mis-quoted) `font-family` to the Kami serif stack. + +The `--check` lint flags any `color-mix(`, `<foreignObject>`, or web-font import that +reaches a PDF-bound template, so an un-normalized SVG cannot slip in. + +## Compatibility notes (verified) + +beautiful-mermaid v1.1.3 graph-type output uses native `<text>` (no +`<foreignObject>`), no `filter`, and no `<pattern>`. After normalization the SVG is +fully static hex on inline presentation attributes, which WeasyPrint renders +correctly, including CJK labels with embedded fonts. + +## When to use a diagram + +A diagram earns its space only when the **structure** is the point. If a table or a +sentence conveys the same relationship, use that instead. Keep diagrams to one +focal idea; the accent (ink-blue) marks the focal element, everything else stays in +warm neutrals. See `references/design.md` and `references/anti-patterns.md`. diff --git a/references/production.md b/references/production.md new file mode 100644 index 0000000..124416b --- /dev/null +++ b/references/production.md @@ -0,0 +1,940 @@ +# Production (Build · Verify · Troubleshoot) + +The engineering runbook for kami: from HTML / Python templates to PDF / PPTX deliverables. Four parts: **HTML -> PDF** · **Python -> PPTX** · **Verify & Debug** · **16 known pitfalls**. + +--- + +## Part 1 · HTML -> PDF (WeasyPrint) + +### Install + +```bash +pip install weasyprint pypdf --break-system-packages --quiet +``` + +Linux first-time: +```bash +apt install -y libpango-1.0-0 libpangoft2-1.0-0 fonts-noto-cjk +``` + +### Generate + +```python +from weasyprint import HTML +HTML('doc.html').write_pdf('output.pdf') +``` + +**CWD matters**: `@font-face { src: url("xxx.ttf") }` uses relative paths, so run from the directory containing the font file. + +```bash +cd /path/to/html-and-font +python3 -c "from weasyprint import HTML; HTML('doc.html').write_pdf('out.pdf')" +``` + +### Fonts + +**Most stable setup**: font file alongside HTML, `@font-face` with relative path. + +```html +<style> +@font-face { + font-family: "TsangerJinKai02"; + src: url("TsangerJinKai02-W04.ttf"); + font-weight: 400 500; +} +body { font-family: Charter, Georgia, Palatino, serif; } +</style> +``` + +**No commercial font available**: fallback chains are embedded in every template. + +```css +/* English */ +font-family: Charter, Georgia, Palatino, + "Times New Roman", serif; + +/* Chinese */ +font-family: "TsangerJinKai02", "Source Han Serif SC", + "Noto Serif CJK SC", "Songti SC", Georgia, serif; + +/* Japanese */ +font-family: "YuMincho", "Yu Mincho", "Hiragino Mincho ProN", + "Noto Serif CJK JP", "Source Han Serif JP", + "TsangerJinKai02", Georgia, serif; + +/* Korean */ +font-family: "Source Han Serif K", "Source Han Serif KR", + "Noto Serif KR", "Apple SD Gothic Neo", AppleMyungjo, + Charter, Georgia, serif; +``` + +**Font fallback affects page count**. Any font swap requires re-running the page-count check. If it overflows: lower `font-size` first, then tighten margins, then cut content. + +**Claude Desktop skill ZIPs do not bundle large CJK font files**: `TsangerJinKai02-W04.ttf`, `TsangerJinKai02-W05.ttf`, `SourceHanSerifKR-Regular.otf`, and `SourceHanSerifKR-Medium.otf` can make Claude.ai / Desktop skill upload or execution time out. The ZIP you upload must be the `scripts/package-skill.sh` output under the 6MB package ceiling, never a hand-zipped checkout. `package-skill.sh` excludes those large font files. Templates still keep local-first and jsDelivr fallback `@font-face` paths. + +When Chinese or Korean fonts are missing (the skill case), `scripts/ensure-fonts.sh` downloads them to the XDG user font dir (`${XDG_DATA_HOME:-~/.local/share}/fonts/kami`, override with `KAMI_FONT_DIR`), **not** into the skill's `assets/fonts`. fontconfig scans that dir by default on macOS and Linux, so WeasyPrint resolves `TsangerJinKai02` and `Source Han Serif K` from there while the installed skill stays small; online renders still use the jsDelivr `@font-face` URL. + +**Standalone HTML export** (sending a filled HTML file to someone else): this is not guaranteed to work outside the project tree. If the recipient cannot set up the font environment, use the PDF output instead. + +If you do need to share HTML: the font file and the HTML must live in the same directory, and the `@font-face src` must use a bare filename with no path prefix: + +```css +@font-face { + font-family: "TsangerJinKai02"; + src: url("TsangerJinKai02-W04.ttf") format("truetype"); +} +``` + +Remove the `../fonts/` prefix that templates use when fonts are in the project tree. The recipient must place the `.ttf` file alongside the `.html` file before running WeasyPrint. When in doubt, deliver the PDF. + +### Page spec + +```css +@page { + size: A4; /* or 210mm 297mm / A4 landscape / 13in 10in */ + margin: 20mm 22mm; + background: #f5f4ed; /* extend past margins to avoid white printed edge */ +} +``` + +### Print / white-paper variant (opt-in) + +Parchment is the default and keeps shipping. Override to white only when a single +document is **headed for a home / office printer**: a full-page `#f5f4ed` tint +bands unevenly and burns toner, where white paper prints clean. This is the one +sanctioned exception to design.md invariant #1 ("never pure white"), and it is +opt-in per document, never the default render. + +White is not a one-line background swap. Parchment also serves as the surface that +`--ivory` cards, code blocks, and striped rows lift *off* of (they are "brighter +than parchment"). Flip the page to white and those surfaces, only 1.5% lighter than +white, vanish. So relocate the warmth instead of deleting it: sink parchment down +into the lift surface. Three edits on the copied, filled template: + +```css +@page { background: #ffffff; } /* was #f5f4ed */ +html, body { background: #ffffff; } /* was var(--parchment) */ +:root { --ivory: #f5f4ed; } /* parchment becomes the card/code/table lift */ +``` + +Everything else is unchanged: ink-blue accent, warm text grays, and `--border` +hairlines all read fine on white. Leave the `--parchment` token itself alone (any +`var(--parchment)` section fill then reads as an intentional warm band on white). +A lifted surface that separated from parchment by fill *alone* (rare, most already +carry a `0.5pt var(--border)` edge) wants that border added so it holds an edge on +white. + +Notes: +- Lint-safe by construction: `scripts/lint.py` off-palette and token-sync guards + scan only registered templates, not generated documents, so `#ffffff` in a + filled output never trips them. +- Page-count and `--check-density` contracts are unaffected; still inspect that + cards, code, and tables read on white before shipping. +- If white-paper output ever becomes a first-class, frequent ask, promote this from + a recipe to a `--page-bg` / `--surface` token pair in every template `:root` + (ships commented, like the `.brand-logo` slot). `@page { background: var(--page-bg) }` + is verified to resolve in WeasyPrint (68.1), so the token path is viable. Until + then, keep it a recipe: the project's contract is "no new mode unless a request + can't be met otherwise", and this recipe already meets the request. + +### Headers & footers + +Long-doc running headers use `string(section-title)`. The default templates set +that string on chapter `h1` elements, not on every `h2`, so a table of contents +heading cannot pin the header to "Contents" / "目录". If a filled document uses a +different element for chapter titles, add `.running-title` to that element. + +```css +@page { + @top-right { + content: counter(page); + font-family: serif; font-size: 9pt; color: #6b6a64; + } + @bottom-center { + content: "{{DOC_NAME}} · {{AUTHOR}}"; + font-size: 9pt; color: #6b6a64; + } +} + +@page:first { + @top-right { content: ""; } + @bottom-center { content: ""; } +} +``` + +### WeasyPrint support matrix + +| Solid | Partial | Unsupported | +|---|---|---| +| CSS Grid / Flexbox | CSS filter / transform (partial) | JavaScript | +| `@page` rules | inline SVG (some attrs) | `position: sticky` | +| `@font-face` | gradients (slow, use sparingly) | CSS animations / transitions | +| `break-before` / `break-inside: avoid` | | | +| CSS variables `var(--name)` | | | +| `target-counter(attr(href), page)` for rendered TOC page numbers | | | +| `::before` / `::after` | | | + +### PDF metadata + +WeasyPrint reads standard meta tags in `<head>` and writes them into the PDF (Title / Author / Subject / Keywords). All templates have pre-built placeholders: + +```html +<head> + <title>{{DOC_TITLE}} + + + + + +``` + +**Auto-inference rules** (Claude fills these from the document content without asking): + +| Field | Source | +|---|---| +| `` | H1 heading or `.header .title` text | +| `author` | Resume / letter / portfolio: person's name from the document; everything else: `"Kami"` | +| `description` | One sentence extracted from the first 2 paragraphs, ≤150 characters | +| `keywords` | 3-5 keywords from title + section headings, comma-separated | +| `generator` | Fixed `"Kami"`, already set in template, do not change | + +**Verify**: + +```bash +pdfinfo assets/examples/one-pager-en.pdf # shows Title / Author / Subject +``` + +--- + +## Part 2 · Python -> PPTX (python-pptx) + +PPT shares the same design language but the medium (screen, 16:9, one-idea-per-slide) changes the details: fonts larger, layouts more rigid. + +### Install + +```bash +pip install python-pptx --break-system-packages --quiet +``` + +### Dimensions + +- **16:9 widescreen** (preferred): 13.33 × 7.5 inch +- **4:3 traditional**: 10 × 7.5 inch +- **Safe zone**: 0.5 inch margin on all sides (projector crop), plus 0.3 inch at bottom for page number + +### Palette (1:1 with design.md) + +```python +from pptx.dml.color import RGBColor + +PARCHMENT = RGBColor(0xf5, 0xf4, 0xed) +IVORY = RGBColor(0xfa, 0xf9, 0xf5) +BRAND = RGBColor(0x1B, 0x36, 0x5D) +NEAR_BLACK = RGBColor(0x14, 0x14, 0x13) +DARK_WARM = RGBColor(0x3d, 0x3d, 0x3a) +OLIVE = RGBColor(0x5e, 0x5d, 0x59) +STONE = RGBColor(0x87, 0x86, 0x7f) +BORDER_WARM = RGBColor(0xe8, 0xe6, 0xdc) +TAG_BG = RGBColor(0xee, 0xf2, 0xf7) +``` + +### Type (bigger than print, optimized for projection) + +| Role | Size | Font | +|---|---|---| +| Title | 48pt | Serif 500 | +| Subtitle | 24pt | Serif 400 | +| H2 chapter | 32pt | Serif 500 | +| H3 subtitle | 20pt | Serif 500 | +| Body | 18pt | Serif 400 | +| Caption | 14pt | Serif 400 | +| Footer | 12pt | Serif 400 | + +English stack on PowerPoint: +- Serif: `Charter` -> `Georgia` -> `Palatino` +- Sans: same as serif (single-font-per-page rule) + +### Nine standard layouts + +1. **Cover**: parchment background, centered display title + brand-colored short line + subtitle / author / date +2. **Contents**: parchment, left-aligned `01 Chapter title` (number serif brand-colored) +3. **Chapter divider**: full brand ink-blue background, centered white title - the **only** fully chromatic slide in the deck +4. **Content slide**: eyebrow (serif stone) + core claim (serif near-black) + brand line + body (serif dark-warm) +5. **Data slide**: top takeaway + 2-4 metric cards (big number serif brand + small label serif olive) +6. **Comparison**: eyebrow + left column (muted, OLIVE/STONE) vs. right column (full-weight, DARK_WARM/NEAR_BLACK), separated by a 1pt BORDER warm-gray vertical divider. Left = "Before/Old/Problem"; right = "After/New/Solution". Use `comparison_slide()`. +7. **Pipeline**: eyebrow + title + serif numerals 01/02/03 + step title + step description, laid out in equal-width columns. All steps visible at once (no click-reveal). Use `pipeline_slide()`. +8. **Quote**: parchment, minimal, centered serif quote + `- Source` +9. **Closing**: parchment, centered "Thank you / Q&A / Contact" + +### Template-bound PPTX inventory + +Use this only when the user provides a real PPTX or brand template and explicitly asks to preserve its layout system. First inspect the template visually, identify the few reusable layout families, and map each planned section to one existing slide family. Then edit content while preserving the template's shape structure. Do not run this inventory step for Kami's default WeasyPrint or Marp paths; those already have fixed template contracts. + +### Script skeleton + +Full working example in `assets/templates/slides-en.py`. Key bits: + +```python +from pptx import Presentation +from pptx.util import Inches, Pt +from pptx.dml.color import RGBColor +from pptx.enum.shapes import MSO_SHAPE +from pptx.enum.text import PP_ALIGN + +PARCHMENT = RGBColor(0xf5, 0xf4, 0xed) +BRAND = RGBColor(0x1B, 0x36, 0x5D) +SERIF = "Charter" +SANS = SERIF + +prs = Presentation() +prs.slide_width = Inches(13.33) +prs.slide_height = Inches(7.5) + +def blank_slide(): + slide = prs.slides.add_slide(prs.slide_layouts[6]) + bg = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, + 0, 0, prs.slide_width, prs.slide_height) + bg.fill.solid(); bg.fill.fore_color.rgb = PARCHMENT + bg.line.fill.background(); bg.shadow.inherit = False + return slide +``` + +### PPT notes + +1. **One idea per slide** - if it runs over three lines, split it +2. **No default PowerPoint template** - it's cool-blue-gray, clashes with parchment +3. **Animations**: don't. Parchment is a print aesthetic, not a SaaS demo. At most `fade` +4. **Export to PDF** for sharing - cross-machine consistency is better than .pptx + - macOS: Keynote -> Export to PDF + - Linux: `libreoffice --headless --convert-to pdf output.pptx` + +### Slide scale rules (from production decks) + +Print and slide share the same palette and serif, but sizing is different. +One rule covers most adjustments: **macro spacing x1.6, micro details x0.5** (letter-spacing, border weight, border-radius). + +| Item | Print | Slide | Why | +|---|---|---|---| +| Container | A4 portrait | 1920x1080 or A4 landscape | Fixed ratio, never `100vw x 100vh` | +| Title size | 30-34pt | 48-64pt | Projection needs larger display | +| Slide padding | N/A | 72-80px top, 80px sides | Less than 72px top feels cramped | +| Eyebrow tracking | 0.5-1pt | 3px max | Print tracking looks scattered at slide scale | +| Display tracking | 0 to -0.2pt | -0.5pt | Tighten large titles to prevent letter gaps | +| Header gap | 8-14pt | 36px+ between rule and H1 | Below 36px the rule looks like an underline | +| Line-height titles | 1.1-1.3 | 1.3 minimum | CJK characters collide below 1.3 at slide sizes | +| Code blocks | Full runnable code | Pseudocode + `.hl` keywords | Slide code is for structure, not execution | +| Images | Inline with text | `width:100%; object-fit:contain; max-height:780px` | Avoid fixed height that clips different ratios | +| Footer | Per-template | Single component, CSS-injected text | Prevents drift across 50+ slides | +| Punctuation | Standard | Use ` - ` for short joins, `<ol>` for parallel items | CJK commas break visual rhythm at slide scale | + +--- + +## Part 2.5 · Markdown -> Marp (variant deck path) + +Marp is the third rendering path, used only when the user explicitly asks for Marp / markdown slides / a deck that lives in a `.md` file. The repo does **not** declare `marp-cli` as a dependency; install it on the user's machine. + +### Install + +Use the `npx @marp-team/marp-cli@latest ...` form below for zero-install. For repeat use, install via `npm i -g @marp-team/marp-cli` or `brew install marp-cli` (see [marp-cli docs](https://github.com/marp-team/marp-cli)). Kami's build pipeline (`build.py` / `package-skill.sh`) does not call `marp`. + +### Files + +| Asset | Path | +|---|---| +| CN theme | `assets/templates/marp/slides-marp.css` (theme name: `kami`) | +| EN theme | `assets/templates/marp/slides-marp-en.css` (theme name: `kami-en`) | +| CN sample deck | `assets/templates/marp/slides-marp.md` | +| EN sample deck | `assets/templates/marp/slides-marp-en.md` | + +### Render commands + +Run from the repo root so input paths resolve. **Input file must come before `--theme-set`**; `--theme-set` is a yargs array option and will swallow any positional arg that follows it. + +**Font path caveat**: Marp inlines the theme CSS into the output HTML verbatim. The `@font-face` `url("../../fonts/...")` paths in the theme therefore resolve relative to the *output file location*, not the theme CSS location. When the output sits inside the repo (e.g. `-o assets/examples/kami.html`), the relative path matches and local Tsanger / Charter loads. When the output sits elsewhere (e.g. `-o /tmp/kami.html`), the relative path misses and the browser falls back to the jsDelivr CDN URL declared alongside each local one. This needs network. This differs from WeasyPrint, where CSS paths resolve relative to the input HTML. + +```bash +# HTML preview (no Chromium needed; zero external download) +npx @marp-team/marp-cli@latest \ + assets/templates/marp/slides-marp.md \ + --theme-set assets/templates/marp \ + -o /tmp/kami-cn.html + +# PDF (needs Chromium; see "Browser strategy" below) +npx @marp-team/marp-cli@latest \ + assets/templates/marp/slides-marp.md \ + --theme-set assets/templates/marp \ + --pdf --allow-local-files \ + -o /tmp/kami-cn.pdf + +# PPTX (Chromium-rendered; not a python-pptx editable deck) +npx @marp-team/marp-cli@latest \ + assets/templates/marp/slides-marp.md \ + --theme-set assets/templates/marp \ + --pptx --allow-local-files \ + -o /tmp/kami-cn.pptx +``` + +`--theme-set` points at the directory; Marp picks up every `.css` in there. `--allow-local-files` is required for PDF / PPTX so the renderer may read the local font files referenced by `@font-face` URLs. + +### Browser strategy (only for PDF / PPTX) + +HTML output is pure Node and needs no browser. PDF / PPTX rendering goes through a headless browser. Three options, lightest first: + +| Strategy | Setup | Cost | +|---|---|---| +| **HTML only, view in browser** | Use the HTML command above; open in any browser; export to PDF from the browser's print dialog if needed | 0 MB | +| **Reuse a local Chromium-family browser** | Set `PUPPETEER_EXECUTABLE_PATH` to the absolute path of an installed Chrome, Edge, Brave, Arc, or Chromium binary. Marp also honours `--browser chrome` / `edge` / `firefox` with `--browser-path`. | 0 MB (assumes the browser is already installed) | +| **Let Marp download its own Chromium** | First run of `--pdf` / `--pptx` triggers Puppeteer to fetch a pinned Chromium build (~150 MB to `~/.cache/puppeteer/`) | ~150 MB, one-time | + +For light verification of the theme and sample deck, prefer the HTML path. + +### Marp gotchas + +| Symptom | Cause | Fix | +|---|---|---| +| Two page numbers per slide | Deck pinned a `.page-num` element and also set `paginate: true` | Pick one; the theme injects pagination via `section::after` | +| `position: absolute` does not pin `.co` | A child `<div>` overrode `position: relative` on the section | Marp themes already set `section { position: relative }`; do not override it per slide | +| PDF / PPTX export hangs on first run | Marp is downloading Chromium | Network-restricted environments need `PUPPETEER_EXECUTABLE_PATH` to a pre-installed Chrome | +| Markdown inside `<div class="c2">` renders as a literal HTML block | Missing blank line between the `<div>` and the Markdown body | Always leave a blank line above and below Markdown inside an HTML wrapper | + +--- + +## Part 3 · Verify & Debug + +### The three-step loop (mandatory after every change) + +```bash +# 1. Generate +python3 -c "from weasyprint import HTML; HTML('doc.html').write_pdf('out.pdf')" + +# 2. Page count +python3 -c "from pypdf import PdfReader; print(len(PdfReader('out.pdf').pages))" + +# 3. Visual inspect (when in doubt) +pdftoppm -png -r 300 out.pdf inspect +``` + +**Not verified = not done.** + +### Did the font actually load? + +```bash +pdffonts output.pdf +``` + +If the output shows `DejaVuSerif` / `Bitstream Vera` - your specified font didn't load, fell through to system ultimate fallback. Expected: `Charter`, `Georgia`, `TsangerJinKai02`, or a Japanese Mincho face such as `YuMincho`, `Hiragino-Mincho`, `Noto-Serif-CJK-JP`, or `Source-Han-Serif-JP`. + +### One-step build + validate + +Project script `scripts/build.py` is the productized version of the three-step loop: + +```bash +python3 scripts/build.py # all 12 examples +python3 scripts/build.py resume-en # one target + page count + fonts +python3 scripts/build.py --check # scan for CSS rule violations +python3 scripts/build.py --check-density # warn on pages with >25% trailing whitespace +python3 scripts/build.py --check-markdown output.pdf # scan for raw Markdown markers +``` + +For long-doc templates, keep TOC entries as links to stable chapter ids. The page +number is generated with CSS `target-counter()` at render time, so content edits +do not require hand-updating page numbers. + +### Page overflow (constrained templates) + +When a constrained template (one-pager, letter, resume, and their variants) runs over its page ceiling, fix it by editing content, not by shrinking type: cut or merge body copy until it fits, since tiny font / line-height / margin changes break the layout (see High-Risk Pitfalls). Then verify: + +```bash +python3 scripts/build.py --check +python3 scripts/build.py --verify +``` + +### Hi-res visual inspection + +```bash +pdftoppm -png -r 160 output.pdf preview # standard +pdftoppm -png -r 300 output.pdf preview # detail bugs +pdftoppm -png -r 400 output.pdf preview # extreme detail (tag double-rect check) +``` + +### 5-point pre-ship review + +A successful render is not enough. Scan these before delivery: + +| Dimension | Pass standard | +|---|---| +| Fact accuracy | Numbers, dates, versions, funding, specs, and market facts have sources; uncertainty is written as magnitude or marked as missing | +| Content structure | Headlines read as a summary; each paragraph opens with a claim; no ceremonial filler | +| Material coverage | Branded docs include logo, product image, or UI screenshot coverage; missing materials are clearly marked | +| Typographic detail | Fonts load correctly, line-height stays in spec, emphasis only marks numbers or distinctive phrases, tag backgrounds are solid hex | +| PDF readiness | Page count fits, placeholders are replaced, visual inspection shows no overflow, overlap, or broken page breaks | + +If any row fails, fix it before delivery. + +### Static product site pre-ship review + +Run this when the deliverable is a landing page, product site, or hosted showcase rather than a PDF: + +| Dimension | Pass standard | +|---|---| +| Runtime preview | Serve locally and inspect desktop plus 375px mobile. The first viewport shows the product category, real asset, CTA, and a hint of the next section. | +| Generated output | If pages come from `template + i18n + content`, run the generator's check mode. It must fail on missing keys and committed output drift. | +| Public metadata | Canonical, hreflang, `og:locale`, social image, JSON-LD, robots, sitemap, `llms.txt`, and `llms-full.txt` all reflect the shipped locale set. | +| Copy sync | Product positioning, price, version, install path, support link, FAQ, `llms.txt`, and `llms-full.txt` carry the same factual claims in every locale. | +| Asset reality | Screenshots are real product surfaces and every image path resolves from the repo or a public URL. No `/Users`, `file://`, or sibling-repo relative paths. | +| Screenshot fit | Hero, gallery, feature, and social-image slots use stable ratios. UI text, numbers, prompts, and controls are not cropped away for aesthetics. | +| Motion fallback | Gallery rotation, entrance animation, or custom transitions respect `prefers-reduced-motion`; a still page remains readable with motion disabled. | +| Link surface | Primary CTA, download, releases, docs, help, social links, and internal locale links resolve. Any named release or download artifact exists. | + +If the site has only one or two locales, hand-maintained static pages are acceptable. For three or more locales, prefer a generator with a drift check over repeated manual edits. + +--- + +## Part 4 · 22 known pitfalls + +Every entry below came from a real failure. Check here first when something looks wrong. + +Severity scale: **(P0)** render-breaking, must fix before delivery. **(P1)** breaks the design contract (rhythm, spec). **(P2)** visible to a careful reader, but not blocking. **(P3)** operational: affects workflow, not visual output. + +### 1. (P0) Tag / Badge double-rectangle bug (the worst) + +**Symptom**: PDFs show two concentric rectangles on tag backgrounds at zoom - an outer softer one and an inner tighter one. Especially visible on mobile PDF viewers. + +**Root cause**: WeasyPrint renders `rgba(..., 0.xx)` by compositing the **padding area** and the **glyph pixel area** independently. Glyph anti-aliasing stacks alpha differently, creating the second visible edge. + +**Fix**: Tag backgrounds must be solid hex. No rgba. + +```css +/* avoid */ .tag { background: rgba(201, 100, 66, 0.18); } +/* use */ .tag { background: #E4ECF5; } +``` + +**rgba -> solid conversion** (parchment `#f5f4ed` base + ink-blue `#1B365D`): + +| rgba alpha | Solid hex | +|---|---| +| 0.08 | `#EEF2F7` | +| 0.14 | `#E4ECF5` | +| **0.18** | **`#E4ECF5`** ← default | +| 0.22 | `#D0DCE9` | +| 0.30 | `#D6E1EE` | + +Formula: `solid_channel = base + (foreground - base) × alpha`. Different base colors (e.g. ivory) need re-computing. + +**Want "breathing" texture?** Use `linear-gradient` - the whole tag rasterizes as one bitmap, no alpha compositing: + +```css +.tag { background: linear-gradient(to right, #D6E1EE, #E4ECF5 70%, #EEF2F7); } +``` + +**Aesthetic warning**: gradients work engineering-wise but usually oversell the tag. Priority order: lightest solid (`#EEF2F7`) > standard solid (`#E4ECF5`) > gradient (rarely). If the reader's eye lands on the tag background shape before the text inside - you went too far. + +### 2. (P0) Thin border + radius = double circle + +**Symptom**: `border: 0.4pt solid ...` + `border-radius: 2pt` shows two parallel arcs on zoom. + +**Root cause**: WeasyPrint strokes border inner and outer paths separately when `< 1pt` + rounded corners - at thin widths they can't overlap. + +**Fix (pick one)**: +1. Use background fill instead (preferred, design-consistent) +2. Border ≥ 1pt +3. Drop `border-radius` + +### 3. (P1) 2-page hard-limit overflow + +For resume, one-pager, and other length-capped docs. + +**Common causes**: font fallback, content added, font-size bumped by accident, line-height pushed from 1.4 to 1.6. + +**Diagnose**: `pdffonts output.pdf` to verify what actually loaded. + +**Fix (priority)**: +1. Cut redundant qualifiers ("deeply researched" -> "researched") +2. Merge related data points in the same section +3. Drop non-essential items whole (not piecemeal) +4. Reduce section spacing (use sparingly - affects global rhythm) +5. Last resort: shrink font by 0.1-0.2pt + +**Don't**: cut cover / education / timeline structural blocks; cut emphasis (resume becomes flat). + +**5-project high-density layout** (when content legitimately needs 5 projects on page 1): add `class="resume--dense"` to `<body>`. This activates a built-in CSS variant that applies the following adjustments without touching any content: + +| Property | Default | Dense | +|---|---|---| +| `body font-size` | 9.2pt | 9pt | +| `.proj-text line-height` | 1.40 | 1.38 (CN) / 1.40 (EN) | +| `.tl-body font-size` | 9pt | 8.5pt (CN only) | +| `.section-title margin-top` | 5mm | 3.5mm | + +Apply dense mode only when the 4-project layout already overflows. Do not use it as a default; the visual rhythm is noticeably tighter. + +Resume templates use section-title bottom rules and borderless project rows. Do not reintroduce project `border-top` as a density aid; it creates double rules below headings and orphan rules at page breaks. + +### 4. (P1) Font fallback causes page count inconsistency + +**Symptom**: 2 pages locally, 4 pages in CI / on server. + +**Root cause**: font file neither alongside HTML nor system-installed. + +**Fix**: + +```bash +# Preferred: multi-source download script (retries, size validation). +# Lands fonts in ${XDG_DATA_HOME:-~/.local/share}/fonts/kami (fontconfig-scanned, +# outside the skill dir), then runs fc-cache. Inside a repo checkout it is a +# no-op because the committed TTFs already satisfy the templates' relative path. +bash scripts/ensure-fonts.sh + +# Or put .ttf alongside the HTML +cp TsangerJinKai02-W04.ttf workspace/ + +# macOS fallback font +brew install --cask font-source-han-serif-sc + +# Linux system install +apt install fonts-noto-cjk +mkdir -p ~/.fonts && cp *.ttf ~/.fonts/ && fc-cache -f +``` + +### 5. (P2) CJK and Latin crowding (Chinese mode only) + +**Symptom**: "125.4k GitHub Stars" - k and G feel glued. + +**Wrong fixes**: hand-added ` ` / `margin-left: 2mm` (misaligns adjacent elements). + +**Right fix**: separate spans with flex gap: + +```html +<div class="metric"> + <span class="metric-value">125.4k</span> + <span class="metric-label">GitHub Stars</span> +</div> +``` +```css +.metric { display: flex; align-items: baseline; gap: 6pt; } +``` + +### 6. (P2) Full-width vs half-width spaces (Chinese mode) + +- **Between Chinese characters**: U+3000 full-width space + `·` + space +- **Between Latin words**: half-width space + `·` + space +- **Mixed**: prefer flex gap, don't hand-type spaces + +### 7. (P2) Thousands / percent / arrows - be consistent + +| Use | Avoid | +|---|---| +| `5,000+` | `5000+` | +| `90%` | `90 %` (pre-space) | +| `->` | `->` / `->` | + +Self-check: +```bash +grep -oE '->|->|⟶|⇒' doc.html | sort | uniq -c +grep -oE '[0-9]{4,}' doc.html | sort -u +``` + +### 8. (P2) Too much / too little emphasis + +- Four or five ink-blue runs in one line -> visual fatigue, no focal point +- Entire section with none -> flat, no scan handles + +**Rule**: ≤ 2 emphases per line, ≥ 1 per section, only **quantifiable numbers or distinctive phrases** get highlighted - never adjectives. + +Healthy ratio: one emphasis per 80-150 words. + +### 9. (P0) `height: 100vh` doesn't work + +**Symptom**: full-bleed cover using `height: 100vh` renders empty. + +**Root cause**: viewport units are undefined in WeasyPrint's `@page` context. + +**Fix**: + +```css +.cover { + min-height: 257mm; /* A4 height 297 - 40mm margins */ + display: flex; + flex-direction: column; + justify-content: center; +} +``` + +### 10. (P1) `break-inside` fails inside flex + +**Symptom**: `.card { break-inside: avoid }` still splits across pages. + +**Root cause**: WeasyPrint's flex/grid `break-inside` support on direct children is incomplete. + +**Fix**: wrap the flex item in an extra block: + +```html +<div class="row"> + <div class="card-wrapper"><div class="card">...</div></div> +</div> +``` +```css +.row { display: flex; } +.card-wrapper { break-inside: avoid; } +``` + +### 11. (P3) Hide page number on the first page + +```css +@page:first { + @top-right { content: ""; } +} +``` + +### 12. (P2) Printed white margin around the page + +**Symptom**: printing produces a white border even though `background` is set. + +**Root cause**: default `@page background` only covers the content area, not the margin. + +**Fix**: + +```css +@page { + size: A4; margin: 20mm; + background: #f5f4ed; /* extends past margins */ +} +``` + +### 13. (P2) Blurry images + +**Symptom**: images in PDF look soft. + +**Root cause**: WeasyPrint renders at source pixel density. A4 @ 300 dpi = 2480 × 3508 pixels. + +**Fix**: source images at 2x or 3x. + +### 14. (P3) Verification loop (catch-all) + +```bash +python3 -c "from weasyprint import HTML; HTML('doc.html').write_pdf('out.pdf')" +python3 -c "from pypdf import PdfReader; print(len(PdfReader('out.pdf').pages))" +pdftoppm -png -r 300 out.pdf inspect # when in doubt +``` + +**Not verified = not done.** + +### 15. (P0) SVG marker `orient="auto"` ignored + +**Symptom**: SVG arrows using `<marker orient="auto">` or `orient="auto-start-reverse"` all point right (the marker's default drawing direction), regardless of the path's tangent angle. + +**Root cause**: WeasyPrint's SVG renderer does not support the `orient="auto"` attribute on markers. The marker is always drawn at 0°. + +**Fix**: skip `<marker>` entirely. Draw each arrowhead as a manual chevron `<path>` at the endpoint, with the direction hardcoded. + +```xml +<!-- Bad: marker arrow, WeasyPrint renders all pointing right --> +<defs> + <marker id="a" orient="auto" ...> + <path d="M2 1L8 5L2 9" .../> + </marker> +</defs> +<path d="M 440 52 Q 568 52 568 244" marker-end="url(#a)"/> + +<!-- Good: manual chevron, direction per endpoint --> +<path d="M 440 52 Q 568 52 568 244" fill="none" stroke="#504e49" stroke-width="1.5"/> +<path d="M 560 236 L 568 244 L 576 236" fill="none" stroke="#504e49" stroke-width="1.5" + stroke-linecap="round" stroke-linejoin="round"/> +``` + +Chevron templates (tip at endpoint, 8px arm length): + +| Direction | chevron path | +|---|---| +| down | `M (x-8) (y-8) L x y L (x+8) (y-8)` | +| left | `M (x+8) (y-8) L x y L (x+8) (y+8)` | +| up | `M (x-8) (y+8) L x y L (x+8) (y+8)` | +| right | `M (x-8) (y-8) L x y L (x-8) (y+8)` | + +### 16. (P1) Slide letter-spacing must be halved + +**Symptom**: Slide text looks "scattered" or over-spaced when print letter-spacing values (e.g. `letter-spacing: 8px`) are used directly. + +**Root cause**: Print letter-spacing values are tuned for small sizes (8-12pt). At slide sizes (48-64px), the same absolute value gets multiplied out of control. + +**Fix**: Slide letter-spacing = print value / 2. Mono fonts are exempt (fixed-width by nature, no extra tracking needed). + +```css +/* Print eyebrow */ +.eyebrow { letter-spacing: 6px; } + +/* Slide eyebrow */ +.slide .eyebrow { letter-spacing: 3px; } /* halved */ +``` + +### 17. (P1) Figure SVG `max-height` starves width + +**Symptom**: An inline `<svg>` inside `<figure>` sits at less than the page content width, leaving a visible parchment gap on the right while the surrounding title and table run full-width. + +**Root cause**: When a figure SVG declares `max-height` without an explicit `width: 100%`, browsers and WeasyPrint preserve the viewBox aspect ratio and shrink width to honor the height cap. For wide viewBoxes (aspect > 1.5) the height cap becomes the binding constraint and width starves. + +**Fix**: Always set both width and height behavior. Use `max-height` only as a safety ceiling, never as the primary sizing rule. + +```css +/* avoid */ +figure svg { max-height: 45mm; } + +/* use */ +figure svg { width: 100%; height: auto; max-height: 70mm; } +``` + +Quick check: if `viewBox` aspect ratio × current `max-height` < page content width, the chart is starved. Bump `max-height` until `aspect × max-height >= content width` or remove the cap. + +### 18. (P1) Multi-column metric labels need word-budget discipline + +**Symptom**: One or more labels in a 3-4 column metric row wrap to two lines while siblings stay on one line, breaking the baseline rhythm and pushing the value/label out of alignment. + +**Root cause**: Equal-flex columns at gap `G` and content width `W` give each column `(W - G·(N-1)) / N` total. After the metric value (typically 12-18mm at 14pt Charter), the label has only what remains - usually 22-28mm for 4 columns at 184mm width. + +**Fix**: Plan label text against the available budget before layout. Approximate budget at 9pt Charter: + +| Layout | Per-column total | Label budget after value | Soft char limit | +|---|---|---|---| +| 4 columns, gap 7mm, content 184mm | ~40.7mm | ~22-26mm | 14-18 chars | +| 3 columns, gap 7mm, content 184mm | ~56.7mm | ~38-42mm | 24-28 chars | +| 4 columns, gap 5mm, content 184mm | ~42.7mm | ~24-28mm | 16-20 chars | + +When the natural label is longer (e.g. "Falcon launches, lifetime"), trim to the data essential ("Falcon launches"); supporting context belongs in nearby body copy, not in a metric chip. + +### 19. (P2) Multi-column body density imbalance + +**Symptom**: A row of N parallel body columns (timeline cards, conviction cards, feature blurbs) renders with one column wrapping to one extra line while the others wrap evenly. The rhythm reads broken even when each individual cell looks fine. + +**Root cause**: Equal-width columns wrap based on character count, not "ideas". A column with 88 chars next to siblings at 67-81 chars at the same width will spill to one extra line. + +**Fix**: Hold body length within ±10 chars across parallel columns of the same width. Rewrite the longest column tighter rather than padding the shorter ones. + +```text +col 1: 67 chars (2 lines) +col 2: 81 chars (2 lines) +col 3: 88 chars (3 lines) <- breaks rhythm +col 3': 66 chars (2 lines) <- fixed by trimming "general intelligence" -> "AGI" +``` + +### 20. (P0) Demo / template HTML must reference assets inside the kami repo + +**Symptom**: Image slot renders as a missing-image placeholder in the PDF; rendered demo PNG looks empty where a screenshot should be. + +**Root cause**: An `<img src="../../../sibling-project/asset.jpg">` reaches outside the kami repo. The path resolves on the maintainer's laptop where the sibling project happens to be checked out, but breaks for every other user, breaks the packaged skill ZIP, and breaks any CI that doesn't recreate the maintainer's working tree. + +**Fix**: Every image referenced by a demo or template must live under `assets/demos/images/` or `assets/illustrations/`. Copy the source into the kami repo, then reference it with a relative path inside the repo. + +```html +<!-- avoid --> +<img src="../../../kaku/website/public/shots/kaku-light.webp" alt="..."> + +<!-- use --> +<img src="images/kaku-hero.jpg" alt="..."> +``` + +Quick check before building any demo: `rg 'src="(\.\./|/Users/|file://)' assets/demos/` should return zero matches. + +### 21. (P1) Metric row baseline-align breaks when labels wrap + +**Symptom**: A horizontal metric row with `display: flex; align-items: baseline` looks fine when every label is one line, but ugly when one label wraps. The big number "10×" sits at the visual top of its column while the multi-line label flows downward; sibling columns with one-line labels look balanced but the wrapped column reads broken. + +**Root cause**: `align-items: baseline` aligns each metric to the **first line** of its label. When labels have different line counts, the visible heights differ but the numbers all sit at the same baseline (= top), making the row look uneven. + +**Fix**: Stack vertically (`flex-direction: column`). All numbers sit on the same top edge, all labels start at the same y below the numbers, and label wrap only extends each column's bottom, which is invisible on a slide / page. + +```css +/* avoid: breaks visually when one label wraps */ +.metric { display: flex; align-items: baseline; gap: 8pt; } + +/* use: vertical stack, number above label */ +.metric { display: flex; flex-direction: column; gap: 6pt; } +``` + +This is especially important on slides where metrics often sit on a baseline strip at the bottom of the page; even a single multi-line label among 3 columns breaks the rhythm. + +### 22. (P2) Slide bullets: prefer short numerals or `•` over en-dash + +**Symptom**: A bulleted list on a slide with `–` (en-dash, U+2013) markers reads heavy and informal, especially at large slide font sizes (12-14pt body). The en-dash is wide and creates a visible gap between marker and text. + +**Root cause**: En-dash is a typographic primitive, originally meant for ranges ("1995–1997"), not list markers. At slide scale it looks elongated and informal. + +**Fix**: Use either small numerals (`1.`, `2.`, `3.`) or a standard bullet (`•`) in brand color. Numerals are tighter horizontally and signal sequence; bullets are tightest visually and signal "items in any order". + +```css +/* avoid on slides: en-dash reads informal at large font sizes */ +ul.pts li::before { content: "\2013"; } + +/* use: numbered, mono digit, brand color */ +ul.pts { counter-reset: pts; } +ul.pts li { counter-increment: pts; padding-left: 18pt; } +ul.pts li::before { + content: counter(pts) "."; + color: var(--brand); + font-weight: 500; + font-variant-numeric: tabular-nums; +} +``` + +Print docs (long-doc, equity-report) keep the editorial en-dash style; slides switch to numerals. + +--- + +## Part 5 · HTML -> DOCX (pandoc + SVG-to-PNG) + +PDF is the delivery format; DOCX is the collaboration format. For proposal / report scenarios where the recipient needs to edit, comment, or forward to their team, ship a `.docx` alongside the PDF. + +### Why two-step + +`pandoc input.html -o output.docx` looks straightforward, but inline `<svg>` blocks fail Word's OOXML validation. Word treats them as unknown content and either drops them or refuses to open the file. The fix is to bake every SVG to PNG first, swap the `<svg>` blocks for `<img>` tags, then run pandoc. + +### Install + +```bash +# macOS +brew install pandoc librsvg + +# Linux +apt install pandoc librsvg2-bin +``` + +### Workflow + +```bash +# 1. Extract every SVG to its own file, ensuring xmlns is set +python3 - << 'EOF' +import re +src = open('input.html').read() +for i, m in enumerate(re.finditer(r'<svg[^>]*>.*?</svg>', src, re.DOTALL)): + svg = m.group(0) + if 'xmlns=' not in svg[:200]: + svg = svg.replace('<svg ', '<svg xmlns="http://www.w3.org/2000/svg" ', 1) + with open(f'/tmp/diagram-{i}.svg', 'w') as f: + f.write('<?xml version="1.0"?>\n' + svg) +EOF + +# 2. Rasterize each SVG to a wide PNG +for f in /tmp/diagram-*.svg; do + rsvg-convert "$f" -o "${f%.svg}.png" -w 1600 +done + +# 3. Replace each <svg>...</svg> in the HTML with <img src="/tmp/diagram-N.png"> +# (do this with the same regex iteration that produced the indices above) + +# 4. Convert +pandoc input-with-png.html -o output.docx +``` + +### What carries over + +| Carries over | Lost | +|---|---| +| Headings, body, lists, tables | Grid layouts, custom positioning | +| Brand color on headings, `<strong>`, `.hl` | Subtle borders and shadows | +| PNG figures at full width | SVG editability | +| Page breaks via `<hr class="page-break">` | `@page` margins | + +The recipient gets a Word document they can edit text in and replace figures in (drag a new PNG over the existing one). Complex layout differences are expected; the goal is editability, not visual fidelity. + +### When not to ship a DOCX + +Skip this for resume, one-pager, slides, and portfolio. They are visual artifacts; converting them produces a degraded version with no editability gain. Long-doc / proposal / equity-report are the document types where a DOCX companion has a real audience. diff --git a/references/resume-writing.md b/references/resume-writing.md new file mode 100644 index 0000000..49f2461 --- /dev/null +++ b/references/resume-writing.md @@ -0,0 +1,268 @@ +# Resume Writing Guide + +Content strategy for kami resume templates (CN and EN). Covers bullet structure, emphasis density, timeline narrative, and open source entries. For general writing quality bars, see `references/writing.md`. + +--- + +## Three-part bullet: Role / Actions / Impact + +Each project row uses a fixed three-part structure. The word and character targets below are maximums, not targets; shorter is better when the meaning is complete. + +**Chinese (CN)** + +| Row | Label | Target | Hard limit | +|---|---|---|---| +| 1 | 角色 | 50 characters | 60 characters | +| 2 | 动作 | 70 characters | 80 characters | +| 3 | 结果 | 90 characters | 100 characters | + +**English (EN)** + +| Row | Label | Target | Hard limit | +|---|---|---|---| +| 1 | Role | 35 words | 40 words | +| 2 | Actions | 45 words | 55 words | +| 3 | Impact | 55 words | 65 words | + +**What goes in each row** + +- Role: what the project was, why it existed, and your position in it. No verbs yet. +- Actions: the decisions and techniques you applied. One concrete approach per sentence. +- Impact: quantified outcomes only. If no number exists, write the magnitude or scope (team size, user count, traffic tier). + +**Self-check**: read Impact aloud. If it sounds like a process description ("improved the pipeline") rather than a result ("reduced p95 latency from 800ms to 120ms"), rewrite. + +--- + +## Source and truth pass + +Run this pass before rewriting when the user provides more than one source, such as an old resume plus a self-review, annual review, promotion packet, or project notes. + +1. Extract every claim, number, project name, role label, and result from each source. +2. Use the richer source for its best material, not as a replacement for the other source. Annual reviews often carry the strongest metrics; old resumes often carry the clearest project narrative. +3. If two sources conflict on scope, owner, unit, or number, ask the user. Do not silently choose the larger or more impressive claim. +4. Drop numbers whose unit or measurement basis is unclear. A precise, traceable number beats a bigger rounded estimate. + +Use precise before/after values when available: `S1 68% / S2 93%` reads more credible than "about 70%". If a number looks like a typo or a unit mismatch, leave it out or mark it as a question. + +--- + +## Personal information hygiene + +Age and gender are optional resume metadata, not evidence. In CN / KO recruiting contexts, include them only when the user supplied them and the target channel expects them. Place them in the contact line after location; never promote them into metric cards, summary, or project copy. + +Do not invent missing personal details. Do not include expected salary in the resume body. Salary expectations, availability, and negotiation constraints belong in the recruiter message or a separate candidate note unless the user explicitly asks for a form-style resume. + +Use role positioning instead of old-style job intention: `AI / Agent 工程` is useful; `求职意向:前端工程师` usually wastes space. + +--- + +## Ownership and title calibration + +Role words carry different promises. Pick the lowest truthful word that still reflects the contribution. + +| Role word | Boundary | Use when | +|---|---|---| +| 方向负责人 / owner / lead | Defined the direction, owned the architecture or outcome, and was the clear accountable person | You can defend the strategy, tradeoffs, and final result | +| 负责 / drove / led | Independently carried a line or module to production | You owned delivery, but not necessarily the whole direction | +| 牵头 / coordinated | Pulled multiple people or systems together around a bounded goal | The value was orchestration and delivery pressure | +| 模块负责人 / module owner | Owned a specific module, industry slice, or workflow | The larger business line had other owners | +| 共建 / contributed / core contributor | Delivered a distinct piece inside a shared project | Another person or team owns the larger project | +| 参与 / implemented | Solid execution without ownership | The result is real, but scope should stay modest | + +Do not let every project say "主导". A strong resume usually mixes owner-level projects with narrower but concrete contributions. This reads more credible than inflating every line. + +--- + +## Team resume mode + +When optimizing multiple resumes from the same team, run a project ownership pass before polishing any single resume. + +1. List each person's project names, aliases, role labels, and headline metrics. +2. Mark shared projects, near-duplicate names, and reused metrics. +3. Ask the user to confirm the unique owner for any project using owner-level language. +4. For non-owners, downgrade the role word and switch to the person's distinct contribution or metric dimension. + +The goal is not to hide collaboration. The goal is to avoid five resumes all claiming the same project as personally owned. Shared work is credible when each person's scope is different and defensible. + +--- + +## Metrics: horizontal vs vertical layout + +Each project card has a `.metrics` row that shows key numbers. Two layout modes are available. + +**Horizontal (default, `flex-direction: row`)**: numbers side by side, good for 2-3 short metrics. + +**Vertical (`flex-direction: column`)**: metrics stack one per line. Use when: +- Any single metric label exceeds 18 characters +- Three or more metrics are present and labels are of unequal length +- The project card already has long Role / Actions / Impact rows that crowd the line + +Use | Avoid +---|--- +Horizontal for two short metrics: `38% reduce latency · 12k daily users` | Horizontal for long labels: `reduced p95 latency from 820ms to 110ms · 12k active users` +Vertical when three or more metrics exist | Vertical for exactly two short metrics (wastes space) + +To switch to vertical: +```css +.metrics { flex-direction: column; gap: 0.8mm; } +``` + +Do not add `flex-wrap: wrap`; it causes uneven line splits that look like layout errors. + +--- + +## Key-number highlight density + +`.hl` applies brand-blue color to mark the single most important figure or phrase in a line. It is not a general emphasis tool. + +**Use:** +- One `.hl` per project row, maximum two across all three rows of one project +- Numbers with units: `<span class="hl">120ms</span>`, `<span class="hl">38%</span>` +- A distinctive noun when no number exists: `<span class="hl">production-first rollout</span>` + +**Avoid:** +- Highlighting adjectives or qualifiers: "significantly", "dramatically", "key" +- Two `.hl` spans on the same row +- Highlighting an entire clause + +Healthy ratio: one emphasis per 80 to 150 characters of body text. + +--- + +## Timeline: three-step evolution arc + +The `.timeline` section shows career judgment, not a job chronology. Three steps, each one sentence. + +**Structuring the arc** + +Each step should answer: what shifted in how you thought or operated, and why does it matter for the reader? + +Use | Avoid +---|--- +"Shifted from feature delivery to owning the model quality loop end-to-end" | "Joined the AI team as senior engineer" +"Moved budget approval to the team level, cutting decision lag from weeks to hours" | "Led a team of 8 engineers" +"Started shipping open-source tools to validate ideas before internal roadmap commits" | "Side projects and open source work" + +**Three-step pattern** + +1. Foundation: the constraint or context that shaped your thinking in the early phase +2. Inflection: the moment or decision that changed what you were optimizing for +3. Present: the operating mode you've settled into and what it produces + +Do not list events. List shifts in judgment and scope. + +--- + +## Open source entries + +Each `.os-item` has three parts: name, one-line description, and star count. No README summaries, no feature lists. + +**Description formula**: `[language or stack] · [what it does] · [platform or audience]` + +Use | Avoid +---|--- +"Rust + Tauri · turns any URL into a lightweight desktop app · macOS / Windows / Linux" | "A lightweight desktop app builder based on Tauri that supports packaging web apps as native applications across multiple platforms" +"Swift · native Markdown notes · macOS AppKit" | "A beautiful and fast Markdown note-taking app for macOS with syntax highlighting and live preview" + +**Star count**: use the `.big` class on the top two entries (your flagship projects). Plain `.os-star` for the rest. + +**`.os-highlight`**: one short paragraph, one anecdote. Focus on a specific external validation moment: a notable person who shared it, a community that formed around it, a moment when it reached an audience you didn't target. Not a feature summary. + +--- + +## Density tuning by project count + +When page 1 carries 3-6 projects, adjust these three CSS properties. Do not change line-heights on page 2 or touch header typography. + +| Projects on page 1 | `body font-size` | `.proj-text line-height` | `.section-title margin-top` | +|---|---|---|---| +| 3 | 9.4pt | 1.42 | 6mm | +| 4 (default) | 9.2pt | 1.40 | 5mm | +| 5 (dense) | 9pt | 1.38 (CN) / 1.40 (EN) | 3.5mm | +| 6 | 9pt | 1.36 (CN) / 1.38 (EN) | 3mm | + +For 5-project layouts, add `class="resume--dense"` to `<body>` instead of manually adjusting CSS. The `resume--dense` class applies the row-5 values above. + +For 6 projects there is no pre-built variant; apply the row-6 values directly and run `--verify` after. Six projects on one page is a strong signal the project list needs editing, not just tightening. + +--- + +## Two-page balance + +Resume output has a stricter contract than general multi-page documents: + +- Exactly 2 pages. +- Each page fill should land around 83-95%. +- The two pages should differ by less than 12 percentage points. + +Verify filled resume PDFs with: + +```bash +python3 scripts/build.py --check-resume-balance path/to/resume.pdf +``` + +If page 2 is too empty, fix content before touching typography: + +1. Split one mixed project only when it honestly contains two different systems or result dimensions. +2. Add one real skills row only when the source material already supports it. +3. Cut filler and repeated metrics before shrinking fonts. + +Do not fill space by padding, duplicating a number, or splitting one sentence into two lines. A sparse second page is acceptable only when the source material is genuinely thin and the user prefers restraint over padding. + +--- + +## Visual rhythm for resume templates + +Resume uses a quieter divider pattern than long-form templates because it has more repeated headings in two pages. + +- Header and section titles use a single warm bottom rule, not a brand-color left bar. +- Project rows have no top or bottom border; separate them with padding and project-name weight. +- Top metrics stack the value over the label inside each of the four columns. Metric labels stay on one line; if a label wraps, shorten it before changing the layout. +- In English resumes, include the unit inside the value (`8 yrs`, `120ms`, `$280K`) rather than as a separate placeholder. +- Highlight boxes such as open-source validation use tint and radius only, without a vertical brand rail. + +This keeps the page editorial and avoids double rules when a project sits directly below a section title or lands at the top of page 2. + +--- + +## Page 2 rhythm + +Page 2 has more space than page 1. Do not compress it to match page 1 density. + +- OS intro: one sentence positioning + one sentence with the aggregate GitHub numbers. No more. +- Convictions: each card is one judgment call + one piece of downstream evidence. Not a project summary. +- Skills: each row names one capability area and demonstrates it with one concrete example. No abstract claims. +- Education: one line. If there is a judgment-flavored note (declined grad school, switched majors), include it. That note signals self-direction better than GPA. + +**Font size and spacing reference** (5 projects on page 1 + complete page 2): + +| Property | Value | +|---|---| +| `body font-size` | 9pt (dense) / 9.2pt (default) | +| `.os-intro` line-height | 1.55 | +| `.conv-body` line-height | 1.40 | +| Page 2 top buffer | 4mm minimum between header and first section | + +This configuration fits 2 pages when TsangerJinKai02 is available. Font fallback to Source Han Serif adds roughly 0.3pt per line; run `--verify` after any font environment change. + +Do not scale page 2 font below 9pt to save space. If page 2 still overflows, cut one Convictions card or reduce Skills to 2 rows. + +--- + +## AI engineering resumes + +AI-facing resumes should show that the candidate treats AI as an engineered system, not a magic tool or a list of model names. + +Use: +- Mechanisms that turn generation into delivery: evaluation gates, feedback loops, harnesses, regression checks, context boundaries, rollback paths. +- Clear human / AI division of labor: humans define scope, tradeoffs, and risk; AI may implement, repair, classify, or generate under constraints. +- Metrics tied to the mechanism: pass rate, failure rate, iteration cycle, case coverage, retrieval precision, hallucination reduction, throughput, review load. +- Evolution in operating model: prompt craft -> context engineering -> tool orchestration -> automated validation. + +Avoid: +- Listing model or tool names as a skill by itself. +- Writing "AI improved efficiency by 50%" without saying what baseline, sample, or workflow was measured. +- Packaging an API demo as an agent platform. +- Claiming a team or platform result as an individual result. +- Using filler such as `赋能`, `抓手`, `组合拳`, or `规模化` without a concrete mechanism and metric. diff --git a/references/tokens.json b/references/tokens.json new file mode 100644 index 0000000..38c0c68 --- /dev/null +++ b/references/tokens.json @@ -0,0 +1,16 @@ +{ + "--parchment": "#f5f4ed", + "--ivory": "#faf9f5", + "--border": "#e8e6dc", + "--border-soft": "#e5e3d8", + "--brand": "#1B365D", + "--brand-tint": "#EEF2F7", + "--tag-bg": "#E4ECF5", + "--near-black": "#141413", + "--dark-warm": "#3d3d3a", + "--charcoal": "#4d4c48", + "--olive": "#504e49", + "--stone": "#6b6a64", + "--breaking-bg": "#f0e0d8", + "--breaking-fg": "#8b4513" +} diff --git a/references/writing.md b/references/writing.md new file mode 100644 index 0000000..1174ae5 --- /dev/null +++ b/references/writing.md @@ -0,0 +1,525 @@ +# Content Strategy + +How to write, not how to lay out. Good typography with bad content is just "polished mediocrity". This document covers the writing principles for both Chinese and English output. Shared rules come first; language-specific details are called out where they matter. + +--- + +## Core principles (all documents) + +### 1. Data over adjectives + +- Avoid: "Delivered significant business growth" +- Use: write the specific numbers and deltas + +Every sentence should survive the follow-up question "how much, specifically?". If you can't answer, don't write it. + +### 2. Judgment over execution + +Junior writes "what they did". Mid writes "how they did it". **Senior writes "why they made that call, and what they predicted correctly"**. + +- Avoid: "Led the platform build-out" +- Use: write what judgment you made and how it was proven right + +### 3. Distinctive phrasing over industry clichés + +- Avoid: "Embrace the AI era, pioneer digital transformation paradigms" +- Use: say it in your own words, skip the industry vocabulary + +**Distinctive phrasing is memorable**. A line you invented beats a line borrowed from an earnings call. It sounds like a person thinking, not a deck regurgitating. + +### 4. Honest boundaries + +- If you didn't do it, don't claim it +- If you don't know the exact number, don't invent one. Write a vague but honest magnitude +- Attribute collaborators + +### 5. Sources before phrasing + +For companies, products, people, launch dates, versions, funding, financials, market data, or technical specs, verify the source before writing. Priority: user-provided source material > official pages / docs / press releases > filings / app stores / repo releases > credible media. + +- Do not write "latest", "new", version numbers, or market figures before checking them +- If sources conflict, list the conflict and ask the user instead of choosing one +- If only the magnitude is known, write the magnitude instead of false precision + +### 6. Materials serve recognition + +Branded documents should first make the subject recognizable, then use decoration and atmosphere with restraint. + +- Company / product / project docs should confirm logo, product image, UI screenshot, and brand color before layout +- If a key material is missing, mark the gap or ask the user. Do not fill the page with unrelated imagery +- Physical products prefer official product images; digital products prefer real UI screenshots +- If brand color is unknown, keep kami ink-blue rather than inventing a new color +- **Third-party figures**: when redrawing a paper figure, patent illustration, or official architecture diagram for visual consistency, mark the redraw as `Schematic redrawn` /「示意重绘」in the caption. Do not style a redrawn version to look like the original screenshot. If the figure carries primary evidentiary value (patent, official spec), embed the original with attribution rather than redrawing it + +### 7. Term annotation half-life + +术语首次出现时注解。注解在 8-10 页或 10 张 slide 后过期。超出半衰期再次使用时,用更短的提示重标,不要假设读者还记得。Cap、卡片副标题、章节摘要这类快速阅读位置尤其严格。 + +- 首次: "LTV (Lifetime Value, 用户在流失前贡献的总收入)" +- 超过半衰期后重现: "LTV (用户终身价值)" +- 半衰期内不要重标,读起来像在解释已知概念 +- 适用于 slides、long-doc、equity report 等超过 8 页的文档。Resume 和 one-pager 太短,不触发 + +### 8. English term density in CJK text + +中日文正文里,单句未注解英文术语 ≤ 1 个。超过就拆句或加 inline 注解。注解优先用动作词 (warmup → 升起来, rollout → 跑一遍生成, credit assignment → 把功劳分到具体哪一步),不用概念词 (预热, 轨迹生成, 信用分配)。动作词描述发生了什么,概念词只是换个外壳。 + +--- + +## Per-document strategies + +### One-Pager + +**Single purpose**: the reader grasps the point in 30 seconds. + +**Structure**: +1. **Headline** (serif display) + one-line subtitle (sans body) +2. **Metrics** - 3-4 cards, numbers first +3. **Core argument** (1-2 paragraphs) +4. **Key evidence / roadmap** (3-5 short bullets) +5. **Next step / contact** (footer) + +**Rules**: +- Length target: English 200-350 words; Chinese 400-600 characters +- All section headlines should work as a standalone outline - reading just the headlines should deliver the gist +- Data must fill 30%+ of the body +- Company / product one-pagers must confirm logo, core screenshot or product image, and source for key metrics +- No opening ceremony ("In recent years, as technology has rapidly evolved...") + +### Long Document + +**Structure**: +1. **Cover** - big title + subtitle + author + date +2. **Contents** (auto-generated or hand-written TOC) +3. **Executive Summary** (≤ 1 page + 3-5 takeaways) +4. **Body** - chapters that each stand alone as an essay +5. **Appendix / references** (if applicable) + +**Rules**: +- Every chapter opens with a "claim paragraph" (2-3 sentences summarizing the argument) +- After long paragraphs (>5 lines), intersperse callouts / quotes / figures to relieve eye fatigue +- Highlight key data / conclusions with `<span class="hl">` +- Chapters with external facts must preserve source cues so readers can distinguish fact, judgment, and inference +- Use "chapter breaks" (blank page + chapter number) between major sections + +### Letter + +**Structure**: +1. Letterhead (sender info, top right or centered) +2. Date (right-aligned) +3. Recipient salutation (left-aligned) +4. Body (3-5 paragraphs) +5. Sign-off ("Sincerely," / "Best regards,") +6. Signature (serif 500) +7. Enclosures (if any) + +**Rules**: +- Minimal - no decorative elements +- Body prefers serif (editorial feel) +- Slightly larger type (11-12pt body) - this will be read, not scanned +- Paragraph spacing ≥ 10pt + +**Common use cases**: +- Resignation / notice +- Recommendation letter +- Formal collaboration proposal +- Personal statement + +**Language notes**: +- Chinese sign-offs can use "此致 / 敬礼", "顺颂商祺", or a context-appropriate formal closing +- English sign-offs should stay simple: "Best regards," / "Sincerely," / "Warm regards," + +### Portfolio + +**Structure**: +1. **Cover** (name + one-line positioning + contact) +2. **About** (half-page introduction) +3. **Per-project 1-2 pages**: + - Project title + type tag + date range + - One-line description + - 2-3 hero images (if applicable) + - Role + challenge + outcome +4. **Selected works list** (additional projects as a short list) +5. **Contact** (return to contact details) + +**Rules**: +- Visuals first, text supports +- Every project's outcome must be quantifiable +- Final product screenshots / real photos > design mockups > code screenshots +- If project images are missing, mark the gap. Do not fill the layout with unrelated imagery +- Don't list every tech stack - a mono tag row is enough + +### Proposal (long-doc variant) + +A proposal is a long-doc whose body argues for a specific commercial engagement. Three voice rules are unique to this variant: + +#### 1. Work-volume frame vs attention frame + +A pricing page can be written two ways. The work-volume frame lists deliverables, hours, or session counts; readers price-anchor against market hourly rates. The attention frame describes a senior advisor's judgment allocated across a few directions; readers price-anchor against the value of the directions. Pick one frame and stay consistent. + +| Dimension | Work-volume frame | Attention frame | +|---|---|---| +| What is sold | Execution of N items | Allocation of judgment | +| Pricing basis | Hours / deliverables | Total engagement | +| Workload density | Fixed commitment | Flexes with the buyer's cadence | +| Reader posture | Procurement | Hiring an advisor | +| Signals | "I can do these tasks" | "I bet on these directions" | +| Fits | Outsourced delivery | Senior advisory work | + +The work-volume frame backfires for advisor pricing: the more itemized the breakdown, the more the reader divides total fee by hours and decides "this is a vendor, can it cut corners?" Avoid these patterns when writing in the attention frame: + +- Rigid monthly volume: `每月 N 份 / 节 / 篇`. Replace with "by milestone" or "as the cadence requires". +- Exhaustive coverage: `每个发布节点全程参与`. Replace with "deep involvement at major milestones". +- Specific session counts: `20 节课程`. Replace with "depth of content" anchors and let scheduling adjust. +- "Full-X" totalizers: `全方位 / 全链路 / 全程`. Replace with "key X" or "core X". + +#### 2. With-price vs without-price modes + +Whether to print the number depends on the reader. Use these defaults and switch by audience: + +| Audience | Mode | Reasoning | +|---|---|---| +| Operating founder, direct buyer | **With price** | Direct decision-makers want the number to evaluate fit | +| Intermediary, business contact | **Without price** | Leave room for the intermediary's own commercial layer | +| Investor, board reader | **Without price** | Strategy first, money second; a number breaks the register | + +The without-price variant drops the large-figure hero and keeps the value anchors. Rename the chapter from "合作方案与投入" (Plan & investment) to "合作方向与价值" (Directions & value) so the title matches the content. + +#### 3. Kickoff-period emphasis pattern + +Twelve-month timelines fail when every month is described at equal weight: the reader cannot tell what is urgent. Surface the first 60 days as a separate beat ahead of the timeline table: + +- One paragraph on time scarcity ("this window closes; recovering it later costs several times more"). +- One paragraph per month: bold the date, name the module, list 2-3 concrete actions, state the outcome. +- Then the full month-by-month table, which reads as detail rather than the story. + +The pattern is "story before grid": the reader leaves the timeline section knowing one thing (the kickoff is dense), not twelve things (every month). + +### Resume + +The most constrained document type in kami. + +**Hard constraints**: +- Strictly 2 A4 pages +- Every project follows three-part: Role / Actions / Impact +- 5 core skills, each with at least one brand-color emphasis +- Team size, tech stack, narrative voice must stay consistent throughout + +**Key sections**: +- Header + 4 metric cards +- Summary (~50 English words or ~80 Chinese characters) +- Timeline (3 steps - long-range evolution signal) +- 3-5 core projects +- Public work / impact (optional) +- 5 core skills +- Education + +**Metric card selection rule**: +- 1 card on **time** (years, consistency) +- 1 card on **scale** (team, users, projects, or other quantifiable scope) +- 2 cards on **results** (quantifiable external proof) + +--- + +## Quality bars by document type + +Structure is necessary but not sufficient. These bars define what separates compelling content from template-filling. + +### Resume + +**Impact formula**: Action + Scope + Measurable Result + Business Outcome. Every bullet must answer "what did I do, at what scale, with what result, and why did it matter?" + +| Avoid | Use | +|---|---| +| "Worked on backend services" | "Redesigned order pipeline serving 2M daily txns, cut p99 latency from 800ms to 120ms, saved $340K/yr in infra costs" | +| "Led a team to deliver features" | "Led 5-engineer squad that shipped real-time collaboration (3-month timeline), adopted by 40% of enterprise accounts within one quarter" | +| "Improved performance" | "Reduced cold-start time 62% across 14 Lambda functions by replacing runtime init with pre-baked layers, cutting median API response from 1.2s to 0.45s" | + +**Rules**: +1. Start every bullet with a strong past-tense verb (designed, led, reduced, migrated). Never "Responsible for" or "Helped with" +2. Every bullet needs at least one number. If no hard metric exists, use scope (team size, user count, codebase size) +3. Connect technical work to business outcomes: revenue, cost, reliability, user retention, time-to-market +4. Include before/after pairs when possible: "from X to Y" is more credible than "improved by Z%" +5. Use precise numbers over round ones: "$280K" reads as measured, "$300K" reads as estimated +6. Distinguish ownership: "owned" vs "contributed to" vs "coordinated". Inflating scope is the fastest way to lose credibility in an interview + +**Senior vs junior**: junior resumes show execution ("built X"). Senior resumes show judgment ("evaluated 3 approaches, chose Y because of tradeoff Z") and multiplier effect ("mentored 4 engineers, 2 promoted within 12 months") + +### Portfolio + +**Core rule**: open every case study with the problem and its stakes, not with your role or the project name. + +**Density bar**: each project page reads as a complete case study. At target font size, a body page that renders under half-full is a draft defect, not a design choice. Merge upward into the previous project or downward into the next; do not pad with filler prose. See SKILL.md Step 4.1 for the items-per-page contract. + +| Avoid | Use | +|---|---| +| "I redesigned the dashboard" | "Enterprise users abandoned the analytics dashboard at 73% rate within the first session. I led the redesign that cut abandonment to 31%." | +| "The client was happy" | "Task completion time dropped from 4.2 min to 1.8 min. NPS increased from 22 to 51 over 3 months." | + +**Rules**: +1. Show 2-3 decision points where you chose between alternatives. Explain the tradeoff, not just the winner +2. Three-layer outcomes: quantitative metric (conversion rate +80%) + qualitative evidence (user quote) + business context ($1.2M additional annual revenue) +3. State your exact role and scope: "I designed" vs "I led" vs "I contributed to" are very different signals +4. 3-5 deep case studies beats 12 shallow ones. Depth is credibility +5. Always close the loop: every problem introduced must have a measured resolution +6. Prefer final product screenshots over mockups. If product images are missing, mark the gap rather than filling with unrelated imagery + +### Slides + +**Core rule**: every slide title should be a full declarative sentence (an assertion), not a topic label. The body provides one piece of evidence supporting the assertion. + +**Density bar**: each body slide carries one assertion + 3-5 supporting items (or 1 chart + 2-3 callouts). Slides with fewer than 3 items and no chart must merge into an adjacent slide. Pinned `.co` callouts at bottom are intentional; bare trailing whitespace on a slide is a draft defect. See SKILL.md Step 4.1. + +| Avoid | Use | +|---|---| +| Title: "Q3 Performance" | Title: "Q3 revenue grew 23% because enterprise deals closed 2x faster" | +| 7 bullet fragments per slide | One chart proving the assertion | +| "Key Takeaways" slide with 8 points | One clear ask or recommendation | + +**Rules**: +1. 20-40 words per slide maximum. If a slide has more than 40 words, split it or convert text to a visual +2. 5 items per list maximum (working memory capacity) +3. Three-act structure: Setup (slides 1-4, establish stakes) -> Evidence (slides 5-12, build the case) -> Resolution (slides 13-16, deliver the payoff) +4. Reading just the slide titles in sequence should tell the full argument +5. Include a "so what" moment every 3-4 slides to re-anchor the audience +6. End with one clear ask, not a bullet list of "key points" + +**Eyebrow vs title non-duplication**: the eyebrow is a stable, cross-slide section label ("Growth / Q3 Results"). The title is a page-unique declarative claim ("Revenue grew 23% because enterprise deals closed 2x faster"). They must never say the same thing in different words. If removing the eyebrow would make the title ambiguous, the title is too weak. If reading the title makes the eyebrow redundant, the eyebrow is a topic label masquerading as context. + +**Deck rhythm (>=12 slides)**: before writing any slide, sketch a layout-type sequence. Rules: every 4-6 slides must include a `chapter_slide` (ink-blue full-bleed divider); never run more than 5 consecutive `content_slides` without a divider; the deck must include at least one `quote_slide` or `metrics_slide` to vary density. Monotony is a structure failure, not a content one. + +**Term consistency self-check**: after drafting, list every domain term that appears 3 or more times (product names, feature names, roles, metrics). Confirm there is exactly one spelling and capitalization for each. Inconsistent casing ("LLM" vs "llm" vs "large language model") signals an unreviewed draft. + +**Caption quality bar**: every cap must answer "why does this slide matter": give a tradeoff, an applicability boundary, a next step, or the insight the diagram alone cannot say. Two failure modes both waste the cap's attention slot: restating the slide title in different words (anti-pattern #29), or restating the flow diagram in prose (anti-pattern #26). If removing the cap would make the slide weaker, it is doing its job; if removing it changes nothing, rewrite it. + +**Term annotation half-life**: decks 超过 10 张时,跨越 10-slide 窗口再出现的术语需重标。见 core principle #7。 + +### Equity Report + +**Core rule**: lead with the variant perception (what you see that the market doesn't) and tie every thesis driver to a measurable financial impact. + +**Density bar**: a body page with only a 2-row table and a sentence is too thin. Each page should carry one section + one table/chart + supporting prose. Combine sections rather than leaving a page half-empty. See SKILL.md Step 4.1. + +| Avoid | Use | +|---|---| +| "Strong management team" | "Management delivered 23% revenue CAGR over 5 years while keeping debt-to-equity below 0.4" | +| "Massive opportunity" | "We estimate 25% upside to $X based on DCF with 12% WACC and 3% terminal growth" | +| Vague "risks include competition" | "BYD's 35% unit cost advantage in the $20-30K segment threatens 15% of addressable volume by 2027" | + +**Rules**: +1. Investment thesis on page 1, above the fold. Rating + price target (if applicable) + 3-5 bullet thesis drivers +2. Every claim backed by a number or a source. No unquantified superlatives +3. At least two valuation methods with sensitivity ranges. Single-method valuation is amateur +4. Catalysts must have dates and expected magnitude: "Robotaxi launch in Dallas, June 2025, adding estimated $X to revenue run-rate by Q4" +5. Competitive positioning with market share numbers, not narrative: "23% share of the $45B market, up from 18% in 2022" +6. Risk factors quantified and connected to the financial model, not generic disclaimers +7. Professional tone: "we estimate" / "our base case" / "we see upside to". Never "this stock will moon" or "buy the dip" +8. Acknowledge counter-arguments before dismissing them. One-sided analysis signals bias, not conviction +9. Separate GAAP from non-GAAP clearly. Flag one-time items (warranty reserves, tax benefits, restructuring charges) + +### Long Document + +**Core rule**: each chapter's claim paragraph must survive the "so what?" test. If the reader asks "why should I care?", the first paragraph must have the answer. + +**Density bar**: each body page carries 1 chapter heading + 2-4 paragraphs + at most 1 figure. A chapter that fits in under 40% of a page must merge into the next chapter rather than claiming its own page. Trailing whitespace at the bottom of a body page is a draft defect. See SKILL.md Step 4.1. + +After converting from Markdown, remove or convert thematic breaks, bold markers, +and inline-code backticks before delivery, then run `python3 scripts/build.py +--check-markdown path/to/filled.pdf` on the rendered PDF. + +**Rules**: +1. Evidence density: at least one data point per paragraph. A paragraph with zero numbers is an opinion paragraph and should be rare +2. Callout or figure after every 3-4 paragraphs of dense text. Long unbroken prose causes eye fatigue in print +3. Counter-arguments addressed before they become reader objections. If you can predict the pushback, address it proactively +4. Source cues preserved inline: "(Gartner, 2025)" or "according to the company's 10-K" so readers can distinguish fact from inference +5. Each chapter should stand alone as a mini-essay with its own arc: claim -> evidence -> conclusion + +### One-Pager + +**Core rule**: the reader grasps the point in 30 seconds. Every element that doesn't serve 30-second comprehension is bloat. + +**Rules**: +1. Metrics are the headline, not supporting evidence. If your 4 metric cards don't tell the story, the metrics are wrong +2. The lead paragraph must contain the single sharpest claim, not context-setting +3. Bullet points should be evidence, not restated arguments. Each bullet: fact + number + "so what" +4. Footer is for contact and classification, not for squeezing in one more argument + +### Letter + +**Core rule**: first paragraph states purpose in one sentence. Last paragraph states the specific ask or next step. Everything in between is evidence. + +**Rules**: +1. One point per middle paragraph, each with its own evidence +2. Tone calibration per use case: resignation (grateful + clear), recommendation (specific + enthusiastic), proposal (value-first + concrete), personal statement (authentic + structured) +3. Sign-off matches formality: "Sincerely" for formal, "Best regards" for professional-warm, "Warm regards" for personal +4. Under no circumstances exceed one page. If you need two pages, it's a memo or a proposal, not a letter + +### Changelog + +**Core rule**: one sentence per change, verb-led, user-facing language. If the user cannot understand the change from the sentence alone, rewrite it. + +**Density bar**: each version block carries 4-8 entries. A version with fewer than 4 entries should sit on the same page as the prior version rather than triggering a near-empty page. See SKILL.md Step 4.1. + +| Avoid | Use | +|---|---| +| "Refactored internal state management module" | "Fix crash when switching tabs rapidly on iPad" | +| "Updated dependencies" | "Upgrade OpenSSL to 3.2.1 (patches CVE-2026-1234)" | + +**Rules**: +1. Breaking changes always first, with migration path ("Replace `config.old` with `config.new`; run `migrate.sh` to convert") +2. 5-8 items per section. If more, this is probably 2 releases +3. Group by user impact (Breaking / Features / Fixes), not by component or file +4. No internal jargon. "Fix memory leak in image decoder" is clear. "Fix retain cycle in UIImageDecoderBridge" is not + +--- + +## Diagrams and infographics + +Words inside a diagram or infographic (title, eyebrow, node label, summary line) follow tighter rules than body prose. Readers process them in seconds, so rhetorical flourish reads as noise. This applies whether the figure is a Kami SVG embedded in a long-doc or an external image rendered elsewhere. + +### Avoid colloquial slogan-words + +| Avoid | Why it fails | +|---|---| +| 白搭 / 立住 / 才顺 / 回炉 / 闸 | Slang verbs; rewrite as a literal claim | +| 必看 / 一图看懂 / 彻底搞懂 | Bait phrasing; state the figure's actual content | +| 爆款 / 神器 | Marketing tone in an engineering figure | +| 飞轮 / 闭环 (unless audience is fluent) | Use 数据循环 / 持续改进 / 四类入口 | + +### Avoid product-specific judgments that date fast + +Pinning a category to a current product name ages the figure within a quarter, because tools evolve faster than diagrams ship. Frame the paradigm and let the reader map products themselves: prefer 「上一代工具范式 / 新一代执行范式」over 「Cursor 是副驾驶 / Claude Code 是自动驾驶」. + +### Slogan to neutral rewrites + +| Before (slogan) | After (neutral) | +|---|---| +| 没对完,不算完成 | 交付前,过三遍 | +| 任一不过则回炉 | 任一步不通过,回到修改 | +| 交付前最后一道闸 | 交付前最后检查 | +| 订阅前先把这些习惯立住 | 订阅前的基础检查 | + +The principle: a diagram caption is engineering documentation, not marketing copy. Restraint reads as competence; bravado reads as filler. + +--- + +## Coupling rules (layout × content) + +### Emphasis rhythm + +Across any document: +- ≤ 2 emphasized items per line +- Emphasis must be a **quantifiable number** or a **distinctive phrase** +- Do not emphasize adjectives + +### Number formatting + +| Use | Avoid | +|---|---| +| 5,000+ | 5000+ (missing thousands separator) | +| 5,000+ | 5,000+ (full-width comma in a metric) | +| 90% | 90 % (space before percent) | +| ~$10M | $9,876,543 (false precision reads fake) | +| 2026.04 | 2026年4月 / April 2026 (when horizontal space is tight) | +| -> | → | + +### Language-specific punctuation + +Chinese documents: +- Prefer `「」` for quoted prose, not straight double quotes +- Keep numbers, commas, percent signs, and dates half-width in metric-heavy areas +- Add spaces between Chinese text and Latin product names when it improves readability + +English documents: +- Use straight quotes in source text unless the document already has a typographic quote convention +- Prefer compact date forms (`2026.04`) in dense layouts and natural dates (`April 2026`) in prose + +### Emphasis is not bold + +Use `color: var(--brand)` alone - don't also add `font-weight: bold`. Bold breaks the single-weight design language. + +--- + +## Pre-ship checklist + +Run through before every draft: + +- [ ] Any jargon like "leverage / unlock / embrace / pioneer"? Cut. +- [ ] Any Chinese filler like "拥抱 / 打造 / 赋能 / 重构"? Rewrite in plain language. +- [ ] Does every paragraph's first sentence stand alone? If not, that paragraph has no claim. +- [ ] Are all numbers verifiable? If asked "where did this come from", can you answer? +- [ ] Are current facts, versions, launch dates, funding, financials, and specs backed by reliable sources? +- [ ] Does every branded document have logo, product image, or UI screenshot coverage? Are missing materials clearly marked? +- [ ] At least one **distinctive phrase** (not industry boilerplate)? +- [ ] Every emphasized (brand-colored) span is either a number or a distinctive phrase? If not, remove the emphasis. +- [ ] Paragraph lengths even? No paragraph over 5 lines? +- [ ] Number format consistent (commas, percent signs, arrows)? +- [ ] Chinese punctuation and Chinese / Latin spacing consistent where applicable? +- [ ] Page count within the document's constraint (resume 2, one-pager 1, letter 1)? +- [ ] Any AI writing cliches? CN: 本质是 / 这意味着 / 值得注意的是 / 不仅...而且 / 破折号堆砌。EN: em dashes, "It's worth noting", "This means that". See anti-patterns #27. +- [ ] Multi-page docs (>8 pages / >10 slides): domain terms re-annotated beyond the half-life window? See principle #7. + +--- + +## Landing Page + +A landing page is not a brochure. It is a conversion surface. Every element either builds trust or wastes attention. + +### Global: screen-only italic exception + +Invariant #10 bans italic in print templates. Landing pages are screen-only, so gallery captions, feature subtitles, and footer ethos may use the limited italic treatment defined in `references/design.md`. Do not add new italic uses beyond those roles. + +### Hero rules + +- **Positioning comes before feature count.** Name the real product category in the first viewport. If the product has grown beyond its old anchor, rewrite the category instead of adding more feature bullets under the old one. +- **Tagline is one sentence, not a paragraph.** If it needs a comma, it is too long. The user decides in 3 seconds whether to scroll. +- **Tokens (key facts) are scannable proof.** Price, platform, refund policy, compatibility. No adjectives. `$9 lifetime` beats `Affordable pricing for everyone`. +- **CTA pair: secondary (try) + primary (buy).** Ghost button for low-commitment action, filled button for revenue action. Never three buttons. + +### Gallery rules + +- **Show, don't describe.** Real screenshots replace feature paragraphs. Each panel is one shipped tool, one workflow, or one state users can actually reach. +- **Technical products can show the workflow itself.** A terminal transcript, command draft, or error recovery panel is a product screenshot when it shows the actual review/confirm boundary. +- **Poetic captions, not marketing copy.** The line under each screenshot should evoke, not explain. `Rainwater clears the soil` over `Efficiently clean your system caches`. +- **3-6 panels maximum.** More than 6 and the auto-rotate becomes noise. Users remember 4. + +### Features list rules + +- **Name is the tool, subtitle is the metaphor.** Feature name in brand color, subtitle in small muted text. +- **Description answers "so what?"** Not what it does, but why the user should care. One paragraph, 2-3 sentences. + +### Principles rules + +- **Title is the commitment, description is the proof.** "Nothing leaves your Mac" is the title. How you enforce it is the description. +- **4-6 principles.** More than 6 dilutes the message. If you have 8, two are redundant. + +### Pricing rules + +- **Price is the headline.** 112px, not buried in a paragraph. Users look for the number. +- **Compare honestly.** Name the competitors, show their subscription price with `<s>`, then your one-time price. No vague "other tools charge more". +- **Terms at the bottom.** Payment methods, refund policy, device limit. Factual, not promotional. + +### FAQ rules + +- **First question is the positioning question.** Before "is it free" or "how do I install", users want to know what category this product is in. Lead with the comparison: "How is this different from {{CLI_NAME}} / {{MAC_APP_NAME}} / {{COMMON_TOOL}}?" or "Who is this not for?". This single question removes the misframing that AI assistants and first-time visitors do most often. +- **Compare against the tool users already know.** Use named alternatives from the source material, not generic "other apps" language. +- **Lead with the question the user is actually thinking.** "Is it free?" before "What's the refund policy?". +- **Answers in 1-2 sentences.** A FAQ answer longer than 3 sentences belongs in the docs page, not here. +- **6-8 questions maximum.** Cover: positioning, free tier, comparison, permissions/privacy, data collection, purchase flow, licensing. +- **llms-full.txt mirrors the FAQ.** Whatever you answer here, restate in `landing-page-llms-full.txt.example` so AI assistants summarizing the product give the same answer the visitor reads on the page. Divergence between FAQ and llms-full.txt is the most common AI-misrecommendation source. + +### Footer rules + +- **Brand mark + closing ethos.** The footer is the last impression. A poetic closing line beats a copyright notice. +- **Links are navigation, not decoration.** Only link to pages that exist. Dead links destroy trust faster than missing links. + +--- + +## Writing references + +- **Paul Graham's essays** - short, direct, judgmental. The gold standard for essayistic writing. +- **Stripe Press books** - print-grade typography paired with deep content. Where to learn the craft of the single sentence. +- **Minto's Pyramid Principle** - conclusion first, evidence below. The shape of every one-pager and exec summary. +- **Ben Horowitz's blog** - how to write technical and business judgment in prose ordinary people can read. The template for long-doc voice. + +None are required, but reading any one of them will move the dial on both your writing and your judgment. diff --git a/robots.txt b/robots.txt new file mode 100644 index 0000000..78f82cc --- /dev/null +++ b/robots.txt @@ -0,0 +1,56 @@ +User-agent: * +Allow: / + +# Sitemap +Sitemap: https://kami.tw93.fun/sitemap.xml + +# Search Engines +User-agent: Googlebot +Allow: / + +User-agent: Bingbot +Allow: / + +User-agent: Baiduspider +Allow: / + +User-agent: Applebot +Allow: / + +# AI Training Crawlers +User-agent: GPTBot +Allow: / + +User-agent: ChatGPT-User +Allow: / + +User-agent: ClaudeBot +Allow: / + +User-agent: anthropic-ai +Allow: / + +User-agent: PerplexityBot +Allow: / + +User-agent: Google-Extended +Allow: / + +User-agent: CCBot +Allow: / + +# AI Search Bots (retrieval-only, not training) +User-agent: OAI-SearchBot +Allow: / + +User-agent: Claude-SearchBot +Allow: / + +User-agent: Claude-User +Allow: / + +User-agent: Perplexity-User +Allow: / + +User-agent: DuckAssistBot +Allow: / diff --git a/scripts/build.py b/scripts/build.py new file mode 100755 index 0000000..1851db9 --- /dev/null +++ b/scripts/build.py @@ -0,0 +1,395 @@ +#!/usr/bin/env python3 +"""kami build & check + +Thin CLI shell. Implementation lives in: + - lint.py (scan_file, check_all, check_cross_template_consistency) + - tokens.py (sync_check) + - site_facts.py (check_site_facts) + - verify.py (verify_target, verify_all, show_fonts, font checks) + - checks.py (check_placeholders, check_markdown_residue, check_orphans, check_density, check_resume_balance, check_rhythm) + +Usage: + python3 scripts/build.py # build all examples (HTML + diagrams + PPTX) + python3 scripts/build.py resume # build one template, print pages + fonts + python3 scripts/build.py landing-page # check one browser-only static template + python3 scripts/build.py --check # lint + token/theme + public-site fact checks + python3 scripts/build.py --check -v # verbose (show each scanned file) + python3 scripts/build.py --sync # check CSS token drift across templates + python3 scripts/build.py --verify # build all + page count + font checks + python3 scripts/build.py --verify resume-en # single target full verification + python3 scripts/build.py --check-placeholders path/to/doc.html + python3 scripts/build.py --check-markdown path/to/doc.pdf + python3 scripts/build.py --check-orphans # scan example PDFs for orphan text + python3 scripts/build.py --check-orphans path/to/doc.pdf + python3 scripts/build.py --check-density # warn on pages with >25% trailing whitespace + python3 scripts/build.py --check-density path/to/doc.pdf + python3 scripts/build.py --check-resume-balance path/to/resume.pdf + python3 scripts/build.py --check-rhythm # warn on monotonous slide sequences + python3 scripts/build.py --check-rhythm slides slides-en +""" +from __future__ import annotations + +import functools +import os +import subprocess +import sys +from pathlib import Path + +from highlight import highlight_code_blocks +from optional_deps import ( + MissingDepError, + require_pypdf_reader, + require_pypdf_writer, + require_weasyprint_html, +) +from shared import ( + DIAGRAMS, + EXAMPLES, + TEMPLATES, + build_targets, + diagram_targets, + screen_targets, +) + +# Implementation modules (also re-exported for tests / external callers). +from checks import ( # noqa: F401 re-exported for test_build.py + _BG_B, + _BG_G, + _BG_R, + _density_bucket, + _last_content_y, + _markdown_residue_issues, + _orphan_last_line, + _parse_slide_sequence, + _resume_balance_issues, + _rhythm_issues, + _scan_density, + check_density, + check_markdown_residue, + check_orphans, + check_placeholders, + check_resume_balance, + check_rhythm, +) +from lint import ( # noqa: F401 re-exported for test_build.py + _extract_root_vars, + _off_palette_findings, + _pair_names, + _root_token_findings, + check_all, + check_cross_template_consistency, + check_off_palette, + scan_file, +) +from site_facts import check_site_facts +from tokens import sync_check +from verify import ( + show_fonts, + verify_all, +) + +# name -> (source, max_pages). max_pages=0 means no hard check. +# Sourced from shared.HTML_TEMPLATES (single source of truth for targets). +HTML_TARGETS: dict[str, tuple[str, int]] = build_targets() +SCREEN_TARGETS: dict[str, str] = screen_targets() +PPTX_TARGETS: dict[str, str] = { + "slides": "slides.py", + "slides-en": "slides-en.py", +} + +# Diagram HTMLs live in a separate directory and have no page-count contract. +# Registry lives in shared.DIAGRAM_TEMPLATES (single home for all template lists). +DIAGRAM_TARGETS: dict[str, str] = diagram_targets() + + +# ------------------------- build helpers ------------------------- + +@functools.lru_cache(maxsize=1) +def infer_author() -> str: + """Infer author name from git config or environment. + + Priority: + 1. git config user.name + 2. KAMI_AUTHOR env var + 3. fallback to "Kami" + + Cached so a full build doesn't shell out for every PDF target. + """ + try: + result = subprocess.run( + ["git", "config", "user.name"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode == 0 and result.stdout.strip(): + return result.stdout.strip() + except FileNotFoundError: + pass + + if env_author := os.environ.get("KAMI_AUTHOR"): + return env_author + + return "Kami" + + +def set_pdf_metadata(pdf_path: Path, author: str | None = None) -> None: + """Set PDF metadata using pypdf, only if placeholders are still present.""" + try: + PdfReader = require_pypdf_reader() + PdfWriter = require_pypdf_writer() + except MissingDepError: + return + + if not pdf_path.exists(): + return + + reader = PdfReader(str(pdf_path)) + + existing = reader.metadata or {} + needs_update = False + metadata = dict(existing) + + if author and existing.get("/Author"): + author_value = str(existing["/Author"]) + if "{{" in author_value and "}}" in author_value: + metadata["/Author"] = author + needs_update = True + + if metadata.get("/Producer") != "Kami": + metadata["/Producer"] = "Kami" + needs_update = True + if metadata.get("/Creator") != "Kami": + metadata["/Creator"] = "Kami" + needs_update = True + + if not needs_update: + return + + writer = PdfWriter() + for page in reader.pages: + writer.add_page(page) + + writer.add_metadata(metadata) + + with open(pdf_path, "wb") as f: + writer.write(f) + + +# ------------------------- build ------------------------- + +def build_html(name: str, source: str, max_pages: int, + src_dir: Path = TEMPLATES) -> bool: + try: + HTML = require_weasyprint_html() + PdfReader = require_pypdf_reader() + except MissingDepError as exc: + print(f"ERROR: {exc}") + return False + + src = src_dir / source + if not src.exists(): + print(f"ERROR: {name}: source not found ({src})") + return False + + EXAMPLES.mkdir(parents=True, exist_ok=True) + out = EXAMPLES / f"{name}.pdf" + + html_text = src.read_text(encoding="utf-8") + html_text = highlight_code_blocks(html_text) + HTML(string=html_text, base_url=str(src.parent)).write_pdf(str(out)) + + set_pdf_metadata(out, author=infer_author()) + + n = len(PdfReader(str(out)).pages) + msg = f"OK: {name}: {n} pages" + if max_pages and n > max_pages: + msg = f"ERROR: {name}: {n} pages (limit {max_pages})" + print(msg) + return False + print(msg) + return True + + +def build_slides(name: str = "slides") -> bool: + source = PPTX_TARGETS.get(name) + if source is None: + print(f"ERROR: {name}: unknown slides target") + return False + src = TEMPLATES / source + if not src.exists(): + print(f"ERROR: {name}: source not found ({src})") + return False + + EXAMPLES.mkdir(parents=True, exist_ok=True) + out = EXAMPLES / f"{name}.pptx" + # Pass --out so the slides script writes directly to the target path. Older + # slides.py defaults to 'output.pptx' in cwd; new copies accept --out. + result = subprocess.run( + [sys.executable, str(src), "--out", str(out)], + cwd=str(src.parent), + capture_output=True, + text=True, + ) + if result.returncode != 0: + print(f"ERROR: {name}: {result.stderr.strip() or 'script failed'}") + return False + if out.exists(): + print(f"OK: {name}: generated {out.name}") + return True + print(f"ERROR: {name}: {out.name} not produced") + return False + + +def build_screen_template(name: str, source: str) -> bool: + src = TEMPLATES / source + if not src.exists(): + print(f"ERROR: {name}: source not found ({src})") + return False + + findings = scan_file(src) + if findings: + print(f"ERROR: {name}: {len(findings)} template violation(s)") + return False + + print(f"OK: {name}: static HTML template") + return True + + +def build_all() -> int: + failures = 0 + for name, (source, max_pages) in HTML_TARGETS.items(): + if not build_html(name, source, max_pages): + failures += 1 + for name, source in SCREEN_TARGETS.items(): + if not build_screen_template(name, source): + failures += 1 + for name, source in DIAGRAM_TARGETS.items(): + if not build_html(name, source, 0, src_dir=DIAGRAMS): + failures += 1 + for name in PPTX_TARGETS: + if not build_slides(name): + failures += 1 + return failures + + +def build_single(name: str) -> int: + if name in HTML_TARGETS: + source, max_pages = HTML_TARGETS[name] + ok = build_html(name, source, max_pages) + if ok: + show_fonts(EXAMPLES / f"{name}.pdf") + return 0 if ok else 1 + if name in SCREEN_TARGETS: + ok = build_screen_template(name, SCREEN_TARGETS[name]) + return 0 if ok else 1 + if name in DIAGRAM_TARGETS: + source = DIAGRAM_TARGETS[name] + ok = build_html(name, source, 0, src_dir=DIAGRAMS) + return 0 if ok else 1 + if name in PPTX_TARGETS: + return 0 if build_slides(name) else 1 + known = list(HTML_TARGETS) + list(SCREEN_TARGETS) + list(DIAGRAM_TARGETS) + list(PPTX_TARGETS) + print(f"ERROR: unknown target: {name}. Known: {', '.join(known)}") + return 2 + + +# ------------------------- verify glue ------------------------- + +def verify_slides_target(name: str) -> list[str]: + return [] if build_slides(name) else ["slides build failed"] + + +def _verify_all(target: str | None) -> int: + return verify_all( + target, + html_targets=HTML_TARGETS, + screen_targets=SCREEN_TARGETS, + diagram_targets=DIAGRAM_TARGETS, + pptx_targets=PPTX_TARGETS, + verify_slides_fn=verify_slides_target, + scan_file_fn=scan_file, + scan_density_fn=_scan_density, + infer_author_fn=infer_author, + set_pdf_metadata_fn=set_pdf_metadata, + ) + + +# ------------------------- entry ------------------------- + +def _unexpected_arg(args: list[str], allowed: set[str] | None = None) -> str | None: + for arg in args: + if allowed is not None: + if arg not in allowed: + return arg + elif arg.startswith("-"): + return arg + return None + + +def _error_unexpected(arg: str) -> int: + print(f"ERROR: unexpected argument: {arg}") + return 2 + + +def main(argv: list[str]) -> int: + args = argv[1:] + if not args: + return build_all() + if args[0] in ("-h", "--help"): + print(__doc__) + return 0 + if args[0] == "--check": + unexpected = _unexpected_arg(args[1:], {"-v", "--verbose"}) + if unexpected: + return _error_unexpected(unexpected) + verbose = "-v" in args[1:] or "--verbose" in args[1:] + css_result = check_all(verbose) + sync_result = sync_check(verbose) + cross_result = check_cross_template_consistency(verbose) + palette_result = check_off_palette(verbose) + site_result = check_site_facts(verbose) + return max(css_result, sync_result, cross_result, palette_result, site_result) + if args[0] == "--sync": + unexpected = _unexpected_arg(args[1:], {"-v", "--verbose"}) + if unexpected: + return _error_unexpected(unexpected) + verbose = "-v" in args[1:] or "--verbose" in args[1:] + return sync_check(verbose) + if args[0] == "--verify": + if len(args) > 2: + return _error_unexpected(args[2]) + if len(args) == 2 and args[1].startswith("-"): + return _error_unexpected(args[1]) + target = args[1] if len(args) > 1 else None + return _verify_all(target) + # Path-taking check subcommands share one guard + dispatch table. + path_checks = { + "--check-orphans": check_orphans, + "--check-density": check_density, + "--check-resume-balance": check_resume_balance, + "--check-placeholders": check_placeholders, + "--check-markdown": check_markdown_residue, + } + handler = path_checks.get(args[0]) + if handler is not None: + unexpected = _unexpected_arg(args[1:]) + if unexpected: + return _error_unexpected(unexpected) + return handler(args[1:]) + if args[0] == "--check-rhythm": + unexpected = _unexpected_arg(args[1:]) + if unexpected: + return _error_unexpected(unexpected) + slide_targets = [a for a in args[1:] if not a.startswith("-")] + return check_rhythm(slide_targets, PPTX_TARGETS, TEMPLATES) + if args[0].startswith("-"): + print(f"ERROR: unknown option: {args[0]}") + return 2 + if len(args) > 1: + return _error_unexpected(args[1]) + return build_single(args[0]) + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/scripts/build_metadata.py b/scripts/build_metadata.py new file mode 100644 index 0000000..1484a23 --- /dev/null +++ b/scripts/build_metadata.py @@ -0,0 +1,393 @@ +#!/usr/bin/env python3 +"""Generate Kami plugin metadata and mirror files. + +Source of truth: + - VERSION + - root SKILL.md + - CHEATSHEET.md + - references/ + - scripts/ + - assets/templates/ + - assets/diagrams/ + - selected lightweight assets + +Generated files: + - .claude-plugin/marketplace.json + - .agents/plugins/marketplace.json + - plugins/kami/.claude-plugin/plugin.json + - plugins/kami/.codex-plugin/plugin.json + - plugins/kami/skills/kami/ + +Modes: + --write (default) regenerate plugin files from source + --check compare generated bytes against committed files + +Run as: python3 scripts/build_metadata.py [--check] [--root PATH] +""" + +from __future__ import annotations + +import argparse +import difflib +import json +import shutil +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + +PLUGIN_NAME = "kami" +CODEX_CATEGORY = "Productivity" +HOMEPAGE = "https://github.com/tw93/kami" +REPOSITORY = "https://github.com/tw93/kami" + +AUTHOR = { + "name": "Tw93", + "email": "hitw93@gmail.com", + "url": "https://github.com/tw93", +} + +CODEX_DESCRIPTION = ( + "Professional document and landing-page typesetting skill for Codex: " + "resumes, one-pagers, reports, letters, portfolios, slides, and more." +) +CLAUDE_MARKETPLACE_DESCRIPTION = "Document typesetting skill for Claude Code." +CLAUDE_PLUGIN_DESCRIPTION = ( + "Typeset professional documents and landing pages with the Kami design system." +) + +SKILL_MIRROR_ROOT = Path("plugins/kami/skills/kami") +SKILL_MIRROR_FILES = ( + "CHEATSHEET.md", + "LICENSE", + "SKILL.md", + "VERSION", +) +SKILL_MIRROR_DIRS = ( + "assets/diagrams", + "assets/fonts", + "assets/images", + "assets/templates", + "references", + "scripts", +) +SKILL_MIRROR_ALLOWED_FONT_FILES = { + "JetBrainsMono.woff2", + "LICENSE-SourceHanSerifK.txt", +} +SKILL_MIRROR_IGNORED_DIRS = { + "__pycache__", + ".mypy_cache", + ".pytest_cache", + ".ruff_cache", +} +SKILL_MIRROR_IGNORED_NAMES = { + ".DS_Store", +} +SKILL_MIRROR_IGNORED_SUFFIXES = { + ".pyc", + ".pyo", +} + + +def read_version(root: Path) -> str: + version_file = root / "VERSION" + if not version_file.exists(): + raise SystemExit(f"ERROR: missing VERSION file at {version_file}") + version = version_file.read_text().strip() + if not version: + raise SystemExit("ERROR: VERSION file is empty") + return version + + +def read_token_value(root: Path, name: str) -> str: + key = name if name.startswith("--") else f"--{name}" + token_file = root / "references" / "tokens.json" + try: + tokens = json.loads(token_file.read_text(encoding="utf-8")) + except OSError as exc: + raise SystemExit(f"ERROR: missing token file at {token_file}: {exc}") from exc + except json.JSONDecodeError as exc: + raise SystemExit(f"ERROR: tokens.json is malformed: {exc}") from exc + try: + return tokens[key] + except KeyError as exc: + raise SystemExit(f"ERROR: missing token {key} in {token_file}") from exc + + +def render_json(data: dict) -> str: + return json.dumps(data, indent=2, ensure_ascii=False) + "\n" + + +def build_codex_plugin(version: str, brand_color: str) -> dict: + return { + "name": PLUGIN_NAME, + "version": version, + "description": CODEX_DESCRIPTION, + "author": AUTHOR, + "homepage": HOMEPAGE, + "repository": REPOSITORY, + "license": "MIT", + "keywords": [ + "codex", + "skills", + "documents", + "typesetting", + "resume", + "slides", + "landing-page", + ], + "skills": "./skills/", + "interface": { + "displayName": "Kami", + "shortDescription": "Typeset polished documents and landing pages", + "longDescription": ( + "Kami packages a warm parchment design system for Codex. Use it " + "to turn briefs and raw material into resumes, one-pagers, long " + "documents, letters, portfolios, slide decks, equity reports, " + "changelogs, and product landing pages." + ), + "developerName": "Tw93", + "category": CODEX_CATEGORY, + "capabilities": [ + "Interactive", + "Write", + ], + "websiteURL": HOMEPAGE, + "defaultPrompt": [ + "Make a polished one-pager from this brief", + "Build a resume using Kami", + "Turn this outline into a slide deck", + ], + "brandColor": brand_color, + }, + } + + +def build_codex_marketplace() -> dict: + return { + "name": PLUGIN_NAME, + "interface": { + "displayName": "Kami", + }, + "plugins": [ + { + "name": PLUGIN_NAME, + "source": { + "source": "local", + "path": "./plugins/kami", + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL", + }, + "category": CODEX_CATEGORY, + } + ], + } + + +def build_claude_plugin(version: str) -> dict: + return { + "name": PLUGIN_NAME, + "version": version, + "description": CLAUDE_PLUGIN_DESCRIPTION, + "author": { + "name": AUTHOR["name"], + "email": AUTHOR["email"], + }, + "homepage": HOMEPAGE, + "repository": REPOSITORY, + "license": "MIT", + "skills": "./skills/", + } + + +def build_claude_marketplace(version: str) -> dict: + return { + "name": PLUGIN_NAME, + "description": CLAUDE_MARKETPLACE_DESCRIPTION, + "owner": { + "name": AUTHOR["name"], + "email": AUTHOR["email"], + }, + "plugins": [ + { + "name": PLUGIN_NAME, + "version": version, + "description": CLAUDE_PLUGIN_DESCRIPTION, + "category": "documents", + "source": "./plugins/kami", + "homepage": HOMEPAGE, + } + ], + } + + +def should_include_skill_mirror_file(path: Path) -> bool: + if any(part in SKILL_MIRROR_IGNORED_DIRS for part in path.parts): + return False + if path.name in SKILL_MIRROR_IGNORED_NAMES: + return False + if path.suffix in SKILL_MIRROR_IGNORED_SUFFIXES: + return False + if path.parts[:2] == ("assets", "fonts"): + return path.name in SKILL_MIRROR_ALLOWED_FONT_FILES + return True + + +def collect_plugin_tree(root: Path, codex_manifest_rendered: str, claude_manifest_rendered: str) -> dict[str, bytes]: + """Build the generated file set for the shared plugin directory. + + Claude Code and Codex install only the directory referenced by their + marketplace source path. Kami's source skill lives at the repository root, + so the plugin mirrors a lightweight skill root under plugins/kami/skills/kami + and keeps all paths referenced by SKILL.md relative to that generated skill + directory. + """ + generated = { + "plugins/kami/.claude-plugin/plugin.json": claude_manifest_rendered.encode(), + "plugins/kami/.codex-plugin/plugin.json": codex_manifest_rendered.encode(), + } + + for source in SKILL_MIRROR_FILES: + path = root / source + if not path.exists(): + raise SystemExit(f"ERROR: missing required plugin source file {path}") + generated[(SKILL_MIRROR_ROOT / source).as_posix()] = path.read_bytes() + + for source in SKILL_MIRROR_DIRS: + source_root = root / source + if not source_root.exists(): + raise SystemExit(f"ERROR: missing required plugin source tree {source_root}") + for path in sorted(source_root.rglob("*")): + if not path.is_file(): + continue + source_rel = path.relative_to(root) + if not should_include_skill_mirror_file(source_rel): + continue + generated[(SKILL_MIRROR_ROOT / source_rel).as_posix()] = path.read_bytes() + + return generated + + +def diff(label: str, expected: str, actual: str) -> str: + return "".join( + difflib.unified_diff( + actual.splitlines(keepends=True), + expected.splitlines(keepends=True), + fromfile=f"committed:{label}", + tofile=f"generated:{label}", + ) + ) + + +def bytes_diff(label: str, expected: bytes, actual: bytes) -> str: + return diff( + label, + expected.decode("utf-8", errors="replace"), + actual.decode("utf-8", errors="replace"), + ) + + +def check_generated(root: Path, generated_json_files: list[tuple[Path, str]], plugin_tree: dict[str, bytes]) -> int: + drift = False + + for generated_path, expected in generated_json_files: + actual = generated_path.read_text() if generated_path.exists() else "" + if actual != expected: + rel = generated_path.relative_to(root).as_posix() + print( + f"DRIFT: {rel} is out of sync with plugin metadata.\n" + "Run scripts/build_metadata.py (no flags) to regenerate.", + ) + print(diff(rel, expected, actual), end="") + drift = True + + for rel, expected in plugin_tree.items(): + path = root / rel + actual = path.read_bytes() if path.exists() else b"" + if actual != expected: + print( + f"DRIFT: {rel} is out of sync with Kami source files.\n" + "Run scripts/build_metadata.py (no flags) to regenerate.", + ) + print(bytes_diff(rel, expected, actual), end="") + drift = True + + codex_plugin_root = root / "plugins" / "kami" + if codex_plugin_root.exists(): + expected_paths = set(plugin_tree) + for path in sorted(codex_plugin_root.rglob("*")): + if not path.is_file(): + continue + rel_path = path.relative_to(root) + plugin_rel = path.relative_to(codex_plugin_root) + if not should_include_skill_mirror_file(plugin_rel): + continue + rel = rel_path.as_posix() + if rel not in expected_paths: + print( + f"DRIFT: {rel} is an extra file in the generated plugin tree.\n" + "Run scripts/build_metadata.py (no flags) to regenerate.", + ) + drift = True + + if drift: + return 1 + + for generated_path, _ in generated_json_files: + print(f"OK: {generated_path.relative_to(root)} matches generator") + print("OK: plugins/kami plugin tree matches generator") + return 0 + + +def write_generated(root: Path, generated_json_files: list[tuple[Path, str]], plugin_tree: dict[str, bytes]) -> int: + for generated_path, expected in generated_json_files: + generated_path.parent.mkdir(parents=True, exist_ok=True) + generated_path.write_text(expected) + print(f"OK: wrote {generated_path.relative_to(root)} ({len(expected)} bytes)") + + codex_plugin_root = root / "plugins" / "kami" + shutil.rmtree(codex_plugin_root, ignore_errors=True) + for rel, expected in plugin_tree.items(): + path = root / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(expected) + print(f"OK: wrote plugins/kami ({len(plugin_tree)} generated files)") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--root", + type=Path, + default=ROOT, + help="Repository root (default: parent of scripts/)", + ) + parser.add_argument( + "--check", + action="store_true", + help="Compare generated bytes to committed files; exit non-zero on drift.", + ) + args = parser.parse_args() + root = args.root.resolve() + + version = read_version(root) + codex_plugin_rendered = render_json(build_codex_plugin(version, read_token_value(root, "brand"))) + codex_marketplace_rendered = render_json(build_codex_marketplace()) + claude_plugin_rendered = render_json(build_claude_plugin(version)) + claude_marketplace_rendered = render_json(build_claude_marketplace(version)) + plugin_tree = collect_plugin_tree(root, codex_plugin_rendered, claude_plugin_rendered) + generated_json_files = [ + (root / ".claude-plugin" / "marketplace.json", claude_marketplace_rendered), + (root / ".agents" / "plugins" / "marketplace.json", codex_marketplace_rendered), + ] + + if args.check: + return check_generated(root, generated_json_files, plugin_tree) + return write_generated(root, generated_json_files, plugin_tree) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check-update.sh b/scripts/check-update.sh new file mode 100755 index 0000000..4b3eac6 --- /dev/null +++ b/scripts/check-update.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Quiet daily update check for the installed kami skill. +# +# Reads the public VERSION file on the default branch and compares it to the +# bundled VERSION. If a newer version exists, prints one line so the agent can +# relay it. No data is ever sent (a plain read-only GET); any failure is silent; +# the check runs at most once per day via a cache marker, so it never blocks work. +set -u + +SKILL="kami" +REPO="tw93/Kami" +DEFAULT_UPDATE_CMD="npx skills add tw93/kami/plugins/kami -a universal -g -y" +# KAMI_UPDATE_URL overrides the source (used by tests); defaults to the public VERSION. +REMOTE_URL="${KAMI_UPDATE_URL:-https://raw.githubusercontent.com/${REPO}/main/VERSION}" + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +local_ver="$(tr -d '[:space:]' < "${root}/VERSION" 2>/dev/null)" +[ -n "${local_ver}" ] || exit 0 + +case "${root}" in + */.claude/plugins/cache/kami/kami/*/skills/kami) + UPDATE_CMD="claude plugin update kami" + ;; + */plugins/cache/kami/kami/*/skills/kami) + UPDATE_CMD="codex plugin marketplace upgrade kami && codex plugin add kami@kami" + ;; + *) + UPDATE_CMD="${DEFAULT_UPDATE_CMD}" + ;; +esac + +# Throttle: at most one check per calendar day, regardless of outcome. One +# dated marker file rewritten in place, so the cache dir does not accumulate +# a new empty update-checked-YYYY-MM-DD file every day. +day="$(date +%F 2>/dev/null)" || exit 0 +cache_dir="${XDG_CACHE_HOME:-${HOME}/.cache}/${SKILL}" +marker="${cache_dir}/update-checked" +[ "$(cat "${marker}" 2>/dev/null)" = "${day}" ] && exit 0 +mkdir -p "${cache_dir}" 2>/dev/null +printf '%s' "${day}" > "${marker}" 2>/dev/null # write first so an offline run does not retry all day +rm -f "${cache_dir}"/update-checked-2* 2>/dev/null # sweep legacy per-day markers + +command -v curl >/dev/null 2>&1 || exit 0 +remote_ver="$(curl -fsSL --max-time 3 "${REMOTE_URL}" 2>/dev/null | tr -d '[:space:]')" +[ -n "${remote_ver}" ] || exit 0 +[ "${remote_ver}" = "${local_ver}" ] && exit 0 + +# Only notify when the remote version sorts strictly higher. Numeric-field +# sort instead of `sort -V`: on a sort without -V support the old pipeline +# yielded an empty string and silently never notified again. +highest="$(printf '%s\n%s\n' "${local_ver}" "${remote_ver}" | sort -t. -k1,1n -k2,2n -k3,3n 2>/dev/null | tail -1)" +[ -n "${highest}" ] || exit 0 +[ "${highest}" = "${remote_ver}" ] || exit 0 + +echo "Kami ${remote_ver} is available (you have ${local_ver}). Update: ${UPDATE_CMD}" +exit 0 diff --git a/scripts/checks.py b/scripts/checks.py new file mode 100644 index 0000000..a5d41a4 --- /dev/null +++ b/scripts/checks.py @@ -0,0 +1,614 @@ +"""PDF-side and content-shape checks for kami documents. + +Splits out from build.py: + - check_placeholders: scan filled HTML for unreplaced `{{...}}` tokens. + - check_orphans: scan rendered PDFs for short trailing lines (typographic orphans). + - check_density: scan rendered PDFs for pages with too much trailing whitespace. + - check_resume_balance: scan resume PDFs for exact two-page balance. + - check_rhythm: scan slides Python source for monotonous deck sequences. + +Density scanning uses a parchment-aware pixel sweep. The hot path is +vectorized with NumPy when available and falls back to a pure-Python loop. +Thresholds and DPI live in `references/checks_thresholds.json`. +""" +from __future__ import annotations + +import re +from html.parser import HTMLParser +from pathlib import Path + +from optional_deps import MissingDepError, require_pymupdf, require_pypdf_reader +from shared import ( + PARCHMENT_RGB, + ROOT, + default_example_pdfs, + load_checks_thresholds, + rel_to_root, +) + +PLACEHOLDER = re.compile(r"\{\{[^}]+\}\}") +MARKDOWN_THEMATIC_BREAK = re.compile(r"^\s*[-*_]{3,}\s*$") +MARKDOWN_RESIDUE_MARKERS = ( + ("markdown thematic break", MARKDOWN_THEMATIC_BREAK), + ("unconverted bold marker", re.compile(r"\*\*")), + ("unconverted inline-code marker", re.compile(r"`")), +) + +# Parchment background RGB for pixel comparison (sourced from shared.PARCHMENT_RGB). +_BG_R, _BG_G, _BG_B = PARCHMENT_RGB +_BG_TOLERANCE = 10 + + +def check_placeholders(paths: list[str]) -> int: + if not paths: + print("ERROR: provide at least one HTML file to scan") + return 2 + + failures = 0 + for raw in paths: + path = Path(raw) + if not path.is_absolute(): + path = ROOT / path + if not path.exists(): + print(f"ERROR: {raw}: file not found") + failures += 1 + continue + text = path.read_text(encoding="utf-8", errors="replace") + hits = list(dict.fromkeys(PLACEHOLDER.findall(text))) + rel = rel_to_root(path) + if hits: + print(f"ERROR: {rel}: unfilled placeholder(s): {', '.join(hits)}") + failures += 1 + else: + print(f"OK: {rel}: no placeholders") + + return 0 if failures == 0 else 1 + + +# ---------- markdown residue check ---------- + +class _VisibleTextParser(HTMLParser): + """Extract visible text from filled HTML while skipping code-like blocks.""" + + _SKIP_TAGS = {"code", "pre", "script", "style"} + + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self._skip_depth = 0 + self.parts: list[str] = [] + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + if tag in self._SKIP_TAGS: + self._skip_depth += 1 + + def handle_endtag(self, tag: str) -> None: + if tag in self._SKIP_TAGS and self._skip_depth > 0: + self._skip_depth -= 1 + + def handle_data(self, data: str) -> None: + if self._skip_depth == 0: + self.parts.append(data) + + +def _visible_html_text(raw: str) -> str: + parser = _VisibleTextParser() + parser.feed(raw) + return "\n".join(parser.parts) + + +def _markdown_residue_issues(text: str, *, page: int | None = None) -> list[str]: + issues: list[str] = [] + for line_no, line in enumerate(text.splitlines(), start=1): + for label, pattern in MARKDOWN_RESIDUE_MARKERS: + if pattern.search(line): + where = f"p{page}" if page is not None else f"line {line_no}" + sample = " ".join(line.strip().split())[:80] + issues.append(f"{where}: {label}: {sample!r}") + return issues + + +def _markdown_text_chunks(path: Path) -> tuple[list[tuple[int | None, str]], str | None]: + suffix = path.suffix.lower() + if suffix == ".pdf": + try: + PdfReader = require_pypdf_reader() + except MissingDepError as exc: + return [], str(exc) + try: + reader = PdfReader(str(path)) + except Exception as exc: + return [], f"could not read PDF text: {exc}" + return [ + (index, page.extract_text() or "") + for index, page in enumerate(reader.pages, start=1) + ], None + + raw = path.read_text(encoding="utf-8", errors="replace") + if suffix in {".html", ".htm"}: + return [(None, _visible_html_text(raw))], None + return [(None, raw)], None + + +def check_markdown_residue(paths: list[str]) -> int: + """Scan filled HTML/PDF outputs for visible raw Markdown markers. + + This catches common hand-conversion misses such as literal `---`, `**bold**`, + and inline-code backticks leaking into the final PDF. + """ + if not paths: + paths = default_example_pdfs() + if not paths: + print("ERROR: no files to scan") + return 2 + + failures = 0 + scanned = 0 + for raw in paths: + path = Path(raw) + if not path.is_absolute(): + path = ROOT / path + rel = rel_to_root(path) + if not path.exists(): + print(f"ERROR: {raw}: file not found") + failures += 1 + continue + + chunks, error = _markdown_text_chunks(path) + if error: + print(f"ERROR: {rel}: {error}") + failures += 1 + continue + + scanned += 1 + issues: list[str] = [] + for page, text in chunks: + issues.extend(_markdown_residue_issues(text, page=page)) + if issues: + failures += 1 + print(f"ERROR: {rel}: markdown residue found") + for issue in issues: + print(f" {issue}") + else: + print(f"OK: {rel}: no markdown residue") + + if scanned == 0: + print("ERROR: no files scanned") + return 2 + return 0 if failures == 0 else 1 + + +# ---------- orphan check ---------- + +def _orphan_last_line(text: str, max_words: int, max_chars: int) -> str | None: + """Return a block's last line if it is an orphan, else None. + + A block orphans when it has 2+ lines and the trailing line is short by + both word count (<= max_words) and length (< max_chars). Pure so the + predicate is unit-testable without a rendered PDF. + """ + lines = text.strip().splitlines() + if len(lines) < 2: + return None + last = lines[-1].strip() + if len(last.split()) <= max_words and len(last) < max_chars: + return last + return None + + +def check_orphans(paths: list[str]) -> int: + """Scan PDF for text blocks whose last line has <= max_words and < max_chars.""" + try: + fitz = require_pymupdf() + except MissingDepError as exc: + print(f"ERROR: {exc}") + return 2 + + if not paths: + paths = default_example_pdfs() + if not paths: + print("ERROR: no PDF files to scan") + return 2 + + orphan_cfg = load_checks_thresholds()["orphan"] + max_words = int(orphan_cfg["max_words"]) + max_chars = int(orphan_cfg["max_chars"]) + + total = 0 + missing = 0 + scanned = 0 + for raw in paths: + path = Path(raw) + if not path.exists(): + print(f"ERROR: {raw}: not found") + missing += 1 + continue + try: + doc = fitz.open(str(path)) + except Exception as exc: + print(f"ERROR: {raw}: could not read PDF: {exc}") + missing += 1 + continue + scanned += 1 + rel = rel_to_root(path) + for page_num in range(len(doc)): + page = doc[page_num] + blocks = page.get_text("blocks") + for bx0, by0, bx1, by1, text, block_no, block_type in blocks: + if block_type != 0: # text blocks only + continue + last = _orphan_last_line(text, max_words, max_chars) + if last is not None: + total += 1 + print(f" {rel} p{page_num + 1}: orphan: \"{last}\" ({len(last.split())} word(s), {len(last)} chars)") + doc.close() + + if scanned == 0: + print(f"ERROR: no PDFs scanned ({missing} missing)") + return 2 + + if total == 0 and missing == 0: + print(f"OK: no orphans found across {scanned} PDF(s)") + return 0 + + if total: + print(f"\n{total} orphan(s) found across {scanned} PDF(s)") + if missing: + print(f"{missing} input(s) missing") + return 1 + + +# ---------- density check ---------- + +def _last_content_y(samples: bytes, w: int, h: int, stride: int, n: int) -> int: + """Return the highest y row index that contains non-parchment content. + + Uses numpy when available (vectorized scan, ~50-100x faster on multi-page + PDFs); falls back to a pure Python loop otherwise. Both paths sample every + fourth column for parity, so the result is identical. + """ + try: + import numpy as np + except ImportError: + last_y = 0 + for y in range(h - 1, -1, -1): + row_start = y * stride + is_bg = True + for x in range(0, w, 4): + offset = row_start + x * n + if (abs(samples[offset] - _BG_R) > _BG_TOLERANCE + or abs(samples[offset + 1] - _BG_G) > _BG_TOLERANCE + or abs(samples[offset + 2] - _BG_B) > _BG_TOLERANCE): + is_bg = False + break + if not is_bg: + last_y = y + break + return last_y + + arr = np.frombuffer(samples, dtype=np.uint8).reshape((h, stride)) + pixels = arr[:, : w * n].reshape((h, w, n)) + rgb = pixels[:, ::4, :3].astype(np.int16) + bg = np.array([_BG_R, _BG_G, _BG_B], dtype=np.int16) + row_is_bg = (np.abs(rgb - bg).max(axis=2) <= _BG_TOLERANCE).all(axis=1) + non_bg = np.where(~row_is_bg)[0] + return int(non_bg[-1]) if non_bg.size else 0 + + +def _density_bucket(empty: float, warn_pct: float, sparse_pct: float) -> str: + """Categorize a page by its trailing-whitespace fraction. + + Pure so `_scan_density` and its tests share one decision. A test that + reimplements these comparisons would stay green if the real operators + drifted (`>` to `>=`, or warn/sparse swapped); calling this keeps the + assertion anchored to production logic. + """ + if empty > sparse_pct: + return "SPARSE" + if empty > warn_pct: + return "WARN" + return "OK" + + +def _scan_density(paths: list[str]) -> tuple[int, int, int, int] | None: + """Scan PDFs and print SPARSE/WARN lines. + + Returns (sparse, warn, missing, scanned), or None if PyMuPDF is missing. + Thresholds (warn_pct, sparse_pct, dpi) come from + references/checks_thresholds.json. + """ + try: + fitz = require_pymupdf() + except MissingDepError as exc: + print(f"ERROR: {exc}") + return None + + density_cfg = load_checks_thresholds()["density"] + warn_pct = float(density_cfg["warn_pct"]) + sparse_pct = float(density_cfg["sparse_pct"]) + dpi = int(density_cfg["dpi"]) + + sparse = 0 + warn = 0 + missing = 0 + scanned = 0 + for raw in paths: + path = Path(raw) + if not path.exists(): + print(f"ERROR: {raw}: not found") + missing += 1 + continue + try: + doc = fitz.open(str(path)) + except Exception as exc: + print(f"ERROR: {raw}: could not read PDF: {exc}") + missing += 1 + continue + scanned += 1 + rel = rel_to_root(path) + for page_num in range(len(doc)): + if page_num == 0: + continue + page = doc[page_num] + pix = page.get_pixmap(dpi=dpi) + w, h = pix.width, pix.height + if h == 0: + continue + last_content_y = _last_content_y(pix.samples, w, h, pix.stride, pix.n) + + empty = (h - last_content_y) / h + bucket = _density_bucket(empty, warn_pct, sparse_pct) + if bucket == "SPARSE": + print(f" SPARSE: {rel} p{page_num + 1}: {empty:.0%} trailing whitespace") + sparse += 1 + elif bucket == "WARN": + print(f" WARN: {rel} p{page_num + 1}: {empty:.0%} trailing whitespace") + warn += 1 + doc.close() + return sparse, warn, missing, scanned + + +def check_density(paths: list[str]) -> int: + """Scan PDF pages for sparse content (large trailing whitespace from + break-inside:avoid pushing content to the next page).""" + if not paths: + paths = default_example_pdfs() + if not paths: + print("ERROR: no PDF files to scan") + return 2 + + result = _scan_density(paths) + if result is None: + return 2 + sparse, warn, missing, scanned = result + + if scanned == 0: + print(f"ERROR: no PDFs scanned ({missing} missing)") + return 2 + + total = sparse + warn + if total == 0 and missing == 0: + print(f"OK: no density issues across {scanned} PDF(s)") + return 0 + + if total: + print(f"\n{total} density warning(s) across {scanned} PDF(s)") + if missing: + print(f"{missing} input(s) missing") + return 1 + + +# ---------- resume balance check ---------- + +def _resume_balance_issues( + fills: list[float], + page_count: int, + min_fill: float, + max_fill: float, + max_gap: float, +) -> list[str]: + """Return human-readable resume balance failures for unit tests and CLI.""" + issues: list[str] = [] + if page_count != 2: + issues.append(f"{page_count} pages (expected 2)") + + for index, fill in enumerate(fills[:2], start=1): + if fill < min_fill: + issues.append(f"p{index} fill {fill:.0%} below {min_fill:.0%}") + elif fill > max_fill: + issues.append(f"p{index} fill {fill:.0%} above {max_fill:.0%}") + + if len(fills) >= 2: + gap = abs(fills[0] - fills[1]) + if gap > max_gap: + issues.append(f"page fill gap {gap:.0%} above {max_gap:.0%}") + + return issues + + +def _resume_page_fills(path: Path, dpi: int) -> tuple[list[float], int] | None: + try: + fitz = require_pymupdf() + except MissingDepError as exc: + print(f"ERROR: {exc}") + return None + + try: + doc = fitz.open(str(path)) + except Exception as exc: + print(f"ERROR: {path}: could not read PDF: {exc}") + return None + fills: list[float] = [] + for page in doc: + pix = page.get_pixmap(dpi=dpi) + if pix.height == 0: + fills.append(0.0) + continue + last_content_y = _last_content_y(pix.samples, pix.width, pix.height, pix.stride, pix.n) + fills.append((last_content_y + 1) / pix.height) + page_count = len(doc) + doc.close() + return fills, page_count + + +def check_resume_balance(paths: list[str]) -> int: + """Require resume PDFs to be exactly 2 pages with balanced content fill.""" + if not paths: + print("ERROR: provide at least one filled resume PDF to scan") + return 2 + + # Resolve the dependency once up front so a missing PyMuPDF stays a + # tooling error (exit 2) while a single unreadable PDF inside the loop + # tallies as missing and lets the remaining files still get scanned. + try: + require_pymupdf() + except MissingDepError as exc: + print(f"ERROR: {exc}") + return 2 + + cfg = load_checks_thresholds()["resume_balance"] + min_fill = float(cfg["min_fill_pct"]) + max_fill = float(cfg["max_fill_pct"]) + max_gap = float(cfg["max_gap_pct"]) + dpi = int(cfg["dpi"]) + + failures = 0 + missing = 0 + scanned = 0 + for raw in paths: + path = Path(raw) + if not path.exists(): + print(f"ERROR: {raw}: not found") + missing += 1 + continue + + result = _resume_page_fills(path, dpi) + if result is None: + missing += 1 + continue + + fills, page_count = result + scanned += 1 + rel = rel_to_root(path) + fill_text = " / ".join(f"{fill:.0%}" for fill in fills) + issues = _resume_balance_issues(fills, page_count, min_fill, max_fill, max_gap) + if issues: + failures += 1 + print(f"ERROR: {rel}: {fill_text} ({'; '.join(issues)})") + else: + gap = abs(fills[0] - fills[1]) + print(f"OK: {rel}: 2 pages, fill {fill_text}, gap {gap:.0%}") + + if scanned == 0: + print(f"ERROR: no PDFs scanned ({missing} missing)") + return 2 + if missing: + print(f"{missing} input(s) missing") + return 0 if failures == 0 and missing == 0 else 1 + + +# ---------- rhythm check ---------- + +# Layout functions that count as "divider" slides (break monotony). +_DIVIDER_FUNCS = {"chapter_slide"} +# Layout functions that count as "density variation" slides. +_DENSITY_VARIATION_FUNCS = {"quote_slide", "metrics_slide"} +# Layout function call pattern in slides.py source. +_SLIDE_CALL = re.compile(r"^\s*(\w+_slide)\s*\(") + + +def _parse_slide_sequence(src: Path) -> list[str]: + """Return the ordered list of slide-function names called in main().""" + text = src.read_text(encoding="utf-8", errors="replace") + in_main = False + sequence: list[str] = [] + for line in text.splitlines(): + if re.match(r"^def main\s*\(", line): + in_main = True + continue + if in_main and re.match(r"^def \w", line): + break + if in_main: + m = _SLIDE_CALL.match(line) + if m: + sequence.append(m.group(1)) + return sequence + + +def _rhythm_issues(seq: list[str], max_content_run: int, divider_min_deck_size: int) -> list[str]: + """Return the rhythm warnings for one parsed slide sequence. + + Pure so the three monotony rules are unit-testable without rendering a + deck, matching the `_resume_balance_issues` seam. + """ + issues: list[str] = [] + + # Rule 1: no run of more than `max_content_run` consecutive content_slides. + run = 0 + max_run = 0 + for fn in seq: + if fn == "content_slide": + run += 1 + max_run = max(max_run, run) + else: + run = 0 + if max_run > max_content_run: + issues.append(f"longest content_slide run is {max_run} (limit {max_content_run})") + + # Rule 2: large decks need at least one chapter_slide divider. + if len(seq) >= divider_min_deck_size and not any(fn in _DIVIDER_FUNCS for fn in seq): + issues.append(f"{len(seq)} slides with no chapter_slide divider") + + # Rule 3: deck must contain at least one density-variation slide. + if not any(fn in _DENSITY_VARIATION_FUNCS for fn in seq): + issues.append("no quote_slide or metrics_slide for density variation") + + return issues + + +def check_rhythm(targets: list[str], pptx_targets: dict[str, str], templates_dir: Path) -> int: + """Scan slide templates for monotony: too many consecutive content_slides, + missing dividers, and missing density variation. + + Thresholds come from references/checks_thresholds.json. + """ + names = targets if targets else list(pptx_targets.keys()) + failures = 0 + rhythm_cfg = load_checks_thresholds()["rhythm"] + max_content_run = int(rhythm_cfg["max_content_run"]) + divider_min_deck_size = int(rhythm_cfg["divider_min_deck_size"]) + + for name in names: + source = pptx_targets.get(name) + if source is None: + print(f"ERROR: {name}: not a known slides target") + failures += 1 + continue + src = templates_dir / source + if not src.exists(): + print(f"ERROR: {name}: source not found ({src})") + failures += 1 + continue + + seq = _parse_slide_sequence(src) + if not seq: + print(f"ERROR: {name}: no slide calls found in main() (deck unparseable)") + failures += 1 + continue + + issues = _rhythm_issues(seq, max_content_run, divider_min_deck_size) + + if issues: + # These fail the run (exit 1), so label them ERROR; WARN is + # reserved for advisory output that does not gate the build. + for issue in issues: + print(f"ERROR: {name}: {issue}") + failures += 1 + else: + content_run = 0 + max_run = 0 + for fn in seq: + content_run = content_run + 1 if fn == "content_slide" else 0 + max_run = max(max_run, content_run) + print(f"OK: {name}: rhythm ok ({len(seq)} slides, max run {max_run})") + + return 0 if failures == 0 else 1 diff --git a/scripts/draft-release-notes.py b/scripts/draft-release-notes.py new file mode 100644 index 0000000..2fc78cc --- /dev/null +++ b/scripts/draft-release-notes.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +"""Draft the next Kami release notes from git log. + +Pulls commit subjects in a rev range and pours them into the V1.4.0-style +template (centered logo + bilingual changelog). The output is a starting +point: regroup the commits into 5-8 product-themed bullets and translate +each to Chinese before publishing. + +Usage: + python3 scripts/draft-release-notes.py + python3 scripts/draft-release-notes.py V1.4.0..HEAD + python3 scripts/draft-release-notes.py \\ + --version V1.5.0 \\ + --title "Steadier Hand" \\ + --subtitle-en "Plugin install fix and audit cleanup." \\ + --subtitle-cn "插件安装修复,审计清理沉淀。" + +The default rev range is `<latest tag>..HEAD`. Output goes to stdout; pipe +to a file or `pbcopy` to feed `gh release create --notes-file`. +""" +from __future__ import annotations + +import argparse +import subprocess +import sys +from textwrap import dedent + +_HEADER = dedent("""\ + <div align="center"> + <img src="https://gw.alipayobjects.com/zos/k/vl/logo.svg" alt="Kami Logo" width="120" /> + <h1 style="margin: 12px 0 6px;">Kami {version}</h1> + <p><em>{subtitle_en}</em></p> + </div> +""") + +_FOOTER = dedent("""\ + > Kami is a quiet design system for professional documents, one constraint set that any agent can trust. https://github.com/tw93/Kami +""") + +# Conventional-commit prefix to a short product label, used as a hint when +# the user reorganizes the auto-listed commits into themed bullets. +_PREFIX_HINT = { + "build": "build", + "ci": "ci", + "feat": "feature", + "fix": "fix", + "docs": "docs", + "chore": "chore", + "refactor": "refactor", + "test": "test", + "perf": "perf", + "revert": "revert", +} + + +def _run(cmd: list[str]) -> str: + result = subprocess.run(cmd, capture_output=True, text=True, check=False) + if result.returncode != 0: + raise RuntimeError(f"{' '.join(cmd)}: {result.stderr.strip() or 'failed'}") + return result.stdout + + +def latest_tag() -> str | None: + try: + out = _run(["git", "describe", "--tags", "--abbrev=0"]).strip() + except RuntimeError: + return None + return out or None + + +def commits_in(rev_range: str) -> list[tuple[str, str]]: + """Return [(short_sha, subject), ...] in chronological order.""" + out = _run(["git", "log", rev_range, "--format=%h\t%s", "--reverse"]) + rows: list[tuple[str, str]] = [] + for line in out.splitlines(): + if "\t" in line: + sha, subject = line.split("\t", 1) + rows.append((sha.strip(), subject.strip())) + return rows + + +def classify(subject: str) -> str: + """Return a one-word commit category derived from the conventional-commit prefix.""" + head = subject.split(":", 1)[0].split("(", 1)[0].strip().lower() + return _PREFIX_HINT.get(head, "other") + + +def render( + version: str, + title: str, + subtitle_en: str, + subtitle_cn: str, + rev_range: str, + commits: list[tuple[str, str]], +) -> str: + out: list[str] = [] + out.append(_HEADER.format(version=version, subtitle_en=subtitle_en)) + out.append(f"<!-- title: {version} {title} -->") + out.append(f"<!-- range: {rev_range} ({len(commits)} commits) -->") + out.append("<!-- regroup the bullets below into 5-8 product-themed items -->") + out.append("") + out.append("### Changelog") + out.append("") + for i, (sha, subject) in enumerate(commits, 1): + out.append(f"{i}. **TODO**: {subject} <!-- {sha} {classify(subject)} -->") + out.append("") + out.append("### 更新日志") + out.append("") + out.append(f"<!-- 副标题: {subtitle_cn} -->") + out.append("<!-- 翻译并对齐到上面英文条目,保持一一对应 -->") + out.append("") + for i in range(1, len(commits) + 1): + out.append(f"{i}. **TODO**:(对应英文第 {i} 条)") + out.append("") + out.append(_FOOTER) + return "\n".join(out) + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "rev_range", + nargs="?", + default=None, + help="Git rev range (default: <latest tag>..HEAD)", + ) + parser.add_argument("--version", default="VX.Y.Z", help="Version label, e.g. V1.5.0") + parser.add_argument("--title", default="<title>", help="Release title, e.g. 'Steadier Hand'") + parser.add_argument( + "--subtitle-en", + default="<one-line subtitle>", + help="Short English subtitle for the centered hero block", + ) + parser.add_argument( + "--subtitle-cn", + default="<一句话中文副标题>", + help="Short Chinese subtitle, kept as a comment hint for translators", + ) + return parser.parse_args(argv[1:]) + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + + rev_range = args.rev_range + if rev_range is None: + tag = latest_tag() + if not tag: + print("ERROR: no tag found; pass an explicit rev range like V1.0.0..HEAD", + file=sys.stderr) + return 2 + rev_range = f"{tag}..HEAD" + + try: + commits = commits_in(rev_range) + except RuntimeError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 2 + + if not commits: + print(f"ERROR: no commits in {rev_range}", file=sys.stderr) + return 1 + + out = render( + version=args.version, + title=args.title, + subtitle_en=args.subtitle_en, + subtitle_cn=args.subtitle_cn, + rev_range=rev_range, + commits=commits, + ) + print(out) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/scripts/ensure-fonts.sh b/scripts/ensure-fonts.sh new file mode 100755 index 0000000..f2b7d12 --- /dev/null +++ b/scripts/ensure-fonts.sh @@ -0,0 +1,220 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Portable across bash 3.2+ (macOS stock /bin/bash) and bash 4+ (Linux, Homebrew). +# Avoids `declare -A` so the script runs on a fresh macOS without `brew install bash`. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_FONT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)/assets/fonts" + +# Download target lives OUTSIDE the skill directory on purpose. +# +# Claude Desktop skill ZIPs exclude the large bundled fonts (TsangerJinKai TTFs, +# Source Han Serif K OTFs). The old code downloaded them back into the skill's +# own assets/fonts, which pushed the installed skill past Claude Desktop's size +# limit ("upload/execution too big"). We instead drop them in the XDG user font +# dir, which fontconfig scans by default on both macOS (Homebrew) and Linux, yet +# does NOT show up in macOS Font Book. WeasyPrint then resolves "TsangerJinKai02" +# / "Source Han Serif K" from here when the template's relative @font-face path +# is absent; online renders still fall back to the jsDelivr URL baked alongside +# each @font-face declaration. +FONT_DIR="${KAMI_FONT_DIR:-${XDG_DATA_HOME:-$HOME/.local/share}/fonts/kami}" + +# Partial downloads from an interrupted run must not linger in FONT_DIR, and +# two concurrent runs must not fight over one temp path: temp files carry this +# run's PID and are swept on exit. +TMP_SUFFIX="tmp.$$" +cleanup_tmp() { rm -f "$FONT_DIR"/*."$TMP_SUFFIX" 2>/dev/null || true; } +trap cleanup_tmp EXIT + +MIN_SIZE_CN=10000000 # 10MB for TsangerJinKai (large CJK glyph set) +MIN_SIZE_KO=6500000 # 6.5MB for Source Han Serif K (Adobe full subset) + +# TsangerJinKai (CN): index N pairs CN_NAMES[N] with CN_LOCAL_NAMES[N]. +CN_NAMES=("仓耳今楷02-W04.ttf" "仓耳今楷02-W05.ttf") +CN_LOCAL_NAMES=("TsangerJinKai02-W04.ttf" "TsangerJinKai02-W05.ttf") + +# Source Han Serif K (KO): mirror filenames match the repo filenames, so there +# is no rename step (unlike Tsanger's Chinese-named official downloads). +KO_NAMES=("SourceHanSerifKR-Regular.otf" "SourceHanSerifKR-Medium.otf") + +# Mirror order is intentionally jsdmirror-first here, opposite of the +# templates' @font-face fallback (which lists jsdelivr first). Reasoning: +# this script runs interactively when fonts are missing locally, often from +# China where jsdmirror is reachable and faster than jsdelivr; templates run +# anywhere and prioritize jsdelivr's broader global coverage. +MIRROR_SOURCES=( + "https://cdn.jsdmirror.com/gh/tw93/Kami@main/assets/fonts" + "https://cdn.jsdelivr.net/gh/tw93/Kami@main/assets/fonts" +) + +check_size() { + local file="$1" + local min_size="$2" + [[ -f "$file" ]] || return 1 + local size + size=$(wc -c < "$file" | tr -d ' ') + [[ "$size" -ge "$min_size" ]] +} + +cn_present_in() { + local dir="$1" name + for name in "${CN_LOCAL_NAMES[@]}"; do + check_size "$dir/$name" "$MIN_SIZE_CN" || return 1 + done + return 0 +} + +ko_present_in() { + local dir="$1" name + for name in "${KO_NAMES[@]}"; do + check_size "$dir/$name" "$MIN_SIZE_KO" || return 1 + done + return 0 +} + +refresh_fontconfig() { + # The XDG font dir is already on fontconfig's default scan path, so a cache + # refresh is all that is needed for WeasyPrint to pick the fonts up. Optional: + # absence of fc-cache (e.g. minimal sandbox) is non-fatal, fontconfig rescans + # the directory lazily on next use. + if command -v fc-cache >/dev/null 2>&1; then + fc-cache -f "$FONT_DIR" >/dev/null 2>&1 || true + fi +} + +download_tsanger() { + local cn_name="$1" + local local_name="$2" + local target="$FONT_DIR/$local_name" + + # Source 1: official tsanger.cn + local official_url="https://tsanger.cn/download/${cn_name}" + echo " Trying: tsanger.cn (official)" + if curl --retry 2 --connect-timeout 15 --max-time 300 -fSL "$official_url" -o "$target.$TMP_SUFFIX" 2>/dev/null; then + if check_size "$target.$TMP_SUFFIX" "$MIN_SIZE_CN"; then + mv "$target.$TMP_SUFFIX" "$target" + echo " OK: $local_name downloaded ($(du -h "$target" | cut -f1))" + return 0 + else + rm -f "$target.$TMP_SUFFIX" + fi + else + rm -f "$target.$TMP_SUFFIX" + fi + + # Source 2+: CDN mirrors (already named TsangerJinKai02-W0x.ttf) + for src in "${MIRROR_SOURCES[@]}"; do + local url="$src/$local_name" + echo " Trying: $url" + if curl --retry 2 --connect-timeout 15 --max-time 300 -fSL "$url" -o "$target.$TMP_SUFFIX" 2>/dev/null; then + if check_size "$target.$TMP_SUFFIX" "$MIN_SIZE_CN"; then + mv "$target.$TMP_SUFFIX" "$target" + echo " OK: $local_name downloaded ($(du -h "$target" | cut -f1))" + return 0 + else + rm -f "$target.$TMP_SUFFIX" + fi + else + rm -f "$target.$TMP_SUFFIX" + fi + done + + echo " ERROR: all sources failed for $local_name" + return 1 +} + +download_ko_serif() { + local local_name="$1" + local target="$FONT_DIR/$local_name" + + # CDN mirrors only: Source Han Serif K has no single official direct-download + # URL, so we serve the committed OTFs from the same jsDelivr/jsdmirror gh path. + for src in "${MIRROR_SOURCES[@]}"; do + local url="$src/$local_name" + echo " Trying: $url" + if curl --retry 2 --connect-timeout 15 --max-time 300 -fSL "$url" -o "$target.$TMP_SUFFIX" 2>/dev/null; then + if check_size "$target.$TMP_SUFFIX" "$MIN_SIZE_KO"; then + mv "$target.$TMP_SUFFIX" "$target" + echo " OK: $local_name downloaded ($(du -h "$target" | cut -f1))" + return 0 + else + rm -f "$target.$TMP_SUFFIX" + fi + else + rm -f "$target.$TMP_SUFFIX" + fi + done + + echo " ERROR: all sources failed for $local_name" + return 1 +} + +# A repo checkout ships the committed font files. Templates resolve their +# relative `../fonts/*` @font-face path against them directly, so there is +# nothing to download or register. These branches are skipped inside a Claude +# Desktop skill, whose assets/fonts has the large fonts stripped out. + +cn_failed=0 +if cn_present_in "$REPO_FONT_DIR"; then + echo "OK: TsangerJinKai fonts present in repo checkout ($REPO_FONT_DIR)" +else + mkdir -p "$FONT_DIR" + if cn_present_in "$FONT_DIR"; then + echo "OK: TsangerJinKai fonts present ($FONT_DIR)" + else + echo "Downloading TsangerJinKai fonts to $FONT_DIR ..." + for i in "${!CN_NAMES[@]}"; do + cn_name="${CN_NAMES[$i]}" + local_name="${CN_LOCAL_NAMES[$i]}" + if check_size "$FONT_DIR/$local_name" "$MIN_SIZE_CN"; then + echo " OK: $local_name already present" + continue + fi + if ! download_tsanger "$cn_name" "$local_name"; then + cn_failed=$((cn_failed + 1)) + fi + done + if [[ "$cn_failed" -gt 0 ]]; then + echo "" + echo "Some TsangerJinKai files could not be downloaded. Alternatives:" + echo " 1. Install Source Han Serif SC: brew install --cask font-source-han-serif-sc" + echo " 2. Copy TsangerJinKai02-W04.ttf and W05.ttf manually into $FONT_DIR" + # Don't exit yet, try the KO recovery too so a Korean-only user still gets KO fonts. + fi + fi +fi + +ko_failed=0 +if ko_present_in "$REPO_FONT_DIR"; then + echo "OK: Source Han Serif K fonts present in repo checkout ($REPO_FONT_DIR)" +else + mkdir -p "$FONT_DIR" + if ko_present_in "$FONT_DIR"; then + echo "OK: Source Han Serif K fonts present ($FONT_DIR)" + else + echo "Downloading Source Han Serif K fonts to $FONT_DIR ..." + for local_name in "${KO_NAMES[@]}"; do + if check_size "$FONT_DIR/$local_name" "$MIN_SIZE_KO"; then + echo " OK: $local_name already present" + continue + fi + if ! download_ko_serif "$local_name"; then + ko_failed=$((ko_failed + 1)) + fi + done + if [[ "$ko_failed" -gt 0 ]]; then + echo "" + echo "Some Source Han Serif K files could not be downloaded. Alternatives:" + echo " 1. Download from https://github.com/adobe-fonts/source-han-serif/releases" + echo " 2. Copy SourceHanSerifKR-Regular.otf and -Medium.otf manually into $FONT_DIR" + fi + fi +fi + +if [[ "$cn_failed" -gt 0 || "$ko_failed" -gt 0 ]]; then + exit 1 +fi + +refresh_fontconfig +echo "OK: all fonts ready" diff --git a/scripts/highlight.py b/scripts/highlight.py new file mode 100644 index 0000000..335d860 --- /dev/null +++ b/scripts/highlight.py @@ -0,0 +1,141 @@ +"""Lightweight syntax highlighting for Kami HTML templates. + +Scans HTML for <pre><code class="language-*"> blocks and applies +Pygments-based inline-style highlighting using Kami design tokens. +Blocks without a language- class pass through unchanged. +""" +from __future__ import annotations + +import html as html_mod +import re +import sys + +from shared import token_value + +CODE_BLOCK_RE = re.compile( + r'(<pre[^>]*>\s*<code\s+class="language-([\w+-]+)"[^>]*>)' + r'(.*?)' + r'(</code>\s*</pre>)', + re.DOTALL, +) + +_KAMI_PALETTE: dict[str, str] | None = None + + +def _kami_palette() -> dict[str, str]: + """Resolve design-token colors lazily. + + Kept out of module scope so importing this module (e.g. by build.py for a + command that never highlights code) does not read tokens.json. That keeps + the import resilient on a half-installed checkout, matching shared.py's + baked-in fallbacks. + """ + global _KAMI_PALETTE + if _KAMI_PALETTE is None: + _KAMI_PALETTE = { + "brand": token_value("brand"), + "stone": token_value("stone"), + "olive": token_value("olive"), + "dark_warm": token_value("dark-warm"), + "near_black": token_value("near-black"), + } + return _KAMI_PALETTE + + +_WARNED_MISSING_PYGMENTS = False + + +def _warn_missing_pygments() -> None: + global _WARNED_MISSING_PYGMENTS + if _WARNED_MISSING_PYGMENTS: + return + print( + "WARN: Pygments is not installed; language-tagged code blocks will render monochrome. " + "Install with `python3 -m pip install Pygments` to enable syntax highlighting.", + file=sys.stderr, + ) + _WARNED_MISSING_PYGMENTS = True + + +def _build_kami_style(): + from pygments.style import Style + from pygments.token import ( + Comment, Keyword, Literal, Name, Number, Operator, + Punctuation, String, Token, + ) + + palette = _kami_palette() + + class KamiStyle(Style): + background_color = "" + default_style = "" + styles = { + Token: "", + Comment: palette["stone"], + Comment.Single: palette["stone"], + Comment.Multiline: palette["stone"], + Comment.Preproc: palette["stone"], + Keyword: palette["brand"], + Keyword.Constant: palette["brand"], + Keyword.Namespace: palette["brand"], + Keyword.Type: palette["brand"], + Name.Builtin: palette["brand"], + Name.Function: palette["near_black"], + Name.Class: palette["near_black"], + Name.Decorator: palette["olive"], + String: palette["olive"], + String.Doc: palette["stone"], + Number: palette["dark_warm"], + Number.Float: palette["dark_warm"], + Number.Integer: palette["dark_warm"], + Literal: palette["dark_warm"], + Operator: "", + Punctuation: "", + } + + return KamiStyle + + +def _highlight_block(match: re.Match[str]) -> str: + from pygments import highlight as pyg_highlight + from pygments.formatters import HtmlFormatter + from pygments.lexers import get_lexer_by_name + + open_tag = match.group(1) + language = match.group(2) + code = match.group(3) + close_tag = match.group(4) + + code_text = html_mod.unescape(code) + + try: + lexer = get_lexer_by_name(language, stripall=False) + except Exception: + return match.group(0) + + formatter = HtmlFormatter( + style=_build_kami_style(), + noclasses=True, + nowrap=True, + ) + + highlighted = pyg_highlight(code_text, lexer, formatter) + return f'{open_tag}{highlighted}{close_tag}' + + +def highlight_code_blocks(html_text: str) -> str: + """Apply syntax highlighting to language-tagged code blocks. + + Returns HTML unchanged if Pygments is not installed or no + language-tagged blocks are found. + """ + if not CODE_BLOCK_RE.search(html_text): + return html_text + + try: + import pygments # noqa: F401 + except ImportError: + _warn_missing_pygments() + return html_text + + return CODE_BLOCK_RE.sub(_highlight_block, html_text) diff --git a/scripts/lint.py b/scripts/lint.py new file mode 100644 index 0000000..6c44e68 --- /dev/null +++ b/scripts/lint.py @@ -0,0 +1,425 @@ +"""Template lint rules and cross-template consistency checks. + +Splits out from build.py: + - scan_file: per-line + per-block lint for HTML/CSS/PPTX templates. + - check_all: scan every template and aggregate findings by rule. + - check_cross_template_consistency: pair CN/EN templates and report :root + variable drift outside the allowlist. + +Each `Finding` is anchored to a file path + line number so editors can jump +straight to the violation. Rules encode real WeasyPrint pitfalls (rgba on +background, thin border with border-radius, etc.), not style preferences. +""" +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from pathlib import Path + +from shared import ( + COOL_GRAY_BLOCKLIST, + HTML_TEMPLATES, + ROOT, + SCREEN_TEMPLATES, + TEMPLATES, + TOKENS_FILE, + iter_template_files, +) +from tokens import ROOT_BLOCK, parse_root_vars + +# Font-stack vars legitimately differ between a base template and its locale +# variants (-en, -ko); every other :root var must match across the pair. +CROSS_TEMPLATE_ALLOWED_VARS = {"--serif", "--sans", "--mono", "--latin-ui"} + +RGBA_BG_DIRECT = re.compile(r"background(?:-color)?\s*:\s*[^;]*rgba\s*\(", re.IGNORECASE) +RGBA_VAR_DEF = re.compile(r"--([\w-]+)\s*:\s*[^;]*rgba\s*\(", re.IGNORECASE) +BG_VAR_USE = re.compile(r"background(?:-color)?\s*:\s*[^;]*var\s*\(\s*--([\w-]+)", re.IGNORECASE) +RGBA_BORDER_DIRECT = re.compile(r"border(?:-\w+)?\s*:\s*[^;]*rgba\s*\(", re.IGNORECASE) +BORDER_VAR_USE = re.compile(r"border(?:-\w+)?\s*:\s*[^;]*var\s*\(\s*--([\w-]+)", re.IGNORECASE) +LINE_HEIGHT_LOOSE = re.compile(r"line-height\s*:\s*1\.[6-9]\d*", re.IGNORECASE) +UNICODE_ARROW = re.compile(r"→") # U+2192; should not appear in EN template body +HEX_ANY = re.compile(r"#[0-9a-fA-F]{3,6}\b") +# Thin closed border: border shorthand (not single-side) with sub-1pt width -- pitfall #2 +THIN_CLOSED_BORDER = re.compile( + r"border(?!-(?:left|right|top|bottom))\s*:\s*[^;]*0\.\d+pt", + re.IGNORECASE, +) +BORDER_RADIUS_PROP = re.compile(r"border-radius\s*:", re.IGNORECASE) +CSS_BLOCK_COMMENT_RE = re.compile(r"/\*.*?\*/", re.DOTALL) +SVG_BLOCK_RE = re.compile(r"<svg\b.*?</svg>", re.DOTALL | re.IGNORECASE) + +# WeasyPrint-unsafe artifacts of un-normalized beautiful-mermaid SVG. These must +# never reach a PDF-bound template/diagram: WeasyPrint does not resolve +# color-mix(), render <foreignObject>, or fetch a runtime web font. The author +# must pipe Mermaid output through scripts/mermaid_normalize.py first. Screen-only +# landing pages are exempt (color-mix in CSS is fine in a real browser). +MERMAID_UNSAFE = { + "mermaid-color-mix": re.compile(r"color-mix\s*\(", re.IGNORECASE), + "mermaid-foreignobject": re.compile(r"<foreignObject\b", re.IGNORECASE), + "mermaid-webfont-import": re.compile(r"fonts\.googleapis\.com", re.IGNORECASE), +} + + +@dataclass +class Finding: + file: Path + line: int + rule: str + excerpt: str + + +def _strip_css_block_comments(text: str) -> str: + """Replace `/* ... */` with spaces of the same length so commented-out + rgba()/cool-gray literals don't trip the per-line scan. Length-preserving + so line numbers and per-line search offsets remain correct. + """ + def repl(m: re.Match[str]) -> str: + return "".join(ch if ch == "\n" else " " for ch in m.group(0)) + return CSS_BLOCK_COMMENT_RE.sub(repl, text) + + +def scan_file(path: Path) -> list[Finding]: + findings: list[Finding] = [] + raw_text = path.read_text(encoding="utf-8", errors="replace") + text = _strip_css_block_comments(raw_text) + lines = text.splitlines() + + # Pass 1: collect variable names that hold rgba(...) so the tag-background + # bug can be detected through one level of indirection. + rgba_vars: set[str] = set() + for raw in lines: + m = RGBA_VAR_DEF.search(raw) + if m: + rgba_vars.add(m.group(1)) + + is_en = path.name.endswith("-en.html") + # Screen-only templates (landing pages) never go through WeasyPrint, so the + # Mermaid-unsafe-SVG rule does not apply to them. + is_screen = path.name in set(SCREEN_TEMPLATES.values()) + + # Pass 2: per-line rule checks + is_python = path.suffix == ".py" + for i, raw in enumerate(lines, start=1): + line = raw.strip() + if not line: + continue + # Skip comment lines. Note: '#' alone is NOT a CSS or HTML comment; it + # is the start of a CSS id selector (e.g. `#hero-bg { ... }`) or part of + # a hex literal. Only treat '#' as a comment when scanning Python. + if line.startswith("//"): + continue + if line.startswith("<!--"): + continue + if is_python and line.startswith("#"): + continue + + if RGBA_BG_DIRECT.search(raw): + findings.append(Finding(path, i, "rgba-background", + "rgba() used directly on background (tag double-rectangle bug)")) + + bg_var = BG_VAR_USE.search(raw) + if bg_var and bg_var.group(1) in rgba_vars: + findings.append(Finding(path, i, "rgba-background", + f"background: var(--{bg_var.group(1)}) resolves to rgba() (tag double-rectangle bug)")) + + if RGBA_BORDER_DIRECT.search(raw): + findings.append(Finding(path, i, "rgba-border", + "rgba() used on border (violates solid-color invariant)")) + + border_var = BORDER_VAR_USE.search(raw) + if border_var and border_var.group(1) in rgba_vars: + findings.append(Finding(path, i, "rgba-border", + f"border: var(--{border_var.group(1)}) resolves to rgba() (solid-color invariant)")) + + if is_en and UNICODE_ARROW.search(raw): + # skip CSS comment lines (/* ... */) and the arrow-in-CSS-content patterns + stripped = raw.lstrip() + if not stripped.startswith("/*") and not stripped.startswith("*") and "content:" not in raw: + findings.append(Finding(path, i, "arrow-unicode-in-en", + "to (U+2192) in English template; use 'to' or '->' per patterns Section 2")) + + m = LINE_HEIGHT_LOOSE.search(raw) + if m: + findings.append(Finding(path, i, "line-height-too-loose", + f"{m.group(0)} exceeds 1.55 ceiling")) + + for hex_match in HEX_ANY.finditer(raw): + h = hex_match.group(0).lower() + if h in COOL_GRAY_BLOCKLIST: + findings.append(Finding(path, i, "cool-gray", + f"{h} is a cool / neutral gray, use warm undertone")) + + if not is_screen and not is_python: + for rule, pattern in MERMAID_UNSAFE.items(): + if pattern.search(raw): + findings.append(Finding(path, i, rule, + "un-normalized Mermaid SVG (run scripts/mermaid_normalize.py before embedding)")) + + # Pass 3: thin-border-radius block scan (pitfall #2 double-ring). + # For each thin closed border line, scan backward to the block open and + # forward to the block close, checking for border-radius in the same block. + for i, raw in enumerate(lines): + if not THIN_CLOSED_BORDER.search(raw): + continue + if "skip-thin-border-radius" in raw: + continue + found = False + # Scan backward; stop at { or } (entering/leaving a block). + for j in range(i - 1, max(0, i - 6) - 1, -1): + if "{" in lines[j] or "}" in lines[j]: + break + if BORDER_RADIUS_PROP.search(lines[j]): + found = True + break + # Scan forward; stop at } (leaving the block). + if not found: + for j in range(i + 1, min(len(lines), i + 6)): + if "}" in lines[j]: + break + if BORDER_RADIUS_PROP.search(lines[j]): + found = True + break + if found: + findings.append(Finding(path, i + 1, "thin-border-radius", + "thin border (<1pt) with border-radius -- pitfall #2 double-ring risk")) + return findings + + +def check_all(verbose: bool) -> int: + targets = iter_template_files(include_py=True, include_diagrams=True, include_marp_css=True) + if not targets: + print("ERROR: no templates found to lint (bad checkout?)") + return 2 + + findings: list[Finding] = [] + for p in targets: + file_findings = scan_file(p) + findings.extend(file_findings) + if verbose: + print(f"scanned {p.relative_to(ROOT)}: {len(file_findings)} finding(s)") + + if not findings: + print(f"OK: no violations across {len(targets)} templates") + return 0 + + by_rule: dict[str, list[Finding]] = {} + for f in findings: + by_rule.setdefault(f.rule, []).append(f) + + print(f"ERROR: {len(findings)} violation(s) across {len({f.file for f in findings})} file(s)") + for rule, items in by_rule.items(): + print(f"\n[{rule}] {len(items)}") + for f in items: + rel = f.file.relative_to(ROOT) + print(f" {rel}:{f.line} {f.excerpt}") + return 1 + + +# ---------- off-palette color guard ---------- +# +# design.md core invariant: a single chromatic accent (ink-blue) plus warm +# neutrals, zero cool tones. The salmon-border regression slipped past the +# token-drift guard because it was a hardcoded hex inside a component rule, not +# a :root token. This guard mechanizes the invariant: any hex literal in an +# editorial template that is neither a registered token value nor a cool-gray +# (those have their own rule) is an off-palette color. The single sanctioned +# semantic exception (the changelog breaking-change badge) is registered as the +# --breaking-* tokens, so it lands in `allowed` and passes. +# +# Scope is deliberately narrow: editorial TEMPLATES/*.html only. Diagrams use +# warm-gray chart ramps that are intentionally not tokens, and inline <svg> +# charts carry their own fills -- both are skipped (diagrams by directory, svg +# by block). :root blocks define the tokens themselves, so they are skipped too. + + +def _blank_block(text: str, regex: re.Pattern[str]) -> str: + """Replace each match with same-length whitespace (newlines preserved) so + line numbers stay accurate after a block is masked out.""" + def repl(m: re.Match[str]) -> str: + return "".join(ch if ch == "\n" else " " for ch in m.group(0)) + return regex.sub(repl, text) + + +def _load_token_values() -> set[str]: + """Return the set of canonical token hex values (lowercased).""" + if not TOKENS_FILE.exists(): + return set() + try: + data = json.loads(TOKENS_FILE.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return set() + return {v.lower() for v in data.values() if isinstance(v, str) and v.startswith("#")} + + +def _off_palette_findings(path: Path, allowed: set[str]) -> list[Finding]: + raw = path.read_text(encoding="utf-8", errors="replace") + text = _strip_css_block_comments(raw) + text = _blank_block(text, ROOT_BLOCK) + text = _blank_block(text, SVG_BLOCK_RE) + findings: list[Finding] = [] + for i, line in enumerate(text.splitlines(), start=1): + for m in HEX_ANY.finditer(line): + h = m.group(0).lower() + if h in allowed: + continue + if h in COOL_GRAY_BLOCKLIST: + continue # reported by the cool-gray rule in scan_file + findings.append(Finding(path, i, "off-palette", + f"{h} is not a registered token; single-accent palette violated")) + return findings + + +ROOT_TOKEN_DEF = re.compile(r"(--[\w-]+)\s*:\s*(#[0-9a-fA-F]{3,6})\b") + + +def _root_token_findings(path: Path, allowed: set[str]) -> list[Finding]: + """Flag `:root` token definitions whose hex is off the registered palette. + + `_off_palette_findings` blanks the `:root` block before scanning property + values, so a dead or off-palette token *defined* in `:root` but never written + as a literal hex in a property escapes every guard (this is how a stray + `--brand-deep: #a64f33` second accent hid in portfolio.html). This closes that + gap for print templates: every `:root` chromatic token must resolve to a + registered tokens.json value. Screen templates (landing pages) keep their own + local tokens outside the print palette, so callers exempt them. + """ + raw = path.read_text(encoding="utf-8", errors="replace") + text = _strip_css_block_comments(raw) + findings: list[Finding] = [] + for block in ROOT_BLOCK.finditer(text): + body_start = block.start(1) + for vm in ROOT_TOKEN_DEF.finditer(block.group(1)): + h = vm.group(2).lower() + if h in allowed: + continue + if h in COOL_GRAY_BLOCKLIST: + continue # reported by the cool-gray rule in scan_file + line = text.count("\n", 0, body_start + vm.start(2)) + 1 + findings.append(Finding(path, line, "off-palette-token", + f"{vm.group(1)}: {h} is a :root token off the registered palette " + "(single-accent invariant; register in tokens.json or remove)")) + return findings + + +def check_off_palette(verbose: bool = False) -> int: + allowed = _load_token_values() + screen_names = set(SCREEN_TEMPLATES.values()) + targets = sorted(TEMPLATES.glob("*.html")) + if not targets: + print("ERROR: no templates found for off-palette scan (bad checkout?)") + return 2 + findings: list[Finding] = [] + for p in targets: + file_findings = _off_palette_findings(p, allowed) + if p.name not in screen_names: + file_findings.extend(_root_token_findings(p, allowed)) + findings.extend(file_findings) + if verbose: + print(f"scanned {p.relative_to(ROOT)}: {len(file_findings)} off-palette finding(s)") + + if not findings: + print(f"OK: no off-palette colors across {len(targets)} template(s)") + return 0 + + print(f"\nERROR: [off-palette] {len(findings)}") + for f in findings: + print(f" {f.file.relative_to(ROOT)}:{f.line} {f.excerpt}") + return 1 + + +# ---------- cross-template consistency ---------- +# +# The project intentionally ships CN/EN templates as forked single-file HTML +# (no shared partials). The price of that decision is drift: a maintainer +# updating one side of a pair can silently leave the other behind. This check +# pairs each base template (e.g. `foo.html`) with every recognized locale +# variant (`foo-en.html`, `foo-ko.html`), parses the `:root { ... }` block of +# each, and flags variables that differ. Font-stack variables (`--serif`, +# `--sans`, `--mono`, `--latin-ui`) are allowlisted because each locale +# deliberately uses different fonts. + +_VARIANT_SUFFIXES: tuple[str, ...] = ("-en", "-ko") + + +def _pair_names() -> list[tuple[str, str]]: + """Return [(base_name, variant_name), ...] for every base template that has + one of the recognized locale-variant siblings (`-en`, `-ko`). + + A base template is any registered name that does not itself end in a + recognized variant suffix. + """ + pairs: list[tuple[str, str]] = [] + seen = set(HTML_TEMPLATES) | set(SCREEN_TEMPLATES) + for name in sorted(seen): + if any(name.endswith(s) for s in _VARIANT_SUFFIXES): + continue + for suffix in _VARIANT_SUFFIXES: + variant = f"{name}{suffix}" + if variant in seen: + pairs.append((name, variant)) + return pairs + + +def _source_for(name: str) -> tuple[Path, Path]: + """Return (source path, directory) for a template name across registries.""" + if name in HTML_TEMPLATES: + return TEMPLATES / HTML_TEMPLATES[name].source, TEMPLATES + if name in SCREEN_TEMPLATES: + return TEMPLATES / SCREEN_TEMPLATES[name], TEMPLATES + raise KeyError(f"unknown template name: {name}") + + +def _extract_root_vars(html_path: Path) -> dict[str, str]: + """Return {var_name: value} merged across every `:root { ... }` block.""" + text = html_path.read_text(encoding="utf-8", errors="replace") + return parse_root_vars(text) + + +def check_cross_template_consistency(verbose: bool = False) -> int: + pairs = _pair_names() + if not pairs: + print("ERROR: no base-variant template pairs found (bad checkout?)") + return 2 + drift: list[tuple[str, str, str, str]] = [] # (pair, var, base_value, variant_value) + + for base_name, variant_name in pairs: + try: + base_path, _ = _source_for(base_name) + variant_path, _ = _source_for(variant_name) + except KeyError: + continue + if not base_path.exists() or not variant_path.exists(): + continue + + base_vars = _extract_root_vars(base_path) + variant_vars = _extract_root_vars(variant_path) + + shared_keys = set(base_vars) & set(variant_vars) + for key in sorted(shared_keys): + if key in CROSS_TEMPLATE_ALLOWED_VARS: + continue + if base_vars[key].lower() != variant_vars[key].lower(): + drift.append((base_name, key, base_vars[key], variant_vars[key])) + + # A var defined on only one side is drift too: it usually means a + # maintainer added or dropped a token on one side of the fork. That is + # exactly the "left the other side behind" failure this check exists + # to catch, so report it instead of silently comparing the overlap. + for key in sorted((set(base_vars) ^ set(variant_vars)) - CROSS_TEMPLATE_ALLOWED_VARS): + if key in base_vars: + drift.append((base_name, key, base_vars[key], f"missing from {variant_name}")) + else: + drift.append((base_name, key, f"missing from {base_name}", variant_vars[key])) + + if verbose: + print(f" pair {base_name}/{variant_name}: checked {len(shared_keys)} shared vars") + + if not drift: + print(f"OK: cross-template :root vars in sync across {len(pairs)} base-variant pair(s)") + return 0 + + print(f"\nERROR: [cross-template-drift] {len(drift)}") + for pair, var, base_val, variant_val in drift: + print(f" {pair}: {var} base={base_val} variant={variant_val}") + return 1 diff --git a/scripts/mermaid_normalize.py b/scripts/mermaid_normalize.py new file mode 100644 index 0000000..6d942a1 --- /dev/null +++ b/scripts/mermaid_normalize.py @@ -0,0 +1,343 @@ +#!/usr/bin/env python3 +"""Re-theme and normalize a beautiful-mermaid SVG into a Kami-styled, WeasyPrint-safe SVG. + +beautiful-mermaid (https://github.com/lukilabs/beautiful-mermaid) hangs its seven +color roles on CSS custom properties on the root ``<svg>`` and derives the rest with +``color-mix(in srgb, ...)``. WeasyPrint's inline-SVG renderer does not resolve +``color-mix()``, does not reliably cascade SVG ``<style>`` custom properties, and +should not fetch a runtime web font. This script: + + 1. **Re-themes** the SVG to the Kami palette by overriding the seven root color + roles with ``references/mermaid-theme.json`` values, so the diagram looks like + Kami no matter which theme it was generated with. + 2. **Resolves** every ``var()`` and ``color-mix(in srgb, ...)`` to a static hex, so + colors land on inline presentation attributes WeasyPrint renders directly. + 3. **Fixes fonts**: strips beautiful-mermaid's Google-Fonts ``@import`` and rewrites + the (mis-quoted) ``font-family`` to the Kami serif stack (with CJK fallback). + +No Node, no network, pure stdlib. Generate the SVG anywhere beautiful-mermaid runs +(e.g. https://agents.craft.do/mermaid or your own one-off script), then run this. + +Scope: graph-type diagrams (flowchart / state / sequence / class / ER) whose colors +flow from the seven root roles. xychart-beta styles via ``<style>`` class selectors, +which WeasyPrint will not apply to inline SVG, so charts stay browser-only; use +Kami's hand-drawn bar/line/donut/candlestick/waterfall diagrams for PDF. See +references/mermaid.md. + +Usage: + python3 scripts/mermaid_normalize.py raw.svg # cleaned SVG to stdout + python3 scripts/mermaid_normalize.py raw.svg -o out.svg + cat raw.svg | python3 scripts/mermaid_normalize.py - # read from stdin +""" +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path + +_ROOT = Path(__file__).resolve().parent.parent +_THEME_FILE = _ROOT / "references" / "mermaid-theme.json" + +# Fallbacks if references/mermaid-theme.json is missing. Mirror that file and +# references/design.md. +_DEFAULT_FONT_STACK = ( + 'Charter, Georgia, "TsangerJinKai02", "Source Han Serif SC", ' + '"Noto Serif CJK SC", serif' +) +_DEFAULT_COLORS = { + "--bg": "#f5f4ed", "--fg": "#141413", "--line": "#504e49", + "--accent": "#1B365D", "--muted": "#6b6a64", "--surface": "#faf9f5", + "--border": "#e8e6dc", +} + +# A handful of CSS named colors beautiful-mermaid may emit. Hex is preferred. +_NAMED = {"white": (255, 255, 255), "black": (0, 0, 0), "transparent": None} + + +def _load_theme() -> tuple[dict[str, str], str]: + """Return (kami color-role overrides, font stack) from the theme file.""" + try: + data = json.loads(_THEME_FILE.read_text(encoding="utf-8")) + colors = {f"--{k}": v for k, v in data.get("colors", {}).items()} + font = data.get("cssFontStack") or _DEFAULT_FONT_STACK + if colors: + return colors, font + except (OSError, ValueError): + pass + return dict(_DEFAULT_COLORS), _DEFAULT_FONT_STACK + + +# --------------------------------------------------------------------------- +# color helpers +# --------------------------------------------------------------------------- + +def _parse_hex(value: str) -> tuple[int, int, int]: + h = value.strip().lstrip("#") + if len(h) == 3: + h = "".join(c * 2 for c in h) + if len(h) != 6: + raise ValueError(f"not a hex color: {value!r}") + return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16) + + +def _to_hex(rgb: tuple[float, float, float]) -> str: + return "#" + "".join(f"{max(0, min(255, round(c))):02x}" for c in rgb) + + +def _mix_srgb(c1: tuple[int, int, int], p1: float, + c2: tuple[int, int, int], p2: float) -> tuple[float, float, float]: + """color-mix(in srgb, c1 p1%, c2 p2%): linear blend in gamma sRGB. + + Percentages are normalized to sum to 100 (per CSS Color 4). + """ + total = p1 + p2 or 1.0 + w1, w2 = p1 / total, p2 / total + return tuple(a * w1 + b * w2 for a, b in zip(c1, c2)) + + +# --------------------------------------------------------------------------- +# balanced-paren parsing +# --------------------------------------------------------------------------- + +def _match_paren(s: str, open_idx: int) -> int: + """Given index of a '(', return index of its matching ')'.""" + depth = 0 + for i in range(open_idx, len(s)): + if s[i] == "(": + depth += 1 + elif s[i] == ")": + depth -= 1 + if depth == 0: + return i + raise ValueError("unbalanced parentheses") + + +def _split_top(s: str, sep: str = ",") -> list[str]: + """Split on `sep` at paren depth 0 only.""" + parts, depth, start = [], 0, 0 + for i, ch in enumerate(s): + if ch == "(": + depth += 1 + elif ch == ")": + depth -= 1 + elif ch == sep and depth == 0: + parts.append(s[start:i]) + start = i + 1 + parts.append(s[start:]) + return [p.strip() for p in parts] + + +class _Resolver: + """Resolves a CSS color value to a static hex using a custom-property map.""" + + def __init__(self, raw_defs: dict[str, str]): + self._raw = raw_defs + + def hex_of(self, value: str) -> str | None: + rgb = self._rgb(value) + return None if rgb is None else _to_hex(rgb) + + def var_map(self) -> dict[str, str]: + out: dict[str, str] = {} + for name in self._raw: + h = self.hex_of(f"var({name})") + if h is not None: + out[name] = h + return out + + def _rgb(self, value: str, _seen: frozenset[str] = frozenset()) -> tuple[int, int, int] | None: + value = value.strip().rstrip(";").strip() + if not value: + return None + if value.startswith("#"): + return _parse_hex(value) + low = value.lower() + if low in _NAMED: + return _NAMED[low] + if value.startswith("var(") and value.endswith(")"): + inner = value[4:_match_paren(value, 3)] + args = _split_top(inner) + name = args[0].strip() + fallback = args[1] if len(args) > 1 else None + if name in _seen: + return None # cycle guard + if name in self._raw: + return self._rgb(self._raw[name], _seen | {name}) + if fallback is not None: + return self._rgb(fallback, _seen | {name}) + return None + if value.startswith("color-mix(") and value.endswith(")"): + inner = value[len("color-mix("):_match_paren(value, len("color-mix"))] + args = _split_top(inner) + # args[0] is the color space, e.g. "in srgb" + if not args or "srgb" not in args[0]: + raise ValueError(f"unsupported color-mix space: {args[0] if args else '?'}") + ops = [self._parse_operand(a, _seen) for a in args[1:3]] + (c1, p1), (c2, p2) = ops[0], ops[1] + if p1 is None and p2 is None: + p1 = p2 = 50.0 + elif p1 is None: + p1 = max(0.0, 100.0 - p2) + elif p2 is None: + p2 = max(0.0, 100.0 - p1) + if c1 is None or c2 is None: + return None + return tuple(round(x) for x in _mix_srgb(c1, p1, c2, p2)) + return None + + def _parse_operand(self, token: str, + _seen: frozenset[str]) -> tuple[tuple[int, int, int] | None, float | None]: + """Parse a color-mix operand 'COLOR' or 'COLOR P%'.""" + token = token.strip() + pct = None + m = re.search(r"\s(\d+(?:\.\d+)?)%\s*$", " " + token) + if m: + pct = float(m.group(1)) + token = token[: token.rfind(m.group(1) + "%")].strip() + return self._rgb(token, _seen), pct + + +# --------------------------------------------------------------------------- +# SVG transforms +# --------------------------------------------------------------------------- + +_STYLE_RE = re.compile(r"<style[^>]*>(.*?)</style>", re.DOTALL) +_CUSTOM_PROP_RE = re.compile(r"(--[\w-]+)\s*:\s*([^;]*?(?:\([^)]*\)[^;]*?)*);") +_IMPORT_RE = re.compile(r"@import\s+url\([^)]*\)\s*;", re.IGNORECASE) +_FONT_FAMILY_RE = re.compile(r"font-family\s*:\s*[^;}]+") + + +def _collect_raw_defs(svg: str, overrides: dict[str, str]) -> dict[str, str]: + """Gather custom-property defs from the root <svg style> and <style> svg{} rules. + + `overrides` (the Kami color roles) replace any same-named root role, re-theming + the diagram regardless of the theme it was generated with. + """ + raw: dict[str, str] = {} + m = re.search(r"<svg\b[^>]*\bstyle=\"([^\"]*)\"", svg) + if m: + for prop in m.group(1).split(";"): + if ":" in prop: + k, v = prop.split(":", 1) + k = k.strip() + if k.startswith("--"): + raw[k] = v.strip() + for sm in _STYLE_RE.finditer(svg): + for pm in _CUSTOM_PROP_RE.finditer(sm.group(1)): + raw[pm.group(1)] = pm.group(2).strip() + raw.update(overrides) # Kami palette wins + return raw + + +def _resolve_functions(text: str, resolver: _Resolver) -> str: + """Replace every var()/color-mix() in `text` with a static hex, innermost-first. + + Raises if any function cannot be resolved to a color: in well-formed + beautiful-mermaid output every var()/color-mix() resolves, so an unresolved + one means the input structure changed. Failing loudly beats silently emitting + an invalid color that renders as a black or invisible diagram. + """ + while True: + starts = [m.start() for m in re.finditer(r"\b(?:var|color-mix)\(", text)] + if not starts: + break + # rightmost opening = guaranteed leaf (no nested var/color-mix after it) + start = max(starts) + open_paren = text.index("(", start) + close = _match_paren(text, open_paren) + expr = text[start:close + 1] + hexval = resolver.hex_of(expr) + if hexval is None: + raise ValueError( + f"could not resolve {expr!r} to a color; the input may not be " + "beautiful-mermaid output or uses an unsupported structure" + ) + text = text[:start] + hexval + text[close + 1:] + return text + + +def _assert_beautiful_mermaid(svg: str) -> None: + """Raise unless the SVG carries beautiful-mermaid's root color-role props. + + beautiful-mermaid v1.x defines --bg / --fg (plus the optional roles) as inline + custom properties on the root <svg>. Their absence means the input came from a + different renderer whose structure this normalizer does not understand, so we + fail loudly here instead of silently producing unresolved colors downstream. + Verified against beautiful-mermaid v1.1.3; see references/mermaid.md. + """ + m = re.search(r"<svg\b[^>]*\bstyle=\"([^\"]*)\"", svg) + style = m.group(1) if m else "" + if "--bg" not in style or "--fg" not in style: + raise ValueError( + "input does not look like beautiful-mermaid output: the root <svg> is " + "missing the --bg/--fg color roles (verified against v1.1.3)" + ) + + +def normalize(svg: str, theme: dict[str, str] | None = None, + font_stack: str | None = None) -> str: + """Return a Kami-re-themed, WeasyPrint-safe copy of a beautiful-mermaid SVG.""" + if theme is None or font_stack is None: + default_colors, default_font = _load_theme() + theme = theme or default_colors + font_stack = font_stack or default_font + + _assert_beautiful_mermaid(svg) + raw_defs = _collect_raw_defs(svg, theme) + resolver = _Resolver(raw_defs) + + out = _resolve_functions(svg, resolver) + out = _IMPORT_RE.sub("", out) + out = _FONT_FAMILY_RE.sub(f"font-family: {font_stack}", out) + + # The derived custom props were inlined into presentation attributes, so the + # <style> svg{} rules and the root role decls are now dead. Strip them, keeping + # only live rules (e.g. the rewritten font-family). + def _clean_style(m: re.Match[str]) -> str: + body = m.group(1) + body = re.sub(r"--[\w-]+\s*:\s*#[0-9a-fA-F]{3,8}\s*;", "", body) + body = re.sub(r"[\w*.#-]+\s*\{\s*(?:/\*.*?\*/\s*)?\}", "", body, flags=re.DOTALL) + body = re.sub(r"\n\s*\n+", "\n", body).strip() + return f"<style>\n {body}\n</style>" if body else "" + + out = _STYLE_RE.sub(_clean_style, out) + + def _clean_root_style(m: re.Match[str]) -> str: + kept = [d.strip() for d in m.group(1).split(";") + if d.strip() and not d.strip().startswith("--")] + return f' style="{";".join(kept)}"' if kept else "" + + out = re.sub(r'\s+style="([^"]*)"', _clean_root_style, out, count=1) + return out + + +def main(argv: list[str]) -> int: + if len(argv) == 1: + print(__doc__) + return 0 + + parser = argparse.ArgumentParser( + description="Re-theme a beautiful-mermaid SVG into a Kami, WeasyPrint-safe SVG.", + ) + parser.add_argument("src", help="Input SVG path, or '-' to read from stdin") + parser.add_argument("-o", "--output", help="Output SVG path (default: stdout)") + args = parser.parse_args(argv[1:]) + + try: + raw = sys.stdin.read() if args.src == "-" else Path(args.src).read_text(encoding="utf-8") + result = normalize(raw) + if args.output: + Path(args.output).write_text(result, encoding="utf-8") + print(f"OK: wrote {args.output}") + else: + sys.stdout.write(result) + except (OSError, ValueError) as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/scripts/optional_deps.py b/scripts/optional_deps.py new file mode 100644 index 0000000..f3c1efa --- /dev/null +++ b/scripts/optional_deps.py @@ -0,0 +1,74 @@ +"""Centralized loader for optional third-party deps (weasyprint, pypdf, PyMuPDF). + +build.py previously had inline `from weasyprint import HTML` try/except blocks +with duplicated install hints; this module collapses those into one resolver +each so the import error message stays consistent and the WeasyPrint runtime +is configured once at the import call site. +""" +from __future__ import annotations + +import sys + +from shared import configure_weasyprint_runtime + +# On Linux, WeasyPrint links against cairo / pango / harfbuzz at runtime; a bare +# `pip install weasyprint` succeeds but then fails to load with a cryptic +# "cannot load library 'libgobject-2.0'" until the native libs are present. Spell +# them out so the error message is actionable on a fresh Linux box. +_LINUX_NATIVE_LIBS = ( + "Linux also needs native libs: " + "sudo apt-get install -y libcairo2 libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz0b " + "(Debian/Ubuntu), or `sudo dnf install cairo pango harfbuzz` (Fedora/RHEL)" +) + +WEASYPRINT_INSTALL_HINT = "pip install weasyprint pypdf --break-system-packages" +if sys.platform.startswith("linux"): + WEASYPRINT_INSTALL_HINT = f"{WEASYPRINT_INSTALL_HINT}. {_LINUX_NATIVE_LIBS}" +PYMUPDF_INSTALL_HINT = "pip install pymupdf --break-system-packages" + + +class MissingDepError(RuntimeError): + """Raised when an optional dependency is requested but not installed.""" + + +def require_weasyprint_html(): + """Return the weasyprint.HTML class, configuring native libs first.""" + configure_weasyprint_runtime() + try: + from weasyprint import HTML + return HTML + except ImportError as exc: + raise MissingDepError( + f"missing weasyprint. {WEASYPRINT_INSTALL_HINT}" + ) from exc + + +def _require_pypdf_attr(name: str): + try: + import pypdf + except ImportError as exc: + raise MissingDepError( + f"missing pypdf. {WEASYPRINT_INSTALL_HINT}" + ) from exc + return getattr(pypdf, name) + + +def require_pypdf_reader(): + """Return the pypdf.PdfReader class.""" + return _require_pypdf_attr("PdfReader") + + +def require_pypdf_writer(): + """Return the pypdf.PdfWriter class.""" + return _require_pypdf_attr("PdfWriter") + + +def require_pymupdf(): + """Return the PyMuPDF module (imported as fitz).""" + try: + import fitz + return fitz + except ImportError as exc: + raise MissingDepError( + f"missing PyMuPDF. {PYMUPDF_INSTALL_HINT}" + ) from exc diff --git a/scripts/package-skill.sh b/scripts/package-skill.sh new file mode 100755 index 0000000..b2820fe --- /dev/null +++ b/scripts/package-skill.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +OUT="${1:-"$ROOT/dist/kami.zip"}" +case "$OUT" in + /*) ;; + *) OUT="$ROOT/$OUT" ;; +esac +PACKAGE_ROOT_NAME="${KAMI_PACKAGE_ROOT_NAME:-kami}" +PACKAGE_MAX_BYTES="${KAMI_PACKAGE_MAX_BYTES:-6000000}" +PACKAGE_FORBIDDEN_RE='^(\.agents/|\.claude/|\.claude-plugin/|\.github/|plugins/|assets/(showcase|demos|examples|illustrations)/|assets/images/[123]\.png$|assets/fonts/TsangerJinKai02-W0[45]\.ttf$|assets/fonts/SourceHanSerifKR-(Regular|Medium)\.otf$|dist/|index(-[^/]+)?\.html$|styles\.css$|llms\.txt$|robots\.txt$|sitemap\.xml$|vercel\.json$|AGENTS\.md$|CLAUDE\.md$|README\.md$|\.gitignore$|scripts/(build_metadata|draft-release-notes|package-skill)\.py$|scripts/package-skill\.sh$|scripts/tests/)' +PACKAGE_REQUIRED_ENTRIES=( + "SKILL.md" + "CHEATSHEET.md" + "VERSION" + "LICENSE" + "assets/images/logo.svg" + "assets/fonts/JetBrainsMono.woff2" + "assets/templates/resume.html" + "assets/templates/landing-page.html" + "assets/diagrams/sequence.html" + "references/design.md" + "scripts/build.py" + "scripts/ensure-fonts.sh" + "scripts/site_facts.py" +) + +mkdir -p "$(dirname "$OUT")" +rm -f "$OUT" + +cd "$ROOT" + +MANIFEST="$(mktemp)" +FILTERED_MANIFEST="$(mktemp)" +ZIP_MANIFEST="$(mktemp)" +STAGING="$(mktemp -d)" +trap 'rm -f "$MANIFEST" "$FILTERED_MANIFEST" "$ZIP_MANIFEST"; rm -rf "$STAGING"' EXIT + +git ls-files > "$MANIFEST" +awk ' + /(^|\/)__pycache__\// { next } + /\.pyc$/ { next } + /(^|\/)\.DS_Store$/ { next } + /^(SKILL\.md|CHEATSHEET\.md|VERSION|LICENSE)$/ { print; next } + /^assets\/templates\// { print; next } + /^assets\/diagrams\// { print; next } + /^assets\/images\/logo\.svg$/ { print; next } + /^assets\/fonts\/JetBrainsMono\.woff2$/ { print; next } + /^assets\/fonts\/LICENSE-SourceHanSerifK\.txt$/ { print; next } + /^references\// { print; next } + /^scripts\/(build|check-update|checks|ensure-fonts|highlight|lint|mermaid_normalize|optional_deps|shared|site_facts|tokens|verify)\.(py|sh)$/ { print; next } +' "$MANIFEST" > "$FILTERED_MANIFEST" + +# Coverage gate: every tracked scripts/ file must be either packaged by the +# allowlist above or named in the repo-only exclusion below. Without this, a +# new runtime module that build.py imports would silently miss the zip and +# the installed skill would ImportError while every local check stays green. +SCRIPTS_REPO_ONLY_RE='^scripts/(build_metadata\.py|draft-release-notes\.py|package-skill\.sh|tests/)' +unaccounted="$(grep '^scripts/' "$MANIFEST" \ + | grep -Ev "$SCRIPTS_REPO_ONLY_RE" \ + | grep -Fvx -f <(grep '^scripts/' "$FILTERED_MANIFEST" || true) || true)" +if [ -n "$unaccounted" ]; then + echo "ERROR: tracked scripts neither packaged nor listed as repo-only:" >&2 + printf '%s\n' "$unaccounted" >&2 + echo "Add them to the packaging allowlist or to SCRIPTS_REPO_ONLY_RE." >&2 + exit 1 +fi + +while IFS= read -r entry; do + dest="$STAGING/$PACKAGE_ROOT_NAME/$entry" + mkdir -p "$(dirname "$dest")" + cp -p "$entry" "$dest" +done < "$FILTERED_MANIFEST" + +( + cd "$STAGING" + find "$PACKAGE_ROOT_NAME" -type f | sort > "$ZIP_MANIFEST" + zip -X -q "$OUT" -@ < "$ZIP_MANIFEST" +) + +entries="$(zipinfo -1 "$OUT")" +bad_root="$(printf '%s\n' "$entries" | awk -v prefix="${PACKAGE_ROOT_NAME}/" 'index($0, prefix) != 1 { print }')" +if [ -n "$bad_root" ]; then + echo "ERROR: package entries must live under ${PACKAGE_ROOT_NAME}/:" >&2 + printf '%s\n' "$bad_root" >&2 + exit 1 +fi + +stripped_entries="$(printf '%s\n' "$entries" | sed "s#^${PACKAGE_ROOT_NAME}/##")" +if forbidden_entries="$(printf '%s\n' "$stripped_entries" | grep -E "$PACKAGE_FORBIDDEN_RE")"; then + echo "ERROR: disallowed package entry found in $OUT:" >&2 + printf '%s\n' "$forbidden_entries" >&2 + exit 1 +fi + +for required in "${PACKAGE_REQUIRED_ENTRIES[@]}"; do + if ! printf '%s\n' "$entries" | grep -Fxq "${PACKAGE_ROOT_NAME}/${required}"; then + echo "ERROR: required package entry missing from $OUT: ${PACKAGE_ROOT_NAME}/${required}" >&2 + exit 1 + fi +done + +size_bytes="$(wc -c < "$OUT" | tr -d '[:space:]')" +if (( size_bytes > PACKAGE_MAX_BYTES )); then + echo "ERROR: package exceeds ${PACKAGE_MAX_BYTES} bytes: ${size_bytes} bytes" >&2 + exit 1 +fi + +echo "OK: package audit passed (${size_bytes} bytes, limit ${PACKAGE_MAX_BYTES})" +echo "OK: wrote $OUT" diff --git a/scripts/shared.py b/scripts/shared.py new file mode 100644 index 0000000..304c1f6 --- /dev/null +++ b/scripts/shared.py @@ -0,0 +1,283 @@ +"""Shared constants and helpers for kami build scripts.""" +from __future__ import annotations + +import functools +import json +import os +import sys +from pathlib import Path +from typing import Any, NamedTuple + + +class TemplateSpec(NamedTuple): + """Per-template configuration. + + build_max_pages: hard ceiling enforced by `build.py --verify`. 0 = no limit. + """ + source: str + build_max_pages: int + +ROOT = Path(__file__).resolve().parent.parent +TEMPLATES = ROOT / "assets" / "templates" +DIAGRAMS = ROOT / "assets" / "diagrams" +EXAMPLES = ROOT / "assets" / "examples" +TOKENS_FILE = ROOT / "references" / "tokens.json" +CHECKS_THRESHOLDS_FILE = ROOT / "references" / "checks_thresholds.json" + +PUBLIC_REPO = "tw93/kami" +CLAUDE_CODE_MIN_VERSION = "2.1.142" +CLAUDE_CODE_INSTALL_COMMANDS = ( + f"/plugin marketplace add {PUBLIC_REPO}", + "/plugin install kami@kami", +) +CODEX_PLUGIN_INSTALL_COMMANDS = ( + f"codex plugin marketplace add {PUBLIC_REPO}", + "codex plugin add kami@kami", +) +GENERIC_AGENT_INSTALL_COMMAND = f"npx skills add {PUBLIC_REPO}/plugins/kami -a universal -g -y" +CLAUDE_DESKTOP_PACKAGE_URL = "https://github.com/tw93/kami/releases/latest/download/kami.zip" + +# Canonical parchment background color, kept here so build/density +# checks share one source of truth instead of redefining the RGB triple. +PARCHMENT_RGB = (0xF5, 0xF4, 0xED) + +_HOMEBREW_PREFIXES = (Path("/opt/homebrew"), Path("/usr/local")) + + +def _default_cache_dir() -> Path: + """Return a sensible per-platform fontconfig cache directory.""" + if sys.platform == "darwin": + return Path("/private/tmp/kami-fontconfig-cache") + xdg = os.environ.get("XDG_CACHE_HOME") + if xdg: + return Path(xdg) / "kami" + return Path.home() / ".cache" / "kami" + + +def configure_weasyprint_runtime() -> None: + """Make platform-native libraries discoverable before importing WeasyPrint. + + On macOS, also surface Homebrew's gobject lib so cairo/pango can load. + On Linux/other, only the fontconfig cache hint is set; the system loader + is expected to find the libraries. + """ + os.environ.setdefault("XDG_CACHE_HOME", str(_default_cache_dir())) + + if sys.platform != "darwin": + return + + brew_lib = next( + (p / "lib" for p in _HOMEBREW_PREFIXES if (p / "lib" / "libgobject-2.0.dylib").exists()), + None, + ) + if brew_lib is None: + return + + existing = os.environ.get("DYLD_FALLBACK_LIBRARY_PATH", "") + paths = [path for path in existing.split(":") if path] + brew_lib_str = str(brew_lib) + if brew_lib_str in paths: + return + + os.environ["DYLD_FALLBACK_LIBRARY_PATH"] = ":".join([brew_lib_str, *paths]) + +# Cool / neutral gray hex values that violate the "warm undertone only" rule. +COOL_GRAY_BLOCKLIST = { + "#888", "#888888", "#666", "#666666", "#999", "#999999", + "#ccc", "#cccccc", "#ddd", "#dddddd", "#eee", "#eeeeee", + "#111", "#111111", "#222", "#222222", "#333", "#333333", + "#444", "#444444", "#555", "#555555", "#777", "#777777", + "#aaa", "#aaaaaa", "#bbb", "#bbbbbb", + # Tailwind cool grays + "#6b7280", "#9ca3af", "#d1d5db", "#e5e7eb", "#f3f4f6", + "#4b5563", "#374151", "#1f2937", "#111827", + # Bootstrap-like neutrals + "#f8f9fa", "#e9ecef", "#dee2e6", "#ced4da", "#adb5bd", + "#6c757d", "#495057", "#343a40", "#212529", +} + + +# --------------------------------------------------------------------------- +# Template registry +# +# Single source of truth for HTML targets used by build.py. +# See TemplateSpec for field meanings. +# --------------------------------------------------------------------------- +HTML_TEMPLATES: dict[str, TemplateSpec] = { + # Core six + "one-pager": TemplateSpec("one-pager.html", 1), + "letter": TemplateSpec("letter.html", 1), + "long-doc": TemplateSpec("long-doc.html", 0), + "portfolio": TemplateSpec("portfolio.html", 0), + "resume": TemplateSpec("resume.html", 2), + "one-pager-en": TemplateSpec("one-pager-en.html", 1), + "letter-en": TemplateSpec("letter-en.html", 1), + "long-doc-en": TemplateSpec("long-doc-en.html", 0), + "portfolio-en": TemplateSpec("portfolio-en.html", 0), + "resume-en": TemplateSpec("resume-en.html", 2), + # Korean + "one-pager-ko": TemplateSpec("one-pager-ko.html", 1), + "letter-ko": TemplateSpec("letter-ko.html", 1), + "long-doc-ko": TemplateSpec("long-doc-ko.html", 0), + "portfolio-ko": TemplateSpec("portfolio-ko.html", 0), + "resume-ko": TemplateSpec("resume-ko.html", 2), + "equity-report-ko": TemplateSpec("equity-report-ko.html", 3), + "changelog-ko": TemplateSpec("changelog-ko.html", 2), + "slides-weasy-ko": TemplateSpec("slides-weasy-ko.html", 0), + # Equity report + "equity-report": TemplateSpec("equity-report.html", 3), + "equity-report-en": TemplateSpec("equity-report-en.html", 3), + # Changelog + "changelog": TemplateSpec("changelog.html", 2), + "changelog-en": TemplateSpec("changelog-en.html", 2), + # Slides (WeasyPrint default) + "slides-weasy": TemplateSpec("slides-weasy.html", 0), + "slides-weasy-en": TemplateSpec("slides-weasy-en.html", 0), +} + +SCREEN_TEMPLATES: dict[str, str] = { + "landing-page": "landing-page.html", + "landing-page-en": "landing-page-en.html", + "landing-page-ko": "landing-page-ko.html", +} + +# Diagram HTMLs live in assets/diagrams and have no page-count contract. +# Registered here (not in build.py) so all template registries share one home. +# The Mermaid-sourced ones are produced via scripts/mermaid_normalize.py. +DIAGRAM_TEMPLATES: dict[str, str] = { + "diagram-architecture": "architecture.html", + "diagram-architecture-board": "architecture-board.html", + "diagram-flowchart": "flowchart.html", + "diagram-quadrant": "quadrant.html", + "diagram-bar-chart": "bar-chart.html", + "diagram-line-chart": "line-chart.html", + "diagram-donut-chart": "donut-chart.html", + "diagram-state-machine": "state-machine.html", + "diagram-timeline": "timeline.html", + "diagram-swimlane": "swimlane.html", + "diagram-tree": "tree.html", + "diagram-layer-stack": "layer-stack.html", + "diagram-venn": "venn.html", + "diagram-candlestick": "candlestick.html", + "diagram-waterfall": "waterfall.html", + # Mermaid-sourced (beautiful-mermaid + scripts/mermaid_normalize.py) + "diagram-sequence": "sequence.html", + "diagram-class": "class.html", + "diagram-er": "er.html", +} + + +PUBLIC_DOCUMENT_TEMPLATE_KINDS = { + "one-pager", + "letter", + "long-doc", + "portfolio", + "resume", + "slides", + "equity-report", + "changelog", +} + + +def _public_template_kind(name: str) -> str: + for suffix in ("-en", "-ko"): + if name.endswith(suffix): + name = name[: -len(suffix)] + break + if name.startswith("slides-weasy"): + return "slides" + return name + + +def public_document_template_kinds() -> set[str]: + """Return public document-template kinds represented by HTML_TEMPLATES.""" + return { + _public_template_kind(name) + for name in HTML_TEMPLATES + if _public_template_kind(name) in PUBLIC_DOCUMENT_TEMPLATE_KINDS + } + + +def public_document_template_count() -> int: + return len(public_document_template_kinds()) + + +def rel_to_root(path: Path) -> Path: + """Return `path` relative to ROOT when possible, else the path unchanged.""" + return path.relative_to(ROOT) if path.is_relative_to(ROOT) else path + + +def default_example_pdfs() -> list[str]: + """Return every rendered example PDF, the default scan set for PDF checks.""" + return [str(p) for p in sorted(EXAMPLES.glob("*.pdf"))] + + +def iter_template_files( + *, + include_py: bool = False, + include_diagrams: bool = False, + include_marp_css: bool = False, +) -> list[Path]: + """Collect template-family files for scanning. + + One shared walker so lint, token-sync, and future checks cannot silently + diverge in coverage (a divergence is how the Marp CSS family once slipped + out of the lint scan while staying in the token scan). + """ + targets: list[Path] = list(TEMPLATES.glob("*.html")) + if include_py: + targets.extend(TEMPLATES.glob("*.py")) + if include_diagrams and DIAGRAMS.exists(): + targets.extend(DIAGRAMS.glob("*.html")) + if include_marp_css: + marp_dir = TEMPLATES / "marp" + if marp_dir.exists(): + targets.extend(marp_dir.glob("*.css")) + return sorted(targets) + + +@functools.lru_cache(maxsize=1) +def kami_version() -> str: + """Return the canonical Kami version from the tracked VERSION file.""" + return (ROOT / "VERSION").read_text(encoding="utf-8").strip() + + +@functools.lru_cache(maxsize=1) +def load_tokens() -> dict[str, str]: + return json.loads(TOKENS_FILE.read_text(encoding="utf-8")) + + +def token_value(name: str) -> str: + key = name if name.startswith("--") else f"--{name}" + return load_tokens()[key] + + +def build_targets() -> dict[str, tuple[str, int]]: + """Return target -> (source, max_pages) mapping for build.py.""" + return {name: (spec.source, spec.build_max_pages) for name, spec in HTML_TEMPLATES.items()} + + +def screen_targets() -> dict[str, str]: + """Return target -> source mapping for browser-only HTML templates.""" + return dict(SCREEN_TEMPLATES) + + +def diagram_targets() -> dict[str, str]: + """Return target -> source mapping for assets/diagrams HTML templates.""" + return dict(DIAGRAM_TEMPLATES) + + +@functools.lru_cache(maxsize=1) +def load_checks_thresholds() -> dict[str, Any]: + """Return rhythm / density / orphan thresholds. + + Falls back to baked-in defaults if the JSON is missing so build.py works + on a half-installed checkout. + """ + if CHECKS_THRESHOLDS_FILE.exists(): + return json.loads(CHECKS_THRESHOLDS_FILE.read_text(encoding="utf-8")) + return { + "rhythm": {"max_content_run": 5, "divider_min_deck_size": 12}, + "density": {"warn_pct": 0.25, "sparse_pct": 0.50, "dpi": 36}, + "orphan": {"max_words": 2, "max_chars": 15}, + } diff --git a/scripts/site_facts.py b/scripts/site_facts.py new file mode 100644 index 0000000..e116f4b --- /dev/null +++ b/scripts/site_facts.py @@ -0,0 +1,282 @@ +"""Public-site fact drift checks for Kami. + +The hosted pages, README, and llms.txt intentionally repeat install and product +facts in multiple languages. This module keeps those facts tied to the shared +registry and public constants so `build.py --check` catches drift before CI. +""" +from __future__ import annotations + +import difflib +import html +import re +from collections.abc import Mapping +from html.parser import HTMLParser + +from shared import ( + CLAUDE_CODE_INSTALL_COMMANDS, + CLAUDE_CODE_MIN_VERSION, + CLAUDE_DESKTOP_PACKAGE_URL, + CODEX_PLUGIN_INSTALL_COMMANDS, + DIAGRAM_TEMPLATES, + GENERIC_AGENT_INSTALL_COMMAND, + PUBLIC_DOCUMENT_TEMPLATE_KINDS, + ROOT, + kami_version, + public_document_template_count, + public_document_template_kinds, +) + +# Locale pages are hand-maintained forks of index.html. Their DOM skeletons +# must stay identical; the only allowed divergence is the language-redirect +# <script> that exists solely on the default page. +SITE_BASE_PAGE = "index.html" +SITE_LOCALE_PAGES = ( + "index-zh.html", + "index-ja.html", + "index-ko.html", + "index-tw.html", +) + +# Every surface that must carry the full public fact set. Derived from the +# locale-page tuple so adding a locale automatically joins both checks. +FULL_PUBLIC_FACT_FILES = ( + "README.md", + "llms.txt", + SITE_BASE_PAGE, + *SITE_LOCALE_PAGES, +) +REDIRECT_SITE_FILE = "index-en.html" +SITE_SURFACE_ABSENT = "__site_surface_absent__" + +_SKELETON_TAGS = frozenset({ + "article", "aside", "dl", "figure", "footer", "form", "header", + "h1", "h2", "h3", "h4", "h5", "h6", + "main", "nav", "ol", "section", "script", "svg", "table", "ul", +}) + +# Spelled-out numerals per template count. Digit patterns below are derived +# from the live registry count, so a registry change keeps the check honest; +# only the localized number words need a new entry here when the count moves. +_TEMPLATE_COUNT_WORDS = { + 8: ( + r"\bEight document template", + r"八种文档模板", + r"八種文件範本", + ), +} + +def _normalize(text: str) -> str: + return html.unescape(text) + + +def _contains_template_count(text: str, expected: int) -> bool: + patterns = [ + rf"\b{expected} document template", + rf"{expected}种文档模板", + rf"{expected}種文件範本", + rf"{expected}種類のドキュメントテンプレート", + rf"{expected}가지 문서 템플릿", + *_TEMPLATE_COUNT_WORDS.get(expected, ()), + ] + return any(re.search(pattern, text, re.IGNORECASE) for pattern in patterns) + + +def _contains_diagram_count(text: str, expected: int) -> bool: + patterns = [ + rf"\b{expected}\s+(?:inline\s+SVG\s+)?diagram", + rf"{expected}\s*(?:种|種).*?(?:图表|圖表)", + rf"{expected}種.*?図表", + rf"{expected}가지.*?다이어그램", + ] + if expected == 18: + patterns.append(r"\bEighteen\s+inline\s+SVG") + return any(re.search(pattern, text, re.IGNORECASE) for pattern in patterns) + + +def _file_texts(files: Mapping[str, str] | None) -> tuple[dict[str, str], list[str]]: + if files is not None: + return dict(files), [] + + texts: dict[str, str] = {} + issues: list[str] = [] + site_files = (*FULL_PUBLIC_FACT_FILES, REDIRECT_SITE_FILE) + if not any((ROOT / rel).exists() for rel in site_files): + return {SITE_SURFACE_ABSENT: ""}, [] + for rel in site_files: + path = ROOT / rel + if not path.exists(): + issues.append(f"{rel}: missing public fact file") + continue + texts[rel] = path.read_text(encoding="utf-8", errors="replace") + return texts, issues + + +class _SkeletonParser(HTMLParser): + """Collect the structural tag sequence of a page, annotated enough to + catch real drift (class identity, script type) without tracking text.""" + + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.rows: list[str] = [] + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + if tag not in _SKELETON_TAGS: + return + attr_map = dict(attrs) + row = tag + css_class = (attr_map.get("class") or "").strip() + if css_class: + row += f".{css_class}" + if tag == "script": + script_type = (attr_map.get("type") or "").strip() + if script_type: + row += f"[{script_type}]" + self.rows.append(row) + + +def _page_skeleton(text: str) -> list[str]: + parser = _SkeletonParser() + parser.feed(text) + return parser.rows + + +def _drop_language_redirect_script(rows: list[str]) -> list[str]: + """Return rows minus the first bare <script> (the default page's + language redirect), which locale pages intentionally omit.""" + for index, row in enumerate(rows): + if row == "script": + return rows[:index] + rows[index + 1:] + return rows + + +def site_structure_issues(files: Mapping[str, str] | None = None) -> list[str]: + """Compare each locale page's DOM skeleton against index.html.""" + texts, issues = _file_texts(files) + if SITE_SURFACE_ABSENT in texts: + return [] + + base_raw = texts.get(SITE_BASE_PAGE) + if base_raw is None: + return issues + expected = _drop_language_redirect_script(_page_skeleton(base_raw)) + + for rel in SITE_LOCALE_PAGES: + raw = texts.get(rel) + if raw is None: + continue + rows = _page_skeleton(raw) + if rows == expected: + continue + delta = [ + line for line in difflib.unified_diff(expected, rows, lineterm="", n=0) + if line[:1] in "+-" and line[:3] not in ("+++", "---") + ] + preview = "; ".join(delta[:6]) + (" ..." if len(delta) > 6 else "") + issues.append( + f"{rel}: DOM skeleton drifted from {SITE_BASE_PAGE} " + f"({len(delta)} row(s): {preview})" + ) + + return issues + + +def site_fact_issues(files: Mapping[str, str] | None = None) -> list[str]: + texts, issues = _file_texts(files) + if SITE_SURFACE_ABSENT in texts: + return [] + + kinds = public_document_template_kinds() + if kinds != PUBLIC_DOCUMENT_TEMPLATE_KINDS: + missing = sorted(PUBLIC_DOCUMENT_TEMPLATE_KINDS - kinds) + extra = sorted(kinds - PUBLIC_DOCUMENT_TEMPLATE_KINDS) + detail = [] + if missing: + detail.append(f"missing public kinds: {', '.join(missing)}") + if extra: + detail.append(f"extra public kinds: {', '.join(extra)}") + issues.append("registry: public document template kinds drifted" + (f" ({'; '.join(detail)})" if detail else "")) + + template_count = public_document_template_count() + diagram_count = len(DIAGRAM_TEMPLATES) + + for rel in FULL_PUBLIC_FACT_FILES: + raw = texts.get(rel) + if raw is None: + if files is not None: + issues.append(f"{rel}: missing public fact file") + continue + text = _normalize(raw) + + if CLAUDE_CODE_MIN_VERSION not in text: + issues.append(f"{rel}: missing Claude Code minimum version {CLAUDE_CODE_MIN_VERSION}") + for command in CLAUDE_CODE_INSTALL_COMMANDS: + if command not in text: + issues.append(f"{rel}: missing Claude Code install command `{command}`") + for command in CODEX_PLUGIN_INSTALL_COMMANDS: + if command not in text: + issues.append(f"{rel}: missing Codex install command `{command}`") + if GENERIC_AGENT_INSTALL_COMMAND not in text: + issues.append(f"{rel}: missing generic agent install command `{GENERIC_AGENT_INSTALL_COMMAND}`") + + if "kami.zip" not in text: + issues.append(f"{rel}: missing Claude Desktop package name kami.zip") + if rel != "llms.txt" and CLAUDE_DESKTOP_PACKAGE_URL not in text: + issues.append(f"{rel}: missing Claude Desktop package URL {CLAUDE_DESKTOP_PACKAGE_URL}") + + # The site pages carry a hand-written Kami version badge; tie it to the + # tracked VERSION file so a release bump cannot leave a page behind. + # README and llms.txt intentionally carry no version string. + if rel.endswith(".html") and f"v{kami_version()}" not in text: + issues.append(f"{rel}: missing Kami version badge v{kami_version()}") + + if not _contains_template_count(text, template_count): + issues.append(f"{rel}: missing public document template count {template_count}") + if not _contains_diagram_count(text, diagram_count): + issues.append(f"{rel}: missing diagram count {diagram_count}") + + redirect = texts.get(REDIRECT_SITE_FILE) + if redirect is None: + if files is not None: + issues.append(f"{REDIRECT_SITE_FILE}: missing redirect page") + else: + text = _normalize(redirect) + for required in ('http-equiv="refresh"', "url=./", 'content="noindex"', 'rel="canonical"'): + if required not in text: + issues.append(f"{REDIRECT_SITE_FILE}: missing redirect marker `{required}`") + if CLAUDE_CODE_MIN_VERSION in text or "kami.zip" in text: + issues.append(f"{REDIRECT_SITE_FILE}: redirect page should not carry product fact copy") + + return issues + + +def check_site_facts(verbose: bool = False) -> int: + if not any((ROOT / rel).exists() for rel in (*FULL_PUBLIC_FACT_FILES, REDIRECT_SITE_FILE)): + print("OK: public site facts skipped (site files absent)") + return 0 + + result = 0 + + issues = site_fact_issues() + if not issues: + scanned = len(FULL_PUBLIC_FACT_FILES) + 1 + print(f"OK: public site facts in sync across {scanned} file(s)") + else: + print(f"\nERROR: [site-fact-drift] {len(issues)}") + for issue in issues: + print(f" {issue}") + if verbose: + print(" source: shared public constants and template registries") + result = 1 + + structure_issues = site_structure_issues() + if not structure_issues: + print(f"OK: locale page structure matches {SITE_BASE_PAGE} across {len(SITE_LOCALE_PAGES)} page(s)") + else: + print(f"\nERROR: [site-structure-drift] {len(structure_issues)}") + for issue in structure_issues: + print(f" {issue}") + if verbose: + print(f" source: DOM skeleton comparison against {SITE_BASE_PAGE}") + result = 1 + + return result diff --git a/scripts/tests/test_build.py b/scripts/tests/test_build.py new file mode 100644 index 0000000..13eb421 --- /dev/null +++ b/scripts/tests/test_build.py @@ -0,0 +1,1473 @@ +#!/usr/bin/env python3 +"""Lightweight tests for scripts/build.py and scripts/shared.py. + +Run with: python3 scripts/tests/test_build.py +The harness uses plain assertions and a tiny runner so it has no third-party +dependency (matching the rest of the repo's lean tooling). +""" +from __future__ import annotations + +import contextlib +import builtins +import importlib.util +import inspect +import io +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +import zipfile +from pathlib import Path + +# Make scripts/ importable when running this file directly. +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) + +from build import ( # noqa: E402 + DIAGRAM_TARGETS, + HTML_TARGETS, + PPTX_TARGETS, + SCREEN_TARGETS, + _BG_B, + _BG_G, + _BG_R, + _density_bucket, + _extract_root_vars, + _last_content_y, + _markdown_residue_issues, + _off_palette_findings, + _orphan_last_line, + _pair_names, + _parse_slide_sequence, + _resume_balance_issues, + _rhythm_issues, + _root_token_findings, + check_all, + check_cross_template_consistency, + check_markdown_residue, + check_off_palette, + check_placeholders, + main as build_main, + scan_file, +) +from shared import ( # noqa: E402 + DIAGRAM_TEMPLATES, + HTML_TEMPLATES, + PARCHMENT_RGB, + ROOT as REPO_ROOT, + SCREEN_TEMPLATES, + TEMPLATES, + build_targets, + diagram_targets, + load_checks_thresholds, + screen_targets, +) +import highlight as highlight_mod # noqa: E402 +from highlight import highlight_code_blocks # noqa: E402 +from site_facts import ( # noqa: E402 + FULL_PUBLIC_FACT_FILES, + REDIRECT_SITE_FILE, + check_site_facts, + site_fact_issues, + site_structure_issues, +) +from tokens import _mermaid_theme_drift # noqa: E402 +from verify import RECOGNIZABLE_FALLBACK_FONT_MARKERS # noqa: E402 + + +# --------------------------- helpers --------------------------- + +_PASS = 0 +_FAIL = 0 + + +def check(name: str, predicate: bool, detail: str = "") -> None: + global _PASS, _FAIL + if predicate: + _PASS += 1 + print(f"OK: {name}") + else: + _FAIL += 1 + print(f"ERROR: {name}{(' - ' + detail) if detail else ''}") + + +def write_temp_html(body: str, suffix: str = "-en.html") -> Path: + f = tempfile.NamedTemporaryFile(mode="w", suffix=suffix, delete=False, encoding="utf-8") + f.write(body) + f.close() + return Path(f.name) + + +def silently(callable_, *args, **kwargs): + """Run a function with stdout suppressed, return its result.""" + sink = io.StringIO() + with contextlib.redirect_stdout(sink): + return callable_(*args, **kwargs) + + +def run_build_args(args: list[str]) -> tuple[int, str]: + sink = io.StringIO() + with contextlib.redirect_stdout(sink): + rc = build_main(["build.py", *args]) + return rc, sink.getvalue() + + +def site_fact_file_map() -> dict[str, str]: + rels = (*FULL_PUBLIC_FACT_FILES, REDIRECT_SITE_FILE) + return { + rel: (REPO_ROOT / rel).read_text(encoding="utf-8", errors="replace") + for rel in rels + } + + +# --------------------------- package archive --------------------------- + +PACKAGE_MAX_BYTES = 6_000_000 +PACKAGE_ROOT_NAME = "kami" +PACKAGE_FORBIDDEN_EXACT = { + ".claude-plugin/marketplace.json", + ".gitignore", + "AGENTS.md", + "CLAUDE.md", + "README.md", + "assets/images/1.png", + "assets/images/2.png", + "assets/images/3.png", + "assets/fonts/TsangerJinKai02-W04.ttf", + "assets/fonts/TsangerJinKai02-W05.ttf", + "assets/fonts/SourceHanSerifKR-Regular.otf", + "assets/fonts/SourceHanSerifKR-Medium.otf", + "index.html", + "index-en.html", + "index-ja.html", + "index-ko.html", + "index-tw.html", + "index-zh.html", + "llms.txt", + "robots.txt", + "scripts/build_metadata.py", + "scripts/draft-release-notes.py", + "scripts/package-skill.sh", + "sitemap.xml", + "styles.css", + "vercel.json", +} +PACKAGE_FORBIDDEN_PREFIXES = ( + ".agents/", + ".claude/", + ".github/", + "assets/demos/", + "assets/examples/", + "assets/illustrations/", + "assets/showcase/", + "plugins/", + "scripts/tests/", +) +PACKAGE_REQUIRED_ENTRIES = { + "SKILL.md", + "CHEATSHEET.md", + "VERSION", + "LICENSE", + "assets/images/logo.svg", + "assets/fonts/JetBrainsMono.woff2", + "assets/templates/resume.html", + "assets/templates/landing-page.html", + "assets/diagrams/sequence.html", + "references/design.md", + "scripts/build.py", + "scripts/ensure-fonts.sh", + "scripts/site_facts.py", +} + + +def test_dist_package_contents() -> None: + archive = REPO_ROOT / "dist" / "kami.zip" + check("dist/kami.zip exists", archive.exists(), f"missing {archive}") + if not archive.exists(): + return + + size_bytes = archive.stat().st_size + check("dist/kami.zip stays below 6MB", + size_bytes <= PACKAGE_MAX_BYTES, + f"{size_bytes} bytes > {PACKAGE_MAX_BYTES} bytes") + + with zipfile.ZipFile(archive) as zf: + names = set(zf.namelist()) + + bad_root = sorted(name for name in names if not name.startswith(f"{PACKAGE_ROOT_NAME}/")) + check("dist/kami.zip uses a Claude-friendly top-level skill folder", + not bad_root, + f"entries outside {PACKAGE_ROOT_NAME}/: {', '.join(bad_root)}") + + payload_names = { + name.removeprefix(f"{PACKAGE_ROOT_NAME}/") + for name in names + if name.startswith(f"{PACKAGE_ROOT_NAME}/") + } + forbidden = sorted( + name for name in payload_names + if name.startswith(PACKAGE_FORBIDDEN_PREFIXES) + or name in PACKAGE_FORBIDDEN_EXACT + ) + check("dist/kami.zip excludes site, CI, tests, demos, generated mirrors, and large bundled fonts", + not forbidden, + f"forbidden entries: {', '.join(forbidden)}") + missing_required = sorted(PACKAGE_REQUIRED_ENTRIES - payload_names) + check("dist/kami.zip keeps required runtime skill files", + not missing_required, + f"missing entries: {', '.join(missing_required)}") + + +def test_plugin_metadata_generated() -> None: + """Claude Code / Codex marketplaces and plugin mirrors must stay generated.""" + script = REPO_ROOT / "scripts" / "build_metadata.py" + check("build_metadata.py exists", script.exists(), f"missing {script}") + if not script.exists(): + return + + result = subprocess.run( + [sys.executable, str(script), "--check"], + cwd=REPO_ROOT, + capture_output=True, + text=True, + ) + detail = (result.stdout + result.stderr).strip() + check("plugin metadata matches generator", result.returncode == 0, detail) + + +def test_claude_plugin_marketplace_version_matches_version_file() -> None: + """Claude Code uses this version instead of falling back to a commit hash.""" + version = (REPO_ROOT / "VERSION").read_text(encoding="utf-8").strip() + marketplace_file = REPO_ROOT / ".claude-plugin" / "marketplace.json" + check("Claude plugin marketplace metadata exists", marketplace_file.exists()) + if not marketplace_file.exists(): + return + + marketplace = json.loads(marketplace_file.read_text(encoding="utf-8")) + plugins = marketplace.get("plugins", []) + kami_plugin = next((plugin for plugin in plugins if plugin.get("name") == "kami"), None) + check("Claude plugin marketplace includes kami", kami_plugin is not None) + if not kami_plugin: + return + + check("Claude plugin marketplace version matches VERSION", + kami_plugin.get("version") == version, + f"marketplace={kami_plugin.get('version')!r}, VERSION={version!r}") + check("Claude plugin marketplace installs the lightweight plugin directory", + kami_plugin.get("source") == "./plugins/kami", + f"source={kami_plugin.get('source')!r}") + + plugin_file = REPO_ROOT / "plugins" / "kami" / ".claude-plugin" / "plugin.json" + check("Claude plugin manifest exists in generated plugin tree", plugin_file.exists()) + if not plugin_file.exists(): + return + + plugin = json.loads(plugin_file.read_text(encoding="utf-8")) + check("Claude plugin manifest version matches VERSION", + plugin.get("version") == version, + f"plugin={plugin.get('version')!r}, VERSION={version!r}") + check("Claude plugin manifest exposes skills directory", + plugin.get("skills") == "./skills/", + f"skills={plugin.get('skills')!r}") + + +def test_build_metadata_reads_tokens_from_root_argument() -> None: + from build_metadata import build_codex_plugin, read_token_value + + with tempfile.TemporaryDirectory() as d: + root = Path(d) + (root / "references").mkdir() + (root / "references" / "tokens.json").write_text('{"--brand":"#123456"}\n', encoding="utf-8") + + brand_color = read_token_value(root, "brand") + plugin = build_codex_plugin("9.9.9", brand_color) + check("build_metadata reads brand token from provided root", + plugin["interface"]["brandColor"] == "#123456", + f"brandColor={plugin['interface']['brandColor']}") + + +# --------------------------- shared registry --------------------------- + +def test_registry_consistency() -> None: + check("HTML_TEMPLATES has 24 entries", len(HTML_TEMPLATES) == 24, + f"got {len(HTML_TEMPLATES)}") + check("SCREEN_TARGETS has 3 entries", len(SCREEN_TARGETS) == 3, + f"got {len(SCREEN_TARGETS)}") + check("build_targets matches HTML_TEMPLATES key set", + set(build_targets()) == set(HTML_TEMPLATES)) + check("screen_targets matches SCREEN_TARGETS key set", + set(screen_targets()) == set(SCREEN_TARGETS)) + check("HTML_TARGETS in build.py matches build_targets()", + dict(HTML_TARGETS) == build_targets()) + check("DIAGRAM_TARGETS has 18 entries", len(DIAGRAM_TARGETS) == 18, + f"got {len(DIAGRAM_TARGETS)}") + check("DIAGRAM_TARGETS in build.py matches shared.diagram_targets()", + dict(DIAGRAM_TARGETS) == diagram_targets() == dict(DIAGRAM_TEMPLATES)) + check("PPTX_TARGETS has 2 entries", len(PPTX_TARGETS) == 2, + f"got {len(PPTX_TARGETS)}") + check("PARCHMENT_RGB is canonical", PARCHMENT_RGB == (0xF5, 0xF4, 0xED)) + + +def test_runner_auto_discovers_tests() -> None: + names = [name for name, _ in _test_functions()] + check("test runner auto-discovers Codex update command test", + "test_check_update_uses_codex_plugin_update_command" in names) + check("test runner auto-discovers this test", + "test_runner_auto_discovers_tests" in names) + + +def test_build_cli_rejects_unexpected_flags() -> None: + rc, out = run_build_args(["resume", "--verify"]) + check("build.py rejects flags after target", + rc == 2 and "ERROR: unexpected argument: --verify" in out, + out.strip()) + + rc, out = run_build_args(["--check-density", "-v"]) + check("build.py rejects unknown flags for path-based checks", + rc == 2 and "ERROR: unexpected argument: -v" in out, + out.strip()) + + rc, out = run_build_args(["--verify", "-v"]) + check("build.py rejects unknown --verify flags", + rc == 2 and "ERROR: unexpected argument: -v" in out, + out.strip()) + + rc, out = run_build_args(["--check-markdown", "-v"]) + check("build.py rejects unknown --check-markdown flags", + rc == 2 and "ERROR: unexpected argument: -v" in out, + out.strip()) + + +def test_long_doc_templates_use_rendered_toc_pages_and_chapter_headers() -> None: + """Long-doc TOCs must use WeasyPrint target-counter, and running headers + must follow chapter h1 titles instead of getting stuck on the TOC h2. + """ + sources = ("long-doc.html", "long-doc-en.html", "long-doc-ko.html") + required_ids = { + "#ch-executive-summary", + "#ch-background", + "#ch-methodology", + "#ch-conclusions", + "#ch-appendix", + } + offenders: list[str] = [] + for source in sources: + text = (TEMPLATES / source).read_text(encoding="utf-8") + if "target-counter(attr(href), page)" not in text: + offenders.append(f"{source}: missing target-counter") + if ".toc-page" in text: + offenders.append(f"{source}: still has obsolete toc-page wiring") + missing_ids = sorted(href for href in required_ids if f'href="{href}"' not in text or f'id="{href[1:]}"' not in text) + if missing_ids: + offenders.append(f"{source}: missing TOC href/id pairs {missing_ids}") + h1_block = re.search(r"(?m)^ h1\s*\{(?P<body>.*?)^ \}", text, re.S) + if not h1_block or "string-set: section-title content();" not in h1_block.group("body"): + offenders.append(f"{source}: h1 does not set running header") + h2_block = re.search(r"(?m)^ h2\s*\{(?P<body>.*?)^ \}", text, re.S) + if h2_block and "string-set:" in h2_block.group("body"): + offenders.append(f"{source}: h2 still sets running header") + + check("long-doc templates use rendered TOC pages and chapter headers", + not offenders, + "; ".join(offenders)) + + +def test_site_facts_repo_clean() -> None: + rc = silently(check_site_facts, False) + check("public site facts match shared constants and registries", rc == 0, + f"check_site_facts returned {rc}") + + +def test_site_facts_flags_bad_diagram_count() -> None: + files = site_fact_file_map() + bad = files["index.html"] + bad = bad.replace("18 inline SVG diagram types", "17 inline SVG diagram types") + bad = bad.replace("Eighteen inline SVG diagram types", "Seventeen inline SVG diagram types") + files["index.html"] = bad + + issues = site_fact_issues(files) + check("public site facts flag stale diagram counts", + any("index.html: missing diagram count 18" in issue for issue in issues), + f"issues: {issues}") + + +def test_site_structure_repo_clean() -> None: + """Locale pages match index.html's DOM skeleton (redirect script exempt).""" + issues = site_structure_issues() + check("locale page structure matches index.html", not issues, + f"issues: {issues}") + + +def test_site_structure_flags_locale_drift() -> None: + files = site_fact_file_map() + files["index-zh.html"] = files["index-zh.html"].replace( + '<h2 class="section-title">', '<h3 class="section-title">', 1) + + issues = site_structure_issues(files) + check("locale structure check flags a drifted heading", + any("index-zh.html: DOM skeleton drifted" in issue for issue in issues), + f"issues: {issues}") + + +def test_chinese_html_templates_keep_single_serif_stack() -> None: + """Chinese templates must keep --sans pinned to --serif for PDF glyph safety.""" + offenders: list[str] = [] + for name, spec in HTML_TEMPLATES.items(): + source = spec.source + if name.endswith("-en"): + continue + text = (TEMPLATES / source).read_text(encoding="utf-8") + if "--sans: var(--serif)" not in text and "--sans: var(--serif)" not in text: + offenders.append(source) + + check("Chinese HTML templates keep --sans: var(--serif)", + not offenders, + f"offenders: {', '.join(offenders)}") + + +def _ko_stack_offenders(text: str) -> list[str]: + """Return CSS declarations that reference the bare `"Source Han Serif K"` + family inside a multi-name fallback stack but omit the real OTF family + name `"Source Han Serif KR"`. + + The bare name `"Source Han Serif K"` is legitimate on its own only as the + `@font-face` declared alias (a single-name `font-family: "Source Han Serif K";` + with no comma, which loads via the file/CDN `src`). Anywhere it appears as a + fallback item in a comma-separated stack (`--serif`, `--mono`, `@page` + margin boxes, `code`/`pre`, ...), `"Source Han Serif KR"` MUST sit alongside + it, or an offline Linux skill install cannot resolve the + ensure-fonts.sh-downloaded font by name. + + Detection: scan only `font-family` / `--serif` / `--sans` / `--mono` + declaration values (up to the next `;`, never crossing `{`/`}`). The token + `"Source Han Serif K"` (closing quote after `K`) never matches + `"Source Han Serif KR"`, so a value that contains the bare token AND a comma + (i.e. a fallback stack, not a bare `@font-face` alias) must also contain KR. + """ + bare = '"Source Han Serif K"' + kr = '"Source Han Serif KR"' + decl_re = re.compile(r"(?:font-family|--serif|--sans|--mono)\s*:\s*([^;{}]*)", re.IGNORECASE) + offenders: list[str] = [] + for m in decl_re.finditer(text): + value = m.group(1) + if bare in value and "," in value and kr not in value: + offenders.append(" ".join(value.split())) + return offenders + + +def test_korean_templates_carry_resolvable_serif_name() -> None: + """Every KO fallback stack that names `Source Han Serif K` must also name + `Source Han Serif KR` (the actual family of the bundled OTFs), so the font + resolves by name on an offline Linux skill install. Checks per-declaration, + not just per-file, so a complete `--serif` cannot mask an incomplete local + stack (page-margin header/footer, code/pre, mono). + """ + offenders: list[str] = [] + ko_sources = [spec.source for name, spec in HTML_TEMPLATES.items() if name.endswith("-ko")] + ko_sources += [source for name, source in SCREEN_TEMPLATES.items() if name.endswith("-ko")] + # Guard against vacuous green: with zero -ko templates the offender loop + # never runs and the check below would pass while enforcing nothing. + check("Korean template set is non-empty", bool(ko_sources), + "no -ko templates found in the registries") + for source in ko_sources: + text = (TEMPLATES / source).read_text(encoding="utf-8") + for bad in _ko_stack_offenders(text): + offenders.append(f"{source}: {bad}") + + check("Korean fallback stacks all carry Source Han Serif KR", + not offenders, + f"offenders: {'; '.join(offenders)}") + + +# ---------- sibling placeholder parity (issue #38 class) ---------- + +# Repeated template structures whose placeholder hints must repeat the first +# block verbatim. Hint richness degrading from block 1 to later siblings makes +# fillers (human or agent) produce degraded copy; see issue #38. Cycle length N +# means placeholders repeat in groups of N (e.g. Role/Actions/Impact rows). +_SIBLING_PARITY_SPECS = ( + ("resume*.html", r'class="proj-text">(\{\{.*?\}\})', 3), + ("resume*.html", r'class="proj-role">(\{\{.*?\}\})', 1), + ("resume*.html", r'class="conv-body">\s*(\{\{.*?\}\})', 1), + ("resume*.html", r'class="os-desc">(\{\{.*?\}\})', 1), + ("resume*.html", r'class="art-stats">(\{\{.*?\}\})', 1), + ("portfolio*.html", r'class="project-block">\s*<h3>[^<]*</h3>\s*<p>(\{\{.*?\}\})</p>', 3), + ("portfolio*.html", r'class="project-type">(\{\{.*?\}\})', 1), + ("portfolio*.html", r'class="project-date">(\{\{.*?\}\})', 1), + ("one-pager*.html", r'<li>(\{\{(?:短 bullet|Short bullet|짧은 bullet).*?\}\})</li>', 3), + ("long-doc*.html", r'(\{\{(?:一段论述|A paragraph|한 단락 논술).*?\}\})', 1), +) + + +def test_sibling_placeholder_hints_stay_in_parity() -> None: + """Same-structure sibling blocks must carry identical placeholder hints.""" + matched = 0 + offenders: list[str] = [] + for glob_pattern, regex, cycle in _SIBLING_PARITY_SPECS: + rx = re.compile(regex, re.DOTALL) + for path in sorted(TEMPLATES.glob(glob_pattern)): + hits = rx.findall(path.read_text(encoding="utf-8")) + if not hits: + offenders.append(f"{path.name}: no match for {regex[:40]!r} (stale spec?)") + continue + matched += 1 + if len(hits) % cycle != 0: + offenders.append(f"{path.name}: {len(hits)} hint(s) not divisible by cycle {cycle}") + continue + first = hits[:cycle] + for start in range(cycle, len(hits), cycle): + block = hits[start:start + cycle] + if block != first: + offenders.append( + f"{path.name}: block {start // cycle + 1} diverges from block 1: " + f"{block} != {first}") + check("sibling parity specs matched across template families", matched >= 30, + f"only {matched} template/spec matches; spec table may be stale") + check("repeated blocks carry identical placeholder hints", not offenders, + "; ".join(offenders[:6])) + + +def test_font_fallback_markers_recognize_pt_serif() -> None: + """macOS without Charter may render English fallbacks as PT Serif.""" + embedded = {"DROIWJ+PT-Serif", "ZBEAAE+JetBrains-Mono"} + fallback_present = any( + marker in font for font in embedded + for marker in RECOGNIZABLE_FALLBACK_FONT_MARKERS + ) + check("font fallback markers recognize PT-Serif", + fallback_present, + f"markers: {RECOGNIZABLE_FALLBACK_FONT_MARKERS}") + + +def test_chinese_slides_mono_has_cjk_fallback() -> None: + """Slide labels may mix mono Latin and CJK; the mono stack needs CJK fallback.""" + text = (TEMPLATES / "slides-weasy.html").read_text(encoding="utf-8") + check("slides-weasy mono stack includes TsangerJinKai02 fallback", + '"TsangerJinKai02"' in text and '"Source Han Serif SC"' in text) + + +# --------------------------- scan_file --------------------------- + +def test_scan_file_skip_bug() -> None: + """Lines starting with '#' (CSS id selectors) must NOT be skipped.""" + fixture = """<!doctype html> +<html><head><style> +#card { background: rgba(0,0,0,0.5); } +</style></head><body></body></html> +""" + p = write_temp_html(fixture) + try: + findings = scan_file(p) + rules = {f.rule for f in findings} + check("scan_file flags rgba on #id-prefixed CSS line", + "rgba-background" in rules, + f"rules found: {rules or '(none)'}") + finally: + p.unlink(missing_ok=True) + + +def test_scan_file_arrow_in_en() -> None: + """`→` in -en.html body should trigger arrow-unicode-in-en.""" + fixture = """<!doctype html> +<html lang="en"><head><style> +.tag { color: #1B365D; } +</style></head><body> +<p>Step 1 → Step 2</p> +</body></html> +""" + p = write_temp_html(fixture, suffix="-en.html") + try: + findings = scan_file(p) + rules = {f.rule for f in findings} + check("scan_file flags U+2192 arrow in -en.html", + "arrow-unicode-in-en" in rules, + f"rules found: {rules or '(none)'}") + finally: + p.unlink(missing_ok=True) + + +def test_scan_file_clean_template() -> None: + """A clean template should produce zero findings.""" + fixture = """<!doctype html> +<html><head><style> +:root { --brand: #1B365D; } +.card { background: var(--ivory); } +.tag { background: #EEF2F7; color: var(--brand); } +</style></head><body></body></html> +""" + p = write_temp_html(fixture) + try: + findings = scan_file(p) + check("scan_file produces no findings on clean template", + len(findings) == 0, + f"got {len(findings)} finding(s): {[f.rule for f in findings]}") + finally: + p.unlink(missing_ok=True) + + +# --------------------------- slide sequence --------------------------- + +def test_parse_slide_sequence_empty() -> None: + fixture = """def main(): + pass +""" + p = write_temp_html(fixture, suffix=".py") + try: + seq = _parse_slide_sequence(p) + check("_parse_slide_sequence returns [] for empty main()", + seq == [], f"got {seq}") + finally: + p.unlink(missing_ok=True) + + +def test_parse_slide_sequence_basic() -> None: + fixture = """def main(): + cover_slide() + content_slide() + content_slide() + chapter_slide() + metrics_slide() + +def helper(): + other_call() +""" + p = write_temp_html(fixture, suffix=".py") + try: + seq = _parse_slide_sequence(p) + expected = ["cover_slide", "content_slide", "content_slide", "chapter_slide", "metrics_slide"] + check("_parse_slide_sequence parses ordered slide calls", + seq == expected, f"got {seq}") + finally: + p.unlink(missing_ok=True) + + +# --------------------------- scan_file extra rules --------------------------- + +def test_scan_file_line_height_too_loose() -> None: + """line-height >= 1.6 should trigger line-height-too-loose.""" + fixture = """<!doctype html> +<html><head><style> +p { line-height: 1.8; } +</style></head><body></body></html> +""" + p = write_temp_html(fixture) + try: + findings = scan_file(p) + rules = {f.rule for f in findings} + check("scan_file flags line-height 1.8 (too loose)", + "line-height-too-loose" in rules, + f"rules found: {rules or '(none)'}") + finally: + p.unlink(missing_ok=True) + + +def test_scan_file_cool_gray() -> None: + """Cool-gray hex literals should be flagged.""" + fixture = """<!doctype html> +<html><head><style> +.muted { color: #888; } +</style></head><body></body></html> +""" + p = write_temp_html(fixture) + try: + findings = scan_file(p) + rules = {f.rule for f in findings} + check("scan_file flags cool gray #888", + "cool-gray" in rules, + f"rules found: {rules or '(none)'}") + finally: + p.unlink(missing_ok=True) + + +def test_off_palette_flags_non_token_hex() -> None: + """A non-token, non-cool-gray hex in a component rule is off-palette.""" + fixture = """<!doctype html> +<html><head><style> +.x { color: #ff00aa; } +</style></head><body></body></html> +""" + p = write_temp_html(fixture) + try: + findings = _off_palette_findings(p, {"#1b365d"}) + rules = {f.rule for f in findings} + check("_off_palette_findings flags non-token hex #ff00aa", + "off-palette" in rules, + f"rules found: {rules or '(none)'}") + finally: + p.unlink(missing_ok=True) + + +def test_off_palette_ignores_root_and_svg() -> None: + """Hex inside :root token defs and inside <svg> blocks must be skipped.""" + fixture = """<!doctype html> +<html><head><style> +:root { --brand: #1B365D; --accent: #ff00aa; } +</style></head><body> +<svg viewBox="0 0 10 10"><rect fill="#ff0000" /></svg> +</body></html> +""" + p = write_temp_html(fixture) + try: + findings = _off_palette_findings(p, {"#1b365d"}) + check("_off_palette_findings skips :root defs and <svg> fills", + findings == [], + f"unexpected findings: {[(f.line, f.excerpt) for f in findings]}") + finally: + p.unlink(missing_ok=True) + + +def test_root_token_findings_flags_off_palette_definition() -> None: + """An off-palette hex *defined* in :root (never used as a literal property + hex) escapes _off_palette_findings, which blanks :root. _root_token_findings + closes that gap: a stray `--brand-deep: #a64f33` second accent is flagged, + while a registered token and cool-gray (reported elsewhere) are not.""" + fixture = """<!doctype html> +<html><head><style> +:root { + --brand: #1B365D; + --brand-deep: #a64f33; +} +</style></head><body></body></html> +""" + p = write_temp_html(fixture) + try: + findings = _root_token_findings(p, {"#1b365d"}) + rules = {f.rule for f in findings} + excerpts = " ".join(f.excerpt for f in findings) + check("_root_token_findings flags off-palette :root token #a64f33", + "off-palette-token" in rules and "#a64f33" in excerpts, + f"rules={rules or '(none)'} excerpts={excerpts or '(none)'}") + check("_root_token_findings does not flag the registered --brand token", + "#1b365d" not in excerpts, + f"unexpectedly flagged brand token: {excerpts}") + finally: + p.unlink(missing_ok=True) + + +def test_off_palette_repo_clean() -> None: + """The real editorial templates must carry no off-palette colors.""" + rc = silently(check_off_palette) + check("check_off_palette passes on the real templates", + rc == 0, + f"check_off_palette returned {rc}") + + +def test_check_update_script() -> None: + """check-update.sh notifies on a newer remote, stays silent when current, + throttles to once per day, and fails silently offline. It only reads a + version file and sends no data; KAMI_UPDATE_URL points it at a fixture.""" + script = REPO_ROOT / "scripts" / "check-update.sh" + check("check-update.sh exists", script.exists()) + if not script.exists(): + return + if shutil.which("bash") is None or shutil.which("curl") is None: + check("check-update.sh behavior (skipped: bash/curl unavailable)", True) + return + local_ver = (REPO_ROOT / "VERSION").read_text(encoding="utf-8").strip() + + def run(cache: str, url: str) -> tuple[int, str]: + env = dict(os.environ, XDG_CACHE_HOME=cache, KAMI_UPDATE_URL=url) + r = subprocess.run(["bash", str(script)], capture_output=True, text=True, env=env) + return r.returncode, r.stdout.strip() + + with tempfile.TemporaryDirectory() as d: + dp = Path(d) + newer = dp / "newer"; newer.write_text("9.9.9\n") + same = dp / "same"; same.write_text(local_ver + "\n") + + rc, out = run(str(dp / "c1"), newer.as_uri()) + check("check-update notifies on a newer remote", rc == 0 and "9.9.9" in out, out) + check("check-update default command uses plugin bundle path", + "npx skills add tw93/kami/plugins/kami -a universal -g -y" in out and "skills update" not in out, + out) + + rc, out = run(str(dp / "c2"), same.as_uri()) + check("check-update is silent when current", rc == 0 and out == "", out) + + c3 = str(dp / "c3") + run(c3, newer.as_uri()) + _, out2 = run(c3, newer.as_uri()) + check("check-update throttles to once per day", out2 == "", out2) + + rc, out = run(str(dp / "c4"), (dp / "nope").as_uri()) + check("check-update fails silently when offline", rc == 0 and out == "", out) + + +def test_check_update_uses_codex_plugin_update_command() -> None: + """When installed through Codex plugin cache, the update hint should use + plugin marketplace refresh commands instead of the legacy npx skill update. + """ + script = REPO_ROOT / "scripts" / "check-update.sh" + check("check-update.sh exists for Codex command test", script.exists()) + if not script.exists(): + return + if shutil.which("bash") is None or shutil.which("curl") is None: + check("check-update Codex command (skipped: bash/curl unavailable)", True) + return + + with tempfile.TemporaryDirectory() as d: + dp = Path(d) + newer = dp / "newer" + newer.write_text("9.9.9\n") + + roots = [ + dp / ".codex" / "plugins" / "cache" / "kami" / "kami" / "1.7.4" / "skills" / "kami", + dp / "custom-codex-home" / "plugins" / "cache" / "kami" / "kami" / "1.7.4" / "skills" / "kami", + ] + for index, install_root in enumerate(roots, start=1): + (install_root / "scripts").mkdir(parents=True) + shutil.copy2(script, install_root / "scripts" / "check-update.sh") + (install_root / "VERSION").write_text("1.7.4\n") + + env = dict(os.environ, XDG_CACHE_HOME=str(dp / f"cache-{index}"), KAMI_UPDATE_URL=newer.as_uri()) + result = subprocess.run( + ["bash", str(install_root / "scripts" / "check-update.sh")], + capture_output=True, + text=True, + env=env, + ) + out = result.stdout.strip() + check(f"check-update uses Codex plugin update command ({install_root.parent.parent.parent.name})", + result.returncode == 0 and "codex plugin marketplace upgrade kami" in out, + out) + + +def test_check_update_uses_claude_plugin_update_command() -> None: + """When installed through Claude Code's plugin cache, the update hint should + use Claude's plugin updater instead of generic npx skill install. + """ + script = REPO_ROOT / "scripts" / "check-update.sh" + check("check-update.sh exists for Claude command test", script.exists()) + if not script.exists(): + return + if shutil.which("bash") is None or shutil.which("curl") is None: + check("check-update Claude command (skipped: bash/curl unavailable)", True) + return + + with tempfile.TemporaryDirectory() as d: + dp = Path(d) + newer = dp / "newer" + newer.write_text("9.9.9\n") + install_root = dp / ".claude" / "plugins" / "cache" / "kami" / "kami" / "1.9.1" / "skills" / "kami" + (install_root / "scripts").mkdir(parents=True) + shutil.copy2(script, install_root / "scripts" / "check-update.sh") + (install_root / "VERSION").write_text("1.9.1\n") + + env = dict(os.environ, XDG_CACHE_HOME=str(dp / "cache"), KAMI_UPDATE_URL=newer.as_uri()) + result = subprocess.run( + ["bash", str(install_root / "scripts" / "check-update.sh")], + capture_output=True, + text=True, + env=env, + ) + out = result.stdout.strip() + check("check-update uses Claude plugin update command", + result.returncode == 0 and "claude plugin update kami" in out and "npx skills" not in out, + out) + + +def test_lint_repo_clean() -> None: + """The full CSS lint (scan_file across every template) must pass. This is + what `build.py --check` runs; covering it here means a rule violation such + as thin-border-radius cannot reach main behind an otherwise green suite.""" + rc = silently(check_all, False) + check("check_all (full CSS lint) passes on the real templates", + rc == 0, + f"check_all returned {rc}") + + +def test_scan_file_ignores_block_comment_rgba() -> None: + """rgba() inside a /* ... */ CSS block comment must not trigger findings.""" + fixture = """<!doctype html> +<html><head><style> +/* historical note: we used to write + background: rgba(0,0,0,0.5); + here, but switched to solid hex. */ +.card { background: #EEF2F7; } +</style></head><body></body></html> +""" + p = write_temp_html(fixture) + try: + findings = scan_file(p) + rules = {f.rule for f in findings} + check("scan_file ignores rgba inside /* */ comment", + "rgba-background" not in rules, + f"rules found: {rules or '(none)'}") + finally: + p.unlink(missing_ok=True) + + +def test_scan_file_thin_border_with_radius() -> None: + """Sub-1pt closed border in a block with border-radius should fire pitfall #2.""" + fixture = """<!doctype html> +<html><head><style> +.tag { + border: 0.5pt solid #1B365D; + border-radius: 3pt; + background: #EEF2F7; +} +</style></head><body></body></html> +""" + p = write_temp_html(fixture) + try: + findings = scan_file(p) + rules = {f.rule for f in findings} + check("scan_file flags thin border with border-radius", + "thin-border-radius" in rules, + f"rules found: {rules or '(none)'}") + finally: + p.unlink(missing_ok=True) + + +# --------------------------- check_placeholders --------------------------- + +def test_check_placeholders_flags_unfilled() -> None: + """A doc with `{{ name }}` left over should fail the check.""" + p = write_temp_html("<html><body><h1>{{ name }}</h1><p>{{ role }}</p></body></html>") + try: + rc = silently(check_placeholders, [str(p)]) + check("check_placeholders fails on {{ name }}", rc == 1, f"rc={rc}") + finally: + p.unlink(missing_ok=True) + + +def test_check_placeholders_passes_clean() -> None: + """A doc with no placeholder syntax should pass.""" + p = write_temp_html("<html><body><h1>Real Name</h1><p>Real role</p></body></html>") + try: + rc = silently(check_placeholders, [str(p)]) + check("check_placeholders passes clean file", rc == 0, f"rc={rc}") + finally: + p.unlink(missing_ok=True) + + +def test_markdown_residue_flags_raw_markers() -> None: + issues = _markdown_residue_issues("Intro\n---\nThis has **raw bold** and `raw code`.") + check("markdown residue flags thematic breaks", + any("thematic break" in issue for issue in issues), + f"issues={issues}") + check("markdown residue flags raw bold markers", + any("bold marker" in issue for issue in issues), + f"issues={issues}") + check("markdown residue flags raw inline-code markers", + any("inline-code marker" in issue for issue in issues), + f"issues={issues}") + + check("markdown residue ignores clean text", + _markdown_residue_issues("Clean paragraph with converted emphasis.") == []) + + +def test_check_markdown_residue_skips_html_code_blocks() -> None: + dirty = write_temp_html("<html><body><p>Visible **raw bold**</p></body></html>", suffix=".html") + clean_code = write_temp_html( + "<html><body><p>Visible text</p><pre><code>**example** `cmd`</code></pre></body></html>", + suffix=".html", + ) + try: + rc = silently(check_markdown_residue, [str(dirty)]) + check("check_markdown_residue fails visible raw markdown", rc == 1, f"rc={rc}") + rc = silently(check_markdown_residue, [str(clean_code)]) + check("check_markdown_residue skips code/pre blocks", rc == 0, f"rc={rc}") + finally: + dirty.unlink(missing_ok=True) + clean_code.unlink(missing_ok=True) + + +# --------------------------- cross-template consistency --------------------------- + +def test_pair_names_includes_known_pairs() -> None: + captured = list(_pair_names()) + check("pair_names includes one-pager", + ("one-pager", "one-pager-en") in captured, + f"got {[v for b, v in captured if b == 'one-pager']!r}") + check("pair_names includes landing-page (CN/EN)", + ("landing-page", "landing-page-en") in captured, + f"got {[v for b, v in captured if b == 'landing-page']!r}") + check("pair_names omits lone -en entries", + not any(name.endswith("-en") for name, _ in _pair_names())) + + +def test_pair_names_includes_ko_variants_when_present() -> None: + """`_pair_names` must yield (base, base-ko) pairs in addition to (base, base-en).""" + captured = list(_pair_names()) + # Sanity: existing CN/EN and CN/KO pairs still detected, so the sweep + # below cannot pass vacuously on an empty or EN-only pair list. + check("CN/EN pair still detected", ("one-pager", "one-pager-en") in captured) + check("CN/KO pair still detected", ("one-pager", "one-pager-ko") in captured) + # Any base whose `-ko` sibling is registered must appear as a (base, base-ko) pair. + bases = {base for base, _ in captured} + seen = set(HTML_TEMPLATES) | set(SCREEN_TEMPLATES) + missing = [ + base for base in bases + if f"{base}-ko" in seen and (base, f"{base}-ko") not in captured + ] + check("pair_names includes ko variants when present", not missing, + f"unpaired KO bases: {missing}") + + +def test_cross_template_consistency_clean() -> None: + """The current repo should pass cross-template consistency.""" + rc = silently(check_cross_template_consistency, False) + check("cross-template returns 0 on current repo", rc == 0, f"rc={rc}") + + +def test_extract_root_vars_picks_up_definitions() -> None: + fixture = """<!doctype html> +<html><head><style> +:root { + --brand: #1B365D; + --parchment: #F5F4ED; + --serif: Charter, Georgia, serif; +} +</style></head><body></body></html> +""" + p = write_temp_html(fixture) + try: + vars_ = _extract_root_vars(p) + check("extract_root_vars finds --brand", vars_.get("--brand") == "#1B365D", + f"got {vars_.get('--brand')!r}") + check("extract_root_vars finds --parchment", vars_.get("--parchment") == "#F5F4ED", + f"got {vars_.get('--parchment')!r}") + finally: + p.unlink(missing_ok=True) + + +# --------------------------- _last_content_y --------------------------- + +def _make_samples(rows_with_content: int, w: int, h: int, n: int = 3) -> bytes: + """Build a flat RGB buffer: parchment everywhere, ink in the top N rows. + + Returns bytes matching the layout PyMuPDF's Pixmap uses, so we can drive + _last_content_y without depending on a real PDF or numpy. + """ + parchment_row = bytes((_BG_R, _BG_G, _BG_B)) * w + ink_row = bytes((27, 54, 93)) * w + out = bytearray() + for y in range(h): + out.extend(ink_row if y < rows_with_content else parchment_row) + return bytes(out) + + +def test_last_content_y_dense_page() -> None: + """Page with content all the way to the bottom: returns h-1.""" + w, h, n = 80, 100, 3 + samples = _make_samples(rows_with_content=h, w=w, h=h, n=n) + y = _last_content_y(samples, w, h, w * n, n) + check("_last_content_y dense page returns last row", y == h - 1, f"got {y}") + + +def test_last_content_y_sparse_page() -> None: + """Page with content only in top 10 rows: returns 9.""" + w, h, n = 80, 100, 3 + samples = _make_samples(rows_with_content=10, w=w, h=h, n=n) + y = _last_content_y(samples, w, h, w * n, n) + check("_last_content_y sparse page returns last content row", + y == 9, f"got {y}") + + +def test_last_content_y_blank_page() -> None: + """Page with no content at all: returns 0.""" + w, h, n = 80, 100, 3 + samples = _make_samples(rows_with_content=0, w=w, h=h, n=n) + y = _last_content_y(samples, w, h, w * n, n) + check("_last_content_y blank page returns 0", y == 0, f"got {y}") + + +def test_density_threshold_buckets() -> None: + """Drive the real `_density_bucket` seam so the SPARSE (>50%) / WARN (>25%) + / OK categorization is asserted against production logic, not a reimplemented + copy. A `>`->`>=` slip or a warn/sparse swap in checks.py fails here.""" + density_cfg = load_checks_thresholds()["density"] + warn_pct = float(density_cfg["warn_pct"]) + sparse_pct = float(density_cfg["sparse_pct"]) + cases = [ + (0.0, "OK"), # full page + (warn_pct, "OK"), # exactly at warn threshold -> not yet WARN (strict >) + (0.30, "WARN"), # 30% trailing + (sparse_pct, "WARN"), # exactly at sparse threshold -> still WARN (strict >) + (0.51, "SPARSE"), # 51% trailing + (1.0, "SPARSE"), # blank page + ] + for empty, expected_bucket in cases: + bucket = _density_bucket(empty, warn_pct, sparse_pct) + check( + f"_density_bucket empty={empty:.2f} -> {expected_bucket}", + bucket == expected_bucket, + f"got {bucket}", + ) + + +def test_rhythm_issues_rules() -> None: + """Drive the three monotony rules in `_rhythm_issues` directly, without + rendering a deck. Covers content-run limit, missing divider, and missing + density-variation slide, plus the clean case.""" + max_run, min_deck = 4, 8 + + healthy = [ + "title_slide", "content_slide", "content_slide", "quote_slide", + "chapter_slide", "content_slide", "metrics_slide", "closing_slide", + ] + check("rhythm: balanced deck has no issues", + _rhythm_issues(healthy, max_run, min_deck) == [], + f"got {_rhythm_issues(healthy, max_run, min_deck)}") + + long_run = ["quote_slide"] + ["content_slide"] * (max_run + 1) + issues = _rhythm_issues(long_run, max_run, min_deck) + check("rhythm: over-long content run flagged", + any("content_slide run" in i for i in issues), f"got {issues}") + + no_divider = ["title_slide"] + ["content_slide", "quote_slide"] * 5 + issues = _rhythm_issues(no_divider, max_run, min_deck) + check("rhythm: large deck without divider flagged", + any("no chapter_slide divider" in i for i in issues), f"got {issues}") + + no_variation = ["title_slide", "content_slide", "chapter_slide", "content_slide"] + issues = _rhythm_issues(no_variation, max_run, min_deck) + check("rhythm: deck without quote/metrics flagged", + any("density variation" in i for i in issues), f"got {issues}") + + +def test_orphan_last_line_predicate() -> None: + """Drive `_orphan_last_line`: a short trailing line on a multi-line block + is an orphan; single-line blocks and long trailing lines are not.""" + max_words, max_chars = 3, 30 + + orphan = "This is a full sentence that wraps\nword" + check("orphan: short trailing line detected", + _orphan_last_line(orphan, max_words, max_chars) == "word", + f"got {_orphan_last_line(orphan, max_words, max_chars)!r}") + + single = "Only one line here" + check("orphan: single-line block is not an orphan", + _orphan_last_line(single, max_words, max_chars) is None, + "single line flagged") + + long_tail = "First line of the block\n" + "x" * (max_chars + 5) + check("orphan: long trailing line is not an orphan", + _orphan_last_line(long_tail, max_words, max_chars) is None, + "long tail flagged") + + many_words = "First line here\none two three four five" + check("orphan: wordy trailing line is not an orphan", + _orphan_last_line(many_words, max_words, max_chars) is None, + "wordy tail flagged") + + +def test_resume_balance_issues() -> None: + min_fill, max_fill, max_gap = 0.83, 0.95, 0.12 + check("resume balance accepts two filled pages", + _resume_balance_issues([0.88, 0.92], 2, min_fill, max_fill, max_gap) == []) + + issues = _resume_balance_issues([0.92, 0.74], 2, min_fill, max_fill, max_gap) + check("resume balance flags low second page", + any("p2 fill" in issue for issue in issues), + f"issues={issues}") + check("resume balance flags page gap", + any("gap" in issue for issue in issues), + f"issues={issues}") + + issues = _resume_balance_issues([0.90, 0.89, 0.50], 3, min_fill, max_fill, max_gap) + check("resume balance requires two pages", + any("expected 2" in issue for issue in issues), + f"issues={issues}") + + +# --------------------------- runner --------------------------- + +def test_highlight_with_language() -> None: + html = '<pre><code class="language-python">def foo():\n pass</code></pre>' + out = highlight_code_blocks(html) + if importlib.util.find_spec("pygments") is None: + check("highlight skips styled output when Pygments is absent", + out == html, + f"out differs: {out[:200]}") + return + + check("highlight adds style spans to language-tagged block", + "<span" in out and "style=" in out, + f"out: {out[:200]}") + check("highlight avoids synthetic bold", + "font-weight" not in out.lower(), + f"out: {out[:200]}") + check("highlight preserves pre/code wrapper", + "<pre" in out and "</code>" in out) + + +def test_highlight_without_language() -> None: + html = '<pre><code>def foo():\n pass</code></pre>' + out = highlight_code_blocks(html) + check("highlight does not modify plain code block", + out == html, + f"out differs: {out[:200]}") + + +def test_highlight_without_pygments_dependency() -> None: + html = '<pre><code class="language-python">def foo():\n pass</code></pre>' + original_import = builtins.__import__ + original_warned = highlight_mod._WARNED_MISSING_PYGMENTS + + def fake_import(name, *args, **kwargs): + if name == "pygments" or name.startswith("pygments."): + raise ImportError("blocked for fallback test") + return original_import(name, *args, **kwargs) + + try: + highlight_mod._WARNED_MISSING_PYGMENTS = False + builtins.__import__ = fake_import + warning = io.StringIO() + with contextlib.redirect_stderr(warning): + out = highlight_code_blocks(html) + finally: + builtins.__import__ = original_import + highlight_mod._WARNED_MISSING_PYGMENTS = original_warned + + check("highlight falls back unchanged without Pygments", + out == html, + f"out differs: {out[:200]}") + check("highlight warns when Pygments is missing", + "WARN: Pygments is not installed" in warning.getvalue(), + f"warning: {warning.getvalue()}") + + +def test_marp_themes_token_synced() -> None: + """Marp theme CSS keeps its :root tokens in sync with tokens.json. + + Locks the invariant AGENTS.md documents (tokens.py globs marp/*.css), so the + Marp decks cannot silently drift even if that glob is later refactored away. + """ + from shared import TOKENS_FILE + from tokens import CSS_VAR, ROOT_BLOCK + + canonical = {k.lstrip("-"): v.strip().lower() + for k, v in json.loads(TOKENS_FILE.read_text(encoding="utf-8")).items()} + marp_files = sorted((TEMPLATES / "marp").glob("*.css")) + check("marp theme CSS present", len(marp_files) >= 1, f"found {len(marp_files)} file(s)") + + drift: list[str] = [] + checked = 0 + for path in marp_files: + block = ROOT_BLOCK.search(path.read_text(encoding="utf-8", errors="replace")) + if not block: + continue + checked += 1 + found = {m.group(1): m.group(2).strip().lower() + for m in CSS_VAR.finditer(block.group(1))} + for name, expected in canonical.items(): + actual = found.get(name) + if actual is not None and actual != expected: + drift.append(f"{path.name}: --{name} expected {expected}, got {actual}") + check("marp theme :root tokens match tokens.json", + checked >= 1 and not drift, + "; ".join(drift) if drift else f"checked {checked}, no :root block found") + + +def test_mermaid_theme_matches_tokens() -> None: + from shared import TOKENS_FILE + canonical = json.loads(TOKENS_FILE.read_text(encoding="utf-8")) + issues = _mermaid_theme_drift(canonical) + check("mermaid theme colors and role docs match tokens.json", + issues == [], + f"issues: {issues}") + + +def test_mermaid_theme_drift_flags_token_mismatch() -> None: + from shared import TOKENS_FILE + canonical = json.loads(TOKENS_FILE.read_text(encoding="utf-8")) + canonical["--brand"] = "#000000" + issues = _mermaid_theme_drift(canonical) + check("mermaid theme drift flags accent token mismatch", + any("accent" in issue and "--brand" in issue for issue in issues), + f"issues: {issues}") + + +def test_mermaid_normalize_defaults_match_theme() -> None: + import mermaid_normalize as mermaid_mod + theme = json.loads((REPO_ROOT / "references" / "mermaid-theme.json").read_text(encoding="utf-8")) + expected_colors = {f"--{key}": value for key, value in theme["colors"].items()} + check("mermaid normalizer fallback colors mirror mermaid-theme.json", + mermaid_mod._DEFAULT_COLORS == expected_colors, + f"default={mermaid_mod._DEFAULT_COLORS}, theme={expected_colors}") + check("mermaid normalizer fallback font mirrors mermaid-theme.json", + mermaid_mod._DEFAULT_FONT_STACK == theme["cssFontStack"], + f"default={mermaid_mod._DEFAULT_FONT_STACK}, theme={theme['cssFontStack']}") + + +# --------------------------- mermaid normalize --------------------------- + +def test_mermaid_color_mix_srgb_single_pct() -> None: + from mermaid_normalize import _Resolver + r = _Resolver({"--fg": "#141413", "--bg": "#f5f4ed"}) + # color-mix(in srgb, fg 12%, bg) == 0.12*fg + 0.88*bg + got = r.hex_of("color-mix(in srgb, var(--fg) 12%, var(--bg))") + check("color-mix(in srgb, fg 12%, bg) resolves to warm gray", + got == "#dad9d3", f"got {got}") + + +def test_mermaid_color_mix_both_pct() -> None: + from mermaid_normalize import _Resolver + r = _Resolver({"--bg": "#ffffff", "--c": "#000000"}) + got = r.hex_of("color-mix(in srgb, var(--bg) 75%, var(--c) 25%)") + check("color-mix honors both explicit percentages", got == "#bfbfbf", f"got {got}") + + +def test_mermaid_normalize_strips_unsafe_features() -> None: + from mermaid_normalize import normalize + # Root carries a deliberately NON-Kami theme (red accent, white bg) to prove + # the normalizer re-themes to the Kami palette regardless of source theme. + raw = ( + '<svg xmlns="http://www.w3.org/2000/svg" ' + 'style="--bg:#ffffff;--fg:#000000;--accent:#ff0000;background:var(--bg)">' + "<style>@import url('https://fonts.googleapis.com/css2?family=Charter');\n" + " text { font-family: 'Charter', system-ui, sans-serif; }\n" + " svg { --_t: color-mix(in srgb, var(--fg) 25%, var(--bg)); }</style>" + '<rect fill="var(--accent)" stroke="var(--fg)"/></svg>' + ) + out = normalize(raw) + check("normalize removes color-mix()", "color-mix(" not in out, out) + check("normalize removes var()", "var(" not in out, out) + check("normalize removes google-fonts import", "googleapis" not in out, out) + check("normalize drops the quoted single-family bug", "'Charter'" not in out, out) + check("normalize keeps the Kami CJK serif stack", "TsangerJinKai02" in out, out) + check("normalize resolves fill to a static hex", 'fill="#' in out, out) + check("normalize re-themes accent to Kami ink-blue", + "#1b365d" in out.lower(), out) + check("normalize drops the source theme's red accent", + "#ff0000" not in out.lower(), out) + + +def test_mermaid_lint_flags_unnormalized_svg() -> None: + body = '<svg><rect fill="color-mix(in srgb, #000000 50%, #ffffff)"/></svg>' + path = write_temp_html(body, suffix=".html") # not a screen-template name + try: + rules = {f.rule for f in scan_file(path)} + check("scan_file flags un-normalized mermaid color-mix", + "mermaid-color-mix" in rules, f"rules={rules}") + finally: + path.unlink(missing_ok=True) + + +def test_mermaid_diagram_templates_normalized() -> None: + for name in ("sequence.html", "class.html", "er.html"): + path = REPO_ROOT / "assets" / "diagrams" / name + check(f"diagram {name} exists", path.exists(), f"missing {path}") + if not path.exists(): + continue + text = path.read_text(encoding="utf-8") + check(f"{name} carries no color-mix()", "color-mix(" not in text) + check(f"{name} carries no <foreignObject>", "<foreignObject" not in text) + check(f"{name} carries no runtime web-font import", "googleapis" not in text) + + +def test_mermaid_diagrams_match_their_mmd_sources() -> None: + """The committed diagram HTML must still carry every node/participant/entity + label from its .mmd source. No Node regenerates these, so this guards against + a .mmd edit that silently leaves the committed SVG stale.""" + src_dir = REPO_ROOT / "assets" / "diagrams" / "src" + sources = sorted(src_dir.glob("*.mmd")) + check("diagram .mmd sources present", len(sources) >= 1, f"found {len(sources)}") + for mmd in sources: + html_path = REPO_ROOT / "assets" / "diagrams" / f"{mmd.stem}.html" + check(f"{mmd.stem}.html exists for {mmd.name}", html_path.exists()) + if not html_path.exists(): + continue + labels: set[str] = set() + for line in mmd.read_text(encoding="utf-8").splitlines(): + line = line.strip() + m = re.match(r"participant\s+\S+\s+as\s+(.+)", line) + if m: + labels.add(m.group(1).strip()) + m = re.match(r"class\s+(\w+)", line) + if m: + labels.add(m.group(1)) + labels.update(re.findall(r"\b([A-Z][A-Z_]{2,})\b", line)) # ER entities + body = html_path.read_text(encoding="utf-8") + missing = sorted(label for label in labels if label not in body) + check(f"{mmd.stem}.html carries all {mmd.name} labels", + not missing, f"missing labels (regenerate the diagram): {missing}") + + +def test_mermaid_normalize_rejects_non_beautiful_mermaid() -> None: + """A non-beautiful-mermaid SVG (no --bg/--fg roles) must raise, not silently + emit unresolved colors.""" + from mermaid_normalize import normalize + raised = False + try: + normalize('<svg xmlns="http://www.w3.org/2000/svg"><rect fill="#000"/></svg>') + except ValueError: + raised = True + check("normalize rejects input lacking --bg/--fg color roles", raised) + + +def test_mermaid_normalize_cli_accepts_output_before_input() -> None: + """CLI parsing should accept both `input -o out` and `-o out input`.""" + script = REPO_ROOT / "scripts" / "mermaid_normalize.py" + raw = ( + '<svg xmlns="http://www.w3.org/2000/svg" ' + 'style="--bg:#ffffff;--fg:#000000;--accent:#ff0000">' + '<rect fill="var(--accent)" /></svg>' + ) + with tempfile.TemporaryDirectory() as d: + dp = Path(d) + src = dp / "raw.svg" + out = dp / "clean.svg" + src.write_text(raw, encoding="utf-8") + result = subprocess.run( + [sys.executable, str(script), "-o", str(out), str(src)], + cwd=REPO_ROOT, + capture_output=True, + text=True, + ) + body = out.read_text(encoding="utf-8") if out.exists() else "" + check("mermaid_normalize CLI supports -o before input", + result.returncode == 0 and out.exists() and "color-mix(" not in body and "var(" not in body, + (result.stdout + result.stderr).strip()) + + +def test_mermaid_normalize_cli_reports_missing_input() -> None: + """Missing input should be a concise ERROR, not a Python traceback.""" + script = REPO_ROOT / "scripts" / "mermaid_normalize.py" + with tempfile.TemporaryDirectory() as d: + result = subprocess.run( + [sys.executable, str(script), str(Path(d) / "missing.svg")], + cwd=REPO_ROOT, + capture_output=True, + text=True, + ) + combined = result.stdout + result.stderr + check("mermaid_normalize CLI reports missing input without traceback", + result.returncode == 1 and "ERROR:" in combined and "Traceback" not in combined, + combined.strip()) + + +def _test_functions(): + tests = [] + for name, func in globals().items(): + if not name.startswith("test_") or not callable(func): + continue + if getattr(func, "__module__", None) != __name__: + continue + code = getattr(func, "__code__", None) + if code is None: + continue + tests.append((code.co_firstlineno, name, func)) + return [(name, func) for _, name, func in sorted(tests)] + + +def main() -> int: + for name, func in _test_functions(): + signature = inspect.signature(func) + if signature.parameters: + params = ", ".join(signature.parameters) + check(f"{name} has no parameters", False, f"parameters: {params}") + continue + func() + print() + print(f"Passed: {_PASS} | Failed: {_FAIL}") + return 0 if _FAIL == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/tokens.py b/scripts/tokens.py new file mode 100644 index 0000000..aa30c76 --- /dev/null +++ b/scripts/tokens.py @@ -0,0 +1,153 @@ +"""Token-sync checker for kami templates. + +Splits out from build.py: scans `:root { ... }` blocks across HTML templates +and `RGBColor(0xXX, 0xXX, 0xXX)` constants in the PPTX slide scripts, and +reports drift from `references/tokens.json` (the canonical color tokens). +""" +from __future__ import annotations + +import json +import re +from pathlib import Path + +from shared import ROOT, TEMPLATES, TOKENS_FILE, iter_template_files + +ROOT_BLOCK = re.compile(r":root\s*\{([^}]*)\}", re.DOTALL) +CSS_VAR = re.compile(r"--([\w-]+)\s*:\s*([^;]+);") + + +def parse_root_vars(text: str) -> dict[str, str]: + """Return {'--name': value} merged across every `:root { ... }` block. + + Scanning all blocks (not just the first) keeps a future dark-mode or print + override inside a second `:root` from silently escaping the drift checks. + """ + found: dict[str, str] = {} + for block in ROOT_BLOCK.finditer(text): + for m in CSS_VAR.finditer(block.group(1)): + found[f"--{m.group(1)}"] = m.group(2).strip() + return found +PY_RGB = re.compile( + r"^([A-Z][A-Z_]+)\s*=\s*RGBColor\(\s*0x([0-9a-fA-F]{2})\s*," + r"\s*0x([0-9a-fA-F]{2})\s*,\s*0x([0-9a-fA-F]{2})\s*\)", + re.MULTILINE, +) +# Python const name -> tokens.json key. Only constants that mirror a CSS token. +PY_TOKEN_MAP = { + "PARCHMENT": "--parchment", + "IVORY": "--ivory", + "BRAND": "--brand", + "NEAR_BLACK": "--near-black", + "DARK_WARM": "--dark-warm", + "CHARCOAL": "--charcoal", + "OLIVE": "--olive", + "STONE": "--stone", +} +MERMAID_THEME_FILE = ROOT / "references" / "mermaid-theme.json" +MERMAID_THEME_TOKEN_MAP = { + "bg": "--parchment", + "fg": "--near-black", + "line": "--olive", + "accent": "--brand", + "muted": "--stone", + "surface": "--ivory", + "border": "--border", +} + + +def _mermaid_theme_drift(canonical: dict[str, str]) -> list[str]: + if not MERMAID_THEME_FILE.exists(): + return [f"mermaid-theme.json not found at {MERMAID_THEME_FILE.relative_to(ROOT)}"] + + try: + theme = json.loads(MERMAID_THEME_FILE.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + return [f"mermaid-theme.json is malformed: {exc}"] + + colors = theme.get("colors", {}) + roles = theme.get("roles", {}) + drift: list[str] = [] + for role, token in MERMAID_THEME_TOKEN_MAP.items(): + expected = canonical.get(token) + actual = colors.get(role) + if expected is None: + drift.append(f"{role}: canonical token {token} missing") + continue + if actual is None: + drift.append(f"{role}: color missing, expected {expected} from {token}") + elif actual.lower() != expected.lower(): + drift.append(f"{role}: expected {expected} from {token}, got {actual}") + + role_doc = str(roles.get(role, "")) + if token not in role_doc: + drift.append(f"{role}: role doc should mention {token}") + + return drift + + +def sync_check(verbose: bool = False) -> int: + if not TOKENS_FILE.exists(): + print(f"ERROR: tokens.json not found at {TOKENS_FILE.relative_to(ROOT)}") + return 2 + + try: + canonical: dict[str, str] = json.loads(TOKENS_FILE.read_text()) + except json.JSONDecodeError as exc: + print(f"ERROR: tokens.json is malformed: {exc}") + return 2 + + targets = iter_template_files(include_diagrams=True, include_marp_css=True) + py_targets: list[Path] = list(TEMPLATES.glob("*.py")) + if not targets and not py_targets: + print("ERROR: no templates found to token-check (bad checkout?)") + return 2 + + drift: list[tuple[str, str, str, str]] = [] # (file, token, expected, actual) + + for path in targets: + text = path.read_text(encoding="utf-8", errors="replace") + found = parse_root_vars(text) + if not found: + if verbose: + print(f" (skip {path.name}: no :root block)") + continue + rel = path.relative_to(ROOT) + for token, expected in canonical.items(): + actual = found.get(token) + # Only flag if the template defines the token but with a wrong value. + # Templates that don't use a token don't need to define it. + if actual is not None and actual.lower() != expected.lower(): + drift.append((str(rel), token, expected, actual)) + + for path in sorted(py_targets): + text = path.read_text(encoding="utf-8", errors="replace") + rel = path.relative_to(ROOT) + for m in PY_RGB.finditer(text): + name = m.group(1) + token = PY_TOKEN_MAP.get(name) + if token is None: + continue + expected = canonical.get(token) + if expected is None: + continue + actual = f"#{m.group(2)}{m.group(3)}{m.group(4)}" + if actual.lower() != expected.lower(): + drift.append((str(rel), token, expected, actual)) + + theme_drift = _mermaid_theme_drift(canonical) + + if not drift and not theme_drift: + scanned = len(targets) + len(py_targets) + print(f"OK: tokens in sync across {scanned} template(s) and mermaid-theme.json") + return 0 + + if drift: + print(f"\nERROR: [token-drift] {len(drift)}") + for file, token, expected, actual in drift: + print(f" {file}: {token} expected {expected}, got {actual}") + if theme_drift: + print(f"\nERROR: [mermaid-theme-drift] {len(theme_drift)}") + for issue in theme_drift: + print(f" {issue}") + + return 1 diff --git a/scripts/verify.py b/scripts/verify.py new file mode 100644 index 0000000..e2fd02a --- /dev/null +++ b/scripts/verify.py @@ -0,0 +1,293 @@ +"""End-to-end render verification for kami templates. + +Splits out from build.py: renders each template via WeasyPrint, validates +page-count against per-template ceilings, inspects embedded PDF fonts to +warn when only a fallback is used, and runs an advisory density scan over +the rendered examples when invoked for the full suite. +""" +from __future__ import annotations + +import os +import re +import subprocess +from pathlib import Path + +from highlight import highlight_code_blocks +from optional_deps import ( + MissingDepError, + require_pypdf_reader, + require_weasyprint_html, +) +from shared import DIAGRAMS, EXAMPLES, TEMPLATES, default_example_pdfs, load_checks_thresholds + +# Primary fonts expected in embedded PDF font names +CN_PRIMARY_FONTS = {"TsangerJinKai02"} +EN_PRIMARY_FONTS = {"Charter"} +KO_PRIMARY_FONTS = {"Source-Han-Serif-K", "SourceHanSerifK"} +RECOGNIZABLE_FALLBACK_FONT_MARKERS = ( + "Georgia", + "Palatino", + "PT-Serif", + "PTSerif", + "TsangerJinKai", + "YuMincho", + "Hiragino", + "SourceHan", + "Noto", + "Charter", + "Songti", + "DejaVu", + "Liberation", +) + + +def show_fonts(pdf: Path) -> None: + if not pdf.exists(): + return + try: + out = subprocess.run(["pdffonts", str(pdf)], capture_output=True, text=True, check=False) + if out.returncode == 0: + print("--- pdffonts ---") + print(out.stdout.rstrip()) + except FileNotFoundError: + pass # pdffonts not installed; silent + + +def _pdf_font_names(pdf_path: Path) -> set[str]: + def _resolve_pdf_obj(obj): + if obj is None: + return None + try: + return obj.get_object() if hasattr(obj, "get_object") else obj + except Exception: + return obj + + try: + PdfReader = require_pypdf_reader() + reader = PdfReader(str(pdf_path)) + fonts: set[str] = set() + for page in reader.pages: + resources = _resolve_pdf_obj(page.get("/Resources")) + if resources is None or not hasattr(resources, "get"): + continue + font_dict = _resolve_pdf_obj(resources.get("/Font")) + if font_dict is None or not hasattr(font_dict, "values"): + continue + for obj in font_dict.values(): + resolved = _resolve_pdf_obj(obj) + if resolved is None or not hasattr(resolved, "get"): + continue + base = resolved.get("/BaseFont") + if base: + fonts.add(str(base).lstrip("/")) + return fonts + except Exception as exc: + print(f" WARN: could not read font names from PDF: {exc}") + return set() + + +def _check_font_sources(html_path: Path) -> list[str]: + """Return list of local @font-face src files that are missing on disk.""" + text = html_path.read_text(encoding="utf-8", errors="replace") + missing: list[str] = [] + for url in re.findall(r"""url\(["']?([^"')]+)["']?\)""", text): + if url.startswith(("http://", "https://", "data:", "#")): + continue + resolved = (html_path.parent / url).resolve() + if not resolved.exists(): + missing.append(url) + return missing + + +def verify_target( + name: str, + source: str, + max_pages: int, + src_dir: Path, + *, + infer_author_fn, + set_pdf_metadata_fn, +) -> list[str]: + """Render `source` to a PDF, then run page-count and font checks. + + `infer_author_fn` and `set_pdf_metadata_fn` are passed in by the caller + (build.py) so this module avoids a circular import on those helpers. + """ + issues: list[str] = [] + src = src_dir / source + if not src.exists(): + issues.append(f"source not found: {src}") + return issues + + try: + HTML = require_weasyprint_html() + PdfReader = require_pypdf_reader() + except MissingDepError as exc: + issues.append(str(exc)) + return issues + + EXAMPLES.mkdir(parents=True, exist_ok=True) + out = EXAMPLES / f"{name}.pdf" + + # Warn about missing local font files before rendering + missing_fonts = _check_font_sources(src) + if missing_fonts: + for mf in missing_fonts: + print(f" [FONT MISS] {name}: {mf} not found") + print(f" [FONT MISS] Repo fix: git checkout -- assets/fonts (commercial TTFs are tracked)") + print(f" [FONT MISS] Skill recovery (downloads to the user font dir, not the skill): bash scripts/ensure-fonts.sh") + print(f" [FONT MISS] Fallback: brew install --cask font-source-han-serif-sc") + + html_text = src.read_text(encoding="utf-8") + html_text = highlight_code_blocks(html_text) + HTML(string=html_text, base_url=str(src.parent)).write_pdf(str(out)) + + # Set PDF metadata (only replaces placeholders, preserves filled values) + set_pdf_metadata_fn(out, author=infer_author_fn()) + + # page count check + n = len(PdfReader(str(out)).pages) + if max_pages and n > max_pages: + over = n - max_pages + hint = "" + if "resume" in name and over == 1: + hint = '; add class="resume--dense" to <body> or tighten .proj-text line-height to 1.38' + issues.append(f"page overflow: {n} pages (limit {max_pages}){hint}") + + # font check + embedded = _pdf_font_names(out) + fallback_present = any( + kw in font for font in embedded + for kw in RECOGNIZABLE_FALLBACK_FONT_MARKERS + ) + + # Diagram templates are language-neutral and often rely on fallback stacks, + # so only enforce that at least one recognizable serif/sans fallback exists. + is_diagram = src_dir == DIAGRAMS + if is_diagram: + if not fallback_present: + issues.append(f"no recognizable font embedded in {out.name}") + return issues + + is_en = name.endswith("-en") + is_ko = name.endswith("-ko") + expected = EN_PRIMARY_FONTS if is_en else (KO_PRIMARY_FONTS if is_ko else CN_PRIMARY_FONTS) + if not any(exp in font_name for exp in expected for font_name in embedded): + primary = next(iter(expected)) + if not fallback_present: + issues.append(f"no recognizable font embedded in {out.name}") + elif os.environ.get("KAMI_ALLOW_FALLBACK_ONLY"): + # CI / headless boxes never have commercial fonts (TsangerJinKai02, + # Charter). Treat "primary missing, fallback present" as a warning + # there so CI can still gate page-count regressions. + print(f" WARN: {name}: primary font ({primary}) not embedded; using fallback") + else: + issues.append(f"primary font ({primary}) not embedded; using fallback") + + return issues + + +def verify_screen_target(name: str, source: str, scan_file_fn) -> list[str]: + """Lint a browser-only template via the caller-provided scan_file.""" + src = TEMPLATES / source + if not src.exists(): + return [f"source not found: {src}"] + findings = scan_file_fn(src) + if findings: + return [f"{len(findings)} template violation(s)"] + return [] + + +def verify_all( + target: str | None, + *, + html_targets, + screen_targets, + diagram_targets, + pptx_targets, + verify_slides_fn, + scan_file_fn, + scan_density_fn, + infer_author_fn, + set_pdf_metadata_fn, +) -> int: + """Drive verification across the requested target set. + + All registries and helper callbacks are passed in to keep this module free + of circular imports back into build.py. + """ + targets_to_run: dict[str, tuple[str, int, Path] | None] = {} + screen_targets_to_run: dict[str, str] = {} + if target: + if target in html_targets: + src, mp = html_targets[target] + targets_to_run[target] = (src, mp, TEMPLATES) + elif target in screen_targets: + screen_targets_to_run[target] = screen_targets[target] + elif target in diagram_targets: + targets_to_run[target] = (diagram_targets[target], 0, DIAGRAMS) + elif target in pptx_targets: + targets_to_run[target] = None + else: + print(f"ERROR: unknown target: {target}") + return 2 + else: + for name, (src, mp) in html_targets.items(): + targets_to_run[name] = (src, mp, TEMPLATES) + for name, src in screen_targets.items(): + screen_targets_to_run[name] = src + for name, src in diagram_targets.items(): + targets_to_run[name] = (src, 0, DIAGRAMS) + for name in pptx_targets: + targets_to_run[name] = None + + failures = 0 + rows: list[tuple[str, str]] = [] + for name, config in targets_to_run.items(): + if config is None: + issues = verify_slides_fn(name) + else: + source, max_pages, src_dir = config + issues = verify_target( + name, source, max_pages, src_dir, + infer_author_fn=infer_author_fn, + set_pdf_metadata_fn=set_pdf_metadata_fn, + ) + if issues: + rows.append((f"ERROR: {name}", "; ".join(issues))) + failures += 1 + else: + rows.append((f"OK: {name}", "ok")) + + for name, source in screen_targets_to_run.items(): + issues = verify_screen_target(name, source, scan_file_fn) + if issues: + rows.append((f"ERROR: {name}", "; ".join(issues))) + failures += 1 + else: + rows.append((f"OK: {name}", "static HTML template")) + + for status, detail in rows: + print(f"{status}: {detail}") + + if target is None: + pdfs = default_example_pdfs() + if pdfs: + print() + print("Density scan (advisory):") + scan = scan_density_fn(pdfs) + if scan is not None: + sparse, warn, _, scanned = scan + if sparse + warn == 0: + print(f" OK: no density issues across {scanned} PDF(s)") + else: + density_cfg = load_checks_thresholds()["density"] + sparse_pct_disp = int(round(float(density_cfg["sparse_pct"]) * 100)) + warn_pct_disp = int(round(float(density_cfg["warn_pct"]) * 100)) + if sparse: + print(f" {sparse} SPARSE page(s) (>{sparse_pct_disp}% trailing whitespace) across {scanned} PDF(s)") + if warn: + print(f" {warn} WARN page(s) (>{warn_pct_disp}%) across {scanned} PDF(s)") + print(" (advisory: re-author with SKILL.md Step 4.1 merge rule. Does not fail --verify.)") + + return 0 if failures == 0 else 1 diff --git a/sitemap.xml b/sitemap.xml new file mode 100644 index 0000000..ec6248c --- /dev/null +++ b/sitemap.xml @@ -0,0 +1,39 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + lastmod is bumped manually on each public-site update. + Suggested workflow: get the latest commit date for the index pages with + `git log -1 +format=%cs` (use the long flag form), then copy that ISO date + into every <lastmod> entry below before tagging a release. +--> +<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> + <url> + <loc>https://kami.tw93.fun/</loc> + <lastmod>2026-07-04</lastmod> + <changefreq>monthly</changefreq> + <priority>1.0</priority> + </url> + <url> + <loc>https://kami.tw93.fun/index-zh.html</loc> + <lastmod>2026-07-04</lastmod> + <changefreq>monthly</changefreq> + <priority>0.8</priority> + </url> + <url> + <loc>https://kami.tw93.fun/index-ja.html</loc> + <lastmod>2026-07-04</lastmod> + <changefreq>monthly</changefreq> + <priority>0.8</priority> + </url> + <url> + <loc>https://kami.tw93.fun/index-ko.html</loc> + <lastmod>2026-07-04</lastmod> + <changefreq>monthly</changefreq> + <priority>0.8</priority> + </url> + <url> + <loc>https://kami.tw93.fun/index-tw.html</loc> + <lastmod>2026-07-04</lastmod> + <changefreq>monthly</changefreq> + <priority>0.8</priority> + </url> +</urlset> diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..750f5ec --- /dev/null +++ b/styles.css @@ -0,0 +1,1208 @@ + /* Palatino is a system font, no @font-face needed */ + @font-face { + font-family: "JetBrains Mono"; + src: url("assets/fonts/JetBrainsMono.woff2") format("woff2"); + font-weight: 400 500; + font-style: normal; + font-display: swap; + } + @font-face { + font-family: "TsangerJinKai02"; + src: url("assets/fonts/TsangerJinKai02-W04.ttf") format("truetype"); + font-weight: 400 500; + font-style: normal; + font-display: swap; + } + + + :root { + --parchment: #f5f4ed; + --ivory: #faf9f5; + --warm-sand: #e8e6dc; + --dark-surface: #30302e; + --deep-dark: #141413; + + --brand: #1B365D; + --brand-light: #2D5A8A; + + --near-black: #141413; + --dark-warm: #3d3d3a; + --olive: #504e49; + --stone: #6b6a64; + + --border: #e8e6dc; + --border-soft: #e5e3d8; + + /* Slide-scale spacing */ + --slide-pad: 80px; + + --serif: Charter, Georgia, "TsangerJinKai02", "Source Han Serif SC", "Noto Serif CJK SC", "Songti SC", Palatino, serif; + --sans: var(--serif); + --mono: "JetBrains Mono", "Fira Code", "SF Mono", Consolas, Monaco, monospace; + } + + html[lang="zh-CN"] { + --serif: "TsangerJinKai02", "Source Han Serif SC", "Noto Serif CJK SC", "Songti SC", Georgia, serif; + --sans: var(--serif); + } + + html[lang="ja"] { + --serif: "YuMincho", "Yu Mincho", "Hiragino Mincho ProN", "Noto Serif CJK JP", "Source Han Serif JP", "TsangerJinKai02", Georgia, serif; + --sans: var(--serif); + --olive: #4d4c48; /* YuMincho strokes are thinner; darken olive text to compensate */ + } + + html[lang="zh-Hant"] { + --serif: "TsangerJinKai02", "Source Han Serif TC", "Noto Serif CJK TC", "Songti TC", Georgia, serif; + --sans: var(--serif); + } + + html[lang="ko"] { + --serif: "Nanum Myeongjo", AppleMyungjo, "Noto Serif CJK KR", "Source Han Serif K", "Source Han Serif KR", Georgia, serif; + --sans: var(--serif); + --olive: #4d4c48; + } + + /* Language switcher */ + .lang-switch { + position: relative; + z-index: 20; + font-family: var(--sans); + letter-spacing: 0; + text-transform: none; + } + .lang-trigger { + min-height: 18px; + display: inline-flex; + align-items: center; + gap: 5px; + padding: 0; + border: 0; + color: var(--stone); + background: transparent; + cursor: pointer; + font: inherit; + font-size: 12px; + font-weight: 500; + line-height: 1; + transition: color 0.15s; + } + .lang-trigger::after { + content: ""; + width: 4px; + height: 4px; + margin-top: -2px; + border-right: 1px solid currentColor; + border-bottom: 1px solid currentColor; + opacity: 0.55; + transform: rotate(45deg); + transition: transform 0.18s ease, opacity 0.18s ease; + } + .lang-trigger:hover, + .lang-switch.is-open .lang-trigger { + color: var(--brand); + } + .lang-switch.is-open .lang-trigger::after { + margin-top: 2px; + opacity: 0.7; + transform: rotate(225deg); + } + .lang-trigger:focus-visible, + .lang-menu a:focus-visible { + outline: 2px solid color-mix(in srgb, var(--brand) 38%, transparent); + outline-offset: 3px; + } + .lang-switch::after { + content: ""; + position: absolute; + left: -10px; + right: -10px; + top: 100%; + height: 12px; + } + .lang-menu { + position: absolute; + top: calc(100% + 8px); + right: 0; + width: 140px; + display: grid; + gap: 1px; + padding: 5px; + border: 1px solid var(--border-soft); + border-radius: 8px; + background: color-mix(in srgb, var(--ivory) 97%, transparent); + -webkit-backdrop-filter: blur(14px); + backdrop-filter: blur(14px); + box-shadow: 0 8px 24px rgba(20, 19, 19, 0.08); + opacity: 0; + visibility: hidden; + pointer-events: none; + transform: translateY(-4px); + transition: opacity 0.16s ease, transform 0.16s ease, visibility 0.16s ease; + } + .lang-switch.is-open .lang-menu, + .lang-switch:hover .lang-menu, + .lang-switch:focus-within .lang-menu { + opacity: 1; + visibility: visible; + pointer-events: auto; + transform: translateY(0); + } + .lang-menu a { + min-height: 29px; + display: flex; + align-items: center; + padding: 0 8px; + border-radius: 6px; + color: var(--olive); + font-size: 12px; + line-height: 1; + text-decoration: none; + transition: color 0.15s, background 0.15s; + } + .lang-menu a:hover { + color: var(--brand); + background: color-mix(in srgb, var(--brand) 8%, transparent); + } + .lang-menu a.active { + color: var(--brand); + font-weight: 600; + background: color-mix(in srgb, var(--brand) 12%, transparent); + } + + /* Page transition */ + @keyframes fadeIn { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } + } + + * { box-sizing: border-box; } + html, body { margin: 0; padding: 0; } + + /* Print rules: keep warm background, avoid harsh page breaks */ + @page { size: A4; margin: 14mm 16mm; background: #f5f4ed; } + @media print { + body { background: #f5f4ed; -webkit-print-color-adjust: exact; print-color-adjust: exact; } + .page { padding: 0 !important; max-width: 100% !important; } + section { page-break-inside: auto; margin-bottom: 36px; } + .hero, .section-head, .family, .comp, .shadow-demo, .tint, .swatch, .radius, blockquote, pre, tr, .anti > div { break-inside: avoid; page-break-inside: avoid; } + .hero h1 { font-size: 76px; } + h2.section-title { font-size: 26px; } + .comp-grid, .swatches, .tint-strip, .family-grid, .shadow-row, .anti { break-inside: auto; } + } + body { + background: var(--parchment); + color: var(--near-black); + font-family: var(--sans); + font-size: 14px; + line-height: 1.55; + letter-spacing: 0; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + html[lang="zh-CN"] body { letter-spacing: 0.35px; } + html[lang="zh-Hant"] body { letter-spacing: 0.35px; } + html[lang="ko"] body { letter-spacing: 0; } + html[lang="en"] body { letter-spacing: 0; } + html[lang="ja"] body { letter-spacing: 0.02em; } + html[lang="en"] .section-title { letter-spacing: -0.3px; } + html[lang="en"] .manifesto, + html[lang="ja"] .manifesto { letter-spacing: 0; } + html[lang="zh-CN"] .section-title, + html[lang="zh-Hant"] .section-title, + html[lang="ko"] .section-title, + html[lang="ja"] .section-title { + letter-spacing: 0; + } + + .page { + max-width: 1120px; + margin: 0 auto; + padding: 88px 64px 120px; + animation: fadeIn 0.4s ease-out; + } + + /* ---------- Hero ---------- */ + .hero { + padding-bottom: 40px; + border-bottom: 1px solid var(--border-soft); + margin-bottom: 48px; + } + .hero-tokens { + display: flex; + gap: 20px; + flex-wrap: wrap; + margin-top: 18px; + } + .hero-tokens span { + display: flex; + gap: 6px; + font-size: 13px; + color: var(--stone); + font-variant-numeric: tabular-nums; + } + .hero-tokens b { + color: var(--dark-warm); + font-weight: 500; + } + .eyebrow { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + font-family: var(--sans); + font-size: 12px; + font-weight: 500; + letter-spacing: 1.2px; + text-transform: uppercase; + color: var(--stone); + margin: 0 0 18px; + } + .hero-links { + display: flex; + gap: 13px; + align-items: center; + flex: 0 0 auto; + } + .hero-links a { + color: var(--stone); + display: flex; + align-items: center; + transition: color 0.15s; + } + .hero-links a:hover { + color: var(--dark-warm); + } + .hero h1 { + font-family: var(--serif); + font-weight: 500; + font-size: 106px; + line-height: 1.05; + letter-spacing: -1.2px; + margin: 0 0 14px; + color: var(--near-black); + } + .hero h1 .cn { + display: inline-block; + margin-left: 16px; + letter-spacing: 0; + color: var(--brand); + } + .hero .tagline { + font-family: var(--serif); + font-weight: 500; + font-size: 21px; + line-height: 1.35; + color: var(--olive); + margin: 0; + } + + /* ---------- Section ---------- */ + section { margin-bottom: 72px; } + .section-head { margin-bottom: 24px; } + .section-num { + font-family: var(--serif); + font-weight: 500; + font-size: 14px; + color: var(--brand); + letter-spacing: 0.4px; + margin: 0 0 6px; + } + .section-title { + font-family: var(--serif); + font-weight: 500; + font-size: 32px; + line-height: 1.2; + color: var(--near-black); + margin: 0; + display: block; + letter-spacing: 0.4px; + } + .section-lede { + font-family: var(--serif); + font-weight: 500; + font-size: 16px; + line-height: 1.55; + color: var(--olive); + margin: 14px 0 0; + } + + /* ---------- Manifesto ---------- */ + .manifesto { + font-family: var(--serif); + font-size: 20px; + line-height: 1.65; + letter-spacing: 0.05em; + color: var(--near-black); + margin: 0 0 28px; + font-weight: 400; + } + .manifesto em { + color: var(--brand); + font-style: normal; + } + + .rules { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0; + border-top: 1px solid var(--border-soft); + } + .rules li { + display: grid; + grid-template-columns: 32px 1fr; + gap: 4px; + padding: 18px 24px 18px 0; + border-bottom: 1px solid var(--border-soft); + align-items: start; + } + .rules li:nth-child(odd) { padding-right: 32px; border-right: 1px solid var(--border-soft); padding-left: 0; } + .rules li:nth-child(even) { padding-left: 32px; } + .rules .n { + font-family: var(--serif); + font-weight: 500; + font-size: 20px; + color: var(--brand); + line-height: 1; + padding-top: 0; + } + .rules p { + margin: 0; + font-size: 14px; + line-height: 1.5; + color: var(--dark-warm); + } + .rules code { + font-family: var(--mono); + font-size: 12px; + color: var(--brand); + background: #EEF2F7; + padding: 1px 5px; + border-radius: 2px; + } + + /* ---------- Color ---------- */ + .swatches { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 14px; + margin-bottom: 28px; + } + .swatch { + border: 1px solid var(--border); + border-radius: 8px; + background: var(--ivory); + overflow: hidden; + transition: box-shadow 0.2s; + } + .swatch:hover { + box-shadow: 0 4px 24px rgba(0,0,0,0.05); + } + .swatch .chip { + height: 96px; + border-bottom: 1px solid var(--border); + } + .swatch .info { + padding: 12px 14px 14px; + } + .swatch .name { + font-family: var(--serif); + font-weight: 500; + font-size: 15px; + color: var(--near-black); + margin: 0; + line-height: 1.25; + } + .swatch .role { + font-size: 12px; + color: var(--olive); + margin: 3px 0 6px; + line-height: 1.4; + } + .swatch .hex { + font-family: var(--mono); + font-size: 12px; + color: var(--stone); + letter-spacing: 0.4px; + } + .swatch.brand .name { color: var(--brand); } + .swatch.dark .chip { border-bottom-color: var(--deep-dark); } + + .subhead { + font-family: var(--serif); + font-weight: 500; + font-size: 18px; + color: var(--dark-warm); + margin: 40px 0 14px; + } + + /* tag tint table */ + .tint-strip { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 12px; + background: var(--parchment); + } + .tint { + border: 1px solid var(--border); + border-radius: 8px; + background: var(--ivory); + padding: 18px 16px 14px; + } + .tint .sample { + display: inline-block; + font-size: 12px; + font-weight: 500; + color: var(--brand); + padding: 2px 8px; + border-radius: 3px; + letter-spacing: 0.4px; + margin-left: -1px; + margin-bottom: 10px; + } + .tint .opacity { + font-family: var(--serif); + font-size: 14px; + color: var(--olive); + margin: 0 0 2px; + } + .tint .hex { + font-family: var(--mono); + font-size: 12px; + color: var(--stone); + } + .tint.default { outline: 1.5px solid var(--brand); outline-offset: -1.5px; } + .tint.default .opacity { color: var(--brand); } + .tint .flag { + font-size: 12px; + font-weight: 500; + letter-spacing: 0.8px; + color: var(--brand); + margin-bottom: 8px; + text-transform: uppercase; + display: block; + height: 11px; + } + + /* ---------- Type ---------- */ + .type-grid { + display: grid; + grid-template-columns: 180px 1fr 160px; + column-gap: 40px; + row-gap: 0; + border-top: 1px solid var(--border-soft); + } + .type-row { + display: contents; + } + .type-row > * { + padding: 22px 0; + border-bottom: 1px solid var(--border-soft); + align-self: stretch; + } + .type-label { + font-family: var(--serif); + font-size: 13px; + font-weight: 500; + letter-spacing: 0; + color: var(--stone); + padding-top: 22px; + } + .type-specs { + font-family: var(--mono); + font-size: 12px; + color: var(--olive); + line-height: 1.6; + text-align: right; + padding-top: 22px; + } + .type-specs b { color: var(--dark-warm); font-weight: 500; } + .type-sample.display { + font-family: var(--serif); font-weight: 500; + font-size: 54px; line-height: 1.1; color: var(--near-black); + letter-spacing: -0.5px; + } + html[lang="zh-CN"] .type-sample.display { + font-size: 48px; + } + html[lang="ja"] .type-sample.display { + font-size: 42px; + line-height: 1.12; + letter-spacing: 0.3px; + } + html[lang="zh-CN"] .type-sample.display { letter-spacing: 0.5px; } + .type-sample.h1 { + font-family: var(--serif); font-weight: 500; + font-size: 30px; line-height: 1.2; color: var(--near-black); + } + .type-sample.h2 { + font-family: var(--serif); font-weight: 500; + font-size: 22px; line-height: 1.25; color: var(--near-black); + } + .type-sample.h3 { + font-family: var(--serif); font-weight: 500; + font-size: 17px; line-height: 1.3; color: var(--near-black); + } + .type-sample.lede { + font-family: var(--serif); font-weight: 500; + font-size: 15px; line-height: 1.55; color: var(--near-black); + max-width: 540px; + } + .type-sample.body { + font-family: var(--serif); font-weight: 500; + font-size: 14px; line-height: 1.55; color: var(--dark-warm); + max-width: 540px; + } + .type-sample.dense { + font-family: var(--serif); font-weight: 500; + font-size: 14px; line-height: 1.4; color: var(--dark-warm); + max-width: 540px; + } + .type-sample.caption { + font-family: var(--serif); font-weight: 500; + font-size: 12px; line-height: 1.45; color: var(--olive); + } + .type-sample.label { + font-family: var(--serif); font-weight: 500; + font-size: 12px; line-height: 1.35; color: var(--brand); + letter-spacing: 0.4px; + text-transform: uppercase; + } + + /* family cards */ + .family-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 16px; + margin-top: 24px; + } + .family { + background: var(--ivory); + border: 1px solid var(--border); + border-radius: 12px; + padding: 28px 24px 22px; + transition: box-shadow 0.2s; + } + .family:hover { + box-shadow: 0 4px 24px rgba(0,0,0,0.05); + } + .family .glyph { + font-weight: 500; + font-size: 80px; + line-height: 1; + color: var(--near-black); + margin: 0 0 18px; + letter-spacing: -1px; + height: 80px; + display: flex; + align-items: center; + } + .family.serif .glyph { font-family: var(--serif); } + .family.sans .glyph { font-family: var(--sans); } + .family.mono .glyph { font-family: var(--mono); font-weight: 500; font-size: 64px; } + .family .role { + font-family: var(--serif); + font-weight: 500; + font-size: 18px; + color: var(--near-black); + margin: 0 0 4px; + } + .family .name { + font-size: 12px; + color: var(--olive); + font-family: var(--mono); + margin: 0 0 12px; + } + .family .use { + font-size: 12px; + color: var(--dark-warm); + line-height: 1.55; + margin: 0; + } + + /* ---------- Spacing ---------- */ + .space-table { + width: 100%; + border-collapse: collapse; + } + .space-table th, .space-table td { + text-align: left; + padding: 14px 16px 14px 0; + border-bottom: 1px solid var(--border-soft); + font-size: 14px; + color: var(--dark-warm); + vertical-align: middle; + } + .space-table th { + font-family: var(--serif); + font-size: 13px; + font-weight: 500; + letter-spacing: 0; + color: var(--stone); + padding-top: 0; + } + .space-table td.name { + font-family: var(--serif); + font-weight: 500; + font-size: 15px; + color: var(--near-black); + width: 120px; + } + .space-table td.val { font-family: var(--mono); font-size: 13px; color: var(--olive); width: 130px; } + .space-table td.bar { + width: 280px; + } + .bar span { + display: block; + height: 4px; + background: var(--border); + border-radius: 2px; + } + .space-table td.use { color: var(--olive); font-size: 13px; } + + /* radius row */ + .radii { + display: flex; + gap: 16px; + flex-wrap: wrap; + margin-top: 18px; + } + .radius { + text-align: center; + flex: 0 0 auto; + } + .radius .box { + width: 72px; + height: 72px; + background: var(--ivory); + border: 1px solid var(--border); + margin-bottom: 8px; + } + .radius .r { font-family: var(--mono); font-size: 12px; color: var(--dark-warm); } + .radius .r b { color: var(--brand); font-weight: 500; } + .radius .label { font-size: 12px; color: var(--olive); margin-top: 2px; } + + /* ---------- Components ---------- */ + .comp-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 24px; + align-items: stretch; + } + .comp { + background: var(--ivory); + border: 1px solid var(--border); + border-radius: 12px; + padding: 28px 28px 24px; + transition: box-shadow 0.2s; + display: flex; + flex-direction: column; + min-width: 0; + } + .comp:hover { + box-shadow: 0 4px 24px rgba(0,0,0,0.05); + } + .comp h3 { + font-family: var(--serif); + font-weight: 500; + font-size: 16px; + color: var(--near-black); + margin: 0 0 4px; + } + .comp .hint { + font-size: 12px; + color: var(--stone); + font-family: var(--mono); + margin: 0 0 20px; + } + .comp .demo { margin-bottom: 4px; flex: 1; } + + /* Buttons */ + .btn { + display: inline-block; + font-family: var(--sans); + font-size: 12px; + font-weight: 500; + padding: 8px 14px; + border-radius: 8px; + cursor: pointer; + user-select: none; + letter-spacing: 0.4px; + } + .btn-primary { + background: var(--brand); + color: var(--ivory); + box-shadow: 0 0 0 1px var(--brand); + } + .btn-secondary { + background: var(--warm-sand); + color: var(--dark-warm); + box-shadow: 0 0 0 1px var(--border); + } + .btn-ghost { + background: transparent; + color: var(--brand); + box-shadow: 0 0 0 1px var(--brand); + } + + /* Tags */ + .tag { + display: inline-block; + font-family: var(--sans); + font-size: 12px; + font-weight: 500; + padding: 2px 7px; + border-radius: 2px; + color: var(--brand); + letter-spacing: 0.4px; + } + .tag.calm { background: #EEF2F7; } + .tag.standard { background: #E4ECF5; border-radius: 4px; padding: 2px 8px; } + .tag.brush { + background: linear-gradient(to right, #D6E1EE, #E4ECF5 70%, #EEF2F7); + } + + /* Quote */ + .quote { + border-left: 2px solid var(--brand); + padding: 4px 0 4px 14px; + color: var(--olive); + font-family: var(--serif); + font-size: 15px; + line-height: 1.55; + margin: 0; + } + + /* Metrics */ + .metrics { + display: flex; + gap: 28px; + } + .metric { + display: flex; + flex-direction: column; + gap: 2px; + } + .metric-value { + font-family: var(--serif); + font-size: 24px; + font-weight: 500; + color: var(--brand); + font-variant-numeric: tabular-nums; + line-height: 1; + } + .metric-label { + font-size: 12px; + color: var(--olive); + } + + /* Section title inline sample: size-led hierarchy, no decoration */ + .sample-section-title { + font-family: var(--serif); + font-weight: 500; + font-size: 20px; + color: var(--near-black); + display: block; + } + + /* Code block */ + .code { + background: var(--ivory); + border: 1px solid var(--border); + border-radius: 6px; + padding: 12px 14px; + font-family: var(--mono); + font-size: 12px; + line-height: 1.55; + color: var(--near-black); + margin: 0; + white-space: pre; + overflow-x: auto; + } + .code .k { color: var(--brand); } + .code .c { color: var(--stone); } + + /* List */ + ul.dash { + list-style: none; + padding: 0; + margin: 0; + font-size: 14px; + line-height: 1.55; + color: var(--dark-warm); + } + ul.dash li { position: relative; padding-left: 14px; } + ul.dash li::before { + content: "\2013"; + position: absolute; + left: 0; + color: var(--brand); + } + + /* Shadows demo */ + .shadow-row { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; + margin-top: 8px; + } + .shadow-demo { + background: var(--ivory); + border-radius: 12px; + padding: 24px 20px; + min-height: 110px; + transition: box-shadow 0.2s; + } + .shadow-demo:hover { + box-shadow: 0 4px 24px rgba(0,0,0,0.05); + } + .shadow-demo .t { + font-family: var(--serif); + font-weight: 500; + font-size: 15px; + color: var(--near-black); + margin: 0 0 4px; + } + .shadow-demo .s { + font-family: var(--mono); + font-size: 12px; + color: var(--olive); + line-height: 1.5; + } + .sd-ring { box-shadow: 0 0 0 1px var(--border); } + .sd-whisper { box-shadow: 0 4px 24px rgba(0,0,0,0.05); } + .sd-alt { background: var(--deep-dark); color: #b0aea5; } + .sd-alt .t { color: var(--ivory); font-family: var(--serif); } + .sd-alt .s { color: #b0aea5; } + + /* Decision table */ + .decision { + width: 100%; + border-collapse: collapse; + font-size: 14px; + } + .decision th, .decision td { + padding: 12px 16px 12px 0; + border-bottom: 1px solid var(--border-soft); + text-align: left; + vertical-align: top; + } + .decision th { + font-size: 12px; + letter-spacing: 0.8px; + text-transform: uppercase; + color: var(--stone); + font-weight: 500; + padding-top: 0; + } + .decision td.task { + font-family: var(--serif); + font-weight: 500; + font-size: 14px; + color: var(--near-black); + width: 38%; + } + .decision td.how { color: var(--dark-warm); } + .decision td.how code { + font-family: var(--mono); + font-size: 12px; + background: #EEF2F7; + color: var(--brand); + padding: 1px 5px; + border-radius: 2px; + } + + /* Demo grid */ + .demo-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 20px; + align-items: start; + } + .demo-card a img { + width: 100%; + border-radius: 8px; + border: 1px solid var(--border); + transition: box-shadow 0.2s; + } + .demo-card a img:hover { + box-shadow: 0 4px 24px rgba(0,0,0,0.05); + } + .demo-card .demo-title { + font-family: var(--sans); + font-size: 14px; + font-weight: 500; + color: var(--near-black); + margin: 10px 0 0; + } + .demo-card .demo-desc { + font-size: 12px; + color: var(--olive); + margin: 2px 0 0; + } + + /* Landing page showcase */ + .landing-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 20px; + align-items: start; + } + .landing-card a img { + width: 100%; + border-radius: 8px; + border: 1px solid var(--border); + transition: box-shadow 0.2s; + } + .landing-card a img:hover { + box-shadow: 0 4px 24px rgba(0,0,0,0.05); + } + .landing-card .demo-title { + font-family: var(--sans); + font-size: 14px; + font-weight: 500; + color: var(--near-black); + margin: 10px 0 0; + } + .landing-card .demo-desc { + font-size: 12px; + color: var(--olive); + margin: 2px 0 0; + } + +@media (min-width: 980px) { + html[lang="ja"] .section-lede.section-lede-nowrap { + white-space: nowrap; + font-size: clamp(12.5px, 1.2vw, 14.5px); + line-height: 1.45; + letter-spacing: 0; + } + + html[lang="ja"] .type-sample.display .display-line-nowrap { + white-space: nowrap; + } + + html[lang="ja"] .demo-desc.demo-desc-nowrap { + white-space: nowrap; + font-size: 12px; + letter-spacing: 0; + } +} + + /* Chart showcase */ + .chart-showcase { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 20px; + align-items: start; + } + .chart-card { + background: var(--ivory); + border: 1px solid var(--border); + border-radius: 12px; + padding: 16px 16px 12px; + } + .chart-card svg { + width: 100%; + height: auto; + display: block; + } + .chart-card svg text { + font-family: var(--sans); + } + .chart-label { + font-family: var(--sans); + font-size: 12px; + font-weight: 500; + color: var(--brand); + letter-spacing: 0.3px; + margin-bottom: 10px; + text-transform: uppercase; + } + .chart-caption { + font-size: 12px; + color: var(--olive); + margin: 8px 0 0; + line-height: 1.4; + } + + /* Background text */ + .background-text { + font-family: var(--serif); + font-size: 16px; + line-height: 1.6; + color: var(--dark-warm); + } + .background-text p { + margin-bottom: 14px; + } + + /* FAQ */ + .faq { + display: flex; + flex-direction: column; + gap: 20px; + } + .faq-pair dt { + font-family: var(--serif); + font-size: 16px; + font-weight: 600; + color: var(--near-black); + margin-bottom: 4px; + } + .faq-pair dd { + font-family: var(--serif); + font-size: 15px; + color: var(--dark-warm); + line-height: 1.6; + margin: 0; + } + + /* Brand footer */ + .footer { + margin-top: 24px; + padding-top: 40px; + border-top: 1px solid var(--border-soft); + display: flex; + justify-content: space-between; + align-items: flex-end; + gap: 40px; + } + .footer .mark { + display: flex; + gap: 16px; + align-items: center; + } + .footer .mark img { width: 56px; height: 56px; } + .footer .mark .wm-name { + font-family: var(--serif); + font-weight: 500; + font-size: 28px; + color: var(--near-black); + margin: 0; + line-height: 1; + } + .footer .mark .wm-sub { + font-family: var(--serif); + font-size: 14px; + color: var(--olive); + margin: 4px 0 0; + } + .footer .colophon { + font-size: 12px; + color: var(--stone); + line-height: 1.6; + max-width: 420px; + text-align: right; + font-variant-numeric: tabular-nums; + } + .footer .colophon b { color: var(--dark-warm); font-weight: 500; } + + /* Anti-patterns */ + .anti { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + margin-top: 20px; + } + .anti > div { + padding: 18px 20px; + border-radius: 8px; + font-size: 14px; + line-height: 1.55; + } + .anti .no { + background: var(--ivory); + border: 1px solid var(--border); + } + .anti .yes { + background: var(--ivory); + border: 1px solid var(--border); + border-left: 3px solid var(--brand); + } + .anti h3 { + font-family: var(--sans); + font-size: 12px; + font-weight: 500; + letter-spacing: 1.2px; + text-transform: uppercase; + margin: 0 0 8px; + color: var(--stone); + } + .anti .yes h3 { color: var(--brand); } + .anti p { + margin: 0; + color: var(--dark-warm); + } + .anti code { + font-family: var(--mono); + font-size: 12px; + color: var(--brand); + background: #EEF2F7; + padding: 1px 4px; + border-radius: 2px; + } + +@media (max-width: 768px) { + /* Container + spacing */ + .page { padding: 48px 20px 64px; } + section { margin-bottom: 48px; } + .section-head { margin-bottom: 20px; } + + /* Typography scale */ + .hero h1 { font-size: 46px; letter-spacing: -0.6px; margin-bottom: 10px; } + .hero h1 .cn { margin-left: 8px; } + .hero .tagline { font-size: 17px; } + .hero-tokens { gap: 14px; margin-top: 14px; } + .section-title { font-size: 24px; } + .manifesto { font-size: 17px; line-height: 1.5; } + .subhead { font-size: 16px; margin: 28px 0 12px; } + + /* Grids: single column */ + .demo-grid { grid-template-columns: repeat(2, 1fr); gap: 14px; } + .demo-card { min-width: 0; } + .landing-grid { grid-template-columns: 1fr; gap: 14px; } + .demo-card .demo-desc { overflow-wrap: break-word; word-break: break-word; } + .swatches { grid-template-columns: repeat(2, 1fr) !important; gap: 10px; } + .tint-strip { grid-template-columns: repeat(3, 1fr); gap: 8px; } + .rules { grid-template-columns: 1fr; } + .rules li:nth-child(odd) { padding-right: 0; border-right: none; } + .rules li:nth-child(even) { padding-left: 0; } + .type-grid { grid-template-columns: 1fr; row-gap: 0; } + .type-row { display: block; padding: 16px 0; border-bottom: 1px solid var(--border-soft); } + .type-row > * { border-bottom: none; padding: 4px 0; } + .family-grid { grid-template-columns: 1fr; } + .comp-grid { grid-template-columns: 1fr; } + .shadow-row { grid-template-columns: 1fr; } + .anti { grid-template-columns: 1fr; } + .chart-showcase { grid-template-columns: 1fr; } + + /* Table: hide bar column */ + .space-table td.bar { display: none; } + + /* Type samples */ + .type-sample.display { font-size: 36px; } + html[lang="zh-CN"] .type-sample.display { font-size: 28px; letter-spacing: 0.3px; } + html[lang="ja"] .type-sample.display { font-size: 26px; letter-spacing: 0.2px; } + .family .glyph { font-size: 48px; } + .family .glyph.mono { font-size: 40px; } + + /* Footer */ + .footer { flex-direction: column; align-items: flex-start; gap: 24px; } + .footer .colophon { text-align: left; max-width: 100%; } + .footer .mark .wm-name { font-size: 22px; } + + /* Eyebrow */ + .eyebrow { gap: 8px; } + .eyebrow-date { display: none; } + .hero-tokens { display: none; } + + /* Misc */ + .hero { padding-bottom: 32px; margin-bottom: 36px; } + .comp { padding: 20px 16px 16px; } + .family { padding: 20px 16px 16px; } + .radius .box { width: 56px; height: 56px; } +} diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..a591bc0 --- /dev/null +++ b/vercel.json @@ -0,0 +1,9 @@ +{ + "redirects": [ + { + "source": "/index-en.html", + "destination": "/", + "permanent": true + } + ] +}