
Feishu Whiteboard Pro
- 1 installs
- 54 repo stars
- Updated June 18, 2026
- lcpmarvel/feishu-whiteboard-pro
Builds editable Feishu/Lark whiteboards, infographics, and diagrams with a mandatory design brief before drawing and a design-critique pass after rendering.
About
Creates genuinely designed Feishu/Lark SVG whiteboards using composition archetypes, a fixed type scale, and a post-render critique rubric. A developer or designer uses it to turn content into a visual explainer on a Feishu canvas rather than a grid of boxes. Requires lark-cli installed and authenticated.
- Gated pipeline: design brief (archetype, focal point, palette) before drawing, critique after render
- fit-check script predicts label overflow and clipping before rendering
Feishu Whiteboard Pro by the numbers
- 1 all-time installs (skills.sh)
- Ranked #1,609 of 1,880 Design & UI/UX skills by installs in the Skillselion catalog
- Data as of Jul 30, 2026 (Skillselion catalog sync)
npx skills add https://github.com/lcpmarvel/feishu-whiteboard-pro --skill feishu-whiteboard-proAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 54 |
| Last updated | June 18, 2026 |
| Repository | lcpmarvel/feishu-whiteboard-pro ↗ |
What it does
Builds editable Feishu/Lark whiteboards, infographics, and diagrams with a mandatory design brief before drawing and a design-critique pass after rendering.
Files
飞书白板大师
一个面向飞书 SVG 白板的设计判断技能。它不是给你一套配色就让你随手摆——它强制 画之前先写设计简报、渲染后做设计评审。产出是写进你飞书文档里的、真实可编辑的白板。
白板媒介是刻意受限的:单一字体、只有原生矩形/圆/连接线、无渐变、无滤镜、无透明度、无动效。 所以这里的「好看」= 构图、层级、节奏、配色克制、留白,绝不靠特效。功力花在这些上。
三件事决定成败,各有归属:
- 媒介允许什么 →
RULES.md。硬限制,真机验证过。必读。 - 怎么构图 →
COMPOSITION.md。带坐标骨架的原型、字号体系、间距栅格。技能核心。 - 到底好不好 →
CRITIQUE.md。渲染后的设计评分表与逐项修法。
何时使用
- 用户想要一张飞书 / Lark 白板、信息图、流程图、海报、系统图或可视化讲解,且要它**看起来
真的被设计过**——清晰焦点、真实层级,不是一墙等大方块。
- 用户给了内容("把 X 讲成白板""把这段变成图"),想要它可视化、可编辑、在飞书画布上。
第 0 步:前置条件(动手前先查)
- 飞书授权(Agent 必读,照做别猜):技能以用户本人身份写入其飞书租户,走扣子平台授权。用户在扣子一键授权后,
运行时自动注入 COZE_FEISHU_WHITEBOARD_<后缀>(后缀因环境而异,脚本按前缀自动识别,不用关心后缀是 project 还是 skill id)。 技能代码不碰 OAuth、不需要建应用或填密钥(详见 `CREDENTIALS.md`)。
- 注入只在「技能运行时」发生:主对话 / 裸终端没有这个变量,必须在扣子「调试/预览」里跑本技能。
- 写飞书前先自检:
python3 scripts/feishu_write.py --check。READY才写;NOT_READY就把它打印的指引原样转达用户去扣子完成授权。 - 拿不到凭证时绝不改用 lark-cli、绝不自造授权链接——本技能只依赖扣子注入。写入不可用不等于任务失败:照样交付本地 PNG + SVG。
- 沙箱已具备 Python(写入层,标准库即可)、Node(本地渲染)、
npx @larksuite/whiteboard-cli(npx 自动下载)。
管线(两道闸门,都不能跳)
理解内容
│
├─▶ 闸门 1 · 设计简报 (写任何 SVG 之前 —— 见下)
▼
按骨架构图 (COMPOSITION.md:原型坐标 + 字号体系 + 间距栅格)
│
▼
渲染 → 修正确性 (RULES.md:溢出 / 重叠 / 裁切 / 箭头)
│
▼
闸门 2 · 设计评审 (CRITIQUE.md:评分、补最弱项、重渲、循环)
│
▼
写进飞书 → 看实时白板 → 交付1. 理解内容
弄清白板要承载什么:内容、目的、受众。内容不清就问一个短问题。不要问用户视觉风格—— 那是你在简报里自己定,最后再提供换色。
闸门 1 · 设计简报(强制,写 SVG 之前)
先读 `COMPOSITION.md` 和 `CATALOG.md`,再写下这五条承诺 (叙事形状→原型、焦点、配色策略+palette、字号角色、反套路检查)。详见 COMPOSITION.md。 配色优先选 CATALOG.md 里的锚点 palette(可靠、可换肤);若没有合适的,按 `templates/GENERATE.md` 现场生成一套——它产出同样的 frontmatter 形态, 照样可换肤,本轮内联使用即可(不必写进技能目录)。 用一句话告诉用户你选了哪个原型和 palette、为什么,然后开画。
2. 按骨架构图
匹配的原型若在 `examples/` 里有,就从那个 .svg 起步、替换内容。在逻辑坐标 空间(≈1600–1700 宽)写 SVG,遵循 COMPOSITION.md 的骨架/字号/栅格,以及 RULES.md 全部硬规则。 只把内容放上画布——绝不放提示词、来源、风格名或任何 meta 行。
3. 渲染 → 修正确性
先不渲染预测缺陷:node scripts/fit-check.mjs <dir>/diagram.svg(标签过宽/挤占/出血)。 然后按 RULES.md 渲染并真的打开 PNG 看: npx -y @larksuite/whiteboard-cli@^0.2.11 -i <dir>/diagram.svg -o <dir>/diagram.png -f svg 就地小步改 SVG,一次看到的问题合并一轮改完再重渲。这步只管正确,不管品味。
闸门 2 · 设计评审(强制,正确性干净后)
按 `CRITIQUE.md` 五轴(层级/平衡/密度/对比/对齐)打分,点名最弱一项,套用 其修法重渲,循环到没有不及格轴。要真正交付的白板,最好让一个独立评审子 agent 只拿渲染图 和评分表、以对抗姿态评。
4. 写进飞书 → 看实时白板 → 交付
授权由扣子平台授权托管,token 在运行时自动注入,技能这步不做任何 OAuth。先自检再写:
python3 scripts/feishu_write.py --check # READY 才继续;NOT_READY 见下
python3 scripts/feishu_write.py --svg <dir>/diagram.svg --title "标题" --image <dir>/board.png--check 报 NOT_READY(或写入报缺凭证)时:把脚本打印的指引原样转达用户(去扣子完成飞书授权 / 确认在「调试·预览」运行时里跑), 不要自己尝试任何授权、不要改用 lark-cli、不要自造授权链接。此时仍交付第 3 步本地渲染的 `diagram.png` + SVG 源文件给用户, 待用户授权后重跑写入——写入不可用 ≠ 任务失败。
它以用户本人身份建文档(内嵌 <whiteboard type="svg">,服务端解析成可编辑节点)→ 返回文档链接 + 白板 token → 导出白板图 board.png。board.png 对版面/填充忠实、但文字颜色不可靠(颜色以实时文档为准), 而且是飞书把任意画板补白成 ~2560×2560 的固定方图预览——仅用于核对实时白板,不作交付图。
交付两样:飞书文档链接 + 第 3 步本地渲染的 diagram.png(贴合构图、无补白),不要交付方图 board.png。 然后告诉用户可随时换 palette,同构图重渲,只改颜色。
文件
- [`RULES.md`](RULES.md) — 媒介硬规则。必读。
- [`COMPOSITION.md`](COMPOSITION.md) — 原型库、字号体系、间距栅格、反套路。核心。
- [`CRITIQUE.md`](CRITIQUE.md) — 渲染后设计评分表 + 逐轴修法 + 独立评审。
- [`CATALOG.md`](CATALOG.md) — 精选 palette 锚点(vibe/formality 一览),按此表选色。由
templates/生成,勿手改。 - [`templates/GENERATE.md`](templates/GENERATE.md) — 没有合适锚点时,如何现场生成一套同形态 palette。
- [`examples/`](examples/) — 各原型的金标准白板,从匹配的那张起步。
- [`templates/<slug>/design.md`](templates/) — 每个 palette 一份(frontmatter:mood + 颜色 + 描边 +
catalog:块),只开你选中的那个。 - [`scripts/fit-check.mjs`](scripts/fit-check.mjs) — 渲染前文字适配/出血预测。
- [`scripts/feishu_write.py`](scripts/feishu_write.py) — 以用户身份直连飞书 OpenAPI 建文档 + 导出图(token 由扣子平台授权注入)。
- [`CREDENTIALS.md`](CREDENTIALS.md) — 飞书授权说明(扣子平台授权,一键即用)。
来源与许可(源码层署名,非商店营销文案)
MIT,详见随包 `LICENSE`。配色与媒介规则改编自 beautiful-feishu-whiteboard(© Zara Zhang,MIT);构图/评审/fit-check/管线层为原创新增。
注:本段是源码层署名(满足 MIT + 扣子来源标注要求)。商店「详细介绍」营销文案不含此致谢。
.DS_Store
*.log
# live-board exports / scratch renders (gallery PNGs under examples/ are committed)
whiteboard_*.png
diagram.png
node_modules/
# assembled Coze skill package (generated by coze/build.sh)
coze/dist/
# Python bytecode (coze/scripts)
__pycache__/
*.pyc
Style Catalogue
<!-- GENERATED by scripts/build-catalog.mjs from templates/*/design.md — do not edit by hand. -->
Use this to pick a style for the user, by the level (how restrained or bold it feels), the formality, and the vibe they want. Any style can carry almost any content, so choose by feel. If the user named a style or a clear preference, honour it. Otherwise infer from the tone they want. After generating, always tell the user they can switch to any other style.
Each row lists its palette signature (canvas + the defining accents, as hex) so you can choose from this table alone. Once you pick a style, open only that one templates/<slug>/design.md for the full colour notes — do not open several templates to compare.
The 6 styles are grouped into three levels, from restrained to bold:
- Restrained — calm, muted, often monochrome; for serious, quiet, editorial boards.
- Balanced — a confident accent or two on a clean ground; the versatile middle. Most work lives here.
- Bold — loud, saturated, lots of colour; for playful, high-energy, attention-grabbing boards.
Formality key: Low = playful / casual, Medium = confident / modern, High = restrained / refined / institutional.
Restrained
| Style | Formality | Vibe | Palette (canvas · accents) |
|---|---|---|---|
| Grove | High | editorial, grounded, parchment and forest green | #E8E4D6 parchment · #192B1B green · #C8524A terracotta |
| Monochrome | High | quiet, minimal, text-first, no accent | #FAFADF cream · #1A1A16 ink · #5E5E54 graphite |
Balanced
| Style | Formality | Vibe | Palette (canvas · accents) |
|---|---|---|---|
| Coral | Medium | warm, friendly, clean, signature coral | #F5F0E8 cream · #E85D5D coral · #1A1A1A ink |
| Riptide Cobalt | Medium | bold poster, low density, high impact | #FDF0E0 cream · #375DFE cobalt · #1A2240 ink |
Bold
| Style | Formality | Vibe | Palette (canvas · accents) |
|---|---|---|---|
| Mint Brut | Low | candy, friendly, app-like | #D0FDE4 mint · #F888C8 pink · #000000 ink |
| Riso Brut | Medium | neo-brutalist editorial, confident, designed | #EFE9D9 cream · #1F8A4C green · #F06CA8 pink · #E85A1F orange |
How to choose
1. Level first. How loud should the board feel? Quiet / serious leans Restrained; playful or high-energy leans Bold; most general work sits comfortably in Balanced. 2. Formality second. Within the level, match how formal it should read (Low / Medium / High). 3. Vibe third. Narrow by the user's mood or colour preference (the Vibe + Palette columns). 4. When unsure, default to a safe or balanced style. 5. Commit from this table, then open exactly one `design.md` — the one for the style you picked. The palette signature here is enough to choose; don't open several templates to compare. 6. Always offer to switch afterwards. Swapping style means re-rendering the same content in a different template's palette.
Composition — Feishu Whiteboard
Colour and the medium rules are handled elsewhere (CATALOG.md, RULES.md). This file is about where things go and how big they are: the part the old skill left to improvisation. Three systems and an archetype library. Use them as a scaffold to fill, not a free canvas to fill from scratch.
All coordinates assume a logical space ≈1600 wide (let height follow the content). They are a starting skeleton — nudge them, but keep the proportions and the spacing rhythm.
---
1. The grid (spacing system)
Consistent spacing is what separates "designed" from "boxes someone dropped on a canvas". One spacing scale, used everywhere. Base module = 8; every gap is a multiple of it.
| Token | Value | Use |
|---|---|---|
| Outer margin | 80 | canvas edge → first content. Never let content touch the edge. |
| Section gap | 56–72 | between major regions (title block → body, row → row) |
| Gutter | 40 | between sibling panels/columns in the same row |
| Panel padding | 32 | panel border → its inner content (text, sub-shapes) |
| Inline gap | 16–24 | between stacked lines/items inside a panel |
| Tight gap | 8 | label → its value, icon → its text |
Working frame: with margin 80, the content band is x ∈ [80, 1520], width 1440. Carve columns out of 1440 minus gutters (e.g. 3 columns = (1440 − 2·40)/3 ≈ 453 each).
Rhythm, not uniformity. Equal padding everywhere reads as monotony. Give the focal region more air than the rest; let a secondary cluster sit tighter. Vary deliberately, never randomly.
---
2. The type scale (single font: Noto Sans SC)
There is no typeface choice — hierarchy is size + weight + casing only. Use these five roles; the contrast between adjacent steps is ≥1.4×, never a flat scale. Every label is a <text>; never set font-family. Keep nothing below 16 (RULES.md).
| Role | Size | Weight | Use |
|---|---|---|---|
| Display | 64–80 | 900 (black) | the board title, or one hero number/word. At most one per board. |
| Heading | 32–40 | 700 (bold) | region / panel titles |
| Subhead | 22–26 | 700 | sub-titles, the lead line inside a panel |
| Body | 18–20 | 400–500 | the actual content text |
| Caption | 16 | 500 | small labels, axis ticks, badge text — only inside high-contrast panels |
Hierarchy is set in the brief, executed here. Three weights max in play at once. If everything is bold, nothing is. Big + black is reserved for the focal point you named — don't spend it twice.
---
3. Archetype library
Pick by the relationship in the content, not by habit. Each archetype gives a skeleton, a focal strategy, and where the title goes. Place panels on the skeleton; do not free-float.
Focal points win on size, not colour. Each archetype below names a focal element. Making it the
saturated one is necessary but not sufficient: a coloured box the same size as its neighbours
loses the eye to a near-black box (highest contrast on cream), to anything higher on the canvas, or
to the title. So make the focal physically the largest — large enough to break its siblings'
shared baseline — and actively demote competitors (shrink equal siblings, trim a heavy dark box,
thin a bright strip). One winner, by size and colour together.
Title block default (all archetypes unless noted): top-left, x=80 y=80, Display title, optionalone-line Subhead beneath it. Left-aligned reads as more designed than centred for explanatory boards.
A. Linear Flow / Pipeline — sequence, process, "X then Y then Z"
Stages left→right, connected by native arrow connectors (marker-end, RULES.md). 3–6 stages.
title x80 y80
stages: 4 panels, y=320, h=300, w≈300, gutter=40 (x = 80, 420, 760, 1100)
arrows: between panels, horizontal, marker-endFocal: the outcome stage (last, or the one that matters) — make it larger or the only saturated fill; others tinted. Don't make all stages identical — that's the cliché. Optional: a thin baseline rule under the row to ground it.
B. Swimlanes — parallel tracks across a shared axis (teams, phases, layers over time)
2–4 horizontal lanes; a shared left label column; items flow rightward within each lane.
title x80 y80
lane labels: x=80, w=220 (Heading, right-aligned to the lane)
lane bodies: x=320 → 1520, each lane h≈220, vertical gap=40Focal: the lane (or the one item) carrying the message — saturate it, tint the rest. A faint full-width divider between lanes keeps them legible without boxing every cell.
C. Hub & Spoke (radial) — one central concept with satellites; "X has these N aspects"
Central node, 4–6 satellites around it, straight connectors from hub to each.
hub: circle/rounded-rect centred ≈ (800, 540), the largest element
satellites: ≈480px radius around the hub, evenly spaced, smaller, uniform size
connectors: straight lines hub→satellite (no arrowheads unless directional)Focal: the hub by definition — biggest, most saturated, Display or Heading type. Satellites are peers: keep them the same size and weight as each other so the hub clearly dominates. Title can sit top-left or be absorbed into the hub if the hub is the title.
D. Comparison Columns — vs, before/after, option A vs B (vs C)
2–3 equal columns; aligned rows of attributes so the eye scans across.
title x80 y80
columns: 2 → w=700 each (x=80, 820); 3 → w≈453 (x=80, 573, 1067); gutter=40
column header: Heading at top of each column, in a coloured cap
shared rows: attribute labels align across columns at the same yFocal: the recommended / winning column — give it the saturated fill or a hard offset shadow (RULES.md), leave the others on tinted ground. Symmetric columns with one asymmetric emphasis beats three identical ones.
E. Layered Stack — architecture tiers, hierarchy of levels, "built on top of"
Full-width horizontal bands stacked vertically; top = highest abstraction (or vice-versa, state which).
title x80 y80
bands: full content width (x=80→1520), each h≈160, vertical gap=24
label on the left inside each band; contents as chips to the rightFocal: the layer the board is about — saturate that band, tint the others; or widen it slightly. Bands of varying height (the important one taller) reads better than identical stripes.
F. Timeline — milestones along a date/phase axis
One strong horizontal axis; events as nodes above/below, alternating to use vertical space.
title x80 y80
axis: horizontal line y≈540, x=120→1480, with tick marks
nodes: alternate above (y≈380) and below (y≈700) the axis; connector stub to each tickFocal: the current / pivotal milestone — larger node, saturated; past muted, future tinted. Don't cram every node to the same size; let the key date own more space.
G. 2×2 Matrix / Quadrant — two dimensions, four positions (effort/impact, etc.)
Two labelled axes, four cells; items placed by position, not listed.
title x80 y80
plot: square ≈ 900×900 centred horizontally (x=350→1250, y=240→1140)
axis labels: outside the plot, Heading; quadrant labels: Caption inside each cell cornerFocal: the "winning" quadrant (e.g. high-impact/low-effort) — tint its cell, place the hero items there largest. The axes themselves should be quiet (thin rules), the items loud.
H. Focus + Detail — one big idea with supporting points; the anti-grid default
Asymmetric split: a large focal panel + a column/row of smaller supporting panels. Use this whenever the content has one main thing, instead of reflexively making equal cards.
title x80 y80
focus panel: x=80, w≈880, h≈560 — the hero (Display number / key statement / central diagram)
support: right column, x=1000, w≈520, 3 stacked panels, h≈170, gap=40Focal: the big panel, obviously — it should be 1.5–2× any support panel. This asymmetry is the hierarchy; resist the urge to even it out.
Combine when needed. Real boards often nest archetypes (a pipeline whose last stage opens into a 2×2; a focus panel above three comparison columns). Compose; don't force one mould.
---
4. Anti-cliché check (run it in the brief)
If a board is forgettable it usually fell into one of these. Name your reflex, then dodge it.
- The equal-card grid. N identical rectangles, each icon + title + text. The single most common
AI-whiteboard tell. Almost any content has a focal point — use Focus + Detail (H) or saturate one card. Equal weight = no hierarchy = bland.
- Everything centred. Centre-aligned title, centred columns, centred text. Reads timid and
template-y. Default to left-aligned titles and text; centre only a true hub or a poster headline.
- The category-reflex palette. Tech→blue pipeline, finance→navy+gold, eco→green. If someone could
guess your palette from the topic alone, pick a different CATALOG style on purpose.
- Uniform everything. Same panel size, same padding, same weight throughout. Monotony. Vary the
focal region's size and air (§1 rhythm, §2 scale).
- Box-in-a-box. Panels inside panels inside panels. Each nesting level should earn itself; two is
usually one too many. A tint or a divider often replaces a border.
- Decorative chrome. Kickers, footers, slugs, "v1.0", date stamps, the style name on the canvas.
Cut all of it (RULES.md). Every text element must be load-bearing.
The litmus: could someone glance at this and say "an AI made that" with no doubt? If yes, the weakest axis is usually hierarchy — go to CRITIQUE.md.
#!/usr/bin/env bash
# Assemble a self-contained Coze skill package, and optionally deploy it into a project root.
#
# bash build.sh # assemble into coze/dist/ (repo-local staging)
# bash build.sh /workspace/projects # also lay the package contents AT that dir (Coze project
# root), so skill files sit at the root — NOT nested.
#
# The shared design core lives once at the repo root; this copies it next to the Coze-specific
# SKILL.md / scripts so the deployed skill is self-contained. A `.coze` manifest (skill_package=skill name)
# is written into the package so the project root is a valid, self-describing Coze skill.
set -euo pipefail
here="$(cd "$(dirname "$0")" && pwd)"
root="$(cd "$here/.." && pwd)"
dist="$here/dist"
# Staging dir is a dedicated folder — safe to wipe. (We never wipe the deploy TARGET below.)
rm -rf "$dist"
mkdir -p "$dist/scripts"
# Coze-specific (override the Claude Code SKILL.md with the Coze one)
cp "$here/SKILL.md" "$dist/SKILL.md"
cp "$here/CREDENTIALS.md" "$dist/CREDENTIALS.md"
cp "$here/scripts/feishu_write.py" "$dist/scripts/feishu_write.py"
# License must travel with the distributed copy (MIT requirement). The store
# 详细介绍 marketing text stays clean of attribution; the credit lives here + in SKILL.md.
cp "$root/LICENSE" "$dist/LICENSE"
# Shared core — single-sourced at repo root
for f in RULES.md COMPOSITION.md CRITIQUE.md CATALOG.md; do
cp "$root/$f" "$dist/$f"
done
cp "$root/scripts/fit-check.mjs" "$dist/scripts/fit-check.mjs"
cp -R "$root/templates" "$dist/templates"
# Examples: ship ONLY the editable .svg sources — they are the runtime starting points
# (SKILL.md step 2: "从那个 .svg 起步"). The rendered .png gallery is reference-only
# (README / store listing), is never read by the skill flow, and is ~90% of the package
# weight, so it stays OUT of the deployed package.
#
# LEAN=1 strips examples/ entirely — a maximally-lean isolation build used to test whether
# Coze's BuildGitCode timeout has anything to do with the payload at all.
if [ -n "${LEAN:-}" ]; then
echo " (LEAN build: skipping examples/)"
else
mkdir -p "$dist/examples"
cp "$root"/examples/*.svg "$dist/examples"/
fi
chmod +x "$dist/scripts/feishu_write.py" 2>/dev/null || true
# Zero-.git guarantee: the package is PURE FILES, never a git working tree. A nested .git
# (e.g. accidentally copied along with a source dir) is the prime suspect for Coze's
# BuildGitCode stall — strip any that snuck in.
find "$dist" -name .git -prune -exec rm -rf {} + 2>/dev/null || true
echo "✅ Assembled Coze package at: $dist (pure files, no .git)"
# Coze packer convention (confirmed against a Coze-generated reference skill on disk):
# - SOURCE folder = the skill `name`, WITHOUT any suffix (e.g. `feishu-whiteboard/`),
# containing SKILL.md + scripts/ + ...
# - `.coze` skill_package = "<name>.skill" — the packer strips `.skill` to find the source
# folder. Without the `.skill` suffix [build][skill] fails "skill not found in source code".
# (`<name>.skill` itself is the BUILT package the pipeline emits — we do NOT create it.)
# - name MUST be ASCII — a non-ASCII (Chinese) folder name gets mangled in the tar.
# So: folder = ASCII slug (no suffix); skill_package = slug + ".skill"; Chinese display name
# lives in SKILL.md frontmatter / store listing, NOT in this path.
SKILL_DIR="feishu-whiteboard-pro"
TARGET="${1:-}"
if [ -n "$TARGET" ]; then
if [ "$(cd "$TARGET" 2>/dev/null && pwd || true)" = "$dist" ]; then
echo " (target is the staging dir; nothing to copy)"
else
pkg="$TARGET/$SKILL_DIR"
rm -rf "$pkg" # safe: only the skill subfolder, never the whole TARGET
mkdir -p "$pkg"
cp -R "$dist/." "$pkg"/
# Pure files only — never let a .git ride into the deployed skill folder.
find "$pkg" -name .git -prune -exec rm -rf {} + 2>/dev/null || true
# Format mirrors the Coze-generated reference .coze exactly: 2-space indent, spaces around
# `=`, skill_package first. skill_package = "<SKILL_DIR>.skill" (ASCII); name = SKILL_DIR.
cat > "$TARGET/.coze" <<EOF
[skill]
skill_package = "$SKILL_DIR.skill"
name = "$SKILL_DIR"
description = "一句话把内容生成为有设计感、可编辑的飞书白板:先定设计简报(构图原型+配色+字号角色),按坐标骨架施工,渲染后过五轴设计评审,最后以你本人身份写进你自己的飞书云文档,成为可编辑白板。"
project_name = "$SKILL_DIR"
project_description = "面向飞书 SVG 白板的设计判断技能:构图原型库 + 精选配色锚点(可现场生成换肤)+ 渲染前文字预检 + 渲染后五轴评审(层级/平衡/密度/对比/对齐),产出写进你飞书、真实可编辑的白板,而非方框网格截图。"
EOF
# 平台授权下没有本地凭证文件:token 由扣子运行时注入(FEISHU_USER_ACCESS_TOKEN),
# 不落盘、不进 git,所以无需任何 .gitignore 自愈逻辑。
echo "✅ Deployed: skill files at $pkg/ ; manifest at $TARGET/.coze"
fi
fi
飞书授权说明(扣子平台授权,一键即用)
这个技能把白板写进你自己的飞书租户。鉴权走扣子的平台授权——扣子官方已集成飞书 OAuth, 你只需在扣子里点一下授权:
1. 安装/使用技能时,扣子引导你授权飞书(跳转飞书同意页,确认即可); 2. 运行时扣子按命名约定 COZE_{CREDENTIAL_NAME 大写}_{COZE_PROJECT_ID} 把你的 user_access_token 注入环境变量(本技能即 COZE_FEISHU_WHITEBOARD_$COZE_PROJECT_ID); 3. 技能即以你本人身份直连飞书 OpenAPI,把白板写进你自己的云文档。
你不需要建飞书应用、不需要填任何密钥、也不需要跑设备码。技能代码不碰 OAuth,token 不落盘、不进 git。
skill_credentials 声明(开发者在 Skill Builder 里调用)
skill_credentials(
credential_name="feishu_whiteboard", # 注入变量名 = COZE_FEISHU_WHITEBOARD_{COZE_PROJECT_ID}
auth_type=3, # 3 = OAuth
# 平台授权:不传 custom_oauth_url,扣子用其内置飞书集成(跨租户可用)
allowed_domain="open.feishu.cn", # 两发接口同域
credential_purpose="飞书白板:建文档 + 导出预览图",
)注入的环境变量名按 COZE_{credential_name 大写}_{COZE_PROJECT_ID} 拼成(脚本里 CREDENTIAL_NAME 常量 须与此 credential_name 保持一致)。赋值方式选「消费者授权」——每个用户用自己的飞书身份授权, 白板写进各自的云空间,互相隔离。
需要的 scope(同意页应包含)
| 用途 | scope | 缺失症状 |
|---|---|---|
| 建云文档 | docx:document:create | 完全无法写入(建文档就失败) |
| 内嵌 SVG 解析成白板节点 | board:whiteboard:node:create | 文档建出但白板为空 |
| 导出白板预览图 | board:whiteboard:node:read | 白板可用、但导不出预览图(可只交付文档链接) |
已对真实飞书 API 验证:这三个 scope 足够建文档 + 导白板,不需要 drive 等其它权限。
排错:python3 scripts/feishu_write.py --check 先确认凭证是否注入;权限类错误码(如 99991672 / 99991679)对照上表补 scope。用到的 OpenAPI(写入层 scripts/feishu_write.py)
POST /open-apis/docs_ai/v1/documents— body{content:"<title>…</title><whiteboard type=\"svg\">…</whiteboard>", format:"xml"},
服务端把内嵌 SVG 解析成可编辑白板节点,返回文档链接 + 白板 block token。
GET /open-apis/board/v1/whiteboards/<token>/download_as_image— 导出 ~2560×2560 预览图(仅供核对实时白板)。
均以 Authorization: Bearer <注入的 user_access_token> 调用。第三方请求必须用 `from coze_workload_identity import requests`——只有经这个代理发出、且域名在 allowed_domain 内, 扣子才把占位符 token 换成真值并校验域名。用 urllib/原生 requests 会拿着占位符直接出网、鉴权失败。
skill_credentials(...) 不写进 skill 文件,而是在扣子项目侧注册(生成/配置凭证时由平台登记)。skill 运行时只负责按上面的约定读注入的环境变量。
注意事项
- 授权对你本人有效,写入的文档归你所有,落在你自己的云空间。
- 国际版 Lark 对应域名为
open.larksuite.com(如需支持,另在allowed_domain与脚本BASE增配)。 - 诊断:
scripts/probe_board_api.py可单独验证这两发接口与当前 token 的 scope(开发自测用,不随包分发)。
#!/usr/bin/env python3
"""
feishu_write.py — 把一张 SVG 写进用户飞书、成为可编辑白板,并导出预览图。
授权模型:扣子「平台授权」。用户在扣子里一键授权飞书后,扣子把用户的 user_access_token
注入环境变量 COZE_{CREDENTIAL_NAME 大写}_{后缀}(如 COZE_FEISHU_WHITEBOARD_<id>,后缀因环境而异,
故下方按前缀扫)。**该变量只在技能被扣子运行时调用时注入,裸终端 shell 没有。** 注入值是占位符——
只有当出网请求经 `coze_workload_identity` 的 requests 发出、且域名在凭证 allowed_domain 内时,
扣子服务端代理才把它换成真 token。**因此第三方 API 必须用 coze_workload_identity.requests,不能用 urllib/原生 requests。**
两发 OpenAPI(已对真实飞书验证):
POST /open-apis/docs_ai/v1/documents 建带内嵌 <whiteboard type="svg"> 的文档,
服务端解析 SVG 成可编辑白板节点
GET /open-apis/board/v1/whiteboards/<t>/download_as_image 导出白板预览图
凭证 feishu_whiteboard 需含 scope(缺失症状见 CREDENTIALS.md):
docx:document:create(建文档)· board:whiteboard:node:create(解析 SVG 成节点)· board:whiteboard:node:read(导出图)
用法:
python3 feishu_write.py --check # 写前自检凭证是否注入
python3 feishu_write.py --svg <path> [--title <str>] [--image <out.png>]
"""
import argparse
import json
import os
import sys
BASE = "https://open.feishu.cn/open-apis"
CREDENTIAL_NAME = "feishu_whiteboard" # 须与扣子项目里声明的 credential_name 一致
PREFIX = f"COZE_{CREDENTIAL_NAME.upper()}_"
# 缺凭证时给 Agent 的结构化指引:照着转达用户,别自造方案。
AUTH_HINT = (
f"未找到注入凭证(前缀 {PREFIX}*)。多半不是代码问题,而是上下文/授权问题:\n"
f" · 凭证只在「技能运行时」注入——主对话 / 裸终端不会有,请在扣子「调试/预览」里运行本技能。\n"
f" · 或凭证 {CREDENTIAL_NAME!r} 尚未在本项目注册/授权——在扣子为本项目注册 OAuth 凭证\n"
f" {CREDENTIAL_NAME}(平台授权、域名 open.feishu.cn)并完成授权。\n"
f" · 不要改用 lark-cli,也不要自造授权链接——本技能只依赖扣子注入。\n"
f" · 写入不可用 ≠ 任务失败:仍可把本地渲染的 PNG / SVG 交付用户,待授权后再写入。"
)
def find_credential():
"""返回 (变量名, token);没有则 (None, None)。变量名形如 COZE_FEISHU_WHITEBOARD_<后缀>,
后缀 project_id/skill_id 因环境而异,故按前缀扫,不写死后缀。"""
hits = {k: v.strip() for k, v in os.environ.items() if k.startswith(PREFIX) and v.strip()}
if len(hits) > 1:
sys.exit(f"匹配到多个凭证变量,无法判定用哪个:{list(hits)}")
return next(iter(hits.items())) if hits else (None, None)
def token():
_, val = find_credential()
if not val:
sys.exit(AUTH_HINT)
return val
def call(method, path, body=None):
# 第三方 API 调用必须从此包导入:凭证代理在这一层把占位符 token 换成真值并校验域名。
# 惰性导入 → --check 等不发请求的路径即使没有该模块也能跑。
from coze_workload_identity import requests
headers = {"Authorization": f"Bearer {token()}", "Content-Type": "application/json"}
url = BASE + path
if method == "GET":
r = requests.get(url, headers=headers, timeout=30)
elif method == "POST":
r = requests.post(url, headers=headers, json=body, timeout=30)
else:
sys.exit(f"unexpected method {method}")
if r.status_code >= 400:
sys.exit(f"飞书 API {method} {path} HTTP {r.status_code}:{r.text[:800]}\n"
f" · 若是权限/scope 错误码(如 99991672 / 99991679),对照 CREDENTIALS.md 的 scope 表补权限;"
f"建文档成功但导出图失败,通常是缺 board:whiteboard:node:read,可只交付文档链接,不影响白板本身。")
return r
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--svg")
ap.add_argument("--title", default="白板")
ap.add_argument("--image")
ap.add_argument("--check", action="store_true",
help="自检:只报凭证注入状态,不写入。写飞书前先跑这个。")
a = ap.parse_args()
# 自检:写飞书前先确认凭证已注入,避免跑完整流程才炸。
if a.check:
name, val = find_credential()
if val:
print(f"READY: 凭证已注入({name}),可写入飞书。")
return
print("NOT_READY:")
print(AUTH_HINT)
sys.exit(3)
if not a.svg:
sys.exit("--svg <path> 必填(仅自检请用 --check)")
with open(a.svg, encoding="utf-8") as f:
svg = f.read()
content = f'<title>{a.title}</title><whiteboard type="svg">{svg}</whiteboard>'
# ① 建文档(内嵌白板,用户本人身份)
j = call("POST", "/docs_ai/v1/documents", {"content": content, "format": "xml"}).json()
if j.get("code") not in (0, None):
sys.exit(f"建文档失败 code={j.get('code')} msg={j.get('msg')!r}")
doc = (j.get("data") or {}).get("document") or {}
doc_url = doc.get("url")
wb_token = next((b.get("block_token") for b in (doc.get("new_blocks") or [])
if b.get("block_type") == "whiteboard"), None)
if not (doc_url and wb_token):
sys.exit(f"建文档返回缺 url/whiteboard token:{json.dumps(j, ensure_ascii=False)[:800]}")
print(f"DOC_URL={doc_url}")
print(f"WHITEBOARD_TOKEN={wb_token}")
# ② 导出预览图(飞书返回 ~2560×2560 固定方图,仅供核对实时白板,不作交付图)
if a.image:
img = call("GET", f"/board/v1/whiteboards/{wb_token}/download_as_image").content
if img[:3] not in (b"\x89PN", b"\xff\xd8\xff"):
sys.exit(f"导出非图片:{img[:300]!r}")
with open(a.image, "wb") as f:
f.write(img)
print(f"IMAGE={a.image}")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
probe_board_api.py — 验证「换授权后」白板写入层那两发 OpenAPI,并打出当前 token 的精确 scope。
它做三件事,跑完给一份结论:
0. 打印 lark-cli 当前已授权身份的 scope(A-prime 既能建文档又能导白板,
所以这串 scope 就是「平台授权同意页」要逐条比对的清单)。
1. POST /open-apis/docs_ai/v1/documents —— 建带内嵌 SVG 白板的文档
2. GET /open-apis/board/v1/whiteboards/<t>/download_as_image —— 导出预览图
两种取数模式,自动选:
- 设了环境变量 FEISHU_TOKEN(裸 user_access_token,模拟扣子注入)→ 走 urllib + Bearer,
这是重写后的真实代码路径。
- 没设 → 借已授权的 lark-cli 身份透传(`lark-cli api` / `whiteboard +query`),零配置即可跑。
用法:
python3 probe_board_api.py # 借 lark-cli 已有授权
FEISHU_TOKEN=u-xxxx python3 probe_board_api.py # 用裸 token 走真实重写路径
"""
import json
import os
import subprocess
import sys
import urllib.request
import urllib.error
BASE = "https://open.feishu.cn"
TOKEN = os.environ.get("FEISHU_TOKEN", "").strip()
# 一张最小的合法白板 SVG,足够触发服务端解析成白板节点。
SVG = ('<svg xmlns="http://www.w3.org/2000/svg" width="320" height="200">'
'<rect x="20" y="20" width="120" height="80" rx="8" fill="#e8eef7" stroke="#3b5b8c"/>'
'<text x="80" y="65" font-size="16" text-anchor="middle" fill="#1f2d3d">probe</text>'
'</svg>')
CONTENT = f'<title>board-api-probe</title><whiteboard type="svg">{SVG}</whiteboard>'
def hr(title):
print("\n" + "=" * 8 + f" {title} " + "=" * 8)
def lark(*args, binary=False):
"""跑 lark-cli,返回 (returncode, stdout, stderr)。优先用全局 lark-cli,否则 npx。"""
base = ["lark-cli"] if _has_lark_cli() else ["npx", "-y", "@larksuite/cli@latest"]
p = subprocess.run(base + list(args), capture_output=True)
out = p.stdout if binary else p.stdout.decode("utf-8", "replace")
return p.returncode, out, p.stderr.decode("utf-8", "replace")
_LARK_CACHE = None
def _has_lark_cli():
global _LARK_CACHE
if _LARK_CACHE is None:
_LARK_CACHE = subprocess.run(["which", "lark-cli"], capture_output=True).returncode == 0
return _LARK_CACHE
def http(method, path, body=None, binary=False):
"""裸 Bearer 调用(重写后的真实路径)。返回 (status, bytes/str)。"""
url = BASE + path
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(url, data=data, method=method)
req.add_header("Authorization", f"Bearer {TOKEN}")
if data is not None:
req.add_header("Content-Type", "application/json; charset=utf-8")
try:
with urllib.request.urlopen(req, timeout=30) as r:
raw = r.read()
return r.status, raw if binary else raw.decode("utf-8", "replace")
except urllib.error.HTTPError as e:
return e.code, e.read().decode("utf-8", "replace")
# ── 0. 当前授权 scope ───────────────────────────────────────────────
hr("0 · 当前 lark-cli 授权状态 + scope")
for sub in (["auth", "status"], ["auth", "scopes"]):
rc, out, err = lark(*sub)
print(f"$ lark-cli {' '.join(sub)}")
print((out or err).strip()[:4000] or "(空)")
print()
# ── 1. 建文档 ───────────────────────────────────────────────────────
hr("1 · 建文档 POST /open-apis/docs_ai/v1/documents")
doc_url = wb_token = None
if TOKEN:
print("模式:裸 Bearer(FEISHU_TOKEN)")
status, text = http("POST", "/open-apis/docs_ai/v1/documents",
{"content": CONTENT, "format": "xml"})
print(f"HTTP {status}\n{text[:2000]}")
try:
j = json.loads(text)
except Exception:
j = {}
else:
print("模式:lark-cli 透传(未设 FEISHU_TOKEN)")
rc, out, err = lark("api", "POST", "/open-apis/docs_ai/v1/documents",
"--data", json.dumps({"content": CONTENT, "format": "xml"}),
"--as", "user")
print((out or err).strip()[:2000])
try:
j = json.loads(out)
except Exception:
j = {}
doc = (j.get("data") or {}).get("document") or j.get("document") or {}
doc_url = doc.get("url")
for b in (doc.get("new_blocks") or []):
if b.get("block_type") == "whiteboard":
wb_token = b.get("block_token")
break
print(f"\n→ DOC_URL = {doc_url}\n→ WHITEBOARD_TOKEN = {wb_token}")
# Feishu 权限不足时 code 通常是 99991672 / 1254xxx,错误体里会点名缺的 scope。
code = j.get("code")
if code not in (0, None):
print(f"\n⚠️ 接口返回非 0 code={code} msg={j.get('msg')!r} —— 若是权限错误,上面错误体里会写明缺的 scope")
# ── 2. 导出图 ───────────────────────────────────────────────────────
hr("2 · 导出白板图 GET /open-apis/board/v1/whiteboards/<t>/download_as_image")
if not wb_token:
print("没拿到 whiteboard token,跳过导出(先看第 1 步是不是权限/参数错)")
else:
out_png = "probe_board.png"
if TOKEN:
status, raw = http("GET", f"/open-apis/board/v1/whiteboards/{wb_token}/download_as_image", binary=True)
if status == 200 and isinstance(raw, bytes) and raw[:4] in (b"\x89PNG", b"\xff\xd8\xff\xe0"):
with open(out_png, "wb") as f:
f.write(raw)
print(f"HTTP 200 → 已存 {out_png}({len(raw)} bytes)")
else:
print(f"HTTP {status} → 非图片,错误体:\n{raw[:1500] if isinstance(raw, str) else raw[:200]}")
else:
rc, out, err = lark("whiteboard", "+query", "--whiteboard-token", wb_token,
"--output_as", "image", "--output", out_png, "--overwrite", "--as", "user")
print((out or err).strip()[:1500])
print(f"(rc={rc}) 若成功,图在 ./{out_png}")
# ── 结论 ────────────────────────────────────────────────────────────
hr("结论")
print(f"建文档: {'OK ' + doc_url if doc_url else '失败 —— 看第 1 步输出'}")
print(f"导白板: {'见第 2 步' }")
print("\n下一步:把第 0 步打出的 scope 清单,拿去和扣子飞书『平台授权』同意页逐条比对。")
print("两个接口域名都是 open.feishu.cn → skill_credentials 的 allowed_domain 就填它。")
Critique — the post-render design pass
Correctness (overflow, overlap, clipping, arrows) is already clean by the time you're here — that was the RULES.md loop. This gate is about taste: a board can be perfectly correct and still look flat. Judge it as a designer.
How to run it
1. Look at the rendered PNG (the real one, not from memory). 2. Score each of the five axes below: pass / weak / fail. 3. Name the single weakest axis. Apply its fix recipe with small targeted SVG edits. 4. Re-render and look again. Repeat. 5. Stop when no axis is fail and at most one is weak — or when two passes produce no real gain, in which case say plainly what's still imperfect rather than looping forever.
Fix one axis per pass, strongest-leverage first. A flat board almost always fails Hierarchy or Balance before anything else — start there, not on alignment nitpicks.
Independent review (for boards that matter)
You grade your own work generously — that's the trap. For any board the user will actually ship, get a second pair of eyes that didn't build it:
- Spawn a critique subagent (Agent tool, e.g.
Exploreor general) and give it only the
rendered PNG and the five axes below. Prompt it adversarially: "You did not make this board. Find the single weakest axis and argue why it fails. Be harsh; do not praise." Take its verdict as the weakest-axis input to step 3 above.
- A reviewer with no authorship bias catches the flat hierarchy or the lopsided balance you've
already rationalised. Apply its finding, re-render, and only then trust the result.
- For a quick throwaway board, self-critique is fine. Scale the rigour to the stakes.
This pairs with the deterministic pre-render check (scripts/fit-check.mjs, run during the build): fit-check catches measurable defects (a label wider than its box, a gutter intrusion, canvas bleed) before you render; this gate and the independent reviewer catch taste defects that no measurement can. Run both — they cover different failure classes.
The five axes
1. Hierarchy — is there an obvious first thing the eye hits?
Squint at it (or imagine it blurred). If three regions compete equally, it fails. There must be one clear focal point, then a clear second tier, then the rest.
- Fail looks like: everything the same size/weight; no entry point; reads as a uniform field.
- Colour alone does not make a focal point. This is the most common self-deception: "it's the
focal because it's the saturated green one." A saturated box the same size as its neighbours will lose the first fixation to a higher-contrast element (a near-black box is the highest-contrast object on a cream canvas), to anything placed higher (top reads first), or to the title. The focal must also win on size or isolation, not just hue.
- Fix: make the focal element physically the largest object — enlarge it so it breaks its
siblings' shared baseline and visibly outsizes them — and give it the only saturated fill. Then demote the competitors: shrink equal-sized siblings, cut a heavy black box's second line, thin a bright accent strip. A board whose stated purpose is one focal point but which has three co-equal anchors is a hierarchy failure of intent, not polish. (COMPOSITION.md §2, §3.)
2. Balance — is visual weight distributed, or pooled in one corner?
Dark/saturated masses are "heavy". Check that weight isn't all top-left with an empty bottom-right, and that the composition isn't accidentally symmetric-and-dull.
- Fail looks like: one dense corner + large dead space elsewhere; or perfectly mirrored and lifeless.
- Fix: move or resize a secondary cluster to counter the heavy mass; pull content to fill dead
zones, or intentionally let whitespace frame the focal point (active negative space, not a gap). Prefer deliberate asymmetry (COMPOSITION.md §H) over forced symmetry.
3. Density — enough breathing room, and even across the board?
Check padding and gaps against COMPOSITION.md §1. Cramped panels and edge-touching text read cheap; vast empty panels read unfinished.
- Fail looks like: text jammed to panel borders; numerals touching edges; or one panel 80% empty
while its neighbour overflows.
- Fix: enforce panel padding 32 and the section/gutter gaps; rebalance content between over- and
under-full panels; widen a tight panel rather than shrinking its text below 16.
4. Contrast — does every element read clearly against its ground?
Text legibility and figure/ground separation. Remember opacity is ignored and the PNG export renders text colour unreliably — verify colour via +query --output_as raw or the live board, not the exported PNG (RULES.md).
- Fail looks like: mid-tone text on a mid-tone fill; small light text on near-black; two adjacent
panels in near-identical fills that visually merge.
- Fix: push text to a high-contrast pair (dark ink on light, or large bold light on saturated
dark); separate merging panels by a stronger fill step, a border, or a gutter. Use solid lighter hexes for tints, never alpha (RULES.md).
5. Alignment — are edges and baselines on a shared grid?
The cheapest tell of "thrown together". Panel edges, text starts, and connector endpoints should land on shared x/y lines.
- Fail looks like: panels off by a few px; ragged left edges across a column; arrows meeting boxes
off-centre; inconsistent column widths.
- Fix: snap edges to the COMPOSITION.md content band (x=80→1520) and column maths; align text
starts to a shared x; centre connector endpoints on panel mid-lines. Make near-equal things exactly equal.
One-line bar to clear
A clear focal point, weight that feels settled, even breathing room, every label legible, edges on
a grid — and it does not look like an AI made it (COMPOSITION.md §4). If all five hold, ship it.
Examples — gold-standard boards
Worked boards that passed both gates (fit-check clean, design critique clear) and rendered well on the real Feishu board. Each pairs a .svg (the editable source) with its .png (the render).
Use them as a starting skeleton. When the content matches one of these relationships, open the matching .svg, copy its structure, and replace the content — coordinates, gutters, type scale, and arrow connectors are already correct. This is faster and safer than composing from scratch, and it anchors quality. Re-run scripts/fit-check.mjs after editing, since your labels differ in length.
01–03 share one palette (Riso Brut) so they read as a series. 04–07 each use a different palette — Riso Brut, Riptide Cobalt, Coral, and a generated one (07) — to show the composition is palette-independent: swapping the palette keeps the layout and only changes the colours.
| File | Archetype (COMPOSITION.md) | Palette | Focal technique | Good for |
|---|---|---|---|---|
01-system-map | System map (vertical zones + focal container) | Riso Brut | Subject node is the only saturated fill, largest, with an orange offset shadow; supporting nodes stay on cream | "where X sits", positioning, component/service maps |
02-pipeline-fork | Linear flow with a symmetric fork/join (§A) | Riso Brut | The parallel section is enlarged and colour-blocked; the sequential prep/finalize steps are quiet cream boxes | processes, request lifecycles, "then it splits and rejoins" |
03-swimlane-sequence | Swimlanes (§B), time flowing down | Riso Brut | One lane (the protagonist) gets a green header + heavier lifeline, giving a flat sequence diagram a focal | sequence diagrams, handshakes, protocols, multi-actor timing |
04-radial-system-map | System map — radial / hub-and-spoke | Riso Brut | The central engine outsizes every satellite (~1.7×) and carries the only offset shadow, so it wins by size + colour, not colour alone; spokes are labelled connectors | hub-and-spoke architectures, "everything around a core", agent/platform maps |
05-comparison-matrix | Comparison matrix (§D), 3 columns × 7 rows | Riptide Cobalt | Per-row colour-blocking marks the winning option; a taller colour-blocked verdict band closes the grid as the focal | option trade-offs, "X vs Y vs Z", decision tables |
06-timeline | Timeline, time flowing left → right | Coral | Eras are ground bands; the climax era is a saturated hero band and the single key milestone is a dark hero card that wins hierarchy | histories, roadmaps, evolution, "how we got here" |
07-hierarchy | Hierarchy — nested containment + relationship arrows | generated | Depth from nested fills (canvas → panel → slate → teal), not shadow; the focal layer holds the darkest mass | object models, containment trees, system decomposition |
What makes each one work (so you can reproduce it, not just copy)
- 01 — a literal box-and-arrow architecture diagram is the AI-slop reflex (equal grey boxes, all-blue, draw.io look). It avoids that with an asymmetric vertical spine (external → cloud → the focal edge zone), exactly one saturated node that visibly outsizes its siblings, and warm cream + collision colour instead of tech-blue.
- 02 — the reflex is a flat row of identical numbered step boxes. It breaks the row: prep and finalize are quiet horizontal mini-rows, but the two parallel lanes blow up into the visual centre, because that fork is what the content is actually about. The fork is a single centred trunk that splits symmetrically — not a lopsided branch — and the join box is a quiet cream box, not a competing dark mass.
- 03 — the reflex is the grey mechanical sequence diagram. It uses the focal-lane move (colour one participant's whole lane) to create hierarchy where a sequence diagram normally has none, frames the polling
loopas a distinct block, fills the otherwise-empty right side with a tall "concurrent work" panel, and highlights the one load-bearing message (the orangecommit) in the accent colour. - 04 — the reflex is N equal boxes joined by lines. It makes the hub dominate on two axes (largest size and the only saturated, shadowed fill) so the eye lands on the core first and then follows labelled spokes outward — colour alone would lose to a bigger or higher box, so size carries it too.
- 05 — the reflex is a flat grid where every cell reads equally and you can't see who wins. It colour-blocks the winning cell per row and closes with a taller verdict band, so the comparison has a conclusion, not just data. Note it deliberately leaves the weaker column honestly emptier rather than colouring cells to fake visual balance — the critique caught and kept this.
- 06 — the reflex is evenly-spaced dots on a line. It groups time into era bands, escalates to a saturated hero band at the climax era, and promotes one milestone to a dark hero card, so the timeline has a peak instead of a flat ribbon.
- 07 — the reflex is an indented bullet tree (or all-equal boxes). It encodes containment as nested fill-darkness (canvas → panel → slate → deep teal) instead of shadows, concentrates the darkest mass in the focal layer, and — notably — runs on a palette generated via `templates/GENERATE.md` ("Steel Infra", a cool teal/slate register), demonstrating that a fresh palette drops straight into the same composition.
MIT License
Copyright (c) 2026 Zara Zhang (@zarazhangrui) — palette templates (a curated, distilled
subset) and medium rules, adapted from the beautiful-feishu-whiteboard project
Copyright (c) 2026 LcpMarvel — composition, critique, fit-check, gated-pipeline, and
palette-generation layers
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.
feishu-whiteboard-pro
一个用于打造真正"经过设计"的飞书 / Lark(飞书)白板的 Claude Code / agent skill——不只是配色 好看,而是讲究构图:清晰的视觉焦点、真实的层次、刻意的留白。它产出的是飞书文档里一块可编辑的真实白板, 而不是一张截图。
它构建于 beautiful-feishu-whiteboard (沿用其配色板与 SVG 白板介质的硬性规则)之上,补上了原项目缺失的那一层:如何构图,以及如何判断结果到底好不好。
相比一个"配色板库"它多了什么
介质本身被刻意限制——单一字体、只有原生矩形 / 圆 / 连接线,没有渐变、滤镜、透明度、动效。所以这里的"好看" 指的是构图、层次、节奏、配色克制与留白。这个 skill 把两个原本靠临场发挥的软步骤变成了关卡(gate):
理解内容
│
├─▶ 关卡 1 · 设计简报 原型 + 焦点 + 配色策略 + 字号角色 + 反套路检查
▼
对照骨架构图 原型坐标 + 固定字号阶梯 + 8px 间距网格
│
▼
渲染前预测缺陷(fit-check) 确定性:标签过宽 / 挤占间距 / 出血,在渲染前就抓出来
│
▼
渲染 → 修正确性 溢出 / 重叠 / 裁切 / 手画箭头
│
▼
关卡 2 · 设计评审 按层次 / 平衡 / 密度 / 对比 / 对齐五轴打分;要交付的板子上独立评审;修最弱项;重复
▼
写入飞书 → 看实时效果 → 交付- `fit-check`(scripts/fit-check.mjs)—— 估算每个标签的宽度(中文 ≈ 1em,
拉丁字符 ≈ 0.6em),在渲染之前就标出溢出 / 挤占间距 / 出血。确定性,不耗模型。
- 独立评审 —— 对要交付的板子,由一个独立评审者对渲染结果做对抗式打分,把"只靠颜色撑起来"的假焦点和
失衡的构图揪出来,而不是自我合理化。
示例
七张标杆样板,每张都过了双关卡(fit-check 干净、设计评审过关)并在真实飞书白板上渲染过。它们覆盖不同原型 与配色板——其中一张还用的是现场生成的配色——但都出自同一条流水线,所以构图与配色是解耦的。把对应的 .svg(在 `examples/` 里)当作起手骨架打开,坐标都已经是评审过关的。
四张复杂板子,四种不同配色:
| !放射式系统图<br>放射式系统图 · Riso Brut | !对比矩阵<br>对比矩阵 · Riptide Cobalt |
| !时间线<br>时间线 · Coral | !层级图<br>层级图 · 现场生成配色 |
三张基础板子,共用一套配色(Riso Brut):
| !系统图<br>系统图 | !流程+分叉/汇合<br>流程 + 分叉 / 汇合 | !泳道时序<br>泳道时序 |
配色 —— 精选锚点,或现场生成
这里的配色是一套设计系统,不是色卡清单:每套配色都给颜色分配角色(画布、墨色、强调色、面板)并附用途 说明和投放剂量,让 agent 知道每种颜色该怎么用,而不只是有哪些 hex。
- 一组精挑的锚点。 `CATALOG.md` 列出从克制到大胆的锚点配色;按气质和正式度挑一套。
每套就是一个 templates/<slug>/design.md,并且是唯一真相源——目录表由它们经 scripts/build-catalog.mjs 生成。
- 没有合适的就现场生成。 如果没有锚点契合简报,skill 会按 `templates/GENERATE.md`
生成一套——OKLCH 明暗推导、带色偏的中性色、角色剂量,以及介质自身的约束(画布绝不用纯白、墨色绝不用 #000、纯平 / 无透明度)。它产出的 frontmatter 形态和锚点完全一致,所以照样能换肤、也能存成新模板。 上面那张层级图用的就是现场生成的 “Steel Infra” 配色。
- 随时换肤。 换配色只改颜色、不动构图。
前置条件
- Node 20+
- [`lark-cli`](https://www.npmjs.com/package/@larksuite/cli) 已安装并完成认证:
npm install -g @larksuite/cli,然后 lark-cli config init(扫码)和 lark-cli auth login
- `@larksuite/whiteboard-cli` —— 通过
npx使用,自动下载,无需安装 - 一个飞书 / Lark 账号(板子写进你自己的租户)
运行 `scripts/preflight.sh` 可一次性检查以上全部。
安装
用 `skills` CLI —— 它会识别你的 agent(Claude Code、Cursor、 Codex…)并把 skill 软链到正确的目录:
# 全局(用户级),所有项目可用:
npx skills add -g LcpMarvel/feishu-whiteboard-pro
# …或项目级,只装进当前仓库:
npx skills add LcpMarvel/feishu-whiteboard-pro不安装、只试一次:npx skills use LcpMarvel/feishu-whiteboard-pro。
<details> <summary>手动安装(clone + 软链)</summary>
git clone https://github.com/LcpMarvel/feishu-whiteboard-pro.git
# Claude Code(用户级 skills):
ln -s "$(pwd)/feishu-whiteboard-pro" ~/.claude/skills/feishu-whiteboard-pro</details>
重启会话;之后只要你让它创建或打磨飞书白板、信息图、图表或可视化讲解,skill 就会触发。
仓库结构
| 路径 | 作用 |
|---|---|
| `SKILL.md` | 门控流水线与编排 |
| `COMPOSITION.md` | 原型库(坐标骨架)、字号阶梯、间距网格、反套路清单——核心 |
| `CRITIQUE.md` | 渲染后设计评分准则(五轴)+ 独立评审说明 |
| `RULES.md` | 飞书 SVG 白板介质的硬性限制,实测验证 |
| `templates/` | 一组精选配色板(每套一个 design.md)——唯一真相源 |
| `CATALOG.md` | 选色表,由 templates/ 经 scripts/build-catalog.mjs 生成 |
| `templates/GENERATE.md` | 没有锚点契合时,如何生成一套(同 frontmatter 形态)新配色 |
| `examples/` | 七张按原型分类的标杆样板(可编辑 .svg + 渲染图) |
| `scripts/` | fit-check.mjs(渲染前预测)、build-catalog.mjs(重生成 CATALOG)、preflight.sh |
致谢与许可
MIT。精选并蒸馏过的一部分配色与介质规则改编自 [beautiful-feishu-whiteboard](https://github.com/zarazhangrui/beautiful-feishu-whiteboard),作者 Zara Zhang([@zarazhangrui](https://github.com/zarazhangrui)) —— © Zara Zhang,MIT。构图、评审、 fit-check、门控流水线与配色生成层为原创新增。设计判断的思路受 impeccable / frontend-design skill 启发 (未拷贝代码)。详见 `LICENSE`。
Feishu SVG Whiteboard — Medium Rules
These apply to every board, regardless of template. A template gives you a color palette and a mood; this file is the hard limits of the medium. All of it is verified empirically on the real board (the image export and the GUI behave differently — see notes below).
Hard rules
- One font. The board hardcodes a single font (Noto Sans SC). Never set
font-family. Type is
size / weight / casing / letter-spacing only — there is no typeface choice.
- Text lives in `<text>` / `<tspan>` — never outline glyphs as
<path>. - Shape vocabulary is native-only — a rectangles-and-circles tool. Build everything from
<rect> (sharp or rounded rx), <circle>, <ellipse>, straight <line> / <polyline> connectors, and <text> — these become real editable shapes. <polygon> and any curved/bezier <path> embed as flat images (not shapes, render lumpy) — at most one simple triangle/diamond as a minor accent, never structural. No freeform / organic / illustrative shapes (blobs, leaves, petals, waves, coral, fans, flowers, stars, confetti, doodles, mascots, "hand-cut" silhouettes). If a reference is organic, keep only its palette and rebuild with rects + circles.
- Arrows = native connectors, never hand-drawn heads. To put an arrowhead on a line, give the
<line> or <polyline> a `marker-end` (and marker-start for a double-headed arrow) pointing at a <marker> in <defs>. The board converts these into native connectors with a clean built-in arrowhead, and the arrow takes the line's stroke colour. Never draw an arrowhead as a separate `<polygon>` / triangle — a polygon embeds as a flat image and renders rough, squiggly, and hand-drawn (unprofessional). One marker definition serves the whole board:
<defs>
<marker id="arrow" markerWidth="12" markerHeight="12" refX="9" refY="4"
orient="auto" markerUnits="strokeWidth"><path d="M0 0 L10 4 L0 8 z"/></marker>
</defs>
<line x1="100" y1="80" x2="360" y2="80" stroke="#0D4FA8" stroke-width="3" marker-end="url(#arrow)"/>
<!-- right-angled connector: polyline (H/V points) + marker-end -->
<polyline points="100,140 240,140 240,220" fill="none" stroke="#0D4FA8" stroke-width="3" marker-end="url(#arrow)"/>
<!-- double-headed (e.g. read/write): add marker-start too -->
<line x1="100" y1="280" x2="360" y2="280" stroke="#0D4FA8" stroke-width="3" marker-start="url(#arrow)" marker-end="url(#arrow)"/>(The marker's own shape/colour is ignored on the board; it just signals "put a native arrowhead here." Keep the line straight or right-angled — those map to clean straight / right_angled connectors.) The classic defect is a <line> plus a separate little <polyline> / <polygon> chevron drawn at its tip — that chevron IS a hand-drawn arrowhead. Delete it and put marker-end on the line. Mandatory self-check before you write the board: run grep -nE '<polygon|<polyline' <dir>/diagram.svg — every <polygon>, and every short <polyline> shaped like a triangle/chevron at a line's endpoint, is a defect to convert. (A <polyline> is fine ONLY as a right-angled connector path that itself carries marker-end and has no separate chevron.) This matters most when you START FROM or EDIT an existing SVG (a gallery template, a previous board, a translation): its arrows may predate this rule and still be hand-drawn — re-check and convert them, don't just edit the text around them.
- Forbidden (break or flatten to a static image): any gradient,
<filter>,<pattern>,
<clipPath>, <mask>, blur.
- Opacity is ignored.
opacity/fill-opacity/stroke-opacityall render fully opaque.
To get a paler tint, use a solid lighter hex — never alpha. (To fake a translucent overlap, paint the overlap region as its own solid darker shape.)
- Text color is unrestricted on the live board (any hex; the GUI's ~9 presets are not a limit) —
but the image export renders text color unreliably (often black). Judge text color via +query --output_as raw (stored hex) or the live board, never the exported PNG. Light/cream text works on saturated-dark fills when it's large and bold; small light text on near-black is unreliable.
- Shadows. No blur/filter shadows (unsupported). Hard offset shadows are allowed and fine —
draw one as a solid duplicate of the SAME shape offset behind the real one (never a blur). It must match the element's shape exactly: same rx (a pill's shadow is a pill, not a rect), same width/height, and inside the same rotate() group so it tilts with the element. Default can still be flat — depth also comes from flat color blocks, role-swaps, and scale.
- No decorative micro-chrome. No ornamental kicker/footer/slug/metadata labels. Every text
element must be load-bearing and legible (≥ ~16px, strong contrast); keep small text inside high-contrast panels, never small on a colored canvas.
- Never echo the user's instructions or your own process onto the board. The board shows the
content — never the request that produced it, the inputs you read, or how you built it. A whiteboard is a finished artifact, not a homework submission, so it carries no "here's what you asked for" framing. Cut every meta / process line, for example:
- scope or task notes — _"整理范围:仅总结张睿发言;问答部分按她的回答归纳"_, _"本图涵盖第 3-5 章"_
- source citations — _"来源:会议逐字稿 00:06:28–00:52:58 的张睿发言"_, _"based on the attached doc"_
- the chosen style / template name — _"风格:Specimen Bold"_, _"Riso Brut template"_
- audience / format directions, restatements of the prompt, and _"summary of… / 总结自…"_ framing
- dates, tokens, file paths, or tooling you were not explicitly asked to display
A title may name the subject (e.g. _"张睿 · 发言总结"_ is fine — that is the topic), but nothing on the board may describe the task, the source material, or the tool. Litmus test: if a line is addressed to the person who asked (or to a grader) rather than being a real part of the artifact, delete it. Put that kind of context in your chat reply to the user, never on the canvas.
- Transforms:
translate/rotate/scaleare safe; avoidskewX/skewY/matrix(...). - No fixed canvas, and no dead space. No 16:9, no scaler. Work in a logical coordinate space
(≈1600–1700 wide) and set the height to wrap the content: the SVG height / viewBox should equal the content's bounding box plus one outer margin (≈80) on every side — nothing more. Short, wide content (a timeline, a one-row comparison) gets a short, wide canvas; never a tall canvas with the board floating in the top third and a blank band below. If a side has a large empty band, either tighten the bounds to the content or redistribute the composition to fill it — do not ship the gap.
- Text reflows by character (CJK ≈ 1em, Latin ≈ 0.6em). Pad boxes generously; never fit text to
the pixel; wrap long lines across <tspan>s rather than shrinking.
Workflow
1. Pick a template (templates/<slug>/design.md) for the palette + mood, and read it. 2. Pick the narrative shape (pipeline / stages / comparison / system map / timeline) and write the SVG in a logical coord space (≈1600–1700 wide); native shapes only; every label a <text>. 3. Render and LOOK at it — then fix what you see. This is the most important step:
npx -y @larksuite/whiteboard-cli@^0.2.11 -i <dir>/diagram.svg -o <dir>/diagram.png -f svgnpx -y @larksuite/whiteboard-cli@^0.2.11 -i <dir>/diagram.svg -f svg --check- Open `diagram.png` and actually view it. Correct the common problems before moving on, and
iterate render → look → fix until it's clean:
- text overflow — text spilling out of its box or past the canvas edge,
- margins / padding — content flush to the canvas edge, or a numeral/title touching a box's
top/side with no breathing room,
- overlaps — shapes or labels colliding unintentionally (a duplicate-offset shadow or an
intentional overlap is fine; an accidental one is not),
- clipping — anything cut off on the right/bottom.
- dead space / loose canvas — a large empty band (most often the bottom) means the
height/
viewBox is taller than the content. Tighten the canvas bounds to the content bbox + one margin, or redistribute the layout to fill it. The board must not float in the top third.
- hand-drawn arrowheads — run
grep -nE '<polygon|<polyline' <dir>/diagram.svgand convert any
chevron/triangle arrowhead to a marker-end (see the Arrows rule above). Do this every time, and especially when you started from an existing SVG whose arrows may predate the rule.
- (
--checkflagstext-overflow/node-overlap; intentional overlaps, off-canvas bleed, or a
centered long Latin headline may report as warnings — judge with your eyes, not just the linter.)
- Fix by editing the `.svg` in place with small targeted edits (nudge a box, widen a panel,
rewrap a label) — never regenerate the whole SVG to fix a local issue, and apply every fix you spotted in one view in a single edit pass before re-rendering. This keeps the iterate loop cheap without losing any pass. 4. Write it into Feishu as an editable whiteboard, then look at the real board too: npx -y @larksuite/whiteboard-cli@^0.2.11 -i <dir>/diagram.svg --to openapi --format json | lark-cli whiteboard +update --whiteboard-token <tok> --source - --input_format raw --idempotent-token <unique> --overwrite --as user then lark-cli whiteboard +query --whiteboard-token <tok> --output_as image --output <dir> --as user, view it, and fix any remaining layout issues. The export is faithful for layout, shapes, fills, opacity — but not text color (verify color via --output_as raw or the live doc, not the PNG).
- This Feishu export is a verification image, not the deliverable. It comes back as a fixed
square preview (Feishu pads any board to ~2560×2560, regardless of its aspect ratio — there is no size/bounds option on the API), so a wide or short board floats in a sea of whitespace. Use it only to check the live board (especially text colour). Deliver the local `diagram.png` from step 3 instead — it's rendered straight from the SVG, so it's tight to the composition.
Creating the doc + whiteboard block to write into
If the user didn't give a target whiteboard, create one:
# new doc with an empty whiteboard block; grab the block_token from the response
lark-cli docs +create --api-version v2 \
--content '<title>My board</title><whiteboard type="blank"></whiteboard>' --as userThen write the SVG to that block_token with the whiteboard +update command in step 4. To add a board to an existing doc the user gave, fetch it (lark-cli docs +fetch) or append a <whiteboard type="blank"></whiteboard> block, and use that block's token.
#!/usr/bin/env node
// build-catalog — regenerate CATALOG.md from the per-template frontmatter.
//
// Each templates/<slug>/design.md carries a `catalog:` block (level / formality /
// vibe / signature). This script reads them all and rewrites CATALOG.md so the table
// is never hand-maintained: add a template folder with a catalog block, run this, done.
//
// Usage: node scripts/build-catalog.mjs [--check]
// --check exit 1 if CATALOG.md is out of date (for CI / pre-commit), don't write.
import { readFileSync, writeFileSync, readdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
const TEMPLATES = join(ROOT, "templates");
const CATALOG = join(ROOT, "CATALOG.md");
const LEVELS = ["Restrained", "Balanced", "Bold"];
// Pull the YAML frontmatter (first block between --- fences).
function frontmatter(md) {
const m = md.match(/^---\n([\s\S]*?)\n---/);
if (!m) throw new Error("no frontmatter");
return m[1];
}
// Targeted parse — we only need name + the catalog: sub-block.
function parse(slug, md) {
const fm = frontmatter(md);
const name = fm.match(/^name:\s*(.+)$/m)?.[1].trim();
if (!name) throw new Error(`${slug}: missing name`);
const cat = fm.match(/^catalog:\n([\s\S]*?)(?=^\S)/m);
if (!cat) throw new Error(`${slug}: missing catalog: block`);
const block = cat[1];
const field = (k) => block.match(new RegExp(`^ ${k}:\\s*(.+)$`, "m"))?.[1].trim();
const level = field("level");
const formality = field("formality");
const vibe = field("vibe");
const signature = [...block.matchAll(/^\s+-\s*"(.+?)"\s*$/gm)].map((m) => m[1].trim());
for (const [k, v] of Object.entries({ level, formality, vibe })) {
if (!v) throw new Error(`${slug}: missing catalog.${k}`);
}
if (!LEVELS.includes(level)) throw new Error(`${slug}: bad level "${level}"`);
if (!signature.length) throw new Error(`${slug}: empty catalog.signature`);
return { slug, name, level, formality, vibe, signature };
}
const rows = readdirSync(TEMPLATES, { withFileTypes: true })
.filter((e) => e.isDirectory())
.map((e) => parse(e.name, readFileSync(join(TEMPLATES, e.name, "design.md"), "utf8")))
.sort((a, b) => a.name.localeCompare(b.name));
const esc = (s) => s.replace(/\|/g, "\\|");
function table(level) {
const r = rows.filter((x) => x.level === level);
const head =
"| Style | Formality | Vibe | Palette (canvas · accents) |\n" +
"| ----- | --------- | ---- | -------------------------- |";
const body = r
.map(
(x) =>
`| [${esc(x.name)}](templates/${x.slug}/) | ${x.formality} | ${esc(x.vibe)} | ${x.signature
.map((s) => `\`${s}\``)
.join(" · ")} |`
)
.join("\n");
return `${head}\n${body}`;
}
const md = `# Style Catalogue
<!-- GENERATED by scripts/build-catalog.mjs from templates/*/design.md — do not edit by hand. -->
Use this to pick a style for the user, by the **level** (how restrained or bold it feels), the
**formality**, and the **vibe** they want. Any style can carry almost any content, so choose by feel.
If the user named a style or a clear preference, honour it. Otherwise infer from the tone they want.
After generating, always tell the user they can switch to any other style.
Each row lists its **palette signature** (canvas + the defining accents, as hex) so you can choose
from this table **alone**. Once you pick a style, open **only that one** \`templates/<slug>/design.md\`
for the full colour notes — do not open several templates to compare.
The ${rows.length} styles are grouped into three levels, from restrained to bold:
- **Restrained** — calm, muted, often monochrome; for serious, quiet, editorial boards.
- **Balanced** — a confident accent or two on a clean ground; the versatile middle. Most work lives here.
- **Bold** — loud, saturated, lots of colour; for playful, high-energy, attention-grabbing boards.
**Formality key:** **Low** = playful / casual, **Medium** = confident / modern, **High** =
restrained / refined / institutional.
## Restrained
${table("Restrained")}
## Balanced
${table("Balanced")}
## Bold
${table("Bold")}
## How to choose
1. **Level first.** How loud should the board feel? Quiet / serious leans **Restrained**; playful or
high-energy leans **Bold**; most general work sits comfortably in **Balanced**.
2. **Formality second.** Within the level, match how formal it should read (Low / Medium / High).
3. **Vibe third.** Narrow by the user's mood or colour preference (the Vibe + Palette columns).
4. **When unsure, default to a safe or balanced style.**
5. **Commit from this table, then open exactly one \`design.md\`** — the one for the style you picked.
The palette signature here is enough to choose; don't open several templates to compare.
6. Always offer to switch afterwards. Swapping style means re-rendering the same content in a
different template's palette.
`;
const current = (() => {
try {
return readFileSync(CATALOG, "utf8");
} catch {
return "";
}
})();
if (process.argv.includes("--check")) {
if (current !== md) {
console.error("CATALOG.md is out of date — run: node scripts/build-catalog.mjs");
process.exit(1);
}
console.log(`CATALOG.md up to date (${rows.length} styles).`);
} else {
writeFileSync(CATALOG, md);
console.log(`Wrote CATALOG.md (${rows.length} styles: ${rows.map((r) => r.name).join(", ")}).`);
}
#!/usr/bin/env node
// fit-check — predict text/layout defects from an SVG BEFORE rendering.
//
// The whiteboard's text reflows by character (RULES.md): CJK/full-width ≈ 1em,
// Latin/digit/punct ≈ 0.6em. Most defects on these boards came from hand-placed
// coordinates where a label was wider than its box, or a between-box label spilled
// into a neighbour. This estimates every <text>'s width and checks it against the
// geometry, so those are caught before the render → look loop, not during it.
//
// Usage: node fit-check.mjs <diagram.svg> [--pad N] [--margin N] [--deadband N]
// --pad min breathing space inside a box, each side (default 12)
// --margin min distance content must keep from the canvas edge (default 40)
// --deadband max empty band tolerated on a side before it's flagged as dead space (default 160)
//
// Exit 1 if any defect is found. It is a predictor, not a renderer: treat hits as
// "look here", and still trust your eyes on the rendered image.
import { readFileSync } from "node:fs";
const args = process.argv.slice(2);
const file = args.find((a) => !a.startsWith("--"));
const opt = (name, def) => {
const i = args.indexOf(`--${name}`);
return i >= 0 && args[i + 1] ? Number(args[i + 1]) : def;
};
if (!file) {
console.error("usage: node fit-check.mjs <diagram.svg> [--pad N] [--margin N]");
process.exit(2);
}
const PAD = opt("pad", 12);
const MARGIN = opt("margin", 40);
const DEADBAND = opt("deadband", 160);
const svg = readFileSync(file, "utf8");
// ---- viewBox ----------------------------------------------------------------
const vb = svg.match(/viewBox\s*=\s*"([\d.\s-]+)"/);
let [vbx, vby, vbw, vbh] = vb ? vb[1].trim().split(/\s+/).map(Number) : [0, 0, Infinity, Infinity];
// ---- character width model (RULES.md ratios) --------------------------------
function isWide(cp) {
return (
(cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
(cp >= 0x2e80 && cp <= 0x9fff) || // CJK radicals … unified
(cp >= 0x3000 && cp <= 0x303f) || // CJK symbols & punctuation
(cp >= 0x3040 && cp <= 0x30ff) || // kana
(cp >= 0x3400 && cp <= 0x4dbf) || // CJK ext A
(cp >= 0xac00 && cp <= 0xd7a3) || // Hangul syllables
(cp >= 0xf900 && cp <= 0xfaff) || // CJK compat
(cp >= 0xff00 && cp <= 0xff60) || // full-width forms
(cp >= 0xffe0 && cp <= 0xffe6)
);
}
function textWidth(str, fs) {
let w = 0;
for (const ch of str) {
const cp = ch.codePointAt(0);
if (ch === " ") w += fs * 0.3;
else if (isWide(cp)) w += fs;
else w += fs * 0.6;
}
return w;
}
function decode(s) {
return s
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/ | /g, " ")
.replace(/ /g, " ");
}
// ---- parse rects ------------------------------------------------------------
const rects = [];
for (const m of svg.matchAll(/<rect\b([^>]*)\/?>/g)) {
const a = m[1];
const num = (k) => {
const r = a.match(new RegExp(`\\b${k}\\s*=\\s*"([\\d.-]+)"`));
return r ? Number(r[1]) : null;
};
const x = num("x"), y = num("y"), w = num("width"), h = num("height");
if (x == null || y == null || w == null || h == null) continue;
rects.push({ x, y, w, h, area: w * h });
}
// ---- parse texts ------------------------------------------------------------
const texts = [];
for (const m of svg.matchAll(/<text\b([^>]*)>([\s\S]*?)<\/text>/g)) {
const a = m[1];
const raw = m[2];
const content = decode(raw.replace(/<[^>]+>/g, "")); // flatten tspans
if (!content.trim()) continue;
const num = (k, def = null) => {
const r = a.match(new RegExp(`\\b${k}\\s*=\\s*"([\\d.-]+)"`));
return r ? Number(r[1]) : def;
};
const anchorM = a.match(/text-anchor\s*=\s*"(start|middle|end)"/);
texts.push({
x: num("x", 0),
y: num("y", 0),
fs: num("font-size", 16),
anchor: anchorM ? anchorM[1] : "start",
str: content,
});
}
// ---- analysis ---------------------------------------------------------------
const issues = [];
const label = (t) => `"${t.str.length > 34 ? t.str.slice(0, 33) + "…" : t.str}"`;
for (const t of texts) {
const w = textWidth(t.str, t.fs);
let L, R;
if (t.anchor === "middle") { L = t.x - w / 2; R = t.x + w / 2; }
else if (t.anchor === "end") { L = t.x - w; R = t.x; }
else { L = t.x; R = t.x + w; }
// canvas bleed
if (L < vbx + MARGIN || R > vbx + vbw - MARGIN) {
issues.push(`BLEED ${label(t)} extent [${Math.round(L)},${Math.round(R)}] < ${MARGIN}px from canvas edge (canvas ${vbx}..${vbx + vbw})`);
}
// smallest rect containing the anchor point = its box
const inside = rects
.filter((r) => t.x >= r.x && t.x <= r.x + r.w && t.y >= r.y && t.y <= r.y + r.h)
.sort((a, b) => a.area - b.area)[0];
if (inside) {
const innerL = inside.x + PAD, innerR = inside.x + inside.w - PAD;
if (L < innerL || R > innerR) {
const over = Math.round(Math.max(innerL - L, R - innerR));
issues.push(`OVERFLOW ${label(t)} ${t.fs}px ≈${Math.round(w)}px wide, box inner width ${Math.round(inside.w - 2 * PAD)}px — over by ${over}px (box x=${inside.x} w=${inside.w})`);
}
} else {
// between-box label: does its horizontal extent intrude into any box at this y?
for (const r of rects) {
if (t.y >= r.y && t.y <= r.y + r.h && R > r.x && L < r.x + r.w) {
const into = Math.round(Math.min(R, r.x + r.w) - Math.max(L, r.x));
if (into > 2) {
issues.push(`INTRUDE ${label(t)} extent [${Math.round(L)},${Math.round(R)}] spills ${into}px into box x=${r.x}..${r.x + r.w} (it sits in no box)`);
break;
}
}
}
}
}
// rects beyond canvas
for (const r of rects) {
if (r.x < vbx - 1 || r.y < vby - 1 || r.x + r.w > vbx + vbw + 1 || r.y + r.h > vby + vbh + 1) {
issues.push(`CLIP rect x=${r.x} y=${r.y} ${r.w}×${r.h} extends past canvas ${vbw}×${vbh}`);
}
}
// ---- dead space (RULES.md: canvas height/viewBox should wrap content + one margin) ----
// Content bbox over rects + estimated text extents; a side whose empty band exceeds DEADBAND
// means the canvas is looser than the content — usually the board floating in the top third.
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const r of rects) {
minX = Math.min(minX, r.x); minY = Math.min(minY, r.y);
maxX = Math.max(maxX, r.x + r.w); maxY = Math.max(maxY, r.y + r.h);
}
for (const t of texts) {
const w = textWidth(t.str, t.fs);
const L = t.anchor === "middle" ? t.x - w / 2 : t.anchor === "end" ? t.x - w : t.x;
minX = Math.min(minX, L); maxX = Math.max(maxX, L + w);
minY = Math.min(minY, t.y - t.fs); maxY = Math.max(maxY, t.y); // baseline − ascent .. baseline
}
if (isFinite(maxY) && isFinite(vbw) && isFinite(vbh)) {
const sides = [
["bottom", vby + vbh - maxY, "y"],
["top", minY - vby, "y"],
["right", vbx + vbw - maxX, "x"],
["left", minX - vbx, "x"],
];
for (const [side, gap, axis] of sides) {
if (gap > DEADBAND) {
const span = axis === "y" ? `content y ${Math.round(minY)}..${Math.round(maxY)} of ${vbh}` : `content x ${Math.round(minX)}..${Math.round(maxX)} of ${vbw}`;
issues.push(`DEADSPACE ${side} band ≈${Math.round(gap)}px empty (${span}) — tighten the viewBox to content + one margin, or redistribute to fill it`);
}
}
}
// ---- report -----------------------------------------------------------------
console.log(`fit-check ${file}`);
console.log(` canvas ${vbw}×${vbh} · ${rects.length} rects · ${texts.length} texts · pad ${PAD} · margin ${MARGIN} · deadband ${DEADBAND}`);
if (!issues.length) {
console.log(" ✓ no predicted fit defects");
process.exit(0);
}
console.log(` ${issues.length} predicted defect(s):`);
for (const i of issues) console.log(" • " + i);
console.log("\n Fix in the SVG (widen the box, shorten the label, or open the gutter), then re-run. Still confirm on the render.");
process.exit(1);
#!/usr/bin/env bash
# Preflight check for the beautiful-feishu-whiteboard skill.
# Verifies the tools needed to render an SVG and write it into Feishu as an editable whiteboard.
set -u
ok=1
echo "▶ Checking prerequisites for beautiful-feishu-whiteboard…"
echo
# Node ≥ 20
if command -v node >/dev/null 2>&1; then
echo " ✓ Node $(node -v)"
else
echo " ✗ Node.js not found — install Node ≥ 20 (https://nodejs.org)"
ok=0
fi
# lark-cli (npm: @larksuite/cli) — auth + writing to Feishu
if command -v lark-cli >/dev/null 2>&1; then
echo " ✓ lark-cli ($(lark-cli --version 2>/dev/null | head -1))"
if lark-cli auth status >/dev/null 2>&1; then
echo " ✓ lark-cli appears authenticated"
else
echo " ! lark-cli may not be authenticated. Run:"
echo " lark-cli config init # first-time setup, scan the QR"
echo " lark-cli auth login # authorize your Feishu/Lark account"
fi
else
echo " ✗ lark-cli not found. Install and authenticate:"
echo " npm install -g @larksuite/cli"
echo " lark-cli config init # scan the QR"
echo " lark-cli auth login"
ok=0
fi
# whiteboard-cli (run via npx, auto-downloads)
if npx -y @larksuite/whiteboard-cli@^0.2.11 -v >/dev/null 2>&1; then
echo " ✓ @larksuite/whiteboard-cli reachable via npx"
else
echo " ! could not reach @larksuite/whiteboard-cli via npx (needs network on first run)"
fi
echo
if [ "$ok" = 1 ]; then
echo "✅ Ready. You also need a Feishu/Lark account — boards are written to your own tenant."
else
echo "❌ Missing prerequisites above. Install them, then re-run this check."
exit 1
fi
Coral
Generating a palette (when no anchor fits)
The `CATALOG.md` anchors are the reliable, swappable skins — prefer one of them. Generate a palette only when none of them serves the brief's mood, or the user wants a specific hue the anchors don't carry. A generated palette must come out structurally identical to an anchor (same design.md frontmatter) so it can be swapped, critiqued, and persisted like any other.
The colour theory below (OKLCH derivation, tinted neutrals, role dosage, dangerous combinations) is distilled from the impeccable skill's color-and-contrast reference — borrowed and adapted to this medium, no code copied. What impeccable can't know is the whiteboard medium; those rules come first.
Medium constraints that shape every palette (non-negotiable)
These override any web/UI colour habit — the board is flat SVG, not CSS:
- Canvas is never pure white. Use a warm/tinted off-white (a hair of chroma, lightness ~92–96%).
Pure white reads cheap and unprinted; the off-white temperature is the whole "designed paper" feel.
- Ink is never pure `#000`. Use a warm near-black with a tiny hue cast (lightness ~10–20%). Pure
black is harsh and flat against a tinted ground.
- Opacity is ignored — no alpha, ever. Need a lighter tone, a tint, or a "faded" note surface?
Compute a real lighter hex; never rely on transparency (RULES.md). A gradient becomes two flat tones.
- Text-on-fill is asymmetric. Large bold light text reads on a saturated or dark fill; **small
light text on a coloured/near-black fill is unreliable** — put small text in a high-contrast panel (paper/cream) with dark ink. (RULES.md.)
- Judge colour on the live board or `--output_as raw`, never the exported PNG — the PNG renders
text colour unreliably.
Recipe
1. Strategy + dosage (from GATE 1, the commitment axis):
- Restrained — tinted ground + one accent, accent ≤ ~10% of visual weight.
- Committed — one colour carries 30–60% (large fields, hero bands); the rest neutral.
- Full — 3–4 named roles that collide; use 2–3 per scene, never all at once.
2. Pick the accent hue first — a mood/brand decision. Do not reflex to blue (hue ~250) or warm orange (hue ~60); those are the AI-design defaults, not an answer. 3. Derive shades in OKLCH, then convert to hex: hold hue + chroma roughly constant and vary lightness; drop chroma as you approach white or black (high chroma at the extremes looks garish). Give every accent a -dark sibling for layered/comparison blocks (it also replaces any gradient — flat only). 4. Canvas = a very light tint cast toward the accent hue (L ~92–96%, chroma ~0.005–0.02). Warm, not white. 5. Ink = warm near-black, cast toward the accent/warm (L ~10–20%, small chroma). Not #000. 6. Neutrals = tint toward the accent hue (chroma 0.005–0.015) — pure grey reads dead next to colour. Provide a secondary and a tertiary text grey. 7. Paper/panel = if the canvas is saturated, add a near-white panel fill so small dark text has a home. 8. Validate by role, not by eye: body text ≥ 4.5:1 contrast, large/bold ≥ 3:1. Screen the dangerous combos — grey on colour (washed out), red/green (8% can't separate), yellow on white, blue on red (vibrates). Fix by darkening the background-coloured text or moving to a panel. 9. Stroke language: pick border weight + radius to match the mood (hairline warm-charcoal for tidy/editorial; 3–4px ink for brutalist; low/zero radius for squared, 12 for friendly-soft).
Emit it as a template
Write the frontmatter exactly like an anchor (see any templates/<slug>/design.md):
---
name: <Title Case>
description: >
<one-or-two-line mood + the core strategy: what is canvas, what carries the accent, what's the spark>
catalog:
level: Restrained | Balanced | Bold # how loud it feels
formality: Low | Medium | High
vibe: <short comma phrase>
signature: # canvas first, then 2–3 defining accents
- "#RRGGBB <name>"
- "#RRGGBB <name>"
colors:
<role>: "#RRGGBB" # per-colour usage note — which role it plays (canvas / ink / accent / panel / text-on-fill)
# ... a one-line dosage rule (how many accents per scene, what carries text on a fill)
stroke:
structural: "<border weight + where>"
radius: "<0–N; the mood>"
# depth: FLAT — ... (or a `shadow:` block if the look uses hard offset shadows)
---
# <Name>Where it goes depends on who's running — a generated palette is data the user owns, so default to never writing into the skill's own directory:
- Ephemeral (default, and the right choice at user runtime). Keep this frontmatter block inline
for the current board only. If the user might want it again, paste it into their own project (a palettes/ note, the board's source folder — anywhere under their working dir), and re-feed it next time. This survives skill upgrades and never needs write access to the skill.
- Do NOT persist into this skill's `templates/` at user runtime. The skill usually lives in a
managed install dir (~/.claude/skills/… or a plugin cache): a template written there is wiped on the next `skills add` upgrade, the try-once skills use dir is ephemeral, and writing outside the user's project may be denied or prompt. It would look saved and silently vanish.
- Persist as a real template only when authoring the skill — i.e. you're working inside a clone /
this git repo. Then save templates/<slug>/design.md and run node scripts/build-catalog.mjs; it's durable because it's version-controlled. To ship a palette upstream, that's the path: add it here, open a PR. The generation layer and the curated set use one format precisely so this graduation is a copy, not a rewrite.