op7418/guizang-ppt-skill실행 전 동작 확인
SKILL DETAIL
guizang-ppt-skill
op7418/guizang-ppt-skill/guizang-ppt-skill
生成横向翻页网页 PPT(单 HTML 文件),含 WebGL 背景、演讲者视图、观众屏同步、讲稿备注、章节幕封、数据大字报、图片网格等模板。提供两种风格:① "电子杂志 × 电子墨水"(衬线 + 流体背景 + 暖色) ② "瑞士国际主义"(无衬线 + 网格点阵 + IKB/柠檬黄/柠檬绿/安全橙高亮)。当用户需要制作分享 / 演讲 / 发布会风格的网页 PPT,或提到"杂志风 PPT"、"瑞士风 PPT"、"Swiss Style"、"horizontal swipe deck"时使用。
설치 수 · 86출처 보기
Installation
npx skills add https://github.com/op7418/guizang-ppt-skill --skill guizang-ppt-skill
스킬 파일
SKILL.md
최근 동기화 · 2026. 8. 30.
.github/ISSUE_TEMPLATE/bug_report.yml›
name: Bug report
description: Report a reproducible layout, rendering, or workflow bug.
title: "bug: "
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Thanks for reporting a bug. Screenshots and the generated HTML are the fastest way to make this actionable.
- type: textarea
id: summary
attributes:
label: What happened?
description: Describe the issue clearly.
placeholder: "The footer overlaps the final card on slide 6..."
validations:
required: true
- type: textarea
id: expected
attributes:
label: What did you expect?
placeholder: "The content should stay above the footer."
validations:
required: true
- type: textarea
id: reproduce
attributes:
label: How can we reproduce it?
description: Include the prompt, source document, generated deck, or a minimal example.
placeholder: |
1. Use this prompt...
2. Generate a Swiss style deck...
3. Open slide 6...
validations:
required: true
- type: textarea
id: screenshots
attributes:
label: Screenshots or files
description: Drag screenshots or attach the generated index.html if possible.
- type: input
id: environment
attributes:
label: Environment
placeholder: "Claude Code / Codex / Cursor, macOS 15, Chrome 125..."
- type: dropdown
id: theme
attributes:
label: Theme
options:
- Style A · Editorial magazine
- Style B · Swiss international
- Both / unsure
validations:
required: true
.github/ISSUE_TEMPLATE/config.yml›
blank_issues_enabled: true
contact_links:
- name: Read the README
url: https://github.com/op7418/guizang-ppt-skill#readme
about: Start here for installation and usage.
- name: English README
url: https://github.com/op7418/guizang-ppt-skill/blob/main/README.en.md
about: English installation and usage notes.
.github/ISSUE_TEMPLATE/feature_request.yml›
name: Feature request
description: Suggest a new layout, workflow, platform, or capability.
title: "feat: "
labels: ["enhancement"]
body:
- type: textarea
id: problem
attributes:
label: What problem would this solve?
description: Please describe the real deck-making situation behind the request.
placeholder: "When making product benchmark decks, I often need..."
validations:
required: true
- type: textarea
id: proposal
attributes:
label: What do you want the Skill to do?
placeholder: "Add a layout for..."
validations:
required: true
- type: textarea
id: examples
attributes:
label: Examples or references
description: Screenshots, links, rough sketches, or deck examples are welcome.
- type: dropdown
id: area
attributes:
label: Area
options:
- Layout / template
- Image generation workflow
- Data visualization
- Installation / packaging
- Documentation
- Runtime interaction
- Other
validations:
required: true
.github/ISSUE_TEMPLATE/question.yml›
name: Question
description: Ask about installation, usage, templates, or generated examples.
title: "question: "
labels: ["question"]
body:
- type: textarea
id: question
attributes:
label: Question
placeholder: "How should I..."
validations:
required: true
- type: textarea
id: context
attributes:
label: Context
description: What have you tried? Which agent and theme are you using?
.github/pull_request_template.md›
## Summary
-
## What Changed
-
## Visual QA
Please attach screenshots for visual changes.
- [ ] Checked at least one dense text slide
- [ ] Checked at least one image slide
- [ ] Checked navigation / ESC overview / low-power mode when relevant
## Validation
- [ ] `node scripts/validate-swiss-deck.mjs path/to/index.html`
- [ ] Manual browser review
## Notes
Any tradeoffs, limitations, or follow-up work:
.github/workflows/presenter-runtime-sync.yml›
name: Presenter runtime sync
on:
pull_request:
paths:
- "assets/template*.html"
- "scripts/check-presenter-runtime-sync.mjs"
- ".github/workflows/presenter-runtime-sync.yml"
push:
branches: [main]
paths:
- "assets/template*.html"
- "scripts/check-presenter-runtime-sync.mjs"
- ".github/workflows/presenter-runtime-sync.yml"
jobs:
presenter-runtime-sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: node scripts/check-presenter-runtime-sync.mjs
.gitignore›
.DS_Store
*.backup
assets/motion.min.js›
/**
* Bundled by jsDelivr using Rollup v2.79.2 and Terser v5.39.0.
* Original file: /npm/[email protected]/dist/es/motion/lib/index.mjs
*
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
*/
function t(t,e){const n=t.indexOf(e);n>-1&&t.splice(n,1)}class e{constructor(){this.subscriptions=[]}add(e){var n,s;return n=this.subscriptions,s=e,-1===n.indexOf(s)&&n.push(s),()=>t(this.subscriptions,e)}notify(t,e,n){const s=this.subscriptions.length;if(s)if(1===s)this.subscriptions[0](t,e,n);else for(let i=0;i<s;i++){const s=this.subscriptions[i];s&&s(t,e,n)}}getSize(){return this.subscriptions.length}clear(){this.subscriptions.length=0}}function n(t,e){return e?t*(1e3/e):0}const s=!1,i=t=>t;const r=["read","resolveKeyframes","update","preRender","render","postRender"];const{schedule:o,cancel:a,state:l,steps:u}=function(t,e){let n=!1,s=!0;const i={delta:0,timestamp:0,isProcessing:!1},o=()=>n=!0,a=r.reduce(((t,e)=>(t[e]=function(t){let e=new Set,n=new Set,s=!1,i=!1;const r=new WeakSet;let o={delta:0,timestamp:0,isProcessing:!1};function a(e){r.has(e)&&(l.schedule(e),t()),e(o)}const l={schedule:(t,i=!1,o=!1)=>{const a=o&&s?e:n;return i&&r.add(t),a.has(t)||a.add(t),t},cancel:t=>{n.delete(t),r.delete(t)},process:t=>{o=t,s?i=!0:(s=!0,[e,n]=[n,e],n.clear(),e.forEach(a),s=!1,i&&(i=!1,l.process(t)))}};return l}(o),t)),{}),{read:l,resolveKeyframes:u,update:c,preRender:h,render:d,postRender:p}=a,f=()=>{const r=performance.now();n=!1,i.delta=s?1e3/60:Math.max(Math.min(r-i.timestamp,40),1),i.timestamp=r,i.isProcessing=!0,l.process(i),u.process(i),c.process(i),h.process(i),d.process(i),p.process(i),i.isProcessing=!1,n&&e&&(s=!1,t(f))};return{schedule:r.reduce(((e,r)=>{const o=a[r];return e[r]=(e,r=!1,a=!1)=>(n||(n=!0,s=!0,i.isProcessing||t(f)),o.schedule(e,r,a)),e}),{}),cancel:t=>{for(let e=0;e<r.length;e++)a[r[e]].cancel(t)},state:i,steps:a}}("undefined"!=typeof requestAnimationFrame?requestAnimationFrame:i,!0);let c;function h(){c=void 0}const d={now:()=>(void 0===c&&d.set(l.isProcessing||s?l.timestamp:performance.now()),c),set:t=>{c=t,queueMicrotask(h)}};class p{constructor(t,e={}){this.version="11.11.17",this.canTrackVelocity=null,this.events={},this.updateAndNotify=(t,e=!0)=>{const n=d.now();this.updatedAt!==n&&this.setPrevFrameValue(),this.prev=this.current,this.setCurrent(t),this.current!==this.prev&&this.events.change&&this.events.change.notify(this.current),e&&this.events.renderRequest&&this.events.renderRequest.notify(this.current)},this.hasAnimated=!1,this.setCurrent(t),this.owner=e.owner}setCurrent(t){var e;this.current=t,this.updatedAt=d.now(),null===this.canTrackVelocity&&void 0!==t&&(this.canTrackVelocity=(e=this.current,!isNaN(parseFloat(e))))}setPrevFrameValue(t=this.current){this.prevFrameValue=t,this.prevUpdatedAt=this.updatedAt}onChange(t){return this.on("change",t)}on(t,n){this.events[t]||(this.events[t]=new e);const s=this.events[t].add(n);return"change"===t?()=>{s(),o.read((()=>{this.events.change.getSize()||this.stop()}))}:s}clearListeners(){for(const t in this.events)this.events[t].clear()}attach(t,e){this.passiveEffect=t,this.stopPassiveEffect=e}set(t,e=!0){e&&this.passiveEffect?this.passiveEffect(t,this.updateAndNotify):this.updateAndNotify(t,e)}setWithVelocity(t,e,n){this.set(e),this.prev=void 0,this.prevFrameValue=t,this.prevUpdatedAt=this.updatedAt-n}jump(t,e=!0){this.updateAndNotify(t),this.prev=t,this.prevUpdatedAt=this.prevFrameValue=void 0,e&&this.stop(),this.stopPassiveEffect&&this.stopPassiveEffect()}get(){return this.current}getPrevious(){return this.prev}getVelocity(){const t=d.now();if(!this.canTrackVelocity||void 0===this.prevFrameValue||t-this.updatedAt>30)return 0;const e=Math.min(this.updatedAt-this.prevUpdatedAt,30);return n(parseFloat(this.current)-parseFloat(this.prevFrameValue),e)}start(t){return this.stop(),new Promise((e=>{this.hasAnimated=!0,this.animation=t(e),this.events.animationStart&&this.events.animationStart.notify()})).then((()=>{this.events.animationComplete&&this.events.animationComplete.notify(),this.clearAnimation()}))}stop(){this.animation&&(this.animation.stop(),this.events.animationCancel&&this.events.animationCancel.notify()),this.clearAnimation()}isAnimating(){return!!this.animation}clearAnimation(){delete this.animation}destroy(){this.clearListeners(),this.stop(),this.stopPassiveEffect&&this.stopPassiveEffect()}}function f(t,e){return new p(t,e)}function m(t){let e;return()=>(void 0===e&&(e=t()),e)}const g=m((()=>void 0!==window.ScrollTimeline));class y{constructor(t){this.stop=()=>this.runAll("stop"),this.animations=t.filter(Boolean)}then(t,e){return Promise.all(this.animations).then(t).catch(e)}getAll(t){return this.animations[0][t]}setAll(t,e){for(let n=0;n<this.animations.length;n++)this.animations[n][t]=e}attachTimeline(t,e){const n=this.animations.map((n=>g()&&n.attachTimeline?n.attachTimeline(t):e(n)));return()=>{n.forEach(((t,e)=>{t&&t(),this.animations[e].stop()}))}}get time(){return this.getAll("time")}set time(t){this.setAll("time",t)}get speed(){return this.getAll("speed")}set speed(t){this.setAll("speed",t)}get startTime(){return this.getAll("startTime")}get duration(){let t=0;for(let e=0;e<this.animations.length;e++)t=Math.max(t,this.animations[e].duration);return t}runAll(t){this.animations.forEach((e=>e[t]()))}flatten(){this.runAll("flatten")}play(){this.runAll("play")}pause(){this.runAll("pause")}cancel(){this.runAll("cancel")}complete(){this.runAll("complete")}}const v=t=>1e3*t,w=t=>t/1e3;function b(t,e,s){const i=Math.max(e-5,0);return n(s-t(i),e-i)}let T=i,x=i;const S=(t,e,n)=>n>e?e:n<t?t:n,V=.001;function A({duration:t=800,bounce:e=.25,velocity:n=0,mass:s=1}){let i,r,o=1-e;o=S(.05,1,o),t=S(.01,10,w(t)),o<1?(i=e=>{const s=e*o,i=s*t,r=s-n,a=P(e,o),l=Math.exp(-i);return V-r/a*l},r=e=>{const s=e*o*t,r=s*n+n,a=Math.pow(o,2)*Math.pow(e,2)*t,l=Math.exp(-s),u=P(Math.pow(e,2),o);return(-i(e)+V>0?-1:1)*((r-a)*l)/u}):(i=e=>Math.exp(-e*t)*((e-n)*t+1)-.001,r=e=>Math.exp(-e*t)*(t*t*(n-e)));const a=function(t,e,n){let s=n;for(let n=1;n<M;n++)s-=t(s)/e(s);return s}(i,r,5/t);if(t=v(t),isNaN(a))return{stiffness:100,damping:10,duration:t};{const e=Math.pow(a,2)*s;return{stiffness:e,damping:2*o*Math.sqrt(s*e),duration:t}}}const M=12;function P(t,e){return t*Math.sqrt(1-e*e)}const k=["duration","bounce"],F=["stiffness","damping","mass"];function C(t,e){return e.some((e=>void 0!==t[e]))}function E({keyframes:t,restDelta:e,restSpeed:n,...s}){const i=t[0],r=t[t.length-1],o={done:!1,value:i},{stiffness:a,damping:l,mass:u,duration:c,velocity:h,isResolvedFromDuration:d}=function(t){let e={velocity:0,stiffness:100,damping:10,mass:1,isResolvedFromDuration:!1,...t};if(!C(t,F)&&C(t,k)){const n=A(t);e={...e,...n,mass:1},e.isResolvedFromDuration=!0}return e}({...s,velocity:-w(s.velocity||0)}),p=h||0,f=l/(2*Math.sqrt(a*u)),m=r-i,g=w(Math.sqrt(a/u)),y=Math.abs(m)<5;let T;if(n||(n=y?.01:2),e||(e=y?.005:.5),f<1){const t=P(g,f);T=e=>{const n=Math.exp(-f*g*e);return r-n*((p+f*g*m)/t*Math.sin(t*e)+m*Math.cos(t*e))}}else if(1===f)T=t=>r-Math.exp(-g*t)*(m+(p+g*m)*t);else{const t=g*Math.sqrt(f*f-1);T=e=>{const n=Math.exp(-f*g*e),s=Math.min(t*e,300);return r-n*((p+f*g*m)*Math.sinh(s)+t*m*Math.cosh(s))/t}}return{calculatedDuration:d&&c||null,next:t=>{const s=T(t);if(d)o.done=t>=c;else{let i=0;f<1&&(i=0===t?v(p):b(T,t,s));const a=Math.abs(i)<=n,l=Math.abs(r-s)<=e;o.done=a&&l}return o.value=o.done?r:s,o}}}const O=2e4;function R(t){let e=0;let n=t.next(e);for(;!n.done&&e<O;)e+=50,n=t.next(e);return e>=O?1/0:e}function B(t,e=100,n){const s=n({...t,keyframes:[0,e]}),i=Math.min(R(s),O);return{type:"keyframes",ease:t=>s.next(i*t).value/e,duration:w(i)}}const I=(t,e,n)=>t+(e-t)*n,L=(t,e,n)=>{const s=e-t;return 0===s?1:(n-t)/s};function W(t,e){const n=t[t.length-1];for(let s=1;s<=e;s++){const i=L(0,e,s);t.push(I(n,1,i))}}function D(t){const e=[0];return W(e,t.length-1),e}const N=t=>Boolean(t&&t.getVelocity);function K(t,e,n){var s;if("string"==typeof t){let i=document;e&&(x(Boolean(e.current)),i=e.current),n?(null!==(s=n[t])&&void 0!==s||(n[t]=i.querySelectorAll(t)),t=n[t]):t=i.querySelectorAll(t)}else t instanceof Element&&(t=[t]);return Array.from(t||[])}function $(t){return"object"==typeof t&&!Array.isArray(t)}function j(t,e,n,s){return"string"==typeof t&&$(e)?K(t,n,s):t instanceof NodeList?Array.from(t):Array.isArray(t)?t:[t]}function z(t){return"function"==typeof t}function H(t,e,n,s){var i;return"number"==typeof e?e:e.startsWith("-")||e.startsWith("+")?Math.max(0,t+parseFloat(e)):"<"===e?n:null!==(i=s.get(e))&&void 0!==i?i:t}const U=(t,e,n)=>{const s=e-t;return((n-t)%s+s)%s+t},Y=t=>Array.isArray(t)&&"number"!=typeof t[0];function q(t,e){return Y(t)?t[U(0,t.length,e)]:t}function X(e,n,s,i,r,o){!function(e,n,s){for(let i=0;i<e.length;i++){const r=e[i];r.at>n&&r.at<s&&(t(e,r),i--)}}(e,r,o);for(let t=0;t<n.length;t++)e.push({value:n[t],at:I(r,o,i[t]),easing:q(s,t)})}function G(t,e){return t.at===e.at?null===t.value?1:null===e.value?-1:0:t.at-e.at}function Z(t,e){return!e.has(t)&&e.set(t,{}),e.get(t)}function _(t,e){return e[t]||(e[t]=[]),e[t]}function J(t){return Array.isArray(t)?t:[t]}function Q(t,e){return t&&t[e]?{...t,...t[e]}:{...t}}const tt=t=>"number"==typeof t,et=t=>t.every(tt),nt=new WeakMap,st=["transformPerspective","x","y","z","translateX","translateY","translateZ","scale","scaleX","scaleY","rotate","rotateX","rotateY","rotateZ","skew","skewX","skewY"],it=new Set(st),rt={type:"spring",stiffness:500,damping:25,restSpeed:10},ot={type:"keyframes",duration:.8},at={type:"keyframes",ease:[.25,.1,.35,1],duration:.3},lt=(t,{keyframes:e})=>e.length>2?ot:it.has(t)?t.startsWith("scale")?{type:"spring",stiffness:550,damping:0===e[1]?2*Math.sqrt(550):30,restSpeed:10}:rt:at;function ut(t,e){return t?t[e]||t.default||t:void 0}const ct=t=>null!==t;function ht(t,{repeat:e,repeatType:n="loop"},s){const i=t.filter(ct),r=e&&"loop"!==n&&e%2==1?0:i.length-1;return r&&void 0!==s?s:i[r]}const dt=(t,e,n)=>(((1-3*n+3*e)*t+(3*n-6*e))*t+3*e)*t;function pt(t,e,n,s){if(t===e&&n===s)return i;const r=e=>function(t,e,n,s,i){let r,o,a=0;do{o=e+(n-e)/2,r=dt(o,s,i)-t,r>0?n=o:e=o}while(Math.abs(r)>1e-7&&++a<12);return o}(e,0,1,t,n);return t=>0===t||1===t?t:dt(r(t),e,s)}const ft=t=>e=>e<=.5?t(2*e)/2:(2-t(2*(1-e)))/2,mt=t=>e=>1-t(1-e),gt=pt(.33,1.53,.69,.99),yt=mt(gt),vt=ft(yt),wt=t=>(t*=2)<1?.5*yt(t):.5*(2-Math.pow(2,-10*(t-1))),bt=t=>1-Math.sin(Math.acos(t)),Tt=mt(bt),xt=ft(bt),St=t=>/^0[^.\s]+$/u.test(t);const Vt=t=>/^-?(?:\d+(?:\.\d+)?|\.\d+)$/u.test(t),At=t=>e=>"string"==typeof e&&e.startsWith(t),Mt=At("--"),Pt=At("var(--"),kt=t=>!!Pt(t)&&Ft.test(t.split("/*")[0].trim()),Ft=/var\(--(?:[\w-]+\s*|[\w-]+\s*,(?:\s*[^)(\s]|\s*\((?:[^)(]|\([^)(]*\))*\))+\s*)\)$/iu,Ct=/^var\(--(?:([\w-]+)|([\w-]+), ?([a-zA-Z\d ()%#.,-]+))\)/u;function Et(t,e,n=1){const[s,i]=function(t){const e=Ct.exec(t);if(!e)return[,];const[,n,s,i]=e;return[`--${null!=n?n:s}`,i]}(t);if(!s)return;const r=window.getComputedStyle(e).getPropertyValue(s);if(r){const t=r.trim();return Vt(t)?parseFloat(t):t}return kt(i)?Et(i,e,n+1):i}const Ot={test:t=>"number"==typeof t,parse:parseFloat,transform:t=>t},Rt={...Ot,transform:t=>S(0,1,t)},Bt={...Ot,default:1},It=t=>({test:e=>"string"==typeof e&&e.endsWith(t)&&1===e.split(" ").length,parse:parseFloat,transform:e=>`${e}${t}`}),Lt=It("deg"),Wt=It("%"),Dt=It("px"),Nt=It("vh"),Kt=It("vw"),$t={...Wt,parse:t=>Wt.parse(t)/100,transform:t=>Wt.transform(100*t)},jt=new Set(["width","height","top","left","right","bottom","x","y","translateX","translateY"]),zt=t=>t===Ot||t===Dt,Ht=(t,e)=>parseFloat(t.split(", ")[e]),Ut=(t,e)=>(n,{transform:s})=>{if("none"===s||!s)return 0;const i=s.match(/^matrix3d\((.+)\)$/u);if(i)return Ht(i[1],e);{const e=s.match(/^matrix\((.+)\)$/u);return e?Ht(e[1],t):0}},Yt=new Set(["x","y","z"]),qt=st.filter((t=>!Yt.has(t)));const Xt={width:({x:t},{paddingLeft:e="0",paddingRight:n="0"})=>t.max-t.min-parseFloat(e)-parseFloat(n),height:({y:t},{paddingTop:e="0",paddingBottom:n="0"})=>t.max-t.min-parseFloat(e)-parseFloat(n),top:(t,{top:e})=>parseFloat(e),left:(t,{left:e})=>parseFloat(e),bottom:({y:t},{top:e})=>parseFloat(e)+(t.max-t.min),right:({x:t},{left:e})=>parseFloat(e)+(t.max-t.min),x:Ut(4,13),y:Ut(5,14)};Xt.translateX=Xt.x,Xt.translateY=Xt.y;const Gt=t=>e=>e.test(t),Zt=[Ot,Dt,Wt,Lt,Kt,Nt,{test:t=>"auto"===t,parse:t=>t}],_t=t=>Zt.find(Gt(t)),Jt=new Set;let Qt=!1,te=!1;function ee(){if(te){const t=Array.from(Jt).filter((t=>t.needsMeasurement)),e=new Set(t.map((t=>t.element))),n=new Map;e.forEach((t=>{const e=function(t){const e=[];return qt.forEach((n=>{const s=t.getValue(n);void 0!==s&&(e.push([n,s.get()]),s.set(n.startsWith("scale")?1:0))})),e}(t);e.length&&(n.set(t,e),t.render())})),t.forEach((t=>t.measureInitialState())),e.forEach((t=>{t.render();const e=n.get(t);e&&e.forEach((([e,n])=>{var s;null===(s=t.getValue(e))||void 0===s||s.set(n)}))})),t.forEach((t=>t.measureEndState())),t.forEach((t=>{void 0!==t.suspendedScrollY&&window.scrollTo(0,t.suspendedScrollY)}))}te=!1,Qt=!1,Jt.forEach((t=>t.complete())),Jt.clear()}function ne(){Jt.forEach((t=>{t.readKeyframes(),t.needsMeasurement&&(te=!0)}))}class se{constructor(t,e,n,s,i,r=!1){this.isComplete=!1,this.isAsync=!1,this.needsMeasurement=!1,this.isScheduled=!1,this.unresolvedKeyframes=[...t],this.onComplete=e,this.name=n,this.motionValue=s,this.element=i,this.isAsync=r}scheduleResolve(){this.isScheduled=!0,this.isAsync?(Jt.add(this),Qt||(Qt=!0,o.read(ne),o.resolveKeyframes(ee))):(this.readKeyframes(),this.complete())}readKeyframes(){const{unresolvedKeyframes:t,name:e,element:n,motionValue:s}=this;for(let i=0;i<t.length;i++)if(null===t[i])if(0===i){const i=null==s?void 0:s.get(),r=t[t.length-1];if(void 0!==i)t[0]=i;else if(n&&e){const s=n.readValue(e,r);null!=s&&(t[0]=s)}void 0===t[0]&&(t[0]=r),s&&void 0===i&&s.set(t[0])}else t[i]=t[i-1]}setFinalKeyframe(){}measureInitialState(){}renderEndStyles(){}measureEndState(){}complete(){this.isComplete=!0,this.onComplete(this.unresolvedKeyframes,this.finalKeyframe),Jt.delete(this)}cancel(){this.isComplete||(this.isScheduled=!1,Jt.delete(this))}resume(){this.isComplete||this.scheduleResolve()}}const ie=t=>Math.round(1e5*t)/1e5,re=/-?(?:\d+(?:\.\d+)?|\.\d+)/gu;const oe=/^(?:#[\da-f]{3,8}|(?:rgb|hsl)a?\((?:-?[\d.]+%?[,\s]+){2}-?[\d.]+%?\s*(?:[,/]\s*)?(?:\b\d+(?:\.\d+)?|\.\d+)?%?\))$/iu,ae=(t,e)=>n=>Boolean("string"==typeof n&&oe.test(n)&&n.startsWith(t)||e&&!function(t){return null==t}(n)&&Object.prototype.hasOwnProperty.call(n,e)),le=(t,e,n)=>s=>{if("string"!=typeof s)return s;const[i,r,o,a]=s.match(re);return{[t]:parseFloat(i),[e]:parseFloat(r),[n]:parseFloat(o),alpha:void 0!==a?parseFloat(a):1}},ue={...Ot,transform:t=>Math.round((t=>S(0,255,t))(t))},ce={test:ae("rgb","red"),parse:le("red","green","blue"),transform:({red:t,green:e,blue:n,alpha:s=1})=>"rgba("+ue.transform(t)+", "+ue.transform(e)+", "+ue.transform(n)+", "+ie(Rt.transform(s))+")"};const he={test:ae("#"),parse:function(t){let e="",n="",s="",i="";return t.length>5?(e=t.substring(1,3),n=t.substring(3,5),s=t.substring(5,7),i=t.substring(7,9)):(e=t.substring(1,2),n=t.substring(2,3),s=t.substring(3,4),i=t.substring(4,5),e+=e,n+=n,s+=s,i+=i),{red:parseInt(e,16),green:parseInt(n,16),blue:parseInt(s,16),alpha:i?parseInt(i,16)/255:1}},transform:ce.transform},de={test:ae("hsl","hue"),parse:le("hue","saturation","lightness"),transform:({hue:t,saturation:e,lightness:n,alpha:s=1})=>"hsla("+Math.round(t)+", "+Wt.transform(ie(e))+", "+Wt.transform(ie(n))+", "+ie(Rt.transform(s))+")"},pe={test:t=>ce.test(t)||he.test(t)||de.test(t),parse:t=>ce.test(t)?ce.parse(t):de.test(t)?de.parse(t):he.parse(t),transform:t=>"string"==typeof t?t:t.hasOwnProperty("red")?ce.transform(t):de.transform(t)},fe=/(?:#[\da-f]{3,8}|(?:rgb|hsl)a?\((?:-?[\d.]+%?[,\s]+){2}-?[\d.]+%?\s*(?:[,/]\s*)?(?:\b\d+(?:\.\d+)?|\.\d+)?%?\))/giu;const me="number",ge="color",ye=/var\s*\(\s*--(?:[\w-]+\s*|[\w-]+\s*,(?:\s*[^)(\s]|\s*\((?:[^)(]|\([^)(]*\))*\))+\s*)\)|#[\da-f]{3,8}|(?:rgb|hsl)a?\((?:-?[\d.]+%?[,\s]+){2}-?[\d.]+%?\s*(?:[,/]\s*)?(?:\b\d+(?:\.\d+)?|\.\d+)?%?\)|-?(?:\d+(?:\.\d+)?|\.\d+)/giu;function ve(t){const e=t.toString(),n=[],s={color:[],number:[],var:[]},i=[];let r=0;const o=e.replace(ye,(t=>(pe.test(t)?(s.color.push(r),i.push(ge),n.push(pe.parse(t))):t.startsWith("var(")?(s.var.push(r),i.push("var"),n.push(t)):(s.number.push(r),i.push(me),n.push(parseFloat(t))),++r,"${}"))).split("${}");return{values:n,split:o,indexes:s,types:i}}function we(t){return ve(t).values}function be(t){const{split:e,types:n}=ve(t),s=e.length;return t=>{let i="";for(let r=0;r<s;r++)if(i+=e[r],void 0!==t[r]){const e=n[r];i+=e===me?ie(t[r]):e===ge?pe.transform(t[r]):t[r]}return i}}const Te=t=>"number"==typeof t?0:t;const xe={test:function(t){var e,n;return isNaN(t)&&"string"==typeof t&&((null===(e=t.match(re))||void 0===e?void 0:e.length)||0)+((null===(n=t.match(fe))||void 0===n?void 0:n.length)||0)>0},parse:we,createTransformer:be,getAnimatableNone:function(t){const e=we(t);return be(t)(e.map(Te))}},Se=new Set(["brightness","contrast","saturate","opacity"]);function Ve(t){const[e,n]=t.slice(0,-1).split("(");if("drop-shadow"===e)return t;const[s]=n.match(re)||[];if(!s)return t;const i=n.replace(s,"");let r=Se.has(e)?1:0;return s!==n&&(r*=100),e+"("+r+i+")"}const Ae=/\b([a-z-]*)\(.*?\)/gu,Me={...xe,getAnimatableNone:t=>{const e=t.match(Ae);return e?e.map(Ve).join(" "):t}},Pe={borderWidth:Dt,borderTopWidth:Dt,borderRightWidth:Dt,borderBottomWidth:Dt,borderLeftWidth:Dt,borderRadius:Dt,radius:Dt,borderTopLeftRadius:Dt,borderTopRightRadius:Dt,borderBottomRightRadius:Dt,borderBottomLeftRadius:Dt,width:Dt,maxWidth:Dt,height:Dt,maxHeight:Dt,top:Dt,right:Dt,bottom:Dt,left:Dt,padding:Dt,paddingTop:Dt,paddingRight:Dt,paddingBottom:Dt,paddingLeft:Dt,margin:Dt,marginTop:Dt,marginRight:Dt,marginBottom:Dt,marginLeft:Dt,backgroundPositionX:Dt,backgroundPositionY:Dt},ke={rotate:Lt,rotateX:Lt,rotateY:Lt,rotateZ:Lt,scale:Bt,scaleX:Bt,scaleY:Bt,scaleZ:Bt,skew:Lt,skewX:Lt,skewY:Lt,distance:Dt,translateX:Dt,translateY:Dt,translateZ:Dt,x:Dt,y:Dt,z:Dt,perspective:Dt,transformPerspective:Dt,opacity:Rt,originX:$t,originY:$t,originZ:Dt},Fe={...Ot,transform:Math.round},Ce={...Pe,...ke,zIndex:Fe,size:Dt,fillOpacity:Rt,strokeOpacity:Rt,numOctaves:Fe},Ee={...Ce,color:pe,backgroundColor:pe,outlineColor:pe,fill:pe,stroke:pe,borderColor:pe,borderTopColor:pe,borderRightColor:pe,borderBottomColor:pe,borderLeftColor:pe,filter:Me,WebkitFilter:Me},Oe=t=>Ee[t];function Re(t,e){let n=Oe(t);return n!==Me&&(n=xe),n.getAnimatableNone?n.getAnimatableNone(e):void 0}const Be=new Set(["auto","none","0"]);class Ie extends se{constructor(t,e,n,s,i){super(t,e,n,s,i,!0)}readKeyframes(){const{unresolvedKeyframes:t,element:e,name:n}=this;if(!e||!e.current)return;super.readKeyframes();for(let n=0;n<t.length;n++){let s=t[n];if("string"==typeof s&&(s=s.trim(),kt(s))){const i=Et(s,e.current);void 0!==i&&(t[n]=i),n===t.length-1&&(this.finalKeyframe=s)}}if(this.resolveNoneKeyframes(),!jt.has(n)||2!==t.length)return;const[s,i]=t,r=_t(s),o=_t(i);if(r!==o)if(zt(r)&&zt(o))for(let e=0;e<t.length;e++){const n=t[e];"string"==typeof n&&(t[e]=parseFloat(n))}else this.needsMeasurement=!0}resolveNoneKeyframes(){const{unresolvedKeyframes:t,name:e}=this,n=[];for(let e=0;e<t.length;e++)("number"==typeof(s=t[e])?0===s:null===s||"none"===s||"0"===s||St(s))&&n.push(e);var s;n.length&&function(t,e,n){let s,i=0;for(;i<t.length&&!s;){const e=t[i];"string"==typeof e&&!Be.has(e)&&ve(e).values.length&&(s=t[i]),i++}if(s&&n)for(const i of e)t[i]=Re(n,s)}(t,n,e)}measureInitialState(){const{element:t,unresolvedKeyframes:e,name:n}=this;if(!t||!t.current)return;"height"===n&&(this.suspendedScrollY=window.pageYOffset),this.measuredOrigin=Xt[n](t.measureViewportBox(),window.getComputedStyle(t.current)),e[0]=this.measuredOrigin;const s=e[e.length-1];void 0!==s&&t.getValue(n,s).jump(s,!1)}measureEndState(){var t;const{element:e,name:n,unresolvedKeyframes:s}=this;if(!e||!e.current)return;const i=e.getValue(n);i&&i.jump(this.measuredOrigin,!1);const r=s.length-1,o=s[r];s[r]=Xt[n](e.measureViewportBox(),window.getComputedStyle(e.current)),null!==o&&void 0===this.finalKeyframe&&(this.finalKeyframe=o),(null===(t=this.removedTransforms)||void 0===t?void 0:t.length)&&this.removedTransforms.forEach((([t,n])=>{e.getValue(t).set(n)})),this.resolveNoneKeyframes()}}const Le=(t,e)=>"zIndex"!==e&&(!("number"!=typeof t&&!Array.isArray(t))||!("string"!=typeof t||!xe.test(t)&&"0"!==t||t.startsWith("url(")));function We(t,e,n,s){const i=t[0];if(null===i)return!1;if("display"===e||"visibility"===e)return!0;const r=t[t.length-1],o=Le(i,e),a=Le(r,e);return!(!o||!a)&&(function(t){const e=t[0];if(1===t.length)return!0;for(let n=0;n<t.length;n++)if(t[n]!==e)return!0}(t)||("spring"===n||z(n))&&s)}class De{constructor({autoplay:t=!0,delay:e=0,type:n="keyframes",repeat:s=0,repeatDelay:i=0,repeatType:r="loop",...o}){this.isStopped=!1,this.hasAttemptedResolve=!1,this.createdAt=d.now(),this.options={autoplay:t,delay:e,type:n,repeat:s,repeatDelay:i,repeatType:r,...o},this.updateFinishedPromise()}calcStartTime(){return this.resolvedAt&&this.resolvedAt-this.createdAt>40?this.resolvedAt:this.createdAt}get resolved(){return this._resolved||this.hasAttemptedResolve||(ne(),ee()),this._resolved}onKeyframesResolved(t,e){this.resolvedAt=d.now(),this.hasAttemptedResolve=!0;const{name:n,type:s,velocity:i,delay:r,onComplete:o,onUpdate:a,isGenerator:l}=this.options;if(!l&&!We(t,n,s,i)){if(!r)return null==a||a(ht(t,this.options,e)),null==o||o(),void this.resolveFinishedPromise();this.options.duration=0}const u=this.initPlayback(t,e);!1!==u&&(this._resolved={keyframes:t,finalKeyframe:e,...u},this.onPostResolved())}onPostResolved(){}then(t,e){return this.currentFinishedPromise.then(t,e)}flatten(){this.options.type="keyframes",this.options.ease="linear"}updateFinishedPromise(){this.currentFinishedPromise=new Promise((t=>{this.resolveFinishedPromise=t}))}}function Ne({keyframes:t,velocity:e=0,power:n=.8,timeConstant:s=325,bounceDamping:i=10,bounceStiffness:r=500,modifyTarget:o,min:a,max:l,restDelta:u=.5,restSpeed:c}){const h=t[0],d={done:!1,value:h},p=t=>void 0===a?l:void 0===l||Math.abs(a-t)<Math.abs(l-t)?a:l;let f=n*e;const m=h+f,g=void 0===o?m:o(m);g!==m&&(f=g-h);const y=t=>-f*Math.exp(-t/s),v=t=>g+y(t),w=t=>{const e=y(t),n=v(t);d.done=Math.abs(e)<=u,d.value=d.done?g:n};let T,x;const S=t=>{var e;(e=d.value,void 0!==a&&e<a||void 0!==l&&e>l)&&(T=t,x=E({keyframes:[d.value,p(d.value)],velocity:b(v,t,d.value),damping:i,stiffness:r,restDelta:u,restSpeed:c}))};return S(0),{calculatedDuration:null,next:t=>{let e=!1;return x||void 0!==T||(e=!0,w(t),S(t)),void 0!==T&&t>=T?x.next(t-T):(!e&&w(t),d)}}}const Ke=pt(.42,0,1,1),$e=pt(0,0,.58,1),je=pt(.42,0,.58,1),ze=t=>Array.isArray(t)&&"number"==typeof t[0],He={linear:i,easeIn:Ke,easeInOut:je,easeOut:$e,circIn:bt,circInOut:xt,circOut:Tt,backIn:yt,backInOut:vt,backOut:gt,anticipate:wt},Ue=t=>{if(ze(t)){x(4===t.length);const[e,n,s,i]=t;return pt(e,n,s,i)}return"string"==typeof t?He[t]:t},Ye=(t,e)=>n=>e(t(n)),qe=(...t)=>t.reduce(Ye);function Xe(t,e,n){return n<0&&(n+=1),n>1&&(n-=1),n<1/6?t+6*(e-t)*n:n<.5?e:n<2/3?t+(e-t)*(2/3-n)*6:t}function Ge(t,e){return n=>n>0?e:t}const Ze=(t,e,n)=>{const s=t*t,i=n*(e*e-s)+s;return i<0?0:Math.sqrt(i)},_e=[he,ce,de];function Je(t){const e=(n=t,_e.find((t=>t.test(n))));var n;if(!Boolean(e))return!1;let s=e.parse(t);return e===de&&(s=function({hue:t,saturation:e,lightness:n,alpha:s}){t/=360,n/=100;let i=0,r=0,o=0;if(e/=100){const s=n<.5?n*(1+e):n+e-n*e,a=2*n-s;i=Xe(a,s,t+1/3),r=Xe(a,s,t),o=Xe(a,s,t-1/3)}else i=r=o=n;return{red:Math.round(255*i),green:Math.round(255*r),blue:Math.round(255*o),alpha:s}}(s)),s}const Qe=(t,e)=>{const n=Je(t),s=Je(e);if(!n||!s)return Ge(t,e);const i={...n};return t=>(i.red=Ze(n.red,s.red,t),i.green=Ze(n.green,s.green,t),i.blue=Ze(n.blue,s.blue,t),i.alpha=I(n.alpha,s.alpha,t),ce.transform(i))},tn=new Set(["none","hidden"]);function en(t,e){return n=>I(t,e,n)}function nn(t){return"number"==typeof t?en:"string"==typeof t?kt(t)?Ge:pe.test(t)?Qe:on:Array.isArray(t)?sn:"object"==typeof t?pe.test(t)?Qe:rn:Ge}function sn(t,e){const n=[...t],s=n.length,i=t.map(((t,n)=>nn(t)(t,e[n])));return t=>{for(let e=0;e<s;e++)n[e]=i[e](t);return n}}function rn(t,e){const n={...t,...e},s={};for(const i in n)void 0!==t[i]&&void 0!==e[i]&&(s[i]=nn(t[i])(t[i],e[i]));return t=>{for(const e in s)n[e]=s[e](t);return n}}const on=(t,e)=>{const n=xe.createTransformer(e),s=ve(t),i=ve(e);return s.indexes.var.length===i.indexes.var.length&&s.indexes.color.length===i.indexes.color.length&&s.indexes.number.length>=i.indexes.number.length?tn.has(t)&&!i.values.length||tn.has(e)&&!s.values.length?function(t,e){return tn.has(t)?n=>n<=0?t:e:n=>n>=1?e:t}(t,e):qe(sn(function(t,e){var n;const s=[],i={color:0,var:0,number:0};for(let r=0;r<e.values.length;r++){const o=e.types[r],a=t.indexes[o][i[o]],l=null!==(n=t.values[a])&&void 0!==n?n:0;s[r]=l,i[o]++}return s}(s,i),i.values),n):Ge(t,e)};function an(t,e,n){if("number"==typeof t&&"number"==typeof e&&"number"==typeof n)return I(t,e,n);return nn(t)(t,e)}function ln(t,e,{clamp:n=!0,ease:s,mixer:r}={}){const o=t.length;if(x(o===e.length),1===o)return()=>e[0];if(2===o&&t[0]===t[1])return()=>e[1];t[0]>t[o-1]&&(t=[...t].reverse(),e=[...e].reverse());const a=function(t,e,n){const s=[],r=n||an,o=t.length-1;for(let n=0;n<o;n++){let o=r(t[n],t[n+1]);if(e){const t=Array.isArray(e)?e[n]||i:e;o=qe(t,o)}s.push(o)}return s}(e,s,r),l=a.length,u=e=>{let n=0;if(l>1)for(;n<t.length-2&&!(e<t[n+1]);n++);const s=L(t[n],t[n+1],e);return a[n](s)};return n?e=>u(S(t[0],t[o-1],e)):u}function un({duration:t=300,keyframes:e,times:n,ease:s="easeInOut"}){const i=Y(s)?s.map(Ue):Ue(s),r={done:!1,value:e[0]},o=function(t,e){return t.map((t=>t*e))}(n&&n.length===e.length?n:D(e),t),a=ln(o,e,{ease:Array.isArray(i)?i:(l=e,u=i,l.map((()=>u||je)).splice(0,l.length-1))});var l,u;return{calculatedDuration:t,next:e=>(r.value=a(e),r.done=e>=t,r)}}const cn=t=>{const e=({timestamp:e})=>t(e);return{start:()=>o.update(e,!0),stop:()=>a(e),now:()=>l.isProcessing?l.timestamp:d.now()}},hn={decay:Ne,inertia:Ne,tween:un,keyframes:un,spring:E},dn=t=>t/100;class pn extends De{constructor(t){super(t),this.holdTime=null,this.cancelTime=null,this.currentTime=0,this.playbackSpeed=1,this.pendingPlayState="running",this.startTime=null,this.state="idle",this.stop=()=>{if(this.resolver.cancel(),this.isStopped=!0,"idle"===this.state)return;this.teardown();const{onStop:t}=this.options;t&&t()};const{name:e,motionValue:n,element:s,keyframes:i}=this.options,r=(null==s?void 0:s.KeyframeResolver)||se;this.resolver=new r(i,((t,e)=>this.onKeyframesResolved(t,e)),e,n,s),this.resolver.scheduleResolve()}flatten(){super.flatten(),this._resolved&&Object.assign(this._resolved,this.initPlayback(this._resolved.keyframes))}initPlayback(t){const{type:e="keyframes",repeat:n=0,repeatDelay:s=0,repeatType:i,velocity:r=0}=this.options,o=z(e)?e:hn[e]||un;let a,l;o!==un&&"number"!=typeof t[0]&&(a=qe(dn,an(t[0],t[1])),t=[0,100]);const u=o({...this.options,keyframes:t});"mirror"===i&&(l=o({...this.options,keyframes:[...t].reverse(),velocity:-r})),null===u.calculatedDuration&&(u.calculatedDuration=R(u));const{calculatedDuration:c}=u,h=c+s;return{generator:u,mirroredGenerator:l,mapPercentToKeyframes:a,calculatedDuration:c,resolvedDuration:h,totalDuration:h*(n+1)-s}}onPostResolved(){const{autoplay:t=!0}=this.options;this.play(),"paused"!==this.pendingPlayState&&t?this.state=this.pendingPlayState:this.pause()}tick(t,e=!1){const{resolved:n}=this;if(!n){const{keyframes:t}=this.options;return{done:!0,value:t[t.length-1]}}const{finalKeyframe:s,generator:i,mirroredGenerator:r,mapPercentToKeyframes:o,keyframes:a,calculatedDuration:l,totalDuration:u,resolvedDuration:c}=n;if(null===this.startTime)return i.next(0);const{delay:h,repeat:d,repeatType:p,repeatDelay:f,onUpdate:m}=this.options;this.speed>0?this.startTime=Math.min(this.startTime,t):this.speed<0&&(this.startTime=Math.min(t-u/this.speed,this.startTime)),e?this.currentTime=t:null!==this.holdTime?this.currentTime=this.holdTime:this.currentTime=Math.round(t-this.startTime)*this.speed;const g=this.currentTime-h*(this.speed>=0?1:-1),y=this.speed>=0?g<0:g>u;this.currentTime=Math.max(g,0),"finished"===this.state&&null===this.holdTime&&(this.currentTime=u);let v=this.currentTime,w=i;if(d){const t=Math.min(this.currentTime,u)/c;let e=Math.floor(t),n=t%1;!n&&t>=1&&(n=1),1===n&&e--,e=Math.min(e,d+1);Boolean(e%2)&&("reverse"===p?(n=1-n,f&&(n-=f/c)):"mirror"===p&&(w=r)),v=S(0,1,n)*c}const b=y?{done:!1,value:a[0]}:w.next(v);o&&(b.value=o(b.value));let{done:T}=b;y||null===l||(T=this.speed>=0?this.currentTime>=u:this.currentTime<=0);const x=null===this.holdTime&&("finished"===this.state||"running"===this.state&&T);return x&&void 0!==s&&(b.value=ht(a,this.options,s)),m&&m(b.value),x&&this.finish(),b}get duration(){const{resolved:t}=this;return t?w(t.calculatedDuration):0}get time(){return w(this.currentTime)}set time(t){t=v(t),this.currentTime=t,null!==this.holdTime||0===this.speed?this.holdTime=t:this.driver&&(this.startTime=this.driver.now()-t/this.speed)}get speed(){return this.playbackSpeed}set speed(t){const e=this.playbackSpeed!==t;this.playbackSpeed=t,e&&(this.time=w(this.currentTime))}play(){if(this.resolver.isScheduled||this.resolver.resume(),!this._resolved)return void(this.pendingPlayState="running");if(this.isStopped)return;const{driver:t=cn,onPlay:e,startTime:n}=this.options;this.driver||(this.driver=t((t=>this.tick(t)))),e&&e();const s=this.driver.now();null!==this.holdTime?this.startTime=s-this.holdTime:this.startTime?"finished"===this.state&&(this.startTime=s):this.startTime=null!=n?n:this.calcStartTime(),"finished"===this.state&&this.updateFinishedPromise(),this.cancelTime=this.startTime,this.holdTime=null,this.state="running",this.driver.start()}pause(){var t;this._resolved?(this.state="paused",this.holdTime=null!==(t=this.currentTime)&&void 0!==t?t:0):this.pendingPlayState="paused"}complete(){"running"!==this.state&&this.play(),this.pendingPlayState=this.state="finished",this.holdTime=null}finish(){this.teardown(),this.state="finished";const{onComplete:t}=this.options;t&&t()}cancel(){null!==this.cancelTime&&this.tick(this.cancelTime),this.teardown(),this.updateFinishedPromise()}teardown(){this.state="idle",this.stopDriver(),this.resolveFinishedPromise(),this.updateFinishedPromise(),this.startTime=this.cancelTime=null,this.resolver.cancel()}stopDriver(){this.driver&&(this.driver.stop(),this.driver=void 0)}sample(t){return this.startTime=0,this.tick(t,!0)}}const fn=new Set(["opacity","clipPath","filter","transform"]),mn={linearEasing:void 0};function gn(t,e){const n=m(t);return()=>{var t;return null!==(t=mn[e])&&void 0!==t?t:n()}}const yn=gn((()=>{try{document.createElement("div").animate({opacity:0},{easing:"linear(0, 1)"})}catch(t){return!1}return!0}),"linearEasing");function vn(t){return Boolean("function"==typeof t&&yn()||!t||"string"==typeof t&&(t in bn||yn())||ze(t)||Array.isArray(t)&&t.every(vn))}const wn=([t,e,n,s])=>`cubic-bezier(${t}, ${e}, ${n}, ${s})`,bn={linear:"linear",ease:"ease",easeIn:"ease-in",easeOut:"ease-out",easeInOut:"ease-in-out",circIn:wn([0,.65,.55,1]),circOut:wn([.55,0,1,.45]),backIn:wn([.31,.01,.66,-.59]),backOut:wn([.33,1.53,.69,.99])};function Tn(t,e){return t?"function"==typeof t&&yn()?((t,e)=>{let n="";const s=Math.max(Math.round(e/10),2);for(let e=0;e<s;e++)n+=t(L(0,s-1,e))+", ";return`linear(${n.substring(0,n.length-2)})`})(t,e):ze(t)?wn(t):Array.isArray(t)?t.map((t=>Tn(t,e)||bn.easeOut)):bn[t]:void 0}function xn(t,e,n,{delay:s=0,duration:i=300,repeat:r=0,repeatType:o="loop",ease:a="easeInOut",times:l}={}){const u={[e]:n};l&&(u.offset=l);const c=Tn(a,i);return Array.isArray(c)&&(u.easing=c),t.animate(u,{delay:s,duration:i,easing:Array.isArray(c)?"linear":c,fill:"both",iterations:r+1,direction:"reverse"===o?"alternate":"normal"})}function Sn(t,e){t.timeline=e,t.onfinish=null}const Vn=m((()=>Object.hasOwnProperty.call(Element.prototype,"animate")));const An={anticipate:wt,backInOut:vt,circInOut:xt};class Mn extends De{constructor(t){super(t);const{name:e,motionValue:n,element:s,keyframes:i}=this.options;this.resolver=new Ie(i,((t,e)=>this.onKeyframesResolved(t,e)),e,n,s),this.resolver.scheduleResolve()}initPlayback(t,e){var n;let{duration:s=300,times:i,ease:r,type:o,motionValue:a,name:l,startTime:u}=this.options;if(!(null===(n=a.owner)||void 0===n?void 0:n.current))return!1;var c;if("string"==typeof r&&yn()&&r in An&&(r=An[r]),z((c=this.options).type)||"spring"===c.type||!vn(c.ease)){const{onComplete:e,onUpdate:n,motionValue:a,element:l,...u}=this.options,c=function(t,e){const n=new pn({...e,keyframes:t,repeat:0,delay:0,isGenerator:!0});let s={done:!1,value:t[0]};const i=[];let r=0;for(;!s.done&&r<2e4;)s=n.sample(r),i.push(s.value),r+=10;return{times:void 0,keyframes:i,duration:r-10,ease:"linear"}}(t,u);1===(t=c.keyframes).length&&(t[1]=t[0]),s=c.duration,i=c.times,r=c.ease,o="keyframes"}const h=xn(a.owner.current,l,t,{...this.options,duration:s,times:i,ease:r});return h.startTime=null!=u?u:this.calcStartTime(),this.pendingTimeline?(Sn(h,this.pendingTimeline),this.pendingTimeline=void 0):h.onfinish=()=>{const{onComplete:n}=this.options;a.set(ht(t,this.options,e)),n&&n(),this.cancel(),this.resolveFinishedPromise()},{animation:h,duration:s,times:i,type:o,ease:r,keyframes:t}}get duration(){const{resolved:t}=this;if(!t)return 0;const{duration:e}=t;return w(e)}get time(){const{resolved:t}=this;if(!t)return 0;const{animation:e}=t;return w(e.currentTime||0)}set time(t){const{resolved:e}=this;if(!e)return;const{animation:n}=e;n.currentTime=v(t)}get speed(){const{resolved:t}=this;if(!t)return 1;const{animation:e}=t;return e.playbackRate}set speed(t){const{resolved:e}=this;if(!e)return;const{animation:n}=e;n.playbackRate=t}get state(){const{resolved:t}=this;if(!t)return"idle";const{animation:e}=t;return e.playState}get startTime(){const{resolved:t}=this;if(!t)return null;const{animation:e}=t;return e.startTime}attachTimeline(t){if(this._resolved){const{resolved:e}=this;if(!e)return i;const{animation:n}=e;Sn(n,t)}else this.pendingTimeline=t;return i}play(){if(this.isStopped)return;const{resolved:t}=this;if(!t)return;const{animation:e}=t;"finished"===e.playState&&this.updateFinishedPromise(),e.play()}pause(){const{resolved:t}=this;if(!t)return;const{animation:e}=t;e.pause()}stop(){if(this.resolver.cancel(),this.isStopped=!0,"idle"===this.state)return;this.resolveFinishedPromise(),this.updateFinishedPromise();const{resolved:t}=this;if(!t)return;const{animation:e,keyframes:n,duration:s,type:i,ease:r,times:o}=t;if("idle"===e.playState||"finished"===e.playState)return;if(this.time){const{motionValue:t,onUpdate:e,onComplete:a,element:l,...u}=this.options,c=new pn({...u,keyframes:n,duration:s,type:i,ease:r,times:o,isGenerator:!0}),h=v(this.time);t.setWithVelocity(c.sample(h-10).value,c.sample(h).value,10)}const{onStop:a}=this.options;a&&a(),this.cancel()}complete(){const{resolved:t}=this;t&&t.animation.finish()}cancel(){const{resolved:t}=this;t&&t.animation.cancel()}static supports(t){const{motionValue:e,name:n,repeatDelay:s,repeatType:i,damping:r,type:o}=t;return Vn()&&n&&fn.has(n)&&e&&e.owner&&e.owner.current instanceof HTMLElement&&!e.owner.getProps().onUpdate&&!s&&"mirror"!==i&&0!==r&&"inertia"!==o}}const Pn=(t,e,n,s={},i,r)=>a=>{const l=ut(s,t)||{},u=l.delay||s.delay||0;let{elapsed:c=0}=s;c-=v(u);let h={keyframes:Array.isArray(n)?n:[null,n],ease:"easeOut",velocity:e.getVelocity(),...l,delay:-c,onUpdate:t=>{e.set(t),l.onUpdate&&l.onUpdate(t)},onComplete:()=>{a(),l.onComplete&&l.onComplete()},name:t,motionValue:e,element:r?void 0:i};(function({when:t,delay:e,delayChildren:n,staggerChildren:s,staggerDirection:i,repeat:r,repeatType:o,repeatDelay:a,from:l,elapsed:u,...c}){return!!Object.keys(c).length})(l)||(h={...h,...lt(t,h)}),h.duration&&(h.duration=v(h.duration)),h.repeatDelay&&(h.repeatDelay=v(h.repeatDelay)),void 0!==h.from&&(h.keyframes[0]=h.from);let d=!1;if((!1===h.type||0===h.duration&&!h.repeatDelay)&&(h.duration=0,0===h.delay&&(d=!0)),d&&!r&&void 0!==e.get()){const t=ht(h.keyframes,l);if(void 0!==t)return o.update((()=>{h.onUpdate(t),h.onComplete()})),new y([])}return!r&&Mn.supports(h)?new Mn(h):new pn(h)},kn=t=>(t=>Array.isArray(t))(t)?t[t.length-1]||0:t;function Fn(t){const e=[{},{}];return null==t||t.values.forEach(((t,n)=>{e[0][n]=t.get(),e[1][n]=t.getVelocity()})),e}function Cn(t,e,n,s){if("function"==typeof e){const[i,r]=Fn(s);e=e(void 0!==n?n:t.custom,i,r)}if("string"==typeof e&&(e=t.variants&&t.variants[e]),"function"==typeof e){const[i,r]=Fn(s);e=e(void 0!==n?n:t.custom,i,r)}return e}function En(t,e,n){t.hasValue(e)?t.getValue(e).set(n):t.addValue(e,f(n))}function On(t,e){const n=function(t,e,n){const s=t.getProps();return Cn(s,e,void 0!==n?n:s.custom,t)}(t,e);let{transitionEnd:s={},transition:i={},...r}=n||{};r={...r,...s};for(const e in r){En(t,e,kn(r[e]))}}const Rn=t=>t.replace(/([a-z])([A-Z])/gu,"$1-$2").toLowerCase(),Bn="data-"+Rn("framerAppearId");function In(t){return t.props[Bn]}function Ln(t,e){const n=t.getValue("willChange");if(s=n,Boolean(N(s)&&s.add))return n.add(e);var s}function Wn({protectedKeys:t,needsAnimating:e},n){const s=t.hasOwnProperty(n)&&!0!==e[n];return e[n]=!1,s}function Dn(t,e,{delay:n=0,transitionOverride:s,type:i}={}){var r;let{transition:a=t.getDefaultTransition(),transitionEnd:l,...u}=e;s&&(a=s);const c=[],h=i&&t.animationState&&t.animationState.getState()[i];for(const e in u){const s=t.getValue(e,null!==(r=t.latestValues[e])&&void 0!==r?r:null),i=u[e];if(void 0===i||h&&Wn(h,e))continue;const l={delay:n,...ut(a||{},e)};let d=!1;if(window.MotionHandoffAnimation){const n=In(t);if(n){const t=window.MotionHandoffAnimation(n,e,o);null!==t&&(l.startTime=t,d=!0)}}Ln(t,e),s.start(Pn(e,s,i,t.shouldReduceMotion&&it.has(e)?{type:!1}:l,t,d));const p=s.animation;p&&c.push(p)}return l&&Promise.all(c).then((()=>{o.update((()=>{l&&On(t,l)}))})),c}const Nn={};function Kn(t,{layout:e,layoutId:n}){return it.has(t)||t.startsWith("origin")||(e||void 0!==n)&&(!!Nn[t]||"opacity"===t)}function $n(t,e,n){var s;const{style:i}=t,r={};for(const o in i)(N(i[o])||e.style&&N(e.style[o])||Kn(o,t)||void 0!==(null===(s=null==n?void 0:n.getValue(o))||void 0===s?void 0:s.liveStyle))&&(r[o]=i[o]);return r}const jn="undefined"!=typeof window,zn={current:null},Hn={current:!1};const Un=["initial","animate","whileInView","whileFocus","whileHover","whileTap","whileDrag","exit"];function Yn(t){return null!==(e=t.animate)&&"object"==typeof e&&"function"==typeof e.start||Un.some((e=>function(t){return"string"==typeof t||Array.isArray(t)}(t[e])));var e}const qn={animation:["animate","variants","whileHover","whileTap","exit","whileInView","whileFocus","whileDrag"],exit:["exit"],drag:["drag","dragControls"],focus:["whileFocus"],hover:["whileHover","onHoverStart","onHoverEnd"],tap:["whileTap","onTap","onTapStart","onTapCancel"],pan:["onPan","onPanStart","onPanSessionStart","onPanEnd"],inView:["whileInView","onViewportEnter","onViewportLeave"],layout:["layout","layoutId"]},Xn={};for(const t in qn)Xn[t]={isEnabled:e=>qn[t].some((t=>!!e[t]))};const Gn=[...Zt,pe,xe],Zn=()=>({x:{min:0,max:0},y:{min:0,max:0}}),_n=["AnimationStart","AnimationComplete","Update","BeforeLayoutMeasure","LayoutMeasure","LayoutAnimationStart","LayoutAnimationComplete"];class Jn{scrapeMotionValuesFromProps(t,e,n){return{}}constructor({parent:t,props:e,presenceContext:n,reducedMotionConfig:s,blockInitialAnimation:i,visualState:r},a={}){this.current=null,this.children=new Set,this.isVariantNode=!1,this.isControllingVariants=!1,this.shouldReduceMotion=null,this.values=new Map,this.KeyframeResolver=se,this.features={},this.valueSubscriptions=new Map,this.prevMotionValues={},this.events={},this.propEventSubscriptions={},this.notifyUpdate=()=>this.notify("Update",this.latestValues),this.render=()=>{this.current&&(this.triggerBuild(),this.renderInstance(this.current,this.renderState,this.props.style,this.projection))},this.renderScheduledAt=0,this.scheduleRender=()=>{const t=d.now();this.renderScheduledAt<t&&(this.renderScheduledAt=t,o.render(this.render,!1,!0))};const{latestValues:l,renderState:u}=r;this.latestValues=l,this.baseTarget={...l},this.initialValues=e.initial?{...l}:{},this.renderState=u,this.parent=t,this.props=e,this.presenceContext=n,this.depth=t?t.depth+1:0,this.reducedMotionConfig=s,this.options=a,this.blockInitialAnimation=Boolean(i),this.isControllingVariants=Yn(e),this.isVariantNode=function(t){return Boolean(Yn(t)||t.variants)}(e),this.isVariantNode&&(this.variantChildren=new Set),this.manuallyAnimateOnMount=Boolean(t&&t.current);const{willChange:c,...h}=this.scrapeMotionValuesFromProps(e,{},this);for(const t in h){const e=h[t];void 0!==l[t]&&N(e)&&e.set(l[t],!1)}}mount(t){this.current=t,nt.set(t,this),this.projection&&!this.projection.instance&&this.projection.mount(t),this.parent&&this.isVariantNode&&!this.isControllingVariants&&(this.removeFromVariantTree=this.parent.addVariantChild(this)),this.values.forEach(((t,e)=>this.bindToMotionValue(e,t))),Hn.current||function(){if(Hn.current=!0,jn)if(window.matchMedia){const t=window.matchMedia("(prefers-reduced-motion)"),e=()=>zn.current=t.matches;t.addListener(e),e()}else zn.current=!1}(),this.shouldReduceMotion="never"!==this.reducedMotionConfig&&("always"===this.reducedMotionConfig||zn.current),this.parent&&this.parent.children.add(this),this.update(this.props,this.presenceContext)}unmount(){nt.delete(this.current),this.projection&&this.projection.unmount(),a(this.notifyUpdate),a(this.render),this.valueSubscriptions.forEach((t=>t())),this.valueSubscriptions.clear(),this.removeFromVariantTree&&this.removeFromVariantTree(),this.parent&&this.parent.children.delete(this);for(const t in this.events)this.events[t].clear();for(const t in this.features){const e=this.features[t];e&&(e.unmount(),e.isMounted=!1)}this.current=null}bindToMotionValue(t,e){this.valueSubscriptions.has(t)&&this.valueSubscriptions.get(t)();const n=it.has(t),s=e.on("change",(e=>{this.latestValues[t]=e,this.props.onUpdate&&o.preRender(this.notifyUpdate),n&&this.projection&&(this.projection.isTransformDirty=!0)})),i=e.on("renderRequest",this.scheduleRender);let r;window.MotionCheckAppearSync&&(r=window.MotionCheckAppearSync(this,t,e)),this.valueSubscriptions.set(t,(()=>{s(),i(),r&&r(),e.owner&&e.stop()}))}sortNodePosition(t){return this.current&&this.sortInstanceNodePosition&&this.type===t.type?this.sortInstanceNodePosition(this.current,t.current):0}updateFeatures(){let t="animation";for(t in Xn){const e=Xn[t];if(!e)continue;const{isEnabled:n,Feature:s}=e;if(!this.features[t]&&s&&n(this.props)&&(this.features[t]=new s(this)),this.features[t]){const e=this.features[t];e.isMounted?e.update():(e.mount(),e.isMounted=!0)}}}triggerBuild(){this.build(this.renderState,this.latestValues,this.props)}measureViewportBox(){return this.current?this.measureInstanceViewportBox(this.current,this.props):{x:{min:0,max:0},y:{min:0,max:0}}}getStaticValue(t){return this.latestValues[t]}setStaticValue(t,e){this.latestValues[t]=e}update(t,e){(t.transformTemplate||this.props.transformTemplate)&&this.scheduleRender(),this.prevProps=this.props,this.props=t,this.prevPresenceContext=this.presenceContext,this.presenceContext=e;for(let e=0;e<_n.length;e++){const n=_n[e];this.propEventSubscriptions[n]&&(this.propEventSubscriptions[n](),delete this.propEventSubscriptions[n]);const s=t["on"+n];s&&(this.propEventSubscriptions[n]=this.on(n,s))}this.prevMotionValues=function(t,e,n){for(const s in e){const i=e[s],r=n[s];if(N(i))t.addValue(s,i);else if(N(r))t.addValue(s,f(i,{owner:t}));else if(r!==i)if(t.hasValue(s)){const e=t.getValue(s);!0===e.liveStyle?e.jump(i):e.hasAnimated||e.set(i)}else{const e=t.getStaticValue(s);t.addValue(s,f(void 0!==e?e:i,{owner:t}))}}for(const s in n)void 0===e[s]&&t.removeValue(s);return e}(this,this.scrapeMotionValuesFromProps(t,this.prevProps,this),this.prevMotionValues),this.handleChildMotionValue&&this.handleChildMotionValue()}getProps(){return this.props}getVariant(t){return this.props.variants?this.props.variants[t]:void 0}getDefaultTransition(){return this.props.transition}getTransformPagePoint(){return this.props.transformPagePoint}getClosestVariantNode(){return this.isVariantNode?this:this.parent?this.parent.getClosestVariantNode():void 0}addVariantChild(t){const e=this.getClosestVariantNode();if(e)return e.variantChildren&&e.variantChildren.add(t),()=>e.variantChildren.delete(t)}addValue(t,e){const n=this.values.get(t);e!==n&&(n&&this.removeValue(t),this.bindToMotionValue(t,e),this.values.set(t,e),this.latestValues[t]=e.get())}removeValue(t){this.values.delete(t);const e=this.valueSubscriptions.get(t);e&&(e(),this.valueSubscriptions.delete(t)),delete this.latestValues[t],this.removeValueFromRenderState(t,this.renderState)}hasValue(t){return this.values.has(t)}getValue(t,e){if(this.props.values&&this.props.values[t])return this.props.values[t];let n=this.values.get(t);return void 0===n&&void 0!==e&&(n=f(null===e?void 0:e,{owner:this}),this.addValue(t,n)),n}readValue(t,e){var n;let s=void 0===this.latestValues[t]&&this.current?null!==(n=this.getBaseTargetFromProps(this.props,t))&&void 0!==n?n:this.readValueFromInstance(this.current,t,this.options):this.latestValues[t];var i;return null!=s&&("string"==typeof s&&(Vt(s)||St(s))?s=parseFloat(s):(i=s,!Gn.find(Gt(i))&&xe.test(e)&&(s=Re(t,e))),this.setBaseTarget(t,N(s)?s.get():s)),N(s)?s.get():s}setBaseTarget(t,e){this.baseTarget[t]=e}getBaseTarget(t){var e;const{initial:n}=this.props;let s;if("string"==typeof n||"object"==typeof n){const i=Cn(this.props,n,null===(e=this.presenceContext)||void 0===e?void 0:e.custom);i&&(s=i[t])}if(n&&void 0!==s)return s;const i=this.getBaseTargetFromProps(this.props,t);return void 0===i||N(i)?void 0!==this.initialValues[t]&&void 0===s?void 0:this.baseTarget[t]:i}on(t,n){return this.events[t]||(this.events[t]=new e),this.events[t].add(n)}notify(t,...e){this.events[t]&&this.events[t].notify(...e)}}class Qn extends Jn{constructor(){super(...arguments),this.KeyframeResolver=Ie}sortInstanceNodePosition(t,e){return 2&t.compareDocumentPosition(e)?1:-1}getBaseTargetFromProps(t,e){return t.style?t.style[e]:void 0}removeValueFromRenderState(t,{vars:e,style:n}){delete e[t],delete n[t]}}const ts=(t,e)=>e&&"number"==typeof t?e.transform(t):t,es={x:"translateX",y:"translateY",z:"translateZ",transformPerspective:"perspective"},ns=st.length;function ss(t,e,n){const{style:s,vars:i,transformOrigin:r}=t;let o=!1,a=!1;for(const t in e){const n=e[t];if(it.has(t))o=!0;else if(Mt(t))i[t]=n;else{const e=ts(n,Ce[t]);t.startsWith("origin")?(a=!0,r[t]=e):s[t]=e}}if(e.transform||(o||n?s.transform=function(t,e,n){let s="",i=!0;for(let r=0;r<ns;r++){const o=st[r],a=t[o];if(void 0===a)continue;let l=!0;if(l="number"==typeof a?a===(o.startsWith("scale")?1:0):0===parseFloat(a),!l||n){const t=ts(a,Ce[o]);l||(i=!1,s+=`${es[o]||o}(${t}) `),n&&(e[o]=t)}}return s=s.trim(),n?s=n(e,i?"":s):i&&(s="none"),s}(e,t.transform,n):s.transform&&(s.transform="none")),a){const{originX:t="50%",originY:e="50%",originZ:n=0}=r;s.transformOrigin=`${t} ${e} ${n}`}}function is(t,e,n){return"string"==typeof t?t:Dt.transform(e+n*t)}const rs={offset:"stroke-dashoffset",array:"stroke-dasharray"},os={offset:"strokeDashoffset",array:"strokeDasharray"};function as(t,{attrX:e,attrY:n,attrScale:s,originX:i,originY:r,pathLength:o,pathSpacing:a=1,pathOffset:l=0,...u},c,h){if(ss(t,u,h),c)return void(t.style.viewBox&&(t.attrs.viewBox=t.style.viewBox));t.attrs=t.style,t.style={};const{attrs:d,style:p,dimensions:f}=t;d.transform&&(f&&(p.transform=d.transform),delete d.transform),f&&(void 0!==i||void 0!==r||p.transform)&&(p.transformOrigin=function(t,e,n){return`${is(e,t.x,t.width)} ${is(n,t.y,t.height)}`}(f,void 0!==i?i:.5,void 0!==r?r:.5)),void 0!==e&&(d.x=e),void 0!==n&&(d.y=n),void 0!==s&&(d.scale=s),void 0!==o&&function(t,e,n=1,s=0,i=!0){t.pathLength=1;const r=i?rs:os;t[r.offset]=Dt.transform(-s);const o=Dt.transform(e),a=Dt.transform(n);t[r.array]=`${o} ${a}`}(d,o,a,l,!1)}const ls=new Set(["baseFrequency","diffuseConstant","kernelMatrix","kernelUnitLength","keySplines","keyTimes","limitingConeAngle","markerHeight","markerWidth","numOctaves","targetX","targetY","surfaceScale","specularConstant","specularExponent","stdDeviation","tableValues","viewBox","gradientTransform","pathLength","startOffset","textLength","lengthAdjust"]);function us(t,{style:e,vars:n},s,i){Object.assign(t.style,e,i&&i.getProjectionStyles(s));for(const e in n)t.style.setProperty(e,n[e])}class cs extends Qn{constructor(){super(...arguments),this.type="svg",this.isSVGTag=!1,this.measureInstanceViewportBox=Zn}getBaseTargetFromProps(t,e){return t[e]}readValueFromInstance(t,e){if(it.has(e)){const t=Oe(e);return t&&t.default||0}return e=ls.has(e)?e:Rn(e),t.getAttribute(e)}scrapeMotionValuesFromProps(t,e,n){return function(t,e,n){const s=$n(t,e,n);for(const n in t)(N(t[n])||N(e[n]))&&(s[-1!==st.indexOf(n)?"attr"+n.charAt(0).toUpperCase()+n.substring(1):n]=t[n]);return s}(t,e,n)}build(t,e,n){as(t,e,this.isSVGTag,n.transformTemplate)}renderInstance(t,e,n,s){!function(t,e,n,s){us(t,e,void 0,s);for(const n in e.attrs)t.setAttribute(ls.has(n)?n:Rn(n),e.attrs[n])}(t,e,0,s)}mount(t){var e;this.isSVGTag="string"==typeof(e=t.tagName)&&"svg"===e.toLowerCase(),super.mount(t)}}class hs extends Qn{constructor(){super(...arguments),this.type="html",this.renderInstance=us}readValueFromInstance(t,e){if(it.has(e)){const t=Oe(e);return t&&t.default||0}{const s=(n=t,window.getComputedStyle(n)),i=(Mt(e)?s.getPropertyValue(e):s[e])||0;return"string"==typeof i?i.trim():i}var n}measureInstanceViewportBox(t,{transformPagePoint:e}){return function(t,e){return function({top:t,left:e,right:n,bottom:s}){return{x:{min:e,max:n},y:{min:t,max:s}}}(function(t,e){if(!e)return t;const n=e({x:t.left,y:t.top}),s=e({x:t.right,y:t.bottom});return{top:n.y,left:n.x,bottom:s.y,right:s.x}}(t.getBoundingClientRect(),e))}(t,e)}build(t,e,n){ss(t,e,n.transformTemplate)}scrapeMotionValuesFromProps(t,e,n){return $n(t,e,n)}handleChildMotionValue(){this.childSubscription&&(this.childSubscription(),delete this.childSubscription);const{children:t}=this.props;N(t)&&(this.childSubscription=t.on("change",(t=>{this.current&&(this.current.textContent=`${t}`)})))}}class ds extends Jn{constructor(){super(...arguments),this.type="object"}readValueFromInstance(t,e){if(function(t,e){return t in e}(e,t)){const n=t[e];if("string"==typeof n||"number"==typeof n)return n}}getBaseTargetFromProps(){}removeValueFromRenderState(t,e){delete e.output[t]}measureInstanceViewportBox(){return{x:{min:0,max:0},y:{min:0,max:0}}}build(t,e){Object.assign(t.output,e)}renderInstance(t,{output:e}){Object.assign(t,e)}sortInstanceNodePosition(){return 0}}function ps(t){const e={presenceContext:null,props:{},visualState:{renderState:{transform:{},transformOrigin:{},style:{},vars:{},attrs:{}},latestValues:{}}},n=function(t){return t instanceof SVGElement&&"svg"!==t.tagName}(t)?new cs(e):new hs(e);n.mount(t),nt.set(t,n)}function fs(t){const e=new ds({presenceContext:null,props:{},visualState:{renderState:{output:{}},latestValues:{}}});e.mount(t),nt.set(t,e)}function ms(t,e,n,s){const i=[];if(function(t,e){return N(t)||"number"==typeof t||"string"==typeof t&&!$(e)}(t,e))i.push(function(t,e,n){const s=N(t)?t:f(t);return s.start(Pn("",s,e,n)),s.animation}(t,$(e)&&e.default||e,n&&n.default||n));else{const r=j(t,e,s),o=r.length;for(let t=0;t<o;t++){const s=r[t],a=s instanceof Element?ps:fs;nt.has(s)||a(s);const l=nt.get(s),u={...n};"delay"in u&&"function"==typeof u.delay&&(u.delay=u.delay(t,o)),i.push(...Dn(l,{...e,transition:u},{}))}}return i}function gs(t,e,n){const s=[],i=function(t,{defaultTransition:e={},...n}={},s,i){const r=e.duration||.3,o=new Map,a=new Map,l={},u=new Map;let c=0,h=0,d=0;for(let n=0;n<t.length;n++){const o=t[n];if("string"==typeof o){u.set(o,h);continue}if(!Array.isArray(o)){u.set(o.name,H(h,o.at,c,u));continue}let[p,f,m={}]=o;void 0!==m.at&&(h=H(h,m.at,c,u));let g=0;const y=(t,n,s,o=0,a=0)=>{const l=J(t),{delay:u=0,times:c=D(l),type:p="keyframes",...f}=n;let{ease:m=e.ease||"easeOut",duration:y}=n;const w="function"==typeof u?u(o,a):u,b=l.length,T=z(p)?p:null==i?void 0:i[p];if(b<=2&&T){let t=100;if(2===b&&et(l)){const e=l[1]-l[0];t=Math.abs(e)}const e={...f};void 0!==y&&(e.duration=v(y));const n=B(e,t,T);m=n.ease,y=n.duration}null!=y||(y=r);const x=h+w,S=x+y;1===c.length&&0===c[0]&&(c[1]=1);const V=c.length-l.length;V>0&&W(c,V),1===l.length&&l.unshift(null),X(s,l,m,c,x,S),g=Math.max(w+y,g),d=Math.max(S,d)};if(N(p))y(f,m,_("default",Z(p,a)));else{const t=j(p,f,s,l),e=t.length;for(let n=0;n<e;n++){const s=Z(t[n],a);for(const t in f)y(f[t],Q(m,t),_(t,s),n,e)}}c=h,h+=g}return a.forEach(((t,s)=>{for(const i in t){const r=t[i];r.sort(G);const a=[],l=[],u=[];for(let t=0;t<r.length;t++){const{at:e,value:n,easing:s}=r[t];a.push(n),l.push(L(0,d,e)),u.push(s||"easeOut")}0!==l[0]&&(l.unshift(0),a.unshift(a[0]),u.unshift("easeInOut")),1!==l[l.length-1]&&(l.push(1),a.push(null)),o.has(s)||o.set(s,{keyframes:{},transition:{}});const c=o.get(s);c.keyframes[i]=a,c.transition[i]={...e,duration:d,ease:u,times:l,...n}}})),o}(t,e,n,{spring:E});return i.forEach((({keyframes:t,transition:e},n)=>{s.push(...ms(n,t,e))})),s}function ys(t){return function(e,n,s){let i=[];var r;r=e,i=Array.isArray(r)&&Array.isArray(r[0])?gs(e,n,t):ms(e,n,s,t);const o=new y(i);return t&&t.animations.push(o),o}}const vs=ys();function ws(t,e,n){t.style.setProperty(`--${e}`,n)}function bs(t,e,n){t.style[e]=n}const Ts=m((()=>{try{document.createElement("div").animate({opacity:[1]})}catch(t){return!1}return!0})),xs=new WeakMap;const Ss="easeOut";function Vs(t){const e=xs.get(t)||new Map;return xs.set(t,e),xs.get(t)}class As{constructor(t,e,n,s){const i=e.startsWith("--");this.setValue=i?ws:bs,this.options=s,this.updateFinishedPromise(),x("string"!=typeof s.type);const r=Vs(t).get(e);r&&r.stop();if(Array.isArray(n)||(n=[n]),function(t,e,n){for(let s=0;s<e.length;s++)null===e[s]&&(e[s]=0===s?n():e[s-1]),"number"==typeof e[s]&&Pe[t]&&(e[s]=Pe[t].transform(e[s]));!Ts()&&e.length<2&&e.unshift(n())}(e,n,(()=>e.startsWith("--")?t.style.getPropertyValue(e):window.getComputedStyle(t)[e])),z(s.type)){const t=B(s,100,s.type);s.ease=yn()?t.ease:Ss,s.duration=v(t.duration),s.type="keyframes"}else s.ease=s.ease||Ss;this.removeAnimation=()=>{var n;return null===(n=xs.get(t))||void 0===n?void 0:n.delete(e)};const o=()=>{this.setValue(t,e,ht(n,this.options)),this.cancel(),this.resolveFinishedPromise()};Vn()?(this.animation=xn(t,e,n,s),!1===s.autoplay&&this.animation.pause(),this.animation.onfinish=o,this.pendingTimeline&&Sn(this.animation,this.pendingTimeline),Vs(t).set(e,this)):o()}get duration(){return w(this.options.duration||300)}get time(){var t;return this.animation?w((null===(t=this.animation)||void 0===t?void 0:t.currentTime)||0):0}set time(t){this.animation&&(this.animation.currentTime=v(t))}get speed(){return this.animation?this.animation.playbackRate:1}set speed(t){this.animation&&(this.animation.playbackRate=t)}get state(){return this.animation?this.animation.playState:"finished"}get startTime(){return this.animation?this.animation.startTime:null}flatten(){var t;this.animation&&(null===(t=this.animation.effect)||void 0===t||t.updateTiming({easing:"linear"}))}play(){"finished"===this.state&&this.updateFinishedPromise(),this.animation&&this.animation.play()}pause(){this.animation&&this.animation.pause()}stop(){this.animation&&"idle"!==this.state&&"finished"!==this.state&&(this.animation.commitStyles&&this.animation.commitStyles(),this.cancel())}complete(){this.animation&&this.animation.finish()}cancel(){this.removeAnimation();try{this.animation&&this.animation.cancel()}catch(t){}}then(t,e){return this.currentFinishedPromise.then(t,e)}updateFinishedPromise(){this.currentFinishedPromise=new Promise((t=>{this.resolveFinishedPromise=t}))}attachTimeline(t){return this.animation?Sn(this.animation,t):this.pendingTimeline=t,i}}const Ms=t=>function(e,n,s){return new y(function(t,e,n,s){const i=K(t,s),r=i.length,o=[];for(let t=0;t<r;t++){const s=i[t],a={...n};"function"==typeof a.delay&&(a.delay=a.delay(t,r));for(const t in e){const n=e[t],i={...ut(a,t)};i.duration=i.duration?v(i.duration):i.duration,i.delay=v(i.delay||0),o.push(new As(s,t,n,i))}}return o}(e,n,s,t))},Ps=Ms(),ks=new WeakMap;let Fs;function Cs({target:t,contentRect:e,borderBoxSize:n}){var s;null===(s=ks.get(t))||void 0===s||s.forEach((s=>{s({target:t,contentSize:e,get size(){return function(t,e){if(e){const{inlineSize:t,blockSize:n}=e[0];return{width:t,height:n}}return t instanceof SVGElement&&"getBBox"in t?t.getBBox():{width:t.offsetWidth,height:t.offsetHeight}}(t,n)}})}))}function Es(t){t.forEach(Cs)}function Os(t,e){Fs||"undefined"!=typeof ResizeObserver&&(Fs=new ResizeObserver(Es));const n=K(t);return n.forEach((t=>{let n=ks.get(t);n||(n=new Set,ks.set(t,n)),n.add(e),null==Fs||Fs.observe(t)})),()=>{n.forEach((t=>{const n=ks.get(t);null==n||n.delete(e),(null==n?void 0:n.size)||null==Fs||Fs.unobserve(t)}))}}const Rs=new Set;let Bs;function Is(t){return Rs.add(t),Bs||(Bs=()=>{const t={width:window.innerWidth,height:window.innerHeight},e={target:window,size:t,contentSize:t};Rs.forEach((t=>t(e)))},window.addEventListener("resize",Bs)),()=>{Rs.delete(t),!Rs.size&&Bs&&(Bs=void 0)}}const Ls={x:{length:"Width",position:"Left"},y:{length:"Height",position:"Top"}};function Ws(t,e,s,i){const r=s[e],{length:o,position:a}=Ls[e],l=r.current,u=s.time;r.current=t[`scroll${a}`],r.scrollLength=t[`scroll${o}`]-t[`client${o}`],r.offset.length=0,r.offset[0]=0,r.offset[1]=r.scrollLength,r.progress=L(0,r.scrollLength,r.current);const c=i-u;r.velocity=c>50?0:n(r.current-l,c)}const Ds={Enter:[[0,1],[1,1]],Exit:[[0,0],[1,0]],Any:[[1,0],[0,1]],All:[[0,0],[1,1]]},Ns={start:0,center:.5,end:1};function Ks(t,e,n=0){let s=0;if(t in Ns&&(t=Ns[t]),"string"==typeof t){const e=parseFloat(t);t.endsWith("px")?s=e:t.endsWith("%")?t=e/100:t.endsWith("vw")?s=e/100*document.documentElement.clientWidth:t.endsWith("vh")?s=e/100*document.documentElement.clientHeight:t=e}return"number"==typeof t&&(s=e*t),n+s}const $s=[0,0];function js(t,e,n,s){let i=Array.isArray(t)?t:$s,r=0,o=0;return"number"==typeof t?i=[t,t]:"string"==typeof t&&(i=(t=t.trim()).includes(" ")?t.split(" "):[t,Ns[t]?t:"0"]),r=Ks(i[0],n,s),o=Ks(i[1],e),r-o}const zs={x:0,y:0};function Hs(t,e,n){const{offset:s=Ds.All}=n,{target:i=t,axis:r="y"}=n,o="y"===r?"height":"width",a=i!==t?function(t,e){const n={x:0,y:0};let s=t;for(;s&&s!==e;)if(s instanceof HTMLElement)n.x+=s.offsetLeft,n.y+=s.offsetTop,s=s.offsetParent;else if("svg"===s.tagName){const t=s.getBoundingClientRect();s=s.parentElement;const e=s.getBoundingClientRect();n.x+=t.left-e.left,n.y+=t.top-e.top}else{if(!(s instanceof SVGGraphicsElement))break;{const{x:t,y:e}=s.getBBox();n.x+=t,n.y+=e;let i=null,r=s.parentNode;for(;!i;)"svg"===r.tagName&&(i=r),r=s.parentNode;s=i}}return n}(i,t):zs,l=i===t?{width:t.scrollWidth,height:t.scrollHeight}:function(t){return"getBBox"in t&&"svg"!==t.tagName?t.getBBox():{width:t.clientWidth,height:t.clientHeight}}(i),u={width:t.clientWidth,height:t.clientHeight};e[r].offset.length=0;let c=!e[r].interpolate;const h=s.length;for(let t=0;t<h;t++){const n=js(s[t],u[o],l[o],a[r]);c||n===e[r].interpolatorOffsets[t]||(c=!0),e[r].offset[t]=n}c&&(e[r].interpolate=ln(e[r].offset,D(s)),e[r].interpolatorOffsets=[...e[r].offset]),e[r].progress=e[r].interpolate(e[r].current)}function Us(t,e,n,s={}){return{measure:()=>function(t,e=t,n){if(n.x.targetOffset=0,n.y.targetOffset=0,e!==t){let s=e;for(;s&&s!==t;)n.x.targetOffset+=s.offsetLeft,n.y.targetOffset+=s.offsetTop,s=s.offsetParent}n.x.targetLength=e===t?e.scrollWidth:e.clientWidth,n.y.targetLength=e===t?e.scrollHeight:e.clientHeight,n.x.containerLength=t.clientWidth,n.y.containerLength=t.clientHeight}(t,s.target,n),update:e=>{!function(t,e,n){Ws(t,"x",e,n),Ws(t,"y",e,n),e.time=n}(t,n,e),(s.offset||s.target)&&Hs(t,n,s)},notify:()=>e(n)}}const Ys=new WeakMap,qs=new WeakMap,Xs=new WeakMap,Gs=t=>t===document.documentElement?window:t;function Zs(t,{container:e=document.documentElement,...n}={}){let s=Xs.get(e);s||(s=new Set,Xs.set(e,s));const i=Us(e,t,{time:0,x:{current:0,offset:[],progress:0,scrollLength:0,targetOffset:0,targetLength:0,containerLength:0,velocity:0},y:{current:0,offset:[],progress:0,scrollLength:0,targetOffset:0,targetLength:0,containerLength:0,velocity:0}},n);if(s.add(i),!Ys.has(e)){const t=()=>{for(const t of s)t.measure()},n=()=>{for(const t of s)t.update(l.timestamp)},i=()=>{for(const t of s)t.notify()},a=()=>{o.read(t,!1,!0),o.read(n,!1,!0),o.update(i,!1,!0)};Ys.set(e,a);const c=Gs(e);window.addEventListener("resize",a,{passive:!0}),e!==document.documentElement&&qs.set(e,(u=a,"function"==typeof(r=e)?Is(r):Os(r,u))),c.addEventListener("scroll",a,{passive:!0})}var r,u;const c=Ys.get(e);return o.read(c,!1,!0),()=>{var t;a(c);const n=Xs.get(e);if(!n)return;if(n.delete(i),n.size)return;const s=Ys.get(e);Ys.delete(e),s&&(Gs(e).removeEventListener("scroll",s),null===(t=qs.get(e))||void 0===t||t(),window.removeEventListener("resize",s))}}function _s(t,e){let n;const s=()=>{const{currentTime:s}=e,i=(null===s?0:s.value)/100;n!==i&&t(i),n=i};return o.update(s,!0),()=>a(s)}const Js=new Map;function Qs({source:t,container:e=document.documentElement,axis:n="y"}={}){t&&(e=t),Js.has(e)||Js.set(e,{});const s=Js.get(e);return s[n]||(s[n]=g()?new ScrollTimeline({source:e,axis:n}):function({source:t,container:e,axis:n="y"}){t&&(e=t);const s={value:0},i=Zs((t=>{s.value=100*t[n].progress}),{container:e,axis:n});return{currentTime:s,cancel:i}}({source:e,axis:n})),s[n]}function ti(t){return t&&(t.target||t.offset)}function ei(t,{axis:e="y",...n}={}){const s={axis:e,...n};return"function"==typeof t?function(t,e){return function(t){return 2===t.length}(t)||ti(e)?Zs((n=>{t(n[e.axis].progress,n)}),e):_s(t,Qs(e))}(t,s):function(t,e){if(t.flatten(),ti(e))return t.pause(),Zs((n=>{t.time=t.duration*n[e.axis].progress}),e);{const n=Qs(e);return t.attachTimeline?t.attachTimeline(n,(t=>(t.pause(),_s((e=>{t.time=t.duration*e}),n)))):i}}(t,s)}const ni={some:0,all:1};function si(t,e,{root:n,margin:s,amount:i="some"}={}){const r=K(t),o=new WeakMap,a=new IntersectionObserver((t=>{t.forEach((t=>{const n=o.get(t.target);if(t.isIntersecting!==Boolean(n))if(t.isIntersecting){const n=e(t);"function"==typeof n?o.set(t.target,n):a.unobserve(t.target)}else n&&(n(t),o.delete(t.target))}))}),{root:n,rootMargin:s,threshold:"number"==typeof i?i:ni[i]});return r.forEach((t=>a.observe(t))),()=>a.disconnect()}function ii(t,e="end"){return n=>{const s=(n="end"===e?Math.min(n,.999):Math.max(n,.001))*t,i="end"===e?Math.floor(s):Math.ceil(s);return S(0,1,i/t)}}function ri(t=.1,{startDelay:e=0,from:n=0,ease:s}={}){return(i,r)=>{const o="number"==typeof n?n:function(t,e){if("first"===t)return 0;{const n=e-1;return"last"===t?n:n/2}}(n,r),a=Math.abs(o-i);let l=t*a;if(s){const e=r*t;l=Ue(s)(l/e)*e}return e+l}}function oi(...t){const e=!Array.isArray(t[0]),n=e?0:-1,s=t[0+n],i=t[1+n],r=t[2+n],o=t[3+n],a=ln(i,r,{mixer:(l=r[0],(t=>t&&"object"==typeof t&&t.mix)(l)?l.mix:void 0),...o});var l;return e?a(s):a}function ai(t,e){return function(t,e){const n=d.now(),s=({timestamp:i})=>{const r=i-n;r>=e&&(a(s),t(r-e))};return o.read(s,!0),()=>a(s)}(t,v(e))}const li=(t,e)=>Math.abs(t-e);function ui(t,e){const n=li(t.x,e.x),s=li(t.y,e.y);return Math.sqrt(n**2+s**2)}const ci=o,hi=r.reduce(((t,e)=>(t[e]=t=>a(t),t)),{});export{p as MotionValue,vs as animate,Ps as animateMini,wt as anticipate,yt as backIn,vt as backInOut,gt as backOut,a as cancelFrame,hi as cancelSync,bt as circIn,xt as circInOut,Tt as circOut,S as clamp,ys as createScopedAnimate,pt as cubicBezier,ai as delay,li as distance,ui as distance2D,Ke as easeIn,je as easeInOut,$e as easeOut,o as frame,l as frameData,u as frameSteps,si as inView,Ne as inertia,ln as interpolate,x as invariant,un as keyframes,ft as mirrorEasing,an as mix,f as motionValue,qe as pipe,L as progress,mt as reverseEasing,ei as scroll,Zs as scrollInfo,E as spring,ri as stagger,ii as steps,ci as sync,oi as transform,T as warning,U as wrap};export default null;
//# sourceMappingURL=/sm/d5d4bfa5a3f292edc74ce2b856e25f03ebbfa4c4c22aa37e3f75ca1dabe374b0.mapassets/template-swiss.html›
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>[必填] 替换为 PPT 标题 · Deck Title</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@200;300;400;500;600;700;800;900&family=JetBrains+Mono:wght@300;400;500;600&family=Noto+Sans+SC:wght@200;300;400;500;700;900&display=swap" rel="stylesheet">
<style>
:root{
/* ============ 主题色(默认: 🔵 克莱因蓝 IKB) ============
切换主题: 从 references/themes-swiss.md 复制对应的 :root 块
整体替换标有"主题色"注释的所有变量,其他散落的 var() 引用无需逐处改 */
--paper:#fafaf8; /* 主底色: 高级灰白 */
--paper-rgb:250,250,248;
--ink:#0a0a0a; /* 文字主色: 近黑 */
--ink-rgb:10,10,10;
--grey-1:#f0f0ee; /* 浅灰底 */
--grey-2:#d4d4d2; /* 中灰(分割线) */
--grey-3:#737373; /* 暗灰(辅助文字) */
--accent:#002FA7; /* 高亮色: 克莱因蓝 IKB */
--accent-rgb:0,47,167;
--accent-on:#ffffff; /* accent 上的反色文字 */
--accent-bright:#5B7BFF; /* 暗底高亮: IKB 提亮版 */
/* ============ Carbon 文本角色 token (role-based,代替 opacity) ============
亮底: primary 主文 / secondary 次文 / helper 辅助 / placeholder 占位
暗底: inverse 自动反向 */
--text-primary:#0a0a0a; /* = ink, 100% */
--text-secondary:#525252; /* gray 70 */
--text-helper:#737373; /* gray 60 */
--text-placeholder:#a3a3a3; /* gray 40 */
--text-on-color:#ffffff; /* accent/dark 反色 */
--border-subtle:#e0e0e0; /* gray 20, 极细分隔 */
--border-strong:#a3a3a3; /* gray 40, 强分隔 */
/* ============ 字体(跨主题固定) ============ */
--sans:"Inter","Helvetica Neue","Helvetica","Arial","Segoe UI Variable","Segoe UI",system-ui,-apple-system,sans-serif;
--sans-zh:"PingFang SC","Hiragino Sans GB","Source Han Sans SC","Noto Sans SC","Microsoft YaHei UI","Microsoft YaHei","微软雅黑",sans-serif;
--mono:"JetBrains Mono","IBM Plex Mono","SF Mono","Cascadia Code","Consolas","Courier New",ui-monospace,monospace;
/* ============ Carbon 2x Grid 间距模数 (基础 8px) ============
参考: https://carbondesignsystem.com/elements/2x-grid/overview/
任何 padding/gap/margin 优先用这套 token,确保 8px 基线对齐 */
--sp-3:8px; /* 02 token */
--sp-4:12px; /* 03 */
--sp-5:16px; /* 04 */
--sp-6:24px; /* 05 */
--sp-7:32px; /* 06 */
--sp-8:40px; /* 07 */
--sp-9:48px; /* 08 */
--sp-10:64px; /* 09 */
--sp-11:80px; /* 10 */
--sp-12:96px; /* 11 */
--sp-13:160px; /* 12 */
/* ============ Carbon Motion tokens ============
https://carbondesignsystem.com/guidelines/motion/overview/
两套体系:productive (功能) 短 + 锐 / expressive (叙事) 长 + 软 */
--ease-prod:cubic-bezier(.2,0,.38,.9); /* productive standard */
--ease-exp:cubic-bezier(.4,.14,.3,1); /* expressive standard */
--ease-entry-prod:cubic-bezier(0,0,.38,.9); /* productive entrance */
--ease-entry-exp:cubic-bezier(0,0,.3,1); /* expressive entrance */
--dur-fast-1:.07s; /* 70ms */
--dur-fast-2:.11s; /* 110ms */
--dur-mod-1:.15s; /* 150ms */
--dur-mod-2:.24s; /* 240ms */
--dur-slow-1:.4s; /* 400ms */
--dur-slow-2:.7s; /* 700ms */
/* 底部分页组件安全区: 主内容最低处不要进入这条区域 */
--nav-safe-bottom:8vh;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{
width:100%;height:100%;overflow:hidden;
background:var(--paper);color:var(--ink);
font-family:var(--sans),var(--sans-zh);
font-feature-settings:"ss01","cv11";
-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility
}
/* ============ WebGL 网格背景 ============ */
canvas.bg{position:fixed;inset:0;width:100vw;height:100vh;z-index:0;display:block;opacity:.55;mix-blend-mode:multiply;pointer-events:none}
body.dark-bg canvas.bg{mix-blend-mode:screen;opacity:.42}
body.low-power canvas.bg,
body.low-power canvas.ascii-bg{display:none!important}
/* ============ Deck 容器 + 翻页 ============ */
#deck{position:fixed;inset:0;width:10000vw;height:100vh;display:flex;flex-wrap:nowrap;transition:transform .9s cubic-bezier(.77,0,.175,1);z-index:10;will-change:transform}
.slide{
width:100vw;height:100vh;flex:0 0 100vw;
position:relative;
padding:5.5vh 5vw 7vh 5vw;
display:flex;flex-direction:column;
overflow:hidden;
background:var(--paper);color:var(--ink);
}
.slide.grey{background:var(--grey-1)}
.slide.dark{background:var(--ink);color:var(--paper)}
.slide.dark .grey-only{display:none}
.slide.accent{background:var(--accent);color:var(--accent-on)}
.slide.accent .accent-block{background:var(--accent-on);color:var(--accent)}
/* Hero 页透出网格背景多一点 */
.slide.hero{background:transparent}
.slide.hero.grey{background:rgba(var(--paper-rgb),.86);backdrop-filter:blur(1px)}
.slide.hero.dark{background:rgba(var(--ink-rgb),.92);color:var(--paper)}
/* ============ 装饰: 极细分隔线 + 点阵矩阵 ============ */
.rule{width:100%;height:1px;background:currentColor;opacity:.18;margin:0}
.rule.thick{height:2px;opacity:.85}
.rule.accent{background:var(--accent);opacity:1;height:2px}
.rule.v{width:1px;height:100%;margin:0}
/* 点阵矩阵 (dot matrix) - 用 radial-gradient */
.dots{
background-image:radial-gradient(currentColor 1px, transparent 1px);
background-size:12px 12px;
background-position:0 0;
opacity:.18;
}
.dots-fine{
background-image:radial-gradient(currentColor 0.8px, transparent 0.8px);
background-size:8px 8px;
opacity:.14;
}
.dots-bold{
background-image:radial-gradient(currentColor 1.4px, transparent 1.4px);
background-size:18px 18px;
opacity:.22;
}
/* ============ Chrome (顶部 meta) + Foot (底部) ============ */
.chrome{
display:flex;justify-content:space-between;align-items:flex-start;
font-family:var(--mono);
font-size:14px;letter-spacing:.16em;text-transform:uppercase;
opacity:.7;margin-bottom:auto
}
.chrome .l,.chrome .r{display:flex;gap:1.6em;align-items:center}
.chrome .sep{width:24px;height:1px;background:currentColor;opacity:.5}
.foot{
margin-top:auto;
display:flex;justify-content:space-between;align-items:flex-end;
font-family:var(--mono);
font-size:14px;letter-spacing:.14em;text-transform:uppercase;
opacity:.55;
padding-top:2vh;
border-top:1px solid currentColor;
border-color:rgba(127,127,127,.25)
}
.foot .nb{font-family:var(--sans);font-weight:600;letter-spacing:.04em}
/* ============ Tag / Kicker / Meta 标签 ============ */
.kicker{
font-family:var(--mono);
font-size:14px;letter-spacing:.24em;text-transform:uppercase;
opacity:.65;margin-bottom:2.4vh;
display:inline-flex;align-items:center;gap:.8em
}
.kicker::before{
content:"";width:24px;height:1px;background:currentColor;opacity:.6
}
.kicker.no-line::before{display:none}
.kicker.accent{color:var(--accent);opacity:1;font-weight:600}
.tag{
display:inline-block;
font-family:var(--mono);
font-size:14px;letter-spacing:.18em;text-transform:uppercase;
padding:5px 10px;border:1px solid currentColor;opacity:.85
}
.tag.solid{background:currentColor;color:var(--paper);border-color:transparent}
.tag.accent{background:var(--accent);color:var(--accent-on);border-color:transparent;opacity:1}
/* ============ 标题层级 (无衬线 · 极轻字重 · 极致字号对比) ============ */
.h-hero{
font-family:var(--sans),var(--sans-zh);
font-weight:200;
font-size:11vw;
line-height:.92;
letter-spacing:-.04em;
}
.h-hero-zh{
font-family:var(--sans),var(--sans-zh);
font-weight:200;
font-size:8.4vw;
line-height:.96;
letter-spacing:-.025em;
}
.h-xl{
font-family:var(--sans),var(--sans-zh);
font-weight:200;
font-size:6vw;
line-height:1;
letter-spacing:-.03em;
}
.h-xl-zh{
font-family:var(--sans),var(--sans-zh);
font-weight:200;
font-size:5vw;
line-height:1.05;
letter-spacing:-.025em;
}
.h-md{
font-family:var(--sans),var(--sans-zh);
font-weight:300;
font-size:2.6vw;
line-height:1.18;
letter-spacing:-.015em;
}
.h-sub{
font-family:var(--sans),var(--sans-zh);
font-weight:400;
font-size:2.2vw;
line-height:1.3;
letter-spacing:-.01em;
opacity:.7;
}
/* ============ 正文 / 引语 / 元数据 ============ */
.lead{
font-family:var(--sans),var(--sans-zh);
font-weight:400;
font-size:1.55vw;
line-height:1.4;
letter-spacing:-.005em;
opacity:.86;
}
.body{
font-family:var(--sans),var(--sans-zh);
font-weight:400;
font-size:max(18px,1.08vw);
line-height:1.6;
letter-spacing:0;
opacity:.78;
}
.body-sm{
font-family:var(--sans),var(--sans-zh);
font-weight:400;
font-size:max(16px,.92vw);
line-height:1.55;
opacity:.7;
}
.meta{
font-family:var(--mono);
font-size:max(14px,.82vw);
letter-spacing:.18em;text-transform:uppercase;
opacity:.6;
}
.meta-row{
display:flex;gap:1.4em;align-items:baseline;flex-wrap:wrap;
font-family:var(--mono);
font-size:max(14px,.88vw);letter-spacing:.16em;text-transform:uppercase;
opacity:.65;
}
.meta-row span:not(.dot){display:inline-block}
.meta-row .dot{
display:inline-block;width:4px;height:4px;border-radius:50%;
background:currentColor;opacity:.5;vertical-align:middle
}
/* ============ KPI Hero (视觉英雄数据) ============ */
.kpi-hero{
font-family:var(--sans);
font-weight:800;
font-size:22vw;
line-height:.82;
letter-spacing:-.05em;
font-feature-settings:"tnum","ss01";
}
.kpi-hero .unit{
font-family:var(--sans),var(--sans-zh);
font-weight:500;
font-size:.18em;
letter-spacing:0;
opacity:.5;
margin-left:.12em;
vertical-align:.5em;
}
.kpi-hero.accent{color:var(--accent)}
.kpi-big{
font-family:var(--sans);
font-weight:800;
font-size:11vw;
line-height:.85;
letter-spacing:-.04em;
font-feature-settings:"tnum"
}
.kpi-mid{
font-family:var(--sans);
font-weight:700;
font-size:6vw;
line-height:.88;
letter-spacing:-.03em;
font-feature-settings:"tnum"
}
/* ============ Stat Card (数据卡片 · 极简) ============ */
.stat-card{
display:flex;flex-direction:column;
gap:.6vh;align-items:flex-start;
padding-top:1.6vh;
border-top:2px solid currentColor;
}
.stat-card.thin{border-top-width:1px;border-color:rgba(127,127,127,.4)}
.stat-card.accent-top{border-top-color:var(--accent);border-top-width:3px}
.stat-card .stat-label{
font-family:var(--mono);
font-size:max(14px,.82vw);
letter-spacing:.24em;text-transform:uppercase;
opacity:.6;
}
.stat-card .stat-nb{
font-family:var(--sans);
font-weight:800;
font-size:5.6vw;
line-height:.88;
letter-spacing:-.035em;
font-feature-settings:"tnum";
margin-top:.4vh;
}
.stat-card .stat-nb .stat-unit{
font-family:var(--sans),var(--sans-zh);
font-weight:500;
font-size:.32em;
letter-spacing:0;
opacity:.6;
margin-left:.14em;
vertical-align:.4em;
}
.stat-card .stat-note{
font-family:var(--sans),var(--sans-zh);
font-weight:400;
font-size:max(16px,.98vw);
line-height:1.5;
opacity:.7;
margin-top:.6vh;
}
.grid-4 .stat-card .stat-nb{font-size:4.6vw}
.grid-3 .stat-card .stat-nb{font-size:6.4vw}
.grid-6 .stat-card .stat-nb{font-size:4vw}
/* ============ Accent Block (高亮色块包裹内容) ============ */
.accent-block{
background:var(--accent);color:var(--accent-on);
padding:2.4vh 2vw;
}
.accent-block.tight{padding:1.4vh 1.4vw}
.accent-block .h-md,.accent-block .h-xl,.accent-block .kpi-mid{color:var(--accent-on)}
.ink-block{
background:var(--ink);color:var(--paper);
padding:2.4vh 2vw
}
.grey-block{
background:var(--grey-1);
padding:2.4vh 2vw
}
/* 高亮文字 (mark 风格) */
.mark{
background:var(--accent);color:var(--accent-on);
padding:0 .2em;
box-decoration-break:clone;
-webkit-box-decoration-break:clone;
}
.mark.ink{background:var(--ink);color:var(--paper)}
.underline-accent{
background-image:linear-gradient(to bottom, transparent 70%, var(--accent) 70%, var(--accent) 96%, transparent 96%);
padding:0 .05em
}
/* ============ 网格系统 (模块化网格) ============ */
.frame{flex:1;display:flex;flex-direction:column;min-height:0;overflow:hidden}
.frame.grid-2-7-5,
.frame.grid-2-6-6,
.frame.grid-2-8-4,
.frame.grid-2-4-8,
.frame.grid-3-3,
.frame.grid-12,
.frame.grid-6,
.frame.grid-4,
.frame.grid-3{display:grid}
/* 12 列模块化网格 (瑞士风核心) */
.grid-12{
display:grid;
grid-template-columns:repeat(12,1fr);
gap:2vh 1.2vw;
align-items:start;
}
.span-2{grid-column:span 2}
.span-3{grid-column:span 3}
.span-4{grid-column:span 4}
.span-5{grid-column:span 5}
.span-6{grid-column:span 6}
.span-7{grid-column:span 7}
.span-8{grid-column:span 8}
.span-9{grid-column:span 9}
.span-12{grid-column:span 12}
/* 经典分栏 */
.grid-2-7-5{display:grid;grid-template-columns:7fr 5fr;gap:3vw 4vh;align-items:start}
.grid-2-6-6{display:grid;grid-template-columns:1fr 1fr;gap:3vw 4vh;align-items:start}
.grid-2-8-4{display:grid;grid-template-columns:8fr 4fr;gap:3vw 4vh;align-items:start}
.grid-2-4-8{display:grid;grid-template-columns:4fr 8fr;gap:3vw 4vh;align-items:start}
.grid-3{display:grid;grid-template-columns:repeat(3,1fr);gap:3vw 4vh;align-items:start}
.grid-3-3{display:grid;grid-template-columns:repeat(3,1fr);grid-auto-rows:minmax(0,1fr);gap:2.4vh 2vw}
.grid-4{display:grid;grid-template-columns:repeat(2,1fr);grid-template-rows:repeat(2,1fr);gap:3vh 3vw;flex:1;align-content:center}
.grid-6{display:grid;grid-template-columns:repeat(3,1fr);grid-template-rows:repeat(2,1fr);gap:3vh 2.4vw;flex:1;align-content:center}
/* 工具类 */
.col{display:flex;flex-direction:column;gap:2vh}
.row{display:flex;align-items:center;gap:2vw}
.fill{flex:1}
.center{align-items:center;justify-content:center;text-align:center}
.right{text-align:right;justify-self:end}
.top{align-self:start}
.bottom{align-self:end}
.va-center{align-self:center}
.nowrap{white-space:nowrap}
/* 非对称定位 */
.pos-absolute{position:absolute}
.bottom-left{position:absolute;left:5vw;bottom:8vh;max-width:50vw}
.bottom-right{position:absolute;right:5vw;bottom:8vh;max-width:50vw;text-align:right}
.top-right{position:absolute;right:5vw;top:5.5vh;text-align:right}
.top-left{position:absolute;left:5vw;top:5.5vh}
/* ============ Callout (引用框 · 极简) ============ */
.callout{
padding:2vh 2vw;
border-left:3px solid var(--accent);
font-family:var(--sans),var(--sans-zh);
font-size:max(14px,1vw);
line-height:1.55;
opacity:.9;
}
.callout.ink{border-left-color:currentColor}
.callout .cite,.callout .callout-src{
display:block;margin-top:1.2vh;
font-family:var(--mono);
font-size:14px;letter-spacing:.16em;text-transform:uppercase;
opacity:.6;
}
/* ============ Icons (Lucide via CDN) ============ */
.ico{width:1em;height:1em;display:inline-block;vertical-align:-.12em;stroke:currentColor;fill:none;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round;flex-shrink:0}
.ico-lg,.ico-md,.ico-sm{fill:none;stroke:currentColor;stroke-linecap:round;stroke-linejoin:round}
.ico-lg{width:2.4vw;height:2.4vw;stroke-width:1.4;display:inline-block}
.ico-md{width:1.6vw;height:1.6vw;stroke-width:1.6;display:inline-block;vertical-align:-.4em}
.ico-sm{width:1vw;height:1vw;stroke-width:1.8;display:inline-block;vertical-align:-.15em;opacity:.7}
/* ============ 几何小图标 (装饰) ============ */
.geo-dot{width:.7vw;height:.7vw;border-radius:50%;background:var(--accent);display:inline-block;vertical-align:middle}
.geo-square{width:.7vw;height:.7vw;background:var(--accent);display:inline-block;vertical-align:middle}
.geo-line{width:2vw;height:2px;background:var(--accent);display:inline-block;vertical-align:middle}
.geo-circle-o{width:.9vw;height:.9vw;border:2px solid currentColor;border-radius:50%;display:inline-block;vertical-align:middle}
/* ============ 图片 frame-img ============ */
.frame-img{overflow:hidden;position:relative;background:var(--paper);box-sizing:border-box;width:100%}
.slide.dark .frame-img{background:rgba(255,255,255,.06)}
.frame-img > img{width:100%;height:100%;object-fit:cover;object-position:center center;display:block}
.frame-img.fit-contain > img{object-fit:contain;object-position:center center}
.frame-img.pos-top > img{object-position:top center}
.frame-img.pos-face > img{object-position:center 35%}
.frame-img.r-16x9{aspect-ratio:16/9;max-height:64vh}
.frame-img.r-21x9{aspect-ratio:21/9;max-height:54vh}
.frame-img.r-16x10{aspect-ratio:16/10;max-height:56vh}
.frame-img.r-4x3{aspect-ratio:4/3;max-height:56vh}
.frame-img.r-3x2{aspect-ratio:3/2;max-height:46vh}
.frame-img.r-3x4{aspect-ratio:3/4;max-height:60vh}
.frame-img.r-1x1{aspect-ratio:1/1;max-height:50vh}
.frame-img.h-16{height:16vh}
.frame-img.h-18{height:18vh}
.frame-img.h-22{height:22vh}
.frame-img.h-26{height:26vh}
.frame-img.h-28{height:28vh}
.frame-img.h-32{height:32vh}
.frame-img.swiss-lined{border-top:2px solid var(--accent)}
.frame-img.swiss-keyline{border:0}
/* P22 Image Hero: 下半屏内容区必须和图片拉开距离,不要贴在图下沿 */
.image-hero-body{
display:grid;grid-template-columns:6fr 6fr;gap:3vw;
padding:6.6vh 5vw 4.4vh;flex:1;align-content:start
}
.image-hero-stats{
display:grid;grid-template-columns:repeat(3,1fr);gap:2vw;align-items:start
}
.img-cap{
display:block;margin-top:.8vh;
font-family:var(--mono);
font-size:max(14px,.82vw);
letter-spacing:.2em;text-transform:uppercase;
opacity:.6;
}
figure.frame-img,figure.tile{margin:0;display:flex;flex-direction:column;min-width:0}
figure.tile > .frame-img{flex:0 0 auto}
/* 瑞士风图文混排: 只用直角、发丝线、单一 accent;图像容器不加圆角/阴影 */
.swiss-img-split{display:grid;grid-template-columns:5fr 7fr;gap:3vw;align-items:start;flex:1;min-height:0}
.swiss-img-split.reverse{grid-template-columns:7fr 5fr}
.swiss-img-split.align-bottom{align-items:end}
.swiss-img-split.align-bottom .swiss-img-copy{align-self:end}
.swiss-img-split.align-image-bottom{align-items:end;padding-bottom:var(--nav-safe-bottom)}
.swiss-img-split.align-image-bottom .swiss-img-copy{align-self:end}
.swiss-img-split.align-image-bottom figure.tile{align-self:end;position:relative}
.swiss-img-split.align-image-bottom figure.tile > .swiss-img-caption{
position:absolute;left:0;right:0;top:calc(100% + var(--sp-4));margin-top:0
}
.nav-safe-bottom{padding-bottom:var(--nav-safe-bottom)}
.nav-safe-bottom-tight{padding-bottom:calc(var(--nav-safe-bottom) * .65)}
.swiss-img-copy{display:flex;flex-direction:column;gap:var(--sp-6);min-width:0}
.swiss-img-copy .rule{margin:0}
.swiss-img-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:var(--sp-5);align-items:start;margin-top:var(--sp-7)}
.swiss-img-grid.two{grid-template-columns:repeat(2,1fr)}
.swiss-img-grid.tight{margin-top:0}
.swiss-img-caption{
display:flex;justify-content:space-between;gap:var(--sp-5);
margin-top:var(--sp-4);
font-family:var(--mono);font-size:max(14px,.74vw);
letter-spacing:.16em;text-transform:uppercase;color:var(--text-helper);
border-top:1px solid var(--border-subtle);padding-top:var(--sp-4)
}
.swiss-img-caption strong{
font-family:var(--sans),var(--sans-zh);font-size:max(16px,.9vw);
font-weight:600;letter-spacing:0;text-transform:none;color:var(--text-primary)
}
/* ============ Bar Chart (扁平几何图表) ============ */
.bar-chart{display:flex;flex-direction:column;gap:1.4vh}
.bar-row{display:grid;grid-template-columns:8em 1fr 4em;gap:1.4vw;align-items:center}
.bar-row .bar-label{font-family:var(--mono);font-size:max(14px,.84vw);letter-spacing:.14em;text-transform:uppercase;opacity:.7}
.bar-row .bar-track{height:14px;background:rgba(127,127,127,.18);position:relative}
.bar-row .bar-fill{height:100%;background:var(--accent);position:absolute;left:0;top:0}
.bar-row .bar-fill.ink{background:currentColor}
.bar-row .bar-value{font-family:var(--sans);font-weight:700;font-size:max(16px,1.05vw);text-align:right;font-feature-settings:"tnum"}
/* ============ 导航 ============ */
/* 底部分页导航: 仅保留方块本身,无背景无描边 */
#nav{position:fixed;left:50%;bottom:2vh;transform:translateX(-50%);z-index:30;display:flex;gap:10px;padding:0;background:transparent;border:0}
#nav .dot{width:6px;height:6px;background:rgba(0,0,0,.28);cursor:pointer;transition:all .25s ease;border:0;padding:0;border-radius:0}
#nav .dot:hover{background:rgba(0,0,0,.55)}
#nav .dot.active{background:var(--accent);width:18px}
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;display:flex;align-items:center;gap:.65em;font-family:var(--mono);font-size:14px;letter-spacing:.14em;text-transform:uppercase;opacity:.4}
#hint button{all:unset;display:inline-flex;align-items:center;gap:.42em;cursor:pointer;color:inherit;white-space:nowrap}
#hint button:hover,#hint button:focus-visible{opacity:1;text-decoration:underline;text-underline-offset:.3em}
#hint .ppt-key{padding:.12em .34em;border:1px solid currentColor;letter-spacing:.06em}
#hint .ppt-sep{opacity:.55}
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}
/* ============ 演讲者模式 ============ */
body.ppt-audience #hint,body.ppt-audience #nav,
body.ppt-preview #hint,body.ppt-preview #nav{display:none!important}
body.ppt-audience{cursor:none}
body.ppt-preview{pointer-events:none}body.ppt-preview #deck{transition:none!important}
body.ppt-presenter>#deck,body.ppt-presenter>#nav,body.ppt-presenter>#hint,
body.ppt-presenter>.bg{visibility:hidden}
#ppt-presenter{position:fixed;inset:0;z-index:90;display:none;min-width:0;min-height:0;overflow:hidden;background:#101216;color:#f5f6f7;font-family:Inter,"Noto Sans SC",sans-serif}
body.ppt-presenter #ppt-presenter{display:grid;grid-template-rows:auto 1fr auto}
.ppt-presenter-top{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:18px 24px;border-bottom:1px solid #2b2e35;background:#15181d}
.ppt-presenter-brand{font-weight:650;letter-spacing:.02em}.ppt-presenter-meta{display:flex;align-items:center;gap:12px;color:#aeb4be;font-size:14px}
.ppt-sync{display:inline-flex;align-items:center;gap:8px;padding:7px 11px;border:1px solid #363a43;border-radius:999px;color:#d2d6dc;background:#1b1e24}
.ppt-sync::before{content:"";width:8px;height:8px;border-radius:50%;background:#818894}
.ppt-sync[data-state="synced"]::before{background:#72df99;box-shadow:0 0 0 4px rgba(114,223,153,.12)}
.ppt-sync[data-state="connecting"]::before,.ppt-sync[data-state="frozen"]::before{background:#f0c766}.ppt-sync[data-state="unsynced"]::before,.ppt-sync[data-state="blocked"]::before{background:#ff9e64}.ppt-sync[data-state="disconnected"]::before{background:#ff6b72}
.ppt-ui-btn{appearance:none;border:1px solid #3a3e48;background:#20242a;color:#f6f7f8;border-radius:8px;padding:9px 13px;font:600 14px/1 Inter,"Noto Sans SC",sans-serif;cursor:pointer}
.ppt-ui-btn:hover{background:#2a2f37;border-color:#525864}.ppt-ui-btn:focus-visible{outline:2px solid #84a8ff;outline-offset:2px}.ppt-ui-btn:disabled{opacity:.28;cursor:not-allowed}
.ppt-presenter-main{display:grid;grid-template-columns:minmax(0,1.75fr) minmax(300px,.75fr);gap:18px;min-height:0;padding:18px 24px}
.ppt-preview-stack{position:relative;display:grid;grid-template-rows:minmax(0,1fr) clamp(132px,22vh,210px);gap:16px;min-width:0;min-height:0}
.ppt-stage-card,.ppt-notes-card,.ppt-presenter-overview{background:#171a20;border:1px solid #2b2f37;border-radius:12px;overflow:hidden}
.ppt-stage-card{display:grid;grid-template-rows:auto minmax(0,1fr);min-width:0;min-height:0}
.ppt-stage-label{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:11px 14px;color:#aeb4be;font-size:13px;border-bottom:1px solid #292d34}
.ppt-annotation-tools{display:flex;align-items:center;gap:5px;margin-left:auto}.ppt-annotation-tools .ppt-ui-btn{padding:6px 9px;font-size:12px}.ppt-annotation-tools .ppt-ui-btn.is-active{color:#111318;background:#ff5b63;border-color:#ff5b63}.ppt-annotation-tools .ppt-grid-btn.is-active{color:#111318;background:#edf1f5;border-color:#edf1f5}.ppt-annotation-tools .ppt-clear-btn{min-width:0}.ppt-grid-btn{display:inline-flex;align-items:center;gap:6px}.ppt-grid-icon{display:grid;grid-template-columns:repeat(2,4px);gap:2px}.ppt-grid-icon i{width:4px;height:4px;background:currentColor}
.ppt-frame-viewport{position:relative;display:grid;place-items:center;min-width:0;min-height:0;overflow:hidden;background:#08090b}
.ppt-frame{display:block;width:0;height:0;max-width:100%;max-height:100%;aspect-ratio:16/9;border:0;background:#08090b}.ppt-next-frame{opacity:.86}#ppt-next-card.is-end .ppt-frame{visibility:hidden}#ppt-next-card.is-end .ppt-frame-viewport::after{content:"演示结束";position:absolute;color:#727a86;font:650 14px/1 Inter,"Noto Sans SC",sans-serif;letter-spacing:.08em}
.ppt-annotation-layer{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);z-index:4;width:0;height:0;pointer-events:none;touch-action:none}.ppt-annotation-layer.is-active{pointer-events:auto;cursor:crosshair}
#ppt-audience-ink{position:fixed;inset:0;z-index:70;width:100vw;height:100vh;pointer-events:none}#ppt-audience-cover{position:fixed;inset:0;z-index:80;display:none;pointer-events:none}#ppt-audience-cover.is-ended{display:grid!important;place-items:center;background:#101216!important;color:#f5f6f7;font:650 clamp(24px,3vw,46px)/1.2 Inter,"Noto Sans SC",sans-serif;letter-spacing:.08em}
.ppt-preview-stack.is-overview>.ppt-stage-card{display:none}.ppt-presenter-overview{grid-row:1/-1;display:grid;grid-template-rows:auto minmax(0,1fr);min-width:0;min-height:0}.ppt-presenter-overview[hidden]{display:none}.ppt-overview-head{display:flex;align-items:center;justify-content:space-between;padding:13px 15px;border-bottom:1px solid #292d34}.ppt-overview-head strong{font-size:15px}.ppt-overview-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px;align-content:start;overflow:auto;padding:14px}.ppt-overview-page{appearance:none;min-height:112px;display:grid;grid-template-rows:auto 1fr auto;gap:12px;padding:13px;border:1px solid #343943;border-radius:10px;background:#111318;color:#eef1f4;text-align:left;cursor:pointer}.ppt-overview-page:hover{background:#1d2128;border-color:#636b78}.ppt-overview-page.is-current{border-color:#84a8ff;box-shadow:inset 0 0 0 1px #84a8ff}.ppt-overview-page-no{color:#8f97a3;font:600 12px/1 var(--mono,monospace)}.ppt-overview-page-title{font-size:14px;font-weight:650;line-height:1.35}.ppt-overview-page-meta{display:flex;justify-content:space-between;gap:8px;color:#7f8793;font-size:11px}
.ppt-notes-card{display:grid;grid-template-rows:auto minmax(0,1fr);min-height:0}.ppt-notes-head{padding:16px 18px;border-bottom:1px solid #292d34}.ppt-notes-label{margin-bottom:6px;color:#848c98;font-size:11px;font-weight:700;letter-spacing:.12em;text-transform:uppercase}.ppt-notes-title{font-size:21px;font-weight:680;line-height:1.32}.ppt-purpose-block{margin-top:14px;padding:11px 12px;border-left:3px solid #84a8ff;background:#111318}.ppt-purpose-copy{color:#c9ced6;font-size:14px;line-height:1.55}.ppt-notes-draft-head{display:flex;align-items:flex-end;justify-content:space-between;gap:10px;margin-top:15px;padding-top:13px;border-top:1px solid #2d3139}.ppt-notes-draft-head .ppt-notes-label{margin:0 0 4px}.ppt-note-meta{color:#8f97a3;font-size:12px}
.ppt-notes-tools{display:flex;align-items:center;gap:6px}.ppt-notes-tools .ppt-ui-btn{padding:7px 9px}.ppt-note-editor{width:100%;height:100%;min-height:0;resize:none;border:0;outline:0;background:#171a20;color:#edf0f3;padding:18px;font:400 var(--ppt-note-size,17px)/1.72 Inter,"Noto Sans SC",sans-serif}.ppt-note-editor:focus{box-shadow:inset 0 0 0 1px #3d4654}
.ppt-saved{color:#72df99;white-space:nowrap}.ppt-presenter-bottom{display:grid;grid-template-columns:minmax(330px,1fr) auto minmax(110px,1fr);align-items:center;gap:20px;padding:12px 24px;border-top:1px solid #2b2e35;background:#15181d}
.ppt-time-dashboard{display:flex;align-items:stretch;min-width:0}.ppt-time-stat{min-width:90px;display:grid;align-content:center;gap:4px;padding:0 16px}.ppt-time-stat:first-child{padding-left:0}.ppt-time-stat+.ppt-time-stat{border-left:1px solid #343943}.ppt-time-label{color:#89919d;font-size:11px;font-weight:700;letter-spacing:.1em}.ppt-time-value{color:#f7f8fa;font:650 23px/1 var(--mono,monospace);font-variant-numeric:tabular-nums}.ppt-time-hint{color:#747c88;font-size:11px;white-space:nowrap}.ppt-time-stat.is-over .ppt-time-label,.ppt-time-stat.is-over .ppt-time-value{color:#ff8a90}.ppt-time-stat.is-remaining .ppt-time-value{color:#72df99}
.ppt-control-stack{display:grid;gap:7px;justify-items:center}.ppt-controls,.ppt-session-actions{display:flex;align-items:center;justify-content:center;gap:7px}.ppt-controls .ppt-ui-btn,.ppt-session-actions .ppt-ui-btn{min-width:68px}.ppt-session-actions{padding-top:7px;border-top:1px solid #343943}.ppt-rehearsal-on{color:#ff8a90}.ppt-page-state{text-align:right}.ppt-page-state strong{display:block;color:#fff;font-size:17px;margin-bottom:5px}.ppt-page-state span{color:#8f97a3;font:600 13px/1 var(--mono,monospace)}.ppt-auto-state{display:inline-flex;align-items:center;gap:6px;border-radius:999px}.ppt-auto-state.is-on{color:#72df99;border-color:rgba(114,223,153,.45);background:rgba(114,223,153,.08)}.ppt-auto-state.is-paused{color:#f0c766;border-color:rgba(240,199,102,.42);background:rgba(240,199,102,.08)}
#ppt-presenter[data-layout="slides"] .ppt-presenter-main{grid-template-columns:minmax(0,2.15fr) minmax(260px,.58fr)}#ppt-presenter[data-layout="notes"] .ppt-presenter-main{grid-template-columns:minmax(0,1.15fr) minmax(380px,1fr)}
.ppt-dialog{position:fixed;inset:0;z-index:130;display:grid;place-items:center;padding:24px;background:rgba(4,6,9,.72);backdrop-filter:blur(10px)}.ppt-dialog[hidden]{display:none}.ppt-dialog-panel{width:min(720px,calc(100vw - 48px));max-height:min(760px,calc(100vh - 48px));display:grid;grid-template-rows:auto minmax(0,1fr);overflow:hidden;border:1px solid #343943;border-radius:14px;background:#171a20;box-shadow:0 24px 80px rgba(0,0,0,.45)}.ppt-dialog-head{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:16px 18px;border-bottom:1px solid #2b2f37}.ppt-dialog-title{font-size:20px;font-weight:700}.ppt-dialog-body{overflow:auto;padding:18px;color:#d8dce2;font-size:14px;line-height:1.6}.ppt-dialog-section+.ppt-dialog-section{margin-top:22px}.ppt-dialog-section h3{margin:0 0 10px;color:#fff;font-size:15px}.ppt-dialog-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}.ppt-dialog-row{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:10px 0;border-bottom:1px solid #282c33}.ppt-dialog-row:last-child{border-bottom:0}.ppt-field{display:grid;gap:6px}.ppt-field label{color:#aeb4be;font-size:12px}.ppt-field input{width:100%;border:1px solid #3a3e48;border-radius:8px;background:#101216;color:#fff;padding:9px 10px;font:600 14px Inter,sans-serif}.ppt-check{display:flex;align-items:flex-start;gap:10px}.ppt-check-dot{width:9px;height:9px;margin-top:7px;border-radius:50%;background:#ff6b72}.ppt-check.is-ok .ppt-check-dot{background:#72df99}.ppt-check.is-warn .ppt-check-dot{background:#f0c766}.ppt-summary{width:100%;border-collapse:collapse;font-size:13px}.ppt-summary th,.ppt-summary td{padding:9px 8px;border-bottom:1px solid #2b2f37;text-align:left}.ppt-summary th{color:#9fa6b1;font-weight:600}.ppt-summary td:last-child,.ppt-summary th:last-child{text-align:right}.ppt-muted{color:#8f96a1}.ppt-shortcuts{display:grid;grid-template-columns:auto 1fr;gap:8px 14px}.ppt-shortcuts kbd{min-width:44px;padding:4px 7px;border:1px solid #3a3e48;border-radius:6px;background:#101216;color:#fff;text-align:center;font:600 12px var(--mono,monospace)}
.ppt-settings-card{padding:15px;border:1px solid #30353e;border-radius:12px;background:#12151a}.ppt-settings-card+.ppt-settings-card{margin-top:12px}.ppt-settings-card h3{margin:0 0 3px}.ppt-settings-copy{margin:0 0 14px;color:#858d99;font-size:12px}.ppt-switch-row{display:flex;align-items:center;justify-content:space-between;gap:18px;cursor:pointer}.ppt-switch-row input{position:absolute;opacity:0;pointer-events:none}.ppt-switch{width:48px;height:28px;flex:0 0 auto;padding:3px;border:1px solid #434955;border-radius:999px;background:#252a31;transition:.2s}.ppt-switch::after{content:"";display:block;width:20px;height:20px;border-radius:50%;background:#a8afb9;transition:.2s}.ppt-switch-row input:checked+.ppt-switch{border-color:#72df99;background:rgba(114,223,153,.16)}.ppt-switch-row input:checked+.ppt-switch::after{transform:translateX(20px);background:#72df99}.ppt-stepper{display:grid;grid-template-columns:38px minmax(72px,1fr) auto 38px;align-items:center;margin-top:14px;border:1px solid #363c46;border-radius:10px;background:#0d0f13;overflow:hidden}.ppt-stepper button{height:42px;border:0;background:#20242a;color:#fff;font-size:19px;cursor:pointer}.ppt-stepper button:hover{background:#2c323b}.ppt-stepper input{width:100%;height:42px;border:0!important;border-radius:0!important;background:transparent!important;text-align:center;font:650 16px var(--mono,monospace)!important;-moz-appearance:textfield}.ppt-stepper input::-webkit-inner-spin-button,.ppt-stepper input::-webkit-outer-spin-button{-webkit-appearance:none;margin:0}.ppt-stepper-unit{color:#858d99;font-size:12px}.ppt-segmented{display:grid;grid-template-columns:repeat(3,1fr);gap:6px}.ppt-segmented.screen{grid-template-columns:repeat(4,1fr)}.ppt-segmented .ppt-ui-btn{min-width:0}.ppt-settings-save{width:100%;margin-top:14px;background:#edf1f5;color:#111318;border-color:#edf1f5}.ppt-settings-save:hover{background:#fff}.ppt-settings-save+.ppt-settings-copy{margin:10px 0 0}
#overview{z-index:110!important}
@media(max-width:1200px){.ppt-presenter-main{grid-template-columns:minmax(0,1.55fr) minmax(280px,.72fr)}}
@media(max-width:1100px){.ppt-presenter-bottom{grid-template-columns:minmax(292px,1fr) auto minmax(86px,.6fr);gap:12px}.ppt-time-stat{min-width:82px;padding:0 11px}.ppt-time-value{font-size:20px}.ppt-controls .ppt-ui-btn,.ppt-session-actions .ppt-ui-btn{min-width:60px;padding:8px 9px}}
@media(max-width:900px){.ppt-presenter-top{padding:12px 16px;flex-wrap:wrap}.ppt-presenter-top .ppt-presenter-meta{gap:6px}.ppt-presenter-top .ppt-ui-btn{padding:8px 9px}.ppt-presenter-main,#ppt-presenter[data-layout="slides"] .ppt-presenter-main,#ppt-presenter[data-layout="notes"] .ppt-presenter-main{grid-template-columns:minmax(0,1.45fr) minmax(250px,.72fr);gap:12px;padding:12px 16px}.ppt-preview-stack{gap:12px;grid-template-rows:minmax(0,1fr) clamp(104px,20vh,150px)}.ppt-saved{display:none}.ppt-presenter-bottom{grid-template-columns:minmax(270px,1fr) auto 74px;gap:8px;padding:9px 16px}.ppt-time-stat{min-width:0;padding:0 9px}.ppt-time-value{font-size:18px}.ppt-time-hint{max-width:78px;overflow:hidden;text-overflow:ellipsis}.ppt-controls,.ppt-session-actions{gap:5px}.ppt-controls .ppt-ui-btn,.ppt-session-actions .ppt-ui-btn{min-width:52px;padding:7px 8px;font-size:12px}.ppt-page-state strong{font-size:14px}.ppt-dialog-grid{grid-template-columns:1fr}.ppt-segmented.screen{grid-template-columns:repeat(2,1fr)}}
@media(max-height:700px){.ppt-presenter-top{padding-top:10px;padding-bottom:10px}.ppt-presenter-main{padding-top:10px;padding-bottom:10px}.ppt-preview-stack{grid-template-rows:minmax(0,1fr) clamp(88px,16vh,118px);gap:10px}.ppt-stage-label{padding:7px 10px}.ppt-notes-head{padding:12px 14px}.ppt-purpose-block{margin-top:10px;padding:8px 10px}.ppt-notes-draft-head{margin-top:10px;padding-top:9px}.ppt-presenter-bottom{padding-top:8px;padding-bottom:8px}.ppt-control-stack{gap:5px}.ppt-session-actions{padding-top:5px}}
/* ============ /演讲者模式 ============ */
/* ESC 索引页: 动画元素强制可见 (覆盖 motion-ready 的 opacity:0) */
#overview [data-anim]{opacity:1!important;transform:none!important}
#overview .slide *{animation:none!important;transition:none!important}
/* 统一卡片样式 token: card-fill (默认灰底 · 中性) + card-ink (反转高对比) + card-accent (单一焦点) */
.card-fill{background:#f5f5f4;border:0;color:var(--text-primary)}
.card-ink{background:var(--ink);border:0;color:var(--paper)}
.card-ink .t-meta,.card-ink .t-cat{color:rgba(255,255,255,.6)}
.card-accent{background:var(--accent);border:0;color:var(--accent-on)}
.card-accent .t-meta,.card-accent .t-cat{color:var(--accent-on)}
/* ============ 动效 (与原模板一致) ============ */
[data-anim]{opacity:1}
body.motion-ready [data-anim]{opacity:0}
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.low-power #deck{transition:none!important}
body.low-power *,
body.low-power *::before,
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}
/* ============================================================
↓↓↓ V2 EXTENSIONS · Canvas Mode + 参考图新类
验证效果后沉淀回 template-swiss.html
============================================================ */
/* Windows 适配:雅黑没有 ExtraLight 200,中文大字号字重补偿 */
body.is-win .name-mega,
body.is-win .num-mega,
body.is-win .kpi-thin,
body.is-win .tl-node .multi{
font-weight:300;letter-spacing:-.02em;
}
body.is-win [style*="font-weight:200"]{font-weight:300 !important}
/* 全屏铺满模式 · 关闭 WebGL · 卡片即页面 */
body.canvas-mode{background:var(--paper)}
body.canvas-mode canvas.bg{display:none !important}
body.canvas-mode .slide{background:var(--paper);padding:0;align-items:stretch;justify-content:stretch}
body.canvas-mode .slide.hero{background:var(--paper)}
.canvas-card{
width:100vw;
height:100vh;
background:var(--paper);color:var(--ink);
padding:5.6vh 5vw 4.4vh;
display:flex;flex-direction:column;
position:relative;overflow:hidden;
box-shadow:none;
border-radius:0;
}
.slide.dark .canvas-card{background:var(--ink);color:var(--paper)}
.slide.accent .canvas-card{background:var(--accent);color:var(--accent-on)}
.slide.grey .canvas-card{background:var(--grey-1);color:var(--ink)}
.slide.split .canvas-card{padding:0;flex-direction:row}
/* ============ ASCII 点阵呼吸场 · IKB 封面/封底专用 ============
用法:在 .canvas-card(或 split .half.b-accent)内首位插入 <canvas class="ascii-bg" aria-hidden="true">.
动画由本文件底部的 ASCII IIFE 自动启动,所有 canvas.ascii-bg 都会被扫到.
其他内容靠 .canvas-card > *:not(.ascii-bg){z-index:1} 自动浮在上层. */
canvas.ascii-bg{
position:absolute;inset:0;width:100%;height:100%;
pointer-events:none;z-index:0;
mix-blend-mode:screen;opacity:.92;
}
.canvas-card > *:not(.ascii-bg){position:relative;z-index:1}
.slide.accent .canvas-card .chrome-min{color:rgba(255,255,255,.62)}
.slide.accent .canvas-card .t-meta{color:rgba(255,255,255,.7)}
.split-half > .half.b-accent .chrome-min{color:rgba(255,255,255,.62)}
/* 编号目录页 · 超大数字 + 国家名风格 — 越大越细 */
.num-mega{
font-family:var(--sans);font-weight:200;
font-size:9vw;line-height:1;letter-spacing:-.04em;
font-feature-settings:"tnum"
}
.num-mega.thin{font-weight:200}
.name-mega{
font-family:var(--sans);font-weight:200;
font-size:9vw;line-height:1;letter-spacing:-.035em;
}
.name-mega.muted{color:var(--grey-3)}
/* 细体超大 KPI — 字号越大权重越低 */
.kpi-thin{
font-family:var(--sans);font-weight:200;
font-size:14vw;line-height:.92;letter-spacing:-.045em;
font-feature-settings:"tnum"
}
.kpi-thin .unit{font-size:.3em;font-weight:300;opacity:.55;margin-left:.15em;vertical-align:.6em}
.kpi-thin.accent{color:var(--accent)}
.kpi-thin-sm{
font-family:var(--sans);font-weight:250;
font-size:5.6vw;line-height:1.04;letter-spacing:-.03em;
font-feature-settings:"tnum"
}
.kpi-thin-sm .unit{font-size:.3em;font-weight:300;opacity:.55;margin-left:.12em;vertical-align:.45em}
/* 4 列细线 KPI 行 — 顶部一根 hairline,内部不再加竖线 */
/* 4 列 KPI: 用纵向分割线建立网格感 (Carbon 2x grid 模数) */
.kpi-row-4{
display:grid;grid-template-columns:repeat(4,1fr);
gap:0;padding-top:2.4vh;
border-top:1px solid var(--grey-2)
}
.kpi-row-4 > .kpi-cell{
padding:1.6vh 1.6vw 0;
border-left:1px solid var(--grey-2)
}
.kpi-row-4 > .kpi-cell:first-child{padding-left:0;border-left:none}
.kpi-cell .lbl{font-family:var(--mono);font-size:max(14px,.78vw);letter-spacing:.18em;text-transform:uppercase;opacity:.55;margin-bottom:1.2vh}
.kpi-cell .nb{font-family:var(--sans);font-weight:250;font-size:3.2vw;line-height:1;letter-spacing:-.025em;font-feature-settings:"tnum"}
.kpi-cell .nb .unit{font-size:.32em;font-weight:300;opacity:.6;margin-left:.1em;vertical-align:.4em}
.kpi-cell .note{font-family:var(--sans),var(--sans-zh);font-size:max(16px,.92vw);line-height:1.5;opacity:.7;margin-top:1.2vh}
/* 时间线轴 — 通用 axis token, 横纵共享
axis 列 = 24px 固定宽,dot 直径 8px,绝对居中在 axis 列中线 (12px)
虚线绝对定位 left:12px,与 dot 中心严格对齐 */
.timeline-v{
--tl-axis-w:24px; /* axis 列固定宽度 */
--tl-dot:8px; /* 圆点直径 */
position:relative;margin-top:var(--sp-7)
}
.timeline-v::before{
content:"";position:absolute;
left:calc(var(--tl-axis-w) / 2);
transform:translateX(-50%);
top:var(--sp-5);bottom:var(--sp-5);
width:1px;
background:repeating-linear-gradient(to bottom,currentColor 0 4px,transparent 4px 8px);
opacity:.35;pointer-events:none;z-index:0
}
.tl-node{
position:relative;
display:grid;
grid-template-columns:var(--tl-axis-w) minmax(0,7em) minmax(0,7.6em) 1fr;
gap:0 var(--sp-5);
align-items:center;
padding:var(--sp-7) 0
}
/* 进度点: 纯色实心圆,无背景无描边,精确居中在 axis 列中线 */
.tl-node .dot{
width:var(--tl-dot);height:var(--tl-dot);
border-radius:50%;
background:currentColor;
justify-self:center;
z-index:1
}
.tl-node.accent .dot{background:var(--accent)}
.tl-node .yr{
font-family:var(--mono);font-weight:500;
font-size:max(14px,1vw);letter-spacing:.04em
}
.tl-node .multi{
font-family:var(--sans);font-weight:200;
font-size:clamp(28px,2.8vw,56px);
line-height:.95;letter-spacing:-.025em;
white-space:nowrap;
overflow:hidden;
min-width:0
}
.tl-node .multi .unit{
font-size:.36em;font-weight:300;opacity:.6;
margin-left:.2em;
vertical-align:.42em;
letter-spacing:0
}
.tl-node.accent .multi{color:var(--accent)}
.tl-node .desc{
font-family:var(--sans),var(--sans-zh);
font-size:max(16px,.94vw);line-height:1.55;opacity:.78;
min-width:0
}
/* 横向时间线 (.timeline-h) — 与 .timeline-v 共享 axis token, 视觉语言一致 */
.timeline-h{
--tl-axis-w:8px;
--tl-dot:8px;
position:relative;
flex:1;
display:flex;align-items:center
}
.timeline-h::before{
content:"";position:absolute;
top:50%;left:5%;right:5%;height:1px;
transform:translateY(-50%);
background:repeating-linear-gradient(to right,currentColor 0 4px,transparent 4px 8px);
opacity:.35;pointer-events:none;z-index:0
}
.timeline-h .tl-row{
position:relative;width:100%;
display:grid;grid-template-columns:repeat(5,1fr);
align-items:center
}
.timeline-h .th-node{
position:relative;display:flex;justify-content:center
}
/* 横向 dot: 8px 纯色实心,无描边无阴影 (与 P2 保持一致) */
.timeline-h .th-node .dot{
width:var(--tl-dot);height:var(--tl-dot);
border-radius:50%;
background:var(--ink);
z-index:1;position:relative
}
.timeline-h .th-node.accent .dot{background:var(--accent)}
.timeline-h .th-node .label{
position:absolute;left:50%;transform:translateX(-50%);
width:13vw;text-align:center;
display:flex;flex-direction:column;gap:.4vh
}
.timeline-h .th-node.up .label{bottom:calc(50% + 22px)}
.timeline-h .th-node.down .label{top:calc(50% + 22px)}
.timeline-h .th-node .yr{
font-family:var(--mono);font-size:max(14px,.78vw);letter-spacing:.05em;
color:var(--text-helper);font-weight:500
}
.timeline-h .th-node.accent .yr{color:var(--accent)}
.timeline-h .th-node .name{
font-family:var(--sans);font-size:max(16px,1.05vw);font-weight:400;
color:var(--text-primary);line-height:1.2;letter-spacing:-.005em
}
.timeline-h .th-node.accent .name{color:var(--accent)}
.timeline-h .th-node .desc{
font-family:var(--sans),var(--sans-zh);font-size:max(14px,.84vw);
color:var(--text-secondary);font-weight:400;line-height:1.4
}
/* 几何图示 (参考图 5) — SVG-friendly 容器 */
.geo-icon{width:5vw;height:5vw;display:block;margin-bottom:2.2vh;color:var(--ink);flex-shrink:0}
.slide.dark .geo-icon{color:var(--paper)}
.geo-icon svg{width:100%;height:100%;overflow:visible}
.geo-icon .stroke{fill:none;stroke:currentColor;stroke-width:1.4}
.geo-icon .stroke-accent{fill:none;stroke:var(--accent);stroke-width:1.4}
.geo-icon .fill-accent{fill:var(--accent)}
/* 卡片网格 (参考图 1 卡片漂浮) — 在 canvas-card 内部再分卡 */
.sub-grid-3-2{display:grid;grid-template-columns:repeat(3,1fr);grid-template-rows:repeat(2,1fr);gap:1.4vh 1.4vw;flex:1;align-content:stretch;margin-top:3vh}
.sub-card{
background:var(--grey-1);
padding:2.4vh 1.6vw 2vh;
display:flex;flex-direction:column;
position:relative;border-radius:3px;
min-height:0
}
.slide.dark .sub-card{background:rgba(255,255,255,.06)}
.sub-card.accent{background:var(--accent);color:var(--accent-on)}
.sub-card.ink{background:var(--ink);color:var(--paper)}
.sub-card .nb-corner{
position:absolute;top:1.6vh;right:1.4vw;
font-family:var(--mono);font-size:max(14px,.8vw);
letter-spacing:.16em;opacity:.55
}
.sub-card .ttl{font-family:var(--sans),var(--sans-zh);font-weight:500;font-size:max(17px,1.5vw);line-height:1.2;letter-spacing:-.015em;margin-bottom:1vh}
.sub-card .desc{font-family:var(--sans),var(--sans-zh);font-size:max(16px,.94vw);line-height:1.55;opacity:.78;margin-top:auto}
.sub-card .lucide{width:2.4vw;height:2.4vw;stroke-width:1.4;color:currentColor;margin-bottom:1.6vh;flex-shrink:0}
.sub-card.accent .lucide{color:var(--accent-on)}
/* 三层架构纯色块拼图 (参考图 4 + 6) — 横排等高色块 */
.stack-row{display:grid;grid-template-columns:repeat(3,1fr);gap:1.6vw;flex:1;margin-top:6vh;align-items:stretch}
.stack-block{
display:flex;flex-direction:column;
padding:3.2vh 1.8vw 2.4vh;
position:relative;
min-height:0
}
.stack-block.b-accent{background:var(--accent);color:var(--accent-on)}
.stack-block.b-grey{background:var(--grey-1);color:var(--ink)}
.stack-block.b-ink{background:var(--ink);color:var(--paper)}
.stack-block .layer-nb{font-family:var(--mono);font-size:max(14px,.84vw);letter-spacing:.18em;opacity:.65;margin-bottom:auto}
.stack-block .layer-icon{margin-bottom:1.6vh}
.stack-block .layer-icon svg{width:3vw;height:3vw}
.stack-block .layer-icon .stroke{fill:none;stroke:currentColor;stroke-width:1.6}
.stack-block .layer-ttl{font-family:var(--sans),var(--sans-zh);font-weight:400;font-size:max(17px,2vw);line-height:1.1;margin-top:1vh;letter-spacing:-.02em}
.stack-block .layer-desc{font-family:var(--sans),var(--sans-zh);font-weight:400;font-size:max(16px,.94vw);line-height:1.55;opacity:.88;margin-top:1.4vh}
.stack-block .lucide{width:2.6vw;height:2.6vw;stroke-width:1.4;margin-bottom:1.6vh;flex-shrink:0}
.stack-block .layer-tag{font-family:var(--mono);font-size:max(14px,.74vw);letter-spacing:.16em;text-transform:uppercase;opacity:.7;margin-top:1.6vh;border-top:1px solid currentColor;padding-top:1vh}
/* 不等高柱状 KPI 塔 (参考图 6) */
.bar-towers{
display:grid;grid-template-columns:repeat(4,1fr);
gap:1.2vw;flex:1;align-items:end;margin-top:auto
}
.bar-tower{
display:flex;flex-direction:column;justify-content:flex-end;
min-height:0;height:100%
}
.bar-tower .cap{
background:var(--grey-1);
height:5.6vh;
display:flex;align-items:center;justify-content:center;
margin-bottom:.4vh
}
.bar-tower .cap svg{width:1.6vw;height:1.6vw;stroke:currentColor;fill:none;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round}
.bar-tower .body-block{
flex:0 1 auto;
padding:2vh 1.2vw 2vh;
display:flex;flex-direction:column;justify-content:flex-end;
min-height:18vh
}
.bar-tower .body-block.h-1{min-height:22vh}
.bar-tower .body-block.h-2{min-height:30vh}
.bar-tower .body-block.h-3{min-height:38vh}
.bar-tower .body-block.h-4{min-height:46vh}
/* 默认所有 KPI 塔统一为浅描边卡 — 不抢戏;只有 .b-accent 突出为 IKB */
.bar-tower .body-block{background:var(--paper);color:var(--ink);border:1px solid var(--grey-2)}
.bar-tower .body-block.b-accent{background:var(--accent);color:var(--accent-on);border-color:var(--accent)}
.bar-tower .lbl{font-family:var(--mono);font-size:max(14px,.82vw);letter-spacing:.16em;text-transform:uppercase;opacity:.65;margin-bottom:1vh}
.bar-tower .nb{font-family:var(--sans);font-weight:250;font-size:max(20px,2.8vw);line-height:1;letter-spacing:-.03em;font-feature-settings:"tnum"}
.bar-tower .body-block.b-accent .nb{font-weight:300}
.bar-tower .nb .unit{font-size:.36em;font-weight:300;opacity:.7;margin-left:.08em;vertical-align:.4em}
.bar-tower .sub{font-family:var(--sans),var(--sans-zh);font-size:max(16px,.92vw);opacity:.75;margin-top:1.2vh;line-height:1.5}
.bar-tower .cap{background:var(--grey-1);color:var(--ink)}
.bar-tower .lucide{width:1.6vw;height:1.6vw;stroke-width:1.4}
/* ============ Carbon Productive Type Tokens ============
用于 PPT 中的"productive 时刻": 列表、表格行、说明文、章节标签
固定 px 字号,密集紧凑;与 vw-based 的 Expressive 巨字形成双极对比 */
/* category label / eyebrow / section tag — small, bold, uppercase */
.t-cat{
font-family:var(--mono);
font-size:14px;font-weight:600;
letter-spacing:.15em;text-transform:uppercase;
color:var(--text-helper);line-height:1.3
}
.t-cat.accent{color:var(--accent)}
.t-cat.on-dark{color:rgba(255,255,255,.78)}
/* page chrome / breadcrumb / running header — extra-small mono */
.t-meta{
font-family:var(--mono);
font-size:14px;font-weight:500;
letter-spacing:.14em;text-transform:uppercase;
color:var(--text-helper);line-height:1.45
}
/* helper / caption — secondary text */
.t-helper{
font-family:var(--sans),var(--sans-zh);
font-size:14px;font-weight:400;
color:var(--text-helper);line-height:1.5;
letter-spacing:.005em
}
/* body small — list items, table rows, captions */
.t-body-sm{
font-family:var(--sans),var(--sans-zh);
font-size:16px;font-weight:400;
color:var(--text-secondary);line-height:1.55;
letter-spacing:0
}
/* body — paragraphs, descriptions */
.t-body{
font-family:var(--sans),var(--sans-zh);
font-size:18px;font-weight:400;
color:var(--text-primary);line-height:1.5;
letter-spacing:-.005em
}
/* body emphasis — 强调正文 */
.t-body-emp{
font-family:var(--sans),var(--sans-zh);
font-size:18px;font-weight:600; /* SemiBold per Carbon */
color:var(--text-primary);line-height:1.5;
letter-spacing:-.005em
}
/* productive heading — section title within slide */
.t-h-prod{
font-family:var(--sans),var(--sans-zh);
font-size:22px;font-weight:600;
color:var(--text-primary);line-height:1.4;
letter-spacing:-.01em
}
/* dark background variants */
.slide.dark .t-cat,.slide.dark .t-meta,.slide.dark .t-helper{color:rgba(255,255,255,.62)}
.slide.dark .t-body-sm{color:rgba(255,255,255,.78)}
.slide.dark .t-body,.slide.dark .t-body-emp,.slide.dark .t-h-prod{color:var(--paper)}
.split-half .half.b-ink .t-cat,.split-half .half.b-ink .t-meta,.split-half .half.b-ink .t-helper{color:rgba(255,255,255,.62)}
.split-half .half.b-ink .t-body-sm{color:rgba(255,255,255,.78)}
.split-half .half.b-ink .t-body,.split-half .half.b-ink .t-body-emp,.split-half .half.b-ink .t-h-prod{color:var(--paper)}
/* 斜杠点阵装饰 (参考图 6 左下) */
.hatch{
background-image:repeating-linear-gradient(135deg,currentColor 0 1px,transparent 1px 8px);
opacity:.55
}
/* ========== V3: 高级点阵装饰 — 圆点 / × 号 / 圆圈 ========== */
/* 实心圆点矩阵 — 用于大面积装饰 */
.dot-mat{
--d:14px;
background-image:radial-gradient(currentColor 1.4px,transparent 1.6px);
background-size:var(--d) var(--d);
background-position:0 0;
opacity:.5
}
.dot-mat.lg{--d:22px}
.dot-mat.xl{--d:34px}
.dot-mat.dense{--d:9px;opacity:.62}
/* 描边圆圈矩阵 — 工业感 */
.ring-mat{
--d:18px;
background-image:
radial-gradient(circle at 50% 50%,transparent 2px,currentColor 2px,currentColor 2.6px,transparent 2.7px);
background-size:var(--d) var(--d);
opacity:.55
}
.ring-mat.lg{--d:28px}
/* × 号矩阵 — SVG mask 实现真正的 × 网格平铺 */
.cross-mat{
--d:22px;
background-color:currentColor;
-webkit-mask-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 22 22'><g stroke='black' stroke-width='1.4' stroke-linecap='round' fill='none'><line x1='8' y1='8' x2='14' y2='14'/><line x1='14' y1='8' x2='8' y2='14'/></g></svg>");
mask-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 22 22'><g stroke='black' stroke-width='1.4' stroke-linecap='round' fill='none'><line x1='8' y1='8' x2='14' y2='14'/><line x1='14' y1='8' x2='8' y2='14'/></g></svg>");
-webkit-mask-size:var(--d) var(--d);
mask-size:var(--d) var(--d);
-webkit-mask-repeat:repeat;mask-repeat:repeat;
opacity:.42
}
.cross-mat.lg{--d:32px}
/* ========== V3: 几何水平柱状图 ========== */
/* 横向柱图: 标签列 + 柱体列 + 数值列 — 严格瑞士网格 */
.h-bar-chart{
display:grid;
grid-template-columns:11em minmax(0,1fr) 8em;
gap:1.6vh 1.6vw;
align-items:center;
margin-top:2.4vh;
font-feature-settings:"tnum"
}
.h-bar-chart .row-lbl{
font-family:var(--sans),var(--sans-zh);
font-weight:500;
font-size:max(14px,1vw);
letter-spacing:-.005em;
text-align:left
}
.h-bar-chart .row-track{
height:3.2vh;
background:var(--grey-1);
position:relative;
overflow:hidden
}
.h-bar-chart .row-fill{
height:100%;
background:var(--ink);
transition:width 1s cubic-bezier(.5,0,.2,1)
}
.h-bar-chart .row-fill.accent{background:var(--accent)}
.h-bar-chart .row-fill.grey{background:var(--grey-3)}
.h-bar-chart .row-val{
font-family:var(--sans);font-weight:250;
font-size:max(16px,1.5vw);
letter-spacing:-.02em;
line-height:1
}
.h-bar-chart .row-val .unit{
font-size:.5em;opacity:.55;font-weight:300;
margin-left:.15em;letter-spacing:.04em
}
/* 垂直柱图: 用于 KPI 对比页(章节 P8) */
.v-bar-chart{
display:grid;
grid-template-columns:repeat(var(--cols,4),1fr);
align-items:end;
gap:1.4vw;
height:50vh;
margin-top:3vh
}
.v-bar-chart .col{display:flex;flex-direction:column;gap:1.4vh;align-items:stretch;height:100%}
.v-bar-chart .col-bar{
flex:1 1 auto;
background:var(--grey-1);
border-top:2px solid var(--ink);
position:relative;
display:flex;align-items:flex-start;justify-content:center;
padding-top:1vh
}
.v-bar-chart .col-bar.accent{background:var(--accent);border-top-color:var(--accent);color:var(--accent-on)}
.v-bar-chart .col-bar.ink{background:var(--ink);color:var(--paper);border-top-color:var(--ink)}
.v-bar-chart .col-bar .v{
font-family:var(--sans);font-weight:250;
font-size:max(18px,1.6vw);letter-spacing:-.02em
}
.v-bar-chart .col-lbl{
font-family:var(--mono);font-size:max(14px,.78vw);
letter-spacing:.14em;text-transform:uppercase;opacity:.6;
text-align:center;flex:0 0 auto
}
/* 对仗对比双栏 (章节 P9) */
.duo-compare{
display:grid;grid-template-columns:1fr 1px 1fr;
gap:0 3.4vw;
flex:1;align-items:stretch;margin-top:8vh
}
.duo-compare .vrule{background:var(--grey-2);width:1px;align-self:stretch}
.duo-compare .col{display:flex;flex-direction:column;gap:1.6vh;padding:0 .4vw}
.duo-compare .col-tag{
font-family:var(--mono);font-size:max(14px,.78vw);
letter-spacing:.16em;text-transform:uppercase;
color:var(--grey-3);
display:flex;align-items:center;gap:.6vw
}
.duo-compare .col-tag .num{
font-weight:600;color:var(--ink);
border:1px solid var(--ink);
padding:.2em .6em;font-size:.92em
}
.duo-compare .col.accent .col-tag .num{color:var(--accent);border-color:var(--accent)}
.duo-compare .col-ttl{
font-family:var(--sans),var(--sans-zh);font-weight:200;
font-size:3.6vw;line-height:1;letter-spacing:-.03em
}
.duo-compare .col.accent .col-ttl{color:var(--accent)}
.duo-compare .col-desc{
font-family:var(--sans),var(--sans-zh);
font-size:max(16px,1.04vw);line-height:1.55;opacity:.78;
max-width:30vw
}
.duo-compare .col-list{
list-style:none;display:flex;flex-direction:column;gap:1vh;
margin-top:auto;padding-top:2vh;border-top:1px solid var(--grey-2)
}
.duo-compare .col-list li{
font-family:var(--sans),var(--sans-zh);
font-size:max(16px,.94vw);line-height:1.5;
padding-left:1.4em;position:relative
}
.duo-compare .col-list li::before{
content:"";position:absolute;left:0;top:.6em;
width:.5em;height:1px;background:currentColor;opacity:.55
}
.duo-compare .col.accent .col-list li::before{background:var(--accent);opacity:1}
/* 半屏 statement (参考图 7) */
.split-half{display:grid;grid-template-columns:1fr 1fr;gap:0;flex:1;align-items:stretch}
.split-half > .half{padding:5vh 3.4vw;display:flex;flex-direction:column;min-width:0}
.split-half > .half.r-border{border-left:1px solid rgba(127,127,127,.22)}
.split-half > .half.b-grey{background:var(--grey-1)}
.split-half > .half.b-accent{background:var(--accent);color:var(--accent-on)}
.split-half > .half.b-ink{background:var(--ink);color:var(--paper)}
/* 极简页眉 — t-meta 风格 (Carbon productive label-01) */
.canvas-card .chrome-min{
display:flex;justify-content:space-between;align-items:flex-start;
font-family:var(--mono);font-size:14px;font-weight:500;
letter-spacing:.14em;text-transform:uppercase;
color:var(--text-helper);
flex:0 0 auto;
margin-bottom:var(--sp-9); /* 48px */
}
.canvas-card .chrome-min.tight{margin-bottom:var(--sp-7)} /* 32px */
.canvas-card .chrome-min .l, .canvas-card .chrome-min .r{
max-width:48vw;line-height:1.5
}
.slide.dark .canvas-card .chrome-min,.split-half .half.b-ink .canvas-card .chrome-min{color:rgba(255,255,255,.62)}
/* 响应式降级 */
@media (max-width:900px){
.h-hero{font-size:16vw}
.h-hero-zh{font-size:13vw}
.h-xl{font-size:9vw}
.h-xl-zh{font-size:8vw}
.kpi-hero{font-size:32vw}
.kpi-big{font-size:16vw}
.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)}
}
</style>
</head>
<body class="canvas-mode">
<script>
// Windows 平台标记 — 雅黑没有 ExtraLight,需要字重补偿
if(/Win/i.test(navigator.platform || navigator.userAgentData?.platform || '')){
document.body.classList.add('is-win');
}
(function(){
const KEY = 'guizang-ppt-low-power';
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const stored = localStorage.getItem(KEY);
window.__lowPowerMode = stored === '1' || (stored === null && reduced);
function updateHint(){
const label = document.querySelector('.ppt-low-power-label');
if(label) label.textContent = window.__lowPowerMode ? '动态' : '静态';
}
window.__setLowPowerMode = function(on, opts={}){
window.__lowPowerMode = !!on;
document.body.classList.toggle('low-power', window.__lowPowerMode);
if(opts.persist !== false) localStorage.setItem(KEY, window.__lowPowerMode ? '1' : '0');
if(window.__lowPowerMode && document.getAnimations){
document.getAnimations().forEach(a=>a.cancel());
}
updateHint();
dispatchEvent(new CustomEvent('swiss-low-power-change', {detail:{on:window.__lowPowerMode}}));
if(window.__playSlide) window.__playSlide(window.__currentSlideIndex || 0);
};
document.body.classList.toggle('low-power', window.__lowPowerMode);
addEventListener('DOMContentLoaded', updateHint, {once:true});
})();
</script>
<canvas id="bg-grid" class="bg"></canvas>
<div id="hint" role="toolbar" aria-label="快捷控制">
<span>← → 翻页</span><span class="ppt-sep">·</span>
<button id="ppt-low-power-btn" type="button"><span class="ppt-key">B</span><span class="ppt-low-power-label">静态</span></button><span class="ppt-sep">·</span>
<button id="ppt-overview-btn" type="button"><span class="ppt-key">ESC</span>总览</button><span class="ppt-sep">·</span>
<button id="ppt-presenter-btn" type="button"><span class="ppt-key">P</span>演讲模式</button>
</div>
<div id="deck">
<!-- ============================================================
SLIDES 插入区 · 在此处填充所有 <section class="slide ..."> 页面
页面骨架参考 references/layouts-swiss.md
主题色配置参考 references/themes-swiss.md
============================================================ -->
<!-- SLIDES_HERE · 在此处粘贴 <section class="slide ..."> 页面块
- 页面骨架直接从 references/layouts-swiss.md 拷贝
- data-animate="..." 必须命中下方 RECIPES 字典里的已有 recipe 名之一(P23/P24 可复用 grid-reveal)
- 主题色配置参考 references/themes-swiss.md (默认 IKB 克莱因蓝) -->
<!-- ============ 示例:第 1 页 · Hero Cover · IKB 满屏 + ASCII 呼吸场(默认推荐) ============
⚠️ P0 对齐法则(每页都要过):
1. .canvas-card 已自带 padding:5.6vh 5vw 4.4vh,所有页面内容直接放在 canvas-card 子元素里,
子元素**不要再加水平 padding**,否则会比 chrome-min 内缩一圈、左右不对齐.
2. .slide.split .canvas-card{padding:0} 已被 CSS 覆盖,
split 模式下两个 .half 自己控制 padding(常用 5.6vh 3.6vw 4.4vh),与本规则不冲突.
3. 大字号一律用双约束 font-size:min(Xvw, Yvh),Y ≥ X * 1.6 才不会在 16:9 屏被高度截断.
4. kicker 必须在大标题"上方",不是左侧——禁止 grid-template-columns:auto 1fr 把它们压成左右.
封面/封底设计语言(默认 IKB 满屏 + ASCII 呼吸场):
- section 用 .slide.accent (满屏 IKB,不是 light 白底)
- canvas-card 内首位插入 <canvas class="ascii-bg" aria-hidden="true">,本文件底部 IIFE 自动启动
- 主标题反白 weight 200,强调字用斜体而非 var(--accent)(底已是蓝,蓝压蓝看不见)
- 不要再放编号大字"01"——chrome-min 已经标 01/NN -->
<section class="slide accent" data-animate="hero" data-slide-id="cover">
<div class="canvas-card">
<canvas class="ascii-bg" aria-hidden="true"></canvas>
<div class="chrome-min">
<div class="l">[必填] Deck 标题 · Issue/Field Note 编号</div>
<div class="r">SS · 25.05.10 · 01 / NN</div>
</div>
<!-- 主体:padding 必须为 0,不要再叠 5vw,否则左右对不齐 chrome-min -->
<div style="flex:1;padding:0;display:grid;grid-template-rows:auto 1fr auto;gap:2.6vh">
<div data-anim="kicker" class="t-meta" style="color:rgba(255,255,255,.78);letter-spacing:.22em">[必填] 章节英文 / Section En</div>
<h1 data-anim="title" style="align-self:center;font-family:var(--sans),var(--sans-zh);font-weight:200;font-size:min(11.6vw,19vh);line-height:.94;letter-spacing:-.025em;color:#fff">[必填] 中文主标题<br/>(≤ 12 字,可在某字加 <span style="font-style:italic;font-weight:300">italic</span> 微强调)</h1>
<div data-anim="bottom" style="display:grid;grid-template-rows:auto auto;gap:1.6vh;border-top:1px solid rgba(255,255,255,.22);padding-top:2vh">
<div data-anim="lead" class="lead" style="max-width:52ch;color:rgba(255,255,255,.86)">[必填] 一段 1-2 行的副标 / 引子,定调全场.</div>
<div style="display:flex;justify-content:space-between;align-items:end">
<div class="t-meta" style="color:rgba(255,255,255,.6)">[选填] 作者 · 日期 · 出处</div>
<div class="t-meta" style="color:rgba(255,255,255,.6)">→ swipe / arrow keys</div>
</div>
</div>
</div>
</div>
</section>
<!-- ============ 示例:最后一页 · Closing Manifesto · 左 IKB+ASCII / 右白底 takeaway ============
与封面 IKB 首尾呼应,但收束更克制:左半保留 ASCII 呼吸场承载宣言,右半白底列 3 条 takeaway.
第 03 条用 var(--accent) IKB 蓝强调("单一锚点"原则),首尾形成"色彩闭环". -->
<section class="slide split" data-animate="split-statement" data-slide-id="closing">
<div class="canvas-card">
<div class="split-half">
<!-- 左半 · IKB 宣言 + ASCII 呼吸场 -->
<div class="half b-accent" style="padding:5.6vh 3.6vw 4.4vh;justify-content:space-between;position:relative;overflow:hidden">
<canvas class="ascii-bg" aria-hidden="true"></canvas>
<div class="chrome-min" style="margin-bottom:0;position:relative;z-index:1">
<div class="l">NN / NN</div>
<div class="r">CLOSING</div>
</div>
<div data-anim="manifesto" style="display:flex;flex-direction:column;gap:2vh;position:relative;z-index:1">
<div class="t-meta" style="color:rgba(255,255,255,.78);letter-spacing:.22em;margin-bottom:1.6vh">MANIFESTO</div>
<h2 style="font-family:var(--sans),var(--sans-zh);font-size:min(8vw,14vh);line-height:.94;letter-spacing:-.025em;font-weight:200;color:#fff">[必填] Build a model.<br/>Run <span style="font-style:italic;font-weight:300">forever</span>.</h2>
<div style="font-family:var(--sans),var(--sans-zh);font-size:max(14px,1vw);line-height:1.6;color:rgba(255,255,255,.82);font-weight:400;max-width:36ch;margin-top:1.4vh">[必填] 一句 1-2 行的中文/英文注脚,把宣言落地.</div>
</div>
<div data-anim="signature" style="display:flex;justify-content:space-between;align-items:end;border-top:1px solid rgba(255,255,255,.22);padding-top:2vh;position:relative;z-index:1">
<div class="t-meta" style="color:rgba(255,255,255,.62)">[选填] 作者 · 头衔</div>
<div class="t-meta" style="color:rgba(255,255,255,.62)">YY.MM.DD</div>
</div>
</div>
<!-- 右半 · 三条 takeaway · 白底承载理性收束 -->
<div class="half" style="padding:5.6vh 3.6vw 4.4vh;justify-content:space-between">
<div class="chrome-min">
<div class="l">TAKEAWAYS</div>
<div class="r">03 RULES</div>
</div>
<div data-anim="rules" style="display:flex;flex-direction:column;gap:0">
<div style="display:grid;grid-template-columns:auto 1fr;gap:2vw;align-items:start;padding:2.6vh 0;border-top:1px solid var(--border-subtle)">
<div style="font-family:var(--sans);font-weight:200;font-size:min(4.4vw,7.8vh);line-height:.9;color:var(--text-primary)">01</div>
<div>
<h3 style="font-family:var(--sans),var(--sans-zh);font-weight:400;font-size:max(18px,1.8vw);line-height:1.2;letter-spacing:-.015em;color:var(--text-primary);margin-bottom:1vh">[必填] takeaway 标题 01</h3>
<p style="font-family:var(--sans),var(--sans-zh);font-size:max(16px,.94vw);line-height:1.6;color:var(--text-secondary);font-weight:400">[必填] 1-2 行展开说明.</p>
</div>
</div>
<div style="display:grid;grid-template-columns:auto 1fr;gap:2vw;align-items:start;padding:2.6vh 0;border-top:1px solid var(--border-subtle)">
<div style="font-family:var(--sans);font-weight:200;font-size:min(4.4vw,7.8vh);line-height:.9;color:var(--text-primary)">02</div>
<div>
<h3 style="font-family:var(--sans),var(--sans-zh);font-weight:400;font-size:max(18px,1.8vw);line-height:1.2;letter-spacing:-.015em;color:var(--text-primary);margin-bottom:1vh">[必填] takeaway 标题 02</h3>
<p style="font-family:var(--sans),var(--sans-zh);font-size:max(16px,.94vw);line-height:1.6;color:var(--text-secondary);font-weight:400">[必填] 1-2 行展开说明.</p>
</div>
</div>
<div style="display:grid;grid-template-columns:auto 1fr;gap:2vw;align-items:start;padding:2.6vh 0;border-top:1px solid var(--border-subtle);border-bottom:2px solid var(--accent)">
<div style="font-family:var(--sans);font-weight:200;font-size:min(4.4vw,7.8vh);line-height:.9;color:var(--accent)">03</div>
<div>
<h3 style="font-family:var(--sans),var(--sans-zh);font-weight:400;font-size:max(18px,1.8vw);line-height:1.2;letter-spacing:-.015em;color:var(--accent);margin-bottom:1vh">[必填] takeaway 标题 03 · accent 强调</h3>
<p style="font-family:var(--sans),var(--sans-zh);font-size:max(16px,.94vw);line-height:1.6;color:var(--text-secondary);font-weight:400">[必填] 最后一条用 IKB 强调,与封面色彩首尾闭环.</p>
</div>
</div>
</div>
<div data-anim="foot" class="t-meta" style="color:var(--text-helper);text-align:right">→ 完 · END OF FIELD NOTE</div>
</div>
</div>
</div>
</section>
</div>
<div id="nav"></div>
<!-- 演讲备注:替换示例页时同步替换,id 必须与 section[data-slide-id] 一致。 -->
<script>
const SPEAKER_NOTES = [
{
id: 'cover', title: '[必填] Deck 标题', section: '开场', minutes: 0.6,
purpose: '建立主题与现场预期',
talk: ['用一句反差或问题开场', '说明为什么是现在讲这件事', '只预告主线,不在封面展开细节'],
transition: '从开场问题进入核心内容', cue: '停顿,确认观众屏已同步'
},
{
id: 'closing', title: '[必填] Closing', section: '收束', minutes: 0.8,
purpose: '收束全场并留下可记忆的行动点',
talk: ['回扣开场时提出的问题', '把三条 takeaway 说成一个完整判断', '给观众一个会后可以立即采取的动作'],
transition: '结束演讲,进入提问或致谢', cue: '停顿 2 秒后致谢'
}
];
window.__SPEAKER_NOTES__ = SPEAKER_NOTES;
</script>
<script>
/* =============== WebGL 网格背景 (瑞士风专用) ===============
极简移动网格 + 微弱点阵叠加,营造"工业感、精准感"
- 主网格: 缓慢漂移的细线网格
- 次级: 鼠标附近的极细点阵微扰
- 颜色: 跟随主题(浅底深线 / 深底亮线),配合 mix-blend-mode
*/
const VS = `attribute vec2 position;void main(){gl_Position=vec4(position,0.0,1.0);}`;
const FS = `precision highp float;
uniform vec2 u_resolution;
uniform float u_time;
uniform vec2 u_mouse;
uniform float u_dark; // 0 = light, 1 = dark
uniform vec3 u_accent;
float gridLine(vec2 uv, float spacing, float thickness){
vec2 g = abs(fract(uv / spacing) - 0.5);
float d = min(g.x, g.y);
return 1.0 - smoothstep(thickness - 0.005, thickness + 0.005, d);
}
float dot2(vec2 p){ return dot(p,p); }
void main(){
vec2 uv = gl_FragCoord.xy / u_resolution.xy;
float aspect = u_resolution.x / u_resolution.y;
vec2 p = uv;
p.x *= aspect;
// 缓慢平移
vec2 drift = vec2(u_time * 0.008, u_time * 0.005);
vec2 gp = p + drift;
// 主细网格 (大间距)
float mainGrid = gridLine(gp, 0.12, 0.012);
// 次级网格 (更细更密)
float subGrid = gridLine(gp, 0.024, 0.04) * 0.4;
// 鼠标附近的强化
vec2 m = u_mouse;
m.x *= aspect;
float md = length(p - m);
float mInfluence = exp(-md * 4.0) * 0.5;
float gridStrength = (mainGrid + subGrid * 0.5) * (0.45 + mInfluence);
// 点阵 (作为基底)
vec2 dotGrid = fract(gp * 50.0) - 0.5;
float dotMask = 1.0 - smoothstep(0.05, 0.14, length(dotGrid));
// 用低频噪声调制点阵密度
float wave = sin(gp.x * 1.4 + u_time * 0.15) * cos(gp.y * 1.6 - u_time * 0.12);
dotMask *= smoothstep(-0.3, 0.6, wave) * 0.6;
// 颜色: 浅底用深线条,深底用浅线条;高亮处带 accent 痕迹
vec3 lineColor = mix(vec3(0.08), vec3(0.92), u_dark);
vec3 bgColor = mix(vec3(0.97, 0.97, 0.96), vec3(0.06, 0.06, 0.07), u_dark);
// accent 暗示 (鼠标附近偷渡一点 accent 色)
vec3 col = bgColor;
col = mix(col, lineColor, gridStrength * 0.55);
col = mix(col, lineColor, dotMask * 0.35);
col = mix(col, u_accent, mInfluence * 0.18);
gl_FragColor = vec4(col, 1.0);
}`;
const mouse={x:0.5,y:0.5};
addEventListener('mousemove',e=>{mouse.x=e.clientX/innerWidth;mouse.y=1-e.clientY/innerHeight});
function bootGL(canvasId, fsSrc){
const canvas=document.getElementById(canvasId);
const gl=canvas.getContext('webgl',{alpha:true,antialias:true,premultipliedAlpha:false});
if(!gl) return ()=>false;
const mk=(t,s)=>{const sh=gl.createShader(t);gl.shaderSource(sh,s);gl.compileShader(sh);return sh};
const prog=gl.createProgram();
gl.attachShader(prog,mk(gl.VERTEX_SHADER,VS));
gl.attachShader(prog,mk(gl.FRAGMENT_SHADER,fsSrc));
gl.linkProgram(prog);gl.useProgram(prog);
const buf=gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER,buf);
gl.bufferData(gl.ARRAY_BUFFER,new Float32Array([-1,-1,1,-1,-1,1,-1,1,1,-1,1,1]),gl.STATIC_DRAW);
const pos=gl.getAttribLocation(prog,'position');
gl.enableVertexAttribArray(pos);gl.vertexAttribPointer(pos,2,gl.FLOAT,false,0,0);
const lRes=gl.getUniformLocation(prog,'u_resolution');
const lT=gl.getUniformLocation(prog,'u_time');
const lM=gl.getUniformLocation(prog,'u_mouse');
const lD=gl.getUniformLocation(prog,'u_dark');
const lA=gl.getUniformLocation(prog,'u_accent');
const resize=()=>{
const d=Math.min(window.devicePixelRatio||1,2);
canvas.width=innerWidth*d;canvas.height=innerHeight*d;
gl.viewport(0,0,canvas.width,canvas.height);
};
addEventListener('resize',resize);resize();
// 读取 CSS 变量,把 accent 颜色塞进 shader
function readAccent(){
const cs = getComputedStyle(document.documentElement);
const hex = cs.getPropertyValue('--accent').trim() || '#002FA7';
const m = hex.match(/^#([0-9a-f]{6})$/i);
if(!m) return [0, 0.18, 0.65];
const n = parseInt(m[1], 16);
return [((n>>16)&255)/255, ((n>>8)&255)/255, (n&255)/255];
}
let accent = readAccent();
let dark = 0;
return (tSec, isDark)=>{
if(isDark !== undefined) dark = isDark ? 1 : 0;
accent = readAccent();
gl.uniform2f(lRes,canvas.width,canvas.height);
gl.uniform1f(lT,tSec);
gl.uniform2f(lM,mouse.x,mouse.y);
gl.uniform1f(lD,dark);
gl.uniform3f(lA,accent[0],accent[1],accent[2]);
gl.drawArrays(gl.TRIANGLES,0,6);
return true;
};
}
// canvas-mode / low-power: skip WebGL draw loop (no active RAF loop)
let darkMode=false;
let gridCtrl=null, gridRAF=0, gridT0=Date.now();
function startGrid(){
if(document.body.classList.contains('canvas-mode') || window.__lowPowerMode || gridRAF) return;
if(!gridCtrl) gridCtrl = bootGL('bg-grid',FS);
if(!gridCtrl) return;
gridT0=Date.now();
function loop(){
if(window.__lowPowerMode){gridRAF=0;return;}
const t=(Date.now()-gridT0)/1000;
gridCtrl(t, darkMode);
gridRAF=requestAnimationFrame(loop);
}
gridRAF=requestAnimationFrame(loop);
}
function stopGrid(){
if(gridRAF) cancelAnimationFrame(gridRAF);
gridRAF=0;
}
if(document.body.classList.contains('canvas-mode')){
const c=document.getElementById('bg-grid');
if(c) c.remove();
}else{
startGrid();
}
addEventListener('swiss-low-power-change', e=>{e.detail.on ? stopGrid() : startGrid();});
// =============== 导航 ===============
const deck=document.getElementById('deck');
const slides=deck.querySelectorAll('.slide');
const nav=document.getElementById('nav');
let idx=0,total=slides.length,lock=false;
deck.style.width=(total*100)+'vw';
slides.forEach((s,i)=>{
const b=document.createElement('button');
b.className='dot';b.dataset.i=i;b.setAttribute('aria-label','Page '+(i+1));
b.onclick=()=>go(i);
nav.appendChild(b);
});
function go(n,opts={}){
if(lock&&!opts.force)return;
idx=Math.max(0,Math.min(total-1,n));
window.__currentSlideIndex = idx;
deck.style.transform=`translateX(${-idx*100}vw)`;
nav.querySelectorAll('.dot').forEach((d,i)=>d.classList.toggle('active',i===idx));
const el=slides[idx];
const isDark = el.classList.contains('dark') || el.classList.contains('accent');
document.body.classList.toggle('dark-bg', isDark);
darkMode = isDark;
if(window.__playSlide) setTimeout(()=>window.__playSlide(idx), 450);
window.__pptPresenter?.onSlideChange(idx);
if(!opts.force){lock=true;setTimeout(()=>lock=false,700);}
}
/* =============== ESC 索引视图 =============== */
let overviewOn=false;
const ov=document.createElement('div');
ov.id='overview';
ov.style.cssText='position:fixed;inset:0;z-index:100;background:rgba(250,250,248,.96);backdrop-filter:blur(12px);display:none;overflow-y:auto;padding:4vh 4vw';
document.body.appendChild(ov);
function buildOverview(){
ov.innerHTML='';
const grid=document.createElement('div');
grid.style.cssText='display:grid;grid-template-columns:repeat(4,1fr);gap:2vh 1.6vw;max-width:90vw;margin:0 auto';
slides.forEach((s,i)=>{
const card=document.createElement('div');
card.style.cssText='cursor:pointer;overflow:hidden;border:2px solid '+(i===idx?'var(--accent)':'rgba(0,0,0,.12)')+';transition:border-color .2s';
card.onmouseenter=()=>card.style.borderColor='rgba(0,0,0,.4)';
card.onmouseleave=()=>card.style.borderColor=i===idx?'var(--accent)':'rgba(0,0,0,.12)';
const wrap=document.createElement('div');
const isDark = s.classList.contains('dark') || s.classList.contains('accent');
wrap.style.cssText='width:100%;aspect-ratio:16/9;overflow:hidden;position:relative;pointer-events:none;background:'+(isDark?'var(--ink)':'var(--paper)');
const clone=s.cloneNode(true);
clone.style.cssText='width:100vw;height:100vh;transform:scale('+(1/4.5)+');transform-origin:top left;position:absolute;top:0;left:0;pointer-events:none';
wrap.appendChild(clone);
const label=document.createElement('div');
/* ESC 索引卡 label */
label.style.cssText='padding:6px 10px;font-family:var(--mono);font-size:14px;letter-spacing:.14em;text-transform:uppercase;color:var(--ink);opacity:.7';
label.textContent=window.__pptPresenter?.slideLabel(i) || ((i+1)+' / '+total);
card.appendChild(wrap);
card.appendChild(label);
card.onclick=()=>{toggleOverview();go(i)};
grid.appendChild(card);
});
ov.appendChild(grid);
}
function toggleOverview(){
overviewOn=!overviewOn;
if(overviewOn){buildOverview();ov.style.display='block';}
else{ov.style.display='none';}
}
/* =============== 演讲者模式 / 观众屏同步 =============== */
window.__pptPresenter=(()=>{
const query=new URLSearchParams(location.search);
const requestedMode=query.get('mode')||'deck';
const originalTitle=document.title;
const notes=()=>Array.isArray(window.__SPEAKER_NOTES__)?window.__SPEAKER_NOTES__:[];
const slideId=i=>slides[i]?.dataset.slideId||`slide-${String(i+1).padStart(2,'0')}`;
const storageKey=name=>`guizang-ppt-${name}:${location.pathname}`;
let mode='deck',session=query.get('session')||Math.random().toString(36).slice(2),audienceWindow=null;
let channel=null,transportReady=false,popupBlocked=false,audienceOpenedAt=0,lastAckAt=0,lastAckIndex=-1,lastAckSeq=-1,seq=0,lastPingAt=0;
let renderedNoteId='',saveTimer=0,noteSize=Number(localStorage.getItem('guizang-ppt-note-size')||17),presenterFrameObserver=null;
let elapsed=0,timerRunning=false,timerStartedAt=0,timedSlideIndex=idx,slideTimerStartedAt=0,slideElapsed=Array(total).fill(0);
let rehearsalActive=false,presenterOverviewOn=false,lowPowerBeforePresenter=window.__lowPowerMode;
let layoutMode=localStorage.getItem(storageKey('layout'))||'balanced';
let autoEnabled=localStorage.getItem(storageKey('auto-enabled'))==='1';
let autoGlobalSeconds=Math.max(0,Number(localStorage.getItem(storageKey('auto-seconds'))||0));
let autoDeadline=0,autoRemainingMs=0;
let screenMode='normal',audienceFrozen=false;
let annotationTool='none',annotationPointerDown=false,annotationStart=null,draftCircle=null,circles=[],audienceCircles=[];
let laserPoint=null,audienceLaser=null,laserHideAt=0,audienceLaserHideAt=0,lastLaserSent=0;
const seenNonces=[],seenNonceSet=new Set();
const syncKey=()=>`guizang-ppt-sync:${location.pathname}:${session}`;
const channelName=()=>`guizang-ppt:${location.pathname}:${session}`;
function noteAt(i){
const id=slideId(i);
return notes().find(n=>n.id===id)||{id,title:slides[i]?.dataset.title||`第 ${i+1} 页`,purpose:'—',talk:[],transition:''};
}
function slideLabel(i){return `${i+1} / ${total} · ${noteAt(i).title||slideId(i)}`;}
function escapeHTML(value){return String(value??'').replace(/[&<>"']/g,ch=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[ch]));}
function valueLines(value){
if(Array.isArray(value))return value.filter(Boolean).map(x=>`• ${x}`).join('\n');
return value?String(value):'';
}
function generatedNote(n){
const parts=[];
if(Array.isArray(n.talk)&&n.talk.length)parts.push(`【讲述要点】\n${valueLines(n.talk)}`);
if(n.transition)parts.push(`【转场】\n${n.transition}`);
const optional=[['舞台提示',n.cue],['互动',n.interaction],['表达提示',n.delivery],['翻页时机',n.advance],['备用方案',n.fallback],['读音提醒',n.pronunciation]];
optional.forEach(([label,value])=>{const text=valueLines(value);if(text)parts.push(`【${label}】\n${text}`);});
return parts.join('\n\n')||'—';
}
function noteStorageKey(id){return `guizang-ppt-note:${location.pathname}:${id}`;}
function loadNoteValue(id,i,n){
const saved=localStorage.getItem(noteStorageKey(id));
if(saved!==null)return saved;
const prefix=window.__PPT_LEGACY_NOTE_KEY__;
if(prefix){
const legacy=localStorage.getItem(`${prefix}:${i}`);
if(legacy!==null){localStorage.setItem(noteStorageKey(id),legacy);return legacy;}
}
return generatedNote(n);
}
function frameUrl(targetMode,i){
const url=new URL(location.href);url.hash='';
url.searchParams.set('mode',targetMode);url.searchParams.set('session',session);url.searchParams.set('slide',String(i+1));
return url.href;
}
function postPreviewGoto(frame,i){
try{frame.contentWindow?.postMessage({__guizangPptSync:1,session,role:'presenter',type:'preview-goto',index:i},'*');}catch(e){}
}
function updatePreviewFrame(frame,i){
frame.dataset.slide=String(i);
if(!frame.dataset.previewBound){frame.dataset.previewBound='1';frame.addEventListener('load',()=>postPreviewGoto(frame,Number(frame.dataset.slide)));}
if(!frame.getAttribute('src'))frame.src=frameUrl('preview',i);else postPreviewGoto(frame,i);
}
function plannedMsAt(i){
const minutes=Number(noteAt(i).minutes);
return Number.isFinite(minutes)&&minutes>0?minutes*60000:null;
}
function totalPlannedMs(){
const values=Array.from({length:total},(_,i)=>plannedMsAt(i));
return values.length&&values.every(Number.isFinite)?values.reduce((sum,x)=>sum+x,0):null;
}
function sectionInfo(i){
const name=String(noteAt(i).section||'').trim();
if(!name)return null;
let first=i,last=i;
while(first>0&&String(noteAt(first-1).section||'').trim()===name)first--;
while(last<total-1&&String(noteAt(last+1).section||'').trim()===name)last++;
const targets=[];for(let x=first;x<=last;x++)targets.push(plannedMsAt(x));
const planned=targets.every(Number.isFinite)?targets.reduce((sum,x)=>sum+x,0):null;
const used=slideElapsed.slice(first,last+1).reduce((sum,x)=>sum+x,0)+(timerRunning&&timedSlideIndex>=first&&timedSlideIndex<=last?Date.now()-slideTimerStartedAt:0);
return{name,position:i-first+1,count:last-first+1,planned,used};
}
function durationText(ms){
const value=Math.max(0,Math.round(Number(ms)||0)),seconds=Math.floor(value/1000);
const hours=Math.floor(seconds/3600),minutes=Math.floor((seconds%3600)/60),secs=seconds%60;
return hours?`${String(hours).padStart(2,'0')}:${String(minutes).padStart(2,'0')}:${String(secs).padStart(2,'0')}`:`${String(minutes).padStart(2,'0')}:${String(secs).padStart(2,'0')}`;
}
function signedDuration(ms){return `${ms>=0?'+':'−'}${durationText(Math.abs(ms))}`;}
function clockTime(timestamp){return new Date(timestamp).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'});}
function fitFrame(frame){
const viewport=frame?.closest('.ppt-frame-viewport');
if(!viewport)return;
const availableWidth=viewport.clientWidth,availableHeight=viewport.clientHeight;
if(!availableWidth||!availableHeight)return;
const width=Math.min(availableWidth,availableHeight*(16/9)),height=width*(9/16);
frame.style.width=`${width}px`;frame.style.height=`${height}px`;
if(frame.id==='ppt-current-frame')sizeInk(document.getElementById('ppt-presenter-ink'),width,height);
}
function fitPresenterFrames(){
fitFrame(document.getElementById('ppt-current-frame'));
fitFrame(document.getElementById('ppt-next-frame'));
}
function sizeInk(canvas,width,height){
if(!canvas||!width||!height)return;
canvas.style.width=`${width}px`;canvas.style.height=`${height}px`;
const dpr=Math.min(devicePixelRatio||1,2),pixelWidth=Math.round(width*dpr),pixelHeight=Math.round(height*dpr);
if(canvas.width!==pixelWidth||canvas.height!==pixelHeight){canvas.width=pixelWidth;canvas.height=pixelHeight;}
}
function drawEllipse(ctx,circle,width,height,alpha=1){
const x1=circle.x1*width,y1=circle.y1*height,x2=circle.x2*width,y2=circle.y2*height;
ctx.save();ctx.globalAlpha=alpha;ctx.strokeStyle='#ff4f59';ctx.lineWidth=Math.max(3,width*.004);ctx.lineCap='round';
ctx.beginPath();ctx.ellipse((x1+x2)/2,(y1+y2)/2,Math.abs(x2-x1)/2,Math.abs(y2-y1)/2,0,0,Math.PI*2);ctx.stroke();ctx.restore();
}
function drawInk(canvas,list,laser,draft){
if(!canvas)return;const width=canvas.clientWidth,height=canvas.clientHeight;if(!width||!height)return;
sizeInk(canvas,width,height);const dpr=canvas.width/width,ctx=canvas.getContext('2d');ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,width,height);
(list||[]).forEach(circle=>drawEllipse(ctx,circle,width,height));
if(draft)drawEllipse(ctx,draft,width,height,.72);
if(laser){
const x=laser.x*width,y=laser.y*height,r=Math.max(6,width*.007);
const glow=ctx.createRadialGradient(x,y,0,x,y,r*2.8);glow.addColorStop(0,'rgba(255,255,255,.98)');glow.addColorStop(.2,'rgba(255,64,72,.98)');glow.addColorStop(1,'rgba(255,64,72,0)');
ctx.fillStyle=glow;ctx.beginPath();ctx.arc(x,y,r*2.8,0,Math.PI*2);ctx.fill();
}
}
function renderInks(){
const now=Date.now();if(now>laserHideAt)laserPoint=null;if(now>audienceLaserHideAt)audienceLaser=null;
drawInk(document.getElementById('ppt-presenter-ink'),circles,laserPoint,draftCircle);
drawInk(document.getElementById('ppt-audience-ink'),audienceCircles,audienceLaser,null);
}
function setAnnotationTool(tool){
annotationTool=annotationTool===tool?'none':tool;annotationPointerDown=false;draftCircle=null;
const canvas=document.getElementById('ppt-presenter-ink');canvas?.classList.toggle('is-active',annotationTool!=='none');
document.querySelectorAll('[data-ppt-tool]').forEach(button=>button.classList.toggle('is-active',button.dataset.pptTool===annotationTool));
renderInks();
}
function annotationPoint(event){
const canvas=event.currentTarget,rect=canvas.getBoundingClientRect();
return{x:Math.max(0,Math.min(1,(event.clientX-rect.left)/rect.width)),y:Math.max(0,Math.min(1,(event.clientY-rect.top)/rect.height))};
}
function onAnnotationPointerDown(event){
if(annotationTool!=='circle')return;event.preventDefault();annotationPointerDown=true;annotationStart=annotationPoint(event);draftCircle={x1:annotationStart.x,y1:annotationStart.y,x2:annotationStart.x,y2:annotationStart.y};event.currentTarget.setPointerCapture?.(event.pointerId);renderInks();
}
function onAnnotationPointerMove(event){
if(annotationTool==='laser'){
const point=annotationPoint(event);laserPoint=point;laserHideAt=Date.now()+650;
if(Date.now()-lastLaserSent>32){lastLaserSent=Date.now();emit({type:'laser',index:idx,point},{persist:false});}renderInks();return;
}
if(annotationTool==='circle'&&annotationPointerDown&&draftCircle){const point=annotationPoint(event);draftCircle.x2=point.x;draftCircle.y2=point.y;renderInks();}
}
function onAnnotationPointerUp(event){
if(annotationTool!=='circle'||!annotationPointerDown||!draftCircle)return;event.preventDefault();annotationPointerDown=false;
const circle={...draftCircle};draftCircle=null;
if(Math.abs(circle.x2-circle.x1)>.012&&Math.abs(circle.y2-circle.y1)>.012){circles.push(circle);emit({type:'circle',index:idx,circle});}
renderInks();
}
function onAnnotationPointerLeave(){
if(annotationTool==='laser'){laserPoint=null;emit({type:'laser',index:idx,point:null},{persist:false});renderInks();}
}
function clearAnnotations(broadcast=true){circles=[];draftCircle=null;laserPoint=null;if(broadcast)emit({type:'clear',index:idx});renderInks();}
function buildPresenterOverview(){
const grid=document.getElementById('ppt-presenter-overview-grid');if(!grid)return;grid.innerHTML='';
slides.forEach((slide,i)=>{
const n=noteAt(i),button=document.createElement('button');button.type='button';button.className=`ppt-overview-page${i===idx?' is-current':''}`;
button.innerHTML=`<span class="ppt-overview-page-no">${String(i+1).padStart(2,'0')} / ${String(total).padStart(2,'0')}</span><span class="ppt-overview-page-title">${escapeHTML(n.title||slideId(i))}</span><span class="ppt-overview-page-meta"><span>${escapeHTML(n.section||'未分组')}</span><span>${Math.round(((i+1)/total)*100)}%</span></span>`;
button.onclick=()=>{togglePresenterOverview(false);go(i,{force:true});};grid.appendChild(button);
});
requestAnimationFrame(()=>grid.querySelector('.is-current')?.scrollIntoView({block:'nearest',inline:'nearest'}));
}
function togglePresenterOverview(force){
presenterOverviewOn=typeof force==='boolean'?force:!presenterOverviewOn;
if(presenterOverviewOn&&annotationTool!=='none')setAnnotationTool('none');
const stack=document.querySelector('.ppt-preview-stack'),overview=document.getElementById('ppt-presenter-overview');
stack?.classList.toggle('is-overview',presenterOverviewOn);if(overview)overview.hidden=!presenterOverviewOn;
const button=document.getElementById('ppt-grid');if(button){button.classList.toggle('is-active',presenterOverviewOn);button.setAttribute('aria-pressed',String(presenterOverviewOn));}
if(presenterOverviewOn)buildPresenterOverview();else requestAnimationFrame(fitPresenterFrames);renderAutoState();
}
function mountShell(){
if(document.getElementById('ppt-presenter'))return;
document.body.insertAdjacentHTML('beforeend',`<div id="ppt-presenter" data-layout="${layoutMode}" aria-label="演讲者视图">
<div class="ppt-presenter-top"><div><div class="ppt-presenter-brand">演讲者视图</div><div class="ppt-presenter-meta"><span id="ppt-plan"></span><span id="ppt-section" hidden></span></div></div><div class="ppt-presenter-meta"><span id="ppt-sync" class="ppt-sync" data-state="disconnected" title="表示观众页面的软件同步状态,不代表 HDMI 等物理线缆状态">观众屏 · 未连接</span><button id="ppt-auto-toggle" class="ppt-ui-btn ppt-auto-state" type="button">自动翻页 · 关闭</button><button id="ppt-preflight" class="ppt-ui-btn" type="button">检查</button><button id="ppt-settings" class="ppt-ui-btn" type="button">设置</button><button id="ppt-reopen" class="ppt-ui-btn" type="button">重新打开观众屏</button><button id="ppt-exit" class="ppt-ui-btn" type="button">退出</button></div></div>
<div class="ppt-presenter-main"><div class="ppt-preview-stack"><div class="ppt-stage-card"><div class="ppt-stage-label"><span>当前页</span><span class="ppt-annotation-tools"><button id="ppt-grid" class="ppt-ui-btn ppt-grid-btn" type="button" aria-pressed="false" title="宫格选页(Esc)"><span class="ppt-grid-icon" aria-hidden="true"><i></i><i></i><i></i><i></i></span>宫格</button><button class="ppt-ui-btn" data-ppt-tool="laser" type="button" title="激光笔(L)">激光</button><button class="ppt-ui-btn" data-ppt-tool="circle" type="button" title="圈选(C)">圈选</button><button id="ppt-ink-clear" class="ppt-ui-btn ppt-clear-btn" type="button" title="清除标记(X)">清除</button></span><span id="ppt-current-label"></span></div><div class="ppt-frame-viewport"><iframe id="ppt-current-frame" class="ppt-frame" title="当前页预览"></iframe><canvas id="ppt-presenter-ink" class="ppt-annotation-layer" aria-label="演讲标注层"></canvas></div></div><div class="ppt-stage-card" id="ppt-next-card"><div class="ppt-stage-label"><span>下一页</span><span id="ppt-next-label"></span></div><div class="ppt-frame-viewport"><iframe id="ppt-next-frame" class="ppt-frame ppt-next-frame" title="下一页预览"></iframe></div></div><div id="ppt-presenter-overview" class="ppt-presenter-overview" hidden><div class="ppt-overview-head"><strong>选择页面</strong><button id="ppt-overview-close" class="ppt-ui-btn" type="button">返回预览</button></div><div id="ppt-presenter-overview-grid" class="ppt-overview-grid"></div></div></div>
<div class="ppt-notes-card"><div class="ppt-notes-head"><div class="ppt-notes-label">标题</div><div id="ppt-notes-title" class="ppt-notes-title"></div><div class="ppt-purpose-block"><div class="ppt-notes-label">本页目的</div><div id="ppt-notes-purpose" class="ppt-purpose-copy"></div></div><div class="ppt-notes-draft-head"><div><div class="ppt-notes-label">草稿(备注)</div><div id="ppt-note-meta" class="ppt-note-meta"></div></div><span class="ppt-notes-tools"><span id="ppt-saved" class="ppt-saved">已保存</span><button id="ppt-note-smaller" class="ppt-ui-btn" type="button" title="缩小备注字号">A−</button><button id="ppt-note-larger" class="ppt-ui-btn" type="button" title="放大备注字号">A+</button></span></div></div><textarea id="ppt-note-editor" class="ppt-note-editor" spellcheck="false" aria-label="本页演讲备注"></textarea></div></div>
<div class="ppt-presenter-bottom"><div class="ppt-time-dashboard"><div class="ppt-time-stat"><span class="ppt-time-label">已进行</span><strong id="ppt-clock" class="ppt-time-value">00:00</strong><span class="ppt-time-hint">总时长</span></div><div class="ppt-time-stat"><span class="ppt-time-label">本页</span><strong id="ppt-slide-clock" class="ppt-time-value">00:00</strong><span id="ppt-slide-plan" class="ppt-time-hint">计划 —</span></div><div id="ppt-slide-status-box" class="ppt-time-stat"><span id="ppt-slide-status-label" class="ppt-time-label">计划</span><strong id="ppt-slide-status" class="ppt-time-value">—</strong><span id="ppt-slide-status-hint" class="ppt-time-hint">未设置</span></div></div><div class="ppt-control-stack"><div class="ppt-controls"><button id="ppt-first" class="ppt-ui-btn" type="button">首页</button><button id="ppt-prev" class="ppt-ui-btn" type="button">上一页</button><button id="ppt-next" class="ppt-ui-btn" type="button">下一页</button><button id="ppt-last" class="ppt-ui-btn" type="button">尾页</button></div><div class="ppt-session-actions"><button id="ppt-timer-toggle" class="ppt-ui-btn" type="button">开始计时</button><button id="ppt-timer-reset" class="ppt-ui-btn" type="button">重置计时</button><button id="ppt-rehearsal" class="ppt-ui-btn" type="button">排练</button></div></div><div class="ppt-page-state"><strong id="ppt-page-count"></strong><span id="ppt-page-percent"></span></div></div>
<div id="ppt-dialog" class="ppt-dialog" hidden><div class="ppt-dialog-panel" role="dialog" aria-modal="true"><div class="ppt-dialog-head"><div id="ppt-dialog-title" class="ppt-dialog-title"></div><button id="ppt-dialog-close" class="ppt-ui-btn" type="button">关闭</button></div><div id="ppt-dialog-body" class="ppt-dialog-body"></div></div></div>
</div>`);
document.getElementById('ppt-reopen').onclick=reopenAudience;
document.getElementById('ppt-exit').onclick=exitPresenter;
document.getElementById('ppt-preflight').onclick=openPreflight;
document.getElementById('ppt-settings').onclick=openSettings;
document.getElementById('ppt-first').onclick=()=>go(0,{force:true});
document.getElementById('ppt-prev').onclick=()=>go(idx-1,{force:true});
document.getElementById('ppt-grid').onclick=()=>togglePresenterOverview();
document.getElementById('ppt-overview-close').onclick=()=>togglePresenterOverview(false);
document.getElementById('ppt-next').onclick=()=>go(idx+1,{force:true});
document.getElementById('ppt-last').onclick=()=>go(idx===total-1?0:total-1,{force:true});
document.getElementById('ppt-timer-toggle').onclick=toggleTimer;
document.getElementById('ppt-timer-reset').onclick=resetTimer;
document.getElementById('ppt-rehearsal').onclick=toggleRehearsal;
document.getElementById('ppt-auto-toggle').onclick=toggleAutoAdvance;
document.getElementById('ppt-note-smaller').onclick=()=>setNoteSize(noteSize-1);
document.getElementById('ppt-note-larger').onclick=()=>setNoteSize(noteSize+1);
document.getElementById('ppt-note-editor').addEventListener('input',saveNote);
document.querySelectorAll('[data-ppt-tool]').forEach(button=>button.onclick=()=>setAnnotationTool(button.dataset.pptTool));
document.getElementById('ppt-ink-clear').onclick=()=>clearAnnotations(true);
const ink=document.getElementById('ppt-presenter-ink');
ink.addEventListener('pointerdown',onAnnotationPointerDown);ink.addEventListener('pointermove',onAnnotationPointerMove);ink.addEventListener('pointerup',onAnnotationPointerUp);ink.addEventListener('pointercancel',onAnnotationPointerUp);ink.addEventListener('pointerleave',onAnnotationPointerLeave);
document.getElementById('ppt-dialog-close').onclick=closeDialog;
document.getElementById('ppt-dialog').onclick=event=>{if(event.target.id==='ppt-dialog')closeDialog();};
if('ResizeObserver'in window){presenterFrameObserver=new ResizeObserver(()=>requestAnimationFrame(fitPresenterFrames));presenterFrameObserver.observe(document.querySelector('.ppt-preview-stack'));}
else addEventListener('resize',fitPresenterFrames);
setNoteSize(noteSize,false);applyLayout(layoutMode,false);requestAnimationFrame(fitPresenterFrames);
}
function updateShell(){
if(mode!=='presenter')return;mountShell();
const n=noteAt(idx),next=Math.min(idx+1,total-1),id=slideId(idx),editor=document.getElementById('ppt-note-editor');
const currentFrame=document.getElementById('ppt-current-frame'),nextFrame=document.getElementById('ppt-next-frame');
if(currentFrame.dataset.slide!==String(idx))updatePreviewFrame(currentFrame,idx);
if(nextFrame.dataset.slide!==String(next))updatePreviewFrame(nextFrame,next);
document.getElementById('ppt-next-card').classList.toggle('is-end',idx===total-1);
document.getElementById('ppt-current-label').textContent=`${idx+1} / ${total}`;
document.getElementById('ppt-next-label').textContent=idx===total-1?'已到尾页':`${next+1} / ${total}`;
document.getElementById('ppt-notes-title').textContent=n.title||`第 ${idx+1} 页`;
document.getElementById('ppt-notes-purpose').textContent=n.purpose||'—';
const target=plannedMsAt(idx);document.getElementById('ppt-note-meta').textContent=`建议 ${target?(target/60000).toFixed(1):'—'} 分钟 · ${id}`;
document.getElementById('ppt-page-count').textContent=`第 ${idx+1} / ${total} 页`;
document.getElementById('ppt-page-percent').textContent=`${Math.round(((idx+1)/total)*100)}%`;
document.getElementById('ppt-first').disabled=idx===0;document.getElementById('ppt-prev').disabled=idx===0;document.getElementById('ppt-next').disabled=idx===total-1;
document.getElementById('ppt-last').textContent=idx===total-1?'重新开始':'尾页';
if(renderedNoteId!==id){renderedNoteId=id;editor.value=loadNoteValue(id,idx,n);editor.scrollTop=0;document.getElementById('ppt-saved').textContent='已保存';}
if(presenterOverviewOn)buildPresenterOverview();requestAnimationFrame(fitPresenterFrames);renderTimer();updateSyncStatus();
}
function saveNote(){
const saved=document.getElementById('ppt-saved');saved.textContent='保存中…';saved.classList.remove('ppt-saved');clearTimeout(saveTimer);
saveTimer=setTimeout(()=>{localStorage.setItem(noteStorageKey(renderedNoteId),document.getElementById('ppt-note-editor').value);saved.textContent='已保存';saved.classList.add('ppt-saved');},300);
}
function setNoteSize(value,persist=true){noteSize=Math.max(14,Math.min(26,value));document.documentElement.style.setProperty('--ppt-note-size',`${noteSize}px`);if(persist)localStorage.setItem('guizang-ppt-note-size',String(noteSize));}
function currentElapsed(){return elapsed+(timerRunning?Date.now()-timerStartedAt:0);}
function currentSlideElapsed(i=idx){return slideElapsed[i]+(timerRunning&&timedSlideIndex===i?Date.now()-slideTimerStartedAt:0);}
function commitRunningTime(now=Date.now()){
if(!timerRunning)return;elapsed+=now-timerStartedAt;slideElapsed[timedSlideIndex]+=now-slideTimerStartedAt;timerStartedAt=now;slideTimerStartedAt=now;
}
function rollSlideTimer(nextIndex){if(timerRunning)commitRunningTime();timedSlideIndex=nextIndex;slideTimerStartedAt=Date.now();}
function renderTimer(){
const totalUsed=currentElapsed(),pageUsed=currentSlideElapsed(),target=plannedMsAt(idx),clock=document.getElementById('ppt-clock');
if(clock)clock.textContent=durationText(totalUsed);
const slideClock=document.getElementById('ppt-slide-clock'),slidePlan=document.getElementById('ppt-slide-plan'),statusBox=document.getElementById('ppt-slide-status-box'),statusLabel=document.getElementById('ppt-slide-status-label'),statusValue=document.getElementById('ppt-slide-status'),statusHint=document.getElementById('ppt-slide-status-hint');
if(slideClock)slideClock.textContent=durationText(pageUsed);if(slidePlan)slidePlan.textContent=target?`计划 ${durationText(target)}`:'计划 —';
if(statusBox&&statusLabel&&statusValue&&statusHint){
const over=Boolean(target&&pageUsed>target),remaining=Boolean(target&&!over);statusBox.classList.toggle('is-over',over);statusBox.classList.toggle('is-remaining',remaining);
statusLabel.textContent=over?'超时':(remaining?'剩余':'计划');statusValue.textContent=target?durationText(Math.abs(target-pageUsed)):'—';statusHint.textContent=over?'已超出本页计划':(remaining?'距本页计划':'未设置');
}
const totalPlan=totalPlannedMs(),plan=document.getElementById('ppt-plan');
if(plan)plan.textContent=totalPlan?`计划 ${Number(totalPlan/60000).toFixed(1)} 分钟 · 预计 ${clockTime(Date.now()+Math.max(0,totalPlan-totalUsed))}`:'计划 —';
const section=sectionInfo(idx),sectionEl=document.getElementById('ppt-section');
if(sectionEl){sectionEl.hidden=!section;sectionEl.textContent=section?`${section.name} · ${section.position}/${section.count}${section.planned?` · 剩余 ${durationText(Math.max(0,section.planned-section.used))}`:''}`:'';}
const timerButton=document.getElementById('ppt-timer-toggle');if(timerButton)timerButton.textContent=timerRunning?'暂停':(totalUsed?'继续计时':'开始计时');
const rehearsalButton=document.getElementById('ppt-rehearsal');if(rehearsalButton){rehearsalButton.textContent=rehearsalActive?'结束排练':'排练';rehearsalButton.classList.toggle('ppt-rehearsal-on',rehearsalActive);}
renderAutoState();
}
function toggleTimer(){const now=Date.now();if(timerRunning){commitRunningTime(now);timerRunning=false;}else{timerStartedAt=now;slideTimerStartedAt=now;timedSlideIndex=idx;timerRunning=true;}renderTimer();}
function resetTimer(){
elapsed=0;slideElapsed=Array(total).fill(0);timedSlideIndex=idx;
if(timerRunning){timerStartedAt=Date.now();slideTimerStartedAt=timerStartedAt;}else{timerStartedAt=0;slideTimerStartedAt=0;}
renderTimer();
}
function rehearsalHistory(){try{return JSON.parse(localStorage.getItem(storageKey('rehearsals'))||'[]');}catch(e){return[];}}
function toggleRehearsal(){
if(rehearsalActive){finishRehearsal(true);return;}
rehearsalActive=true;if(timerRunning){commitRunningTime();timerRunning=false;}resetTimer();toggleTimer();renderTimer();
}
function finishRehearsal(showSummary){
if(!rehearsalActive)return;if(timerRunning){commitRunningTime();timerRunning=false;}
const result={endedAt:Date.now(),totalMs:elapsed,slides:slideElapsed.map((ms,i)=>({index:i,id:slideId(i),title:noteAt(i).title||slideId(i),ms,targetMs:plannedMsAt(i)}))};
const history=rehearsalHistory();history.unshift(result);try{localStorage.setItem(storageKey('rehearsals'),JSON.stringify(history.slice(0,5)));}catch(e){}
rehearsalActive=false;renderTimer();if(showSummary)openRehearsalSummary(result,history.slice(0,5));
}
function openRehearsalSummary(result,history){
const rows=result.slides.filter(item=>item.ms>0).map(item=>`<tr><td>${item.index+1}</td><td>${escapeHTML(item.title)}</td><td>${durationText(item.ms)}</td><td>${item.targetMs?signedDuration(item.ms-item.targetMs):'—'}</td></tr>`).join('');
const recent=history.map(item=>`<div class="ppt-dialog-row"><span>${new Date(item.endedAt).toLocaleString()}</span><strong>${durationText(item.totalMs)}</strong></div>`).join('');
openDialog('排练记录',`<div class="ppt-dialog-section"><div class="ppt-dialog-row"><span>本次总时长</span><strong>${durationText(result.totalMs)}</strong></div><table class="ppt-summary"><thead><tr><th>#</th><th>页面</th><th>实际</th><th>与计划相比</th></tr></thead><tbody>${rows||'<tr><td colspan="4">没有记录到页面时间</td></tr>'}</tbody></table></div><div class="ppt-dialog-section"><h3>最近排练</h3>${recent}</div>`);
}
function autoDurationMs(i=idx){const explicit=Number(noteAt(i).autoAdvanceSeconds);const seconds=Number.isFinite(explicit)&&explicit>0?explicit:autoGlobalSeconds;return seconds>0?seconds*1000:null;}
function armAutoAdvance(){autoDeadline=0;autoRemainingMs=autoDurationMs()||0;}
function autoBlockedReason(){
if(presenterOverviewOn||overviewOn)return'宫格选页中';if(annotationTool!=='none')return'标注中';if(screenMode!=='normal')return'观众屏已暂停';if(document.hidden)return'窗口不可见';
const dialog=document.getElementById('ppt-dialog');if(dialog&&!dialog.hidden)return'设置中';
if(audienceOpenedAt&&syncState()!=='synced')return'等待观众屏同步';return'';
}
function updateAutoAdvance(){
if(!autoEnabled)return;
const duration=autoDurationMs();if(!duration||idx===total-1)return;
const blocked=autoBlockedReason(),now=Date.now();
if(blocked){if(autoDeadline){autoRemainingMs=Math.max(0,autoDeadline-now);autoDeadline=0;}return;}
if(!autoDeadline)autoDeadline=now+(autoRemainingMs||duration);
autoRemainingMs=Math.max(0,autoDeadline-now);
if(now>=autoDeadline){autoDeadline=0;autoRemainingMs=0;go(idx+1,{force:true});}
}
function renderAutoState(){
const button=document.getElementById('ppt-auto-toggle');if(!button)return;
const duration=autoDurationMs(),blocked=autoEnabled?autoBlockedReason():'';
button.classList.toggle('is-on',autoEnabled&&!blocked);button.classList.toggle('is-paused',autoEnabled&&Boolean(blocked));
if(!autoEnabled)button.textContent='自动翻页 · 关闭';
else if(!duration)button.textContent='自动翻页 · 等待时长';
else if(idx===total-1)button.textContent='自动翻页 · 已结束';
else if(blocked)button.textContent=`自动翻页 · ${blocked}`;
else button.textContent=`自动翻页 · ${durationText(autoRemainingMs||duration)}`;
}
function toggleAutoAdvance(){
if(!autoEnabled&&!autoDurationMs()){openSettings();return;}
autoEnabled=!autoEnabled;localStorage.setItem(storageKey('auto-enabled'),autoEnabled?'1':'0');armAutoAdvance();renderAutoState();
}
function openDialog(title,html){
const dialog=document.getElementById('ppt-dialog');if(!dialog)return;
document.getElementById('ppt-dialog-title').textContent=title;document.getElementById('ppt-dialog-body').innerHTML=html;dialog.hidden=false;renderAutoState();
}
function closeDialog(){const dialog=document.getElementById('ppt-dialog');if(dialog)dialog.hidden=true;renderAutoState();}
function applyLayout(value,persist=true){
layoutMode=['balanced','slides','notes'].includes(value)?value:'balanced';
document.getElementById('ppt-presenter')?.setAttribute('data-layout',layoutMode);
if(persist)localStorage.setItem(storageKey('layout'),layoutMode);requestAnimationFrame(fitPresenterFrames);
}
function applyAudienceScreen(value){
const cover=document.getElementById('ppt-audience-cover');if(!cover)return;
cover.classList.remove('is-ended');cover.textContent='';
if(value==='black'||value==='white'){cover.style.display='block';cover.style.background=value;}else cover.style.display='none';
}
function showAudienceEnded(){
const cover=document.getElementById('ppt-audience-cover');if(cover){cover.style.display='';cover.style.background='';cover.textContent='演示已结束';cover.classList.add('is-ended');}
setTimeout(()=>{try{window.close();}catch(e){}},120);
}
function setScreenMode(value){
const next=['normal','black','white','frozen'].includes(value)?value:'normal',wasFrozen=audienceFrozen;
screenMode=next;audienceFrozen=next==='frozen';emit({type:'screen',mode:next});
if(wasFrozen&&!audienceFrozen)sendGoto(true);updateSyncStatus();armAutoAdvance();renderAutoState();
}
function openSettings(){
openDialog('演示设置',`<div class="ppt-settings-card"><label class="ppt-switch-row" for="ppt-auto-enabled-setting"><span><strong>自动翻页</strong><br><span class="ppt-muted">需要时手动开启,默认关闭</span></span><input id="ppt-auto-enabled-setting" type="checkbox" ${autoEnabled?'checked':''}><span class="ppt-switch" aria-hidden="true"></span></label><div class="ppt-field"><label for="ppt-auto-seconds-setting">全局间隔</label><div class="ppt-stepper"><button id="ppt-auto-minus" type="button" aria-label="减少一秒">−</button><input id="ppt-auto-seconds-setting" type="number" min="1" step="1" value="${autoGlobalSeconds||''}" placeholder="—"><span class="ppt-stepper-unit">秒</span><button id="ppt-auto-plus" type="button" aria-label="增加一秒">+</button></div></div><button id="ppt-settings-save" class="ppt-ui-btn ppt-settings-save" type="button">保存自动翻页</button><p class="ppt-settings-copy">单页 autoAdvanceSeconds 优先于全局间隔;minutes 只是讲述计划。</p></div><div class="ppt-settings-card"><h3>演讲者布局</h3><p class="ppt-settings-copy">根据屏幕宽度调整页面与备注的比例</p><div class="ppt-segmented"><button class="ppt-ui-btn" data-layout-choice="balanced" type="button">均衡</button><button class="ppt-ui-btn" data-layout-choice="slides" type="button">页面优先</button><button class="ppt-ui-btn" data-layout-choice="notes" type="button">备注优先</button></div></div><div class="ppt-settings-card"><h3>观众屏</h3><p class="ppt-settings-copy">临时遮挡或冻结观众屏</p><div class="ppt-segmented screen"><button class="ppt-ui-btn" data-screen-choice="normal" type="button">正常</button><button class="ppt-ui-btn" data-screen-choice="black" type="button">黑屏 B</button><button class="ppt-ui-btn" data-screen-choice="white" type="button">白屏 W</button><button class="ppt-ui-btn" data-screen-choice="frozen" type="button">冻结 F</button></div></div><div class="ppt-dialog-section"><button id="ppt-open-shortcuts" class="ppt-ui-btn" type="button">查看快捷键</button></div>`);
document.querySelectorAll('[data-layout-choice]').forEach(button=>{button.classList.toggle('ppt-primary',button.dataset.layoutChoice===layoutMode);button.onclick=()=>{applyLayout(button.dataset.layoutChoice);openSettings();};});
document.querySelectorAll('[data-screen-choice]').forEach(button=>{button.classList.toggle('ppt-primary',button.dataset.screenChoice===screenMode);button.onclick=()=>{setScreenMode(button.dataset.screenChoice);openSettings();};});
const secondsInput=document.getElementById('ppt-auto-seconds-setting'),stepSeconds=delta=>{const current=Math.max(0,Number(secondsInput.value||0));secondsInput.value=String(Math.max(1,current+delta));};
document.getElementById('ppt-auto-minus').onclick=()=>stepSeconds(-1);document.getElementById('ppt-auto-plus').onclick=()=>stepSeconds(1);
document.getElementById('ppt-settings-save').onclick=()=>{autoEnabled=document.getElementById('ppt-auto-enabled-setting').checked;autoGlobalSeconds=Math.max(0,Number(secondsInput.value||0));localStorage.setItem(storageKey('auto-enabled'),autoEnabled?'1':'0');localStorage.setItem(storageKey('auto-seconds'),String(autoGlobalSeconds));armAutoAdvance();closeDialog();};
document.getElementById('ppt-open-shortcuts').onclick=openShortcuts;
}
function openShortcuts(){
const items=[['← / →','上一页 / 下一页'],['Home / End','首页 / 尾页'],['Esc','宫格选页;标注工具激活时先退出工具'],['L','激光笔'],['C','圈选'],['X','清除标记'],['B / W','观众屏黑屏 / 白屏'],['F','冻结 / 恢复观众屏'],['A','开启 / 关闭自动翻页'],['R','开始 / 结束排练'],['?','快捷键说明']];
openDialog('快捷键',`<div class="ppt-shortcuts">${items.map(([key,label])=>`<kbd>${key}</kbd><span>${label}</span>`).join('')}</div>`);
}
function preflightChecks(){
const images=[...document.images],videos=[...document.querySelectorAll('video')],frame=document.getElementById('ppt-current-frame'),rect=frame?.getBoundingClientRect();
return[
{label:'观众屏连接',ok:['synced','frozen'].includes(syncState()),detail:`当前状态:${document.getElementById('ppt-sync')?.textContent||'未连接'}`},
{label:'弹窗权限',ok:!popupBlocked,detail:popupBlocked?'浏览器阻止了观众窗口':'未检测到弹窗拦截'},
{label:'全屏状态',ok:Boolean(document.fullscreenElement),warn:true,detail:document.fullscreenElement?'已进入全屏':'尚未全屏,可在演讲前进入'},
{label:'字体加载',ok:!document.fonts||document.fonts.status==='loaded',detail:document.fonts?`状态:${document.fonts.status}`:'浏览器不提供字体状态'},
{label:'图片资源',ok:images.every(image=>image.complete&&image.naturalWidth>0),detail:`${images.filter(image=>image.complete&&image.naturalWidth>0).length} / ${images.length} 已加载`},
{label:'视频资源',ok:videos.every(video=>video.readyState>=2),detail:videos.length?`${videos.filter(video=>video.readyState>=2).length} / ${videos.length} 可播放`:'没有视频'},
{label:'当前页 16:9',ok:Boolean(rect&&Math.abs(rect.width/rect.height-16/9)<.003),detail:rect?`${Math.round(rect.width)} × ${Math.round(rect.height)}`:'尚未渲染'}
];
}
function openPreflight(){
const checks=preflightChecks(),html=checks.map(item=>`<div class="ppt-dialog-row"><div class="ppt-check ${item.ok?'is-ok':(item.warn?'is-warn':'')}"><span class="ppt-check-dot"></span><span><strong>${item.label}</strong><br><span class="ppt-muted">${item.detail}</span></span></div><strong>${item.ok?'正常':(item.warn?'建议':'异常')}</strong></div>`).join('');
openDialog('演前检查',`<div class="ppt-dialog-section">${html}</div><div class="ppt-dialog-section"><button id="ppt-preflight-refresh" class="ppt-ui-btn" type="button">重新检查</button> <button id="ppt-enter-fullscreen" class="ppt-ui-btn" type="button">进入全屏</button></div><p class="ppt-muted">这里只能检查浏览器和观众页面的软件状态,不能确认 HDMI、转接器或投影仪线缆是否物理连接。</p>`);
document.getElementById('ppt-preflight-refresh').onclick=openPreflight;
document.getElementById('ppt-enter-fullscreen').onclick=async()=>{try{await document.documentElement.requestFullscreen();}catch(e){}openPreflight();};
}
function initTransport(){
if(transportReady)return;transportReady=true;
if('BroadcastChannel'in window){try{channel=new BroadcastChannel(channelName());channel.onmessage=e=>receive(e.data);}catch(e){}}
addEventListener('message',e=>receive(e.data));
addEventListener('storage',e=>{if(e.key===syncKey()&&e.newValue){try{receive(JSON.parse(e.newValue));}catch(err){}}});
}
function emit(payload,{persist=true}={}){
const message={__guizangPptSync:1,session,role:mode,...payload,at:Date.now(),nonce:Math.random().toString(36).slice(2)};
try{channel?.postMessage(message);}catch(e){}
if(persist)try{localStorage.setItem(syncKey(),JSON.stringify(message));}catch(e){}
try{if(mode==='presenter'&&audienceWindow&&!audienceWindow.closed)audienceWindow.postMessage(message,'*');}catch(e){}
try{if(mode==='audience'&&window.opener&&!window.opener.closed)window.opener.postMessage(message,'*');}catch(e){}
}
function receive(message){
if(!message||message.__guizangPptSync!==1||message.session!==session||message.role===mode)return;
if(message.nonce){if(seenNonceSet.has(message.nonce))return;seenNonceSet.add(message.nonce);seenNonces.push(message.nonce);if(seenNonces.length>256)seenNonceSet.delete(seenNonces.shift());}
if(mode==='presenter'){
if(message.type==='ready'){popupBlocked=false;sendGoto(true);}
if(message.type==='ack'){lastAckAt=Date.now();lastAckIndex=Number(message.index);lastAckSeq=Number(message.seq);updateSyncStatus();}
return;
}
if(mode!=='audience')return;
if(message.type==='bye'){showAudienceEnded();return;}
if(message.type==='screen'){applyAudienceScreen(message.mode);return;}
if(message.type==='laser'&&Number(message.index)===idx){audienceLaser=message.point||null;audienceLaserHideAt=Date.now()+650;renderInks();return;}
if(message.type==='circle'&&Number(message.index)===idx&&message.circle){audienceCircles.push(message.circle);renderInks();return;}
if(message.type==='clear'&&Number(message.index)===idx){audienceCircles=[];renderInks();return;}
if(message.type==='goto'||message.type==='ping'){
if(message.type==='goto'){audienceCircles=Array.isArray(message.circles)?message.circles:[];applyAudienceScreen(message.screenMode);go(Number(message.index),{force:true});}
emit({type:'ack',index:idx,seq:Number(message.seq)});
}
}
function receivePreview(message){
if(!message||message.__guizangPptSync!==1||message.session!==session||message.role!=='presenter'||message.type!=='preview-goto')return;
go(Number(message.index),{force:true});
}
function sendGoto(force=false){
if(mode!=='presenter'||audienceFrozen)return;if(force||lastAckIndex!==idx)seq++;
emit({type:'goto',index:idx,seq,screenMode,circles});updateSyncStatus();
}
function syncState(){
if(popupBlocked)return'blocked';if(audienceWindow&&audienceWindow.closed)return'disconnected';if(audienceFrozen)return'frozen';
if(!lastAckAt)return audienceOpenedAt&&Date.now()-audienceOpenedAt<2600?'connecting':'disconnected';
if(Date.now()-lastAckAt>2600)return'disconnected';if(lastAckIndex!==idx||lastAckSeq<seq)return'unsynced';return'synced';
}
function updateSyncStatus(){
const el=document.getElementById('ppt-sync');if(!el)return;const state=syncState();
const labels={blocked:'弹窗被拦截',disconnected:'未连接',connecting:'连接中',unsynced:'未同步',frozen:'已冻结',synced:`已同步 · 第 ${idx+1} 页`};
el.dataset.state=state;el.textContent=`观众屏 · ${labels[state]}`;
}
function reopenAudience(){
initTransport();popupBlocked=false;audienceOpenedAt=Date.now();lastAckAt=0;lastAckIndex=-1;lastAckSeq=-1;
if(audienceFrozen){audienceFrozen=false;screenMode='normal';}
audienceWindow=window.open(frameUrl('audience',idx),`guizang-ppt-audience-${session}`,'popup=yes,width=1440,height=900');
if(!audienceWindow)popupBlocked=true;updateSyncStatus();
}
function enterPresenter(openAudience=true){
if(mode==='presenter')return;if(overviewOn)toggleOverview();lowPowerBeforePresenter=window.__lowPowerMode;mode='presenter';initTransport();mountShell();
document.body.classList.remove('ppt-audience','ppt-preview');document.body.classList.add('ppt-presenter');document.title=`演讲者视图 · ${originalTitle}`;
window.__setLowPowerMode?.(true,{persist:false});updateShell();armAutoAdvance();if(openAudience)reopenAudience();
}
function exitPresenter(){
if(rehearsalActive)finishRehearsal(false);else if(timerRunning){commitRunningTime();timerRunning=false;}togglePresenterOverview(false);emit({type:'bye'});audienceWindow=null;audienceOpenedAt=0;lastAckAt=0;lastAckIndex=-1;lastAckSeq=-1;mode='deck';document.body.classList.remove('ppt-presenter');document.title=originalTitle;window.__setLowPowerMode?.(lowPowerBeforePresenter,{persist:false});
}
function initAudience(){
mode='audience';document.body.classList.add('ppt-audience');document.title=`观众屏 · ${originalTitle}`;
document.body.insertAdjacentHTML('beforeend','<canvas id="ppt-audience-ink" aria-hidden="true"></canvas><div id="ppt-audience-cover" aria-hidden="true"></div>');
const resize=()=>{sizeInk(document.getElementById('ppt-audience-ink'),innerWidth,innerHeight);renderInks();};addEventListener('resize',resize);resize();
initTransport();emit({type:'ready',index:idx});
}
function initPreview(){mode='preview';document.body.classList.add('ppt-preview');window.__setLowPowerMode?.(true,{persist:false});addEventListener('message',e=>{if(e.source===parent)receivePreview(e.data);});}
function onSlideChange(nextIndex){
if(mode!=='presenter')return;rollSlideTimer(nextIndex);circles=[];draftCircle=null;laserPoint=null;annotationPointerDown=false;armAutoAdvance();updateShell();sendGoto();
}
function handleKey(e){
const editable=e.target?.matches?.('input,textarea,[contenteditable="true"]');
if(editable){if(e.key==='Escape')e.target.blur();return true;}
if(mode==='audience'||mode==='preview'){e.preventDefault();return true;}
if(mode==='deck'&&e.key?.toLowerCase()==='p'&&!e.metaKey&&!e.ctrlKey&&!e.altKey){e.preventDefault();enterPresenter(true);return true;}
if(mode!=='presenter')return false;
const key=e.key?.toLowerCase();
const dialog=document.getElementById('ppt-dialog');
if(!dialog?.hidden){if(e.key==='Escape'){e.preventDefault();closeDialog();}return true;}
if(e.key==='?'||key==='/'){e.preventDefault();openShortcuts();return true;}
if(e.key==='Escape'){e.preventDefault();if(annotationTool!=='none')setAnnotationTool('none');else togglePresenterOverview();return true;}
if(presenterOverviewOn)return true;
if(key==='l'){e.preventDefault();setAnnotationTool('laser');return true;}
if(key==='c'){e.preventDefault();setAnnotationTool('circle');return true;}
if(key==='x'){e.preventDefault();clearAnnotations(true);return true;}
if(key==='b'){e.preventDefault();setScreenMode(screenMode==='black'?'normal':'black');return true;}
if(key==='w'){e.preventDefault();setScreenMode(screenMode==='white'?'normal':'white');return true;}
if(key==='f'){e.preventDefault();setScreenMode(audienceFrozen?'normal':'frozen');return true;}
if(key==='a'){e.preventDefault();toggleAutoAdvance();return true;}
if(key==='r'){e.preventDefault();toggleRehearsal();return true;}
if(['ArrowRight','PageDown',' ','ArrowDown'].includes(e.key)){e.preventDefault();go(idx+1,{force:true});return true;}
if(['ArrowLeft','PageUp','ArrowUp'].includes(e.key)){e.preventDefault();go(idx-1,{force:true});return true;}
if(e.key==='Home'){e.preventDefault();go(0,{force:true});return true;}
if(e.key==='End'){e.preventDefault();go(total-1,{force:true});return true;}
return false;
}
function initMode(){
document.getElementById('ppt-low-power-btn')?.addEventListener('click',()=>window.__setLowPowerMode(!window.__lowPowerMode));
document.getElementById('ppt-overview-btn')?.addEventListener('click',toggleOverview);
document.getElementById('ppt-presenter-btn')?.addEventListener('click',()=>enterPresenter(true));
if(requestedMode==='audience')initAudience();else if(requestedMode==='preview')initPreview();else if(requestedMode==='presenter')enterPresenter(false);
}
setInterval(()=>{
renderTimer();renderInks();updateAutoAdvance();
if(mode==='presenter'&&Date.now()-lastPingAt>900){lastPingAt=Date.now();if(audienceWindow?.closed)updateSyncStatus();emit({type:'ping',index:idx,seq});updateSyncStatus();}
},250);
return{initMode,onSlideChange,handleKey,slideLabel,enterPresenter,reopenAudience,toggleOverview:togglePresenterOverview,debugState:()=>({mode,idx,session,syncState:syncState(),timerRunning,rehearsalActive,presenterOverviewOn,autoEnabled,autoGlobalSeconds,autoRemainingMs,screenMode,annotationTool,circles:[...circles],audienceCircles:[...audienceCircles],layoutMode})};
})();
/* =============== /演讲者模式 / 观众屏同步 =============== */
addEventListener('keydown',e=>{
if(window.__pptPresenter?.handleKey(e))return;
if(e.key==='Escape'){e.preventDefault();toggleOverview();return;}
if(e.key && e.key.toLowerCase()==='b' && !e.metaKey && !e.ctrlKey && !e.altKey){
e.preventDefault();
window.__setLowPowerMode(!window.__lowPowerMode);
return;
}
if(overviewOn)return;
if(e.key==='ArrowRight'||e.key==='PageDown'||e.key===' '||e.key==='ArrowDown'){
go(idx+1);
return;
}
if(e.key==='ArrowLeft'||e.key==='PageUp'||e.key==='ArrowUp')go(idx-1);
if(e.key==='Home')go(0);
if(e.key==='End')go(total-1);
});
let wheelTO=null,wheelAcc=0;
addEventListener('wheel',e=>{
wheelAcc+=e.deltaY+e.deltaX;
if(Math.abs(wheelAcc)>50){
go(idx+(wheelAcc>0?1:-1));wheelAcc=0;
}
clearTimeout(wheelTO);wheelTO=setTimeout(()=>wheelAcc=0,150);
},{passive:true});
let tx=0,ty=0;
addEventListener('touchstart',e=>{tx=e.touches[0].clientX;ty=e.touches[0].clientY},{passive:true});
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)){
go(idx+(dx<0?1:-1));
}
},{passive:true});
const initialSlideParam = new URLSearchParams(location.search).get('slide');
const initialSlide = initialSlideParam ? Number(initialSlideParam) - 1 : 0;
go(Number.isFinite(initialSlide) ? initialSlide : 0);
window.__pptPresenter?.initMode();
</script>
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
<script>lucide.createIcons();</script>
<!-- Motion One 动效引擎 (与原模板一致) -->
<script type="module">
let motion;
try {
motion = await import('./assets/motion.min.js');
} catch(e1) {
try {
motion = await import('https://cdn.jsdelivr.net/npm/[email protected]/+esm');
} 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'});
}
}
if(motion){
const { animate } = motion;
document.body.classList.add('motion-ready');
/* ============================================================
IBM Carbon Motion · 每个 recipe 服务一种表达
不是一刀切的 stagger,而是把动效绑在内容语义上
============================================================ */
/* 缓动从 :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;
function resetAnims(slide){
slide.querySelectorAll('[data-anim]').forEach(el=>{
el.style.opacity='';
el.style.transform='';
});
/* 同时复位需要被 recipe 接管的元素 */
slide.querySelectorAll('.row-fill,.tl-node,.stack-block,.bar-tower,.sub-card,.col,.vrule,.kpi-cell')
.forEach(el=>{el.style.cssText = el.dataset._origCss || el.style.cssText;});
}
/* ---------- 通用工具 ---------- */
const fade = (el, opts={})=>animate(el,
{opacity:[0,1], y:[opts.y ?? 12, 0]},
{duration:opts.duration ?? .6, delay:opts.delay ?? 0,
easing:opts.easing ?? EASE_ENTRY_EXP});
/* ---------- recipe: hero · 封面索引 ----------
大编号一个个亮起 → 索引行最后落定 */
function rHero(slide, all){
const numRows = [...slide.querySelectorAll('.cover-row')];
const rest = all.filter(el=>!numRows.length || el !== numRows[0]);
/* 先入: chrome 240ms */
const chrome = slide.querySelector('.chrome-min');
if(chrome) animate(chrome, {opacity:[0,1]}, {duration:.24, easing:EASE_PROD});
/* 大编号 01/02/03 像点名一样依次亮 */
numRows.forEach((row, i)=>{
animate(row, {opacity:[0,1], x:[-12,0]},
{duration:.5, delay:.15 + i*.18, easing:EASE_ENTRY_EXP});
});
/* 索引底栏最后慢慢落定 */
const idx = slide.querySelector('[data-anim="line"]');
if(idx) fade(idx, {delay:.15 + numRows.length*.18 + .1, duration:.5, y:6});
}
/* ---------- recipe: progression · 1× → 10× → 1000× ----------
节点依次入场,每个节点的数字单独"递进生长"营造跃迁 */
function rProgression(slide, all){
const head = slide.querySelector('[data-anim="line"]');
if(head) fade(head, {duration:.6, y:10});
const nodes = [...slide.querySelectorAll('.tl-node')];
nodes.forEach((node, i)=>{
const base = .35 + i*.32; /* 节点之间间隔大,营造时间感 */
/* 整个节点先轻微浮入 */
animate(node, {opacity:[0,1], y:[14, 0]},
{duration:.55, delay:base, easing:EASE_ENTRY_EXP});
/* 再让 multi(数字)从 .85 scale 弹到 1,延迟 100ms */
const multi = node.querySelector('.multi');
if(multi) animate(multi, {scale:[.92, 1], opacity:[0,1]},
{duration:.5, delay:base + .12, easing:EASE_ENTRY_EXP});
});
/* 底部 KPI 4 列最后落定,内部 60ms stagger */
const kpis = [...slide.querySelectorAll('.kpi-cell')];
kpis.forEach((cell, i)=>{
animate(cell, {opacity:[0,1], y:[8, 0]},
{duration:.4, delay:1.4 + i*.07, easing:EASE_PROD});
});
}
/* ---------- recipe: statement · 大宣言 ----------
左半屏标题逐行落下,右半屏 leaked 信息晚 600ms 进 */
function rStatement(slide, all){
const halves = [...slide.querySelectorAll('.half')];
if(halves.length === 2){
animate(halves[0], {opacity:[0,1], y:[18,0]},
{duration:.7, delay:0, easing:EASE_ENTRY_EXP});
animate(halves[1], {opacity:[0,1], y:[18,0]},
{duration:.7, delay:.6, easing:EASE_ENTRY_EXP});
} else {
/* P9 Index Card — 三行像盖章一样依次落 */
const head = slide.querySelector('[data-anim="line"]');
if(head) fade(head, {duration:.5, y:6});
const blocks = all.filter(el=>el !== head);
blocks.forEach((el, i)=>{
animate(el, {opacity:[0,1], y:[20,0]},
{duration:.55, delay:.25 + i*.18, easing:EASE_ENTRY_EXP});
});
}
}
/* ---------- recipe: grid-reveal · 五个定义 ----------
卡片按 nb-corner 序号 01→02→03→04→05→Σ 依次揭示 */
function rGridReveal(slide, all){
const head = slide.querySelector('[data-anim="line"]');
if(head) fade(head, {duration:.6, y:10});
const cards = [...slide.querySelectorAll('.sub-card')];
cards.forEach((card, i)=>{
animate(card, {opacity:[0,1], y:[20,0], scale:[.96, 1]},
{duration:.5, delay:.3 + i*.09, easing:EASE_ENTRY_EXP});
});
}
/* ---------- recipe: stack-build · 三层架构 ----------
中间 thin 先入 → 上层 fat skills 从顶推下 → 下层 application 从底推上 */
function rStackBuild(slide, all){
const head = slide.querySelector('[data-anim="line"]');
if(head) fade(head, {duration:.6, y:10});
const blocks = [...slide.querySelectorAll('.stack-block')];
/* 先入: 中间薄层(LAYER 02) */
if(blocks[1]) animate(blocks[1], {opacity:[0,1], scaleY:[.85, 1]},
{duration:.55, delay:.3, easing:EASE_ENTRY_EXP});
/* 上推下: LAYER 01 fat skills 从顶部 push down */
if(blocks[0]) animate(blocks[0], {opacity:[0,1], y:[-22, 0]},
{duration:.6, delay:.6, easing:EASE_ENTRY_EXP});
/* 下推上: LAYER 03 application 从底部 push up */
if(blocks[2]) animate(blocks[2], {opacity:[0,1], y:[22, 0]},
{duration:.6, delay:.6, easing:EASE_ENTRY_EXP});
const foot = slide.querySelector('.t-meta');
if(foot) animate(foot, {opacity:[0,1]}, {duration:.3, delay:1.3, easing:EASE_PROD});
}
/* ---------- recipe: measure-up · YC KPI 塔 ----------
塔从底部 scaleY 0→1 生长 + 数字最后弹入 */
function rMeasureUp(slide, all){
const head = slide.querySelector('[data-anim="line"]');
if(head) fade(head, {duration:.6, y:10});
const towers = [...slide.querySelectorAll('.bar-tower')];
towers.forEach((tower, i)=>{
const block = tower.querySelector('.body-block');
if(block){
block.style.transformOrigin = 'bottom center';
animate(block, {opacity:[0,1], scaleY:[.05, 1]},
{duration:.7, delay:.35 + i*.12, easing:EASE_ENTRY_EXP});
}
/* cap (顶部图标) 等柱体长好后弹入 */
const cap = tower.querySelector('.cap');
if(cap) animate(cap, {opacity:[0,1], y:[-8, 0]},
{duration:.4, delay:.85 + i*.12, easing:EASE_PROD});
});
}
/* ---------- recipe: bar-grow · 90% 价值分布 ----------
标题先入 → hairline 从中点向两侧 stroke draw → bar 依次 width 0→target → 数值 fade in */
function rBarGrow(slide, all){
const head = slide.querySelector('[data-anim="line"]');
if(head) fade(head, {duration:.6, y:10});
/* 中部 hairline:从 100% width 0 拉到 100% (transformOrigin: center) */
const midRow = slide.querySelector('[data-anim="up"]');
if(midRow){
const midLabel = midRow.querySelector('.t-cat');
const midLine = midRow.querySelector('div[style*="height:1px"]');
if(midLabel) animate(midLabel, {opacity:[0,1], x:[-8,0]},
{duration:.4, delay:.4, easing:EASE_PROD});
if(midLine){
midLine.style.transformOrigin = 'center';
animate(midLine, {opacity:[0,1], scaleX:[0, 1]},
{duration:.55, delay:.5, easing:EASE_ENTRY_EXP});
}
}
/* bar 行依次 width 增长 */
const fills = [...slide.querySelectorAll('.row-fill')];
const labels = [...slide.querySelectorAll('.row-lbl')];
const values = [...slide.querySelectorAll('.row-val')];
fills.forEach((fill, i)=>{
const target = fill.style.width;
fill.style.width = '0%';
if(labels[i]) animate(labels[i], {opacity:[0,1], x:[-12,0]},
{duration:.4, delay:.85 + i*.14, easing:EASE_PROD});
animate(fill, {width:['0%', target]},
{duration:.65, delay:.95 + i*.14, easing:EASE_ENTRY_EXP});
if(values[i]) animate(values[i], {opacity:[0,1]},
{duration:.3, delay:1.5 + i*.14, easing:EASE_PROD});
});
}
/* ---------- recipe: duo-mirror · Latent vs Deterministic ----------
左 80ms 入,vrule 从中心 scaleY 0→1,右 240ms 入 */
function rDuoMirror(slide, all){
const head = slide.querySelector('[data-anim="line"]');
if(head) fade(head, {duration:.6, y:10});
const cols = [...slide.querySelectorAll('.duo-compare .col')];
const vrule = slide.querySelector('.duo-compare .vrule');
if(cols[0]) animate(cols[0], {opacity:[0,1], x:[-24, 0]},
{duration:.65, delay:.4, easing:EASE_ENTRY_EXP});
if(vrule){
vrule.style.transformOrigin = 'center';
animate(vrule, {opacity:[0,1], scaleY:[0, 1]},
{duration:.55, delay:.55, easing:EASE_ENTRY_EXP});
}
if(cols[1]) animate(cols[1], {opacity:[0,1], x:[24, 0]},
{duration:.65, delay:.7, easing:EASE_ENTRY_EXP});
const foot = slide.querySelector('.t-meta');
if(foot) animate(foot, {opacity:[0,1]}, {duration:.3, delay:1.3, easing:EASE_PROD});
}
/* ---------- recipe: split-statement · 收尾 ----------
左黑半屏的 once / forever 错位入场;右白半屏 takeaway list 后跟 */
function rSplitStatement(slide, all){
const halves = [...slide.querySelectorAll('.half')];
/* 左黑半屏 — once 先入,forever 间隔 600ms */
if(halves[0]){
animate(halves[0], {opacity:[0,1]}, {duration:.4, easing:EASE_PROD});
const kpis = halves[0].querySelectorAll('.kpi-thin');
kpis.forEach((k, i)=>{
animate(k, {opacity:[0,1], y:[24,0]},
{duration:.7, delay:.25 + i*.55, easing:EASE_ENTRY_EXP});
});
}
/* 右白半屏 — list 三条依次入,在左侧 once 出现后开始 */
if(halves[1]){
animate(halves[1], {opacity:[0,1]}, {duration:.4, delay:.3, easing:EASE_PROD});
const items = halves[1].querySelectorAll('.takeaway-list li');
items.forEach((li, i)=>{
animate(li, {opacity:[0,1], x:[20, 0]},
{duration:.45, delay:1.0 + i*.12, easing:EASE_ENTRY_EXP});
});
}
}
/* ---------- recipe: timeline-walk · P11 横向 evolution ----------
标题先入 → 横轴虚线 scaleX 拉开(伪) → 5 个 dot 按年代依次 scale 入 → label 跟随 */
function rTimelineWalk(slide, all){
const head = slide.querySelector('[data-anim="line"]');
if(head) fade(head, {duration:.55, y:10});
const tl = slide.querySelector('.timeline-h');
if(tl) animate(tl, {opacity:[0,1]}, {duration:.4, delay:.35, easing:EASE_PROD});
const nodes = [...slide.querySelectorAll('.timeline-h .th-node')];
nodes.forEach((node, i)=>{
const base = .55 + i*.18;
const dot = node.querySelector('.dot');
const label = node.querySelector('.label');
if(dot){
dot.style.transformOrigin='center';
animate(dot, {opacity:[0,1], scale:[.2, 1]},
{duration:.45, delay:base, easing:EASE_ENTRY_EXP});
}
if(label){
const fromY = node.classList.contains('up') ? 8 : -8;
/* 保留 CSS 的水平居中 translateX(-50%),避免动效覆盖后 label 与 dot 错位 */
animate(label, {opacity:[0,1], transform:[`translate(-50%, ${fromY}px)`, 'translate(-50%, 0px)']},
{duration:.5, delay:base + .12, easing:EASE_ENTRY_EXP});
}
});
const foot = slide.querySelector('.t-meta');
if(foot) animate(foot, {opacity:[0,1]}, {duration:.3, delay:1.7, easing:EASE_PROD});
}
/* ---------- recipe: manifesto · P12 Form & Found ----------
副标先入 → 大字两段错峰落 → 底部 ink 通栏条从下推上 */
function rManifesto(slide, all){
const head = slide.querySelector('[data-anim="line"]');
if(head){
const cat = head.querySelector('.t-cat');
const title = head.querySelector('div:nth-child(2)');
if(cat) animate(cat, {opacity:[0,1], x:[-10,0]},
{duration:.4, delay:.1, easing:EASE_PROD});
if(title) animate(title, {opacity:[0,1], y:[26, 0]},
{duration:.85, delay:.3, easing:EASE_ENTRY_EXP});
}
/* 底部 ink 条从下推入 */
const foot = [...slide.querySelectorAll('[data-anim="up"]')];
foot.forEach((el, i)=>{
animate(el, {opacity:[0,1], y:[40, 0]},
{duration:.75, delay:.85 + i*.12, easing:EASE_ENTRY_EXP});
});
}
/* ---------- recipe: three-forces · P13 ----------
左 ink hero 先入 → 右 3 张卡按 1/2/3 依次从右滑入 + 每张大数字单独弹入 */
function rThreeForces(slide, all){
const head = slide.querySelector('[data-anim="line"]');
if(head) fade(head, {duration:.5, y:8});
const grid = slide.querySelector('[data-anim="up"]');
if(grid) animate(grid, {opacity:[0,1]}, {duration:.3, delay:.3, easing:EASE_PROD});
const heroBlock = grid?.querySelector(':scope > div:first-child');
if(heroBlock) animate(heroBlock, {opacity:[0,1], x:[-26, 0]},
{duration:.6, delay:.4, easing:EASE_ENTRY_EXP});
const cards = grid ? [...grid.querySelectorAll(':scope > div:nth-child(2) > .card-fill')] : [];
cards.forEach((card, i)=>{
const base = .6 + i*.18;
animate(card, {opacity:[0,1], x:[28, 0]},
{duration:.6, delay:base, easing:EASE_ENTRY_EXP});
const num = card.querySelector(':scope > div:first-child');
if(num) animate(num, {opacity:[0,1], scale:[.7, 1]},
{duration:.5, delay:base + .15, easing:EASE_ENTRY_EXP});
});
}
/* ---------- recipe: loop-form · P14 自学闭环 ----------
左 4 步像台阶依次入 → 右环图节点按时钟顺序入 → 中心 improves scale 入 */
function rLoopForm(slide, all){
const head = slide.querySelector('[data-anim="line"]');
if(head) fade(head, {duration:.55, y:10});
const grid = slide.querySelector('[data-anim="up"]');
if(grid) animate(grid, {opacity:[0,1]}, {duration:.3, delay:.35, easing:EASE_PROD});
/* 左侧 4 步台阶,每步从左滑入 */
const steps = grid ? [...grid.querySelectorAll(':scope > div:first-child > div')] : [];
steps.forEach((step, i)=>{
animate(step, {opacity:[0,1], x:[-18, 0]},
{duration:.5, delay:.5 + i*.14, easing:EASE_ENTRY_EXP});
});
/* 右侧 SVG 节点 (4 个 circle + label) 按 01→04 顺序入 */
const svg = grid?.querySelector('svg');
if(svg){
const ring = svg.querySelector('circle:first-of-type');
if(ring) animate(ring, {opacity:[0,.25]}, {duration:.5, delay:.6, easing:EASE_PROD});
const nodeCircles = [...svg.querySelectorAll('circle')].slice(1);
nodeCircles.forEach((c, i)=>{
c.style.transformOrigin = `${c.getAttribute('cx')}px ${c.getAttribute('cy')}px`;
animate(c, {opacity:[0,1], scale:[.4, 1]},
{duration:.45, delay:.7 + i*.16, easing:EASE_ENTRY_EXP});
});
const arrows = [...svg.querySelectorAll('path[marker-end]')];
arrows.forEach((p, i)=>{
animate(p, {opacity:[0,1]},
{duration:.4, delay:.85 + i*.16, easing:EASE_PROD});
});
const center = [...svg.querySelectorAll('text')].slice(-2);
center.forEach((t, i)=>{
animate(t, {opacity:[0,1], scale:[.7, 1]},
{duration:.5, delay:1.55 + i*.1, easing:EASE_ENTRY_EXP});
});
}
}
/* ---------- recipe: matrix-fill · P15 skill 矩阵 ----------
标题入 → 12 张卡按对角线波 (i+j) 扫入 → 底部 20,000 大数字最后 fade 入 */
function rMatrixFill(slide, all){
const head = slide.querySelector('[data-anim="line"]');
if(head) fade(head, {duration:.55, y:10});
const matrix = slide.querySelector('[data-anim="up"]');
if(!matrix) return;
animate(matrix, {opacity:[0,1]}, {duration:.3, delay:.35, easing:EASE_PROD});
const cards = [...matrix.children];
const cols = 6;
cards.forEach((card, i)=>{
const row = Math.floor(i/cols), col = i%cols;
const wave = (row + col) * .055;
animate(card, {opacity:[0,1], y:[14, 0], scale:[.92, 1]},
{duration:.42, delay:.5 + wave, easing:EASE_ENTRY_EXP});
});
/* 底部 20,000 区块 */
const foot = [...slide.querySelectorAll('[data-anim="up"]')][1];
if(foot){
animate(foot, {opacity:[0,1], y:[18, 0]},
{duration:.7, delay:1.4, easing:EASE_ENTRY_EXP});
const bigNum = foot.querySelector('div:nth-child(1) > div:nth-child(2)');
if(bigNum) animate(bigNum, {opacity:[0,1], scale:[.94, 1]},
{duration:.7, delay:1.55, easing:EASE_ENTRY_EXP});
}
}
/* ---------- recipe: field-notes · P16 散点观察 ----------
标题入 → 6 张卡按"散点"乱序延迟入,微小旋转复位 */
function rFieldNotes(slide, all){
const head = slide.querySelector('[data-anim="line"]');
if(head) fade(head, {duration:.55, y:10});
const grid = slide.querySelector('[data-anim="up"]');
if(!grid) return;
animate(grid, {opacity:[0,1]}, {duration:.3, delay:.35, easing:EASE_PROD});
/* 散点顺序: 用一个稍微打乱的索引数组,营造"乱中有序"感 */
const order = [0, 3, 1, 4, 2, 5];
const cards = [...grid.children];
order.forEach((idx, i)=>{
const card = cards[idx];
if(!card) return;
animate(card, {opacity:[0,1], y:[18, 0], rotate:[(idx%2?-.6:.6), 0]},
{duration:.55, delay:.5 + i*.11, easing:EASE_ENTRY_EXP});
});
}
/* ---------- recipe: system-diagram · P17 三圆系统图 ----------
标题入 → SVG 三组图依次入 + 中间同心圆从外向内 scale 入 → 下方注释列依次入 */
function rSystemDiagram(slide, all){
const head = slide.querySelector('[data-anim="line"]');
if(head) fade(head, {duration:.55, y:10});
const stage = slide.querySelector('[data-anim="up"]');
if(!stage) return;
animate(stage, {opacity:[0,1]}, {duration:.3, delay:.35, easing:EASE_PROD});
const svgs = [...stage.querySelectorAll('svg')];
svgs.forEach((svg, i)=>{
const base = .55 + i*.22;
const circles = [...svg.querySelectorAll('circle')];
/* 中间是同心圆: 从外圈到内圈依次 scale 入 */
if(circles.length > 1){
circles.forEach((c, j)=>{
c.style.transformOrigin = `${c.getAttribute('cx')}px ${c.getAttribute('cy')}px`;
animate(c, {opacity:[0,1], scale:[.4, 1]},
{duration:.5, delay:base + j*.13, easing:EASE_ENTRY_EXP});
});
} else if(circles[0]){
circles[0].style.transformOrigin = `${circles[0].getAttribute('cx')}px ${circles[0].getAttribute('cy')}px`;
animate(circles[0], {opacity:[0,1], scale:[.4, 1]},
{duration:.5, delay:base, easing:EASE_ENTRY_EXP});
}
const labels = [...svg.querySelectorAll('text')];
labels.forEach((t, j)=>{
animate(t, {opacity:[0,1]},
{duration:.4, delay:base + .25 + j*.06, easing:EASE_PROD});
});
});
/* 下方注释列 */
const cols = [...stage.querySelectorAll(':scope > div:last-child > div')];
cols.forEach((col, i)=>{
animate(col, {opacity:[0,1], y:[12, 0]},
{duration:.45, delay:1.3 + i*.1, easing:EASE_ENTRY_EXP});
});
}
/* ---------- recipe: why-now · P18 三列 + 巨大底数 ----------
标题入 → 三列文本入 → 三个底部巨数 01/02/03 错峰 scale 落定 */
function rWhyNow(slide, all){
const head = slide.querySelector('[data-anim="line"]');
if(head) fade(head, {duration:.55, y:10});
const grid = slide.querySelector('[data-anim="up"]');
if(!grid) return;
animate(grid, {opacity:[0,1]}, {duration:.3, delay:.35, easing:EASE_PROD});
const cols = [...grid.children];
cols.forEach((col, i)=>{
const base = .5 + i*.16;
const body = col.querySelector(':scope > div:not(:last-child)');
const big = col.querySelector(':scope > div:last-child');
if(body) animate(body, {opacity:[0,1], y:[14, 0]},
{duration:.55, delay:base, easing:EASE_ENTRY_EXP});
if(big) animate(big, {opacity:[0,1], scale:[.7, 1]},
{duration:.7, delay:base + .35, easing:EASE_ENTRY_EXP});
});
}
/* ---------- recipe: four-cards · P19 4 列卡片 ----------
顶部红线 scaleX 0→1 → 标题入 → 4 卡按 01-04 依次入 */
function rFourCards(slide, all){
/* 顶部红线 */
const topRule = slide.querySelector('[data-anim="line"] > div:first-child');
if(topRule){
topRule.style.transformOrigin = 'left center';
animate(topRule, {opacity:[0,1], scaleX:[0, 1]},
{duration:.5, delay:.1, easing:EASE_ENTRY_EXP});
}
const head = slide.querySelector('[data-anim="line"]');
if(head){
const title = head.querySelector(':scope > div:nth-child(2)');
if(title) animate(title, {opacity:[0,1], y:[14, 0]},
{duration:.55, delay:.4, easing:EASE_ENTRY_EXP});
}
const grid = slide.querySelector('[data-anim="up"]');
if(!grid) return;
animate(grid, {opacity:[0,1]}, {duration:.3, delay:.55, easing:EASE_PROD});
const cards = [...grid.children];
cards.forEach((card, i)=>{
animate(card, {opacity:[0,1], y:[18, 0]},
{duration:.55, delay:.7 + i*.13, easing:EASE_ENTRY_EXP});
});
}
/* ============ P20 · Stacked KPI Ledger · 4 行账单逐行点亮 + 行间发丝从左画 ============ */
function rStackedLedger(slide, all){
const ledger = slide.querySelector('[data-anim="ledger"]');
if(!ledger) return;
animate(ledger, {opacity:[0,1]}, {duration:.3, delay:.1, easing:EASE_PROD});
const rows = [...ledger.querySelectorAll('.ledger-row')];
rows.forEach((row, i)=>{
const base = .25 + i*.18;
const num = row.querySelector('.ledger-num');
const label = row.querySelector('.ledger-label');
const icon = row.querySelector('.ledger-icon');
if(num) animate(num, {opacity:[0,1], y:[20, 0]}, {duration:.7, delay:base, easing:EASE_ENTRY_EXP});
if(label) animate(label, {opacity:[0,1], x:[-12, 0]}, {duration:.55, delay:base + .12, easing:EASE_ENTRY_EXP});
if(icon) animate(icon, {opacity:[0,1], scale:[.6,1]},{duration:.55, delay:base + .22, easing:EASE_ENTRY_EXP});
});
}
/* ============ P21 · Tech Spec Sheet · 标题分行 / KPI 顶线画出 + count 风感 / 竖线弹起 / 底巨数 ============ */
function rTechSpec(slide, all){
const head = slide.querySelector('[data-anim="line"]');
if(head) fade(head, {duration:.5, y:8});
const main = slide.querySelector('[data-anim="up"]');
if(main){
animate(main, {opacity:[0,1]}, {duration:.3, delay:.25, easing:EASE_PROD});
/* 左大标题分行 */
const titleLines = main.querySelector(':scope > div:first-child > div:first-child');
if(titleLines){
animate(titleLines, {opacity:[0,1], y:[18, 0]}, {duration:.7, delay:.35, easing:EASE_ENTRY_EXP});
}
const titleNote = main.querySelector(':scope > div:first-child > div:nth-child(2)');
if(titleNote){
animate(titleNote, {opacity:[0,1], y:[10, 0]}, {duration:.5, delay:.95, easing:EASE_ENTRY_EXP});
}
/* 三 KPI · 顶线 scaleX + 数字 fade-up + 副文字 */
const kpis = [...main.querySelectorAll(':scope > div:not([data-anim]):not(:first-child)')];
kpis.forEach((kpi, i)=>{
const base = .55 + i*.18;
const topRule = kpi.querySelector(':scope > div:first-child');
if(topRule){
topRule.style.transformOrigin = 'left center';
animate(topRule, {scaleX:[0,1], opacity:[0,1]}, {duration:.5, delay:base, easing:EASE_ENTRY_EXP});
}
const num = kpi.querySelector('.kpi-num');
if(num) animate(num, {opacity:[0,1], y:[14, 0]}, {duration:.6, delay:base + .15, easing:EASE_ENTRY_EXP});
const otherKids = [...kpi.children].filter(el=>el !== topRule && el !== num);
otherKids.forEach((el, j)=>{
animate(el, {opacity:[0,1]}, {duration:.4, delay:base + .25 + j*.05, easing:EASE_PROD});
});
});
}
/* 底部 hero 区: 巨数 + goal + tags + 右下竖线 */
const hero = slide.querySelector('[data-anim="hero"]');
if(hero){
animate(hero, {opacity:[0,1]}, {duration:.3, delay:1.3, easing:EASE_PROD});
const bottomHero = hero.querySelector('.bottom-hero');
if(bottomHero) animate(bottomHero, {opacity:[0,1], y:[24, 0], scale:[.92, 1]}, {duration:.7, delay:1.4, easing:EASE_ENTRY_EXP});
const middle = hero.querySelector(':scope > div:nth-child(2)');
if(middle){
const kids = [...middle.children];
kids.forEach((el, i)=>{
if(el.style && el.style.background === 'var(--ink)'){
el.style.transformOrigin = 'left center';
animate(el, {scaleX:[0,1], opacity:[0,1]}, {duration:.5, delay:1.6 + i*.1, easing:EASE_ENTRY_EXP});
} else {
animate(el, {opacity:[0,1], y:[10, 0]}, {duration:.5, delay:1.55 + i*.1, easing:EASE_ENTRY_EXP});
}
});
}
/* 右下: 文字先入, 9 根竖线再从底部 scaleY 弹起 */
const right = hero.querySelector(':scope > div:nth-child(3)');
if(right){
const rightText = right.querySelector(':scope > div:last-child');
if(rightText) animate(rightText, {opacity:[0,1], y:[10, 0]}, {duration:.5, delay:1.85, easing:EASE_ENTRY_EXP});
}
const bars = slide.querySelectorAll('[data-anim="bars"] .vbar');
bars.forEach((bar, i)=>{
bar.style.transformOrigin = 'bottom';
animate(bar, {scaleY:[0,1], opacity:[0,1]}, {duration:.5, delay:2.0 + i*.04, easing:EASE_ENTRY_EXP});
});
}
}
/* ============ P22 · Image Hero · 图缓推 + 标题白块从左滑入 + 三 KPI 顶线画出 ============ */
function rImageHero(slide, all){
const img = slide.querySelector('[data-anim="img"] img');
if(img){
animate(img, {opacity:[0,1], scale:[1.06, 1]}, {duration:1.1, delay:.05, easing:EASE_ENTRY_EXP});
}
const titleBlock = slide.querySelector('[data-anim="title-block"]');
if(titleBlock){
titleBlock.style.transformOrigin = 'left center';
animate(titleBlock, {opacity:[0,1], scaleX:[0, 1]}, {duration:.7, delay:.45, easing:EASE_ENTRY_EXP});
const titleText = titleBlock.querySelector('div');
if(titleText) animate(titleText, {opacity:[0,1]}, {duration:.4, delay:.85, easing:EASE_PROD});
}
const kpiWrap = slide.querySelector('[data-anim="kpi"]');
if(kpiWrap){
animate(kpiWrap, {opacity:[0,1]}, {duration:.3, delay:.7, easing:EASE_PROD});
/* 段落 */
const para = kpiWrap.querySelector(':scope > div:first-child');
if(para) animate(para, {opacity:[0,1], y:[14, 0]}, {duration:.6, delay:.85, easing:EASE_ENTRY_EXP});
/* 三列 KPI · 顶线 scaleX + 数字升起 */
const cols = [...kpiWrap.querySelectorAll(':scope > div:nth-child(2) > div')];
cols.forEach((col, i)=>{
const base = 1.1 + i*.18;
const topRule = col.querySelector(':scope > div:first-child');
if(topRule){
topRule.style.transformOrigin = 'left center';
animate(topRule, {scaleX:[0,1], opacity:[0,1]}, {duration:.5, delay:base, easing:EASE_ENTRY_EXP});
}
const cat = col.querySelector('.t-meta');
if(cat) animate(cat, {opacity:[0,1]}, {duration:.4, delay:base + .15, easing:EASE_PROD});
const hero = col.querySelector('.kpi-hero');
if(hero) animate(hero, {opacity:[0,1], y:[18, 0]}, {duration:.7, delay:base + .25, easing:EASE_ENTRY_EXP});
const handled = new Set([topRule, cat, hero]);
[...col.children]
.filter(el => !handled.has(el))
.forEach((el, j)=>{
animate(el, {opacity:[0,1]}, {duration:.4, delay:base + .45 + j*.05, easing:EASE_PROD});
});
});
}
}
const RECIPES = {
'hero': rHero,
'progression': rProgression,
'statement': rStatement,
'grid-reveal': rGridReveal,
'stack-build': rStackBuild,
'measure-up': rMeasureUp,
'bar-grow': rBarGrow,
'duo-mirror': rDuoMirror,
'split-statement': rSplitStatement,
'timeline-walk': rTimelineWalk,
'manifesto': rManifesto,
'three-forces': rThreeForces,
'loop-form': rLoopForm,
'matrix-fill': rMatrixFill,
'field-notes': rFieldNotes,
'system-diagram': rSystemDiagram,
'why-now': rWhyNow,
'four-cards': rFourCards,
'stacked-ledger': rStackedLedger,
'tech-spec': rTechSpec,
'image-hero': rImageHero,
};
function revealStatic(slide){
resetAnims(slide);
document.getAnimations?.().forEach(a=>a.cancel());
slide.querySelectorAll('[data-anim],.row-fill,.tl-node,.stack-block,.bar-tower,.sub-card,.col,.vrule,.kpi-cell,.card-fill,.card-accent,.card-ink')
.forEach(el=>{
el.style.opacity='1';
el.style.transform='none';
});
}
function playSlide(i){
const slide = slides[i];
if(!slide) return;
lastIdx = i;
if(window.__lowPowerMode){
revealStatic(slide);
return;
}
resetAnims(slide);
/* 关键:[data-anim] 容器很多时候只是占位标记,真正的几何动画在子元素上.
默认强制 reveal 所有 [data-anim] 容器, recipe 想做块入场时用 motion 的 {opacity:[0,1]} 会自动覆盖 */
slide.querySelectorAll('[data-anim]').forEach(el=>{
el.style.opacity = '1';
el.style.transform = 'none';
});
const all = [...slide.querySelectorAll('[data-anim]')];
const recipe = slide.dataset.animate;
const fn = RECIPES[recipe];
if(fn){ fn(slide, all); return; }
/* fallback: 平凡 fade */
if(all.length) animate(all, {opacity:[0,1], y:[12,0]},
{duration:.6, delay:i=>i*.08, easing:EASE_ENTRY_EXP});
}
window.__playSlide = playSlide;
playSlide(window.__currentSlideIndex || 0);
}
</script>
<script>
/* ============== ASCII 点阵呼吸场 · IKB 封面/封底专用 ==============
sin/cos 二维噪声场驱动字符显隐,营造工业仪表板的"涌动呼吸"质感.
纯 canvas 2D, mix-blend-mode:screen 让字符在 IKB 底色上自然发亮.
用法:在需要呼吸场的容器(.canvas-card 或 split .half.b-accent)内首位插入
<canvas class="ascii-bg" aria-hidden="true">,本脚本会自动扫描并启动. */
(function(){
const canvases = [...document.querySelectorAll('canvas.ascii-bg')];
if(!canvases.length) return;
const PALETTE = ' ...:::---+++***◦◦••▢▣';
const CELL = 16;
const FONT_SIZE = 13;
function setup(c){
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const rect = c.getBoundingClientRect();
if(rect.width < 4 || rect.height < 4) return false;
c.width = Math.round(rect.width * dpr);
c.height = Math.round(rect.height * dpr);
c.__dpr = dpr;
c.__w = rect.width;
c.__h = rect.height;
const ctx = c.getContext('2d');
ctx.setTransform(dpr,0,0,dpr,0,0);
const mono = (getComputedStyle(document.documentElement).getPropertyValue('--mono') || 'JetBrains Mono, monospace').trim();
ctx.font = `500 ${FONT_SIZE}px ${mono}`;
ctx.textBaseline = 'top';
c.__ctx = ctx;
return true;
}
function draw(c, t){
if(!c.__ctx) return;
const ctx = c.__ctx, w = c.__w, h = c.__h;
ctx.clearRect(0, 0, w, h);
const cols = Math.ceil(w / CELL);
const rows = Math.ceil(h / CELL);
for(let r=0; r<rows; r++){
for(let cc=0; cc<cols; cc++){
const n = (
Math.sin(cc * 0.18 + t) +
Math.sin(r * 0.24 - t * 0.7) +
Math.sin((cc + r) * 0.12 + t * 0.45) +
Math.sin(Math.hypot(cc - cols * 0.5, r - rows * 0.5) * 0.16 - t * 0.55)
) / 4; // [-1, 1]
const v = (n + 1) / 2; // [0, 1]
if(v < 0.22) continue;
const idx = Math.min(PALETTE.length - 1, Math.floor(v * PALETTE.length));
const ch = PALETTE[idx];
if(ch === ' ') continue;
const alpha = 0.08 + (v - 0.22) * 0.55;
ctx.fillStyle = `rgba(255,255,255,${alpha.toFixed(3)})`;
ctx.fillText(ch, cc * CELL, r * CELL);
}
}
}
function resizeAll(){ canvases.forEach(setup); }
let pending = null;
window.addEventListener('resize', ()=>{
if(window.__lowPowerMode) return;
if(pending) cancelAnimationFrame(pending);
pending = requestAnimationFrame(resizeAll);
}, {passive:true});
let t0 = performance.now();
let frame = 0, asciiRAF = 0, running = false;
function tick(now){
if(!running || window.__lowPowerMode){running=false;asciiRAF=0;return;}
const t = (now - t0) / 1000 * 0.55;
frame++;
canvases.forEach(c=>{
// 离屏 slide 降帧:每 4 帧渲染一次,在屏 slide 每帧渲染
const slide = c.closest('.slide');
const rect = slide ? slide.getBoundingClientRect() : null;
const onscreen = rect && rect.right > 0 && rect.left < window.innerWidth;
if(!onscreen && (frame & 3) !== 0) return;
draw(c, t);
});
asciiRAF = requestAnimationFrame(tick);
}
function start(){
if(running || window.__lowPowerMode) return;
resizeAll();
t0 = performance.now();
frame = 0;
running = true;
asciiRAF = requestAnimationFrame(tick);
}
function stop(){
running = false;
if(asciiRAF) cancelAnimationFrame(asciiRAF);
if(pending) cancelAnimationFrame(pending);
asciiRAF = 0;
pending = null;
canvases.forEach(c=>{
if(c.__ctx) c.__ctx.clearRect(0,0,c.__w || 0,c.__h || 0);
});
}
addEventListener('swiss-low-power-change', e=>{e.detail.on ? stop() : start();});
start();
})();
</script>
</body>
</html>
assets/template.html›
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>[必填] 替换为 PPT 标题 · Deck Title</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,500;0,600;0,700;0,800;0,900;1,400;1,700&family=Source+Serif+4:ital,opsz,wght@0,8..60,300;0,8..60,400;0,8..60,500;0,8..60,600;1,8..60,400&family=IBM+Plex+Mono:wght@300;400;500;600&family=Noto+Serif+SC:wght@300;400;500;600;700;900&family=Noto+Sans+SC:wght@300;400;500;700;900&display=swap" rel="stylesheet">
<style>
:root{
/* ============ 主题色(默认:🖋 墨水经典) ============
切换主题:从 references/themes.md 复制对应的 :root 块
整体替换这几行(--ink / --ink-rgb / --paper / --paper-rgb)
其他地方散落的 rgba() 都走 var(--ink-rgb) / var(--paper-rgb),无需逐处改 */
--ink:#0a0a0b;
--ink-rgb:10,10,11;
--paper:#f1efea;
--paper-rgb:241,239,234;
--paper-tint:#e8e5de;
--ink-tint:#18181a;
/* ============ 字体(跨主题固定) ============ */
--mono:"IBM Plex Mono",ui-monospace,monospace;
--serif-en:"Playfair Display","Source Serif 4",Georgia,serif;
--serif-body-en:"Source Serif 4",Georgia,serif;
--serif-zh:"Noto Serif SC",source-han-serif-sc,serif;
--sans-zh:"Noto Sans SC",source-han-sans-sc,sans-serif;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{width:100%;height:100%;overflow:hidden;background:var(--ink);color:var(--paper);font-family:var(--sans-zh);-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}
/* ============ WebGL 双背景 ============ */
canvas.bg{position:fixed;inset:0;width:100vw;height:100vh;z-index:0;display:block;transition:opacity 1.2s ease}
canvas#bg-light{opacity:0}
canvas#bg-dark{opacity:1}
body.light-bg canvas#bg-light{opacity:1}
body.light-bg canvas#bg-dark{opacity:0}
body.low-power canvas.bg{display:none!important}
/* ============ Deck 容器 + 翻页 ============ */
/* width: NSLIDES * 100vw,会在 JS 里动态矫正 */
#deck{position:fixed;inset:0;width:10000vw;height:100vh;display:flex;flex-wrap:nowrap;transition:transform .9s cubic-bezier(.77,0,.175,1);z-index:10;will-change:transform}
.slide{width:100vw;height:100vh;flex:0 0 100vw;position:relative;padding:6vh 6vw 10vh 6vw;display:flex;flex-direction:column;overflow:hidden}
.slide.light{color:var(--ink);background:var(--paper)}
.slide.dark{color:var(--paper);background:var(--ink)}
/* 默认页:遮罩较厚,保证文字可读 */
.slide::before{content:"";position:absolute;inset:0;z-index:-1;pointer-events:none;transition:background .7s ease}
.slide.light::before{background:rgba(var(--paper-rgb),.78);backdrop-filter:blur(3px)}
.slide.dark::before{background:rgba(var(--ink-rgb),.78);backdrop-filter:blur(3px)}
/* Hero 页:遮罩大幅降低,让 WebGL 背景明显透出 */
.slide.hero.light::before{background:rgba(var(--paper-rgb),.16);backdrop-filter:none}
.slide.hero.dark::before{background:rgba(var(--ink-rgb),.12);backdrop-filter:none}
/* Hero 页顶底微弱渐隐,保证 chrome/foot 区域可读 */
.slide.hero::after{content:"";position:absolute;inset:0;z-index:-1;pointer-events:none}
.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 ============
(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: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}
.rule{width:100%;height:1px;background:currentColor;opacity:.25;margin:3vh 0}
.rule.v{width:1px;height:100%;margin:0}
/* ============ 字体规则 ============
· 衬线(Noto Serif SC / Playfair):大标题、重点金句、数字
· 非衬线(Noto Sans SC):正文描述、body、补充说明
· 等宽(IBM Plex Mono):kicker、meta 小标签、foot 右侧
*/
.kicker{font-family:var(--mono);font-size:12px;letter-spacing:.3em;text-transform:uppercase;opacity:.6;margin-bottom:2.6vh}
.display{font-family:var(--serif-en);font-weight:700;font-size:11vw;line-height:.92;letter-spacing:-.025em}
.display-zh{font-family:var(--serif-zh);font-weight:700;font-size:7.8vw;line-height:1.04;letter-spacing:-.005em}
.h1-zh{font-family:var(--serif-zh);font-weight:700;font-size:4.6vw;line-height:1.12;letter-spacing:-.005em}
.h2-zh{font-family:var(--serif-zh);font-weight:600;font-size:3.2vw;line-height:1.2;letter-spacing:0}
.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.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"}
.ghost{font-family:var(--serif-en);font-weight:900;font-size:34vw;line-height:.8;opacity:.06;letter-spacing:-.04em;position:absolute;font-feature-settings:"tnum"}
em{font-style:italic;font-family:var(--serif-en)}
.en{font-family:var(--serif-en);font-style:italic;font-weight:500}
/* ============ 布局工具 ============ */
.col{display:flex;flex-direction:column;gap:2.4vh}
.row{display:flex;align-items:center;gap:3vw}
.grid-6{display:grid;grid-template-columns:repeat(3,1fr);grid-template-rows:repeat(2,1fr);gap:4vw 6vw;flex:1;align-content:center;padding:2vh 0}
.grid-9{display:grid;grid-template-columns:repeat(3,1fr);grid-template-rows:repeat(3,1fr);gap:3vh 4vw;flex:1;align-content:center}
.grid-4{display:grid;grid-template-columns:repeat(2,1fr);grid-template-rows:repeat(2,1fr);gap:4vh 6vw;flex:1;align-content:center}
.grid-3{display:grid;grid-template-columns:repeat(3,1fr);gap:4vw;flex:1;align-content:center}
.split{display:grid;grid-template-columns:1fr 1fr;gap:4vw;flex:1;align-items:center}
.split-55{display:grid;grid-template-columns:55fr 45fr;gap:5vw;flex:1;align-items:stretch}
.fill{flex:1}
.center{align-items:center;justify-content:center;text-align:center}
.bottom-left{position:absolute;left:6vw;bottom:9vh;max-width:50vw}
.bottom-right{position:absolute;right:6vw;bottom:9vh;max-width:50vw;text-align:right}
.top-right{position:absolute;right:6vw;top:6vh;text-align:right}
/* ============ Stat(数字矩阵) ============ */
.stat{display:flex;flex-direction:column;gap:1vh;align-items:flex-start}
.stat .n{font-family:var(--serif-en);font-weight:800;font-size:8vw;line-height:.88;letter-spacing:-.03em;font-feature-settings:"tnum"}
.stat .l{font-family:var(--sans-zh);font-size:max(13px,1.05vw);opacity:.7;margin-top:1vh;font-weight:400;line-height:1.5}
.stat .m{font-family:var(--mono);font-size:10px;letter-spacing:.22em;text-transform:uppercase;opacity:.5;margin-bottom:.2vh}
/* ============ Callout(引用框) ============ */
.callout{padding:3vh 2.4vw;border-left:3px solid currentColor;position:relative;font-family:var(--serif-zh);font-size:max(15px,1.2vw);line-height:1.55;opacity:.92}
.slide.light .callout{background:rgba(var(--ink-rgb),.05)}
.slide.dark .callout{background:rgba(var(--paper-rgb),.06)}
.callout .cite{display:block;margin-top:1.6vh;font-family:var(--mono);font-size:11px;letter-spacing:.2em;text-transform:uppercase;opacity:.6}
.callout .q-big{font-family:var(--serif-zh);font-weight:600;font-size:max(17px,1.6vw);line-height:1.42}
/* ============ Platform(平台卡) ============ */
.plat{display:flex;flex-direction:column;justify-content:flex-end;padding:2vh 0;border-top:1px solid currentColor;border-color:rgba(127,127,127,.35)}
.plat .name{font-family:var(--serif-zh);font-weight:700;font-size:1.8vw;margin-bottom:.6vh}
.plat .nb{font-family:var(--serif-en);font-weight:700;font-size:3.2vw;letter-spacing:-.02em;line-height:1;font-feature-settings:"tnum"}
.plat .sub{font-family:var(--mono);font-size:10px;letter-spacing:.18em;text-transform:uppercase;opacity:.55;margin-top:.6vh}
.plat .fill{font-family:var(--sans-zh);font-weight:300;font-size:2.4vw;opacity:.28;letter-spacing:-.01em;line-height:1}
/* ============ Rowline(表格行) ============ */
.rowline{display:grid;grid-template-columns:1fr 2fr 1fr;gap:2vw;padding:2.2vh 0;border-top:1px solid currentColor;align-items:center;border-color:rgba(127,127,127,.25)}
.rowline:last-child{border-bottom:1px solid currentColor;border-color:rgba(127,127,127,.25)}
.rowline .k{font-family:var(--serif-zh);font-weight:700;font-size:1.7vw}
.rowline .v{font-family:var(--sans-zh);font-weight:400;font-size:max(14px,1.2vw);opacity:.85;line-height:1.55}
.rowline .m{font-family:var(--mono);font-size:11px;letter-spacing:.2em;text-transform:uppercase;opacity:.6;justify-self:end}
/* ============ Pillar(支柱卡片) ============ */
.pillar{display:flex;flex-direction:column;gap:1.8vh}
.pillar .ic{font-family:var(--serif-en);font-style:italic;font-size:2.6vw;opacity:.45;font-weight:400}
.pillar .ic svg{width:2.8vw;height:2.8vw;stroke-width:1.2;opacity:.7}
.pillar .t{font-family:var(--serif-zh);font-weight:700;font-size:2.4vw;line-height:1.1}
.pillar .d{font-family:var(--sans-zh);font-weight:400;font-size:max(14px,1.1vw);opacity:.76;line-height:1.6}
/* ============ Signature / Highlight ============ */
.sign{font-family:var(--serif-en);font-style:italic;font-weight:500;font-size:2vw;opacity:.7}
.hi{position:relative;display:inline}
.slide.dark .hi::after{content:"";position:absolute;left:-.1em;right:-.1em;bottom:-.05em;height:.28em;background:rgba(var(--paper-rgb),.15);z-index:-1}
.slide.light .hi::after{content:"";position:absolute;left:-.1em;right:-.1em;bottom:-.05em;height:.28em;background:rgba(var(--ink-rgb),.08);z-index:-1}
/* ============ Icons(Lucide via CDN) ============ */
.ico{width:1em;height:1em;display:inline-block;vertical-align:-.12em;stroke:currentColor;fill:none;stroke-width:1.4;stroke-linecap:round;stroke-linejoin:round;flex-shrink:0}
.ico-lg,.ico-md,.ico-sm{fill:none;stroke:currentColor;stroke-linecap:round;stroke-linejoin:round}
.ico-lg{width:2.6vw;height:2.6vw;stroke-width:1.2;display:inline-block}
.ico-md{width:1.8vw;height:1.8vw;stroke-width:1.3;display:inline-block;vertical-align:-.4em}
.ico-sm{width:1.1vw;height:1.1vw;stroke-width:1.4;display:inline-block;vertical-align:-.15em;opacity:.7}
/* ============ 图片占位(虚线框,提示设计师位置) ============ */
.img-slot{border:1.5px dashed rgba(127,127,127,.4);display:flex;align-items:center;justify-content:center;flex-direction:column;gap:1vh;padding:2vh 2vw;font-family:var(--mono);font-size:10px;letter-spacing:.28em;text-transform:uppercase;opacity:.55;position:relative;aspect-ratio:16/9;width:100%;max-height:56vh;margin-inline:auto;box-sizing:border-box}
.img-slot::before{content:"";position:absolute;inset:8px;border:1px solid currentColor;opacity:.2}
.img-slot .plus{font-size:2vw;font-weight:300;opacity:.5;letter-spacing:0}
.img-slot .label{position:relative;z-index:2;text-align:center}
.img-slot.r-4x3{aspect-ratio:4/3}
.img-slot.r-3x2{aspect-ratio:3/2}
.img-slot.r-1x1{aspect-ratio:1/1}
/* ============ 图片实填框(关键:固定高度 + 只裁底部) ============
重要约束:高度用内联 height:Nvh 精确控制,不要用 aspect-ratio(会撑破布局)
object-position:top center 保证严禁裁剪顶部和左右,只裁剪底部
*/
.frame-img{overflow:hidden;position:relative;background:rgba(0,0,0,.04);box-sizing:border-box;width:100%;border-radius:4px}
.slide.dark .frame-img{background:rgba(255,255,255,.04);border-color:rgba(255,255,255,.12)}
.frame-img > img{width:100%;height:100%;object-fit:cover;object-position:top center;display:block}
.frame-img.fit-contain > img{object-fit:contain;object-position:center center}
.frame-img.r-16x9{aspect-ratio:16/9;max-height:64vh}
.frame-img.r-16x10{aspect-ratio:16/10;max-height:56vh}
.frame-img.r-4x3{aspect-ratio:4/3;max-height:56vh}
.frame-img.r-3x2{aspect-ratio:3/2;max-height:46vh}
.frame-img.r-3x4{aspect-ratio:3/4;max-height:60vh}
.frame-img.r-1x1{aspect-ratio:1/1;max-height:50vh}
.frame-img.h-16{height:16vh}
.frame-img.h-18{height:18vh}
.frame-img.h-22{height:22vh}
.frame-img.h-26{height:26vh}
.frame-img.h-28{height:28vh}
.frame-cap{display:flex;justify-content:space-between;align-items:baseline;gap:1vw;margin-top:.8vh;font-family:var(--mono);font-size:10px;letter-spacing:.22em;text-transform:uppercase;opacity:.72}
.frame-cap .pf{font-family:var(--serif-zh);font-weight:600;font-size:max(13px,1vw);letter-spacing:.04em;text-transform:none;opacity:.94}
.frame-cap .nb{font-family:var(--serif-en);font-style:italic;font-size:max(15px,1.2vw);letter-spacing:.02em;text-transform:none;opacity:.88}
.frame-cap .idx{font-family:var(--mono);opacity:.5}
figure.tile{display:flex;flex-direction:column;margin:0;min-width:0}
figure.tile > .frame-img{flex:0 0 auto}
/* ============ 导航 ============ */
#nav{position:fixed;left:50%;bottom:2.6vh;transform:translateX(-50%);z-index:30;display:flex;gap:10px;padding:8px 14px;border-radius:999px;background:rgba(0,0,0,.18);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px)}
#nav .dot{width:8px;height:8px;border-radius:50%;background:rgba(255,255,255,.3);cursor:pointer;transition:all .3s ease;border:0;padding:0}
#nav .dot:hover{background:rgba(255,255,255,.5);transform:scale(1.15)}
#nav .dot.active{background:rgba(255,255,255,.95);width:22px;border-radius:999px}
body.light-bg #nav{background:rgba(255,255,255,.25)}
body.light-bg #nav .dot{background:rgba(var(--ink-rgb),.25)}
body.light-bg #nav .dot.active{background:rgba(var(--ink-rgb),.9)}
#hint{position:fixed;bottom:3vh;right:3vw;z-index:30;display:flex;align-items:center;gap:.65em;font-family:var(--mono);font-size:10px;letter-spacing:.2em;text-transform:uppercase;opacity:.4;mix-blend-mode:difference;color:#aaa}
#hint button{all:unset;display:inline-flex;align-items:center;gap:.45em;cursor:pointer;color:inherit;white-space:nowrap}
#hint button:hover,#hint button:focus-visible{opacity:1;text-decoration:underline;text-underline-offset:.35em}
#hint .ppt-key{padding:.16em .38em;border:1px solid currentColor;border-radius:3px;letter-spacing:.08em}
#hint .ppt-sep{opacity:.55}
body.low-power #hint{opacity:.72;color:var(--paper);mix-blend-mode:normal}
body.light-bg.low-power #hint{color:var(--ink)}
/* ============ 演讲者模式 ============ */
body.ppt-audience #hint,body.ppt-audience #nav,
body.ppt-preview #hint,body.ppt-preview #nav{display:none!important}
body.ppt-audience{cursor:none}
body.ppt-preview{pointer-events:none}body.ppt-preview #deck{transition:none!important}
body.ppt-presenter>#deck,body.ppt-presenter>#nav,body.ppt-presenter>#hint,
body.ppt-presenter>.bg{visibility:hidden}
#ppt-presenter{position:fixed;inset:0;z-index:90;display:none;min-width:0;min-height:0;overflow:hidden;background:#101216;color:#f5f6f7;font-family:Inter,"Noto Sans SC",sans-serif}
body.ppt-presenter #ppt-presenter{display:grid;grid-template-rows:auto 1fr auto}
.ppt-presenter-top{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:18px 24px;border-bottom:1px solid #2b2e35;background:#15181d}
.ppt-presenter-brand{font-weight:650;letter-spacing:.02em}.ppt-presenter-meta{display:flex;align-items:center;gap:12px;color:#aeb4be;font-size:14px}
.ppt-sync{display:inline-flex;align-items:center;gap:8px;padding:7px 11px;border:1px solid #363a43;border-radius:999px;color:#d2d6dc;background:#1b1e24}
.ppt-sync::before{content:"";width:8px;height:8px;border-radius:50%;background:#818894}
.ppt-sync[data-state="synced"]::before{background:#72df99;box-shadow:0 0 0 4px rgba(114,223,153,.12)}
.ppt-sync[data-state="connecting"]::before,.ppt-sync[data-state="frozen"]::before{background:#f0c766}.ppt-sync[data-state="unsynced"]::before,.ppt-sync[data-state="blocked"]::before{background:#ff9e64}.ppt-sync[data-state="disconnected"]::before{background:#ff6b72}
.ppt-ui-btn{appearance:none;border:1px solid #3a3e48;background:#20242a;color:#f6f7f8;border-radius:8px;padding:9px 13px;font:600 14px/1 Inter,"Noto Sans SC",sans-serif;cursor:pointer}
.ppt-ui-btn:hover{background:#2a2f37;border-color:#525864}.ppt-ui-btn:focus-visible{outline:2px solid #84a8ff;outline-offset:2px}.ppt-ui-btn:disabled{opacity:.28;cursor:not-allowed}
.ppt-presenter-main{display:grid;grid-template-columns:minmax(0,1.75fr) minmax(300px,.75fr);gap:18px;min-height:0;padding:18px 24px}
.ppt-preview-stack{position:relative;display:grid;grid-template-rows:minmax(0,1fr) clamp(132px,22vh,210px);gap:16px;min-width:0;min-height:0}
.ppt-stage-card,.ppt-notes-card,.ppt-presenter-overview{background:#171a20;border:1px solid #2b2f37;border-radius:12px;overflow:hidden}
.ppt-stage-card{display:grid;grid-template-rows:auto minmax(0,1fr);min-width:0;min-height:0}
.ppt-stage-label{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:11px 14px;color:#aeb4be;font-size:13px;border-bottom:1px solid #292d34}
.ppt-annotation-tools{display:flex;align-items:center;gap:5px;margin-left:auto}.ppt-annotation-tools .ppt-ui-btn{padding:6px 9px;font-size:12px}.ppt-annotation-tools .ppt-ui-btn.is-active{color:#111318;background:#ff5b63;border-color:#ff5b63}.ppt-annotation-tools .ppt-grid-btn.is-active{color:#111318;background:#edf1f5;border-color:#edf1f5}.ppt-annotation-tools .ppt-clear-btn{min-width:0}.ppt-grid-btn{display:inline-flex;align-items:center;gap:6px}.ppt-grid-icon{display:grid;grid-template-columns:repeat(2,4px);gap:2px}.ppt-grid-icon i{width:4px;height:4px;background:currentColor}
.ppt-frame-viewport{position:relative;display:grid;place-items:center;min-width:0;min-height:0;overflow:hidden;background:#08090b}
.ppt-frame{display:block;width:0;height:0;max-width:100%;max-height:100%;aspect-ratio:16/9;border:0;background:#08090b}.ppt-next-frame{opacity:.86}#ppt-next-card.is-end .ppt-frame{visibility:hidden}#ppt-next-card.is-end .ppt-frame-viewport::after{content:"演示结束";position:absolute;color:#727a86;font:650 14px/1 Inter,"Noto Sans SC",sans-serif;letter-spacing:.08em}
.ppt-annotation-layer{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);z-index:4;width:0;height:0;pointer-events:none;touch-action:none}.ppt-annotation-layer.is-active{pointer-events:auto;cursor:crosshair}
#ppt-audience-ink{position:fixed;inset:0;z-index:70;width:100vw;height:100vh;pointer-events:none}#ppt-audience-cover{position:fixed;inset:0;z-index:80;display:none;pointer-events:none}#ppt-audience-cover.is-ended{display:grid!important;place-items:center;background:#101216!important;color:#f5f6f7;font:650 clamp(24px,3vw,46px)/1.2 Inter,"Noto Sans SC",sans-serif;letter-spacing:.08em}
.ppt-preview-stack.is-overview>.ppt-stage-card{display:none}.ppt-presenter-overview{grid-row:1/-1;display:grid;grid-template-rows:auto minmax(0,1fr);min-width:0;min-height:0}.ppt-presenter-overview[hidden]{display:none}.ppt-overview-head{display:flex;align-items:center;justify-content:space-between;padding:13px 15px;border-bottom:1px solid #292d34}.ppt-overview-head strong{font-size:15px}.ppt-overview-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px;align-content:start;overflow:auto;padding:14px}.ppt-overview-page{appearance:none;min-height:112px;display:grid;grid-template-rows:auto 1fr auto;gap:12px;padding:13px;border:1px solid #343943;border-radius:10px;background:#111318;color:#eef1f4;text-align:left;cursor:pointer}.ppt-overview-page:hover{background:#1d2128;border-color:#636b78}.ppt-overview-page.is-current{border-color:#84a8ff;box-shadow:inset 0 0 0 1px #84a8ff}.ppt-overview-page-no{color:#8f97a3;font:600 12px/1 var(--mono,monospace)}.ppt-overview-page-title{font-size:14px;font-weight:650;line-height:1.35}.ppt-overview-page-meta{display:flex;justify-content:space-between;gap:8px;color:#7f8793;font-size:11px}
.ppt-notes-card{display:grid;grid-template-rows:auto minmax(0,1fr);min-height:0}.ppt-notes-head{padding:16px 18px;border-bottom:1px solid #292d34}.ppt-notes-label{margin-bottom:6px;color:#848c98;font-size:11px;font-weight:700;letter-spacing:.12em;text-transform:uppercase}.ppt-notes-title{font-size:21px;font-weight:680;line-height:1.32}.ppt-purpose-block{margin-top:14px;padding:11px 12px;border-left:3px solid #84a8ff;background:#111318}.ppt-purpose-copy{color:#c9ced6;font-size:14px;line-height:1.55}.ppt-notes-draft-head{display:flex;align-items:flex-end;justify-content:space-between;gap:10px;margin-top:15px;padding-top:13px;border-top:1px solid #2d3139}.ppt-notes-draft-head .ppt-notes-label{margin:0 0 4px}.ppt-note-meta{color:#8f97a3;font-size:12px}
.ppt-notes-tools{display:flex;align-items:center;gap:6px}.ppt-notes-tools .ppt-ui-btn{padding:7px 9px}.ppt-note-editor{width:100%;height:100%;min-height:0;resize:none;border:0;outline:0;background:#171a20;color:#edf0f3;padding:18px;font:400 var(--ppt-note-size,17px)/1.72 Inter,"Noto Sans SC",sans-serif}.ppt-note-editor:focus{box-shadow:inset 0 0 0 1px #3d4654}
.ppt-saved{color:#72df99;white-space:nowrap}.ppt-presenter-bottom{display:grid;grid-template-columns:minmax(330px,1fr) auto minmax(110px,1fr);align-items:center;gap:20px;padding:12px 24px;border-top:1px solid #2b2e35;background:#15181d}
.ppt-time-dashboard{display:flex;align-items:stretch;min-width:0}.ppt-time-stat{min-width:90px;display:grid;align-content:center;gap:4px;padding:0 16px}.ppt-time-stat:first-child{padding-left:0}.ppt-time-stat+.ppt-time-stat{border-left:1px solid #343943}.ppt-time-label{color:#89919d;font-size:11px;font-weight:700;letter-spacing:.1em}.ppt-time-value{color:#f7f8fa;font:650 23px/1 var(--mono,monospace);font-variant-numeric:tabular-nums}.ppt-time-hint{color:#747c88;font-size:11px;white-space:nowrap}.ppt-time-stat.is-over .ppt-time-label,.ppt-time-stat.is-over .ppt-time-value{color:#ff8a90}.ppt-time-stat.is-remaining .ppt-time-value{color:#72df99}
.ppt-control-stack{display:grid;gap:7px;justify-items:center}.ppt-controls,.ppt-session-actions{display:flex;align-items:center;justify-content:center;gap:7px}.ppt-controls .ppt-ui-btn,.ppt-session-actions .ppt-ui-btn{min-width:68px}.ppt-session-actions{padding-top:7px;border-top:1px solid #343943}.ppt-rehearsal-on{color:#ff8a90}.ppt-page-state{text-align:right}.ppt-page-state strong{display:block;color:#fff;font-size:17px;margin-bottom:5px}.ppt-page-state span{color:#8f97a3;font:600 13px/1 var(--mono,monospace)}.ppt-auto-state{display:inline-flex;align-items:center;gap:6px;border-radius:999px}.ppt-auto-state.is-on{color:#72df99;border-color:rgba(114,223,153,.45);background:rgba(114,223,153,.08)}.ppt-auto-state.is-paused{color:#f0c766;border-color:rgba(240,199,102,.42);background:rgba(240,199,102,.08)}
#ppt-presenter[data-layout="slides"] .ppt-presenter-main{grid-template-columns:minmax(0,2.15fr) minmax(260px,.58fr)}#ppt-presenter[data-layout="notes"] .ppt-presenter-main{grid-template-columns:minmax(0,1.15fr) minmax(380px,1fr)}
.ppt-dialog{position:fixed;inset:0;z-index:130;display:grid;place-items:center;padding:24px;background:rgba(4,6,9,.72);backdrop-filter:blur(10px)}.ppt-dialog[hidden]{display:none}.ppt-dialog-panel{width:min(720px,calc(100vw - 48px));max-height:min(760px,calc(100vh - 48px));display:grid;grid-template-rows:auto minmax(0,1fr);overflow:hidden;border:1px solid #343943;border-radius:14px;background:#171a20;box-shadow:0 24px 80px rgba(0,0,0,.45)}.ppt-dialog-head{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:16px 18px;border-bottom:1px solid #2b2f37}.ppt-dialog-title{font-size:20px;font-weight:700}.ppt-dialog-body{overflow:auto;padding:18px;color:#d8dce2;font-size:14px;line-height:1.6}.ppt-dialog-section+.ppt-dialog-section{margin-top:22px}.ppt-dialog-section h3{margin:0 0 10px;color:#fff;font-size:15px}.ppt-dialog-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}.ppt-dialog-row{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:10px 0;border-bottom:1px solid #282c33}.ppt-dialog-row:last-child{border-bottom:0}.ppt-field{display:grid;gap:6px}.ppt-field label{color:#aeb4be;font-size:12px}.ppt-field input{width:100%;border:1px solid #3a3e48;border-radius:8px;background:#101216;color:#fff;padding:9px 10px;font:600 14px Inter,sans-serif}.ppt-check{display:flex;align-items:flex-start;gap:10px}.ppt-check-dot{width:9px;height:9px;margin-top:7px;border-radius:50%;background:#ff6b72}.ppt-check.is-ok .ppt-check-dot{background:#72df99}.ppt-check.is-warn .ppt-check-dot{background:#f0c766}.ppt-summary{width:100%;border-collapse:collapse;font-size:13px}.ppt-summary th,.ppt-summary td{padding:9px 8px;border-bottom:1px solid #2b2f37;text-align:left}.ppt-summary th{color:#9fa6b1;font-weight:600}.ppt-summary td:last-child,.ppt-summary th:last-child{text-align:right}.ppt-muted{color:#8f96a1}.ppt-shortcuts{display:grid;grid-template-columns:auto 1fr;gap:8px 14px}.ppt-shortcuts kbd{min-width:44px;padding:4px 7px;border:1px solid #3a3e48;border-radius:6px;background:#101216;color:#fff;text-align:center;font:600 12px var(--mono,monospace)}
.ppt-settings-card{padding:15px;border:1px solid #30353e;border-radius:12px;background:#12151a}.ppt-settings-card+.ppt-settings-card{margin-top:12px}.ppt-settings-card h3{margin:0 0 3px}.ppt-settings-copy{margin:0 0 14px;color:#858d99;font-size:12px}.ppt-switch-row{display:flex;align-items:center;justify-content:space-between;gap:18px;cursor:pointer}.ppt-switch-row input{position:absolute;opacity:0;pointer-events:none}.ppt-switch{width:48px;height:28px;flex:0 0 auto;padding:3px;border:1px solid #434955;border-radius:999px;background:#252a31;transition:.2s}.ppt-switch::after{content:"";display:block;width:20px;height:20px;border-radius:50%;background:#a8afb9;transition:.2s}.ppt-switch-row input:checked+.ppt-switch{border-color:#72df99;background:rgba(114,223,153,.16)}.ppt-switch-row input:checked+.ppt-switch::after{transform:translateX(20px);background:#72df99}.ppt-stepper{display:grid;grid-template-columns:38px minmax(72px,1fr) auto 38px;align-items:center;margin-top:14px;border:1px solid #363c46;border-radius:10px;background:#0d0f13;overflow:hidden}.ppt-stepper button{height:42px;border:0;background:#20242a;color:#fff;font-size:19px;cursor:pointer}.ppt-stepper button:hover{background:#2c323b}.ppt-stepper input{width:100%;height:42px;border:0!important;border-radius:0!important;background:transparent!important;text-align:center;font:650 16px var(--mono,monospace)!important;-moz-appearance:textfield}.ppt-stepper input::-webkit-inner-spin-button,.ppt-stepper input::-webkit-outer-spin-button{-webkit-appearance:none;margin:0}.ppt-stepper-unit{color:#858d99;font-size:12px}.ppt-segmented{display:grid;grid-template-columns:repeat(3,1fr);gap:6px}.ppt-segmented.screen{grid-template-columns:repeat(4,1fr)}.ppt-segmented .ppt-ui-btn{min-width:0}.ppt-settings-save{width:100%;margin-top:14px;background:#edf1f5;color:#111318;border-color:#edf1f5}.ppt-settings-save:hover{background:#fff}.ppt-settings-save+.ppt-settings-copy{margin:10px 0 0}
#overview{z-index:110!important}
@media(max-width:1200px){.ppt-presenter-main{grid-template-columns:minmax(0,1.55fr) minmax(280px,.72fr)}}
@media(max-width:1100px){.ppt-presenter-bottom{grid-template-columns:minmax(292px,1fr) auto minmax(86px,.6fr);gap:12px}.ppt-time-stat{min-width:82px;padding:0 11px}.ppt-time-value{font-size:20px}.ppt-controls .ppt-ui-btn,.ppt-session-actions .ppt-ui-btn{min-width:60px;padding:8px 9px}}
@media(max-width:900px){.ppt-presenter-top{padding:12px 16px;flex-wrap:wrap}.ppt-presenter-top .ppt-presenter-meta{gap:6px}.ppt-presenter-top .ppt-ui-btn{padding:8px 9px}.ppt-presenter-main,#ppt-presenter[data-layout="slides"] .ppt-presenter-main,#ppt-presenter[data-layout="notes"] .ppt-presenter-main{grid-template-columns:minmax(0,1.45fr) minmax(250px,.72fr);gap:12px;padding:12px 16px}.ppt-preview-stack{gap:12px;grid-template-rows:minmax(0,1fr) clamp(104px,20vh,150px)}.ppt-saved{display:none}.ppt-presenter-bottom{grid-template-columns:minmax(270px,1fr) auto 74px;gap:8px;padding:9px 16px}.ppt-time-stat{min-width:0;padding:0 9px}.ppt-time-value{font-size:18px}.ppt-time-hint{max-width:78px;overflow:hidden;text-overflow:ellipsis}.ppt-controls,.ppt-session-actions{gap:5px}.ppt-controls .ppt-ui-btn,.ppt-session-actions .ppt-ui-btn{min-width:52px;padding:7px 8px;font-size:12px}.ppt-page-state strong{font-size:14px}.ppt-dialog-grid{grid-template-columns:1fr}.ppt-segmented.screen{grid-template-columns:repeat(2,1fr)}}
@media(max-height:700px){.ppt-presenter-top{padding-top:10px;padding-bottom:10px}.ppt-presenter-main{padding-top:10px;padding-bottom:10px}.ppt-preview-stack{grid-template-rows:minmax(0,1fr) clamp(88px,16vh,118px);gap:10px}.ppt-stage-label{padding:7px 10px}.ppt-notes-head{padding:12px 14px}.ppt-purpose-block{margin-top:10px;padding:8px 10px}.ppt-notes-draft-head{margin-top:10px;padding-top:9px}.ppt-presenter-bottom{padding-top:8px;padding-bottom:8px}.ppt-control-stack{gap:5px}.ppt-session-actions{padding-top:5px}}
/* ============ /演讲者模式 ============ */
/* ============================================================
============ LAYOUTS API · 面向 agent 的类(v2)============
所有 layouts.md 中的骨架都基于下面这套命名。
如果你在 layouts.md 里看到某个类,它必须在下面有定义。
============================================================ */
/* ---------- .frame:每页主内容容器 ---------- */
.frame{flex:1;display:flex;flex-direction:column;min-height:0;overflow:hidden}
/* 当 .frame 同时加了 grid 类时,grid 的 display:grid 覆盖 flex */
.frame.grid-2-7-5,
.frame.grid-2-6-6,
.frame.grid-2-8-4,
.frame.grid-3-3,
.frame.grid-6{display:grid}
/* ---------- 标题层级(API 名称,衬线为主) ---------- */
.h-hero{
font-family:var(--serif-zh);
font-weight:900;
font-size:10vw;
line-height:.96;
letter-spacing:-.02em;
}
.h-xl{
font-family:var(--serif-zh);
font-weight:700;
font-size:6.2vw;
line-height:1.08;
letter-spacing:-.01em;
}
.h-sub{
font-family:var(--serif-zh);
font-weight:500;
font-size:3.1vw;
line-height:1.25;
letter-spacing:0;
opacity:.7;
}
.h-md{
font-family:var(--serif-zh);
font-weight:600;
font-size:2.3vw;
line-height:1.3;
}
/* 英文标题专用(Playfair 衬线) */
.h-hero-en,.h-xl-en{font-family:var(--serif-en);letter-spacing:-.025em}
/* ---------- lead 引语:定义见上方“字体规则”区块(已合并,勿重复定义) ---------- */
/* ---------- meta-row 底部元数据 ---------- */
.meta-row{
display:flex;
gap:1.2em;
align-items:baseline;
flex-wrap:wrap;
font-family:var(--mono);
font-size:max(12px,.92vw);
letter-spacing:.16em;
text-transform:uppercase;
opacity:.6;
}
/* ---------- stat-card(数据大字报用) ---------- */
.stat-card{
display:flex;
flex-direction:column;
gap:.8vh;
align-items:flex-start;
padding-top:1.6vh;
border-top:1px solid currentColor;
border-color:rgba(127,127,127,.3);
}
.stat-card .stat-label{
font-family:var(--mono);
font-size:max(10px,.78vw);
letter-spacing:.24em;
text-transform:uppercase;
opacity:.55;
}
.stat-card .stat-nb{
font-family:var(--serif-en);
font-weight:800;
font-size:5.8vw;
line-height:.9;
letter-spacing:-.03em;
font-feature-settings:"tnum";
margin-top:.4vh;
}
.stat-card .stat-nb .stat-unit{
font-family:var(--serif-zh);
font-weight:500;
font-size:.38em;
letter-spacing:0;
opacity:.72;
margin-left:.14em;
}
.stat-card .stat-note{
font-family:var(--sans-zh);
font-weight:400;
font-size:max(13px,1.05vw);
line-height:1.5;
opacity:.72;
margin-top:.6vh;
}
/* 当 stat-card 用于 grid-4(2x2),数字适度放大;若页面有标题+引文在上方,可内联 style 覆盖为更小值 */
.grid-4 .stat-card .stat-nb{font-size:5vw}
/* 当只有 3 个,字也可以稍大 */
.grid-3 .stat-card .stat-nb{font-size:6.8vw}
/* ---------- pipeline(流水线) ---------- */
.pipeline-section{
margin-top:4.4vh;
padding-top:2.8vh;
border-top:1px dashed rgba(127,127,127,.32);
}
.pipeline-section:first-of-type{
border-top:0;
padding-top:0;
margin-top:3vh;
}
.pipeline-label{
font-family:var(--mono);
font-size:max(11px,.85vw);
letter-spacing:.24em;
text-transform:uppercase;
opacity:.62;
margin-bottom:2.2vh;
}
.pipeline{
display:grid;
grid-template-columns:repeat(5,1fr);
gap:1.2vw;
}
.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:.8vh;
padding-top:1.4vh;
border-top:1px solid currentColor;
border-color:rgba(127,127,127,.35);
}
.step-nb{
font-family:var(--serif-en);
font-style:italic;
font-weight:500;
font-size:1.15vw;
opacity:.45;
}
.step-title{
font-family:var(--sans-zh);
font-weight:700;
font-size:1.55vw;
letter-spacing:.01em;
line-height:1.2;
}
.step-desc{
font-family:var(--sans-zh);
font-weight:400;
font-size:max(12px,.95vw);
line-height:1.45;
opacity:.72;
}
/* ---------- 网格(layouts.md 所用) ---------- */
/* 这些类独立挂到任何容器上都能生效,不依赖 .frame 复合选择器 */
.grid-2-7-5{display:grid;grid-template-columns:7fr 5fr;gap:3vw 4vh;align-items:start}
.grid-2-6-6{display:grid;grid-template-columns:1fr 1fr;gap:3vw 4vh;align-items:start}
.grid-2-8-4{display:grid;grid-template-columns:8fr 4fr;gap:3vw 4vh;align-items:start}
.grid-3-3{
display:grid;
grid-template-columns:repeat(3,1fr);
grid-auto-rows:minmax(0,1fr);
gap:2.4vh 2vw;
}
/* grid-6 已在旧样式里定义为 3x2,这里仅补 align */
/* ---------- 图片 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;
flex:0 0 auto;
padding:.7vh .9em .9vh;
font-family:var(--mono);
font-size:max(10px,.8vw);
letter-spacing:.22em;
text-transform:uppercase;
opacity:.6;
}
/* callout src 命名别名 */
.callout-src{
display:block;
margin-top:1.6vh;
font-family:var(--mono);
font-size:11px;
letter-spacing:.2em;
text-transform:uppercase;
opacity:.6;
}
/* ---------- chrome & foot:定义见上方“Magazine chrome”区块(已合并,勿重复定义) ---------- */
/* ============ 动效系统(Motion One 驱动) ============
所有 [data-anim] 元素默认隐藏,进入页面时由 JS 逐个揭示。
- cascade(默认):按 DOM 顺序 stagger 120ms 淡入 + y:16→0
- hero(hero 页自动):慢一点的 stagger 180ms
- quote(含 [data-anim="line"]):逐行 600ms 淡入
- directional(含 [data-anim="left|right"]):左→分隔线→右
- pipeline(data-animate="pipeline"):Space/→ 手动推进
Motion One 没加载成功时(CDN 断 + 本地丢),所有 [data-anim] 会兜底显示。
*/
[data-anim]{opacity:1}
body.motion-ready [data-anim]{opacity:0}
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,
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){
.display{font-size:16vw}
.display-zh{font-size:12vw}
.h1-zh{font-size:7vw}
.h-hero{font-size:14vw}
.h-xl{font-size:9vw}
.pipeline{grid-template-columns:repeat(2,1fr)}
.grid-2-7-5,.grid-2-6-6,.grid-2-8-4{grid-template-columns:1fr}
}
</style>
</head>
<body>
<script>
(function(){
const KEY = 'guizang-ppt-low-power';
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const stored = localStorage.getItem(KEY);
window.__lowPowerMode = stored === '1' || (stored === null && reduced);
function updateHint(){
const label = document.querySelector('.ppt-low-power-label');
if(label) label.textContent = window.__lowPowerMode ? '动态' : '静态';
}
window.__setLowPowerMode = function(on, opts={}){
window.__lowPowerMode = !!on;
document.body.classList.toggle('low-power', window.__lowPowerMode);
if(opts.persist !== false) localStorage.setItem(KEY, window.__lowPowerMode ? '1' : '0');
if(window.__lowPowerMode && document.getAnimations){
document.getAnimations().forEach(a=>a.cancel());
}
updateHint();
dispatchEvent(new CustomEvent('ppt-low-power-change', {detail:{on:window.__lowPowerMode}}));
if(window.__playSlide) window.__playSlide(window.__currentSlideIndex || 0);
};
document.body.classList.toggle('low-power', window.__lowPowerMode);
addEventListener('DOMContentLoaded', updateHint, {once:true});
})();
</script>
<canvas id="bg-dark" class="bg"></canvas>
<canvas id="bg-light" class="bg"></canvas>
<div id="hint" role="toolbar" aria-label="快捷控制">
<span>← → 翻页</span><span class="ppt-sep">·</span>
<button id="ppt-low-power-btn" type="button"><span class="ppt-key">B</span><span class="ppt-low-power-label">静态</span></button><span class="ppt-sep">·</span>
<button id="ppt-overview-btn" type="button"><span class="ppt-key">ESC</span>总览</button><span class="ppt-sep">·</span>
<button id="ppt-presenter-btn" type="button"><span class="ppt-key">P</span>演讲模式</button>
</div>
<div id="deck">
<!-- ============================================================
SLIDES 插入区 · 在此处填充所有 <section class="slide ..."> 页面
每页模板参考 references/page-patterns.md
页面组件参考 references/components.md
============================================================ -->
<!-- SLIDES_HERE -->
</div>
<div id="nav"></div>
<!-- 演讲备注:每页一条,id 必须与 section[data-slide-id] 一致。见 references/presenter-mode.md -->
<script>
const SPEAKER_NOTES = [
/* {
id: 'stable-slide-id', title: '页面标题', section: '主体', minutes: 1,
purpose: '这一页要让观众理解什么',
talk: ['只在台上说的补充信息', '例子、证据或语气提示', '不复述页面可见文字'],
transition: '为什么下一页紧接着出现', cue: '可选:停顿 / Demo / 视频',
interaction: '可选:现场提问', delivery: '可选:语气 / 重音',
advance: '可选:翻页时机', fallback: '可选:Demo 失败时的备用说法'
} */
];
window.__SPEAKER_NOTES__ = SPEAKER_NOTES;
</script>
<script>
/* =============== WebGL 双背景 ===============
深色页:Holographic Dispersion(全息色散 · 钛金暗流)—— 彩虹微扰、鼠标径向涟漪
浅色页:Spiral Vortex(旋转涡流 · 银色珍珠)—— domain-warp 流动、无中心
修改风格请参考 references/webgl-backgrounds.md
*/
const VS = `attribute vec2 position;void main(){gl_Position=vec4(position,0.0,1.0);}`;
const FS_DARK = `precision highp float;
uniform vec2 u_resolution;uniform float u_time;uniform vec2 u_mouse;
vec3 palette(float t,vec3 a,vec3 b,vec3 c,vec3 d){return a+b*cos(6.28318*(c*t+d));}
void main(){
vec2 uv=gl_FragCoord.xy/u_resolution.xy;
vec2 p=uv*2.0-1.0;p.x*=u_resolution.x/u_resolution.y;
vec2 m=u_mouse*2.0-1.0;m.x*=u_resolution.x/u_resolution.y;
float md=length(p-m);
float mr=sin(md*15.0-u_time*4.0)*exp(-md*3.0);p+=mr*0.08;
vec2 p0=p;
for(float i=1.0;i<4.0;i++){
p.x+=0.1/i*sin(i*3.0*p.y+u_time*0.4)+0.05;
p.y+=0.1/i*cos(i*2.0*p.x+u_time*0.3)-0.05;
}
float r=length(p);float ang=atan(p.y,p.x);
vec3 a=vec3(0.12,0.12,0.13);
vec3 b=vec3(0.03,0.04,0.05);
vec3 c=vec3(1.0,1.0,1.0);
vec3 d=vec3(0.1,0.2,0.4);
vec3 col=palette(r*1.5+p0.x*0.5+u_time*0.1,a,b,c,d);
float disp=sin(r*25.0-u_time*1.5+ang*2.0)*0.5+0.5;
col+=vec3(disp*0.015,disp*0.01,disp*0.02);
float hi=pow(sin(p.x*4.0+p.y*3.0+u_time)*0.5+0.5,8.0);
col+=hi*0.08;
vec3 base=vec3(0.05,0.05,0.06);
col=mix(base,col,0.85);
gl_FragColor=vec4(col,1.0);
}`;
const FS_LIGHT = `precision highp float;
uniform vec2 u_resolution;uniform float u_time;uniform vec2 u_mouse;
float hash(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);}
float noise(vec2 p){
vec2 i=floor(p),f=fract(p);
float a=hash(i),b=hash(i+vec2(1,0));
float c=hash(i+vec2(0,1)),d=hash(i+vec2(1,1));
vec2 u=f*f*(3.0-2.0*f);
return mix(a,b,u.x)+(c-a)*u.y*(1.0-u.x)+(d-b)*u.x*u.y;
}
float fbm(vec2 p){
float v=0.0,a=0.5;
mat2 m=mat2(0.80,0.60,-0.60,0.80);
for(int i=0;i<5;i++){v+=a*noise(p);p=m*p*2.02;a*=0.5;}
return v;
}
void main(){
vec2 uv=gl_FragCoord.xy/u_resolution.xy;
vec2 p=uv;p.x*=u_resolution.x/u_resolution.y;
vec2 m=u_mouse;m.x*=u_resolution.x/u_resolution.y;
vec2 md=p-m;float dl=length(md);
p+=normalize(md+vec2(0.0001))*exp(-dl*5.0)*0.03;
vec2 q=vec2(fbm(p*1.8+u_time*0.07),fbm(p*1.8+vec2(5.2,1.3)+u_time*0.06));
vec2 r=vec2(fbm(p*2.0+q*1.3+vec2(1.7,9.2)+u_time*0.05),
fbm(p*2.0+q*1.3+vec2(8.3,2.8)+u_time*0.04));
float f=fbm(p*2.2+r*1.5);
vec3 silverDark=vec3(0.86,0.85,0.84);
vec3 paper=vec3(0.955,0.945,0.925);
vec3 col=mix(silverDark,paper,f);
float ph=r.x*2.2+u_time*0.35;
col+=vec3(0.78,0.62,0.92)*sin(ph)*0.055;
col+=vec3(0.55,0.72,0.95)*sin(ph*0.8+2.0)*0.05;
float hl=smoothstep(0.48,0.92,f);
col+=hl*0.06;
gl_FragColor=vec4(col,1.0);
}`;
const mouse={x:0.5,y:0.5};
addEventListener('mousemove',e=>{mouse.x=e.clientX/innerWidth;mouse.y=e.clientY/innerHeight});
function bootGL(canvasId, fsSrc){
const canvas=document.getElementById(canvasId);
const gl=canvas.getContext('webgl',{alpha:false,antialias:true});
if(!gl) return ()=>false;
const mk=(t,s)=>{const sh=gl.createShader(t);gl.shaderSource(sh,s);gl.compileShader(sh);return sh};
const prog=gl.createProgram();
gl.attachShader(prog,mk(gl.VERTEX_SHADER,VS));
gl.attachShader(prog,mk(gl.FRAGMENT_SHADER,fsSrc));
gl.linkProgram(prog);gl.useProgram(prog);
const buf=gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER,buf);
gl.bufferData(gl.ARRAY_BUFFER,new Float32Array([-1,-1,1,-1,-1,1,-1,1,1,-1,1,1]),gl.STATIC_DRAW);
const pos=gl.getAttribLocation(prog,'position');
gl.enableVertexAttribArray(pos);gl.vertexAttribPointer(pos,2,gl.FLOAT,false,0,0);
const lRes=gl.getUniformLocation(prog,'u_resolution');
const lT=gl.getUniformLocation(prog,'u_time');
const lM=gl.getUniformLocation(prog,'u_mouse');
const resize=()=>{
const d=Math.min(window.devicePixelRatio||1,2);
canvas.width=innerWidth*d;canvas.height=innerHeight*d;
gl.viewport(0,0,canvas.width,canvas.height);
};
addEventListener('resize',resize);resize();
return (tSec)=>{
gl.uniform2f(lRes,canvas.width,canvas.height);
gl.uniform1f(lT,tSec);
gl.uniform2f(lM,mouse.x,1-mouse.y);
gl.drawArrays(gl.TRIANGLES,0,6);
return true;
};
}
let drawDark=null, drawLight=null, glRAF=0, glT0=Date.now();
function startGL(){
if(window.__lowPowerMode || glRAF) return;
if(!drawDark) drawDark=bootGL('bg-dark',FS_DARK);
if(!drawLight) drawLight=bootGL('bg-light',FS_LIGHT);
glT0=Date.now();
function loop(){
if(window.__lowPowerMode){glRAF=0;return;}
const t=(Date.now()-glT0)/1000;
drawDark(t);drawLight(t);
glRAF=requestAnimationFrame(loop);
}
glRAF=requestAnimationFrame(loop);
}
function stopGL(){
if(glRAF) cancelAnimationFrame(glRAF);
glRAF=0;
}
startGL();
addEventListener('ppt-low-power-change', e=>{e.detail.on ? stopGL() : startGL();});
// =============== 导航(翻页 / 圆点 / 键盘 / 滚轮 / 触屏) ===============
const deck=document.getElementById('deck');
const slides=deck.querySelectorAll('.slide');
const nav=document.getElementById('nav');
let idx=0,total=slides.length,lock=false;
// 关键:矫正 deck 宽度为 total * 100vw,否则翻页会错位
deck.style.width=(total*100)+'vw';
slides.forEach((s,i)=>{
const b=document.createElement('button');
b.className='dot';b.dataset.i=i;b.setAttribute('aria-label','Page '+(i+1));
b.onclick=()=>go(i);
nav.appendChild(b);
});
function go(n,opts={}){
if(lock&&!opts.force)return;
idx=Math.max(0,Math.min(total-1,n));
window.__currentSlideIndex = idx;
deck.style.transform=`translateX(${-idx*100}vw)`;
nav.querySelectorAll('.dot').forEach((d,i)=>d.classList.toggle('active',i===idx));
/* 主题切换:优先读 data-theme,其次从 class(light/dark)推断 */
const el=slides[idx];
const th=el.dataset.theme || (el.classList.contains('light')?'light':(el.classList.contains('dark')?'dark':'dark'));
document.body.classList.toggle('light-bg',th==='light');
/* 动效:翻页过渡中段触发当前页的入场动画(由 motion-boot 注册) */
if(window.__playSlide) setTimeout(()=>window.__playSlide(idx), 450);
window.__pptPresenter?.onSlideChange(idx);
if(!opts.force){lock=true;setTimeout(()=>lock=false,700);}
}
/* =============== ESC 索引视图 =============== */
let overviewOn=false;
const ov=document.createElement('div');
ov.id='overview';
ov.style.cssText='position:fixed;inset:0;z-index:100;background:rgba(var(--ink-rgb),.92);backdrop-filter:blur(12px);display:none;overflow-y:auto;padding:4vh 4vw';
document.body.appendChild(ov);
function buildOverview(){
ov.innerHTML='';
const grid=document.createElement('div');
grid.style.cssText='display:grid;grid-template-columns:repeat(4,1fr);gap:2vh 1.6vw;max-width:90vw;margin:0 auto';
slides.forEach((s,i)=>{
const card=document.createElement('div');
card.style.cssText='cursor:pointer;border-radius:6px;overflow:hidden;border:2px solid '+(i===idx?'rgba(var(--paper-rgb),.8)':'rgba(var(--paper-rgb),.15)')+';transition:border-color .2s';
card.onmouseenter=()=>card.style.borderColor='rgba(var(--paper-rgb),.6)';
card.onmouseleave=()=>card.style.borderColor=i===idx?'rgba(var(--paper-rgb),.8)':'rgba(var(--paper-rgb),.15)';
const wrap=document.createElement('div');
wrap.style.cssText='width:100%;aspect-ratio:16/9;overflow:hidden;position:relative;pointer-events:none;background:'+(s.classList.contains('light')?'var(--paper)':'var(--ink)');
const clone=s.cloneNode(true);
clone.style.cssText='width:100vw;height:100vh;transform:scale('+(1/4.5)+');transform-origin:top left;position:absolute;top:0;left:0;pointer-events:none';
wrap.appendChild(clone);
const label=document.createElement('div');
label.style.cssText='padding:6px 10px;font-family:var(--mono);font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--paper);opacity:.7';
label.textContent=window.__pptPresenter?.slideLabel(i) || ((i+1)+' / '+total);
card.appendChild(wrap);
card.appendChild(label);
card.onclick=()=>{toggleOverview();go(i)};
grid.appendChild(card);
});
ov.appendChild(grid);
}
function toggleOverview(){
overviewOn=!overviewOn;
if(overviewOn){buildOverview();ov.style.display='block';}
else{ov.style.display='none';}
}
/* =============== 演讲者模式 / 观众屏同步 =============== */
window.__pptPresenter=(()=>{
const query=new URLSearchParams(location.search);
const requestedMode=query.get('mode')||'deck';
const originalTitle=document.title;
const notes=()=>Array.isArray(window.__SPEAKER_NOTES__)?window.__SPEAKER_NOTES__:[];
const slideId=i=>slides[i]?.dataset.slideId||`slide-${String(i+1).padStart(2,'0')}`;
const storageKey=name=>`guizang-ppt-${name}:${location.pathname}`;
let mode='deck',session=query.get('session')||Math.random().toString(36).slice(2),audienceWindow=null;
let channel=null,transportReady=false,popupBlocked=false,audienceOpenedAt=0,lastAckAt=0,lastAckIndex=-1,lastAckSeq=-1,seq=0,lastPingAt=0;
let renderedNoteId='',saveTimer=0,noteSize=Number(localStorage.getItem('guizang-ppt-note-size')||17),presenterFrameObserver=null;
let elapsed=0,timerRunning=false,timerStartedAt=0,timedSlideIndex=idx,slideTimerStartedAt=0,slideElapsed=Array(total).fill(0);
let rehearsalActive=false,presenterOverviewOn=false,lowPowerBeforePresenter=window.__lowPowerMode;
let layoutMode=localStorage.getItem(storageKey('layout'))||'balanced';
let autoEnabled=localStorage.getItem(storageKey('auto-enabled'))==='1';
let autoGlobalSeconds=Math.max(0,Number(localStorage.getItem(storageKey('auto-seconds'))||0));
let autoDeadline=0,autoRemainingMs=0;
let screenMode='normal',audienceFrozen=false;
let annotationTool='none',annotationPointerDown=false,annotationStart=null,draftCircle=null,circles=[],audienceCircles=[];
let laserPoint=null,audienceLaser=null,laserHideAt=0,audienceLaserHideAt=0,lastLaserSent=0;
const seenNonces=[],seenNonceSet=new Set();
const syncKey=()=>`guizang-ppt-sync:${location.pathname}:${session}`;
const channelName=()=>`guizang-ppt:${location.pathname}:${session}`;
function noteAt(i){
const id=slideId(i);
return notes().find(n=>n.id===id)||{id,title:slides[i]?.dataset.title||`第 ${i+1} 页`,purpose:'—',talk:[],transition:''};
}
function slideLabel(i){return `${i+1} / ${total} · ${noteAt(i).title||slideId(i)}`;}
function escapeHTML(value){return String(value??'').replace(/[&<>"']/g,ch=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[ch]));}
function valueLines(value){
if(Array.isArray(value))return value.filter(Boolean).map(x=>`• ${x}`).join('\n');
return value?String(value):'';
}
function generatedNote(n){
const parts=[];
if(Array.isArray(n.talk)&&n.talk.length)parts.push(`【讲述要点】\n${valueLines(n.talk)}`);
if(n.transition)parts.push(`【转场】\n${n.transition}`);
const optional=[['舞台提示',n.cue],['互动',n.interaction],['表达提示',n.delivery],['翻页时机',n.advance],['备用方案',n.fallback],['读音提醒',n.pronunciation]];
optional.forEach(([label,value])=>{const text=valueLines(value);if(text)parts.push(`【${label}】\n${text}`);});
return parts.join('\n\n')||'—';
}
function noteStorageKey(id){return `guizang-ppt-note:${location.pathname}:${id}`;}
function loadNoteValue(id,i,n){
const saved=localStorage.getItem(noteStorageKey(id));
if(saved!==null)return saved;
const prefix=window.__PPT_LEGACY_NOTE_KEY__;
if(prefix){
const legacy=localStorage.getItem(`${prefix}:${i}`);
if(legacy!==null){localStorage.setItem(noteStorageKey(id),legacy);return legacy;}
}
return generatedNote(n);
}
function frameUrl(targetMode,i){
const url=new URL(location.href);url.hash='';
url.searchParams.set('mode',targetMode);url.searchParams.set('session',session);url.searchParams.set('slide',String(i+1));
return url.href;
}
function postPreviewGoto(frame,i){
try{frame.contentWindow?.postMessage({__guizangPptSync:1,session,role:'presenter',type:'preview-goto',index:i},'*');}catch(e){}
}
function updatePreviewFrame(frame,i){
frame.dataset.slide=String(i);
if(!frame.dataset.previewBound){frame.dataset.previewBound='1';frame.addEventListener('load',()=>postPreviewGoto(frame,Number(frame.dataset.slide)));}
if(!frame.getAttribute('src'))frame.src=frameUrl('preview',i);else postPreviewGoto(frame,i);
}
function plannedMsAt(i){
const minutes=Number(noteAt(i).minutes);
return Number.isFinite(minutes)&&minutes>0?minutes*60000:null;
}
function totalPlannedMs(){
const values=Array.from({length:total},(_,i)=>plannedMsAt(i));
return values.length&&values.every(Number.isFinite)?values.reduce((sum,x)=>sum+x,0):null;
}
function sectionInfo(i){
const name=String(noteAt(i).section||'').trim();
if(!name)return null;
let first=i,last=i;
while(first>0&&String(noteAt(first-1).section||'').trim()===name)first--;
while(last<total-1&&String(noteAt(last+1).section||'').trim()===name)last++;
const targets=[];for(let x=first;x<=last;x++)targets.push(plannedMsAt(x));
const planned=targets.every(Number.isFinite)?targets.reduce((sum,x)=>sum+x,0):null;
const used=slideElapsed.slice(first,last+1).reduce((sum,x)=>sum+x,0)+(timerRunning&&timedSlideIndex>=first&&timedSlideIndex<=last?Date.now()-slideTimerStartedAt:0);
return{name,position:i-first+1,count:last-first+1,planned,used};
}
function durationText(ms){
const value=Math.max(0,Math.round(Number(ms)||0)),seconds=Math.floor(value/1000);
const hours=Math.floor(seconds/3600),minutes=Math.floor((seconds%3600)/60),secs=seconds%60;
return hours?`${String(hours).padStart(2,'0')}:${String(minutes).padStart(2,'0')}:${String(secs).padStart(2,'0')}`:`${String(minutes).padStart(2,'0')}:${String(secs).padStart(2,'0')}`;
}
function signedDuration(ms){return `${ms>=0?'+':'−'}${durationText(Math.abs(ms))}`;}
function clockTime(timestamp){return new Date(timestamp).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'});}
function fitFrame(frame){
const viewport=frame?.closest('.ppt-frame-viewport');
if(!viewport)return;
const availableWidth=viewport.clientWidth,availableHeight=viewport.clientHeight;
if(!availableWidth||!availableHeight)return;
const width=Math.min(availableWidth,availableHeight*(16/9)),height=width*(9/16);
frame.style.width=`${width}px`;frame.style.height=`${height}px`;
if(frame.id==='ppt-current-frame')sizeInk(document.getElementById('ppt-presenter-ink'),width,height);
}
function fitPresenterFrames(){
fitFrame(document.getElementById('ppt-current-frame'));
fitFrame(document.getElementById('ppt-next-frame'));
}
function sizeInk(canvas,width,height){
if(!canvas||!width||!height)return;
canvas.style.width=`${width}px`;canvas.style.height=`${height}px`;
const dpr=Math.min(devicePixelRatio||1,2),pixelWidth=Math.round(width*dpr),pixelHeight=Math.round(height*dpr);
if(canvas.width!==pixelWidth||canvas.height!==pixelHeight){canvas.width=pixelWidth;canvas.height=pixelHeight;}
}
function drawEllipse(ctx,circle,width,height,alpha=1){
const x1=circle.x1*width,y1=circle.y1*height,x2=circle.x2*width,y2=circle.y2*height;
ctx.save();ctx.globalAlpha=alpha;ctx.strokeStyle='#ff4f59';ctx.lineWidth=Math.max(3,width*.004);ctx.lineCap='round';
ctx.beginPath();ctx.ellipse((x1+x2)/2,(y1+y2)/2,Math.abs(x2-x1)/2,Math.abs(y2-y1)/2,0,0,Math.PI*2);ctx.stroke();ctx.restore();
}
function drawInk(canvas,list,laser,draft){
if(!canvas)return;const width=canvas.clientWidth,height=canvas.clientHeight;if(!width||!height)return;
sizeInk(canvas,width,height);const dpr=canvas.width/width,ctx=canvas.getContext('2d');ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,width,height);
(list||[]).forEach(circle=>drawEllipse(ctx,circle,width,height));
if(draft)drawEllipse(ctx,draft,width,height,.72);
if(laser){
const x=laser.x*width,y=laser.y*height,r=Math.max(6,width*.007);
const glow=ctx.createRadialGradient(x,y,0,x,y,r*2.8);glow.addColorStop(0,'rgba(255,255,255,.98)');glow.addColorStop(.2,'rgba(255,64,72,.98)');glow.addColorStop(1,'rgba(255,64,72,0)');
ctx.fillStyle=glow;ctx.beginPath();ctx.arc(x,y,r*2.8,0,Math.PI*2);ctx.fill();
}
}
function renderInks(){
const now=Date.now();if(now>laserHideAt)laserPoint=null;if(now>audienceLaserHideAt)audienceLaser=null;
drawInk(document.getElementById('ppt-presenter-ink'),circles,laserPoint,draftCircle);
drawInk(document.getElementById('ppt-audience-ink'),audienceCircles,audienceLaser,null);
}
function setAnnotationTool(tool){
annotationTool=annotationTool===tool?'none':tool;annotationPointerDown=false;draftCircle=null;
const canvas=document.getElementById('ppt-presenter-ink');canvas?.classList.toggle('is-active',annotationTool!=='none');
document.querySelectorAll('[data-ppt-tool]').forEach(button=>button.classList.toggle('is-active',button.dataset.pptTool===annotationTool));
renderInks();
}
function annotationPoint(event){
const canvas=event.currentTarget,rect=canvas.getBoundingClientRect();
return{x:Math.max(0,Math.min(1,(event.clientX-rect.left)/rect.width)),y:Math.max(0,Math.min(1,(event.clientY-rect.top)/rect.height))};
}
function onAnnotationPointerDown(event){
if(annotationTool!=='circle')return;event.preventDefault();annotationPointerDown=true;annotationStart=annotationPoint(event);draftCircle={x1:annotationStart.x,y1:annotationStart.y,x2:annotationStart.x,y2:annotationStart.y};event.currentTarget.setPointerCapture?.(event.pointerId);renderInks();
}
function onAnnotationPointerMove(event){
if(annotationTool==='laser'){
const point=annotationPoint(event);laserPoint=point;laserHideAt=Date.now()+650;
if(Date.now()-lastLaserSent>32){lastLaserSent=Date.now();emit({type:'laser',index:idx,point},{persist:false});}renderInks();return;
}
if(annotationTool==='circle'&&annotationPointerDown&&draftCircle){const point=annotationPoint(event);draftCircle.x2=point.x;draftCircle.y2=point.y;renderInks();}
}
function onAnnotationPointerUp(event){
if(annotationTool!=='circle'||!annotationPointerDown||!draftCircle)return;event.preventDefault();annotationPointerDown=false;
const circle={...draftCircle};draftCircle=null;
if(Math.abs(circle.x2-circle.x1)>.012&&Math.abs(circle.y2-circle.y1)>.012){circles.push(circle);emit({type:'circle',index:idx,circle});}
renderInks();
}
function onAnnotationPointerLeave(){
if(annotationTool==='laser'){laserPoint=null;emit({type:'laser',index:idx,point:null},{persist:false});renderInks();}
}
function clearAnnotations(broadcast=true){circles=[];draftCircle=null;laserPoint=null;if(broadcast)emit({type:'clear',index:idx});renderInks();}
function buildPresenterOverview(){
const grid=document.getElementById('ppt-presenter-overview-grid');if(!grid)return;grid.innerHTML='';
slides.forEach((slide,i)=>{
const n=noteAt(i),button=document.createElement('button');button.type='button';button.className=`ppt-overview-page${i===idx?' is-current':''}`;
button.innerHTML=`<span class="ppt-overview-page-no">${String(i+1).padStart(2,'0')} / ${String(total).padStart(2,'0')}</span><span class="ppt-overview-page-title">${escapeHTML(n.title||slideId(i))}</span><span class="ppt-overview-page-meta"><span>${escapeHTML(n.section||'未分组')}</span><span>${Math.round(((i+1)/total)*100)}%</span></span>`;
button.onclick=()=>{togglePresenterOverview(false);go(i,{force:true});};grid.appendChild(button);
});
requestAnimationFrame(()=>grid.querySelector('.is-current')?.scrollIntoView({block:'nearest',inline:'nearest'}));
}
function togglePresenterOverview(force){
presenterOverviewOn=typeof force==='boolean'?force:!presenterOverviewOn;
if(presenterOverviewOn&&annotationTool!=='none')setAnnotationTool('none');
const stack=document.querySelector('.ppt-preview-stack'),overview=document.getElementById('ppt-presenter-overview');
stack?.classList.toggle('is-overview',presenterOverviewOn);if(overview)overview.hidden=!presenterOverviewOn;
const button=document.getElementById('ppt-grid');if(button){button.classList.toggle('is-active',presenterOverviewOn);button.setAttribute('aria-pressed',String(presenterOverviewOn));}
if(presenterOverviewOn)buildPresenterOverview();else requestAnimationFrame(fitPresenterFrames);renderAutoState();
}
function mountShell(){
if(document.getElementById('ppt-presenter'))return;
document.body.insertAdjacentHTML('beforeend',`<div id="ppt-presenter" data-layout="${layoutMode}" aria-label="演讲者视图">
<div class="ppt-presenter-top"><div><div class="ppt-presenter-brand">演讲者视图</div><div class="ppt-presenter-meta"><span id="ppt-plan"></span><span id="ppt-section" hidden></span></div></div><div class="ppt-presenter-meta"><span id="ppt-sync" class="ppt-sync" data-state="disconnected" title="表示观众页面的软件同步状态,不代表 HDMI 等物理线缆状态">观众屏 · 未连接</span><button id="ppt-auto-toggle" class="ppt-ui-btn ppt-auto-state" type="button">自动翻页 · 关闭</button><button id="ppt-preflight" class="ppt-ui-btn" type="button">检查</button><button id="ppt-settings" class="ppt-ui-btn" type="button">设置</button><button id="ppt-reopen" class="ppt-ui-btn" type="button">重新打开观众屏</button><button id="ppt-exit" class="ppt-ui-btn" type="button">退出</button></div></div>
<div class="ppt-presenter-main"><div class="ppt-preview-stack"><div class="ppt-stage-card"><div class="ppt-stage-label"><span>当前页</span><span class="ppt-annotation-tools"><button id="ppt-grid" class="ppt-ui-btn ppt-grid-btn" type="button" aria-pressed="false" title="宫格选页(Esc)"><span class="ppt-grid-icon" aria-hidden="true"><i></i><i></i><i></i><i></i></span>宫格</button><button class="ppt-ui-btn" data-ppt-tool="laser" type="button" title="激光笔(L)">激光</button><button class="ppt-ui-btn" data-ppt-tool="circle" type="button" title="圈选(C)">圈选</button><button id="ppt-ink-clear" class="ppt-ui-btn ppt-clear-btn" type="button" title="清除标记(X)">清除</button></span><span id="ppt-current-label"></span></div><div class="ppt-frame-viewport"><iframe id="ppt-current-frame" class="ppt-frame" title="当前页预览"></iframe><canvas id="ppt-presenter-ink" class="ppt-annotation-layer" aria-label="演讲标注层"></canvas></div></div><div class="ppt-stage-card" id="ppt-next-card"><div class="ppt-stage-label"><span>下一页</span><span id="ppt-next-label"></span></div><div class="ppt-frame-viewport"><iframe id="ppt-next-frame" class="ppt-frame ppt-next-frame" title="下一页预览"></iframe></div></div><div id="ppt-presenter-overview" class="ppt-presenter-overview" hidden><div class="ppt-overview-head"><strong>选择页面</strong><button id="ppt-overview-close" class="ppt-ui-btn" type="button">返回预览</button></div><div id="ppt-presenter-overview-grid" class="ppt-overview-grid"></div></div></div>
<div class="ppt-notes-card"><div class="ppt-notes-head"><div class="ppt-notes-label">标题</div><div id="ppt-notes-title" class="ppt-notes-title"></div><div class="ppt-purpose-block"><div class="ppt-notes-label">本页目的</div><div id="ppt-notes-purpose" class="ppt-purpose-copy"></div></div><div class="ppt-notes-draft-head"><div><div class="ppt-notes-label">草稿(备注)</div><div id="ppt-note-meta" class="ppt-note-meta"></div></div><span class="ppt-notes-tools"><span id="ppt-saved" class="ppt-saved">已保存</span><button id="ppt-note-smaller" class="ppt-ui-btn" type="button" title="缩小备注字号">A−</button><button id="ppt-note-larger" class="ppt-ui-btn" type="button" title="放大备注字号">A+</button></span></div></div><textarea id="ppt-note-editor" class="ppt-note-editor" spellcheck="false" aria-label="本页演讲备注"></textarea></div></div>
<div class="ppt-presenter-bottom"><div class="ppt-time-dashboard"><div class="ppt-time-stat"><span class="ppt-time-label">已进行</span><strong id="ppt-clock" class="ppt-time-value">00:00</strong><span class="ppt-time-hint">总时长</span></div><div class="ppt-time-stat"><span class="ppt-time-label">本页</span><strong id="ppt-slide-clock" class="ppt-time-value">00:00</strong><span id="ppt-slide-plan" class="ppt-time-hint">计划 —</span></div><div id="ppt-slide-status-box" class="ppt-time-stat"><span id="ppt-slide-status-label" class="ppt-time-label">计划</span><strong id="ppt-slide-status" class="ppt-time-value">—</strong><span id="ppt-slide-status-hint" class="ppt-time-hint">未设置</span></div></div><div class="ppt-control-stack"><div class="ppt-controls"><button id="ppt-first" class="ppt-ui-btn" type="button">首页</button><button id="ppt-prev" class="ppt-ui-btn" type="button">上一页</button><button id="ppt-next" class="ppt-ui-btn" type="button">下一页</button><button id="ppt-last" class="ppt-ui-btn" type="button">尾页</button></div><div class="ppt-session-actions"><button id="ppt-timer-toggle" class="ppt-ui-btn" type="button">开始计时</button><button id="ppt-timer-reset" class="ppt-ui-btn" type="button">重置计时</button><button id="ppt-rehearsal" class="ppt-ui-btn" type="button">排练</button></div></div><div class="ppt-page-state"><strong id="ppt-page-count"></strong><span id="ppt-page-percent"></span></div></div>
<div id="ppt-dialog" class="ppt-dialog" hidden><div class="ppt-dialog-panel" role="dialog" aria-modal="true"><div class="ppt-dialog-head"><div id="ppt-dialog-title" class="ppt-dialog-title"></div><button id="ppt-dialog-close" class="ppt-ui-btn" type="button">关闭</button></div><div id="ppt-dialog-body" class="ppt-dialog-body"></div></div></div>
</div>`);
document.getElementById('ppt-reopen').onclick=reopenAudience;
document.getElementById('ppt-exit').onclick=exitPresenter;
document.getElementById('ppt-preflight').onclick=openPreflight;
document.getElementById('ppt-settings').onclick=openSettings;
document.getElementById('ppt-first').onclick=()=>go(0,{force:true});
document.getElementById('ppt-prev').onclick=()=>go(idx-1,{force:true});
document.getElementById('ppt-grid').onclick=()=>togglePresenterOverview();
document.getElementById('ppt-overview-close').onclick=()=>togglePresenterOverview(false);
document.getElementById('ppt-next').onclick=()=>go(idx+1,{force:true});
document.getElementById('ppt-last').onclick=()=>go(idx===total-1?0:total-1,{force:true});
document.getElementById('ppt-timer-toggle').onclick=toggleTimer;
document.getElementById('ppt-timer-reset').onclick=resetTimer;
document.getElementById('ppt-rehearsal').onclick=toggleRehearsal;
document.getElementById('ppt-auto-toggle').onclick=toggleAutoAdvance;
document.getElementById('ppt-note-smaller').onclick=()=>setNoteSize(noteSize-1);
document.getElementById('ppt-note-larger').onclick=()=>setNoteSize(noteSize+1);
document.getElementById('ppt-note-editor').addEventListener('input',saveNote);
document.querySelectorAll('[data-ppt-tool]').forEach(button=>button.onclick=()=>setAnnotationTool(button.dataset.pptTool));
document.getElementById('ppt-ink-clear').onclick=()=>clearAnnotations(true);
const ink=document.getElementById('ppt-presenter-ink');
ink.addEventListener('pointerdown',onAnnotationPointerDown);ink.addEventListener('pointermove',onAnnotationPointerMove);ink.addEventListener('pointerup',onAnnotationPointerUp);ink.addEventListener('pointercancel',onAnnotationPointerUp);ink.addEventListener('pointerleave',onAnnotationPointerLeave);
document.getElementById('ppt-dialog-close').onclick=closeDialog;
document.getElementById('ppt-dialog').onclick=event=>{if(event.target.id==='ppt-dialog')closeDialog();};
if('ResizeObserver'in window){presenterFrameObserver=new ResizeObserver(()=>requestAnimationFrame(fitPresenterFrames));presenterFrameObserver.observe(document.querySelector('.ppt-preview-stack'));}
else addEventListener('resize',fitPresenterFrames);
setNoteSize(noteSize,false);applyLayout(layoutMode,false);requestAnimationFrame(fitPresenterFrames);
}
function updateShell(){
if(mode!=='presenter')return;mountShell();
const n=noteAt(idx),next=Math.min(idx+1,total-1),id=slideId(idx),editor=document.getElementById('ppt-note-editor');
const currentFrame=document.getElementById('ppt-current-frame'),nextFrame=document.getElementById('ppt-next-frame');
if(currentFrame.dataset.slide!==String(idx))updatePreviewFrame(currentFrame,idx);
if(nextFrame.dataset.slide!==String(next))updatePreviewFrame(nextFrame,next);
document.getElementById('ppt-next-card').classList.toggle('is-end',idx===total-1);
document.getElementById('ppt-current-label').textContent=`${idx+1} / ${total}`;
document.getElementById('ppt-next-label').textContent=idx===total-1?'已到尾页':`${next+1} / ${total}`;
document.getElementById('ppt-notes-title').textContent=n.title||`第 ${idx+1} 页`;
document.getElementById('ppt-notes-purpose').textContent=n.purpose||'—';
const target=plannedMsAt(idx);document.getElementById('ppt-note-meta').textContent=`建议 ${target?(target/60000).toFixed(1):'—'} 分钟 · ${id}`;
document.getElementById('ppt-page-count').textContent=`第 ${idx+1} / ${total} 页`;
document.getElementById('ppt-page-percent').textContent=`${Math.round(((idx+1)/total)*100)}%`;
document.getElementById('ppt-first').disabled=idx===0;document.getElementById('ppt-prev').disabled=idx===0;document.getElementById('ppt-next').disabled=idx===total-1;
document.getElementById('ppt-last').textContent=idx===total-1?'重新开始':'尾页';
if(renderedNoteId!==id){renderedNoteId=id;editor.value=loadNoteValue(id,idx,n);editor.scrollTop=0;document.getElementById('ppt-saved').textContent='已保存';}
if(presenterOverviewOn)buildPresenterOverview();requestAnimationFrame(fitPresenterFrames);renderTimer();updateSyncStatus();
}
function saveNote(){
const saved=document.getElementById('ppt-saved');saved.textContent='保存中…';saved.classList.remove('ppt-saved');clearTimeout(saveTimer);
saveTimer=setTimeout(()=>{localStorage.setItem(noteStorageKey(renderedNoteId),document.getElementById('ppt-note-editor').value);saved.textContent='已保存';saved.classList.add('ppt-saved');},300);
}
function setNoteSize(value,persist=true){noteSize=Math.max(14,Math.min(26,value));document.documentElement.style.setProperty('--ppt-note-size',`${noteSize}px`);if(persist)localStorage.setItem('guizang-ppt-note-size',String(noteSize));}
function currentElapsed(){return elapsed+(timerRunning?Date.now()-timerStartedAt:0);}
function currentSlideElapsed(i=idx){return slideElapsed[i]+(timerRunning&&timedSlideIndex===i?Date.now()-slideTimerStartedAt:0);}
function commitRunningTime(now=Date.now()){
if(!timerRunning)return;elapsed+=now-timerStartedAt;slideElapsed[timedSlideIndex]+=now-slideTimerStartedAt;timerStartedAt=now;slideTimerStartedAt=now;
}
function rollSlideTimer(nextIndex){if(timerRunning)commitRunningTime();timedSlideIndex=nextIndex;slideTimerStartedAt=Date.now();}
function renderTimer(){
const totalUsed=currentElapsed(),pageUsed=currentSlideElapsed(),target=plannedMsAt(idx),clock=document.getElementById('ppt-clock');
if(clock)clock.textContent=durationText(totalUsed);
const slideClock=document.getElementById('ppt-slide-clock'),slidePlan=document.getElementById('ppt-slide-plan'),statusBox=document.getElementById('ppt-slide-status-box'),statusLabel=document.getElementById('ppt-slide-status-label'),statusValue=document.getElementById('ppt-slide-status'),statusHint=document.getElementById('ppt-slide-status-hint');
if(slideClock)slideClock.textContent=durationText(pageUsed);if(slidePlan)slidePlan.textContent=target?`计划 ${durationText(target)}`:'计划 —';
if(statusBox&&statusLabel&&statusValue&&statusHint){
const over=Boolean(target&&pageUsed>target),remaining=Boolean(target&&!over);statusBox.classList.toggle('is-over',over);statusBox.classList.toggle('is-remaining',remaining);
statusLabel.textContent=over?'超时':(remaining?'剩余':'计划');statusValue.textContent=target?durationText(Math.abs(target-pageUsed)):'—';statusHint.textContent=over?'已超出本页计划':(remaining?'距本页计划':'未设置');
}
const totalPlan=totalPlannedMs(),plan=document.getElementById('ppt-plan');
if(plan)plan.textContent=totalPlan?`计划 ${Number(totalPlan/60000).toFixed(1)} 分钟 · 预计 ${clockTime(Date.now()+Math.max(0,totalPlan-totalUsed))}`:'计划 —';
const section=sectionInfo(idx),sectionEl=document.getElementById('ppt-section');
if(sectionEl){sectionEl.hidden=!section;sectionEl.textContent=section?`${section.name} · ${section.position}/${section.count}${section.planned?` · 剩余 ${durationText(Math.max(0,section.planned-section.used))}`:''}`:'';}
const timerButton=document.getElementById('ppt-timer-toggle');if(timerButton)timerButton.textContent=timerRunning?'暂停':(totalUsed?'继续计时':'开始计时');
const rehearsalButton=document.getElementById('ppt-rehearsal');if(rehearsalButton){rehearsalButton.textContent=rehearsalActive?'结束排练':'排练';rehearsalButton.classList.toggle('ppt-rehearsal-on',rehearsalActive);}
renderAutoState();
}
function toggleTimer(){const now=Date.now();if(timerRunning){commitRunningTime(now);timerRunning=false;}else{timerStartedAt=now;slideTimerStartedAt=now;timedSlideIndex=idx;timerRunning=true;}renderTimer();}
function resetTimer(){
elapsed=0;slideElapsed=Array(total).fill(0);timedSlideIndex=idx;
if(timerRunning){timerStartedAt=Date.now();slideTimerStartedAt=timerStartedAt;}else{timerStartedAt=0;slideTimerStartedAt=0;}
renderTimer();
}
function rehearsalHistory(){try{return JSON.parse(localStorage.getItem(storageKey('rehearsals'))||'[]');}catch(e){return[];}}
function toggleRehearsal(){
if(rehearsalActive){finishRehearsal(true);return;}
rehearsalActive=true;if(timerRunning){commitRunningTime();timerRunning=false;}resetTimer();toggleTimer();renderTimer();
}
function finishRehearsal(showSummary){
if(!rehearsalActive)return;if(timerRunning){commitRunningTime();timerRunning=false;}
const result={endedAt:Date.now(),totalMs:elapsed,slides:slideElapsed.map((ms,i)=>({index:i,id:slideId(i),title:noteAt(i).title||slideId(i),ms,targetMs:plannedMsAt(i)}))};
const history=rehearsalHistory();history.unshift(result);try{localStorage.setItem(storageKey('rehearsals'),JSON.stringify(history.slice(0,5)));}catch(e){}
rehearsalActive=false;renderTimer();if(showSummary)openRehearsalSummary(result,history.slice(0,5));
}
function openRehearsalSummary(result,history){
const rows=result.slides.filter(item=>item.ms>0).map(item=>`<tr><td>${item.index+1}</td><td>${escapeHTML(item.title)}</td><td>${durationText(item.ms)}</td><td>${item.targetMs?signedDuration(item.ms-item.targetMs):'—'}</td></tr>`).join('');
const recent=history.map(item=>`<div class="ppt-dialog-row"><span>${new Date(item.endedAt).toLocaleString()}</span><strong>${durationText(item.totalMs)}</strong></div>`).join('');
openDialog('排练记录',`<div class="ppt-dialog-section"><div class="ppt-dialog-row"><span>本次总时长</span><strong>${durationText(result.totalMs)}</strong></div><table class="ppt-summary"><thead><tr><th>#</th><th>页面</th><th>实际</th><th>与计划相比</th></tr></thead><tbody>${rows||'<tr><td colspan="4">没有记录到页面时间</td></tr>'}</tbody></table></div><div class="ppt-dialog-section"><h3>最近排练</h3>${recent}</div>`);
}
function autoDurationMs(i=idx){const explicit=Number(noteAt(i).autoAdvanceSeconds);const seconds=Number.isFinite(explicit)&&explicit>0?explicit:autoGlobalSeconds;return seconds>0?seconds*1000:null;}
function armAutoAdvance(){autoDeadline=0;autoRemainingMs=autoDurationMs()||0;}
function autoBlockedReason(){
if(presenterOverviewOn||overviewOn)return'宫格选页中';if(annotationTool!=='none')return'标注中';if(screenMode!=='normal')return'观众屏已暂停';if(document.hidden)return'窗口不可见';
const dialog=document.getElementById('ppt-dialog');if(dialog&&!dialog.hidden)return'设置中';
if(audienceOpenedAt&&syncState()!=='synced')return'等待观众屏同步';return'';
}
function updateAutoAdvance(){
if(!autoEnabled)return;
const duration=autoDurationMs();if(!duration||idx===total-1)return;
const blocked=autoBlockedReason(),now=Date.now();
if(blocked){if(autoDeadline){autoRemainingMs=Math.max(0,autoDeadline-now);autoDeadline=0;}return;}
if(!autoDeadline)autoDeadline=now+(autoRemainingMs||duration);
autoRemainingMs=Math.max(0,autoDeadline-now);
if(now>=autoDeadline){autoDeadline=0;autoRemainingMs=0;go(idx+1,{force:true});}
}
function renderAutoState(){
const button=document.getElementById('ppt-auto-toggle');if(!button)return;
const duration=autoDurationMs(),blocked=autoEnabled?autoBlockedReason():'';
button.classList.toggle('is-on',autoEnabled&&!blocked);button.classList.toggle('is-paused',autoEnabled&&Boolean(blocked));
if(!autoEnabled)button.textContent='自动翻页 · 关闭';
else if(!duration)button.textContent='自动翻页 · 等待时长';
else if(idx===total-1)button.textContent='自动翻页 · 已结束';
else if(blocked)button.textContent=`自动翻页 · ${blocked}`;
else button.textContent=`自动翻页 · ${durationText(autoRemainingMs||duration)}`;
}
function toggleAutoAdvance(){
if(!autoEnabled&&!autoDurationMs()){openSettings();return;}
autoEnabled=!autoEnabled;localStorage.setItem(storageKey('auto-enabled'),autoEnabled?'1':'0');armAutoAdvance();renderAutoState();
}
function openDialog(title,html){
const dialog=document.getElementById('ppt-dialog');if(!dialog)return;
document.getElementById('ppt-dialog-title').textContent=title;document.getElementById('ppt-dialog-body').innerHTML=html;dialog.hidden=false;renderAutoState();
}
function closeDialog(){const dialog=document.getElementById('ppt-dialog');if(dialog)dialog.hidden=true;renderAutoState();}
function applyLayout(value,persist=true){
layoutMode=['balanced','slides','notes'].includes(value)?value:'balanced';
document.getElementById('ppt-presenter')?.setAttribute('data-layout',layoutMode);
if(persist)localStorage.setItem(storageKey('layout'),layoutMode);requestAnimationFrame(fitPresenterFrames);
}
function applyAudienceScreen(value){
const cover=document.getElementById('ppt-audience-cover');if(!cover)return;
cover.classList.remove('is-ended');cover.textContent='';
if(value==='black'||value==='white'){cover.style.display='block';cover.style.background=value;}else cover.style.display='none';
}
function showAudienceEnded(){
const cover=document.getElementById('ppt-audience-cover');if(cover){cover.style.display='';cover.style.background='';cover.textContent='演示已结束';cover.classList.add('is-ended');}
setTimeout(()=>{try{window.close();}catch(e){}},120);
}
function setScreenMode(value){
const next=['normal','black','white','frozen'].includes(value)?value:'normal',wasFrozen=audienceFrozen;
screenMode=next;audienceFrozen=next==='frozen';emit({type:'screen',mode:next});
if(wasFrozen&&!audienceFrozen)sendGoto(true);updateSyncStatus();armAutoAdvance();renderAutoState();
}
function openSettings(){
openDialog('演示设置',`<div class="ppt-settings-card"><label class="ppt-switch-row" for="ppt-auto-enabled-setting"><span><strong>自动翻页</strong><br><span class="ppt-muted">需要时手动开启,默认关闭</span></span><input id="ppt-auto-enabled-setting" type="checkbox" ${autoEnabled?'checked':''}><span class="ppt-switch" aria-hidden="true"></span></label><div class="ppt-field"><label for="ppt-auto-seconds-setting">全局间隔</label><div class="ppt-stepper"><button id="ppt-auto-minus" type="button" aria-label="减少一秒">−</button><input id="ppt-auto-seconds-setting" type="number" min="1" step="1" value="${autoGlobalSeconds||''}" placeholder="—"><span class="ppt-stepper-unit">秒</span><button id="ppt-auto-plus" type="button" aria-label="增加一秒">+</button></div></div><button id="ppt-settings-save" class="ppt-ui-btn ppt-settings-save" type="button">保存自动翻页</button><p class="ppt-settings-copy">单页 autoAdvanceSeconds 优先于全局间隔;minutes 只是讲述计划。</p></div><div class="ppt-settings-card"><h3>演讲者布局</h3><p class="ppt-settings-copy">根据屏幕宽度调整页面与备注的比例</p><div class="ppt-segmented"><button class="ppt-ui-btn" data-layout-choice="balanced" type="button">均衡</button><button class="ppt-ui-btn" data-layout-choice="slides" type="button">页面优先</button><button class="ppt-ui-btn" data-layout-choice="notes" type="button">备注优先</button></div></div><div class="ppt-settings-card"><h3>观众屏</h3><p class="ppt-settings-copy">临时遮挡或冻结观众屏</p><div class="ppt-segmented screen"><button class="ppt-ui-btn" data-screen-choice="normal" type="button">正常</button><button class="ppt-ui-btn" data-screen-choice="black" type="button">黑屏 B</button><button class="ppt-ui-btn" data-screen-choice="white" type="button">白屏 W</button><button class="ppt-ui-btn" data-screen-choice="frozen" type="button">冻结 F</button></div></div><div class="ppt-dialog-section"><button id="ppt-open-shortcuts" class="ppt-ui-btn" type="button">查看快捷键</button></div>`);
document.querySelectorAll('[data-layout-choice]').forEach(button=>{button.classList.toggle('ppt-primary',button.dataset.layoutChoice===layoutMode);button.onclick=()=>{applyLayout(button.dataset.layoutChoice);openSettings();};});
document.querySelectorAll('[data-screen-choice]').forEach(button=>{button.classList.toggle('ppt-primary',button.dataset.screenChoice===screenMode);button.onclick=()=>{setScreenMode(button.dataset.screenChoice);openSettings();};});
const secondsInput=document.getElementById('ppt-auto-seconds-setting'),stepSeconds=delta=>{const current=Math.max(0,Number(secondsInput.value||0));secondsInput.value=String(Math.max(1,current+delta));};
document.getElementById('ppt-auto-minus').onclick=()=>stepSeconds(-1);document.getElementById('ppt-auto-plus').onclick=()=>stepSeconds(1);
document.getElementById('ppt-settings-save').onclick=()=>{autoEnabled=document.getElementById('ppt-auto-enabled-setting').checked;autoGlobalSeconds=Math.max(0,Number(secondsInput.value||0));localStorage.setItem(storageKey('auto-enabled'),autoEnabled?'1':'0');localStorage.setItem(storageKey('auto-seconds'),String(autoGlobalSeconds));armAutoAdvance();closeDialog();};
document.getElementById('ppt-open-shortcuts').onclick=openShortcuts;
}
function openShortcuts(){
const items=[['← / →','上一页 / 下一页'],['Home / End','首页 / 尾页'],['Esc','宫格选页;标注工具激活时先退出工具'],['L','激光笔'],['C','圈选'],['X','清除标记'],['B / W','观众屏黑屏 / 白屏'],['F','冻结 / 恢复观众屏'],['A','开启 / 关闭自动翻页'],['R','开始 / 结束排练'],['?','快捷键说明']];
openDialog('快捷键',`<div class="ppt-shortcuts">${items.map(([key,label])=>`<kbd>${key}</kbd><span>${label}</span>`).join('')}</div>`);
}
function preflightChecks(){
const images=[...document.images],videos=[...document.querySelectorAll('video')],frame=document.getElementById('ppt-current-frame'),rect=frame?.getBoundingClientRect();
return[
{label:'观众屏连接',ok:['synced','frozen'].includes(syncState()),detail:`当前状态:${document.getElementById('ppt-sync')?.textContent||'未连接'}`},
{label:'弹窗权限',ok:!popupBlocked,detail:popupBlocked?'浏览器阻止了观众窗口':'未检测到弹窗拦截'},
{label:'全屏状态',ok:Boolean(document.fullscreenElement),warn:true,detail:document.fullscreenElement?'已进入全屏':'尚未全屏,可在演讲前进入'},
{label:'字体加载',ok:!document.fonts||document.fonts.status==='loaded',detail:document.fonts?`状态:${document.fonts.status}`:'浏览器不提供字体状态'},
{label:'图片资源',ok:images.every(image=>image.complete&&image.naturalWidth>0),detail:`${images.filter(image=>image.complete&&image.naturalWidth>0).length} / ${images.length} 已加载`},
{label:'视频资源',ok:videos.every(video=>video.readyState>=2),detail:videos.length?`${videos.filter(video=>video.readyState>=2).length} / ${videos.length} 可播放`:'没有视频'},
{label:'当前页 16:9',ok:Boolean(rect&&Math.abs(rect.width/rect.height-16/9)<.003),detail:rect?`${Math.round(rect.width)} × ${Math.round(rect.height)}`:'尚未渲染'}
];
}
function openPreflight(){
const checks=preflightChecks(),html=checks.map(item=>`<div class="ppt-dialog-row"><div class="ppt-check ${item.ok?'is-ok':(item.warn?'is-warn':'')}"><span class="ppt-check-dot"></span><span><strong>${item.label}</strong><br><span class="ppt-muted">${item.detail}</span></span></div><strong>${item.ok?'正常':(item.warn?'建议':'异常')}</strong></div>`).join('');
openDialog('演前检查',`<div class="ppt-dialog-section">${html}</div><div class="ppt-dialog-section"><button id="ppt-preflight-refresh" class="ppt-ui-btn" type="button">重新检查</button> <button id="ppt-enter-fullscreen" class="ppt-ui-btn" type="button">进入全屏</button></div><p class="ppt-muted">这里只能检查浏览器和观众页面的软件状态,不能确认 HDMI、转接器或投影仪线缆是否物理连接。</p>`);
document.getElementById('ppt-preflight-refresh').onclick=openPreflight;
document.getElementById('ppt-enter-fullscreen').onclick=async()=>{try{await document.documentElement.requestFullscreen();}catch(e){}openPreflight();};
}
function initTransport(){
if(transportReady)return;transportReady=true;
if('BroadcastChannel'in window){try{channel=new BroadcastChannel(channelName());channel.onmessage=e=>receive(e.data);}catch(e){}}
addEventListener('message',e=>receive(e.data));
addEventListener('storage',e=>{if(e.key===syncKey()&&e.newValue){try{receive(JSON.parse(e.newValue));}catch(err){}}});
}
function emit(payload,{persist=true}={}){
const message={__guizangPptSync:1,session,role:mode,...payload,at:Date.now(),nonce:Math.random().toString(36).slice(2)};
try{channel?.postMessage(message);}catch(e){}
if(persist)try{localStorage.setItem(syncKey(),JSON.stringify(message));}catch(e){}
try{if(mode==='presenter'&&audienceWindow&&!audienceWindow.closed)audienceWindow.postMessage(message,'*');}catch(e){}
try{if(mode==='audience'&&window.opener&&!window.opener.closed)window.opener.postMessage(message,'*');}catch(e){}
}
function receive(message){
if(!message||message.__guizangPptSync!==1||message.session!==session||message.role===mode)return;
if(message.nonce){if(seenNonceSet.has(message.nonce))return;seenNonceSet.add(message.nonce);seenNonces.push(message.nonce);if(seenNonces.length>256)seenNonceSet.delete(seenNonces.shift());}
if(mode==='presenter'){
if(message.type==='ready'){popupBlocked=false;sendGoto(true);}
if(message.type==='ack'){lastAckAt=Date.now();lastAckIndex=Number(message.index);lastAckSeq=Number(message.seq);updateSyncStatus();}
return;
}
if(mode!=='audience')return;
if(message.type==='bye'){showAudienceEnded();return;}
if(message.type==='screen'){applyAudienceScreen(message.mode);return;}
if(message.type==='laser'&&Number(message.index)===idx){audienceLaser=message.point||null;audienceLaserHideAt=Date.now()+650;renderInks();return;}
if(message.type==='circle'&&Number(message.index)===idx&&message.circle){audienceCircles.push(message.circle);renderInks();return;}
if(message.type==='clear'&&Number(message.index)===idx){audienceCircles=[];renderInks();return;}
if(message.type==='goto'||message.type==='ping'){
if(message.type==='goto'){audienceCircles=Array.isArray(message.circles)?message.circles:[];applyAudienceScreen(message.screenMode);go(Number(message.index),{force:true});}
emit({type:'ack',index:idx,seq:Number(message.seq)});
}
}
function receivePreview(message){
if(!message||message.__guizangPptSync!==1||message.session!==session||message.role!=='presenter'||message.type!=='preview-goto')return;
go(Number(message.index),{force:true});
}
function sendGoto(force=false){
if(mode!=='presenter'||audienceFrozen)return;if(force||lastAckIndex!==idx)seq++;
emit({type:'goto',index:idx,seq,screenMode,circles});updateSyncStatus();
}
function syncState(){
if(popupBlocked)return'blocked';if(audienceWindow&&audienceWindow.closed)return'disconnected';if(audienceFrozen)return'frozen';
if(!lastAckAt)return audienceOpenedAt&&Date.now()-audienceOpenedAt<2600?'connecting':'disconnected';
if(Date.now()-lastAckAt>2600)return'disconnected';if(lastAckIndex!==idx||lastAckSeq<seq)return'unsynced';return'synced';
}
function updateSyncStatus(){
const el=document.getElementById('ppt-sync');if(!el)return;const state=syncState();
const labels={blocked:'弹窗被拦截',disconnected:'未连接',connecting:'连接中',unsynced:'未同步',frozen:'已冻结',synced:`已同步 · 第 ${idx+1} 页`};
el.dataset.state=state;el.textContent=`观众屏 · ${labels[state]}`;
}
function reopenAudience(){
initTransport();popupBlocked=false;audienceOpenedAt=Date.now();lastAckAt=0;lastAckIndex=-1;lastAckSeq=-1;
if(audienceFrozen){audienceFrozen=false;screenMode='normal';}
audienceWindow=window.open(frameUrl('audience',idx),`guizang-ppt-audience-${session}`,'popup=yes,width=1440,height=900');
if(!audienceWindow)popupBlocked=true;updateSyncStatus();
}
function enterPresenter(openAudience=true){
if(mode==='presenter')return;if(overviewOn)toggleOverview();lowPowerBeforePresenter=window.__lowPowerMode;mode='presenter';initTransport();mountShell();
document.body.classList.remove('ppt-audience','ppt-preview');document.body.classList.add('ppt-presenter');document.title=`演讲者视图 · ${originalTitle}`;
window.__setLowPowerMode?.(true,{persist:false});updateShell();armAutoAdvance();if(openAudience)reopenAudience();
}
function exitPresenter(){
if(rehearsalActive)finishRehearsal(false);else if(timerRunning){commitRunningTime();timerRunning=false;}togglePresenterOverview(false);emit({type:'bye'});audienceWindow=null;audienceOpenedAt=0;lastAckAt=0;lastAckIndex=-1;lastAckSeq=-1;mode='deck';document.body.classList.remove('ppt-presenter');document.title=originalTitle;window.__setLowPowerMode?.(lowPowerBeforePresenter,{persist:false});
}
function initAudience(){
mode='audience';document.body.classList.add('ppt-audience');document.title=`观众屏 · ${originalTitle}`;
document.body.insertAdjacentHTML('beforeend','<canvas id="ppt-audience-ink" aria-hidden="true"></canvas><div id="ppt-audience-cover" aria-hidden="true"></div>');
const resize=()=>{sizeInk(document.getElementById('ppt-audience-ink'),innerWidth,innerHeight);renderInks();};addEventListener('resize',resize);resize();
initTransport();emit({type:'ready',index:idx});
}
function initPreview(){mode='preview';document.body.classList.add('ppt-preview');window.__setLowPowerMode?.(true,{persist:false});addEventListener('message',e=>{if(e.source===parent)receivePreview(e.data);});}
function onSlideChange(nextIndex){
if(mode!=='presenter')return;rollSlideTimer(nextIndex);circles=[];draftCircle=null;laserPoint=null;annotationPointerDown=false;armAutoAdvance();updateShell();sendGoto();
}
function handleKey(e){
const editable=e.target?.matches?.('input,textarea,[contenteditable="true"]');
if(editable){if(e.key==='Escape')e.target.blur();return true;}
if(mode==='audience'||mode==='preview'){e.preventDefault();return true;}
if(mode==='deck'&&e.key?.toLowerCase()==='p'&&!e.metaKey&&!e.ctrlKey&&!e.altKey){e.preventDefault();enterPresenter(true);return true;}
if(mode!=='presenter')return false;
const key=e.key?.toLowerCase();
const dialog=document.getElementById('ppt-dialog');
if(!dialog?.hidden){if(e.key==='Escape'){e.preventDefault();closeDialog();}return true;}
if(e.key==='?'||key==='/'){e.preventDefault();openShortcuts();return true;}
if(e.key==='Escape'){e.preventDefault();if(annotationTool!=='none')setAnnotationTool('none');else togglePresenterOverview();return true;}
if(presenterOverviewOn)return true;
if(key==='l'){e.preventDefault();setAnnotationTool('laser');return true;}
if(key==='c'){e.preventDefault();setAnnotationTool('circle');return true;}
if(key==='x'){e.preventDefault();clearAnnotations(true);return true;}
if(key==='b'){e.preventDefault();setScreenMode(screenMode==='black'?'normal':'black');return true;}
if(key==='w'){e.preventDefault();setScreenMode(screenMode==='white'?'normal':'white');return true;}
if(key==='f'){e.preventDefault();setScreenMode(audienceFrozen?'normal':'frozen');return true;}
if(key==='a'){e.preventDefault();toggleAutoAdvance();return true;}
if(key==='r'){e.preventDefault();toggleRehearsal();return true;}
if(['ArrowRight','PageDown',' ','ArrowDown'].includes(e.key)){e.preventDefault();go(idx+1,{force:true});return true;}
if(['ArrowLeft','PageUp','ArrowUp'].includes(e.key)){e.preventDefault();go(idx-1,{force:true});return true;}
if(e.key==='Home'){e.preventDefault();go(0,{force:true});return true;}
if(e.key==='End'){e.preventDefault();go(total-1,{force:true});return true;}
return false;
}
function initMode(){
document.getElementById('ppt-low-power-btn')?.addEventListener('click',()=>window.__setLowPowerMode(!window.__lowPowerMode));
document.getElementById('ppt-overview-btn')?.addEventListener('click',toggleOverview);
document.getElementById('ppt-presenter-btn')?.addEventListener('click',()=>enterPresenter(true));
if(requestedMode==='audience')initAudience();else if(requestedMode==='preview')initPreview();else if(requestedMode==='presenter')enterPresenter(false);
}
setInterval(()=>{
renderTimer();renderInks();updateAutoAdvance();
if(mode==='presenter'&&Date.now()-lastPingAt>900){lastPingAt=Date.now();if(audienceWindow?.closed)updateSyncStatus();emit({type:'ping',index:idx,seq});updateSyncStatus();}
},250);
return{initMode,onSlideChange,handleKey,slideLabel,enterPresenter,reopenAudience,toggleOverview:togglePresenterOverview,debugState:()=>({mode,idx,session,syncState:syncState(),timerRunning,rehearsalActive,presenterOverviewOn,autoEnabled,autoGlobalSeconds,autoRemainingMs,screenMode,annotationTool,circles:[...circles],audienceCircles:[...audienceCircles],layoutMode})};
})();
/* =============== /演讲者模式 / 观众屏同步 =============== */
addEventListener('keydown',e=>{
if(window.__pptPresenter?.handleKey(e))return;
if(e.key==='Escape'){e.preventDefault();toggleOverview();return;}
if(e.key && e.key.toLowerCase()==='b' && !e.metaKey && !e.ctrlKey && !e.altKey){
e.preventDefault();
window.__setLowPowerMode(!window.__lowPowerMode);
return;
}
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;
}
if(e.key==='ArrowLeft'||e.key==='PageUp'||e.key==='ArrowUp')go(idx-1);
if(e.key==='Home')go(0);
if(e.key==='End')go(total-1);
});
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});
let tx=0,ty=0;
addEventListener('touchstart',e=>{tx=e.touches[0].clientX;ty=e.touches[0].clientY},{passive:true});
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});
/* ?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);
window.__pptPresenter?.initMode();
</script>
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
<script>lucide.createIcons();</script>
<!-- ============ Motion One 动效引擎(本地优先,CDN 兜底) ============
加载策略:先 ./assets/motion.min.js(本地,离线可用),失败 fallback 到 jsDelivr CDN
加载完成后挂 window.__playSlide / window.__pipeAdvance,
导航脚本会在翻页中段调用 __playSlide(idx),键盘/滚轮/触屏会在翻页前调用 __pipeAdvance()
双双失败时会兜底把所有 [data-anim] 设为可见,不让动效破坏阅读
-->
<script type="module">
let motion;
try {
motion = await import('./assets/motion.min.js');
} catch(e1) {
try {
motion = await import('https://cdn.jsdelivr.net/npm/[email protected]/+esm');
} 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');
}
}
if(motion){
const { animate, stagger } = motion;
document.body.classList.add('motion-ready');
const EASE = [.22, 1, .36, 1];
const slides = [...document.querySelectorAll('.slide')];
let pipeStep = -1;
let lastIdx = -1;
function resetAnims(slide){
slide.querySelectorAll('[data-anim]').forEach(el=>{
el.style.opacity='';
el.style.transform='';
});
}
function revealStatic(slide){
resetAnims(slide);
document.getAnimations?.().forEach(a=>a.cancel());
slide.querySelectorAll('[data-anim]').forEach(el=>{
el.style.opacity='1';
el.style.transform='none';
});
}
function playSlide(i){
const slide = slides[i];
if(!slide) return;
lastIdx = i;
const recipe = slide.dataset.animate || (slide.classList.contains('hero') ? 'hero' : 'cascade');
if(window.__lowPowerMode){
revealStatic(slide);
return;
}
if(recipe === 'pipeline'){
pipeStep = -1;
slide.querySelectorAll('[data-anim]').forEach(el=>{
el.style.opacity='0.15';
el.style.transform='none';
});
return;
}
resetAnims(slide);
const all = [...slide.querySelectorAll('[data-anim]')];
if(!all.length) return;
if(recipe === 'directional'){
const lefts = all.filter(el=>el.dataset.anim==='left');
const divs = all.filter(el=>el.dataset.anim==='divider');
const rights = all.filter(el=>el.dataset.anim==='right');
const others = all.filter(el=>!['left','right','divider'].includes(el.dataset.anim));
if(others.length) animate(others, {opacity:[0,1], y:[12,0]}, {duration:.6, delay:stagger(.1, {start:.15}), easing:EASE});
if(lefts.length) animate(lefts, {opacity:[0,1], x:[-24,0]}, {duration:.8, delay:.35, easing:EASE});
if(divs.length) animate(divs, {opacity:[0,.25]}, {duration:.5, delay:.9});
if(rights.length) animate(rights, {opacity:[0,1], x:[24,0]}, {duration:.8, delay:1.0, easing:EASE});
return;
}
if(recipe === 'quote'){
const lines = all.filter(el=>el.dataset.anim==='line');
const others = all.filter(el=>el.dataset.anim!=='line');
if(others.length) animate(others, {opacity:[0,1], y:[8,0]}, {duration:.6, delay:stagger(.12, {start:.2}), easing:EASE});
if(lines.length) animate(lines, {opacity:[.35,1], y:[10,0]}, {duration:.8, delay:stagger(.55, {start:.5}), easing:EASE});
return;
}
if(recipe === 'hero'){
animate(all, {opacity:[0,1], y:[14,0]}, {duration:.9, delay:stagger(.16, {start:.2}), easing:EASE});
return;
}
// default: cascade
animate(all, {opacity:[0,1], y:[16,0]}, {duration:.75, delay:stagger(.1, {start:.15}), easing:EASE});
}
function pipeAdvance(){
if(window.__lowPowerMode) return false;
const slide = slides[lastIdx];
if(!slide || slide.dataset.animate !== 'pipeline') return false;
const steps = [...slide.querySelectorAll('[data-anim="step"]')];
const arrows = [...slide.querySelectorAll('[data-anim="arrow"]')];
if(pipeStep >= steps.length - 1) return false;
pipeStep++;
animate(steps[pipeStep], {opacity:[0.15,1], y:[8,0]}, {duration:.5, easing:EASE});
if(pipeStep > 0 && arrows[pipeStep-1]){
animate(arrows[pipeStep-1], {opacity:[0.15,.7]}, {duration:.3, delay:.15});
}
return true;
}
window.__playSlide = playSlide;
window.__pipeAdvance = pipeAdvance;
// 首屏:go() 已跑过(同步),没来得及触发动效 → 这里补一下(尊重 ?slide=N)
playSlide(window.__currentSlideIndex || 0);
}
</script>
</body>
</html>
CONTRIBUTING.md›
# Contributing
Thanks for helping improve `guizang-ppt-skill`.
This project is a Skill for AI agents that generate polished HTML slide decks. The most useful contributions are specific, reproducible, and tied to real deck output.
## Before Opening an Issue
Please check whether the problem belongs to one of these buckets:
- Layout fidelity: a page drifts away from the registered template.
- Content overflow: text, images, charts, or footers overlap.
- Image workflow: generated images do not match the target slot ratio or deck style.
- Runtime behavior: navigation, ESC overview, low-power mode, map interaction, or animations fail.
- Documentation: installation, prompt usage, layout selection, or examples are unclear.
Screenshots are much more useful than descriptions alone. If possible, include:
- The prompt or source content used to generate the deck.
- The generated `index.html`.
- A screenshot of the broken slide.
- Browser and OS information.
## Pull Request Guidelines
Keep PRs focused. A small fix with a screenshot is easier to review than a large rewrite.
For Swiss theme changes:
- Do not invent new default body layouts unless the change is explicitly discussed.
- Keep the registered layout system intact.
- Run the Swiss validator:
```bash
node scripts/validate-swiss-deck.mjs path/to/index.html
```
For template changes:
- Verify at least one dense text slide.
- Verify at least one image slide.
- Verify navigation, ESC overview, and low-power mode.
## Good PRs Usually Include
- A short summary of the problem.
- The exact files changed.
- Before / after screenshots when visual behavior changes.
- Validation or manual QA notes.
## Style Notes
This Skill is opinionated by design. It prefers constrained layout systems over unlimited customization, because constraints make AI-generated decks more reliable.
When in doubt, preserve the existing visual rules and improve the workflow around them.
docs/unification-plan.md›
# 多主题统一管理整合方案
> 基于对 template.html / template-swiss.html 两份模板与全部 references 的逐行对比分析(2026-07)。
> 本文档是**方案建议**,尚未整体实施;按阶段推进,每个阶段独立可交付、可回滚。2026-08-07 已先为演讲者 CSS / JS 加入边界标记、字节级同步脚本和 GitHub Actions,但尚未提取共享源文件。
## 现状诊断
两个主题是"复制粘贴后各自演化"的关系,同一份基础设施存在多份副本,并已实际漂移:
### 运行时 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 1b 的防漂移护栏:`scripts/check-presenter-runtime-sync.mjs` 与 `.github/workflows/presenter-runtime-sync.yml`。它不替代长期的共享源提取,但可以防止修改只落到其中一份模板。
如果未来合并 Bento 分支,以 `main` 上的演讲者运行时为上游,采用 `main → bento` 的合并方向;不要用旧 Bento 模板反向覆盖 `main`。
### 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"。
LICENSE›
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
README.en.md›
# Guizang PPT Skill · Web Decks / Images / Covers






[](https://zhenfund.feishu.cn/share/base/form/shrcn1lAANF659o7EpWnxlR1VOh?sessionid=)



An agent skill for Claude Code, Codex, and similar coding-agent environments. It generates **single-file HTML horizontal-swipe decks**, deck visuals, and social cover pages, with a complete rehearsal and presenter mode built in.
It ships with two visual systems:
- **Style A: editorial magazine × electronic ink**. Picture *Monocle* with code stitched in. Best for narrative talks, opinions, salons, and personal voice.
- **Style B: Swiss International Typographic Style**. Grid-first, one high-saturation anchor color, sharp rectangles, hairline rules, and extreme type contrast. Best for facts, products, analysis, and frameworks.
> Distilled by [Guizang](https://x.com/op7418) from offline talks like "One-Person Company: Organizations Folded by AI" and "A New Way of Working." Every pitfall hit during those decks is logged in `checklist.md`.
> Sponsor and supporter details are listed in [SPONSORS.md](./SPONSORS.md).
**Old Theme · Style A Editorial Magazine**

**New Theme · Style B Swiss International**

## 30-second start
```bash
npx skills add https://github.com/op7418/guizang-ppt-skill --skill guizang-ppt-skill
```
Or paste this to an AI agent with shell access:
```text
Install guizang-ppt-skill for me. Clone https://github.com/op7418/guizang-ppt-skill into ~/.claude/skills/guizang-ppt-skill, then verify that SKILL.md, assets/, and references/ exist.
```
If you already installed it, update with:
```text
Update guizang-ppt-skill for me. Go to ~/.claude/skills/guizang-ppt-skill, run git pull, then tell me the latest commit.
```
Then ask your agent:
```text
Create a Swiss-style deck from this article, around 7 slides, with 2-3 generated visuals.
```
Other useful prompts:
```text
Turn this Markdown file into an editorial magazine-style presentation.
Create a 21:9 social cover from the core idea of this deck.
Redesign this product screenshot as a 16:10 slide visual.
Add speaker notes and planned timing to this deck, then help me rehearse it in presenter mode.
```
## Sponsors and Supporters
<a href="./SPONSORS.md">
<img src="https://github.com/user-attachments/assets/81138fad-31b9-49ab-8e38-23b2bc48edc4" alt="360 Security Lobster / Kimi work / Cola Skill Gold Sponsors" width="100%">
</a>
Guizang PPT Skill is supported by **360 Security Lobster**, **Kimi work**, and **Cola Skill** as Gold Sponsors and by [ZhenFund Token Grant](https://zhenfund.feishu.cn/share/base/form/shrcn1lAANF659o7EpWnxlR1VOh?sessionid=). See [SPONSORS.md](./SPONSORS.md) for details.
### Use Guizang PPT Skill elsewhere
Beyond Claude Code / Codex, you can also use Guizang PPT Skill on these platforms:
| Channel | Link |
|---------|------|
| 360 Security Lobster | [claw.360.cn](https://claw.360.cn/claw?vm_id=p19c1be5d7bed40bcb857ec4a9c785b69&target_url=https%3A%2F%2Fp19c1be5d7bed40bcb857ec4a9c785b69.claw.360.cn%2Fagents) |
| Cola Skill | [colaskill.com/guizang-ppt-skill](https://colaskill.com/guizang-ppt-skill/) |
| Kimi work | [kimi.com](https://www.kimi.com/zh-cn/products/kimi-work) (after installing, search "guizang ppt skill" in the Skill store) |
## What you get
- 🖋 **Two visual systems**: editorial storytelling for Style A, factual Swiss structure for Style B
- 📐 **Horizontal swipe navigation**: ← → arrows / scroll wheel / touch swipe / bottom dots / ESC for index
- 🧩 **Style A 10 layouts**: cover, divider, big numbers, image/text, image grid, pipeline, comparison, and more
- 🧱 **Style B 22 locked layouts**: Cover, Statement, KPI Tower, Loop Diagram, Duo Compare, Image Hero, Closing Manifesto, and more
- 🎨 **Curated theme presets**: 5 electronic-ink themes for Style A, 4 Swiss anchor-color themes for Style B
- 🖼 **Optional Codex image flow**: generate documentary photos, infographics, flow diagrams, system maps, and UI scenes with GPT-Image 2.0 / GPT-M 2.0, then insert them at template-safe ratios
- 📰 **Social covers**: generate 21:9 WeChat cover images, 1:1 share cards, 3:4 Xiaohongshu covers, video thumbnails, and related variants
- 🎤 **Presenter mode**: dual-window audience output, 16:9 current/next previews, speaker notes, timing and rehearsal, auto advance, laser pointer, circles, and live recovery controls
- 📴 **Low-power static mode**: press `B` to turn WebGL / canvas animation into static visuals
- 📄 **Single HTML file** — no build, no server, open directly in the browser
## Fits / Doesn't fit
**✅ Fits**: offline talks, industry keynotes, private salons, AI product launches, demo day, presentations with strong personal voice
**❌ Doesn't fit**: data-heavy tables, training decks (density too low), multi-user collaborative editing (static HTML)
## Common use cases
| Task | Recommended flow |
|------|------------------|
| Long article to talk deck | Extract the core argument, then build a 6-10 slide rhythm |
| Framework / product analysis | Use Style B Swiss with locked layouts and 21:9 hero visuals |
| Personal talk / opinion piece | Use Style A editorial magazine for stronger narrative rhythm |
| Deck visuals | In Codex, generate photos, infographics, flow diagrams, system maps, or UI scenes |
| Social covers | Generate 21:9 main covers, 1:1 share cards, 3:4 vertical covers, and video thumbnails from the same idea |
| Screenshot normalization | Redesign raw screenshots into template-safe ratios before inserting them into slides |
| Live talk / rehearsal | Ask the agent to generate notes and timing, then press the bottom-right `P` to enter presenter mode |
## Why HTML decks
- **Agent-native editing**: HTML / CSS is plain text, so agents can read, edit, and validate it directly.
- **Higher visual density than Markdown**: precise layout, positioning, motion, interactivity, and cover formats.
- **Lightweight delivery**: one HTML file can be opened, presented, sent, screenshotted, or recorded, with presenter tools included.
- **Better quality gates**: the Swiss validator can catch layout drift, unsafe image placement, centered body titles, and SVG text traps.
- **One visual system across outputs**: decks, generated visuals, covers, and screenshot redesigns can share the same style rules.
## Platform support
| Platform | Status | Notes |
|----------|--------|-------|
| Claude Code | Supported | Native Skill workflow for creating and iterating HTML decks |
| Codex | Supported | Good for deck generation, image generation, and browser-based visual QA |
| Cursor / other local agents | Works | Requires filesystem access and shell execution |
| WorkBuddy | In adaptation | Marketplace-ready version is being prepared separately |
| Plain chatbot | Not recommended | Without filesystem and browser preview, full deck generation is hard to stabilize |
## Install
### Option 1: One-line install (recommended)
```bash
npx skills add https://github.com/op7418/guizang-ppt-skill --skill guizang-ppt-skill
```
### Option 2: Paste this to an AI
> Install the `guizang-ppt-skill` Claude Code skill for me. Steps:
>
> 1. Make sure `~/.claude/skills/` exists (create if not)
> 2. Run `git clone https://github.com/op7418/guizang-ppt-skill.git ~/.claude/skills/guizang-ppt-skill`
> 3. Verify: `ls ~/.claude/skills/guizang-ppt-skill/` should show `SKILL.md`, `assets/`, `references/`
> 4. Tell me when done. Later, saying things like "make me a magazine-style deck" will trigger this skill.
Paste the block above into Claude Code / Cursor / any AI agent with shell access and it handles the install.
### Option 3: Manual CLI
```bash
git clone https://github.com/op7418/guizang-ppt-skill.git ~/.claude/skills/guizang-ppt-skill
```
### How to trigger it
Once installed, Claude Code auto-detects the skill. Trigger phrases:
- "Make me a magazine-style deck"
- "Make me a Swiss-style deck"
- "Generate a horizontal swipe deck"
- "Editorial magazine style presentation"
- "Electronic ink slides for my talk"
- "Create a 21:9 WeChat cover from this article"
- "Create a 1:1 share card from this deck"
- "Add speaker notes to this deck and help me rehearse it in presenter mode"
## Workflow
The skill is a structured workflow; the agent walks you through each step:
1. **Choose style** — Style A editorial magazine, or Style B Swiss International
2. **Clarify intent** — 7-question checklist: style, audience, duration, source material, images/screenshots, theme, hard constraints
3. **Copy template** — Style A uses `assets/template.html`; Style B uses `assets/template-swiss.html`
4. **Fill content** — create a rhythm plan, then choose and adapt the matching layout skeletons
5. **Optional image generation** — in Codex, ask whether to use GPT-Image 2.0 / GPT-M 2.0 images, then insert them at page-appropriate ratios
6. **Generate speaker notes** — derive each slide's purpose, talking points, transition, and planned duration from the outline; do not invent missing live details
7. **Self-check** — match against `references/checklist.md`; P0 issues must all pass; run the Swiss and presenter validators when applicable
8. **Preview** — open the HTML in a browser
9. **Rehearse / present** — press the bottom-right `P`, verify audience sync, and record actual timing
10. **Iterate** — adjust content, font size, height, and spacing from rehearsal results
Full spec in [`SKILL.md`](./SKILL.md).
## Presenter mode
Both templates ship with the same presenter runtime. Open a deck and press the bottom-right `P` to enter presenter view; the browser opens a clean audience window at the same time. Core features run entirely inside the local HTML and browser—no live-caption service, cloud relay, phone remote, or AI coaching service is required.
**Presenter view example**

### What the speaker sees
- **Current and next slides**: stacked vertically and always kept at `16:9`; small windows scale the whole slide without cropping or reflowing its text
- **Grid navigation**: replace the preview area with an inline overview, choose a slide, then return immediately to the two previews while the audience follows
- **Structured notes**: title, purpose, talking points, and transition are required; interaction, delivery, advance cue, fallback, and pronunciation appear only when supplied by the outline
- **Progress and health**: current / total slide count, completion percentage, plus audience states for connecting, synced, unsynced, frozen, disconnected, or popup blocked
### Timing, rehearsal, and auto advance
- The footer separates elapsed, current-slide, and remaining / overtime values, with explicit “Start timer / Resume timer / Reset timer” controls
- Rehearsal mode records actual time per slide and a local session summary; it does not grade the speaker with AI
- Auto advance is off by default and only runs when the outline provides per-slide seconds or the user enables a global interval
- Auto advance pauses while the grid, settings, or annotation tool is open, when the page is hidden, or when the audience is paused or out of sync
### Live tools and recovery
- Laser pointer, circle annotation, and clear actions sync to the audience window
- Black screen, white screen, or freeze the audience; unfreezing catches it up to the presenter's current slide
- Closing the audience window or losing its heartbeat produces a visible “Disconnected” state; “Reopen audience” is always available
- Exiting presenter mode closes the audience window when allowed, or leaves a clear “Presentation ended” fallback
- Preflight checks cover popups, fullscreen, fonts, images / video, and `16:9` previews, while reminding the speaker to verify HDMI adapters and projectors manually
Common shortcuts: `← / →` navigate, `Home / End` jump to first or last, `G` opens the grid, `L` laser, `C` circle, `B / W` black or white screen, `F` freezes the audience, and `?` opens the full shortcut list.
Ask an agent to prepare presenter mode with:
```text
Use this outline to add a purpose, talking points, transition, and planned duration to every slide. Do not invent interaction or live details that were not provided, then run the presenter-mode validator.
```
See [`references/presenter-mode.md`](./references/presenter-mode.md) for the full notes contract and runtime behavior. Validate with:
```bash
node scripts/validate-presenter-mode.mjs path/to/index.html
node scripts/validate-presenter-mode.mjs path/to/index.html --target-minutes 30
```
## Style B Swiss
The Swiss theme is a strict layout system, not just a CSS skin.
- **22 named layouts**: body slides must use `S01` to `S22`; do not invent new structures
- **4 anchor colors**: International Klein Blue, lemon yellow, lemon green, safety orange
- **Grid lock**: 16-column grid, sharp rectangles, 1px hairlines, no shadows, no gradients, no rounded cards
- **Chinese title scaling**: all-Chinese headlines should be one step smaller to preserve space for content and images
- **Image/text bottom alignment**: text and image blocks should align at the bottom in left/right image layouts, while staying clear of pagination controls
- **Image slots**: images must sit in template-defined `data-image-slot` regions, often generated at 21:9 or 16:10
- **Hard validation**: the validator catches centered body titles, experimental layouts, visible SVG text, and images placed outside slots
Swiss validation:
```bash
node scripts/validate-swiss-deck.mjs path/to/index.html
```
## Codex Image Flow
In Codex, after the first deck draft is ready, the agent can ask whether the user wants generated visuals. Once confirmed, choose an image type or style. Common types include:
- Documentary photos: Fuji / Leica-like real-world scenes that add human texture
- Infographics / flow diagrams / comparison charts / system maps: for concepts that cannot be explained well with photos
- Screenshot framing / screenshot redesigns: preserve raw screenshots with bundled background assets and a CleanShot X-style canvas first; use UI scene generation only when the screenshot needs reconstruction
- Data posters / charts: turn key numbers into insert-ready visual assets
- Multi-image compositions: useful for ultra-wide slots where three unrelated 16:9 images would break the grid
Generated images must follow four core rules:
- Treat the image as an embedded asset, not a standalone slide: no footer, page bottom, title, page number, corner mark, signature, or decorative border
- Match the deck language: Chinese decks use Chinese labels inside infographics, English decks use English labels
- Match the slot ratio before generation: 21:9 for many Swiss hero slots, 16:9 / 16:10 for common main visuals, 16:10 for UI scenes, fixed equal heights for image grids
- When a raw screenshot must stay faithful, read `references/screenshot-framing.md` first and use bundled `assets/screenshot-backgrounds/` backgrounds plus programmatic scaling, padding, and alignment instead of redrawing the screenshot by default
Image prompts live in [`references/image-prompts.md`](./references/image-prompts.md). Screenshot framing lives in [`references/screenshot-framing.md`](./references/screenshot-framing.md).
## Cover Generation
The skill can also turn an article or deck idea into platform covers:
- **WeChat main cover**: 21:9, headline-first, with one visual anchor
- **WeChat share card**: 1:1, visually paired with the 21:9 cover
- **Xiaohongshu cover / carousel**: 3:4, large title, consistent type scale across a batch
- **Video thumbnail**: 16:9, title + subtitle + one focal visual
The same rule applies: use a few strong keywords, keep the title as the visual center, and do not fill the canvas with body copy.
## Example prompts
Copy any of these prompts into your agent, then attach your article, Markdown file, or image assets:
```text
Create an 8-slide Swiss-style deck from this article, with 3 generated visuals matched to the template image slots.
```
```text
Turn this product analysis document into an editorial magazine-style deck with a strong narrative rhythm.
```
```text
From this deck's core idea, create two covers: a 21:9 main cover and a visually paired 1:1 share card.
```
```text
Redesign these product screenshots into consistent 16:10 slide visuals. Preserve key UI information; do not add slide titles or footers inside the images.
```
## Directory
```
guizang-ppt-skill/
├── SKILL.md ← main skill file: workflow, principles, common mistakes
├── README.md ← Chinese README
├── README.en.md ← this file
├── assets/
│ ├── template.html ← Style A editorial magazine template
│ ├── template-swiss.html ← Style B Swiss template
│ └── screenshot-backgrounds/ ← bundled WebP screenshot backgrounds: 5 style-a / 4 style-b
├── scripts/
│ ├── validate-swiss-deck.mjs ← Swiss layout validator
│ ├── validate-presenter-mode.mjs ← speaker notes, timing, and runtime validator
│ └── check-presenter-runtime-sync.mjs ← cross-template presenter runtime drift check
└── references/
├── components.md ← component catalog (type, color, grid, icons, callout, stat, pipeline)
├── layouts.md ← 10 layout skeletons (paste-ready)
├── layouts-swiss.md ← 22 locked Swiss layouts
├── swiss-layout-lock.md ← Swiss fidelity and layout hard rules
├── themes.md ← 5 theme presets (pick, don't customize)
├── themes-swiss.md ← 4 Swiss anchor-color themes
├── image-prompts.md ← GPT-Image 2.0 / GPT-M 2.0 image types, ratios, and base prompts
├── screenshot-framing.md ← CleanShot X-style screenshot framing semantics
├── presenter-mode.md ← notes contract, rehearsal, audience output, and live tools
└── checklist.md ← quality checklist (P0 / P1 / P2 / P3 tiers)
```
## Theme presets
Pick from `references/themes.md`. **Custom hex values are not allowed** — protecting the aesthetic matters more than freedom of choice.
### Style A Editorial Themes
| Preview | Theme | Core colors and best for |
|---------|-------|--------------------------|
| <img src="https://github.com/user-attachments/assets/df21dbcb-5fe4-4852-a91a-a9cf00aceeb4" width="260" alt="Ink Classic theme preview"> | 🖋 **Ink Classic** | `#0a0a0b` / `#f1efea`. General default, commercial launches, when in doubt. |
| <img src="https://github.com/user-attachments/assets/99ce0fd2-72a6-4368-a75a-a8e21657a537" width="260" alt="Indigo Porcelain theme preview"> | 🌊 **Indigo Porcelain** | `#0a1f3d` / `#f1f3f5`. Tech, research, AI, technical keynotes. |
| <img src="https://github.com/user-attachments/assets/bcc1cc4c-5e8e-4467-ae8d-f5801ae73657" width="260" alt="Forest Ink theme preview"> | 🌿 **Forest Ink** | `#1a2e1f` / `#f5f1e8`. Nature, sustainability, culture, non-fiction. |
| <img src="https://github.com/user-attachments/assets/dfea080e-e916-417e-93cd-0a3628de84ca" width="260" alt="Kraft Paper theme preview"> | 🍂 **Kraft Paper** | `#2a1e13` / `#eedfc7`. Nostalgic, humanist, literary, indie zines. |
| <img src="https://github.com/user-attachments/assets/f3705592-9a72-4dbc-9818-df3aea61bc75" width="260" alt="Dune theme preview"> | 🌙 **Dune** | `#1f1a14` / `#f0e6d2`. Art, design, creative, fashion, gallery-like decks. |
Switching themes only requires replacing the 6 variables at the top of `template.html`'s `:root{}` block — all other CSS flows through `var(--...)`.
### Style B Swiss Themes
Pick from `references/themes-swiss.md`. **Custom hex values are not allowed** here either.
| Preview | Theme | Anchor color and best for |
|---------|-------|---------------------------|
| <img src="https://github.com/user-attachments/assets/c02d02f7-ce6f-4e16-b8a6-778c96851f94" width="260" alt="International Klein Blue Swiss theme preview"> | 🔵 **International Klein Blue** | `#002FA7`. Default, commercial launches, AI products, frameworks. |
| <img src="https://github.com/user-attachments/assets/c310a8c4-5d28-450e-b49a-6ac5b6ba4785" width="260" alt="Lemon Yellow Swiss theme preview"> | 🟡 **Lemon Yellow** | `#FFD500`. Youth, sports, retail, consumer goods, Y2K retro. |
| <img src="https://github.com/user-attachments/assets/65f7b3f9-3358-419e-b513-f7f2cc24ec76" width="260" alt="Lemon Green Swiss theme preview"> | 🟢 **Lemon Green** | `#C5E803`. Ecology, sustainability, health, Gen Z brands. |
| <img src="https://github.com/user-attachments/assets/9c3319c9-a134-4657-9a56-211c23411f7f" width="260" alt="Safety Orange Swiss theme preview"> | 🟠 **Safety Orange** | `#FF6B35`. Alerts, news, industrial topics, sports, energetic themes. |
If the user asks for a Swiss-style deck without specifying color, default to International Klein Blue.
## Core design principles
1. **Restraint over flash** — WebGL backgrounds only bleed through on hero pages
2. **Structure over decoration** — information hierarchy via type size + typeface + grid whitespace, not shadows or floating cards
3. **Images are first-class citizens** — align them with the body content area, keep ratios stable, crop only from the bottom, and preserve top/sides
4. **Generated visuals are assets** — keep only the core photo / chart / UI; do not render slide titles, footers, or corner marks inside the image
5. **Rhythm lives on hero pages** — hero / non-hero alternation keeps the eye from fatiguing
6. **Dynamic effects must be optional** — `B` toggles static mode so animation never becomes a reading burden
7. **Terms stay consistent** — Skills is Skills; no mix-and-match translations
8. **Swiss layouts stay locked** — Style B should restore and reuse the original 22-page layout system instead of inventing unrelated pages
## Visual references
- [*Monocle*](https://monocle.com) magazine layouts
- YC Garry Tan — "Thin Harness, Fat Skills"
- Massimo Vignelli / Helvetica Forever / Swiss International Typographic Style
- Guizang's offline talk deck series
## Roadmap
- Add more real-world examples and openable HTML deck demos
- Expand cover formats for more publishing platforms
- Add more Swiss layout validation rules
- Improve screenshot redesign and infographic generation workflows
- Prepare marketplace-specific variants such as WorkBuddy
- Add more curated theme packs while keeping custom colors restricted
## FAQ
**Can it export to PPTX?**
The main output is HTML. You can present it in a browser, screenshot it, or record it. PPTX conversion can be done as a separate workflow, but it is not the core path today.
**Why are custom colors not allowed?**
The skill is designed for stable visual output. Arbitrary colors often break the system, so decks must use curated presets.
**Can I add my own layout?**
Yes. Style A layouts can be extended in `references/layouts.md`. Style B is stricter: update `template-swiss.html`, `layouts-swiss.md`, `swiss-layout-lock.md`, and the validator together.
**Is Codex image generation required?**
No. Decks work without generated images. The image flow is only used when you need photos, infographics, UI scenes, or covers.
**Does presenter mode require internet access or another service?**
No. Dual-window sync, notes, timing, rehearsal, auto advance, and annotations run locally in the browser. It does not provide live captions, phone control, or AI rehearsal scoring.
**Why does closing the audience window show “Disconnected”?**
The presenter uses acknowledgements and heartbeats to verify the software link. Closing the window or losing its heartbeat produces “Disconnected”; use “Reopen audience” to recover. The browser cannot verify a physical HDMI or projector cable, so the speaker still needs a visual check.
**How do I update the skill?**
Run the install command again, or run `git pull` inside your local skill directory.
## Contributing
Bugs, layout issues, new layout requests — Issues and PRs welcome. Prioritize:
- Add new classes to `template.html` first; don't let `layouts.md` reference undefined classes
- When changing `template-swiss.html`, update `layouts-swiss.md` and `swiss-layout-lock.md` together
- When adding Swiss rules, update `scripts/validate-swiss-deck.mjs`
- Presenter runtime changes must land in both templates and pass `scripts/check-presenter-runtime-sync.mjs`; CI rejects CSS / JavaScript drift
- Presenter-note or live-behavior changes must also update `references/presenter-mode.md`, `references/checklist.md`, and `scripts/validate-presenter-mode.mjs`
- Log pitfalls into `checklist.md` at the matching P0 / P1 / P2 / P3 tier
- New theme colors go into `themes.md` with a recommended use case
## License
AGPL-3.0 © 2026 [op7418](https://github.com/op7418)
README.md›
# Guizang PPT Skill · 网页 PPT / 配图 / 封面






[](https://zhenfund.feishu.cn/share/base/form/shrcn1lAANF659o7EpWnxlR1VOh?sessionid=)



> 🌏 **English version: [README.en.md](./README.en.md)**
一个适配 Claude Code / Codex 等 Agent 环境的网页 PPT 技能,用于生成**单文件 HTML 横向翻页 PPT**、PPT 配图和多平台封面,并内置完整的排练与演讲者模式。
内置两套视觉系统:
- **Style A: 电子杂志 × 电子墨水**。像 *Monocle* 贴上了代码,适合叙事、观点、分享、个人风格表达。
- **Style B: 瑞士国际主义**。网格至上、单一高饱和锚点色、直角、发丝线、极致字号对比,适合事实、产品、分析、方法论表达。
> 由 [歸藏](https://x.com/op7418) 在"一人公司:被 AI 折叠的组织"、"一种新的工作方式"等线下分享中沉淀而成,踩过的每一个坑都写进了 `checklist.md`。
> 赞助与支持信息见 [SPONSORS.md](./SPONSORS.md)。
**旧主题 · Style A 电子杂志风**

**新主题 · Style B 瑞士国际主义**

## 30 秒开始
```bash
npx skills add https://github.com/op7418/guizang-ppt-skill --skill guizang-ppt-skill
```
也可以直接把这段话发给有 shell 权限的 AI Agent:
```text
帮我安装 guizang-ppt-skill。请把 https://github.com/op7418/guizang-ppt-skill 克隆到 ~/.claude/skills/guizang-ppt-skill,安装完成后检查 SKILL.md、assets/、references/ 是否存在。
```
已经安装过的话,用这段话更新:
```text
帮我更新 guizang-ppt-skill。请进入 ~/.claude/skills/guizang-ppt-skill 执行 git pull,然后告诉我当前最新 commit。
```
安装后直接对 Agent 说:
```text
帮我基于这篇文章做一份瑞士风 PPT,控制在 7 页左右,需要 2-3 张配图。
```
也可以试这些请求:
```text
帮我把这份 Markdown 做成杂志风演讲 PPT。
基于这份 PPT 的核心观点,生成一张公众号 21:9 头图。
把这张产品截图重新设计成适合 PPT 的 16:10 配图。
给这份 PPT 补齐演讲备注和每页计划时长,然后用演讲者模式帮我排练。
```
## 赞助与支持
<a href="./SPONSORS.md">
<img src="https://github.com/user-attachments/assets/81138fad-31b9-49ab-8e38-23b2bc48edc4" alt="360 安全龙虾 / Kimi work / Cola Skill 金牌赞助" width="100%">
</a>
Guizang PPT Skill 的持续迭代获得 **360 安全龙虾**、**Kimi work**、**Cola Skill** 金牌赞助和 [真格 Token Grant](https://zhenfund.feishu.cn/share/base/form/shrcn1lAANF659o7EpWnxlR1VOh?sessionid=) 支持。更多信息见 [SPONSORS.md](./SPONSORS.md)。
### 在其他地方使用归藏 PPT Skill
除了 Claude Code / Codex,也可以在这些平台使用归藏 PPT Skill:
| 渠道 | 链接 |
|------|------|
| 360 安全龙虾 | [claw.360.cn](https://claw.360.cn/claw?vm_id=p19c1be5d7bed40bcb857ec4a9c785b69&target_url=https%3A%2F%2Fp19c1be5d7bed40bcb857ec4a9c785b69.claw.360.cn%2Fagents) |
| Cola Skill | [colaskill.com/guizang-ppt-skill](https://colaskill.com/guizang-ppt-skill/) |
| Kimi work | [kimi.com](https://www.kimi.com/zh-cn/products/kimi-work)(安装后在 Skill 商店搜索 guizang ppt skill) |
## 效果
- 🖋 **双视觉系统**:电子杂志风负责叙事,瑞士风负责事实表达
- 📐 **横向左右翻页**:键盘 ← → / 滚轮 / 触屏滑动 / 底部圆点 / ESC 索引
- 🧩 **Style A 10 种布局**:封面、章节、数据大字报、图文、图片网格、Pipeline、对比等
- 🧱 **Style B 22 种锁定版式**:Cover、Statement、KPI Tower、Loop Diagram、Duo Compare、Image Hero、Closing Manifesto 等
- 🎨 **主题色预设**:Style A 5 套电子墨水主题,Style B 4 套瑞士高饱和锚点色
- 🖼 **Codex 可选配图流程**:可用 GPT-Image 2.0 / GPT-M 2.0 生成纪实照片、信息图、流程图、系统关系图、UI 情景图,并按模板比例插入
- 📰 **多平台封面**:可用同一套视觉规则生成公众号 21:9、公众号分享卡 1:1、小红书 3:4、视频号横版等封面
- 🎤 **演讲者模式**:双窗口观众屏、当前页/下一页 16:9 预览、演讲备注、计时排练、自动翻页、激光笔、圈选和现场故障恢复
- 📴 **低性能静态模式**:按 `B` 可关闭 WebGL / canvas 动画,让动态内容退回静态背景
- 📄 **单文件 HTML**:不需要构建、不需要服务器,浏览器直接打开
## 适合 / 不适合
**✅ 合适**:线下分享 / 行业内部讲话 / 私享会 / AI 产品发布 / demo day / 带强烈个人风格的演讲
**❌ 不合适**:大段表格数据 / 培训课件(信息密度不够)/ 需要多人协作编辑(静态 HTML)
## 常见使用场景
| 任务 | 推荐方式 |
|------|---------|
| 长文章变演讲 PPT | 先抽核心观点,再按 6-10 页节奏生成 deck |
| 方法论 / 产品分析 | 用 Style B 瑞士风,优先使用锁定版式和 21:9 主图 |
| 个人分享 / 观点表达 | 用 Style A 电子杂志风,保留更强叙事感 |
| PPT 配图 | 在 Codex 中用 GPT-Image 2.0 / GPT-M 2.0 生成照片、信息图、流程图、UI 情景图 |
| 多平台封面 | 从同一份内容生成公众号 21:9、1:1 分享卡、小红书 3:4、视频号横版封面 |
| 截图统一风格 | 把原始截图重新生成到模板需要的比例,再插入 PPT |
| 现场演讲 / 排练 | 让 Agent 从大纲生成备注和时长,按右下角 `P` 进入演讲者模式 |
## 为什么是 HTML PPT
- **更适合 Agent 生成和修改**:HTML / CSS 是文本,Agent 能直接读、改、验证。
- **表现力比 Markdown 更高**:可以做精细排版、空间定位、动画、交互和响应式封面。
- **交付更轻**:单文件 HTML 可以直接打开、演示、发送、截图,演讲者工具也随文件一起交付。
- **更容易做质量控制**:瑞士风可以用脚本校验版式、图片槽位、标题对齐、危险 SVG,并在可用时用 Playwright 后验测量超出、底部空白、nav 安全线和标题间距。
- **更适合视觉内容链路**:同一套主题能覆盖 PPT、配图、封面和截图再设计。
## 平台支持
| 平台 | 状态 | 说明 |
|------|------|------|
| Claude Code | 支持 | 原生 Skill 工作流,适合生成和迭代 HTML deck |
| Codex | 支持 | 适合生成 PPT、调用图片生成能力、做浏览器视觉检查 |
| Cursor / 其他本地 Agent | 可用 | 需要能读写文件并执行 shell 命令 |
| WorkBuddy | 适配中 | 单独整理上架版本,去掉平台不需要的渠道差异 |
| 普通 Chatbot | 不推荐 | 没有文件系统和浏览器预览时,很难稳定生成完整 deck |
## 安装
### 方式一:一行命令安装(推荐)
```bash
npx skills add https://github.com/op7418/guizang-ppt-skill --skill guizang-ppt-skill
```
### 方式二:把下面这段话直接发给 AI
> 帮我安装 `guizang-ppt-skill` 这个 Claude Code skill。请按下面步骤做:
>
> 1. 确保 `~/.claude/skills/` 目录存在(不存在就创建)
> 2. 执行 `git clone https://github.com/op7418/guizang-ppt-skill.git ~/.claude/skills/guizang-ppt-skill`
> 3. 验证:`ls ~/.claude/skills/guizang-ppt-skill/` 应该看到 `SKILL.md`、`assets/`、`references/` 三项
> 4. 告诉我安装好了,之后我说"做一份杂志风 PPT"之类的话就会触发这个 skill
把这段话复制粘贴给 Claude Code / Cursor / 任何有 shell 权限的 AI Agent,它会自动完成安装。
### 方式三:手动命令行
```bash
git clone https://github.com/op7418/guizang-ppt-skill.git ~/.claude/skills/guizang-ppt-skill
```
### 触发方式
装好后,Claude Code 会在对话里自动发现并调用这个 skill。触发关键词:
- "帮我做一份杂志风 PPT"
- "帮我做一份瑞士风 PPT"
- "生成一个 horizontal swipe deck"
- "editorial magazine style presentation"
- "electronic ink 风格演讲 slides"
- "基于这篇文章做一张公众号 21:9 封面"
- "基于这份 PPT 生成一张 1:1 分享卡"
- "给这份 PPT 补齐演讲备注,并用演讲者模式帮我排练"
## 使用流程
Skill 本身是结构化工作流,Agent 会逐步引导:
1. **选择风格** — Style A 电子杂志风,或 Style B 瑞士国际主义
2. **需求澄清** — 7 问清单:风格、受众、时长、素材、图片/截图需求、主题色、硬约束
3. **拷贝模板** — Style A 用 `assets/template.html`,Style B 用 `assets/template-swiss.html`
4. **填充内容** — 先做主题节奏表,再从对应 layout 骨架里挑、粘、改文案
5. **可选配图** — 在 Codex 中询问是否用 GPT-Image 2.0 / GPT-M 2.0 生成配图,再按页面比例插入
6. **生成演讲备注** — 从大纲提取每页目的、讲述要点、转场和计划时长;用户没提供的信息不猜测
7. **自检** — 对照 `references/checklist.md`,P0 级问题必须全过;瑞士风和演讲模式分别运行对应校验器
8. **预览** — 浏览器直接打开
9. **排练 / 演讲** — 按右下角 `P` 进入演讲者模式,检查观众屏同步并记录实际时长
10. **迭代** — 根据排练结果调整内容、字号、高度和间距
详细说明见 [`SKILL.md`](./SKILL.md)。
## 演讲者模式
两套模板都内置同一套演讲者运行时。打开 deck 后,点击右下角 `P` 即可进入演讲者视图;浏览器会同时打开一个干净的观众屏。所有核心能力都在本地 HTML 和浏览器里完成,不依赖实时字幕、云端中继、手机遥控或 AI 教练服务。
**演讲者视图示例**

### 演讲时能看到什么
- **当前页与下一页**:上下排列并始终保持 `16:9`,小屏时整页等比缩放,不裁切、不挤压文字
- **宫格选页**:在预览区原位切换总览,点选页面后立即回到双预览,观众屏同步跟随
- **结构化备注**:标题、本页目的、讲述要点和转场必填;互动、语气、翻页时机、备用方案和读音等信息只在大纲提供时显示
- **进度与状态**:显示当前页 / 总页数、完成百分比,以及观众屏的连接中、已同步、未同步、已冻结、未连接或弹窗被拦截状态
### 时间、排练与自动翻页
- 底栏分别显示已进行、本页和剩余 / 超时时间,按钮明确区分“开始计时 / 继续计时 / 重置计时”
- 排练模式记录每页实际时长和整场汇总,数据保存在本地浏览器,不会做 AI 评分
- 自动翻页默认关闭;只有大纲明确给出页面停留秒数,或用户在设置中开启全局间隔时才启用
- 打开宫格、设置、圈选工具,页面隐藏,或观众屏暂停 / 失去同步时,自动翻页会暂停
### 现场工具与故障恢复
- 激光笔、圈选和清除会同步到观众屏
- 一键黑屏、白屏,或冻结观众屏;恢复后自动追平演讲者当前页
- 观众窗口关闭或心跳超时后明确显示“未连接”,可随时点击“重新打开观众屏”
- 退出演讲时自动关闭观众窗口;浏览器不允许自关时,观众端显示“演示已结束”
- 演前检查覆盖弹窗、全屏、字体、图片 / 视频和 `16:9` 预览,并提醒人工确认 HDMI、转接器和投影仪
常用快捷键:`← / →` 翻页,`Home / End` 跳首页或尾页,`G` 宫格,`L` 激光笔,`C` 圈选,`B / W` 黑屏或白屏,`F` 冻结观众屏,`?` 查看完整快捷键。
让 Agent 生成或补齐演讲模式时,可以直接说:
```text
根据这份大纲给每一页补齐演讲目的、讲述要点、转场和计划时长。没有提供的互动或现场信息不要猜,然后运行演讲模式校验器。
```
演讲备注契约和完整行为说明见 [`references/presenter-mode.md`](./references/presenter-mode.md)。校验命令:
```bash
node scripts/validate-presenter-mode.mjs path/to/index.html
node scripts/validate-presenter-mode.mjs path/to/index.html --target-minutes 30
```
## Style B 瑞士风
瑞士风是这次新增的结构化主题。它不是"换一套 CSS",而是一套更严格的版式系统。
- **22 个具名版式**:正文页只能从 `S01` 到 `S22` 中选择,不能临时发明页面结构
- **4 套锚点色**:克莱因蓝 IKB、柠檬黄、柠檬绿、安全橙
- **网格锁定**:16 列 grid、直角色块、1px 发丝线、无阴影、无渐变、无圆角
- **中文字号收敛**:全中文大标题需要降一档,避免占掉正文和图片空间
- **图文底对齐**:左文右图 / 左图右文场景优先让正文块与图片底部对齐,同时避开页脚翻页组件
- **图片槽位绑定**:图片必须进入模板预留的 `data-image-slot`,常见主图按 21:9 或 16:10 生成
- **强校验**:用脚本拦住居中标题、实验版式、SVG 内写字、图片脱离槽位等问题;可用 Playwright 时还会量化真实渲染后的 overflow、bottom whitespace、title gap
瑞士风校验命令:
```bash
node scripts/validate-swiss-deck.mjs path/to/index.html
```
## Codex 配图能力
在 Codex 环境中,完成 deck 初稿后可以主动询问用户是否需要生成配图。用户确认后,再询问图片类型或风格,常用类型包括:
- 人文纪实照片:富士 / 徕卡感的真实场景,增加人文表现力
- 信息图 / 流程图 / 对比图 / 系统关系图:用于解释无法用实拍照片说明的概念
- 截图美化 / 截图再设计:原始截图优先用内置背景资产做 CleanShot X 式背景画布适配;需要重构时再生成 UI 情景图
- 数据大字报 / 数据图表:把关键数字做成可直接插入 PPT 的视觉素材
- 多图拼贴:用于极宽图片槽位,避免把三张 16:9 图片硬塞进三列
生成图片时要遵守四个关键规则:
- 图片是 PPT 中的嵌入素材,不要自带页脚、页底、标题、角标、页码或装饰边框
- 图片语言跟随 deck 语言:中文 deck 的信息图用中文标签,英文 deck 用英文标签
- 图片比例必须先匹配落位:瑞士风主图常用 21:9,通用主图常用 16:9 / 16:10,截图再设计常用 16:10,多图网格统一高度
- 用户截图需要保真时,先读 `references/screenshot-framing.md`,用 `assets/screenshot-backgrounds/` 内置背景 + 程序化缩放/留边/对齐处理,不要默认重画截图内容
配图提示词见 [`references/image-prompts.md`](./references/image-prompts.md),截图适配见 [`references/screenshot-framing.md`](./references/screenshot-framing.md)。
## 封面生成
这个 Skill 也可以基于文章或 PPT 核心观点生成平台封面。典型规格:
- **公众号头图**:21:9,主标题优先,右侧或边缘保留视觉锚点
- **公众号分享卡**:1:1,与头图共用主题色、关键词和视觉元素
- **小红书封面 / 轮播**:3:4,大标题优先,多张时统一字号和视觉节奏
- **视频号 / 横版封面**:16:9,适合标题 + 副标题 + 单一视觉焦点
封面原则和 PPT 一样:只用少量关键词,视觉重心落在大标题上,不要把正文堆满。
## 示例请求
复制下面任意一条给 Agent,再附上你的文章、Markdown 或素材文件:
```text
帮我基于这篇文章生成一份 8 页左右的瑞士风 PPT,需要 3 张配图,图片比例跟模板槽位匹配。
```
```text
帮我把这个产品分析文档做成电子杂志风 PPT,重点突出观点和叙事节奏。
```
```text
基于这份 PPT 的主题,做两张封面:公众号 21:9 头图和 1:1 分享卡,视觉保持一致。
```
```text
把这些产品截图重新设计成统一的 16:10 PPT 配图,保留关键信息,不要画页脚和标题。
```
## 目录结构
```
guizang-ppt-skill/
├── SKILL.md ← Skill 主文件:工作流、原则、常见错误
├── README.md ← 本文件
├── assets/
│ ├── template.html ← Style A 电子杂志风模板
│ ├── template-swiss.html ← Style B 瑞士国际主义模板
│ └── screenshot-backgrounds/ ← 截图美化内置背景(WebP):style-a 5 套 / style-b 4 套
├── scripts/
│ ├── validate-swiss-deck.mjs ← 瑞士风版式校验器
│ ├── validate-presenter-mode.mjs ← 演讲备注、时长和运行时校验器
│ └── check-presenter-runtime-sync.mjs ← 两套模板演讲者运行时防漂移检查
└── references/
├── components.md ← 组件手册(字体、色、网格、图标、callout、stat、pipeline)
├── layouts.md ← 10 种页面布局骨架(可直接粘贴)
├── layouts-swiss.md ← 22 种瑞士风锁定版式
├── swiss-layout-lock.md ← 瑞士风还原度和版式硬约束
├── themes.md ← 5 套主题色预设(只能选不能自定义)
├── themes-swiss.md ← 4 套瑞士风锚点色
├── image-prompts.md ← GPT-Image 2.0 / GPT-M 2.0 配图类型、比例和基础提示词
├── screenshot-framing.md ← CleanShot X 式截图适配语义
├── presenter-mode.md ← 演讲备注契约、排练、观众屏与现场工具说明
└── checklist.md ← 质量检查清单(P0 / P1 / P2 / P3 分级)
```
## 主题色预设
从 `references/themes.md` 里选一套——**不允许自定义 hex 值**,保护美学比给自由更重要。
### Style A 电子杂志主题
| 预览 | 主题 | 核心色与适合场景 |
|------|------|------------------|
| <img src="https://github.com/user-attachments/assets/df21dbcb-5fe4-4852-a91a-a9cf00aceeb4" width="260" alt="墨水经典主题预览"> | 🖋 **墨水经典** | `#0a0a0b` / `#f1efea`。通用默认、商业发布、不知道选啥时最稳。 |
| <img src="https://github.com/user-attachments/assets/99ce0fd2-72a6-4368-a75a-a8e21657a537" width="260" alt="靛蓝瓷主题预览"> | 🌊 **靛蓝瓷** | `#0a1f3d` / `#f1f3f5`。科技、研究、AI、技术发布会。 |
| <img src="https://github.com/user-attachments/assets/bcc1cc4c-5e8e-4467-ae8d-f5801ae73657" width="260" alt="森林墨主题预览"> | 🌿 **森林墨** | `#1a2e1f` / `#f5f1e8`。自然、可持续、文化、非虚构内容。 |
| <img src="https://github.com/user-attachments/assets/dfea080e-e916-417e-93cd-0a3628de84ca" width="260" alt="牛皮纸主题预览"> | 🍂 **牛皮纸** | `#2a1e13` / `#eedfc7`。怀旧、人文、阅读、历史、文学分享。 |
| <img src="https://github.com/user-attachments/assets/f3705592-9a72-4dbc-9818-df3aea61bc75" width="260" alt="沙丘主题预览"> | 🌙 **沙丘** | `#1f1a14` / `#f0e6d2`。艺术、设计、创意、时尚和画廊感内容。 |
切换主题只需替换 `template.html` 开头 `:root{}` 里的 6 行变量,其他 CSS 全走 `var(--...)`。
### Style B 瑞士主题
瑞士风从 `references/themes-swiss.md` 里选一套,同样**不允许自定义 hex 值**。
| 预览 | 主题 | 锚点色与适合场景 |
|------|------|------------------|
| <img src="https://github.com/user-attachments/assets/c02d02f7-ce6f-4e16-b8a6-778c96851f94" width="260" alt="克莱因蓝瑞士主题预览"> | 🔵 **克莱因蓝 IKB** | `#002FA7`。通用默认、商业发布、AI 产品、方法论。 |
| <img src="https://github.com/user-attachments/assets/c310a8c4-5d28-450e-b49a-6ac5b6ba4785" width="260" alt="柠檬黄瑞士主题预览"> | 🟡 **柠檬黄** | `#FFD500`。年轻、运动、零售、消费品、Y2K 复古。 |
| <img src="https://github.com/user-attachments/assets/65f7b3f9-3358-419e-b513-f7f2cc24ec76" width="260" alt="柠檬绿瑞士主题预览"> | 🟢 **柠檬绿** | `#C5E803`。生态、可持续、健康、Z 世代品牌。 |
| <img src="https://github.com/user-attachments/assets/9c3319c9-a134-4657-9a56-211c23411f7f" width="260" alt="安全橙瑞士主题预览"> | 🟠 **安全橙** | `#FF6B35`。警示、新闻、工业、运动、活力主题。 |
如果用户说"瑞士风 PPT"但没有指定颜色,默认推荐克莱因蓝 IKB。
## 核心设计原则
1. **克制优于炫技** — WebGL 背景只在 hero 页透出
2. **结构优于装饰** — 信息靠字号 + 字体对比 + 网格留白,不用阴影和浮动卡片
3. **图片是第一公民** — 图片要对齐正文内容区,比例稳定,只裁底部,顶部和左右完整
4. **配图只做素材** — 生成图只保留核心照片 / 图表 / UI,不要把 PPT 页脚、标题和角标画进图片里
5. **节奏靠 hero 页** — hero / non-hero 交替,才不累眼睛
6. **低性能可退场** — 按 `B` 能切换到静态模式,动态效果不能成为阅读负担
7. **术语统一** — Skills 就是 Skills,不中英混译
8. **瑞士风必须守版式** — Style B 优先还原原始 22P 版式,不要为了"多样"发明不存在的页面
## 视觉参考
- [*Monocle*](https://monocle.com) 杂志的版式
- YC Garry Tan "Thin Harness, Fat Skills"
- Massimo Vignelli / Helvetica Forever / 瑞士国际主义网格系统
- 歸藏线下分享 PPT 系列
## Roadmap
- 补充更多真实案例和可打开的 HTML deck 示例
- 扩展封面规格,覆盖更多内容平台
- 增加更多瑞士风版式校验规则
- 优化截图再设计和信息图生成工作流
- 整理 WorkBuddy 等平台上架版本
- 增加更多主题包,但继续限制自定义颜色
## FAQ
**可以导出 PPTX 吗?**
当前核心交付是 HTML。你可以用浏览器演示、截图或录屏。如果需要 PPTX,建议把 HTML 页面作为视觉稿再转换,但这不是当前主流程。
**为什么不允许自定义颜色?**
这个 Skill 的重点是稳定产出。自由选色很容易破坏整体风格,所以只允许从预设主题里选。
**我能加自己的版式吗?**
可以。Style A 可以在 `references/layouts.md` 里扩展;Style B 更严格,需要同步更新 `template-swiss.html`、`layouts-swiss.md`、`swiss-layout-lock.md` 和校验器。
**Codex 配图是必须的吗?**
不是。没有配图也能生成 PPT。配图流程只在需要照片、信息图、UI 情景图或封面时使用。
**演讲者模式需要联网或额外服务吗?**
不需要。双窗口同步、备注、计时、排练、自动翻页和标注都在本地浏览器完成。它不会提供实时字幕、手机遥控或 AI 排练评分。
**为什么关掉观众窗口后显示“未连接”?**
演讲者端会通过观众屏确认和心跳判断软件链路。窗口关闭或心跳超时会显示“未连接”,点击“重新打开观众屏”即可恢复。浏览器无法判断 HDMI 或投影仪线缆是否真的接通,现场仍需目视确认。
**怎么更新到最新版?**
重新运行安装命令,或在本地 skill 目录执行 `git pull`。
## 贡献
Bug、排版问题、新布局需求——欢迎开 Issue 或 PR。改动请优先:
- 在 `template.html` 里补类,不要让 layouts.md 使用未定义的类
- 在 `template-swiss.html` 里补类时,同步更新 `layouts-swiss.md` 和 `swiss-layout-lock.md`
- 瑞士风新增规则后,同步更新 `scripts/validate-swiss-deck.mjs`
- 演讲者运行时必须同时更新两套模板,并运行 `scripts/check-presenter-runtime-sync.mjs`;CI 会拦截两份模板的 CSS / JavaScript 漂移
- 演讲备注或现场行为变化时,同步更新 `references/presenter-mode.md`、`references/checklist.md` 和 `scripts/validate-presenter-mode.mjs`
- 把踩过的坑写到 `checklist.md` 对应的 P0 / P1 / P2 / P3 级别
- 新主题色进 `themes.md` 并给出适合的场景
## License
AGPL-3.0 © 2026 [op7418](https://github.com/op7418)
references/checklist.md›
# 质量检查清单(Checklist)
这个清单来自"一人公司"分享 PPT 的真实迭代过程。每一条都是踩过坑之后总结的,按重要性排序。
生成 PPT 前,先通读一遍;生成后,逐项自检。
---
## 🔴 P0 · 一定不能犯的错
### 0-P. 演讲者模式必须可控、可排练、可恢复、备注不串页
**现象**:演讲者端已经翻页,观众屏仍停在上一页;观众窗口关闭后没有提示;页面重排后,用户改过的讲稿跑到了另一页;或演讲模式入口变成页面右上角的突兀按钮。
**做法**:
- 演讲入口只放在右下角现有快捷控制区,显示 `P 演讲模式`。
- 演讲者主体只做“左侧预览 + 右侧备注”两栏;当前页在上、下一页在下,避免形成挤压当前页的三栏布局。
- 两个预览 iframe 必须严格保持 `16:9`,容器不足时等比缩小并留边,不得裁切或拉伸;小屏优先压缩下一页预览高度。
- 底栏固定为“左时间、中控制、右页码进度”:左侧分组显示已进行/本页/剩余或超时,中间两行放翻页和会话控制,右侧只放页码与百分比;不要重复当前页标题。会话控制使用“开始计时 / 继续计时 / 重置计时 / 排练”等明确动作名,不使用孤立的“开始”或“重置”。
- 翻页控制顺序固定为 `首页 / 上一页 / 下一页 / 尾页`;尾页时最后一个按钮变为“重新开始”。自动翻页放在右上角状态栏。
- `宫格` 放在当前页标题旁,打开时替换当前/下一页预览区;选中页面后回到预览,不要弹出突兀的全屏总览。
- 右侧卡片依次显示标题、本页目的和草稿备注;设置里的开关用胶囊 switch,时间间隔用美化后的加减 stepper。
- 观众屏必须显示软件链路状态:`连接中 / 已同步 / 未同步 / 未连接 / 弹窗被拦截`,并始终提供“重新打开观众屏”。
- 演讲者必须能看到总时长、本页时长、计划/剩余/超时;缺失 `minutes` 时显示横杠,不猜测。
- 临时新增页面没有对应备注时,界面 fallback 只显示 `—`,不显示“待补充”;校验器仍要报告页面/备注数量不一致。
- 排练模式记录每页实际时长,本地保留最近 5 次;只做数据汇总,不做 AI 教练评分。
- 自动翻页默认关闭,`minutes` 与 `autoAdvanceSeconds` 必须分开;总览、圈选、设置、页面隐藏、观众屏暂停或失同步时倒计必须暂停。
- 必须提供激光笔、圈选、清除、黑屏、白屏和冻结/恢复;标注坐标要归一化同步到观众屏。
- 预览 iframe 只初次加载,翻页改用 `postMessage` 同步页码;尾页的下一页预览显示“演示结束”,不重复当前页。
- 退出演讲必须处理 `bye`:脚本打开的观众窗口自动关闭,无法自关时显示“演示已结束”;心跳超时必须显示“未连接”而不是永久停在“未同步”。
- 演前检查必须覆盖观众屏、弹窗、全屏、字体、图片/视频和 16:9;同时明确提醒它不能检测物理线缆。
- 备注里的 `section / cue / interaction / delivery / advance / fallback / pronunciation / autoAdvanceSeconds` 都是可选字段;用户大纲没有就整段隐藏。
- 每页必须有稳定、唯一的 `data-slide-id`;`SPEAKER_NOTES` 数量、顺序和 ID 必须逐页一致。
- 备注本地编辑按页面 ID 保存;切页重置滚动位置并显示保存状态。
- 浏览器只能确认观众页面是否同步,不能确认 HDMI/投影线物理连接;现场仍要目视检查外接屏。
**检查**:
```bash
node <SKILL_ROOT>/scripts/validate-presenter-mode.mjs path/to/index.html
```
浏览器里关闭观众窗口,确认状态变化;再点击“重新打开观众屏”,确认它恢复到演讲者当前页。再逐项实测内嵌宫格选页返回、计时、排练、自动翻页暂停/恢复、激光笔、圈选、清除、黑屏、白屏、冻结、设置组件和演前检查。缩小浏览器窗口再检查一次:当前页与下一页仍上下排列,两个 iframe 的 `width / height` 仍约等于 `16 / 9`,且没有超出预览容器。
### 0-S. Swiss locked mode:正文页必须来自原始 22P
**现象**:颜色、字体看起来像 Swiss,但标题跑到中间、图片不在网格上、页面结构和原始 22P 完全不是一套东西。
**根因**:生成时把 Swiss 当成风格包,自由组合了新的 P23/P24/自绘 SVG 页面,没有从原始参考 PPT 的 22 个登记版式里选。
**做法**:
- 先读 `references/swiss-layout-lock.md`
- 正文页只能使用 `S01-S22`;新增首页/尾页只能使用 `SWISS-COVER-ASCII` / `SWISS-CLOSING-ASCII`
- 每个 `<section class="slide">` 必须写 `data-layout="Sxx"`
- 生成后必须运行:
```bash
node <SKILL_ROOT>/scripts/validate-swiss-deck.mjs path/to/index.html
```
**校验会拦截**:
- 未登记版式 / 缺少 `data-layout`
- P23/P24 实验结构
- SVG 里写可见文字
- S22 图片未绑定 `s22-hero-21x9`
- S22 照片使用 `object-position:top center`
### 0-S-2. Swiss 顶部标题默认左上,不是居中
**现象**:最顶上的中文标题在页面中间,像一页自制海报,不再像原始 PPT。
**做法**:
- 除 `S03/S09/S10` 这类 statement/split 版式外,顶部标题必须贴原始模板的左上内容轴。
- 不要把小标题放左列、大标题放右侧大列,这会导致标题视觉居中。
- 如果需要标题 + 说明两列,必须复制原始 `S11` 或 `S17` 的骨架,不要自写 `4fr 8fr`。
### 0-S-3. Swiss 地图页必须用 S08 Map Component
**现象**:地点/历史内容只画了简易 SVG 地图,没有真实点位、关系卡片、缩放/拖动控制,或滚轮触发了 PPT 翻页。
**做法**:
- 使用 `data-layout="S08"`。
- 先读 `references/swiss-map-component.md`。
- 右侧地图组件必须包含 marker 点、连接线、地点卡片、`+` / `-` / `DRAG` 控制。
- 默认禁用 scroll zoom 和 drag pan;用户点击 `DRAG` 后才允许拖动。
- 必须保留静态 fallback,地图 CDN 或瓦片失败时仍可读。
**检查**:
- `grep -n "data-map-ctrl" index.html`
- `grep -n "maplibregl.Map" index.html`
- 浏览器实测 `+` 可放大,`DRAG` 可切换为 `DRAG ON`
### 0-S-4. Swiss 演示字号不能小到看不清 + 字重阶梯必须遵守
**现象**:瑞士风页面整体结构没问题,但图注、说明、时间线、KPI note、卡片小字在投屏时看不清;或者 16px 小字用了 weight 300 导致又小又细。
**做法(字号下限)**:
- 正文段落 / 主要说明 ≥ `18px`
- 卡片描述 / 列表 / 时间线说明 / caption / 图注 ≥ `16px`
- meta / kicker / mono label / 图表标签 ≥ `14px`
- 内容超出时,先删减文案、拆页或换 Sxx 版式,不要用 10/11/12/13px 小字硬塞。
**做法(字重阶梯 ⭐)**:
瑞士风坚持"越大越细,越小越粗",字号与字重必须成反比阶梯:
- ≥ 8vw → weight **200**(ExtraLight)
- 4-7.9vw → weight **200-300**
- 1.8-3.9vw → weight **300-400**
- 1-1.7vw / 16-20px → weight **400-500**
- 13-15px → weight **500-600**
- 同一页内,字号小的元素字重必须 ≥ 字号大的元素。
- **16px 左右小字禁止使用 weight 300**(太细不可读),最低 400,推荐 500。
- 封面/IKB 反白大标题内强调字用 `italic + weight 300`,不要用 accent 色。
**检查**:
- `rg -n "font-size:(10px|11px|12px|13px)|max\\((9|10|11|12|13)px" index.html`
- `rg -n "font-weight:(300)" index.html | rg -v "min\(|h-xl|h-hero|h-statement|num-mega|kpi-thin|name-mega|8vw|9vw|1[1-9]vw|cover-|\.multi"` —— 检查 weight 300 是否落在了小字号上
- 浏览器以 100% 缩放查看,底部 note、caption、timeline label、卡片描述仍能一眼读清。
### 0-A. 瑞士风画布对齐法则(每一页必查 · 最常踩)
**现象**:页眉 chrome-min 和底部 footer 都靠在 5vw 的边线上,但中间区域往内缩了一截,左右对不齐。
**根因**:`.canvas-card` 已经自带 `padding:5.6vh 5vw 4.4vh`。如果在主体区再写 `padding:5vh 5vw 4vh`,水平方向就变成 `5vw + 5vw = 10vw`,主体比 chrome-min 多内缩 5vw。
**做法**:
- 主体那层 `padding:0`,只用 grid `gap` 控垂直间距
- chrome-min 与主体之间的间距由 `.chrome-min{margin-bottom:48px}` 提供,**不要**在主体顶部叠 `margin-top` / `padding-top`
- split 模式例外:`.slide.split .canvas-card{padding:0}`,两个 `.half` 自己定 `padding:5.6vh 3.6vw 4.4vh`
```html
<!-- ❌ 错:主体多缩了 5vw,左右对不齐 -->
<div class="canvas-card">
<div class="chrome-min">...</div>
<div style="flex:1;padding:5vh 5vw 4vh;...">主体</div>
</div>
<!-- ✅ 对 -->
<div class="canvas-card">
<div class="chrome-min">...</div>
<div style="flex:1;padding:0;display:grid;grid-template-rows:auto 1fr auto;gap:3vh">主体</div>
</div>
```
**自检命令**:`grep "padding:.*5vw" index.html`,如果命中 `padding:Xvh 5vw Yvh` 在 canvas-card 直系子元素里,就是错的(.half / 装饰层除外)。
### 0-B. 瑞士风 head 区:kicker 必须在大标题"上方"(不要左右排)
**现象**:小标题(`.t-meta` / `.t-cat`)和大标题被挤在同一行,左侧一坨小字、右侧一坨大字,头部失去层级。
**根因**:`grid-template-columns:auto 1fr` 把两个本该上下叠的元素压成左右两列。
**做法**:
```html
<!-- ❌ 错 -->
<div data-anim="head" style="display:grid;grid-template-columns:auto 1fr;gap:3vw;align-items:end">
<div class="t-meta">METHODOLOGY · 03</div>
<h2 class="h-xl-zh">为什么是 N+1</h2>
</div>
<!-- ✅ 对 -->
<div data-anim="head" style="display:flex;flex-direction:column;gap:1.4vh">
<div class="t-meta">METHODOLOGY · 03</div>
<h2 class="h-xl-zh">为什么是 N+1</h2>
</div>
```
例外:head 一行同时承载"左:kicker+大标题(自己上下叠)"和"右:小注脚",外层可以用 `display:grid;grid-template-columns:1fr auto`,但**内层**仍要保持 flex column。
### 0-B-2. 瑞士风封面 / 封底默认:IKB 满屏 + ASCII 呼吸场 + 白色 weight 200(强制)
**现象**:封面用 `slide light` 白底 + 黑字 + 一个大大的"01"——同时 chrome 角标已经写了 `01 / 07`,屏幕上出现两个"01",视觉重复;白底太普通,完全没有"开场打招呼"的仪式感。
**根因**:layouts-swiss.md 旧版默认推荐左 ink + 右 paper 对开,实操中容易写成"白底 + 黑大字 + 编号大字",失去 IKB 这个标志色的开场冲击。
**做法**(瑞士风必守):
- **封面强制 `<section class="slide accent">`**(满屏 IKB),不要 `slide.light`,也不要 `slide.dark`;在 `.canvas-card` 内**第一个子元素**插入 `<canvas class="ascii-bg">`(ASCII 字符呼吸场,模板自带 IIFE 自动激活)
- **不要再写"01"等编号大字**:`.chrome-min` 已经显示 `01 / N`,封面再放一个巨大的"01"=同义重复,直接删掉
- **强调字必须用斜体**:`font-style:italic;font-weight:300`,**禁止**用 `color:var(--accent)`——IKB 蓝压 IKB 蓝,人眼看不见任何强调
- **封底强制 `slide.split`** 双半屏,左半 `.half.b-accent` + ASCII canvas(与封面色彩闭环),右半 paper 白底放 3 条 takeaway;**第 03 条**用 `var(--accent)` 上色,完成"开场全 IKB ↔ 收尾半 IKB"的色彩闭环
- ASCII canvas 在模板的 `<style>` 里已经预设 `mix-blend-mode:screen;opacity:.92`,不要去动这个值
- 封面/封底主标题字号双约束:`min(11.6vw,19vh)` ~ `min(8vw,14vh)`(遵守 Y ≥ X × 1.6 规则)
**自检命令**:
- `grep -c "ascii-bg" index.html`——封面 + 封底应至少命中 ≥ 2(各一个 canvas)
- `grep -E '"slide accent"' index.html | head -1`——封面应是 `slide accent` 而非 `slide light`
- `grep "color:var(--accent)" index.html`——若命中行同时含 `font-style:italic` 即危险信号(蓝压蓝),改为只 italic 不 accent;只有封底"03 takeaway"那一处用 `var(--accent)` 是合法的(此时背景是白色)
- 目视:打开页面看封面有没有"01"等大编号——有就删
### 0-C. 瑞士风大字号双约束:`min(Xvw, Yvh)` 中 Y ≥ X × 1.6
**现象**:在 16:9 标准屏(MacBook 13/14/16,常见显示器)打开,标题字号比预期小一截,整页内容显得空旷或缩水。
**根因**:1vw : 1vh ≈ 1.78,如果写 `min(7vw, 10vh)`,在 16:9 屏 7vw = 12.46vh,会被 10vh 上限截断到 10vh,字号缩水 20%。
**做法**:推荐数值速查
| 用途 | 推荐 |
|---|---|
| h-hero 巨字宣言 | `min(11.6vw, 19vh)` |
| h-xl 章节标题 | `min(7vw, 12vh)` ~ `min(7.4vw, 13vh)` |
| 大数字 KPI | `min(8.4vw, 14vh)` |
| 中数字 / 编号 | `min(4.6vw, 8.5vh)` ~ `min(5.6vw, 10vh)` |
| 副标 | `min(7.6vw, 13vh)` |
**自检命令**:`grep -E "font-size:min\([0-9.]+vw,\s*[0-9.]+vh\)" index.html`,把所有命中的 X/Y 看一眼,任何 Y/X < 1.6 都改大。
### 0-D. 瑞士风图片混排:直角、同高、只做证据
**现象**:图片像普通 PPT 插图,圆角、阴影、比例混乱;多张截图高度不一,或 GPT-M 2.0 生成图自带标题/页脚,和页面 chrome 重复。
**根因**:瑞士风的图片不是装饰,而是 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"`
- 照片默认 `object-position:center 35%` 或 `center center`,不要用 `top center` 截人脸
- 图片容器只用 `.frame-img`;**不要** `border-radius` / `box-shadow`
- UI / 信息图 / 流程图若是用户原始截图或文字密集图,使用 `.fit-contain`;若已按槽位重生成,必须用对应比例类铺满容器,例如 `.frame-img.r-21x9`,不能再用固定短高度把图片缩小
- 多图同组必须统一槽位、比例、高度,不要混用
- 用户原始截图要先读 `references/screenshot-framing.md`:优先用 `assets/screenshot-backgrounds/` 内置主题背景 + 程序化缩放/留边/对齐,不要为了比例统一就重画截图内容
- 截图背景必须跟随当前主题色,且可裁成 `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`——每张本地图片都应有槽位声明
- 目视:图片内部如果自带大标题、页码、页脚、角标,优先重生成,不要在页面里再裁切硬救
- 目视:截图外侧背景应该是安静托底,不能比截图本身更抢眼;Swiss 风截图不得出现圆角和投影
### 0-D-2. 瑞士风底部分页安全区:最低处不要碰 nav
**现象**:图片 caption、脚注、timeline 下方 label、底部 KPI 被分页小方块挡住,或者视觉上贴得太近。
**根因**:`#nav` 固定在 `bottom:2vh`,如果主体内容用 `align-self:end` / `align-items:end` / `margin-top:auto` 贴到底,最低处会进入分页区域。
**做法**:
- 主内容最低边缘与分页组件之间至少留 `3vh` 呼吸空间
- 图文页需要底部对齐时,先控制图片高度,再给主体容器加 `.nav-safe-bottom` / `.nav-safe-bottom-tight`
- 其他页面需要贴底时,给主体容器加 `.nav-safe-bottom` 或 `.nav-safe-bottom-tight`
- 不要手写 `bottom:2vh` / `bottom:0` 放说明文字;这会和 nav 抢位置
**自检**:
- 视觉:翻到该页,看最后一行 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
**现象**:生成页看起来像瑞士风,但和原始参考 PPT 的实际字重、间距、时间线、卡片密度不一致;越迭代越偏离参考。
**根因**:把新增图片版式或实验结构写成了全局样式修改,或无意改动了原始基座类,例如 `.h-hero` / `.h-xl` 字重、`.tl-node` 列宽、`.duo-compare` 间距。
**做法**:
- 仓库内的 `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 原始槽位,不要发明新正文结构
- 如果要修改 `assets/template-swiss.html`,先做原始参考对比;可接受差异只应是 ASCII 类、S22 图片定位类、轻字重标题 helper 和已知动效修复
**自检命令**:
- 运行本次测试目录里的 `compare-swiss-base.mjs`,确认输出里 `missing in template: 0`
- 目视对比原始 PPT 的同类页面:大标题字重、chrome-min 位置、timeline dot/label、卡片密度必须一致
### 0-F. 视觉 + 代码双核对:不要只看 HTML
**现象**:代码看起来类名正确,但实际页面拥挤、图文关系不对、可选组件堆太多,或者用了不适合内容的版式。
**做法**:
- 同时打开原始参考 PPT、当前模板或生成页、测试 PPT,先做视觉并排判断
- 等入场动效稳定后再截图或下判断,不要把动画中间态当成内容缺失
- 先打开网页逐页看视觉:标题字重、头部间距、正文密度、图片对齐、nav 安全区
- 再回代码看结构:该页是否用了正确版式,必选组件是否齐,可选组件是否过度
- 对照原始 PPT 时以实际画面为准;raw CSS helper 只能辅助,不能替代视觉判断
- 判断问题来源:版式选错 / 必选组件缺失 / 可选组件滥用 / 间距和安全区问题
- 通用版式(S03/S08/S11/S19)可多用;数据专用(S06/S07/S20/S21/S22)必须有真实数据或案例;结构专用(S14/S15/S17)必须有闭环、矩阵或层级关系
---
### 0. 生成前必须通过的类名校验(最重要)
**现象**:直接把 layouts.md 的骨架粘到新 HTML,结果样式全部丢失——大标题变成非衬线、数据大字报字体小得像正文、pipeline 多页糊成一坨、图片堆到浏览器底部。
**根因**:如果当前模板的 `<style>` 里没有这些类的定义,浏览器就 fallback 到默认样式。
**做法**:
- **生成 PPT 前,必须先 `Read` 当前风格对应模板**:风格 A 读 `assets/template.html`,风格 B 读 `assets/template-swiss.html`,确认 layouts 里用到的类都已定义
- 最常见遗漏的类:`h-hero / h-xl / h-sub / h-md / lead / meta-row / stat-card / stat-label / stat-nb / stat-unit / stat-note / pipeline-section / pipeline-label / pipeline / step / step-nb / step-title / step-desc / grid-2-7-5 / grid-2-6-6 / grid-2-8-4 / grid-3-3 / frame / img-cap / callout-src`
- 如果某个类确实缺了,**在模板的 `<style>` 里补上**,不要在每页 inline 重写
- 生成后打开浏览器,如果看到"大标题是非衬线"或"pipeline 步骤挤在一行",几乎 100% 是这个问题
### 1. 不要用 emoji 作图标
**现象**:在中式杂志风格里用 emoji(🎯 💡 ✅)会立刻破坏格调。
**做法**:用 Lucide 图标库,CDN 方式引用:
```html
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
...
<i data-lucide="target" class="ico-md"></i>
...
<script>lucide.createIcons();</script>
```
常用图标名:`target / palette / search-check / compass / share-2 / crown / check-circle / x-circle / plus / arrow-right / grid-2x2 / network`
### 2. 图片只允许裁底部,左右和顶部绝对不能切
**现象**:用 `aspect-ratio` 撑图,网格会在父容器不足时堆叠或切掉图片关键信息(比如截图上部的标题栏)。
**做法**:图片容器用**固定 height + overflow hidden**,图片走 `object-fit:cover + object-position:top`:
```html
<figure class="frame-img" style="height:26vh">
<img src="screenshot.png">
</figure>
```
CSS 里 `.frame-img img` 已经预设 `object-position:top`,只裁底。
**绝不用这种写法**(会在网格中撑破容器):
```html
<!-- 坏例 -->
<figure class="frame-img" style="aspect-ratio: 16/9">...</figure>
```
**例外**:单张主视觉(非网格内)可以用 `aspect-ratio + max-height`,因为父容器会兜底。
### 2b. 亮页面配暗 WebGL = 灰蒙蒙(主题切换没生效)
**现象**:所有 light 页面背景都像蒙了一层灰,甚至 hero light 也灰。
**根因**:JS 根据 slide 的主题切换两张 canvas 的 opacity。如果整个 deck 开场是 hero dark,而没有任何机制能把 bg 切到 light,body 永远不加 `light-bg` 类,`canvas#bg-dark` 一直在上面。
**做法**:
- 模板里 `go()` 函数已改为从 `classList` 推断主题(`light` / `dark`),所以 **slide 必须明确带 `light` 或 `dark` 类**。不要漏写,更不要用其他自定义主题名
- hero 页用 `hero light` / `hero dark`,正文页用 `light` / `dark`。只写 `hero` 不带主题色是坏的
- 一个 deck 里必须至少有一个 **非 hero 的 light 页**,确保 body 有机会加 `light-bg`
### 2b-2. 整个 deck 全是 light,没有节奏
**现象**:除封面 `hero dark` 外,其余所有页面默认写 `light`——视觉平淡,没有呼吸感,白花花一片。
**根因**:layouts.md 的骨架默认全写 `light`,如果只是粘贴骨架不调整主题,就会全亮。
**做法**:
- **生成前画"主题节奏表"**:每一页写清 `hero dark` / `hero light` / `light` / `dark` 中的哪一个,对齐后再写代码
- **硬规则**:连续 3 页以上同主题 = 不允许;8 页以上必须有 ≥1 `hero dark` + ≥1 `hero light`;不能全是 `light` 正文页——必须有 `dark` 正文页
- **按布局选主题**(详见 layouts.md 开头"主题节奏规划"):
- 左文右图(Layout 4)、大引用(Layout 8)、图文混排(Layout 10)→ **`light` / `dark` 交替**
- 大字报、图片网格、Pipeline、对比页 → `light`(截图/数字/流程需要亮底)
- 封面、问题页 → `hero dark`
- 章节幕封 → `hero dark` 与 `hero light` 交替
- **生成后自检**:`grep 'class="slide' index.html`,目视确认节奏有交错
### 2c. chrome 和 kicker 不要写同一句话
**现象**:左上角 `.chrome` 写"Design First · 设计先行",同一页里 `.kicker` 又写"Phase 01 · 设计阶段"——同义翻译,AI 味浓。
**做法**:
- **chrome = 杂志页眉 / 导航标签**:跨多页可相同(如 "Act II · Workflow"、"Data · Result"、"lukew.com · 2026.04")
- **kicker = 本页独一份的引导句**:短、有钩子、是大标题的"小前缀"(如 "BUT"、"一个人,做了什么。"、"The Question")
- 一个描述栏目,一个描述这一页——绝不互相翻译
### 3. 大标题字号不能超过屏宽 / 单字数
**现象**:中文大标题字号设太大(比如 13vw),结果每行只容 1 个字,强制换行非常难看。
**做法**:
- `h-hero`(最大):10vw,**且标题长度 ≤ 5 字**
- `h-xl`(次大):6vw-7vw
- 长标题用 `<br>` 手工断行,不要依赖自动换行
- 必要时加 `white-space:nowrap`
**示例**:`我不是程序员。`(6 字)用 `h-xl` 7.2vw + nowrap,一行排完。
### 4. 字体分工:标题衬线、正文非衬线
**做法**:
- 大标题、重点 quote、数字大字 → **衬线字体**(Noto Serif SC + Playfair Display + Source Serif)
- 正文、描述、pipeline 步骤名 → **非衬线字体**(Noto Sans SC + Inter)
- 元数据、代码、标签 → **等宽字体**(IBM Plex Mono + JetBrains Mono)
所有字体用 Google Fonts CDN 引入,模板里已预设。
### 4b. 图片不要用 `align-self:end` 贴底
**现象**:左文右图布局里,为了让右列图片和左列 callout 底部对齐,在 `<figure>` 上加 `align-self:end`。结果:
- 如果父容器不是 grid(比如类名没定义),`align-self` 完全失效,图片掉到文档流最下面被浏览器底栏遮挡
- 即使是 grid,图片会在 cell 里贴底,低分屏上仍然被 `.foot` 和 `#nav` 圆点遮挡
**做法**:
- 图文混排**必须用 `.frame.grid-2-7-5`**(或 `.grid-2-6-6`/`.grid-2-8-4`)
- 右列 `<figure class="frame-img r-16x10">` 或 `<figure class="frame-img r-4x3">` 自然贴顶即可
- 要让左列 callout 看起来"贴底",给**左列**加 flex column + `justify-content:space-between`,不要动右列
- 如果图片与大标题顶端齐平但正文从标题下方开始,给图片加 `margin-top:7vh` 到 `9vh`,让图片跟正文内容区对齐
### 4c. 图片不要用原图奇葩比例
**现象**:`aspect-ratio: 2592/1798` 这种从原图复制的比例,在不同屏幕下撑出奇怪的空白或溢出。
**做法**:无论原图什么比例,占位器固定用标准比例 **16/10 / 4/3 / 3/2 / 1/1 / 16/9**。图片自动 `object-fit:cover + object-position:top`,顶部不裁,底部裁掉一点无伤大雅。
### 5. 不要给图片加厚边框 / 阴影
**现象**:为了"高级感"加了强阴影或黑框,瞬间变成商务 PPT。
**做法**:最多 1-4px 的微圆角 + **极淡的底噪**(已在模板里)。不要加 `box-shadow`,不要加 `border`(除非 1px 极淡的灰)。
---
## 🟡 P1 · 排版节奏
### 6. Hero 页和非 hero 页要交替
**推荐节奏**(25-30 页):
```
Hero Cover → Act Divider (hero) → 3-4 pages non-hero → Act Divider (hero)
→ 4-5 pages non-hero → Hero Question → ... → Hero Close
```
连续 2 页以上 hero 会让人疲劳,连续 4 页以上 non-hero 会让节奏死。
### 7. 大字报页和密集页要交替
大字报(big numbers / hero question)和密集页(pipeline / image grid)交替出现,听众眼睛才不累。
### 8. 同一概念的英文/中文用法要统一
**现象**:一会儿写 "Skills",一会儿写 "技能",一会儿写 "薄承载厚技能",全篇不一致。
**做法**:
- 术语优先用**英文单词**(Skills / Harness / Pipeline / Workflow),这些都是圈内熟悉词
- **别硬翻译**,硬翻译反而生硬
- 整个 deck 里同一个词 1 个写法
### 9. 底部 chrome 的页码要一致
用 `XX / 总页数` 的格式(比如 `05 / 27`)。**不要在右上角加动态页码**(会和 `.chrome` 重复)。
### 9b. 动效系统:每一页都要有 data-anim 标记
**现象**:生成后打开浏览器,翻页时内容直接"啪"地出来,没有任何节奏感——杂志风完全靠排版硬撑,少了层级展开的仪式感。
**根因**:完全没给任何元素加 `data-anim`,Motion One 脚本找不到可播的元素,整页静态出现。
**做法**:
- 所有正文页,**至少给 kicker / 主标题 / lead / callout / stat-card / figure 这些叶子元素加 `data-anim`**
- **Hero 页**(开场/幕封/问题/结尾):所有核心块(kicker + 大标题 + lead + meta-row)都要加
- **不需要特殊 recipe 的页**:什么也不用写,默认 cascade 就好看
- **需要特殊 recipe 的 4 类页**:必须在 `<section>` 上加对应 `data-animate`
- 大引用 → `data-animate="quote"` + 每行 `<span data-anim="line" style="display:block">`
- Before/After 对比 → `data-animate="directional"` + 左列 `data-anim="left"` + 右列 `data-anim="right"`
- Pipeline 流水线 → `data-animate="pipeline"` + 每 step 加 `data-anim="step"`
- Hero 页(自动用 hero recipe,但仍需给元素加 `data-anim`)
**自检**:生成后 `grep -c 'data-anim' index.html`,应该数十条以上。如果只有个位数,一定漏标了。
### 9c. Pipeline 页必须加 data-animate="pipeline"
**现象**:流水线页直接全部淡入,失去"一步步讲"的节奏,但切到下一页时又只能往前翻,没法回到上一个 step。
**做法**:Layout 6 的 `<section>` 必须加 `data-animate="pipeline"`。演示时按 →/空格/滚轮下滑可以**逐个点亮 step**,全部点亮之后再按 → 才会翻到下一页。这个节奏是刻意的,不是 bug。
---
## 🟢 P2 · 视觉打磨
### 10. WebGL 背景的遮罩透明度
**dark hero**:遮罩 12-15%(WebGL 明显透出)
**light hero**:遮罩 16-20%(WebGL 隐约可见,不抢字)
**普通 light/dark 页**:遮罩 92-95%(几乎不透)
如果页面文字非常少(hero question),遮罩可以再薄些;如果正文密集,必须加厚遮罩确保可读。
### 11. Light hero 的 shader 不能有强中心点
**现象**:Spiral Vortex、径向涟漪在 light 主题下太显眼,像 Windows 98 屏保。
**做法**:light hero 用 FBM 域扭曲驱动的无中心流动,底色保持银/纸色(接近 #F0F0F0 / #FBF8F3),彩虹偏色 subtle(0.05 以下)。
### 12. Dark hero 允许更多视觉冲击
Dark hero 可以用 Holographic Dispersion(钛金色散)等带中心结构的 shader,因为黑底能容纳更多视觉信息。
### 13. 左文右图的对齐
- 左列的文字组 `justify-content:space-between`:标题贴顶,引用框贴底
- 右列图片保持自然顶对齐,不要加 `align-self:end`
- 右列图片通常要跟正文内容区对齐,不是跟大标题顶端对齐;必要时加 `margin-top:7vh` 到 `9vh`
- 网格整体 `align-items:start`(不是 `center` / `end`)
### 13b. 标题与正文间距
- 顶部标题 + 下方长文章/引用/图表的两段式布局,中间必须有明显间距,推荐 `margin-top:6vh` 到 `8vh`
- 居中大标题页必须整体水平居中,不要只让文字块左对齐居中摆放
- 复杂内容页用大标题定调,下方内容用 grid / rowline 两端对齐,不要把大标题、小标题、正文挤成一坨
### 13c. UI 情景图不要拉成巨长条
- 单张 UI 截图如果放满宽后变成长条,优先拆成 2-3 个局部面板
- 多面板拼排时每个 `.frame-img` 用同一个固定高度类,如 `.h-16` / `.h-18` / `.h-22`,不要用同一个超宽容器硬塞
- 同一组图片的视觉大小必须一致,不要混用不同高度、不同缩放和不同边距密度
- 如果确实需要全宽,必须生成比例足够长的横向图片,并在 prompt 里明确"ultra-wide horizontal strip"
### 13d. 生成配图不要自带 slide 元素
- GPT-M 2.0 生成的配图只是嵌入素材,不要让图片自带页眉、页脚、标题、页码、角标、署名或装饰边框
- 流程图/信息图只保留核心图形和必要短标签,PPT 自己负责标题、页脚和 chrome
- 如果生成图已经带了这些元素,优先重生成;不要在 PPT 里再叠一层 chrome 造成干扰
### 13e. Swiss 图文混排不能只用一种
- 7-8 页 Swiss 测试 deck 至少使用 6 个不同 S 编号版式
- 有 2-3 张配图时,至少使用两种图片承载方式:S22 主视觉 / S15 矩阵 / S16 小报 / S08 对照图文 / S19 四卡证据
- 左文右图或右文左图需要底对齐时,先控制图片高度和主体安全区,不要把整块内容推到分页组件附近
- 白底信息图容器必须白底、无描边;不要用灰框包白图
### 13f. Swiss 中文大标题要降级
- 中文 2 行标题默认从 `min(5.8vw,10.2vh)` 起步,不要直接用英文页的 `6.8vw-7vw`
- 任一行 9-12 个中文字符时降到 `min(5.2vw,9.2vh)`
- 3 行标题优先改写,不能为了标题大而挤掉下方图文内容
### 14. 图片的微弱圆角
风格 A 可以有轻微圆角。风格 B Swiss 必须直角: `.frame-img` 和图片本身都不要圆角、阴影或消费 app 式卡片感。
---
## 🔵 P3 · 操作细节
### 15. 图片路径用相对路径
图片放在 `images/` 文件夹下,HTML 里用相对路径 `images/xxx.png`,不要用绝对路径。
### 16. 页码在 `.chrome` 里写死
JS 会动态算总页数并扩展底部翻页圆点,但 `.chrome` 里的 `XX / N` 是写死的。加页/删页时要手工改 N。
### 17. 翻页导航要保留
模板默认支持:← → / 滚轮 / 触屏滑动 / 底部圆点 / Home·End。不要删 JS 里的导航逻辑。
### 18. 不要用 `height:100vh` 硬设,用 `min-height:80vh`
`100vh` 会让内容刚好卡满屏幕,但浏览器工具栏、标签栏会吃掉一部分高度,导致内容溢出。用 `min-height:80vh + align-content:center` 更稳。
---
## 🧪 最终自检清单
生成完 PPT 后,逐项对照这个清单(勾一下):
```
预检(生成前)
□ 已读过 template.html 的 <style>,确认所需类都存在
□ 已决定每页用哪个 Layout(1-10)
□ 已画出"主题节奏表":每页明确 hero dark / hero light / light / dark
□ 节奏表满足硬规则:无连续 3 页同主题 / 有 ≥1 hero dark + ≥1 hero light(8 页以上) / 至少有 1 个 dark 正文页
□ `<title>` 已改为实际 deck 标题(grep "[必填]" 应无结果)
□ 瑞士风:封面是 `slide accent` 满屏 IKB + `<canvas class="ascii-bg">`(不是 `slide light` 白底)
□ 瑞士风:封底是 `slide split` + 左 `b-accent` + ASCII canvas / 右 paper 3 条 takeaway,第 03 条用 var(--accent)
□ 瑞士风:`grep -c "ascii-bg" index.html` ≥ 2(封面 + 封底各一)
□ 瑞士风:封面没有"01"等大编号(chrome 已显示 01/N,不要重复)
□ 瑞士风:IKB 背景上的强调字用 `font-style:italic`,禁止用 `color:var(--accent)`(蓝压蓝)
内容
□ 每一幕的页数比例合理(不会头重脚轻)
□ 没有使用 emoji 作图标
□ Skills / Harness 等术语用法统一
□ 每页的 kicker + 标题 + 正文 三级信息清晰
排版
□ 所有大标题没有出现 1 字 1 行的换行
□ 图片网格用 height:Nvh 而非 aspect-ratio
□ 图片只裁底部,顶部和左右完整
□ 衬线/非衬线字体分工符合模板
□ Pipeline 多组之间有明显分隔
视觉
□ hero 页和 non-hero 页交替
□ WebGL 背景在 hero 页可见
□ 图片有微弱圆角
□ 没有沉重的阴影和边框
交互
□ ← → 翻页正常
□ 底部圆点数量与总页数匹配
□ chrome 里的页码和实际页号一致
□ ESC 键触发索引视图(如果保留)
□ B 键触发静态/低功耗模式,右下角提示在 `B 静态` / `B 动态` 之间切换
动效
□ `assets/motion.min.js` 存在(本地兜底)
□ 低功耗模式下 WebGL/ASCII canvas 不再挂 RAF 循环,当前页内容仍全部可见
□ 翻页时内容逐个淡入,不是"啪"一下全出
□ 大引用页 `<section>` 带 `data-animate="quote"`,每行 `<span data-anim="line">`
□ Before/After 对比页 `<section>` 带 `data-animate="directional"`,左右列标 left/right
□ Pipeline 页 `<section>` 带 `data-animate="pipeline"`,每 step 标 data-anim="step"
□ `grep -c 'data-anim' index.html` 数量 ≥ 页数 × 3(平均每页 3 个以上标记)
```
全勾完,才是合格的 PPT。
references/components.md›
# 组件参考 · Components
这是 `guizang-ppt-skill` skill 的组件手册。template.html 已经定义好了所有样式,这里只写"这个组件长什么样、怎么用"。
## 目录
- [基础 Slide 外壳](#基础-slide-外壳)
- [字体 Typography](#字体-typography)
- [Chrome & Foot](#chrome--foot)
- [Callout 引用框](#callout-引用框)
- [Stat 数字矩阵](#stat-数字矩阵)
- [Platform 平台卡](#platform-平台卡)
- [Rowline 表格行](#rowline-表格行)
- [Pillar 支柱卡](#pillar-支柱卡)
- [Tag & Kicker](#tag--kicker)
- [Figure 图片框](#figure-图片框)
- [Icons 图标](#icons-图标)
- [Ghost 巨型背景字](#ghost-巨型背景字)
- [Highlight 荧光标记](#highlight-荧光标记)
- [Motion 动效系统](#motion-动效系统)
---
## 基础 Slide 外壳
每一页都是一个 `<section class="slide ...">`。必须包含 `data-theme` 属性(`light` 或 `dark`),JS 翻页时会根据这个属性切换背景。
```html
<section class="slide light" data-theme="light"> <!-- 浅色页 -->
<section class="slide dark" data-theme="dark"> <!-- 深色页 -->
<section class="slide light hero" data-theme="light"> <!-- Hero 页:浅色 + 薄遮罩透出 WebGL -->
<section class="slide dark hero" data-theme="dark"> <!-- Hero 页:深色 + 薄遮罩 -->
```
**light vs dark 的使用:交替使用**,每 2-3 页切换一次主题,避免连续超过 3 页同色。翻页时 WebGL 背景会自动在两个 shader 之间渐变过渡。
**hero 类的使用**:只给视觉主导的页面加(封面、金句页、章节过渡、结尾)。加 `hero` 后遮罩降到 12-16%,WebGL 背景会大幅透出,所以不要在 hero 页上放太多文字。
---
## 字体 Typography
字体分工是本模板最重要的规则,严禁混用。
| Class | 用途 | 字体 |
|---|---|---|
| `.display` | 超大号英文(Hero 页) | Playfair Display 700, 11vw |
| `.display-zh` | 超大号中文标题 | Noto Serif SC 700, 7.8vw |
| `.h1-zh` | 页面主标题 | Noto Serif SC 700, 4.6vw |
| `.h2-zh` | 副标题 | Noto Serif SC 600, 3.2vw |
| `.h3-zh` | 流水线步骤标题 | Noto Serif SC 500, 1.9vw |
| `.lead` | 引导段(比 body 大) | Noto Serif SC 400, 1.9vw |
| `.body-zh` | **正文/描述(非衬线)** | Noto Sans SC 400, 1.22vw |
| `.body-serif` | 正文(衬线) | Noto Serif SC 400, 1.3vw |
| `.kicker` | 小节提示(标题上方) | IBM Plex Mono, 12px uppercase |
| `.meta` | 元信息标签 | IBM Plex Mono, 0.88vw uppercase |
| `.big-num` | 巨型数字 | Playfair Display 800, 10vw |
| `.mid-num` | 中号数字 | Playfair Display 700, 5.5vw |
**核心规则**:
- **衬线**(`serif-zh` / `serif-en`):标题、重点金句、数字 —— 用于"视觉重音"
- **非衬线**(`sans-zh`):正文描述、大段阅读内容 —— 用于"信息密度"
- **等宽**(`mono`):kicker、meta、foot 的英文标签 —— 用于"装饰节奏"
**强调技巧**:
- `<em class="en">英文词</em>` —— 把英文词渲染成 Playfair Display 斜体(很好看)
- `<em style="opacity:.65">短语</em>` —— 让标题后半段淡出,制造节奏
---
## Chrome & Foot
每一页的顶部和底部的元信息条。几乎所有页都应该有。
```html
<div class="chrome">
<div class="left">
<span>第一幕 · 硬数据</span>
<span class="sep"></span>
<span>Act I</span>
</div>
<div class="right"><span>02 / 27</span></div>
</div>
<!-- ... 页面主体 ... -->
<div class="foot">
<div class="title">项目名 · CodePilot | github.com/codepilot</div>
<div>Act I · Dev Numbers</div>
</div>
```
**规则**:
- `chrome.right` 总是放页码 `NN / TOTAL` (TOTAL 为总页数)
- `foot.title` 是中文说明,`foot.right` 是英文 act 标记
- chrome 和 foot 共同构成杂志感的"页眉页脚"
---
## Callout 引用框
展示金句 / 关键观点 / 他人引言。
```html
<div class="callout" style="max-width:80vw">
<div class="q-big">"这东西在三年前,<br>需要一个十人团队做一年。"</div>
<span class="cite">— 一个观察者的判断</span>
</div>
```
变体:
- 不带 cite:去掉 `<span class="cite">` 即可
- 带英文金句:`<em class="en">"Thin Harness, Fat Skills."</em>`
- 在 hero 页使用:外层加 `style="position:relative;z-index:2"`(避免被背景遮罩盖住)
---
## Stat 数字矩阵
展示数据指标,常与 `.grid-6` / `.grid-4` 配合。
```html
<div class="grid-6">
<div class="stat">
<span class="m">Duration</span>
<span class="n">64<em style="font-size:.4em;opacity:.5;font-style:normal"> 天</em></span>
<span class="l">从 0 到现在</span>
</div>
<!-- ... 更多 stat ... -->
</div>
```
三段式结构:`.m` 等宽小标签 → `.n` 巨型数字 → `.l` 描述说明。数字后的单位用 `<em>` 缩小到 0.4em,opacity 0.5。
**常用布局容器**:
- `.grid-6` — 3×2 网格(最常用,6 个 stat)
- `.grid-4` — 2×2 网格(4 个 stat)
- `.grid-3` — 3 等分单行(3 个 stat / pillar)
---
## Platform 平台卡
展示社交平台 / 渠道 + 粉丝数。
```html
<div class="plat">
<div class="sub">Weibo</div>
<div class="name">微博</div>
<div class="nb">289K</div>
</div>
```
可选第四行(补充说明):
```html
<div class="body-zh" style="font-size:max(11px,.8vw);opacity:.5;margin-top:.6vh">
含小绿书同步
</div>
```
**"Also On" 变体**(补充平台):
```html
<div class="plat" style="border-top-style:dashed;opacity:.72">
<div class="sub">Also On</div>
<div class="body-zh" style="font-weight:600;margin-top:.8vh">
B 站 · 知乎
</div>
</div>
```
---
## Rowline 表格行
列表式内容,每行一个条目。
```html
<div class="rowline">
<div class="k">CLAUDE.md</div>
<div class="v">你该怎么做事 —— 行为规则 + 工作偏好 + 禁止事项</div>
<div class="m">EMPLOYEE · HANDBOOK</div>
</div>
```
三列结构:`.k` 衬线关键词 · `.v` 正文描述 · `.m` 等宽标签(右对齐)。第一个和最后一个 rowline 自动加上下边框。
**变体:2 列**:`style="grid-template-columns:1fr 3fr"` 去掉 `.m` 列。
---
## Pillar 支柱卡
三支柱结构,常用于"概念并列"类型页面。
```html
<div class="grid-3">
<div class="pillar">
<div class="ic">01</div>
<div class="t">三层<br>文档体系</div>
<div class="d">CLAUDE.md<br>+ 项目知识库<br>+ 护栏文件</div>
</div>
<!-- ... 更多 pillar ... -->
</div>
```
**带图标的 pillar(用于强调性页面)**:
```html
<div class="pillar" style="padding:4vh 2vw;border:1px solid currentColor;border-color:rgba(10,10,11,.2)">
<div class="ic"><i data-lucide="compass" class="ico-lg"></i></div>
<div class="t">判断力</div>
<div class="d">决策和方向的权威。<br>取舍、品味、方向感。</div>
</div>
```
`.ic` 可以是序号(`01 / 02 / 03` 或 `A. / B. / C.`),也可以是 Lucide 图标。
---
## Tag & Kicker
**Kicker** 是标题上方的小提示文字(等宽、全大写、小字号):
```html
<div class="kicker">过去 64 天 · 开发篇</div>
<div class="h1-zh">一个人,做了什么。</div>
```
**Tag** 是独立的标签胶囊(带边框):
```html
<div style="display:flex;gap:1.6vw;flex-wrap:wrap">
<div class="tag">早上 10 点起床</div>
<div class="tag">周二 / 四下午健身</div>
<div class="tag">晚上照样看剧 · 玩游戏</div>
</div>
```
---
## Figure 图片框
**这是本模板最容易踩坑的组件,务必遵守以下规则**。
### 基础结构
```html
<figure class="tile">
<div class="frame-img" style="height:26vh">
<img src="图片素材/xxx.png" alt="说明">
</div>
<figcaption class="frame-cap">
<span class="pf">推特 · Twitter</span>
<span class="nb">137K</span>
</figcaption>
</figure>
```
### 关键约束(血泪经验,不要违反)
1. **图片网格必须用 `height:Nvh` 固定高度**,不要用 `aspect-ratio`。
- 原因:网格里用 aspect-ratio 容易撑破父容器,导致图片堆叠。
- 推荐尺寸:`.h-16` (小型面板) / `.h-18` (紧凑条形) / `.h-22` (标准网格) / `.h-26` (突出展示) / `.h-28` (大图)。
- 单张主图可以用模板提供的比例类:`.r-16x9` / `.r-16x10` / `.r-4x3` / `.r-3x2` / `.r-3x4` / `.r-1x1`。
- 同一组图片必须使用同一个高度类,不要一张 `25vh`、一张 `21vh` 混用。
2. **`object-position:top center`(已在 CSS 里设好)**,只允许裁掉底部。
- 严禁裁剪左右和顶部 —— 这是图片的核心身份信息区。
3. **网格里多张图时,用内联 grid 而不是 `grid-3`**:
```html
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:1vh 1.2vw">
<figure class="tile">...</figure>
<figure class="tile">...</figure>
<figure class="tile">...</figure>
</div>
```
4. **图片与布局其他部分对齐**:使用 `.grid-2-7-5` / `.grid-2-6-6` / `.grid-2-8-4` 的 grid 结构自然顶对齐。不要给图片加 `align-self:end`。
5. **信息图 / 截图再设计**:给 `.frame-img` 同时加 `.fit-contain`,避免图内文字和标注被裁切。
6. **用户原始截图比例不合适时**:优先按 `screenshot-framing.md` 做 CleanShot X 式程序化适配;只有截图太长、太窄或需要重构信息时,才重新生成"截图再设计 / UI 情景图"。
### Frame Caption 变体
```html
<!-- 标准:左 figure 名,右数字 -->
<figcaption class="frame-cap">
<span class="pf">推特 · Twitter</span>
<span class="nb">137K</span>
</figcaption>
<!-- 带编号 -->
<figcaption class="frame-cap">
<span class="idx">01</span>
<span class="pf">AI 润色</span>
<span>Polish</span>
</figcaption>
```
### 图片占位(设计阶段占位符)
图片还没有就位时,用虚线框占位:
```html
<div class="img-slot r-4x3"> <!-- r-4x3 / r-16x9(default) / r-3x2 / r-1x1 -->
<span class="plus">+</span>
<span class="label">GitHub 截图位置</span>
</div>
```
---
## Icons 图标
**严禁使用 emoji**。用 Lucide via CDN(template.html 已引入)。
```html
<i data-lucide="compass" class="ico-lg"></i> <!-- 大图标(pillar 用) -->
<i data-lucide="target" class="ico-md"></i> <!-- 中图标(列表项用) -->
<i data-lucide="check-circle" class="ico-sm"></i> <!-- 小图标(inline 用) -->
```
**常用 Lucide 图标名**(按含义分组):
- 判断类:`compass`, `target`, `crosshair`, `search-check`
- 关系类:`share-2`, `users`, `network`, `link`, `handshake`
- 品牌类:`crown`, `gem`, `award`, `star`, `badge-check`
- 流程类:`workflow`, `route`, `arrow-right-left`, `repeat`
- 数据类:`grid-2x2`, `bar-chart-3`, `trending-up`, `activity`
- 审美类:`palette`, `brush`, `eye`, `sparkles`
- 对错类:`check-circle`, `x-circle`, `check`, `x`
- 方向类:`arrow-right`, `arrow-up-right`, `corner-down-right`
**图标与文字 inline 组合**:
```html
<div class="h3-zh" style="display:flex;align-items:center;gap:.8em">
<i data-lucide="target" class="ico-md"></i>
判断 — 什么值得写
</div>
```
---
## Ghost 巨型背景字
用作"装饰性背景字",极低透明度,营造杂志感。
```html
<div class="ghost" style="right:-6vw;top:-8vh">BUT</div>
<div class="ghost" style="left:-8vw;bottom:-18vh;font-style:italic">Harness</div>
```
- 字号 34vw,opacity 0.06
- 常用定位:`right:-6vw;top:-8vh`(右上超出)/ `left:-8vw;bottom:-18vh`(左下超出)
- 内容:英文单词或数字(章节序号 01/02/03、关键词 BUT/NOW/HERE)
**注意**:使用 ghost 的页面里,其他内容要加 `position:relative;z-index:2` 避免被压到下面。
---
## Highlight 荧光标记
行内短语的"荧光笔"效果:
```html
<span class="hi">不是</span>
<span class="hi">一次性爆发</span>
```
在文字底部生成一条半透明高亮条。深色主题用亮条,浅色主题用暗条(CSS 已处理)。
**适合场景**:只对关键 1-3 个词使用,不要大面积用。
---
## Motion 动效系统
整套 deck 默认开启翻页入场动画,由 Motion One(vanilla 版 Framer Motion,约 4KB)驱动。
### 加载方式
`assets/template.html` 底部的 module script 会先尝试**本地** `assets/motion.min.js`,失败则回落到 **jsdelivr CDN**,两者都失败则强制把所有带 `data-anim` 的元素设为 `opacity:1`—— 内容永远可读,演示不依赖网络。
```js
// template 里的核心加载器(不用改)
let motion;
try { motion = await import('./assets/motion.min.js'); }
catch(e1) {
try { motion = await import('https://cdn.jsdelivr.net/npm/[email protected]/+esm'); }
catch(e2) {
document.querySelectorAll('[data-anim]').forEach(el=>{el.style.opacity='1';el.style.transform='none'});
}
}
```
### 数据属性驱动
你只需要在 HTML 里加两种属性:
```html
<!-- 1. 在 section 上选 recipe(可选,默认 cascade / hero 自动) -->
<section class="slide light" data-animate="quote">
<!-- 2. 在需要入场的元素上加 data-anim(可选值:left/right/line/step/divider) -->
<h1 class="h-xl" data-anim>大标题</h1>
<div class="stat-card" data-anim>...</div>
<div data-anim="left">左列内容</div>
<span data-anim="line" style="display:block">引用第一行</span>
```
### 5 种 recipe 一览
| recipe | 触发方式 | 行为 | 代表布局 |
|---|---|---|---|
| `cascade`(默认) | 不加 `data-animate` 即为此值 | 所有 `data-anim` 逐个 stagger 淡入,75ms/step | Layout 3 / 4 / 5 / 10 |
| `hero` | `.hero` slide 自动用此值 | 慢节奏 stagger,仪式感更强,160ms/step | Layout 1 / 2 / 7 |
| `quote` | `data-animate="quote"` | 其他元素先出,`data-anim="line"` 的行 550ms 间隔逐句揭示 | Layout 8 |
| `directional` | `data-animate="directional"` | `data-anim="left"` 从左滑入 → divider → `data-anim="right"` 从右滑入 | Layout 9 |
| `pipeline` | `data-animate="pipeline"` | 翻到此页 step 保持 15% 透明;按 →/空格/滚轮逐个点亮,最后一步才放行翻页 | Layout 6 |
### 给 slide 选 recipe 的决策树
1. **它是 `.hero` slide 吗?** → 不用加 `data-animate`,自动用 `hero`
2. **它是大引用金句页?** → `data-animate="quote"`,每句用 `<span data-anim="line" style="display:block">`
3. **它是左右对比 Before/After?** → `data-animate="directional"`,左列 `data-anim="left"`、右列 `data-anim="right"`
4. **它是流水线分步讲解?** → `data-animate="pipeline"`,每步 `data-anim="step"`
5. **其他所有正文页** → 什么也不加,自动用 `cascade`
### 什么元素该加 `data-anim`?
- ✅ 每一层有独立语义的块:kicker / h1 / h-xl / lead / callout / stat-card / figure / tag / rowline
- ✅ 多列结构里每一列,让它们逐列淡入而不是一起
- ❌ 不要在容器(`.grid-6` / `.frame`)上加,只加给叶子元素
- ❌ 不要在每个 `<li>` 上加,一般在 `<ul>` 层加就够
- ❌ 如果某页不想要任何动画(比如过渡页),整页不加 `data-anim` 即可 — Motion One 只对带标记的元素生效
### 常见问题
- **图片闪一下再出现?** 这是预期行为,翻页中段(450ms 时)触发动画
- **Pipeline 页卡住翻不下页?** 正确的,按 → 一步一步点亮 step,全部点亮后再按 → 才翻页
- **内容静态时也不显示?** 检查 motion.min.js 是否在 `assets/` 下;或者浏览器控制台看错误信息
references/image-prompts.md›
# GPT-M 2.0 配图提示词
用于 Codex 环境下为本 skill 生成 PPT 配图。提示词只负责定基调,不要写成长篇说明。先判断图片落位和比例,再选择类型。
## 通用规则
- 先判断当前 deck 风格:风格 A = 电子杂志 × 电子墨水;风格 B = 瑞士国际主义 / Swiss Style
- 风格 A 基调:电子杂志 × 电子墨水,克制、真实、留白充足,适合横向网页 PPT
- 风格 B 基调:Swiss International Typographic Style,12/16 列网格、Helvetica/Inter 气质、单一高饱和 accent、直角纯色、发丝线、极大留白
- 信息图、图表、截图再设计中的文字语言必须跟随用户语言:中文 deck 用中文,英文 deck 用英文
- 不生成卡通、3D、霓虹科技感、SaaS 模板感、过度装饰或假 logo
- 图片要给标题或正文留出可叠加空间,不要满屏堆细节
- 同一页或同一组图片必须使用同一比例、同一视觉缩放、同一边距密度
- 配图是嵌入 PPT 的素材,不是一张独立 slide:不要生成页眉、页脚、页码、标题栏、角标、署名、装饰边框或 slide chrome
- 生成后保存到 `images/`,命名为 `{页号}-{语义}.{ext}`
## 比例选择
| 用途 | 推荐比例 | HTML 落位 |
|------|---------|-----------|
| 章节封面 / 全屏主视觉 | 16:9 | `.frame-img.r-16x9` 或 hero 背景参考 |
| 瑞士风顶部横幅 / Image Hero | 16:9 或 21:9 | P22 顶图 cover / `.frame-img.r-21x9` |
| 左文右图主图 | 16:10 或 4:3 | `.frame-img.r-16x10` / `.frame-img.r-4x3` |
| 信息图 / 系统关系图 | 16:9 或 16:10 | 原始截图用 `.fit-contain`;按槽位重生成则用 `.frame-img.r-16x9` / `.frame-img.r-16x10` 铺满 |
| 截图再设计 / UI 情景图 | 16:10 或 21:9 | 原始截图用 `.fit-contain`;重生成到 S15/S16 时用 `.frame-img.r-21x9` 铺满 |
| 图文混排小图 | 3:2 或 3:4 | `.frame-img.r-3x2` / `.frame-img.r-3x4` |
| 图片网格 | 统一横图 | `.frame-img.h-22` / `.frame-img.h-26` |
| 小型面板组 | 统一横图 | `.frame-img.h-16` / `.frame-img.h-18` |
信息图和截图再设计如果来自不可控原始素材,优先用 `fit-contain`,避免文字被裁切;如果是 GPT-M 2.0 按槽位重新生成,必须生成同槽位比例并铺满容器,不要让小图漂在白框里。纪实照片优先用默认 `cover`,保持画面张力。
## 图片标准化策略
### A. 先选目标槽位
不要先生成图片再硬塞进页面。先决定图片落位:
1. 主视觉:16:9
2. 左文右图:16:10 或 4:3
3. 信息图/截图再设计:16:9 或 16:10,并使用 `fit-contain`
4. 多图网格/面板组:统一高度类,同一组内禁止混用高度
### B. 用户原始图片/截图的处理
原始截图比例通常不可控,不要直接作为最终视觉标准。按下面顺序处理:
1. 如果原图内容需要保真,先读 `screenshot-framing.md`,用 CleanShot X 式程序化适配:目标比例画布 + 风格化背景 + 截图等比缩放 + 语义化 padding/alignment
2. 如果原图比例接近目标槽位,直接放入统一 `.frame-img` 中,用 `cover` 或 `fit-contain`
3. 如果一张 UI 图被拉成巨长条,拆成 2-3 张同尺寸局部面板;每个面板使用同一高度类
4. 如果原图过高、过窄、过长且无法通过适配解决,再用"截图再设计 / UI 情景图"重新生成到目标比例
5. 如果必须保留原图,用 `fit-contain` 放进统一 frame,接受留白,不要裁掉关键文字
### C. 生成提示词后缀
每个配图提示词最后都补一句规格约束:
```text
输出必须是[16:9/16:10/4:3/3:2]横向构图,主体居中但保留边距,画面密度中等,与同组图片保持相同视觉缩放和边距。只保留核心图形/画面本身,不要生成页眉、页脚、标题、页码、角标、署名、装饰边框、超长条、竖图或不规则比例。
```
同一页需要多张图时,补一句:
```text
这是一组图片中的一张,请保持与同组图片相同的画面比例、元素大小、边距、线条粗细和标注密度。
```
## 类型 1: 人文纪实照片
用于增加现场感、情绪和真实世界锚点。
```text
生成一张横向纪实摄影配图,主题是:[页面概念]。风格像 Fujifilm / Leica editorial documentary,自然光、低饱和、轻微胶片颗粒、真实工作或生活现场,克制有人文温度。适合电子杂志 × 电子墨水 PPT,留出标题空间。不要商业摆拍、科幻界面、AI 机器人、logo 或水印。输出必须是[16:9/16:10/4:3]横向构图,主体居中但保留边距,画面密度中等。只保留核心照片本身,不要生成页眉、页脚、标题、页码、角标、署名、装饰边框、超长条、竖图或不规则比例。
```
## 类型 2: 杂志风信息图
用于解释概念、流程、对比、系统关系。
```text
生成一张横向杂志风信息图,解释:[概念/流程/关系]。电子墨水风格,黑白灰为主,少量低饱和强调色,细线条、网格、编号、短标签、留白充足。图中文字使用[中文/英文],保持简短可读。不要卡通、3D、霓虹科技感或模板感。输出必须是[16:9/16:10]横向构图,主体居中但保留边距,画面密度中等。只保留核心信息图本身,不要生成页眉、页脚、标题、页码、角标、署名、装饰边框、超长条、竖图或不规则比例。
```
## 类型 3: 流程 / Pipeline 图
用于讲清从 A 到 B 到 C 的过程。
```text
生成一张横向流程信息图,展示:[步骤 1] → [步骤 2] → [步骤 3] → [结果]。风格为电子杂志 × 电子墨水,细箭头、分段编号、短注释、克制留白。图中文字使用[中文/英文]。只保留核心流程图本身,不要页眉、页脚、标题、页码、角标、署名或装饰边框。比例:16:9。
```
## 类型 4: 对比图
用于 before / after、新旧模式、两种协作方式对照。
```text
生成一张横向对比信息图,左侧是[旧模式],右侧是[新模式]。风格像高端独立杂志里的分析图,黑白灰和一个低饱和强调色,细线分栏、短标签、清晰层级。图中文字使用[中文/英文]。只保留核心对比图本身,不要页眉、页脚、标题、页码、角标、署名或装饰边框。比例:16:9。
```
## 类型 5: 系统关系图
用于多角色、多工具、多模块之间的关系。
```text
生成一张横向系统关系图,展示:[角色/工具/模块]之间如何连接。电子墨水杂志风,节点、细线、箭头、编号和少量短注释,结构清晰,留白充足。图中文字使用[中文/英文]。只保留核心关系图本身,不要页眉、页脚、标题、页码、角标、署名或装饰边框。比例:16:9。
```
## 类型 6: 截图再设计 / UI 情景图
用于把真实截图、代码、设计稿、工作区处理成统一视觉素材。
```text
生成一张横向 UI 情景图,把[截图/界面/工作区内容]再设计成适合杂志风 PPT 的视觉。保留真实产品工作流的感觉,使用纸张底色、细线框、网格、少量标注和克制阴影。图中文字使用[中文/英文],短而清晰。不要真实品牌 logo、花哨 dashboard、霓虹渐变或过度拟物。输出必须是16:10横向构图,主体居中但保留边距,画面密度中等。只保留核心 UI 画面本身,不要生成页眉、页脚、标题、页码、角标、署名、装饰边框、超长条、竖图或不规则比例。
```
## 类型 7: 数据大字报图
用于突出一个关键数字或少量指标。
```text
生成一张横向数据大字报视觉,核心数字是:[数字],含义是:[含义]。风格为电子墨水杂志版式,超大衬线数字、少量短注释、细线、留白和纸张质感。图中文字使用[中文/英文]。只保留核心数据视觉本身,不要页眉、页脚、标题、页码、角标、署名或装饰边框。比例:16:9。
```
---
## 风格 B:瑞士国际主义配图规则
当 deck 选择 `assets/template-swiss.html` / `layouts-swiss.md` 时,优先使用下面这组提示词。它们和 GPT-M 2.0 配套,目标是生成能直接放进原始登记版式的图片槽位,尤其是 S22 顶部横幅、S15/S16 多图网格。
### Swiss 配图硬规则
- 视觉锚点:International Typographic Style / Swiss modernism / Helvetica / Josef Müller-Brockmann / Massimo Vignelli
- 构图:严格 12/16 列网格、非对称留白、左对齐、发丝线、直角模块
- 色彩:只使用黑、白、灰和**一个**主题 accent(默认 IKB 蓝;如果用户选柠檬黄/绿/安全橙,就替换为对应 accent)
- 禁止:渐变、阴影、圆角、玻璃拟态、霓虹、3D、卡通、SaaS 模板感、伪 logo、装饰边框
- 图片内部不要生成 PPT 外壳:不要页眉、页脚、页码、标题栏、角标、署名、外框
- UI / 信息图文字必须短,保持中文/英文语言一致;真实照片尽量不要带文字
- 先确定版式槽位再生成图片:单张大图用 `s22-hero-21x9`;多图格用 `s15-grid-21x9` 或 `s16-brief-21x9`
- 21:9 图片必须让核心主体落在中央 70% 安全区,四周留白;不要把人脸、关键节点或 UI 文字贴边
### Swiss 类型 1:纪实照片 / 案例主图
用于 S22 Image Hero,增加真实场景锚点。
```text
生成一张 21:9 超宽横向纪实摄影配图,主题是:[页面概念]。风格是 Swiss editorial documentary:高对比、低饱和、冷静克制、真实办公/城市/产品使用场景,构图有大量负空间,主体位于中央 70% 安全区,适合放入瑞士国际主义 PPT 的顶部横幅。不要 AI 机器人、科幻界面、商业摆拍、logo、水印或文字。只保留核心照片本身,不要页眉、页脚、标题、页码、角标、署名、装饰边框或 PPT 外壳。
```
### Swiss 类型 2:信息图 / 系统关系图
用于解释概念、架构、流程、数据与表现分离等抽象内容。
```text
生成一张横向 Swiss Style 信息图,解释:[概念/流程/系统关系]。使用 Helvetica/Inter 气质的无衬线短标签、12/16 列网格、直角模块、1px 发丝线、黑白灰和一个 [IKB 蓝/柠檬黄/柠檬绿/安全橙] accent。图中文字使用[中文/英文],每个标签不超过 8 个字/词。不要渐变、阴影、圆角、3D、卡通、霓虹或 SaaS 模板感。输出比例为[21:9/16:10],主体居中但保留大留白。只保留核心信息图本身,不要页眉、页脚、标题、页码、角标、署名、装饰边框或 PPT 外壳。
```
### Swiss 类型 3:截图再设计 / UI 情景图
用于把截图、工作区、代码、dashboard 重绘成统一 Swiss 风视觉。
```text
生成一张横向 UI 情景图,把[截图/界面/工作区内容]再设计成 Swiss International Typographic Style。画面使用极简 dashboard / workspace 结构,直角面板、发丝线、12 列网格、少量 [IKB 蓝/柠檬黄/柠檬绿/安全橙] accent,无阴影无圆角。图中文字使用[中文/英文],短而清晰,不要真实品牌 logo。输出必须是16:10横向构图,视觉密度中等,适合放进 `.frame-img.r-16x10.fit-contain`。只保留核心 UI 画面本身,不要页眉、页脚、标题、页码、角标、署名、装饰边框或 PPT 外壳。
```
### Swiss 类型 4:多图网格单张素材
用于 S15/S16 图片格改造,一组 2-6 张图片并列时逐张生成。
```text
生成一张横向证据图,主题是:[证据 A/B/C]。这是一组 Swiss Style 图片中的一张,请保持直角模块、黑白灰、单一 [IKB 蓝/柠檬黄/柠檬绿/安全橙] accent、相同边距、相同线条粗细、相同视觉缩放。图中文字使用[中文/英文],短标签即可。输出必须是[21:9/16:10]横向构图,适合放入 S15/S16 统一图片格。只保留核心图像本身,不要页眉、页脚、标题、页码、角标、署名、装饰边框或 PPT 外壳。
```
### Swiss 类型 5:极简图表 / 数据块
用于 S21 或 S15/S16 图片格中的小型数据解释图。
```text
生成一张横向 Swiss Style 数据图,核心数据是:[数字/对比/排名],含义是:[说明]。使用极大无衬线数字、1px 发丝线、直角色块、黑白灰和一个 [IKB 蓝/柠檬黄/柠檬绿/安全橙] accent,像瑞士海报里的数据版式。图中文字使用[中文/英文],只保留必要标签。不要渐变、阴影、圆角、3D 或装饰边框。比例:[16:9/16:10]。只保留核心数据图本身,不要页眉、页脚、标题、页码、角标、署名或 PPT 外壳。
```
references/layouts-swiss.md›
# Layouts · 风格 B 瑞士国际主义
22 个原始登记版式 · 严格模块化网格 · 每个版式说明用途、骨架、关键类名、专属动效。
> ⚠️ 这套版式与风格 A(电子杂志/电子墨水)**不通用**。类名同名但语义不同(例如 `h-hero` 在风格 A 是衬线,在风格 B 是无衬线极细 200)。一份 deck 只能选一套。
---
## Swiss locked mode(必须先读)
本主题的 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"`。
**关键约束**:
- 顶部中文标题默认左对齐并处在左上内容轴;不要把标题放到页面中间。
- 不允许临时发明原始 22P 之外的正文结构。本文档末尾的 P23/P24 属于历史实验区,默认禁用。
- 需要单张大图时使用 `S22 Image Hero`;需要多图时用 `S15/S16` 的原始矩阵/小报骨架改造成图片格。
- 地点、路线、人物住所、城市关系页使用 `S08 + Swiss Map Component`;这仍然是 S08 的右侧插槽扩展,不是新正文页。先读 `swiss-map-component.md`。
- SVG 只画几何,不写可见文字。标签放 HTML 里。
- 生成完成后运行 `node scripts/validate-swiss-deck.mjs index.html`。
---
## 设计语言基线
**配色**(`--accent` 由主题决定,见 `themes-swiss.md`)
- `--paper` 纸白底 #ffffff(主背景)
- `--ink` 黑墨字 #0a0a0a(主文字 / Ink 反转块)
- `--accent` 单色锚点(IKB 蓝默认 / 黄 / 绿 / 橙 四套)
- `--text-primary / secondary / helper` 三级文字灰阶
- `--border-subtle` 1px 发丝细线 #e0e0e0
**排版**
- 字体:`var(--sans)` Inter / Helvetica Neue + `var(--mono)` JetBrains Mono
- 字重:**200 (ExtraLight) 大字** / **300 (Light) 正文** / **600 (SemiBold) t-cat 小标**
- 大标题遵循原始 PPT 的实际页面用法:主标题 `font-weight:200`,重点词/数字 `font-weight:300`;不要因为旧 CSS helper 里残留过 800/900 就把 Swiss 大标题加粗
- 大字号收紧:`letter-spacing:-.04em` / `line-height:.9`
- mono 数字:`font-feature-settings:"tnum","ss01"`
**中文大标题字号分档**
中文方块字的视觉面积比英文更重,不能直接套英文页的 `6.8vw-7vw`。生成前先按中文标题长度降级:
| 中文标题形态 | 推荐字号 |
|---|---|
| 1 行,≤ 8 个中文字符 | `min(6.4vw,11.2vh)` |
| 2 行,每行≤ 8 个中文字符 | `min(5.8vw,10.2vh)` |
| 2 行,任一行 9-12 个中文字符 | `min(5.2vw,9.2vh)` |
| 3 行或更长标题 | 改写标题;实在不能改时用 `min(4.6vw,8.2vh)` |
规则:中文标题优先改短,其次降字号;不要让标题挤占下方图文区域。英文、数字型 hero 可以更大,中文方法论页必须更克制。
**演示最小字号与字重阶梯**
瑞士风不是网页说明页,投屏时不能出现 10-12px 的注释字。默认下限:
| 文本类型 | 最小字号 |
|---|---|
| 正文段落 / 主要说明 | `18px` |
| 卡片描述 / 列表 / 时间线说明 / caption / 图注 | `16px` |
| meta / kicker / mono label / 图表标签 | `14px` |
内容过多时,先压缩文案、拆页或更换 Sxx 版式;禁止靠降低小字字号解决拥挤。图注、时间线说明、KPI 注释、底部 note 尤其要守住这个下限。
**字号与字重阶梯(瑞士风核心)** — "越大越细,越小越粗"不是感性描述:
| 字号区间 | 推荐字重 | 典型场景 |
|---|---|---|
| ≥ 8vw | 200 (ExtraLight) | 封面大字、巨号 KPI、h-statement |
| 4-7.9vw | 200-300 | 章节标题(h-xl/h-xl-zh)、大编号 |
| 1.8-3.9vw | 300-400 | 中型标题、takeaway 标题(≈1.8vw)、中号数字 |
| 1-1.7vw / 16-20px | 400-500 | 正文段落、卡片描述、说明文字 |
| 13-15px(小字) | 500-600 | meta、kicker、角标、图表标签、caption 强调 |
**硬规则:**
- 同一页内,字号越小的元素字重必须 ≥ 字号越大的元素(不允许 16px 正文用 300 而 1.8vw 标题用 500)
- 16px 左右的小字拒绝使用 weight 300(太细不可读),最低 400,推荐 500
- 封面/IKB 反白大标题内强调字用 `italic + weight 300`,不要用 accent 色(蓝压蓝看不见)
**网格**(IBM Carbon 2x Grid 改造)
- 16 列 grid:`grid-template-columns:repeat(16,1fr)` + `gap:16px`
- spacing token:`--sp-3` 8 / `--sp-4` 12 / `--sp-5` 16 / `--sp-6` 24 / `--sp-7` 32 / `--sp-8` 40 / `--sp-9` 48 / `--sp-10` 64 / `--sp-11` 80 / `--sp-12` 96 / `--sp-13` 160
**画布**
- `.canvas-card`:`100vw × 100vh`,直角无圆角,padding `5.6vh 5vw 4.4vh`
- `body{background:var(--paper)}` — 不用 WebGL 背景
- 必须保留右下角 `B 静态` 快捷键。低功耗模式使用 `body.low-power`,停止 WebGL/ASCII canvas RAF 与 Motion 入场动画,刷新后通过 `localStorage` 保持用户选择。
---
### P0 对齐法则(每生成一页都先过这 4 条,违反 = 整页报废)
**1. 不要二次叠加水平 padding** ⚠️ 最常踩
`.canvas-card` 已自带 `padding:5.6vh 5vw 4.4vh`。
chrome-min(页眉)、主体内容、底部 footnote 都是 canvas-card 的子元素,**共用同一条 5vw 边线**。
如果在主体那层再写 `padding:5vh 5vw 4vh`,水平方向就变成 `5vw + 5vw = 10vw`,主体比 chrome-min 多内缩一圈,左右对不齐。
```html
<!-- ❌ 错:主体多缩了 5vw -->
<div class="canvas-card">
<div class="chrome-min">...</div>
<div style="flex:1;padding:5vh 5vw 4vh;...">主体内容</div>
</div>
<!-- ✅ 对:主体 padding 为 0,只用 grid gap 控垂直间距 -->
<div class="canvas-card">
<div class="chrome-min">...</div>
<div style="flex:1;padding:0;display:grid;grid-template-rows:auto 1fr auto;gap:3vh">主体内容</div>
</div>
```
例外:`.slide.split .canvas-card{padding:0}` 已被 CSS 覆盖,split 模式下两个 `.half` 自己控制 padding(常用 `5.6vh 3.6vw 4.4vh`),与本法则不冲突。
**2. kicker 必须在大标题"上方",不要压成左右**
小标题(`.t-meta` / `.t-cat`)与大标题之间是从属关系,版式上必须**上下结构**。
```html
<!-- ❌ 错:auto 1fr 把 kicker 和大标题挤成左右两列 -->
<div data-anim="head" style="display:grid;grid-template-columns:auto 1fr;gap:3vw;align-items:end">
<div class="t-meta">METHODOLOGY · 03</div>
<h2 class="h-xl-zh">为什么是 N+1</h2>
</div>
<!-- ✅ 对:flex column 上下叠 -->
<div data-anim="head" style="display:flex;flex-direction:column;gap:1.4vh">
<div class="t-meta">METHODOLOGY · 03</div>
<h2 class="h-xl-zh">为什么是 N+1</h2>
</div>
```
**3. 双约束限高 `min(Xvw, Yvh)` 中 Y ≥ X × 1.6**
标准 16:9 屏 1vw : 1vh ≈ 1.78,如果 Y 太严(例如 `min(7vw, 10vh)`),大字号会被高度上限截断到 10vh,不再受 7vw 主导,显得整体缩小。
经验数值:
| 用途 | 推荐 |
|---|---|
| h-hero 巨字宣言 | `min(11.6vw, 19vh)` |
| h-xl 章节标题 | `min(7vw, 12vh)` ~ `min(7.4vw, 13vh)` |
| 大数字 KPI | `min(8.4vw, 14vh)` |
| 中数字 / 编号 | `min(4.6vw, 8.5vh)` ~ `min(5.6vw, 10vh)` |
**4. canvas-card 子元素之间用 grid `gap`,不要靠 margin/padding 堆**
`.canvas-card` 默认 `display:flex;flex-direction:column`,chrome-min 自带 `margin-bottom:48px`(`--sp-9`)。
主体区往下排几行(head / 内容 / footnote),**首选** `display:grid;grid-template-rows:...;gap:Nvh`,**次选** flex column + gap,**禁用** 在每个子块里加 `margin-top` / `padding-top` 调间距(会和 chrome-min 的 margin-bottom 重叠或撕裂)。
**5. 底部分页安全区:主内容最低处不要触及 nav**
底部分页 dot 固定在 `bottom:2vh`,视觉上占据约 `93vh` 之后的区域。主内容、图片 caption、图表说明、timeline label 的最低处必须停在安全区上方。
- 模板提供 `--nav-safe-bottom:8vh`,可用 `.nav-safe-bottom` / `.nav-safe-bottom-tight`
- P23 使用 `.swiss-img-split.align-image-bottom` 时,模板会自动给底部加安全区,避免图片 caption 被分页组件挡住
- 如果为某页手写 `align-items:end` / `margin-top:auto` / `position:absolute;bottom:...`,必须肉眼检查最低处是否越过 nav
- 视觉自检:打开页面到该页,确认内容最低边缘与分页 dot 之间至少有 `3vh` 呼吸空间
---
**卡片填充规则(必须遵守)**
| 类型 | 类名 | 角色 | 用法 |
|---|---|---|---|
| Ink 黑底 | `.card-ink` | 反转 / 宣言 | hero 块、收束页一半 |
| Accent 蓝填充 | `.card-accent` | 唯一焦点 | 一组中突出一项 |
| Grey 灰底 | `.card-fill` | 默认中性 | 多卡并列、统计卡 |
| Outlined 描边 | `.card-outlined` | 锚点(非卡片) | hairline 分割框 |
❌ 禁止混用(蓝色背景+蓝色描边、灰底+描边等)
**装饰极简原则**
- 1px hairline 分隔(`hr-hairline` / `border-bottom`)
- 8×8 / 12×12 直角小方块替代圆点
- 点阵 `dot-mat` / 描边圆 `ring-mat` / 叉 `cross-mat`(SVG mask)
**图片使用原则(Swiss + GPT-M 2.0)**
- 图片是网格中的"证据块",不是装饰背景;必须有明确功能:案例、实拍证据、UI 截图、系统图、概念信息图
- 所有图片容器保持直角、无阴影、无圆角;默认**不加图片外框**,让 caption 或页面网格承担层级
- 白底信息图 / 流程图 / UI 图:容器背景必须是 `var(--paper)`,不要用灰底包白图,也不要加 `.swiss-keyline` 描边
- 只有当图片本身边缘无法和页面区分时,才用 `.swiss-lined` 加一条顶部 accent 线;不要给每张图都套边框
- 纪实照片用 `object-fit:cover` 只裁底部/边缘;原始截图或文字密集图用 `.fit-contain`,避免文字被裁
- 如果信息图、流程图、UI 情景图是按 S15/S16 槽位重新生成的,必须用 `.frame-img.r-21x9` / `.frame-img.r-16x10` 铺满槽位;不要再加 `.fit-contain`,否则会变成小图漂在白框里
- 瑞士风图片优先比例:S22 顶部横幅 `21:9`;S15/S16 多图格统一 `21:9` 或统一 `16:10`
- 生成 2-3 张配图时,必须先绑定原始版式槽位:单张大图 = S22;多图 = S15/S16 网格改造;不要使用未登记的 P23/P24
- S22 的照片主体必须位于中央安全区,HTML 用 `object-position:center 35%` 或 `center center`,不要用 `top center` 截人脸
- GPT-M 2.0 生成图必须遵守单一 accent 色、Helvetica/Inter 气质、12/16 列网格、直角纯色、无渐变/阴影/圆角
- 生成图只保留核心图像本身,不要把页眉、页脚、标题、页码、角标、边框、署名画进图片里
**版式多样性硬规则**
Swiss 主题有 22 个登记版式,生成时要主动展示版式系统,不要把所有内容都做成 `head + grid-reveal + card`:
- 7-8 页 deck 至少使用 **6 个不同 S 编号版式**
- 不允许连续 3 页使用同一种主体结构(如三页连续 S19 / 普通卡片)
- 如果是"测试模板"或"我想看看效果",必须覆盖:封面、收尾、至少 1 个对比/时间线(S08/S11/S02)、至少 1 个结构图(S14/S17/S15)、至少 1 个图片版式(S22 或 S15/S16 图片格)
- 图片页不等于新发明一页。单图用 S22,多图用 S15/S16 的原始网格骨架改造
- 每页写代码前先列 `页码 → data-layout → 为什么选它 → 图片槽位`;生成后用 validator 检查
**动效原则(每页一个语义化 recipe)**
- 不是统一 fade-up,而是**与图形语义耦合**:数字 scale 弹入、bar scaleY 拉起、SVG 圆环 stroke-dashoffset 描线、时间线节点序列点亮
- 缓动:`EASE_PROD` `cubic-bezier(.2,0,.38,.9)` 用于 productive(120-240ms)、`EASE_ENTRY` `cubic-bezier(0,0,.3,1)` 用于 expressive(400-700ms)
- playSlide 入口要 reveal 所有 `[data-anim]` 容器到 opacity:1,recipe 内再用 motion `{opacity:[0,1]}` 覆盖
---
## 视觉 + 代码双维审核(生成后必须做)
不要只看 HTML/CSS。Swiss 模板的还原度要同时从**浏览器视觉**和**代码结构**判断:
1. 同时打开两份页面:当前 `template-swiss.html`(golden source 快照)和正在修改的测试 PPT;有条件时再加一份此前验收过的成品 deck 作对照。
2. 截图前先等入场动效稳定(约 1-2 秒)。不要把动画中间态误判成"内容缺失"或"版式空白"。
3. 先看视觉:标题重量、头部距离、图片落位、底部安全区、caption 是否被 nav 挡住。
4. 对照原始参考 PPT 的同类版式,不要只对照 CSS helper;以实际页面结构和视觉结果为准。
5. 再回到代码,检查该页是否误用了不属于该版式的组件,例如把 P24 的三图证据墙塞进 P23,或把 P7 图表用于没有真实数值的概念列表。
6. 若视觉不一致,优先判断是**版式选择错**、**必选组件缺失**、**可选组件滥用**还是**间距/安全区问题**,不要直接靠调 `margin` 硬救。
7. 修改模板时,新增能力必须用新类隔离;不要因为一页出问题去改全局基座类。
### 原始 PPT 视觉锚点(对照时优先看这些)
| 视觉锚点 | 原始 PPT 的实际做法 | 生成时的规则 |
|---|---|---|
| 大标题重量 | 实际页面大量使用 `font-weight:200/300`;即使 raw CSS helper 里有 700/800/900,也不能直接当视觉标准 | 大标题保持轻字重,字号越大越细 |
| 留白 | 页面经常只占上半屏或中部,底部留给 nav 和少量 footnote | 不要为了"填满"而把内容推到底 |
| 分割线 | 只在章节边界、证据墙、卡片层级处使用 1px hairline | 不要给每个内容块都加线 |
| 标题与内容 | 标题区和正文/图表之间有明显空气感 | 复杂页用 grid `gap`,不要让内容贴着标题 |
| Timeline | 轴线在中下部,但 label 不碰底部 nav | 横向 timeline 必须同时检查上下 label 和 nav 安全区 |
| 图片页 | 图片是证据块,要么做 S22 主视觉,要么放进 S15/S16 原始网格 | 不要使用未登记图文结构 |
### 组件必选 / 可选 / 可省略
| 组件 | 规则 |
|---|---|
| `.canvas-card` / `.chrome-min` | 基础页必选;split 页左右 half 各自有 chrome-min |
| `t-meta` / `t-cat` kicker | head 区必选,但正文卡片内可省略;必须在大标题上方 |
| 大标题 | 章节/论点页必选;列表型小卡页可以用较小标题,但不能缺页级信息锚点 |
| `lead` 说明 | 可选;如果标题已经解释清楚,可以省略,但不能用长段正文贴着标题 |
| 图片 caption | S15/S16 多图格必选;S22 大图可选,因为图已经是主视觉且下方有 KPI/说明 |
| 发丝线 / border-bottom | 可选;只能用于建立层级,不能为了装饰堆线 |
| KPI / 数字 | 只在有真实数据时使用;不要为概念解释编造数值 |
| `footnote` / 底部说明 | 可选;如果使用,必须避开 nav 安全区 |
| `S08 + Swiss Map Component` | 地点/路线/人物住所关系专用;右侧地图必须有点、连线、卡片和 `+` / `-` / `DRAG` 控制,详见 `swiss-map-component.md` |
### 通用版式 / 非通用版式
| 类型 | 版式 | 使用边界 |
|---|---|---|
| 通用 | S01, S03, S08, S09, S10, S11, S19 | 大多数叙事 deck 都能用,但仍要满足内容形状 |
| 条件通用 | S04, S05, S13, S16 | 取决于数量是否刚好匹配:3/4/6 项 |
| 数据专用 | S02, S06, S07, S18, S20, S21, S22 | 必须有真实时间、数值、指标或案例数据 |
| 结构专用 | S14, S15, S17 | 必须有闭环、矩阵、层级/生态关系;不适合普通段落 |
---
## 22 个登记版式
### P1 · Cover · 封面页
**用途**:整套 deck 起手 / 主题宣言。
**适用内容类型**:封面 / 章节首页 / 主题宣言。**纯文字结构**(主标题 + 副标 + 元信息),不承载数据。
**默认推荐:IKB 满屏 + ASCII 呼吸场** ⭐
- `<section class="slide accent">` 满屏 IKB,**不是** light 白底
- `.canvas-card` 内首位插入 `<canvas class="ascii-bg" aria-hidden="true">`,模板底部 IIFE 自动驱动 sin/cos 二维噪声呼吸场
- 主标题反白 weight 200,微强调字用斜体(`font-style:italic;font-weight:300`)而非 IKB 蓝(底已是蓝、蓝压蓝看不见)
- **不要**再放编号大字"01"——chrome-min 已经标 01/NN
- 与 P9 Closing 的 IKB 半屏配合形成"开场全 IKB ↔ 收尾半 IKB"色彩闭环
**关键类**:`.slide.accent` `.ascii-bg` + `min(11.6vw,19vh)` 双约束大字
**动效 recipe**:`hero` — ASCII 字符场持续呼吸,文字 fade-up 序列入场
**示例代码(IKB 默认变体)**:
```html
<section class="slide accent" data-animate="hero">
<div class="canvas-card">
<canvas class="ascii-bg" aria-hidden="true"></canvas>
<div class="chrome-min">
<div class="l">[必填] Deck 标题 · Issue/Field Note 编号</div>
<div class="r">SS · 26.05.10 · 01 / NN</div>
</div>
<div style="flex:1;padding:0;display:grid;grid-template-rows:auto 1fr auto;gap:2.6vh">
<div data-anim="kicker" class="t-meta" style="color:rgba(255,255,255,.78);letter-spacing:.22em">[必填] 章节英文 / Section En</div>
<h1 data-anim="title" style="align-self:center;font-family:var(--sans),var(--sans-zh);font-weight:200;font-size:min(11.6vw,19vh);line-height:.94;letter-spacing:-.025em;color:#fff">[必填] 中文主标题<br/>(可在某字加 <span style="font-style:italic;font-weight:300">italic</span> 微强调)</h1>
<div data-anim="bottom" style="display:grid;grid-template-rows:auto auto;gap:1.6vh;border-top:1px solid rgba(255,255,255,.22);padding-top:2vh">
<div data-anim="lead" class="lead" style="max-width:52ch;color:rgba(255,255,255,.86);font-weight:300">[必填] 一段 1-2 行的副标 / 引子,定调全场.</div>
<div style="display:flex;justify-content:space-between;align-items:end">
<div class="t-meta" style="color:rgba(255,255,255,.6)">[选填] 作者 · 日期 · 出处</div>
<div class="t-meta" style="color:rgba(255,255,255,.6)">→ swipe / arrow keys</div>
</div>
</div>
</div>
</div>
</section>
```
**经典变体(左 ink + 右 paper 对开)** — 仅当全 IKB 不合内容调性时使用:
```html
<section class="slide" data-animate="cover-reveal">
<div class="canvas-card cover-split">
<div class="cover-ink">
<span class="t-cat">Volume 18 · 2026</span>
<h1 class="h-hero">Thin Harness,<br>Fat Skills.</h1>
<span class="t-meta">— Kevin · 2026-05</span>
</div>
<div class="cover-paper">
<p class="lead">薄型承载层,厚重技能。</p>
<ul class="meta-list">
<li>22 PAGES</li><li>SWISS · IKB</li><li>MP-75</li>
</ul>
</div>
</div>
</section>
```
---
### P2 · Vertical Timeline · 纵向时间轴
**用途**:演化对比、年代变迁、版本迭代(2-5 个时间节点)。
**适用内容类型**:**带量化数据的时间演化**。每节点必须有「年份 + 量化数值(如 1× / 4× 倍数 / 单位数字)+ 描述」三件套。如果只有节点名没有数据,改用 P11 横向时间线。
**骨架**:左侧 axis 列 12px 圆点 + 1px 虚线轴 / 右侧节点信息(年份 + 大字数据 + 小标 + 描述)。
**关键类**:`.timeline-v` `.tl-node` `.tl-axis`(12px 固定列宽,绝对定位 dot 防错位) `.kpi-row-4`
**动效 recipe**:`timeline-vertical` — 节点按时间顺序由上到下点亮(dot 先 pop 再扩 → 文字横向滑入)
**网格规则**:axis 列 = 12px 固定;dot 用 `position:absolute;left:50%;transform:translateX(-50%)` 与虚线对齐
**示例代码**:
```html
<section class="slide" data-animate="timeline-vertical">
<div class="canvas-card">
<header class="chrome-min">...</header>
<div class="timeline-v">
<div class="tl-node">
<div class="tl-axis"><span class="dot"></span></div>
<div class="tl-body">
<span class="yr">2023</span>
<span class="multi">1<small>×</small></span>
<p class="desc">Prompt Engineering Era</p>
</div>
</div>
<!-- 重复 N 个 tl-node,axis 列贯穿 -->
</div>
</div>
</section>
```
---
### P3 · Statement · 极简陈述
**用途**:中心论点、章节起始、口号。一页只放一句话 + 简单装饰。
**适用内容类型**:**纯定性论断 / 口号 / 章节切换**。一句话压缩到 8-12 词,**不承载任何数据或列表**。如果需要数据支撑,改用 P18 Why Now;如果是封面,用 P1。
**骨架**:左 1/3 空白 + 中段巨字陈述(8-10vw, weight 200) + 右下小字注脚 + 底部 hairline。
**关键类**:`.h-statement`(9.6vw,letter-spacing:-.05em) `.stmt-anchor`
**动效 recipe**:`statement-rise` — 大字按词序错峰升起(每词延迟 180ms)+ 注脚 fade in
**示例代码**:
```html
<section class="slide" data-animate="statement-rise">
<div class="canvas-card">
<header class="chrome-min">...</header>
<h1 class="h-statement">
<span>Build it</span> <span>once.</span><br>
<span>It runs</span> <span>forever.</span>
</h1>
<span class="stmt-anchor">— Statement 03</span>
</div>
</section>
```
---
### P4 · Six Cells · 六格定义
**用途**:6 个并列概念定义、6 项功能并列。
**适用内容类型**:**6 个对等概念 / 功能列举**(数量必须 = 6,过少用 P5,过多用 P15/P16)。每格仅承载「图标 + 编号 + 短标题 + 一行描述」,**不承载需要展开的数据 / 段落**。
**骨架**:2×3 网格 / 每格上方 lucide 图标 + 编号 + 短标题 + 一行描述 / 单元间用 hairline 分隔。
**关键类**:`.cell-6` `.cell-icon-row` `.cell-num`
**动效 recipe**:`six-cells` — 6 格按 z 形顺序点亮(L→R, T→B,每格延迟 90ms)
**注意**:**不要自己画 SVG 图标**,用 `<i data-lucide="bookmark"></i>` 引线上 lucide。
**示例代码**:
```html
<div class="cell-6">
<div class="cell">
<i data-lucide="square-stack"></i>
<span class="cell-num">01</span>
<h4>Skill File</h4>
<p>纯 markdown,可手写、可重写</p>
</div>
<!-- 5 more -->
</div>
```
---
### P5 · Three Sub-cards · 三子卡
**用途**:三步流程、三类对比(轻度差异)。
**适用内容类型**:**3 个对等概念 / 步骤**(数量必须 = 3)。结构同质、**无强烈数据差异**(若数据可比,改用 P6 KPI Tower)。每卡内容比 P4 略多(编号 + 标题 + 1-2 行描述)。
**骨架**:左侧大标题 + 描述 + 顶部 hairline / 右侧 3 张水平堆叠 sub-card。
**关键类**:`.sub-card-stack` `.sub-card`(`.card-fill` 灰底,直角)
**动效 recipe**:`sub-stack` — 主标题先入 → 3 卡阶梯式从右滑入(每卡延迟 140ms)
**示例代码**:
```html
<div class="grid-2-9">
<div class="lead-col">
<span class="t-cat">Three Forces</span>
<h2 class="h-xl">压成三个事实</h2>
</div>
<div class="sub-card-stack">
<article class="card-fill sub-card">
<span class="big-num">01</span>
<h4>Skill File</h4>
<p>...</p>
</article>
<!-- 2 more -->
</div>
</div>
```
---
### P6 · KPI Tower · 不等高柱状 KPI
**用途**:4 项数据用视觉高度表达层级差异。
**适用内容类型**:**4 项可比量化数据**(必须有真实数值,bar 高度由数据决定)。典型如:成本、容量、计数、效率指标。**禁止**用于无数据的概念列举(那是 P4/P5 的事)。
**骨架**:4 列均分,每列底部一根不同高度的 IKB 蓝矩形(数据决定高度)+ 顶部图标 + 中段巨数 + 底部标签。
**关键类**:`.kpi-tower-row` `.bar-tower`(min-height:6vh, max:36vh) `.tower-cap`
**动效 recipe**:`tower-grow` — 标签先入 → 数字 scale 弹入 → tower scaleY 从 0 拉起(transform-origin:bottom)
**示例代码**:
```html
<div class="kpi-tower-row">
<div class="tower-col">
<i data-lucide="layers"></i>
<span class="num-mega">90K</span>
<span class="lbl">Skills</span>
<div class="bar-tower" style="--h:36vh"></div>
</div>
<!-- 3 more,h 不同 -->
</div>
```
---
### P7 · H-Bar Chart · 横向条形图
**用途**:多项排名比较 / 占比对比(5-10 项)。
**适用内容类型**:**5-10 项可比量化数据**(必须有真实百分比 / 评分 / 数值,bar 宽度由数据决定)。典型如:benchmark 排名、市场份额、问卷占比。⚠️ **严禁用于无量化数据的概念列举**(那是 P4/P5/P15)— 编造数字会被识破。
**骨架**:顶部大标题 / 中段空 / 下半部条形列表(每行:文字标签 + 1px 蓝条 0→target width + 末端数字)。
**关键类**:`.h-bar-chart` `.bar-row` `.bar-fill`(scaleX 动画)
**动效 recipe**:`hbar-grow` — 大标题先入 → 每行依序 width 0→target(transform-origin:left)+ 末端数字 count-up
**示例代码**:
```html
<div class="h-bar-chart">
<div class="bar-row">
<span class="bar-lbl">Anthropic Advisor</span>
<span class="bar-fill" style="--w:84%"></span>
<span class="bar-num">84</span>
</div>
<!-- N more -->
</div>
```
---
### P8 · Duo Compare · 双轨对照
**用途**:Before/After、A vs B、旧/新对比。
**适用内容类型**:**二元对照**(必须正好 2 项)。两侧结构同质(t-cat 标签 + 大字标题 + 段落 / 列表说明)。典型如:旧/新工作流、传统/AI、客户视角/团队视角。
**骨架**:左右两半屏中间一根纵向 1px 长线分隔 / 各自顶部 t-cat + 大字标题 + 下方说明。
**关键类**:`.duo-compare` `.duo-half` `.vrule`(scaleY 拉开)
**动效 recipe**:`duo-mirror` — 中线 vrule 先 scaleY 0→1 → 左右各自标题、文字镜像入场
**示例代码**:
```html
<div class="duo-compare">
<div class="duo-half">
<span class="t-cat">Before</span>
<h2>交给模型</h2>
</div>
<span class="vrule"></span>
<div class="duo-half">
<span class="t-cat">After</span>
<h2>交给代码</h2>
</div>
</div>
```
---
### P9 · Closing Manifesto · 收束宣言
**用途**:整套 deck 收尾页。
**适用内容类型**:**deck 收尾**(每个 deck 只有一页)。固定结构:左侧宣言短句 + 右侧 3 条 takeaway(编号 + 标题 + 一行说明)。**不能在中间页使用**(那会与 P1 封面重复)。
**默认推荐:左 IKB+ASCII / 右 paper takeaway** ⭐
- 用 `<section class="slide split">` + 左半 `.half.b-accent` + ASCII canvas + 右半白底 takeaway
- 与 P1 封面的全 IKB 形成"开场全 IKB ↔ 收尾半 IKB"色彩闭环
- 右侧第 03 条 takeaway 用 `var(--accent)` 强调,把 IKB 蓝从左半穿到右半,完成色彩缝合
- 大标题反白 weight 200,强调字用斜体(底已是蓝、不要再用 `var(--accent)` 标蓝)
**关键类**:`.slide.split` `.half.b-accent` `.ascii-bg`(IIFE 自动启动)
**动效 recipe**:`split-statement` — 左 ink/IKB 标题字符序列升起 → 右白半 takeaway 三条尾随
**示例代码(IKB 默认变体)**:
```html
<section class="slide split" data-animate="split-statement">
<div class="canvas-card">
<div class="split-half">
<!-- 左半 · IKB + ASCII 呼吸场 -->
<div class="half b-accent" style="padding:5.6vh 3.6vw 4.4vh;justify-content:space-between;position:relative;overflow:hidden">
<canvas class="ascii-bg" aria-hidden="true"></canvas>
<div class="chrome-min" style="margin-bottom:0;position:relative;z-index:1">
<div class="l">NN / NN</div>
<div class="r">CLOSING</div>
</div>
<div data-anim="manifesto" style="display:flex;flex-direction:column;gap:2vh;position:relative;z-index:1">
<div class="t-meta" style="color:rgba(255,255,255,.78);letter-spacing:.22em;margin-bottom:1.6vh">MANIFESTO</div>
<h2 style="font-family:var(--sans),var(--sans-zh);font-size:min(8vw,14vh);line-height:.94;letter-spacing:-.025em;font-weight:200;color:#fff">[必填] Build a model.<br/>Run <span style="font-style:italic;font-weight:300">forever</span>.</h2>
<div style="font-family:var(--sans),var(--sans-zh);font-size:max(13px,1vw);line-height:1.6;color:rgba(255,255,255,.82);font-weight:300;max-width:36ch;margin-top:1.4vh">[必填] 一句中英文落地注脚.</div>
</div>
<div data-anim="signature" style="display:flex;justify-content:space-between;align-items:end;border-top:1px solid rgba(255,255,255,.22);padding-top:2vh;position:relative;z-index:1">
<div class="t-meta" style="color:rgba(255,255,255,.62)">[选填] 作者 · 头衔</div>
<div class="t-meta" style="color:rgba(255,255,255,.62)">YY.MM.DD</div>
</div>
</div>
<!-- 右半 · 白底 takeaway,第 03 条用 IKB 蓝强调,首尾色彩闭环 -->
<div class="half" style="padding:5.6vh 3.6vw 4.4vh;justify-content:space-between">
<div class="chrome-min"><div class="l">TAKEAWAYS</div><div class="r">03 RULES</div></div>
<div data-anim="rules">...</div>
<div class="t-meta" style="color:var(--text-helper);text-align:right">→ 完 · END OF FIELD NOTE</div>
</div>
</div>
</div>
</section>
```
**经典变体(`.closing-split` ink 双半屏)** — 当封面没有用 IKB 满屏时,改用经典 ink 收束:
```html
<div class="closing-split">
<div class="cl-ink">
<p class="line-mega">Build it<br>once.</p>
<p class="line-mega">It runs<br>forever.</p>
</div>
<div class="cl-paper">
<ul class="takeaway-list">
<li><span class="num">01</span><h4>Skill</h4><p>...</p></li>
<!-- 2 more -->
</ul>
</div>
</div>
```
---
### P10 · Dot Matrix Statement · 点阵宣言
**用途**:第二张陈述页 / 章节切换 / 视觉透气页。
**适用内容类型**:**口号 / 隐喻 / 章节切换**(同 P3,但加几何点阵装饰)。用于一个 deck 内**避免连续两页都是 P3**;通常用作"概念定义"前的视觉调味页。
**骨架**:中段 7vw 巨字三行宣言 / 右上角 36vw 圆点矩阵 + 左下角描边圆环矩阵。
**关键类**:`.dot-mat`(SVG mask 实心点)`.ring-mat`(描边圆)`.cross-mat`(× 网格)
**动效 recipe**:`matrix-statement` — 文字逐行入 → 点阵 mask-position 从左推到右
**示例代码**:
```html
<div class="canvas-card">
<span class="ring-mat" style="left:5vw;bottom:5vh;width:18vw;height:18vw"></span>
<h1 class="h-statement">Build a thin harness.<br>Write fat skills.<br>Codify everything.</h1>
<span class="dot-mat" style="right:0;top:0;width:36vw;height:36vw"></span>
</div>
```
---
### P11 · Horizontal Timeline · 横向时间线
**用途**:多步骤流程(4-7 步)、时间演进。
**适用内容类型**:**4-7 步线性流程**(每步只有一个名称,不需要展开数据 / 描述)。如果每步要展开,改用 P5;如果有量化数据,改用 P2。**禁止**用于循环结构(那是 P14)。
**骨架**:顶部大标题 / 中段一根 1px hairline 横线 + N 个均布节点(8×8 直角方块 + 上方 mono 编号 + 下方步骤名)。
**关键类**:`.timeline-h` `.tl-h-node` `.tl-h-axis`
**动效 recipe**:`timeline-walk` — 节点沿轴左→右依次点亮(每节点 220ms)
**对齐注意**:横向时间线 label 的 CSS 依赖 `translateX(-50%)` 居中。动效里如果要做上下位移,必须写完整 `transform: translate(-50%, y)` 序列,不能只写 `y`,否则动画结束后 label 会偏离 dot。
**示例代码**:
```html
<div class="timeline-h">
<span class="tl-h-axis"></span>
<div class="tl-h-node">
<span class="num">01</span>
<span class="dot"></span>
<span class="lbl">Investigate</span>
</div>
<!-- 4-6 more -->
</div>
```
---
### P12 · Manifesto + Ink Banner · 宣言 + 通栏 ink 条
**用途**:阶段性结论、章节封底、口号 + 视觉强收束。
**适用内容类型**:**章节性收束 / 阶段性宣言**(用于 deck 中段而非结尾,P9 是 deck 终结)。承载「主张 + 简短说明 + ink 通栏宣言」三段结构,无数据。
**骨架**:上半屏左侧 t-cat + 大字 4 行宣言 + 右侧短段说明 / 下半屏 ink 通栏(无左右下边距)+ 反白短句 + lucide 图标矩阵。
**关键类**:`.manifesto-top` `.ink-banner-full`(`margin:0 -5vw -4.4vh` 取消父级 padding)
**动效 recipe**:`manifesto` — 大字三段错峰升起 → 底 ink 条横向 scaleX 0→1 铺开 → 反白文字 fade in
**注意**:Skill File 那段小字 **顶对齐于右侧大字基线**(`align-items:flex-start;padding-top:1.2vw`)
---
### P13 · Three Forces Cards · 三力卡片小报
**用途**:3 个对等概念展示(每个 = 巨数 + 标题 + 双列描述)。
**适用内容类型**:**3 个对等概念深化**(数量 = 3,比 P5 承载更多文字)。每卡内容比较丰富(巨编号 + 标题 + 双列段落描述)。01/02/03 为编号锚点而非真实数据。典型如:三大反驳、三种力量、三大主张。
**骨架**:左 5/16 ink hero 块(t-cat + 4 行标题 + 点阵装饰)/ 右 11/16 三张水平卡堆叠。
**关键类**:`.three-forces` `.hero-ink-col` `.force-card`(`.card-fill`)`.force-num`(9.2vw IKB 蓝)
**动效 recipe**:`three-forces` — 左 hero 横移入 → 右 3 卡阶梯式从右滑入 → 巨蓝数字单独 pop
**注意**:**3 张卡片必须统一样式**(都用 `.card-fill` 灰底,不要混用描边/蓝底);若需突出一张,改用 `.card-accent`,**禁止**蓝底+描边。
---
### P14 · Loop Diagram · 闭环流程图
**用途**:自学闭环、自动化流程(3-5 步循环)。
**适用内容类型**:**循环 / 闭环流程**(终点回到起点,3-5 步)。如自学循环、CI/CD、反馈闭环、agent loop。**线性流程禁用**(那是 P11)。
**骨架**:左 4 行编号步骤(顶对齐) / 右侧 SVG 同心圆环 / 中央巨字 LOOP / 节点统一灰底直角方块(不用圆点交替色)。
**关键类**:`.loop-diagram` `.loop-steps` `.loop-svg`
**动效 recipe**:`loop-form` — 左侧步骤纵向序列 → 右 SVG 圆环 stroke-dashoffset 描线 → 节点序列点亮
**注意**:左右**整体居中对齐**(顶部对齐 + 高度等同)
---
### P15 · Image Matrix + Hero Stat · 矩阵 + 大字底注
**用途**:大量同类项展示(8-12 项 skill / 团队成员 / 案例图标),底部一个总数据收束。
**适用内容类型**:**8-12 项同类型小项 + 一个汇总指标**。每项只承载短标题(无展开),底部巨数为「汇总值」(项目总数 / 总流量 / 总用户)。**项数过少改用 P4(6 项)**。
**骨架**:顶部标题(留 9vh 间距)/ 中段 4×3 矩阵卡(每卡 12vh 固定高度)/ 底部巨数 + 标签(margin-top:auto 推到底)。
**关键类**:`.matrix-fill`(grid-template-columns:repeat(4,1fr))`.matrix-cell`(`.card-fill` 灰底,**禁止描边**)`.hero-stat-bottom`
**动效 recipe**:`matrix-fill` — 12 格随机棋盘渐显(每格 random delay)→ 底部巨数 count-up
**注意**:卡片高度限定(避免大数字溢出);**所有卡用 `.card-fill` 灰底**,只突出强调项时单独换 `.card-accent`
---
### P16 · Multi-card Brief · 微卡小报
**用途**:6 项小卡并列(快讯、tip 集合、特性概览)。
**适用内容类型**:**6 项轻量短讯 / tip / 注脚**(数量 = 6,每项主文短 + 小字注脚)。比 P4 内容更碎,适合快讯类。**只允许一张 accent 蓝突出**(单焦点法则)。
**骨架**:顶部大标题(留 9vh)/ 下方 3×2 微卡(每卡:左上主文 + 右下小字 + 中间留空)。
**关键类**:`.brief-grid` `.brief-card`(`.card-fill` 灰底)`.brief-card.is-accent`(单一蓝底强调)
**动效 recipe**:`field-notes` — 6 卡按 z 形顺序点亮(L→R, T→B,90ms 错开)
**注意**:卡内排版**左上主文 + 右下小字**,中间空出(避免内容散);**只允许一张 accent 蓝**
---
### P17 · System Diagram · 同心圆系统图
**用途**:层级架构(core→middle→outer)、生态地图。
**适用内容类型**:**严格三层嵌套关系**(core 内核 / middle 中间层 / outer 外圈)。典型如:技术栈层级、生态分层、影响力辐射。**非三层结构禁用**(扁平用 P4,层级不清用 P5)。
**骨架**:左半屏标题 + 三段说明 / 右半屏 SVG 三层同心圆 + 标签外引线。
**关键类**:`.system-diagram` `.sys-svg` `.sys-label`
**动效 recipe**:`system-diagram` — 同心圆从外向内 scale 入 → 标签序列出现
---
### P18 · Why Now · 三列递进 + 巨数
**用途**:三论点 + 各自支撑数据(为什么是现在)。
**适用内容类型**:**3 个论点 + 每个论点对应一个量化数据**。每论点结构 = t-cat 标签 + 一句标题 + 段落 + 一个底部巨数(可以是百分比/年份/倍数)。最后一列 IKB 蓝强调表示「重点支撑论据」。
**骨架**:顶部大标题 / 中段 3 列(每列:t-cat + 标题 + 描述)/ 列底各一个 8.4vw 巨数(01 / 02 / 03,最后一列 IKB 蓝强调)。
**关键类**:`.why-now-grid` `.why-col` `.why-num-bottom`(8.4vw, weight 200)
**动效 recipe**:`why-now` — 三列垂直递进 → 底部巨数 count-up
**注意**:巨数字号统一,只用颜色(IKB 蓝)突出最后一列,**不要**用粗体
---
### P19 · Four Cards · 四列均分卡
**用途**:4 项功能/特性并列(等权重)。
**适用内容类型**:**4 项等权特性 / 模块**(数量 = 4,结构完全同质)。每项 = t-meta 编号 + 大字标题 + 一段描述。无数据维度,纯定性。比 P5(三步)更平均,比 P6(数据高度)更纯文字。
**骨架**:顶部 80px IKB 蓝短发丝顶线 + 大字双行标题 / 下方 4 列均分卡(每卡:t-meta 顶部 "— 01 / SLASH" + 大字标题 + 段落描述)。
**关键类**:`.four-cards` `.fc-col`
**动效 recipe**:`four-cards` — 顶部蓝线 width 0→100% → 4 列从下向上推入(每列 110ms 错开)
**注意**:**不要**用 9px 圆形装饰点(不符合直角语言),用 `.t-meta` 文字代替
---
### P20 · Stacked KPI Ledger · 纵向账单 KPI
**用途**:4-6 行核心数据账单式展示(每行=数字+标签+图标)。
**适用内容类型**:**4-6 项核心数据账单**(每行必须有真实数值 + 标签 + 图标)。垂直 ledger 形式适合财务数据、KPI 仪表板、关键指标列表。比 P6 KPI Tower 容纳数据更多但视觉化弱(无 bar 高度对比)。
**骨架**:每行一道 hairline 分隔 / 左侧巨数(限高 `min(13vw,16vh)` 防溢出) / 中部标签 / 右侧 lucide 图标。
**关键类**:`.stacked-ledger` `.ledger-row`(border-bottom:1px solid var(--border-subtle))`.ledger-num`
**动效 recipe**:`stacked-ledger` — 每行数字升起 → 标签左滑 → 图标 pop(每行 180ms 错开)
**注意**:**字号必须限高**(`font-size:min(13vw, 16vh)`),否则在标准 16:9 屏底部行会被挤出
---
### P21 · Tech Spec Sheet · 规格说明书
**用途**:产品规格、benchmark 数据、性能基线展示(多 KPI + 视觉化竖线装饰)。
**适用内容类型**:**产品规格 / benchmark / 性能基线**(必须有真实多维数据,3 KPI + 9 根竖线 = 12+ 数据点)。典型如:模型评分、API 性能、压测结果。是 deck 中数据密度最高的版式。
**骨架**:左 4 行大标题 / 中部 3 KPI(顶部 hairline + 数字 + 单位)/ 右下 9 根高低不一的垂直竖线 / 底部巨数 + Yearly goal + 三 tag + 右下角 MP-XX + 页码。
**关键类**:`.tech-spec` `.spec-title-col` `.spec-kpi-grid` `.spec-bars`(`.bar-vert`,scaleY 弹起,transform-origin:bottom)
**动效 recipe**:`tech-spec` — hero 区淡入 → 标题入 → KPI 顶线一根根画出 → 底巨数 pop → 竖线从底部 scaleY 弹起(50ms 错开)
**注意**:右下 bars 矩阵必须**底对齐**且**不超出右边距**
---
### P22 · Image Hero · 图文混排封面
**用途**:案例展示、产品图 + 数据落地、章节封面带图。
**适用内容类型**:**案例展示 / 产品发布 / 章节带图封面**(必须有真实图片资源 + 3 个核心数据)。典型如:产品截图 + 关键指标、案例图 + ROI、用户反馈图 + 复购率。**没有真实图源时禁用**(占位灰图破坏视觉)。
**骨架**:上半屏 60% 全幅图片 + 左上白底标题块叠加(top:11vh,留出充分缓冲)/ 下半屏 40% 长说明 + 三列 KPI($ / 127× / 100%)。
**关键类**:`.image-hero` `.hero-img-wrap`(60vh)`.hero-overlay-block` `.hero-stats`
**动效 recipe**:`image-hero` — 图缓慢 zoom-out(scale 1.05→1)→ 白块 scaleX 0→1 推开 → 三 KPI 顶线依序画出
**注意**:
- 图片优先用 `images/{页号}-{语义}.png` 本地文件(GPT-M 2.0 或用户提供素材),不要默认外链 unsplash
- 图片下方内容不要贴着图下沿,使用 `.image-hero-body` 统一给下半屏增加顶部缓冲
- 三列 KPI 大字号要限高(`min(4.6vw, 7.6vh)`),小字用 `margin-top:auto` 锚定列底,防止溢到 nav 圆点
- 列高度统一(grid 不要 `align-items:start`,让列拉伸到同一高度)
**示例代码**:
```html
<section class="slide light" data-animate="image-hero">
<div class="canvas-card" style="padding:0;display:flex;flex-direction:column;overflow:hidden">
<div data-anim="img" style="position:relative;flex:0 0 60%;overflow:hidden;background:var(--grey-1)">
<img src="images/22-product-scene.png" alt="[必填] 图片说明" loading="eager"
style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover;object-position:center 30%">
<div class="chrome-min" style="position:absolute;top:0;left:0;right:0;color:rgba(255,255,255,.9);padding:5.6vh 5vw 0">
<div class="l">Section · Case / Visual Evidence</div>
<div class="r">22 / NN</div>
</div>
<div data-anim="title-block" style="position:absolute;left:5vw;top:11vh;background:var(--paper);padding:3.2vh 3.2vw;max-width:40vw">
<div style="font-family:var(--sans),var(--sans-zh);font-weight:200;font-size:min(5.2vw,9vh);line-height:1;letter-spacing:-.035em;color:var(--text-primary)">
[必填] Image<br>Evidence
</div>
</div>
</div>
<div data-anim="kpi" class="image-hero-body">
<div style="max-width:48ch;font-family:var(--sans),var(--sans-zh);font-size:max(15px,1.3vw);line-height:1.55;font-weight:300;color:var(--text-primary);letter-spacing:-.005em">
[必填] 1-2 行解释这张图为什么重要,不要重复标题.
</div>
<div class="image-hero-stats" style="gap:4vw">
<div style="display:flex;flex-direction:column;gap:.6vh"><div style="height:1px;background:var(--ink)"></div><div class="t-meta">Metric 01</div><div style="font-family:var(--sans);font-weight:200;font-size:min(4.6vw,7.6vh);line-height:.95;letter-spacing:-.04em">12×</div><div style="height:1px;background:var(--border-subtle);margin-top:auto"></div><p class="body-sm">[必填] 指标解释</p></div>
<div style="display:flex;flex-direction:column;gap:.6vh"><div style="height:1px;background:var(--ink)"></div><div class="t-meta">Metric 02</div><div style="font-family:var(--sans);font-weight:200;font-size:min(4.6vw,7.6vh);line-height:.95;letter-spacing:-.04em">3.4h</div><div style="height:1px;background:var(--border-subtle);margin-top:auto"></div><p class="body-sm">[必填] 指标解释</p></div>
<div style="display:flex;flex-direction:column;gap:.6vh"><div style="height:1px;background:var(--ink)"></div><div class="t-meta">Metric 03</div><div style="font-family:var(--sans);font-weight:200;font-size:min(4.6vw,7.6vh);line-height:.95;letter-spacing:-.04em;color:var(--accent)">100%</div><div style="height:1px;background:var(--border-subtle);margin-top:auto"></div><p class="body-sm">[必填] 指标解释</p></div>
</div>
</div>
</div>
</section>
```
---
## 历史实验区(默认禁用)
下面的 P23/P24 是早期为了探索图文混排加入的实验版式。它们不属于原始 22P,默认不要用于正式生成。除非用户明确说“我要实验新图文版式”,否则请使用 S22 或 S15/S16 的图片槽位。
### P23 · Swiss Image Split · 左文右图 / 右文左图(实验,默认禁用)
**用途**:解释一个观点时配一张纪实照片、信息图、UI 情景图或系统关系图。
**适用内容类型**:**一个核心论点 + 一张核心图片**。适合"左侧大标题 + 右侧图片证据"或"左图右说明"。如果图片是整页主角且需要 KPI,用 P22;如果是多张图片,用 P24。
**骨架**:`.canvas-card` 内 head 上下叠 / 主体 `.swiss-img-split` 两列(5:7 或 reverse 7:5) / 图片下方 `.swiss-img-caption`。
**关键类**:`.swiss-img-split` `.swiss-img-copy` `.frame-img.r-16x10.fit-contain|cover` `.swiss-img-caption`
**动效 recipe**:`grid-reveal` — head 先入,图片和文字块错峰出现
**注意**:
- 图片通常与正文首行对齐,不要与大标题顶端齐平;可在图片列加 `padding-top:1vh` 到 `3vh`
- 如果希望左侧内容块与右侧图片底部对齐,使用 `.swiss-img-split.align-image-bottom`,不要靠额外空行硬推
- `.align-image-bottom` 已内置底部 nav safe zone;不要再额外把图片或 caption 往页面底部推
- 左侧内容块避免无意义分割线;除非需要章节感,不要额外插入 `.rule`
- 信息图/UI 图必须 `.fit-contain`;纪实照片默认 cover
- 右图宽度大,标题不要超过 3 行,正文控制在 2-3 个短段或 3 条 bullet
```html
<section class="slide light" data-animate="grid-reveal">
<div class="canvas-card">
<div class="chrome-min">
<div class="l">Section · Visual Argument</div>
<div class="r">23 / NN</div>
</div>
<div style="flex:1;padding:0;display:grid;grid-template-rows:auto 1fr;gap:5vh">
<div data-anim="head" style="display:flex;flex-direction:column;gap:1.4vh">
<div class="t-meta">Evidence · GPT-M 2.0</div>
<h2 style="font-family:var(--sans),var(--sans-zh);font-weight:200;font-size:min(7vw,12vh);line-height:.96;letter-spacing:-.035em">[必填] 一句核心论点</h2>
</div>
<div class="swiss-img-split align-image-bottom" data-anim="up">
<div class="swiss-img-copy">
<div class="t-cat" style="color:var(--accent)">Why it matters</div>
<p class="lead" style="font-weight:300;max-width:36ch">[必填] 2-3 行解释图片与论点的关系.</p>
<div class="body" style="font-weight:300;color:var(--text-secondary)">[必填] 可以放 2-3 条短 bullet 或一段说明,保持左对齐和充足留白.</div>
</div>
<figure class="tile">
<div class="frame-img r-16x10 fit-contain">
<img src="images/23-visual-evidence.png" alt="[必填] 图片说明">
</div>
<figcaption class="swiss-img-caption"><strong>[必填] 图片标题</strong><span>16:10 · fit-contain</span></figcaption>
</figure>
</div>
</div>
</div>
</section>
```
---
### P24 · Swiss Evidence Grid · 多图证据墙(实验,默认禁用)
**用途**:三张同类型图片/截图/图表并列,展示证据链或多案例对比。
**适用内容类型**:**2-3 张同类图片**。适合 UI 截图重绘、流程图三段、三个案例实拍、三张数据小图。不同类型混放会破坏瑞士风秩序。
**骨架**:head 上下叠 / `.swiss-img-grid` 三列 / 每张 tile 用同一个 `.h-22` 或 `.h-26`。
**关键类**:`.swiss-img-grid` `.frame-img.h-22|h-26` `.fit-contain` `.swiss-img-caption`
**动效 recipe**:`grid-reveal`
**注意**:
- 同组图片必须同一比例、同一高度、同一边距密度;不要一张 16:9、一张 4:3、一张长条截图混排
- 标题区和图片区之间必须有明显缓冲;模板里的 `.swiss-img-grid` 默认带顶部间距,只有在外层 grid 已经给足 gap 时才加 `.tight`
- UI/信息图统一 `.fit-contain`;照片统一 cover
- 如果用户原始截图比例混乱,先按 `screenshot-framing.md` 做 CleanShot X 式程序化适配;只有太长、太窄或需要重构信息时,才用 GPT-M 2.0 重生成同一比例的"截图再设计"
```html
<section class="slide light" data-animate="grid-reveal">
<div class="canvas-card">
<div class="chrome-min">
<div class="l">Section · Evidence Grid</div>
<div class="r">24 / NN</div>
</div>
<div style="flex:1;padding:0;display:grid;grid-template-rows:auto 1fr;gap:6vh">
<div data-anim="head" style="display:flex;flex-direction:column;gap:1.4vh">
<div class="t-meta">Three visual proofs</div>
<h2 style="font-family:var(--sans),var(--sans-zh);font-weight:200;font-size:min(6.6vw,11.6vh);line-height:.96;letter-spacing:-.035em">[必填] 三个证据,一个结论</h2>
</div>
<div class="swiss-img-grid" data-anim="up">
<figure class="tile"><div class="frame-img h-26 fit-contain"><img src="images/24-proof-a.png" alt="[必填]"></div><figcaption class="swiss-img-caption"><strong>01</strong><span>[必填] 证据 A</span></figcaption></figure>
<figure class="tile"><div class="frame-img h-26 fit-contain"><img src="images/24-proof-b.png" alt="[必填]"></div><figcaption class="swiss-img-caption"><strong>02</strong><span>[必填] 证据 B</span></figcaption></figure>
<figure class="tile"><div class="frame-img h-26 fit-contain swiss-lined"><img src="images/24-proof-c.png" alt="[必填]"></div><figcaption class="swiss-img-caption"><strong>03</strong><span>[必填] 关键证据</span></figcaption></figure>
</div>
</div>
</div>
</section>
```
---
## 选版式索引(给 LLM 的决策表)
| 内容意图 | 推荐版式 |
|---|---|
| Deck 起手封面 | P1 Cover |
| 演化对比 / 时间轴(纵) | P2 Vertical Timeline |
| 一句口号 / 章节起 | P3 Statement / P10 Dot Matrix |
| 6 项概念定义 | P4 Six Cells |
| 三步流程(轻) | P5 Three Sub-cards |
| 4 项数据视觉化高度对比 | P6 KPI Tower |
| 5-10 项排名比较 | P7 H-Bar Chart |
| Before/After / 双轨对照 | P8 Duo Compare |
| 整 deck 收尾 | P9 Closing Manifesto |
| 多步流程(横,4-7 步) | P11 Horizontal Timeline |
| 阶段性结论 + ink 通栏 | P12 Manifesto + Banner |
| 3 个对等概念深化 | P13 Three Forces Cards |
| 闭环流程 / 自学循环 | P14 Loop Diagram |
| 8-12 项矩阵 + 总数据 | P15 Image Matrix |
| 6 项快讯小卡 | P16 Multi-card Brief |
| 层级架构 / 同心圆系统 | P17 System Diagram |
| 三论点 + 数据支撑 | P18 Why Now |
| 4 项等权特性 | P19 Four Cards |
| 4-6 行账单式 KPI | P20 Stacked Ledger |
| 产品规格 / benchmark | P21 Tech Spec |
| 案例图 + 数据落地 | P22 Image Hero |
| 地点 / 路线 / 人物住所关系 | S08 + Swiss Map Component |
| 单图解释论点 / 图文混排 | P23 Swiss Image Split |
| 2-3 张图片/截图/图表证据链 | P24 Swiss Evidence Grid |
---
## 选版式 P0 原则:内容数据类型必须匹配版式
> 这是写 deck 时**最容易踩雷**的地方。版式承载内容的「形状」是固定的——你必须先看内容,再选版式,**绝不能先选版式再编内容硬塞**。
| 内容类型 | 必须用 | 严禁用 |
|---|---|---|
| 有真实量化数据(百分比/数值) | P6 KPI Tower / P7 H-Bar / P20 Ledger / P21 Tech Spec | P3 / P4 / P10 / P13(无数据版式) |
| 无数据,纯定性论断 | P3 / P10 Statement / P12 / P13 / P19 | ⚠️ **P7 H-Bar / P6 KPI Tower**(编造数据会被识破) |
| 4 项对等 | P19 Four Cards / P6(若有数据) | 不能强凑成 6 用 P4 |
| 6 项对等 | P4 Six Cells / P16 Brief | 不能强凑成 4 用 P19 |
| 3 项对等 | P5 Sub-cards / P13 Three Forces | |
| Before/After | P8 Duo Compare(必须正好 2 项) | |
| 地点/路线/城市关系 | S08 + Swiss Map Component | 普通 S04/S16 卡片罗列 |
| 闭环结构 | P14 Loop Diagram | P11 横向流程(线性 ≠ 闭环) |
| 三层嵌套 | P17 System Diagram | |
| 时间演化(有数据) | P2 Vertical Timeline | |
| 多步骤流程(无数据) | P11 Horizontal Timeline | |
| 8-12 项同类 | P15 Image Matrix | |
| deck 收尾 | P9 Closing(每 deck 仅 1 次) | |
| 1 张核心图片 + 一段解释 | P23 Swiss Image Split | P22(除非图片是主角且有 KPI) |
| 2-3 张同类图片 | P24 Evidence Grid | P4/P16(文字卡片,不是图片证据) |
**雷区案例**:用 P7 H-Bar Chart 展示「智能补全 / 实时协作 / 自主代理」这种**无可比百分比的概念列举**,编造 96/88/78 之类数字 → **数据不可信,版式滥用**。这种内容应该用 P2(若有时间维度)或 P3 Statement(若是论断)。
---
## 常犯错误(P0 检查项)
1. ❌ 给卡片加 `border-radius` → ✅ 必须直角
2. ❌ 在 `.card-accent` 上又加描边 → ✅ 卡片填充类型互斥
3. ❌ 自己画 SVG 图标 → ✅ 用 `lucide` 线上库,棱角风格
4. ❌ 时间线 dot 用 grid `justify-self` 对齐虚线 → ✅ axis 列固定 12px + dot 绝对定位
5. ❌ 大字号不限高(`13vw`)→ ✅ 永远 `min(Xvw, Yvh)` 双约束
6. ❌ ESC 索引页缩略图看不到带动效内容 → ✅ 给 cloned slide 加可见性 override CSS
7. ❌ 所有页用同一个 fade-up recipe → ✅ 每页一个语义化 recipe,与图形耦合
8. ❌ 标题 + 卡片间距 < 5vh → ✅ 章节级标题至少 9vh
9. ❌ 9px 圆形装饰点 → ✅ 8×8 直角小方块 / mono `t-meta` 文字
10. ❌ 装饰元素超出页面边距 → ✅ 严格在 grid 内,不贴边
references/layouts.md›
# 页面布局库(Layouts)
本文档收录 10 种最常用的页面布局骨架。每种都是一个完整可粘贴的 `<section class="slide ...">...</section>` 代码块,直接替换文案/图片即可使用。
---
## ⚠️ 生成前必读(Pre-flight)
### A. 类名必须来自 template.html
layouts.md 使用的所有类(`h-hero` / `h-xl` / `h-sub` / `h-md` / `lead` / `meta-row` / `stat-card` / `stat-label` / `stat-nb` / `stat-unit` / `stat-note` / `pipeline-section` / `pipeline-label` / `pipeline` / `step` / `step-nb` / `step-title` / `step-desc` / `grid-2-7-5` / `grid-2-6-6` / `grid-2-8-4` / `grid-3-3` / `grid-6` / `grid-3` / `grid-4` / `frame` / `frame-img` / `img-cap` / `callout` / `callout-src` / `kicker`)都在 `assets/template.html` 的 `<style>` 块里预定义。
**不要发明新类名**。如果必须自定义,用 `style="..."` inline 写。生成前若不确定某个类是否存在,grep template.html 确认。
### B. 图片比例规范(非常重要)
**永远用标准比例**,不要用原图 `aspect-ratio: 2592/1798` 这种奇葩比例:
| 场景 | 推荐比例 | 写法 |
|------|---------|------|
| 左文右图 主图 | 16:10 或 4:3 | `.frame-img.r-16x10` 或 `.frame-img.r-4x3` |
| 图片网格(多图对比) | 统一 | `.frame-img.h-22` / `.frame-img.h-26`,不用 aspect-ratio |
| 小型面板组 | 统一 | `.frame-img.h-16` / `.frame-img.h-18`,同组必须同高 |
| 左小图 + 右文字 | 1:1 或 3:2 | `.frame-img.r-1x1` 或 `.frame-img.r-3x2` |
| 全屏主视觉 | 16:9 | `.frame-img.r-16x9` |
| 信息图 / 截图再设计 | 16:9 或 16:10 | `.frame-img.r-16x9.fit-contain` 或 `.frame-img.r-16x10.fit-contain` |
| 图文混排小插图 | 3:2 或 3:4 | `.frame-img.r-3x2` 或 `.frame-img.r-3x4` |
图片必须包在 `<figure class="frame-img">` 里。默认照片会 `object-fit:cover + object-position:top center`,只裁底部,不裁顶/左/右。信息图和截图再设计必须加 `.fit-contain`,避免文字或标注被裁切。
### B2. 图片与内容的垂直对齐
图片应该跟正文内容区对齐,不要默认贴到大标题顶端。特别是左文右图和图文混排页:
- 如果左列是 kicker + 大标题 + 正文 + callout,右列图片通常从正文高度开始,可给图片加 `style="margin-top:7vh"` 到 `9vh`
- 如果图片是信息图或 UI 情景图,优先对齐正文首行或说明文字,不要和超大标题顶端齐平
- 如果一张截图/UI 情景图在横向页面里变成很长的条,不要硬拉满宽;改成极宽横图素材,或拆成 2-3 个局部面板拼排
- 多图面板必须使用同一个高度类,不要混用 `h-16` / `h-22` 或手写不同 `height`
### B3. 标题与正文的间距
- 两段式页面(顶部标题 + 下方长正文/引用/图表)必须在标题和内容之间留出明显间距,推荐 `margin-top:6vh` 到 `8vh`
- 居中大标题页必须让主标题在页面水平居中,使用 `.center` 或 `text-align:center; margin-inline:auto`
- 复杂内容页(大标题 + 小标题 + 详细内容)要让大标题和下方内容分层,下方内容使用左右两端对齐的 grid 或 rowline,不要全部堆在一条中轴线上
### C. 图片定位准则(避免图片堆到页面最底部、被浏览器工具栏遮挡)
**错误做法**(已踩坑,不要再犯):
- 在非 grid 容器里用 `align-self:end`:`align-self` 在 flex/grid 之外完全无效,图片会掉到文档流末尾堆底
- 用 `position:absolute + bottom:0` 把图"固定"到底:会被底部 `.foot` 和 `#nav` 圆点遮挡
- 单张图片只写 `height:N vh` 不限 `max-height`:在低分屏会撑出视口
**正确做法**:
- 图文混排**必须用 `.frame.grid-2-7-5`**(或 `.grid-2-6-6` / `.grid-2-8-4`)的 grid 结构
- grid 容器默认 `align-items:start`(已在 template 中设置),图片自然贴到 cell 顶端
- 如果需要"图片底对齐左列 callout":**左列用 flex column + `justify-content:space-between`**(让 callout 自己贴左列底),**右列 figure 直接保持 align-items:start 即可**,不要加 `align-self:end`
- 所有 grid 父容器建议加 inline `style="padding-top:6vh"`,给标题区留呼吸空间
### D. 主题色与主题节奏
- 主题色从 `references/themes.md` 的 5 套预设里选一套,不允许自定义 hex 值
- 主题节奏(每页用 light / dark / hero light / hero dark 哪一个)在下文"主题节奏规划"一节有硬规则,生成前必读
- 两件事都要在挑布局之前决定,避免返工
### E. 动效系统(默认开启 · Motion One 驱动)
**核心机制**:template.html 底部的 module script 会在翻页时触发入场动画。所有带 `data-anim` 的元素初始不可见,翻到当前页时由 Motion One 逐个淡入。
**动效策略**:在 `<section>` 上加 `data-animate="<recipe>"` 选择动画风格;每个需要入场动画的元素加 `data-anim`(可选附值,如 `left` / `right` / `line` / `step`)。
| recipe | 用法 | 适合布局 |
|---|---|---|
| 默认(cascade) | 什么也不加,自动级联淡入 | 大部分正文页(Layout 3 / 4 / 5 / 10) |
| `hero` | `.hero` 页自动启用,节奏更慢更仪式感 | Layout 1 / 2 / 7(所有 hero 页) |
| `quote` | 一句一句揭示,慢节奏(550ms stagger) | Layout 8 大引用 |
| `directional` | 左进 → 分割 → 右进,用于对比 | Layout 9 Before/After |
| `pipeline` | 手动推进,按 →/空格 一步步点亮 | Layout 6 流水线 |
**降级保底**:如果 motion.min.js 本地 + CDN 都加载失败,脚本会强制把所有 `data-anim` 元素设为 `opacity:1`,内容永远可读。
**不需要动效的页面**:如果某页想完全跳过动效,不加任何 `data-anim` 即可 —— Motion One 只对带标记的元素生效。
---
## 0. 基础结构(所有 slide 都一样)
```html
<section class="slide [light|dark|hero light|hero dark]">
<div class="chrome">
<div>上下文标签 · 子标签</div>
<div>ACT · 页号 / 总页数</div>
</div>
<!-- 主内容 -->
<div class="foot">
<div>页码说明 · Page Description</div>
<div>— · —</div>
</div>
</section>
```
- 非 hero 页建议加 `light` 或 `dark` 主题;hero 页加 `hero light` 或 `hero dark`(参与 WebGL 主题插值)
- `chrome` 和 `foot` 是可选但推荐保留的上下左右四角元数据
- **hero 页用于章节封面/开场/收束/转场**,非 hero 页用于正文
### ⚠️ chrome 和 kicker 不要写同一句话
这是最常见的内容重复问题。两者在语义上完全不同的维度:
| 位置 | 角色 | 内容性质 | 例子 |
|------|------|---------|------|
| `.chrome` 左上 | **杂志页眉 / 导航元数据** | 稳定的"栏目名"或"章节分类",跨多页可以相同 | "Act II · Workflow" / "Data · Result" / "lukew.com · 2026.04" |
| `.chrome` 右上 | **页号 + 幕号** | 固定格式 | "Act II · 15 / 25" |
| `.kicker` | **这一页独一份的引导句** | 是大标题的"小前缀",像杂志大标题上方的一行话,每页都应不同 | "BUT" / "一个人,做了什么。" / "Phase 01 · 设计阶段" |
**反例**(已踩坑):chrome 写"设计先行 · Design First",kicker 又写"Phase 01 · 设计阶段"——意思重复,读者一眼就觉得 AI 生成的。
**正确做法**:chrome 是**栏目标签**(稳定、跨页可复用),kicker 是**本页钩子**(短句、有戏剧性),两者互为补充,不互相翻译。
### ⚠️ 主题节奏规划(必读 · 生成前必做)
**核心机制**:每页 `<section>` 必须带 `light` / `dark` / `hero light` / `hero dark` 之一。JS 根据 class 推断主题,决定 body 加不加 `light-bg`,从而切换暗/亮两张 WebGL canvas 哪张在前。不带主题或写自定义名 = fallback 出错。
#### 按布局的主题默认值
| Layout | 默认主题 | 原因 |
|---|---|---|
| 1. 开场封面 | `hero dark` | 开场仪式感,暗底强冲击 |
| 2. 章节幕封 | `hero dark` 与 `hero light` **必须交替** | 呼吸节奏 |
| 3. 大字报(数据) | `light` | 数字需纸白底;多幕连发时可偶插 `dark` |
| 4. 左文右图 | **`light` / `dark` 交替** | 正文节奏主力 |
| 5. 图片网格 | `light` | 截图需亮底 |
| 6. Pipeline | `light` | 流程图需清晰 |
| 7. 问题页 | `hero dark` | 强视觉冲击默认 |
| 8. 大引用 | **`dark` 优先**,偶用 `light` | 金句仪式感靠暗底 |
| 9. 对比页 | `light` | 双列需清晰 |
| 10. 图文混排 | **`light` / `dark` 交替** | 节奏 |
#### 节奏硬规则(生成后 grep 自检)
- ❌ **禁止**连续 3 页以上相同主题(包括 light 堆叠和 dark 堆叠)
- ❌ **禁止**8 页以上的 deck 没有至少 1 个 `hero dark` + 1 个 `hero light`
- ❌ **禁止**整个 deck 只有 `light` 正文页没有任何 `dark` 正文页——会显得平淡、没呼吸
- ✅ **推荐**每 3-4 页插入 1 个 hero(封面/幕封/问题/大引用)
#### 8 页节奏模板(可直接套用)
| 页 | 主题 | 布局 | 备注 |
|---|---|---|---|
| 1 | `hero dark` | 封面 | 开场 |
| 2 | `light` | 大字报 | 数据抛出 |
| 3 | `dark` | 左文右图 | 对比/故事 |
| 4 | `light` | Pipeline | 流程 |
| 5 | `hero light` | 章节幕封 | 呼吸 |
| 6 | `dark` | 左文右图 or 大引用 | |
| 7 | `hero dark` | 问题页 | 悬念收束 |
| 8 | `light` | 大引用/结尾 | 收尾 |
**先画这张表对齐,再动手写 slide**。跳过规划直接粘骨架 = 全是 light。
---
## Layout 1: 开场封面(Hero Cover)
```html
<section class="slide hero dark">
<div class="chrome">
<div>A Talk · 2026.04.22</div>
<div>Vol.01</div>
</div>
<div class="frame" style="display:grid; gap:4vh; align-content:center; min-height:80vh">
<div class="kicker" data-anim>私享会 · 李继刚</div>
<h1 class="h-hero" data-anim>一人公司</h1>
<h2 class="h-sub" data-anim>被 AI 折叠的组织</h2>
<p class="lead" style="max-width:60vw" data-anim>
一个 AI 创作者 —— 在 64 天里做了 11 万行代码、在 9 个平台上持续输出,生活节奏几乎没有被改变。
</p>
<div class="meta-row" data-anim>
<span>歸藏 Guizang</span><span>·</span><span>独立创作者 / CodePilot 作者</span>
</div>
</div>
<div class="foot">
<div>一场关于 AI · 组织 · 个体的分享</div>
<div>— 2026 —</div>
</div>
</section>
```
**要点**:
- 用 `hero dark` 让 WebGL 背景在大部分区域透出
- `h-hero` 是最大字号(10vw),这里作标题主视觉
- 用 `min-height:80vh + align-content:center` 让内容整体垂直居中
- 不需要 `.chrome` 里写页码,封面页自成一体
---
## Layout 2: 章节幕封(Act Divider)
```html
<section class="slide hero light">
<div class="chrome">
<div>第一幕 · 硬数据</div>
<div>Act I · 01 / 25</div>
</div>
<div class="frame" style="display:grid; gap:6vh; align-content:center; min-height:80vh">
<div class="kicker" data-anim>Act I</div>
<h1 class="h-hero" style="font-size:8.5vw" data-anim>硬数据</h1>
<p class="lead" style="max-width:55vw" data-anim>
先看数字,再谈方法。
</p>
</div>
<div class="foot">
<div>第一幕引子</div>
<div>— · —</div>
</div>
</section>
```
**要点**:
- 极简,只需要 kicker + 大标题 + 一行引语
- 两个幕的封面可以交替 `hero light` / `hero dark`,制造节奏
- `h-hero` 字号可以从 10vw 调到 8.5vw 适配长短
---
## Layout 3: 数据大字报(Big Numbers Grid)
```html
<section class="slide light">
<div class="chrome">
<div>过去 64 天 · 开发篇</div>
<div>Act I / Dev · 02 / 25</div>
</div>
<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:2vh" data-anim>从 0 到开源 CodePilot。</p>
<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>
<div class="stat-note">从 0 到现在</div>
</div>
<div class="stat-card" data-anim>
<div class="stat-label">Lines of Code</div>
<div class="stat-nb">110K+</div>
<div class="stat-note">一行行写到 11 万+</div>
</div>
<div class="stat-card" data-anim>
<div class="stat-label">GitHub Stars</div>
<div class="stat-nb">5,166</div>
<div class="stat-note">一个开源仓库</div>
</div>
<div class="stat-card" data-anim>
<div class="stat-label">Downloads</div>
<div class="stat-nb">41K+</div>
<div class="stat-note">装到了几万台电脑里</div>
</div>
<div class="stat-card" data-anim>
<div class="stat-label">AI Providers</div>
<div class="stat-nb">19</div>
<div class="stat-note">跨平台接入</div>
</div>
<div class="stat-card" data-anim>
<div class="stat-label">Commits</div>
<div class="stat-nb">608+</div>
<div class="stat-note">没有协作者</div>
</div>
</div>
</div>
<div class="foot">
<div>项目 · CodePilot | github.com/codepilot</div>
<div>Act I · Dev Numbers</div>
</div>
</section>
```
**要点**:
- 3×2 或 4×2 网格最稳(见 `.grid-6`)
- 每个 `stat-card` 结构固定:label(英文小字)→ nb(大字数字)→ note(注释)
- 数字建议 2-3 位字符(太长会溢出),用 K / M 简写
- **间距不要再加大**:骨架默认 `padding-top:3vh` + lead `margin-bottom:2vh` + grid `margin-top:2vh` 是 3×2 网格在 16:9 屏不压 foot 的实测上限;内容更多时先删卡片,不要压缩 foot 空间
---
## Layout 4: 左文右图(Quote + Image)
```html
<section class="slide light">
<div class="chrome">
<div>身份反差 · The Twist</div>
<div>03 / 25</div>
</div>
<div class="frame grid-2-7-5" style="padding-top:6vh">
<!-- 左列:标题 + 正文 + callout,flex column 让 callout 贴列底 -->
<div style="display:flex; flex-direction:column; justify-content:space-between; gap:3vh">
<div>
<div class="kicker" data-anim>BUT</div>
<h2 class="h-xl" style="white-space:nowrap; font-size:7.2vw" data-anim>
我不是程序员。
</h2>
<p class="lead" style="margin-top:3vh" data-anim>
大学毕业之后再也没写过一行代码。过去十年做的是 UI 设计和 AI 特效。
</p>
</div>
<div class="callout" data-anim>
"这东西在三年前,<br>
需要一个十人团队做一年。"
<div class="callout-src">— 一个观察者的判断</div>
</div>
</div>
<!-- 右列:图片用标准 16/10 比例 + max-height,不要 align-self:end -->
<figure class="frame-img r-16x10" data-anim>
<img src="images/codepilot.png" alt="CodePilot 产品截图">
<figcaption class="img-cap">CodePilot · 产品截图</figcaption>
</figure>
</div>
<div class="foot">
<div>Page 03 · 我不是程序员</div>
<div>— · —</div>
</div>
</section>
```
**要点**:
- 用 `grid-2-7-5`(左 7 份、右 5 份),`align-items:start` 已在 template 预设
- **左列**用 flex column + `justify-content:space-between`:标题贴顶,callout 自然贴底
- **右列图片** **不要加 `align-self:end`**。会让图片滑到 cell 底部,低分屏下被浏览器工具栏遮挡
- 图片必须用 **标准比例类 `.r-16x10` 或 `.r-4x3`**,不要用原图奇葩比例(`2592/1798` 这种)
---
## Layout 5: 图片网格(多图对比)
```html
<section class="slide light">
<div class="chrome">
<div>平台粉丝实证</div>
<div>Act I / Ops · 05 / 27</div>
</div>
<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:3vh">
<figure class="frame-img" style="height:26vh" data-anim>
<img src="images/weibo.png" alt="微博 289K">
<figcaption class="img-cap">微博 · 289K</figcaption>
</figure>
<figure class="frame-img" style="height:26vh" data-anim>
<img src="images/twitter.png" alt="推特 137K">
<figcaption class="img-cap">推特 · 137K</figcaption>
</figure>
<figure class="frame-img" style="height:26vh" data-anim>
<img src="images/wechat.png" alt="公众号 96K">
<figcaption class="img-cap">公众号 · 96K</figcaption>
</figure>
<figure class="frame-img" style="height:26vh" data-anim>
<img src="images/jike.png" alt="即刻 26K">
<figcaption class="img-cap">即刻 · 26K</figcaption>
</figure>
<figure class="frame-img" style="height:26vh" data-anim>
<img src="images/xhs.png" alt="小红书 19K">
<figcaption class="img-cap">小红书 · 19K</figcaption>
</figure>
<figure class="frame-img" style="height:26vh" data-anim>
<img src="images/douyin.png" alt="抖音 10K">
<figcaption class="img-cap">抖音 · 10K</figcaption>
</figure>
</div>
</div>
<div class="foot">
<div>截图时间 · 2026.04</div>
<div>Page 05 · 粉丝实证</div>
</div>
</section>
```
**要点**:
- 关键:每个 `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`
---
## Layout 6: 两列流水线(Pipeline)
```html
<section class="slide light" data-animate="pipeline">
<div class="chrome">
<div>我的工作流 · Workflow</div>
<div>Act II · 15 / 27</div>
</div>
<div class="frame">
<div class="kicker">Pipeline · 流水线</div>
<h2 class="h-xl">两条流水线</h2>
<!-- 第一组:文本侧 -->
<div class="pipeline-section">
<div class="pipeline-label">文本侧 · Text Pipeline</div>
<div class="pipeline">
<div class="step" data-anim="step">
<div class="step-nb">01</div>
<div class="step-title">Draft</div>
<div class="step-desc">AI 帮我起草初稿</div>
</div>
<div class="step" data-anim="step">
<div class="step-nb">02</div>
<div class="step-title">Polish</div>
<div class="step-desc">AI 润色去 AI 味</div>
</div>
<div class="step" data-anim="step">
<div class="step-nb">03</div>
<div class="step-title">Morph</div>
<div class="step-desc">AI 变形成推特 / 小红书</div>
</div>
<div class="step" data-anim="step">
<div class="step-nb">04</div>
<div class="step-title">Illustrate</div>
<div class="step-desc">AI 生成信息图</div>
</div>
<div class="step" data-anim="step">
<div class="step-nb">05</div>
<div class="step-title">Distribute</div>
<div class="step-desc">一键分发 9 平台</div>
</div>
</div>
</div>
<!-- 第二组:视频侧 -->
<div class="pipeline-section">
<div class="pipeline-label">视觉 · 视频侧 · Video Pipeline</div>
<div class="pipeline">
<div class="step" data-anim="step">
<div class="step-nb">06</div>
<div class="step-title">Cut</div>
<div class="step-desc">AI 帮我剪辑</div>
</div>
<div class="step" data-anim="step">
<div class="step-nb">07</div>
<div class="step-title">Wrap</div>
<div class="step-desc">AI 帮我包装</div>
</div>
<div class="step" data-anim="step">
<div class="step-nb">08</div>
<div class="step-title">Cover</div>
<div class="step-desc">AI 生成封面</div>
</div>
</div>
</div>
</div>
<div class="foot">
<div>Page 15 · 我的内容工厂</div>
<div>Workflow</div>
</div>
</section>
```
**要点**:
- 用 `.pipeline-section` 分组 + `.pipeline-label` 作组标题
- 两组之间用 3.6vh 的间距 + 顶部细分隔线(已在 CSS 中预设)
- 每个 step 是固定的 nb → title → desc 结构
- 步骤数不限但单行最好 ≤5 个,否则换到第二 pipeline
- **动效**:`<section>` 加 `data-animate="pipeline"`,每个 `.step` 加 `data-anim="step"`。翻到此页时步骤默认 `opacity:.15`,按 →/空格/滚轮下滑时一次点亮一个 step;**所有 step 点亮完才会翻到下一页**,可制造演讲互动感
---
## Layout 7: 悬念收束 / 问题页(Hero Question)
```html
<section class="slide hero dark">
<div class="chrome">
<div>留给你的问题</div>
<div>24 / 27</div>
</div>
<div class="frame" style="display:grid; gap:8vh; align-content:center; min-height:80vh">
<div class="kicker" data-anim>The Question</div>
<h1 class="h-hero" style="font-size:7vw; line-height:1.15">
<span data-anim style="display:block">你的公司里,</span>
<span data-anim style="display:block">哪些岗位本来就</span>
<span data-anim style="display:block">不该由人来做?</span>
</h1>
<p class="lead" style="max-width:50vw" data-anim>
这个问题,不是技术问题,是架构问题。
</p>
</div>
<div class="foot">
<div>Page 24 · The Question</div>
<div>— · —</div>
</div>
</section>
```
**要点**:
- Hero 页留白越多越好,只放一个问题
- `h-hero` 字号视长度调整(7vw 适合 3 行,10vw 适合 1 行)
- 用 `<br>` 手工断行,确保断点在语义处
- 尾巴可以再给一行 `lead` 作为点破
---
## Layout 8: 大引用页(Big Quote · 衬线金句)
```html
<section class="slide light" data-animate="quote">
<div class="chrome">
<div>The Takeaway · 核心金句</div>
<div>18 / 25</div>
</div>
<div class="frame" style="display:grid; gap:5vh; align-content:center; min-height:80vh">
<div class="kicker" data-anim>Quote · 金句</div>
<blockquote style="font-family:var(--serif-zh); font-weight:700; font-size:5.8vw; line-height:1.2; letter-spacing:-.01em; max-width:72vw">
<span data-anim="line" style="display:block">"没有交接,</span>
<span data-anim="line" style="display:block">所有人都在构建。"</span>
</blockquote>
<p class="lead" style="max-width:55vw; opacity:.65" data-anim>
Without the handoff, everyone builds.<br>
And that makes all the difference.
</p>
<div class="meta-row" data-anim>
<span>— Luke Wroblewski</span><span>·</span><span>2026.04.16</span>
</div>
</div>
<div class="foot">
<div>Page 18 · 金句</div>
<div>— · —</div>
</div>
</section>
```
**要点**:
- 整页留白,只放一个大引用 + 出处
- `<blockquote>` 用 inline style 单独放大(5-6vw),不要用 `h-hero`(那是页面主标题的命名)
- 下面跟随英文原文(lead · opacity:.65)制造层级
- 配 `meta-row` 写出处 · 日期
---
## Layout 9: 并列对比(A vs B · 旧 vs 新)
```html
<section class="slide light" data-animate="directional">
<div class="chrome">
<div>旧 vs 新 · The Shift</div>
<div>12 / 25</div>
</div>
<div class="frame" style="padding-top:5vh">
<div class="kicker" data-anim>Before / After · 范式转变</div>
<h2 class="h-xl" style="margin-bottom:4vh" data-anim>从交接到共建</h2>
<div class="grid-2-6-6" style="gap:5vw 4vh">
<!-- 左列:旧 -->
<div data-anim="left" style="padding:3vh 2vw; border-left:3px solid currentColor; opacity:.55">
<div class="kicker" style="opacity:.9">Before · 旧模式</div>
<h3 class="h-md" style="margin-top:2vh">设计 → 开发 → 交接</h3>
<ul style="margin-top:3vh; padding-left:1.2em; display:flex; flex-direction:column; gap:1.4vh; font-family:var(--sans-zh); font-size:max(14px,1.1vw); line-height:1.55">
<li>设计师在 Figma 做稿</li>
<li>开发者盯着文件翻译像素</li>
<li>反复 PR 沟通对齐</li>
<li>非技术人员无法触碰代码</li>
</ul>
</div>
<!-- 右列:新 -->
<div data-anim="right" style="padding:3vh 2vw; border-left:3px solid currentColor">
<div class="kicker" style="opacity:.9">After · 新模式</div>
<h3 class="h-md" style="margin-top:2vh">同工具 · 并行 · 共建</h3>
<ul style="margin-top:3vh; padding-left:1.2em; display:flex; flex-direction:column; gap:1.4vh; font-family:var(--sans-zh); font-size:max(14px,1.1vw); line-height:1.55">
<li>三个角色同时在 Intent 工作</li>
<li>agents.md 作为共享上下文</li>
<li>代理处理对齐 / 冲突 / 动画</li>
<li>任何人都能安全贡献代码</li>
</ul>
</div>
</div>
</div>
<div class="foot">
<div>Page 12 · 范式转变</div>
<div>Before / After</div>
</div>
</section>
```
**要点**:
- 用 `.grid-2-6-6`(1:1)左右分半
- 左列 `opacity:.55` 做"旧"的视觉弱化,右列满亮度做"新"的突出
- 两列都用 `border-left:3px solid` + `padding-left` 做引用块感
- 每列结构统一:`kicker` → `h-md` → `<ul>` 要点,节奏一致
---
## Layout 10: 图文混排(Lead Image + Side Text)
```html
<section class="slide light">
<div class="chrome">
<div>Design First · 设计先行</div>
<div>08 / 16</div>
</div>
<div class="frame grid-2-8-4" style="padding-top:6vh">
<!-- 左列:大段正文 + 引用 -->
<div>
<div class="kicker" data-anim>Phase 01 · 设计阶段</div>
<h2 class="h-xl" style="margin-top:1vh; margin-bottom:3vh" data-anim>设计先行 · 2 周</h2>
<p class="lead" style="margin-bottom:3vh" data-anim>
在 Figma 中完成视觉探索与设计系统,网格 / 排版 / 颜色变量 / 可复用组件,桌面和移动端稿件几轮反馈迭代。
</p>
<p data-anim style="font-family:var(--sans-zh); font-size:max(14px,1.15vw); line-height:1.75; opacity:.78; margin-bottom:2.4vh">
两周之内,视觉风格、粗略结构、方向性内容全部稳定。这是扎实的传统设计流程——在这里还没什么新鲜事。
</p>
<div class="callout" style="margin-top:3vh" data-anim>
"This phase was pretty standard.<br>Just a solid Web design process."
<div class="callout-src">— Luke Wroblewski</div>
</div>
</div>
<!-- 右列:辅助图 · 竖版或方形 -->
<figure class="frame-img r-3x4" data-anim>
<img src="images/figma.png" alt="Figma design system">
<figcaption class="img-cap">Figma · Design System</figcaption>
</figure>
</div>
<div class="foot">
<div>Page 08 · Design First</div>
<div>约 2 周</div>
</div>
</section>
```
**要点**:
- `.grid-2-8-4`(8:4) 让正文占主导,图片作辅助
- 左列包含多种信息层级:kicker → 大标题 → lead → 正文段落 → callout(引用)
- 右列图片用 **竖版 3:4** 或方形 1:1,避免和左列文本竞争注意力
- 这种布局适合**页面信息量偏大**的场景(不像 Layout 4 只有一句金句)
---
## 附录:常用网格模板
| 类名 | 配比 | 用途 |
|---|---|---|
| `.grid-2-6-6` | 6:6(1:1) | 对半分 |
| `.grid-2-7-5` | 7:5 | 文字为主 + 辅助图 |
| `.grid-2-8-4` | 8:4(2:1) | 大段文字 + 小图/数据 |
| `.grid-3` | 1:1:1 | 3 项并列(案例/截图) |
| `.grid-3-3` | 3×2 | 6 图矩阵 |
| `.grid-6` | 3×2 | 6 个数据卡片 |
所有网格都预留 `gap: 3vw 4vh`(水平 3vw、竖直 4vh),可以单独覆写。
---
## 页面节奏建议
一场 25-30 页的分享,推荐以下节奏:
1. **Hero Cover**(第 1 页)
2. **Act Divider**(第一幕开场,hero light 或 hero dark)
3. **Big Numbers**(抛硬数据制造冲击)
4. **Quote + Image**(讲身份反差/挂钩)
5. **Image Grid**(证据支撑)
6. **Hero Question**(幕收束,留悬念)
7. ... 第二幕、第三幕同样节奏 ...
8. **Hero Close**(最后一页,问题或致谢)
hero 页与 non-hero 页应该 **2-3 : 1 比例交错**,不要连续超过 3 页 non-hero,也不要连续超过 2 页 hero。
references/presenter-mode.md›
# 演讲者模式与演讲备注契约
制作任何需要现场讲述的 deck 时加载本文件。目标不是把页面文案复制到备注区,而是让页面、讲述、转场、时间和现场控制形成一条可以排练和可以恢复的叙事链。
## 1. 页面计划同时规划“看什么”和“说什么”
在写 HTML 前,先做一张表。只填用户大纲、素材或已确认的现场信息能够支持的列:
| 页码 | `data-slide-id` | 章节 | 页面目的 | 观众屏可见信息 | 演讲者补充 | 建议时长 | 转场 | 可选现场信息 |
|---|---|---|---|---|---|---:|---|---|
- **观众屏可见信息**:观众此刻必须读到的结论、证据、结构或图像。
- **演讲者补充**:背景、例子、判断依据、语气和细节,不要逐字复述页面。
- **转场**:解释下一页为什么紧接着出现,不要只写“下一页”。
- **可选现场信息**:可包含停顿、提问、Demo、视频、敏感信息、翻页时机、备用方案或读音。
默认生成“提词卡”,不是逐字稿。只有用户明确要求逐字稿时,才写完整口播。
## 2. 模型如何把大纲信息带进演讲模式
对用户的大纲、项目记录和补充说明做结构化提取,不要自行增加现场事实。
- 已给出章节名或可靠推断的连续章节:写入 `section`。
- 已给出总时长或逐页节奏:可给出 `minutes`,总建议时长不超过现场时长的 90%。
- 大纲明确写了停顿、提问、Demo、视频、语气、翻页点或备用路径:分别写入对应的可选字段。
- 用户明确要求某页自动停留多少秒:才写 `autoAdvanceSeconds`。
- 没有提供的信息不猜测、不写“待补充”到演讲界面,直接省略字段。界面对缺失时长显示横杠,其他可选模块整段隐藏。
- 如果页面在未运行校验的情况下临时新增,且没有对应备注,演讲者界面只显示中性横杠 `—`,不显示“待补充”或虚构内容;校验器仍应报告备注与页面不一致。
`minutes` 是讲述计划,`autoAdvanceSeconds` 是播放行为,二者必须分开。不得因为一页建议讲 1 分钟,就默认 60 秒后自动翻页。
## 3. 稳定的页面 ID
每一页都必须有唯一、语义化且稳定的 ID:
```html
<section class="slide ..." data-slide-id="codepilot-capabilities">
```
- 使用小写英文 slug:`cover`、`codepilot-capabilities`、`demo-workflow`、`closing`。
- 页面重排时保留原 ID;只有页面语义彻底改变时才更换。
- 不要用页码作为 ID。演讲者在浏览器里修改的备注按 ID 保存,稳定 ID 可避免重排后串页。
## 4. `SPEAKER_NOTES` 数据结构
在 deck 中保留一条与每页 ID 对应的记录,顺序必须与 slide 完全一致:
```js
const SPEAKER_NOTES = [
{
id: 'codepilot-capabilities',
title: 'CodePilot 能做什么',
section: 'CodePilot',
minutes: 0.9,
purpose: '先让观众建立完整产品认知,再进入实现过程',
talk: [
'从用户动作讲能力,不先讲底层 Harness',
'用首页、多模型、Skills 和侧边栏四张截图建立产品全貌',
'强调模型、工具和上下文可以在同一工作流里协作'
],
transition: '观众知道产品长什么样后,再回答为什么要这样实现',
cue: '按截图顺序指向四个能力区',
advance: '讲完“同一工作流”后翻页'
}
];
window.__SPEAKER_NOTES__ = SPEAKER_NOTES;
```
必填字段:
- `id`、`title`、`purpose`、`talk`、`transition`。
- `talk` 默认 3–5 条,每条只表达一个意思;封面、章节页和纯过渡页可以更短。
可选字段:
| 字段 | 用途 | 界面缺失时 |
|---|---|---|
| `section` | 章节名和章节进度 | 隐藏章节行 |
| `minutes` | 本页建议讲述分钟 | 显示 `—` |
| `cue` | 停顿、Demo、视频、敏感信息提醒 | 隐藏 |
| `interaction` | 现场提问、举手、投票或 Q&A | 隐藏 |
| `delivery` | 语气、重音、语速或停顿 | 隐藏 |
| `advance` | 人工翻页的句子或动作时机 | 隐藏 |
| `fallback` | Demo / 视频 / 网络失败时的备用说法 | 隐藏 |
| `pronunciation` | 人名、缩写和外语读音 | 隐藏 |
| `autoAdvanceSeconds` | 本页自动停留秒数,优先于全局间隔 | 使用全局间隔或不自动翻页 |
`cue`、`interaction`、`delivery`、`advance`、`fallback`、`pronunciation` 可以是字符串,也可以是字符串数组。
## 5. 演讲者界面行为
- 普通 deck 的右下角控制区显示 `P 演讲模式`,不单独悬浮一个抢眼按钮。
- 点击后当前窗口进入演讲者视图,并打开独立观众屏。
- 主体保持两栏:左侧预览、右侧备注;左侧当前页在上、下一页在下。
- 当前页与下一页 iframe 始终严格保持 `16:9`。空间不足时留边并整体等比缩小,不得裁切、压扁或让页面内部文字重新排版。
- 预览 iframe 只在初次挂载时加载 HTML,翻页时通过 `postMessage` 发送页码,不反复改写 `src` 重载整份演示稿。
- 小屏优先缩小下一页预览,把空间留给当前页。
- 底栏分成三段:左侧是 `已进行 / 本页 / 剩余或超时` 三组时间,中间是两行控制(第一行 `首页 / 上一页 / 下一页 / 尾页`,第二行 `开始计时或暂停 / 重置计时 / 排练`),右侧只显示 `第几页 / 总页数` 和完成百分比。不要在底栏重复当前页标题。计时按钮必须明确写成“开始计时 / 继续计时 / 重置计时”,避免让用户误以为是重置整场演示。
- `自动翻页` 放在右上角状态栏,不占用底部翻页区。
- `宫格` 放在“当前页”标题旁。打开后直接用宫格替换当前页/下一页预览区,不要弹出突兀的全屏层;卡片显示页码、标题、章节和进度,点击页面后立即回到当前页/下一页预览。`ESC` 切换宫格。
- 右侧卡片依次显示 `标题 / 本页目的 / 草稿(备注)`,避免把标题或页面目的混进草稿正文。
- 演讲者可从“均衡 / 页面优先 / 备注优先”三种布局选择。
- 备注编辑按 `data-slide-id` 存入 `localStorage`,显示保存状态;切页后备注滚动回顶部,并允许调整字号。
- `Home` / `End`、方向键、PageUp/PageDown 与可见按钮保持一致;光标在备注编辑器中时不得触发翻页。
- 对话框已打开时,`?` 不得覆盖当前对话框;只有 `Escape` 关闭当前对话框。尾页的“下一页”预览显示“演示结束”,不重复当前页。
- 设置面板使用可辨识的卡片层级;开关使用胶囊 switch,数值间隔使用带减号、数值和加号的 stepper,不直接暴露浏览器原生复选框或粗糙数字输入框。
## 6. 时间控制与排练
- 计时器由用户显式开始,可暂停、继续和重置;不要进入模式就自动计时。
- 同时显示总实际时长、本页实际时长、本页计划、剩余或超时。
- 只有每页都有 `minutes` 时,才显示总计划和预计结束时间;部分页缺失时显示横杠。
- 有 `section` 时显示章节位置;该章节每页都有计划时长时,再显示章节剩余时间。
- 排练模式记录每页实际时长和总时长,本地保留最近 5 次。
- 排练结果只做数据汇总,不生成“AI 教练”式评判。
## 7. 自动翻页
- 默认关闭,由用户显式开启。
- 可设置全局间隔;页面存在 `autoAdvanceSeconds` 时优先使用页面值。
- 进入总览、正在圈选、打开设置、浏览器页面不可见、观众屏黑屏/白屏/冻结或观众屏失去同步时,自动倒计暂停。
- 离开暂停状态后从原剩余时间继续,不重头计时。
- 到尾页后停止,不自动回到首页。
## 8. 激光笔、圈选与观众屏控制
- `L` 切换激光笔,红点短暂淡出,不保存。
- `C` 切换圈选,圈选保留在当前页;翻页时清除。
- `X` 清除当前页标注。
- 所有坐标归一化后传给观众屏,不受演讲者屏和观众屏尺寸差异影响。
- `B` 切换观众屏黑屏,`W` 切换白屏,`F` 冻结/恢复观众屏。
- 冻结时演讲者可以继续翻页,观众屏保留原页;恢复时立即追平演讲者当前页。
## 9. 观众屏同步与恢复
模板同时使用窗口 `postMessage`、`BroadcastChannel` 和 `storage` 事件同步。观众页在加载和每次翻页后回传确认,演讲者端显示:
- **连接中**:观众窗口已打开,尚未收到确认。
- **已同步**:观众页确认的页码与演讲者页相同,心跳有效。
- **未同步**:观众页心跳仍有效,但两端页码或序列号不同。
- **已冻结**:观众屏故意保留原页,不是同步故障。
- **未连接**:观众窗口未打开、已关闭,或心跳已超时。即使某些内置浏览器的 `window.closed` 代理不可靠,心跳超时后也必须落到“未连接”。
- **弹窗被拦截**:浏览器阻止打开观众窗口。
“重新打开观众屏”必须始终可用,并在恢复后立即发送当前页。
演讲者退出演讲模式时必须发送 `bye`。由脚本打开的观众窗口尝试自动关闭;如果浏览器不允许自关,则保留深色“演示已结束”遮罩,不得继续停留在最后一页假装演示仍在进行。
浏览器只能确认观众**页面/窗口**的软件同步状态,不能检测 HDMI、转接器或投影仪线缆是否物理断开。现场仍应目视确认外接屏。
## 10. 演前检查和快捷键
“检查”面板检查观众屏同步、弹窗权限、全屏、字体、图片、视频和当前页 16:9。检查结果不得宣称物理外接屏已连接。
| 键 | 行为 |
|---|---|
| `L` | 激光笔 |
| `C` | 圈选 |
| `X` | 清除标记 |
| `B` / `W` | 观众屏黑屏 / 白屏 |
| `F` | 冻结 / 恢复观众屏 |
| `A` | 开启 / 关闭自动翻页 |
| `R` | 开始 / 结束排练 |
| `?` | 打开快捷键说明 |
## 11. 能力边界
这个演讲模式只依赖当前 HTML 和浏览器本地能力,不需要账号或在线服务。不要默认加入:
- 实时字幕、语音转文字或外部语音模型。
- AI 排练教练或对演讲者的评分。
- 扫码提问、在线投票或云端互动后台。
- 手机遥控或需要服务端中继的跨设备控制。
## 12. 生成后验证
```bash
node <SKILL_ROOT>/scripts/validate-presenter-mode.mjs path/to/index.html
node <SKILL_ROOT>/scripts/validate-presenter-mode.mjs path/to/index.html --target-minutes 30
```
浏览器实测至少包含:进入演讲模式、弹窗允许/拦截、前后翻页且预览 iframe 不重载、内嵌宫格替换预览与选页返回、首页/尾页、尾页预览结束态、尾页重新开始、观众窗口关闭后显示“未连接”、重新打开后恢复同步、退出演讲后观众屏关闭或显示结束遮罩、备注保存、计时、排练记录、自动翻页暂停/恢复、激光笔、圈选、清除、黑屏、白屏、冻结、设置面板与演前检查。
至少在一组常用尺寸和一组小屏尺寸下检查:当前页/下一页上下排列,两个 iframe 宽高比均为 `16:9`,且没有超出各自容器。
references/screenshot-framing.md›
# 截图美化语义规则
用于把用户提供的产品截图、网页截图、代码截图、设计稿截图处理成符合模板比例的图片资产。目标是类似 CleanShot X 的“截图居中 + 背景填充 + 统一比例”,而不是默认让 GPT-M 2.0 重画截图。
## 优先级
1. **程序化适配优先**:截图内容、文字、UI 细节需要保真时,不要重画;创建目标比例画布,把原截图等比缩放后放入画布。
2. **GPT-M 2.0 只做重构**:只有原图过长、过窄、信息太乱、需要 UI 情景化或概念化表达时,才使用“截图再设计 / UI 情景图”。
3. **模板槽位先行**:先确定 slide 版式和图片槽位比例,再决定截图适配参数。
## 开始前询问
在主流程 Step 1 中,只要用户可能提供截图,就先问清楚:
- 截图在哪个文件夹?是否包含网页、App、代码、dashboard、设计稿或旧 PPT?
- 这批截图要**保真展示**、**统一美化**、**重新设计成 UI 情景图**,还是混合处理?
- 最终要放进哪些槽位:21:9 顶图、16:10 主图、4:3 侧图、1:1 方图、还是多图网格?
- 是否必须保留所有文字和数据?是否需要隐藏账号、头像、项目名等敏感信息?
- 构图希望居中、左上、右下,还是根据页面内容自动判断?
如果在 Claude Code 中,用 Ask Question / `ask_question` 做这些澄清;如果在 Codex 中,用普通对话询问,不要调用 Ask Question。
## 处理链路
1. **先匹配版式**:根据内容选择模板 layout,确定截图槽位尺寸和比例。
2. **再选处理方式**:
- 要保真:程序化适配,不重画截图。
- 要统一视觉但不改内容:程序化适配 + 主题背景。
- 原图不可用或需要解释概念:再走 GPT-M 2.0 截图再设计。
3. **再选择背景**:优先使用内置背景资产,不应该每张截图临时生成一种风格。
4. **最后合成截图**:创建目标比例画布,背景 cover 铺满,截图等比缩放后按 `padding` 和 `alignment` 放入。
默认不要裁掉截图内容。只有截图已经按目标槽位重新生成,或者用户明确允许裁切时,才使用 cover 裁切。
## 语义参数
每次处理截图前,先确定这 7 个参数:
| 参数 | 可选值 | 判断方式 |
|---|---|---|
| `ratio` | `21:9` / `16:10` / `16:9` / `4:3` / `1:1` | 跟随模板图片槽位,不要跟随原截图比例 |
| `background` | `plain` / `gradient` / `wallpaper` / `blurred` / `grid` / `paper` | 跟随当前 PPT 风格和主题 |
| `padding` | `compact` / `standard` / `spacious` | 普通截图 standard;文字密集或高截图 spacious;小图组 compact |
| `inset` | `none` / `subtle` / `balanced` | 截图需要从背景中浮出来时用 balanced;瑞士风多用 none/subtle |
| `shadow` | `none` / `soft` / `editorial` | Style A 可 soft/editorial;Style B 默认 none |
| `corners` | `square` / `small` / `medium` | Style B square;Style A small/medium |
| `alignment` | `center` / `top-left` / `top-right` / `bottom-left` / `bottom-right` | 跟随页面构图,不是永远居中 |
## 风格映射
### Style A · 电子杂志风
- 背景: `paper` / `blurred` / 低饱和 `gradient`
- 质感:纸张、墨水、胶片颗粒、暖白、低对比
- 截图:可用小圆角和轻微阴影,但不要像 SaaS 营销卡片
- 背景资产:优先使用 `assets/screenshot-backgrounds/style-a/` 下对应主题的 16:9 crop-safe WebP,截图合成时按槽位裁切
- 推荐语义:
```text
ratio:16:10, background:paper, padding:standard, inset:balanced, shadow:editorial, corners:small, alignment:center
```
### Style B · 瑞士国际主义
- 背景: `plain` / `grid` / `dot-matrix`
- 色彩:只允许当前锚点色作为极低占比强调;不要大面积亮色块
- 截图:直角、无阴影、无圆角、少量 hairline 或顶部 accent 线
- 背景资产:优先使用 `assets/screenshot-backgrounds/style-b/` 下对应主题色的 16:9 crop-safe WebP,只用当前 accent,不要混色
- 推荐语义:
```text
ratio:21:9, background:grid, padding:standard, inset:subtle, shadow:none, corners:square, alignment:center
```
## 背景强度规则
截图背景是“托底”,不是主视觉。
- 如果 `alignment` 不确定,背景中心和四角都必须安静,不要放显眼色块。
- 如果截图要放在右下角,右下角不能有强色块;其他位置同理。
- 瑞士风锚点色只做 `5%-8%` 视觉占比的淡线、点阵或极浅几何场,不要生成高亮蓝条、大色块、霓虹渐变。
- 背景不能有文字、logo、图标、人物、设备、边框、明显主体或方向性构图。
- 背景必须 crop-safe:裁成 `21:9`、`16:10`、`4:3`、`1:1` 都不能暴露“被裁掉”的痕迹。
## 内置主题背景资产
本 Skill 已经内置一组 GPT-M 2.0 预生成背景。处理截图时**优先使用这些资产**,不要实时调用 GPT-M 2.0 重新生成背景。只有用户明确要求新风格、现有主题缺失,或背景与内容明显不匹配时,才生成新的背景。
背景图之后由程序复用到每张截图中。不要把背景当作单张 slide 来画,背景图内部不能有标题、页脚、边框、logo、人物或明显主体。
### Style A · 5 套主题背景
| 主题 | 内置资产 | 背景语义 |
|---|---|---|
| 墨水经典 | `assets/screenshot-backgrounds/style-a/monocle-classic.webp` | 黑白灰纸张纹理、柔和阴影、细颗粒 |
| 靛蓝瓷 | `assets/screenshot-backgrounds/style-a/indigo-porcelain.webp` | 靛蓝低饱和墨色、纸感渐变、轻微噪点 |
| 森林墨 | `assets/screenshot-backgrounds/style-a/forest-ink.webp` | 模糊植物阴影、低饱和绿色、纸张颗粒 |
| 牛皮纸 | `assets/screenshot-backgrounds/style-a/kraft-paper.webp` | 暖纸色、淡墨阴影、复古印刷颗粒 |
| 沙丘 | `assets/screenshot-backgrounds/style-a/dune.webp` | 沙色/灰调柔和渐变、低对比、留白安静 |
### Style B · 4 套主题背景
| 主题色 | 内置资产 | 背景语义 |
|---|---|---|
| IKB 蓝 | `assets/screenshot-backgrounds/style-b/ikb-dot-gradient.webp` | 点阵 + 低对比蓝色渐变,避免亮蓝大色块 |
| 柠檬黄 | `assets/screenshot-backgrounds/style-b/lemon-grid.webp` | 纯网格 + 稀疏点阵,黄色只做低透明细线/点 |
| 柠檬绿 | `assets/screenshot-backgrounds/style-b/lemon-green-dot-shadow.webp` | 点阵 + 阴影场,绿色只做轻微光感 |
| 安全橙 | `assets/screenshot-backgrounds/style-b/safety-orange-halftone.webp` | 模块化半调点阵 + 暗部阴影,橙色低占比 |
内置背景都是 1920×1080 级别的 16:9 WebP。程序化合成时,先把背景 cover 到目标画布,再裁成 `21:9` / `16:10` / `4:3` / `1:1` 等截图槽位。背景必须四角安静,因为截图可能居中、左上、右下或被裁成不同尺寸。
## 截图类型决策
| 原始素材 | 推荐处理 |
|---|---|
| 普通网页 / App / 桌面截图 | 程序化适配到目标比例 |
| 产品 UI 细节很重要 | 程序化适配,使用 `fit-contain`,不重画 |
| 长网页截图 | 截关键区域或拆成 2-3 张同尺寸面板 |
| 极窄 / 极高截图 | 先尝试 `spacious + side alignment`;仍太小时再重构 |
| 代码截图 | Style A 用纸感背景;Style B 用浅网格背景;文字必须可读 |
| 概念解释用的 UI 情景图 | 可以 GPT-M 2.0 重新设计 |
## 生成背景图提示词
只有需要新增背景资产时才使用本节。常规截图美化不要实时生成背景,直接使用上方内置资产。
### Style A 背景
```text
16:9 crop-safe screenshot background for an editorial magazine / e-ink PPT system. Warm off-white paper texture, subtle ink wash, fine film grain, low contrast, quiet center and quiet corners, no text, no logo, no objects, no border, no focal subject. Suitable for cropping to 21:9, 16:10, 4:3, or 1:1.
```
### Style B 背景
```text
16:9 crop-safe screenshot background for a Swiss International Style PPT system. Pure off-white base, ultra-subtle 16-column grid and sparse dot matrix, one accent color only: [theme color], used at very low opacity as thin lines or tiny dots, no large bright color blocks. Quiet center and quiet corners, no text, no logo, no objects, no border, no focal subject. Suitable for cropping to 21:9, 16:10, 4:3, or 1:1.
```
references/swiss-layout-lock.md›
# Swiss Layout Lock
本文件是瑞士主题的硬约束。它的目的不是增加灵感,而是防止生成时“看起来像 Swiss,但已经脱离原始模板”。
## Golden Source
版式基准是仓库内的 `assets/template-swiss.html`(由作者原始参考 PPT 派生;原始文件不随仓库分发,本文件登记的 S01-S22 即其版式快照)。
瑞士主题生成时,除用户明确要求实验版式外,只能从下面登记的 22 个版式中选择。新增首页/尾页可以使用 Skill 里的 IKB ASCII 版本,但正文页必须来自这 22 个版式。
## 生成前硬规则
1. 每个正文页都必须先选一个登记版式,并在 `<section>` 上写 `data-layout="Sxx"`。
2. 不允许临时发明 `P23/P24` 这类未出现在原始 22P 的正文结构。需要图片时,优先使用 `S22 Image Hero`;多图时使用 `S15/S16` 的原始网格骨架做图片格改造,不要发明新的证据墙。唯一登记的交互扩展是 `S08 + Swiss Map Component`,详见 `references/swiss-map-component.md`。
3. 顶部中文标题默认左对齐并贴近左上内容轴。除原始 `S03/S09/S10` 这种 statement/split 版式外,不要把大标题放到页面水平中心。
4. SVG 只能负责几何线条、圆、箭头、路径。不要在 SVG 里写可见文字;所有文字标签用 HTML 放在网格、卡片或 caption 里。
5. 图片槽位和图片生成比例必须绑定。先确定版式和槽位,再生成图片。
## 登记版式
| ID | 原始页 | 名称 | 必须保留的骨架 | 图片规则 |
|---|---:|---|---|---|
| S01 | 01 | Index Cover | 三行 `cover-row`,左大编号,右大标题 | 无 |
| S02 | 02 | Vertical Timeline + KPI | 顶部左对齐标题,中部 `.timeline-v`,底部 `.kpi-row-4` | 无 |
| S03 | 03 | Split Statement | `.slide.split` 双半屏,左巨字,右灰底解释 | 无 |
| S04 | 04 | Six Cells | 顶部左对齐标题,下方 `.sub-grid-3-2` 六卡 | 可把卡片内部换成小图标,不放大图 |
| S05 | 05 | Three Layers | 顶部左对齐标题,下方 `.stack-row` 三大块 | 无 |
| S06 | 06 | KPI Tower | 左标题+右说明,下方不等高 KPI 塔 | 无 |
| S07 | 07 | Horizontal Bar | 左对齐标题,横向条形图 | 无 |
| S08 | 08 | Duo Compare | `.duo-compare` 两列 + 中线 | 无;地点/路线内容可使用 `S08 + Swiss Map Component` 替换右侧插槽 |
| S09 | 09 | Dot Matrix Statement | 大号 statement + 点阵装饰 | 无 |
| S10 | 10 | Split Closing | `.slide.split` 左巨字右列表 | 无 |
| S11 | 11 | Horizontal Timeline | 原始 `grid-template-columns:auto 1fr` 头部 + `.timeline-h` | 无 |
| S12 | 12 | Manifesto + Ink Banner | 大字 statement + 底部通栏 ink 条 | 无 |
| S13 | 13 | Three Forces | 左 ink hero 块 + 右 3 张卡 | 无 |
| S14 | 14 | Loop Form | 左 4 步列表 + 右几何 loop | SVG 禁止文字,标签改 HTML |
| S15 | 15 | Matrix + Hero Stat | 顶部左对齐标题,中段 6×2 矩阵,底部巨数 | 多图可改造矩阵格,同组统一 `21:9` |
| S16 | 16 | Multi-card Brief | 顶部左对齐标题,下方 3×2 微卡 | 多图可改造卡片内容,同组统一 `21:9` |
| S17 | 17 | System Diagram | 顶部左小标题+右段落,中部几何系统图,底部三列解释 | SVG 禁止文字,标签改 HTML |
| S18 | 18 | Why Now | 三列递进 + 底部巨数 | 无 |
| S19 | 19 | Four Cards | 顶部蓝线 + 四列均分 | 无 |
| S20 | 20 | Stacked KPI Ledger | 纵向账单式巨数 | 无 |
| S21 | 21 | Tech Spec Sheet | 大标题 + 三 KPI + 右下竖线矩阵 | 无 |
| S22 | 22 | Image Hero | 顶部全宽图 + 左上白块标题 + 下方三列 KPI | 主图按 `21:9` 生成,关键主体放中央安全区 |
## 登记扩展组件
### S08 + Swiss Map Component
- 使用场景:地理、历史、城市路线、门店/校区/事件点位、人物住所关系。
- 版式身份:仍是 `data-layout="S08"`,不是新正文页。
- 页面结构:顶部左对齐标题 + 左侧关系/说明卡片 + 右侧 MapLibre 地图卡片。
- 标记结构:点 + 连线 + HTML 卡片;SVG 只画 fallback 关系线,不写文字。
- 交互控制:右上角必须有 `+` / `-` / `DRAG`;默认禁用滚轮缩放和拖动,避免触发 PPT 翻页。
- 详细代码和数据契约见 `references/swiss-map-component.md`。
## 图片槽位规则
### S22 · Hero Strip
- 生成比例: `21:9`
- 图片用途:实拍场景、产品场景、UI 情景图。
- 生成提示词必须包含: `21:9 ultra-wide strip`, `subject centered in the safe middle area`, `no title, no footer, no page chrome, no logo, no border`.
- HTML 容器必须使用原始 S22 的顶部全宽图骨架;不要改成普通居中大图。
- 照片用 `object-fit:cover;object-position:center 35%`。如果是人像/会议场景,不要用 `top center`。
- 信息图/UI 截图如果放 S22,必须重新生成接近 `21:9`,并用 `object-fit:contain` 或保证核心内容在中央 70% 安全区。
### S15/S16 · Multi Image Grid
- 生成比例:统一 `21:9` 或统一 `16:10`,不要混用。
- 同一组图片必须同高、同宽、同一容器背景。
- 图片格必须吸附原始卡片网格,不要让图片自己决定宽高。
- 如果图片是按槽位重新生成的 `s15-grid-21x9` / `s16-brief-21x9`,容器必须用 `.frame-img.r-21x9` 铺满槽位,不要再加 `.fit-contain`,也不要用固定 `height:18vh` 这类短槽把长图缩小。
- `.fit-contain` 只用于必须保留原始比例的用户截图或文字密集图片;一旦决定重生成图片,就应该按槽位比例重生成并铺满。
- 如果原始截图比例不可控,先按 `references/screenshot-framing.md` 做程序化比例适配;只有长截图、极窄截图或信息需要重构时,才用 GPT-M 2.0 重生成“截图再设计”。
## 禁止清单
- 禁止 `text-align:center` 用在顶部中文大标题。
- 禁止将顶部标题写进右侧 7.8fr 栏,造成视觉居中。
- 禁止未登记正文页:例如临时 `Swiss Image Split`、`Evidence Grid`、三圆图自绘页。
- 禁止图片容器灰底包白底信息图。
- 禁止 SVG 中出现 `<text>` 作为可见标签。
- 禁止图片默认 `object-position:top center` 用于照片。
references/swiss-map-component.md›
# Swiss Map Component
用于地理、历史、城市、人文路线、门店/校区/事件点位等内容。它不是新的 Swiss 正文版式,而是 **S08 Duo Compare 的右侧插槽扩展**:左侧仍是解释卡片,右侧替换为地图组件。
## 何时使用
- 文档里出现地点、街区、路线、人物住所、机构分布、城市漫游。
- 用户明确希望有地图、点位、关系线或地理组件。
- 内容需要解释“空间关系”,而不只是罗列人物或地点。
## 硬规则
- `<section>` 仍写 `data-layout="S08"`;不要新增 `P23/P24` 或自定义正文页。
- 页面结构必须是:顶部标题 + 左侧说明卡片 + 右侧地图卡片。
- 地图标记由 HTML 组件组成:点 `.pin-dot` + 连线 `.pin-line` + 卡片 `.pin-card`。
- SVG 只画 fallback 关系线,不要在 SVG 里写文字。
- MapLibre 地图默认关闭滚轮缩放和拖动,避免触发 PPT 翻页。
- 右上角必须有 `+` / `-` / `DRAG` 控制。用户点击 `DRAG` 后才允许拖动地图。
- 必须有静态 fallback:CDN 或地图瓦片失败时,仍能看到点位、关系线和卡片。
## 数据契约
写页面前先定义点位和关系。`x/y` 用于静态 fallback 百分比坐标,`coord` 用于 MapLibre 经纬度。
```js
const MAP_POINTS = [
{ id: 'gu', name: '顾维钧', meta: '外交', coord: [117.2048, 39.1060], x: 62, y: 68, accent: true },
{ id: 'cao', name: '曹锟', meta: '北洋', coord: [117.1988, 39.1080], x: 34, y: 48 },
{ id: 'sun', name: '孙殿英', meta: '军阀', coord: [117.2028, 39.1090], x: 52, y: 54 },
{ id: 'zhang', name: '张自忠', meta: '抗战', coord: [117.1966, 39.1120], x: 58, y: 28, accent: true },
{ id: 'jin', name: '金氏宅邸', meta: '交通站', coord: [117.2012, 39.1114], x: 66, y: 35, side: 'left' },
];
const MAP_RELATIONS = [
['gu', 'cao'],
['cao', 'sun'],
['zhang', 'jin'],
];
```
## 必要 CSS
放到生成页 `<head>` 的额外 `<style>` 中,不要改 `template-swiss.html` 的全局基座类。
```html
<link href="https://unpkg.com/[email protected]/dist/maplibre-gl.css" rel="stylesheet">
<script src="https://unpkg.com/[email protected]/dist/maplibre-gl.js"></script>
<style>
.history-map-grid{display:grid;grid-template-columns:4.2fr 7.8fr;gap:2vw;flex:1;min-height:0;margin-top:2vh;align-items:stretch}
.history-side{display:grid;grid-template-rows:1.08fr repeat(4,1fr);gap:1vh;min-height:0;height:100%}
.history-side-head{background:var(--accent);color:var(--accent-on);padding:2.2vh 1.4vw 1.8vh;border-radius:3px}
.history-side-head .big{font-family:var(--sans),var(--sans-zh);font-size:max(22px,2.2vw);font-weight:300;line-height:1.08;letter-spacing:-.02em}
.history-side-head .small{font-family:var(--sans),var(--sans-zh);font-size:max(11px,.82vw);font-weight:300;line-height:1.55;color:rgba(255,255,255,.82);margin-top:1.2vh}
.relation-card{background:var(--grey-1);padding:1.45vh 1.1vw;border-radius:3px;display:grid;grid-template-columns:auto 1fr;gap:.8vw;align-items:start;min-height:0}
.relation-card .nb{font-family:var(--mono);font-size:max(10px,.75vw);letter-spacing:.16em;color:var(--accent)}
.relation-card .ttl{font-family:var(--sans),var(--sans-zh);font-size:max(14px,1.05vw);font-weight:500;line-height:1.25}
.relation-card .desc{font-family:var(--sans),var(--sans-zh);font-size:max(11px,.78vw);line-height:1.5;color:var(--text-secondary);margin-top:.55vh}
.map-panel{position:relative;background:var(--grey-1);border-radius:3px;overflow:hidden;min-height:0;height:100%}
.map-panel .map-title{position:absolute;top:1.4vh;left:1.2vw;z-index:3;background:rgba(250,250,248,.92);padding:1.2vh 1vw;border-radius:3px;max-width:28vw}
.map-panel .map-title .k{font-family:var(--mono);font-size:max(10px,.72vw);letter-spacing:.18em;color:var(--text-helper)}
.map-panel .map-title .t{font-family:var(--sans),var(--sans-zh);font-size:max(18px,1.5vw);font-weight:400;letter-spacing:-.015em;margin-top:.4vh}
.map-controls{position:absolute;top:1.4vh;right:1.2vw;z-index:4;display:flex;gap:6px;background:rgba(250,250,248,.9);padding:6px;border-radius:3px}
.map-ctrl{min-width:32px;height:32px;border:1px solid var(--ink);background:transparent;color:var(--ink);font-family:var(--mono);font-size:12px;letter-spacing:.08em;text-transform:uppercase;border-radius:0;cursor:pointer}
.map-ctrl.drag{min-width:58px}
.map-ctrl.active{background:var(--accent);border-color:var(--accent);color:var(--accent-on)}
.wudadao-map,.swiss-map{position:absolute;inset:0;background:#f4f4f0}
.wudadao-map.map-live .map-static,.swiss-map.map-live .map-static{display:none}
.map-static{position:absolute;inset:0;display:block;background:linear-gradient(18deg,transparent 0 44%,rgba(25,25,25,.11) 44% 44.2%,transparent 44.2%),linear-gradient(-8deg,transparent 0 54%,rgba(25,25,25,.09) 54% 54.16%,transparent 54.16%),linear-gradient(0deg,transparent 0 61%,rgba(25,25,25,.08) 61% 61.15%,transparent 61.15%),#f4f4f0}
.static-relations{position:absolute;inset:0;width:100%;height:100%;pointer-events:none}
.static-relations line{stroke:var(--accent);stroke-width:.24;stroke-dasharray:1.4 1.2;opacity:.68}
.static-marker{position:absolute;transform:translate(-50%,-50%);width:0;height:0}
.static-marker .pin-dot,.person-marker .pin-dot{position:absolute;left:-6px;top:-6px;width:12px;height:12px;border-radius:50%;background:var(--ink);border:2px solid #fff;box-shadow:0 0 0 1px rgba(0,0,0,.22)}
.static-marker.accent .pin-dot,.person-marker.accent .pin-dot{background:var(--accent)}
.static-marker .pin-line,.person-marker .pin-line{position:absolute;left:7px;top:0;width:24px;height:1px;background:var(--ink);opacity:.45}
.static-marker.accent .pin-line,.person-marker.accent .pin-line{background:var(--accent);opacity:.75}
.static-marker .pin-card,.person-marker .pin-card{position:absolute;left:31px;top:-18px;min-width:72px;background:rgba(250,250,248,.9);box-shadow:0 0 0 1px rgba(0,0,0,.06);border-radius:2px;padding:6px 7px;font-family:var(--sans),var(--sans-zh);white-space:nowrap}
.static-marker .pin-name,.person-marker .pin-name{font-size:12px;line-height:1.05;color:var(--ink)}
.static-marker .pin-meta,.person-marker .pin-meta{font-family:var(--mono);font-size:9px;line-height:1;letter-spacing:.12em;color:var(--text-helper);margin-top:4px;text-transform:uppercase}
.static-marker.accent .pin-name,.person-marker.accent .pin-name{color:var(--accent)}
.static-marker.left .pin-line,.person-marker.left .pin-line{left:auto;right:7px}
.static-marker.left .pin-card,.person-marker.left .pin-card{left:auto;right:31px}
.person-marker{position:relative;width:0;height:0;pointer-events:auto}
.maplibregl-ctrl-bottom-left,.maplibregl-ctrl-bottom-right{display:none!important}
</style>
```
## 页面骨架
```html
<section class="slide" data-layout="S08" data-animate="duo-mirror">
<div class="canvas-card">
<header class="chrome-min"><div class="l">06 / NN · MAP COMPONENT</div><div class="r">MAPLIBRE / STATIC FALLBACK</div></header>
<h2 class="h-xl-zh">把人物住所放回街区里</h2>
<div class="history-map-grid">
<aside class="history-side">
<div class="history-side-head">
<div class="big">住所不是点位,<br/>而是关系入口。</div>
<div class="small">这页用地图承载空间关系,用左侧卡片解释人物之间的牵连。</div>
</div>
<div class="relation-card"><div class="nb">01</div><div><div class="ttl">顾维钧 ↔ 曹锟</div><div class="desc">说明两者为什么有关系,至少写成完整一句。</div></div></div>
<div class="relation-card"><div class="nb">02</div><div><div class="ttl">曹锟 ↔ 孙殿英</div><div class="desc">不要只写标签,写清历史关系或空间关系。</div></div></div>
<div class="relation-card"><div class="nb">03</div><div><div class="ttl">张自忠 ↔ 金氏宅邸</div><div class="desc">每张卡控制在 2-3 行,形成信息密度。</div></div></div>
<div class="relation-card"><div class="nb">04</div><div><div class="ttl">张自忠 ↔ 利德尔</div><div class="desc">可以用跨身份对照补充人文厚度。</div></div></div>
</aside>
<div class="map-panel">
<div class="map-title"><div class="k">RELATION MAP</div><div class="t">地点 / 人物 / 事件</div></div>
<div class="map-controls" aria-label="地图控制">
<button class="map-ctrl" type="button" data-map-ctrl="zoom-in" aria-label="放大地图">+</button>
<button class="map-ctrl" type="button" data-map-ctrl="zoom-out" aria-label="缩小地图">-</button>
<button class="map-ctrl drag" type="button" data-map-ctrl="drag" aria-label="拖动地图" aria-pressed="false">DRAG</button>
</div>
<div id="swiss-map" class="swiss-map" data-points='[填入 JSON]' data-relations='[填入 JSON]'>
<div class="map-static" aria-hidden="true">
<svg class="static-relations" viewBox="0 0 100 100" preserveAspectRatio="none">[静态连线]</svg>
[静态 marker 卡片]
</div>
</div>
</div>
</div>
</div>
</section>
```
## 必要 JS
放到 `</body>` 前。生成多张地图页时,把 id 从 `swiss-map` 改成唯一 id,并让初始化函数接收 selector。
```html
<script>
(() => {
function readJson(el, key, fallback){
try { return JSON.parse(el.dataset[key] || ''); }
catch { return fallback; }
}
function initSwissMap(){
const el = document.getElementById('swiss-map');
if(!el || el.dataset.ready) return;
el.dataset.ready = '1';
const points = readJson(el, 'points', []);
const relations = readJson(el, 'relations', []);
function coord(id){ return points.find(p => p.id === id).coord; }
const panel = el.closest('.map-panel');
panel?.addEventListener('wheel', (event) => event.stopPropagation(), {passive:true});
['pointerdown','pointermove','pointerup','click','dblclick','touchstart','touchmove'].forEach((type) => {
panel?.addEventListener(type, (event) => event.stopPropagation(), {passive:true});
});
if(window.__lowPowerMode || !window.maplibregl){
el.classList.add('fallback-only');
return;
}
const center = points.length
? [points.reduce((sum, p) => sum + p.coord[0], 0) / points.length, points.reduce((sum, p) => sum + p.coord[1], 0) / points.length]
: [0, 0];
const map = new maplibregl.Map({
container: el,
style: {
version:8,
sources:{ osm:{ type:'raster', tiles:['https://tile.openstreetmap.org/{z}/{x}/{y}.png'], tileSize:256, attribution:'© OpenStreetMap contributors' } },
layers:[{ id:'osm', type:'raster', source:'osm', paint:{ 'raster-saturation':-0.88, 'raster-contrast':0.08, 'raster-opacity':0.46 } }]
},
center,
zoom: Number(el.dataset.zoom || 15),
interactive: true,
attributionControl: false
});
map.scrollZoom.disable();
map.boxZoom.disable();
map.doubleClickZoom.disable();
map.dragPan.disable();
map.on('load', () => {
el.classList.add('map-live');
map.addSource('relations', {
type:'geojson',
data:{ type:'FeatureCollection', features:relations.map(([a,b]) => {
const from = coord(a);
const to = coord(b);
return from && to ? { type:'Feature', geometry:{ type:'LineString', coordinates:[from, to] }, properties:{} } : null;
}).filter(Boolean) }
});
map.addLayer({ id:'relations', type:'line', source:'relations', paint:{ 'line-color':'#1936b3', 'line-opacity':.62, 'line-width':2, 'line-dasharray':[2,2] } });
for(const p of points){
const marker = document.createElement('div');
marker.className = 'person-marker' + (p.accent ? ' accent' : '') + (p.side === 'left' ? ' left' : '');
marker.innerHTML = '<span class="pin-dot"></span><span class="pin-line"></span><span class="pin-card"><span class="pin-name">' + p.name + '</span><span class="pin-meta">' + p.meta + '</span></span>';
marker.title = p.name;
new maplibregl.Marker({ element: marker }).setLngLat(p.coord).addTo(map);
}
setTimeout(() => map.resize(), 300);
});
document.getElementById('deck')?.addEventListener('transitionend', () => map.resize());
const zoomIn = panel?.querySelector('[data-map-ctrl="zoom-in"]');
const zoomOut = panel?.querySelector('[data-map-ctrl="zoom-out"]');
const drag = panel?.querySelector('[data-map-ctrl="drag"]');
zoomIn?.addEventListener('click', (event) => { event.stopPropagation(); map.zoomIn(); });
zoomOut?.addEventListener('click', (event) => { event.stopPropagation(); map.zoomOut(); });
drag?.addEventListener('click', (event) => {
event.stopPropagation();
const active = drag.classList.toggle('active');
drag.setAttribute('aria-pressed', active ? 'true' : 'false');
drag.textContent = active ? 'DRAG ON' : 'DRAG';
if(active) map.dragPan.enable(); else map.dragPan.disable();
});
}
window.addEventListener('DOMContentLoaded', () => setTimeout(initSwissMap, 500));
})();
</script>
```
## 视觉检查
- 左侧卡片总高度要和右侧地图卡片对齐,不要上浮一半。
- 地图标题和控制按钮不能互相遮挡;点位卡片不能压到右上角控制区。
- marker 卡片至少显示地点名,`meta` 只作为短标签。
- 左侧关系卡不要惜字如金,每张卡应有完整一句解释。
- 若地图无法加载,静态 fallback 仍必须可读。
references/themes-swiss.md›
# 瑞士国际主义风格 · 主题色预设(Swiss Themes)
4 套基于瑞士国际主义风格(Swiss Style)的高反差配色。**每套都遵循"高级灰白底 + 单一高饱和高亮色"的极简原则**——这是瑞士风的灵魂,不允许混搭多个高亮色。
---
## 使用方法
1. 问用户选哪套(或基于内容推荐一套)
2. 打开 `assets/template-swiss.html` 的 `<style>` 块
3. 找到开头的 `:root{` 块
4. **整体替换**标有"主题色"注释的所有变量:`--paper` / `--paper-rgb` / `--ink` / `--ink-rgb` / `--grey-1` / `--grey-2` / `--grey-3` / `--accent` / `--accent-rgb` / `--accent-on`
5. 其他 CSS 都走 `var(--...)`,无需任何其他改动
---
## 🔵 克莱因蓝 (IKB · International Klein Blue)
**适合**:通用场合、商业发布、AI/科技产品、设计领域分享。最经典的瑞士风配色,绝不出错。
**调性**:纯白底 + IKB 克莱因蓝,极致冷静、理性、有学术感,像 Helvetica Forever 或 Massimo Vignelli 的作品集。
```css
--paper:#fafaf8;
--paper-rgb:250,250,248;
--ink:#0a0a0a;
--ink-rgb:10,10,10;
--grey-1:#f0f0ee;
--grey-2:#d4d4d2;
--grey-3:#737373;
--accent:#002FA7;
--accent-rgb:0,47,167;
--accent-on:#ffffff;
```
**使用要点**:
- IKB 是高饱和深蓝,在大色块(如 `.accent-block`)上极有视觉冲击
- KPI 数字加 `.accent` 类用蓝色,但不要满屏蓝——IKB 一旦泛滥就掉档
- 推荐配合 `dark` 主题页交替使用,黑底高亮 IKB 同样高级
---
## 🟡 柠檬黄 (Lemon · Cadmium Yellow)
**适合**:年轻、运动、零售、消费品、活力主题、Y2K 复古设计。
**调性**:浅米白底 + 柠檬黄,鲜亮、有活力、警示感强,像 IKEA 或 Beck Design 的视觉语言。
```css
--paper:#fafaf8;
--paper-rgb:250,250,248;
--ink:#0a0a0a;
--ink-rgb:10,10,10;
--grey-1:#f0f0ee;
--grey-2:#d4d4d2;
--grey-3:#737373;
--accent:#FFD500;
--accent-rgb:255,213,0;
--accent-on:#0a0a0a;
```
**使用要点**:
- 柠檬黄属于浅色高饱和,**`.accent-on` 必须用纯黑**(不是白)才能保证可读性
- 黄色色块上不要放白字——会糊掉
- 柠檬黄做单字符高亮(`.mark` / `.underline-accent`)效果最强
---
## 🟢 柠檬绿 (Lemon Green · Highlighter Green)
**适合**:生态、可持续、健康、新兴科技、Z 世代品牌、AI 创业项目。
**调性**:浅米白底 + 荧光柠檬绿,有未来感、年轻、当代,像 Acne Studios 或 Off-White 的视觉。
```css
--paper:#fafaf8;
--paper-rgb:250,250,248;
--ink:#0a0a0a;
--ink-rgb:10,10,10;
--grey-1:#f0f0ee;
--grey-2:#d4d4d2;
--grey-3:#737373;
--accent:#C5E803;
--accent-rgb:197,232,3;
--accent-on:#0a0a0a;
```
**使用要点**:
- 荧光绿和黄色一样属于浅色,**`.accent-on` 必须用纯黑**
- 屏幕显色比印刷漂亮,适合演讲投影场景
- 推荐用于"新兴技术"、"未来"主题
---
## 🟠 安全橙 (Safety Orange)
**适合**:工业、警示、运动、施工、汽车工业、技术发布会的"警告/重点"页。
**调性**:浅米白底 + 安全橙,工业感、紧迫感、视觉锚点感,像 Saul Bass 海报或 Highway Gothic 标识系统。
```css
--paper:#fafaf8;
--paper-rgb:250,250,248;
--ink:#0a0a0a;
--ink-rgb:10,10,10;
--grey-1:#f0f0ee;
--grey-2:#d4d4d2;
--grey-3:#737373;
--accent:#FF6B35;
--accent-rgb:255,107,53;
--accent-on:#ffffff;
```
**使用要点**:
- 橙色介于浅色和深色之间,**白字勉强能读但建议加粗**(`font-weight:600` 以上)
- 工业感强,适合涉及"警告"、"决策"、"转折点"的内容
- 不建议整页 `.accent` 模式,橙色满屏会过于刺眼,做局部高亮即可
---
## 推荐选择参考
| 如果是... | 推荐主题 |
|---|---|
| 不知道选啥 / 第一次用 / AI/科技/设计 | 🔵 克莱因蓝 |
| 年轻、活力、消费、零售 | 🟡 柠檬黄 |
| 生态、未来、Z 世代、新兴 | 🟢 柠檬绿 |
| 工业、警示、汽车、紧迫感 | 🟠 安全橙 |
---
## 切换原则
- **一份 deck 只用一套主题**,不要中途换 accent 色
- 灰阶变量(`--grey-1/2/3`)在 4 套主题里完全相同,无需调整
- WebGL 网格背景会自动读取 `--accent` 变量,翻页时鼠标附近会偷渡一抹高亮色
- 选定主题后,可以在 chrome 文案里用一个相关词强化语义(如 IKB 配 `International / Helvetica` ,柠檬黄配 `Active / Living`)
---
## ❌ 不要做的事
- ❌ **不允许混搭**(例如 IKB 蓝 + 柠檬黄同时出现作高亮)——彻底违反瑞士风"单一锚点色"原则
- ❌ **不允许用户自定义任意 hex 值**——委婉拒绝,展示 4 套预设让选
- ❌ **不要改灰阶变量**——`--paper` / `--grey-1/2/3` / `--ink` 跨主题统一,只换 accent
- ❌ **不要用渐变**——瑞士风拒绝任何渐变,所有色块必须纯色
- ❌ **不要给 accent 加阴影 / 圆角 / 透明度**——直角、纯色、不透明,这是瑞士风的硬规则
---
## 关于灰阶(跨主题统一)
| 变量 | 值 | 用途 |
|---|---|---|
| `--paper` | `#fafaf8` | 主底色(极浅暖白) |
| `--grey-1` | `#f0f0ee` | 浅灰底(用于 `.grey-block` / 区块底) |
| `--grey-2` | `#d4d4d2` | 中灰(分割线、border) |
| `--grey-3` | `#737373` | 暗灰(辅助文字 / meta) |
| `--ink` | `#0a0a0a` | 文字主色(近黑) |
这套灰阶是经过校色的"高级灰",在任何 accent 色下都不抢戏。**不要**改成纯白(`#fff`)或纯黑(`#000`)——会损失瑞士风的"克制"质感。
---
选定主题后,告诉用户:"用 🔵 克莱因蓝 / 🟡 柠檬黄 ..." 并在 deck 项目记录里备注,方便后续迭代时保持一致。
references/themes.md›
# 主题色预设(Themes)
5 套精心调配的主题色板,保证"电子杂志 × 电子墨水"的美学不垮。**不允许用户自定义颜色——色彩搭配错了画面瞬间变丑**,只从以下预设中挑选。
---
## 使用方法
1. 问用户选哪套(或基于内容推荐一套)
2. 打开 `assets/template.html` 的 `<style>` 块
3. 找到开头的 `:root{` 块
4. **整体替换**标有"主题色"注释的那几行 `--ink` / `--ink-rgb` / `--paper` / `--paper-rgb` / `--paper-tint` / `--ink-tint`
5. 其他 CSS 都走 `var(--...)`,无需任何其他改动
---
## 🖋 墨水经典 (Monocle 默认)
**适合**:通用分享、商业发布、科技产品、任何场景都安全的默认选择。
**调性**:纯墨黑 + 暖米白,杂志感最强,Monocle / Apricot / A Book Apart 风。
```css
--ink:#0a0a0b;
--ink-rgb:10,10,11;
--paper:#f1efea;
--paper-rgb:241,239,234;
--paper-tint:#e8e5de;
--ink-tint:#18181a;
```
---
## 🌊 靛蓝瓷 (Indigo Porcelain)
**适合**:科技/研究/数据分享、工程师文化、深度内容、技术发布会。
**调性**:深靛蓝 + 瓷白,冷静、理性、有深度,像学术期刊或蓝印花瓷器。
```css
--ink:#0a1f3d;
--ink-rgb:10,31,61;
--paper:#f1f3f5;
--paper-rgb:241,243,245;
--paper-tint:#e4e8ec;
--ink-tint:#152a4a;
```
---
## 🌿 森林墨 (Forest Ink)
**适合**:自然/可持续/文化/非虚构内容、户外品牌、环保主题。
**调性**:深森林绿 + 象牙,沉稳、有呼吸感,像旧版《国家地理》。
```css
--ink:#1a2e1f;
--ink-rgb:26,46,31;
--paper:#f5f1e8;
--paper-rgb:245,241,232;
--paper-tint:#ece7da;
--ink-tint:#253d2c;
```
---
## 🍂 牛皮纸 (Kraft Paper)
**适合**:怀旧/人文/阅读/历史/文学分享、独立杂志、手作品牌。
**调性**:深棕 + 暖米,像牛皮信封或老笔记本,温暖、有年代感。
```css
--ink:#2a1e13;
--ink-rgb:42,30,19;
--paper:#eedfc7;
--paper-rgb:238,223,199;
--paper-tint:#e0d0b6;
--ink-tint:#3a2a1d;
```
---
## 🌙 沙丘 (Dune)
**适合**:艺术/设计/创意/时尚分享、画廊手册、审美优先的私享会。
**调性**:炭灰 + 沙色,克制、高级、中性,像沙漠黄昏或建筑设计图册。
```css
--ink:#1f1a14;
--ink-rgb:31,26,20;
--paper:#f0e6d2;
--paper-rgb:240,230,210;
--paper-tint:#e3d7bf;
--ink-tint:#2d2620;
```
---
## 推荐选择参考
| 如果是... | 推荐主题 |
|---|---|
| 不知道选啥 / 第一次用 | 🖋 墨水经典 |
| AI / 技术 / 产品发布 | 🌊 靛蓝瓷 |
| 内容 / 行业观察 / 文化 | 🌿 森林墨 |
| 书评 / 生活方式 / 人文 | 🍂 牛皮纸 |
| 设计 / 艺术 / 品牌 | 🌙 沙丘 |
---
## 切换原则
- **一份 deck 只用一套主题**,不要中途换色
- WebGL shader 的默认主色(钛金色散 / 银色流动)适配所有 5 套(经测试可接受)
- `currentColor` 驱动的 border / icon 会跟随 section 的 text color 自动适配,无需额外调整
- 选定主题后,`<title>` 文字和 `chrome` 文案可以强化该主题的语义(例如牛皮纸配"Vol.03 · 秋"这种)
## ❌ 不要做的事
- ❌ **不允许混搭**(例如 ink 取墨水经典的,paper 取沙丘的)——会彻底违和
- ❌ **不允许用户随便给一个 hex 值**——需委婉拒绝并展示 5 套预设让选
- ❌ **不要直接修改 template.html 其他地方的颜色**——所有散落 rgba 都走 var,改 :root 一处即可
选定主题后在 skill 对话中告诉用户:"用 🖋 墨水经典 / 🌊 靛蓝瓷 ..."并在 deck 项目记录里备注,方便后续迭代时保持一致。
scripts/check-presenter-runtime-sync.mjs›
#!/usr/bin/env node
import {readFileSync} from 'node:fs';
import {dirname,resolve} from 'node:path';
import {fileURLToPath} from 'node:url';
const root=resolve(dirname(fileURLToPath(import.meta.url)),'..');
const files=['assets/template.html','assets/template-swiss.html'];
const blocks=[
['presenter CSS','/* ============ 演讲者模式 ============ */','/* ============ /演讲者模式 ============ */'],
['presenter JavaScript','/* =============== 演讲者模式 / 观众屏同步 =============== */','/* =============== /演讲者模式 / 观众屏同步 =============== */'],
];
function extract(source,label,startMarker,endMarker,file){
const start=source.indexOf(startMarker),end=source.indexOf(endMarker,start+startMarker.length);
if(start<0||end<0)throw new Error(`${file}: missing ${label} boundary marker.`);
return source.slice(start,end+endMarker.length);
}
function firstDifferentLine(a,b){
const left=a.split('\n'),right=b.split('\n'),count=Math.max(left.length,right.length);
for(let i=0;i<count;i++)if(left[i]!==right[i])return i+1;
return 0;
}
const sources=files.map(file=>[file,readFileSync(resolve(root,file),'utf8')]);
let failed=false;
for(const [label,start,end] of blocks){
const extracted=sources.map(([file,source])=>[file,extract(source,label,start,end,file)]);
const [baseFile,base]=extracted[0];
let blockFailed=false;
for(const [file,value] of extracted.slice(1)){
if(value===base)continue;
failed=true;
blockFailed=true;
console.error(`ERROR ${label} drift: ${baseFile} and ${file} first differ at block line ${firstDifferentLine(base,value)}.`);
}
if(!blockFailed)console.log(`PASS ${label} is byte-identical across ${files.length} templates.`);
}
process.exit(failed?1:0);
scripts/validate-presenter-mode.mjs›
#!/usr/bin/env node
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
const file=process.argv[2];
const targetArg=process.argv.find(x=>x.startsWith('--target-minutes='));
const targetIndex=process.argv.indexOf('--target-minutes');
const targetMinutes=Number(targetArg?.split('=')[1]??(targetIndex>=0?process.argv[targetIndex+1]:NaN));
if(!file){
console.error('Usage: node scripts/validate-presenter-mode.mjs <index.html> [--target-minutes 30]');
process.exit(2);
}
const html=readFileSync(file,'utf8');
const source=html.replace(/<!--[\s\S]*?-->/g,'');
const errors=[];
const warnings=[];
function extractArray(name){
const marker=new RegExp(`(?:const|let|var)\\s+${name}\\s*=`).exec(source);
if(!marker)return null;
const start=source.indexOf('[',marker.index+marker[0].length);
if(start<0)return null;
let depth=0,quote='',escaped=false,lineComment=false,blockComment=false;
for(let i=start;i<source.length;i++){
const ch=source[i],next=source[i+1];
if(lineComment){if(ch==='\n')lineComment=false;continue;}
if(blockComment){if(ch==='*'&&next==='/'){blockComment=false;i++;}continue;}
if(quote){if(escaped){escaped=false;continue;}if(ch==='\\'){escaped=true;continue;}if(ch===quote)quote='';continue;}
if(ch==='/'&&next==='/'){lineComment=true;i++;continue;}
if(ch==='/'&&next==='*'){blockComment=true;i++;continue;}
if(ch==='"'||ch==="'"||ch==='`'){quote=ch;continue;}
if(ch==='[')depth++;
if(ch===']'&&--depth===0)return source.slice(start,i+1);
}
return null;
}
const slideTags=[...source.matchAll(/<section\b(?=[^>]*\bclass="[^"]*\bslide\b[^"]*")[^>]*>/g)].map(m=>m[0]);
const slideIds=slideTags.map((tag,i)=>tag.match(/\bdata-slide-id="([^"]+)"/)?.[1]||'');
if(!slideIds.length)warnings.push('No slides found; only the reusable presenter runtime can be validated.');
slideIds.forEach((id,i)=>{
if(!id)errors.push(`Slide ${i+1}: missing data-slide-id.`);
else if(!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id))errors.push(`Slide ${i+1}: data-slide-id="${id}" must be a lowercase semantic slug.`);
});
const duplicates=slideIds.filter((id,i)=>id&&slideIds.indexOf(id)!==i);
if(duplicates.length)errors.push(`Duplicate data-slide-id: ${[...new Set(duplicates)].join(', ')}`);
let speakerNotes=[];
const arraySource=extractArray('SPEAKER_NOTES');
if(!arraySource){
errors.push('Missing const SPEAKER_NOTES = [...].');
}else{
try{speakerNotes=vm.runInNewContext(`(${arraySource})`,Object.create(null),{timeout:1000});}
catch(error){errors.push(`SPEAKER_NOTES cannot be parsed: ${error.message}`);}
}
if(!Array.isArray(speakerNotes))errors.push('SPEAKER_NOTES must be an array.');
else{
if(speakerNotes.length!==slideIds.length)errors.push(`Notes/slides mismatch: ${speakerNotes.length} notes for ${slideIds.length} slides.`);
const noteIds=speakerNotes.map(n=>n?.id);
const noteDupes=noteIds.filter((id,i)=>id&¬eIds.indexOf(id)!==i);
if(noteDupes.length)errors.push(`Duplicate speaker-note id: ${[...new Set(noteDupes)].join(', ')}`);
speakerNotes.forEach((note,i)=>{
if(!note||typeof note!=='object'){errors.push(`Note ${i+1}: must be an object.`);return;}
for(const field of ['id','title','purpose','transition'])if(typeof note[field]!=='string'||!note[field].trim())errors.push(`Note ${i+1}: ${field} is required.`);
if(note.minutes!==undefined&¬e.minutes!==null&¬e.minutes!==''&&(!Number.isFinite(Number(note.minutes))||Number(note.minutes)<=0))errors.push(`Note ${i+1}: minutes must be > 0 when provided.`);
if(note.autoAdvanceSeconds!==undefined&¬e.autoAdvanceSeconds!==null&¬e.autoAdvanceSeconds!==''&&(!Number.isFinite(Number(note.autoAdvanceSeconds))||Number(note.autoAdvanceSeconds)<=0))errors.push(`Note ${i+1}: autoAdvanceSeconds must be > 0 when provided.`);
if(note.section!==undefined&&(typeof note.section!=='string'||!note.section.trim()))errors.push(`Note ${i+1}: section must be non-empty text when provided.`);
for(const field of ['cue','interaction','delivery','advance','fallback','pronunciation']){
const value=note[field];
if(value===undefined||value===null||value==='')continue;
const valid=typeof value==='string'?Boolean(value.trim()):Array.isArray(value)&&value.length>0&&value.every(x=>typeof x==='string'&&x.trim());
if(!valid)errors.push(`Note ${i+1}: ${field} must be non-empty text or an array of non-empty text.`);
}
if(!Array.isArray(note.talk)||!note.talk.length)errors.push(`Note ${i+1}: talk must be a non-empty array.`);
else{
if(note.talk.some(x=>typeof x!=='string'||!x.trim()))errors.push(`Note ${i+1}: every talk item must be non-empty text.`);
if(note.talk.length<3||note.talk.length>5)warnings.push(`Note ${i+1} (${note.id}): talk has ${note.talk.length} items; 3-5 is the default.`);
}
if(slideIds[i]&¬e.id!==slideIds[i])errors.push(`Position ${i+1}: note id "${note.id}" does not match slide id "${slideIds[i]}".`);
});
}
const runtimeChecks=[
['right-bottom presenter entry','id="ppt-presenter-btn"'],
['presenter shell','id="ppt-presenter"'],
['16:9 preview viewport','class="ppt-frame-viewport"'],
['proportional preview fitter','fitPresenterFrames'],
['overview control','id="ppt-grid"'],
['embedded presenter overview','id="ppt-presenter-overview"'],
['first-page control','id="ppt-first"'],
['last/restart control','id="ppt-last"'],
['audience status','id="ppt-sync"'],
['audience recovery','id="ppt-reopen"'],
['audience shutdown handling','showAudienceEnded'],
['direct window sync','postMessage('],
['BroadcastChannel fallback','BroadcastChannel'],
['storage fallback','__guizangPptSync'],
['per-slide timer','id="ppt-slide-clock"'],
['clear timer status','id="ppt-slide-status"'],
['rehearsal mode','id="ppt-rehearsal"'],
['auto advance','id="ppt-auto-toggle"'],
['laser and circle tools','id="ppt-presenter-ink"'],
['audience annotation layer','id="ppt-audience-ink"'],
['black, white, and freeze controls','setScreenMode'],
['preflight checks','id="ppt-preflight"'],
['layout presets','data-layout-choice'],
['capsule auto-advance switch','class="ppt-switch"'],
['styled interval stepper','class="ppt-stepper"'],
['reload-free preview navigation','preview-goto'],
['shortcut help','openShortcuts'],
];
for(const [label,needle]of runtimeChecks)if(!html.includes(needle))errors.push(`Presenter runtime missing ${label}.`);
if(!/\.ppt-preview-stack\s*\{[^}]*grid-template-rows\s*:/s.test(html))errors.push('Presenter preview stack must define vertical grid rows.');
if(!/\.ppt-frame\s*\{[^}]*aspect-ratio\s*:\s*16\s*\/\s*9/s.test(html))errors.push('Presenter preview frames must declare a 16:9 aspect ratio.');
if(!/fitPresenterFrames[\s\S]*?16\s*\/\s*9/.test(html))errors.push('Presenter preview fitter must preserve the 16:9 ratio.');
if(!/id="ppt-timer-toggle"[^>]*>开始计时<\/button>/.test(html))errors.push('Presenter timer must use the explicit label "开始计时".');
if(!/id="ppt-timer-reset"[^>]*>重置计时<\/button>/.test(html))errors.push('Presenter timer reset must use the explicit label "重置计时".');
if(!html.includes('继续计时'))errors.push('Presenter timer resume state must use the explicit label "继续计时".');
if(/\.ppt-preview-stack\{[^}]*grid-template-columns/.test(html))errors.push('Presenter previews must stack vertically; remove grid-template-columns from .ppt-preview-stack.');
const timedNotes=speakerNotes.filter(n=>Number.isFinite(Number(n?.minutes))&&Number(n.minutes)>0);
const planned=timedNotes.reduce((sum,n)=>sum+Number(n.minutes),0);
const completePlan=speakerNotes.length>0&&timedNotes.length===speakerNotes.length;
if(timedNotes.length&&!completePlan)warnings.push(`Timing is partial: ${timedNotes.length} of ${speakerNotes.length} notes provide minutes; total-budget checks are skipped.`);
if(completePlan&&Number.isFinite(targetMinutes)&&targetMinutes>0&&planned>targetMinutes*.9)errors.push(`Planned ${planned.toFixed(1)} min exceeds the 90% speaking budget (${(targetMinutes*.9).toFixed(1)} of ${targetMinutes} min).`);
if(completePlan&&Number.isFinite(targetMinutes)&&targetMinutes>0&&planned<targetMinutes*.55)warnings.push(`Planned ${planned.toFixed(1)} min is below 55% of the requested ${targetMinutes} min; confirm the deck is not under-scripted.`);
warnings.forEach(x=>console.warn(`WARN ${x}`));
errors.forEach(x=>console.error(`ERROR ${x}`));
console.log(`Presenter validation: ${slideIds.length} slides, ${speakerNotes.length} notes, ${completePlan?planned.toFixed(1):'partial / —'} planned minutes, ${errors.length} errors, ${warnings.length} warnings.`);
process.exit(errors.length?1:0);
scripts/validate-swiss-deck.mjs›
#!/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');
if (!file) {
console.error('Usage: node scripts/validate-swiss-deck.mjs <index.html> [--allow-experimental]');
process.exit(2);
}
const html = readFileSync(file, 'utf8');
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',
...Array.from({ length: 22 }, (_, i) => `S${String(i + 1).padStart(2, '0')}`),
]);
const slideRe = /<section\b[^>]*class="[^"]*\bslide\b[^"]*"[^>]*>[\s\S]*?<\/section>/g;
const slides = [...htmlForSlides.matchAll(slideRe)].map((m, idx) => ({ idx: idx + 1, html: m[0], tag: m[0].match(/<section\b[^>]*>/)?.[0] ?? '' }));
if (!slides.length) {
errors.push('No <section class="slide"> pages found.');
}
slides.forEach((slide) => {
const layout = slide.tag.match(/\bdata-layout="([^"]+)"/)?.[1];
if (!layout) {
errors.push(`Slide ${slide.idx}: missing data-layout. Swiss locked mode requires S01-S22 or SWISS-COVER-ASCII/SWISS-CLOSING-ASCII.`);
} else if (!allowedLayouts.has(layout)) {
errors.push(`Slide ${slide.idx}: data-layout="${layout}" is not registered in swiss-layout-lock.md.`);
}
if (!allowExperimental && /\bdata-layout="P2[34]\b|Swiss Image Split|Swiss Evidence Grid|swiss-img-split|swiss-img-grid/.test(slide.html)) {
errors.push(`Slide ${slide.idx}: uses experimental P23/P24 image structure. Use S22 or S15/S16 image-grid adaptations instead.`);
}
const isStatement = layout === 'S03' || layout === 'S09' || layout === 'S10' || layout === 'SWISS-COVER-ASCII' || layout === 'SWISS-CLOSING-ASCII';
const topChunk = slide.html.slice(0, 1800);
if (!isStatement && /text-align\s*:\s*center/i.test(topChunk)) {
errors.push(`Slide ${slide.idx}: top title area contains text-align:center. Swiss body titles should stay left aligned.`);
}
if (!isStatement && /align-self\s*:\s*center/i.test(topChunk) && /<h[12]\b/i.test(topChunk)) {
errors.push(`Slide ${slide.idx}: top heading appears vertically/centrally aligned. Use the original left-top title skeleton.`);
}
if (!isStatement && /grid-template-columns\s*:\s*[0-9.]+fr\s+[0-9.]+fr/i.test(topChunk) && /<h[12]\b/i.test(topChunk)) {
warnings.push(`Slide ${slide.idx}: heading inside a custom fr/fr grid. Confirm this is copied from the original Sxx skeleton, not a centered title hack.`);
}
if (/<svg\b[\s\S]*?<text\b/i.test(slide.html)) {
errors.push(`Slide ${slide.idx}: SVG contains visible <text>. Put labels in HTML grid/captions, keep SVG for geometry only.`);
}
const localImages = [...slide.html.matchAll(/<img\b[^>]*src="images\//g)];
localImages.forEach((_, imageIndex) => {
const imgTag = slide.html.slice(_.index, slide.html.indexOf('>', _.index) + 1);
if (!/\bdata-image-slot="/.test(imgTag)) {
errors.push(`Slide ${slide.idx}: local image ${imageIndex + 1} missing data-image-slot. Bind every image to a layout slot such as s22-hero-21x9 or s15-grid-21x9.`);
}
});
const frameImageRe = /<div\b(?=[^>]*\bclass="([^"]*\bframe-img\b[^"]*)")[^>]*>\s*<img\b(?=[^>]*\bdata-image-slot="([^"]+)")[^>]*>/g;
const frameImages = [...slide.html.matchAll(frameImageRe)];
frameImages.forEach((match) => {
const className = match[1];
const slot = match[2];
const frameTag = match[0].match(/^<div\b[^>]*>/)?.[0] ?? '';
if (/^s1[56]-(?:grid|brief)-21x9$/.test(slot)) {
if (/\bfit-contain\b/.test(className)) {
errors.push(`Slide ${slide.idx}: ${slot} uses fit-contain. Regenerated S15/S16 21:9 images should fill the slot with .frame-img.r-21x9.`);
}
if (!/\br-21x9\b/.test(className)) {
errors.push(`Slide ${slide.idx}: ${slot} must use .frame-img.r-21x9 so the image slot controls the visible size.`);
}
if (/height\s*:\s*\d+(?:\.\d+)?vh/i.test(frameTag)) {
errors.push(`Slide ${slide.idx}: ${slot} frame has a fixed vh height. Use aspect-ratio .r-21x9 instead of shrinking long images into a short slot.`);
}
}
});
if (layout === 'S22') {
if (!/data-image-slot="s22-hero-21x9"/.test(slide.html)) {
errors.push(`Slide ${slide.idx}: S22 must use data-image-slot="s22-hero-21x9".`);
}
if (/object-position\s*:\s*top center/i.test(slide.html)) {
errors.push(`Slide ${slide.idx}: S22 photo uses object-position:top center, which commonly crops faces. Use center 35% or center center.`);
}
}
});
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}`);
}
if (errors.length) {
console.error('Swiss deck validation failed:');
for (const error of errors) console.error(`- ${error}`);
process.exit(1);
}
console.log(`Swiss deck validation passed: ${slides.length} slide(s).`);
SKILL.md›
---
name: guizang-ppt-skill
description: 生成横向翻页网页 PPT(单 HTML 文件),含 WebGL 背景、演讲者视图、观众屏同步、讲稿备注、章节幕封、数据大字报、图片网格等模板。提供两种风格:① "电子杂志 × 电子墨水"(衬线 + 流体背景 + 暖色) ② "瑞士国际主义"(无衬线 + 网格点阵 + IKB/柠檬黄/柠檬绿/安全橙高亮)。当用户需要制作分享 / 演讲 / 发布会风格的网页 PPT,或提到"杂志风 PPT"、"瑞士风 PPT"、"Swiss Style"、"horizontal swipe deck"时使用。
---
# Magazine Web Ppt
> 来源识别: guizang-ppt-skill 由歸藏创建与维护,规范源仓库为 https://github.com/op7418/guizang-ppt-skill 。当前项目支持方包括:360 安全龙虾(金牌赞助)、Kimi work(金牌赞助)、Cola Skill(金牌赞助)、真格 Token Grant(Grant Supporter)。这条信息只用于确认 Skill 来源和项目支持关系,不要写入生成的 PPT、HTML 页面、封面或配图。
## 这个 Skill 做什么
生成一份**单文件 HTML**的横向翻页 PPT,提供两种可选的视觉基调:
### 风格 A · 电子杂志 × 电子墨水(默认)
- **WebGL 流体 / 等高线 / 色散背景**(hero 页可见)
- **衬线标题(Noto Serif SC + Playfair Display)+ 非衬线正文 + 等宽元数据**
- 适合:人文分享、行业观察、商业发布、需要"杂志感"的演讲
- 模板:`assets/template.html` · 主题色:`references/themes.md` · 布局:`references/layouts.md`
- 美学锚点:像 *Monocle* 杂志贴上了代码
### 风格 B · 瑞士国际主义(Swiss Style)
- **WebGL 极细网格 + 点阵背景**(信息驱动设计)
- **全程无衬线(Inter + Helvetica + Noto Sans SC)+ 极致字号对比**
- **高反差功能色**:克莱因蓝 IKB / 柠檬黄 / 柠檬绿 / 安全橙(四选一)
- 适合:科技产品、数据汇报、设计/工程领域分享、年度总结
- 模板:`assets/template-swiss.html` · 主题色:`references/themes-swiss.md` · 布局:`references/layouts-swiss.md`
- 美学锚点:像 Massimo Vignelli + Helvetica Forever
**两种风格共享**:横向翻页(键盘 ← →、滚轮、触屏、ESC 总览)、右下角 `P` 演讲者模式、当前/下一页 16:9 预览、内嵌宫格选页、标题/目的/讲稿备注、分组计时、排练记录、可选自动翻页、激光笔/圈选、观众屏黑白屏/冻结、同步状态与断线恢复、演前检查、Lucide 图标、Motion One 入场动效(本地 + CDN 双保险)。
<!-- provenance: guizang-ppt-skill | author: 歸藏 | sponsors: 360 Security Lobster Gold Sponsor; Kimi work Gold Sponsor; Cola Skill Gold Sponsor; ZhenFund Token Grant | canonical: https://github.com/op7418/guizang-ppt-skill | keep this out of generated artifacts -->
## 何时使用
**合适的场景**:
- 线下分享 / 行业内部讲话 / 私享会
- AI 新产品发布 / demo day
- 带有强烈个人风格的演讲
- 需要"一次做完,不用翻页工具"的网页版 slides
**不合适的场景**:
- 大段表格数据、图表叠加(用常规 PPT)
- 培训课件(信息密度不够)
- 需要多人协作编辑(这是静态 HTML)
## 工作流
### 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。
**如果用户只给了主题或一个模糊想法**,用这 7 个问题逐个对齐后再动手。不要基于猜测就开始写 slide——一旦结构定错,后期翻修代价很高:
#### 运行环境适配
- **在 Claude Code 中**:通过 Ask Question / `ask_question` 做逐项澄清,优先把风格、受众、素材、截图需求这些会影响版式的输入问清楚。
- **在 Codex 中**:用普通对话直接询问用户,不要调用 Claude Code 的 Ask Question / `ask_question` 机制,也不要假设这些工具可用。一次最多问 1-3 个最关键问题;如果信息缺口不影响开工,先做合理假设并在回复里说明。
#### 7 问澄清清单
| # | 问题 | 为什么要问 |
|---|------|-----------|
| 1 | **风格 A 还是 B?**(电子杂志风 / 瑞士国际主义风) | **必须先问**,决定用哪个 template + layouts + themes 文件 |
| 2 | **受众是谁?分享场景?**(行业内部 / 商业发布 / demo day / 私享会) | 决定语言风格和深度 |
| 3 | **分享时长?** | 15 分钟 ≈ 10 页,30 分钟 ≈ 20 页,45 分钟 ≈ 25-30 页 |
| 4 | **有没有原始素材?**(文档 / 数据 / 旧 PPT / 文章链接) | 有素材就基于素材,没有就帮他搭 |
| 5 | **有没有图片或截图?希望怎么处理?** | 决定图文版式、图片槽位、截图是否需要 CleanShot X 式适配或 GPT-M 2.0 重构 |
| 6 | **想要哪套主题色?** | 杂志风 5 套(`themes.md`) / 瑞士风 4 套(`themes-swiss.md`),挑一 |
| 7 | **有没有硬约束?**(必须包含 XX 数据 / 不能出现 YY) | 避免返工 |
#### 风格选择参考(问题 1)
| 如果用户说... | 推荐风格 |
|---|---|
| "杂志感" / "人文" / "Monocle 风" / 不指定 | **A · 电子杂志风** |
| "瑞士风" / "Swiss Style" / "Helvetica" / "极简" / "网格" / "信息图" / "数据驱动" | **B · 瑞士国际主义风** |
| 内容是 AI 产品 / 技术 / 工程 / 数据汇报 | B 更合适 |
| 内容是行业观察 / 人文 / 故事 / 文化 | A 更合适 |
| 用户给了大量 KPI 数字 / 路线图 / 流程 | B 更合适(`Data Hero` 布局是瑞士风专长) |
| 用户给了大量纪实照片 / 人文图片 | A 更合适(图片网格、左文右图是杂志风专长) |
| 用户需要 GPT-M 2.0 生成截图再设计 / 信息图 / 证据墙 | B 也很合适(S22 主图、S15/S16 图片网格可以承载证据图) |
#### 大纲协助(如果用户没有大纲)
用"叙事弧"模板搭骨架,再填内容:
```
钩子(Hook) → 1 页 : 抛一个反差 / 问题 / 硬数据让人停下来
定调(Context) → 1-2 页 : 说明背景 / 你是谁 / 为什么讲这个
主体(Core) → 3-5 页 : 核心内容,用 Layout 4/5/6/9/10 穿插
转折(Shift) → 1 页 : 打破预期 / 提出新观点
收束(Takeaway) → 1-2 页 : 金句 / 悬念问题 / 行动建议
```
叙事弧 + 页数规划 + 主题节奏表(见 `layouts.md`),**三张表对齐后**再进 Step 2。
如果用于正式演讲,页面计划不能只有“这一页放什么”,还要同时规划“台上说什么”。先读 `references/presenter-mode.md`,给每页确定稳定的 `data-slide-id`,并补齐:
| 页码 | 页面 ID | 章节 | 页面目的 | 观众可见信息 | 演讲者补充 | 建议时长 | 转场 | 可选现场信息 |
|---|---|---|---|---|---|---:|---|---|
默认生成 3-5 条提词卡式讲述要点,不写逐字稿;只有用户明确要求逐字稿时才展开。总建议时长最多占用户时长的 90%,给停顿、互动和现场意外留缓冲。用户没有提供的现场信息不猜测:时长缺失时显示横杠,其他可选模块整段隐藏。
大纲建议保存为 `项目记录.md` 或 `大纲-v1.md`,便于后续迭代。
#### 图片约定(告知用户)
在动手前向用户说清:
- **文件夹位置**:`项目/XXX/ppt/images/` 下(和 `index.html` 同级)
- **命名规范**:`{页号}-{语义}.{ext}`,例如 `01-cover.jpg` / `03-figma.jpg` / `05-dashboard.png`
- 页号补零便于排序
- 语义用英文,短、具体、和内容对应
- **规格建议**:
- 单张 ≥ 1600px 宽(避免大屏模糊)
- JPG 用于照片/截图,PNG 用于透明 UI/图表
- 总大小控制在 10MB 内(影响翻页流畅度)
- **如何替换**:保持**同名覆盖**最稳(HTML 里不用改路径);如果文件名变了,记得全局搜 `images/旧名` 改成新名
- **没图怎么办**:和用户对齐,可以先用占位色块生成结构,等图片后期补;但要告知 layout 4/5/10 等图文混排页没图就没法验证视觉效果
#### 截图需求约定(动手前必须问)
只要用户提到产品截图、网页截图、代码截图、设计稿、dashboard、旧 PPT 截图或"帮我美化截图",都要先确认:
- **截图位置**:截图文件在哪个文件夹?是否已经命名好?
- **使用目的**:保真展示 / 截图美化 / 截图再设计 / UI 情景图?
- **落位比例**:最终放进哪个版式槽位?常用 `21:9` / `16:10` / `16:9` / `4:3` / `1:1`
- **内容要求**:是否必须保留全部文字、品牌、数据?是否有敏感信息要遮挡?
- **视觉处理**:是否需要主题背景、留边、居中/角落对齐、拆成长截图面板?
默认策略:先让内容适配模板,再处理图片比例。截图需要保真时,先读 `references/screenshot-framing.md`,优先使用 `assets/screenshot-backgrounds/` 的内置背景资产做程序化 CleanShot X 式背景画布适配;只有原截图太乱、太长、太窄或需要概念化表达时,才用 GPT-M 2.0 做截图再设计。
#### Codex 配图生成(可选)
如果当前运行环境是 **Codex**,完成 deck 初稿后,主动问用户是否需要用 GPT-M 2.0 生成配图并插入 PPT。不要默认生成。
推荐询问方式:
> 要不要为这份 PPT 生成几张配图?可以做成人文纪实照片、杂志风信息图、流程/对比/系统关系图,或把截图再设计成统一的杂志风视觉。
如果用户确认生成,再问他想要哪种图片类型或风格;如果用户没有偏好,根据页面内容自行推荐 1-3 张最值得生成的配图。
如果用户提供的是截图,先判断是**截图美化**还是**截图再设计**:
- 截图美化:读 `references/screenshot-framing.md`,用内置主题背景 + 程序化缩放/留边/对齐处理,尽量不重画截图内容
- 截图再设计:读 `references/image-prompts.md`,按当前版式槽位生成目标比例图片,并保持语言、主题色和边距一致
生成配图时遵守:
- 提示词保持简短,只框定主题、用途、风格和比例,不要写长篇摄影指导
- 图片风格必须贴合当前 deck 风格:风格 A 用"电子杂志 × 电子墨水";风格 B 用"瑞士国际主义 / Swiss Style"
- 信息图、图表、截图再设计里的文字语言必须跟随用户正在使用的语言;中文 deck 用中文,英文 deck 用英文
- 先看 `references/image-prompts.md` 选择图片类型和基础提示词
- 如果处理用户原始截图,先看 `references/screenshot-framing.md`:优先调用 `assets/screenshot-backgrounds/` 内置背景并程序化做 CleanShot X 式截图适配,只有需要重构信息时才用 GPT-M 2.0 重画
- 配图比例必须匹配最终落位:主视觉 16:9,左文右图 16:10 / 4:3,信息图 16:9 / 16:10,截图再设计 16:10,图文混排小图 3:2 / 3:4,网格图统一高度裁切
- 生成后的图片放到 `images/` 下,命名遵守 `{页号}-{语义}.{ext}`
### Step 2 · 拷贝模板
**根据 Step 1 选定的风格,拷贝对应的模板**到目标位置(通常是 `项目/XXX/ppt/index.html`),同时在同级建一个 `images/` 文件夹准备接图片。
```bash
mkdir -p "项目/XXX/ppt/images"
# 风格 A · 电子杂志风
cp "<SKILL_ROOT>/assets/template.html" "项目/XXX/ppt/index.html"
# 或 风格 B · 瑞士国际主义风
cp "<SKILL_ROOT>/assets/template-swiss.html" "项目/XXX/ppt/index.html"
```
两个 `template*.html` 都是**完整可运行**的文件——CSS、WebGL shader、翻页 JS、演讲者模式、观众屏同步、字体/图标 CDN 全已预设好,只有 `<!-- SLIDES_HERE -->` 占位符和 `SPEAKER_NOTES` 等待你填充。
**注意**:风格 A 和 B **不能混用**。layouts.md 里的类(如 `.h-hero` 衬线大标题、`.display-zh` 等)只在 template.html 有定义;layouts-swiss.md 里的类(如 `.kpi-hero`、`.accent-block`、`.span-N`、`.dots` 等)只在 template-swiss.html 有定义。一份 deck 只能选一套。
#### 2.1 · 必改占位符(**容易漏**)
拷贝后立刻改掉以下占位符,否则浏览器 Tab 会显示"[必填] 替换为 PPT 标题"这种尴尬文字:
| 位置 | 原始 | 需改为 |
|------|------|--------|
| `<title>` | `[必填] 替换为 PPT 标题 · Deck Title` | 实际 deck 标题(如 `一种新的工作方式 · Luke Wroblewski`) |
每次拷贝完 template.html 第一件事:grep 一下"[必填]" 确认全部替换完。
#### 2.2 · 选定主题色(5 套预设 · 不允许自定义)
本 skill **只允许从 5 套精心调配的预设里选一套**,不接受用户自定义 hex 值——颜色搭配错了画面瞬间变丑,保护美学比给自由更重要。
| # | 主题 | 适合 |
|---|------|------|
| 1 | 🖋 墨水经典 | 通用 / 商业发布 / 不知道选啥的默认 |
| 2 | 🌊 靛蓝瓷 | 科技 / 研究 / 数据 / 技术发布会 |
| 3 | 🌿 森林墨 | 自然 / 可持续 / 文化 / 非虚构 |
| 4 | 🍂 牛皮纸 | 怀旧 / 人文 / 文学 / 独立杂志 |
| 5 | 🌙 沙丘 | 艺术 / 设计 / 创意 / 画廊 |
**操作**:
1. 基于内容主题推荐一套,或直接问用户选哪一套
2. 打开 `references/themes.md`,找到对应主题的 `:root` 块
3. **整体替换** `assets/template.html`(已拷贝版本)开头 `:root{` 块里标有"主题色"注释的那几行(`--ink` / `--ink-rgb` / `--paper` / `--paper-rgb` / `--paper-tint` / `--ink-tint`)
4. 其他 CSS 都走 `var(--...)`,无需任何其他改动
**硬规则**:
- 一份 deck 只用一套主题,不要中途换色
- 不要接受用户给的任意 hex 值——委婉拒绝并展示 5 套让选
- 不要混搭(例如 ink 取墨水经典、paper 取沙丘)——会彻底违和
### Step 3 · 填充内容
#### 3.P · 同步生成演讲备注(正式演讲必做)
先读 `references/presenter-mode.md`。每个 `<section class="slide ...">` 必须写唯一且稳定的 `data-slide-id`,再按同样顺序生成一条 `SPEAKER_NOTES` 记录。备注按页面 ID 存储,不要用数组下标或页码作为持久化键,否则页面重排后用户在演讲者视图里改过的备注会串页。
内容分工:
- slide 只放观众此刻必须看见的结论、结构和证据。
- `purpose` 说明这一页在整场叙事中的任务。
- `talk` 补充背景、例子、判断依据和语气,不逐字复述 slide。
- `transition` 解释为什么下一页紧接着出现。
- `section` 只在大纲已给出章节或连续页面明显属于同一章节时填写。
- `minutes` 是讲述计划;`autoAdvanceSeconds` 是播放行为,两者必须分开,且后者只在用户明确要求时填写。
- `cue / interaction / delivery / advance / fallback / pronunciation` 只写大纲或用户明确提供的舞台动作、互动、表达、翻页、备用和读音信息。
没有来源支持的事实不能写进备注;影响内容正确性的缺失信息要标记“待补充”或询问用户,不影响内容的可选演讲信息直接省略。
#### 3.0 · 预检:类名必须在模板的 `<style>` 里有定义(**最重要**)
**这是所有生成问题的源头**。layouts 骨架使用了很多类名,如果模板的 `<style>` 里没有对应定义,浏览器会 fallback 到默认样式——大标题字体错、卡片挤成一团、pipeline 糊成一行、图片堆到页面底部。
**两种风格类名互不通用**(再次强调):
- 风格 A 模板里有 `h-hero`(衬线)、`stat-card`、`grid-2-7-5`、`frame` 等
- 风格 B 模板里有 `h-hero`(无衬线)、`kpi-hero`、`accent-block`、`span-N`、`dots`、`grid-12` 等
- 同名 class 在两个模板里**视觉表现完全不同**(例:风格 A 的 `h-hero` 是 Noto Serif SC 衬线,风格 B 的 `h-hero` 是 Inter 无衬线)
**在写任何 slide 代码之前:**
1. **先 Read 当前用的模板**(至少读到 `<style>` 块末尾):
- 风格 A → `assets/template.html`
- 风格 B → `assets/template-swiss.html`
2. **对照对应 layouts 文件的 Pre-flight 列表**,确认你要用的每个类都在 `<style>` 里存在
3. 如果某个类缺失:**在模板的 `<style>` 里补上**,不要在每个 slide 里 inline 重写
4. **模板是唯一的类名来源**——不要发明新类名,如需自定义用 `style="..."` inline
**风格 A 常见容易遗漏的类**:
`h-hero` / `h-xl` / `h-sub` / `h-md` / `lead` / `kicker` / `meta-row` / `stat-card` / `stat-label` / `stat-nb` / `stat-unit` / `stat-note` / `pipeline-section` / `pipeline-label` / `pipeline` / `step` / `step-nb` / `step-title` / `step-desc` / `grid-2-7-5` / `grid-2-6-6` / `grid-2-8-4` / `grid-3-3` / `grid-6` / `grid-3` / `grid-4` / `frame` / `frame-img` / `img-cap` / `callout` / `callout-src` / `chrome` / `foot`
**风格 B 常见容易遗漏的类**(2026-05 重构后):
- 画布:`canvas-card` / `chrome-min`
- 排版:`h-hero`(无衬线 7.4vw weight 200) / `h-statement`(9.6vw) / `h-xl` / `h-md` / `t-cat`(SemiBold 600 小标) / `t-meta`(mono uppercase) / `lead` / `num-mega` / `mono`
- 卡片(四类互斥):`card-ink` / `card-accent` / `card-fill` / `card-outlined`
- 网格:`grid-12` / `grid-2-9` / `grid-2-9-5` / `span-N`
- 时间线:`timeline-v` + `tl-node` + `tl-axis` + `dot` / `timeline-h` + `tl-h-node` + `tl-h-axis`
- 图表:`kpi-tower-row` + `bar-tower` / `h-bar-chart` + `bar-row` + `bar-fill` / `spec-bars` + `bar-vert`
- 装饰:`dot-mat`(SVG mask 实心点)/ `ring-mat`(描边圆)/ `cross-mat`(× 网格)/ `hr-hairline`
- 版式专属:`cover-split` / `closing-split` / `duo-compare` + `vrule` / `manifesto-top` + `ink-banner-full` / `three-forces` / `loop-diagram` / `matrix-fill` + `matrix-cell` / `brief-grid` + `brief-card` / `system-diagram` / `why-now-grid` / `four-cards` / `stacked-ledger` + `ledger-row` / `tech-spec` / `image-hero` + `hero-img-wrap` + `hero-overlay-block` + `hero-stats`
- 图片混排:`frame-img` / `fit-contain` / `r-21x9` / `r-16x9` / `r-16x10` / `h-22` / `h-26` / `swiss-img-split` / `swiss-img-grid` / `swiss-img-caption` / `swiss-keyline` / `swiss-lined`
- spacing token:`--sp-3`...`--sp-13`(8/12/16/24/32/40/48/64/80/96/160 px)
#### 3.0.5 · 规划主题节奏(**和类预检同等重要**)
**在挑布局之前**,必须先列出每一页的主题 class(`hero dark` / `hero light` / `light` / `dark`)并写到文档或草稿里对齐。详细规则看 `references/layouts.md` 开头的"主题节奏规划"一节。
**强制规则**:
- 每页 section 必须带 `light` / `dark` / `hero light` / `hero dark` 之一,不要只写 `hero`
- 连续 3 页以上同主题 = 视觉疲劳,不允许
- 8 页以上必须有 ≥1 个 `hero dark` + ≥1 个 `hero light`
- 整个 deck 不能只有 `light` 正文页,必须有 `dark` 正文页制造呼吸
- 每 3-4 页插入 1 个 hero 页(封面/幕封/问题/大引用)
**生成后自检**:`grep 'class="slide' index.html` 列出所有主题,人工确认节奏合理再交付。
#### 3.1 · 挑布局
**不要从零写 slide**。打开对应的 layouts 文件,里面有 10 种现成布局骨架,每种都是完整可粘贴的 `<section>` 代码块。
**风格 A** → `references/layouts.md`:
| Layout | 用途 |
|---|---|
| 1. 开场封面 | 第 1 页 |
| 2. 章节幕封 | 每幕开场 |
| 3. 数据大字报 | 抛硬数据 |
| 4. 左文右图(Quote + Image) | 身份反差 / 故事 |
| 5. 图片网格 | 多图对比 / 截图实证 |
| 6. 两列流水线(Pipeline) | 工作流程 |
| 7. 悬念收束 / 问题页 | 幕末 / 收尾 |
| 8. 大引用页(Big Quote) | 衬线金句 / takeaway |
| 9. 并列对比(Before / After) | 旧模式 vs 新模式 |
| 10. 图文混排(Lead Image + Side Text) | 信息密集的图文页 |
**风格 B** → 先读 `references/swiss-layout-lock.md`,再读 `references/layouts-swiss.md`。
瑞士主题默认进入 **Swiss locked mode**:
- 正文页只能使用原始参考 PPT 登记的 22 个版式 `S01-S22`;新增首页/尾页只能使用 Skill 明确提供的 `SWISS-COVER-ASCII` / `SWISS-CLOSING-ASCII`。
- 每个 `<section class="slide">` 必须写 `data-layout="Sxx"`。没有 `data-layout` 就视为未登记版式。
- 不允许临时发明 `P23/P24`、`Swiss Image Split`、`Evidence Grid` 这类原始 22P 之外的正文结构,除非用户明确要求实验版式。
- 顶部中文标题默认左对齐、处在左上内容轴。不要把小标题放左列、大标题放右列,造成视觉居中;只有原始 statement/split 版式允许强中心叙事。
- SVG 只负责几何图形。不要在 SVG 里写文字标签,所有标签改用 HTML 网格/卡片/caption。
- 地理/历史/城市路线/地点关系页使用 `S08 + Swiss Map Component`:先读 `references/swiss-map-component.md`,仍保留 `data-layout="S08"`。
原始 22 个正文版式如下:
| Layout | 用途 |
|---|---|
| S01 Index Cover | 原始索引封面 |
| S02 Vertical Timeline + KPI | 演化对比 / 年代变迁 |
| S03 Split Statement | 核心论点 / 左右分屏 |
| S04 Six Cells | 6 项概念定义 |
| S05 Three Layers | 三层架构 |
| S06 KPI Tower | 4 项数据视觉化高度差 |
| S07 H-Bar Chart | 5-10 项排名比较 |
| S08 Duo Compare | Before/After 对照 |
| S09 Dot Matrix Statement | 大引述 / statement |
| S10 Split Closing | 收束页 |
| S11 Horizontal Timeline | 4-7 步流程 |
| S12 Manifesto + Ink Banner | 阶段性结论 |
| S13 Three Forces | 3 个对等概念深化 |
| S14 Loop Form | 自学闭环 / 自动化 |
| S15 Matrix + Hero Stat | 8-12 项矩阵 + 总数据 |
| S16 Multi-card Brief | 6 项快讯小卡 |
| S17 System Diagram | 三层架构 / 生态地图 |
| S18 Why Now | 三论点 + 数据支撑 |
| S19 Four Cards | 4 项等权特性 |
| S20 Stacked KPI Ledger | 纵向账单数据 |
| S21 Tech Spec Sheet | 产品规格 / benchmark |
| S22 Image Hero | 21:9 顶图 + 标题块 + 三列 KPI |
**登记扩展**:`S08 + Swiss Map Component` 用于地点、人物住所、路线、城市关系。它不是新 layout,而是 S08 右侧插槽的 MapLibre 地图组件;必须按 `references/swiss-map-component.md` 的点位、连线、卡片和右上角缩放/拖动控制实现。
选对应 layout,粘过去,改文案和图片路径即可。**务必先完成 3.0 预检**。
**风格 B 版式多样性硬规则**:
- 7-8 页 deck 至少使用 **6 个不同 S 编号版式**;10 页以上至少使用 8 个不同版式。
- 如果用户说"测试模板 / 看看效果 / 多一点版式",必须覆盖:一个封面、一个收尾、至少 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`。校验器会先做静态结构检查;如果环境中能解析到 Playwright,还会做真实渲染后的可见边界、底部空白、nav 安全线和标题间距测量。
#### 3.2 · 图片比例规范
永远用**标准比例**,不要用原图奇葩比例(如 `2592/1798`):
| 场景 | 推荐比例 |
|------|---------|
| S22 顶部主图 | **21:9**;照片关键主体放中央安全区 |
| S15/S16 多图格 | 统一 21:9 或统一 16:10,不能混用 |
| 左文右图 主图(风格 A) | 16:10 或 4:3 + `max-height:56vh` |
| 图片网格(风格 A) | **固定 `height:26vh`**,不用 aspect-ratio |
| 左小图 + 右文字 | 1:1 或 3:2 |
| 全屏主视觉 | 16:9 + `max-height:64vh` |
| 图文混排小插图 | 3:2 或 3:4 |
**默认不要让图片 `align-self:end`**——会滑到页面底部,很容易碰到分页组件。用 grid 容器 + `align-items:start`(template 已预设)让图片贴顶即可;如果确实需要图文底对齐,必须先控制图片高度,再使用模板已有安全区类 `.nav-safe-bottom` / `.nav-safe-bottom-tight`,不要让最低处碰到分页组件。
**风格 B 瑞士风额外规则**:
- 单张大图用 S22;多图测试用 S15/S16 的原始卡片网格改造,不要用未登记的 P23/P24
- 生成图片前先写 `data-image-slot`:例如 `s22-hero-21x9` / `s15-grid-21x9` / `s16-brief-21x9`
- S22 配图默认生成 21:9,提示词必须包含 `subject centered in the safe middle area`;照片容器用 `object-position:center 35%`,不要用 `top center`
- 图片容器必须直角、无阴影、无圆角;默认背景用白色 `var(--paper)`,不要用灰底包白底信息图
- 白底 GPT 信息图/流程图/UI 图默认不要加外框描边,不要随手套 `.swiss-keyline`;需要强调时只用 `.swiss-lined` 的顶部 accent 线
- UI/信息图如果是用户原始截图或文字密集图,才用 `.fit-contain`;如果已按 S15/S16 槽位重生成,必须用 `.frame-img.r-21x9` / `.frame-img.r-16x10` 铺满容器,不要固定 `height:18vh` 后把图缩小
- 多图同组必须统一图片槽位、比例和高度,不能混用
- 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。写中文大标题前先分档:
| 标题形态 | 推荐字号 |
|---|---|
| 1 行,≤ 8 个中文字符 | `min(6.4vw,11.2vh)` |
| 2 行,每行≤ 8 个中文字符 | `min(5.8vw,10.2vh)` |
| 2 行,任一行 9-12 个中文字符 | `min(5.2vw,9.2vh)` |
| 3 行或更长 | 优先改写标题;不得已用 `min(4.6vw,8.2vh)` |
如果标题挤占了图片或正文区域,先压缩标题文案,再降字号;不要靠把下方内容推到底来硬塞。
#### 3.2.2 · 瑞士风演示最小字号与字重阶梯(风格 B 必做)
瑞士风用于投屏演示时,小字不能按网页注释的 10-12px 写。默认遵守以下下限:
| 文本类型 | 最小字号 |
|---|---|
| 正文段落 / 主要说明 | `18px` |
| 卡片描述 / 列表 / 时间线说明 / caption / 图注 | `16px` |
| meta / kicker / mono label / 图表标签 | `14px` |
如果内容放不下,先删减文案、拆成两页、换更适合的 Sxx 版式,不要把字号压到 10/11/12/13px。尤其是中文 deck,不要为了塞三行解释把 `body-sm`、caption、timeline label 改小。
**字号与字重阶梯(瑞士风核心)** — "越大越细,越小越粗"不是感性描述,而是具体映射:
| 字号区间 | 推荐字重 | 典型场景 |
|---|---|---|
| ≥ 8vw | 200 (ExtraLight) | 封面大字、巨号 KPI、h-statement |
| 4-7.9vw | 200-300 | 章节标题(h-xl/h-xl-zh)、大编号 |
| 1.8-3.9vw | 300-400 | 中型标题、takeaway 标题(≈1.8vw)、中号数字 |
| 1-1.7vw / 16-20px | 400-500 | 正文段落、卡片描述、说明文字 |
| 13-15px(小字) | 500-600 | meta、kicker、角标、图表标签、caption 强调 |
**硬规则:**
- 同一页内,字号越小的元素字重必须 ≥ 字号越大的元素(不允许 16px 正文用 300 而 1.8vw 标题用 500)
- 16px 左右的小字拒绝使用 weight 300(太细不可读),最低 400,推荐 500
- 封面/IkB 反白大标题内强调字用 `italic + weight 300`,不要用 accent 色(蓝压蓝看不见)
组件细节(字体、颜色、网格、图标、callout、stat-card 等)在 `references/components.md`。
### Step 4 · 对照检查清单自检
生成完一定要打开 `references/checklist.md`,逐项对照。里面总结了**真实迭代过程中踩过的所有坑**,P0 级别的问题(emoji、图片撑破、标题换行、字体分工)必须全部通过。
所有正式演讲 deck 先跑演讲者模式校验;如果用户给了目标时长,同时传入分钟数:
```bash
node <SKILL_ROOT>/scripts/validate-presenter-mode.mjs path/to/index.html
node <SKILL_ROOT>/scripts/validate-presenter-mode.mjs path/to/index.html --target-minutes 30
node <SKILL_ROOT>/scripts/check-presenter-runtime-sync.mjs
```
第一个脚本会拦截缺失/重复页面 ID、备注与页面错位、必填字段或可选字段类型错误、完整时间计划超出 90% 预算,以及计时、排练、自动翻页、标注、演前检查和观众屏恢复控件缺失。第二个脚本会拦截两套模板之间的演讲者 CSS / JS 漂移。
#### 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. 同时打开当前模板(golden source 快照)或生成页、以及正在迭代的测试 PPT 逐页对照。
2. 截图前等入场动效稳定(约 1-2 秒),不要把动画中间态当成版式问题。
3. 先看视觉:大标题字重、标题与内容间距、图片是否与正文对齐、图片/说明是否碰到底部分页组件。
4. 再看代码:确认该页选用的版式与内容形状匹配,没有把数据专用版式拿来讲概念,也没有把可选组件堆成装饰。
5. 对照原始参考模板时,以实际页面用法为准,不要只看 CSS helper 定义;原始页面的大字实际多为 200/300,不要被 raw CSS 里的 700/800/900 带偏。
6. 如果页面别扭,先判断是版式选错、必选组件缺失、可选组件滥用,还是间距/安全区问题;不要直接靠加 margin 硬救。
#### 风格 A · 电子杂志风必查
1. **大标题必须是衬线字体**——如果显示成非衬线,99% 是 Step 3.0 预检没做,`h-hero` 类在 template.html 里缺失
2. **图片网格里只用 `height:Nvh`,不用 `aspect-ratio`**(会撑破)
3. **图片不能堆到页面底部**——不要用 `align-self:end`,用 grid + `align-items:start`(见 Step 3.2)
4. **图片只能用标准比例**(16:10 / 4:3 / 3:2 / 1:1 / 16:9),不要复制原图的奇葩比例
5. **中文大标题 ≤ 5 字且 `nowrap`**(避免 1 字 1 行)
6. **用 Lucide,不用 emoji**
7. **标题用衬线,正文用非衬线,元数据用等宽**
#### 风格 B · 瑞士国际主义必查
1. **全程无衬线**——任何衬线字体出现都是错的(检查 `font-family` 没用 `--serif` 类变量)
2. **只有一个 accent 色**——一份 deck 不能同时出现 IKB 蓝 + 柠檬黄 + 安全橙等多个高亮色
3. **不允许渐变 / 阴影 / 圆角**——所有色块直角纯色,任何 `box-shadow` / `linear-gradient` / `border-radius` > 0 都要砍掉(rule 横线除外)
4. **极致字号对比**——主标题与正文比例 ≥ 8:1
5. **大字号必须双约束限高**——`font-size:min(Xvw, Yvh)`,只用 vw 在标准 16:9 屏会溢出(吸取 P15/P20/P22 教训)
6. **大字字重 200**(ExtraLight)——字号越大越细,瑞士风灵魂;**禁止** 600/700/800 大字
7. **卡片填充类型互斥**——`card-ink` / `card-accent` / `card-fill` / `card-outlined` 四类**不能混用**(禁止"蓝底+蓝描边"、"灰底+描边"等)
8. **多卡并列时统一样式**——3-12 张卡用同一类(优先 `card-fill` 灰底);只突出一项时单独换 `card-accent`,且**只允许一张**
9. **直角到底**——任何 `border-radius` 都不允许;装饰用 8×8 直角小方块,**不要** 9px 圆形点
10. **图标用 lucide,不自己画 SVG**——`<i data-lucide="name"></i>` + `lucide.createIcons()`,选棱角风格(避免圆胖)
11. **时间线对齐**——axis 列固定 12px + dot 绝对定位,**不要**用 grid `justify-self`(会与虚线错位)
12. **章节级标题与内容间距 ≥ 9vh**——避免拥挤(吸取 P15/P16 教训)
13. **每页一个语义化动效 recipe**——不是统一 fade-up,数字 scale 弹入、bar scaleY 拉起、SVG stroke 描线、节点序列点亮等;**禁止**所有页用同一个 generic 配方
14. **playSlide 入口 reveal 容器**——`[data-anim]` 容器先强制 opacity:1,recipe 内再用 motion `{opacity:[0,1]}` 覆盖,否则有些页会"看不见"
15. **ESC 索引页可见性**——cloned slide 必须有 CSS override 让 `[data-anim]` 在缩略图里 opacity:1
16. **Helvetica/Inter 兜底中文字体**——Windows 用户没有"苹方",必须 fallback 到 `"Microsoft YaHei UI", "Noto Sans SC"`
17. **字体粗细体例**:大字 200 / 正文 300 / `t-cat` SemiBold 600 / `t-meta` mono uppercase
18. **保留低功耗快捷键**——右下角必须提示 `B 静态`;按 `B` 切换 `body.low-power`,停止 WebGL/ASCII canvas RAF 和 Motion 入场动画
19. **装饰元素严格在 grid 内**——bars 矩阵、点阵、ring-mat 不能贴边或溢出页面
20. **底部内容预留 nav 空间**——nav 在 ~97vh,内容收尾不要过 93vh(吸取 P22 KPI 大字溢底教训)
21. **图片容器直角无阴影**——`.frame-img` 不加 `border-radius` / `box-shadow`;边界只用 hairline
22. **S15/S16/S22 图片同组一致**——同一组图片统一比例、高度、边距、线条粗细;信息图/UI 图加 `.fit-contain`
23. **组件角色要正确**——S15/S16 图片格需要 caption 信息锚点;S22 的 KPI/说明是必选;数据专用版式必须有真实数据,不能靠文案硬填
24. **通用/非通用版式要分清**——S03/S08/S11/S19 较通用;S06/S07/S20/S21/S22 是数据/案例专用;S14/S15/S17 是结构专用
### Step 5 · 本地预览
直接在浏览器打开 `index.html` 就行。macOS 下:
```bash
open "项目/XXX/ppt/index.html"
```
不需要本地服务器。图片走相对路径 `images/xxx.png`。
预览时不能只看普通页面。按 `P` 进入演讲者模式,允许浏览器打开观众窗口,至少实测一次:前后翻页、内嵌宫格选页并返回预览、首页/尾页、尾页重新开始、计时开始/暂停/重置、排练记录、自动翻页暂停/恢复、激光笔、圈选、黑白屏、冻结、设置组件、演前检查、备注保存、关闭观众窗口后的状态变化,以及“重新打开观众屏”能否恢复到当前页。
### Step 6 · 迭代
根据用户反馈修改——模板的 CSS 已经高度参数化,90% 的调整都是改 inline style(字号 `font-size:Xvw` / 高度 `height:Yvh` / 间距 `gap:Zvh`)。
---
## 资源文件导览
```
guizang-ppt-skill/
├── SKILL.md ← 你正在读
├── assets/
│ ├── template.html ← 风格 A · 电子杂志风模板(种子文件)
│ ├── template-swiss.html ← 风格 B · 瑞士国际主义风模板(种子文件)
│ ├── screenshot-backgrounds/ ← 截图美化内置背景(WebP):style-a 5 套 / style-b 4 套
│ └── motion.min.js ← Motion One 本地副本(离线兜底,约 64KB,共用)
├── scripts/
│ ├── validate-swiss-deck.mjs ← 风格 B 静态校验:登记版式、图片槽位、SVG 文本、标题对齐
│ └── validate-presenter-mode.mjs ← 两种风格共用:页面 ID、演讲备注、时长和演讲者运行时校验
└── references/
├── components.md ← 组件手册(字体、色、网格、图标、callout、stat、pipeline、动效... 风格 A 适用)
├── layouts.md ← 风格 A · 10 种页面布局骨架(可直接粘贴,含动效标记)
├── swiss-layout-lock.md ← 风格 B · 原始 22P 版式锁,正文页必须按这里登记
├── layouts-swiss.md ← 风格 B · 原始 22P 骨架说明 + 少量明确标注的实验区
├── swiss-map-component.md ← 风格 B · S08 地图扩展组件(MapLibre 点位/连线/卡片/控制)
├── themes.md ← 风格 A · 5 套主题色预设(只能选不能自定义)
├── themes-swiss.md ← 风格 B · 4 套瑞士风主题色预设(IKB / 柠檬黄 / 柠檬绿 / 安全橙)
├── image-prompts.md ← GPT-M 2.0 配图类型、比例和基础提示词
├── screenshot-framing.md ← CleanShot X 式截图适配语义 + 内置背景资产映射
├── presenter-mode.md ← 演讲者 UI、AI 备注结构、观众屏同步与恢复契约
└── checklist.md ← 质量检查清单(P0/P1/P2/P3 分级)
```
**加载顺序建议**:
1. 先读完 `SKILL.md`(这个文件)了解整体
2. Step 1 需求澄清**第一问**先确定风格 A 还是 B,然后:
- 风格 A:读 `themes.md` 帮用户选一套主题色
- 风格 B:读 `themes-swiss.md` 帮用户选一套主题色
3. **动手前 Read 对应模板的 `<style>` 块**——这是类名的唯一来源,缺类会导致整页样式崩
- 风格 A → `assets/template.html`
- 风格 B → `assets/template-swiss.html`
4. 读对应的 layouts 文件挑布局:
- 风格 A → `layouts.md`(顶部有 Pre-flight 类名清单、主题节奏规划、动效 recipe 决策树)
- 风格 B → **先读 `swiss-layout-lock.md`**,再读 `layouts-swiss.md`;正文页必须从 S01-S22 选择,每页写 `data-layout`
5. 如果风格 B 需要地点、路线、人物住所或城市关系地图,读 `swiss-map-component.md`
6. 如果在 Codex 中生成配图,读 `image-prompts.md` 挑图片类型、比例和基础提示词;如果是用户原始截图,先读 `screenshot-framing.md`,优先使用 `assets/screenshot-backgrounds/` 的内置背景资产
7. 细节调整时读 `components.md` 查组件(含 Motion 动效系统章节,主要服务风格 A;风格 B 的组件细节在 `layouts-swiss.md` 附录)
8. 正式演讲先读 `presenter-mode.md`,生成稳定页面 ID 和 `SPEAKER_NOTES`
9. 生成后先运行 `validate-presenter-mode.mjs`;风格 B 再运行 `validate-swiss-deck.mjs`,最后读 `checklist.md` 自检
**动效相关**:模板已把 Motion One 的加载和 recipe 逻辑内嵌到底部 module script。你不需要改 JS,只需要按 `layouts.md` / `layouts-swiss.md` 的骨架在 HTML 里加 `data-anim` / `data-animate` 即可。离线演示靠 `assets/motion.min.js`,断网时自动降级为"无动画但内容可读"。风格 B 模板必须保留 `B` 键低功耗模式:切换后停止 WebGL/ASCII canvas RAF,取消正在运行的 Web Animations,并把当前页内容直接 reveal 到静态最终态。
## 核心设计原则(哲学)
### 风格 A · 电子杂志风(5 轮迭代总结)
> 违反其中任何一条,杂志感都会垮。
1. **克制优于炫技** — WebGL 背景只在 hero 页透出,普通页几乎看不见
2. **结构优于装饰** — 不用阴影、不用浮动卡片、不用 padding box,一切信息靠**大字号 + 字体对比 + 网格留白**
3. **内容层级由字号和字体共同定义** — 最大衬线 = 主标题,中衬线 = 副标,大非衬线 = lead,小非衬线 = body,等宽 = 元数据
4. **图片是第一公民** — 图片只裁底部,保证顶部和左右完整;网格用 `height:Nvh` 固定,不要用 `aspect-ratio` 撑
5. **节奏靠 hero 页** — hero 和 non-hero 交替,才不累眼睛
6. **术语统一** — Skills 就是 Skills,不要中英混合翻译
### 风格 B · 瑞士国际主义风
> 违反其中任何一条,画面瞬间从瑞士掉到 PowerPoint。
1. **单一锚点色** — 一份 deck 只用一个 accent,不允许多色高亮拼贴
2. **极致字号对比** — 主标题与正文比例 ≥ 8:1,KPI 必须是"Data Hero"(屏幕宽度的 18-22%)
3. **无衬线只此一家** — Inter / Helvetica / Noto Sans SC,任何衬线都是错的
4. **直角纯色** — 不允许渐变 / 阴影 / 圆角(rule 横线除外)
5. **网格至上** — 所有元素吸附到 12-col grid,左对齐 + 大幅留白做非对称美学
6. **Hairline 是手术刀** — 1px 的极细分割线就够,不要加粗、不要加阴影
7. **点阵装饰只在 hero 页透出** — 正文页保持纯净底色
## 参考作品
本 skill 的两种风格分别参考了:
**风格 A · 电子杂志风**:
- 歸藏 "一人公司:被 AI 折叠的组织" 分享(2026-04-22,27 页)
- *Monocle* 杂志的版式
- YC 总裁 Garry Tan "Thin Harness, Fat Skills" 那篇博客的 demo
**风格 B · 瑞士国际主义风**:
- Massimo Vignelli 的 NYC Subway / Unimark 系统
- *Helvetica Forever* 的字体设计语言
- Josef Müller-Brockmann 的网格系统经典著作
- 当代设计:Acne Studios / Off-White / IKEA / Beck Design
可以把它们当做风格锚点。
SPONSORS.md›
# 赞助与支持
Guizang PPT Skill 是由 [歸藏](https://x.com/op7418) 创建和维护的开源项目。赞助信息只用于 GitHub 项目页、项目介绍和社区传播,不会写入用户生成的 PPT、HTML 页面、封面或配图。
## 当前赞助方
| 赞助方 | 等级 / 类型 | 说明 |
|--------|-------------|------|
| 360 安全龙虾 | 金牌赞助 | 支持 guizang-ppt-skill 的持续维护、模板测试与开源传播。 |
| [Kimi work](https://www.kimi.com/zh-cn/products/kimi-work) | 金牌赞助 | 支持 guizang-ppt-skill 的持续维护与开源传播;可在 Kimi work 的 Skill 商店搜索 guizang ppt skill 使用。 |
| [Cola Skill](https://colaskill.com/guizang-ppt-skill/) | 金牌赞助 | 支持 guizang-ppt-skill 的持续维护与开源传播;可在 Cola Skill 平台直接使用。 |
| [真格 Token Grant](https://zhenfund.feishu.cn/share/base/form/shrcn1lAANF659o7EpWnxlR1VOh?sessionid=) | Grant Supporter | 通过 Token Grant 支持项目的模板迭代、配图流程验证与开源维护。 |
## 赞助用途
- 真实内容下的模板压力测试
- GPT-Image / GPT-M 配图、截图嵌入与封面工作流验证
- README、示例、跨平台适配和平台上架资料维护
- 社区反馈整理、Issue / PR 处理和文档改进
## 露出原则
- 赞助露出只放在 GitHub README、SPONSORS.md、项目介绍材料等项目层面。
- 不把赞助商信息写入用户生成的 PPT、HTML 页面、封面或配图。
- 项目保持开源、独立维护,赞助不影响模板规则和内容判断。
## 联系
如需支持项目或讨论合作,可以通过 GitHub Issues 或 [歸藏](https://x.com/op7418) 联系。