Sync multi-theme optimizations from dev (exclude Bento/Style C)

- Magazine (A) + Swiss (B) template updates: ?slide=N, ESC thumbnail fix, reduced-motion, spacing fixes
- Swiss validator: Playwright M1/M2 rendered measurement (overflow/whitespace/nav-safe/title-gap)
- SKILL.md: Step 0 upstream-update check, image-mixing decision tree, measure-before-edit ladder
- checklist.md: image-role guidance, quiet-zone rule, post-render measurement section
- Remove author-local golden-source absolute paths; point to in-repo template-swiss.html snapshot
- Add ppt-skill-showcase.png and A/B-only unification-plan.md
- Exclude all Bento/Style C assets, references, validator, and design-research docs

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
郭浩andCursor committed 2026-07-22 14:53:09 +08:00
1 parent 82fe5ae129
commit ba12c5c9ad
11 files changed
+428 -94

No files matched your search

+2 -2
View File
@@ -103,7 +103,7 @@ Guizang PPT Skill 的持续迭代获得 **360 安全龙虾** 金牌赞助和 [
- **更适合 Agent 生成和修改**:HTML / CSS 是文本,Agent 能直接读、改、验证。
- **表现力比 Markdown 更高**:可以做精细排版、空间定位、动画、交互和响应式封面。
- **交付更轻**:单文件 HTML 可以直接打开、演示、发送、截图。
- **更容易做质量控制**:瑞士风可以用脚本校验版式、图片槽位、标题对齐和危险 SVG。
- **更容易做质量控制**:瑞士风可以用脚本校验版式、图片槽位、标题对齐、危险 SVG,并在可用时用 Playwright 后验测量超出、底部空白、nav 安全线和标题间距。
- **更适合视觉内容链路**:同一套主题能覆盖 PPT、配图、封面和截图再设计。
## 平台支持
@@ -178,7 +178,7 @@ Skill 本身是结构化工作流,Agent 会逐步引导:
- **中文字号收敛**:全中文大标题需要降一档,避免占掉正文和图片空间
- **图文底对齐**:左文右图 / 左图右文场景优先让正文块与图片底部对齐,同时避开页脚翻页组件
- **图片槽位绑定**:图片必须进入模板预留的 `data-image-slot`,常见主图按 21:9 或 16:10 生成
- **强校验**:用脚本拦住居中标题、实验版式、SVG 内写字、图片脱离槽位等问题
- **强校验**:用脚本拦住居中标题、实验版式、SVG 内写字、图片脱离槽位等问题;可用 Playwright 时还会量化真实渲染后的 overflow、bottom whitespace、title gap
瑞士风校验命令:
+55 -2
View File
@@ -47,6 +47,23 @@ description: 生成横向翻页网页 PPT(单 HTML 文件),含 WebGL 背
## 工作流
### Step 0 · 启动前检查更新(必做)
每次启动本 Skill 前,先在 Skill 根目录检查 GitHub 上游是否有更新;有更新时先问用户是否要更新,用户确认后再执行更新,然后继续后续流程。
```bash
git -C "<SKILL_ROOT>" fetch --quiet
git -C "<SKILL_ROOT>" rev-list --count HEAD..@{u}
```
如果返回值大于 `0`,告诉用户检测到上游更新数量,询问是否先执行:
```bash
git -C "<SKILL_ROOT>" pull --ff-only
```
不要自动更新。用户拒绝时继续使用当前版本;如果网络不可用、没有 upstream 或不是 git 仓库,说明无法检查更新并继续流程。
### Step 1 · 需求澄清(**动手前必做**)
**如果用户已经给了完整的大纲 + 图片/截图处理要求**,可以跳过直接进 Step 2。
@@ -316,7 +333,7 @@ cp "<SKILL_ROOT>/assets/template-swiss.html" "项目/XXX/ppt/index.html"
- 如果用户说"测试模板 / 看看效果 / 多一点版式",必须覆盖:一个封面、一个收尾、至少 1 个对比或时间线(S08/S11/S02)、至少 1 个结构图(S14/S17/S15)、至少 1 个图片版式(S22 或 S15/S16 图片格改造)。
- 不允许连续 3 页使用同一种主体结构,例如连续三页 `head + grid + card`。
- 图片页不能偷懒发明新结构。2-3 张图时,用 S15/S16 的原始网格骨架改造成图片格;单张大图用 S22。
- 开写 HTML 前先列一张 `页码 → data-layout → 选用理由 → 图片槽位` 草稿;交付前运行 `node <SKILL_ROOT>/scripts/validate-swiss-deck.mjs index.html`。
- 开写 HTML 前先列一张 `页码 → data-layout → 选用理由 → 图片槽位` 草稿;交付前运行 `node <SKILL_ROOT>/scripts/validate-swiss-deck.mjs index.html`。校验器会先做静态结构检查;如果环境中能解析到 Playwright,还会做真实渲染后的可见边界、底部空白、nav 安全线和标题间距测量。
#### 3.2 · 图片比例规范
@@ -345,6 +362,18 @@ cp "<SKILL_ROOT>/assets/template-swiss.html" "项目/XXX/ppt/index.html"
- GPT-M 2.0 生成图使用 `image-prompts.md` 的"风格 B:瑞士国际主义配图规则"
- 任何图片、caption、timeline label、footnote 的最低处都不能进入底部分页区域;需要贴底时用 `.nav-safe-bottom` / `.nav-safe-bottom-tight`,不要手写 `bottom:2vh`
#### 3.2.0 · 图文混排决策树(从社交卡片规则迁移)
先判断图片在这一页里的角色,再决定容器、比例和裁切方式:
- **证据截图 / UI / 代码 / dashboard**:保真优先,先读 `references/screenshot-framing.md`;关键文字和数据不能被裁掉。需要统一比例时,优先程序化背景画布 + `.fit-contain`,不要为了铺满而裁掉 UI 内容。
- **已按槽位重生成的信息图 / 插图**:按目标槽位铺满,例如 S22 用 `21:9`,S15/S16 用统一 `21:9` 或 `16:10`;不要再用短高度把图缩小成小贴片。
- **照片 / 产品图 / 人物图**:使用标准比例 + 明确 `object-position`;主体、人脸、产品和关键证据不能被标题、caption 或裁切压住。
- **文字压图 / 全屏主视觉**:先做 quiet-zone 判断,图里至少要有约 30% 低细节区域承载文字;不通过就换图、换裁切或改成图文分栏。只在必要时加局部 tint,不要整页套黑色/白色遮罩。
- **多图组**:同一组统一比例、高度、容器处理和 caption 密度;不要一张 `contain`,另一张 `cover`。
- **生成图是素材,不是整页 slide**:图片内部不要自带页眉、页脚、页码、logo、主标题、装饰边框或署名,避免和 deck chrome 重复。
- **图文呼吸**:标题、图片、caption、正文必须各自留出间距;生成后用 validator 的 `M1/M2` 检查可见边界、底部空白、nav 安全线和标题间距。
#### 3.2.1 · 中文大标题字号分档(风格 B 必做)
中文方块字视觉面积大,不能直接套英文 hero 的 6.8-7vw。写中文大标题前先分档:
@@ -391,11 +420,35 @@ cp "<SKILL_ROOT>/assets/template-swiss.html" "项目/XXX/ppt/index.html"
生成完一定要打开 `references/checklist.md`,逐项对照。里面总结了**真实迭代过程中踩过的所有坑**,P0 级别的问题(emoji、图片撑破、标题换行、字体分工)必须全部通过。
#### 4.0.1 · 先量后改:超出 / 空白 / 标题间距
当一页内容超出或显得巨空时,不要先凭感觉大幅删改。先运行:
```bash
node <SKILL_ROOT>/scripts/validate-swiss-deck.mjs path/to/index.html
```
看校验输出里的测量项:
- `M1 DOM/visual overflow`:具体超出多少 px,以及最低/最高问题元素
- `M1 bottom whitespace`:底部空白多少 px,active content height 占比多少
- `M1 nav-safe`:最低内容是否进入底部分页安全线
- `M2 title gap`:标题和下一块内容之间的实际距离
修正阶梯:
- `1-40px` over:只微调,上移内容组或收紧一个 gap/padding,不要删内容。
- `40-90px` over:局部压缩间距或模块高度,仍优先保留内容。
- `90-160px` over:轻微压标题或压缩一段正文,必要时拆页。
- `160px+` over:才考虑换版式、合并模块或删内容。
修完再跑一次 validator。如果 `M1 bottom whitespace` 变大,说明修过头了;恢复部分间距、放大最后一块或把内容组向下回调。
#### 4.0 · 不只看代码:必须打开网页做视觉核对
代码只能证明类名和结构存在,不能证明版式舒服。生成后必须打开网页逐页看:
1. 同时打开原始参考 PPT、当前模板或生成页、测试 PPT;原始参考是 `/Users/guohao/Documents/op7418的仓库/项目/Thin-Harness-Fat-Skills/ppt/index.html`。
1. 同时打开当前模板(golden source 快照)或生成页、以及正在迭代的测试 PPT 逐页对照。
2. 截图前等入场动效稳定(约 1-2 秒),不要把动画中间态当成版式问题。
3. 先看视觉:大标题字重、标题与内容间距、图片是否与正文对齐、图片/说明是否碰到底部分页组件。
4. 再看代码:确认该页选用的版式与内容形状匹配,没有把数据专用版式拿来讲概念,也没有把可选组件堆成装饰。
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

+10 -57
View File
@@ -374,50 +374,6 @@
padding:0 .05em
}
/* ============ Pipeline / Step ============ */
.pipeline-section{margin-top:3.2vh;padding-top:2.2vh;border-top:1px solid rgba(127,127,127,.3)}
.pipeline-section:first-of-type{border-top:0;padding-top:0;margin-top:2.4vh}
.pipeline-label{
font-family:var(--mono);
font-size:max(14px,.84vw);
letter-spacing:.24em;text-transform:uppercase;
opacity:.6;margin-bottom:1.8vh;
}
.pipeline{
display:grid;
grid-template-columns:repeat(5,1fr);
gap:1vw;
}
.pipeline[data-cols="3"]{grid-template-columns:repeat(3,1fr)}
.pipeline[data-cols="4"]{grid-template-columns:repeat(4,1fr)}
.pipeline[data-cols="6"]{grid-template-columns:repeat(6,1fr)}
.step{
display:flex;flex-direction:column;gap:.6vh;
padding-top:1.2vh;
border-top:2px solid currentColor;
}
.step.accent-top{border-top-color:var(--accent);border-top-width:3px}
.step-nb{
font-family:var(--mono);
font-weight:500;
font-size:max(14px,1vw);
opacity:.5;letter-spacing:.04em
}
.step-title{
font-family:var(--sans),var(--sans-zh);
font-weight:700;
font-size:1.4vw;
letter-spacing:-.01em;
line-height:1.2;
}
.step-desc{
font-family:var(--sans),var(--sans-zh);
font-weight:400;
font-size:max(16px,.94vw);
line-height:1.45;
opacity:.7;
}
/* ============ 网格系统 (模块化网格) ============ */
.frame{flex:1;display:flex;flex-direction:column;min-height:0;overflow:hidden}
.frame.grid-2-7-5,
@@ -594,7 +550,7 @@
body.dark-bg #nav .dot{background:rgba(255,255,255,.32)}
body.dark-bg #nav .dot.active{background:var(--accent)}
#hint{position:fixed;bottom:2.4vh;right:2.5vw;z-index:30;font-family:var(--mono);font-size:14px;letter-spacing:.14em;text-transform:uppercase;opacity:.4;color:var(--ink-tint, currentColor)}
#hint{position:fixed;bottom:2.4vh;right:2.5vw;z-index:30;font-family:var(--mono);font-size:14px;letter-spacing:.14em;text-transform:uppercase;opacity:.4}
body.dark-bg #hint{color:var(--paper);opacity:.4}
body.low-power #hint{color:var(--accent);opacity:.72}
body.dark-bg.low-power #hint{color:var(--paper);opacity:.72}
@@ -616,7 +572,6 @@
body.motion-ready [data-anim="left"]{transform:translateX(-24px)}
body.motion-ready [data-anim="right"]{transform:translateX(24px)}
body.motion-ready [data-anim="line"]{opacity:0;transform:translateY(10px)}
body.motion-ready [data-animate="pipeline"] [data-anim]{opacity:.15}
body.low-power #deck{transition:none!important}
body.low-power *,
body.low-power *::before,
@@ -1176,7 +1131,6 @@
.h-xl-zh{font-size:8vw}
.kpi-hero{font-size:32vw}
.kpi-big{font-size:16vw}
.pipeline{grid-template-columns:repeat(2,1fr)}
.grid-2-7-5,.grid-2-6-6,.grid-2-8-4,.grid-2-4-8{grid-template-columns:1fr}
.grid-12{grid-template-columns:repeat(6,1fr)}
}
@@ -1560,7 +1514,6 @@ addEventListener('keydown',e=>{
}
if(overviewOn)return;
if(e.key==='ArrowRight'||e.key==='PageDown'||e.key===' '||e.key==='ArrowDown'){
if(window.__pipeAdvance && window.__pipeAdvance()) return;
go(idx+1);
return;
}
@@ -1573,12 +1526,8 @@ let wheelTO=null,wheelAcc=0;
addEventListener('wheel',e=>{
wheelAcc+=e.deltaY+e.deltaX;
if(Math.abs(wheelAcc)>50){
if(wheelAcc>0 && window.__pipeAdvance && window.__pipeAdvance()){
wheelAcc=0;
}else{
go(idx+(wheelAcc>0?1:-1));wheelAcc=0;
}
}
clearTimeout(wheelTO);wheelTO=setTimeout(()=>wheelAcc=0,150);
},{passive:true});
@@ -1588,7 +1537,6 @@ addEventListener('touchend',e=>{
const dx=(e.changedTouches[0].clientX-tx);
const dy=(e.changedTouches[0].clientY-ty);
if(Math.abs(dx)>50&&Math.abs(dx)>Math.abs(dy)){
if(dx<0 && window.__pipeAdvance && window.__pipeAdvance()) return;
go(idx+(dx<0?1:-1));
}
},{passive:true});
@@ -1611,7 +1559,6 @@ try {
} catch(e2) {
console.warn('[motion] local + CDN both failed, disabling animations', e1, e2);
document.querySelectorAll('[data-anim]').forEach(el=>{el.style.opacity='1';el.style.transform='none'});
document.querySelectorAll('[data-animate="pipeline"] [data-anim]').forEach(el=>el.style.opacity='1');
}
}
@@ -1623,8 +1570,15 @@ if(motion){
IBM Carbon Motion · 每个 recipe 服务一种表达
不是一刀切的 stagger,而是把动效绑在内容语义上
============================================================ */
const EASE_PROD = [.2, 0, .38, .9];
const EASE_ENTRY_EXP = [0, 0, .3, 1];
/* 缓动从 :root 的 Carbon motion token 读取,CSS 是唯一事实源(硬编码仅作解析失败兜底) */
const cssEase = (name, fallback) => {
const v = getComputedStyle(document.documentElement).getPropertyValue(name);
const m = v && v.match(/cubic-bezier\(([^)]+)\)/);
const nums = m ? m[1].split(',').map(Number) : [];
return (nums.length === 4 && nums.every(Number.isFinite)) ? nums : fallback;
};
const EASE_PROD = cssEase('--ease-prod', [.2, 0, .38, .9]);
const EASE_ENTRY_EXP = cssEase('--ease-entry-exp', [0, 0, .3, 1]);
const slides = [...document.querySelectorAll('.slide')];
let lastIdx = -1;
@@ -2305,7 +2259,6 @@ if(motion){
}
window.__playSlide = playSlide;
window.__pipeAdvance = ()=>false; /* 当前 deck 不用 pipeline recipe */
playSlide(window.__currentSlideIndex || 0);
}
+20 -19
View File
@@ -57,11 +57,12 @@
.slide.hero.light::after{background:linear-gradient(180deg,rgba(var(--paper-rgb),.28) 0%,rgba(var(--paper-rgb),0) 14%,rgba(var(--paper-rgb),0) 86%,rgba(var(--paper-rgb),.28) 100%)}
.slide.hero.dark::after{background:linear-gradient(180deg,rgba(var(--ink-rgb),.32) 0%,rgba(var(--ink-rgb),0) 14%,rgba(var(--ink-rgb),0) 86%,rgba(var(--ink-rgb),.32) 100%)}
/* ============ Magazine chrome:顶部 meta + 底部 foot ============ */
.chrome{display:flex;justify-content:space-between;align-items:flex-start;font-family:var(--mono);font-size:12px;letter-spacing:.18em;text-transform:uppercase;opacity:.7}
/* ============ Magazine chrome:顶部 meta + 底部 foot ============
(v1 布局 + v2 字号在此合并为唯一定义,不要在别处二次定义) */
.chrome{display:flex;justify-content:space-between;align-items:flex-start;font-family:var(--mono);font-size:max(11px,.78vw);letter-spacing:.2em;text-transform:uppercase;opacity:.62}
.chrome .left,.chrome .right{display:flex;gap:2.4em;align-items:center}
.chrome .sep{width:40px;height:1px;background:currentColor;opacity:.4}
.foot{margin-top:auto;display:flex;justify-content:space-between;align-items:flex-end;font-family:var(--mono);font-size:12px;letter-spacing:.14em;text-transform:uppercase;opacity:.55}
.foot{margin-top:auto;display:flex;justify-content:space-between;align-items:flex-end;font-family:var(--mono);font-size:max(11px,.78vw);letter-spacing:.18em;text-transform:uppercase;opacity:.5}
.foot .title{font-family:var(--serif-zh);font-weight:400;letter-spacing:.05em;text-transform:none;opacity:.75;font-size:13px}
.tag{display:inline-block;font-family:var(--mono);font-size:11px;letter-spacing:.24em;text-transform:uppercase;padding:6px 14px;border:1px solid currentColor;opacity:.85}
@@ -81,7 +82,7 @@
.h3-zh{font-family:var(--serif-zh);font-weight:500;font-size:1.9vw;line-height:1.35}
.body-zh{font-family:var(--sans-zh);font-weight:400;font-size:max(15px,1.22vw);line-height:1.75;opacity:.82;letter-spacing:.01em}
.body-serif{font-family:var(--serif-zh);font-weight:400;font-size:max(15px,1.3vw);line-height:1.65;opacity:.88}
.lead{font-family:var(--serif-zh);font-weight:400;font-size:1.9vw;line-height:1.4;opacity:.85}
.lead{font-family:var(--serif-zh);font-weight:400;font-size:1.75vw;line-height:1.5;opacity:.86}
.meta{font-family:var(--mono);font-size:max(11px,.88vw);letter-spacing:.16em;text-transform:uppercase;opacity:.6}
.big-num{font-family:var(--serif-en);font-weight:800;font-size:10vw;line-height:.85;letter-spacing:-.03em;font-feature-settings:"tnum"}
.mid-num{font-family:var(--serif-en);font-weight:700;font-size:5.5vw;line-height:.88;letter-spacing:-.02em;font-feature-settings:"tnum"}
@@ -245,14 +246,7 @@
/* 英文标题专用(Playfair 衬线) */
.h-hero-en,.h-xl-en{font-family:var(--serif-en);letter-spacing:-.025em}
/* ---------- lead 引语 ---------- */
.lead{
font-family:var(--serif-zh);
font-weight:400;
font-size:1.75vw;
line-height:1.5;
opacity:.86;
}
/* ---------- lead 引语:定义见上方“字体规则”区块(已合并,勿重复定义) ---------- */
/* ---------- meta-row 底部元数据 ---------- */
.meta-row{
@@ -387,9 +381,13 @@
/* ---------- 图片 frame-img(layouts.md 主命名) ---------- */
/* 在旧样式里已定义,这里补 img-cap 命名别名与增强 */
figure.frame-img{margin:0;display:flex;flex-direction:column;min-width:0}
/* 有图注时:图片弹性收缩,图注收进固定高度/比例框内部,
否则 img 的 height:100% 会把 figcaption 挤出 overflow:hidden 裁掉 */
figure.frame-img:has(> .img-cap) > img{flex:1 1 auto;min-height:0;height:auto}
.img-cap{
display:block;
margin-top:.8vh;
flex:0 0 auto;
padding:.7vh .9em .9vh;
font-family:var(--mono);
font-size:max(10px,.8vw);
letter-spacing:.22em;
@@ -407,9 +405,7 @@
opacity:.6;
}
/* ---------- chrome & foot 补位(layouts.md 简单写法) ---------- */
.chrome{font-family:var(--mono);font-size:max(11px,.78vw);letter-spacing:.2em;text-transform:uppercase;opacity:.62}
.foot{font-family:var(--mono);font-size:max(11px,.78vw);letter-spacing:.18em;text-transform:uppercase;opacity:.5}
/* ---------- chrome & foot:定义见上方“Magazine chrome”区块(已合并,勿重复定义) ---------- */
/* ============ 动效系统(Motion One 驱动) ============
所有 [data-anim] 元素默认隐藏,进入页面时由 JS 逐个揭示。
@@ -432,6 +428,8 @@
body.low-power *::after{animation:none!important;transition:none!important}
body.low-power.motion-ready [data-anim],
body.low-power [data-anim]{opacity:1!important;transform:none!important}
/* ESC 索引缩略图:克隆的 slide 不经过动效播放,强制显示所有待动画元素 */
#overview [data-anim]{opacity:1!important;transform:none!important}
/* ---------- 响应式降级 ---------- */
@media (max-width:900px){
@@ -730,7 +728,10 @@ addEventListener('touchend',e=>{
}
},{passive:true});
go(0);
/* ?slide=N 直达(1-based),与 Swiss 模板行为一致 */
const initialSlideParam = new URLSearchParams(location.search).get('slide');
const initialSlide = initialSlideParam ? Number(initialSlideParam) - 1 : 0;
go(Number.isFinite(initialSlide) ? initialSlide : 0);
</script>
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
<script>lucide.createIcons();</script>
@@ -850,8 +851,8 @@ if(motion){
window.__playSlide = playSlide;
window.__pipeAdvance = pipeAdvance;
// 首屏:go(0) 已跑过(同步),没来得及触发动效 → 这里补一下
playSlide(0);
// 首屏:go() 已跑过(同步),没来得及触发动效 → 这里补一下(尊重 ?slide=N)
playSlide(window.__currentSlideIndex || 0);
}
</script>
</body>
+96
View File
@@ -0,0 +1,96 @@
# 多主题统一管理整合方案
> 基于对 template.html / template-swiss.html 两份模板与全部 references 的逐行对比分析(2026-07)。
> 本文档是**方案建议**,尚未实施;按阶段推进,每个阶段独立可交付、可回滚。
## 现状诊断
两个主题是"复制粘贴后各自演化"的关系,同一份基础设施存在多份副本,并已实际漂移:
### 运行时 JS 漂移(约 250 行 × 每主题一份)
| 模块 | A 杂志风 | B 瑞士风 |
|---|---|---|
| 低功耗事件名 | `ppt-low-power-change` | `swiss-low-power-change` |
| localStorage key | `guizang-ppt-low-power`(共用!) | 同左 |
| ESC 索引缩略图可见性修复 | ✅(本次补齐) | ✅ |
| `?slide=N` 直达参数 | ✅(本次补齐) | ✅ |
| Windows 字重补偿 `is-win` | 缺失 | ✅ |
| prefers-reduced-motion 自动降级 | ✅(本次补齐) | ✅ |
| 翻页/滚轮/触屏/键盘核心 | 两份逐字符几乎相同 | 同左 |
### Token 体系成熟度不一
- **B 最成熟**:主题色 + Carbon 文字角色 token + `--sp-3~13` 8px 间距阶梯 + motion token + `--nav-safe-bottom`
- **A 最简陋**:只有 6 个颜色变量,层级靠 opacity 表达,存在 10-11px 小字(投屏不可读),`.lead/.chrome/.foot` 在模板内部重复定义(v1 与 v2 API 叠加)
### 已知具体坏味道(可立即修的小项)
1. B 模板 `#hint` 引用了未定义变量 `--ink-tint`(靠 fallback 兜底)
2. B 模板 pipeline 死代码:`__pipeAdvance` 恒返 false,但 43 行 pipeline CSS 和动效 CSS 还在
3. B 的 motion token 双维护:CSS 定义了 `--ease-prod`,JS 里又硬编码 `EASE_PROD=[.2,0,.38,.9]`
4. A 是唯一没有校验器的主题,但 validate-swiss-deck.mjs 的标题选择器已经包含 A 的类名(`.display/.h1-zh` 等),说明测量层本来就通用
## 整合原则
**不建议**把两个模板合并成一个"大一统模板"——两种风格的美学规则互斥(直角 vs 圆角、衬线 vs 无衬线),强行合并会让类名前缀和条件分支爆炸,也违背"每份 deck 单文件交付"的定位。
**建议**的整合层次:**共享的是"基建"和"契约",不共享"皮肤"**。
## 分阶段方案
### Phase 1 · 共享运行时(收益最大)
把各主题漂移的 JS 收敛为一份 `assets/runtime.js`(构建时内联进各模板,保持单文件交付):
- 翻页核心 go() / 键盘 / 滚轮 / 触屏 / nav 圆点 / ESC 索引(含缩略图可见性修复)
- 低功耗 IIFE:统一事件名为 `deck-low-power-change`,统一 localStorage key,保留各主题旧事件名一个版本作为 alias
- `?slide=N`、prefers-reduced-motion、Motion One 加载器
- 主题差异通过配置对象注入:`initDeck({ darkClass:'dark', onSlideChange(){...} })`
实施方式二选一:
- **a. 构建脚本**(推荐):`scripts/build-templates.mjs` 把 `src/runtime.js` + 各主题 `src/style-*.css` + 皮肤 HTML 组装成各 template*.html。模板仍是完整单文件,但源头唯一。
- **b. 文档约定**:不引入构建,把 runtime 段落用 `<!-- SHARED-RUNTIME v3 -->` 注释标记,改动时用脚本校验各份一致。成本低但漂移风险仍在。
### Phase 2 · 公共 Token 契约
在各模板间统一**命名和语义**(值可以不同):
- 间距:全部采用 B 的 `--sp-*` 8px 阶梯(A 补齐)
- 文字角色:`--text-primary/secondary/helper/on-color` + `--border-subtle/strong` 各主题同名(A 需要从 opacity 方案迁移)
- 安全区:`--nav-safe-bottom` + `.nav-safe-bottom(-tight)` 各主题同名(已完成:B 有,A 待补)
- Motion:`--ease-*/--dur-*` 统一命名,JS 从 CSS var 读取,消除双维护
- 最小字号地板:A 对齐 B 的"正文 ≥16px、caption ≥14px"投屏标准(A 现存 10-11px 小字需要清理)
### Phase 3 · 校验器公共核
抽 `scripts/lib/validate-core.mjs`:
- slide 解析、Playwright 加载(双根解析 + try/finally + swiftshader)、isMeaningful 过滤器、M1 溢出/底部空白/nav 安全线、M2 标题间距、overflowFix 分级建议
- 各主题瘦身为 config + 专属静态规则:`validate-swiss-deck.mjs`(版式锁定)、**新增 `validate-magazine-deck.mjs`**(A 目前裸奔,公共核直接就能给它 M1/M2 测量)
### Phase 4 · References 去重
- `themes*.md` 各份结构完全同构 → 统一"主题卡片 schema",每主题只留数据
- 图片规则目前散在 4+ 处且 A/B 规则有分叉(A:信息图必须 fit-contain;B:重生成图禁止 fit-contain)→ 合并为单一来源 `references/images.md`,分叉处显式按主题参数化
- 动效说明多处重复 → 收敛到各 layouts 文件,组件文档只放链接
- 清理 golden source 绝对路径:改为相对于 `<SKILL_ROOT>` 的路径或删除
### 顺手修复清单(可与任一阶段同批)
- [ ] A:`.lead/.chrome/.foot` 双定义合并
- [ ] A:补齐 `--nav-safe-bottom` 安全区 token
- [ ] B:删 pipeline 死 CSS 或恢复 `__pipeAdvance`
- [ ] B:`--ink-tint` 未定义引用
- [ ] B:JS 缓动改读 CSS var
- [ ] 文档:去除 `/Users/guohao/...` 绝对路径(已完成)
- [ ] 各模板共有:`file://` 直开时 module 动态 import 本地 `./assets/motion.min.js` 被 Chrome CORS 拦截,实际总是走 jsDelivr CDN;离线 + 直开的组合只能拿到静态降级。共享 runtime 时考虑把 motion 关键函数直接内联进模板,或文档明确"离线演示请起本地 server"
## 建议排序
1. **Phase 1a(构建脚本 + 共享 runtime)** —— 一次性消灭最大的漂移面
2. **顺手修复清单** —— 全部是小改动,跟 Phase 1 同一个 PR
3. **Phase 3(校验器公共核 + A 校验器)** —— A 从此也有质量护栏
4. Phase 2、4 可以慢慢来,不阻塞新主题
完成 Phase 1+3 后,再增加"新主题"的边际成本会从"复制 2000 行再改"降到"写一份皮肤 CSS + 一份 layouts 文档 + 一个 validator config"。
+33 -1
View File
@@ -173,6 +173,13 @@ node <SKILL_ROOT>/scripts/validate-swiss-deck.mjs path/to/index.html
**根因**:瑞士风的图片不是装饰,而是 grid 里的证据块。没有先选原始版式和图片槽位,就会把任意图片硬塞进页面。
**先判断图像角色**:
- 证据截图、UI、代码、dashboard:保真优先,关键文字和数据不能裁;需要统一比例时先做截图背景画布和 `.fit-contain`。
- 已按槽位生成的信息图/插图:按 S22/S15/S16 的目标比例铺满,不要再缩成短小图片。
- 照片/产品图/人物图:必须写清 `object-position`,主体不能被裁切、标题块或 caption 压住。
- 文字压图:必须先判断是否有足够 quiet zone;没有低细节留白就不要把标题压在图上。
- 多图组:统一比例、高度、容器样式和 caption 密度;视觉角色不同的图不要硬放同一组。
**做法**:
- 先选版式:单张大图 + KPI 用 `S22`;多图用 `S15/S16` 的原始网格骨架改造
- S22 生成图比例固定 `21:9`,并在 `<img>` 上写 `data-image-slot="s22-hero-21x9"`
@@ -184,6 +191,8 @@ node <SKILL_ROOT>/scripts/validate-swiss-deck.mjs path/to/index.html
- 截图背景必须跟随当前主题色,且可裁成 `21:9` / `16:10` / `4:3` / `1:1`;背景里不能有标题、页脚、边框、logo、人物或明显主体
- GPT-M 2.0 提示词必须写明:Swiss Style、单一 accent、直角、无渐变/阴影/圆角、无页眉页脚标题角标
- 文字压图 / 全屏主视觉必须先做 quiet-zone 判断:至少约 30% 低细节区域可承载标题;不通过就换图、换裁切或改成图文分栏,不要整页套黑色/白色遮罩
**自检命令**:
- `grep -E "frame-img.*border-radius|box-shadow" index.html`——命中就删
- `grep -n "data-image-slot" index.html`——每张本地图片都应有槽位声明
@@ -206,6 +215,29 @@ node <SKILL_ROOT>/scripts/validate-swiss-deck.mjs path/to/index.html
- 视觉:翻到该页,看最后一行 caption/label 是否明显高于分页组件
- 代码:`grep -E "align-items:end|align-self:end|bottom:0|bottom:2vh|margin-top:auto" index.html`,命中后逐个确认是否有 nav safe zone
### 0-D-3. 后验测量:先量超出和空白,再改版式
**现象**:一页只超出 20-30px,但修的时候删掉大块内容,结果下方多出一大片空白;或者标题与正文贴在一起,肉眼检查时容易漏。
**做法**:
- 生成后运行 `node <SKILL_ROOT>/scripts/validate-swiss-deck.mjs path/to/index.html`
- 如果环境里能解析到 Playwright,校验器会额外执行真实渲染测量:
- `M1 DOM/visual overflow`:量出超出多少 px,并指出最低/最高的问题元素
- `M1 bottom whitespace`:量出底部空白和 active content height,防止从"超出"修成"巨空"
- `M1 nav-safe`:量出最低内容是否进入底部分页安全线
- `M2 title gap`:量出标题到下一块内容的距离,防止标题和正文贴住
**Overflow 修正阶梯**:
- `1-40px` over:只做微调,上移内容组或收紧一个 gap/padding;不要删内容。
- `40-90px` over:局部压缩 gap/padding 或降低一个模块高度;仍然优先保留内容。
- `90-160px` over:轻微压标题或压缩一段正文,再考虑拆页。
- `160px+` over:才考虑换更高容量版式、合并模块或删内容。
**修完反查**:
- 如果 `M1 bottom whitespace` 变大,说明修过头了;恢复部分间距、放大最后一块或把内容组向下回调。
- 每轮只做一个档位的调整,重渲染后再跑 validator。
- 不要靠多模态肉眼先猜;超出、底部空白和标题间距先看测量值。
---
### 0-E. Swiss 模板还原度守卫:原始 PPT 是 golden source
@@ -215,7 +247,7 @@ node <SKILL_ROOT>/scripts/validate-swiss-deck.mjs path/to/index.html
**根因**:把新增图片版式或实验结构写成了全局样式修改,或无意改动了原始基座类,例如 `.h-hero` / `.h-xl` 字重、`.tl-node` 列宽、`.duo-compare` 间距。
**做法**:
- 原始参考文件 `/Users/guohao/Documents/op7418的仓库/项目/Thin-Harness-Fat-Skills/ppt/index.html` 是 Swiss 主题的 golden source,但要以**实际页面用法**为准,不要只看未使用的 CSS helper
- 仓库内的 `assets/template-swiss.html` 是 Swiss 主题的 golden source 快照,但要以**实际页面用法**为准,不要只看未使用的 CSS helper
- 原始页面的大标题大量使用 `font-weight:200`,强调词/数字用 `300`;`.h-hero` / `.h-xl` / `.h-hero-zh` / `.h-xl-zh` 在本模板里必须保持轻字重,不要恢复成 800/900
- 除新增封面/封底 ASCII 机制、S22 图片槽位修复、横向时间线 label 居中修复、以及把标题 helper 校正为实际轻字重外,不要改动原始基座 CSS/JS recipe
- 新增图片能力必须绑定到 S22/S15/S16 原始槽位,不要发明新正文结构
+2 -4
View File
@@ -8,9 +8,7 @@
## Swiss locked mode(必须先读)
本主题的 golden source 是:
`/Users/guohao/Documents/op7418的仓库/项目/Thin-Harness-Fat-Skills/ppt/index.html`
本主题的 golden source 是仓库内的 `assets/template-swiss.html`(由作者本机的原始参考 PPT 派生;原始文件不随仓库分发,`swiss-layout-lock.md` 登记的 S01-S22 即其版式快照)。
生成正文页时不要把 Swiss 当成“自由组合的风格包”。默认只能使用 `references/swiss-layout-lock.md` 登记的 `S01-S22`。每个 slide 都必须在 `<section>` 上写 `data-layout="Sxx"`。
@@ -203,7 +201,7 @@ Swiss 主题有 22 个登记版式,生成时要主动展示版式系统,不要
不要只看 HTML/CSS。Swiss 模板的还原度要同时从**浏览器视觉**和**代码结构**判断:
1. 同时打开三份页面:原始参考 PPT、当前 `template-swiss.html` 或生成页、正在修改的测试 PPT。原始参考路径是 `/Users/guohao/Documents/op7418的仓库/项目/Thin-Harness-Fat-Skills/ppt/index.html`。
1. 同时打开两份页面:当前 `template-swiss.html`(golden source 快照)和正在修改的测试 PPT;有条件时再加一份此前验收过的成品 deck 作对照。
2. 截图前先等入场动效稳定(约 1-2 秒)。不要把动画中间态误判成"内容缺失"或"版式空白"。
3. 先看视觉:标题重量、头部距离、图片落位、底部安全区、caption 是否被 nav 挡住。
4. 对照原始参考 PPT 的同类版式,不要只对照 CSS helper;以实际页面结构和视觉结果为准。
+8 -6
View File
@@ -230,12 +230,12 @@ layouts.md 使用的所有类(`h-hero` / `h-xl` / `h-sub` / `h-md` / `lead` /
<div>过去 64 天 · 开发篇</div>
<div>Act I / Dev · 02 / 25</div>
</div>
<div class="frame" style="padding-top:6vh">
<div class="frame" style="padding-top:3vh">
<div class="kicker" data-anim>一个人,做了什么。</div>
<h2 class="h-xl" data-anim>过去 64 天</h2>
<p class="lead" style="margin-bottom:5vh" data-anim>从 0 到开源 CodePilot。</p>
<p class="lead" style="margin-bottom:2vh" data-anim>从 0 到开源 CodePilot。</p>
<div class="grid-6" style="margin-top:6vh">
<div class="grid-6" style="margin-top:2vh">
<div class="stat-card" data-anim>
<div class="stat-label">Duration</div>
<div class="stat-nb">64 <span class="stat-unit">天</span></div>
@@ -279,7 +279,7 @@ layouts.md 使用的所有类(`h-hero` / `h-xl` / `h-sub` / `h-md` / `lead` /
- 3×2 或 4×2 网格最稳(见 `.grid-6`)
- 每个 `stat-card` 结构固定:label(英文小字)→ nb(大字数字)→ note(注释)
- 数字建议 2-3 位字符(太长会溢出),用 K / M 简写
- 留 5vh 以上的上方缓冲,让标题区先抢眼球
- **间距不要再加大**:骨架默认 `padding-top:3vh` + lead `margin-bottom:2vh` + grid `margin-top:2vh` 是 3×2 网格在 16:9 屏不压 foot 的实测上限;内容更多时先删卡片,不要压缩 foot 空间
---
@@ -338,11 +338,11 @@ layouts.md 使用的所有类(`h-hero` / `h-xl` / `h-sub` / `h-md` / `lead` /
<div>平台粉丝实证</div>
<div>Act I / Ops · 05 / 27</div>
</div>
<div class="frame" style="padding-top:5vh">
<div class="frame" style="padding-top:3vh">
<div class="kicker" data-anim>Proof · 粉丝实证</div>
<h2 class="h-xl" data-anim>10 个平台 · 6 张截图</h2>
<div class="grid-3-3" style="margin-top:4vh">
<div class="grid-3-3" style="margin-top:3vh">
<figure class="frame-img" style="height:26vh" data-anim>
<img src="images/weibo.png" alt="微博 289K">
<figcaption class="img-cap">微博 · 289K</figcaption>
@@ -379,7 +379,9 @@ layouts.md 使用的所有类(`h-hero` / `h-xl` / `h-sub` / `h-md` / `lead` /
**要点**:
- 关键:每个 `frame-img` 必须写死 `height:NNvh`(不要用 `aspect-ratio`),否则网格会撑破
- 图片会自动 `object-fit:cover + object-position:top`,只裁底部
- **图注在框内**:`figcaption.img-cap` 会显示在固定高度框的内部底边(模板已处理弹性收缩),不额外占外部高度;有图注时图片实际展示高度 ≈ NNvh − 4vh
- 用 `.grid-3-3`(3×2)或 `.grid-3`(3×1)承载
- 3×2 双行 + 图注时,`height:26vh` 是不压 foot 的上限;标题更长或加说明行时降到 `22vh`
---
+1 -3
View File
@@ -4,9 +4,7 @@
## Golden Source
原始参考文件:
`/Users/guohao/Documents/op7418的仓库/项目/Thin-Harness-Fat-Skills/ppt/index.html`
版式基准是仓库内的 `assets/template-swiss.html`(由作者原始参考 PPT 派生;原始文件不随仓库分发,本文件登记的 S01-S22 即其版式快照)。
瑞士主题生成时,除用户明确要求实验版式外,只能从下面登记的 22 个版式中选择。新增首页/尾页可以使用 Skill 里的 IKB ASCII 版本,但正文页必须来自这 22 个版式。
+201
View File
@@ -1,5 +1,8 @@
#!/usr/bin/env node
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { createRequire } from 'node:module';
import { pathToFileURL } from 'node:url';
const file = process.argv[2];
const allowExperimental = process.argv.includes('--allow-experimental');
@@ -14,6 +17,31 @@ const htmlForSlides = html.replace(/<!--[\s\S]*?-->/g, '');
const errors = [];
const warnings = [];
function overflowFix(px) {
const n = Math.round(px);
if (n <= 40) return `only ${n}px over: nudge content up or tighten one gap/padding by 20-40px; do not delete content`;
if (n <= 90) return `${n}px over: compact local gaps/padding and reduce one block height; avoid cutting copy`;
if (n <= 160) return `${n}px over: reduce a display title slightly or compress one paragraph before deleting content`;
return `${n}px over: switch to a higher-capacity layout or remove/merge content intentionally`;
}
async function loadPlaywright() {
const candidates = [
createRequire(import.meta.url),
createRequire(pathToFileURL(path.join(process.cwd(), 'package.json')).href),
];
for (const req of candidates) {
try {
const resolved = req.resolve('playwright');
const mod = await import(pathToFileURL(resolved).href);
return mod.default || mod;
} catch {
// Try the next resolution root.
}
}
return null;
}
const allowedLayouts = new Set([
'SWISS-COVER-ASCII',
'SWISS-CLOSING-ASCII',
@@ -96,6 +124,179 @@ slides.forEach((slide) => {
}
});
async function runRenderedMeasurements() {
const playwright = await loadPlaywright();
if (!playwright?.chromium) {
warnings.push('Rendered measurement skipped: Playwright is not resolvable from the skill folder or current project. Static Swiss checks still ran.');
return;
}
const browser = await playwright.chromium.launch({
args: ['--use-angle=swiftshader', '--enable-unsafe-swiftshader'],
});
try {
const ctx = await browser.newContext({
viewport: { width: 1600, height: 900 },
deviceScaleFactor: 1,
});
const page = await ctx.newPage();
await page.goto(pathToFileURL(path.resolve(file)).href, { waitUntil: 'domcontentloaded' });
await Promise.race([
page.evaluate(() => document.fonts && document.fonts.ready),
page.waitForTimeout(1800),
]);
await page.waitForTimeout(800);
const measures = await page.$$eval('section.slide', (els) => {
const TRANSPARENT = /rgba?\(\s*0\s*,\s*0\s*,\s*0\s*,\s*0?\s*\)|transparent/;
const titleSelector = [
'.h-hero', '.h-hero-zh', '.h-xl', '.h-xl-zh', '.h-statement',
'.display', '.display-zh', '.h1-zh', '.h2-zh', '.h-md',
'.step-title', 'h1', 'h2', 'h3',
].join(',');
const hasDirectText = (n) => {
for (const c of n.childNodes) {
if (c.nodeType === 3 && c.textContent.trim().length > 0) return true;
}
return false;
};
const colorVisible = (c) => c && !TRANSPARENT.test(c);
const labelFor = (n) => {
const cls = n.className ? '.' + String(n.className).split(' ').filter(Boolean).join('.') : n.tagName.toLowerCase();
const text = n.textContent.trim().replace(/\s+/g, ' ').slice(0, 40);
return text ? `${cls} "${text}"` : cls;
};
const isMeaningful = (el, n) => {
const er = el.getBoundingClientRect();
const posterArea = er.width * er.height;
const tag = n.tagName;
const cs = getComputedStyle(n);
const r = n.getBoundingClientRect();
if (r.width < 6 || r.height < 6) return false;
if (n === el || n.classList.contains('canvas-card')) return false;
if (cs.position === 'fixed') return false;
if (cs.position === 'absolute' && r.width * r.height >= posterArea * 0.82) return false;
if (n.matches('canvas.ascii-bg, canvas.mag-bg, .grain, .dot-mat, .ring-mat, .cross-mat')) return false;
const isText = hasDirectText(n);
const isMedia = tag === 'IMG' || tag === 'CANVAS' || tag === 'SVG';
const isRule = tag === 'HR' || (r.height <= 4 && (
parseFloat(cs.borderTopWidth) >= 1 ||
parseFloat(cs.borderBottomWidth) >= 1 ||
colorVisible(cs.backgroundColor)
));
const hasFill = colorVisible(cs.backgroundColor) && r.width * r.height >= 1600 && !['MAIN', 'SECTION'].includes(tag);
const hasBorder = (parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth) +
parseFloat(cs.borderLeftWidth) + parseFloat(cs.borderRightWidth)) >= 1 && r.width * r.height >= 1600;
return isText || isMedia || isRule || hasFill || hasBorder;
};
const titleGapChecks = (el, nodes) => {
const titles = Array.from(el.querySelectorAll(titleSelector)).filter((n) => n.textContent.trim());
const out = [];
for (const title of titles) {
const tr = title.getBoundingClientRect();
const isLocal = title.matches('.h-md, .step-title, h3');
const minGap = isLocal ? 14 : 32;
let nearest = null;
let nearestGap = Infinity;
for (const node of nodes) {
if (node === title || title.contains(node) || node.contains(title)) continue;
const nr = node.getBoundingClientRect();
const gap = nr.top - tr.bottom;
if (gap < -2) continue;
const overlap = Math.max(0, Math.min(tr.right, nr.right) - Math.max(tr.left, nr.left));
const overlapRatio = overlap / Math.min(tr.width, nr.width);
if (overlapRatio < 0.12 && gap < 96) continue;
if (gap < nearestGap) {
nearestGap = gap;
nearest = node;
}
}
if (nearest && nearestGap < minGap) {
out.push({ title: labelFor(title), next: labelFor(nearest), gap: Math.round(nearestGap), minGap });
}
}
return out;
};
return els.map((el, index) => {
const er = el.getBoundingClientRect();
const H = el.clientHeight;
const W = el.clientWidth;
const nodes = Array.from(el.querySelectorAll('*')).filter((n) => isMeaningful(el, n));
let top = Infinity;
let bottom = -Infinity;
let topNode = null;
let bottomNode = null;
for (const n of nodes) {
const r = n.getBoundingClientRect();
const itemTop = r.top - er.top;
const itemBottom = r.bottom - er.top;
if (itemTop < top) {
top = itemTop;
topNode = n;
}
if (itemBottom > bottom) {
bottom = itemBottom;
bottomNode = n;
}
}
if (!nodes.length) {
top = 0;
bottom = 0;
}
const safeBottom = Math.round(H * 0.93);
return {
idx: index + 1,
id: el.id || '',
layout: el.dataset.layout || '',
width: W,
height: H,
scrollOverflow: Math.max(0, Math.round(el.scrollHeight - H)),
visual: {
top: Math.round(top),
bottom: Math.round(bottom),
activeRatio: H ? (bottom - top) / H : 0,
bottomGap: Math.max(0, Math.round(H - bottom)),
topOverflow: Math.max(0, Math.round(-top)),
bottomOverflow: Math.max(0, Math.round(bottom - H)),
bottomNode: bottomNode ? labelFor(bottomNode) : '',
topNode: topNode ? labelFor(topNode) : '',
safeBottom,
},
titleGaps: titleGapChecks(el, nodes),
};
});
});
for (const m of measures) {
const prefix = `Slide ${m.idx}${m.layout ? ` (${m.layout})` : ''}`;
if (m.scrollOverflow > 4) {
errors.push(`${prefix}: M1 DOM overflow ${m.scrollOverflow}px. ${overflowFix(m.scrollOverflow)}.`);
}
if (m.visual.bottomOverflow > 4) {
errors.push(`${prefix}: M1 visual bottom overflow ${m.visual.bottomOverflow}px; lowest element is ${m.visual.bottomNode}. ${overflowFix(m.visual.bottomOverflow)}.`);
}
if (m.visual.topOverflow > 4) {
errors.push(`${prefix}: M1 visual top overflow ${m.visual.topOverflow}px; highest element is ${m.visual.topNode}. Move content down by the measured overflow plus 16-24px.`);
}
if (m.visual.bottom > m.visual.safeBottom) {
warnings.push(`${prefix}: M1 content reaches ${Math.round(m.visual.bottom)}px; nav-safe line is ${m.visual.safeBottom}px. Lift the lowest block or add .nav-safe-bottom.`);
}
if (m.visual.bottomGap > 170 && m.visual.activeRatio < 0.74) {
warnings.push(`${prefix}: M1 bottom whitespace ${m.visual.bottomGap}px; active content height ${Math.round(m.visual.activeRatio * 100)}%. Restore spacing/content instead of over-correcting overflow.`);
}
for (const gap of m.titleGaps) {
warnings.push(`${prefix}: M2 ${gap.title} has ${gap.gap}px gap before ${gap.next} (min ${gap.minGap}px).`);
}
}
} finally {
await browser.close();
}
}
await runRenderedMeasurements();
if (warnings.length) {
console.warn('Warnings:');
for (const warning of warnings) console.warn(`- ${warning}`);