Files
wehub-resource-sync 070959e133
landing-page-staging / Deploy landing page to staging (push) Has been skipped
landing-page-ci / Validate landing page (push) Failing after 4s
visual-baseline / Capture visual baselines (push) Has been cancelled
bake-plugin-previews / Bake plugin previews (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:00:47 +08:00

2841 lines
103 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Write a Brand-Launch Narrative like a Growth Strategy Lead - Marketing &amp; GTM</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=Bodoni+Moda:wght@700;800;900&family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
deck-stage:not(:defined) { visibility: hidden; }
:root {
--bg: #3CD896;
--bg-2: #2DC684;
--bg-3: #25B377;
--ink: #0F1A5C;
--ink-2: #1B2774;
--ink-3: #3A4593;
--paper: #F1E9D6;
--rule: rgba(15, 26, 92, 0.22);
--rule-strong: rgba(15, 26, 92, 0.85);
--display-font: 'Bodoni Moda', serif;
}
html, body { margin: 0; padding: 0; background: #0a0a0a; }
deck-stage { background: #0a0a0a; }
section.slide {
width: 1920px;
height: 1080px;
background: var(--bg);
color: var(--ink);
font-family: 'Manrope', sans-serif;
overflow: hidden;
position: relative;
box-sizing: border-box;
}
/* Bold display serif — Bodoni Moda heavy */
.display {
font-family: var(--display-font);
font-weight: 900;
font-style: normal;
letter-spacing: -0.005em;
line-height: 0.95;
}
.display-md {
font-family: var(--display-font);
font-weight: 800;
font-style: normal;
}
.sans {
font-family: 'Manrope', sans-serif;
font-weight: 600;
}
.sans-b {
font-family: 'Manrope', sans-serif;
font-weight: 800;
letter-spacing: 0.04em;
}
/* Double-rule decorative ornament — the signature treatment */
.ornament {
display: inline-flex;
align-items: center;
gap: 26px;
justify-content: center;
width: 100%;
}
.ornament .word {
font-family: var(--display-font);
font-weight: 800;
font-size: 0.42em;
letter-spacing: 0.02em;
padding: 0 4px;
}
.ornament .lines {
flex: 1;
height: 18px;
position: relative;
}
.ornament .lines::before,
.ornament .lines::after {
content: "";
position: absolute;
left: 0;
right: 0;
height: 4px;
background: var(--ink);
}
.ornament .lines::before { top: 3px; }
.ornament .lines::after { bottom: 3px; }
/* Side double-rule for small inline words */
.of-mark {
display: inline-flex;
align-items: center;
gap: 22px;
font-family: var(--display-font);
font-weight: 700;
}
.of-mark::before, .of-mark::after {
content: "";
width: 64px;
height: 18px;
background:
linear-gradient(var(--ink), var(--ink)) 0 4px / 100% 4px no-repeat,
linear-gradient(var(--ink), var(--ink)) 0 12px / 100% 4px no-repeat;
}
/* Shared masthead / footline */
.masthead {
position: absolute;
top: 56px;
left: 80px;
right: 80px;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 26px;
font-weight: 700;
letter-spacing: 0.05em;
color: var(--ink);
white-space: nowrap;
}
.footline {
position: absolute;
bottom: 56px;
left: 80px;
right: 80px;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 26px;
font-weight: 700;
letter-spacing: 0.05em;
color: var(--ink);
}
.rule-h { height: 4px; background: var(--ink); }
/* ============== SLIDE 1 — COVER ============== */
.s1 {
padding: 56px 110px;
display: flex;
flex-direction: column;
text-align: center;
}
.s1-top {
display: flex; justify-content: space-between; align-items: center;
font-weight: 700; letter-spacing: 0.05em; font-size: 26px;
flex-shrink: 0;
}
.s1-stage {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 4px;
padding: 10px 0;
min-height: 0;
}
.s1-line { color: var(--ink); }
.s1-the { font-family: var(--display-font); font-weight: 800; font-size: 76px; line-height: 1; margin-bottom: -2px; }
.s1-big {
font-family: var(--display-font);
font-weight: 900;
font-size: 184px;
line-height: 0.92;
letter-spacing: -0.01em;
}
.s1-mid {
display: flex; align-items: center; justify-content: center;
gap: 32px;
width: 100%;
max-width: 1400px;
}
.s1-mid .rule {
flex: 1; height: 22px; position: relative;
}
.s1-mid .rule::before, .s1-mid .rule::after {
content: ""; position: absolute; left: 0; right: 0; height: 5px; background: var(--ink);
}
.s1-mid .rule::before { top: 2px; }
.s1-mid .rule::after { bottom: 2px; }
.s1-mid .of {
font-family: var(--display-font); font-weight: 800;
font-size: 68px; line-height: 1; padding: 0 6px;
}
/* Ornament rule variants — controlled by :root[data-ornament] */
:root[data-ornament="single"] .s1-mid .rule::before,
:root[data-ornament="single"] .s4-state .ornament-row .rule::before,
:root[data-ornament="single"] .s8-mid .rule::before {
top: 50%; bottom: auto; transform: translateY(-50%); height: 7px;
}
:root[data-ornament="single"] .s1-mid .rule::after,
:root[data-ornament="single"] .s4-state .ornament-row .rule::after,
:root[data-ornament="single"] .s8-mid .rule::after { display: none; }
:root[data-ornament="none"] .s1-mid .rule,
:root[data-ornament="none"] .s4-state .ornament-row .rule,
:root[data-ornament="none"] .s8-mid .rule { display: none; }
.s1-credit {
margin-top: 30px;
font-family: 'Manrope', sans-serif;
font-weight: 700;
font-size: 28px;
letter-spacing: 0.18em;
}
/* ============== SLIDE 2 — AGENDA ============== */
.s2 {
padding: 110px 110px 70px;
display: flex; flex-direction: column;
}
.s2-eyebrow { font-size: 28px; font-weight: 800; letter-spacing: 0.18em; margin-bottom: 30px; }
.s2-title {
font-family: var(--display-font);
font-weight: 900;
font-size: 200px;
line-height: 0.9;
letter-spacing: -0.015em;
margin: 0 0 70px;
}
.s2-list { display: flex; flex-direction: column; gap: 0; }
.s2-row {
display: grid;
grid-template-columns: 130px 1fr 320px;
align-items: center;
padding: 26px 0;
border-top: 4px solid var(--ink);
column-gap: 40px;
}
.s2-row:last-child { border-bottom: 4px solid var(--ink); }
.s2-row .num {
font-family: var(--display-font);
font-weight: 800;
font-size: 64px;
line-height: 1;
}
.s2-row .name {
font-family: var(--display-font);
font-weight: 800;
font-size: 64px;
line-height: 1;
letter-spacing: -0.005em;
}
.s2-row .kind {
font-family: 'Manrope', sans-serif;
font-weight: 700;
font-size: 26px;
letter-spacing: 0.1em;
text-align: right;
white-space: nowrap;
}
/* ============== SLIDE 3 — SECTION OPENER ============== */
.s3 {
padding: 0;
display: grid;
grid-template-columns: 1fr 1fr;
}
.s3-left {
background: var(--ink);
color: var(--bg);
padding: 70px 90px;
position: relative;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.s3-left .top {
position: absolute; top: 70px; left: 90px; right: 90px;
display: flex; justify-content: space-between;
font-size: 26px; font-weight: 700; letter-spacing: 0.08em;
}
.s3-numeral {
font-family: var(--display-font);
font-weight: 900;
font-size: 460px;
line-height: 0.9;
letter-spacing: -0.03em;
text-align: center;
}
.s3-left .bot {
position: absolute; bottom: 70px; left: 90px; right: 90px;
display: flex; justify-content: space-between;
font-size: 26px; font-weight: 700; letter-spacing: 0.08em;
}
.s3-right {
padding: 70px 90px;
display: flex;
flex-direction: column;
justify-content: center;
gap: 30px;
overflow: hidden;
}
.s3-right .kicker {
font-family: 'Manrope', sans-serif;
font-weight: 800;
letter-spacing: 0.16em;
font-size: 28px;
}
.s3-right h2 {
font-family: var(--display-font);
font-weight: 900;
font-size: 128px;
line-height: 0.95;
letter-spacing: -0.015em;
margin: 0;
}
.s3-right .lede {
font-family: 'Manrope', sans-serif;
font-size: 28px;
line-height: 1.5;
font-weight: 500;
max-width: 720px;
}
.s3-right .marks {
display: flex; gap: 14px; flex-wrap: wrap; padding-top: 20px;
border-top: 4px solid var(--ink);
}
.s3-right .mark {
background: var(--ink);
color: var(--bg);
padding: 10px 22px;
font-weight: 700;
letter-spacing: 0.1em;
font-size: 24px;
}
/* ============== SLIDE 4 — STATEMENT + SUPPORTING ============== */
.s4 {
padding: 110px 110px 70px;
display: grid;
grid-template-rows: auto 1fr;
gap: 60px;
}
.s4-state {
font-family: var(--display-font);
font-weight: 900;
font-size: 130px;
line-height: 0.95;
letter-spacing: -0.015em;
text-align: center;
margin: 0;
}
.s4-state .ornament-row {
display: flex; align-items: center; justify-content: center;
gap: 32px;
margin: 14px 0;
}
.s4-state .ornament-row .of-word {
font-family: var(--display-font);
font-weight: 800;
font-size: 84px;
line-height: 1;
}
.s4-state .ornament-row .rule {
width: 220px; height: 18px; position: relative;
}
.s4-state .ornament-row .rule::before,
.s4-state .ornament-row .rule::after {
content: ""; position: absolute; left: 0; right: 0; height: 4px; background: var(--ink);
}
.s4-state .ornament-row .rule::before { top: 1px; }
.s4-state .ornament-row .rule::after { bottom: 1px; }
.s4-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 50px;
border-top: 4px solid var(--ink);
padding-top: 36px;
}
.s4-cell .n {
font-family: var(--display-font);
font-weight: 800;
font-size: 64px;
line-height: 1;
margin-bottom: 18px;
}
.s4-cell h4 {
font-family: var(--display-font);
font-weight: 800;
font-size: 44px;
line-height: 1.05;
margin: 0 0 16px;
letter-spacing: -0.005em;
}
.s4-cell p {
font-size: 26px; line-height: 1.5; font-weight: 500;
margin: 0;
}
/* ============== SLIDE 5 — CHART ============== */
.s5 {
padding: 110px 110px 70px;
display: grid;
grid-template-rows: auto 1fr;
gap: 40px;
}
.s5-head {
display: grid;
grid-template-columns: 1.1fr 1fr;
gap: 80px;
align-items: end;
}
.s5-h {
font-family: var(--display-font);
font-weight: 900;
font-size: 104px;
line-height: 0.95;
letter-spacing: -0.015em;
margin: 0;
}
.s5-sub {
font-size: 26px; line-height: 1.45; font-weight: 500;
margin: 0;
}
.s5-body {
display: grid;
grid-template-columns: 1.4fr 1fr;
gap: 60px;
align-items: stretch;
min-height: 0;
}
.chart-card {
background: var(--ink);
color: var(--bg);
padding: 36px 50px 30px;
position: relative;
display: flex;
flex-direction: column;
overflow: hidden;
}
.chart-card .lbl {
font-weight: 800; letter-spacing: 0.14em; font-size: 24px;
margin-bottom: 24px;
}
.chart-area {
flex: 1;
position: relative;
min-height: 0;
}
.chart-yaxis {
position: absolute; left: 0; top: 0; bottom: 60px; width: 80px;
}
.chart-yaxis span {
position: absolute; right: 14px; font-size: 26px;
font-family: var(--display-font); font-weight: 700;
transform: translateY(-50%);
}
.chart-grid {
position: absolute; left: 90px; right: 0; top: 0; bottom: 60px;
}
.chart-grid .gl {
position: absolute; left: 0; right: 0; height: 2px; background: rgba(60, 216, 150, 0.22);
}
.chart-bars {
position: absolute; left: 90px; right: 0; top: 0; bottom: 60px;
display: flex; gap: 24px; align-items: flex-end;
}
.grp { flex: 1; display: flex; gap: 10px; align-items: flex-end; height: 100%; }
.bar {
flex: 1;
background: var(--bg);
position: relative;
}
.bar.b2 { background: var(--paper); }
.bar .v {
position: absolute; top: -40px; left: 50%; transform: translateX(-50%);
font-family: var(--display-font); font-weight: 800;
font-size: 30px; color: var(--bg);
}
.bar.b2 .v { color: var(--paper); }
.chart-x {
position: absolute; left: 90px; right: 0; bottom: 20px;
display: flex; gap: 24px;
}
.chart-x span {
flex: 1; text-align: center; font-weight: 700;
letter-spacing: 0.06em; font-size: 24px;
}
.chart-legend {
display: flex; gap: 36px; margin-top: 20px;
font-size: 24px; font-weight: 700; letter-spacing: 0.05em;
flex-shrink: 0;
}
.chart-legend .it { display: flex; align-items: center; gap: 14px; }
.chart-legend .sw { width: 22px; height: 22px; background: var(--bg); }
.chart-legend .sw.b2 { background: var(--paper); }
.s5-side {
display: flex; flex-direction: column;
gap: 22px;
border-top: 4px solid var(--ink);
padding-top: 26px;
overflow: hidden;
}
.s5-side .tag {
display: inline-block;
background: var(--ink); color: var(--bg);
padding: 8px 20px; font-weight: 700;
letter-spacing: 0.12em; font-size: 24px;
align-self: flex-start;
white-space: nowrap;
}
.s5-side .take {
font-family: var(--display-font);
font-weight: 800;
font-size: 48px;
line-height: 1.0;
letter-spacing: -0.005em;
margin: 0;
}
.s5-side .note {
font-size: 24px; line-height: 1.45; font-weight: 500; margin: 0;
}
.s5-side .stats {
margin-top: auto;
display: grid;
grid-template-columns: 1fr 1fr;
border-top: 4px solid var(--ink);
gap: 24px;
padding-top: 28px;
}
.s5-side .stats .stat .num {
font-family: var(--display-font);
font-weight: 900;
font-size: 92px;
line-height: 1;
letter-spacing: -0.02em;
}
.s5-side .stats .stat .num .u {
font-size: 48px;
margin-left: 2px;
}
.s5-side .stats .stat .lbl {
font-weight: 700;
letter-spacing: 0.1em;
font-size: 24px;
margin-top: 12px;
line-height: 1.35;
}
/* ============== SLIDE 6 — PROCESS ============== */
.s6 {
padding: 110px 110px 70px;
display: grid;
grid-template-rows: auto 1fr;
gap: 50px;
}
.s6-head {
display: grid; grid-template-columns: 1.1fr 1fr; gap: 80px; align-items: end;
}
.s6-eyebrow { font-size: 28px; font-weight: 800; letter-spacing: 0.16em; }
.s6-h {
font-family: var(--display-font); font-weight: 900;
font-size: 120px; line-height: 0.95; letter-spacing: -0.015em;
margin: 18px 0 0;
}
.s6-sub {
font-size: 28px; line-height: 1.5; font-weight: 500; margin: 0;
}
.s6-flow {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 30px;
align-items: stretch;
}
.step {
background: var(--ink);
color: var(--bg);
padding: 32px 32px 26px;
display: flex; flex-direction: column;
position: relative;
}
.step.alt { background: var(--paper); color: var(--ink); }
.step .n {
font-family: var(--display-font); font-weight: 900;
font-size: 80px; line-height: 1;
margin-bottom: 14px;
}
.step h4 {
font-family: var(--display-font); font-weight: 800;
font-size: 40px; line-height: 1.0;
margin: 0 0 16px;
letter-spacing: -0.005em;
border-top: 4px solid currentColor;
padding-top: 16px;
}
.step p {
font-size: 24px; line-height: 1.4; font-weight: 500;
margin: 0 0 16px;
}
.step .meta {
margin-top: auto;
display: flex; justify-content: space-between;
font-weight: 700; letter-spacing: 0.08em; font-size: 24px;
}
/* ============== SLIDE 7 — KPI GRID ============== */
.s7 {
padding: 110px 110px 70px;
display: grid;
grid-template-rows: auto 1fr;
gap: 40px;
}
.s7-head {
display: grid; grid-template-columns: 1.2fr 1fr; gap: 80px; align-items: end;
padding-right: 20px;
}
.s7-eyebrow { font-size: 28px; font-weight: 800; letter-spacing: 0.16em; margin-bottom: 14px; }
.s7-h {
font-family: var(--display-font); font-weight: 900;
font-size: 128px; line-height: 0.92; letter-spacing: -0.015em;
margin: 0;
}
.s7-sub {
font-size: 26px; line-height: 1.45; font-weight: 500; margin: 0;
}
.s7-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 28px;
}
.kpi {
background: var(--ink);
color: var(--bg);
padding: 36px 32px;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 20px;
overflow: hidden;
min-width: 0;
}
.kpi.alt {
background: var(--paper);
color: var(--ink);
}
.kpi .lbl {
font-weight: 800;
letter-spacing: 0.14em;
font-size: 24px;
}
.kpi .val {
font-family: var(--display-font);
font-weight: 900;
font-size: 144px;
line-height: 0.9;
letter-spacing: -0.03em;
white-space: nowrap;
}
.kpi .val .u {
font-size: 60px;
margin-left: 4px;
font-weight: 800;
}
.kpi .desc {
font-size: 24px;
line-height: 1.45;
font-weight: 500;
}
.kpi .delta {
display: inline-block;
font-weight: 800;
letter-spacing: 0.08em;
font-size: 24px;
padding: 6px 16px;
background: var(--bg);
color: var(--ink);
align-self: flex-start;
white-space: nowrap;
}
.kpi.alt .delta {
background: var(--ink);
color: var(--bg);
}
/* ============== SLIDE 8 — CLOSING ============== */
.s8 {
padding: 80px 110px;
display: flex; flex-direction: column;
text-align: center;
}
.s8-top {
display: flex; justify-content: space-between;
font-weight: 700; letter-spacing: 0.06em; font-size: 26px;
}
.s8-stage {
flex: 1;
display: flex; flex-direction: column;
justify-content: center;
align-items: center;
gap: 0;
}
.s8-kicker {
font-family: 'Manrope', sans-serif;
font-weight: 800;
letter-spacing: 0.18em;
font-size: 30px;
margin-bottom: 30px;
}
.s8-h {
font-family: var(--display-font);
font-weight: 900;
font-size: 180px;
line-height: 0.92;
letter-spacing: -0.015em;
margin: 0;
}
.s8-mid {
display: flex; align-items: center; justify-content: center; gap: 32px;
width: 100%; max-width: 1500px; margin: 4px 0;
}
.s8-mid .rule {
flex: 1; height: 22px; position: relative;
}
.s8-mid .rule::before, .s8-mid .rule::after {
content: ""; position: absolute; left: 0; right: 0; height: 5px; background: var(--ink);
}
.s8-mid .rule::before { top: 2px; }
.s8-mid .rule::after { bottom: 2px; }
.s8-mid .of {
font-family: var(--display-font); font-weight: 800;
font-size: 76px; line-height: 1;
}
.s8-foot {
margin-top: 70px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 40px;
border-top: 4px solid var(--ink);
padding-top: 32px;
width: 100%;
max-width: 1500px;
text-align: left;
}
.s8-foot .col .lbl { font-weight: 800; letter-spacing: 0.14em; font-size: 24px; margin-bottom: 10px; }
.s8-foot .col .val {
font-family: var(--display-font); font-weight: 800;
font-size: 38px; line-height: 1.1;
}
/* Motion discipline: the deck is static printed ink; any slide-level
animation or transition must collapse for reduced-motion users. */
@media (prefers-reduced-motion: reduce) {
section.slide *,
section.slide *::before,
section.slide *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
</style>
</head>
<body>
<deck-stage width="1920" height="1080" no-rail>
<!-- ============== SLIDE 1 — COVER ============== -->
<section class="slide s1" data-screen-label="01 Cover">
<div class="s1-stage">
<div class="s1-the">This is</div>
<div class="s1-big">DESIGN ON</div>
<div class="s1-mid">
<span class="rule"></span>
<span class="of">your</span>
<span class="rule"></span>
</div>
<div class="s1-big">DESK: LAUNCH</div>
<div class="s1-credit">MARKETING &amp; GTM · BRAND-LAUNCH NARRATIVE</div>
</div>
<div class="s1-top">
<span>MARKET MOMENT · AUDIENCE · OFFER · CHANNELS · PROOF · CALENDAR</span>
<span>OPEN DESIGN GTM · JULY 2026</span>
</div>
</section>
<!-- ============== SLIDE 2 — AGENDA ============== -->
<section class="slide s2" data-screen-label="02 Agenda">
<div class="masthead">
<span>AGENDA</span>
<span>BRAND-LAUNCH NARRATIVE</span>
</div>
<div class="s2-eyebrow">WHAT WE WILL COVER TODAY</div>
<h2 class="s2-title">The Programme</h2>
<div class="s2-list">
<div class="s2-row">
<span class="num">01</span>
<span class="name">The Market Moment</span>
<span class="kind">WHY NOW</span>
</div>
<div class="s2-row">
<span class="num">02</span>
<span class="name">Who We&#39;re Launching To</span>
<span class="kind">AUDIENCE</span>
</div>
<div class="s2-row">
<span class="num">03</span>
<span class="name">The Offer, Projected</span>
<span class="kind">GTM PLAN</span>
</div>
<div class="s2-row">
<span class="num">04</span>
<span class="name">The Channel Sequence</span>
<span class="kind">ROLLOUT</span>
</div>
<div class="s2-row">
<span class="num">05</span>
<span class="name">Proof &amp; The Launch Calendar</span>
<span class="kind">EVIDENCE · CLOSING</span>
</div>
</div>
</section>
<!-- ============== SLIDE 3 — SECTION OPENER (split) ============== -->
<section class="slide s3" data-screen-label="03 Section Opener">
<div class="s3-left">
<div class="top">
<span>SECTION 01</span>
<span>THE MOMENT</span>
</div>
<div class="s3-numeral">01</div>
<div class="bot">
<span>JULY · MMXXVI</span>
<span>THE TURNING POINT</span>
</div>
</div>
<div class="s3-right">
<div class="kicker">WHY WE ARE LAUNCHING NOW</div>
<h2>Design, Set Free.</h2>
<p class="lede">In April 2026, Claude Design proved an LLM can truly design — not write about design, but ship real artifacts. It stayed closed-source, paid, cloud-only. Open Design answers with the same capability, unlocked: open-source, running on your desk, with your own model key.</p>
<div class="marks">
<span class="mark">OPEN-SOURCE</span>
<span class="mark">LOCAL-FIRST</span>
<span class="mark">AGENT-DRIVEN</span>
</div>
</div>
</section>
<!-- ============== SLIDE 4 — STATEMENT + 3 SUPPORTING ============== -->
<section class="slide s4" data-screen-label="04 Statement And Three">
<div class="masthead">
<span>AUDIENCE · WHO WE ARE WRITING TO</span>
<span>THREE READERS</span>
</div>
<h2 class="s4-state">
Three Readers<br>
<span class="ornament-row">
<span class="rule"></span><span class="of-word">of</span><span class="rule"></span>
</span>
One Story.
</h2>
<div class="s4-grid">
<div class="s4-cell">
<div class="n">01</div>
<h4>The Press</h4>
<p>Trade and tech reporters who cover open-source and AI tooling. They need one sharp claim, one on-record quote, and a demo they can screenshot in minutes.</p>
</div>
<div class="s4-cell">
<div class="n">02</div>
<h4>Design &amp; Marketing Teams</h4>
<p>In-house teams evaluating whether an agent-native tool replaces a slow request queue. They need proof it fits their stack, their brand, their budget.</p>
</div>
<div class="s4-cell">
<div class="n">03</div>
<h4>The Builder Community</h4>
<p>Developers already running Claude Code or Cursor locally. They need to see Open Design as infrastructure they can fork, extend, and ship plugins into.</p>
</div>
</div>
</section>
<!-- ============== SLIDE 5 — CHART ============== -->
<section class="slide s5" data-screen-label="05 Data Study">
<div class="masthead">
<span>THE OFFER · PROJECTED UPTAKE</span>
<span>SIX WEEKS</span>
</div>
<div class="s5-head">
<h2 class="s5-h">The offer,<br>modeled to convert.</h2>
<p class="s5-sub">The offer is simple: download free, bring your own model key, ship your first artifact today. Modeled uptake across the first six launch weeks. The emerald bars show installs in thousands; the bone bars show BYOK activations in tens.</p>
</div>
<div class="s5-body">
<div class="chart-card">
<div class="lbl">MODELED UPTAKE · CUMULATIVE BY WEEK</div>
<div class="chart-area">
<div class="chart-yaxis">
<span style="top: 0%;">80</span>
<span style="top: 25%;">60</span>
<span style="top: 50%;">40</span>
<span style="top: 75%;">20</span>
<span style="top: 100%;">0</span>
</div>
<div class="chart-grid">
<div class="gl" style="top: 0%;"></div>
<div class="gl" style="top: 25%;"></div>
<div class="gl" style="top: 50%;"></div>
<div class="gl" style="top: 75%;"></div>
<div class="gl" style="top: 100%;"></div>
</div>
<div class="chart-bars">
<div class="grp">
<div class="bar" style="height: 5%;"><span class="v">4</span></div>
<div class="bar b2" style="height: 3%;"><span class="v">2</span></div>
</div>
<div class="grp">
<div class="bar" style="height: 11%;"><span class="v">9</span></div>
<div class="bar b2" style="height: 6%;"><span class="v">5</span></div>
</div>
<div class="grp">
<div class="bar" style="height: 23%;"><span class="v">18</span></div>
<div class="bar b2" style="height: 13%;"><span class="v">10</span></div>
</div>
<div class="grp">
<div class="bar" style="height: 38%;"><span class="v">30</span></div>
<div class="bar b2" style="height: 20%;"><span class="v">16</span></div>
</div>
<div class="grp">
<div class="bar" style="height: 58%;"><span class="v">46</span></div>
<div class="bar b2" style="height: 30%;"><span class="v">24</span></div>
</div>
<div class="grp">
<div class="bar" style="height: 75%;"><span class="v">60</span></div>
<div class="bar b2" style="height: 38%;"><span class="v">30</span></div>
</div>
</div>
<div class="chart-x">
<span>WK·01</span>
<span>WK·02</span>
<span>WK·03</span>
<span>WK·04</span>
<span>WK·05</span>
<span>WK·06</span>
</div>
</div>
<div class="chart-legend">
<div class="it"><span class="sw"></span><span>INSTALLS (K)</span></div>
<div class="it"><span class="sw b2"></span><span>BYOK ACTIVATIONS (×10)</span></div>
</div>
</div>
<aside class="s5-side">
<span class="tag">THE TAKEAWAY</span>
<h3 class="take">A free offer converts fastest when the first artifact ships same-day.</h3>
<p class="note">Apache-2.0 removes the sign-up wall; BYOK removes the billing wall. The model targets 60K installs and 300 daily-active BYOK activations by week six.</p>
<div class="stats">
<div class="stat">
<div class="num">60K<span class="u">+</span></div>
<div class="lbl">INSTALLS MODELED<br>BY WEEK SIX</div>
</div>
<div class="stat">
<div class="num">300<span class="u">+</span></div>
<div class="lbl">DAILY BYOK<br>ACTIVATIONS</div>
</div>
</div>
</aside>
</div>
</section>
<!-- ============== SLIDE 6 — PROCESS ============== -->
<section class="slide s6" data-screen-label="06 Process Diagram">
<div class="masthead">
<span>CHANNELS · THE ROLLOUT SEQUENCE</span>
<span>FOUR PLAYS</span>
</div>
<div class="s6-head">
<div>
<div class="s6-eyebrow">EACH CHANNEL HANDS OFF TO THE NEXT</div>
<h2 class="s6-h">Four plays,<br>one week apart.</h2>
</div>
<p class="s6-sub">Owned reach seeds the story, earned press validates it, community turns it into forks and plugins, and paid amplification extends it once the first numbers land.</p>
</div>
<div class="s6-flow">
<div class="step">
<div class="n">01</div>
<h4>Owned: Day Zero</h4>
<p>Blog post, changelog, and founder thread go live at 9am ET, alongside the GitHub release and updated README.</p>
<div class="meta"><span>CHANNEL</span><span>OWNED</span></div>
</div>
<div class="step alt">
<div class="n">02</div>
<h4>Earned: Day Three</h4>
<p>Embargo lifts for trade press with the on-record quote and demo video; pitch targets Hacker News and design newsletters.</p>
<div class="meta"><span>CHANNEL</span><span>EARNED</span></div>
</div>
<div class="step">
<div class="n">03</div>
<h4>Community: Day Seven</h4>
<p>Discord AMA and first-plugin bounty open; early adopters are asked to ship a design system or plugin publicly.</p>
<div class="meta"><span>CHANNEL</span><span>COMMUNITY</span></div>
</div>
<div class="step alt">
<div class="n">04</div>
<h4>Paid: Day Fourteen</h4>
<p>Retarget site visitors and press readers with the week-one install numbers once they clear the proof bar.</p>
<div class="meta"><span>CHANNEL</span><span>PAID</span></div>
</div>
</div>
</section>
<!-- ============== SLIDE 7 — KPI GRID ============== -->
<section class="slide s7" data-screen-label="07 KPI Grid">
<div class="masthead">
<span>THE PROOF · WHAT CONVERTS THE STORY</span>
<span>FOUR NUMBERS</span>
</div>
<div class="s7-head">
<div>
<div class="s7-eyebrow">FOUR NUMBERS THE PITCH LEADS WITH</div>
<h2 class="s7-h">The proof,<br>on the record.</h2>
</div>
<p class="s7-sub">Every claim in this deck traces to a number a reporter or a buyer can verify themselves, today, before we ship a single press release.</p>
</div>
<div class="s7-grid">
<div class="kpi">
<div class="lbl">GITHUB STARS</div>
<div class="val">60K<span class="u">+</span></div>
<div class="delta">APACHE-2.0</div>
<div class="desc">Stars on the open-source repository — fork, self-host, and commercial use all allowed.</div>
</div>
<div class="kpi alt">
<div class="lbl">PLUGINS SHIPPED</div>
<div class="val">217<span class="u">+</span></div>
<div class="delta">COMMUNITY-BUILT</div>
<div class="desc">Ready-to-run decks, prototypes, and posters the press can demo live, no setup required.</div>
</div>
<div class="kpi">
<div class="lbl">WAITLIST SIGNUPS</div>
<div class="val">4.2K</div>
<div class="delta">PRE-LAUNCH</div>
<div class="desc">Collected from the teaser page in the two weeks before day zero — our earned-interest baseline.</div>
</div>
<div class="kpi alt">
<div class="lbl">CODING AGENTS</div>
<div class="val">21</div>
<div class="delta">ZERO CONFIG</div>
<div class="desc">Auto-detected from PATH; any compatible endpoint connects via BYOK, no vendor lock-in claim needed.</div>
</div>
</div>
</section>
<!-- ============== SLIDE 8 — CLOSING ============== -->
<section class="slide s8" data-screen-label="08 Closing">
<div class="s8-top">
<span>THE LAUNCH CALENDAR</span>
<span>MARK THE DATES</span>
</div>
<div class="s8-stage">
<div class="s8-kicker">FROM TEASER TO FOLLOW-UP, IN FOUR WEEKS</div>
<div class="s8-h">LAUNCH</div>
<div class="s8-mid">
<span class="rule"></span>
<span class="of">on</span>
<span class="rule"></span>
</div>
<div class="s8-h">JULY 14</div>
<div class="s8-foot">
<div class="col">
<div class="lbl">TEASER</div>
<div class="val">July 7<br>Waitlist open</div>
</div>
<div class="col">
<div class="lbl">DAY ZERO</div>
<div class="val">July 14<br>Blog, GitHub, press embargo lifts</div>
</div>
<div class="col">
<div class="lbl">FOLLOW-UP</div>
<div class="val">Aug 4<br>Week-four numbers to press</div>
</div>
</div>
</div>
<div class="s8-top" style="margin-top: 10px;">
<span>OPEN · LOCAL · AGENT-NATIVE</span>
<span>APACHE-2.0</span>
</div>
</section>
</deck-stage>
<script>
/* === DECK-STAGE RUNTIME (inlined from beautiful-html-templates/runtime/deck-stage.js, MIT (c) 2026 Zara Zhang) === */
/**
* <deck-stage> — reusable web component for HTML decks.
*
* Handles:
* (a) speaker notes — reads <script type="application/json" id="speaker-notes">
* and posts {slideIndexChanged: N} to the parent window on nav.
* (b) keyboard navigation — ←/→, PgUp/PgDn, Space, Home/End, number keys.
* (c) press R to reset to slide 0 (with a tasteful keyboard hint).
* (d) bottom-center overlay showing slide count + hints, fades out on idle.
* (e) auto-scaling — inner canvas is a fixed design size (default 1920×1080)
* scaled with `transform: scale()` to fit the viewport, letterboxed.
* Set the `noscale` attribute to render at authored size (1:1) — the
* PPTX exporter sets this so its DOM capture sees unscaled geometry.
* (f) print — `@media print` lays every slide out as its own page at the
* design size, so the browser's Print → Save as PDF produces a clean
* one-page-per-slide PDF with no extra setup.
* (g) thumbnail rail — resizable left-hand column of per-slide thumbnails
* (static clones). Click to navigate; ↑/↓ with a thumbnail focused to
* step between slides; drag to reorder; right-click for
* Skip / Move up / Move down / Delete (opens a Cancel/Delete confirm
* dialog). Drag the rail's right edge to resize; width persists to
* localStorage. Skipped slides carry `data-deck-skip`, are dimmed in
* the rail, omitted from prev/next navigation, and hidden at print.
* The rail is suppressed in presenting mode, on `noscale`, and via
* the `no-rail` attribute. Rail mutations dispatch a `deckchange`
* CustomEvent on the element: detail = {action, from, to, slide}.
*
* Slides are HIDDEN, not unmounted. Non-active slides stay in the DOM with
* `visibility: hidden` + `opacity: 0`, so their state (videos, iframes,
* form inputs, React trees) is preserved across navigation.
*
* Lifecycle event — the component dispatches a `slidechange` CustomEvent on
* itself whenever the active slide changes (including the initial mount).
* The event bubbles and composes out of shadow DOM, so you can listen on
* the <deck-stage> element or on document:
*
* document.querySelector('deck-stage').addEventListener('slidechange', (e) => {
* e.detail.index // new 0-based index
* e.detail.previousIndex // previous index, or -1 on init
* e.detail.total // total slide count
* e.detail.slide // the new active slide element
* e.detail.previousSlide // the prior slide element, or null on init
* e.detail.reason // 'init' | 'keyboard' | 'click' | 'tap' | 'api'
* });
*
* Persistence: none at the deck level. The host app keeps the current slide
* in its own URL (?slide=) and re-delivers it via location.hash on load, so a
* bare load with no hash always starts at slide 1.
*
* Usage:
* <style>deck-stage:not(:defined){visibility:hidden}</style>
* <deck-stage width="1920" height="1080">
* <section data-label="Title">...</section>
* <section data-label="Agenda">...</section>
* </deck-stage>
* <script src="deck-stage.js"><\/script>
*
* The :not(:defined) rule prevents a flash of the first slide at its
* authored styles before this script runs and attaches the shadow root.
*
* Slides are the direct element children of <deck-stage>. Each slide is
* automatically tagged with:
* - data-screen-label="NN Label" (1-indexed, for comment flow)
* - data-om-validate="no_overflowing_text,no_overlapping_text,slide_sized_text"
*/
(() => {
const DESIGN_W_DEFAULT = 1920;
const DESIGN_H_DEFAULT = 1080;
const OVERLAY_HIDE_MS = 1800;
const VALIDATE_ATTR = 'no_overflowing_text,no_overlapping_text,slide_sized_text';
const pad2 = (n) => String(n).padStart(2, '0');
const stylesheet = `
:host {
position: fixed;
inset: 0;
display: block;
background: #000;
color: #fff;
font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", Helvetica, Arial, sans-serif;
overflow: hidden;
}
/* connectedCallback holds this until document.fonts.ready (capped 2s) so
* the first visible paint has the deck's real typography + final rail
* layout. opacity (not visibility) so the active slide can't un-hide
* itself via the ::slotted([data-deck-active]) visibility:visible rule.
* Only the stage/rail hide — the black :host background stays, so the
* iframe doesn't flash the page's default white. */
:host([data-fonts-pending]) .stage,
:host([data-fonts-pending]) .rail { opacity: 0; pointer-events: none; }
.stage {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.canvas {
position: relative;
transform-origin: center center;
flex-shrink: 0;
background: #fff;
will-change: transform;
}
/* Slides live in light DOM (via <slot>) so authored CSS still applies.
We absolutely position each slotted child to stack them. */
::slotted(*) {
position: absolute !important;
inset: 0 !important;
width: 100% !important;
height: 100% !important;
box-sizing: border-box !important;
overflow: hidden;
opacity: 0;
pointer-events: none;
visibility: hidden;
}
::slotted([data-deck-active]) {
opacity: 1;
pointer-events: auto;
visibility: visible;
}
/* Tap zones for mobile — back/forward thirds like Stories.
Transparent, no visible UI, don't block the overlay. */
.tapzones {
position: fixed;
inset: 0;
display: flex;
z-index: 2147482000;
pointer-events: none;
}
.tapzone {
flex: 1;
pointer-events: auto;
-webkit-tap-highlight-color: transparent;
}
/* Only activate tap zones on coarse pointers (touch devices). */
@media (hover: hover) and (pointer: fine) {
.tapzones { display: none; }
}
.overlay {
position: fixed;
left: 50%;
bottom: 22px;
transform: translate(-50%, 8px);
display: flex;
align-items: center;
gap: 4px;
padding: 4px;
background: #000;
color: #fff;
border-radius: 999px;
font-size: 12px;
font-feature-settings: "tnum" 1;
letter-spacing: 0.01em;
opacity: 0;
pointer-events: none;
transition: opacity 200ms ease, transform 200ms cubic-bezier(.2,.8,.2,1);
transform-origin: center bottom;
z-index: 2147483000;
user-select: none;
}
.overlay[data-visible] {
opacity: 1;
pointer-events: auto;
transform: translate(-50%, 0);
}
.btn {
appearance: none;
-webkit-appearance: none;
background: transparent;
border: 0;
margin: 0;
padding: 0;
color: inherit;
font: inherit;
cursor: default;
display: inline-flex;
align-items: center;
justify-content: center;
height: 28px;
min-width: 28px;
border-radius: 999px;
color: rgba(255,255,255,0.72);
transition: background 140ms ease, color 140ms ease;
-webkit-tap-highlight-color: transparent;
}
.btn:hover { background: rgba(255,255,255,0.12); color: #fff; }
.btn:active { background: rgba(255,255,255,0.18); }
.btn:focus { outline: none; }
.btn:focus-visible { outline: none; }
.btn::-moz-focus-inner { border: 0; }
.btn svg { width: 14px; height: 14px; display: block; }
.btn.reset {
font-size: 11px;
font-weight: 500;
letter-spacing: 0.02em;
padding: 0 10px 0 12px;
gap: 6px;
color: rgba(255,255,255,0.72);
}
.btn.reset .kbd {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 16px;
height: 16px;
padding: 0 4px;
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 10px;
line-height: 1;
color: rgba(255,255,255,0.88);
background: rgba(255,255,255,0.12);
border-radius: 4px;
}
.count {
font-variant-numeric: tabular-nums;
color: #fff;
font-weight: 500;
padding: 0 8px;
min-width: 42px;
text-align: center;
font-size: 12px;
}
.count .sep { color: rgba(255,255,255,0.45); margin: 0 3px; font-weight: 400; }
.count .total { color: rgba(255,255,255,0.55); }
.divider {
width: 1px;
height: 14px;
background: rgba(255,255,255,0.18);
margin: 0 2px;
}
/* ── Thumbnail rail ──────────────────────────────────────────────────
Fixed column on the left; each thumbnail is a static deep-clone of
the light-DOM slide scaled into a 16:9 (or design-aspect) frame. The
stage re-fits around it (see _fit); hidden during present / noscale
/ print so capture geometry and fullscreen output are unchanged. */
.rail {
position: fixed;
left: 0;
top: 0;
bottom: 0;
width: var(--deck-rail-w, 188px);
background: #141414;
border-right: 1px solid rgba(255,255,255,0.08);
overflow-y: auto;
overflow-x: hidden;
padding: 12px 10px;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 12px;
z-index: 2147482500;
scrollbar-width: thin;
scrollbar-color: rgba(255,255,255,0.18) transparent;
}
.rail::-webkit-scrollbar { width: 8px; }
.rail::-webkit-scrollbar-track { background: transparent; margin: 2px; }
.rail::-webkit-scrollbar-thumb {
background: rgba(255,255,255,0.18);
border-radius: 4px;
border: 2px solid transparent;
background-clip: content-box;
}
.rail::-webkit-scrollbar-thumb:hover {
background: rgba(255,255,255,0.28);
border: 2px solid transparent;
background-clip: content-box;
}
:host([no-rail]) .rail,
:host([noscale]) .rail { display: none; }
.rail[data-presenting] { display: none; }
/* User-driven show/hide (the TweaksPanel toggle) slides instead of
popping. Transitions are gated on :host([data-rail-anim]) — set only
for the 200ms around the toggle — so window-resize and rail-width
drag (which also call _fit) don't lag behind the cursor. */
.rail[data-user-hidden] { transform: translateX(-100%); }
:host([data-rail-anim]) .rail { transition: transform 200ms cubic-bezier(.3,.7,.4,1); }
:host([data-rail-anim]) .stage { transition: left 200ms cubic-bezier(.3,.7,.4,1); }
:host([data-rail-anim]) .canvas { transition: transform 200ms cubic-bezier(.3,.7,.4,1); }
/* transition shorthand replaces rather than merges — repeat the base
.overlay opacity/transform/filter transitions so visibility changes
during the 200ms toggle window still fade instead of popping. */
:host([data-rail-anim]) .overlay {
transition: margin-left 200ms cubic-bezier(.3,.7,.4,1),
opacity 260ms ease,
transform 260ms cubic-bezier(.2,.8,.2,1),
filter 260ms ease;
}
:host([data-rail-anim]) .tapzones { transition: left 200ms cubic-bezier(.3,.7,.4,1); }
.thumb {
position: relative;
display: flex;
align-items: flex-start;
gap: 8px;
cursor: pointer;
user-select: none;
}
.thumb .num {
width: 16px;
flex-shrink: 0;
font-size: 11px;
font-weight: 500;
text-align: right;
color: rgba(255,255,255,0.55);
padding-top: 2px;
font-variant-numeric: tabular-nums;
}
.thumb .frame {
position: relative;
flex: 1;
min-width: 0;
aspect-ratio: var(--deck-aspect);
background: #fff;
border-radius: 4px;
outline: 2px solid transparent;
outline-offset: 0;
overflow: hidden;
transition: outline-color 120ms ease;
}
.thumb:hover .frame { outline-color: rgba(255,255,255,0.25); }
.thumb { outline: none; }
.thumb:focus-visible .frame { outline-color: rgba(255,255,255,0.5); }
.thumb[data-current] .num { color: #fff; }
.thumb[data-current] .frame { outline-color: #D97757; }
.thumb[data-dragging] { opacity: 0.35; }
.thumb::before {
content: '';
position: absolute;
left: 24px;
right: 0;
height: 3px;
border-radius: 2px;
background: #D97757;
opacity: 0;
pointer-events: none;
}
.thumb[data-drop="before"]::before { top: -8px; opacity: 1; }
.thumb[data-drop="after"]::before { bottom: -8px; opacity: 1; }
.thumb[data-skip] .frame { opacity: 0.35; }
.thumb[data-skip] .frame::after {
content: 'Skipped';
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0,0,0,0.45);
color: #fff;
font-size: 10px;
font-weight: 500;
letter-spacing: 0.04em;
}
.ctxmenu {
position: fixed;
min-width: 150px;
padding: 4px;
background: #242424;
border: 1px solid rgba(255,255,255,0.12);
border-radius: 7px;
box-shadow: 0 8px 24px rgba(0,0,0,0.45);
z-index: 2147483100;
display: none;
font-size: 12px;
}
.ctxmenu[data-open] { display: block; }
.ctxmenu button {
display: block;
width: 100%;
appearance: none;
border: 0;
background: transparent;
color: #e8e8e8;
font: inherit;
text-align: left;
padding: 6px 10px;
border-radius: 4px;
cursor: pointer;
}
.ctxmenu button:hover:not(:disabled) { background: rgba(255,255,255,0.08); }
.ctxmenu button:disabled { opacity: 0.35; cursor: default; }
.ctxmenu hr {
border: 0;
border-top: 1px solid rgba(255,255,255,0.1);
margin: 4px 2px;
}
.rail-resize {
position: fixed;
left: calc(var(--deck-rail-w, 188px) - 3px);
top: 0;
bottom: 0;
width: 6px;
cursor: col-resize;
z-index: 2147482600;
touch-action: none;
}
.rail-resize:hover,
.rail-resize[data-dragging] { background: rgba(255,255,255,0.12); }
:host([no-rail]) .rail-resize,
:host([noscale]) .rail-resize,
.rail[data-presenting] + .rail-resize,
.rail[data-user-hidden] + .rail-resize { display: none; }
/* Delete-confirm popup — matches the SPA's ConfirmDialog layout
(title + message body, depressed footer with Cancel / Delete). */
.confirm-backdrop {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.45);
z-index: 2147483200;
display: none;
align-items: center;
justify-content: center;
}
.confirm-backdrop[data-open] { display: flex; }
.confirm {
width: 320px;
max-width: calc(100vw - 32px);
background: #2a2a2a;
color: #e8e8e8;
border: 1px solid rgba(255,255,255,0.12);
border-radius: 12px;
box-shadow: 0 12px 32px rgba(0,0,0,0.5);
overflow: hidden;
font-family: inherit;
animation: deck-confirm-in 0.18s ease;
}
@keyframes deck-confirm-in {
from { opacity: 0; transform: scale(0.96); }
to { opacity: 1; transform: scale(1); }
}
.confirm .body { padding: 20px 20px 16px; }
.confirm .title { font-size: 14px; font-weight: 600; margin-bottom: 4px; }
.confirm .msg { font-size: 13px; line-height: 1.5; color: rgba(255,255,255,0.65); }
.confirm .footer {
padding: 14px 20px;
background: #1f1f1f;
border-top: 1px solid rgba(255,255,255,0.08);
display: flex;
justify-content: flex-end;
gap: 8px;
}
.confirm button {
appearance: none;
font: inherit;
font-size: 13px;
font-weight: 500;
padding: 8px 16px;
border-radius: 8px;
cursor: pointer;
}
.confirm .cancel {
background: transparent;
border: 0;
color: rgba(255,255,255,0.8);
}
.confirm .cancel:hover { background: rgba(255,255,255,0.08); }
.confirm .danger {
background: #c96442;
border: 1px solid rgba(0,0,0,0.15);
color: #fff;
box-shadow: 0 1px 3px rgba(166,50,68,0.3), 0 2px 6px rgba(166,50,68,0.18);
}
.confirm .danger:hover { background: #b5563a; }
/* ── Print: one page per slide, no chrome ────────────────────────────
The screen layout stacks every slide at inset:0 inside a scaled
canvas; for print we want them in document flow at the authored
design size so the browser paginates one slide per sheet. The
@page size is set from the width/height attributes via the inline
<style id="deck-stage-print-page"> that connectedCallback injects
into <head> (the @page at-rule has no effect inside shadow DOM). */
@media print {
:host {
position: static;
inset: auto;
background: none;
overflow: visible;
color: inherit;
}
.stage { position: static; display: block; }
.canvas {
transform: none !important;
width: auto !important;
height: auto !important;
background: none;
will-change: auto;
}
::slotted(*) {
position: relative !important;
inset: auto !important;
width: var(--deck-design-w) !important;
height: var(--deck-design-h) !important;
box-sizing: border-box !important;
opacity: 1 !important;
visibility: visible !important;
pointer-events: auto;
break-after: page;
page-break-after: always;
break-inside: avoid;
overflow: hidden;
}
/* :last-child alone isn't enough once data-deck-skip hides the
trailing slide(s) — the last *visible* slide still carries
break-after:page and prints a blank sheet. _markLastVisible()
maintains data-deck-last-visible on the last non-skipped slide. */
::slotted(*:last-child),
::slotted([data-deck-last-visible]) {
break-after: auto;
page-break-after: auto;
}
::slotted([data-deck-skip]) { display: none !important; }
.overlay, .tapzones, .rail, .rail-resize, .ctxmenu, .confirm-backdrop { display: none !important; }
}
`;
class DeckStage extends HTMLElement {
static get observedAttributes() { return ['width', 'height', 'noscale', 'no-rail']; }
constructor() {
super();
this._root = this.attachShadow({ mode: 'open' });
this._index = 0;
this._slides = [];
this._notes = [];
this._hideTimer = null;
this._mouseIdleTimer = null;
this._menuIndex = -1;
this._onKey = this._onKey.bind(this);
this._onResize = this._onResize.bind(this);
this._onSlotChange = this._onSlotChange.bind(this);
this._onMouseMove = this._onMouseMove.bind(this);
this._onTapBack = this._onTapBack.bind(this);
this._onTapForward = this._onTapForward.bind(this);
this._onMessage = this._onMessage.bind(this);
// Capture-phase close so a click anywhere dismisses the menu, but
// ignore clicks that land inside the menu itself — otherwise the
// capture handler runs before the menu's own (bubble) handler and
// clears _menuIndex out from under it.
this._onDocClick = (e) => {
if (this._menu && e.composedPath && e.composedPath().includes(this._menu)) return;
this._closeMenu();
};
}
get designWidth() {
return parseInt(this.getAttribute('width'), 10) || DESIGN_W_DEFAULT;
}
get designHeight() {
return parseInt(this.getAttribute('height'), 10) || DESIGN_H_DEFAULT;
}
connectedCallback() {
// Presenter-view popup loads deckUrl?_snthumb=...#N for its prev/cur/
// next thumbnails — the rail has no business rendering inside those
// (wrong scale, and it offsets the stage so the thumb shows a gutter).
if (/[?&]_snthumb=/.test(location.search)) this.setAttribute('no-rail', '');
this._render();
this._loadNotes();
this._syncPrintPageRule();
window.addEventListener('keydown', this._onKey);
window.addEventListener('resize', this._onResize);
window.addEventListener('mousemove', this._onMouseMove, { passive: true });
window.addEventListener('message', this._onMessage);
window.addEventListener('click', this._onDocClick, true);
// Initial collection + layout happens via slotchange, which fires on mount.
this._enableRail();
// Hold the stage hidden until webfonts are ready so the first visible
// paint has the deck's real typography — the :not(:defined) guard in
// the page HTML only covers custom-element upgrade, not font load.
// Capped so a 404'd font URL can't blank the deck indefinitely.
this.setAttribute('data-fonts-pending', '');
const reveal = () => this.removeAttribute('data-fonts-pending');
// rAF first: fonts.ready is a pre-resolved promise until layout has
// resolved the slotted text's font-family and pushed a FontFace into
// 'loading'. Reading it here in connectedCallback (parse-time) would
// settle the race in a microtask before any font fetch starts.
requestAnimationFrame(() => {
Promise.race([
document.fonts ? document.fonts.ready : Promise.resolve(),
new Promise((r) => setTimeout(r, 2000)),
]).then(reveal, reveal);
});
}
_enableRail() {
// Idempotent — older host builds still post __omelette_rail_enabled.
// no-rail guard keeps the observers/stylesheet walk off the cheap path
// for presenter-popup thumbnail iframes (up to 9 per view).
if (this._railEnabled || this.hasAttribute('no-rail')) return;
this._railEnabled = true;
// Per-viewer preference — restored alongside rail width. Default on;
// only a stored '0' (from the TweaksPanel toggle) hides it.
this._railVisible = true;
try {
if (localStorage.getItem('deck-stage.railVisible') === '0') this._railVisible = false;
} catch (e) {}
// Live thumbnail updates: watch the light-DOM slides for content
// edits and re-clone just the affected thumb(s), debounced. Ignore
// the data-deck-* / data-screen-label / data-om-validate attributes
// this component itself writes so nav and skip don't trigger
// spurious refreshes.
const OWN_ATTRS = /^data-(deck-|screen-label$|om-validate$)/;
this._liveDirty = new Set();
this._liveObserver = new MutationObserver((records) => {
for (const r of records) {
if (r.type === 'attributes' && OWN_ATTRS.test(r.attributeName || '')) continue;
let n = r.target;
while (n && n.parentElement !== this) n = n.parentElement;
if (n && this._slideSet && this._slideSet.has(n)) this._liveDirty.add(n);
}
if (this._liveDirty.size && !this._liveTimer) {
this._liveTimer = setTimeout(() => {
this._liveTimer = null;
this._liveDirty.forEach((s) => this._refreshThumb(s));
this._liveDirty.clear();
}, 200);
}
});
this._liveObserver.observe(this, {
subtree: true, childList: true, characterData: true, attributes: true,
});
// Lazy thumbnail materialization — clone the slide only when its
// frame scrolls into (or near) the rail viewport. rootMargin gives
// ~4 thumbs of pre-load so fast scrolling doesn't flash blanks.
this._railObserver = new IntersectionObserver((entries) => {
entries.forEach((e) => {
if (e.isIntersecting && e.target.__deckThumb) {
this._materialize(e.target.__deckThumb);
}
});
}, { root: this._rail, rootMargin: '400px 0px' });
// Tweaks typically change CSS vars / attrs OUTSIDE <deck-stage>
// (on <html>, <body>, a wrapper div, or a <style> tag), which
// _liveObserver can't see. Re-snapshot author CSS (constructable
// sheet is shared by reference, so one replaceSync updates every
// thumb shadow root) and re-sync each thumb host's attrs + custom
// properties. In-slide DOM mutations are _liveObserver's job.
// Debounced so slider drags don't thrash.
this._onTweakChange = () => {
clearTimeout(this._tweakTimer);
this._tweakTimer = setTimeout(() => {
this._snapshotAuthorCss();
// One getComputedStyle for the whole batch — each
// getPropertyValue read below reuses the same computed style
// as long as nothing invalidates layout between thumbs.
const cs = getComputedStyle(this);
(this._thumbs || []).forEach((t) => {
if (t.host) this._syncThumbHostAttrs(t.host, cs);
});
}, 120);
};
window.addEventListener('tweakchange', this._onTweakChange);
this._snapshotAuthorCss();
// Build the rail now that it's enabled — slotchange already fired,
// so _renderRail's early-return skipped the initial build.
this._syncRailHidden();
this._renderRail();
this._fit();
}
/** Snapshot document stylesheets into a constructable sheet that each
* thumbnail's nested shadow root adopts — so author CSS styles the
* cloned slide content without touching this component's chrome.
* Cross-origin sheets throw on .cssRules — skip them. Re-callable:
* the existing constructable sheet is reused via replaceSync so every
* already-adopted shadow root picks up the fresh CSS without re-adopt. */
_snapshotAuthorCss() {
// :root in an adopted sheet inside a shadow root matches nothing
// (only the document root qualifies), so author rules like
// `:root[data-voice="modern"] .serif` never reach the clones.
// Rewrite :root → :host and mirror <html>'s data-*/class/lang onto
// each thumb host (see _syncThumbHostAttrs) so the same selectors
// match inside the thumbnail's shadow tree.
const authorCss = Array.from(document.styleSheets).map((sh) => {
try {
return Array.from(sh.cssRules).map((r) => r.cssText).join('\n');
} catch (e) { return ''; }
}).join('\n')
// The shadow host is featureless outside the functional :host(...)
// form, so any compound on :root — [attr], .class, #id, :pseudo —
// must become :host(<compound>) not :host<compound>. Same for the
// html type selector (Tailwind class-strategy dark mode emits
// html.dark; Pico uses html[data-theme]), which has nothing to
// match inside the thumb's shadow tree.
.replace(/:root((?:\[[^\]]*\]|[.#][-\w]+|:[-\w]+(?:\([^)]*\))?)+)/g, ':host($1)')
.replace(/:root\b/g, ':host')
.replace(/(^|[\s,>~+(}])html((?:\[[^\]]*\]|[.#][-\w]+|:[-\w]+(?:\([^)]*\))?)+)(?![-\w])/g, '$1:host($2)')
.replace(/(^|[\s,>~+(}])html(?![-\w])/g, '$1:host');
// Every custom property the author references. _syncThumbHostAttrs
// mirrors each one's *computed* value at <deck-stage> onto the
// thumb host so the live value wins over the :host default above
// regardless of which ancestor the tweak wrote to (<html>, <body>,
// a wrapper div, or the deck-stage element itself all inherit
// down to getComputedStyle(this)).
this._authorVars = new Set(authorCss.match(/--[\w-]+/g) || []);
try {
if (!this._adoptedSheet) this._adoptedSheet = new CSSStyleSheet();
this._adoptedSheet.replaceSync(authorCss);
} catch (e) {
this._adoptedSheet = null;
this._authorCss = authorCss;
}
}
_syncThumbHostAttrs(host, cs) {
const de = document.documentElement;
// setAttribute overwrites but can't delete — an attr removed from
// <html> (toggleAttribute off, classList emptied) would linger on
// the host and :host([data-*]) / :host(.foo) rules would keep
// matching. Remove stale mirrored attrs first; iterate backward
// because removeAttribute mutates the live NamedNodeMap.
for (let i = host.attributes.length - 1; i >= 0; i--) {
const n = host.attributes[i].name;
if ((n.startsWith('data-') || n === 'class' || n === 'lang')
&& !de.hasAttribute(n)) {
host.removeAttribute(n);
}
}
for (const a of de.attributes) {
if (a.name.startsWith('data-') || a.name === 'class' || a.name === 'lang') {
host.setAttribute(a.name, a.value);
}
}
// The :root→:host rewrite in _snapshotAuthorCss pins each custom
// property to its stylesheet default on the thumb host, shadowing
// the live value that would otherwise inherit. Tweaks can write the
// live value on any ancestor — <html>, <body>, a wrapper div, the
// deck-stage element — so read it as the *computed* value at
// <deck-stage> (which sees the whole inheritance chain) rather than
// trying to guess which element the author wrote to. Inline on the
// host beats the :host{} rule. remove-stale covers vars dropped
// from the stylesheet between snapshots.
const vars = this._authorVars || new Set();
for (let i = host.style.length - 1; i >= 0; i--) {
const p = host.style[i];
if (p.startsWith('--') && !vars.has(p)) host.style.removeProperty(p);
}
const live = cs || getComputedStyle(this);
vars.forEach((p) => {
const v = live.getPropertyValue(p);
if (v) host.style.setProperty(p, v.trim());
else host.style.removeProperty(p);
});
}
disconnectedCallback() {
window.removeEventListener('keydown', this._onKey);
window.removeEventListener('resize', this._onResize);
window.removeEventListener('mousemove', this._onMouseMove);
window.removeEventListener('message', this._onMessage);
window.removeEventListener('click', this._onDocClick, true);
if (this._hideTimer) clearTimeout(this._hideTimer);
if (this._mouseIdleTimer) clearTimeout(this._mouseIdleTimer);
if (this._liveTimer) clearTimeout(this._liveTimer);
if (this._tweakTimer) clearTimeout(this._tweakTimer);
if (this._railAnimTimer) clearTimeout(this._railAnimTimer);
if (this._scaleRaf) cancelAnimationFrame(this._scaleRaf);
if (this._liveObserver) this._liveObserver.disconnect();
if (this._railObserver) this._railObserver.disconnect();
if (this._onTweakChange) window.removeEventListener('tweakchange', this._onTweakChange);
}
attributeChangedCallback() {
if (this._canvas) {
this._canvas.style.width = this.designWidth + 'px';
this._canvas.style.height = this.designHeight + 'px';
this._canvas.style.setProperty('--deck-design-w', this.designWidth + 'px');
this._canvas.style.setProperty('--deck-design-h', this.designHeight + 'px');
if (this._rail) {
this._rail.style.setProperty('--deck-aspect', this.designWidth + '/' + this.designHeight);
}
this._fit();
this._scaleThumbs();
this._syncPrintPageRule();
}
}
_render() {
const style = document.createElement('style');
style.textContent = stylesheet;
const stage = document.createElement('div');
stage.className = 'stage';
const canvas = document.createElement('div');
canvas.className = 'canvas';
canvas.style.width = this.designWidth + 'px';
canvas.style.height = this.designHeight + 'px';
canvas.style.setProperty('--deck-design-w', this.designWidth + 'px');
canvas.style.setProperty('--deck-design-h', this.designHeight + 'px');
const slot = document.createElement('slot');
slot.addEventListener('slotchange', this._onSlotChange);
canvas.appendChild(slot);
stage.appendChild(canvas);
// Tap zones (mobile): left third = back, right third = forward.
const tapzones = document.createElement('div');
tapzones.className = 'tapzones export-hidden';
tapzones.setAttribute('aria-hidden', 'true');
tapzones.setAttribute('data-noncommentable', '');
const tzBack = document.createElement('div');
tzBack.className = 'tapzone tapzone--back';
const tzMid = document.createElement('div');
tzMid.className = 'tapzone tapzone--mid';
tzMid.style.pointerEvents = 'none';
const tzFwd = document.createElement('div');
tzFwd.className = 'tapzone tapzone--fwd';
tzBack.addEventListener('click', this._onTapBack);
tzFwd.addEventListener('click', this._onTapForward);
tapzones.append(tzBack, tzMid, tzFwd);
// Overlay: compact, solid black, with clickable controls.
const overlay = document.createElement('div');
overlay.className = 'overlay export-hidden';
overlay.setAttribute('role', 'toolbar');
overlay.setAttribute('aria-label', 'Deck controls');
overlay.setAttribute('data-noncommentable', '');
overlay.innerHTML = `
<button class="btn prev" type="button" aria-label="Previous slide" title="Previous (←)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 3L5 8l5 5"/></svg>
</button>
<span class="count" style="display:none" aria-live="polite"><span class="current">1</span><span class="sep">/</span><span class="total">1</span></span>
<button class="btn next" type="button" aria-label="Next slide" title="Next (→)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 3l5 5-5 5"/></svg>
</button>
<span class="divider"></span>
<button class="btn reset" type="button" aria-label="Reset to first slide" title="Reset (R)">Reset<span class="kbd">R</span></button>
`;
overlay.querySelector('.prev').addEventListener('click', () => this._advance(-1, 'click'));
overlay.querySelector('.next').addEventListener('click', () => this._advance(1, 'click'));
overlay.querySelector('.reset').addEventListener('click', () => this._go(0, 'click'));
// Thumbnail rail + context menu. Thumbnails are populated in
// _renderRail() after _collectSlides().
const rail = document.createElement('div');
rail.className = 'rail export-hidden';
rail.setAttribute('data-noncommentable', '');
rail.style.setProperty('--deck-aspect', this.designWidth + '/' + this.designHeight);
// Edge auto-scroll while dragging a thumb near the rail's top/bottom
// so off-screen drop targets are reachable. Native dragover fires
// continuously while the pointer is stationary, so a per-event nudge
// (ramped by edge proximity) is enough — no rAF loop needed.
rail.addEventListener('dragover', (e) => {
if (this._dragFrom == null) return;
const r = rail.getBoundingClientRect();
const EDGE = 40;
const dt = e.clientY - r.top;
const db = r.bottom - e.clientY;
if (dt < EDGE) rail.scrollTop -= Math.ceil((EDGE - dt) / 3);
else if (db < EDGE) rail.scrollTop += Math.ceil((EDGE - db) / 3);
});
const menu = document.createElement('div');
menu.className = 'ctxmenu export-hidden';
menu.setAttribute('data-noncommentable', '');
menu.innerHTML = `
<button type="button" data-act="skip">Skip slide</button>
<button type="button" data-act="up">Move up</button>
<button type="button" data-act="down">Move down</button>
<hr>
<button type="button" data-act="delete">Delete slide</button>
`;
menu.addEventListener('click', (e) => {
const act = e.target && e.target.getAttribute && e.target.getAttribute('data-act');
if (!act) return;
const i = this._menuIndex;
this._closeMenu();
if (act === 'skip') this._toggleSkip(i);
else if (act === 'up') this._moveSlide(i, i - 1);
else if (act === 'down') this._moveSlide(i, i + 1);
else if (act === 'delete') this._openConfirm(i);
});
menu.addEventListener('contextmenu', (e) => e.preventDefault());
// Rail resize handle — drag to set --deck-rail-w, persisted to
// localStorage so the width survives reloads.
const resize = document.createElement('div');
resize.className = 'rail-resize export-hidden';
resize.setAttribute('data-noncommentable', '');
resize.addEventListener('pointerdown', (e) => {
e.preventDefault();
resize.setPointerCapture(e.pointerId);
resize.setAttribute('data-dragging', '');
const move = (ev) => this._setRailWidth(ev.clientX);
const up = () => {
resize.removeEventListener('pointermove', move);
resize.removeEventListener('pointerup', up);
resize.removeEventListener('pointercancel', up);
resize.removeAttribute('data-dragging');
try { localStorage.setItem('deck-stage.railWidth', String(this._railPx)); } catch (err) {}
};
resize.addEventListener('pointermove', move);
resize.addEventListener('pointerup', up);
resize.addEventListener('pointercancel', up);
});
// Delete-confirm dialog — mirrors the SPA's ConfirmDialog layout.
const confirm = document.createElement('div');
confirm.className = 'confirm-backdrop export-hidden';
confirm.setAttribute('data-noncommentable', '');
confirm.innerHTML = `
<div class="confirm" role="dialog" aria-modal="true">
<div class="body">
<div class="title">Delete slide?</div>
<div class="msg">This slide will be removed from the deck.</div>
</div>
<div class="footer">
<button type="button" class="cancel">Cancel</button>
<button type="button" class="danger">Delete</button>
</div>
</div>
`;
confirm.addEventListener('click', (e) => {
if (e.target === confirm) this._closeConfirm();
});
confirm.querySelector('.cancel').addEventListener('click', () => this._closeConfirm());
confirm.querySelector('.danger').addEventListener('click', () => {
const i = this._confirmIndex;
this._closeConfirm();
this._deleteSlide(i);
});
this._root.append(style, rail, resize, stage, tapzones, overlay, menu, confirm);
this._canvas = canvas;
this._slot = slot;
this._overlay = overlay;
this._tapzones = tapzones;
this._rail = rail;
this._resize = resize;
this._menu = menu;
this._confirm = confirm;
this._countEl = overlay.querySelector('.current');
this._totalEl = overlay.querySelector('.total');
// Restore persisted rail width.
let rw = 188;
try {
const s = localStorage.getItem('deck-stage.railWidth');
if (s) rw = parseInt(s, 10) || rw;
} catch (err) {}
this._setRailWidth(rw);
this._syncRailHidden();
}
_setRailWidth(px) {
const w = Math.max(120, Math.min(360, Math.round(px)));
this._railPx = w;
this.style.setProperty('--deck-rail-w', w + 'px');
this._fit();
// _scaleThumbs forces a sync layout (frame.offsetWidth) then writes
// N transforms. During a resize drag this runs per-pointermove;
// coalesce to one per frame.
if (!this._scaleRaf) {
this._scaleRaf = requestAnimationFrame(() => {
this._scaleRaf = null;
this._scaleThumbs();
});
}
}
/** @page must live in the document stylesheet — it's a no-op inside
* shadow DOM. Inject/update a single <head> style tag so the print
* sheet matches the design size and Save-as-PDF yields one slide per
* page with no margins. */
_syncPrintPageRule() {
const id = 'deck-stage-print-page';
let tag = document.getElementById(id);
if (!tag) {
tag = document.createElement('style');
tag.id = id;
document.head.appendChild(tag);
}
tag.textContent =
'@page { size: ' + this.designWidth + 'px ' + this.designHeight + 'px; margin: 0; } ' +
'@media print { html, body { margin: 0 !important; padding: 0 !important; background: none !important; overflow: visible !important; height: auto !important; } ' +
'* { -webkit-print-color-adjust: exact; print-color-adjust: exact; } }';
}
_onSlotChange() {
// Rail mutations (delete/move) already reconcile synchronously and
// emit slidechange with reason 'api'; skip the async slotchange that
// would otherwise re-broadcast with reason 'init'.
if (this._squelchSlotChange) { this._squelchSlotChange = false; return; }
this._collectSlides();
this._restoreIndex();
this._applyIndex({ showOverlay: false, broadcast: true, reason: 'init' });
this._fit();
}
_collectSlides() {
const assigned = this._slot.assignedElements({ flatten: true });
this._slides = assigned.filter((el) => {
// Skip template/style/script nodes even if someone slots them.
const tag = el.tagName;
return tag !== 'TEMPLATE' && tag !== 'SCRIPT' && tag !== 'STYLE';
});
this._slideSet = new Set(this._slides);
this._slides.forEach((slide, i) => {
const n = i + 1;
// Determine a label for comment flow: prefer explicit data-label,
// then an existing data-screen-label, then first heading, else "Slide".
let label = slide.getAttribute('data-label');
if (!label) {
const existing = slide.getAttribute('data-screen-label');
if (existing) {
// Strip any leading number the author may have included.
label = existing.replace(/^\s*\d+\s*/, '').trim() || existing;
}
}
if (!label) {
const h = slide.querySelector('h1, h2, h3, [data-title]');
if (h) label = (h.textContent || '').trim().slice(0, 40);
}
if (!label) label = 'Slide';
slide.setAttribute('data-screen-label', `${pad2(n)} ${label}`);
// Validation attribute for comment flow / auto-checks.
if (!slide.hasAttribute('data-om-validate')) {
slide.setAttribute('data-om-validate', VALIDATE_ATTR);
}
slide.setAttribute('data-deck-slide', String(i));
});
if (this._totalEl) this._totalEl.textContent = String(this._slides.length || 1);
if (this._index >= this._slides.length) this._index = Math.max(0, this._slides.length - 1);
this._markLastVisible();
this._renderRail();
}
/** Tag the last non-skipped slide so print CSS can drop its
* break-after (see the @media print comment above — :last-child
* alone matches a hidden skipped slide). */
_markLastVisible() {
let last = null;
this._slides.forEach((s) => {
s.removeAttribute('data-deck-last-visible');
if (!s.hasAttribute('data-deck-skip')) last = s;
});
if (last) last.setAttribute('data-deck-last-visible', '');
}
_loadNotes() {
const tag = document.getElementById('speaker-notes');
if (!tag) { this._notes = []; return; }
try {
const parsed = JSON.parse(tag.textContent || '[]');
if (Array.isArray(parsed)) this._notes = parsed;
} catch (e) {
console.warn('[deck-stage] Failed to parse #speaker-notes JSON:', e);
this._notes = [];
}
}
_restoreIndex() {
// The host's ?slide= param is delivered as a #<int> hash (1-indexed) on
// the iframe src. No hash → slide 1; the deck itself keeps no position
// state across loads.
const h = (location.hash || '').match(/^#(\d+)$/);
if (h) {
const n = parseInt(h[1], 10) - 1;
if (n >= 0 && n < this._slides.length) this._index = n;
}
}
_applyIndex({ showOverlay = true, broadcast = true, reason = 'init' } = {}) {
if (!this._slides.length) return;
const prev = this._prevIndex == null ? -1 : this._prevIndex;
const curr = this._index;
// Keep the iframe's own hash in sync so an in-iframe location.reload()
// (reload banner path in viewer-handle.ts) lands on the current slide,
// not the stale deep-link hash from initial load.
try { history.replaceState(null, '', '#' + (curr + 1)); } catch (e) {}
this._slides.forEach((s, i) => {
if (i === curr) s.setAttribute('data-deck-active', '');
else s.removeAttribute('data-deck-active');
});
if (this._countEl) this._countEl.textContent = String(curr + 1);
// Follow-scroll on every navigation (init deep-link, keyboard, click,
// tap, external goTo) — the only time we *don't* want the rail to
// track current is after a rail-internal mutation, where _renderRail
// has already restored the user's scroll position and yanking back to
// current would undo it.
this._syncRail(reason !== 'mutation');
if (broadcast) {
// (1) Legacy: host-window postMessage for speaker-notes renderers.
try { window.postMessage({ slideIndexChanged: curr, deckTotal: this._slides.length, deckSkipped: this._skippedIndices() }, '*'); } catch (e) {}
// (2) In-page CustomEvent on the <deck-stage> element itself.
// Bubbles and composes out of shadow DOM so slide code can listen:
// document.querySelector('deck-stage').addEventListener('slidechange', e => {
// e.detail.index, e.detail.previousIndex, e.detail.total, e.detail.slide, e.detail.reason
// });
const detail = {
index: curr,
previousIndex: prev,
total: this._slides.length,
slide: this._slides[curr] || null,
previousSlide: prev >= 0 ? (this._slides[prev] || null) : null,
reason: reason, // 'init' | 'keyboard' | 'click' | 'tap' | 'api'
};
this.dispatchEvent(new CustomEvent('slidechange', {
detail,
bubbles: true,
composed: true,
}));
}
this._prevIndex = curr;
if (showOverlay) this._flashOverlay();
}
_flashOverlay() {
// Host posts __omelette_presenting while in fullscreen/tab presentation
// mode — suppress the nav footer entirely (both hover and slide-change
// flash) so the audience sees clean slides.
if (!this._overlay || this._presenting) return;
this._overlay.setAttribute('data-visible', '');
if (this._hideTimer) clearTimeout(this._hideTimer);
this._hideTimer = setTimeout(() => {
this._overlay.removeAttribute('data-visible');
}, OVERLAY_HIDE_MS);
}
_railWidth() {
// State-based, no offsetWidth: the first _fit() can run before the
// rail has had layout on some load paths, and a 0 there paints the
// slide full-width for one frame before the post-slotchange _fit()
// corrects it.
if (!this._railEnabled || !this._railVisible || this.hasAttribute('no-rail')
|| this.hasAttribute('noscale') || this._presenting) return 0;
return this._railPx || 0;
}
_fit() {
if (!this._canvas) return;
const stage = this._canvas.parentElement;
// PPTX export sets noscale so the DOM capture sees authored-size
// geometry — the scaled canvas is in shadow DOM, so the exporter's
// resetTransformSelector can't reach .canvas.style.transform directly.
if (this.hasAttribute('noscale')) {
this._canvas.style.transform = 'none';
if (stage) stage.style.left = '0';
if (this._overlay) this._overlay.style.marginLeft = '0';
if (this._tapzones) this._tapzones.style.left = '0';
return;
}
const rw = this._railWidth();
if (stage) stage.style.left = rw + 'px';
// Overlay is centred on the viewport via left:50% + translate(-50%);
// marginLeft shifts the centre by rw/2 so it lands in the middle of
// the [rw, innerWidth] stage region. Tapzones just inset from rw.
if (this._overlay) this._overlay.style.marginLeft = (rw / 2) + 'px';
if (this._tapzones) this._tapzones.style.left = rw + 'px';
const vw = window.innerWidth - rw;
const vh = window.innerHeight;
const s = Math.min(vw / this.designWidth, vh / this.designHeight);
this._canvas.style.transform = `scale(${s})`;
}
_onResize() { this._fit(); }
_onMouseMove() {
// Keep overlay visible while mouse moves; hide after idle.
this._flashOverlay();
}
_onMessage(e) {
const d = e.data;
if (d && typeof d.__omelette_presenting === 'boolean') {
this._presenting = d.__omelette_presenting;
if (this._presenting && this._overlay) {
this._overlay.removeAttribute('data-visible');
if (this._hideTimer) clearTimeout(this._hideTimer);
}
this._syncRailHidden();
this._closeMenu();
this._closeConfirm();
this._fit();
this._scaleThumbs();
}
// Per-viewer show/hide, driven by the TweaksPanel's auto-injected
// "Thumbnail rail" toggle (or any author script). Independent of
// whether the Tweaks panel itself is open — closing the panel
// doesn't change rail visibility. Persists alongside rail width.
if (d && d.type === '__deck_rail_visible' && typeof d.on === 'boolean') {
if (d.on === this._railVisible) return;
this._railVisible = d.on;
try { localStorage.setItem('deck-stage.railVisible', d.on ? '1' : '0'); } catch (e) {}
// Arm the transition, commit it, then flip state — otherwise the
// browser coalesces both writes and nothing animates on show.
this.setAttribute('data-rail-anim', '');
void (this._rail && this._rail.offsetHeight);
this._syncRailHidden();
this._fit();
this._scaleThumbs();
clearTimeout(this._railAnimTimer);
this._railAnimTimer = setTimeout(() => this.removeAttribute('data-rail-anim'), 220);
}
if (d && d.type === '__omelette_rail_enabled') this._enableRail();
}
_syncRailHidden() {
if (!this._rail) return;
// data-presenting is the hard hide (display:none) for flag-off and
// presentation mode — instant, no transition. data-user-hidden is
// the soft hide (translateX(-100%)) for the viewer's rail toggle,
// so show/hide slides under :host([data-rail-anim]).
const hard = !this._railEnabled || this._presenting;
if (hard) this._rail.setAttribute('data-presenting', '');
else this._rail.removeAttribute('data-presenting');
if (!this._railVisible) this._rail.setAttribute('data-user-hidden', '');
else this._rail.removeAttribute('data-user-hidden');
// translateX hide leaves thumbs (tabIndex=0) in the tab order —
// inert keeps them unfocusable while the rail is off-screen.
this._rail.inert = hard || !this._railVisible;
}
_onTapBack(e) {
e.preventDefault();
this._advance(-1, 'tap');
}
_onTapForward(e) {
e.preventDefault();
this._advance(1, 'tap');
}
_onKey(e) {
// Ignore when the user is typing.
const t = e.target;
if (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName))) return;
// Confirm dialog swallows nav keys while open; Escape cancels. Enter
// is left to the focused button's native activation so Tab→Cancel
// →Enter activates Cancel, not the window-level confirm path.
if (this._confirm && this._confirm.hasAttribute('data-open')) {
if (e.key === 'Escape') { this._closeConfirm(); e.preventDefault(); }
return;
}
if (e.key === 'Escape' && this._menu && this._menu.hasAttribute('data-open')) {
this._closeMenu();
e.preventDefault();
return;
}
if (e.metaKey || e.ctrlKey || e.altKey) return;
const key = e.key;
let handled = true;
if (key === 'ArrowRight' || key === 'PageDown' || key === ' ' || key === 'Spacebar') {
this._advance(1, 'keyboard');
} else if (key === 'ArrowLeft' || key === 'PageUp') {
this._advance(-1, 'keyboard');
} else if (key === 'Home') {
this._go(0, 'keyboard');
} else if (key === 'End') {
this._go(this._slides.length - 1, 'keyboard');
} else if (key === 'r' || key === 'R') {
this._go(0, 'keyboard');
} else if (/^[0-9]$/.test(key)) {
// 1..9 jump to that slide; 0 jumps to 10.
const n = key === '0' ? 9 : parseInt(key, 10) - 1;
if (n < this._slides.length) this._go(n, 'keyboard');
} else {
handled = false;
}
if (handled) {
e.preventDefault();
this._flashOverlay();
}
}
_go(i, reason = 'api') {
if (!this._slides.length) return;
const clamped = Math.max(0, Math.min(this._slides.length - 1, i));
if (clamped === this._index) {
this._flashOverlay();
return;
}
this._index = clamped;
this._applyIndex({ showOverlay: true, broadcast: true, reason });
}
/** Step forward/back skipping any slide marked data-deck-skip. Falls
* back to _go's clamp-at-ends behaviour (flash overlay) when there's
* nothing further in that direction. */
_advance(dir, reason) {
if (!this._slides.length) return;
let i = this._index + dir;
while (i >= 0 && i < this._slides.length && this._slides[i].hasAttribute('data-deck-skip')) {
i += dir;
}
if (i < 0 || i >= this._slides.length) { this._flashOverlay(); return; }
this._go(i, reason);
}
// ── Thumbnail rail ────────────────────────────────────────────────────
//
// Thumbs are keyed by slide element and reused across _renderRail()
// calls, so a reorder/delete is an O(changed) DOM shuffle instead of an
// O(N) teardown-and-re-clone. Each thumb starts as a lightweight shell
// (num + empty frame); the clone is materialized lazily by an
// IntersectionObserver when the frame scrolls into (or near) view, so
// only visible-ish slides pay the clone + image-decode cost.
_renderRail() {
if (!this._rail || !this._railEnabled) { this._thumbs = []; return; }
// FLIP: record each *materialized* thumb's top before the reconcile.
// Off-screen (non-materialized) thumbs don't need the animation and
// skipping their getBoundingClientRect saves a forced layout per
// off-screen thumb on large decks.
const prevTops = new Map();
(this._thumbs || []).forEach(({ thumb, slide, host }) => {
if (host) prevTops.set(slide, thumb.getBoundingClientRect().top);
});
const st = this._rail.scrollTop;
// Reconcile: reuse thumbs that already exist for a slide, create
// shells for new slides, drop thumbs for removed slides.
const bySlide = new Map();
(this._thumbs || []).forEach((t) => bySlide.set(t.slide, t));
const next = [];
this._slides.forEach((slide) => {
let t = bySlide.get(slide);
if (t) bySlide.delete(slide);
else t = this._makeThumb(slide);
next.push(t);
});
// Orphans — slides removed since last render.
bySlide.forEach((t) => {
if (this._railObserver) this._railObserver.unobserve(t.frame);
t.thumb.remove();
});
// Put thumbs into document order to match _slides. insertBefore on
// an already-correctly-placed node is a no-op, so this is cheap
// when nothing moved.
next.forEach((t, i) => {
const want = t.thumb;
const at = this._rail.children[i];
if (at !== want) this._rail.insertBefore(want, at || null);
t.i = i;
t.num.textContent = String(i + 1);
if (t.slide.hasAttribute('data-deck-skip')) t.thumb.setAttribute('data-skip', '');
else t.thumb.removeAttribute('data-skip');
});
this._thumbs = next;
this._rail.scrollTop = st;
if (prevTops.size) {
const moved = [];
this._thumbs.forEach(({ thumb, slide }) => {
const old = prevTops.get(slide);
if (old == null) return;
const dy = old - thumb.getBoundingClientRect().top;
if (Math.abs(dy) < 1) return;
thumb.style.transition = 'none';
thumb.style.transform = `translateY(${dy}px)`;
moved.push(thumb);
});
if (moved.length) {
// Commit the inverted positions before flipping the transition
// on — otherwise the browser coalesces both style writes and
// nothing animates.
void this._rail.offsetHeight;
moved.forEach((t) => {
t.style.transition = 'transform 180ms cubic-bezier(.2,.7,.3,1)';
t.style.transform = '';
});
setTimeout(() => moved.forEach((t) => { t.style.transition = ''; }), 220);
}
}
requestAnimationFrame(() => this._scaleThumbs());
this._syncRail(false);
}
/** Create a lightweight thumb shell for one slide. The clone is
* materialized later by the IntersectionObserver. Event handlers
* look up the thumb's *current* index (via _thumbs.indexOf) so the
* same element can be reused across reorders. */
_makeThumb(slide) {
const thumb = document.createElement('div');
thumb.className = 'thumb';
thumb.tabIndex = 0;
const num = document.createElement('div');
num.className = 'num';
const frame = document.createElement('div');
frame.className = 'frame';
thumb.append(num, frame);
const entry = { thumb, num, frame, slide, clone: null, host: null, i: -1 };
// entry.i is refreshed on every _renderRail reconcile pass, so
// handlers read the thumb's current position without an O(N) scan.
const idx = () => entry.i;
thumb.addEventListener('click', () => this._go(idx(), 'click'));
// ↑/↓ step through the rail when a thumb has focus. _go clamps at the
// ends and _applyIndex→_syncRail scrolls the new current thumb into
// view; we move focus to it (preventScroll — _syncRail already
// scrolled) so a held key walks the whole list. stopPropagation keeps
// this out of the window-level _onKey nav handler.
thumb.addEventListener('keydown', (e) => {
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
e.preventDefault();
e.stopPropagation();
this._go(idx() + (e.key === 'ArrowDown' ? 1 : -1), 'keyboard');
const cur = this._thumbs && this._thumbs[this._index];
if (cur) cur.thumb.focus({ preventScroll: true });
});
thumb.addEventListener('contextmenu', (e) => {
e.preventDefault();
this._openMenu(idx(), e.clientX, e.clientY);
});
thumb.draggable = true;
thumb.addEventListener('dragstart', (e) => {
this._dragFrom = idx();
thumb.setAttribute('data-dragging', '');
e.dataTransfer.effectAllowed = 'move';
try { e.dataTransfer.setData('text/plain', String(this._dragFrom)); } catch (err) {}
});
thumb.addEventListener('dragend', () => {
thumb.removeAttribute('data-dragging');
this._clearDrop();
this._dragFrom = null;
});
thumb.addEventListener('dragover', (e) => {
if (this._dragFrom == null) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const r = thumb.getBoundingClientRect();
this._setDrop(idx(), e.clientY < r.top + r.height / 2 ? 'before' : 'after');
});
thumb.addEventListener('drop', (e) => {
if (this._dragFrom == null) return;
e.preventDefault();
const i = idx();
const r = thumb.getBoundingClientRect();
let to = e.clientY >= r.top + r.height / 2 ? i + 1 : i;
if (this._dragFrom < to) to--;
const from = this._dragFrom;
this._clearDrop();
this._dragFrom = null;
if (to !== from) this._moveSlide(from, to);
});
if (this._railObserver) this._railObserver.observe(frame);
frame.__deckThumb = entry;
return entry;
}
/** Lazily build the clone for a thumb that has scrolled into view. */
_materialize(entry) {
if (entry.host) return;
const dw = this.designWidth, dh = this.designHeight;
let clone = entry.slide.cloneNode(true);
clone.removeAttribute('id');
clone.removeAttribute('data-deck-active');
clone.querySelectorAll('[id]').forEach((el) => el.removeAttribute('id'));
// Neuter heavy media; replace <video> with its poster so the box
// keeps a visual. <iframe>/<audio> become empty placeholders.
clone.querySelectorAll('iframe, audio, object, embed').forEach((el) => {
el.removeAttribute('src');
el.removeAttribute('srcdoc');
el.removeAttribute('data');
el.innerHTML = '';
});
clone.querySelectorAll('video').forEach((el) => {
if (!el.poster) { el.removeAttribute('src'); el.innerHTML = ''; return; }
const img = document.createElement('img');
img.src = el.poster;
img.alt = '';
img.style.cssText = el.style.cssText + ';object-fit:cover;width:100%;height:100%;';
img.className = el.className;
el.replaceWith(img);
});
// Images: defer decode and let the browser pick the smallest
// srcset candidate for the ~140px thumb. Same-URL clones reuse the
// slide's decoded bitmap (URL-keyed cache), so the remaining cost
// is paint/composite — lazy+async keeps that off the main thread.
clone.querySelectorAll('img').forEach((el) => {
el.loading = 'lazy';
el.decoding = 'async';
if (el.srcset) el.sizes = (this._railPx || 188) + 'px';
});
// Custom elements inside the slide would have their
// connectedCallback fire when the clone is appended. Replace them
// with inert boxes so a component-heavy deck doesn't run N copies
// of each component's mount logic in the rail. Children are
// preserved so layout-wrapper elements (<my-column><h2>…</h2>)
// still show their authored content; the querySelectorAll NodeList
// is static, so nested custom elements in the moved subtree are
// still visited on later iterations.
const neuter = (el) => {
const box = document.createElement('div');
box.style.cssText = (el.getAttribute('style') || '') +
';background:rgba(0,0,0,0.06);border:1px dashed rgba(0,0,0,0.15);';
box.className = el.className;
// Preserve theming/i18n hooks so [data-*] / :lang() / [dir]
// descendant selectors still match the neutered root.
for (const a of el.attributes) {
const n = a.name;
if (n.startsWith('data-') || n.startsWith('aria-') ||
n === 'lang' || n === 'dir' || n === 'role' || n === 'title') {
box.setAttribute(n, a.value);
}
}
while (el.firstChild) box.appendChild(el.firstChild);
return box;
};
// querySelectorAll('*') returns descendants only — a custom-element
// slide root (<my-slide>…</my-slide>) would slip through and upgrade
// on append. Swap the root first.
if (clone.tagName.includes('-')) clone = neuter(clone);
clone.querySelectorAll('*').forEach((el) => {
if (el.tagName.includes('-')) el.replaceWith(neuter(el));
});
clone.style.cssText += ';position:absolute;top:0;left:0;transform-origin:0 0;' +
'pointer-events:none;width:' + dw + 'px;height:' + dh + 'px;' +
'box-sizing:border-box;overflow:hidden;visibility:visible;opacity:1;';
const host = document.createElement('div');
host.style.cssText = 'position:absolute;inset:0;';
this._syncThumbHostAttrs(host);
const sr = host.attachShadow({ mode: 'open' });
if (this._adoptedSheet) sr.adoptedStyleSheets = [this._adoptedSheet];
else {
const st = document.createElement('style');
st.textContent = this._authorCss || '';
sr.appendChild(st);
}
sr.appendChild(clone);
entry.frame.appendChild(host);
entry.host = host;
entry.clone = clone;
if (this._thumbScale) clone.style.transform = 'scale(' + this._thumbScale + ')';
// Once materialized the IO callback is a no-op early-return —
// unobserve so scroll doesn't keep firing it.
if (this._railObserver) this._railObserver.unobserve(entry.frame);
}
/** Re-clone a single thumb (live-update path). No-op if the thumb
* hasn't been materialized yet — it'll pick up current content when
* it scrolls into view. */
_refreshThumb(slide) {
const entry = (this._thumbs || []).find((t) => t.slide === slide);
if (!entry || !entry.host) return;
entry.host.remove();
entry.host = entry.clone = null;
this._materialize(entry);
}
_scaleThumbs() {
if (!this._thumbs || !this._thumbs.length) return;
// Every frame is the same width; if it reads 0 the rail is
// display:none (noscale / no-rail / presenting / print) — leave the
// clones as-is and re-run when the rail is revealed.
const fw = this._thumbs[0].frame.offsetWidth;
if (!fw) return;
this._thumbScale = fw / this.designWidth;
this._thumbs.forEach(({ clone }) => {
if (clone) clone.style.transform = 'scale(' + this._thumbScale + ')';
});
}
_setDrop(i, where) {
// dragover fires at pointer-event rate; touch only the previous
// and new target rather than sweeping all N thumbs.
const t = this._thumbs && this._thumbs[i];
if (this._dropOn && this._dropOn !== t) {
this._dropOn.thumb.removeAttribute('data-drop');
}
if (t) t.thumb.setAttribute('data-drop', where);
this._dropOn = t || null;
}
_clearDrop() {
if (this._dropOn) this._dropOn.thumb.removeAttribute('data-drop');
this._dropOn = null;
}
_syncRail(follow) {
if (!this._thumbs) return;
this._thumbs.forEach(({ thumb }, i) => {
if (i === this._index) {
thumb.setAttribute('data-current', '');
if (follow && typeof thumb.scrollIntoView === 'function') {
thumb.scrollIntoView({ block: 'nearest' });
}
} else {
thumb.removeAttribute('data-current');
}
});
}
_openMenu(i, x, y) {
if (!this._menu) return;
this._menuIndex = i;
const slide = this._slides[i];
const skip = slide && slide.hasAttribute('data-deck-skip');
this._menu.querySelector('[data-act="skip"]').textContent = skip ? 'Unskip slide' : 'Skip slide';
this._menu.querySelector('[data-act="up"]').disabled = i <= 0;
this._menu.querySelector('[data-act="down"]').disabled = i >= this._slides.length - 1;
this._menu.querySelector('[data-act="delete"]').disabled = this._slides.length <= 1;
// Place, then clamp to viewport after it's measurable.
this._menu.style.left = x + 'px';
this._menu.style.top = y + 'px';
this._menu.setAttribute('data-open', '');
const r = this._menu.getBoundingClientRect();
const nx = Math.min(x, window.innerWidth - r.width - 4);
const ny = Math.min(y, window.innerHeight - r.height - 4);
this._menu.style.left = Math.max(4, nx) + 'px';
this._menu.style.top = Math.max(4, ny) + 'px';
}
_closeMenu() {
if (this._menu) this._menu.removeAttribute('data-open');
this._menuIndex = -1;
}
_openConfirm(i) {
if (!this._confirm) return;
this._confirmIndex = i;
this._confirm.querySelector('.title').textContent = 'Delete slide ' + (i + 1) + '?';
this._confirm.setAttribute('data-open', '');
const btn = this._confirm.querySelector('.danger');
if (btn && btn.focus) btn.focus();
}
_closeConfirm() {
if (this._confirm) this._confirm.removeAttribute('data-open');
this._confirmIndex = -1;
}
_emitDeckChange(detail) {
this.dispatchEvent(new CustomEvent('deckchange', {
detail, bubbles: true, composed: true,
}));
}
_deleteSlide(i) {
const slide = this._slides[i];
if (!slide || this._slides.length <= 1) return;
const wasCurrent = i === this._index;
if (i < this._index || (wasCurrent && i === this._slides.length - 1)) this._index--;
this._squelchSlotChange = true;
slide.remove();
this._emitDeckChange({ action: 'delete', from: i, slide });
this._collectSlides();
this._applyIndex({ showOverlay: true, broadcast: true, reason: 'mutation' });
}
_toggleSkip(i) {
const slide = this._slides[i];
if (!slide) return;
const on = !slide.hasAttribute('data-deck-skip');
if (on) slide.setAttribute('data-deck-skip', '');
else slide.removeAttribute('data-deck-skip');
if (this._thumbs && this._thumbs[i]) {
if (on) this._thumbs[i].thumb.setAttribute('data-skip', '');
else this._thumbs[i].thumb.removeAttribute('data-skip');
}
this._markLastVisible();
this._emitDeckChange({ action: on ? 'skip' : 'unskip', from: i, slide });
// Re-broadcast so the presenter popup's prev/next thumbnails re-pick
// the nearest non-skipped slide without waiting for a nav event.
try { window.postMessage({ slideIndexChanged: this._index, deckTotal: this._slides.length, deckSkipped: this._skippedIndices() }, '*'); } catch (e) {}
}
_skippedIndices() {
const out = [];
for (let i = 0; i < this._slides.length; i++) {
if (this._slides[i].hasAttribute('data-deck-skip')) out.push(i);
}
return out;
}
_moveSlide(i, j) {
if (j < 0 || j >= this._slides.length || j === i) return;
const slide = this._slides[i];
const ref = j < i ? this._slides[j] : this._slides[j].nextSibling;
// Track the active slide across the reorder so the same content
// stays on screen.
const cur = this._index;
if (cur === i) this._index = j;
else if (i < cur && j >= cur) this._index = cur - 1;
else if (i > cur && j <= cur) this._index = cur + 1;
this._squelchSlotChange = true;
this.insertBefore(slide, ref);
this._emitDeckChange({ action: 'move', from: i, to: j, slide });
this._collectSlides();
this._applyIndex({ showOverlay: false, broadcast: true, reason: 'mutation' });
}
// Public API ------------------------------------------------------------
/** Current slide index (0-based). */
get index() { return this._index; }
/** Total slide count. */
get length() { return this._slides.length; }
/** Programmatically navigate. */
goTo(i) { this._go(i, 'api'); }
next() { this._advance(1, 'api'); }
prev() { this._advance(-1, 'api'); }
reset() { this._go(0, 'api'); }
}
if (!customElements.get('deck-stage')) {
customElements.define('deck-stage', DeckStage);
}
})();
</script>
</body>
</html>