@deckio/deck-engine 1.7.7 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -121,7 +121,7 @@ export default function Navigation({ pdfPath = null, pdfLabel = 'Deck PDF' }) {
121
121
  <path d="M9 15l3 3 3-3" />
122
122
  <path d="M8 10h8" />
123
123
  </svg>
124
- <span className={styles.exportLabel}>{exportStatus}</span>
124
+ <span className={styles.exportLabel}>{isExporting ? exportStatus : '⬇ PDF'}</span>
125
125
  </button>
126
126
  )}
127
127
 
@@ -1,15 +1,43 @@
1
+ import { useRef, useEffect, useState } from 'react'
1
2
  import { useSlides } from '../context/SlideContext'
2
3
 
4
+ const DEV = typeof import.meta !== 'undefined' && import.meta.env?.DEV
5
+
3
6
  export default function Slide({ index, className = '', children }) {
4
7
  const { current } = useSlides()
8
+ const ref = useRef(null)
9
+ const [overflow, setOverflow] = useState(false)
5
10
 
6
11
  let stateClass = ''
7
12
  if (index === current) stateClass = 'active'
8
13
  else if (index < current) stateClass = 'exit-left'
9
14
 
15
+ useEffect(() => {
16
+ if (!DEV || index !== current || !ref.current) return
17
+ const el = ref.current
18
+ const check = () => {
19
+ // Only check flow-positioned children; ignore absolute/fixed decorations (orbs, accent-bar)
20
+ const hasOverflow = Array.from(el.children).some(c => {
21
+ const pos = getComputedStyle(c).position
22
+ if (pos === 'absolute' || pos === 'fixed') return false
23
+ return c.offsetTop + c.offsetHeight > el.clientHeight
24
+ })
25
+ setOverflow(hasOverflow)
26
+ }
27
+ check()
28
+ const obs = new ResizeObserver(check)
29
+ obs.observe(el)
30
+ return () => obs.disconnect()
31
+ }, [index, current])
32
+
10
33
  return (
11
- <div className={`slide ${stateClass} ${className}`} data-slide={index}>
34
+ <div ref={ref} className={`slide ${stateClass} ${className}`} data-slide={index}>
12
35
  {children}
36
+ {DEV && overflow && (
37
+ <div className="slide-overflow-warn">
38
+ ⚠ Content overflows slide — reduce content or use smaller elements
39
+ </div>
40
+ )}
13
41
  </div>
14
42
  )
15
43
  }
@@ -1,57 +1,50 @@
1
- const DEFAULT_PAGE_WIDTH = 1920
2
- const DEFAULT_PAGE_HEIGHT = 1080
3
- const EXPORT_TRANSITION_MS = 750
4
-
5
- function wait(ms) {
6
- return new Promise((resolve) => {
7
- window.setTimeout(resolve, ms)
8
- })
9
- }
1
+ /**
2
+ * Export deck slides to PDF — direct download, no dialogs.
3
+ *
4
+ * Uses modern-screenshot (SVG foreignObject) + jspdf.
5
+ * The browser's own renderer handles all CSS natively:
6
+ * - background-clip: text ✅ (explicit fix in modern-screenshot)
7
+ * - filter: blur() ✅ (native foreignObject rendering)
8
+ * - gradients, shadows ✅
9
+ * - animations paused before capture
10
+ */
11
+
12
+ const PAGE_W = 1920
13
+ const PAGE_H = 1080
14
+ const SETTLE_MS = 600
15
+
16
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms))
10
17
 
11
18
  async function waitForPaint() {
12
- await new Promise((resolve) => requestAnimationFrame(() => resolve()))
13
- await new Promise((resolve) => requestAnimationFrame(() => resolve()))
14
- }
15
-
16
- async function waitForFonts() {
17
- if (document.fonts?.ready) {
18
- await document.fonts.ready
19
- }
20
- }
21
-
22
- function sanitizeFileName(value) {
23
- return String(value || 'deck')
24
- .trim()
25
- .toLowerCase()
26
- .replace(/[^a-z0-9]+/g, '-')
27
- .replace(/^-|-$/g, '') || 'deck'
28
- }
29
-
30
- function getDeckElement() {
31
- return document.querySelector('.deck')
19
+ await new Promise((r) => requestAnimationFrame(() => r()))
20
+ await new Promise((r) => requestAnimationFrame(() => r()))
32
21
  }
33
22
 
