@dfosco/storyboard-react 4.0.0-beta.28 → 4.0.0-beta.29

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.
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard-react",
3
- "version": "4.0.0-beta.28",
3
+ "version": "4.0.0-beta.29",
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@dfosco/storyboard-core": "4.0.0-beta.28",
7
- "@dfosco/tiny-canvas": "4.0.0-beta.28",
6
+ "@dfosco/storyboard-core": "4.0.0-beta.29",
7
+ "@dfosco/tiny-canvas": "4.0.0-beta.29",
8
8
  "@neodrag/react": "^2.3.1",
9
9
  "glob": "^11.0.0",
10
10
  "jsonc-parser": "^3.3.1",
@@ -0,0 +1,291 @@
1
+ /**
2
+ * CodePen embed widget for canvas.
3
+ *
4
+ * Behaves like FigmaEmbed: click-to-interact overlay, iframe kept alive
5
+ * after deselect, expand modal, open-external action. Created via paste
6
+ * when a CodePen URL is pasted onto the canvas.
7
+ */
8
+ import { forwardRef, useImperativeHandle, useMemo, useCallback, useState, useEffect, useRef } from 'react'
9
+ import { createPortal } from 'react-dom'
10
+ import WidgetWrapper from './WidgetWrapper.jsx'
11
+ import { readProp } from './widgetProps.js'
12
+ import { schemas } from './widgetConfig.js'
13
+ import { isCodePenUrl, toCodePenEmbedUrl, getCodePenTitle, fetchCodePenMeta } from './codepenUrl.js'
14
+ import { useIframeDevLogs } from './iframeDevLogs.js'
15
+ import styles from './CodePenEmbed.module.css'
16
+ import overlayStyles from './embedOverlay.module.css'
17
+
18
+ const codepenEmbedSchema = schemas['codepen-embed']
19
+
20
+ /** CodePen logo SVG */
21
+ function CodePenLogo({ className }) {
22
+ return (
23
+ <svg className={className} viewBox="0 0 100 100" fill="none" aria-hidden="true">
24
+ <path
25
+ d="M100 34.2c0-.4-.1-.8-.2-1.2 0-.1 0-.2-.1-.3 0-.2-.1-.3-.2-.5 0-.1-.1-.2-.1-.3-.1-.2-.1-.3-.2-.4-.1-.1-.1-.2-.2-.3-.1-.1-.1-.3-.2-.4l-.3-.3-.2-.3c-.1-.1-.2-.2-.3-.2l-.3-.3c-.1-.1-.2-.1-.3-.2l-.3-.3-.4-.2-.6-.3L52.1 2.5c-1.3-.8-2.9-.8-4.2 0L3.2 27.6l-.6.3c-.1.1-.2.1-.4.2l-.3.3c-.1.1-.2.1-.3.2l-.3.3-.3.3-.2.3c-.1.1-.2.3-.2.4-.1.1-.1.2-.2.3-.1.1-.1.3-.2.4 0 .1-.1.2-.1.3-.1.2-.1.3-.2.5 0 .1 0 .2-.1.3-.1.4-.1.8-.2 1.2v31.4c0 .4.1.8.2 1.2 0 .1 0 .2.1.3 0 .2.1.3.2.5 0 .1.1.2.1.3.1.2.1.3.2.4.1.1.1.2.2.3.1.1.1.3.2.4l.3.3.2.3c.1.1.2.2.3.2l.3.3c.1.1.2.1.3.2l.3.3.4.2.6.3 44.7 25.1c.6.4 1.4.5 2.1.5.7 0 1.4-.2 2.1-.5l44.7-25.1.6-.3c.1-.1.2-.1.4-.2l.3-.3c.1-.1.2-.1.3-.2l.3-.3.3-.3.2-.3c.1-.1.2-.3.2-.4.1-.1.1-.2.2-.3.1-.1.1-.3.2-.4 0-.1.1-.2.1-.3.1-.2.1-.3.2-.5 0-.1 0-.2.1-.3.1-.4.1-.8.2-1.2V34.2zm-50-24L88.5 33 73 43.4 54.8 32.2V10.2zM45.2 10.2v22l-18.2 11.2L11.5 33l38.5-22.8zm-40 28.4L18.4 49.8 5.2 60.9V38.6zm40 51.2L6.7 66.6 22.2 56.2l23 14.1v19.5zm4.8-24.3l-16-9.8 16-9.8 16 9.8-16 9.8zm4.8 24.3V70.3l23-14.1 15.5 10.4L54.8 89.8zm40-28.9L81.6 49.8 94.8 38.6v22.3z"
26
+ fill="currentColor"
27
+ />
28
+ </svg>
29
+ )
30
+ }
31
+
32
+ /** Stroke-based code icon for empty state */
33
+ function CodeIcon({ size = 32, className }) {
34
+ return (
35
+ <svg
36
+ className={className}
37
+ width={size}
38
+ height={size}
39
+ viewBox="0 0 24 24"
40
+ fill="none"
41
+ stroke="currentColor"
42
+ strokeWidth="2"
43
+ strokeLinecap="round"
44
+ strokeLinejoin="round"
45
+ aria-hidden="true"
46
+ >
47
+ <polyline points="16 18 22 12 16 6" />
48
+ <polyline points="8 6 2 12 8 18" />
49
+ </svg>
50
+ )
51
+ }
52
+
53
+ export default forwardRef(function CodePenEmbed({ props, onUpdate, resizable }, ref) {
54
+ const url = readProp(props, 'url', codepenEmbedSchema)
55
+ const width = readProp(props, 'width', codepenEmbedSchema)
56
+ const height = readProp(props, 'height', codepenEmbedSchema)
57
+
58
+ const [interactive, setInteractive] = useState(false)
59
+ const [showIframe, setShowIframe] = useState(true)
60
+ const [expanded, setExpanded] = useState(false)
61
+
62
+ const iframeRef = useRef(null)
63
+ const embedRef = useRef(null)
64
+ const inlineContainerRef = useRef(null)
65
+ const modalContainerRef = useRef(null)
66
+ const teardownTimerRef = useRef(null)
67
+ const exitSessionRef = useRef(0)
68
+
69
+ const isValid = useMemo(() => isCodePenUrl(url), [url])
70
+ const embedUrl = useMemo(() => (isValid ? toCodePenEmbedUrl(url) : ''), [url, isValid])
71
+ const fallbackTitle = useMemo(() => (url ? getCodePenTitle(url) : 'CodePen'), [url])
72
+
73
+ // Fetch pen metadata (title + author) from CodePen oEmbed API
74
+ const [penMeta, setPenMeta] = useState(null)
75
+ useEffect(() => {
76
+ if (!url || !isValid) return
77
+ let cancelled = false
78
+ fetchCodePenMeta(url).then((meta) => {
79
+ if (!cancelled && meta) setPenMeta(meta)
80
+ })
81
+ return () => { cancelled = true }
82
+ }, [url, isValid])
83
+
84
+ const headerTitle = penMeta?.title
85
+ ? `${penMeta.title} · ${penMeta.author || fallbackTitle}`
86
+ : fallbackTitle
87
+
88
+ useIframeDevLogs({
89
+ widget: 'CodePenEmbed',
90
+ loaded: showIframe && Boolean(embedUrl),
91
+ src: embedUrl,
92
+ })
93
+
94
+ const enterInteractive = useCallback(() => {
95
+ exitSessionRef.current++
96
+ clearTimeout(teardownTimerRef.current)
97
+ setShowIframe(true)
98
+ setInteractive(true)
99
+ }, [])
100
+
101
+ // Exit interactive mode on click outside — keep iframe alive for 2 min
102
+ useEffect(() => {
103
+ if (!interactive || expanded) return
104
+ function handlePointerDown(e) {
105
+ if (embedRef.current && !embedRef.current.contains(e.target)) {
106
+ setInteractive(false)
107
+ const session = ++exitSessionRef.current
108
+ clearTimeout(teardownTimerRef.current)
109
+ teardownTimerRef.current = setTimeout(() => {
110
+ if (exitSessionRef.current !== session) return
111
+ setShowIframe(false)
112
+ }, 2 * 60 * 1000)
113
+ }
114
+ }
115
+ document.addEventListener('pointerdown', handlePointerDown)
116
+ return () => document.removeEventListener('pointerdown', handlePointerDown)
117
+ }, [interactive, expanded])
118
+
119
+ useEffect(() => () => clearTimeout(teardownTimerRef.current), [])
120
+
121
+ // Close expanded modal on Escape
122
+ useEffect(() => {
123
+ if (!expanded) return
124
+ function handleKeyDown(e) {
125
+ if (e.key === 'Escape') {
126
+ e.stopPropagation()
127
+ setExpanded(false)
128
+ }
129
+ }
130
+ document.addEventListener('keydown', handleKeyDown, true)
131
+ return () => document.removeEventListener('keydown', handleKeyDown, true)
132
+ }, [expanded])
133
+
134
+ // Reparent iframe between inline and modal containers
135
+ useEffect(() => {
136
+ const iframe = iframeRef.current
137
+ if (!iframe) return
138
+
139
+ if (expanded && modalContainerRef.current) {
140
+ iframe._savedClassName = iframe.className
141
+ iframe._savedStyle = iframe.getAttribute('style') || ''
142
+ iframe.className = styles.expandIframe
143
+ iframe.removeAttribute('style')
144
+ const target = modalContainerRef.current
145
+ if (target.moveBefore) {
146
+ target.moveBefore(iframe, target.firstChild)
147
+ } else {
148
+ target.prepend(iframe)
149
+ }
150
+ } else if (!expanded && inlineContainerRef.current) {
151
+ if (iframe._savedClassName !== undefined) {
152
+ iframe.className = iframe._savedClassName
153
+ iframe.setAttribute('style', iframe._savedStyle)
154
+ delete iframe._savedClassName
155
+ delete iframe._savedStyle
156
+ }
157
+ const target = inlineContainerRef.current
158
+ if (target.moveBefore) {
159
+ target.moveBefore(iframe, null)
160
+ } else {
161
+ target.appendChild(iframe)
162
+ }
163
+ }
164
+ }, [expanded])
165
+
166
+ useImperativeHandle(ref, () => ({
167
+ handleAction(actionId) {
168
+ if (actionId === 'open-external') {
169
+ if (url) window.open(url, '_blank', 'noopener')
170
+ } else if (actionId === 'expand') {
171
+ setShowIframe(true)
172
+ setExpanded(true)
173
+ }
174
+ },
175
+ }), [url])
176
+
177
+ return (
178
+ <>
179
+ <WidgetWrapper>
180
+ <div ref={embedRef} className={styles.embed} style={{ width, height }}>
181
+ <div className={styles.header}>
182
+ <CodePenLogo className={styles.codepenLogo} />
183
+ <span className={styles.headerTitle}>{headerTitle}</span>
184
+ </div>
185
+ {embedUrl ? (
186
+ <>
187
+ {showIframe ? (
188
+ <div
189
+ ref={inlineContainerRef}
190
+ className={styles.iframeContainer}
191
+ style={expanded ? { visibility: 'hidden' } : undefined}
192
+ >
193
+ <iframe
194
+ ref={iframeRef}
195
+ src={embedUrl}
196
+ className={styles.iframe}
197
+ title={`CodePen: ${headerTitle}`}
198
+ allowFullScreen
199
+ loading="lazy"
200
+ />
201
+ </div>
202
+ ) : (
203
+ <div className={styles.iframeContainer} />
204
+ )}
205
+ {!interactive && !expanded && (
206
+ <div
207
+ className={overlayStyles.interactOverlay}
208
+ onClick={(e) => {
209
+ if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
210
+ enterInteractive()
211
+ }}
212
+ role="button"
213
+ tabIndex={0}
214
+ onKeyDown={(e) => {
215
+ if (e.key === 'Enter' || e.key === ' ') {
216
+ e.preventDefault()
217
+ e.stopPropagation()
218
+ enterInteractive()
219
+ }
220
+ }}
221
+ aria-label="Click to interact with CodePen embed"
222
+ >
223
+ <span className={overlayStyles.interactHint}>Click to interact</span>
224
+ </div>
225
+ )}
226
+ </>
227
+ ) : (
228
+ <div className={styles.emptyState}>
229
+ <CodeIcon size={32} className={styles.emptyIcon} />
230
+ <span className={styles.emptyLabel}>No CodePen URL</span>
231
+ </div>
232
+ )}
233
+ </div>
234
+ {resizable && (
235
+ <div
236
+ className={styles.resizeHandle}
237
+ onMouseDown={(e) => {
238
+ e.stopPropagation()
239
+ e.preventDefault()
240
+ const startX = e.clientX
241
+ const startY = e.clientY
242
+ const startW = width
243
+ const startH = height
244
+ function onMove(ev) {
245
+ const newW = Math.max(200, startW + ev.clientX - startX)
246
+ const newH = Math.max(150, startH + ev.clientY - startY)
247
+ onUpdate?.({ width: newW, height: newH })
248
+ }
249
+ function onUp() {
250
+ document.removeEventListener('mousemove', onMove)
251
+ document.removeEventListener('mouseup', onUp)
252
+ }
253
+ document.addEventListener('mousemove', onMove)
254
+ document.addEventListener('mouseup', onUp)
255
+ }}
256
+ onPointerDown={(e) => e.stopPropagation()}
257
+ />
258
+ )}
259
+ </WidgetWrapper>
260
+ {createPortal(
261
+ <div
262
+ className={styles.expandBackdrop}
263
+ style={expanded && embedUrl ? undefined : { display: 'none' }}
264
+ onClick={() => setExpanded(false)}
265
+ onPointerDown={(e) => e.stopPropagation()}
266
+ onKeyDown={(e) => {
267
+ e.stopPropagation()
268
+ if (e.key === 'Escape') setExpanded(false)
269
+ }}
270
+ onWheel={(e) => e.stopPropagation()}
271
+ tabIndex={-1}
272
+ ref={(el) => { if (el && expanded) el.focus() }}
273
+ >
274
+ <div
275
+ ref={modalContainerRef}
276
+ className={styles.expandContainer}
277
+ onClick={(e) => e.stopPropagation()}
278
+ >
279
+ <button
280
+ className={styles.expandClose}
281
+ onClick={() => setExpanded(false)}
282
+ aria-label="Close expanded view"
283
+ autoFocus
284
+ >✕</button>
285
+ </div>
286
+ </div>,
287
+ document.body
288
+ )}
289
+ </>
290
+ )
291
+ })
@@ -0,0 +1,161 @@
1
+ .embed {
2
+ position: relative;
3
+ overflow: hidden;
4
+ background: var(--bgColor-default, #ffffff);
5
+ border: 3px solid var(--borderColor-default, #d0d7de);
6
+ border-radius: 12px;
7
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
8
+ }
9
+
10
+ .header {
11
+ display: flex;
12
+ align-items: center;
13
+ gap: 6px;
14
+ padding: 6px 10px;
15
+ font-size: 12px;
16
+ font-weight: 500;
17
+ color: var(--fgColor-muted, #656d76);
18
+ background: var(--bgColor-muted, #f6f8fa);
19
+ border-bottom: 1px solid var(--borderColor-muted, #d8dee4);
20
+ white-space: nowrap;
21
+ overflow: hidden;
22
+ text-overflow: ellipsis;
23
+ user-select: none;
24
+ }
25
+
26
+ .codepenLogo {
27
+ width: 16px;
28
+ height: 16px;
29
+ flex-shrink: 0;
30
+ }
31
+
32
+ .headerTitle {
33
+ overflow: hidden;
34
+ text-overflow: ellipsis;
35
+ }
36
+
37
+ .iframeContainer {
38
+ width: 100%;
39
+ height: calc(100% - 10px);
40
+ overflow: hidden;
41
+ }
42
+
43
+ .iframe {
44
+ width: 100%;
45
+ height: 100%;
46
+ border: none;
47
+ display: block;
48
+ }
49
+
50
+ .resizeHandle {
51
+ position: absolute;
52
+ bottom: 0;
53
+ right: 0;
54
+ width: 16px;
55
+ height: 16px;
56
+ cursor: nwse-resize;
57
+ background: linear-gradient(
58
+ 135deg,
59
+ transparent 40%,
60
+ var(--borderColor-muted, rgba(0, 0, 0, 0.15)) 40%,
61
+ var(--borderColor-muted, rgba(0, 0, 0, 0.15)) 50%,
62
+ transparent 50%,
63
+ transparent 65%,
64
+ var(--borderColor-muted, rgba(0, 0, 0, 0.15)) 65%,
65
+ var(--borderColor-muted, rgba(0, 0, 0, 0.15)) 75%,
66
+ transparent 75%
67
+ );
68
+ opacity: 0;
69
+ transition: opacity 150ms;
70
+ z-index: 2;
71
+ }
72
+
73
+ .embed:hover ~ .resizeHandle,
74
+ .resizeHandle:hover {
75
+ opacity: 1;
76
+ }
77
+
78
+ /* Expand modal */
79
+ .expandBackdrop {
80
+ position: fixed;
81
+ inset: 0;
82
+ z-index: 100000;
83
+ background: rgba(0, 0, 0, 0.8);
84
+ display: flex;
85
+ align-items: center;
86
+ justify-content: center;
87
+ animation: expandFadeIn 0.15s ease;
88
+ }
89
+
90
+ @keyframes expandFadeIn {
91
+ from { opacity: 0; }
92
+ to { opacity: 1; }
93
+ }
94
+
95
+ .expandContainer {
96
+ width: 90vw;
97
+ height: 90vh;
98
+ position: relative;
99
+ border-radius: 12px;
100
+ overflow: hidden;
101
+ background: var(--bgColor-default, #ffffff);
102
+ box-shadow: 0 24px 64px rgba(0, 0, 0, 0.4);
103
+ animation: expandScaleIn 0.2s ease;
104
+ }
105
+
106
+ @keyframes expandScaleIn {
107
+ from { transform: scale(0.95); opacity: 0; }
108
+ to { transform: scale(1); opacity: 1; }
109
+ }
110
+
111
+ .expandIframe {
112
+ border: none;
113
+ display: block;
114
+ width: 100%;
115
+ height: 100%;
116
+ }
117
+
118
+ .expandClose {
119
+ all: unset;
120
+ cursor: pointer;
121
+ position: absolute;
122
+ top: 12px;
123
+ right: 12px;
124
+ width: 32px;
125
+ height: 32px;
126
+ display: flex;
127
+ align-items: center;
128
+ justify-content: center;
129
+ border-radius: 8px;
130
+ background: rgba(0, 0, 0, 0.5);
131
+ color: #ffffff;
132
+ font-size: 16px;
133
+ z-index: 1;
134
+ transition: background 100ms;
135
+ backdrop-filter: blur(4px);
136
+ }
137
+
138
+ .expandClose:hover {
139
+ background: rgba(0, 0, 0, 0.7);
140
+ }
141
+
142
+ .emptyState {
143
+ width: 100%;
144
+ height: calc(100% - 10px);
145
+ display: flex;
146
+ flex-direction: column;
147
+ align-items: center;
148
+ justify-content: center;
149
+ gap: 8px;
150
+ }
151
+
152
+ .emptyIcon {
153
+ color: var(--fgColor-muted, #656d76);
154
+ opacity: 0.5;
155
+ }
156
+
157
+ .emptyLabel {
158
+ color: var(--fgColor-muted, #656d76);
159
+ font-size: 13px;
160
+ font-style: italic;
161
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * CodePen URL utilities — parse, validate, and convert CodePen URLs
3
+ * to their embeddable format.
4
+ *
5
+ * Supported URL formats:
6
+ * https://codepen.io/{user}/pen/{penId}
7
+ * https://codepen.io/{user}/full/{penId}
8
+ * https://codepen.io/{user}/details/{penId}
9
+ * https://codepen.io/{user}/embed/{penId}
10
+ */
11
+
12
+ const CODEPEN_RE = /^https?:\/\/codepen\.io\/([^/]+)\/(pen|full|details|embed)\/([A-Za-z0-9]+)/
13
+
14
+ /**
15
+ * Check if a URL is a valid CodePen pen URL.
16
+ */
17
+ export function isCodePenUrl(url) {
18
+ if (!url) return false
19
+ return CODEPEN_RE.test(url)
20
+ }
21
+
22
+ /**
23
+ * Convert any CodePen pen URL to the embed format.
24
+ * Defaults to showing the result tab with a dark theme.
25
+ */
26
+ export function toCodePenEmbedUrl(url) {
27
+ const m = url?.match(CODEPEN_RE)
28
+ if (!m) return ''
29
+ const [, user, , penId] = m
30
+ return `https://codepen.io/${user}/embed/${penId}?default-tab=result&editable=true`
31
+ }
32
+
33
+ /**
34
+ * Extract a fallback title from a CodePen URL (user/penId).
35
+ */
36
+ export function getCodePenTitle(url) {
37
+ const m = url?.match(CODEPEN_RE)
38
+ if (!m) return 'CodePen'
39
+ return `${m[1]}/${m[3]}`
40
+ }
41
+
42
+ /**
43
+ * Extract the username from a CodePen URL.
44
+ */
45
+ export function getCodePenUser(url) {
46
+ const m = url?.match(CODEPEN_RE)
47
+ return m?.[1] || ''
48
+ }
49
+
50
+ /** In-memory cache for oEmbed results keyed by pen URL. */
51
+ const _oembedCache = new Map()
52
+
53
+ /**
54
+ * Fetch pen metadata (title, author_name) via CodePen's oEmbed API.
55
+ * Returns `{ title, author }` or null on failure. Results are cached.
56
+ */
57
+ export async function fetchCodePenMeta(url) {
58
+ if (!url || !isCodePenUrl(url)) return null
59
+ if (_oembedCache.has(url)) return _oembedCache.get(url)
60
+
61
+ try {
62
+ const endpoint = `https://codepen.io/api/oembed?url=${encodeURIComponent(url)}&format=json`
63
+ const res = await fetch(endpoint)
64
+ if (!res.ok) return null
65
+ const data = await res.json()
66
+ const meta = {
67
+ title: data.title || '',
68
+ author: data.author_name || '',
69
+ }
70
+ _oembedCache.set(url, meta)
71
+ return meta
72
+ } catch {
73
+ return null
74
+ }
75
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Tests for CodePen URL utilities.
3
+ */
4
+ import { describe, it, expect } from 'vitest'
5
+ import { isCodePenUrl, toCodePenEmbedUrl, getCodePenTitle, getCodePenUser } from './codepenUrl.js'
6
+
7
+ describe('isCodePenUrl', () => {
8
+ it('returns true for pen URLs', () => {
9
+ expect(isCodePenUrl('https://codepen.io/Calleb/pen/jEMXgvq')).toBe(true)
10
+ })
11
+
12
+ it('returns true for full view URLs', () => {
13
+ expect(isCodePenUrl('https://codepen.io/Calleb/full/jEMXgvq')).toBe(true)
14
+ })
15
+
16
+ it('returns true for details URLs', () => {
17
+ expect(isCodePenUrl('https://codepen.io/Calleb/details/jEMXgvq')).toBe(true)
18
+ })
19
+
20
+ it('returns true for embed URLs', () => {
21
+ expect(isCodePenUrl('https://codepen.io/Calleb/embed/jEMXgvq')).toBe(true)
22
+ })
23
+
24
+ it('returns false for non-CodePen URLs', () => {
25
+ expect(isCodePenUrl('https://example.com')).toBe(false)
26
+ expect(isCodePenUrl('https://figma.com/design/abc')).toBe(false)
27
+ })
28
+
29
+ it('returns false for CodePen homepage', () => {
30
+ expect(isCodePenUrl('https://codepen.io')).toBe(false)
31
+ expect(isCodePenUrl('https://codepen.io/Calleb')).toBe(false)
32
+ })
33
+
34
+ it('returns false for null/empty', () => {
35
+ expect(isCodePenUrl(null)).toBe(false)
36
+ expect(isCodePenUrl('')).toBe(false)
37
+ })
38
+ })
39
+
40
+ describe('toCodePenEmbedUrl', () => {
41
+ it('converts pen URL to embed format', () => {
42
+ const result = toCodePenEmbedUrl('https://codepen.io/Calleb/pen/jEMXgvq')
43
+ expect(result).toBe('https://codepen.io/Calleb/embed/jEMXgvq?default-tab=result&editable=true')
44
+ })
45
+
46
+ it('converts full URL to embed format', () => {
47
+ const result = toCodePenEmbedUrl('https://codepen.io/Calleb/full/jEMXgvq')
48
+ expect(result).toBe('https://codepen.io/Calleb/embed/jEMXgvq?default-tab=result&editable=true')
49
+ })
50
+
51
+ it('returns empty string for invalid URL', () => {
52
+ expect(toCodePenEmbedUrl('https://example.com')).toBe('')
53
+ expect(toCodePenEmbedUrl(null)).toBe('')
54
+ })
55
+ })
56
+
57
+ describe('getCodePenTitle', () => {
58
+ it('extracts user/penId from URL', () => {
59
+ expect(getCodePenTitle('https://codepen.io/Calleb/pen/jEMXgvq')).toBe('Calleb/jEMXgvq')
60
+ })
61
+
62
+ it('returns "CodePen" for invalid URL', () => {
63
+ expect(getCodePenTitle('https://example.com')).toBe('CodePen')
64
+ expect(getCodePenTitle(null)).toBe('CodePen')
65
+ })
66
+ })
67
+
68
+ describe('getCodePenUser', () => {
69
+ it('extracts username', () => {
70
+ expect(getCodePenUser('https://codepen.io/Calleb/pen/jEMXgvq')).toBe('Calleb')
71
+ })
72
+
73
+ it('returns empty for invalid URL', () => {
74
+ expect(getCodePenUser('https://example.com')).toBe('')
75
+ })
76
+ })
@@ -4,6 +4,7 @@ import PrototypeEmbed from './PrototypeEmbed.jsx'
4
4
  import LinkPreview from './LinkPreview.jsx'
5
5
  import ImageWidget from './ImageWidget.jsx'
6
6
  import FigmaEmbed from './FigmaEmbed.jsx'
7
+ import CodePenEmbed from './CodePenEmbed.jsx'
7
8
  import StoryWidget from './StoryWidget.jsx'
8
9
 
9
10
  /**
@@ -17,6 +18,7 @@ export const widgetRegistry = {
17
18
  'link-preview': LinkPreview,
18
19
  'image': ImageWidget,
19
20
  'figma-embed': FigmaEmbed,
21
+ 'codepen-embed': CodePenEmbed,
20
22
  'story': StoryWidget,
21
23
  }
22
24
 
@@ -157,6 +157,6 @@ export function getWidgetMeta(type) {
157
157
  */
158
158
  export function getMenuWidgetTypes() {
159
159
  return Object.entries(widgetTypes)
160
- .filter(([type]) => type !== 'link-preview' && type !== 'image' && type !== 'figma-embed' && type !== 'story')
160
+ .filter(([type]) => type !== 'link-preview' && type !== 'image' && type !== 'figma-embed' && type !== 'codepen-embed' && type !== 'story')
161
161
  .map(([type, def]) => ({ type, label: def.label, icon: def.icon }))
162
162
  }