@deckio/deck-engine 0.1.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.
@@ -0,0 +1,9 @@
1
+ import styles from './BottomBar.module.css'
2
+
3
+ export default function BottomBar({ text }) {
4
+ return (
5
+ <div className={styles.bar}>
6
+ <span>{text || <>GitHub Copilot &nbsp;&middot;&nbsp; Reimagine Software Development</>}</span>
7
+ </div>
8
+ )
9
+ }
@@ -0,0 +1,17 @@
1
+ .bar {
2
+ position: absolute;
3
+ bottom: 0; left: 0; right: 0;
4
+ height: var(--space-11);
5
+ background: var(--background-overlay);
6
+ backdrop-filter: blur(12px);
7
+ border-top: 1px solid var(--border);
8
+ display: flex;
9
+ align-items: center;
10
+ justify-content: space-between;
11
+ padding: 0 var(--space-10);
12
+ font-size: var(--font-size-xs);
13
+ letter-spacing: var(--letter-spacing-wider);
14
+ text-transform: uppercase;
15
+ color: var(--muted-foreground);
16
+ z-index: var(--z-bar);
17
+ }
@@ -0,0 +1,195 @@
1
+ import { useSlides } from '../context/SlideContext'
2
+ import styles from './Navigation.module.css'
3
+ import { useState, useEffect, useRef } from 'react'
4
+ import { exportDeckPdf } from './exportDeckPdf.js'
5
+ import { exportDeckPptx } from './exportDeckPptx.js'
6
+
7
+ function resolveProp(value, context) {
8
+ return typeof value === 'function' ? value(context) : value
9
+ }
10
+
11
+ export default function Navigation({ pdfPath = null, pdfLabel = 'Deck PDF' }) {
12
+ const { current, totalSlides, go, goTo, selectedCustomer, project } = useSlides()
13
+ const [hintVisible, setHintVisible] = useState(true)
14
+ const [idle, setIdle] = useState(false)
15
+ const [isExporting, setIsExporting] = useState(false)
16
+ const [exportStatus, setExportStatus] = useState('PDF')
17
+ const [exportMenuOpen, setExportMenuOpen] = useState(false)
18
+ const exportMenuRef = useRef(null)
19
+ const timerRef = useRef(null)
20
+
21
+ useEffect(() => {
22
+ const t = setTimeout(() => setHintVisible(false), 5000)
23
+ return () => clearTimeout(t)
24
+ }, [])
25
+
26
+ useEffect(() => {
27
+ const resetIdle = () => {
28
+ setIdle(false)
29
+ clearTimeout(timerRef.current)
30
+ timerRef.current = setTimeout(() => setIdle(true), 2000)
31
+ }
32
+ resetIdle()
33
+ window.addEventListener('mousemove', resetIdle)
34
+ window.addEventListener('mousedown', resetIdle)
35
+ return () => {
36
+ window.removeEventListener('mousemove', resetIdle)
37
+ window.removeEventListener('mousedown', resetIdle)
38
+ clearTimeout(timerRef.current)
39
+ }
40
+ }, [])
41
+
42
+ // Close export menu when clicking outside
43
+ useEffect(() => {
44
+ if (!exportMenuOpen) return
45
+ const handleClickOutside = (e) => {
46
+ if (exportMenuRef.current && !exportMenuRef.current.contains(e.target)) {
47
+ setExportMenuOpen(false)
48
+ }
49
+ }
50
+ document.addEventListener('mousedown', handleClickOutside)
51
+ return () => document.removeEventListener('mousedown', handleClickOutside)
52
+ }, [exportMenuOpen])
53
+
54
+ const progress = ((current + 1) / totalSlides) * 100
55
+ const navigationState = { current, totalSlides, selectedCustomer, project }
56
+ const resolvedPdfPath = resolveProp(pdfPath, navigationState)
57
+ const resolvedPdfLabel = resolveProp(pdfLabel, navigationState) || 'Deck PDF'
58
+
59
+ async function handleExport(format) {
60
+ if (isExporting) return
61
+ setExportMenuOpen(false)
62
+ setIsExporting(true)
63
+ setExportStatus('Preparing')
64
+
65
+ const exportFn = format === 'pptx' ? exportDeckPptx : exportDeckPdf
66
+ const label = format === 'pptx' ? 'PPTX' : 'PDF'
67
+
68
+ try {
69
+ await exportFn({
70
+ current,
71
+ goTo,
72
+ project,
73
+ selectedCustomer,
74
+ totalSlides,
75
+ onProgress: ({ current: slideNumber, total }) => {
76
+ setExportStatus(`${slideNumber}/${total}`)
77
+ },
78
+ })
79
+ setExportStatus('Done')
80
+ } catch (error) {
81
+ console.error(`${label} export failed`, error)
82
+ setExportStatus('Error')
83
+ } finally {
84
+ window.setTimeout(() => {
85
+ setIsExporting(false)
86
+ setExportStatus('PDF')
87
+ }, 1200)
88
+ }
89
+ }
90
+
91
+ return (
92
+ <div className={`${styles.navWrapper} ${idle ? styles.navHidden : ''}`}>
93
+ <div className={styles.progressTrack}>
94
+ <div className={styles.progressFill} style={{ width: `${progress}%` }} />
95
+ </div>
96
+
97
+ {current !== 0 && (
98
+ <button
99
+ className={styles.homeBtn}
100
+ onClick={() => goTo(0)}
101
+ title="Back to home"
102
+ >
103
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
104
+ <path d="M3 12l9-9 9 9" />
105
+ <path d="M9 21V9h6v12" />
106
+ </svg>
107
+ </button>
108
+ )}
109
+
110
+ <div className={styles.exportGroup} ref={exportMenuRef}>
111
+ {resolvedPdfPath ? (
112
+ <a
113
+ className={styles.exportBtn}
114
+ href={resolvedPdfPath}
115
+ target="_blank"
116
+ rel="noopener noreferrer"
117
+ title={resolvedPdfLabel}
118
+ >
119
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
120
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
121
+ <polyline points="14 2 14 8 20 8" />
122
+ <line x1="16" y1="13" x2="8" y2="13" />
123
+ <line x1="16" y1="17" x2="8" y2="17" />
124
+ <polyline points="10 9 9 9 8 9" />
125
+ </svg>
126
+ <span className={styles.exportLabel}>PDF</span>
127
+ </a>
128
+ ) : (
129
+ <button
130
+ className={`${styles.exportBtn} ${isExporting ? styles.exportBtnBusy : ''}`}
131
+ type="button"
132
+ onClick={() => isExporting ? null : setExportMenuOpen(!exportMenuOpen)}
133
+ disabled={isExporting}
134
+ title={isExporting ? 'Exporting...' : 'Export deck'}
135
+ >
136
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
137
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
138
+ <polyline points="14 2 14 8 20 8" />
139
+ <path d="M12 12v6" />
140
+ <path d="M9 15l3 3 3-3" />
141
+ <path d="M8 10h8" />
142
+ </svg>
143
+ <span className={styles.exportLabel}>{isExporting ? exportStatus : '⬇'}</span>
144
+ </button>
145
+ )}
146
+
147
+ {exportMenuOpen && !isExporting && (
148
+ <div className={styles.exportMenu}>
149
+ <button className={styles.exportMenuItem} onClick={() => handleExport('pdf')}>
150
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" width="14" height="14">
151
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
152
+ <polyline points="14 2 14 8 20 8" />
153
+ </svg>
154
+ PDF
155
+ </button>
156
+ <button className={styles.exportMenuItem} onClick={() => handleExport('pptx')}>
157
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" width="14" height="14">
158
+ <rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
159
+ <path d="M8 21h8" />
160
+ <path d="M12 17v4" />
161
+ </svg>
162
+ PowerPoint
163
+ </button>
164
+ </div>
165
+ )}
166
+ </div>
167
+
168
+ <button
169
+ className={`${styles.navBtn} ${styles.prev}`}
170
+ disabled={current === 0}
171
+ onClick={() => go(-1)}
172
+ >
173
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
174
+ <polyline points="15 18 9 12 15 6" />
175
+ </svg>
176
+ </button>
177
+ <button
178
+ className={`${styles.navBtn} ${styles.next}`}
179
+ disabled={current === totalSlides - 1}
180
+ onClick={() => go(1)}
181
+ >
182
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
183
+ <polyline points="9 6 15 12 9 18" />
184
+ </svg>
185
+ </button>
186
+
187
+ {hintVisible && (
188
+ <div className={styles.keyHint}>
189
+ <span className={styles.kbd}>&larr;</span>
190
+ <span className={styles.kbd}>&rarr;</span> or click arrows to navigate
191
+ </div>
192
+ )}
193
+ </div>
194
+ )
195
+ }
@@ -0,0 +1,210 @@
1
+ .navWrapper {
2
+ transition: opacity var(--transition-slow);
3
+ opacity: 1;
4
+ }
5
+
6
+ .navHidden {
7
+ opacity: 0;
8
+ pointer-events: none;
9
+ }
10
+
11
+ .progressTrack {
12
+ position: fixed;
13
+ top: 0; left: 0; right: 0;
14
+ height: 3px;
15
+ background: var(--border-subtle);
16
+ z-index: var(--z-progress);
17
+ }
18
+
19
+ .progressFill {
20
+ height: 100%;
21
+ background: linear-gradient(90deg, var(--purple), var(--accent), var(--cyan));
22
+ transition: width var(--duration-progress) var(--ease-slide);
23
+ border-radius: 0 2px 2px 0;
24
+ box-shadow: 0 0 12px var(--accent);
25
+ }
26
+
27
+ .homeBtn {
28
+ position: fixed;
29
+ top: var(--space-4);
30
+ left: var(--space-5);
31
+ z-index: var(--z-nav);
32
+ width: 36px; height: 36px;
33
+ border-radius: var(--radius-lg);
34
+ border: 1px solid var(--border);
35
+ background: var(--surface-overlay);
36
+ backdrop-filter: blur(8px);
37
+ color: var(--foreground);
38
+ cursor: pointer;
39
+ display: flex;
40
+ align-items: center;
41
+ justify-content: center;
42
+ transition: all var(--transition-base);
43
+ opacity: 0.45;
44
+ }
45
+ .homeBtn:hover {
46
+ opacity: 1;
47
+ border-color: var(--accent);
48
+ background: var(--glow-primary);
49
+ }
50
+ .homeBtn svg {
51
+ width: 16px; height: 16px;
52
+ }
53
+
54
+ .exportBtn {
55
+ position: fixed;
56
+ top: var(--space-4);
57
+ right: var(--space-5);
58
+ z-index: var(--z-nav);
59
+ height: 36px;
60
+ min-width: 36px;
61
+ padding: 0 var(--space-3);
62
+ border-radius: var(--radius-lg);
63
+ border: 1px solid var(--border);
64
+ background: var(--surface-overlay);
65
+ backdrop-filter: blur(8px);
66
+ color: var(--foreground);
67
+ cursor: pointer;
68
+ display: flex;
69
+ align-items: center;
70
+ justify-content: center;
71
+ gap: var(--space-1-5);
72
+ transition: all var(--transition-base);
73
+ opacity: 0.45;
74
+ text-decoration: none;
75
+ font-family: inherit;
76
+ }
77
+ .exportBtn:disabled {
78
+ cursor: wait;
79
+ }
80
+ .exportBtn:hover {
81
+ opacity: 1;
82
+ border-color: var(--accent);
83
+ background: var(--glow-primary);
84
+ }
85
+ .exportBtnBusy {
86
+ opacity: 1;
87
+ border-color: var(--accent);
88
+ background: var(--glow-primary-strong);
89
+ box-shadow: 0 0 0 1px var(--glow-ring), 0 0 24px var(--glow-primary-shadow);
90
+ }
91
+ .exportBtn svg {
92
+ width: 16px; height: 16px;
93
+ }
94
+ .exportLabel {
95
+ font-size: var(--font-size-xs);
96
+ font-weight: var(--font-weight-semibold);
97
+ letter-spacing: 0.04em;
98
+ text-transform: uppercase;
99
+ }
100
+
101
+ .exportGroup {
102
+ position: fixed;
103
+ top: var(--space-4);
104
+ right: var(--space-5);
105
+ z-index: var(--z-nav);
106
+ }
107
+ .exportGroup .exportBtn {
108
+ position: static;
109
+ }
110
+
111
+ .exportMenu {
112
+ position: absolute;
113
+ top: calc(100% + var(--space-1-5));
114
+ right: 0;
115
+ min-width: 140px;
116
+ border-radius: var(--radius-lg);
117
+ border: 1px solid var(--border);
118
+ background: var(--surface-overlay-heavy);
119
+ backdrop-filter: blur(12px);
120
+ padding: var(--space-1);
121
+ display: flex;
122
+ flex-direction: column;
123
+ gap: var(--space-0-5);
124
+ box-shadow: var(--shadow-elevated);
125
+ animation: menuFadeIn var(--transition-fast);
126
+ }
127
+
128
+ @keyframes menuFadeIn {
129
+ from { opacity: 0; transform: translateY(-4px); }
130
+ to { opacity: 1; transform: translateY(0); }
131
+ }
132
+
133
+ .exportMenuItem {
134
+ display: flex;
135
+ align-items: center;
136
+ gap: var(--space-2);
137
+ padding: var(--space-2) var(--space-3);
138
+ border: none;
139
+ border-radius: var(--radius-md);
140
+ background: transparent;
141
+ color: var(--foreground);
142
+ font-size: var(--font-size-sm);
143
+ font-weight: var(--font-weight-medium);
144
+ font-family: inherit;
145
+ cursor: pointer;
146
+ transition: background var(--transition-fast);
147
+ white-space: nowrap;
148
+ }
149
+ .exportMenuItem:hover {
150
+ background: var(--glow-primary);
151
+ }
152
+ .exportMenuItem svg {
153
+ width: 14px; height: 14px;
154
+ flex-shrink: 0;
155
+ }
156
+
157
+ .navBtn {
158
+ position: fixed;
159
+ top: 50%;
160
+ transform: translateY(-50%);
161
+ z-index: var(--z-nav);
162
+ width: var(--space-12); height: var(--space-12);
163
+ border-radius: var(--radius-full);
164
+ border: 1px solid var(--border);
165
+ background: var(--surface-overlay);
166
+ backdrop-filter: blur(8px);
167
+ color: var(--foreground);
168
+ cursor: pointer;
169
+ display: flex;
170
+ align-items: center;
171
+ justify-content: center;
172
+ transition: all var(--transition-base);
173
+ opacity: 0.5;
174
+ }
175
+ .navBtn:hover { opacity: 1; border-color: var(--accent); background: var(--glow-primary); }
176
+ .prev { left: var(--space-5); }
177
+ .next { right: var(--space-5); }
178
+ .navBtn svg { width: var(--space-5); height: var(--space-5); }
179
+ .navBtn:disabled { opacity: 0.15; cursor: default; pointer-events: none; }
180
+
181
+ .keyHint {
182
+ position: fixed;
183
+ bottom: var(--space-14); right: var(--space-10);
184
+ z-index: var(--z-nav);
185
+ display: flex;
186
+ align-items: center;
187
+ gap: var(--space-1-5);
188
+ font-size: var(--font-size-xs);
189
+ color: var(--muted-foreground);
190
+ opacity: 0.5;
191
+ animation: fadeOut 3s 5s forwards;
192
+ }
193
+
194
+ @keyframes fadeOut {
195
+ to { opacity: 0; }
196
+ }
197
+
198
+ .kbd {
199
+ display: inline-flex;
200
+ align-items: center;
201
+ justify-content: center;
202
+ min-width: 22px; height: 22px;
203
+ padding: 0 5px;
204
+ border: 1px solid var(--border);
205
+ border-radius: var(--radius-sm);
206
+ background: var(--secondary);
207
+ font-size: var(--font-size-2xs);
208
+ font-weight: var(--font-weight-semibold);
209
+ font-family: inherit;
210
+ }
@@ -0,0 +1,43 @@
1
+ import { useRef, useEffect, useState } from 'react'
2
+ import { useSlides } from '../context/SlideContext'
3
+
4
+ const DEV = typeof import.meta !== 'undefined' && import.meta.env?.DEV
5
+
6
+ export default function Slide({ index, className = '', children }) {
7
+ const { current } = useSlides()
8
+ const ref = useRef(null)
9
+ const [overflow, setOverflow] = useState(false)
10
+
11
+ let stateClass = ''
12
+ if (index === current) stateClass = 'active'
13
+ else if (index < current) stateClass = 'exit-left'
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
+
33
+ return (
34
+ <div ref={ref} className={`slide ${stateClass} ${className}`} data-slide={index}>
35
+ {children}
36
+ {DEV && overflow && (
37
+ <div className="slide-overflow-warn">
38
+ ⚠ Content overflows slide — reduce content or use smaller elements
39
+ </div>
40
+ )}
41
+ </div>
42
+ )
43
+ }
@@ -0,0 +1,142 @@
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))
17
+
18
+ async function waitForPaint() {
19
+ await new Promise((r) => requestAnimationFrame(() => r()))
20
+ await new Promise((r) => requestAnimationFrame(() => r()))
21
+ }
22
+
23
+ function sanitize(v) {
24
+ return String(v || 'deck').trim().toLowerCase()
25
+ .replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'deck'
26
+ }
27
+
28
+ function buildFileName({ project, selectedCustomer }) {
29
+ const base = selectedCustomer
30
+ ? `${selectedCustomer} ${document.title || project || 'deck'}`
31
+ : document.title || project || 'deck'
32
+ return `${sanitize(base)}.pdf`
33
+ }
34
+
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]() }
48
+ }
49
+
50
+ export async function exportDeckPdf({
51
+ current,
52
+ goTo,
53
+ project,
54
+ selectedCustomer,
55
+ totalSlides,
56
+ onProgress,
57
+ }) {
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')
61
+
62
+ // Dynamic imports — tree-shaken, only loaded on export
63
+ const [{ domToPng }, { jsPDF }] = await Promise.all([
64
+ import('modern-screenshot'),
65
+ import('jspdf'),
66
+ ])
67
+
68
+ const bg = getComputedStyle(document.documentElement)
69
+ .getPropertyValue('--background').trim() || '#080b10'
70
+ const scale = Math.min(window.devicePixelRatio || 1, 2)
71
+
72
+ const pdf = new jsPDF({
73
+ orientation: 'landscape',
74
+ unit: 'px',
75
+ format: [PAGE_W, PAGE_H],
76
+ compress: true,
77
+ hotfixes: ['px_scaling'],
78
+ })
79
+
80
+ if (document.fonts?.ready) await document.fonts.ready
81
+
82
+ // Force deck to canonical PDF dimensions so slides render at exactly
83
+ // PAGE_W × PAGE_H regardless of the current viewport size.
84
+ const origDeckCss = deck.style.cssText
85
+ deck.style.width = `${PAGE_W}px`
86
+ deck.style.height = `${PAGE_H}px`
87
+ await waitForPaint()
88
+ await wait(SETTLE_MS)
89
+
90
+ try {
91
+ for (let i = 0; i < totalSlides; i++) {
92
+ onProgress?.({ current: i + 1, total: totalSlides })
93
+ goTo(i)
94
+ await waitForPaint()
95
+ await wait(SETTLE_MS)
96
+
97
+ const active = document.querySelector('.slide.active') || slides[i]
98
+ if (!active) throw new Error(`Slide ${i + 1} not found`)
99
+
100
+ const restore = pauseAnimations(active)
101
+ await waitForPaint()
102
+
103
+ let dataUrl
104
+ try {
105
+ dataUrl = await domToPng(active, {
106
+ width: PAGE_W,
107
+ height: PAGE_H,
108
+ backgroundColor: bg,
109
+ scale,
110
+ style: {
111
+ // Ensure the captured element is visible and static
112
+ opacity: '1',
113
+ transform: 'none',
114
+ transition: 'none',
115
+ },
116
+ })
117
+ } finally {
118
+ restore()
119
+ }
120
+
121
+ if (i > 0) pdf.addPage([PAGE_W, PAGE_H], 'landscape')
122
+ pdf.addImage(dataUrl, 'PNG', 0, 0, PAGE_W, PAGE_H, undefined, 'FAST')
123
+ }
124
+ } finally {
125
+ deck.style.cssText = origDeckCss
126
+ goTo(current)
127
+ await waitForPaint()
128
+ }
129
+
130
+ // Direct download — no dialog
131
+ const blob = pdf.output('blob')
132
+ const url = URL.createObjectURL(blob)
133
+ const a = document.createElement('a')
134
+ a.href = url
135
+ a.download = buildFileName({ project, selectedCustomer })
136
+ document.body.appendChild(a)
137
+ a.click()
138
+ a.remove()
139
+ setTimeout(() => URL.revokeObjectURL(url), 1000)
140
+
141
+ return { fileName: a.download }
142
+ }