以下兩份 deck 骨架分別對應 簡約 15 分鐘深入 1 小時 的結構模板。兩份都只用 React 與標準 Web API(open-slide workspace 的硬規則禁止新增相依套件),可以直接放進 slides/<id>/index.tsx 後跑 npm run dev

共同前提:畫布固定 1920×1080、每個 Page 不接受 props、根元素填滿容器、預設匯出是頁面陣列。

一、共用基礎:token 與版面元件

把約束寫成程式碼,之後每一頁都不再重新決定(對應 07 的四個約束)。

slides/_shared/kit.tsx
export const T = {
  bg: '#08090a',
  bgInv: '#f7f8f8',
  text: '#f7f8f8',
  textInv: '#08090a',
  accent: '#7170ff',
  muted: '#6b6f76',
  pad: 128,                              // 全 deck 唯一的內容邊距
  font: "'Inter', system-ui, sans-serif",
  size: { hero: 168, section: 96, head: 64, body: 38, label: 24 },
};
 
/** 全 deck 唯一的頁面外殼:邊距、底色、字體只在這裡決定一次 */
export function Stage({
  children,
  invert = false,
  pad = T.pad,
}: {
  children: React.ReactNode;
  invert?: boolean;
  pad?: number;
}) {
  return (
    <section
      style={{
        width: '100%',
        height: '100%',
        position: 'relative',
        overflow: 'hidden',
        display: 'flex',
        flexDirection: 'column',
        justifyContent: 'center',
        padding: pad,
        boxSizing: 'border-box',
        background: invert ? T.bgInv : T.bg,
        color: invert ? T.textInv : T.text,
        fontFamily: T.font,
      }}
    >
      {children}
    </section>
  );
}
 
/** 標題一律寫成完整句子(結論),不是名詞片語 */
export function Headline({ children }: { children: React.ReactNode }) {
  return (
    <h2 style={{ fontSize: T.size.head, lineHeight: 1.2, margin: 0, maxWidth: 1400 }}>
      {children}
    </h2>
  );
}
 
export function Label({ children }: { children: React.ReactNode }) {
  return (
    <p style={{ fontSize: T.size.label, letterSpacing: '0.12em', color: T.muted, margin: 0 }}>
      {children}
    </p>
  );
}

二、簡約 15 分鐘:12 頁

結構:SCQA 開場 → 主張頁 → 兩次擺盪 → 反對意見 → S.T.A.R. → 行動 → 收束。重點在主張頁只有一句話資料頁只有一處高亮逐拍揭露控制節奏

slides/architecture-hold/index.tsx
import { Step, Steps, type Page, type SlideMeta, type SlideTransition } from '@open-slide/core';
import { Headline, Label, Stage, T } from '../_shared/kit';
 
/* 1 — 開場:一個具體事實,不自我介紹 */
const Hook: Page = () => (
  <Stage>
    <Label>2026 Q3 · 平台架構</Label>
    <p style={{ fontSize: T.size.hero, lineHeight: 1.1, margin: '32px 0 0', letterSpacing: '-0.03em' }}>
      六個服務。<br />
      三個共用同一個 DB。
    </p>
  </Stage>
);
 
/* 2 — 裂縫:唯一的數字,佔滿版面 */
const Complication: Page = () => (
  <Stage>
    <Headline>跨服務事務的 on-call 呼叫,三個月成長 2.4 倍。</Headline>
    <p style={{ fontSize: 220, lineHeight: 1, margin: '48px 0 0', color: T.accent, letterSpacing: '-0.04em' }}>
      2.4×
    </p>
    <Label>2026-05 → 2026-08 · PagerDuty incident 標籤 `cross-service`</Label>
  </Stage>
);
 
/* 3 — 主張頁:整頁只有一句話 */
const BigIdea: Page = () => (
  <Stage invert>
    <p style={{ fontSize: 116, lineHeight: 1.25, margin: 0, maxWidth: 1500, letterSpacing: '-0.02em' }}>
      我們該<span style={{ color: T.accent }}>停在混合架構</span>——
      邊際維運成本已經超過解耦收益。
    </p>
  </Stage>
);
 