34
- function getSlideNodes(deckElement) {
35
- return Array.from(deckElement?.querySelectorAll('.slide') || [])
23
+ function sanitize(v) {
24
+ return String(v || 'deck').trim().toLowerCase()
25
+ .replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'deck'
36
26
  }
37
27
 
38
- function getExportFileName({ project, selectedCustomer }) {
39
- const baseName = selectedCustomer
28
+ function buildFileName({ project, selectedCustomer }) {
29
+ const base = selectedCustomer
40
30
  ? `${selectedCustomer} ${document.title || project || 'deck'}`
41
31
  : document.title || project || 'deck'
42
-
43
- return `${sanitizeFileName(baseName)}.pdf`
32
+ return `${sanitize(base)}.pdf`
44
33
  }
45
34
 
46
- function triggerDownload(blob, fileName) {
47
- const blobUrl = URL.createObjectURL(blob)
48
- const link = document.createElement('a')
49
- link.href = blobUrl
50
- link.download = fileName
51
- document.body.append(link)
52
- link.click()
53
- link.remove()
54
- window.setTimeout(() => URL.revokeObjectURL(blobUrl), 1000)
35
+ /**
36
+ * Pause animations on a slide for deterministic capture. Returns restore fn.
37
+ */
38
+ function pauseAnimations(slide) {
39
+ const undo = []
40
+ const pause = (el) => {
41
+ const orig = el.style.animationPlayState
42
+ el.style.animationPlayState = 'paused'
43
+ undo.push(() => { el.style.animationPlayState = orig })
44
+ }
45
+ pause(slide)
46
+ slide.querySelectorAll('*').forEach(pause)
47
+ return () => { for (let i = undo.length - 1; i >= 0; i--) undo[i]() }
55
48
  }
56
49
 
57
50
  export async function exportDeckPdf({
@@ -62,73 +55,79 @@ export async function exportDeckPdf({
62
55
  totalSlides,
63
56
  onProgress,
64
57
  }) {
65
- const deckElement = getDeckElement()
66
- const slideNodes = getSlideNodes(deckElement)
58
+ const deck = document.querySelector('.deck')
59
+ const slides = Array.from(deck?.querySelectorAll('.slide') || [])
60
+ if (!deck || slides.length === 0) throw new Error('No slides found')
67
61
 
68
- if (!deckElement || slideNodes.length === 0) {
69
- throw new Error('No slides found to export')
70
- }
71
-
72
- const [{ default: html2canvas }, { jsPDF }] = await Promise.all([
73
- import('html2canvas'),
62
+ // Dynamic imports tree-shaken, only loaded on export
63
+ const [{ domToPng }, { jsPDF }] = await Promise.all([
64
+ import('modern-screenshot'),
74
65
  import('jspdf'),
75
66
  ])
76
67
 
77
- const pageWidth = deckElement.clientWidth || DEFAULT_PAGE_WIDTH
78
- const pageHeight = deckElement.clientHeight || DEFAULT_PAGE_HEIGHT
79
- const backgroundColor = getComputedStyle(document.documentElement)
80
- .getPropertyValue('--bg-deep')
81
- .trim() || '#080b10'
68
+ const bg = getComputedStyle(document.documentElement)
69
+ .getPropertyValue('--bg-deep').trim() || '#080b10'
82
70
  const scale = Math.min(window.devicePixelRatio || 1, 2)
71
+
83
72
  const pdf = new jsPDF({
84
73
  orientation: 'landscape',
85
74
  unit: 'px',
86
- format: [DEFAULT_PAGE_WIDTH, DEFAULT_PAGE_HEIGHT],
75
+ format: [PAGE_W, PAGE_H],
87
76
  compress: true,
88
77
  hotfixes: ['px_scaling'],
89
78
  })
90
79
 
91
- await waitForFonts()
80
+ if (document.fonts?.ready) await document.fonts.ready
92
81
 
93
82
  try {
94
- for (let index = 0; index < totalSlides; index += 1) {
95
- onProgress?.({ current: index + 1, total: totalSlides })
96
- goTo(index)
83
+ for (let i = 0; i < totalSlides; i++) {
84
+ onProgress?.({ current: i + 1, total: totalSlides })
85
+ goTo(i)
97
86
  await waitForPaint()
98
- await wait(EXPORT_TRANSITION_MS)
87
+ await wait(SETTLE_MS)
99
88
 
100
- const activeSlide = document.querySelector('.slide.active') || slideNodes[index]
101
- if (!activeSlide) {
102
- throw new Error(`Slide ${index + 1} could not be rendered`)
103
- }
89
+ const active = document.querySelector('.slide.active') || slides[i]
90
+ if (!active) throw new Error(`Slide ${i + 1} not found`)
91
+
92
+ const restore = pauseAnimations(active)
93
+ await waitForPaint()
104
94
 
105
- const canvas = await html2canvas(activeSlide, {
106
- backgroundColor,
107
- useCORS: true,
108
- allowTaint: true,
109
- logging: false,
110
- scale,
111
- width: pageWidth,
112
- height: pageHeight,
113
- windowWidth: window.innerWidth,
114
- windowHeight: window.innerHeight,
115
- })
116
-
117
- const imageData = canvas.toDataURL('image/png')
118
- if (index > 0) {
119
- pdf.addPage([DEFAULT_PAGE_WIDTH, DEFAULT_PAGE_HEIGHT], 'landscape')
95
+ let dataUrl
96
+ try {
97
+ dataUrl = await domToPng(active, {
98
+ width: active.clientWidth || PAGE_W,
99
+ height: active.clientHeight || PAGE_H,
100
+ backgroundColor: bg,
101
+ scale,
102
+ style: {
103
+ // Ensure the captured element is visible and static
104
+ opacity: '1',
105
+ transform: 'none',
106
+ transition: 'none',
107
+ },
108
+ })
109
+ } finally {
110
+ restore()
120
111
  }
121
112
 
122
- pdf.addImage(imageData, 'PNG', 0, 0, DEFAULT_PAGE_WIDTH, DEFAULT_PAGE_HEIGHT, undefined, 'FAST')
113
+ if (i > 0) pdf.addPage([PAGE_W, PAGE_H], 'landscape')
114
+ pdf.addImage(dataUrl, 'PNG', 0, 0, PAGE_W, PAGE_H, undefined, 'FAST')
123
115
  }
124
116
  } finally {
125
117
  goTo(current)
126
118
  await waitForPaint()
127
119
  }
128
120
 
121
+ // Direct download — no dialog
129
122
  const blob = pdf.output('blob')
130
- const fileName = getExportFileName({ project, selectedCustomer })
131
- triggerDownload(blob, fileName)
132
-
133
- return { fileName }
134
- }
123
+ const url = URL.createObjectURL(blob)
124
+ const a = document.createElement('a')
125
+ a.href = url
126
+ a.download = buildFileName({ project, selectedCustomer })
127
+ document.body.appendChild(a)
128
+ a.click()
129
+ a.remove()
130
+ setTimeout(() => URL.revokeObjectURL(url), 1000)
131
+
132
+ return { fileName: a.download }
133
+ }
@@ -10,12 +10,13 @@ applyTo: "**/slides/**/*.module.css"
10
10
  ```css
11
11
  .mySlide {
12
12
  background: var(--bg-deep);
13
- flex-direction: column;
14
13
  padding: 0 0 44px 0; /* reserve BottomBar height */
15
14
  }
16
15
  ```
17
16
 
18
- Add `justify-content: center` for cover or thank-you slides.
17
+ The engine's `.slide` class already sets `flex-direction: column`, `justify-content: center`, and `overflow: hidden`. The engine also sets `flex-grow: 0` on all direct slide children, so **content stays at its natural height and is vertically centered by default** — building from the center outward. No scrolling is allowed.
18
+
19
+ For dense slides that need top-alignment, override with `justify-content: flex-start`.
19
20
 
20
21
  ## Orb positioning recipe
21
22
 
@@ -40,12 +41,12 @@ Add `justify-content: center` for cover or thank-you slides.
40
41
  z-index: 10;
41
42
  display: flex;
42
43
  flex-direction: column;
43
- justify-content: center;
44
- flex: 1;
45
- min-height: 0;
44
+ gap: 24px;
46
45
  }
47
46
  ```
48
47
 
48
+ > **Do NOT add `flex: 1` or `flex-grow: 1`** to the body wrapper or any direct slide child — it stretches the wrapper to fill the slide and defeats the engine's built-in vertical centering. The engine sets `flex-grow: 0` on all direct slide children to ensure content builds from the center outward. Inner elements within the body wrapper should also avoid `flex: 1` unless they genuinely need to fill remaining space within the body.
49
+
49
50
  ## Theme variables (always use these, never hard-code colors)
50
51
 
51
52
  | Variable | Value |
@@ -89,3 +90,7 @@ Add `justify-content: center` for cover or thank-you slides.
89
90
  | Subtitle | `17px` | 300–400 | — |
90
91
  | Body | `13px–14px` | 400 | — |
91
92
  | Badge | `10px–11px` | 600–700 | `1.5px` |
93
+
94
+ ## Content density limits
95
+
96
+ Slides must never overflow the viewport. The engine shows a **red dashed border warning** in dev mode when content exceeds the slide bounds. When content doesn't fit, split across multiple slides rather than cramming. A presentation with more slides is better than one with clipped content.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deckio/deck-engine",
3
- "version": "1.7.7",
3
+ "version": "1.8.0",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "registry": "https://registry.npmjs.org",
@@ -35,8 +35,8 @@
35
35
  "**/*.css"
36
36
  ],
37
37
  "dependencies": {
38
- "html2canvas": "^1.4.1",
39
- "jspdf": "^3.0.3"
38
+ "jspdf": "^3.0.3",
39
+ "modern-screenshot": "^4.6.8"
40
40
  },
41
41
  "peerDependencies": {
42
42
  "react": "^19.1.0",
@@ -59,16 +59,16 @@ Create a companion `.module.css` file matching the JSX filename (e.g., `MyNewSli
59
59
  ```css
60
60
  .myNewSlide {
61
61
  background: var(--bg-deep);
62
- flex-direction: column;
63
62
  padding: 0 0 44px 0;
64
63
  }
65
64
  ```
66
65
 
67
66
  - `background: var(--bg-deep)` — dark background on every slide
68
- - `flex-direction: column` — global `.slide` sets `display: flex`; this orients content vertically
69
67
  - `padding: 0 0 44px 0` — reserves space for the 44px BottomBar
70
68
 
71
- Optional: add `justify-content: center` to vertically center content (cover slides, thank-you slides).
69
+ The engine's `.slide` class provides `flex-direction: column`, `justify-content: center`, `align-items: stretch`, and `overflow: hidden` by default. It also sets `flex-grow: 0` on all direct slide children, so **content stays at its natural height and is vertically centered by default** — building from the center outward. No scrolling is allowed.
70
+
71
+ For dense slides that need top-alignment, override with `justify-content: flex-start`.
72
72
 
73
73
  ### Orb positioning (standard recipe)
74
74
 
@@ -85,7 +85,7 @@ Optional: add `justify-content: center` to vertically center content (cover slid
85
85
  }
86
86
  ```
87
87
 
88
- ### Vertical centering body wrapper
88
+ ### Body wrapper
89
89
 
90
90
  ```css
91
91
  .body {
@@ -93,12 +93,12 @@ Optional: add `justify-content: center` to vertically center content (cover slid
93
93
  z-index: 10;
94
94
  display: flex;
95
95
  flex-direction: column;
96
- justify-content: center;
97
- flex: 1;
98
- min-height: 0;
96
+ gap: 24px;
99
97
  }
100
98
  ```
101
99
 
100
+ > **Do NOT add `flex: 1` or `flex-grow: 1`** to the body wrapper or any direct slide child — it stretches the wrapper to fill the slide and defeats the engine's built-in vertical centering. Inner elements within the body should also avoid `flex: 1` unless they genuinely need to fill remaining space within the body.
101
+
102
102
  ### Available CSS custom properties
103
103
 
104
104
  ```
@@ -190,7 +190,23 @@ export default {
190
190
 
191
191
  ---
192
192
 
193
- ## F. Anti-Patterns to Avoid
193
+ ## F. Content Density Limits
194
+
195
+ Slides must never overflow the viewport. The engine shows a **red dashed border warning** in dev mode when content exceeds the slide bounds. Follow these limits:
196
+
197
+ | Layout | Max items | Notes |
198
+ |--------|-----------|-------|
199
+ | Cards (3-col grid) | 6 (2 rows) | Reduce card padding if tight |
200
+ | Cards (2-col grid) | 4 (2 rows) | Preferred for detailed cards |
201
+ | Timeline / event list | 3–4 items | Use compact card height for 4 |
202
+ | Bullet points | 6–8 | Depends on line length |
203
+ | Full-width content blocks | 2–3 | E.g. quote + detail section |
204
+
205
+ **When content exceeds limits**, split across multiple slides rather than cramming.
206
+
207
+ ---
208
+
209
+ ## G. Anti-Patterns to Avoid
194
210
 
195
211
  1. **Missing `accent-bar`** — include on every slide.
196
212
  2. **Missing `content-frame content-gutter`** — content will be full-width without standard margins.
@@ -198,10 +214,13 @@ export default {
198
214
  4. **String paths for images** — always use `import logo from '../data/...'` (Vite resolves to URL).
199
215
  5. **Missing `padding: 0 0 44px 0`** on the slide root CSS class — content will overlap the BottomBar.
200
216
  6. **Inconsistent `BottomBar text`** — check existing slides and match their footer text.
217
+ 7. **Using `flex: 1` on body wrapper** — defeats vertical centering; the body should size to its content.
218
+ 8. **Adding `flex-direction: column` on slide root** — already provided by the engine's `.slide` class.
219
+ 9. **Overloading a slide** — if the dev server shows a red dashed border, the slide has too much content. Split into multiple slides.
201
220
 
202
221
  ---
203
222
 
204
- ## G. Complete Step-by-Step
223
+ ## H. Complete Step-by-Step
205
224
 
206
225
  1. **Create** `src/slides/<SlideName>Slide.jsx` following the mandatory skeleton (section A).
207
226
  2. **Create** `src/slides/<SlideName>Slide.module.css` with required root properties (section B).
@@ -211,7 +230,7 @@ export default {
211
230
  ### Quick checklist
212
231
 
213
232
  - [ ] Created `<SlideName>Slide.jsx` with Slide, accent-bar, orbs, content-frame, BottomBar
214
- - [ ] Created `<SlideName>Slide.module.css` with `background: var(--bg-deep)`, `flex-direction: column`, `padding: 0 0 44px 0`, body centering wrapper
233
+ - [ ] Created `<SlideName>Slide.module.css` with `background: var(--bg-deep)`, `padding: 0 0 44px 0`, body wrapper (no `flex: 1`)
215
234
  - [ ] Import added to `deck.config.js`
216
235
  - [ ] Component added to `slides` array at correct position
217
236
  - [ ] `BottomBar text` matches project convention
@@ -35,8 +35,9 @@ For each slide `.jsx` file in `src/slides/`, verify:
35
35
 
36
36
  For each `.module.css` file, verify the root class has:
37
37
  - [ ] `background: var(--bg-deep)`
38
- - [ ] `flex-direction: column`
39
38
  - [ ] `padding: 0 0 44px 0`
39
+ - [ ] Does NOT use `flex: 1` on the body wrapper (defeats vertical centering)
40
+ - [ ] Does NOT redundantly set `flex-direction: column` (inherited from engine `.slide` class)
40
41
 
41
42
  ---
42
43
 
@@ -76,5 +77,5 @@ Summarize findings:
76
77
  - [ ] Every `.jsx` slide has a companion `.module.css`
77
78
  - [ ] All slides have accent-bar, content-frame, BottomBar
78
79
  - [ ] BottomBar text is consistent across the project
79
- - [ ] CSS root classes have required properties
80
+ - [ ] CSS root classes have required properties (`background`, `padding`) and no `flex: 1` on body wrapper
80
81
  - [ ] Project metadata (id, title, subtitle, icon, accent) is present
package/styles/global.css CHANGED
@@ -40,6 +40,9 @@ html, body, #root {
40
40
  position: absolute;
41
41
  inset: 0;
42
42
  display: flex;
43
+ flex-direction: column;
44
+ justify-content: center;
45
+ align-items: stretch;
43
46
  opacity: 0;
44
47
  pointer-events: none;
45
48
  transition: opacity 0.6s cubic-bezier(0.4, 0, 0.2, 1),
@@ -47,6 +50,9 @@ html, body, #root {
47
50
  transform: translateX(60px);
48
51
  overflow: hidden;
49
52
  }
53
+ .slide > * {
54
+ flex-grow: 0;
55
+ }
50
56
  .slide.active {
51
57
  opacity: 1;
52
58
  pointer-events: auto;
@@ -57,6 +63,24 @@ html, body, #root {
57
63
  transform: translateX(-60px);
58
64
  }
59
65
 
66
+ /* ── Dev-mode overflow warning ── */
67
+ .slide-overflow-warn {
68
+ position: absolute;
69
+ inset: 0;
70
+ border: 3px dashed #f85149;
71
+ pointer-events: none;
72
+ z-index: 9999;
73
+ display: flex;
74
+ align-items: flex-end;
75
+ justify-content: center;
76
+ padding-bottom: 56px;
77
+ background: rgba(248, 81, 73, 0.04);
78
+ font-size: 13px;
79
+ font-weight: 600;
80
+ color: #f85149;
81
+ letter-spacing: 0.3px;
82
+ }
83
+
60
84
  /* ── Shared Decorations ── */
61
85
  .orb {
62
86
  position: absolute;
@@ -98,11 +122,7 @@ html, body, #root {
98
122
  ══════════════════════════════════════════════════ */
99
123
  .deck-ty {
100
124
  background: var(--bg-deep);
101
- flex-direction: column;
102
- align-items: stretch;
103
- justify-content: center;
104
125
  padding: 0 0 44px 0;
105
- overflow: hidden;
106
126
  }
107
127
 
108
128
  /* Ambient glow orbs */