design-folio 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,103 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import SimulatedWindow from './SimulatedWindow.jsx';
3
+ import DesignNotes from './DesignNotes.jsx';
4
+ import ScreensToolbar from './ScreensToolbar.jsx';
5
+
6
+ export default function GalleryView({
7
+ states,
8
+ activeKey,
9
+ viewport,
10
+ syncTheme,
11
+ isDark,
12
+ onNavigateToScreen,
13
+ viewMode,
14
+ onToggleView,
15
+ uiHidden,
16
+ onToggleUI,
17
+ children,
18
+ }) {
19
+ const currentIndex = states.findIndex((s) => s.key === activeKey);
20
+ const activeState = states[currentIndex];
21
+ const isFirst = currentIndex <= 0;
22
+ const isLast = currentIndex >= states.length - 1;
23
+
24
+ const [copied, setCopied] = useState(false);
25
+
26
+ function handleCopyLink() {
27
+ navigator.clipboard.writeText(window.location.href);
28
+ setCopied(true);
29
+ setTimeout(() => setCopied(false), 2000);
30
+ }
31
+
32
+ function handlePrev() {
33
+ if (!isFirst) {
34
+ onNavigateToScreen(states[currentIndex - 1].key);
35
+ }
36
+ }
37
+
38
+ function handleNext() {
39
+ if (!isLast) {
40
+ onNavigateToScreen(states[currentIndex + 1].key);
41
+ }
42
+ }
43
+
44
+ // Arrow key navigation
45
+ useEffect(() => {
46
+ function onKeyDown(e) {
47
+ if (e.key === 'ArrowLeft' && currentIndex > 0) {
48
+ onNavigateToScreen(states[currentIndex - 1].key);
49
+ }
50
+ if (e.key === 'ArrowRight' && currentIndex < states.length - 1) {
51
+ onNavigateToScreen(states[currentIndex + 1].key);
52
+ }
53
+ }
54
+ window.addEventListener('keydown', onKeyDown);
55
+ return () => window.removeEventListener('keydown', onKeyDown);
56
+ }, [currentIndex, states, onNavigateToScreen]);
57
+
58
+ if (!activeState) return null;
59
+
60
+ return (
61
+ <section
62
+ className="flex flex-col h-full bg-neutral-50 dark:bg-neutral-950"
63
+ role="tabpanel"
64
+ aria-label="Gallery view"
65
+ >
66
+ {/* Toolbar — fixed at top, not scrollable */}
67
+ <div className="shrink-0 px-5 pt-5">
68
+ <ScreensToolbar
69
+ viewMode={viewMode}
70
+ onToggleView={onToggleView}
71
+ uiHidden={uiHidden}
72
+ onToggleUI={onToggleUI}
73
+ onPrev={handlePrev}
74
+ onNext={handleNext}
75
+ isFirst={isFirst}
76
+ isLast={isLast}
77
+ currentIndex={currentIndex}
78
+ totalScreens={states.length}
79
+ onCopyLink={handleCopyLink}
80
+ copied={copied}
81
+ />
82
+ </div>
83
+
84
+ {/* Scrollable content */}
85
+ <div className="flex-1 overflow-y-auto px-5 pb-5">
86
+ {/* Screen title */}
87
+ <h2 className="text-2xl font-semibold text-neutral-800 dark:text-neutral-200 mb-4 mt-4 shrink-0">
88
+ {activeState.name}
89
+ </h2>
90
+
91
+ {/* Screen + notes share one container so notes match screen width */}
92
+ <div className="max-w-full" style={{ width: 'fit-content' }}>
93
+ <div className="pointer-events-none">
94
+ <SimulatedWindow viewport={viewport} syncTheme={syncTheme} isDark={isDark} fixed>
95
+ {typeof children === 'function' ? children(activeState) : React.isValidElement(children) ? React.cloneElement(children, { appState: activeState.state }) : children}
96
+ </SimulatedWindow>
97
+ </div>
98
+ <DesignNotes notes={activeState.notes} />
99
+ </div>
100
+ </div>
101
+ </section>
102
+ );
103
+ }
@@ -0,0 +1,30 @@
1
+ import ThemeToggle from './ThemeToggle.jsx';
2
+
3
+ function formatDate(dateStr) {
4
+ if (!dateStr) return null;
5
+ const date = new Date(dateStr);
6
+ if (isNaN(date.getTime())) return null;
7
+ return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
8
+ }
9
+
10
+ export default function Header({ name, designer, updatedAt, isDark, onToggleTheme }) {
11
+ const formattedDate = formatDate(updatedAt);
12
+ const subtitleParts = [];
13
+ if (designer) subtitleParts.push(designer);
14
+ if (formattedDate) subtitleParts.push(`Updated ${formattedDate}`);
15
+ const subtitle = subtitleParts.join(' · ');
16
+
17
+ return (
18
+ <header className="flex items-center justify-between px-5 py-3 bg-white dark:bg-neutral-900 border-b border-neutral-300 dark:border-neutral-700">
19
+ <hgroup>
20
+ <h1 className="text-sm font-semibold text-neutral-900 dark:text-white">
21
+ {name || 'Untitled'}
22
+ </h1>
23
+ {subtitle && (
24
+ <p className="text-xs text-neutral-500 dark:text-neutral-400">{subtitle}</p>
25
+ )}
26
+ </hgroup>
27
+ <ThemeToggle isDark={isDark} onToggle={onToggleTheme} />
28
+ </header>
29
+ );
30
+ }
@@ -0,0 +1,20 @@
1
+ import SimulatedWindow from './SimulatedWindow.jsx';
2
+
3
+ export default function PrototypeTab({
4
+ viewport,
5
+ syncTheme,
6
+ isDark,
7
+ children,
8
+ }) {
9
+ return (
10
+ <div className="flex h-full" role="tabpanel" aria-label="Prototype view">
11
+ <main className="flex-1 p-5 flex flex-col bg-neutral-50 dark:bg-neutral-950 overflow-hidden">
12
+ <div className="flex-1 w-full min-h-0">
13
+ <SimulatedWindow viewport={viewport} syncTheme={syncTheme} isDark={isDark} fillHeight>
14
+ {children}
15
+ </SimulatedWindow>
16
+ </div>
17
+ </main>
18
+ </div>
19
+ );
20
+ }
@@ -0,0 +1,36 @@
1
+ import SimulatedWindow from './SimulatedWindow.jsx';
2
+
3
+ /**
4
+ * ScreenFrame – renders a single prototype state as a labelled, non-interactive
5
+ * preview with a clickable title that navigates to the Prototype tab.
6
+ *
7
+ * Props:
8
+ * state – { key, name, notes, ... }
9
+ * viewport – viewport preset string or { width, height } object
10
+ * syncTheme – boolean
11
+ * isDark – boolean
12
+ * onNavigate – callback(key) – called when title button is clicked
13
+ * children – rendered prototype content
14
+ */
15
+ export default function ScreenFrame({ state, viewport, syncTheme, isDark, onNavigate, children }) {
16
+ return (
17
+ <div className="flex flex-col items-start" data-screen-key={state.key}>
18
+ {/* Clickable state name */}
19
+ <button
20
+ type="button"
21
+ onClick={() => onNavigate(state.key)}
22
+ className="mb-4 text-4xl font-semibold text-neutral-600 dark:text-neutral-400 hover:text-black dark:hover:text-white hover:underline transition-colors cursor-pointer"
23
+ >
24
+ {state.name}
25
+ </button>
26
+
27
+ {/* Non-interactive preview */}
28
+ <div className="pointer-events-none">
29
+ <SimulatedWindow viewport={viewport} syncTheme={syncTheme} isDark={isDark} fixed>
30
+ {children}
31
+ </SimulatedWindow>
32
+ </div>
33
+
34
+ </div>
35
+ );
36
+ }
@@ -0,0 +1,158 @@
1
+ import React, { useRef, useEffect } from 'react';
2
+ import { groupStates } from './StatePicker.jsx';
3
+
4
+ function chunk(arr, size) {
5
+ const rows = [];
6
+ for (let i = 0; i < arr.length; i += size) {
7
+ rows.push(arr.slice(i, i + size));
8
+ }
9
+ return rows;
10
+ }
11
+ import ScreensToolbar from './ScreensToolbar.jsx';
12
+ import ScreenFrame from './ScreenFrame.jsx';
13
+ import { usePanZoom } from '../hooks/usePanZoom.js';
14
+
15
+ export default function ScreensTab({
16
+ states,
17
+ viewport,
18
+ syncTheme,
19
+ isDark,
20
+ onNavigateToState,
21
+ uiHidden,
22
+ onToggleUI,
23
+ viewMode,
24
+ onToggleView,
25
+ children,
26
+ }) {
27
+ const containerRef = useRef(null);
28
+ const contentRef = useRef(null);
29
+
30
+ const { zoom, offset, setOffset, setZoom, fitToView, handlers } = usePanZoom(containerRef);
31
+
32
+ // Listen for scroll-to-screen events from sidebar navigation
33
+ useEffect(() => {
34
+ function handleScrollToScreen(e) {
35
+ const { key } = e.detail;
36
+ const el = document.querySelector(`[data-screen-key="${key}"]`);
37
+ const container = containerRef.current;
38
+ const content = contentRef.current;
39
+ if (!el || !container || !content) return;
40
+
41
+ // Get the element's position relative to the content container (at natural scale)
42
+ const elRect = el.getBoundingClientRect();
43
+ const contentRect = content.getBoundingClientRect();
44
+
45
+ // Convert screen coordinates back to content coordinates
46
+ const elX = (elRect.left - contentRect.left) / zoom;
47
+ const elY = (elRect.top - contentRect.top) / zoom;
48
+ const elW = elRect.width / zoom;
49
+ const elH = elRect.height / zoom;
50
+
51
+ const containerRect = container.getBoundingClientRect();
52
+
53
+ // Center the element in the container
54
+ const newOffsetX = containerRect.width / 2 - (elX + elW / 2) * zoom;
55
+ const newOffsetY = containerRect.height / 2 - (elY + elH / 2) * zoom;
56
+
57
+ setOffset({ x: newOffsetX, y: newOffsetY });
58
+ }
59
+
60
+ window.addEventListener('folio:scroll-to-screen', handleScrollToScreen);
61
+ return () => window.removeEventListener('folio:scroll-to-screen', handleScrollToScreen);
62
+ }, [zoom, setOffset]);
63
+
64
+ const handleFit = () => fitToView(contentRef);
65
+
66
+ const stepZoom = (direction) => {
67
+ const container = containerRef.current;
68
+ if (!container) return;
69
+ const rect = container.getBoundingClientRect();
70
+ const centerX = rect.left + rect.width / 2;
71
+ const centerY = rect.top + rect.height / 2;
72
+
73
+ const event = new WheelEvent('wheel', {
74
+ bubbles: true,
75
+ cancelable: true,
76
+ ctrlKey: true,
77
+ clientX: centerX,
78
+ clientY: centerY,
79
+ deltaY: direction * -100,
80
+ });
81
+ container.dispatchEvent(event);
82
+ };
83
+
84
+ const groups = groupStates(states);
85
+
86
+ return (
87
+ <section
88
+ className="flex flex-col h-full"
89
+ role="tabpanel"
90
+ aria-label="Screens view"
91
+ >
92
+ <div className="shrink-0 px-5 pt-5">
93
+ <ScreensToolbar
94
+ viewMode={viewMode}
95
+ onToggleView={onToggleView}
96
+ zoom={zoom}
97
+ uiHidden={uiHidden}
98
+ onToggleUI={onToggleUI}
99
+ onZoomIn={() => stepZoom(1)}
100
+ onZoomOut={() => stepZoom(-1)}
101
+ onFit={handleFit}
102
+ inline
103
+ />
104
+ </div>
105
+
106
+ <div
107
+ ref={containerRef}
108
+ className="flex-1 overflow-hidden relative bg-neutral-50 dark:bg-neutral-950 cursor-grab active:cursor-grabbing"
109
+ {...handlers}
110
+ style={{ touchAction: 'none' }}
111
+ >
112
+ <div
113
+ ref={contentRef}
114
+ style={{
115
+ transform: `translate(${offset.x}px, ${offset.y}px) scale(${zoom})`,
116
+ transformOrigin: '0 0',
117
+ position: 'absolute',
118
+ top: 0,
119
+ left: 0,
120
+ }}
121
+ >
122
+ <div className="grid p-12" style={{ width: 'max-content', gridTemplateColumns: 'auto 1fr', gap: '0 2.5rem' }}>
123
+ {groups.map(({ group, states: groupStates }, i) => (
124
+ <React.Fragment key={group}>
125
+ {i > 0 && (
126
+ <hr className="border-t border-neutral-200 dark:border-neutral-800 my-16 col-span-2" />
127
+ )}
128
+ <div className="pt-10 whitespace-nowrap self-start">
129
+ <h2 className="text-5xl font-bold text-neutral-800 dark:text-neutral-200">
130
+ {group}
131
+ </h2>
132
+ </div>
133
+ <div className="flex flex-col gap-10">
134
+ {chunk(groupStates, 4).map((row, rowIdx) => (
135
+ <div key={rowIdx} className="flex gap-10">
136
+ {row.map((state) => (
137
+ <ScreenFrame
138
+ key={state.key}
139
+ state={state}
140
+ viewport={viewport}
141
+ syncTheme={syncTheme}
142
+ isDark={isDark}
143
+ onNavigate={onNavigateToState}
144
+ >
145
+ {typeof children === 'function' ? children(state) : React.isValidElement(children) ? React.cloneElement(children, { appState: state.state }) : children}
146
+ </ScreenFrame>
147
+ ))}
148
+ </div>
149
+ ))}
150
+ </div>
151
+ </React.Fragment>
152
+ ))}
153
+ </div>
154
+ </div>
155
+ </div>
156
+ </section>
157
+ );
158
+ }
@@ -0,0 +1,134 @@
1
+ import { GridIcon, SquareIcon, ChevronLeftIcon, ChevronRightIcon, LinkIcon } from '../icons.jsx';
2
+
3
+ /**
4
+ * ScreensToolbar – view toggle, zoom controls, and gallery navigation for the Screens tab.
5
+ *
6
+ * Height math (all elements = 30px):
7
+ * Secondary buttons: border(2) + py-1.5(12) + text(16) = 30
8
+ * Containers: border(2) + py-1.5(12) + content(16) = 30
9
+ * Inner buttons have py-0 so they don't add height.
10
+ * Icons are w-4 h-4 (16px) to match text line-height.
11
+ */
12
+ export default function ScreensToolbar({
13
+ viewMode,
14
+ onToggleView,
15
+ zoom,
16
+ onZoomIn,
17
+ onZoomOut,
18
+ onFit,
19
+ uiHidden,
20
+ onToggleUI,
21
+ // Gallery-only props
22
+ onPrev,
23
+ onNext,
24
+ isFirst,
25
+ isLast,
26
+ currentIndex,
27
+ totalScreens,
28
+ onCopyLink,
29
+ copied,
30
+ }) {
31
+ // Inner buttons: equal padding on all sides for square icon buttons
32
+ const btnClass = "text-xs text-neutral-500 dark:text-neutral-400 hover:text-black dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors cursor-pointer p-1 rounded-md";
33
+ // Standalone buttons: own border + padding
34
+ const secondaryBtnClass = "text-xs text-neutral-600 dark:text-neutral-300 border border-neutral-200 dark:border-neutral-700 hover:border-neutral-400 dark:hover:border-neutral-500 hover:text-black dark:hover:text-white transition-colors cursor-pointer px-3 py-1.5 rounded-lg";
35
+ // Container: border + small padding, buttons provide the rest
36
+ const groupClass = "flex items-center border border-neutral-200 dark:border-neutral-700 rounded-lg p-0.5 gap-0.5";
37
+
38
+ return (
39
+ <nav aria-label="Screen controls" className="flex items-center justify-between w-full px-3 py-2 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 rounded-xl shrink-0">
40
+ <div className="flex items-center gap-3">
41
+ {/* Hide/Show UI — left-most */}
42
+ {onToggleUI && (
43
+ <button
44
+ onClick={onToggleUI}
45
+ className={secondaryBtnClass}
46
+ aria-label={uiHidden ? 'Show UI' : 'Hide UI'}
47
+ aria-pressed={uiHidden}
48
+ >
49
+ {uiHidden ? 'Show UI' : 'Hide UI'}
50
+ </button>
51
+ )}
52
+
53
+ {/* Grid / Gallery toggle */}
54
+ <div className={groupClass} role="toolbar" aria-label="View mode">
55
+ <button
56
+ onClick={() => onToggleView('grid')}
57
+ className={`${btnClass} ${viewMode === 'grid' ? 'bg-neutral-100 dark:bg-neutral-800' : ''}`}
58
+ aria-label="Grid view"
59
+ aria-pressed={viewMode === 'grid'}
60
+ >
61
+ <GridIcon className="w-4 h-4" />
62
+ </button>
63
+ <button
64
+ onClick={() => onToggleView('gallery')}
65
+ className={`${btnClass} ${viewMode === 'gallery' ? 'bg-neutral-100 dark:bg-neutral-800' : ''}`}
66
+ aria-label="Gallery view"
67
+ aria-pressed={viewMode === 'gallery'}
68
+ >
69
+ <SquareIcon className="w-4 h-4" />
70
+ </button>
71
+ </div>
72
+
73
+ {/* Zoom controls — only in grid mode */}
74
+ {viewMode === 'grid' && (
75
+ <div className={groupClass} role="toolbar" aria-label="Zoom">
76
+ <button onClick={onZoomOut} className={`${btnClass} w-6 h-6 flex items-center justify-center text-sm leading-none !p-0`} aria-label="Zoom out">
77
+
78
+ </button>
79
+ <span className="text-xs tabular-nums text-neutral-400 dark:text-neutral-500 w-10 text-center select-none leading-none">
80
+ {Math.round(zoom * 100)}%
81
+ </span>
82
+ <button onClick={onZoomIn} className={`${btnClass} w-6 h-6 flex items-center justify-center text-sm leading-none !p-0`} aria-label="Zoom in">
83
+ +
84
+ </button>
85
+ <button onClick={onFit} className={`${btnClass} px-2`}>
86
+ Fit
87
+ </button>
88
+ </div>
89
+ )}
90
+
91
+ {/* Gallery nav — prev/next and position */}
92
+ {viewMode === 'gallery' && (
93
+ <div className={groupClass} role="toolbar" aria-label="Screen navigation">
94
+ <button
95
+ onClick={onPrev}
96
+ disabled={isFirst}
97
+ aria-label="Previous screen"
98
+ className={`${btnClass} disabled:opacity-30 disabled:cursor-default`}
99
+ >
100
+ <ChevronLeftIcon className="w-4 h-4" />
101
+ </button>
102
+ <span className="text-xs tabular-nums text-neutral-400 dark:text-neutral-500 w-10 text-center select-none leading-none">
103
+ {currentIndex + 1} / {totalScreens}
104
+ </span>
105
+ <button
106
+ onClick={onNext}
107
+ disabled={isLast}
108
+ aria-label="Next screen"
109
+ className={`${btnClass} disabled:opacity-30 disabled:cursor-default`}
110
+ >
111
+ <ChevronRightIcon className="w-4 h-4" />
112
+ </button>
113
+ </div>
114
+ )}
115
+ </div>
116
+
117
+ {/* Right side */}
118
+ {viewMode === 'grid' && (
119
+ <span className="text-xs text-neutral-400 dark:text-neutral-500 select-none">
120
+ Drag to pan · ⌘+scroll to zoom
121
+ </span>
122
+ )}
123
+ {viewMode === 'gallery' && onCopyLink && (
124
+ <button
125
+ onClick={onCopyLink}
126
+ className={`${secondaryBtnClass} flex items-center gap-1`}
127
+ >
128
+ <LinkIcon className="w-3 h-3" />
129
+ {copied ? 'Copied!' : 'Copy link'}
130
+ </button>
131
+ )}
132
+ </nav>
133
+ );
134
+ }
@@ -0,0 +1,106 @@
1
+ import { ClockIcon, ExternalLinkIcon } from '../icons.jsx';
2
+ import { groupStates } from './StatePicker.jsx';
3
+
4
+ function formatDate(dateStr) {
5
+ const date = new Date(dateStr + 'T00:00:00');
6
+ return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
7
+ }
8
+
9
+ export default function Sidebar({ tab, name, states, activeKey, onSelectState, changelog, onSelectDate, description, prototypeUrl }) {
10
+ return (
11
+ <aside className="w-52 shrink-0 ml-5 my-5 mr-3 p-4 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 rounded-xl overflow-y-auto self-start">
12
+ {tab === 'prototype' ? (
13
+ <ProjectInfo name={name} description={description} prototypeUrl={prototypeUrl} />
14
+ ) : tab === 'screens' ? (
15
+ <StatesList states={states} activeKey={activeKey} onSelect={onSelectState} />
16
+ ) : tab === 'changelog' ? (
17
+ <DatesList changelog={changelog} onSelect={onSelectDate} />
18
+ ) : null}
19
+ </aside>
20
+ );
21
+ }
22
+
23
+ function StatesList({ states, activeKey, onSelect }) {
24
+ const groups = groupStates(states);
25
+ return (
26
+ <>
27
+ <nav aria-label="Screen states">
28
+ {groups.map(({ group, states: groupStates }) => (
29
+ <section key={group} className="mb-3">
30
+ {group !== 'Ungrouped' && (
31
+ <h3 className="text-xs font-semibold text-neutral-500 dark:text-neutral-400 mb-1">{group}</h3>
32
+ )}
33
+ <ul className="list-none m-0 p-0">
34
+ {groupStates.map((state) => {
35
+ const isActive = state.key === activeKey;
36
+ return (
37
+ <li
38
+ key={state.key}
39
+ aria-current={isActive ? 'true' : undefined}
40
+ className={`text-xs px-2 py-1.5 rounded mb-0.5 cursor-pointer transition-colors ${
41
+ isActive
42
+ ? 'bg-black dark:bg-white text-white dark:text-black font-medium'
43
+ : 'text-neutral-800 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800'
44
+ }`}
45
+ onClick={() => onSelect(state.key)}
46
+ >
47
+ {state.name}
48
+ </li>
49
+ );
50
+ })}
51
+ </ul>
52
+ </section>
53
+ ))}
54
+ </nav>
55
+ </>
56
+ );
57
+ }
58
+
59
+ function DatesList({ changelog, onSelect }) {
60
+ return (
61
+ <>
62
+ <h2 className="flex items-center gap-1 text-xs font-semibold text-neutral-500 dark:text-neutral-400 uppercase tracking-wide mb-3">
63
+ <ClockIcon className="w-3.5 h-3.5" /> Dates
64
+ </h2>
65
+ <nav aria-label="Changelog dates">
66
+ <ul className="list-none m-0 p-0">
67
+ {changelog.map(({ date }) => (
68
+ <li
69
+ key={date}
70
+ className="text-xs px-2 py-1.5 rounded mb-0.5 cursor-pointer text-neutral-800 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
71
+ onClick={() => onSelect?.(date)}
72
+ >
73
+ {formatDate(date)}
74
+ </li>
75
+ ))}
76
+ </ul>
77
+ </nav>
78
+ </>
79
+ );
80
+ }
81
+
82
+ function ProjectInfo({ name, description, prototypeUrl }) {
83
+ return (
84
+ <div>
85
+ {name && (
86
+ <h2 className="text-xs font-semibold text-neutral-500 dark:text-neutral-400 uppercase tracking-wide mb-3">
87
+ About
88
+ </h2>
89
+ )}
90
+ {description && (
91
+ <p className="text-xs text-neutral-600 dark:text-neutral-400 leading-relaxed mb-4">
92
+ {description}
93
+ </p>
94
+ )}
95
+ <a
96
+ href={prototypeUrl || '/'}
97
+ target="_blank"
98
+ rel="noopener noreferrer"
99
+ className="flex items-center gap-1.5 text-xs font-medium text-neutral-500 dark:text-neutral-400 hover:text-black dark:hover:text-white transition-colors"
100
+ >
101
+ <ExternalLinkIcon className="w-3.5 h-3.5" />
102
+ Open prototype
103
+ </a>
104
+ </div>
105
+ );
106
+ }
@@ -0,0 +1,47 @@
1
+ import { VIEWPORT_PRESETS } from '../config/defaults.js';
2
+
3
+ export default function SimulatedWindow({ viewport, children, syncTheme, isDark, fixed, fillHeight }) {
4
+ let dimensions;
5
+ if (typeof viewport === 'string') {
6
+ dimensions = VIEWPORT_PRESETS[viewport] ?? VIEWPORT_PRESETS.desktop;
7
+ } else if (viewport && typeof viewport === 'object') {
8
+ dimensions = viewport;
9
+ } else {
10
+ dimensions = VIEWPORT_PRESETS.desktop;
11
+ }
12
+
13
+ const isResponsive = dimensions.width === '100%';
14
+
15
+ let style;
16
+ if (fillHeight) {
17
+ // Fill available height — for prototype tab with iframe content
18
+ style = { width: '100%', height: '100%' };
19
+ } else if (fixed) {
20
+ // Fixed pixel dimensions for canvas/screens view
21
+ // Responsive falls back to wide-desktop dimensions so frames don't collapse
22
+ const fixedDimensions = isResponsive
23
+ ? { width: 1440, height: 900 }
24
+ : dimensions;
25
+ style = { width: `${fixedDimensions.width}px`, height: `${fixedDimensions.height}px`, maxWidth: '100%' };
26
+ } else {
27
+ // Fluid — fills available width, maintains aspect ratio
28
+ style = isResponsive
29
+ ? { width: '100%', height: 'auto', minHeight: '400px' }
30
+ : { width: '100%', aspectRatio: `${dimensions.width} / ${dimensions.height}` };
31
+ }
32
+
33
+ let themeClass = '';
34
+ if (syncTheme) {
35
+ themeClass = isDark ? ' folio-theme-dark' : ' folio-theme-light';
36
+ }
37
+
38
+ return (
39
+ <figure
40
+ aria-label="Prototype window"
41
+ style={style}
42
+ className={`relative bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-700 rounded-lg shadow-sm dark:shadow-lg overflow-hidden${themeClass}`}
43
+ >
44
+ {children}
45
+ </figure>
46
+ );
47
+ }