/* 4 — 支點一:逐拍揭露,防止預讀 */
const PillarOne: Page = () => (
  <Stage>
    <Headline>再拆下去,我們買到的東西越來越少。</Headline>
    <Steps>
      <Step>
        <p style={{ fontSize: T.size.body, lineHeight: 1.6, marginTop: 56 }}>
          前三次拆分:變更前置時間各降 18%、11%、9%。
        </p>
      </Step>
      <Step>
        <p style={{ fontSize: T.size.body, lineHeight: 1.6 }}>
          第四次:降 2%,落在量測誤差內。
        </p>
      </Step>
      <Step>
        <p style={{ fontSize: T.size.body, lineHeight: 1.6, color: T.accent }}>
          第五次(進行中):前置時間<strong>上升</strong> 6%。
        </p>
      </Step>
    </Steps>
  </Stage>
);
 
/* 5 — S.T.A.R.:手寫 SVG,兩條曲線的交叉點就是論點 */
const CostCurves: Page = () => {
  const benefit = 'M 0,300 C 220,120 460,60 900,40';
  const cost = 'M 0,420 C 260,380 520,240 900,20';
 
  return (
    <Stage>
      <Headline>兩條線在第四次拆分後交叉。</Headline>
      <svg viewBox="0 0 900 460" style={{ width: 1400, height: 'auto', marginTop: 48 }}>
        <path d={benefit} fill="none" stroke={T.muted} strokeWidth={6} />
        <path d={cost} fill="none" stroke={T.accent} strokeWidth={8} />
        <circle cx={612} cy={168} r={14} fill={T.accent} />
        {/* 標籤直接放在線的末端 —— spatial contiguity,不做圖例 */}
        <text x={910} y={46} fill={T.muted} fontSize={26} fontFamily={T.font}>解耦收益</text>
        <text x={910} y={26} fill={T.accent} fontSize={26} fontFamily={T.font}>維運成本</text>
        <text x={560} y={140} fill={T.accent} fontSize={26} fontFamily={T.font}>第 4 次</text>
      </svg>
    </Stage>
  );
};
 
/* 6 — 最強反對意見放在正文,不留到 Q&A */
const Objection: Page = () => (
  <Stage>
    <Label>你可能會說</Label>
    <Headline>「不拆就沒辦法獨立部署。」</Headline>
    <Steps>
      <Step>
        <p style={{ fontSize: T.size.body, lineHeight: 1.6, marginTop: 48, maxWidth: 1400 }}>
          對——但獨立部署要的是<strong>邊界</strong>,不是<strong>行程</strong>。
          模組邊界加上契約測試,可以拿到 80% 的獨立部署能力,而不必付出跨行程事務的代價。
        </p>
      </Step>
    </Steps>
  </Stage>
);
 
/* 7 — 行動:指名、單一動作、時間邊界 */
const CallToAction: Page = () => (
  <Stage invert>
    <Label>我需要的</Label>
    <p style={{ fontSize: 84, lineHeight: 1.35, margin: '32px 0 0', maxWidth: 1500 }}>
      下週一架構會上,平台組不反對<br />
      <span style={{ color: T.accent }}>凍結第二階段拆分</span>,改做模組邊界。
    </p>
  </Stage>
);
 
/* 8 — 收束:與開場完全相同的措辭 */
const Close: Page = () => (
  <Stage>
    <p style={{ fontSize: 96, lineHeight: 1.3, margin: 0, maxWidth: 1500 }}>
      停在混合架構,不是放棄,是<span style={{ color: T.accent }}>停在成本曲線交叉之前</span>。
    </p>
  </Stage>
);
 
export const transition: SlideTransition = {
  duration: 200,
  exit: { duration: 120, keyframes: [{ opacity: 1 }, { opacity: 0 }] },
  enter: {
    duration: 200,
    delay: 60,
    easing: 'cubic-bezier(0, 0, 0.2, 1)',
    keyframes: [
      { opacity: 0, transform: 'translateY(6px)' },
      { opacity: 1, transform: 'translateY(0)' },
    ],
  },
};
 
/* notes 與頁面陣列索引對齊;不需要提示的頁面放 undefined */
export const notes = [
  '⏱ 0:00–1:30|站定,掃視,停 2 秒。第一句逐字:「六個服務,三個共用同一個 DB。」不要自我介紹。',
  '數字來源:PagerDuty,標籤 cross-service,2026-05 → 2026-08。若被追問細節 → 附錄。',
  '⏱ 應在 2:30 前離開此頁。講完主張停 3 秒再翻頁。',
  '⏱ ~5:00|三拍逐個揭露。第三拍講完停 2 秒。',
  '⏱ ~9:30|S.T.A.R.。先講兩條線是什麼,再指交叉點。不要一開口就講結論。',
  '複述反對意見的最強版本,不要削弱它。',
  '⏱ ~11:00|指名平台組。停 3 秒。',
  '最後一句講完就停住。不要說「以上就是我的分享」。',
];
 
