diff --git a/README.md b/README.md index b152ed2..2e79217 100644 --- a/README.md +++ b/README.md @@ -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 瑞士风校验命令: diff --git a/SKILL.md b/SKILL.md index 1ecd51b..343e6c5 100644 --- a/SKILL.md +++ b/SKILL.md @@ -47,6 +47,23 @@ description: 生成横向翻页网页 PPT(单 HTML 文件),含 WebGL 背 ## 工作流 +### Step 0 · 启动前检查更新(必做) + +每次启动本 Skill 前,先在 Skill 根目录检查 GitHub 上游是否有更新;有更新时先问用户是否要更新,用户确认后再执行更新,然后继续后续流程。 + +```bash +git -C "" fetch --quiet +git -C "" rev-list --count HEAD..@{u} +``` + +如果返回值大于 `0`,告诉用户检测到上游更新数量,询问是否先执行: + +```bash +git -C "" pull --ff-only +``` + +不要自动更新。用户拒绝时继续使用当前版本;如果网络不可用、没有 upstream 或不是 git 仓库,说明无法检查更新并继续流程。 + ### Step 1 · 需求澄清(**动手前必做**) **如果用户已经给了完整的大纲 + 图片/截图处理要求**,可以跳过直接进 Step 2。 @@ -316,7 +333,7 @@ cp "/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 /scripts/validate-swiss-deck.mjs index.html`。 +- 开写 HTML 前先列一张 `页码 → data-layout → 选用理由 → 图片槽位` 草稿;交付前运行 `node /scripts/validate-swiss-deck.mjs index.html`。校验器会先做静态结构检查;如果环境中能解析到 Playwright,还会做真实渲染后的可见边界、底部空白、nav 安全线和标题间距测量。 #### 3.2 · 图片比例规范 @@ -345,6 +362,18 @@ cp "/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 "/assets/template-swiss.html" "项目/XXX/ppt/index.html" 生成完一定要打开 `references/checklist.md`,逐项对照。里面总结了**真实迭代过程中踩过的所有坑**,P0 级别的问题(emoji、图片撑破、标题换行、字体分工)必须全部通过。 +#### 4.0.1 · 先量后改:超出 / 空白 / 标题间距 + +当一页内容超出或显得巨空时,不要先凭感觉大幅删改。先运行: + +```bash +node /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. 再看代码:确认该页选用的版式与内容形状匹配,没有把数据专用版式拿来讲概念,也没有把可选组件堆成装饰。 diff --git a/assets/ppt-skill-showcase.png b/assets/ppt-skill-showcase.png new file mode 100644 index 0000000..196eb62 Binary files /dev/null and b/assets/ppt-skill-showcase.png differ diff --git a/assets/template-swiss.html b/assets/template-swiss.html index bce57fe..135dad8 100644 --- a/assets/template-swiss.html +++ b/assets/template-swiss.html @@ -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,11 +1526,7 @@ 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; - } + 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); } diff --git a/assets/template.html b/assets/template.html index 3140850..2f02bf2 100644 --- a/assets/template.html +++ b/assets/template.html @@ -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); @@ -850,8 +851,8 @@ if(motion){ window.__playSlide = playSlide; window.__pipeAdvance = pipeAdvance; - // 首屏:go(0) 已跑过(同步),没来得及触发动效 → 这里补一下 - playSlide(0); + // 首屏:go() 已跑过(同步),没来得及触发动效 → 这里补一下(尊重 ?slide=N) + playSlide(window.__currentSlideIndex || 0); } diff --git a/docs/unification-plan.md b/docs/unification-plan.md new file mode 100644 index 0000000..6a1aeed --- /dev/null +++ b/docs/unification-plan.md @@ -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 段落用 `` 注释标记,改动时用脚本校验各份一致。成本低但漂移风险仍在。 + +### 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 绝对路径:改为相对于 `` 的路径或删除 + +### 顺手修复清单(可与任一阶段同批) + +- [ ] 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"。 diff --git a/references/checklist.md b/references/checklist.md index 611d59a..324c4a1 100644 --- a/references/checklist.md +++ b/references/checklist.md @@ -173,6 +173,13 @@ node /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`,并在 `` 上写 `data-image-slot="s22-hero-21x9"` @@ -184,6 +191,8 @@ node /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 /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 /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 /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 原始槽位,不要发明新正文结构 diff --git a/references/layouts-swiss.md b/references/layouts-swiss.md index 3e13910..96942bc 100644 --- a/references/layouts-swiss.md +++ b/references/layouts-swiss.md @@ -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 都必须在 `
` 上写 `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;以实际页面结构和视觉结果为准。 diff --git a/references/layouts.md b/references/layouts.md index 35925df..0e661d7 100644 --- a/references/layouts.md +++ b/references/layouts.md @@ -230,12 +230,12 @@ layouts.md 使用的所有类(`h-hero` / `h-xl` / `h-sub` / `h-md` / `lead` /
过去 64 天 · 开发篇
Act I / Dev · 02 / 25
-
+
一个人,做了什么。

过去 64 天

-

从 0 到开源 CodePilot。

+

从 0 到开源 CodePilot。

-
+
Duration
64 天
@@ -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` /
平台粉丝实证
Act I / Ops · 05 / 27
-
+
Proof · 粉丝实证

10 个平台 · 6 张截图

-
+
微博 289K
微博 · 289K
@@ -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` --- diff --git a/references/swiss-layout-lock.md b/references/swiss-layout-lock.md index 134991a..ae9fb35 100644 --- a/references/swiss-layout-lock.md +++ b/references/swiss-layout-lock.md @@ -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 个版式。 diff --git a/scripts/validate-swiss-deck.mjs b/scripts/validate-swiss-deck.mjs index c378476..df5de5e 100644 --- a/scripts/validate-swiss-deck.mjs +++ b/scripts/validate-swiss-deck.mjs @@ -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(//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}`);