handoff-app 1.0.4 → 1.0.5

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/create.ts ADDED
@@ -0,0 +1,302 @@
1
+ import * as p from '@clack/prompts';
2
+ import chalk from 'chalk';
3
+ import { spawn } from 'child_process';
4
+ import * as fs from 'fs';
5
+ import * as path from 'path';
6
+
7
+ /**
8
+ * Get the current handoff-app version from package.json
9
+ */
10
+ const getVersion = (): string => {
11
+ try {
12
+ const packageJsonPath = path.resolve(__dirname, '../package.json');
13
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
14
+ return packageJson.version || 'unknown';
15
+ } catch {
16
+ return 'unknown';
17
+ }
18
+ };
19
+
20
+ interface CreateOptions {
21
+ projectName: string;
22
+ figmaProjectId: string;
23
+ figmaAccessToken: string;
24
+ [key: string]: string; // Allow dynamic access for template replacement
25
+ }
26
+
27
+ class CreateError extends Error {
28
+ constructor(message: string) {
29
+ super(message);
30
+ this.name = 'CreateError';
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Check if directory exists
36
+ */
37
+ const directoryExists = (dirPath: string): boolean => {
38
+ try {
39
+ return fs.existsSync(dirPath);
40
+ } catch {
41
+ return false;
42
+ }
43
+ };
44
+
45
+ /**
46
+ * Create directory recursively
47
+ */
48
+ const createDirectory = (dirPath: string): void => {
49
+ try {
50
+ fs.mkdirSync(dirPath, { recursive: true });
51
+ } catch (error) {
52
+ throw new CreateError(`Failed to create directory: ${error}`);
53
+ }
54
+ };
55
+
56
+ interface TemplateConfig {
57
+ source: string;
58
+ destination: string;
59
+ isExample?: boolean; // Flag to indicate if this is an example file
60
+ }
61
+
62
+ /**
63
+ * Get mandatory templates based on whether examples are included
64
+ * Uses different handoff config templates for blank vs. with-examples projects
65
+ */
66
+ const getMandatoryTemplates = (includeExamples: boolean): TemplateConfig[] => [
67
+ { source: 'package.json.tpl', destination: 'package.json' },
68
+ {
69
+ source: includeExamples ? 'handoff.config.with-examples.js.tpl' : 'handoff.config.js.tpl',
70
+ destination: 'handoff.config.js'
71
+ },
72
+ { source: 'tsconfig.json.tpl', destination: 'tsconfig.json' },
73
+ { source: 'gitignore.tpl', destination: '.gitignore' },
74
+ { source: 'env.tpl', destination: '.env' },
75
+ { source: 'README.md.tpl', destination: 'README.md' },
76
+ ];
77
+
78
+ // Example component files (only included when user opts for examples)
79
+ const exampleTemplates: TemplateConfig[] = [
80
+ { source: 'components/button/Button.tsx.tpl', destination: 'components/button/Button.tsx', isExample: true },
81
+ { source: 'components/button/button.js.tpl', destination: 'components/button/button.js', isExample: true },
82
+ ];
83
+
84
+ /**
85
+ * Render a template string by replacing placeholders with values
86
+ */
87
+ const renderTemplate = (content: string, data: CreateOptions): string => {
88
+ return content.replace(/\{\{(\w+)\}\}/g, (_, key) => {
89
+ if (!(key in data)) {
90
+ console.warn(chalk.yellow(`Warning: Unknown template placeholder '{{${key}}}'`));
91
+ }
92
+ return data[key] || '';
93
+ });
94
+ };
95
+
96
+ /**
97
+ * Execute npm install
98
+ */
99
+ const runNpmInstall = async (projectPath: string): Promise<void> => {
100
+ const spinner = p.spinner();
101
+ spinner.start('Installing dependencies...');
102
+
103
+ return new Promise((resolve, reject) => {
104
+ const npmProcess = spawn('npm', ['install'], {
105
+ cwd: projectPath,
106
+ stdio: 'pipe',
107
+ shell: true,
108
+ });
109
+
110
+ let stderr = '';
111
+
112
+ npmProcess.stderr?.on('data', (data) => {
113
+ stderr += data.toString();
114
+ });
115
+
116
+ npmProcess.on('close', (code) => {
117
+ if (code === 0) {
118
+ spinner.stop('Dependencies installed successfully');
119
+ resolve();
120
+ } else {
121
+ spinner.stop('Failed to install dependencies');
122
+ reject(new CreateError(`npm install failed: ${stderr}`));
123
+ }
124
+ });
125
+
126
+ npmProcess.on('error', (error) => {
127
+ spinner.stop('Failed to install dependencies');
128
+ reject(new CreateError(`Failed to run npm install: ${error.message}`));
129
+ });
130
+ });
131
+ };
132
+
133
+ /**
134
+ * Validate project name
135
+ */
136
+ const validateProjectName = (name: string): boolean => {
137
+ const validNameRegex = /^[a-zA-Z0-9-_]+$/;
138
+ return validNameRegex.test(name) && name.length > 0;
139
+ };
140
+
141
+ /**
142
+ * Main create function
143
+ */
144
+ const create = async (): Promise<void> => {
145
+ const version = getVersion();
146
+ p.intro(chalk.bgCyan.black(` Handoff App Creator (v${version}) `));
147
+
148
+ try {
149
+ // Get project name
150
+ const projectName = await p.text({
151
+ message: 'Enter project name:',
152
+ placeholder: 'my-handoff-project',
153
+ validate: (value) => {
154
+ if (!value.trim()) return 'Project name is required';
155
+ if (!validateProjectName(value)) {
156
+ return 'Invalid project name. Use only letters, numbers, hyphens, and underscores.';
157
+ }
158
+ },
159
+ });
160
+
161
+ if (p.isCancel(projectName)) {
162
+ p.cancel('Project creation cancelled.');
163
+ process.exit(0);
164
+ }
165
+
166
+ const projectPath = path.resolve(process.cwd(), projectName as string);
167
+
168
+ // Check if directory exists
169
+ if (directoryExists(projectPath)) {
170
+ const overwrite = await p.confirm({
171
+ message: `Directory "${projectName}" already exists. Do you want to overwrite it?`,
172
+ initialValue: false,
173
+ });
174
+
175
+ if (p.isCancel(overwrite) || !overwrite) {
176
+ p.cancel('Project creation cancelled.');
177
+ process.exit(0);
178
+ }
179
+
180
+ // Remove existing directory
181
+ p.log.info('Removing existing directory...');
182
+ fs.rmSync(projectPath, { recursive: true, force: true });
183
+ }
184
+
185
+ // Create project directory
186
+ p.log.info('Creating project directory...');
187
+ createDirectory(projectPath);
188
+
189
+ // Ask if user wants a blank project or one with examples
190
+ p.log.step(chalk.blue('Project Configuration'));
191
+ const projectType = await p.select({
192
+ message: 'What type of project would you like to create?',
193
+ options: [
194
+ { value: 'with-examples', label: 'Project with sample components', hint: 'Includes example components to get started' },
195
+ { value: 'blank', label: 'Blank project', hint: 'Only mandatory files, no examples' },
196
+ ],
197
+ initialValue: 'with-examples',
198
+ });
199
+
200
+ if (p.isCancel(projectType)) {
201
+ p.cancel('Project creation cancelled.');
202
+ process.exit(0);
203
+ }
204
+
205
+ const includeExamples = projectType === 'with-examples';
206
+
207
+ // Get Figma project ID
208
+ p.log.step(chalk.blue('Figma Configuration'));
209
+ const figmaProjectId = await p.text({
210
+ message: 'Enter Figma project ID:',
211
+ placeholder: 'Optional - can be configured later',
212
+ });
213
+
214
+ if (p.isCancel(figmaProjectId)) {
215
+ p.cancel('Project creation cancelled.');
216
+ process.exit(0);
217
+ }
218
+
219
+ // Get Figma access token
220
+ let figmaAccessToken: string | boolean | symbol = '';
221
+
222
+ if (figmaProjectId) {
223
+ figmaAccessToken = await p.password({
224
+ message: 'Enter Figma developer access token:',
225
+ });
226
+
227
+ if (p.isCancel(figmaAccessToken)) {
228
+ p.cancel('Project creation cancelled.');
229
+ process.exit(0);
230
+ }
231
+ }
232
+
233
+ const options: CreateOptions = {
234
+ projectName: projectName as string,
235
+ figmaProjectId: ((figmaProjectId as string) || '').trim(),
236
+ figmaAccessToken: ((figmaAccessToken as string) || '').trim(),
237
+ };
238
+
239
+ // Create files
240
+ const spinner = p.spinner();
241
+ spinner.start('Creating project files...');
242
+
243
+ const templateDir = path.resolve(__dirname, '../templates/default');
244
+
245
+ if (!fs.existsSync(templateDir)) {
246
+ throw new CreateError(`Template directory not found at ${templateDir}. This may indicate a packaging issue.`);
247
+ }
248
+
249
+ // Combine mandatory templates with example templates based on user choice
250
+ const templates = [
251
+ ...getMandatoryTemplates(includeExamples),
252
+ ...(includeExamples ? exampleTemplates : []),
253
+ ];
254
+
255
+ try {
256
+ for (const template of templates) {
257
+ const templatePath = path.join(templateDir, template.source);
258
+ const destinationPath = path.join(projectPath, template.destination);
259
+
260
+ try {
261
+ const content = fs.readFileSync(templatePath, 'utf8');
262
+ const rendered = renderTemplate(content, options);
263
+ fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
264
+ fs.writeFileSync(destinationPath, rendered);
265
+ } catch (err) {
266
+ throw new CreateError(`Failed to create file ${template.destination}: ${err}`);
267
+ }
268
+ }
269
+ spinner.stop('Project files created');
270
+ } catch (err) {
271
+ spinner.stop('Failed to create project files');
272
+ throw err;
273
+ }
274
+
275
+ // Install dependencies
276
+ await runNpmInstall(projectPath);
277
+
278
+ // Success message
279
+ const nextSteps = [`cd ${projectName}`];
280
+ if (figmaProjectId && figmaAccessToken) {
281
+ nextSteps.push('npm run fetch');
282
+ }
283
+ nextSteps.push('npm run start');
284
+
285
+ p.note(nextSteps.join('\n'), 'Next steps');
286
+
287
+ const outroMessage = includeExamples
288
+ ? 'Project created successfully with sample components! First, fetch your Figma data (optional), then start the documentation site.'
289
+ : 'Blank project created successfully! First, fetch your Figma data, then start the documentation site.';
290
+
291
+ p.outro(chalk.green(outroMessage));
292
+ } catch (error) {
293
+ if (error instanceof CreateError) {
294
+ p.cancel(chalk.red(`Error: ${error.message}`));
295
+ } else {
296
+ p.cancel(chalk.red(`Unexpected error: ${error}`));
297
+ }
298
+ process.exit(1);
299
+ }
300
+ };
301
+
302
+ export default create;
@@ -0,0 +1,15 @@
1
+ # {{projectName}}
2
+
3
+ A design system documentation site powered by Handoff App.
4
+
5
+ ## Getting Started
6
+
7
+ 1. Configure your Figma credentials in `.env`
8
+ 2. Run `npm run fetch` to pull design tokens
9
+ 3. Run `npm run start` to launch the documentation site
10
+
11
+ ## Scripts
12
+
13
+ - `npm run fetch` - Fetch design data from Figma
14
+ - `npm run start` - Start the documentation server
15
+ - `npm run build` - Build for production
@@ -0,0 +1,92 @@
1
+ import React, { useState } from 'react';
2
+
3
+ interface ButtonProps {
4
+ type?: 'primary' | 'secondary';
5
+ children: React.ReactNode;
6
+ disabled?: boolean;
7
+ className?: string;
8
+ showCounter?: boolean;
9
+ }
10
+
11
+ const Button: React.FC<ButtonProps> = ({
12
+ type = 'primary',
13
+ children,
14
+ disabled = false,
15
+ className = '',
16
+ showCounter = true
17
+ }) => {
18
+ const [clickCount, setClickCount] = useState(0);
19
+
20
+ const baseColor = type === 'primary' ? '#4f46e5' : '#dc2626';
21
+ const hoverColor = type === 'primary' ? '#4338ca' : '#b91c1c';
22
+
23
+ const handleClick = () => {
24
+ if (!disabled) {
25
+ setClickCount(prev => prev + 1);
26
+ }
27
+ };
28
+
29
+ const variantStyle: React.CSSProperties = {
30
+ backgroundColor: baseColor,
31
+ color: '#ffffff',
32
+ border: 'none',
33
+ borderRadius: '100px',
34
+ fontSize: '1rem',
35
+ padding: '0.625rem 1rem',
36
+ outline: 'none',
37
+ cursor: disabled ? 'not-allowed' : 'pointer',
38
+ opacity: disabled ? 0.6 : 1,
39
+ transition: 'all 0.15s ease-in-out',
40
+ fontWeight: 500,
41
+ display: 'inline-flex',
42
+ alignItems: 'center',
43
+ gap: '8px'
44
+ };
45
+
46
+ const wrapperStyle: React.CSSProperties = {
47
+ padding: '8px 0'
48
+ };
49
+
50
+ const counterBadgeStyle: React.CSSProperties = {
51
+ backgroundColor: 'rgba(255, 255, 255, 0.3)',
52
+ borderRadius: '12px',
53
+ padding: '2px 8px',
54
+ fontSize: '0.875rem',
55
+ fontWeight: 600,
56
+ minWidth: '24px',
57
+ textAlign: 'center'
58
+ };
59
+
60
+ return (
61
+ <div style={wrapperStyle}>
62
+ <button
63
+ type="button"
64
+ disabled={disabled}
65
+ style={variantStyle}
66
+ className={`btn btn-${type} ${className}`.trim()}
67
+ onClick={handleClick}
68
+ onMouseEnter={(e) => {
69
+ if (!disabled) {
70
+ e.currentTarget.style.backgroundColor = hoverColor;
71
+ e.currentTarget.style.transform = 'translateY(-1px)';
72
+ }
73
+ }}
74
+ onMouseLeave={(e) => {
75
+ if (!disabled) {
76
+ e.currentTarget.style.backgroundColor = baseColor;
77
+ e.currentTarget.style.transform = 'translateY(0)';
78
+ }
79
+ }}
80
+ >
81
+ <span>{children}</span>
82
+ {showCounter && (
83
+ <span style={counterBadgeStyle}>
84
+ {clickCount}
85
+ </span>
86
+ )}
87
+ </button>
88
+ </div>
89
+ );
90
+ };
91
+
92
+ export default Button;
@@ -0,0 +1,62 @@
1
+ /** @type {import('handoff-app').Component} */
2
+ module.exports = {
3
+ title: "Button",
4
+ description: "Interactive buttons that trigger actions and allow users to complete tasks, such as submitting a form or confirming a choice. This component includes click tracking to demonstrate stateful component behavior.",
5
+ group: "Atomic Elements",
6
+ type: "element",
7
+ categories: [
8
+ "functionality",
9
+ "interactive"
10
+ ],
11
+ entries: {
12
+ template: "Button.tsx",
13
+ // schema: "Schema.ts"
14
+ },
15
+ // Intentionally omit figmaComponentId so docs variants are not sourced from Figma.
16
+ previews: {
17
+ primary: {
18
+ title: "Primary Button",
19
+ values: {
20
+ type: "primary",
21
+ children: "Click me!",
22
+ showCounter: true
23
+ }
24
+ },
25
+ secondary: {
26
+ title: "Secondary Button",
27
+ values: {
28
+ type: "secondary",
29
+ children: "Click me too!",
30
+ showCounter: true
31
+ }
32
+ },
33
+ primaryDisabled: {
34
+ title: "Disabled State",
35
+ values: {
36
+ type: "primary",
37
+ children: "Disabled",
38
+ disabled: true,
39
+ showCounter: true
40
+ }
41
+ },
42
+ withoutCounter: {
43
+ title: "Without Counter",
44
+ values: {
45
+ type: "primary",
46
+ children: "Submit",
47
+ showCounter: false
48
+ }
49
+ }
50
+ },
51
+ should_do: [
52
+ "Keep one primary button per section so main action is clear.",
53
+ "Use the secondary variant for less important actions.",
54
+ "Provide clear, action-oriented labels that describe what will happen.",
55
+ "Show visual feedback on hover and click interactions."
56
+ ],
57
+ should_not_do: [
58
+ "Do not use vague labels like Click here or OK when the action can be specific.",
59
+ "Do not mix primary and secondary styles inconsistently.",
60
+ "Do not disable buttons without providing alternative guidance or feedback."
61
+ ]
62
+ };
@@ -0,0 +1,2 @@
1
+ HANDOFF_FIGMA_PROJECT_ID={{figmaProjectId}}
2
+ HANDOFF_DEV_ACCESS_TOKEN={{figmaAccessToken}}
@@ -0,0 +1,7 @@
1
+ node_modules/
2
+ .env
3
+ dist/
4
+ .next/
5
+ out/
6
+ .DS_Store
7
+ *.log
@@ -0,0 +1,181 @@
1
+
2
+
3
+ /** @type {import('handoff-app').Config} */
4
+ module.exports = {
5
+ app: {
6
+ theme: "default",
7
+ title: "{{projectName}} Design System",
8
+ client: "{{projectName}}",
9
+ google_tag_manager: null,
10
+ attribution: true,
11
+ type_copy: "Almost before we knew it, we had left the ground.",
12
+ type_sort: [
13
+ "Heading 1",
14
+ "Heading 2",
15
+ "Heading 3",
16
+ "Heading 4",
17
+ "Heading 5",
18
+ "Heading 6",
19
+ "Paragraph",
20
+ "Subheading",
21
+ "Blockquote",
22
+ "Input Labels",
23
+ "Link"
24
+ ],
25
+ color_sort: [
26
+ "primary",
27
+ "secondary",
28
+ "extra",
29
+ "system"
30
+ ],
31
+ component_sort: [
32
+ "primary",
33
+ "secondary",
34
+ "transparent"
35
+ ],
36
+ base_path: "",
37
+ breakpoints: {
38
+ sm: { size: 576, name: "Small" },
39
+ md: { size: 768, name: "Medium" },
40
+ lg: { size: 992, name: "Large" },
41
+ xl: { size: 1200, name: "Extra Large" }
42
+ }
43
+ },
44
+
45
+ // entries: {
46
+ // /**
47
+ // * Array of component paths to be included in the build
48
+ // * Add your component directories here
49
+ // */
50
+ // components: ["components/button"]
51
+ // },
52
+
53
+ hooks: {
54
+ /**
55
+ * Optional validation callback for components
56
+ * @param component - The component instance to validate
57
+ * @returns A record of validation results where keys are validation types and values are detailed validation results
58
+ *
59
+ * @example
60
+ * validateComponent: async (component) => ({
61
+ * a11y: {
62
+ * description: 'Accessibility validation check',
63
+ * passed: true,
64
+ * messages: ['No accessibility issues found']
65
+ * },
66
+ * responsive: {
67
+ * description: 'Responsive design validation',
68
+ * passed: false,
69
+ * messages: ['Component breaks at mobile breakpoint']
70
+ * }
71
+ * })
72
+ */
73
+ // validateComponent: async (component) => {
74
+ // // Add your custom validation logic here
75
+ // return {};
76
+ // },
77
+
78
+ /**
79
+ * Optional hook to override the SSR build configuration used in the ssrRenderPlugin
80
+ * @param config - The default esbuild configuration
81
+ * @returns Modified esbuild configuration
82
+ *
83
+ * @example
84
+ * ssrBuildConfig: (config) => {
85
+ * // Modify the esbuild config as needed
86
+ * return config;
87
+ * }
88
+ */
89
+ // ssrBuildConfig: (config) => {
90
+ // // Add your custom SSR build configuration here
91
+ // return config;
92
+ // },
93
+
94
+ /**
95
+ * Optional hook to override the client-side build configuration used in the ssrRenderPlugin
96
+ * @param config - The default esbuild configuration
97
+ * @returns Modified esbuild configuration
98
+ *
99
+ * @example
100
+ * clientBuildConfig: (config) => {
101
+ * // Modify the esbuild config as needed
102
+ * return config;
103
+ * }
104
+ */
105
+ // clientBuildConfig: (config) => {
106
+ // // Add your custom client build configuration here
107
+ // return config;
108
+ // },
109
+
110
+ /**
111
+ * Optional hook to specify which export property contains the schema
112
+ * @param exports - The module exports object containing the schema
113
+ * @returns The schema object from the exports
114
+ *
115
+ * @example
116
+ * getSchemaFromExports: (exports) => exports.customSchema || exports.default
117
+ */
118
+ // getSchemaFromExports: (exports) => {
119
+ // // Add your custom schema extraction logic here
120
+ // return exports.default;
121
+ // },
122
+
123
+ /**
124
+ * Optional hook to transform the schema into properties
125
+ * @param schema - The schema object to transform
126
+ * @returns The transformed properties object
127
+ */
128
+ // schemaToProperties: (schema) => {
129
+ // // Add your custom schema to properties transformation here
130
+ // return {};
131
+ // },
132
+
133
+ /**
134
+ * Optional hook to override the JavaScript Vite configuration
135
+ * @param config - The default Vite configuration
136
+ * @returns Modified Vite configuration
137
+ *
138
+ * @example
139
+ * jsBuildConfig: (config) => {
140
+ * // Modify the Vite config as needed
141
+ * return config;
142
+ * }
143
+ */
144
+ // jsBuildConfig: (config) => {
145
+ // // Add your custom JavaScript build configuration here
146
+ // return config;
147
+ // },
148
+
149
+ /**
150
+ * Optional hook to override the CSS Vite configuration
151
+ * @param config - The default Vite configuration
152
+ * @returns Modified Vite configuration
153
+ *
154
+ * @example
155
+ * cssBuildConfig: (config) => {
156
+ * // Modify the Vite config as needed
157
+ * return config;
158
+ * }
159
+ */
160
+ // cssBuildConfig: (config) => {
161
+ // // Add your custom CSS build configuration here
162
+ // return config;
163
+ // },
164
+
165
+ /**
166
+ * Optional hook to override the HTML Vite configuration
167
+ * @param config - The default Vite configuration
168
+ * @returns Modified Vite configuration
169
+ *
170
+ * @example
171
+ * htmlBuildConfig: (config) => {
172
+ * // Modify the Vite config as needed
173
+ * return config;
174
+ * }
175
+ */
176
+ // htmlBuildConfig: (config) => {
177
+ // // Add your custom HTML build configuration here
178
+ // return config;
179
+ // },
180
+ },
181
+ };