export const meta: SlideMeta = { title: '架構決策:停在混合架構', theme: 'corporate' };
 
export default [
  Hook,
  Complication,
  BigIdea,
  PillarOne,
  CostCurves,
  Objection,
  CallToAction,
  Close,
] satisfies Page[];

八個元件對應八個節拍區段;實際上台前會把支點二與附錄補進去,落在 10–14 頁。結構就是最後那個陣列——改論證順序只需要重排一行,diff 一眼可見。

三、深入 1 小時:章節化骨架

一小時體裁的差別在三個機制:重複出現的地圖頁每章開頭的預測題每章結尾的提取頁(畫面上沒有答案)。這些都適合抽成共用元件,讓四章共用同一組節奏。

slides/event-sourcing-deep-dive/index.tsx
import { Step, Steps, type Page, type SlideMeta, useSlidePageNumber } from '@open-slide/core';
import { Headline, Label, Stage, T } from '../_shared/kit';
 
const CHAPTERS = ['事件是什麼', '寫入模型', '讀取模型', '落地與取捨'];
 
/** 地圖頁:整場重複出現五次,第四次曝光後它就是聽眾的心智架構 */
function Map({ active }: { active: number }) {
  return (
    <Stage>
      <Label>今天的路線</Label>
      <ol style={{ listStyle: 'none', padding: 0, margin: '56px 0 0' }}>
        {CHAPTERS.map((c, i) => (
          <li
            key={c}
            style={{
              fontSize: 72,
              lineHeight: 1.5,
              color: i === active ? T.text : T.muted,
              opacity: i === active ? 1 : 0.45,
            }}
          >
            <span style={{ color: i === active ? T.accent : T.muted, marginRight: 32 }}>
              {String(i + 1).padStart(2, '0')}
            </span>
            {c}
          </li>
        ))}
      </ol>
    </Stage>
  );
}
 
/** 章節斷點:反轉配色 + 較長的轉場,讓斷點在感官上「重」一點 */
function ChapterTitle({ n }: { n: number }) {
  return (
    <Stage invert>
      <Label>{`${n + 1} / ${CHAPTERS.length}`}</Label>
      <p style={{ fontSize: T.size.section, lineHeight: 1.2, margin: '24px 0 0' }}>
        {CHAPTERS[n]}
      </p>
    </Stage>
  );
}
 
/** 預測題:先猜再教。答案不在這一頁 */
function Predict({ question }: { question: string }) {
  return (
    <Stage>
      <Label>先猜一個答案</Label>
      <p style={{ fontSize: 76, lineHeight: 1.4, margin: '40px 0 0', maxWidth: 1500 }}>
        {question}
      </p>
    </Stage>
  );
}
 
/** 提取頁:畫面上絕對不能有答案,否則退化成朗讀 */
function Recall({ prompt }: { prompt: string }) {
  return (
    <Stage invert>
      <Label>不要看筆記</Label>
      <p style={{ fontSize: 80, lineHeight: 1.4, margin: '40px 0 0', maxWidth: 1500 }}>
        {prompt}
      </p>
    </Stage>
  );
}
 
/** 進度:不知道還要多久,是主動放棄注意力的頭號原因 */
function Progress() {
  const { current, total } = useSlidePageNumber();
  return (
    <div style={{ position: 'absolute', right: T.pad, bottom: 64, fontSize: T.size.label, color: T.muted }}>
      {current} / {total}
    </div>
  );
}
 
/* ---- 開場與契約(0:00–5:00)---- */
const Cover: Page = () => (
  <Stage>
    <p style={{ fontSize: T.size.hero, lineHeight: 1.1, margin: 0, letterSpacing: '-0.03em' }}>
      Event Sourcing<br />實際落地
    </p>
    <Label>60 分鐘 · 內含兩次動手 · Q&A 另計</Label>
  </Stage>
);
 
const Contract: Page = () => (
  <Stage>
    <Headline>你會帶走三件事。</Headline>
    <Steps>
      <Step><p style={{ fontSize: T.size.body, lineHeight: 1.7, marginTop: 48 }}>1. 判斷你的系統該不該用 event sourcing 的三個問題。</p></Step>
      <Step><p style={{ fontSize: T.size.body, lineHeight: 1.7 }}>2. 一個可以照抄的寫入模型骨架。</p></Step>
      <Step><p style={{ fontSize: T.size.body, lineHeight: 1.7 }}>3. 三個會讓你後悔的決定,以及它們的早期徵兆。</p></Step>
    </Steps>
    <Progress />
  </Stage>
);
 
