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/README.md ADDED
@@ -0,0 +1,109 @@
1
+ # Design Folio
2
+
3
+ A design presentation system for vibe-coded prototypes. Bridges the gap between how designers build prototypes with Claude and how they share and present design work.
4
+
5
+ ## What it does
6
+
7
+ Folio wraps your prototype in a viewer with:
8
+
9
+ - **Prototype tab** — your prototype at full size for testing interactions. No state picker, no design notes. Navigate using the links and UI in your prototype.
10
+ - **Screens tab** — all your screens laid out for design review. Grid view (4-up with pan/zoom) and gallery view (single screen with design notes). This is where you examine design decisions.
11
+ - **Changelog** — Claude-summarized history of what changed and when.
12
+ - **Documents** — links to Figma files, PRDs, research decks, and other external context.
13
+
14
+ ## Setup
15
+
16
+ Folio is designed to be set up by the `/folio` Claude Code skill, which handles installation, configuration, and ongoing maintenance automatically. You shouldn't need to configure anything manually.
17
+
18
+ If you want to set it up manually:
19
+
20
+ ```bash
21
+ npm install design-folio --save-dev
22
+ ```
23
+
24
+ Add to your Vite config:
25
+
26
+ ```js
27
+ import { folioPlugin } from 'design-folio/vite';
28
+
29
+ export default defineConfig({
30
+ plugins: [folioPlugin()],
31
+ });
32
+ ```
33
+
34
+ Create a `folio.json` at your project root:
35
+
36
+ ```json
37
+ {
38
+ "name": "Project Name",
39
+ "designer": "Your Name",
40
+ "description": "A short paragraph about the project.",
41
+ "viewport": "desktop",
42
+ "component": "./src/App.jsx",
43
+ "states": [
44
+ {
45
+ "key": "home",
46
+ "name": "1. Home",
47
+ "group": "Main",
48
+ "state": {},
49
+ "notes": "Design rationale for this screen."
50
+ }
51
+ ]
52
+ }
53
+ ```
54
+
55
+ ## Configuration
56
+
57
+ All fields in `folio.json` are optional. Folio works with zero config by defaulting to `./src/App.jsx` with a desktop viewport.
58
+
59
+ | Field | Type | Default | Description |
60
+ |-------|------|---------|-------------|
61
+ | `name` | string | `""` | Project name shown in the header |
62
+ | `designer` | string | `""` | Designer name shown in the header |
63
+ | `description` | string | `""` | Project description shown in the prototype tab sidebar |
64
+ | `viewport` | string or object | `"desktop"` | `"desktop"`, `"wide-desktop"`, `"tablet"`, `"mobile"`, `"responsive"`, or `{ width, height }` |
65
+ | `component` | string | `"./src/App.jsx"` | Path to the prototype component |
66
+ | `syncTheme` | boolean | `false` | Pass Folio's light/dark theme to the prototype |
67
+ | `prototypeUrl` | string | `"/"` | URL for "Open prototype" link |
68
+ | `states` | array | `[]` | Screen states with keys, names, groups, state objects, and notes |
69
+ | `changelog` | array | `[]` | Changelog entries grouped by date |
70
+ | `links` | array | `[]` | External document links |
71
+
72
+ ### Viewport presets
73
+
74
+ | Preset | Dimensions |
75
+ |--------|-----------|
76
+ | `desktop` | 1080 × 720 |
77
+ | `wide-desktop` | 1440 × 900 |
78
+ | `tablet` | 768 × 1024 |
79
+ | `mobile` | 375 × 812 |
80
+ | `responsive` | 100% width, auto height |
81
+
82
+ ### State objects
83
+
84
+ Each state in the `states` array drives a screen. Your prototype component receives the `state` object as an `appState` prop and should render accordingly.
85
+
86
+ ```json
87
+ {
88
+ "key": "home-empty",
89
+ "name": "2. Empty State",
90
+ "group": "Home",
91
+ "state": { "items": [], "loading": false },
92
+ "notes": "Shown before any data is loaded."
93
+ }
94
+ ```
95
+
96
+ ## How it works
97
+
98
+ Folio installs as a Vite plugin that serves your prototype component inside its viewer. Your prototype code is never modified — Folio wraps around it.
99
+
100
+ The `/folio` skill handles:
101
+ - Detecting your prototype type and structure
102
+ - Generating `folio.json` with states, notes, and changelog
103
+ - Pulling the project description from your README
104
+ - Installing the Tailwind CSS dependency (required by the viewer)
105
+ - Keeping the config updated as you continue vibe coding
106
+
107
+ ## License
108
+
109
+ MIT
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "design-folio",
3
+ "version": "0.1.0",
4
+ "description": "A design presentation system for vibe-coded prototypes",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js",
9
+ "./vite": "./src/plugin/vite-plugin.js",
10
+ "./styles": "./src/styles/folio.css"
11
+ },
12
+ "files": [
13
+ "src",
14
+ "README.md"
15
+ ],
16
+ "scripts": {
17
+ "dev": "cd demo && npm run dev",
18
+ "test": "vitest run",
19
+ "test:watch": "vitest"
20
+ },
21
+ "peerDependencies": {
22
+ "react": "^18.0.0 || ^19.0.0",
23
+ "react-dom": "^18.0.0 || ^19.0.0",
24
+ "tailwindcss": "^4.0.0"
25
+ },
26
+ "devDependencies": {
27
+ "@tailwindcss/vite": "^4.2.2",
28
+ "@testing-library/jest-dom": "^6.9.1",
29
+ "@testing-library/react": "^16.3.2",
30
+ "@vitejs/plugin-react": "^6.0.1",
31
+ "jsdom": "^29.0.1",
32
+ "react": "^19.2.4",
33
+ "react-dom": "^19.2.4",
34
+ "tailwindcss": "^4.2.2",
35
+ "vite": "^8.0.3",
36
+ "vitest": "^4.1.2"
37
+ },
38
+ "keywords": [
39
+ "design",
40
+ "prototype",
41
+ "viewer",
42
+ "vite-plugin"
43
+ ],
44
+ "license": "UNLICENSED"
45
+ }
@@ -0,0 +1,19 @@
1
+ export const VIEWPORT_PRESETS = {
2
+ desktop: { width: 1080, height: 720 },
3
+ 'wide-desktop': { width: 1440, height: 900 },
4
+ tablet: { width: 768, height: 1024 },
5
+ mobile: { width: 375, height: 812 },
6
+ responsive: { width: '100%', height: 'auto' },
7
+ };
8
+
9
+ export const DEFAULT_CONFIG = {
10
+ name: '',
11
+ designer: '',
12
+ description: '',
13
+ viewport: 'desktop',
14
+ component: './src/App.jsx',
15
+ syncTheme: false,
16
+ states: [],
17
+ changelog: [],
18
+ links: [],
19
+ };
@@ -0,0 +1,20 @@
1
+ import { DEFAULT_CONFIG } from './defaults.js';
2
+
3
+ /**
4
+ * Merges a user config object over the defaults.
5
+ * Returns a new object — does not mutate DEFAULT_CONFIG.
6
+ * Returns defaults when userConfig is null or undefined.
7
+ */
8
+ export function mergeConfig(userConfig) {
9
+ if (userConfig == null) {
10
+ return { ...DEFAULT_CONFIG, states: [], changelog: [], links: [] };
11
+ }
12
+
13
+ return {
14
+ ...DEFAULT_CONFIG,
15
+ ...userConfig,
16
+ states: Array.isArray(userConfig.states) ? [...userConfig.states] : [...DEFAULT_CONFIG.states],
17
+ changelog: Array.isArray(userConfig.changelog) ? [...userConfig.changelog] : [...DEFAULT_CONFIG.changelog],
18
+ links: Array.isArray(userConfig.links) ? [...userConfig.links] : [...DEFAULT_CONFIG.links],
19
+ };
20
+ }
@@ -0,0 +1,93 @@
1
+ import { useState, useEffect, useCallback } from 'react';
2
+
3
+ /**
4
+ * Parses the current window.location.hash into { tab, stateKey }.
5
+ *
6
+ * Supported patterns:
7
+ * #/state/{key} → { tab: 'prototype', stateKey: key }
8
+ * #/screens/{key} → { tab: 'screens', stateKey: key }
9
+ * #/screens → { tab: 'screens', stateKey: null }
10
+ * #/changelog → { tab: 'changelog', stateKey: null }
11
+ * #/documents → { tab: 'documents', stateKey: null }
12
+ * (empty / none) → { tab: 'prototype', stateKey: null }
13
+ */
14
+ function parseHash(hash) {
15
+ if (!hash || hash === '#' || hash === '#/') {
16
+ return { tab: 'prototype', stateKey: null };
17
+ }
18
+
19
+ // Strip the leading '#'
20
+ const path = hash.startsWith('#') ? hash.slice(1) : hash;
21
+ // Strip optional leading '/'
22
+ const trimmed = path.startsWith('/') ? path.slice(1) : path;
23
+
24
+ const stateMatch = trimmed.match(/^state\/(.+)$/);
25
+ if (stateMatch) {
26
+ return { tab: 'prototype', stateKey: stateMatch[1] };
27
+ }
28
+
29
+ const screensMatch = trimmed.match(/^screens\/(.+)$/);
30
+ if (screensMatch) {
31
+ return { tab: 'screens', stateKey: screensMatch[1] };
32
+ }
33
+
34
+ const knownTabs = ['screens', 'changelog', 'documents'];
35
+ if (knownTabs.includes(trimmed)) {
36
+ return { tab: trimmed, stateKey: null };
37
+ }
38
+
39
+ return { tab: 'prototype', stateKey: null };
40
+ }
41
+
42
+ /**
43
+ * Hook that synchronises React state with the URL hash.
44
+ *
45
+ * Returns:
46
+ * tab - current tab name ('prototype' | 'screens' | 'changelog' | 'documents')
47
+ * stateKey - state key (string or null)
48
+ * navigateTo - (key: string) => void — navigate to #/state/{key}
49
+ * navigateToTab - (tab: string) => void — navigate to #/{tab} (empty for 'prototype')
50
+ * navigateToScreen - (key: string) => void — navigate to #/screens/{key}
51
+ */
52
+ export function useHashRoute() {
53
+ const [route, setRoute] = useState(() => parseHash(window.location.hash));
54
+
55
+ useEffect(() => {
56
+ function handleHashChange() {
57
+ setRoute(parseHash(window.location.hash));
58
+ }
59
+
60
+ window.addEventListener('hashchange', handleHashChange);
61
+ return () => {
62
+ window.removeEventListener('hashchange', handleHashChange);
63
+ };
64
+ }, []);
65
+
66
+ const navigateTo = useCallback((key) => {
67
+ window.location.hash = `#/state/${key}`;
68
+ setRoute({ tab: 'prototype', stateKey: key });
69
+ }, []);
70
+
71
+ const navigateToTab = useCallback((tab) => {
72
+ if (tab === 'prototype') {
73
+ window.location.hash = '';
74
+ setRoute({ tab: 'prototype', stateKey: null });
75
+ } else {
76
+ window.location.hash = `#/${tab}`;
77
+ setRoute({ tab, stateKey: null });
78
+ }
79
+ }, []);
80
+
81
+ const navigateToScreen = useCallback((key) => {
82
+ window.location.hash = `#/screens/${key}`;
83
+ setRoute({ tab: 'screens', stateKey: key });
84
+ }, []);
85
+
86
+ return {
87
+ tab: route.tab,
88
+ stateKey: route.stateKey,
89
+ navigateTo,
90
+ navigateToTab,
91
+ navigateToScreen,
92
+ };
93
+ }
@@ -0,0 +1,156 @@
1
+ import { useState, useCallback, useRef, useEffect } from 'react';
2
+
3
+ const MIN_ZOOM = 0.1;
4
+ const MAX_ZOOM = 2;
5
+ const INITIAL_ZOOM = 0.35;
6
+
7
+ /**
8
+ * usePanZoom – provides pan and zoom state for a canvas container.
9
+ *
10
+ * @param {React.RefObject} containerRef – ref to the scroll/clip container element
11
+ * @returns {{ zoom, offset, fitToView, handlers }}
12
+ */
13
+ export function usePanZoom(containerRef) {
14
+ const [zoom, setZoom] = useState(INITIAL_ZOOM);
15
+ const [offset, setOffset] = useState({ x: 0, y: 0 });
16
+
17
+ // Pan drag state (stored in a ref to avoid stale closures in mousemove)
18
+ const isPanning = useRef(false);
19
+ const panStart = useRef({ x: 0, y: 0 });
20
+ const offsetAtStart = useRef({ x: 0, y: 0 });
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Wheel: zoom toward cursor (Cmd/Ctrl + wheel only)
24
+ // ---------------------------------------------------------------------------
25
+ const onWheel = useCallback(
26
+ (e) => {
27
+ e.preventDefault();
28
+
29
+ const container = containerRef.current;
30
+ if (!container) return;
31
+
32
+ // Ctrl/Cmd + wheel = zoom toward cursor
33
+ if (e.ctrlKey || e.metaKey) {
34
+ const rect = container.getBoundingClientRect();
35
+ const cursorX = e.clientX - rect.left;
36
+ const cursorY = e.clientY - rect.top;
37
+
38
+ setZoom((prevZoom) => {
39
+ const delta = -e.deltaY * 0.001;
40
+ const nextZoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, prevZoom + delta * prevZoom));
41
+
42
+ setOffset((prevOffset) => ({
43
+ x: cursorX - (cursorX - prevOffset.x) * (nextZoom / prevZoom),
44
+ y: cursorY - (cursorY - prevOffset.y) * (nextZoom / prevZoom),
45
+ }));
46
+
47
+ return nextZoom;
48
+ });
49
+ } else {
50
+ // Regular scroll/trackpad = pan
51
+ setOffset((prev) => ({
52
+ x: prev.x - e.deltaX,
53
+ y: prev.y - e.deltaY,
54
+ }));
55
+ }
56
+ },
57
+ [containerRef],
58
+ );
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Pan – mouse drag
62
+ // ---------------------------------------------------------------------------
63
+ const onMouseDown = useCallback((e) => {
64
+ // Skip if the click target is an interactive element
65
+ if (e.target.closest('a, button')) return;
66
+
67
+ isPanning.current = true;
68
+ panStart.current = { x: e.clientX, y: e.clientY };
69
+ offsetAtStart.current = { x: 0, y: 0 }; // will be set from current offset below
70
+
71
+ // Capture current offset in the ref so mousemove can reference it
72
+ setOffset((prev) => {
73
+ offsetAtStart.current = prev;
74
+ return prev;
75
+ });
76
+
77
+ document.body.style.userSelect = 'none';
78
+ }, []);
79
+
80
+ const onMouseMove = useCallback((e) => {
81
+ if (!isPanning.current) return;
82
+
83
+ const dx = e.clientX - panStart.current.x;
84
+ const dy = e.clientY - panStart.current.y;
85
+
86
+ setOffset({
87
+ x: offsetAtStart.current.x + dx,
88
+ y: offsetAtStart.current.y + dy,
89
+ });
90
+ }, []);
91
+
92
+ const endPan = useCallback(() => {
93
+ if (!isPanning.current) return;
94
+ isPanning.current = false;
95
+ document.body.style.userSelect = '';
96
+ }, []);
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // fitToView – measures content vs container and calculates a zoom to fit
100
+ // ---------------------------------------------------------------------------
101
+ const contentRef = useRef(null);
102
+
103
+ const fitToView = useCallback(
104
+ (ref) => {
105
+ // Allow passing an explicit content ref, or use the stored one
106
+ const contentEl = (ref || contentRef).current;
107
+ const containerEl = containerRef.current;
108
+ if (!contentEl || !containerEl) return;
109
+
110
+ const containerRect = containerEl.getBoundingClientRect();
111
+ // Measure content at zoom=1 (natural size)
112
+ const naturalWidth = contentEl.scrollWidth;
113
+ const naturalHeight = contentEl.scrollHeight;
114
+
115
+ const margin = 0.9;
116
+ const zoomX = (containerRect.width / naturalWidth) * margin;
117
+ const zoomY = (containerRect.height / naturalHeight) * margin;
118
+ const nextZoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, Math.min(zoomX, zoomY)));
119
+
120
+ // Center the content
121
+ const scaledWidth = naturalWidth * nextZoom;
122
+ const scaledHeight = naturalHeight * nextZoom;
123
+ const offsetX = (containerRect.width - scaledWidth) / 2;
124
+ const offsetY = (containerRect.height - scaledHeight) / 2;
125
+
126
+ setZoom(nextZoom);
127
+ setOffset({ x: offsetX, y: offsetY });
128
+ },
129
+ [containerRef],
130
+ );
131
+
132
+ // Attach wheel listener with { passive: false } so preventDefault() works
133
+ useEffect(() => {
134
+ const el = containerRef.current;
135
+ if (!el) return;
136
+ el.addEventListener('wheel', onWheel, { passive: false });
137
+ return () => el.removeEventListener('wheel', onWheel);
138
+ }, [onWheel, containerRef]);
139
+
140
+ return {
141
+ zoom,
142
+ offset,
143
+ setZoom,
144
+ setOffset,
145
+ fitToView,
146
+ contentRef,
147
+ handlers: {
148
+ onMouseDown,
149
+ onMouseMove,
150
+ onMouseUp: endPan,
151
+ onMouseLeave: endPan,
152
+ },
153
+ };
154
+ }
155
+
156
+ export default usePanZoom;
@@ -0,0 +1,21 @@
1
+ import { useState, useEffect } from 'react';
2
+
3
+ export function useTheme() {
4
+ const [theme, setTheme] = useState('light');
5
+
6
+ useEffect(() => {
7
+ if (theme === 'dark') {
8
+ document.documentElement.classList.add('dark');
9
+ } else {
10
+ document.documentElement.classList.remove('dark');
11
+ }
12
+ }, [theme]);
13
+
14
+ const toggleTheme = () => {
15
+ setTheme((prev) => (prev === 'light' ? 'dark' : 'light'));
16
+ };
17
+
18
+ const isDark = theme === 'dark';
19
+
20
+ return { theme, isDark, toggleTheme };
21
+ }