Commit 14818e090e
Unsigned
Layout: unified · split
LICENSE added +21
| @@ -0,0 +1,21 @@ | |||
| 1 | MIT License | ||
| 2 | |||
| 3 | Copyright (c) 2026 ZeroLabs | ||
| 4 | |||
| 5 | Permission is hereby granted, free of charge, to any person obtaining a copy | ||
| 6 | of this software and associated documentation files (the "Software"), to deal | ||
| 7 | in the Software without restriction, including without limitation the rights | ||
| 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | ||
| 9 | copies of the Software, and to permit persons to whom the Software is | ||
| 10 | furnished to do so, subject to the following conditions: | ||
| 11 | |||
| 12 | The above copyright notice and this permission notice shall be included in all | ||
| 13 | copies or substantial portions of the Software. | ||
| 14 | |||
| 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | ||
| 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | ||
| 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | ||
| 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | ||
| 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | ||
| 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||
| 21 | SOFTWARE. | ||
README.md added +108
| @@ -0,0 +1,108 @@ | |||
| 1 | # Brand Bench | ||
| 2 | |||
| 3 | A client-side brand identity generator. Describe a project and get a complete | ||
| 4 | brand package — positioning, tone of voice, color palette, typography system, | ||
| 5 | logo concepts, taglines, and usage examples — instantly in the browser. | ||
| 6 | |||
| 7 | No backend. No accounts. Everything runs locally and persists in `localStorage`. | ||
| 8 | |||
| 9 |  | ||
| 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 | ||
| 31 | npm install | ||
| 32 | npm run dev | ||
| 33 | ``` | ||
| 34 | |||
| 35 | Open `http://localhost:5173`. | ||
| 36 | |||
| 37 | To build for production: | ||
| 38 | |||
| 39 | ```bash | ||
| 40 | npm run build # outputs to dist/ | ||
| 41 | npm run preview # serve the build locally | ||
| 42 | ``` | ||
| 43 | |||
| 44 | ## AI with Ollama | ||
| 45 | |||
| 46 | Brandbench can generate brand packages using a locally running Ollama model. | ||
| 47 | |||
| 48 | 1. [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 | |||
| 56 | 2. Make sure Ollama is running: | ||
| 57 | |||
| 58 | ```bash | ||
| 59 | ollama serve | ||
| 60 | ``` | ||
| 61 | |||
| 62 | 3. 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 | |||
| 69 | 4. 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 | |||
| 73 | Larger models produce better-structured output. If generation fails with a JSON | ||
| 74 | error, try a bigger model. Generation typically takes 15–60 seconds depending on | ||
| 75 | hardware. | ||
| 76 | |||
| 77 | ## Project structure | ||
| 78 | |||
| 79 | ``` | ||
| 80 | src/ | ||
| 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 | |||
| 108 | MIT | ||
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 @@ | |||
| 1 | import { useState, useEffect } from 'react'; | ||
| 2 | import { useWorkspace } from './hooks/useWorkspace'; | ||
| 3 | import { usePackages } from './hooks/usePackages'; | ||
| 4 | import { useSettings } from './hooks/useSettings'; | ||
| 5 | import type { PackageSlot } from './hooks/usePackages'; | ||
| 6 | import { InputPanel } from './components/InputPanel'; | ||
| 7 | import { PreviewPanel } from './components/PreviewPanel'; | ||
| 8 | import { PackageSwitcher } from './components/PackageSwitcher'; | ||
| 9 | import { SettingsPanel } from './components/SettingsPanel'; | ||
| 10 | import type { BrandOutputs } from './types'; | ||
| 11 | |||
| 12 | interface 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 | |||
| 25 | function 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 | |||
| 105 | export 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 @@ | |||
| 1 | import { useRef, useState } from 'react'; | ||
| 2 | import type { BrandInputs, BrandOutputs, ColorSwatch, LockedSections, Typography } from '../types'; | ||
| 3 | import { CopyButton } from './CopyButton'; | ||
| 4 | |||
| 5 | // ── Editable text ───────────────────────────────────────────────────────────── | ||
| 6 | |||
| 7 | interface EditableProps { | ||
| 8 | value: string; | ||
| 9 | onChange: (v: string) => void; | ||
| 10 | multiline?: boolean; | ||
| 11 | } | ||
| 12 | |||
| 13 | function 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 | |||
| 93 | interface SectionProps { | ||
| 94 | title: string; | ||
| 95 | locked: boolean; | ||
| 96 | onToggleLock: () => void; | ||
| 97 | copyText?: string; | ||
| 98 | children: React.ReactNode; | ||
| 99 | } | ||
| 100 | |||
| 101 | function 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 | |||
| 127 | interface EditableListProps { | ||
| 128 | items: string[]; | ||
| 129 | onChange: (items: string[]) => void; | ||
| 130 | numbered?: boolean; | ||
| 131 | } | ||
| 132 | |||
| 133 | function 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 | |||
| 174 | function 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 | |||
| 217 | interface ColorSwatchCardProps { | ||
| 218 | swatch: ColorSwatch; | ||
| 219 | onChange: (s: ColorSwatch) => void; | ||
| 220 | onRemove: () => void; | ||
| 221 | } | ||
| 222 | |||
| 223 | function 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 | |||
| 300 | interface 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 | |||
| 308 | export 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 @@ | |||
| 1 | import { useState } from 'react'; | ||
| 2 | import { copyToClipboard } from '../lib/clipboard'; | ||
| 3 | |||
| 4 | interface Props { | ||
| 5 | text: string; | ||
| 6 | label?: string; | ||
| 7 | className?: string; | ||
| 8 | } | ||
| 9 | |||
| 10 | export 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 @@ | |||
| 1 | import type { BrandInputs } from '../types'; | ||
| 2 | import { TokenInput } from './TokenInput'; | ||
| 3 | |||
| 4 | const TONE_SUGGESTIONS = ['minimal', 'technical', 'calm', 'bold', 'warm', 'dry', 'focused', 'sharp']; | ||
| 5 | const AVOID_SUGGESTIONS = ['buzzwords', 'hype', 'startup language', 'passive voice', 'corporate tone', 'exclamation points', 'superlatives']; | ||
| 6 | |||
| 7 | interface Props { | ||
| 8 | inputs: BrandInputs; | ||
| 9 | onChange: (inputs: BrandInputs) => void; | ||
| 10 | onGenerate: () => void; | ||
| 11 | isGenerating: boolean; | ||
| 12 | generateLabel?: string; | ||
| 13 | } | ||
| 14 | |||
| 15 | export 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 @@ | |||
| 1 | import { useState, useEffect } from 'react'; | ||
| 2 | import { createPortal } from 'react-dom'; | ||
| 3 | import type { PackageSlot } from '../hooks/usePackages'; | ||
| 4 | |||
| 5 | interface 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 | |||
| 15 | interface MenuState { | ||
| 16 | id: string; | ||
| 17 | top: number; | ||
| 18 | left: number; | ||
| 19 | } | ||
| 20 | |||
| 21 | export 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 @@ | |||
| 1 | import type { BrandInputs, BrandOutputs, LockedSections } from '../types'; | ||
| 2 | import { BrandDoc } from './BrandDoc'; | ||
| 3 | import { CopyButton } from './CopyButton'; | ||
| 4 | import { toMarkdown, toJSON, toHTML, downloadFile } from '../lib/export'; | ||
| 5 | |||
| 6 | interface 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 | |||
| 18 | export 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 @@ | |||
| 1 | import { useState, useEffect } from 'react'; | ||
| 2 | import type { OllamaSettings } from '../hooks/useSettings'; | ||
| 3 | import { testOllamaConnection } from '../engine/aiGenerator'; | ||
| 4 | |||
| 5 | interface Props { | ||
| 6 | settings: OllamaSettings; | ||
| 7 | onChange: (next: Partial<OllamaSettings>) => void; | ||
| 8 | onClose: () => void; | ||
| 9 | } | ||
| 10 | |||
| 11 | type TestState = | ||
| 12 | | { status: 'idle' } | ||
| 13 | | { status: 'testing' } | ||
| 14 | | { status: 'ok'; models: string[] } | ||
| 15 | | { status: 'error'; message: string }; | ||
| 16 | |||
| 17 | export 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 @@ | |||
| 1 | import { useState, useRef, KeyboardEvent } from 'react'; | ||
| 2 | |||
| 3 | interface Props { | ||
| 4 | label: string; | ||
| 5 | values: string[]; | ||
| 6 | onChange: (values: string[]) => void; | ||
| 7 | suggestions?: string[]; | ||
| 8 | placeholder?: string; | ||
| 9 | } | ||
| 10 | |||
| 11 | export 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 @@ | |||
| 1 | import type { BrandInputs, BrandOutputs } from '../types'; | ||
| 2 | import type { OllamaSettings } from '../hooks/useSettings'; | ||
| 3 | import { sanitizeOutputs } from '../lib/sanitize'; | ||
| 4 | |||
| 5 | const 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 | |||
| 7 | function 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 | |||
| 76 | function 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 | |||
| 82 | export 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 | ||
| 132 | export 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 @@ | |||
| 1 | import 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 | |||
| 14 | type CategoryType = 'developer' | 'creative' | 'product' | 'services' | 'personal' | 'general'; | ||
| 15 | |||
| 16 | interface 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 | |||
| 31 | function 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 | |||
| 46 | function 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 | |||
| 67 | function pick<T>(arr: T[]): T { | ||
| 68 | return arr[Math.floor(Math.random() * arr.length)]; | ||
| 69 | } | ||
| 70 | |||
| 71 | function 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 | |||
| 81 | function cap(s: string): string { | ||
| 82 | return s.charAt(0).toUpperCase() + s.slice(1); | ||
| 83 | } | ||
| 84 | |||
| 85 | // ── Overview ───────────────────────────────────────────────────────────────── | ||
| 86 | |||
| 87 | function 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 | |||
| 111 | function 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 | |||
| 151 | function 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 | |||
| 253 | function 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 | |||
| 289 | function 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 | |||
| 309 | function 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 | |||
| 388 | function 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 | |||
| 517 | function 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 | |||
| 612 | function 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 | |||
| 673 | function 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 | |||
| 714 | export 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 | |||
| 725 | type FontPair = { primary: string; secondary: string; mono: string; pairNote: string }; | ||
| 726 | |||
| 727 | const 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 | |||
| 760 | function 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 | |||
| 767 | function makeSwatches(entries: [string, string, string][]): ColorSwatch[] { | ||
| 768 | return entries.map(([name, hex, role], i) => ({ id: `s${i}`, name, hex, role })); | ||
| 769 | } | ||
| 770 | |||
| 771 | function 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 | |||
| 940 | export 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 @@ | |||
| 1 | import { useState, useCallback } from 'react'; | ||
| 2 | |||
| 3 | export interface PackageSlot { | ||
| 4 | id: string; | ||
| 5 | name: string; | ||
| 6 | createdAt: string; | ||
| 7 | storageKey: string; | ||
| 8 | } | ||
| 9 | |||
| 10 | interface PackagesStore { | ||
| 11 | activeId: string; | ||
| 12 | slots: PackageSlot[]; | ||
| 13 | } | ||
| 14 | |||
| 15 | const STORE_KEY = 'bw:packages'; | ||
| 16 | const LEGACY_KEY = 'bw:workspace'; | ||
| 17 | |||
| 18 | function makeId(): string { | ||
| 19 | return `pkg-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; | ||
| 20 | } | ||
| 21 | |||
| 22 | function makeSlot(name: string, id = makeId()): PackageSlot { | ||
| 23 | return { id, name, createdAt: new Date().toISOString(), storageKey: `bw:ws:${id}` }; | ||
| 24 | } | ||
| 25 | |||
| 26 | function 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 | |||
| 44 | function saveStore(store: PackagesStore): void { | ||
| 45 | try { localStorage.setItem(STORE_KEY, JSON.stringify(store)); } catch { /* ignore */ } | ||
| 46 | } | ||
| 47 | |||
| 48 | export 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 @@ | |||
| 1 | import { useState } from 'react'; | ||
| 2 | |||
| 3 | export interface OllamaSettings { | ||
| 4 | enabled: boolean; | ||
| 5 | baseUrl: string; | ||
| 6 | model: string; | ||
| 7 | } | ||
| 8 | |||
| 9 | const DEFAULTS: OllamaSettings = { | ||
| 10 | enabled: false, | ||
| 11 | baseUrl: 'http://localhost:11434', | ||
| 12 | model: 'llama3.2', | ||
| 13 | }; | ||
| 14 | |||
| 15 | const KEY = 'bw:settings'; | ||
| 16 | |||
| 17 | function 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 | ||
| 27 | export function readSettings(): OllamaSettings { | ||
| 28 | return load(); | ||
| 29 | } | ||
| 30 | |||
| 31 | export 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 @@ | |||
| 1 | import { useState, useCallback, useRef } from 'react'; | ||
| 2 | import type { BrandInputs, BrandOutputs, LockedSections, WorkspaceState } from '../types'; | ||
| 3 | import { generate } from '../engine/generator'; | ||
| 4 | import { generateWithAI } from '../engine/aiGenerator'; | ||
| 5 | import { readSettings } from './useSettings'; | ||
| 6 | import { sanitizeOutputs } from '../lib/sanitize'; | ||
| 7 | |||
| 8 | const DEFAULT_INPUTS: BrandInputs = { | ||
| 9 | name: '', | ||
| 10 | category: '', | ||
| 11 | purpose: '', | ||
| 12 | audience: '', | ||
| 13 | tone: [], | ||
| 14 | avoid: [], | ||
| 15 | notes: '', | ||
| 16 | }; | ||
| 17 | |||
| 18 | const 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 | |||
| 31 | function 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 | |||
| 48 | function saveState(storageKey: string, state: WorkspaceState): void { | ||
| 49 | try { localStorage.setItem(storageKey, JSON.stringify(state)); } catch { /* ignore */ } | ||
| 50 | } | ||
| 51 | |||
| 52 | function 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 | |||
| 76 | export 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 @@ | |||
| 1 | export 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 @@ | |||
| 1 | import type { BrandInputs, BrandOutputs } from '../types'; | ||
| 2 | |||
| 3 | export 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 | |||
| 125 | export 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 | |||
| 145 | export 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, '&').replace(/</g, '<').replace(/>/g, '>'); | ||
| 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 & 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 | |||
| 318 | export 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 | */ | ||
| 7 | import type { BrandOutputs } from '../types'; | ||
| 8 | import { TYPE_SCALE } from '../engine/generator'; | ||
| 9 | |||
| 10 | // --------------------------------------------------------------------------- | ||
| 11 | // Primitive coercions | ||
| 12 | // --------------------------------------------------------------------------- | ||
| 13 | |||
| 14 | /** Coerce any value to a non-empty string. */ | ||
| 15 | export 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. */ | ||
| 30 | export 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 | |||
| 37 | function 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 | */ | ||
| 51 | export 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 @@ | |||
| 1 | import { StrictMode } from 'react'; | ||
| 2 | import { createRoot } from 'react-dom/client'; | ||
| 3 | import './styles/globals.css'; | ||
| 4 | import App from './App'; | ||
| 5 | |||
| 6 | createRoot(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; } | ||
| 4 | html { 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 | |||
| 41 | body { | ||
| 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 @@ | |||
| 1 | export 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 | |||
| 11 | export interface ToneGuidance { | ||
| 12 | attributes: string[]; | ||
| 13 | voiceNotes: string; | ||
| 14 | avoidList: string[]; | ||
| 15 | examplePhrases: string[]; | ||
| 16 | } | ||
| 17 | |||
| 18 | export interface VisualDirection { | ||
| 19 | id: string; | ||
| 20 | name: string; | ||
| 21 | description: string; | ||
| 22 | palette: string; | ||
| 23 | typography: string; | ||
| 24 | references: string; | ||
| 25 | } | ||
| 26 | |||
| 27 | export interface LogoConcept { | ||
| 28 | id: string; | ||
| 29 | title: string; | ||
| 30 | concept: string; | ||
| 31 | mark: string; | ||
| 32 | execution: string; | ||
| 33 | } | ||
| 34 | |||
| 35 | export interface UsageExample { | ||
| 36 | context: string; | ||
| 37 | text: string; | ||
| 38 | } | ||
| 39 | |||
| 40 | export interface TypographyToken { | ||
| 41 | label: string; | ||
| 42 | size: string; | ||
| 43 | weight: string; | ||
| 44 | lineHeight: string; | ||
| 45 | usage: string; | ||
| 46 | } | ||
| 47 | |||
| 48 | export interface Typography { | ||
| 49 | primary: string; | ||
| 50 | secondary: string; | ||
| 51 | mono: string; | ||
| 52 | pairNote: string; | ||
| 53 | scale: TypographyToken[]; | ||
| 54 | } | ||
| 55 | |||
| 56 | export interface ColorSwatch { | ||
| 57 | id: string; | ||
| 58 | name: string; | ||
| 59 | hex: string; | ||
| 60 | role: string; | ||
| 61 | } | ||
| 62 | |||
| 63 | export interface ColorPalette { | ||
| 64 | swatches: ColorSwatch[]; | ||
| 65 | } | ||
| 66 | |||
| 67 | export 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 | |||
| 82 | export 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 | |||
| 95 | export 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 @@ | |||
| 1 | import { defineConfig } from 'vite'; | ||
| 2 | import react from '@vitejs/plugin-react'; | ||
| 3 | |||
| 4 | export default defineConfig({ | ||
| 5 | plugins: [react()], | ||
| 6 | }); | ||