krz/brand-bench

A dynamic brand documentation generator with Ollama integration. branding documentation generator ollama

Commit 14818e090e

14818e090e468685a7240126c671292fbf99dd10

Unsigned

cmc <hello@cleberg.net> · 2026-04-19 03:32 UTC

initial commit

Layout: unified · split

LICENSE added +21
@@ -0,0 +1,21 @@
1MIT License
2
3Copyright (c) 2026 ZeroLabs
4
5Permission is hereby granted, free of charge, to any person obtaining a copy
6of this software and associated documentation files (the "Software"), to deal
7in the Software without restriction, including without limitation the rights
8to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9copies of the Software, and to permit persons to whom the Software is
10furnished to do so, subject to the following conditions:
11
12The above copyright notice and this permission notice shall be included in all
13copies or substantial portions of the Software.
14
15THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21SOFTWARE.
README.md added +108
@@ -0,0 +1,108 @@
1# Brand Bench
2
3A client-side brand identity generator. Describe a project and get a complete
4brand package — positioning, tone of voice, color palette, typography system,
5logo concepts, taglines, and usage examples — instantly in the browser.
6
7No backend. No accounts. Everything runs locally and persists in `localStorage`.
8
9![Brandbench screenshot](docs/screenshot.png)
10
11## Features
12
13- **Template generation** — instant, offline-capable brand packages tailored by
14 category (developer tool, creative studio, SaaS product, etc.)
15- **AI generation** — connect your local [Ollama](https://ollama.com) instance
16 for LLM-powered output; cancellable mid-stream
17- **Multiple packages** — create, rename, duplicate, and switch between brand
18 packages in tabs; each package is independently persisted
19- **Inline editing** — click any field in the preview to edit it directly
20- **Section locking** — lock sections before regenerating so they stay unchanged
21 across runs
22- **Undo / redo** — full edit history per package (`⌘Z` / `⌘⇧Z`)
23- **Color palette** — editable swatches with a native color picker
24- **Typography system** — curated font pairings per category with an 8-level
25 type scale
26- **Export** — download as Markdown, JSON, or a self-contained HTML guidelines page
27
28## Getting started
29
30```bash
31npm install
32npm run dev
33```
34
35Open `http://localhost:5173`.
36
37To build for production:
38
39```bash
40npm run build # outputs to dist/
41npm run preview # serve the build locally
42```
43
44## AI with Ollama
45
46Brandbench can generate brand packages using a locally running Ollama model.
47
481. [Install Ollama](https://ollama.com/download) and pull a model:
49
50 ```bash
51 ollama pull llama3.2 # recommended default
52 ollama pull mistral # good alternative
53 ollama pull gemma3 # another option
54 ```
55
562. Make sure Ollama is running:
57
58 ```bash
59 ollama serve
60 ```
61
623. If you're running Ollama on a non-default port or a different host, set the
63 `OLLAMA_ORIGINS` environment variable to allow browser requests:
64
65 ```bash
66 OLLAMA_ORIGINS="*" ollama serve
67 ```
68
694. Open Settings (⚙) in the app, enable AI, set your base URL
70 (`http://localhost:11434` by default), select a model, and click **Test
71 connection**.
72
73Larger models produce better-structured output. If generation fails with a JSON
74error, try a bigger model. Generation typically takes 15–60 seconds depending on
75hardware.
76
77## Project structure
78
79```
80src/
81 engine/
82 generator.ts # Template-based brand package generator
83 aiGenerator.ts # Ollama integration
84 hooks/
85 useWorkspace.ts # Per-package state, generate, undo/redo
86 usePackages.ts # Multi-package tabs and localStorage slots
87 useSettings.ts # Ollama settings persistence
88 components/
89 InputPanel.tsx # Project details form
90 PreviewPanel.tsx # Brand package preview with toolbar
91 BrandDoc.tsx # Full brand document (sections, palette, type scale)
92 PackageSwitcher.tsx # Tab bar with rename/duplicate/delete
93 SettingsPanel.tsx # AI settings drawer
94 lib/
95 sanitize.ts # Defensive coercion for AI output fields
96 export.ts # Markdown / JSON / HTML export formatters
97 types.ts # All shared TypeScript interfaces
98```
99
100## Tech stack
101
102- [React 18](https://react.dev) + [TypeScript](https://www.typescriptlang.org)
103- [Vite](https://vitejs.dev)
104- No UI library — plain CSS with custom properties
105
106## License
107
108MIT
docs/screenshot.png added

Binary file not shown.

index.html added +12
@@ -0,0 +1,12 @@
1<!doctype html>
2<html lang="en">
3 <head>
4 <meta charset="UTF-8" />
5 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6 <title>Brand Workbench</title>
7 </head>
8 <body>
9 <div id="root"></div>
10 <script type="module" src="/src/main.tsx"></script>
11 </body>
12</html>
package-lock.json added +1768
@@ -0,0 +1,1768 @@
1{
2 "name": "brand-workbench",
3 "version": "0.1.0",
4 "lockfileVersion": 3,
5 "requires": true,
6 "packages": {
7 "": {
8 "name": "brand-workbench",
9 "version": "0.1.0",
10 "dependencies": {
11 "react": "^18.3.1",
12 "react-dom": "^18.3.1"
13 },
14 "devDependencies": {
15 "@types/react": "^18.3.1",
16 "@types/react-dom": "^18.3.1",
17 "@vitejs/plugin-react": "^4.3.1",
18 "typescript": "^5.5.3",
19 "vite": "^5.4.1"
20 }
21 },
22 "node_modules/@babel/code-frame": {
23 "version": "7.29.0",
24 "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
25 "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
26 "dev": true,
27 "license": "MIT",
28 "dependencies": {
29 "@babel/helper-validator-identifier": "^7.28.5",
30 "js-tokens": "^4.0.0",
31 "picocolors": "^1.1.1"
32 },
33 "engines": {
34 "node": ">=6.9.0"
35 }
36 },
37 "node_modules/@babel/compat-data": {
38 "version": "7.29.0",
39 "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
40 "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
41 "dev": true,
42 "license": "MIT",
43 "engines": {
44 "node": ">=6.9.0"
45 }
46 },
47 "node_modules/@babel/core": {
48 "version": "7.29.0",
49 "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
50 "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
51 "dev": true,
52 "license": "MIT",
53 "dependencies": {
54 "@babel/code-frame": "^7.29.0",
55 "@babel/generator": "^7.29.0",
56 "@babel/helper-compilation-targets": "^7.28.6",
57 "@babel/helper-module-transforms": "^7.28.6",
58 "@babel/helpers": "^7.28.6",
59 "@babel/parser": "^7.29.0",
60 "@babel/template": "^7.28.6",
61 "@babel/traverse": "^7.29.0",
62 "@babel/types": "^7.29.0",
63 "@jridgewell/remapping": "^2.3.5",
64 "convert-source-map": "^2.0.0",
65 "debug": "^4.1.0",
66 "gensync": "^1.0.0-beta.2",
67 "json5": "^2.2.3",
68 "semver": "^6.3.1"
69 },
70 "engines": {
71 "node": ">=6.9.0"
72 },
73 "funding": {
74 "type": "opencollective",
75 "url": "https://opencollective.com/babel"
76 }
77 },
78 "node_modules/@babel/generator": {
79 "version": "7.29.1",
80 "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
81 "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
82 "dev": true,
83 "license": "MIT",
84 "dependencies": {
85 "@babel/parser": "^7.29.0",
86 "@babel/types": "^7.29.0",
87 "@jridgewell/gen-mapping": "^0.3.12",
88 "@jridgewell/trace-mapping": "^0.3.28",
89 "jsesc": "^3.0.2"
90 },
91 "engines": {
92 "node": ">=6.9.0"
93 }
94 },
95 "node_modules/@babel/helper-compilation-targets": {
96 "version": "7.28.6",
97 "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
98 "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
99 "dev": true,
100 "license": "MIT",
101 "dependencies": {
102 "@babel/compat-data": "^7.28.6",
103 "@babel/helper-validator-option": "^7.27.1",
104 "browserslist": "^4.24.0",
105 "lru-cache": "^5.1.1",
106 "semver": "^6.3.1"
107 },
108 "engines": {
109 "node": ">=6.9.0"
110 }
111 },
112 "node_modules/@babel/helper-globals": {
113 "version": "7.28.0",
114 "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
115 "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
116 "dev": true,
117 "license": "MIT",
118 "engines": {
119 "node": ">=6.9.0"
120 }
121 },
122 "node_modules/@babel/helper-module-imports": {
123 "version": "7.28.6",
124 "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
125 "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
126 "dev": true,
127 "license": "MIT",
128 "dependencies": {
129 "@babel/traverse": "^7.28.6",
130 "@babel/types": "^7.28.6"
131 },
132 "engines": {
133 "node": ">=6.9.0"
134 }
135 },
136 "node_modules/@babel/helper-module-transforms": {
137 "version": "7.28.6",
138 "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
139 "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
140 "dev": true,
141 "license": "MIT",
142 "dependencies": {
143 "@babel/helper-module-imports": "^7.28.6",
144 "@babel/helper-validator-identifier": "^7.28.5",
145 "@babel/traverse": "^7.28.6"
146 },
147 "engines": {
148 "node": ">=6.9.0"
149 },
150 "peerDependencies": {
151 "@babel/core": "^7.0.0"
152 }
153 },
154 "node_modules/@babel/helper-plugin-utils": {
155 "version": "7.28.6",
156 "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
157 "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
158 "dev": true,
159 "license": "MIT",
160 "engines": {
161 "node": ">=6.9.0"
162 }
163 },
164 "node_modules/@babel/helper-string-parser": {
165 "version": "7.27.1",
166 "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
167 "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
168 "dev": true,
169 "license": "MIT",
170 "engines": {
171 "node": ">=6.9.0"
172 }
173 },
174 "node_modules/@babel/helper-validator-identifier": {
175 "version": "7.28.5",
176 "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
177 "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
178 "dev": true,
179 "license": "MIT",
180 "engines": {
181 "node": ">=6.9.0"
182 }
183 },
184 "node_modules/@babel/helper-validator-option": {
185 "version": "7.27.1",
186 "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
187 "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
188 "dev": true,
189 "license": "MIT",
190 "engines": {
191 "node": ">=6.9.0"
192 }
193 },
194 "node_modules/@babel/helpers": {
195 "version": "7.29.2",
196 "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz",
197 "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
198 "dev": true,
199 "license": "MIT",
200 "dependencies": {
201 "@babel/template": "^7.28.6",
202 "@babel/types": "^7.29.0"
203 },
204 "engines": {
205 "node": ">=6.9.0"
206 }
207 },
208 "node_modules/@babel/parser": {
209 "version": "7.29.2",
210 "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
211 "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
212 "dev": true,
213 "license": "MIT",
214 "dependencies": {
215 "@babel/types": "^7.29.0"
216 },
217 "bin": {
218 "parser": "bin/babel-parser.js"
219 },
220 "engines": {
221 "node": ">=6.0.0"
222 }
223 },
224 "node_modules/@babel/plugin-transform-react-jsx-self": {
225 "version": "7.27.1",
226 "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
227 "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
228 "dev": true,
229 "license": "MIT",
230 "dependencies": {
231 "@babel/helper-plugin-utils": "^7.27.1"
232 },
233 "engines": {
234 "node": ">=6.9.0"
235 },
236 "peerDependencies": {
237 "@babel/core": "^7.0.0-0"
238 }
239 },
240 "node_modules/@babel/plugin-transform-react-jsx-source": {
241 "version": "7.27.1",
242 "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
243 "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
244 "dev": true,
245 "license": "MIT",
246 "dependencies": {
247 "@babel/helper-plugin-utils": "^7.27.1"
248 },
249 "engines": {
250 "node": ">=6.9.0"
251 },
252 "peerDependencies": {
253 "@babel/core": "^7.0.0-0"
254 }
255 },
256 "node_modules/@babel/template": {
257 "version": "7.28.6",
258 "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
259 "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
260 "dev": true,
261 "license": "MIT",
262 "dependencies": {
263 "@babel/code-frame": "^7.28.6",
264 "@babel/parser": "^7.28.6",
265 "@babel/types": "^7.28.6"
266 },
267 "engines": {
268 "node": ">=6.9.0"
269 }
270 },
271 "node_modules/@babel/traverse": {
272 "version": "7.29.0",
273 "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
274 "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
275 "dev": true,
276 "license": "MIT",
277 "dependencies": {
278 "@babel/code-frame": "^7.29.0",
279 "@babel/generator": "^7.29.0",
280 "@babel/helper-globals": "^7.28.0",
281 "@babel/parser": "^7.29.0",
282 "@babel/template": "^7.28.6",
283 "@babel/types": "^7.29.0",
284 "debug": "^4.3.1"
285 },
286 "engines": {
287 "node": ">=6.9.0"
288 }
289 },
290 "node_modules/@babel/types": {
291 "version": "7.29.0",
292 "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
293 "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
294 "dev": true,
295 "license": "MIT",
296 "dependencies": {
297 "@babel/helper-string-parser": "^7.27.1",
298 "@babel/helper-validator-identifier": "^7.28.5"
299 },
300 "engines": {
301 "node": ">=6.9.0"
302 }
303 },
304 "node_modules/@esbuild/aix-ppc64": {
305 "version": "0.21.5",
306 "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
307 "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
308 "cpu": [
309 "ppc64"
310 ],
311 "dev": true,
312 "license": "MIT",
313 "optional": true,
314 "os": [
315 "aix"
316 ],
317 "engines": {
318 "node": ">=12"
319 }
320 },
321 "node_modules/@esbuild/android-arm": {
322 "version": "0.21.5",
323 "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
324 "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
325 "cpu": [
326 "arm"
327 ],
328 "dev": true,
329 "license": "MIT",
330 "optional": true,
331 "os": [
332 "android"
333 ],
334 "engines": {
335 "node": ">=12"
336 }
337 },
338 "node_modules/@esbuild/android-arm64": {
339 "version": "0.21.5",
340 "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
341 "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
342 "cpu": [
343 "arm64"
344 ],
345 "dev": true,
346 "license": "MIT",
347 "optional": true,
348 "os": [
349 "android"
350 ],
351 "engines": {
352 "node": ">=12"
353 }
354 },
355 "node_modules/@esbuild/android-x64": {
356 "version": "0.21.5",
357 "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
358 "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
359 "cpu": [
360 "x64"
361 ],
362 "dev": true,
363 "license": "MIT",
364 "optional": true,
365 "os": [
366 "android"
367 ],
368 "engines": {
369 "node": ">=12"
370 }
371 },
372 "node_modules/@esbuild/darwin-arm64": {
373 "version": "0.21.5",
374 "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
375 "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
376 "cpu": [
377 "arm64"
378 ],
379 "dev": true,
380 "license": "MIT",
381 "optional": true,
382 "os": [
383 "darwin"
384 ],
385 "engines": {
386 "node": ">=12"
387 }
388 },
389 "node_modules/@esbuild/darwin-x64": {
390 "version": "0.21.5",
391 "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
392 "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
393 "cpu": [
394 "x64"
395 ],
396 "dev": true,
397 "license": "MIT",
398 "optional": true,
399 "os": [
400 "darwin"
401 ],
402 "engines": {
403 "node": ">=12"
404 }
405 },
406 "node_modules/@esbuild/freebsd-arm64": {
407 "version": "0.21.5",
408 "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
409 "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
410 "cpu": [
411 "arm64"
412 ],
413 "dev": true,
414 "license": "MIT",
415 "optional": true,
416 "os": [
417 "freebsd"
418 ],
419 "engines": {
420 "node": ">=12"
421 }
422 },
423 "node_modules/@esbuild/freebsd-x64": {
424 "version": "0.21.5",
425 "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
426 "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
427 "cpu": [
428 "x64"
429 ],
430 "dev": true,
431 "license": "MIT",
432 "optional": true,
433 "os": [
434 "freebsd"
435 ],
436 "engines": {
437 "node": ">=12"
438 }
439 },
440 "node_modules/@esbuild/linux-arm": {
441 "version": "0.21.5",
442 "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
443 "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
444 "cpu": [
445 "arm"
446 ],
447 "dev": true,
448 "license": "MIT",
449 "optional": true,
450 "os": [
451 "linux"
452 ],
453 "engines": {
454 "node": ">=12"
455 }
456 },
457 "node_modules/@esbuild/linux-arm64": {
458 "version": "0.21.5",
459 "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
460 "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
461 "cpu": [
462 "arm64"
463 ],
464 "dev": true,
465 "license": "MIT",
466 "optional": true,
467 "os": [
468 "linux"
469 ],
470 "engines": {
471 "node": ">=12"
472 }
473 },
474 "node_modules/@esbuild/linux-ia32": {
475 "version": "0.21.5",
476 "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
477 "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
478 "cpu": [
479 "ia32"
480 ],
481 "dev": true,
482 "license": "MIT",
483 "optional": true,
484 "os": [
485 "linux"
486 ],
487 "engines": {
488 "node": ">=12"
489 }
490 },
491 "node_modules/@esbuild/linux-loong64": {
492 "version": "0.21.5",
493 "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
494 "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
495 "cpu": [
496 "loong64"
497 ],
498 "dev": true,
499 "license": "MIT",
500 "optional": true,
501 "os": [
502 "linux"
503 ],
504 "engines": {
505 "node": ">=12"
506 }
507 },
508 "node_modules/@esbuild/linux-mips64el": {
509 "version": "0.21.5",
510 "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
511 "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
512 "cpu": [
513 "mips64el"
514 ],
515 "dev": true,
516 "license": "MIT",
517 "optional": true,
518 "os": [
519 "linux"
520 ],
521 "engines": {
522 "node": ">=12"
523 }
524 },
525 "node_modules/@esbuild/linux-ppc64": {
526 "version": "0.21.5",
527 "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
528 "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
529 "cpu": [
530 "ppc64"
531 ],
532 "dev": true,
533 "license": "MIT",
534 "optional": true,
535 "os": [
536 "linux"
537 ],
538 "engines": {
539 "node": ">=12"
540 }
541 },
542 "node_modules/@esbuild/linux-riscv64": {
543 "version": "0.21.5",
544 "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
545 "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
546 "cpu": [
547 "riscv64"
548 ],
549 "dev": true,
550 "license": "MIT",
551 "optional": true,
552 "os": [
553 "linux"
554 ],
555 "engines": {
556 "node": ">=12"
557 }
558 },
559 "node_modules/@esbuild/linux-s390x": {
560 "version": "0.21.5",
561 "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
562 "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
563 "cpu": [
564 "s390x"
565 ],
566 "dev": true,
567 "license": "MIT",
568 "optional": true,
569 "os": [
570 "linux"
571 ],
572 "engines": {
573 "node": ">=12"
574 }
575 },
576 "node_modules/@esbuild/linux-x64": {
577 "version": "0.21.5",
578 "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
579 "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
580 "cpu": [
581 "x64"
582 ],
583 "dev": true,
584 "license": "MIT",
585 "optional": true,
586 "os": [
587 "linux"
588 ],
589 "engines": {
590 "node": ">=12"
591 }
592 },
593 "node_modules/@esbuild/netbsd-x64": {
594 "version": "0.21.5",
595 "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
596 "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
597 "cpu": [
598 "x64"
599 ],
600 "dev": true,
601 "license": "MIT",
602 "optional": true,
603 "os": [
604 "netbsd"
605 ],
606 "engines": {
607 "node": ">=12"
608 }
609 },
610 "node_modules/@esbuild/openbsd-x64": {
611 "version": "0.21.5",
612 "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
613 "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
614 "cpu": [
615 "x64"
616 ],
617 "dev": true,
618 "license": "MIT",
619 "optional": true,
620 "os": [
621 "openbsd"
622 ],
623 "engines": {
624 "node": ">=12"
625 }
626 },
627 "node_modules/@esbuild/sunos-x64": {
628 "version": "0.21.5",
629 "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
630 "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
631 "cpu": [
632 "x64"
633 ],
634 "dev": true,
635 "license": "MIT",
636 "optional": true,
637 "os": [
638 "sunos"
639 ],
640 "engines": {
641 "node": ">=12"
642 }
643 },
644 "node_modules/@esbuild/win32-arm64": {
645 "version": "0.21.5",
646 "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
647 "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
648 "cpu": [
649 "arm64"
650 ],
651 "dev": true,
652 "license": "MIT",
653 "optional": true,
654 "os": [
655 "win32"
656 ],
657 "engines": {
658 "node": ">=12"
659 }
660 },
661 "node_modules/@esbuild/win32-ia32": {
662 "version": "0.21.5",
663 "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
664 "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
665 "cpu": [
666 "ia32"
667 ],
668 "dev": true,
669 "license": "MIT",
670 "optional": true,
671 "os": [
672 "win32"
673 ],
674 "engines": {
675 "node": ">=12"
676 }
677 },
678 "node_modules/@esbuild/win32-x64": {
679 "version": "0.21.5",
680 "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
681 "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
682 "cpu": [
683 "x64"
684 ],
685 "dev": true,
686 "license": "MIT",
687 "optional": true,
688 "os": [
689 "win32"
690 ],
691 "engines": {
692 "node": ">=12"
693 }
694 },
695 "node_modules/@jridgewell/gen-mapping": {
696 "version": "0.3.13",
697 "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
698 "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
699 "dev": true,
700 "license": "MIT",
701 "dependencies": {
702 "@jridgewell/sourcemap-codec": "^1.5.0",
703 "@jridgewell/trace-mapping": "^0.3.24"
704 }
705 },
706 "node_modules/@jridgewell/remapping": {
707 "version": "2.3.5",
708 "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
709 "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
710 "dev": true,
711 "license": "MIT",
712 "dependencies": {
713 "@jridgewell/gen-mapping": "^0.3.5",
714 "@jridgewell/trace-mapping": "^0.3.24"
715 }
716 },
717 "node_modules/@jridgewell/resolve-uri": {
718 "version": "3.1.2",
719 "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
720 "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
721 "dev": true,
722 "license": "MIT",
723 "engines": {
724 "node": ">=6.0.0"
725 }
726 },
727 "node_modules/@jridgewell/sourcemap-codec": {
728 "version": "1.5.5",
729 "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
730 "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
731 "dev": true,
732 "license": "MIT"
733 },
734 "node_modules/@jridgewell/trace-mapping": {
735 "version": "0.3.31",
736 "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
737 "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
738 "dev": true,
739 "license": "MIT",
740 "dependencies": {
741 "@jridgewell/resolve-uri": "^3.1.0",
742 "@jridgewell/sourcemap-codec": "^1.4.14"
743 }
744 },
745 "node_modules/@rolldown/pluginutils": {
746 "version": "1.0.0-beta.27",
747 "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
748 "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
749 "dev": true,
750 "license": "MIT"
751 },
752 "node_modules/@rollup/rollup-android-arm-eabi": {
753 "version": "4.60.1",
754 "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz",
755 "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==",
756 "cpu": [
757 "arm"
758 ],
759 "dev": true,
760 "license": "MIT",
761 "optional": true,
762 "os": [
763 "android"
764 ]
765 },
766 "node_modules/@rollup/rollup-android-arm64": {
767 "version": "4.60.1",
768 "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz",
769 "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==",
770 "cpu": [
771 "arm64"
772 ],
773 "dev": true,
774 "license": "MIT",
775 "optional": true,
776 "os": [
777 "android"
778 ]
779 },
780 "node_modules/@rollup/rollup-darwin-arm64": {
781 "version": "4.60.1",
782 "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz",
783 "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==",
784 "cpu": [
785 "arm64"
786 ],
787 "dev": true,
788 "license": "MIT",
789 "optional": true,
790 "os": [
791 "darwin"
792 ]
793 },
794 "node_modules/@rollup/rollup-darwin-x64": {
795 "version": "4.60.1",
796 "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz",
797 "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==",
798 "cpu": [
799 "x64"
800 ],
801 "dev": true,
802 "license": "MIT",
803 "optional": true,
804 "os": [
805 "darwin"
806 ]
807 },
808 "node_modules/@rollup/rollup-freebsd-arm64": {
809 "version": "4.60.1",
810 "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz",
811 "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==",
812 "cpu": [
813 "arm64"
814 ],
815 "dev": true,
816 "license": "MIT",
817 "optional": true,
818 "os": [
819 "freebsd"
820 ]
821 },
822 "node_modules/@rollup/rollup-freebsd-x64": {
823 "version": "4.60.1",
824 "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz",
825 "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==",
826 "cpu": [
827 "x64"
828 ],
829 "dev": true,
830 "license": "MIT",
831 "optional": true,
832 "os": [
833 "freebsd"
834 ]
835 },
836 "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
837 "version": "4.60.1",
838 "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz",
839 "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==",
840 "cpu": [
841 "arm"
842 ],
843 "dev": true,
844 "libc": [
845 "glibc"
846 ],
847 "license": "MIT",
848 "optional": true,
849 "os": [
850 "linux"
851 ]
852 },
853 "node_modules/@rollup/rollup-linux-arm-musleabihf": {
854 "version": "4.60.1",
855 "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz",
856 "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==",
857 "cpu": [
858 "arm"
859 ],
860 "dev": true,
861 "libc": [
862 "musl"
863 ],
864 "license": "MIT",
865 "optional": true,
866 "os": [
867 "linux"
868 ]
869 },
870 "node_modules/@rollup/rollup-linux-arm64-gnu": {
871 "version": "4.60.1",
872 "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz",
873 "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==",
874 "cpu": [
875 "arm64"
876 ],
877 "dev": true,
878 "libc": [
879 "glibc"
880 ],
881 "license": "MIT",
882 "optional": true,
883 "os": [
884 "linux"
885 ]
886 },
887 "node_modules/@rollup/rollup-linux-arm64-musl": {
888 "version": "4.60.1",
889 "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz",
890 "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==",
891 "cpu": [
892 "arm64"
893 ],
894 "dev": true,
895 "libc": [
896 "musl"
897 ],
898 "license": "MIT",
899 "optional": true,
900 "os": [
901 "linux"
902 ]
903 },
904 "node_modules/@rollup/rollup-linux-loong64-gnu": {
905 "version": "4.60.1",
906 "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz",
907 "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==",
908 "cpu": [
909 "loong64"
910 ],
911 "dev": true,
912 "libc": [
913 "glibc"
914 ],
915 "license": "MIT",
916 "optional": true,
917 "os": [
918 "linux"
919 ]
920 },
921 "node_modules/@rollup/rollup-linux-loong64-musl": {
922 "version": "4.60.1",
923 "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz",
924 "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==",
925 "cpu": [
926 "loong64"
927 ],
928 "dev": true,
929 "libc": [
930 "musl"
931 ],
932 "license": "MIT",
933 "optional": true,
934 "os": [
935 "linux"
936 ]
937 },
938 "node_modules/@rollup/rollup-linux-ppc64-gnu": {
939 "version": "4.60.1",
940 "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz",
941 "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==",
942 "cpu": [
943 "ppc64"
944 ],
945 "dev": true,
946 "libc": [
947 "glibc"
948 ],
949 "license": "MIT",
950 "optional": true,
951 "os": [
952 "linux"
953 ]
954 },
955 "node_modules/@rollup/rollup-linux-ppc64-musl": {
956 "version": "4.60.1",
957 "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz",
958 "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==",
959 "cpu": [
960 "ppc64"
961 ],
962 "dev": true,
963 "libc": [
964 "musl"
965 ],
966 "license": "MIT",
967 "optional": true,
968 "os": [
969 "linux"
970 ]
971 },
972 "node_modules/@rollup/rollup-linux-riscv64-gnu": {
973 "version": "4.60.1",
974 "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz",
975 "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==",
976 "cpu": [
977 "riscv64"
978 ],
979 "dev": true,
980 "libc": [
981 "glibc"
982 ],
983 "license": "MIT",
984 "optional": true,
985 "os": [
986 "linux"
987 ]
988 },
989 "node_modules/@rollup/rollup-linux-riscv64-musl": {
990 "version": "4.60.1",
991 "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz",
992 "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==",
993 "cpu": [
994 "riscv64"
995 ],
996 "dev": true,
997 "libc": [
998 "musl"
999 ],
1000 "license": "MIT",
1001 "optional": true,
1002 "os": [
1003 "linux"
1004 ]
1005 },
1006 "node_modules/@rollup/rollup-linux-s390x-gnu": {
1007 "version": "4.60.1",
1008 "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz",
1009 "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==",
1010 "cpu": [
1011 "s390x"
1012 ],
1013 "dev": true,
1014 "libc": [
1015 "glibc"
1016 ],
1017 "license": "MIT",
1018 "optional": true,
1019 "os": [
1020 "linux"
1021 ]
1022 },
1023 "node_modules/@rollup/rollup-linux-x64-gnu": {
1024 "version": "4.60.1",
1025 "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz",
1026 "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==",
1027 "cpu": [
1028 "x64"
1029 ],
1030 "dev": true,
1031 "libc": [
1032 "glibc"
1033 ],
1034 "license": "MIT",
1035 "optional": true,
1036 "os": [
1037 "linux"
1038 ]
1039 },
1040 "node_modules/@rollup/rollup-linux-x64-musl": {
1041 "version": "4.60.1",
1042 "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz",
1043 "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==",
1044 "cpu": [
1045 "x64"
1046 ],
1047 "dev": true,
1048 "libc": [
1049 "musl"
1050 ],
1051 "license": "MIT",
1052 "optional": true,
1053 "os": [
1054 "linux"
1055 ]
1056 },
1057 "node_modules/@rollup/rollup-openbsd-x64": {
1058 "version": "4.60.1",
1059 "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz",
1060 "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==",
1061 "cpu": [
1062 "x64"
1063 ],
1064 "dev": true,
1065 "license": "MIT",
1066 "optional": true,
1067 "os": [
1068 "openbsd"
1069 ]
1070 },
1071 "node_modules/@rollup/rollup-openharmony-arm64": {
1072 "version": "4.60.1",
1073 "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz",
1074 "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==",
1075 "cpu": [
1076 "arm64"
1077 ],
1078 "dev": true,
1079 "license": "MIT",
1080 "optional": true,
1081 "os": [
1082 "openharmony"
1083 ]
1084 },
1085 "node_modules/@rollup/rollup-win32-arm64-msvc": {
1086 "version": "4.60.1",
1087 "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz",
1088 "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==",
1089 "cpu": [
1090 "arm64"
1091 ],
1092 "dev": true,
1093 "license": "MIT",
1094 "optional": true,
1095 "os": [
1096 "win32"
1097 ]
1098 },
1099 "node_modules/@rollup/rollup-win32-ia32-msvc": {
1100 "version": "4.60.1",
1101 "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz",
1102 "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==",
1103 "cpu": [
1104 "ia32"
1105 ],
1106 "dev": true,
1107 "license": "MIT",
1108 "optional": true,
1109 "os": [
1110 "win32"
1111 ]
1112 },
1113 "node_modules/@rollup/rollup-win32-x64-gnu": {
1114 "version": "4.60.1",
1115 "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz",
1116 "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==",
1117 "cpu": [
1118 "x64"
1119 ],
1120 "dev": true,
1121 "license": "MIT",
1122 "optional": true,
1123 "os": [
1124 "win32"
1125 ]
1126 },
1127 "node_modules/@rollup/rollup-win32-x64-msvc": {
1128 "version": "4.60.1",
1129 "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz",
1130 "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==",
1131 "cpu": [
1132 "x64"
1133 ],
1134 "dev": true,
1135 "license": "MIT",
1136 "optional": true,
1137 "os": [
1138 "win32"
1139 ]
1140 },
1141 "node_modules/@types/babel__core": {
1142 "version": "7.20.5",
1143 "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
1144 "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
1145 "dev": true,
1146 "license": "MIT",
1147 "dependencies": {
1148 "@babel/parser": "^7.20.7",
1149 "@babel/types": "^7.20.7",
1150 "@types/babel__generator": "*",
1151 "@types/babel__template": "*",
1152 "@types/babel__traverse": "*"
1153 }
1154 },
1155 "node_modules/@types/babel__generator": {
1156 "version": "7.27.0",
1157 "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
1158 "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
1159 "dev": true,
1160 "license": "MIT",
1161 "dependencies": {
1162 "@babel/types": "^7.0.0"
1163 }
1164 },
1165 "node_modules/@types/babel__template": {
1166 "version": "7.4.4",
1167 "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
1168 "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
1169 "dev": true,
1170 "license": "MIT",
1171 "dependencies": {
1172 "@babel/parser": "^7.1.0",
1173 "@babel/types": "^7.0.0"
1174 }
1175 },
1176 "node_modules/@types/babel__traverse": {
1177 "version": "7.28.0",
1178 "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
1179 "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
1180 "dev": true,
1181 "license": "MIT",
1182 "dependencies": {
1183 "@babel/types": "^7.28.2"
1184 }
1185 },
1186 "node_modules/@types/estree": {
1187 "version": "1.0.8",
1188 "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
1189 "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
1190 "dev": true,
1191 "license": "MIT"
1192 },
1193 "node_modules/@types/prop-types": {
1194 "version": "15.7.15",
1195 "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
1196 "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
1197 "dev": true,
1198 "license": "MIT"
1199 },
1200 "node_modules/@types/react": {
1201 "version": "18.3.28",
1202 "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
1203 "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
1204 "dev": true,
1205 "license": "MIT",
1206 "dependencies": {
1207 "@types/prop-types": "*",
1208 "csstype": "^3.2.2"
1209 }
1210 },
1211 "node_modules/@types/react-dom": {
1212 "version": "18.3.7",
1213 "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
1214 "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
1215 "dev": true,
1216 "license": "MIT",
1217 "peerDependencies": {
1218 "@types/react": "^18.0.0"
1219 }
1220 },
1221 "node_modules/@vitejs/plugin-react": {
1222 "version": "4.7.0",
1223 "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
1224 "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
1225 "dev": true,
1226 "license": "MIT",
1227 "dependencies": {
1228 "@babel/core": "^7.28.0",
1229 "@babel/plugin-transform-react-jsx-self": "^7.27.1",
1230 "@babel/plugin-transform-react-jsx-source": "^7.27.1",
1231 "@rolldown/pluginutils": "1.0.0-beta.27",
1232 "@types/babel__core": "^7.20.5",
1233 "react-refresh": "^0.17.0"
1234 },
1235 "engines": {
1236 "node": "^14.18.0 || >=16.0.0"
1237 },
1238 "peerDependencies": {
1239 "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
1240 }
1241 },
1242 "node_modules/baseline-browser-mapping": {
1243 "version": "2.10.19",
1244 "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.19.tgz",
1245 "integrity": "sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g==",
1246 "dev": true,
1247 "license": "Apache-2.0",
1248 "bin": {
1249 "baseline-browser-mapping": "dist/cli.cjs"
1250 },
1251 "engines": {
1252 "node": ">=6.0.0"
1253 }
1254 },
1255 "node_modules/browserslist": {
1256 "version": "4.28.2",
1257 "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
1258 "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
1259 "dev": true,
1260 "funding": [
1261 {
1262 "type": "opencollective",
1263 "url": "https://opencollective.com/browserslist"
1264 },
1265 {
1266 "type": "tidelift",
1267 "url": "https://tidelift.com/funding/github/npm/browserslist"
1268 },
1269 {
1270 "type": "github",
1271 "url": "https://github.com/sponsors/ai"
1272 }
1273 ],
1274 "license": "MIT",
1275 "dependencies": {
1276 "baseline-browser-mapping": "^2.10.12",
1277 "caniuse-lite": "^1.0.30001782",
1278 "electron-to-chromium": "^1.5.328",
1279 "node-releases": "^2.0.36",
1280 "update-browserslist-db": "^1.2.3"
1281 },
1282 "bin": {
1283 "browserslist": "cli.js"
1284 },
1285 "engines": {
1286 "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
1287 }
1288 },
1289 "node_modules/caniuse-lite": {
1290 "version": "1.0.30001788",
1291 "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz",
1292 "integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==",
1293 "dev": true,
1294 "funding": [
1295 {
1296 "type": "opencollective",
1297 "url": "https://opencollective.com/browserslist"
1298 },
1299 {
1300 "type": "tidelift",
1301 "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
1302 },
1303 {
1304 "type": "github",
1305 "url": "https://github.com/sponsors/ai"
1306 }
1307 ],
1308 "license": "CC-BY-4.0"
1309 },
1310 "node_modules/convert-source-map": {
1311 "version": "2.0.0",
1312 "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
1313 "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
1314 "dev": true,
1315 "license": "MIT"
1316 },
1317 "node_modules/csstype": {
1318 "version": "3.2.3",
1319 "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
1320 "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
1321 "dev": true,
1322 "license": "MIT"
1323 },
1324 "node_modules/debug": {
1325 "version": "4.4.3",
1326 "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
1327 "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
1328 "dev": true,
1329 "license": "MIT",
1330 "dependencies": {
1331 "ms": "^2.1.3"
1332 },
1333 "engines": {
1334 "node": ">=6.0"
1335 },
1336 "peerDependenciesMeta": {
1337 "supports-color": {
1338 "optional": true
1339 }
1340 }
1341 },
1342 "node_modules/electron-to-chromium": {
1343 "version": "1.5.340",
1344 "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.340.tgz",
1345 "integrity": "sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA==",
1346 "dev": true,
1347 "license": "ISC"
1348 },
1349 "node_modules/esbuild": {
1350 "version": "0.21.5",
1351 "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
1352 "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
1353 "dev": true,
1354 "hasInstallScript": true,
1355 "license": "MIT",
1356 "bin": {
1357 "esbuild": "bin/esbuild"
1358 },
1359 "engines": {
1360 "node": ">=12"
1361 },
1362 "optionalDependencies": {
1363 "@esbuild/aix-ppc64": "0.21.5",
1364 "@esbuild/android-arm": "0.21.5",
1365 "@esbuild/android-arm64": "0.21.5",
1366 "@esbuild/android-x64": "0.21.5",
1367 "@esbuild/darwin-arm64": "0.21.5",
1368 "@esbuild/darwin-x64": "0.21.5",
1369 "@esbuild/freebsd-arm64": "0.21.5",
1370 "@esbuild/freebsd-x64": "0.21.5",
1371 "@esbuild/linux-arm": "0.21.5",
1372 "@esbuild/linux-arm64": "0.21.5",
1373 "@esbuild/linux-ia32": "0.21.5",
1374 "@esbuild/linux-loong64": "0.21.5",
1375 "@esbuild/linux-mips64el": "0.21.5",
1376 "@esbuild/linux-ppc64": "0.21.5",
1377 "@esbuild/linux-riscv64": "0.21.5",
1378 "@esbuild/linux-s390x": "0.21.5",
1379 "@esbuild/linux-x64": "0.21.5",
1380 "@esbuild/netbsd-x64": "0.21.5",
1381 "@esbuild/openbsd-x64": "0.21.5",
1382 "@esbuild/sunos-x64": "0.21.5",
1383 "@esbuild/win32-arm64": "0.21.5",
1384 "@esbuild/win32-ia32": "0.21.5",
1385 "@esbuild/win32-x64": "0.21.5"
1386 }
1387 },
1388 "node_modules/escalade": {
1389 "version": "3.2.0",
1390 "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
1391 "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
1392 "dev": true,
1393 "license": "MIT",
1394 "engines": {
1395 "node": ">=6"
1396 }
1397 },
1398 "node_modules/fsevents": {
1399 "version": "2.3.3",
1400 "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
1401 "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
1402 "dev": true,
1403 "hasInstallScript": true,
1404 "license": "MIT",
1405 "optional": true,
1406 "os": [
1407 "darwin"
1408 ],
1409 "engines": {
1410 "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
1411 }
1412 },
1413 "node_modules/gensync": {
1414 "version": "1.0.0-beta.2",
1415 "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
1416 "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
1417 "dev": true,
1418 "license": "MIT",
1419 "engines": {
1420 "node": ">=6.9.0"
1421 }
1422 },
1423 "node_modules/js-tokens": {
1424 "version": "4.0.0",
1425 "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
1426 "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
1427 "license": "MIT"
1428 },
1429 "node_modules/jsesc": {
1430 "version": "3.1.0",
1431 "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
1432 "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
1433 "dev": true,
1434 "license": "MIT",
1435 "bin": {
1436 "jsesc": "bin/jsesc"
1437 },
1438 "engines": {
1439 "node": ">=6"
1440 }
1441 },
1442 "node_modules/json5": {
1443 "version": "2.2.3",
1444 "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
1445 "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
1446 "dev": true,
1447 "license": "MIT",
1448 "bin": {
1449 "json5": "lib/cli.js"
1450 },
1451 "engines": {
1452 "node": ">=6"
1453 }
1454 },
1455 "node_modules/loose-envify": {
1456 "version": "1.4.0",
1457 "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
1458 "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
1459 "license": "MIT",
1460 "dependencies": {
1461 "js-tokens": "^3.0.0 || ^4.0.0"
1462 },
1463 "bin": {
1464 "loose-envify": "cli.js"
1465 }
1466 },
1467 "node_modules/lru-cache": {
1468 "version": "5.1.1",
1469 "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
1470 "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
1471 "dev": true,
1472 "license": "ISC",
1473 "dependencies": {
1474 "yallist": "^3.0.2"
1475 }
1476 },
1477 "node_modules/ms": {
1478 "version": "2.1.3",
1479 "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
1480 "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
1481 "dev": true,
1482 "license": "MIT"
1483 },
1484 "node_modules/nanoid": {
1485 "version": "3.3.11",
1486 "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
1487 "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
1488 "dev": true,
1489 "funding": [
1490 {
1491 "type": "github",
1492 "url": "https://github.com/sponsors/ai"
1493 }
1494 ],
1495 "license": "MIT",
1496 "bin": {
1497 "nanoid": "bin/nanoid.cjs"
1498 },
1499 "engines": {
1500 "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
1501 }
1502 },
1503 "node_modules/node-releases": {
1504 "version": "2.0.37",
1505 "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz",
1506 "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==",
1507 "dev": true,
1508 "license": "MIT"
1509 },
1510 "node_modules/picocolors": {
1511 "version": "1.1.1",
1512 "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
1513 "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
1514 "dev": true,
1515 "license": "ISC"
1516 },
1517 "node_modules/postcss": {
1518 "version": "8.5.10",
1519 "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
1520 "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
1521 "dev": true,
1522 "funding": [
1523 {
1524 "type": "opencollective",
1525 "url": "https://opencollective.com/postcss/"
1526 },
1527 {
1528 "type": "tidelift",
1529 "url": "https://tidelift.com/funding/github/npm/postcss"
1530 },
1531 {
1532 "type": "github",
1533 "url": "https://github.com/sponsors/ai"
1534 }
1535 ],
1536 "license": "MIT",
1537 "dependencies": {
1538 "nanoid": "^3.3.11",
1539 "picocolors": "^1.1.1",
1540 "source-map-js": "^1.2.1"
1541 },
1542 "engines": {
1543 "node": "^10 || ^12 || >=14"
1544 }
1545 },
1546 "node_modules/react": {
1547 "version": "18.3.1",
1548 "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
1549 "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
1550 "license": "MIT",
1551 "dependencies": {
1552 "loose-envify": "^1.1.0"
1553 },
1554 "engines": {
1555 "node": ">=0.10.0"
1556 }
1557 },
1558 "node_modules/react-dom": {
1559 "version": "18.3.1",
1560 "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
1561 "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
1562 "license": "MIT",
1563 "dependencies": {
1564 "loose-envify": "^1.1.0",
1565 "scheduler": "^0.23.2"
1566 },
1567 "peerDependencies": {
1568 "react": "^18.3.1"
1569 }
1570 },
1571 "node_modules/react-refresh": {
1572 "version": "0.17.0",
1573 "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
1574 "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
1575 "dev": true,
1576 "license": "MIT",
1577 "engines": {
1578 "node": ">=0.10.0"
1579 }
1580 },
1581 "node_modules/rollup": {
1582 "version": "4.60.1",
1583 "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
1584 "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
1585 "dev": true,
1586 "license": "MIT",
1587 "dependencies": {
1588 "@types/estree": "1.0.8"
1589 },
1590 "bin": {
1591 "rollup": "dist/bin/rollup"
1592 },
1593 "engines": {
1594 "node": ">=18.0.0",
1595 "npm": ">=8.0.0"
1596 },
1597 "optionalDependencies": {
1598 "@rollup/rollup-android-arm-eabi": "4.60.1",
1599 "@rollup/rollup-android-arm64": "4.60.1",
1600 "@rollup/rollup-darwin-arm64": "4.60.1",
1601 "@rollup/rollup-darwin-x64": "4.60.1",
1602 "@rollup/rollup-freebsd-arm64": "4.60.1",
1603 "@rollup/rollup-freebsd-x64": "4.60.1",
1604 "@rollup/rollup-linux-arm-gnueabihf": "4.60.1",
1605 "@rollup/rollup-linux-arm-musleabihf": "4.60.1",
1606 "@rollup/rollup-linux-arm64-gnu": "4.60.1",
1607 "@rollup/rollup-linux-arm64-musl": "4.60.1",
1608 "@rollup/rollup-linux-loong64-gnu": "4.60.1",
1609 "@rollup/rollup-linux-loong64-musl": "4.60.1",
1610 "@rollup/rollup-linux-ppc64-gnu": "4.60.1",
1611 "@rollup/rollup-linux-ppc64-musl": "4.60.1",
1612 "@rollup/rollup-linux-riscv64-gnu": "4.60.1",
1613 "@rollup/rollup-linux-riscv64-musl": "4.60.1",
1614 "@rollup/rollup-linux-s390x-gnu": "4.60.1",
1615 "@rollup/rollup-linux-x64-gnu": "4.60.1",
1616 "@rollup/rollup-linux-x64-musl": "4.60.1",
1617 "@rollup/rollup-openbsd-x64": "4.60.1",
1618 "@rollup/rollup-openharmony-arm64": "4.60.1",
1619 "@rollup/rollup-win32-arm64-msvc": "4.60.1",
1620 "@rollup/rollup-win32-ia32-msvc": "4.60.1",
1621 "@rollup/rollup-win32-x64-gnu": "4.60.1",
1622 "@rollup/rollup-win32-x64-msvc": "4.60.1",
1623 "fsevents": "~2.3.2"
1624 }
1625 },
1626 "node_modules/scheduler": {
1627 "version": "0.23.2",
1628 "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
1629 "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
1630 "license": "MIT",
1631 "dependencies": {
1632 "loose-envify": "^1.1.0"
1633 }
1634 },
1635 "node_modules/semver": {
1636 "version": "6.3.1",
1637 "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
1638 "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
1639 "dev": true,
1640 "license": "ISC",
1641 "bin": {
1642 "semver": "bin/semver.js"
1643 }
1644 },
1645 "node_modules/source-map-js": {
1646 "version": "1.2.1",
1647 "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
1648 "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
1649 "dev": true,
1650 "license": "BSD-3-Clause",
1651 "engines": {
1652 "node": ">=0.10.0"
1653 }
1654 },
1655 "node_modules/typescript": {
1656 "version": "5.9.3",
1657 "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
1658 "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
1659 "dev": true,
1660 "license": "Apache-2.0",
1661 "bin": {
1662 "tsc": "bin/tsc",
1663 "tsserver": "bin/tsserver"
1664 },
1665 "engines": {
1666 "node": ">=14.17"
1667 }
1668 },
1669 "node_modules/update-browserslist-db": {
1670 "version": "1.2.3",
1671 "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
1672 "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
1673 "dev": true,
1674 "funding": [
1675 {
1676 "type": "opencollective",
1677 "url": "https://opencollective.com/browserslist"
1678 },
1679 {
1680 "type": "tidelift",
1681 "url": "https://tidelift.com/funding/github/npm/browserslist"
1682 },
1683 {
1684 "type": "github",
1685 "url": "https://github.com/sponsors/ai"
1686 }
1687 ],
1688 "license": "MIT",
1689 "dependencies": {
1690 "escalade": "^3.2.0",
1691 "picocolors": "^1.1.1"
1692 },
1693 "bin": {
1694 "update-browserslist-db": "cli.js"
1695 },
1696 "peerDependencies": {
1697 "browserslist": ">= 4.21.0"
1698 }
1699 },
1700 "node_modules/vite": {
1701 "version": "5.4.21",
1702 "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
1703 "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
1704 "dev": true,
1705 "license": "MIT",
1706 "dependencies": {
1707 "esbuild": "^0.21.3",
1708 "postcss": "^8.4.43",
1709 "rollup": "^4.20.0"
1710 },
1711 "bin": {
1712 "vite": "bin/vite.js"
1713 },
1714 "engines": {
1715 "node": "^18.0.0 || >=20.0.0"
1716 },
1717 "funding": {
1718 "url": "https://github.com/vitejs/vite?sponsor=1"
1719 },
1720 "optionalDependencies": {
1721 "fsevents": "~2.3.3"
1722 },
1723 "peerDependencies": {
1724 "@types/node": "^18.0.0 || >=20.0.0",
1725 "less": "*",
1726 "lightningcss": "^1.21.0",
1727 "sass": "*",
1728 "sass-embedded": "*",
1729 "stylus": "*",
1730 "sugarss": "*",
1731 "terser": "^5.4.0"
1732 },
1733 "peerDependenciesMeta": {
1734 "@types/node": {
1735 "optional": true
1736 },
1737 "less": {
1738 "optional": true
1739 },
1740 "lightningcss": {
1741 "optional": true
1742 },
1743 "sass": {
1744 "optional": true
1745 },
1746 "sass-embedded": {
1747 "optional": true
1748 },
1749 "stylus": {
1750 "optional": true
1751 },
1752 "sugarss": {
1753 "optional": true
1754 },
1755 "terser": {
1756 "optional": true
1757 }
1758 }
1759 },
1760 "node_modules/yallist": {
1761 "version": "3.1.1",
1762 "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
1763 "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
1764 "dev": true,
1765 "license": "ISC"
1766 }
1767 }
1768}
package.json added +22
@@ -0,0 +1,22 @@
1{
2 "name": "brand-workbench",
3 "version": "0.1.0",
4 "private": true,
5 "type": "module",
6 "scripts": {
7 "dev": "vite",
8 "build": "tsc && vite build",
9 "preview": "vite preview"
10 },
11 "dependencies": {
12 "react": "^18.3.1",
13 "react-dom": "^18.3.1"
14 },
15 "devDependencies": {
16 "@types/react": "^18.3.1",
17 "@types/react-dom": "^18.3.1",
18 "@vitejs/plugin-react": "^4.3.1",
19 "typescript": "^5.5.3",
20 "vite": "^5.4.1"
21 }
22}
src/App.tsx added +130
@@ -0,0 +1,130 @@
1import { useState, useEffect } from 'react';
2import { useWorkspace } from './hooks/useWorkspace';
3import { usePackages } from './hooks/usePackages';
4import { useSettings } from './hooks/useSettings';
5import type { PackageSlot } from './hooks/usePackages';
6import { InputPanel } from './components/InputPanel';
7import { PreviewPanel } from './components/PreviewPanel';
8import { PackageSwitcher } from './components/PackageSwitcher';
9import { SettingsPanel } from './components/SettingsPanel';
10import type { BrandOutputs } from './types';
11
12interface WorkspaceShellProps {
13 storageKey: string;
14 slots: PackageSlot[];
15 activeId: string;
16 onSwitch: (id: string) => void;
17 onCreate: () => void;
18 onRemove: (id: string) => void;
19 onRename: (id: string, name: string) => void;
20 onDuplicate: (id: string) => void;
21 onOpenSettings: () => void;
22 aiEnabled: boolean;
23}
24
25function WorkspaceShell({
26 storageKey, slots, activeId,
27 onSwitch, onCreate, onRemove, onRename, onDuplicate,
28 onOpenSettings, aiEnabled,
29}: WorkspaceShellProps) {
30 const {
31 inputs, setInputs, outputs, isGenerating, generateMode, generateError,
32 locked, runGenerate, cancelGenerate, updateEdit, toggleLock,
33 undo, redo, canUndo, canRedo,
34 } = useWorkspace(storageKey);
35
36 useEffect(() => {
37 const handler = (e: KeyboardEvent) => {
38 if (!(e.metaKey || e.ctrlKey) || e.key !== 'z') return;
39 e.preventDefault();
40 if (e.shiftKey) redo();
41 else undo();
42 };
43 window.addEventListener('keydown', handler);
44 return () => window.removeEventListener('keydown', handler);
45 }, [undo, redo]);
46
47 const handleEdit = <K extends keyof BrandOutputs>(key: K, value: BrandOutputs[K]) =>
48 updateEdit(key, value);
49
50 const generateLabel = isGenerating
51 ? 'Cancel'
52 : outputs
53 ? (aiEnabled ? `Regenerate` : 'Regenerate')
54 : (aiEnabled ? 'Generate with AI' : 'Generate');
55
56 return (
57 <>
58 <header className="app-header">
59 <div className="app-header-left">
60 <span className="app-wordmark">Brand<span>bench</span></span>
61 <PackageSwitcher
62 slots={slots} activeId={activeId}
63 onSwitch={onSwitch} onCreate={onCreate}
64 onRemove={onRemove} onRename={onRename} onDuplicate={onDuplicate}
65 />
66 </div>
67 <div className="app-header-right">
68 {outputs && (
69 <>
70 <button className="btn btn-ghost" onClick={undo} disabled={!canUndo} title="Undo (⌘Z)">Undo</button>
71 <button className="btn btn-ghost" onClick={redo} disabled={!canRedo} title="Redo (⌘⇧Z)">Redo</button>
72 </>
73 )}
74 {aiEnabled && (
75 <span className="ai-badge" title={`AI mode: ${generateMode}`}>AI</span>
76 )}
77 <button className="btn btn-ghost settings-open-btn" onClick={onOpenSettings} title="Settings">⚙</button>
78 </div>
79 </header>
80
81 <div className="app-shell">
82 <InputPanel
83 inputs={inputs}
84 onChange={setInputs}
85 onGenerate={isGenerating ? cancelGenerate : runGenerate}
86 isGenerating={isGenerating}
87 generateLabel={generateLabel}
88 />
89 <PreviewPanel
90 inputs={inputs}
91 outputs={outputs}
92 locked={locked}
93 onToggleLock={toggleLock}
94 onEdit={handleEdit}
95 isGenerating={isGenerating}
96 generateMode={generateMode}
97 generateError={generateError}
98 onGenerate={runGenerate}
99 />
100 </div>
101 </>
102 );
103}
104
105export default function App() {
106 const { slots, activeId, activeSlot, switchTo, createNew, remove, rename, duplicate } = usePackages();
107 const { settings, setSettings } = useSettings();
108 const [settingsOpen, setSettingsOpen] = useState(false);
109
110 return (
111 <>
112 <WorkspaceShell
113 key={activeId}
114 storageKey={activeSlot.storageKey}
115 slots={slots} activeId={activeId}
116 onSwitch={switchTo} onCreate={createNew}
117 onRemove={remove} onRename={rename} onDuplicate={duplicate}
118 onOpenSettings={() => setSettingsOpen(true)}
119 aiEnabled={settings.enabled}
120 />
121 {settingsOpen && (
122 <SettingsPanel
123 settings={settings}
124 onChange={setSettings}
125 onClose={() => setSettingsOpen(false)}
126 />
127 )}
128 </>
129 );
130}
src/components/BrandDoc.tsx added +518
@@ -0,0 +1,518 @@
1import { useRef, useState } from 'react';
2import type { BrandInputs, BrandOutputs, ColorSwatch, LockedSections, Typography } from '../types';
3import { CopyButton } from './CopyButton';
4
5// ── Editable text ─────────────────────────────────────────────────────────────
6
7interface EditableProps {
8 value: string;
9 onChange: (v: string) => void;
10 multiline?: boolean;
11}
12
13function Editable({ value, onChange, multiline = false }: EditableProps) {
14 const [editing, setEditing] = useState(false);
15 const [draft, setDraft] = useState(value);
16 const ref = useRef<HTMLTextAreaElement | HTMLInputElement>(null);
17
18 const commit = () => {
19 setEditing(false);
20 if (draft !== value) onChange(draft);
21 };
22
23 if (!editing) {
24 return (
25 <div
26 className="editable-text"
27 onClick={() => { setDraft(value); setEditing(true); }}
28 title="Click to edit"
29 style={{ whiteSpace: multiline ? 'pre-wrap' : 'normal', padding: '2px 4px', margin: '-2px -4px' }}
30 >
31 {value}
32 </div>
33 );
34 }
35
36 if (multiline) {
37 return (
38 <textarea
39 ref={ref as React.RefObject<HTMLTextAreaElement>}
40 autoFocus
41 className="editable-text editing"
42 value={draft}
43 onChange={e => setDraft(e.target.value)}
44 onBlur={commit}
45 onKeyDown={e => { if (e.key === 'Escape') { setEditing(false); setDraft(value); } }}
46 style={{
47 width: '100%',
48 resize: 'vertical',
49 minHeight: 80,
50 padding: '4px 6px',
51 fontFamily: 'inherit',
52 fontSize: 'inherit',
53 lineHeight: 1.65,
54 background: 'var(--bg-2)',
55 border: '1px solid var(--border-3)',
56 borderRadius: 3,
57 color: 'var(--text)',
58 outline: 'none',
59 }}
60 />
61 );
62 }
63
64 return (
65 <input
66 ref={ref as React.RefObject<HTMLInputElement>}
67 autoFocus
68 className="editable-text editing"
69 value={draft}
70 onChange={e => setDraft(e.target.value)}
71 onBlur={commit}
72 onKeyDown={e => {
73 if (e.key === 'Enter') commit();
74 if (e.key === 'Escape') { setEditing(false); setDraft(value); }
75 }}
76 style={{
77 width: '100%',
78 padding: '2px 6px',
79 fontFamily: 'inherit',
80 fontSize: 'inherit',
81 background: 'var(--bg-2)',
82 border: '1px solid var(--border-3)',
83 borderRadius: 3,
84 color: 'var(--text)',
85 outline: 'none',
86 }}
87 />
88 );
89}
90
91// ── Section wrapper ───────────────────────────────────────────────────────────
92
93interface SectionProps {
94 title: string;
95 locked: boolean;
96 onToggleLock: () => void;
97 copyText?: string;
98 children: React.ReactNode;
99}
100
101function Section({ title, locked, onToggleLock, copyText, children }: SectionProps) {
102 return (
103 <div className={`doc-section${locked ? ' is-locked' : ''}`}>
104 <div className="doc-section-header">
105 <div className="doc-section-title">{title}</div>
106 <div className="doc-section-actions">
107 {copyText && <CopyButton text={copyText} label="Copy" />}
108 <button
109 type="button"
110 className={`section-lock${locked ? ' locked' : ''}`}
111 onClick={onToggleLock}
112 title={locked ? 'Locked — click to unlock' : 'Lock to preserve on regenerate'}
113 >
114 {locked ? '● locked' : '○ lock'}
115 </button>
116 </div>
117 </div>
118 <div className="doc-section-body">
119 {children}
120 </div>
121 </div>
122 );
123}
124
125// ── Editable list item ────────────────────────────────────────────────────────
126
127interface EditableListProps {
128 items: string[];
129 onChange: (items: string[]) => void;
130 numbered?: boolean;
131}
132
133function EditableList({ items, onChange, numbered = true }: EditableListProps) {
134 const updateAt = (i: number, v: string) => {
135 const next = [...items];
136 next[i] = v;
137 onChange(next);
138 };
139
140 if (numbered) {
141 return (
142 <div className="numbered-list">
143 {items.map((item, i) => (
144 <div key={i} className="numbered-item">
145 <span className="numbered-item-num">{i + 1}.</span>
146 <div
147 className="numbered-item-text"
148 contentEditable
149 suppressContentEditableWarning
150 onBlur={e => updateAt(i, e.currentTarget.textContent ?? '')}
151 onKeyDown={e => { if (e.key === 'Escape') e.currentTarget.blur(); }}
152 >
153 {item}
154 </div>
155 </div>
156 ))}
157 </div>
158 );
159 }
160
161 return (
162 <div className="bullet-list">
163 {items.map((item, i) => (
164 <div key={i} className="bullet-item">
165 {item}
166 </div>
167 ))}
168 </div>
169 );
170}
171
172// ── Typography section ────────────────────────────────────────────────────────
173
174function TypographySection({ typography }: { typography: Typography }) {
175 return (
176 <div className="type-section">
177 <div className="type-fonts">
178 <div className="type-font-row">
179 <span className="type-font-role">Primary</span>
180 <span className="type-font-name">{typography.primary}</span>
181 </div>
182 {typography.secondary !== typography.primary && (
183 <div className="type-font-row">
184 <span className="type-font-role">Secondary</span>
185 <span className="type-font-name">{typography.secondary}</span>
186 </div>
187 )}
188 <div className="type-font-row">
189 <span className="type-font-role">Monospace</span>
190 <span className="type-font-name" style={{ fontFamily: 'var(--font-mono)' }}>{typography.mono}</span>
191 </div>
192 <div className="type-pair-note">{typography.pairNote}</div>
193 </div>
194
195 <div className="type-scale">
196 <div className="type-scale-header">
197 <span>Style</span>
198 <span>Size</span>
199 <span>Weight</span>
200 <span className="type-scale-usage">Usage</span>
201 </div>
202 {typography.scale.map(token => (
203 <div key={token.label} className="type-scale-row">
204 <span className="type-scale-label">{token.label}</span>
205 <span className="type-scale-size">{token.size}</span>
206 <span className="type-scale-weight">{token.weight}</span>
207 <span className="type-scale-usage">{token.usage}</span>
208 </div>
209 ))}
210 </div>
211 </div>
212 );
213}
214
215// ── Color swatch card ─────────────────────────────────────────────────────────
216
217interface ColorSwatchCardProps {
218 swatch: ColorSwatch;
219 onChange: (s: ColorSwatch) => void;
220 onRemove: () => void;
221}
222
223function ColorSwatchCard({ swatch, onChange, onRemove }: ColorSwatchCardProps) {
224 const [nameEditing, setNameEditing] = useState(false);
225 const [nameDraft, setNameDraft] = useState(swatch.name);
226 const [hexEditing, setHexEditing] = useState(false);
227 const [hexDraft, setHexDraft] = useState(swatch.hex);
228
229 const commitName = () => {
230 setNameEditing(false);
231 const v = nameDraft.trim();
232 if (v && v !== swatch.name) onChange({ ...swatch, name: v });
233 };
234
235 const commitHex = () => {
236 setHexEditing(false);
237 const v = hexDraft.trim().toLowerCase();
238 const normalized = v.startsWith('#') ? v : `#${v}`;
239 if (/^#[0-9a-f]{6}$/.test(normalized)) {
240 onChange({ ...swatch, hex: normalized });
241 } else {
242 setHexDraft(swatch.hex);
243 }
244 };
245
246 return (
247 <div className="color-swatch-card">
248 <div className="color-swatch-preview" style={{ background: swatch.hex }}>
249 <input
250 type="color"
251 className="color-swatch-picker"
252 value={swatch.hex}
253 onChange={e => onChange({ ...swatch, hex: e.target.value })}
254 title="Pick color"
255 />
256 <button className="color-swatch-remove" onClick={onRemove} title="Remove">×</button>
257 </div>
258 <div className="color-swatch-info">
259 {nameEditing ? (
260 <input
261 autoFocus
262 className="color-swatch-field-input"
263 value={nameDraft}
264 onChange={e => setNameDraft(e.target.value)}
265 onBlur={commitName}
266 onKeyDown={e => {
267 if (e.key === 'Enter') commitName();
268 if (e.key === 'Escape') { setNameEditing(false); setNameDraft(swatch.name); }
269 }}
270 />
271 ) : (
272 <div className="color-swatch-name" onClick={() => { setNameDraft(swatch.name); setNameEditing(true); }} title="Click to edit">
273 {swatch.name}
274 </div>
275 )}
276 {hexEditing ? (
277 <input
278 autoFocus
279 className="color-swatch-field-input color-swatch-hex-input"
280 value={hexDraft}
281 onChange={e => setHexDraft(e.target.value)}
282 onBlur={commitHex}
283 onKeyDown={e => {
284 if (e.key === 'Enter') commitHex();
285 if (e.key === 'Escape') { setHexEditing(false); setHexDraft(swatch.hex); }
286 }}
287 />
288 ) : (
289 <div className="color-swatch-hex" onClick={() => { setHexDraft(swatch.hex); setHexEditing(true); }} title="Click to edit">
290 {swatch.hex}
291 </div>
292 )}
293 </div>
294 </div>
295 );
296}
297
298// ── Main BrandDoc ─────────────────────────────────────────────────────────────
299
300interface Props {
301 inputs: BrandInputs;
302 outputs: BrandOutputs;
303 locked: LockedSections;
304 onToggleLock: (section: keyof LockedSections) => void;
305 onEdit: <K extends keyof BrandOutputs>(key: K, value: BrandOutputs[K]) => void;
306}
307
308export function BrandDoc({ inputs, outputs, locked, onToggleLock, onEdit }: Props) {
309 const paletteCopy = outputs.palette.swatches
310 .map(s => `${s.name}: ${s.hex}`)
311 .join('\n');
312
313 const updateSwatch = (index: number, updated: ColorSwatch) => {
314 const swatches = [...outputs.palette.swatches];
315 swatches[index] = updated;
316 onEdit('palette', { swatches });
317 };
318
319 const removeSwatch = (index: number) => {
320 const swatches = outputs.palette.swatches.filter((_, i) => i !== index);
321 onEdit('palette', { swatches });
322 };
323
324 const addSwatch = () => {
325 const id = `s${Date.now()}`;
326 onEdit('palette', {
327 swatches: [...outputs.palette.swatches, { id, name: 'New Color', hex: '#888888', role: 'accent' }],
328 });
329 };
330
331 const messagingCopy = [
332 'Titles:',
333 ...outputs.titles.map((t, i) => `${i + 1}. ${t}`),
334 '',
335 'Taglines:',
336 ...outputs.taglines.map((t, i) => `${i + 1}. ${t}`),
337 ].join('\n');
338
339 return (
340 <div className="brand-doc">
341 <div className="brand-doc-header">
342 <div className="brand-doc-title">{inputs.name || 'Brand Package'}</div>
343 <div className="brand-doc-meta">
344 {inputs.category && (
345 <span className="brand-doc-meta-item">
346 <span className="brand-doc-meta-label">category</span>
347 {inputs.category}
348 </span>
349 )}
350 {inputs.audience && (
351 <span className="brand-doc-meta-item">
352 <span className="brand-doc-meta-label">audience</span>
353 {inputs.audience}
354 </span>
355 )}
356 </div>
357 </div>
358
359 {/* Overview */}
360 <Section title="Overview" locked={locked.overview} onToggleLock={() => onToggleLock('overview')} copyText={outputs.overview}>
361 <Editable value={outputs.overview} onChange={v => onEdit('overview', v)} multiline />
362 </Section>
363
364 {/* Positioning */}
365 <Section title="Positioning" locked={locked.positioning} onToggleLock={() => onToggleLock('positioning')} copyText={outputs.positioning}>
366 <Editable value={outputs.positioning} onChange={v => onEdit('positioning', v)} multiline />
367 </Section>
368
369 {/* Tone */}
370 <Section title="Tone & Voice" locked={locked.tone} onToggleLock={() => onToggleLock('tone')}>
371 <div className="tone-grid">
372 <div className="tone-row">
373 <div className="tone-row-label">Attributes</div>
374 <div className="tone-tags">
375 {outputs.tone.attributes.map(a => (
376 <span key={a} className="tone-tag">{a}</span>
377 ))}
378 </div>
379 </div>
380 <div className="tone-row">
381 <div className="tone-row-label">Voice</div>
382 <div className="tone-row-value">{outputs.tone.voiceNotes}</div>
383 </div>
384 {outputs.tone.avoidList.length > 0 && (
385 <div className="tone-row">
386 <div className="tone-row-label">Avoid</div>
387 <div className="tone-row-value">{outputs.tone.avoidList.join(', ')}</div>
388 </div>
389 )}
390 {outputs.tone.examplePhrases.length > 0 && (
391 <div className="tone-row">
392 <div className="tone-row-label">Example phrases</div>
393 <div className="tone-phrases">
394 {outputs.tone.examplePhrases.map((p, i) => (
395 <div key={i} className="tone-phrase">{p}</div>
396 ))}
397 </div>
398 </div>
399 )}
400 </div>
401 </Section>
402
403 {/* Messaging */}
404 <Section title="Messaging" locked={locked.messaging} onToggleLock={() => onToggleLock('messaging')} copyText={messagingCopy}>
405 <div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
406 <div>
407 <div style={{ fontSize: 11, color: 'var(--text-3)', fontWeight: 500, marginBottom: 10 }}>Titles</div>
408 <EditableList items={outputs.titles} onChange={v => onEdit('titles', v)} />
409 </div>
410 <div>
411 <div style={{ fontSize: 11, color: 'var(--text-3)', fontWeight: 500, marginBottom: 10 }}>Subtitles</div>
412 <EditableList items={outputs.subtitles} onChange={v => onEdit('subtitles', v)} />
413 </div>
414 <div>
415 <div style={{ fontSize: 11, color: 'var(--text-3)', fontWeight: 500, marginBottom: 10 }}>Taglines</div>
416 <EditableList items={outputs.taglines} onChange={v => onEdit('taglines', v)} />
417 </div>
418 </div>
419 </Section>
420
421 {/* Visual Directions */}
422 <Section title="Visual Direction" locked={locked.visual} onToggleLock={() => onToggleLock('visual')}>
423 <div className="directions-grid">
424 {outputs.visualDirections.map(dir => (
425 <div key={dir.id} className="direction-card">
426 <div className="direction-name">{dir.name}</div>
427 <div className="direction-desc">{dir.description}</div>
428 <div className="direction-attrs">
429 <div className="direction-attr">
430 <span className="direction-attr-label">Palette</span>
431 <span className="direction-attr-value">{dir.palette}</span>
432 </div>
433 <div className="direction-attr">
434 <span className="direction-attr-label">Typography</span>
435 <span className="direction-attr-value">{dir.typography}</span>
436 </div>
437 <div className="direction-attr">
438 <span className="direction-attr-label">References</span>
439 <span className="direction-attr-value">{dir.references}</span>
440 </div>
441 </div>
442 </div>
443 ))}
444 </div>
445 </Section>
446
447 {/* Color Palette */}
448 <Section title="Color Palette" locked={locked.palette} onToggleLock={() => onToggleLock('palette')} copyText={paletteCopy}>
449 <div className="color-palette">
450 {outputs.palette.swatches.map((swatch, i) => (
451 <ColorSwatchCard
452 key={swatch.id}
453 swatch={swatch}
454 onChange={updated => updateSwatch(i, updated)}
455 onRemove={() => removeSwatch(i)}
456 />
457 ))}
458 <button className="color-swatch-add" onClick={addSwatch} title="Add color">
459 +
460 </button>
461 </div>
462 </Section>
463
464 {/* Typography */}
465 <Section title="Typography" locked={locked.typography} onToggleLock={() => onToggleLock('typography')}>
466 <TypographySection typography={outputs.typography} />
467 </Section>
468
469 {/* Logo Concepts */}
470 <Section title="Logo Concepts" locked={locked.logo} onToggleLock={() => onToggleLock('logo')}>
471 <div className="logo-grid">
472 {outputs.logoConcepts.map(lc => (
473 <div key={lc.id} className="logo-card">
474 <div className="logo-card-title">{lc.title}</div>
475 <div className="logo-card-concept">{lc.concept}</div>
476 <div className="logo-card-attrs">
477 <div className="logo-card-attr">
478 <span className="logo-card-attr-label">Mark</span>
479 <span className="logo-card-attr-value">{lc.mark}</span>
480 </div>
481 <div className="logo-card-attr">
482 <span className="logo-card-attr-label">Execution</span>
483 <span className="logo-card-attr-value">{lc.execution}</span>
484 </div>
485 </div>
486 </div>
487 ))}
488 </div>
489 </Section>
490
491 {/* Usage Examples */}
492 <Section title="Usage Examples" locked={locked.usage} onToggleLock={() => onToggleLock('usage')}>
493 <div className="usage-grid">
494 {outputs.usageExamples.map((ex, i) => (
495 <div key={i} className="usage-item">
496 <div className="usage-context">{ex.context}</div>
497 <div className="usage-text" style={{ position: 'relative' }}>
498 {ex.text}
499 <div className="usage-copy">
500 <CopyButton text={ex.text} label="Copy" />
501 </div>
502 </div>
503 </div>
504 ))}
505 </div>
506 </Section>
507
508 {/* Constraints */}
509 <Section title="Constraints" locked={locked.constraints} onToggleLock={() => onToggleLock('constraints')}>
510 <div className="constraints-list">
511 {outputs.constraints.map((c, i) => (
512 <div key={i} className="constraint-item">{c}</div>
513 ))}
514 </div>
515 </Section>
516 </div>
517 );
518}
src/components/CopyButton.tsx added +31
@@ -0,0 +1,31 @@
1import { useState } from 'react';
2import { copyToClipboard } from '../lib/clipboard';
3
4interface Props {
5 text: string;
6 label?: string;
7 className?: string;
8}
9
10export function CopyButton({ text, label = 'Copy', className = '' }: Props) {
11 const [copied, setCopied] = useState(false);
12
13 const handle = async () => {
14 const ok = await copyToClipboard(text);
15 if (ok) {
16 setCopied(true);
17 setTimeout(() => setCopied(false), 1500);
18 }
19 };
20
21 return (
22 <button
23 type="button"
24 className={`copy-btn${copied ? ' copied' : ''} ${className}`}
25 onClick={handle}
26 title={label}
27 >
28 {copied ? '✓ Copied' : label}
29 </button>
30 );
31}
src/components/InputPanel.tsx added +133
@@ -0,0 +1,133 @@
1import type { BrandInputs } from '../types';
2import { TokenInput } from './TokenInput';
3
4const TONE_SUGGESTIONS = ['minimal', 'technical', 'calm', 'bold', 'warm', 'dry', 'focused', 'sharp'];
5const AVOID_SUGGESTIONS = ['buzzwords', 'hype', 'startup language', 'passive voice', 'corporate tone', 'exclamation points', 'superlatives'];
6
7interface Props {
8 inputs: BrandInputs;
9 onChange: (inputs: BrandInputs) => void;
10 onGenerate: () => void;
11 isGenerating: boolean;
12 generateLabel?: string;
13}
14
15export function InputPanel({ inputs, onChange, onGenerate, isGenerating, generateLabel }: Props) {
16 const set = <K extends keyof BrandInputs>(key: K) =>
17 (value: BrandInputs[K]) => onChange({ ...inputs, [key]: value });
18
19 const handleKey = (e: React.KeyboardEvent) => {
20 if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
21 e.preventDefault();
22 onGenerate();
23 }
24 };
25
26 const canGenerate = inputs.name.trim().length > 0 && inputs.purpose.trim().length > 0;
27
28 return (
29 <div className="input-panel" onKeyDown={handleKey}>
30 <div className="input-section">
31 <div className="input-section-label">Project</div>
32 <div className="field">
33 <label className="field-label" htmlFor="f-name">Name</label>
34 <input
35 id="f-name"
36 className="field-input"
37 placeholder="e.g. Foundry"
38 value={inputs.name}
39 onChange={e => set('name')(e.target.value)}
40 />
41 </div>
42 <div className="field">
43 <label className="field-label" htmlFor="f-category">Category</label>
44 <input
45 id="f-category"
46 className="field-input"
47 placeholder="e.g. developer tool, design studio"
48 value={inputs.category}
49 onChange={e => set('category')(e.target.value)}
50 />
51 </div>
52 <div className="field">
53 <label className="field-label" htmlFor="f-purpose">One-line purpose</label>
54 <input
55 id="f-purpose"
56 className="field-input"
57 placeholder="e.g. deploy microservices without boilerplate"
58 value={inputs.purpose}
59 onChange={e => set('purpose')(e.target.value)}
60 />
61 </div>
62 <div className="field">
63 <label className="field-label" htmlFor="f-audience">Audience</label>
64 <input
65 id="f-audience"
66 className="field-input"
67 placeholder="e.g. backend engineers"
68 value={inputs.audience}
69 onChange={e => set('audience')(e.target.value)}
70 />
71 </div>
72 </div>
73
74 <div className="input-section">
75 <div className="input-section-label">Voice</div>
76 <div className="field">
77 <TokenInput
78 label="Tone attributes"
79 values={inputs.tone}
80 onChange={set('tone')}
81 suggestions={TONE_SUGGESTIONS}
82 placeholder="Add tone..."
83 />
84 </div>
85 <div className="field" style={{ marginTop: 8 }}>
86 <TokenInput
87 label="Avoid"
88 values={inputs.avoid}
89 onChange={set('avoid')}
90 suggestions={AVOID_SUGGESTIONS}
91 placeholder="Add avoid..."
92 />
93 </div>
94 </div>
95
96 <div className="input-section">
97 <div className="input-section-label">Notes</div>
98 <div className="field">
99 <label className="field-label sr-only" htmlFor="f-notes">Notes & constraints</label>
100 <textarea
101 id="f-notes"
102 className="field-textarea"
103 placeholder="Optional: constraints, context, inspirations, or anything the brand package should reflect."
104 value={inputs.notes}
105 onChange={e => set('notes')(e.target.value)}
106 rows={4}
107 />
108 </div>
109 </div>
110
111 <div className="generate-area">
112 <button
113 className={`btn-generate${isGenerating ? ' generating' : ''}`}
114 onClick={onGenerate}
115 disabled={!isGenerating && !canGenerate}
116 title={!canGenerate ? 'Enter a name and purpose to generate' : 'Generate brand package (⌘Enter)'}
117 >
118 {generateLabel ?? (isGenerating ? 'Generating…' : 'Generate brand package')}
119 </button>
120 {!canGenerate && (
121 <div style={{ marginTop: 7, fontSize: 11, color: 'var(--text-4)', textAlign: 'center' }}>
122 Name and purpose required
123 </div>
124 )}
125 {canGenerate && !isGenerating && (
126 <div style={{ marginTop: 7, fontSize: 11, color: 'var(--text-4)', textAlign: 'center' }}>
127 ⌘ Enter to generate
128 </div>
129 )}
130 </div>
131 </div>
132 );
133}
src/components/PackageSwitcher.tsx added +125
@@ -0,0 +1,125 @@
1import { useState, useEffect } from 'react';
2import { createPortal } from 'react-dom';
3import type { PackageSlot } from '../hooks/usePackages';
4
5interface Props {
6 slots: PackageSlot[];
7 activeId: string;
8 onSwitch: (id: string) => void;
9 onCreate: () => void;
10 onRemove: (id: string) => void;
11 onRename: (id: string, name: string) => void;
12 onDuplicate: (id: string) => void;
13}
14
15interface MenuState {
16 id: string;
17 top: number;
18 left: number;
19}
20
21export function PackageSwitcher({ slots, activeId, onSwitch, onCreate, onRemove, onRename, onDuplicate }: Props) {
22 const [editingId, setEditingId] = useState<string | null>(null);
23 const [draft, setDraft] = useState('');
24 const [menu, setMenu] = useState<MenuState | null>(null);
25
26 // Close dropdown on any outside click
27 useEffect(() => {
28 if (!menu) return;
29 const handler = () => setMenu(null);
30 document.addEventListener('click', handler);
31 return () => document.removeEventListener('click', handler);
32 }, [menu]);
33
34 const openMenu = (id: string, e: React.MouseEvent<HTMLButtonElement>) => {
35 e.stopPropagation();
36 if (menu?.id === id) { setMenu(null); return; }
37 const rect = e.currentTarget.getBoundingClientRect();
38 setMenu({ id, top: rect.bottom + 4, left: rect.left });
39 };
40
41 const commitRename = (id: string) => {
42 const name = draft.trim();
43 if (name) onRename(id, name);
44 setEditingId(null);
45 };
46
47 return (
48 <div className="pkg-switcher">
49 {slots.map(slot => (
50 <div
51 key={slot.id}
52 className={`pkg-tab${slot.id === activeId ? ' active' : ''}`}
53 onClick={() => slot.id !== activeId && onSwitch(slot.id)}
54 >
55 {editingId === slot.id ? (
56 <input
57 autoFocus
58 className="pkg-tab-input"
59 value={draft}
60 onChange={e => setDraft(e.target.value)}
61 onBlur={() => commitRename(slot.id)}
62 onKeyDown={e => {
63 if (e.key === 'Enter') commitRename(slot.id);
64 if (e.key === 'Escape') setEditingId(null);
65 }}
66 onClick={e => e.stopPropagation()}
67 />
68 ) : (
69 <span
70 className="pkg-tab-name"
71 onDoubleClick={e => {
72 e.stopPropagation();
73 setDraft(slot.name);
74 setEditingId(slot.id);
75 }}
76 >
77 {slot.name}
78 </span>
79 )}
80
81 <div className="pkg-tab-actions">
82 <button
83 className="pkg-tab-menu-btn"
84 title="Options"
85 onClick={e => openMenu(slot.id, e)}
86 >
87 ···
88 </button>
89 {slots.length > 1 && (
90 <button
91 className="pkg-tab-close"
92 title="Remove"
93 onClick={e => { e.stopPropagation(); onRemove(slot.id); }}
94 >
95 ×
96 </button>
97 )}
98 </div>
99 </div>
100 ))}
101
102 <button className="pkg-new-btn" onClick={onCreate} title="New package">+</button>
103
104 {/* Dropdown rendered in a portal to escape overflow clipping */}
105 {menu && createPortal(
106 <div
107 className="pkg-tab-dropdown"
108 style={{ position: 'fixed', top: menu.top, left: menu.left }}
109 onClick={e => e.stopPropagation()}
110 >
111 <button onClick={() => {
112 const slot = slots.find(s => s.id === menu.id);
113 if (slot) { setDraft(slot.name); setEditingId(slot.id); }
114 setMenu(null);
115 }}>Rename</button>
116 <button onClick={() => { onDuplicate(menu.id); setMenu(null); }}>Duplicate</button>
117 {slots.length > 1 && (
118 <button className="danger" onClick={() => { onRemove(menu.id); setMenu(null); }}>Delete</button>
119 )}
120 </div>,
121 document.body
122 )}
123 </div>
124 );
125}
src/components/PreviewPanel.tsx added +116
@@ -0,0 +1,116 @@
1import type { BrandInputs, BrandOutputs, LockedSections } from '../types';
2import { BrandDoc } from './BrandDoc';
3import { CopyButton } from './CopyButton';
4import { toMarkdown, toJSON, toHTML, downloadFile } from '../lib/export';
5
6interface Props {
7 inputs: BrandInputs;
8 outputs: BrandOutputs | null;
9 locked: LockedSections;
10 onToggleLock: (section: keyof LockedSections) => void;
11 onEdit: <K extends keyof BrandOutputs>(key: K, value: BrandOutputs[K]) => void;
12 isGenerating: boolean;
13 generateMode?: 'template' | string;
14 generateError?: string | null;
15 onGenerate?: () => void;
16}
17
18export function PreviewPanel({ inputs, outputs, locked, onToggleLock, onEdit, isGenerating, generateMode, generateError, onGenerate }: Props) {
19 const handleExportMd = () => {
20 if (!outputs) return;
21 downloadFile(toMarkdown(inputs, outputs), `${inputs.name || 'brand'}-package.md`, 'text/markdown');
22 };
23
24 const handleExportJson = () => {
25 if (!outputs) return;
26 downloadFile(toJSON(inputs, outputs), `${inputs.name || 'brand'}-package.json`, 'application/json');
27 };
28
29 const handleExportHtml = () => {
30 if (!outputs) return;
31 downloadFile(toHTML(inputs, outputs), `${inputs.name || 'brand'}-guidelines.html`, 'text/html');
32 };
33
34 const fullMarkdown = outputs ? toMarkdown(inputs, outputs) : '';
35
36 return (
37 <div className="preview-panel">
38 <div className="preview-toolbar">
39 <div className="preview-toolbar-left">
40 <span className="preview-doc-label">
41 {outputs ? 'Brand Package' : 'Preview'}
42 </span>
43 {outputs && (
44 <span style={{ fontSize: 11, color: 'var(--text-4)' }}>
45 · {new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
46 </span>
47 )}
48 </div>
49 {outputs && (
50 <div className="preview-toolbar-right">
51 <CopyButton text={fullMarkdown} label="Copy all" />
52 </div>
53 )}
54 </div>
55
56 {generateError && (
57 <div className="generate-error">
58 <span className="generate-error-icon">⚠</span>
59 <span className="generate-error-msg">{generateError}</span>
60 {onGenerate && (
61 <button className="generate-error-retry btn btn-ghost" onClick={onGenerate}>
62 Try template instead
63 </button>
64 )}
65 </div>
66 )}
67
68 <div className="preview-scroll">
69 {isGenerating ? (
70 <div className="empty-state">
71 <div className="empty-state-title" style={{ color: 'var(--text-3)' }}>
72 {generateMode && generateMode !== 'template'
73 ? `Thinking with ${generateMode}…`
74 : 'Generating…'}
75 </div>
76 {generateMode && generateMode !== 'template' && (
77 <div className="empty-state-sub">This may take 15–60 seconds depending on your hardware</div>
78 )}
79 </div>
80 ) : outputs ? (
81 <BrandDoc
82 inputs={inputs}
83 outputs={outputs}
84 locked={locked}
85 onToggleLock={onToggleLock}
86 onEdit={onEdit}
87 />
88 ) : (
89 <div className="empty-state">
90 <div className="empty-state-title">No package generated yet</div>
91 <div className="empty-state-sub">Fill in the project details and click Generate</div>
92 </div>
93 )}
94 </div>
95
96 {outputs && (
97 <div className="export-bar">
98 <span className="export-bar-left">
99 Export
100 </span>
101 <div className="export-bar-right">
102 <button className="btn" onClick={handleExportMd}>
103 Export Markdown
104 </button>
105 <button className="btn" onClick={handleExportJson}>
106 Export JSON
107 </button>
108 <button className="btn" onClick={handleExportHtml}>
109 Export HTML
110 </button>
111 </div>
112 </div>
113 )}
114 </div>
115 );
116}
src/components/SettingsPanel.tsx added +158
@@ -0,0 +1,158 @@
1import { useState, useEffect } from 'react';
2import type { OllamaSettings } from '../hooks/useSettings';
3import { testOllamaConnection } from '../engine/aiGenerator';
4
5interface Props {
6 settings: OllamaSettings;
7 onChange: (next: Partial<OllamaSettings>) => void;
8 onClose: () => void;
9}
10
11type TestState =
12 | { status: 'idle' }
13 | { status: 'testing' }
14 | { status: 'ok'; models: string[] }
15 | { status: 'error'; message: string };
16
17export function SettingsPanel({ settings, onChange, onClose }: Props) {
18 const [url, setUrl] = useState(settings.baseUrl);
19 const [model, setModel] = useState(settings.model);
20 const [test, setTest] = useState<TestState>({ status: 'idle' });
21
22 // Commit field on blur/enter
23 const commitUrl = () => onChange({ baseUrl: url });
24 const commitModel = (m: string) => { setModel(m); onChange({ model: m }); };
25
26 const runTest = async () => {
27 onChange({ baseUrl: url }); // save current URL first
28 setTest({ status: 'testing' });
29 try {
30 const models = await testOllamaConnection(url);
31 setTest({ status: 'ok', models });
32 } catch (err) {
33 setTest({ status: 'error', message: (err as Error).message });
34 }
35 };
36
37 // Close on Escape
38 useEffect(() => {
39 const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
40 window.addEventListener('keydown', handler);
41 return () => window.removeEventListener('keydown', handler);
42 }, [onClose]);
43
44 return (
45 <div className="settings-overlay" onClick={onClose}>
46 <div className="settings-panel" onClick={e => e.stopPropagation()}>
47 <div className="settings-header">
48 <span className="settings-title">Settings</span>
49 <button className="settings-close" onClick={onClose}>×</button>
50 </div>
51
52 <div className="settings-body">
53 {/* AI toggle */}
54 <div className="settings-section">
55 <div className="settings-section-label">AI Generation</div>
56 <label className="settings-toggle-row">
57 <div className="settings-toggle-info">
58 <span className="settings-toggle-name">Use Ollama</span>
59 <span className="settings-toggle-desc">
60 Replace template generation with a local AI model
61 </span>
62 </div>
63 <button
64 className={`toggle${settings.enabled ? ' on' : ''}`}
65 onClick={() => onChange({ enabled: !settings.enabled })}
66 role="switch"
67 aria-checked={settings.enabled}
68 >
69 <span className="toggle-thumb" />
70 </button>
71 </label>
72 </div>
73
74 {/* Ollama config */}
75 {settings.enabled && (
76 <div className="settings-section">
77 <div className="settings-section-label">Ollama</div>
78
79 <div className="settings-field">
80 <label className="settings-field-label">Server URL</label>
81 <div className="settings-field-row">
82 <input
83 className="settings-input"
84 type="url"
85 value={url}
86 onChange={e => setUrl(e.target.value)}
87 onBlur={commitUrl}
88 onKeyDown={e => e.key === 'Enter' && commitUrl()}
89 placeholder="http://localhost:11434"
90 spellCheck={false}
91 />
92 <button
93 className={`btn settings-test-btn${test.status === 'testing' ? ' testing' : ''}`}
94 onClick={runTest}
95 disabled={test.status === 'testing'}
96 >
97 {test.status === 'testing' ? 'Testing…' : 'Test'}
98 </button>
99 </div>
100
101 {test.status === 'ok' && (
102 <div className="settings-status ok">
103 Connected · {test.models.length} model{test.models.length !== 1 ? 's' : ''} available
104 </div>
105 )}
106 {test.status === 'error' && (
107 <div className="settings-status error">{test.message}</div>
108 )}
109 </div>
110
111 <div className="settings-field">
112 <label className="settings-field-label">Model</label>
113 <input
114 className="settings-input"
115 value={model}
116 onChange={e => setModel(e.target.value)}
117 onBlur={() => commitModel(model)}
118 onKeyDown={e => e.key === 'Enter' && commitModel(model)}
119 placeholder="llama3.2"
120 spellCheck={false}
121 />
122 {test.status === 'ok' && test.models.length > 0 && (
123 <div className="settings-model-list">
124 {test.models.map(m => (
125 <button
126 key={m}
127 className={`settings-model-item${m === model ? ' active' : ''}`}
128 onClick={() => commitModel(m)}
129 >
130 {m}
131 </button>
132 ))}
133 </div>
134 )}
135 <div className="settings-field-hint">
136 Pull a model first: <code>ollama pull llama3.2</code>
137 </div>
138 </div>
139 </div>
140 )}
141
142 {/* Help */}
143 <div className="settings-section settings-help">
144 <div className="settings-section-label">About Ollama</div>
145 <p>
146 Ollama runs AI models locally on your machine — no API key, no data sent externally.
147 Install from <strong>ollama.com</strong>, then pull any model to get started.
148 </p>
149 <p>
150 If connection fails, ensure Ollama is running and CORS is open:<br />
151 <code>OLLAMA_ORIGINS=* ollama serve</code>
152 </p>
153 </div>
154 </div>
155 </div>
156 </div>
157 );
158}
src/components/TokenInput.tsx added +85
@@ -0,0 +1,85 @@
1import { useState, useRef, KeyboardEvent } from 'react';
2
3interface Props {
4 label: string;
5 values: string[];
6 onChange: (values: string[]) => void;
7 suggestions?: string[];
8 placeholder?: string;
9}
10
11export function TokenInput({ label, values, onChange, suggestions = [], placeholder = 'Type and press Enter' }: Props) {
12 const [draft, setDraft] = useState('');
13 const inputRef = useRef<HTMLInputElement>(null);
14
15 const add = (val: string) => {
16 const trimmed = val.trim().toLowerCase();
17 if (trimmed && !values.includes(trimmed)) {
18 onChange([...values, trimmed]);
19 }
20 setDraft('');
21 };
22
23 const remove = (val: string) => {
24 onChange(values.filter(v => v !== val));
25 };
26
27 const handleKey = (e: KeyboardEvent<HTMLInputElement>) => {
28 if ((e.key === 'Enter' || e.key === ',') && draft.trim()) {
29 e.preventDefault();
30 add(draft);
31 } else if (e.key === 'Backspace' && !draft && values.length > 0) {
32 remove(values[values.length - 1]);
33 }
34 };
35
36 const availableSuggestions = suggestions.filter(s => !values.includes(s));
37
38 return (
39 <div className="token-field">
40 <label className="field-label">{label}</label>
41 <div
42 className="token-container"
43 onClick={() => inputRef.current?.focus()}
44 >
45 {values.map(v => (
46 <span key={v} className="token">
47 {v}
48 <button
49 type="button"
50 className="token-remove"
51 onClick={e => { e.stopPropagation(); remove(v); }}
52 aria-label={`Remove ${v}`}
53 >
54 ×
55 </button>
56 </span>
57 ))}
58 <input
59 ref={inputRef}
60 className="token-input"
61 value={draft}
62 onChange={e => setDraft(e.target.value)}
63 onKeyDown={handleKey}
64 onBlur={() => { if (draft.trim()) add(draft); }}
65 placeholder={values.length === 0 ? placeholder : ''}
66 aria-label={label}
67 />
68 </div>
69 {availableSuggestions.length > 0 && (
70 <div className="token-suggestions">
71 {availableSuggestions.map(s => (
72 <button
73 key={s}
74 type="button"
75 className={`token-suggestion${values.includes(s) ? ' active' : ''}`}
76 onClick={() => add(s)}
77 >
78 {s}
79 </button>
80 ))}
81 </div>
82 )}
83 </div>
84 );
85}
src/engine/aiGenerator.ts added +138
@@ -0,0 +1,138 @@
1import type { BrandInputs, BrandOutputs } from '../types';
2import type { OllamaSettings } from '../hooks/useSettings';
3import { sanitizeOutputs } from '../lib/sanitize';
4
5const SYSTEM_PROMPT = `You are an expert brand strategist and copywriter. Generate a complete brand identity package as a JSON object. Be specific and creative — every output must feel crafted for the exact brand described, not generic. Use the project details to inform all choices: tone, color palette, typography, and messaging.`;
6
7function buildUserPrompt(inputs: BrandInputs): string {
8 const lines = [
9 'Generate a brand package for this project.',
10 '',
11 'PROJECT DETAILS:',
12 `Name: ${inputs.name || 'Untitled'}`,
13 `Category: ${inputs.category || 'general'}`,
14 `Purpose: ${inputs.purpose || 'not specified'}`,
15 `Target audience: ${inputs.audience || 'not specified'}`,
16 inputs.tone.length ? `Tone words: ${inputs.tone.join(', ')}` : '',
17 inputs.avoid.length ? `Words/phrases to avoid: ${inputs.avoid.join(', ')}` : '',
18 inputs.notes ? `Additional notes: ${inputs.notes}` : '',
19 '',
20 'Return ONLY a valid JSON object with exactly this structure:',
21 '',
22 JSON.stringify({
23 overview: 'One crisp sentence: what this project is and who it helps',
24 positioning: '2–3 sentence strategic positioning statement — what makes this brand distinct',
25 tone: {
26 attributes: ['attribute1', 'attribute2', 'attribute3', 'attribute4'],
27 voiceNotes: '2–3 sentences describing how the brand writes and speaks',
28 avoidList: ['thing to never say or do', 'another thing to avoid'],
29 examplePhrases: ['A phrase that shows the brand voice', 'Another example', 'A third example'],
30 },
31 titles: ['Title option 1', 'Title option 2', 'Title option 3'],
32 subtitles: ['Subtitle option 1', 'Subtitle option 2', 'Subtitle option 3'],
33 taglines: ['Short punchy tagline', 'Alternative tagline', 'Third tagline option'],
34 palette: {
35 swatches: [
36 { id: 's0', name: 'Background', hex: '#hexcode', role: 'background' },
37 { id: 's1', name: 'Surface', hex: '#hexcode', role: 'neutral' },
38 { id: 's2', name: 'Primary', hex: '#hexcode', role: 'primary' },
39 { id: 's3', name: 'Accent', hex: '#hexcode', role: 'accent' },
40 { id: 's4', name: 'Text', hex: '#hexcode', role: 'text' },
41 ],
42 },
43 typography: {
44 primary: 'Primary font name (e.g. Inter, Playfair Display)',
45 secondary: 'Secondary font name or same as primary',
46 mono: 'Monospace font name (e.g. JetBrains Mono, Fira Code)',
47 pairNote: 'One sentence on why this pairing fits the brand',
48 },
49 visualDirections: [
50 { id: 'v1', name: 'Direction name', description: '2–3 sentence visual direction description', palette: 'Color mood description', typography: 'Type style description', references: 'Visual references and inspirations' },
51 { id: 'v2', name: 'Direction name', description: '2–3 sentence description', palette: 'Color mood', typography: 'Type style', references: 'Visual references' },
52 { id: 'v3', name: 'Direction name', description: '2–3 sentence description', palette: 'Color mood', typography: 'Type style', references: 'Visual references' },
53 ],
54 logoConcepts: [
55 { id: 'l1', title: 'Logo concept name', concept: 'Concept description and rationale', mark: 'Mark/symbol description', execution: 'Execution and usage guidelines' },
56 { id: 'l2', title: 'Second concept name', concept: 'Concept description', mark: 'Mark description', execution: 'Execution guidelines' },
57 ],
58 usageExamples: [
59 { context: 'README headline', text: 'Actual example copy here' },
60 { context: 'Landing page hero', text: 'Actual example copy here' },
61 { context: 'Social bio', text: 'Actual example copy here' },
62 { context: 'Email subject line', text: 'Actual example copy here' },
63 ],
64 constraints: [
65 'A specific copy rule',
66 'Another brand constraint',
67 'A third constraint',
68 'A fourth constraint',
69 ],
70 }, null, 2),
71 ];
72
73 return lines.filter(l => l !== null).join('\n');
74}
75
76function validate(raw: unknown): BrandOutputs {
77 const result = sanitizeOutputs(raw);
78 if (!result) throw new Error('Response is not a valid brand output object');
79 return result;
80}
81
82export async function generateWithAI(
83 inputs: BrandInputs,
84 settings: OllamaSettings,
85 signal?: AbortSignal,
86): Promise<BrandOutputs> {
87 const url = `${settings.baseUrl.replace(/\/$/, '')}/api/chat`;
88
89 let res: Response;
90 try {
91 res = await fetch(url, {
92 method: 'POST',
93 headers: { 'Content-Type': 'application/json' },
94 signal,
95 body: JSON.stringify({
96 model: settings.model,
97 messages: [
98 { role: 'system', content: SYSTEM_PROMPT },
99 { role: 'user', content: buildUserPrompt(inputs) },
100 ],
101 format: 'json',
102 stream: false,
103 }),
104 });
105 } catch (err) {
106 if ((err as Error).name === 'AbortError') throw err;
107 throw new Error(`Cannot reach Ollama at ${settings.baseUrl}. Is it running?`);
108 }
109
110 if (!res.ok) {
111 const text = await res.text().catch(() => '');
112 if (res.status === 404) throw new Error(`Model "${settings.model}" not found. Pull it first: ollama pull ${settings.model}`);
113 throw new Error(`Ollama error ${res.status}: ${text.slice(0, 120)}`);
114 }
115
116 const data = await res.json() as { message?: { content?: string } };
117 const content = data?.message?.content;
118 if (!content) throw new Error('Empty response from Ollama');
119
120 let parsed: unknown;
121 try {
122 parsed = JSON.parse(content);
123 } catch {
124 throw new Error('Ollama returned invalid JSON. Try a larger model.');
125 }
126
127 // validate() calls sanitizeOutputs(), which injects TYPE_SCALE for fresh AI output
128 return validate(parsed);
129}
130
131// Test connectivity and return available model names
132export async function testOllamaConnection(baseUrl: string): Promise<string[]> {
133 const url = `${baseUrl.replace(/\/$/, '')}/api/tags`;
134 const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
135 if (!res.ok) throw new Error(`Ollama responded with ${res.status}`);
136 const data = await res.json() as { models?: Array<{ name: string }> };
137 return (data.models ?? []).map(m => m.name.replace(/:latest$/, ''));
138}
src/engine/generator.ts added +957
@@ -0,0 +1,957 @@
1import type {
2 BrandInputs,
3 BrandOutputs,
4 ToneGuidance,
5 VisualDirection,
6 LogoConcept,
7 UsageExample,
8 ColorPalette,
9 ColorSwatch,
10 Typography,
11 TypographyToken,
12} from '../types';
13
14type CategoryType = 'developer' | 'creative' | 'product' | 'services' | 'personal' | 'general';
15
16interface GenContext {
17 name: string;
18 category: string;
19 purpose: string;
20 audience: string;
21 tone: string[];
22 avoid: string[];
23 notes: string;
24 catType: CategoryType;
25 hasTone: (t: string) => boolean;
26 hasAvoid: (a: string) => boolean;
27}
28
29// ── Helpers ──────────────────────────────────────────────────────────────────
30
31function detectCategory(raw: string): CategoryType {
32 const c = raw.toLowerCase();
33 if (/\b(dev|developer|tool|cli|sdk|api|library|lib|framework|plugin|compiler|linter|utility)\b/.test(c))
34 return 'developer';
35 if (/\b(design|creative|studio|agency|art|visual|branding|illustration|photography)\b/.test(c))
36 return 'creative';
37 if (/\b(saas|platform|service|app|software|product|startup|dashboard)\b/.test(c))
38 return 'product';
39 if (/\b(consult|advisory|freelance|firm|practice|coach|training)\b/.test(c))
40 return 'services';
41 if (/\b(personal|portfolio|blog|brand|me|writer|designer|maker|creator)\b/.test(c))
42 return 'personal';
43 return 'general';
44}
45
46function buildContext(inputs: BrandInputs): GenContext {
47 const catType = detectCategory(inputs.category);
48 const hasTone = (t: string) =>
49 inputs.tone.some(x => x.toLowerCase().includes(t.toLowerCase()));
50 const hasAvoid = (a: string) =>
51 inputs.avoid.some(x => x.toLowerCase().includes(a.toLowerCase()));
52
53 return {
54 name: inputs.name.trim() || 'Untitled',
55 category: inputs.category.trim() || 'project',
56 purpose: inputs.purpose.trim() || 'solve a specific problem',
57 audience: inputs.audience.trim() || 'its intended users',
58 tone: inputs.tone,
59 avoid: inputs.avoid,
60 notes: inputs.notes.trim(),
61 catType,
62 hasTone,
63 hasAvoid,
64 };
65}
66
67function pick<T>(arr: T[]): T {
68 return arr[Math.floor(Math.random() * arr.length)];
69}
70
71function pickN<T>(arr: T[], n: number): T[] {
72 const copy = [...arr];
73 const result: T[] = [];
74 while (result.length < n && copy.length > 0) {
75 const i = Math.floor(Math.random() * copy.length);
76 result.push(copy.splice(i, 1)[0]);
77 }
78 return result;
79}
80
81function cap(s: string): string {
82 return s.charAt(0).toUpperCase() + s.slice(1);
83}
84
85// ── Overview ─────────────────────────────────────────────────────────────────
86
87function generateOverview(ctx: GenContext): string {
88 const { name, category, purpose, audience, catType } = ctx;
89
90 const base = pick([
91 `${name} is a ${category} for ${audience}. ${cap(purpose)}.`,
92 `A ${category} built for ${audience}. ${name} is built to ${purpose}.`,
93 `${name} helps ${audience} ${purpose}.`,
94 `${name} is a ${category} built to ${purpose}. Made for ${audience}.`,
95 ]);
96
97 let suffix = '';
98 if (ctx.hasTone('minimal')) suffix = ' Nothing more.';
99 else if (ctx.hasTone('technical')) suffix = ' Stays out of the way while doing its job.';
100 else if (ctx.hasTone('calm')) suffix = ' Designed to reduce friction, not add to it.';
101 else if (ctx.hasTone('bold')) suffix = ' No compromises.';
102 else if (ctx.hasTone('warm')) suffix = ' Made with care.';
103 else if (catType === 'developer') suffix = ' Built to stay composable and predictable.';
104 else if (catType === 'creative') suffix = ' Work that speaks before the introduction.';
105
106 return base + suffix;
107}
108
109// ── Positioning ───────────────────────────────────────────────────────────────
110
111function generatePositioning(ctx: GenContext): string {
112 const { name, catType } = ctx;
113
114 const templates: Record<CategoryType, string[]> = {
115 developer: [
116 `${name} sits in the space between "build it yourself" and "platform lock-in." It's a workflow layer, not an abstraction. You keep control; ${name} handles the repetitive parts.`,
117 `Most tools in this space try to do too much. ${name} doesn't. It handles exactly what it promises — then stays out of your way.`,
118 `${name} is not a platform. It's a tool. You own the infrastructure; ${name} owns the workflow. That distinction matters.`,
119 ],
120 creative: [
121 `${name} is defined by its process as much as its output. The work should be recognizable without a logo — that's the goal.`,
122 `${name} occupies a deliberate position: between personal and professional, between output and craft. Not trying to be everything. Trying to be specific.`,
123 `There's no shortage of creative work. ${name} earns attention through quality and consistency, not volume or novelty.`,
124 ],
125 product: [
126 `${name} doesn't try to replace your existing workflow. It fits into it. The goal is to reduce friction in a specific, measurable place.`,
127 `The space ${name} plays in has no shortage of tools. What it offers is focus — one problem, done well, with clear boundaries.`,
128 `${name} is built for people who've tried the alternatives and found them too complicated, too expensive, or too broad.`,
129 ],
130 services: [
131 `${name} is not a generalist practice. It works on a specific type of problem with a specific type of client. That specificity is the positioning.`,
132 `Clients come to ${name} when they need someone who has solved this problem before. The brand should communicate experience, not aspiration.`,
133 `${name} does one thing well and charges accordingly. Clarity of scope is the offer.`,
134 ],
135 personal: [
136 `${name} is a deliberate presence — not a portfolio, not a platform, not a personal brand in the marketing sense. A clear point of view, consistently expressed.`,
137 `${name} doesn't try to appeal to everyone. It's built around a specific set of interests and a specific way of working.`,
138 `There are a lot of personal sites. ${name} is distinct by being specific — not broad, not aspirational, not trying to cover every base.`,
139 ],
140 general: [
141 `${name} occupies a clear position: built for a specific audience with a specific need. Not trying to be everything.`,
142 `${name} is not for everyone. That's intentional. Clarity of purpose is more valuable than breadth of appeal.`,
143 ],
144 };
145
146 return pick(templates[catType] ?? templates.general);
147}
148
149// ── Tone Guidance ─────────────────────────────────────────────────────────────
150
151function generateToneGuidance(ctx: GenContext): ToneGuidance {
152 const { name, purpose, tone, avoid, catType, hasTone } = ctx;
153
154 const voiceMap: Record<string, string> = {
155 minimal: 'Short sentences. No adjectives unless load-bearing. Direct.',
156 technical: 'Precise nouns, specific verbs. Write for experts. Avoid explaining what the reader already knows.',
157 calm: 'Measured pace. Confident statements. No urgency cues. Let the work speak.',
158 bold: 'Strong verbs. Active voice. Make a claim and stand behind it.',
159 warm: 'Approachable but not casual. Human without being informal.',
160 dry: 'Deadpan. Understate. Trust the reader to get it.',
161 focused: 'Stay on topic. One idea per sentence. Cut the rest.',
162 };
163
164 const catVoiceDefaults: Record<CategoryType, string> = {
165 developer: 'Write for engineers. Assume technical literacy. Specificity earns trust.',
166 creative: 'Lead with the work. Copy should serve the visual, not explain it.',
167 product: 'Clear over clever. Features earn their mention by solving something real.',
168 services: "Experience over enthusiasm. What you've done, not what you'll do.",
169 personal: 'First person where appropriate. Honest and considered.',
170 general: 'Clear, direct, grounded. Earn attention with specificity.',
171 };
172
173 const toneVoiceParts = tone.map(t => voiceMap[t.toLowerCase()]).filter(Boolean);
174 const voiceNotes =
175 toneVoiceParts.length > 0
176 ? toneVoiceParts.join(' ')
177 : catVoiceDefaults[catType] ?? catVoiceDefaults.general;
178
179 const avoidDefaults: Record<CategoryType, string[]> = {
180 developer: ['"seamlessly"', '"game-changing"', '"powerful" as an adjective', 'passive voice'],
181 creative: ['"unique"', '"innovative"', '"passion-driven"', 'agency-speak'],
182 product: ['"revolutionary"', '"disrupts"', '"leverage"', '"synergy"'],
183 services: ['"partner"', '"solutions"', '"holistic"', '"best-in-class"'],
184 personal: ['"journey"', '"passionate about"', '"thought leader"', '"excited to announce"'],
185 general: ['"world-class"', '"cutting-edge"', '"innovative"', '"paradigm"'],
186 };
187
188 const avoidList = [
189 ...avoid,
190 ...(avoidDefaults[catType] ?? avoidDefaults.general).filter(
191 a => !avoid.some(ua => ua.toLowerCase().includes(a.toLowerCase().replace(/"/g, '')))
192 ),
193 ];
194
195 const phrasesByTone: string[] = [];
196 if (hasTone('minimal')) phrasesByTone.push(`"${name}. ${cap(purpose)}."`);
197 if (hasTone('technical'))
198 phrasesByTone.push('"Typed, composable, deterministic."');
199 if (hasTone('calm'))
200 phrasesByTone.push('"Reliable tools, clearly documented, quietly maintained."');
201 if (hasTone('bold')) phrasesByTone.push('"Pick it up. It works. Put it down."');
202 if (hasTone('warm'))
203 phrasesByTone.push('"Made for people who care about their tools."');
204
205 const catPhrases: Record<CategoryType, string[]> = {
206 developer: [
207 '"One command. Done."',
208 '"Less ceremony. More output."',
209 '"Configure once. Forget about it."',
210 ],
211 creative: [
212 '"The work is the argument."',
213 '"No portfolio lorem ipsum."',
214 '"Good work, clearly presented."',
215 ],
216 product: [
217 '"It fits where you already work."',
218 '"No setup tax."',
219 '"Does one thing. Does it well."',
220 ],
221 services: [
222 '"You\'ve seen this problem before. So have we."',
223 '"Specific outcomes, clear process."',
224 '"We don\'t do vague."',
225 ],
226 personal: [
227 '"This is what I work on."',
228 '"Specific interests, honest opinions."',
229 '"No personal brand. Just work."',
230 ],
231 general: [
232 '"Built for a reason."',
233 '"Useful before impressive."',
234 '"Does what it says."',
235 ],
236 };
237
238 const allPhrases = [
239 ...phrasesByTone,
240 ...(catPhrases[catType] ?? catPhrases.general),
241 ];
242
243 return {
244 attributes: tone.length > 0 ? tone : ['direct', 'clear'],
245 voiceNotes,
246 avoidList,
247 examplePhrases: pickN(allPhrases, Math.min(4, allPhrases.length)),
248 };
249}
250
251// ── Titles ────────────────────────────────────────────────────────────────────
252
253function generateTitles(ctx: GenContext): string[] {
254 const { name, category, purpose, audience, hasTone } = ctx;
255
256 const purposeWords = purpose.split(/\s+/);
257 const coreVerb = purposeWords[0] ?? 'build';
258 const audienceShort = audience.split(/\s+/).slice(0, 3).join(' ');
259
260 // Distinct structural patterns — each must look meaningfully different
261 const variants: string[] = [
262 `${name} — ${category} for ${audienceShort}`,
263 `${name} / ${audience}`,
264 `${name}: ${cap(coreVerb)} without ceremony`,
265 `${name} for ${audienceShort}`,
266 `${name} — built to ${coreVerb}`,
267 ];
268
269 // Tone-specific variants
270 if (hasTone('minimal')) variants.push(`${name}.`);
271 if (hasTone('technical')) {
272 variants.push(`${name} — ${category} utility`);
273 variants.push(`${name} — ${coreVerb}, ship, repeat`);
274 }
275 if (hasTone('bold')) {
276 variants.push(`${name}. Built for ${audienceShort}.`);
277 variants.push(`${name}. No ceremony.`);
278 }
279 if (hasTone('calm')) variants.push(`${name} — a ${category} for ${audienceShort}`);
280
281 // Deduplicate and ensure first is always bare name
282 const pool = variants.filter(v => v !== name);
283 const extras = pickN(pool, 2);
284 return [name, ...extras];
285}
286
287// ── Subtitles ─────────────────────────────────────────────────────────────────
288
289function generateSubtitles(ctx: GenContext): string[] {
290 const { name, category, purpose, audience } = ctx;
291 const purposeShort = purpose.split(/\s+/).slice(0, 6).join(' ');
292
293 const all: string[] = [
294 `${cap(category)} for ${audience}.`,
295 `${cap(purpose)}.`,
296 `A ${category} built to ${purpose}.`,
297 `Built for ${audience} who need to ${purposeShort}.`,
298 `${name}: the ${category} for ${audience}.`,
299 `${cap(category)}. For ${audience}.`,
300 `${cap(purpose)}. No overhead.`,
301 `The ${category} for ${audience} who know what they need.`,
302 ];
303
304 return pickN(all, 3);
305}
306
307// ── Taglines ──────────────────────────────────────────────────────────────────
308
309function generateTaglines(ctx: GenContext): string[] {
310 const { category, purpose, audience, catType, hasTone } = ctx;
311
312 const purposeWords = purpose.split(/\s+/);
313 const coreVerb = purposeWords[0] ?? 'build';
314 // Take only the object noun (skip prepositions like "without", "for", "with")
315 const stopWords = new Set(['without', 'with', 'for', 'and', 'or', 'the', 'a', 'an']);
316 const coreNounWords = purposeWords.slice(1).filter(w => !stopWords.has(w.toLowerCase()));
317 const coreNoun = coreNounWords[0] || 'your work';
318 const audienceShort = audience.split(/\s+/).slice(0, 2).join(' ');
319
320 const catTaglines: Record<CategoryType, string[]> = {
321 developer: [
322 `${cap(coreVerb)}, ship, move on.`,
323 `Less boilerplate. More control.`,
324 `${cap(category)} that stays out of your way.`,
325 `One command. Done.`,
326 `Configure once. Forget about it.`,
327 `Less ceremony. More output.`,
328 `Built for ${audienceShort} who ship.`,
329 `${cap(coreNoun)}, no overhead.`,
330 ],
331 creative: [
332 `The work is the argument.`,
333 `Good work, clearly presented.`,
334 `No introduction needed.`,
335 `${cap(coreNoun)}, done properly.`,
336 `Craft over noise.`,
337 `Say less. Show more.`,
338 `Quality, consistently.`,
339 ],
340 product: [
341 `Does one thing. Does it well.`,
342 `Fits where you already work.`,
343 `No setup tax.`,
344 `Built for ${audienceShort}.`,
345 `${cap(coreVerb)} without the friction.`,
346 `One less problem.`,
347 `Useful before impressive.`,
348 ],
349 services: [
350 `You've seen this problem before. So have we.`,
351 `Specific outcomes. Clear process.`,
352 `Experience, not enthusiasm.`,
353 `We don't do vague.`,
354 `The result is the product.`,
355 `Built for the problem you actually have.`,
356 ],
357 personal: [
358 `This is what I work on.`,
359 `Specific interests. Honest opinions.`,
360 `Work, not performance.`,
361 `Making things that matter.`,
362 `No brand. Just work.`,
363 ],
364 general: [
365 `Built for a reason.`,
366 `Useful before impressive.`,
367 `Does what it says.`,
368 `${cap(coreVerb)} without the noise.`,
369 `For ${audienceShort} who know what they want.`,
370 ],
371 };
372
373 const toneTaglines: string[] = [];
374 if (hasTone('minimal')) toneTaglines.push(`${cap(coreVerb)}. Ship.`, `Simple by design.`);
375 if (hasTone('calm'))
376 toneTaglines.push(`Reliable tools, quietly maintained.`, `Steady. Dependable. Yours.`);
377 if (hasTone('bold'))
378 toneTaglines.push(`Pick it up. It works.`, `No compromises.`, `Built to be used.`);
379 if (hasTone('technical'))
380 toneTaglines.push(`Typed. Composable. Predictable.`, `Deterministic by design.`);
381
382 const pool = [...(catTaglines[catType] ?? catTaglines.general), ...toneTaglines];
383 return pickN(pool, 3);
384}
385
386// ── Visual Directions ─────────────────────────────────────────────────────────
387
388function generateVisualDirections(ctx: GenContext): VisualDirection[] {
389 const { catType, hasTone } = ctx;
390
391 const allDirections: VisualDirection[] = [];
392
393 if (catType === 'developer' || hasTone('technical') || hasTone('minimal')) {
394 allDirections.push(
395 {
396 id: 'terminal-minimal',
397 name: 'Terminal Minimal',
398 description:
399 'Dark background, monospace type throughout, no ornament. Functional and uncompromising. Every element earns its place.',
400 palette: 'Near-black ground (#0d0d0d), off-white text (#e0e0e0), single muted accent (amber or green).',
401 typography: 'Monospace primary — JetBrains Mono or Iosevka. Consistent weight. No italic.',
402 references: 'htop, k9s, the Stripe CLI, Linear issue view.',
403 },
404 {
405 id: 'technical-document',
406 name: 'Technical Document',
407 description:
408 'Off-white ground, dense information layout, RFC/spec aesthetic. Designed for reading, not scanning. Typography does all the work.',
409 palette: 'Warm white (#f5f3ef), dark text (#1a1a1a), minimal color — one functional accent only.',
410 typography: 'Sans-serif (Inter or similar) for body, mono for code. Tight line height. Strong hierarchy through size and weight alone.',
411 references: 'Stripe docs, Oxide Computer RFCs, GNU manpages reformatted.',
412 },
413 {
414 id: 'precision-interface',
415 name: 'Precision Interface',
416 description:
417 'Neutral mid-range palette, strong grid, engineering-tool aesthetic. Balanced between document and application. Calm but capable.',
418 palette: 'Mid-grey ground (#f0f0f0 or #1c1c1c), charcoal type, restrained use of blue or slate as action color.',
419 typography: 'Sans-serif for UI, mono for data. Clear size differentiation. No decorative weight use.',
420 references: 'Figma sidebar, Retool, Zed editor, TablePlus.',
421 }
422 );
423 }
424
425 if (catType === 'creative' || catType === 'personal') {
426 allDirections.push(
427 {
428 id: 'editorial',
429 name: 'Editorial',
430 description:
431 'Strong typographic hierarchy, restrained palette, print-design influences. Work foreground, everything else background.',
432 palette: 'Off-white or cream (#f7f4ef), near-black type, accent used once — a single warm or cool tone.',
433 typography: 'A good serif for display, neutral sans for body. Generous leading. No decorative fonts.',
434 references: 'Are.na, Typewolf, Emigre back catalog, Letterform Archive.',
435 },
436 {
437 id: 'quiet-studio',
438 name: 'Quiet Studio',
439 description:
440 'Neutral and considered. Nothing decorative. Space used to direct attention, not fill it.',
441 palette: 'Warm white (#fafaf8) or deep neutral (#141414), type-only color use. No gradients.',
442 typography: 'One typeface family, two weights. Let leading and spacing create rhythm.',
443 references: 'Pentagram case studies, Swiss International Style, Muji product design.',
444 },
445 {
446 id: 'contemporary-craft',
447 name: 'Contemporary Craft',
448 description:
449 'Tactile references — paper, grain, texture — applied with restraint. Warmth without nostalgia.',
450 palette: 'Off-white base with a warm paper tone, subtle texture overlays, earthy accent.',
451 typography: 'Mix: display serif + utility sans. Comfortable reading size. Generous margins.',
452 references: 'Oak Studio, Analog, Offscreen Magazine, Present & Correct.',
453 }
454 );
455 }
456
457 if (catType === 'product' || catType === 'services') {
458 allDirections.push(
459 {
460 id: 'focused-product',
461 name: 'Focused Product',
462 description:
463 'Clean, professional, information-forward. Looks like it was built to be used, not to be admired. Trust through clarity.',
464 palette: 'White or light grey ground, dark neutral type, one brand color used only for primary actions.',
465 typography: 'Neutral sans-serif, systematic sizing, no personality — the product is the personality.',
466 references: 'Linear, Cron (v1), Vercel dashboard, Raycast.',
467 },
468 {
469 id: 'minimal-commerce',
470 name: 'Minimal Commerce',
471 description:
472 'Premium restraint. No decorative elements. White space signals quality. Type-driven.',
473 palette: 'White ground, black type, one warm accent for selective emphasis.',
474 typography: 'A refined sans-serif. Large display size for key claims. Small, tracked caps for labels.',
475 references: 'Stripe marketing, Basecamp, Arc browser landing page.',
476 },
477 {
478 id: 'structured-trust',
479 name: 'Structured Trust',
480 description:
481 'Grid-heavy, methodical, legible. Communicates that things are in order. More function, less flourish.',
482 palette: 'Light neutral ground, two text weights (body + emphasis), a contained accent color.',
483 typography: 'Professional sans-serif — GT Walsheim or Plus Jakarta or similar. Tight tracking for headings.',
484 references: 'Harvest app, FreshBooks, Notion, Loom landing page.',
485 }
486 );
487 }
488
489 if (allDirections.length === 0) {
490 allDirections.push(
491 {
492 id: 'type-forward',
493 name: 'Type Forward',
494 description:
495 'Typography as the only design element. No illustration, no photography, no pattern. Words do everything.',
496 palette: 'Black and white, one optional accent. No gradients.',
497 typography: 'One great typeface. Multiple weights. Extreme size contrast. Nothing else needed.',
498 references: 'Early Bloomberg Businessweek covers, Helvetica film poster, The Economist.',
499 },
500 {
501 id: 'system-neutral',
502 name: 'System Neutral',
503 description:
504 'Invisible design — system fonts, default spacing, no signature. The brand is in the content, not the container.',
505 palette: 'System defaults. One custom color — the brand color. Everything else inherited.',
506 typography: 'System UI stack. Optimized for the OS it runs on.',
507 references: 'HN, iA Writer, Pinboard, older Stripe.',
508 }
509 );
510 }
511
512 return pickN(allDirections, Math.min(3, allDirections.length));
513}
514
515// ── Logo Concepts ─────────────────────────────────────────────────────────────
516
517function generateLogoConcepts(ctx: GenContext): LogoConcept[] {
518 const { name, catType } = ctx;
519 const initial = name.charAt(0).toUpperCase();
520 const initials =
521 name
522 .split(/\s+/)
523 .slice(0, 2)
524 .map(w => w.charAt(0).toUpperCase())
525 .join('') || initial;
526
527 const concepts: LogoConcept[] = [];
528
529 if (catType === 'developer') {
530 concepts.push(
531 {
532 id: 'geometric-letterform',
533 title: 'Geometric Letterform',
534 concept: `The letter "${initial}" treated as a structural element — not styled, just precise. Think grid construction, not calligraphy.`,
535 mark: 'Monoweight geometric construction. Works at 16px and 1600px. No gradients, no effects.',
536 execution:
537 'Build on a strict grid. Consider negative space as intentional, not leftover. Test at 16×16 favicon size first.',
538 },
539 {
540 id: 'wordmark-mono',
541 title: 'Wordmark in Mono',
542 concept: `"${name}" set in a monospace typeface, tracked slightly loose. The choice of mono is the signal.`,
543 mark: 'Wordmark only. No icon. The name is the mark.',
544 execution:
545 'Try JetBrains Mono, Iosevka, or Commit Mono at medium weight. Adjust tracking. Optically align.',
546 },
547 {
548 id: 'abstract-structure',
549 title: 'Abstract Structure',
550 concept:
551 'A geometric mark suggesting assembly, layering, or composition — aligned with the product metaphor.',
552 mark: 'Two or three simple shapes in precise relation. No ornamentation.',
553 execution:
554 'Explore grid fragments, interlocking forms, or stacked bars. Test inversion on dark and light.',
555 }
556 );
557 } else if (catType === 'creative' || catType === 'personal') {
558 concepts.push(
559 {
560 id: 'custom-wordmark',
561 title: 'Custom Wordmark',
562 concept: `"${name}" as a custom letterform — not a font off the shelf, but drawn. The craft shows.`,
563 mark: 'Wordmark with subtle custom refinements: adjusted spacing, modified terminals, intentional details.',
564 execution:
565 'Start with a base typeface. Modify key letterforms. The goal is invisible craft, not obvious customization.',
566 },
567 {
568 id: 'monogram',
569 title: 'Monogram',
570 concept: `"${initials}" as a tight, legible monogram. Simple enough to stamp, refined enough to scale up.`,
571 mark: 'Two letterforms in structural relation. Not overlapping decoratively — compositionally.',
572 execution:
573 'Grid-align. Consider positive/negative figure-ground play. Must read clearly at 24px.',
574 }
575 );
576 } else {
577 concepts.push(
578 {
579 id: 'clean-wordmark',
580 title: 'Wordmark',
581 concept: `"${name}" set in a well-chosen typeface, thoughtfully spaced. No icon needed.`,
582 mark: 'Wordmark. The typeface selection and spacing carry the identity.',
583 execution:
584 'Choose a typeface with character but not personality. Adjust tracking. Optically center.',
585 },
586 {
587 id: 'initial-mark',
588 title: `"${initial}" Mark`,
589 concept: `A standalone "${initial}" mark that works as a favicon, app icon, and small-scale identifier.`,
590 mark: 'Single letter, geometric or structured. Consistent weight with wordmark.',
591 execution:
592 'Build on an 8-unit grid. Test at 16px, 32px, and 512px. Must work in one color.',
593 }
594 );
595 }
596
597 concepts.push({
598 id: 'symbol-plus-wordmark',
599 title: 'Symbol + Wordmark System',
600 concept:
601 'A mark system: standalone symbol for small contexts, symbol + name for full contexts. Flexible.',
602 mark: 'Two formats: symbol alone, symbol left-aligned with wordmark right.',
603 execution:
604 'Define the relationship (size ratio, spacing) precisely. Lock it. Never deviate. Test both formats in context.',
605 });
606
607 return pickN(concepts, 2);
608}
609
610// ── Usage Examples ────────────────────────────────────────────────────────────
611
612function generateUsageExamples(ctx: GenContext): UsageExample[] {
613 const { name, category, purpose, audience, catType } = ctx;
614 // Take a clean verb phrase for landing copy — stop before prepositions
615 const stopWords = new Set(['without', 'for', 'with', 'and', 'or', 'the', 'a', 'an', 'via', 'using']);
616 const purposeWords = purpose.split(/\s+/);
617 const heroWords: string[] = [];
618 for (const w of purposeWords) {
619 if (stopWords.has(w.toLowerCase()) && heroWords.length > 0) break;
620 heroWords.push(w);
621 }
622 const heroVerb = heroWords.join(' ') || purpose;
623
624 const examples: UsageExample[] = [
625 {
626 context: 'README header',
627 text: `# ${name}\n\n${cap(category)} for ${audience}. ${cap(purpose)}.`,
628 },
629 {
630 context: 'Landing page hero',
631 text: `${cap(heroVerb)} without ceremony.\n\n${name} is a ${category} built for ${audience} who need to ${purpose}.`,
632 },
633 {
634 context: 'Social / bio',
635 text: `Building ${name} — ${category} for ${audience}. ${cap(purpose)}, no overhead.`,
636 },
637 {
638 context: 'One-liner',
639 text: `${name}: ${category} built to ${purpose}.`,
640 },
641 ];
642
643 if (catType === 'developer') {
644 examples.push({
645 context: 'Package registry description',
646 text: `${name} is a ${category} for ${audience}. ${cap(purpose)}. No configuration required.`,
647 });
648 examples.push({
649 context: 'CLI help text intro',
650 text: `${name} — ${cap(purpose)}.`,
651 });
652 }
653
654 if (catType === 'creative' || catType === 'personal') {
655 examples.push({
656 context: 'Portfolio about line',
657 text: `${name} is the practice of ${audience.split(/\s+/).slice(0, 2).join(' ')} ${heroVerb}.`,
658 });
659 }
660
661 if (catType === 'product') {
662 examples.push({
663 context: 'App store description',
664 text: `${name} is a ${category} for ${audience}. It ${purpose} — without the complexity of larger platforms.`,
665 });
666 }
667
668 return examples.slice(0, 6);
669}
670
671// ── Constraints ───────────────────────────────────────────────────────────────
672
673function generateConstraints(ctx: GenContext): string[] {
674 const { tone, avoid, hasTone } = ctx;
675 const list: string[] = [];
676
677 if (tone.length > 0) {
678 list.push(`Tone: ${tone.join(', ')}.`);
679 }
680
681 if (avoid.length > 0) {
682 list.push(`Avoid: ${avoid.join(', ')}.`);
683 }
684
685 if (hasTone('minimal')) {
686 list.push('Headlines: 6 words maximum.');
687 list.push('Body copy: 2 sentences per paragraph maximum.');
688 }
689
690 if (hasTone('technical')) {
691 list.push('Assume reader has domain knowledge. Skip definitions.');
692 list.push('Prefer specific nouns over categorical ones (say the actual thing).');
693 }
694
695 if (hasTone('calm')) {
696 list.push('No urgency language ("act now", "limited time", "don\'t miss").');
697 list.push('No exclamation points.');
698 }
699
700 if (hasTone('bold')) {
701 list.push('Active voice always. No passive constructions.');
702 list.push('Every claim should be substantiable.');
703 }
704
705 list.push('No em dashes in casual contexts. Use a period or restructure.');
706 list.push('Spell out numbers under 10 in prose. Use numerals for data.');
707 list.push('One idea per sentence. Split if in doubt.');
708
709 return list;
710}
711
712// ── Typography ────────────────────────────────────────────────────────────────
713
714export const TYPE_SCALE: TypographyToken[] = [
715 { label: 'Display', size: '56px', weight: '700', lineHeight: '1.1', usage: 'Hero headlines, major landing sections' },
716 { label: 'Heading 1', size: '40px', weight: '700', lineHeight: '1.2', usage: 'Page titles, primary headers' },
717 { label: 'Heading 2', size: '28px', weight: '600', lineHeight: '1.25', usage: 'Section headers, card titles' },
718 { label: 'Heading 3', size: '20px', weight: '600', lineHeight: '1.3', usage: 'Sub-section headers, feature titles' },
719 { label: 'Body Large', size: '18px', weight: '400', lineHeight: '1.6', usage: 'Lead paragraphs, key descriptions' },
720 { label: 'Body', size: '16px', weight: '400', lineHeight: '1.65', usage: 'Default body copy' },
721 { label: 'Caption', size: '13px', weight: '400', lineHeight: '1.5', usage: 'Meta info, timestamps, helper text' },
722 { label: 'Label', size: '11px', weight: '600', lineHeight: '1.4', usage: 'UI labels, tags, overlines' },
723];
724
725type FontPair = { primary: string; secondary: string; mono: string; pairNote: string };
726
727const FONT_PAIRS: Record<string, FontPair[]> = {
728 developer: [
729 { primary: 'Geist', secondary: 'Geist', mono: 'Geist Mono', pairNote: 'Single-family system. Clean, neutral, interface-optimized.' },
730 { primary: 'Inter', secondary: 'Inter', mono: 'JetBrains Mono', pairNote: 'Inter for all UI copy; JetBrains Mono for code.' },
731 { primary: 'IBM Plex Sans', secondary: 'IBM Plex Sans', mono: 'IBM Plex Mono', pairNote: 'IBM Plex family — coherent, technical, widely legible.' },
732 ],
733 creative: [
734 { primary: 'Playfair Display', secondary: 'Lato', mono: 'Courier Prime', pairNote: 'High-contrast editorial serif for display; Lato for body.' },
735 { primary: 'Fraunces', secondary: 'DM Sans', mono: 'DM Mono', pairNote: 'Optical-size serif for headlines; DM Sans for body. Expressive and modern.' },
736 { primary: 'Cormorant Garamond', secondary: 'Nunito Sans', mono: 'Courier Prime', pairNote: 'Refined luxury serif for display; Nunito Sans for readable body.' },
737 ],
738 product: [
739 { primary: 'Plus Jakarta Sans', secondary: 'Plus Jakarta Sans', mono: 'DM Mono', pairNote: 'Jakarta Sans at varying weights; DM Mono for data and code.' },
740 { primary: 'Inter', secondary: 'Inter', mono: 'Fira Code', pairNote: 'Inter throughout — modern, neutral, excellent hinting.' },
741 { primary: 'Manrope', secondary: 'Manrope', mono: 'JetBrains Mono', pairNote: 'Geometric Manrope for all UI; JetBrains Mono for code blocks.' },
742 ],
743 services: [
744 { primary: 'Libre Baskerville', secondary: 'Source Sans 3', mono: 'Source Code Pro', pairNote: 'Baskerville for authority and trust; Source Sans for approachable body.' },
745 { primary: 'Merriweather', secondary: 'Open Sans', mono: 'Roboto Mono', pairNote: 'Merriweather for credibility; Open Sans keeps body warm.' },
746 { primary: 'Lora', secondary: 'Nunito Sans', mono: 'Courier Prime', pairNote: 'Lora brings warmth to headlines; Nunito Sans lightens the reading.' },
747 ],
748 personal: [
749 { primary: 'Lora', secondary: 'Nunito', mono: 'DM Mono', pairNote: 'Lora for expressive headlines; Nunito for friendly body copy.' },
750 { primary: 'DM Serif Display', secondary: 'DM Sans', mono: 'DM Mono', pairNote: 'Unified DM family. Serif display for character; sans for clarity.' },
751 { primary: 'Playfair Display', secondary: 'Source Sans 3', mono: 'Courier Prime', pairNote: 'Playfair adds personality; Source Sans 3 grounds body text.' },
752 ],
753 general: [
754 { primary: 'Inter', secondary: 'Inter', mono: 'JetBrains Mono', pairNote: 'Inter throughout with weight variation. Universal starting point.' },
755 { primary: 'Plus Jakarta Sans', secondary: 'Lora', mono: 'Fira Code', pairNote: 'Geometric sans for UI; Lora serif for long-form content.' },
756 { primary: 'Manrope', secondary: 'Merriweather', mono: 'Source Code Pro', pairNote: 'Friendly geometric sans paired with a trusted editorial serif.' },
757 ],
758};
759
760function generateTypography(ctx: GenContext): Typography {
761 const pairs = FONT_PAIRS[ctx.catType] ?? FONT_PAIRS.general;
762 return { ...pick(pairs), scale: TYPE_SCALE };
763}
764
765// ── Color Palette ─────────────────────────────────────────────────────────────
766
767function makeSwatches(entries: [string, string, string][]): ColorSwatch[] {
768 return entries.map(([name, hex, role], i) => ({ id: `s${i}`, name, hex, role }));
769}
770
771function generateColorPalette(ctx: GenContext): ColorPalette {
772 type SwatchEntry = [string, string, string]; // [name, hex, role]
773 type PaletteSet = SwatchEntry[][];
774
775 const palettes: Record<string, PaletteSet> = {
776 developer: [
777 [
778 ['Background', '#0d1117', 'background'],
779 ['Surface', '#161b22', 'neutral'],
780 ['Border', '#30363d', 'neutral'],
781 ['Primary', '#58a6ff', 'primary'],
782 ['Accent', '#3fb950', 'accent'],
783 ['Text', '#f0f6fc', 'text'],
784 ],
785 [
786 ['Background', '#0a0010', 'background'],
787 ['Surface', '#160025', 'neutral'],
788 ['Neutral', '#2d1f3d', 'neutral'],
789 ['Primary', '#7c3aed', 'primary'],
790 ['Accent', '#a78bfa', 'accent'],
791 ['Text', '#e2d9f3', 'text'],
792 ],
793 [
794 ['Background', '#0a0a0a', 'background'],
795 ['Surface', '#141414', 'neutral'],
796 ['Neutral', '#292929', 'neutral'],
797 ['Primary', '#e5e5e5', 'primary'],
798 ['Accent', '#c9a96e', 'accent'],
799 ['Text', '#f5f5f5', 'text'],
800 ],
801 ],
802 creative: [
803 [
804 ['Background', '#faf7f2', 'background'],
805 ['Surface', '#f5efe4', 'neutral'],
806 ['Neutral', '#e8d9c4', 'neutral'],
807 ['Primary', '#c07850', 'primary'],
808 ['Accent', '#4a7c5f', 'accent'],
809 ['Text', '#1a1410', 'text'],
810 ],
811 [
812 ['Background', '#0f0f0f', 'background'],
813 ['Surface', '#1a1a1a', 'neutral'],
814 ['Neutral', '#2e2e2e', 'neutral'],
815 ['Primary', '#ff6b35', 'primary'],
816 ['Accent', '#ffd700', 'accent'],
817 ['Text', '#f8f8f8', 'text'],
818 ],
819 [
820 ['Background', '#f8f4ef', 'background'],
821 ['Surface', '#efe9e0', 'neutral'],
822 ['Neutral', '#d4c4b0', 'neutral'],
823 ['Primary', '#8b5e3c', 'primary'],
824 ['Accent', '#6b8f71', 'accent'],
825 ['Text', '#2c2018', 'text'],
826 ],
827 ],
828 product: [
829 [
830 ['Background', '#fafbfc', 'background'],
831 ['Surface', '#f0f4f8', 'neutral'],
832 ['Neutral', '#d1dce8', 'neutral'],
833 ['Primary', '#2563eb', 'primary'],
834 ['Accent', '#7c3aed', 'accent'],
835 ['Text', '#1e293b', 'text'],
836 ],
837 [
838 ['Background', '#0f172a', 'background'],
839 ['Surface', '#1e293b', 'neutral'],
840 ['Neutral', '#334155', 'neutral'],
841 ['Primary', '#6366f1', 'primary'],
842 ['Accent', '#22d3ee', 'accent'],
843 ['Text', '#f1f5f9', 'text'],
844 ],
845 [
846 ['Background', '#f0fafa', 'background'],
847 ['Surface', '#e0f5f5', 'neutral'],
848 ['Neutral', '#b2dede', 'neutral'],
849 ['Primary', '#0d9488', 'primary'],
850 ['Accent', '#f59e0b', 'accent'],
851 ['Text', '#134e4a', 'text'],
852 ],
853 ],
854 services: [
855 [
856 ['Background', '#f8fafd', 'background'],
857 ['Surface', '#edf2fa', 'neutral'],
858 ['Neutral', '#ccd9ee', 'neutral'],
859 ['Primary', '#1d4ed8', 'primary'],
860 ['Accent', '#0f9e6e', 'accent'],
861 ['Text', '#1a2038', 'text'],
862 ],
863 [
864 ['Background', '#0c1421', 'background'],
865 ['Surface', '#152035', 'neutral'],
866 ['Neutral', '#243450', 'neutral'],
867 ['Primary', '#3b82f6', 'primary'],
868 ['Accent', '#34d399', 'accent'],
869 ['Text', '#f8fafc', 'text'],
870 ],
871 [
872 ['Background', '#faf8f5', 'background'],
873 ['Surface', '#f0ebe0', 'neutral'],
874 ['Neutral', '#d9cdb8', 'neutral'],
875 ['Primary', '#78523a', 'primary'],
876 ['Accent', '#2d6a4f', 'accent'],
877 ['Text', '#1c1410', 'text'],
878 ],
879 ],
880 personal: [
881 [
882 ['Background', '#fffef9', 'background'],
883 ['Surface', '#fdf8ee', 'neutral'],
884 ['Neutral', '#f0e6cc', 'neutral'],
885 ['Primary', '#c9a96e', 'primary'],
886 ['Accent', '#7c9e87', 'accent'],
887 ['Text', '#2d2d2d', 'text'],
888 ],
889 [
890 ['Background', '#fafafa', 'background'],
891 ['Surface', '#f5f5f5', 'neutral'],
892 ['Neutral', '#e5e5e5', 'neutral'],
893 ['Primary', '#171717', 'primary'],
894 ['Accent', '#737373', 'accent'],
895 ['Text', '#404040', 'text'],
896 ],
897 [
898 ['Background', '#f9f8ff', 'background'],
899 ['Surface', '#f0eeff', 'neutral'],
900 ['Neutral', '#ddd8f7', 'neutral'],
901 ['Primary', '#4f46e5', 'primary'],
902 ['Accent', '#ec4899', 'accent'],
903 ['Text', '#1e1b4b', 'text'],
904 ],
905 ],
906 general: [
907 [
908 ['Background', '#111111', 'background'],
909 ['Surface', '#1a1a1a', 'neutral'],
910 ['Neutral', '#2e2e2e', 'neutral'],
911 ['Primary', '#c9a96e', 'primary'],
912 ['Accent', '#6b8f71', 'accent'],
913 ['Text', '#dedede', 'text'],
914 ],
915 [
916 ['Background', '#ffffff', 'background'],
917 ['Surface', '#f5f5f5', 'neutral'],
918 ['Neutral', '#e0e0e0', 'neutral'],
919 ['Primary', '#1a1a1a', 'primary'],
920 ['Accent', '#3b82f6', 'accent'],
921 ['Text', '#333333', 'text'],
922 ],
923 [
924 ['Background', '#0f0f1a', 'background'],
925 ['Surface', '#1a1a2e', 'neutral'],
926 ['Neutral', '#252545', 'neutral'],
927 ['Primary', '#4f8ef7', 'primary'],
928 ['Accent', '#a78bfa', 'accent'],
929 ['Text', '#e2e8f0', 'text'],
930 ],
931 ],
932 };
933
934 const pool = palettes[ctx.catType] ?? palettes.general;
935 return { swatches: makeSwatches(pick(pool) as SwatchEntry[]) };
936}
937
938// ── Entry point ───────────────────────────────────────────────────────────────
939
940export function generate(inputs: BrandInputs): BrandOutputs {
941 const ctx = buildContext(inputs);
942
943 return {
944 overview: generateOverview(ctx),
945 positioning: generatePositioning(ctx),
946 tone: generateToneGuidance(ctx),
947 titles: generateTitles(ctx),
948 subtitles: generateSubtitles(ctx),
949 taglines: generateTaglines(ctx),
950 visualDirections: generateVisualDirections(ctx),
951 palette: generateColorPalette(ctx),
952 typography: generateTypography(ctx),
953 logoConcepts: generateLogoConcepts(ctx),
954 usageExamples: generateUsageExamples(ctx),
955 constraints: generateConstraints(ctx),
956 };
957}
src/hooks/usePackages.ts added +112
@@ -0,0 +1,112 @@
1import { useState, useCallback } from 'react';
2
3export interface PackageSlot {
4 id: string;
5 name: string;
6 createdAt: string;
7 storageKey: string;
8}
9
10interface PackagesStore {
11 activeId: string;
12 slots: PackageSlot[];
13}
14
15const STORE_KEY = 'bw:packages';
16const LEGACY_KEY = 'bw:workspace';
17
18function makeId(): string {
19 return `pkg-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
20}
21
22function makeSlot(name: string, id = makeId()): PackageSlot {
23 return { id, name, createdAt: new Date().toISOString(), storageKey: `bw:ws:${id}` };
24}
25
26function loadStore(): PackagesStore {
27 try {
28 const raw = localStorage.getItem(STORE_KEY);
29 if (raw) return JSON.parse(raw);
30 } catch { /* ignore */ }
31
32 // First run — migrate any legacy single-workspace data into the first slot
33 const firstSlot = makeSlot('Package 1');
34 try {
35 const legacy = localStorage.getItem(LEGACY_KEY);
36 if (legacy) localStorage.setItem(firstSlot.storageKey, legacy);
37 } catch { /* ignore */ }
38
39 const store: PackagesStore = { activeId: firstSlot.id, slots: [firstSlot] };
40 saveStore(store);
41 return store;
42}
43
44function saveStore(store: PackagesStore): void {
45 try { localStorage.setItem(STORE_KEY, JSON.stringify(store)); } catch { /* ignore */ }
46}
47
48export function usePackages() {
49 const [store, setStore] = useState<PackagesStore>(loadStore);
50
51 const activeSlot = store.slots.find(s => s.id === store.activeId) ?? store.slots[0];
52
53 // useWorkspace saves directly to each slot's storageKey on every change,
54 // so switching just needs to update activeId — no snapshot/copy required.
55 const switchTo = useCallback((id: string) => {
56 setStore(prev => {
57 if (prev.activeId === id) return prev;
58 const next = { ...prev, activeId: id };
59 saveStore(next);
60 return next;
61 });
62 }, []);
63
64 const createNew = useCallback(() => {
65 setStore(prev => {
66 const slot = makeSlot(`Package ${prev.slots.length + 1}`);
67 // New slot has no data in localStorage — useWorkspace will start fresh
68 const next = { activeId: slot.id, slots: [...prev.slots, slot] };
69 saveStore(next);
70 return next;
71 });
72 }, []);
73
74 const remove = useCallback((id: string) => {
75 setStore(prev => {
76 if (prev.slots.length <= 1) return prev;
77 const target = prev.slots.find(s => s.id === id);
78 try { if (target) localStorage.removeItem(target.storageKey); } catch { /* ignore */ }
79 const slots = prev.slots.filter(s => s.id !== id);
80 const activeId = prev.activeId === id ? slots[0].id : prev.activeId;
81 const next = { activeId, slots };
82 saveStore(next);
83 return next;
84 });
85 }, []);
86
87 const rename = useCallback((id: string, name: string) => {
88 setStore(prev => {
89 const slots = prev.slots.map(s => s.id === id ? { ...s, name } : s);
90 const next = { ...prev, slots };
91 saveStore(next);
92 return next;
93 });
94 }, []);
95
96 const duplicate = useCallback((id: string) => {
97 setStore(prev => {
98 const source = prev.slots.find(s => s.id === id);
99 if (!source) return prev;
100 const newSlot = makeSlot(`${source.name} copy`);
101 try {
102 const data = localStorage.getItem(source.storageKey);
103 if (data) localStorage.setItem(newSlot.storageKey, data);
104 } catch { /* ignore */ }
105 const next = { activeId: newSlot.id, slots: [...prev.slots, newSlot] };
106 saveStore(next);
107 return next;
108 });
109 }, []);
110
111 return { slots: store.slots, activeId: store.activeId, activeSlot, switchTo, createNew, remove, rename, duplicate };
112}
src/hooks/useSettings.ts added +41
@@ -0,0 +1,41 @@
1import { useState } from 'react';
2
3export interface OllamaSettings {
4 enabled: boolean;
5 baseUrl: string;
6 model: string;
7}
8
9const DEFAULTS: OllamaSettings = {
10 enabled: false,
11 baseUrl: 'http://localhost:11434',
12 model: 'llama3.2',
13};
14
15const KEY = 'bw:settings';
16
17function load(): OllamaSettings {
18 try {
19 const raw = localStorage.getItem(KEY);
20 return raw ? { ...DEFAULTS, ...JSON.parse(raw) } : { ...DEFAULTS };
21 } catch {
22 return { ...DEFAULTS };
23 }
24}
25
26// Non-reactive read — used inside callbacks without needing the hook
27export function readSettings(): OllamaSettings {
28 return load();
29}
30
31export function useSettings() {
32 const [settings, setSettingsRaw] = useState<OllamaSettings>(load);
33
34 const setSettings = (next: Partial<OllamaSettings>) => {
35 const merged = { ...settings, ...next };
36 try { localStorage.setItem(KEY, JSON.stringify(merged)); } catch { /* ignore */ }
37 setSettingsRaw(merged);
38 };
39
40 return { settings, setSettings };
41}
src/hooks/useWorkspace.ts added +225
@@ -0,0 +1,225 @@
1import { useState, useCallback, useRef } from 'react';
2import type { BrandInputs, BrandOutputs, LockedSections, WorkspaceState } from '../types';
3import { generate } from '../engine/generator';
4import { generateWithAI } from '../engine/aiGenerator';
5import { readSettings } from './useSettings';
6import { sanitizeOutputs } from '../lib/sanitize';
7
8const DEFAULT_INPUTS: BrandInputs = {
9 name: '',
10 category: '',
11 purpose: '',
12 audience: '',
13 tone: [],
14 avoid: [],
15 notes: '',
16};
17
18const DEFAULT_LOCKED: LockedSections = {
19 overview: false,
20 positioning: false,
21 tone: false,
22 messaging: false,
23 visual: false,
24 palette: false,
25 typography: false,
26 logo: false,
27 usage: false,
28 constraints: false,
29};
30
31function loadState(storageKey: string): Partial<WorkspaceState> {
32 try {
33 const raw = localStorage.getItem(storageKey);
34 if (raw) {
35 const state = JSON.parse(raw) as Partial<WorkspaceState>;
36 // Always run through sanitizeOutputs — coerces any corrupted/legacy field
37 // (e.g. AI-returned objects in string fields) so React never crashes on render.
38 if (state.outputs) {
39 state.outputs = sanitizeOutputs(state.outputs) ?? null;
40 if (!state.outputs) state.edits = {};
41 }
42 return state;
43 }
44 } catch { /* ignore */ }
45 return {};
46}
47
48function saveState(storageKey: string, state: WorkspaceState): void {
49 try { localStorage.setItem(storageKey, JSON.stringify(state)); } catch { /* ignore */ }
50}
51
52function applyLocks(
53 merged: BrandOutputs,
54 locked: LockedSections,
55 outputs: BrandOutputs | null,
56 edits: Partial<BrandOutputs>,
57): BrandOutputs {
58 if (!outputs) return merged;
59 if (locked.overview) merged.overview = edits.overview ?? outputs.overview;
60 if (locked.positioning) merged.positioning = edits.positioning ?? outputs.positioning;
61 if (locked.tone) merged.tone = edits.tone ?? outputs.tone;
62 if (locked.messaging) {
63 merged.titles = edits.titles ?? outputs.titles;
64 merged.subtitles = edits.subtitles ?? outputs.subtitles;
65 merged.taglines = edits.taglines ?? outputs.taglines;
66 }
67 if (locked.visual) merged.visualDirections = edits.visualDirections ?? outputs.visualDirections;
68 if (locked.palette) merged.palette = edits.palette ?? outputs.palette;
69 if (locked.typography) merged.typography = edits.typography ?? outputs.typography;
70 if (locked.logo) merged.logoConcepts = edits.logoConcepts ?? outputs.logoConcepts;
71 if (locked.usage) merged.usageExamples = edits.usageExamples ?? outputs.usageExamples;
72 if (locked.constraints) merged.constraints = edits.constraints ?? outputs.constraints;
73 return merged;
74}
75
76export function useWorkspace(storageKey = 'bw:workspace') {
77 const saved = loadState(storageKey);
78
79 const [inputs, setInputsRaw] = useState<BrandInputs>(saved.inputs ?? DEFAULT_INPUTS);
80 const [outputs, setOutputs] = useState<BrandOutputs | null>(saved.outputs ?? null);
81 const [edits, setEditsRaw] = useState<Partial<BrandOutputs>>(saved.edits ?? {});
82 const [locked, setLocked] = useState<LockedSections>(saved.locked ?? DEFAULT_LOCKED);
83 const [isGenerating, setIsGenerating] = useState(false);
84 const [generateMode, setGenerateMode] = useState<'template' | string>('template');
85 const [generateError, setGenerateError] = useState<string | null>(null);
86 const [canUndo, setCanUndo] = useState(false);
87 const [canRedo, setCanRedo] = useState(false);
88
89 const editStack = useRef<Array<Partial<BrandOutputs>>>([saved.edits ?? {}]);
90 const stackIdx = useRef(0);
91 const abortRef = useRef<AbortController | null>(null);
92
93 const stateRef = useRef({ inputs, outputs, edits, locked });
94 stateRef.current = { inputs, outputs, edits, locked };
95
96 const syncUndoRedo = () => {
97 setCanUndo(stackIdx.current > 0);
98 setCanRedo(stackIdx.current < editStack.current.length - 1);
99 };
100
101 const setEdits = useCallback((next: Partial<BrandOutputs>, pushHistory: boolean) => {
102 if (pushHistory) {
103 editStack.current = editStack.current.slice(0, stackIdx.current + 1);
104 editStack.current.push(next);
105 stackIdx.current = editStack.current.length - 1;
106 }
107 setEditsRaw(next);
108 syncUndoRedo();
109 }, []);
110
111 const persistInputs = useCallback((value: BrandInputs) => {
112 const { outputs, edits, locked } = stateRef.current;
113 saveState(storageKey, { inputs: value, outputs, edits, locked });
114 }, [storageKey]);
115
116 const setInputs = useCallback((next: BrandInputs | ((prev: BrandInputs) => BrandInputs)) => {
117 setInputsRaw(prev => {
118 const value = typeof next === 'function' ? next(prev) : next;
119 persistInputs(value);
120 return value;
121 });
122 }, [persistInputs]);
123
124 const runGenerate = useCallback(async () => {
125 // Cancel any in-flight request
126 abortRef.current?.abort();
127 const abort = new AbortController();
128 abortRef.current = abort;
129
130 const { inputs, outputs, edits, locked } = stateRef.current;
131 const settings = readSettings();
132
133 setIsGenerating(true);
134 setGenerateError(null);
135 setGenerateMode(settings.enabled ? settings.model : 'template');
136
137 try {
138 let newOutputs: BrandOutputs;
139
140 if (settings.enabled) {
141 newOutputs = await generateWithAI(inputs, settings, abort.signal);
142 } else {
143 await new Promise(r => setTimeout(r, 120));
144 if (abort.signal.aborted) return;
145 newOutputs = generate(inputs);
146 }
147
148 if (abort.signal.aborted) return;
149
150 const merged = applyLocks({ ...newOutputs }, locked, outputs, edits);
151 setOutputs(merged);
152 editStack.current = [{}];
153 stackIdx.current = 0;
154 setEditsRaw({});
155 syncUndoRedo();
156 saveState(storageKey, { inputs, outputs: merged, edits: {}, locked });
157 } catch (err) {
158 if ((err as Error).name === 'AbortError') return;
159 setGenerateError((err as Error).message);
160 } finally {
161 setIsGenerating(false);
162 }
163 }, [storageKey]);
164
165 const cancelGenerate = useCallback(() => {
166 abortRef.current?.abort();
167 setIsGenerating(false);
168 setGenerateError(null);
169 }, []);
170
171 const updateEdit = useCallback(<K extends keyof BrandOutputs>(key: K, value: BrandOutputs[K]) => {
172 const { inputs, outputs, locked } = stateRef.current;
173 const next = { ...stateRef.current.edits, [key]: value };
174 setEdits(next, true);
175 saveState(storageKey, { inputs, outputs, edits: next, locked });
176 }, [storageKey, setEdits]);
177
178 const undo = useCallback(() => {
179 if (stackIdx.current <= 0) return;
180 stackIdx.current -= 1;
181 const prev = editStack.current[stackIdx.current];
182 const { inputs, outputs, locked } = stateRef.current;
183 setEdits(prev, false);
184 saveState(storageKey, { inputs, outputs, edits: prev, locked });
185 }, [storageKey, setEdits]);
186
187 const redo = useCallback(() => {
188 if (stackIdx.current >= editStack.current.length - 1) return;
189 stackIdx.current += 1;
190 const next = editStack.current[stackIdx.current];
191 const { inputs, outputs, locked } = stateRef.current;
192 setEdits(next, false);
193 saveState(storageKey, { inputs, outputs, edits: next, locked });
194 }, [storageKey, setEdits]);
195
196 const toggleLock = useCallback((section: keyof LockedSections) => {
197 setLocked(prev => {
198 const next = { ...prev, [section]: !prev[section] };
199 const { inputs, outputs, edits } = stateRef.current;
200 saveState(storageKey, { inputs, outputs, edits, locked: next });
201 return next;
202 });
203 }, [storageKey]);
204
205 const effectiveOutputs: BrandOutputs | null = outputs ? { ...outputs, ...edits } : null;
206
207 return {
208 inputs,
209 setInputs,
210 outputs: effectiveOutputs,
211 isGenerating,
212 generateMode,
213 generateError,
214 locked,
215 runGenerate,
216 cancelGenerate,
217 updateEdit,
218 toggleLock,
219 undo,
220 redo,
221 canUndo,
222 canRedo,
223 hasOutput: outputs !== null,
224 };
225}
src/lib/clipboard.ts added +17
@@ -0,0 +1,17 @@
1export async function copyToClipboard(text: string): Promise<boolean> {
2 try {
3 await navigator.clipboard.writeText(text);
4 return true;
5 } catch {
6 // Fallback for older browsers
7 const el = document.createElement('textarea');
8 el.value = text;
9 el.style.position = 'fixed';
10 el.style.left = '-9999px';
11 document.body.appendChild(el);
12 el.select();
13 const ok = document.execCommand('copy');
14 document.body.removeChild(el);
15 return ok;
16 }
17}
src/lib/export.ts added +326
@@ -0,0 +1,326 @@
1import type { BrandInputs, BrandOutputs } from '../types';
2
3export function toMarkdown(inputs: BrandInputs, outputs: BrandOutputs): string {
4 const lines: string[] = [];
5
6 lines.push(`# Brand Package — ${inputs.name || 'Untitled'}`);
7 lines.push('');
8 lines.push(`*Generated ${new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}*`);
9 lines.push('');
10 lines.push('---');
11 lines.push('');
12
13 lines.push('## Overview');
14 lines.push('');
15 lines.push(outputs.overview);
16 lines.push('');
17
18 lines.push('## Positioning');
19 lines.push('');
20 lines.push(outputs.positioning);
21 lines.push('');
22
23 lines.push('## Tone & Voice');
24 lines.push('');
25 lines.push(`**Attributes:** ${outputs.tone.attributes.join(', ')}`);
26 lines.push('');
27 lines.push(`**Voice:** ${outputs.tone.voiceNotes}`);
28 lines.push('');
29 if (outputs.tone.avoidList.length > 0) {
30 lines.push(`**Avoid:** ${outputs.tone.avoidList.join(', ')}`);
31 lines.push('');
32 }
33 if (outputs.tone.examplePhrases.length > 0) {
34 lines.push('**Example phrases:**');
35 outputs.tone.examplePhrases.forEach(p => lines.push(`- ${p}`));
36 lines.push('');
37 }
38
39 lines.push('## Messaging');
40 lines.push('');
41 lines.push('### Titles');
42 outputs.titles.forEach((t, i) => lines.push(`${i + 1}. ${t}`));
43 lines.push('');
44 lines.push('### Subtitles');
45 outputs.subtitles.forEach((s, i) => lines.push(`${i + 1}. ${s}`));
46 lines.push('');
47 lines.push('### Taglines');
48 outputs.taglines.forEach((t, i) => lines.push(`${i + 1}. ${t}`));
49 lines.push('');
50
51 lines.push('## Color Palette');
52 lines.push('');
53 outputs.palette.swatches.forEach(s => {
54 lines.push(`- **${s.name}** — \`${s.hex}\` *(${s.role})*`);
55 });
56 lines.push('');
57
58 lines.push('## Typography');
59 lines.push('');
60 lines.push(`**Primary:** ${outputs.typography.primary}`);
61 if (outputs.typography.secondary !== outputs.typography.primary)
62 lines.push(`**Secondary:** ${outputs.typography.secondary}`);
63 lines.push(`**Monospace:** ${outputs.typography.mono}`);
64 lines.push('');
65 lines.push(`*${outputs.typography.pairNote}*`);
66 lines.push('');
67 lines.push('| Style | Size | Weight | Usage |');
68 lines.push('|-------|------|--------|-------|');
69 outputs.typography.scale.forEach(t => {
70 lines.push(`| ${t.label} | ${t.size} | ${t.weight} | ${t.usage} |`);
71 });
72 lines.push('');
73
74 lines.push('## Visual Direction');
75 lines.push('');
76 outputs.visualDirections.forEach(dir => {
77 lines.push(`### ${dir.name}`);
78 lines.push('');
79 lines.push(dir.description);
80 lines.push('');
81 lines.push(`**Palette:** ${dir.palette}`);
82 lines.push('');
83 lines.push(`**Typography:** ${dir.typography}`);
84 lines.push('');
85 lines.push(`**References:** ${dir.references}`);
86 lines.push('');
87 });
88
89 lines.push('## Logo Concepts');
90 lines.push('');
91 outputs.logoConcepts.forEach(lc => {
92 lines.push(`### ${lc.title}`);
93 lines.push('');
94 lines.push(lc.concept);
95 lines.push('');
96 lines.push(`**Mark:** ${lc.mark}`);
97 lines.push('');
98 lines.push(`**Execution:** ${lc.execution}`);
99 lines.push('');
100 });
101
102 lines.push('## Usage Examples');
103 lines.push('');
104 outputs.usageExamples.forEach(ex => {
105 lines.push(`### ${ex.context}`);
106 lines.push('');
107 lines.push('```');
108 lines.push(ex.text);
109 lines.push('```');
110 lines.push('');
111 });
112
113 lines.push('## Constraints');
114 lines.push('');
115 outputs.constraints.forEach(c => lines.push(`- ${c}`));
116 lines.push('');
117
118 lines.push('---');
119 lines.push('');
120 lines.push('*Brand Workbench*');
121
122 return lines.join('\n');
123}
124
125export function toJSON(inputs: BrandInputs, outputs: BrandOutputs): string {
126 return JSON.stringify(
127 {
128 project: {
129 name: inputs.name,
130 category: inputs.category,
131 purpose: inputs.purpose,
132 audience: inputs.audience,
133 },
134 brand: outputs,
135 meta: {
136 generated: new Date().toISOString(),
137 version: '1.0',
138 },
139 },
140 null,
141 2
142 );
143}
144
145export function toHTML(inputs: BrandInputs, outputs: BrandOutputs): string {
146 const date = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
147 const esc = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
148
149 const swatchesHtml = outputs.palette.swatches.map(s => `
150 <div class="swatch">
151 <div class="swatch-color" style="background:${esc(s.hex)}"></div>
152 <div class="swatch-name">${esc(s.name)}</div>
153 <div class="swatch-hex">${esc(s.hex)}</div>
154 </div>`).join('');
155
156 const scaleRows = outputs.typography.scale.map(t => `
157 <tr><td>${esc(t.label)}</td><td>${esc(t.size)}</td><td>${esc(t.weight)}</td><td>${esc(t.usage)}</td></tr>`).join('');
158
159 const dirsHtml = outputs.visualDirections.map(d => `
160 <div class="card">
161 <h3>${esc(d.name)}</h3>
162 <p>${esc(d.description)}</p>
163 <dl>
164 <dt>Palette</dt><dd>${esc(d.palette)}</dd>
165 <dt>Typography</dt><dd>${esc(d.typography)}</dd>
166 <dt>References</dt><dd>${esc(d.references)}</dd>
167 </dl>
168 </div>`).join('');
169
170 const logosHtml = outputs.logoConcepts.map(l => `
171 <div class="card">
172 <h3>${esc(l.title)}</h3>
173 <p>${esc(l.concept)}</p>
174 <dl>
175 <dt>Mark</dt><dd>${esc(l.mark)}</dd>
176 <dt>Execution</dt><dd>${esc(l.execution)}</dd>
177 </dl>
178 </div>`).join('');
179
180 const usageHtml = outputs.usageExamples.map(ex => `
181 <div class="usage-item">
182 <div class="usage-label">${esc(ex.context)}</div>
183 <pre>${esc(ex.text)}</pre>
184 </div>`).join('');
185
186 const constraintsHtml = outputs.constraints.map(c => `<li>${esc(c)}</li>`).join('\n');
187
188 const secondaryRow = outputs.typography.secondary !== outputs.typography.primary
189 ? `<tr><td>Secondary</td><td>${esc(outputs.typography.secondary)}</td></tr>` : '';
190
191 return `<!DOCTYPE html>
192<html lang="en">
193<head>
194<meta charset="UTF-8">
195<meta name="viewport" content="width=device-width,initial-scale=1">
196<title>${esc(inputs.name || 'Brand')} — Brand Guidelines</title>
197<style>
198 *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
199 html { font-size: 15px; }
200 body { font-family: ui-sans-serif, -apple-system, system-ui, sans-serif; color: #1a1a1a; background: #fff; line-height: 1.6; }
201 .guide { max-width: 860px; margin: 0 auto; padding: 60px 40px 100px; }
202 .guide-header { border-bottom: 2px solid #111; padding-bottom: 24px; margin-bottom: 48px; }
203 .guide-header h1 { font-size: 36px; font-weight: 700; letter-spacing: -0.02em; }
204 .guide-header .meta { font-size: 13px; color: #777; margin-top: 6px; }
205 h2 { font-size: 11px; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; color: #999; margin-bottom: 20px; padding-bottom: 8px; border-bottom: 1px solid #e5e5e5; }
206 section { margin-bottom: 56px; }
207 p { color: #333; margin-bottom: 12px; }
208 .font-table, .scale-table { width: 100%; border-collapse: collapse; font-size: 14px; margin-bottom: 16px; }
209 .font-table td, .scale-table td, .scale-table th { padding: 8px 12px; border-bottom: 1px solid #eee; text-align: left; }
210 .scale-table th { font-size: 11px; font-weight: 600; color: #999; text-transform: uppercase; letter-spacing: 0.06em; }
211 .font-table td:first-child { color: #999; width: 120px; }
212 .pair-note { font-size: 13px; color: #777; font-style: italic; margin-top: 8px; }
213 .swatches { display: flex; flex-wrap: wrap; gap: 12px; }
214 .swatch { width: 100px; }
215 .swatch-color { width: 100px; height: 64px; border-radius: 6px; border: 1px solid rgba(0,0,0,.08); margin-bottom: 6px; }
216 .swatch-name { font-size: 12px; font-weight: 500; color: #333; }
217 .swatch-hex { font-size: 11px; font-family: ui-monospace, monospace; color: #777; }
218 .card { border: 1px solid #e5e5e5; border-radius: 8px; padding: 20px; margin-bottom: 16px; }
219 .card h3 { font-size: 15px; font-weight: 600; margin-bottom: 8px; }
220 .card p { font-size: 14px; color: #555; margin-bottom: 12px; }
221 dl { display: grid; grid-template-columns: 100px 1fr; gap: 4px 16px; font-size: 13px; }
222 dt { color: #999; font-weight: 500; }
223 dd { color: #444; }
224 .usage-item { margin-bottom: 20px; }
225 .usage-label { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: #999; margin-bottom: 6px; }
226 pre { background: #f5f5f5; border-radius: 6px; padding: 14px 16px; font-size: 13px; font-family: ui-monospace, monospace; white-space: pre-wrap; color: #333; line-height: 1.6; }
227 ul { padding-left: 20px; }
228 li { font-size: 14px; color: #444; margin-bottom: 6px; }
229 .tone-pills { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }
230 .tone-pill { background: #f0f0f0; border-radius: 4px; padding: 3px 10px; font-size: 12px; color: #555; }
231 .tone-row { font-size: 14px; color: #444; margin-bottom: 8px; }
232 .tone-row strong { color: #999; display: inline-block; min-width: 100px; }
233 .taglines ol, .titles ol { padding-left: 20px; }
234 .taglines li, .titles li { font-size: 15px; color: #333; margin-bottom: 8px; }
235</style>
236</head>
237<body>
238<div class="guide">
239 <div class="guide-header">
240 <h1>${esc(inputs.name || 'Brand')} Guidelines</h1>
241 <div class="meta">Generated ${date}${inputs.category ? ` · ${esc(inputs.category)}` : ''}${inputs.audience ? ` · ${esc(inputs.audience)}` : ''}</div>
242 </div>
243
244 <section>
245 <h2>Overview</h2>
246 <p>${esc(outputs.overview)}</p>
247 </section>
248
249 <section>
250 <h2>Positioning</h2>
251 <p>${esc(outputs.positioning)}</p>
252 </section>
253
254 <section>
255 <h2>Tone &amp; Voice</h2>
256 <div class="tone-pills">${outputs.tone.attributes.map(a => `<span class="tone-pill">${esc(a)}</span>`).join('')}</div>
257 <div class="tone-row"><strong>Voice</strong>${esc(outputs.tone.voiceNotes)}</div>
258 ${outputs.tone.avoidList.length ? `<div class="tone-row"><strong>Avoid</strong>${esc(outputs.tone.avoidList.join(', '))}</div>` : ''}
259 </section>
260
261 <section>
262 <h2>Messaging</h2>
263 <div class="titles">
264 <p><strong>Titles</strong></p>
265 <ol>${outputs.titles.map(t => `<li>${esc(t)}</li>`).join('')}</ol>
266 </div>
267 <br>
268 <div class="taglines">
269 <p><strong>Taglines</strong></p>
270 <ol>${outputs.taglines.map(t => `<li>${esc(t)}</li>`).join('')}</ol>
271 </div>
272 </section>
273
274 <section>
275 <h2>Color Palette</h2>
276 <div class="swatches">${swatchesHtml}</div>
277 </section>
278
279 <section>
280 <h2>Typography</h2>
281 <table class="font-table">
282 <tr><td>Primary</td><td>${esc(outputs.typography.primary)}</td></tr>
283 ${secondaryRow}
284 <tr><td>Monospace</td><td>${esc(outputs.typography.mono)}</td></tr>
285 </table>
286 <p class="pair-note">${esc(outputs.typography.pairNote)}</p>
287 <br>
288 <table class="scale-table">
289 <thead><tr><th>Style</th><th>Size</th><th>Weight</th><th>Usage</th></tr></thead>
290 <tbody>${scaleRows}</tbody>
291 </table>
292 </section>
293
294 <section>
295 <h2>Visual Direction</h2>
296 ${dirsHtml}
297 </section>
298
299 <section>
300 <h2>Logo Concepts</h2>
301 ${logosHtml}
302 </section>
303
304 <section>
305 <h2>Usage Examples</h2>
306 ${usageHtml}
307 </section>
308
309 <section>
310 <h2>Constraints</h2>
311 <ul>${constraintsHtml}</ul>
312 </section>
313</div>
314</body>
315</html>`;
316}
317
318export function downloadFile(content: string, filename: string, mimeType: string): void {
319 const blob = new Blob([content], { type: mimeType });
320 const url = URL.createObjectURL(blob);
321 const a = document.createElement('a');
322 a.href = url;
323 a.download = filename;
324 a.click();
325 URL.revokeObjectURL(url);
326}
src/lib/sanitize.ts added +141
@@ -0,0 +1,141 @@
1/**
2 * Defensive coercion helpers for AI-generated brand outputs.
3 * AI models sometimes return structured objects instead of plain strings/arrays;
4 * these helpers normalise any value to the expected type so React never receives
5 * an object where it expects a renderable child.
6 */
7import type { BrandOutputs } from '../types';
8import { TYPE_SCALE } from '../engine/generator';
9
10// ---------------------------------------------------------------------------
11// Primitive coercions
12// ---------------------------------------------------------------------------
13
14/** Coerce any value to a non-empty string. */
15export function str(v: unknown, fallback = ''): string {
16 if (typeof v === 'string') return v;
17 if (typeof v === 'number' || typeof v === 'boolean') return String(v);
18 if (v && typeof v === 'object') {
19 const o = v as Record<string, unknown>;
20 for (const k of ['text', 'content', 'value', 'description', 'name', 'label']) {
21 if (typeof o[k] === 'string' && o[k]) return o[k] as string;
22 }
23 const vals = Object.values(o).filter(x => typeof x === 'string') as string[];
24 if (vals.length) return vals.join(' — ');
25 }
26 return fallback;
27}
28
29/** Coerce any value to an array of non-empty strings. */
30export function strArr(v: unknown, fallback: string[] = []): string[] {
31 if (Array.isArray(v)) return v.map(x => str(x)).filter(Boolean);
32 if (typeof v === 'string' && v) return [v];
33 if (v && typeof v === 'object') return [str(v)].filter(Boolean);
34 return fallback;
35}
36
37function normalizeHex(hex: string): string {
38 const h = hex.trim().toLowerCase();
39 const clean = h.startsWith('#') ? h : `#${h}`;
40 return /^#[0-9a-f]{6}$/.test(clean) ? clean : '#888888';
41}
42
43// ---------------------------------------------------------------------------
44// Full output sanitiser — safe to call on any untrusted value
45// ---------------------------------------------------------------------------
46
47/**
48 * Recursively coerce every field of a brand output object to its expected type.
49 * Returns `null` if the input is clearly not a valid brand output at all.
50 */
51export function sanitizeOutputs(raw: unknown): BrandOutputs | null {
52 if (!raw || typeof raw !== 'object') return null;
53 const r = raw as Record<string, unknown>;
54
55 // Must have at least the core keys to be worth keeping
56 if (!r.overview && !r.positioning && !r.tone) return null;
57
58 const tone = (r.tone && typeof r.tone === 'object' ? r.tone : {}) as Record<string, unknown>;
59 const typo = (r.typography && typeof r.typography === 'object' ? r.typography : {}) as Record<string, unknown>;
60 const palette = (r.palette && typeof r.palette === 'object' ? r.palette : {}) as Record<string, unknown>;
61
62 const swatches = Array.isArray(palette.swatches)
63 ? palette.swatches.map((s: unknown, i: number) => {
64 const sw = (s && typeof s === 'object' ? s : {}) as Record<string, unknown>;
65 return {
66 id: str(sw.id, `s${i}`),
67 name: str(sw.name, 'Color'),
68 hex: normalizeHex(str(sw.hex, '#888888')),
69 role: str(sw.role, 'accent'),
70 };
71 })
72 : [];
73
74 if (!swatches.length) return null;
75
76 const visualDirections = Array.isArray(r.visualDirections)
77 ? r.visualDirections.map((v: unknown, i: number) => {
78 const d = (v && typeof v === 'object' ? v : {}) as Record<string, unknown>;
79 return {
80 id: str(d.id, `v${i + 1}`),
81 name: str(d.name, `Direction ${i + 1}`),
82 description: str(d.description, ''),
83 palette: str(d.palette, ''),
84 typography: str(d.typography, ''),
85 references: str(d.references, ''),
86 };
87 })
88 : [];
89
90 const logoConcepts = Array.isArray(r.logoConcepts)
91 ? r.logoConcepts.map((v: unknown, i: number) => {
92 const c = (v && typeof v === 'object' ? v : {}) as Record<string, unknown>;
93 return {
94 id: str(c.id, `l${i + 1}`),
95 title: str(c.title, `Concept ${i + 1}`),
96 concept: str(c.concept, ''),
97 mark: str(c.mark, ''),
98 execution: str(c.execution, ''),
99 };
100 })
101 : [];
102
103 const usageExamples = Array.isArray(r.usageExamples)
104 ? r.usageExamples.map((v: unknown) => {
105 const e = (v && typeof v === 'object' ? v : {}) as Record<string, unknown>;
106 return {
107 context: str(e.context, 'Example'),
108 text: str(e.text, ''),
109 };
110 })
111 : [];
112
113 // Preserve an existing valid scale (already-saved data) or fall back to default
114 const existingScale = Array.isArray(typo.scale) ? typo.scale : TYPE_SCALE;
115
116 return {
117 overview: str(r.overview, ''),
118 positioning: str(r.positioning, ''),
119 tone: {
120 attributes: strArr(tone.attributes, []),
121 voiceNotes: str(tone.voiceNotes, ''),
122 avoidList: strArr(tone.avoidList, []),
123 examplePhrases: strArr(tone.examplePhrases, []),
124 },
125 titles: strArr(r.titles, []),
126 subtitles: strArr(r.subtitles, []),
127 taglines: strArr(r.taglines, []),
128 palette: { swatches },
129 typography: {
130 primary: str(typo.primary, 'Inter'),
131 secondary: str(typo.secondary, 'Inter'),
132 mono: str(typo.mono, 'JetBrains Mono'),
133 pairNote: str(typo.pairNote, ''),
134 scale: existingScale,
135 },
136 visualDirections,
137 logoConcepts,
138 usageExamples,
139 constraints: strArr(r.constraints, []),
140 };
141}
src/main.tsx added +10
@@ -0,0 +1,10 @@
1import { StrictMode } from 'react';
2import { createRoot } from 'react-dom/client';
3import './styles/globals.css';
4import App from './App';
5
6createRoot(document.getElementById('root')!).render(
7 <StrictMode>
8 <App />
9 </StrictMode>
10);
src/styles/globals.css added +1594
@@ -0,0 +1,1594 @@
1/* ── Reset ───────────────────────────────────────────────────────────────── */
2
3*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
4html { font-size: 14px; }
5
6/* ── Tokens ──────────────────────────────────────────────────────────────── */
7
8:root {
9 --bg: #0d0d0d;
10 --bg-1: #111111;
11 --bg-2: #161616;
12 --bg-3: #1c1c1c;
13 --bg-4: #222222;
14
15 --border: #242424;
16 --border-2: #1a1a1a;
17 --border-3: #2e2e2e;
18
19 --text: #dedede;
20 --text-2: #999999;
21 --text-3: #555555;
22 --text-4: #333333;
23
24 --accent: #c9a96e;
25 --accent-dim: #7a6341;
26
27 --success: #4a9470;
28 --danger: #8a3030;
29
30 --font-sans: ui-sans-serif, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
31 --font-mono: ui-monospace, 'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, monospace;
32
33 --r: 4px;
34 --r-lg: 6px;
35 --header-h: 44px;
36 --panel-w: 360px;
37}
38
39/* ── Base ────────────────────────────────────────────────────────────────── */
40
41body {
42 background: var(--bg);
43 color: var(--text);
44 font-family: var(--font-sans);
45 font-size: 13px;
46 line-height: 1.5;
47 -webkit-font-smoothing: antialiased;
48 overflow: hidden;
49 height: 100vh;
50}
51
52#root { height: 100vh; display: flex; flex-direction: column; }
53
54/* ── App Header ──────────────────────────────────────────────────────────── */
55
56.app-header {
57 height: var(--header-h);
58 min-height: var(--header-h);
59 display: flex;
60 align-items: center;
61 justify-content: space-between;
62 padding: 0 16px;
63 border-bottom: 1px solid var(--border);
64 background: var(--bg-1);
65 flex-shrink: 0;
66 gap: 12px;
67}
68
69.app-header-left {
70 display: flex;
71 align-items: center;
72 gap: 12px;
73 min-width: 0;
74}
75
76.app-wordmark {
77 font-size: 12px;
78 font-weight: 600;
79 letter-spacing: 0.05em;
80 color: var(--text);
81 text-transform: uppercase;
82 flex-shrink: 0;
83}
84
85.app-wordmark span {
86 color: var(--text-3);
87 font-weight: 400;
88}
89
90.app-project-name {
91 font-size: 12px;
92 color: var(--text-3);
93 overflow: hidden;
94 text-overflow: ellipsis;
95 white-space: nowrap;
96}
97
98.app-project-name::before {
99 content: '/ ';
100 color: var(--text-4);
101}
102
103.app-header-right {
104 display: flex;
105 align-items: center;
106 gap: 6px;
107 flex-shrink: 0;
108}
109
110/* ── Package Switcher ────────────────────────────────────────────────────── */
111
112.pkg-switcher {
113 display: flex;
114 align-items: center;
115 gap: 2px;
116 overflow-x: auto;
117 scrollbar-width: none;
118 max-width: 480px;
119}
120
121.pkg-switcher::-webkit-scrollbar { display: none; }
122
123.pkg-tab {
124 display: flex;
125 align-items: center;
126 gap: 4px;
127 padding: 0 8px;
128 height: 26px;
129 border-radius: var(--r);
130 border: 1px solid transparent;
131 font-size: 12px;
132 color: var(--text-3);
133 cursor: pointer;
134 white-space: nowrap;
135 user-select: none;
136 transition: all 0.1s;
137 flex-shrink: 0;
138}
139
140.pkg-tab:hover {
141 background: var(--bg-3);
142 color: var(--text-2);
143}
144
145.pkg-tab.active {
146 background: var(--bg-3);
147 border-color: var(--border);
148 color: var(--text);
149}
150
151.pkg-tab-name {
152 max-width: 120px;
153 overflow: hidden;
154 text-overflow: ellipsis;
155}
156
157.pkg-tab-input {
158 width: 100px;
159 background: transparent;
160 border: none;
161 outline: none;
162 font-size: 12px;
163 font-family: inherit;
164 color: var(--text);
165 padding: 0;
166}
167
168.pkg-tab-actions {
169 display: flex;
170 align-items: center;
171 gap: 2px;
172 margin-left: 2px;
173 opacity: 0;
174 transition: opacity 0.1s;
175}
176
177.pkg-tab:hover .pkg-tab-actions,
178.pkg-tab.active .pkg-tab-actions {
179 opacity: 1;
180}
181
182.pkg-tab-close, .pkg-tab-menu-btn {
183 background: none;
184 border: none;
185 color: var(--text-4);
186 cursor: pointer;
187 font-size: 12px;
188 padding: 0 2px;
189 line-height: 1;
190 display: flex;
191 align-items: center;
192 border-radius: 2px;
193 height: 16px;
194}
195
196.pkg-tab-close:hover, .pkg-tab-menu-btn:hover {
197 background: var(--bg-4);
198 color: var(--text-2);
199}
200
201.pkg-tab-menu-btn {
202 letter-spacing: -1px;
203 font-size: 10px;
204}
205
206.pkg-tab-dropdown {
207 background: var(--bg-3);
208 border: 1px solid var(--border-3);
209 border-radius: var(--r-lg);
210 padding: 4px;
211 z-index: 100;
212 min-width: 120px;
213 box-shadow: 0 4px 16px rgba(0,0,0,0.4);
214}
215
216.pkg-tab-dropdown button {
217 display: block;
218 width: 100%;
219 text-align: left;
220 background: none;
221 border: none;
222 color: var(--text-2);
223 font-size: 12px;
224 font-family: inherit;
225 padding: 5px 8px;
226 border-radius: var(--r);
227 cursor: pointer;
228}
229
230.pkg-tab-dropdown button:hover {
231 background: var(--bg-4);
232 color: var(--text);
233}
234
235.pkg-tab-dropdown button.danger { color: #c04040; }
236.pkg-tab-dropdown button.danger:hover { background: rgba(192,64,64,0.15); }
237
238.pkg-new-btn {
239 width: 26px;
240 height: 26px;
241 border-radius: var(--r);
242 border: 1px dashed var(--border-3);
243 background: none;
244 color: var(--text-4);
245 font-size: 16px;
246 cursor: pointer;
247 display: flex;
248 align-items: center;
249 justify-content: center;
250 flex-shrink: 0;
251 transition: all 0.1s;
252}
253
254.pkg-new-btn:hover {
255 border-color: var(--text-3);
256 color: var(--text-2);
257 background: var(--bg-3);
258}
259
260/* ── App Shell ───────────────────────────────────────────────────────────── */
261
262.app-shell {
263 display: flex;
264 flex: 1;
265 min-height: 0;
266 overflow: hidden;
267}
268
269/* ── Input Panel ─────────────────────────────────────────────────────────── */
270
271.input-panel {
272 width: var(--panel-w);
273 min-width: var(--panel-w);
274 border-right: 1px solid var(--border);
275 display: flex;
276 flex-direction: column;
277 overflow-y: auto;
278 background: var(--bg-1);
279}
280
281.input-panel::-webkit-scrollbar { width: 4px; }
282.input-panel::-webkit-scrollbar-track { background: transparent; }
283.input-panel::-webkit-scrollbar-thumb { background: var(--border-3); border-radius: 2px; }
284
285.input-section {
286 padding: 16px;
287 border-bottom: 1px solid var(--border-2);
288}
289
290.input-section:last-of-type { border-bottom: none; }
291
292.input-section-label {
293 font-size: 10px;
294 font-weight: 600;
295 letter-spacing: 0.1em;
296 text-transform: uppercase;
297 color: var(--text-3);
298 margin-bottom: 12px;
299}
300
301/* ── Form Fields ─────────────────────────────────────────────────────────── */
302
303.field {
304 display: flex;
305 flex-direction: column;
306 gap: 5px;
307 margin-bottom: 10px;
308}
309
310.field:last-child { margin-bottom: 0; }
311
312.field-label {
313 font-size: 11px;
314 color: var(--text-2);
315 font-weight: 500;
316}
317
318.field-input,
319.field-textarea {
320 background: var(--bg);
321 border: 1px solid var(--border);
322 border-radius: var(--r);
323 color: var(--text);
324 font-family: var(--font-sans);
325 font-size: 13px;
326 padding: 7px 10px;
327 width: 100%;
328 transition: border-color 0.1s;
329 outline: none;
330}
331
332.field-input::placeholder,
333.field-textarea::placeholder {
334 color: var(--text-4);
335}
336
337.field-input:focus,
338.field-textarea:focus {
339 border-color: var(--border-3);
340}
341
342.field-textarea {
343 resize: vertical;
344 min-height: 72px;
345 line-height: 1.5;
346}
347
348/* ── Token Input ─────────────────────────────────────────────────────────── */
349
350.token-field {
351 display: flex;
352 flex-direction: column;
353 gap: 5px;
354}
355
356.token-container {
357 display: flex;
358 flex-wrap: wrap;
359 gap: 5px;
360 background: var(--bg);
361 border: 1px solid var(--border);
362 border-radius: var(--r);
363 padding: 6px;
364 min-height: 36px;
365 cursor: text;
366 transition: border-color 0.1s;
367}
368
369.token-container:focus-within {
370 border-color: var(--border-3);
371}
372
373.token {
374 display: flex;
375 align-items: center;
376 gap: 4px;
377 background: var(--bg-3);
378 border: 1px solid var(--border);
379 border-radius: 3px;
380 padding: 2px 7px 2px 8px;
381 font-size: 12px;
382 color: var(--text-2);
383 line-height: 1;
384 height: 22px;
385}
386
387.token-remove {
388 background: none;
389 border: none;
390 color: var(--text-3);
391 cursor: pointer;
392 font-size: 14px;
393 line-height: 1;
394 padding: 0;
395 display: flex;
396 align-items: center;
397 margin-left: 1px;
398 opacity: 0.7;
399}
400
401.token-remove:hover { color: var(--text); opacity: 1; }
402
403.token-input {
404 background: none;
405 border: none;
406 color: var(--text);
407 font-family: var(--font-sans);
408 font-size: 12px;
409 outline: none;
410 min-width: 80px;
411 flex: 1;
412 padding: 2px 2px;
413}
414
415.token-input::placeholder { color: var(--text-4); }
416
417.token-suggestions {
418 display: flex;
419 flex-wrap: wrap;
420 gap: 4px;
421 margin-top: 6px;
422}
423
424.token-suggestion {
425 background: none;
426 border: 1px solid var(--border);
427 border-radius: 3px;
428 color: var(--text-3);
429 cursor: pointer;
430 font-family: var(--font-sans);
431 font-size: 11px;
432 padding: 2px 7px;
433 transition: all 0.1s;
434}
435
436.token-suggestion:hover {
437 border-color: var(--border-3);
438 color: var(--text-2);
439}
440
441.token-suggestion.active {
442 background: var(--bg-3);
443 border-color: var(--border-3);
444 color: var(--text);
445}
446
447/* ── Generate Button ─────────────────────────────────────────────────────── */
448
449.generate-area {
450 padding: 12px 16px 16px;
451}
452
453.btn-generate {
454 width: 100%;
455 background: var(--text);
456 border: none;
457 border-radius: var(--r);
458 color: var(--bg);
459 cursor: pointer;
460 font-family: var(--font-sans);
461 font-size: 13px;
462 font-weight: 600;
463 height: 36px;
464 letter-spacing: 0.01em;
465 transition: opacity 0.1s, background 0.1s;
466}
467
468.btn-generate:hover:not(:disabled) {
469 opacity: 0.9;
470}
471
472.btn-generate:disabled {
473 opacity: 0.4;
474 cursor: not-allowed;
475}
476
477.btn-generate.generating {
478 background: var(--bg-3);
479 color: var(--text-2);
480}
481
482/* ── Shared Buttons ──────────────────────────────────────────────────────── */
483
484.btn {
485 align-items: center;
486 background: var(--bg-3);
487 border: 1px solid var(--border);
488 border-radius: var(--r);
489 color: var(--text-2);
490 cursor: pointer;
491 display: inline-flex;
492 font-family: var(--font-sans);
493 font-size: 12px;
494 gap: 5px;
495 height: 28px;
496 padding: 0 10px;
497 transition: all 0.1s;
498 white-space: nowrap;
499}
500
501.btn:hover {
502 border-color: var(--border-3);
503 color: var(--text);
504}
505
506.btn-ghost {
507 background: none;
508 border-color: transparent;
509 color: var(--text-3);
510}
511
512.btn-ghost:hover {
513 background: var(--bg-3);
514 border-color: var(--border);
515 color: var(--text-2);
516}
517
518.btn-icon {
519 padding: 0;
520 width: 28px;
521 justify-content: center;
522}
523
524/* ── Preview Panel ───────────────────────────────────────────────────────── */
525
526.preview-panel {
527 flex: 1;
528 min-width: 0;
529 display: flex;
530 flex-direction: column;
531 overflow: hidden;
532 background: var(--bg);
533}
534
535.preview-toolbar {
536 height: 40px;
537 min-height: 40px;
538 display: flex;
539 align-items: center;
540 justify-content: space-between;
541 padding: 0 20px;
542 border-bottom: 1px solid var(--border-2);
543 background: var(--bg);
544 flex-shrink: 0;
545 gap: 12px;
546}
547
548.preview-toolbar-left {
549 display: flex;
550 align-items: center;
551 gap: 10px;
552}
553
554.preview-toolbar-right {
555 display: flex;
556 align-items: center;
557 gap: 6px;
558}
559
560.preview-doc-label {
561 font-size: 11px;
562 color: var(--text-3);
563 font-weight: 500;
564}
565
566.preview-scroll {
567 flex: 1;
568 overflow-y: auto;
569 padding: 40px 48px 80px;
570}
571
572.preview-scroll::-webkit-scrollbar { width: 6px; }
573.preview-scroll::-webkit-scrollbar-track { background: transparent; }
574.preview-scroll::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
575
576/* ── Empty State ─────────────────────────────────────────────────────────── */
577
578.empty-state {
579 display: flex;
580 flex-direction: column;
581 align-items: center;
582 justify-content: center;
583 height: 100%;
584 gap: 8px;
585 color: var(--text-4);
586}
587
588.empty-state-title {
589 font-size: 14px;
590 color: var(--text-3);
591 font-weight: 500;
592}
593
594.empty-state-sub {
595 font-size: 12px;
596 color: var(--text-4);
597}
598
599/* ── Brand Document ──────────────────────────────────────────────────────── */
600
601.brand-doc {
602 max-width: 720px;
603}
604
605.brand-doc-header {
606 margin-bottom: 40px;
607 padding-bottom: 24px;
608 border-bottom: 1px solid var(--border-2);
609}
610
611.brand-doc-title {
612 font-size: 28px;
613 font-weight: 600;
614 letter-spacing: -0.02em;
615 color: var(--text);
616 line-height: 1.2;
617 margin-bottom: 6px;
618}
619
620.brand-doc-meta {
621 font-size: 11px;
622 color: var(--text-3);
623 display: flex;
624 gap: 12px;
625}
626
627.brand-doc-meta-item {
628 display: flex;
629 gap: 5px;
630}
631
632.brand-doc-meta-label {
633 color: var(--text-4);
634}
635
636/* ── Doc Section ─────────────────────────────────────────────────────────── */
637
638.doc-section {
639 margin-bottom: 36px;
640 border-radius: var(--r);
641 padding: 2px;
642 margin: -2px -2px 36px;
643}
644
645.doc-section.is-locked {
646 background: rgba(201, 169, 110, 0.025);
647}
648
649.doc-section-header {
650 display: flex;
651 align-items: center;
652 justify-content: space-between;
653 margin-bottom: 14px;
654 gap: 8px;
655}
656
657.doc-section-title {
658 font-size: 10px;
659 font-weight: 600;
660 letter-spacing: 0.12em;
661 text-transform: uppercase;
662 color: var(--text-3);
663 display: flex;
664 align-items: center;
665 gap: 7px;
666}
667
668.section-lock {
669 background: none;
670 border: 1px solid transparent;
671 border-radius: 3px;
672 color: var(--text-4);
673 cursor: pointer;
674 font-family: var(--font-sans);
675 font-size: 10px;
676 letter-spacing: 0.04em;
677 padding: 2px 6px;
678 height: 20px;
679 display: inline-flex;
680 align-items: center;
681 transition: all 0.1s;
682 line-height: 1;
683 white-space: nowrap;
684}
685
686.section-lock:hover {
687 border-color: var(--border);
688 color: var(--text-3);
689 background: var(--bg-3);
690}
691
692.section-lock.locked {
693 color: var(--accent);
694 border-color: var(--accent-dim);
695 background: rgba(201, 169, 110, 0.06);
696}
697
698.doc-section-actions {
699 display: flex;
700 align-items: center;
701 gap: 4px;
702 opacity: 0;
703 transition: opacity 0.15s;
704}
705
706.doc-section:hover .doc-section-actions { opacity: 1; }
707.doc-section .doc-section-actions:has(.section-lock.locked) { opacity: 1; }
708
709.doc-section-body {
710 border-left: 1px solid var(--border);
711 padding-left: 16px;
712}
713
714.doc-section.is-locked .doc-section-body {
715 border-left-color: var(--accent-dim);
716 border-left-width: 2px;
717}
718
719/* ── Editable Text ───────────────────────────────────────────────────────── */
720
721.editable-text {
722 cursor: text;
723 border-radius: 3px;
724 transition: background 0.1s, outline 0.1s;
725 line-height: 1.65;
726 font-size: 14px;
727 color: var(--text);
728 min-height: 1em;
729 outline: 1px solid transparent;
730}
731
732.editable-text:hover {
733 background: var(--bg-2);
734 outline-color: var(--border-2);
735}
736
737.editable-text.editing {
738 background: var(--bg-2);
739 outline: 1px solid var(--border-3);
740 border-radius: 3px;
741}
742
743/* ── Numbered List ───────────────────────────────────────────────────────── */
744
745.numbered-list {
746 display: flex;
747 flex-direction: column;
748 gap: 8px;
749}
750
751.numbered-item {
752 display: flex;
753 gap: 10px;
754 align-items: flex-start;
755}
756
757.numbered-item-num {
758 font-size: 11px;
759 color: var(--text-4);
760 font-family: var(--font-mono);
761 min-width: 16px;
762 margin-top: 2px;
763 flex-shrink: 0;
764}
765
766.numbered-item-text {
767 flex: 1;
768 font-size: 14px;
769 color: var(--text);
770 line-height: 1.5;
771 cursor: text;
772 border-radius: 3px;
773 padding: 1px 4px;
774 margin: -1px -4px;
775 transition: background 0.1s;
776}
777
778.numbered-item-text:hover { background: var(--bg-2); }
779.numbered-item-text:focus { background: var(--bg-2); outline: 1px solid var(--border-3); }
780
781/* ── Bullet List ─────────────────────────────────────────────────────────── */
782
783.bullet-list {
784 display: flex;
785 flex-direction: column;
786 gap: 6px;
787}
788
789.bullet-item {
790 display: flex;
791 gap: 8px;
792 align-items: flex-start;
793 font-size: 13px;
794 color: var(--text-2);
795 line-height: 1.5;
796}
797
798.bullet-item::before {
799 content: '–';
800 color: var(--text-4);
801 flex-shrink: 0;
802 margin-top: 0;
803}
804
805/* ── Tone Section ────────────────────────────────────────────────────────── */
806
807.tone-grid {
808 display: flex;
809 flex-direction: column;
810 gap: 12px;
811}
812
813.tone-row {
814 display: flex;
815 flex-direction: column;
816 gap: 4px;
817}
818
819.tone-row-label {
820 font-size: 11px;
821 color: var(--text-3);
822 font-weight: 500;
823}
824
825.tone-row-value {
826 font-size: 13px;
827 color: var(--text-2);
828 line-height: 1.55;
829}
830
831.tone-tags {
832 display: flex;
833 flex-wrap: wrap;
834 gap: 5px;
835}
836
837.tone-tag {
838 background: var(--bg-3);
839 border: 1px solid var(--border);
840 border-radius: 3px;
841 color: var(--text-2);
842 font-size: 11px;
843 padding: 2px 8px;
844}
845
846.tone-phrases {
847 display: flex;
848 flex-direction: column;
849 gap: 5px;
850}
851
852.tone-phrase {
853 font-family: var(--font-mono);
854 font-size: 12px;
855 color: var(--text-2);
856 padding: 5px 8px;
857 background: var(--bg-2);
858 border-radius: 3px;
859 border-left: 2px solid var(--border-3);
860}
861
862/* ── Visual Directions ───────────────────────────────────────────────────── */
863
864.directions-grid {
865 display: flex;
866 flex-direction: column;
867 gap: 20px;
868}
869
870.direction-card {
871 border: 1px solid var(--border);
872 border-radius: var(--r-lg);
873 padding: 16px;
874 background: var(--bg-1);
875}
876
877.direction-name {
878 font-size: 13px;
879 font-weight: 600;
880 color: var(--text);
881 margin-bottom: 8px;
882}
883
884.direction-desc {
885 font-size: 13px;
886 color: var(--text-2);
887 line-height: 1.6;
888 margin-bottom: 12px;
889}
890
891.direction-attrs {
892 display: flex;
893 flex-direction: column;
894 gap: 6px;
895}
896
897.direction-attr {
898 display: flex;
899 gap: 8px;
900 font-size: 12px;
901 line-height: 1.5;
902}
903
904.direction-attr-label {
905 color: var(--text-4);
906 font-weight: 500;
907 min-width: 88px;
908 flex-shrink: 0;
909}
910
911.direction-attr-value {
912 color: var(--text-2);
913}
914
915/* ── Logo Concepts ───────────────────────────────────────────────────────── */
916
917.logo-grid {
918 display: flex;
919 flex-direction: column;
920 gap: 20px;
921}
922
923.logo-card {
924 border: 1px solid var(--border);
925 border-radius: var(--r-lg);
926 padding: 16px;
927 background: var(--bg-1);
928}
929
930.logo-card-title {
931 font-size: 13px;
932 font-weight: 600;
933 color: var(--text);
934 margin-bottom: 8px;
935}
936
937.logo-card-concept {
938 font-size: 13px;
939 color: var(--text-2);
940 line-height: 1.6;
941 margin-bottom: 12px;
942}
943
944.logo-card-attrs {
945 display: flex;
946 flex-direction: column;
947 gap: 6px;
948}
949
950.logo-card-attr {
951 display: flex;
952 gap: 8px;
953 font-size: 12px;
954 line-height: 1.5;
955}
956
957.logo-card-attr-label {
958 color: var(--text-4);
959 font-weight: 500;
960 min-width: 72px;
961 flex-shrink: 0;
962}
963
964.logo-card-attr-value {
965 color: var(--text-2);
966}
967
968/* ── Usage Examples ──────────────────────────────────────────────────────── */
969
970.usage-grid {
971 display: flex;
972 flex-direction: column;
973 gap: 16px;
974}
975
976.usage-item {
977 display: flex;
978 flex-direction: column;
979 gap: 6px;
980}
981
982.usage-context {
983 font-size: 10px;
984 font-weight: 600;
985 letter-spacing: 0.08em;
986 text-transform: uppercase;
987 color: var(--text-4);
988}
989
990.usage-text {
991 font-family: var(--font-mono);
992 font-size: 12px;
993 color: var(--text-2);
994 background: var(--bg-2);
995 border: 1px solid var(--border-2);
996 border-radius: var(--r);
997 padding: 10px 12px;
998 white-space: pre-wrap;
999 line-height: 1.6;
1000 position: relative;
1001}
1002
1003.usage-copy {
1004 position: absolute;
1005 top: 6px;
1006 right: 6px;
1007 opacity: 0;
1008 transition: opacity 0.1s;
1009}
1010
1011.usage-item:hover .usage-copy { opacity: 1; }
1012
1013/* ── Typography Section ──────────────────────────────────────────────────── */
1014
1015.type-section {
1016 display: flex;
1017 flex-direction: column;
1018 gap: 20px;
1019}
1020
1021.type-fonts {
1022 display: flex;
1023 flex-direction: column;
1024 gap: 6px;
1025}
1026
1027.type-font-row {
1028 display: flex;
1029 align-items: baseline;
1030 gap: 10px;
1031 font-size: 13px;
1032}
1033
1034.type-font-role {
1035 font-size: 11px;
1036 color: var(--text-4);
1037 font-weight: 500;
1038 min-width: 80px;
1039 flex-shrink: 0;
1040}
1041
1042.type-font-name {
1043 color: var(--text);
1044 font-weight: 500;
1045}
1046
1047.type-pair-note {
1048 font-size: 12px;
1049 color: var(--text-3);
1050 margin-top: 4px;
1051}
1052
1053.type-scale {
1054 display: flex;
1055 flex-direction: column;
1056}
1057
1058.type-scale-header {
1059 display: grid;
1060 grid-template-columns: 90px 52px 56px 1fr;
1061 gap: 0 12px;
1062 font-size: 10px;
1063 font-weight: 600;
1064 color: var(--text-4);
1065 letter-spacing: 0.06em;
1066 text-transform: uppercase;
1067 padding: 0 0 6px;
1068 border-bottom: 1px solid var(--border-2);
1069}
1070
1071.type-scale-row {
1072 display: grid;
1073 grid-template-columns: 90px 52px 56px 1fr;
1074 gap: 0 12px;
1075 font-size: 12px;
1076 padding: 7px 0;
1077 border-bottom: 1px solid var(--border-2);
1078 align-items: center;
1079}
1080
1081.type-scale-row:last-child { border-bottom: none; }
1082
1083.type-scale-label { color: var(--text-2); font-weight: 500; }
1084.type-scale-size { color: var(--text-3); font-family: var(--font-mono); }
1085.type-scale-weight{ color: var(--text-3); font-family: var(--font-mono); }
1086.type-scale-usage { color: var(--text-4); }
1087
1088/* ── Color Palette ───────────────────────────────────────────────────────── */
1089
1090.color-palette {
1091 display: flex;
1092 flex-wrap: wrap;
1093 gap: 10px;
1094 align-items: flex-start;
1095}
1096
1097.color-swatch-card {
1098 display: flex;
1099 flex-direction: column;
1100 width: 88px;
1101 gap: 6px;
1102}
1103
1104.color-swatch-preview {
1105 position: relative;
1106 width: 88px;
1107 height: 60px;
1108 border-radius: var(--r);
1109 border: 1px solid rgba(0,0,0,0.15);
1110 overflow: hidden;
1111 cursor: pointer;
1112}
1113
1114.color-swatch-picker {
1115 position: absolute;
1116 inset: 0;
1117 width: 100%;
1118 height: 100%;
1119 opacity: 0;
1120 cursor: pointer;
1121 border: none;
1122 padding: 0;
1123}
1124
1125.color-swatch-remove {
1126 position: absolute;
1127 top: 4px;
1128 right: 4px;
1129 width: 16px;
1130 height: 16px;
1131 border-radius: 50%;
1132 background: rgba(0,0,0,0.5);
1133 border: none;
1134 color: #fff;
1135 font-size: 12px;
1136 line-height: 1;
1137 cursor: pointer;
1138 display: flex;
1139 align-items: center;
1140 justify-content: center;
1141 opacity: 0;
1142 transition: opacity 0.1s;
1143 padding: 0;
1144 z-index: 1;
1145}
1146
1147.color-swatch-card:hover .color-swatch-remove { opacity: 1; }
1148
1149.color-swatch-info {
1150 display: flex;
1151 flex-direction: column;
1152 gap: 2px;
1153}
1154
1155.color-swatch-name {
1156 font-size: 11px;
1157 font-weight: 500;
1158 color: var(--text-2);
1159 cursor: text;
1160 white-space: nowrap;
1161 overflow: hidden;
1162 text-overflow: ellipsis;
1163}
1164
1165.color-swatch-name:hover { color: var(--text); }
1166
1167.color-swatch-hex {
1168 font-family: var(--font-mono);
1169 font-size: 10px;
1170 color: var(--text-3);
1171 cursor: text;
1172}
1173
1174.color-swatch-hex:hover { color: var(--text-2); }
1175
1176.color-swatch-field-input {
1177 width: 100%;
1178 background: var(--bg-2);
1179 border: 1px solid var(--border-3);
1180 border-radius: 3px;
1181 color: var(--text);
1182 font-family: inherit;
1183 font-size: 11px;
1184 padding: 1px 4px;
1185 outline: none;
1186}
1187
1188.color-swatch-hex-input {
1189 font-family: var(--font-mono);
1190 font-size: 10px;
1191}
1192
1193.color-swatch-add {
1194 width: 88px;
1195 height: 60px;
1196 border-radius: var(--r);
1197 border: 1px dashed var(--border-3);
1198 background: none;
1199 color: var(--text-4);
1200 font-size: 20px;
1201 cursor: pointer;
1202 display: flex;
1203 align-items: center;
1204 justify-content: center;
1205 transition: all 0.1s;
1206 align-self: flex-start;
1207}
1208
1209.color-swatch-add:hover {
1210 border-color: var(--text-3);
1211 color: var(--text-2);
1212 background: var(--bg-2);
1213}
1214
1215/* ── Constraints ─────────────────────────────────────────────────────────── */
1216
1217.constraints-list {
1218 display: flex;
1219 flex-direction: column;
1220 gap: 5px;
1221}
1222
1223.constraint-item {
1224 font-size: 13px;
1225 color: var(--text-2);
1226 line-height: 1.5;
1227 display: flex;
1228 gap: 8px;
1229}
1230
1231.constraint-item::before {
1232 content: '·';
1233 color: var(--text-4);
1234 flex-shrink: 0;
1235}
1236
1237/* ── Copy feedback ───────────────────────────────────────────────────────── */
1238
1239.copy-btn {
1240 background: var(--bg-3);
1241 border: 1px solid var(--border);
1242 border-radius: 3px;
1243 color: var(--text-3);
1244 cursor: pointer;
1245 font-family: var(--font-sans);
1246 font-size: 10px;
1247 padding: 2px 7px;
1248 height: 20px;
1249 display: inline-flex;
1250 align-items: center;
1251 gap: 4px;
1252 transition: all 0.1s;
1253 white-space: nowrap;
1254}
1255
1256.copy-btn:hover {
1257 border-color: var(--border-3);
1258 color: var(--text-2);
1259}
1260
1261.copy-btn.copied {
1262 color: var(--success);
1263 border-color: var(--success);
1264}
1265
1266/* ── Export Bar ──────────────────────────────────────────────────────────── */
1267
1268.export-bar {
1269 height: 44px;
1270 min-height: 44px;
1271 display: flex;
1272 align-items: center;
1273 justify-content: space-between;
1274 padding: 0 20px;
1275 border-top: 1px solid var(--border);
1276 background: var(--bg-1);
1277 flex-shrink: 0;
1278}
1279
1280.export-bar-left {
1281 font-size: 11px;
1282 color: var(--text-4);
1283}
1284
1285.export-bar-right {
1286 display: flex;
1287 gap: 6px;
1288}
1289
1290/* ── AI badge ────────────────────────────────────────────────────────────── */
1291
1292.settings-open-btn { font-size: 16px; }
1293
1294.ai-badge {
1295 font-size: 9px;
1296 font-weight: 700;
1297 letter-spacing: 0.08em;
1298 color: var(--accent);
1299 border: 1px solid var(--accent-dim);
1300 border-radius: 3px;
1301 padding: 1px 5px;
1302 cursor: default;
1303 flex-shrink: 0;
1304}
1305
1306/* ── Generate error banner ───────────────────────────────────────────────── */
1307
1308.generate-error {
1309 display: flex;
1310 align-items: center;
1311 gap: 8px;
1312 padding: 8px 20px;
1313 background: rgba(138, 48, 48, 0.15);
1314 border-bottom: 1px solid rgba(138, 48, 48, 0.3);
1315 font-size: 12px;
1316 flex-shrink: 0;
1317}
1318
1319.generate-error-icon { color: #c04040; flex-shrink: 0; }
1320.generate-error-msg { color: var(--text-2); flex: 1; line-height: 1.4; }
1321.generate-error-retry { margin-left: auto; font-size: 11px; flex-shrink: 0; }
1322
1323/* ── Settings overlay + panel ────────────────────────────────────────────── */
1324
1325.settings-overlay {
1326 position: fixed;
1327 inset: 0;
1328 background: rgba(0, 0, 0, 0.6);
1329 z-index: 200;
1330 display: flex;
1331 align-items: flex-start;
1332 justify-content: flex-end;
1333 padding: var(--header-h) 0 0;
1334}
1335
1336.settings-panel {
1337 width: 400px;
1338 height: calc(100vh - var(--header-h));
1339 background: var(--bg-2);
1340 border-left: 1px solid var(--border-3);
1341 display: flex;
1342 flex-direction: column;
1343 overflow: hidden;
1344}
1345
1346.settings-header {
1347 display: flex;
1348 align-items: center;
1349 justify-content: space-between;
1350 padding: 0 20px;
1351 height: 44px;
1352 border-bottom: 1px solid var(--border);
1353 flex-shrink: 0;
1354}
1355
1356.settings-title {
1357 font-size: 12px;
1358 font-weight: 600;
1359 color: var(--text);
1360 letter-spacing: 0.04em;
1361 text-transform: uppercase;
1362}
1363
1364.settings-close {
1365 background: none;
1366 border: none;
1367 color: var(--text-3);
1368 font-size: 18px;
1369 cursor: pointer;
1370 padding: 0;
1371 line-height: 1;
1372 width: 24px;
1373 height: 24px;
1374 display: flex;
1375 align-items: center;
1376 justify-content: center;
1377 border-radius: var(--r);
1378}
1379
1380.settings-close:hover { background: var(--bg-3); color: var(--text); }
1381
1382.settings-body {
1383 flex: 1;
1384 overflow-y: auto;
1385 padding: 20px;
1386 display: flex;
1387 flex-direction: column;
1388 gap: 28px;
1389}
1390
1391.settings-section {
1392 display: flex;
1393 flex-direction: column;
1394 gap: 12px;
1395}
1396
1397.settings-section-label {
1398 font-size: 10px;
1399 font-weight: 700;
1400 letter-spacing: 0.1em;
1401 text-transform: uppercase;
1402 color: var(--text-4);
1403}
1404
1405.settings-toggle-row {
1406 display: flex;
1407 align-items: center;
1408 justify-content: space-between;
1409 gap: 12px;
1410 cursor: pointer;
1411}
1412
1413.settings-toggle-info {
1414 display: flex;
1415 flex-direction: column;
1416 gap: 2px;
1417}
1418
1419.settings-toggle-name {
1420 font-size: 13px;
1421 color: var(--text);
1422 font-weight: 500;
1423}
1424
1425.settings-toggle-desc {
1426 font-size: 11px;
1427 color: var(--text-3);
1428 line-height: 1.4;
1429}
1430
1431/* Toggle switch */
1432.toggle {
1433 width: 36px;
1434 height: 20px;
1435 background: var(--bg-4);
1436 border: 1px solid var(--border-3);
1437 border-radius: 10px;
1438 cursor: pointer;
1439 position: relative;
1440 flex-shrink: 0;
1441 transition: background 0.15s, border-color 0.15s;
1442}
1443
1444.toggle.on {
1445 background: var(--accent-dim);
1446 border-color: var(--accent);
1447}
1448
1449.toggle-thumb {
1450 position: absolute;
1451 top: 2px;
1452 left: 2px;
1453 width: 14px;
1454 height: 14px;
1455 border-radius: 50%;
1456 background: var(--text-3);
1457 transition: transform 0.15s, background 0.15s;
1458}
1459
1460.toggle.on .toggle-thumb {
1461 transform: translateX(16px);
1462 background: var(--accent);
1463}
1464
1465/* Settings fields */
1466.settings-field {
1467 display: flex;
1468 flex-direction: column;
1469 gap: 6px;
1470}
1471
1472.settings-field-label {
1473 font-size: 11px;
1474 font-weight: 500;
1475 color: var(--text-3);
1476}
1477
1478.settings-field-row {
1479 display: flex;
1480 gap: 6px;
1481}
1482
1483.settings-input {
1484 flex: 1;
1485 background: var(--bg-1);
1486 border: 1px solid var(--border-3);
1487 border-radius: var(--r);
1488 color: var(--text);
1489 font-family: var(--font-mono);
1490 font-size: 12px;
1491 padding: 6px 10px;
1492 outline: none;
1493 width: 100%;
1494 transition: border-color 0.1s;
1495}
1496
1497.settings-input:focus { border-color: var(--accent-dim); }
1498
1499.settings-test-btn { flex-shrink: 0; }
1500.settings-test-btn.testing { opacity: 0.6; }
1501
1502.settings-status {
1503 font-size: 11px;
1504 padding: 4px 8px;
1505 border-radius: var(--r);
1506 line-height: 1.4;
1507}
1508
1509.settings-status.ok { background: rgba(74, 148, 112, 0.15); color: #4a9470; }
1510.settings-status.error { background: rgba(138, 48, 48, 0.15); color: #c04040; }
1511
1512.settings-model-list {
1513 display: flex;
1514 flex-direction: column;
1515 gap: 2px;
1516 max-height: 180px;
1517 overflow-y: auto;
1518 border: 1px solid var(--border);
1519 border-radius: var(--r);
1520 padding: 4px;
1521 background: var(--bg-1);
1522}
1523
1524.settings-model-item {
1525 text-align: left;
1526 background: none;
1527 border: none;
1528 color: var(--text-2);
1529 font-family: var(--font-mono);
1530 font-size: 12px;
1531 padding: 5px 8px;
1532 border-radius: 3px;
1533 cursor: pointer;
1534}
1535
1536.settings-model-item:hover { background: var(--bg-3); color: var(--text); }
1537.settings-model-item.active { background: var(--bg-3); color: var(--accent); }
1538
1539.settings-field-hint {
1540 font-size: 11px;
1541 color: var(--text-4);
1542 line-height: 1.5;
1543}
1544
1545.settings-field-hint code {
1546 font-family: var(--font-mono);
1547 color: var(--text-3);
1548 background: var(--bg-1);
1549 padding: 1px 4px;
1550 border-radius: 3px;
1551}
1552
1553.settings-help p {
1554 font-size: 12px;
1555 color: var(--text-3);
1556 line-height: 1.6;
1557 margin-bottom: 8px;
1558}
1559
1560.settings-help p:last-child { margin-bottom: 0; }
1561
1562.settings-help strong { color: var(--text-2); font-weight: 500; }
1563
1564.settings-help code {
1565 font-family: var(--font-mono);
1566 font-size: 11px;
1567 color: var(--text-3);
1568 background: var(--bg-1);
1569 padding: 2px 5px;
1570 border-radius: 3px;
1571 display: inline-block;
1572 margin-top: 4px;
1573}
1574
1575/* ── Scrollbar global ────────────────────────────────────────────────────── */
1576
1577::-webkit-scrollbar { width: 6px; height: 6px; }
1578::-webkit-scrollbar-track { background: transparent; }
1579::-webkit-scrollbar-thumb { background: var(--border-3); border-radius: 3px; }
1580::-webkit-scrollbar-thumb:hover { background: var(--border-3); }
1581
1582/* ── Utilities ───────────────────────────────────────────────────────────── */
1583
1584.sr-only {
1585 position: absolute;
1586 width: 1px;
1587 height: 1px;
1588 padding: 0;
1589 margin: -1px;
1590 overflow: hidden;
1591 clip: rect(0, 0, 0, 0);
1592 white-space: nowrap;
1593 border: 0;
1594}
src/types.ts added +100
@@ -0,0 +1,100 @@
1export interface BrandInputs {
2 name: string;
3 category: string;
4 purpose: string;
5 audience: string;
6 tone: string[];
7 avoid: string[];
8 notes: string;
9}
10
11export interface ToneGuidance {
12 attributes: string[];
13 voiceNotes: string;
14 avoidList: string[];
15 examplePhrases: string[];
16}
17
18export interface VisualDirection {
19 id: string;
20 name: string;
21 description: string;
22 palette: string;
23 typography: string;
24 references: string;
25}
26
27export interface LogoConcept {
28 id: string;
29 title: string;
30 concept: string;
31 mark: string;
32 execution: string;
33}
34
35export interface UsageExample {
36 context: string;
37 text: string;
38}
39
40export interface TypographyToken {
41 label: string;
42 size: string;
43 weight: string;
44 lineHeight: string;
45 usage: string;
46}
47
48export interface Typography {
49 primary: string;
50 secondary: string;
51 mono: string;
52 pairNote: string;
53 scale: TypographyToken[];
54}
55
56export interface ColorSwatch {
57 id: string;
58 name: string;
59 hex: string;
60 role: string;
61}
62
63export interface ColorPalette {
64 swatches: ColorSwatch[];
65}
66
67export interface BrandOutputs {
68 overview: string;
69 positioning: string;
70 tone: ToneGuidance;
71 titles: string[];
72 subtitles: string[];
73 taglines: string[];
74 visualDirections: VisualDirection[];
75 palette: ColorPalette;
76 typography: Typography;
77 logoConcepts: LogoConcept[];
78 usageExamples: UsageExample[];
79 constraints: string[];
80}
81
82export interface LockedSections {
83 overview: boolean;
84 positioning: boolean;
85 tone: boolean;
86 messaging: boolean;
87 visual: boolean;
88 palette: boolean;
89 typography: boolean;
90 logo: boolean;
91 usage: boolean;
92 constraints: boolean;
93}
94
95export interface WorkspaceState {
96 inputs: BrandInputs;
97 outputs: BrandOutputs | null;
98 edits: Partial<BrandOutputs>;
99 locked: LockedSections;
100}
tsconfig.json added +20
@@ -0,0 +1,20 @@
1{
2 "compilerOptions": {
3 "target": "ES2020",
4 "useDefineForClassFields": true,
5 "lib": ["ES2020", "DOM", "DOM.Iterable"],
6 "module": "ESNext",
7 "skipLibCheck": true,
8 "moduleResolution": "bundler",
9 "allowImportingTsExtensions": true,
10 "isolatedModules": true,
11 "moduleDetection": "force",
12 "noEmit": true,
13 "jsx": "react-jsx",
14 "strict": true,
15 "noUnusedLocals": true,
16 "noUnusedParameters": true,
17 "noFallthroughCasesInSwitch": true
18 },
19 "include": ["src"]
20}
vite.config.ts added +6
@@ -0,0 +1,6 @@
1import { defineConfig } from 'vite';
2import react from '@vitejs/plugin-react';
3
4export default defineConfig({
5 plugins: [react()],
6});