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.
package/src/icons.jsx ADDED
@@ -0,0 +1,261 @@
1
+ const svgProps = {
2
+ xmlns: "http://www.w3.org/2000/svg",
3
+ viewBox: "0 0 24 24",
4
+ fill: "none",
5
+ stroke: "currentColor",
6
+ strokeWidth: "2",
7
+ strokeLinecap: "round",
8
+ strokeLinejoin: "round",
9
+ "aria-hidden": "true",
10
+ };
11
+
12
+ export function SunIcon({ className }) {
13
+ return (
14
+ <svg {...svgProps} className={className}>
15
+ <circle cx="12" cy="12" r="4" />
16
+ <path d="M12 2v2" />
17
+ <path d="M12 20v2" />
18
+ <path d="M4.93 4.93l1.41 1.41" />
19
+ <path d="M17.66 17.66l1.41 1.41" />
20
+ <path d="M2 12h2" />
21
+ <path d="M20 12h2" />
22
+ <path d="M6.34 17.66l-1.41 1.41" />
23
+ <path d="M19.07 4.93l-1.41 1.41" />
24
+ </svg>
25
+ );
26
+ }
27
+
28
+ export function MoonIcon({ className }) {
29
+ return (
30
+ <svg {...svgProps} className={className}>
31
+ <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
32
+ </svg>
33
+ );
34
+ }
35
+
36
+ export function PlayIcon({ className }) {
37
+ return (
38
+ <svg
39
+ xmlns="http://www.w3.org/2000/svg"
40
+ viewBox="0 0 24 24"
41
+ fill="currentColor"
42
+ aria-hidden="true"
43
+ className={className}
44
+ >
45
+ <path d="M8 5v14l11-7z" />
46
+ </svg>
47
+ );
48
+ }
49
+
50
+ export function GridIcon({ className }) {
51
+ return (
52
+ <svg {...svgProps} className={className}>
53
+ <rect x="3" y="3" width="7" height="7" rx="1" />
54
+ <rect x="14" y="3" width="7" height="7" rx="1" />
55
+ <rect x="3" y="14" width="7" height="7" rx="1" />
56
+ <rect x="14" y="14" width="7" height="7" rx="1" />
57
+ </svg>
58
+ );
59
+ }
60
+
61
+ export function ClockIcon({ className }) {
62
+ return (
63
+ <svg {...svgProps} className={className}>
64
+ <circle cx="12" cy="12" r="10" />
65
+ <polyline points="12 6 12 12 16 14" />
66
+ </svg>
67
+ );
68
+ }
69
+
70
+ export function LinkIcon({ className }) {
71
+ return (
72
+ <svg {...svgProps} className={className}>
73
+ <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
74
+ <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
75
+ </svg>
76
+ );
77
+ }
78
+
79
+ export function LayersIcon({ className }) {
80
+ return (
81
+ <svg {...svgProps} className={className}>
82
+ <polygon points="12 2 2 7 12 12 22 7 12 2" />
83
+ <polyline points="2 17 12 22 22 17" />
84
+ <polyline points="2 12 12 17 22 12" />
85
+ </svg>
86
+ );
87
+ }
88
+
89
+ export function ChatIcon({ className }) {
90
+ return (
91
+ <svg {...svgProps} className={className}>
92
+ <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
93
+ </svg>
94
+ );
95
+ }
96
+
97
+ export function RowsIcon({ className }) {
98
+ return (
99
+ <svg {...svgProps} className={className}>
100
+ <rect x="3" y="4" width="18" height="4" rx="1" />
101
+ <rect x="3" y="10" width="18" height="4" rx="1" />
102
+ <rect x="3" y="16" width="18" height="4" rx="1" />
103
+ </svg>
104
+ );
105
+ }
106
+
107
+ export function GridSmallIcon({ className }) {
108
+ return (
109
+ <svg {...svgProps} className={className}>
110
+ <rect x="3" y="3" width="8" height="8" rx="1" />
111
+ <rect x="13" y="3" width="8" height="8" rx="1" />
112
+ <rect x="3" y="13" width="8" height="8" rx="1" />
113
+ <rect x="13" y="13" width="8" height="8" rx="1" />
114
+ </svg>
115
+ );
116
+ }
117
+
118
+ export function MinusIcon({ className }) {
119
+ return (
120
+ <svg {...svgProps} className={className}>
121
+ <line x1="5" y1="12" x2="19" y2="12" />
122
+ </svg>
123
+ );
124
+ }
125
+
126
+ export function PlusIcon({ className }) {
127
+ return (
128
+ <svg {...svgProps} className={className}>
129
+ <line x1="12" y1="5" x2="12" y2="19" />
130
+ <line x1="5" y1="12" x2="19" y2="12" />
131
+ </svg>
132
+ );
133
+ }
134
+
135
+ export function MaximizeIcon({ className }) {
136
+ return (
137
+ <svg {...svgProps} className={className}>
138
+ <polyline points="15 3 21 3 21 9" />
139
+ <polyline points="9 21 3 21 3 15" />
140
+ <line x1="21" y1="3" x2="14" y2="10" />
141
+ <line x1="3" y1="21" x2="10" y2="14" />
142
+ </svg>
143
+ );
144
+ }
145
+
146
+ export function ExternalLinkIcon({ className }) {
147
+ return (
148
+ <svg {...svgProps} className={className}>
149
+ <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
150
+ <polyline points="15 3 21 3 21 9" />
151
+ <line x1="10" y1="14" x2="21" y2="3" />
152
+ </svg>
153
+ );
154
+ }
155
+
156
+ export function DocumentIcon({ className }) {
157
+ return (
158
+ <svg {...svgProps} className={className}>
159
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
160
+ <polyline points="14 2 14 8 20 8" />
161
+ <line x1="16" y1="13" x2="8" y2="13" />
162
+ <line x1="16" y1="17" x2="8" y2="17" />
163
+ <polyline points="10 9 9 9 8 9" />
164
+ </svg>
165
+ );
166
+ }
167
+
168
+ export function ChevronLeftIcon({ className }) {
169
+ return (
170
+ <svg {...svgProps} className={className}>
171
+ <polyline points="15 18 9 12 15 6" />
172
+ </svg>
173
+ );
174
+ }
175
+
176
+ export function ChevronRightIcon({ className }) {
177
+ return (
178
+ <svg {...svgProps} className={className}>
179
+ <polyline points="9 6 15 12 9 18" />
180
+ </svg>
181
+ );
182
+ }
183
+
184
+ export function ImageIcon({ className }) {
185
+ return (
186
+ <svg {...svgProps} className={className}>
187
+ <rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
188
+ <circle cx="8.5" cy="8.5" r="1.5" />
189
+ <polyline points="21 15 16 10 5 21" />
190
+ </svg>
191
+ );
192
+ }
193
+
194
+ export function SquareIcon({ className }) {
195
+ return (
196
+ <svg {...svgProps} className={className}>
197
+ <rect x="3" y="3" width="18" height="18" rx="2" />
198
+ </svg>
199
+ );
200
+ }
201
+
202
+ export function SpreadsheetIcon({ className }) {
203
+ return (
204
+ <svg {...svgProps} className={className}>
205
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
206
+ <polyline points="14 2 14 8 20 8" />
207
+ <line x1="8" y1="13" x2="16" y2="13" />
208
+ <line x1="8" y1="17" x2="16" y2="17" />
209
+ <line x1="12" y1="9" x2="12" y2="21" />
210
+ </svg>
211
+ );
212
+ }
213
+
214
+ export function PresentationIcon({ className }) {
215
+ return (
216
+ <svg {...svgProps} className={className}>
217
+ <rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
218
+ <line x1="8" y1="21" x2="16" y2="21" />
219
+ <line x1="12" y1="17" x2="12" y2="21" />
220
+ </svg>
221
+ );
222
+ }
223
+
224
+ export function PenToolIcon({ className }) {
225
+ return (
226
+ <svg {...svgProps} className={className}>
227
+ <path d="M12 19l7-7 3 3-7 7-3-3z" />
228
+ <path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z" />
229
+ <path d="M2 2l7.586 7.586" />
230
+ <circle cx="11" cy="11" r="2" />
231
+ </svg>
232
+ );
233
+ }
234
+
235
+ export function VideoIcon({ className }) {
236
+ return (
237
+ <svg {...svgProps} className={className}>
238
+ <polygon points="23 7 16 12 23 17 23 7" />
239
+ <rect x="1" y="5" width="15" height="14" rx="2" ry="2" />
240
+ </svg>
241
+ );
242
+ }
243
+
244
+ export function FileIcon({ className }) {
245
+ return (
246
+ <svg {...svgProps} className={className}>
247
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
248
+ <polyline points="14 2 14 8 20 8" />
249
+ </svg>
250
+ );
251
+ }
252
+
253
+ export function ExternalWindowIcon({ className }) {
254
+ return (
255
+ <svg {...svgProps} className={className}>
256
+ <rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
257
+ <line x1="8" y1="21" x2="16" y2="21" />
258
+ <line x1="12" y1="17" x2="12" y2="21" />
259
+ </svg>
260
+ );
261
+ }
package/src/index.js ADDED
@@ -0,0 +1 @@
1
+ export { default as FolioViewer } from './viewer/FolioViewer.jsx';
@@ -0,0 +1,57 @@
1
+ import { readFileSync, existsSync } from 'fs';
2
+ import { resolve } from 'path';
3
+
4
+ const VIRTUAL_ENTRY_ID = 'virtual:folio-entry';
5
+ const RESOLVED_VIRTUAL_ENTRY_ID = '\0' + VIRTUAL_ENTRY_ID;
6
+
7
+ export default function folioPlugin(options = {}) {
8
+ let projectRoot;
9
+
10
+ return {
11
+ name: 'vite-plugin-folio',
12
+
13
+ configResolved(config) {
14
+ projectRoot = config.root;
15
+ },
16
+
17
+ resolveId(id) {
18
+ if (id === VIRTUAL_ENTRY_ID) {
19
+ return RESOLVED_VIRTUAL_ENTRY_ID;
20
+ }
21
+ },
22
+
23
+ load(id) {
24
+ if (id !== RESOLVED_VIRTUAL_ENTRY_ID) return;
25
+
26
+ const configPath = resolve(projectRoot, 'folio.json');
27
+ let config = {};
28
+ if (existsSync(configPath)) {
29
+ config = JSON.parse(readFileSync(configPath, 'utf-8'));
30
+ }
31
+
32
+ const componentPath = config.component || './src/App.jsx';
33
+
34
+ return `
35
+ import React from 'react';
36
+ import { createRoot } from 'react-dom/client';
37
+ import { FolioViewer } from 'design-folio';
38
+ import 'design-folio/styles';
39
+ import App from '${componentPath}';
40
+
41
+ const config = ${JSON.stringify(config)};
42
+
43
+ createRoot(document.getElementById('root')).render(
44
+ React.createElement(FolioViewer, { config }, React.createElement(App))
45
+ );
46
+ `;
47
+ },
48
+
49
+ config() {
50
+ return {
51
+ optimizeDeps: {
52
+ include: ['react', 'react-dom'],
53
+ },
54
+ };
55
+ },
56
+ };
57
+ }
@@ -0,0 +1,3 @@
1
+ @import 'tailwindcss';
2
+ @source "../";
3
+ @custom-variant dark (&:where(.dark, .dark *));
@@ -0,0 +1,38 @@
1
+ import { ClockIcon } from '../icons.jsx';
2
+
3
+ function formatDate(dateStr) {
4
+ const date = new Date(dateStr + 'T00:00:00');
5
+ return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
6
+ }
7
+
8
+ export default function ChangelogTab({ changelog }) {
9
+ return (
10
+ <section
11
+ role="tabpanel"
12
+ aria-label="Changelog"
13
+ className="flex-1 p-5 bg-neutral-50 dark:bg-neutral-950 overflow-auto h-full"
14
+ >
15
+ <div className="max-w-2xl">
16
+ {changelog.length === 0 ? (
17
+ <p className="text-sm text-neutral-700 dark:text-neutral-300">No changelog entries yet.</p>
18
+ ) : (
19
+ changelog.map(({ date, entries }) => (
20
+ <section key={date} id={`date-${date}`} className="mb-6">
21
+ <h2 className="flex items-center gap-2 text-sm font-semibold text-black dark:text-white mb-3">
22
+ <ClockIcon className="w-4 h-4" />
23
+ {formatDate(date)}
24
+ </h2>
25
+ <ul>
26
+ {entries.map((entry, i) => (
27
+ <li key={i} className="text-sm text-neutral-700 dark:text-neutral-300">
28
+ {entry}
29
+ </li>
30
+ ))}
31
+ </ul>
32
+ </section>
33
+ ))
34
+ )}
35
+ </div>
36
+ </section>
37
+ );
38
+ }
@@ -0,0 +1,18 @@
1
+ import { ChatIcon } from '../icons.jsx';
2
+
3
+ export default function DesignNotes({ notes }) {
4
+ if (!notes) return null;
5
+
6
+ return (
7
+ <aside
8
+ aria-label="Design notes"
9
+ className="mt-3 p-4 w-full bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-md"
10
+ >
11
+ <h2 className="flex items-center gap-1.5 text-xs font-semibold text-neutral-500 dark:text-neutral-400 uppercase tracking-wide mb-1">
12
+ <ChatIcon className="w-3.5 h-3.5" />
13
+ Design Notes
14
+ </h2>
15
+ <p className="text-sm text-neutral-700 dark:text-neutral-300 leading-relaxed">{notes}</p>
16
+ </aside>
17
+ );
18
+ }
@@ -0,0 +1,76 @@
1
+ import { DocumentIcon, SpreadsheetIcon, PresentationIcon, PenToolIcon, VideoIcon, ImageIcon, FileIcon } from '../icons.jsx';
2
+
3
+ /**
4
+ * Maps a URL or label to a document type icon.
5
+ * Falls back to FileIcon (generic) if no match.
6
+ */
7
+ function getDocIcon(url, label) {
8
+ const lower = (url + ' ' + label).toLowerCase();
9
+
10
+ // Design tools
11
+ if (lower.includes('figma') || lower.includes('sketch') || lower.includes('framer') || lower.includes('penpot'))
12
+ return PenToolIcon;
13
+
14
+ // Spreadsheets
15
+ if (lower.includes('sheets') || lower.includes('airtable') || lower.includes('spreadsheet') || lower.includes('.csv') || lower.includes('.xlsx'))
16
+ return SpreadsheetIcon;
17
+
18
+ // Presentations
19
+ if (lower.includes('slides') || lower.includes('pitch') || lower.includes('keynote') || lower.includes('presentation') || lower.includes('.pptx'))
20
+ return PresentationIcon;
21
+
22
+ // Video
23
+ if (lower.includes('loom') || lower.includes('youtube') || lower.includes('vimeo') || lower.includes('video') || lower.includes('.mp4'))
24
+ return VideoIcon;
25
+
26
+ // Images
27
+ if (lower.includes('.png') || lower.includes('.jpg') || lower.includes('.svg') || lower.includes('screenshot') || lower.includes('moodboard'))
28
+ return ImageIcon;
29
+
30
+ // Documents (Notion, Google Docs, Confluence, etc.)
31
+ if (lower.includes('notion') || lower.includes('docs.google') || lower.includes('confluence') || lower.includes('prd') || lower.includes('spec') || lower.includes('requirements') || lower.includes('.pdf') || lower.includes('.docx'))
32
+ return DocumentIcon;
33
+
34
+ // Generic fallback
35
+ return FileIcon;
36
+ }
37
+
38
+ export default function DocumentsTab({ links }) {
39
+ return (
40
+ <section
41
+ role="tabpanel"
42
+ aria-label="Documents"
43
+ className="flex-1 p-5 bg-neutral-50 dark:bg-neutral-950 overflow-auto h-full"
44
+ >
45
+ <div className="max-w-2xl">
46
+ {links.length === 0 ? (
47
+ <p className="text-sm text-neutral-500 dark:text-neutral-400">
48
+ No documents linked yet.
49
+ </p>
50
+ ) : (
51
+ <ul className="space-y-3">
52
+ {links.map(({ label, url }) => {
53
+ const Icon = getDocIcon(url, label);
54
+ return (
55
+ <li key={url}>
56
+ <a
57
+ href={url}
58
+ target="_blank"
59
+ rel="noopener noreferrer"
60
+ className="flex items-center gap-3 p-4 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 rounded-xl hover:border-neutral-400 dark:hover:border-neutral-500 transition-colors"
61
+ >
62
+ <Icon className="w-5 h-5 text-neutral-400 dark:text-neutral-500 shrink-0" />
63
+ <div className="min-w-0">
64
+ <p className="text-sm font-medium text-black dark:text-white">{label}</p>
65
+ <p className="text-xs text-neutral-500 dark:text-neutral-400 truncate">{url}</p>
66
+ </div>
67
+ </a>
68
+ </li>
69
+ );
70
+ })}
71
+ </ul>
72
+ )}
73
+ </div>
74
+ </section>
75
+ );
76
+ }
@@ -0,0 +1,140 @@
1
+ import { useState } from 'react';
2
+ import { mergeConfig } from '../config/load-config.js';
3
+ import { useTheme } from '../hooks/useTheme.js';
4
+ import { useHashRoute } from '../hooks/useHashRoute.js';
5
+ import Header from './Header.jsx';
6
+ import TabNav from './TabNav.jsx';
7
+ import PrototypeTab from './PrototypeTab.jsx';
8
+ import ScreensTab from './ScreensTab.jsx';
9
+ import GalleryView from './GalleryView.jsx';
10
+ import ChangelogTab from './ChangelogTab.jsx';
11
+ import DocumentsTab from './DocumentsTab.jsx';
12
+ import Sidebar from './Sidebar.jsx';
13
+ import '../styles/folio.css';
14
+
15
+ export default function FolioViewer({ config: userConfig, children }) {
16
+ const config = mergeConfig(userConfig);
17
+ const { theme, isDark, toggleTheme } = useTheme();
18
+ const { tab, stateKey, navigateTo, navigateToTab, navigateToScreen } = useHashRoute();
19
+ const [uiHidden, setUiHidden] = useState(false);
20
+
21
+ // Derive view mode: gallery when on screens tab with a state key
22
+ const isGallery = tab === 'screens' && stateKey !== null;
23
+ const viewMode = isGallery ? 'gallery' : 'grid';
24
+
25
+ // Active key for gallery view — fall back to first state
26
+ const galleryKey = (isGallery && config.states.find((s) => s.key === stateKey))
27
+ ? stateKey
28
+ : config.states[0]?.key || null;
29
+
30
+ function handleScrollToScreen(key) {
31
+ // scrollIntoView doesn't work with CSS-transformed containers.
32
+ // Instead, dispatch a custom event that ScreensTab listens for.
33
+ window.dispatchEvent(new CustomEvent('folio:scroll-to-screen', { detail: { key } }));
34
+ }
35
+
36
+ function handleSelectDate(date) {
37
+ const el = document.getElementById(`date-${date}`);
38
+ if (el) el.scrollIntoView({ behavior: 'smooth' });
39
+ }
40
+
41
+ function handleTabChange(newTab) {
42
+ setUiHidden(false);
43
+ navigateToTab(newTab);
44
+ }
45
+
46
+ function handleToggleView(mode) {
47
+ if (mode === 'gallery') {
48
+ navigateToScreen(galleryKey);
49
+ } else {
50
+ navigateToTab('screens');
51
+ }
52
+ }
53
+
54
+ function handleSidebarSelectState(key) {
55
+ if (isGallery) {
56
+ navigateToScreen(key);
57
+ } else {
58
+ handleScrollToScreen(key);
59
+ }
60
+ }
61
+
62
+ // Determine if sidebar should show
63
+ const showSidebar = (tab === 'prototype' || tab === 'screens' || tab === 'changelog') && !(uiHidden && tab === 'screens');
64
+
65
+ return (
66
+ <div className="flex flex-col h-screen bg-neutral-50 dark:bg-neutral-950">
67
+ {!(uiHidden && tab === 'screens') && (
68
+ <>
69
+ <Header
70
+ name={config.name}
71
+ designer={config.designer}
72
+ updatedAt={config.updatedAt}
73
+ isDark={isDark}
74
+ onToggleTheme={toggleTheme}
75
+ />
76
+ <TabNav activeTab={tab} onTabChange={handleTabChange} />
77
+ </>
78
+ )}
79
+ <div className="flex flex-1 overflow-hidden">
80
+ {showSidebar && (
81
+ <Sidebar
82
+ tab={tab}
83
+ name={config.name}
84
+ states={config.states}
85
+ activeKey={isGallery ? galleryKey : null}
86
+ onSelectState={handleSidebarSelectState}
87
+ changelog={config.changelog}
88
+ onSelectDate={handleSelectDate}
89
+ description={config.description}
90
+ prototypeUrl={config.prototypeUrl}
91
+ />
92
+ )}
93
+ <div className="flex-1 overflow-hidden">
94
+ {tab === 'prototype' && (
95
+ <PrototypeTab
96
+ viewport={config.viewport}
97
+ syncTheme={config.syncTheme}
98
+ isDark={isDark}
99
+ >
100
+ {children}
101
+ </PrototypeTab>
102
+ )}
103
+ {tab === 'screens' && !isGallery && (
104
+ <ScreensTab
105
+ states={config.states}
106
+ viewport={config.viewport}
107
+ syncTheme={config.syncTheme}
108
+ isDark={isDark}
109
+ onNavigateToState={(key) => navigateToScreen(key)}
110
+ uiHidden={uiHidden}
111
+ onToggleUI={() => setUiHidden((v) => !v)}
112
+ viewMode={viewMode}
113
+ onToggleView={handleToggleView}
114
+ >
115
+ {children}
116
+ </ScreensTab>
117
+ )}
118
+ {tab === 'screens' && isGallery && (
119
+ <GalleryView
120
+ states={config.states}
121
+ activeKey={galleryKey}
122
+ viewport={config.viewport}
123
+ syncTheme={config.syncTheme}
124
+ isDark={isDark}
125
+ onNavigateToScreen={navigateToScreen}
126
+ viewMode={viewMode}
127
+ onToggleView={handleToggleView}
128
+ uiHidden={uiHidden}
129
+ onToggleUI={() => setUiHidden((v) => !v)}
130
+ >
131
+ {children}
132
+ </GalleryView>
133
+ )}
134
+ {tab === 'changelog' && <ChangelogTab changelog={config.changelog} />}
135
+ {tab === 'documents' && <DocumentsTab links={config.links} />}
136
+ </div>
137
+ </div>
138
+ </div>
139
+ );
140
+ }