const MapIntro: Page = () => <Map active={-1} />;
 
/* ---- 第 1 章(5:00–17:00)---- */
const Ch1Title: Page = () => <ChapterTitle n={0} />;
const Ch1Map: Page = () => <Map active={0} />;
const Ch1Predict: Page = () => (
  <Predict question="一筆訂單被取消三次又復原兩次。用傳統 CRUD,你還原得出第二次取消的原因嗎?" />
);
const Ch1Core: Page = () => (
  <Stage>
    <Headline>事件是既成事實,不是狀態的差異。</Headline>
    <Steps>
      <Step><p style={{ fontSize: T.size.body, lineHeight: 1.7, marginTop: 48 }}>命名用過去式:<code>OrderCancelled</code>,不是 <code>CancelOrder</code>。</p></Step>
      <Step><p style={{ fontSize: T.size.body, lineHeight: 1.7 }}>事件不可變更。修正靠補一筆新事件,不靠改舊的。</p></Step>
      <Step><p style={{ fontSize: T.size.body, lineHeight: 1.7 }}>事件帶意圖:<code>reason</code> 欄位是它與 diff 的全部差別。</p></Step>
    </Steps>
    <Progress />
  </Stage>
);
const Ch1Recall: Page = () => (
  <Recall prompt="用你自己的話,跟旁邊的人說明:事件跟狀態變更記錄差在哪裡?(60 秒)" />
);
 
/* ---- 中場(29:00–34:00)---- */
const Interlude: Page = () => (
  <Stage invert>
    <Label>中場 · 5 分鐘</Label>
    <Headline>在紙上寫下:你手上的系統,哪一個聚合最需要完整歷史?</Headline>
    <p style={{ fontSize: T.size.body, lineHeight: 1.7, marginTop: 40, color: T.muted }}>
      寫完之後,前半場的問題我一次處理。後半場我們接著往下走。
    </p>
  </Stage>
);
 
/* ---- 綜合與行動(53:00–57:00)---- */
const Synthesis: Page = () => <Map active={-1} />;
const Takeaway: Page = () => (
  <Recall prompt="四章各自最重要的一件事是什麼?我想聽三個人說。" />
);
 
export const notes = [
  '⏱ 0:00|第一句逐字:「今天不談理論,談三個會讓你後悔的決定。」',
  '⏱ 應在 5:00 前離開契約頁。三拍逐個揭露。',
  '地圖頁:明講「這張圖今天會出現五次」。',
  undefined,
  undefined,
  '⏱ ~6:00|預測題。停 8 秒,明說「心裡先給一個答案」。不要點人。',
  '答案在下一拍才給。講完第三拍停 2 秒。',
  '⏱ ~16:00|提取。畫面上沒有答案是刻意的。走到房間後方,讓他們真的講。',
  '⏱ 29:00|中場。先收前半場問題,最多 8 分鐘,超過就切。',
  '⏱ 53:00|回到地圖。',
  '⏱ 55:00|讓聽眾說,我只補遺漏的那一條。Q&A 在 57:00 之後,不算在 60 分鐘內。',
];
 
export const meta: SlideMeta = { title: 'Event Sourcing 實際落地', theme: 'corporate' };
 
export default [
  Cover, Contract, MapIntro,
  Ch1Title, Ch1Map, Ch1Predict, Ch1Core, Ch1Recall,
  // …第 2 章同樣的五件套
  Interlude,
  // …第 3、4 章
  Synthesis, Takeaway,
] satisfies Page[];

四章共用 ChapterTitle / Map / Predict / Recall 四個元件,是這份骨架的重點:節奏一致靠的是元件,不是自律。加一章就是加五個 Page,每章都必然有預測題與提取頁,因為模板本身逼你填。

四、兩份範例的差異總結

簡約 15 分鐘深入 1 小時
頁數10–1440–55
共用元件Stage / Headline / Label再加 Map / ChapterTitle / Predict / Recall / Progress
Steps 用量少而準(只在需要控制預讀處)每章核心解釋頁都用
轉場全 deck 一組,150–250 ms內容頁短、章節頁長(400–600 ms)
notes時間錨點 + 第一句逐字再加互動指令與中場切換條件
進度指示不需要必要

延伸

  • 這兩份結構的設計理由:0304
  • 預測題與提取頁的原理:06
  • 上台放映:14

來源