stoa-mcp 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +219 -67
- package/dist/cli/moodboard-edit.d.ts +1 -0
- package/dist/cli/moodboard-edit.js +245 -0
- package/dist/cli/moodboard-picker.d.ts +2 -0
- package/dist/cli/moodboard-picker.js +125 -0
- package/dist/cli.js +176 -5
- package/dist/storage/moodboard-presets.d.ts +30 -0
- package/dist/storage/moodboard-presets.js +160 -0
- package/dist/storage/project.js +23 -11
- package/package.json +1 -1
- package/templates/moodboard-presets/bold.json +18 -0
- package/templates/moodboard-presets/clean.json +18 -0
- package/templates/moodboard-presets/dark.json +18 -0
- package/templates/moodboard-presets/warm.json +18 -0
package/README.md
CHANGED
|
@@ -32,48 +32,60 @@ This creates a `.stoa/` folder with everything Stoa needs:
|
|
|
32
32
|
```
|
|
33
33
|
.stoa/
|
|
34
34
|
moodboard/notes.md ← your design direction (colors, layout, style)
|
|
35
|
+
moodboard/tokens.json ← auto-generated machine-readable design tokens
|
|
35
36
|
context.md ← dependencies, conventions, brand voice
|
|
36
37
|
lessons.md ← project memory (grows automatically)
|
|
37
38
|
guardrails/ ← rules the AI must follow
|
|
38
39
|
roles/ ← AI personas (Builder, Fixer, Planner)
|
|
40
|
+
presets/ ← your saved custom style presets
|
|
39
41
|
specs/ ← saved specifications
|
|
40
42
|
```
|
|
41
43
|
|
|
44
|
+
Every new project starts with the **Clean** style preset (white, minimal, Linear-style). Every spec you generate will include these design tokens automatically.
|
|
45
|
+
|
|
42
46
|
### 2. Set up your moodboard (optional but powerful)
|
|
43
47
|
|
|
44
|
-
|
|
48
|
+
Your project already has a working design system. Check it:
|
|
45
49
|
|
|
46
50
|
```bash
|
|
47
|
-
stoa
|
|
51
|
+
stoa moodboard
|
|
48
52
|
```
|
|
49
53
|
|
|
50
|
-
|
|
54
|
+
**Want a different style?** Pick a preset:
|
|
51
55
|
|
|
52
|
-
```
|
|
53
|
-
|
|
54
|
-
|
|
56
|
+
```bash
|
|
57
|
+
stoa moodboard preset
|
|
58
|
+
```
|
|
55
59
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
+
Use arrow keys to browse 4 built-in presets with live previews:
|
|
61
|
+
- **Clean** — White, minimal, Linear/Vercel style
|
|
62
|
+
- **Dark** — Dark background, muted accents, GitHub/Raycast style
|
|
63
|
+
- **Warm** — Cream tones, friendly SaaS feel
|
|
64
|
+
- **Bold** — High contrast, sharp corners, brutalist
|
|
60
65
|
|
|
61
|
-
|
|
62
|
-
Sidebar navigation left, main content right
|
|
66
|
+
Or apply directly: `stoa moodboard preset dark`
|
|
63
67
|
|
|
64
|
-
|
|
65
|
-
|
|
68
|
+
**Want to customize?** Edit interactively in the terminal:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
stoa moodboard edit
|
|
66
72
|
```
|
|
67
73
|
|
|
68
|
-
|
|
74
|
+
Walks you through each field (colors, typography, layout) one by one. Press Enter to keep, type a new value to change, or `q` to quit anytime.
|
|
69
75
|
|
|
70
|
-
**Have a screenshot of a design you like?**
|
|
76
|
+
**Have a screenshot of a design you like?** Run:
|
|
71
77
|
|
|
72
78
|
```bash
|
|
73
79
|
stoa moodboard describe
|
|
74
80
|
```
|
|
75
81
|
|
|
76
|
-
If you have an Anthropic API key, Stoa
|
|
82
|
+
This opens the moodboard folder in Finder — drop your screenshots in, press Enter. If you have an Anthropic API key, Stoa analyzes the images and writes the design system for you. If not, it prints a prompt you can paste into any Claude chat.
|
|
83
|
+
|
|
84
|
+
**Save your custom style** for reuse across projects:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
stoa moodboard save-preset my-brand
|
|
88
|
+
```
|
|
77
89
|
|
|
78
90
|
### 3. Refine your idea
|
|
79
91
|
|
|
@@ -185,12 +197,11 @@ Stage 1 will reference your existing files, components, and design system. The s
|
|
|
185
197
|
|
|
186
198
|
## Changing the Design
|
|
187
199
|
|
|
188
|
-
|
|
200
|
+
Switch preset, edit interactively, or both:
|
|
189
201
|
|
|
190
202
|
```bash
|
|
191
|
-
stoa
|
|
192
|
-
#
|
|
193
|
-
|
|
203
|
+
stoa moodboard preset dark # switch to dark theme
|
|
204
|
+
stoa moodboard edit # tweak individual values
|
|
194
205
|
stoa refine "Redesign the app to match the updated design system"
|
|
195
206
|
```
|
|
196
207
|
|
|
@@ -206,6 +217,7 @@ All files in `.stoa/` are optional. Use what you need, ignore what you don't.
|
|
|
206
217
|
|------|-------------|----------|
|
|
207
218
|
| `moodboard/notes.md` | Design direction: colors, layout, typography | Web apps, UI projects |
|
|
208
219
|
| `moodboard/tokens.json` | Auto-generated machine-readable design values | Generated by `stoa moodboard sync` |
|
|
220
|
+
| `presets/*.json` | Custom saved style presets | Reuse across projects |
|
|
209
221
|
| `context.md` | Dependencies, conventions, brand voice | All projects |
|
|
210
222
|
| `lessons.md` | Past mistakes — auto-grows, prevents repeats | All projects (grows over time) |
|
|
211
223
|
| `guardrails/*.md` | Rules the AI must follow (e.g. "don't delete code") | All projects |
|
|
@@ -253,62 +265,190 @@ Every future refine includes past lessons as failure modes to avoid. Your projec
|
|
|
253
265
|
|
|
254
266
|
## CLI Reference
|
|
255
267
|
|
|
268
|
+
### Setup
|
|
269
|
+
|
|
270
|
+
```bash
|
|
271
|
+
stoa init
|
|
272
|
+
```
|
|
273
|
+
Creates `.stoa/` in the current directory with the Clean style preset, 5 guardrails, and 3 roles. Run this once per project.
|
|
274
|
+
|
|
275
|
+
```bash
|
|
276
|
+
stoa edit moodboard # open moodboard in VS Code/Cursor
|
|
277
|
+
stoa edit context # open context.md
|
|
278
|
+
stoa edit lessons # open lessons.md
|
|
279
|
+
```
|
|
280
|
+
Opens files in the best available editor. Detection order: Cursor → VS Code → `$EDITOR` → macOS default → nano.
|
|
281
|
+
|
|
282
|
+
---
|
|
283
|
+
|
|
284
|
+
### Moodboard
|
|
285
|
+
|
|
286
|
+
```bash
|
|
287
|
+
stoa moodboard
|
|
288
|
+
```
|
|
289
|
+
Shows current moodboard status: active style, color count, image count, and available commands.
|
|
290
|
+
|
|
291
|
+
```bash
|
|
292
|
+
stoa moodboard preset
|
|
293
|
+
```
|
|
294
|
+
**Interactive.** Browse 4 built-in presets (Clean, Dark, Warm, Bold) + any custom presets with arrow keys. Shows a live preview with colors, typography, and references. Press **Enter** to apply, **q** to cancel.
|
|
295
|
+
|
|
296
|
+
You can also apply directly without the picker:
|
|
297
|
+
```bash
|
|
298
|
+
stoa moodboard preset dark
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
```bash
|
|
302
|
+
stoa moodboard edit
|
|
303
|
+
```
|
|
304
|
+
**Interactive.** Walks through each field one by one: Design Direction → Colors (each individually) → Typography → Layout → Component Style → References. Press **Enter** to keep current value. Type a new value to replace. Type **q** to quit at any point.
|
|
305
|
+
|
|
306
|
+
```bash
|
|
307
|
+
stoa moodboard describe
|
|
308
|
+
```
|
|
309
|
+
**Interactive.** Opens the `.stoa/moodboard/` folder in Finder so you can drag screenshots in. Press **Enter** when ready. If you have an API key, Stoa analyzes the images with AI and writes the design system automatically. Without an API key, it prints a prompt you can paste into any Claude chat alongside your screenshot.
|
|
310
|
+
|
|
311
|
+
```bash
|
|
312
|
+
stoa moodboard sync
|
|
313
|
+
```
|
|
314
|
+
Regenerates `tokens.json` from `notes.md`. Usually happens automatically after preset or edit, but run this if you edited `notes.md` by hand.
|
|
315
|
+
|
|
316
|
+
```bash
|
|
317
|
+
stoa moodboard save-preset my-brand
|
|
318
|
+
```
|
|
319
|
+
Saves the current moodboard as `.stoa/presets/my-brand.json`. Reusable across projects — shows up in `stoa moodboard preset` picker.
|
|
320
|
+
|
|
321
|
+
---
|
|
322
|
+
|
|
323
|
+
### Refine
|
|
324
|
+
|
|
325
|
+
```bash
|
|
326
|
+
stoa refine "Build a waitlist page with email signup and referral system"
|
|
327
|
+
```
|
|
328
|
+
Runs the 5-stage pipeline. After completion, shows an interactive menu:
|
|
329
|
+
|
|
330
|
+
| Key | Action | Notes |
|
|
331
|
+
|-----|--------|-------|
|
|
332
|
+
| **b** | Build with Claude Code | Launches Claude Code with the spec |
|
|
333
|
+
| **c** | Copy spec to clipboard | Re-copy (Stage 1 is auto-copied on finish) |
|
|
334
|
+
| **e** | Export as markdown | Writes to `specs/<slug>/spec.md` in project root |
|
|
335
|
+
| **v** | View spec files | Opens spec directory in Finder |
|
|
336
|
+
| **q** | Done | Exits |
|
|
337
|
+
|
|
338
|
+
**Options:**
|
|
256
339
|
```bash
|
|
257
|
-
#
|
|
258
|
-
stoa
|
|
259
|
-
stoa
|
|
260
|
-
stoa
|
|
261
|
-
stoa
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
stoa
|
|
270
|
-
stoa
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
stoa scenarios list # List scenarios for latest spec
|
|
340
|
+
stoa refine "task" --mode api # Force Anthropic API (needs key)
|
|
341
|
+
stoa refine "task" --mode claude-code # Force Claude Code CLI
|
|
342
|
+
stoa refine "task" --mode clipboard # Get prompts without AI calls (free)
|
|
343
|
+
stoa refine "task" --role planner # Use a specific role
|
|
344
|
+
stoa refine "task" --stages clarify,structure # Run specific stages only
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
---
|
|
348
|
+
|
|
349
|
+
### Specs
|
|
350
|
+
|
|
351
|
+
```bash
|
|
352
|
+
stoa specs list # List all saved specs with dates and stage count
|
|
353
|
+
stoa specs show <name> # Print a spec's contents to terminal
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
Specs are saved in `.stoa/specs/<slug>/` with one markdown file per stage. The `[e]` export writes a combined `spec.md` to the visible `specs/` folder in your project root.
|
|
357
|
+
|
|
358
|
+
---
|
|
359
|
+
|
|
360
|
+
### Scenarios
|
|
361
|
+
|
|
362
|
+
```bash
|
|
363
|
+
stoa scenarios list # List scenarios for the latest spec
|
|
364
|
+
stoa scenarios list <name> # List scenarios for a specific spec
|
|
281
365
|
stoa scenarios run # Walk through scenarios interactively
|
|
282
366
|
stoa scenarios run <name> # Run scenarios for a specific spec
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
**Interactive.** Each scenario shows GIVEN (what to set up) and EXPECTED (what to check). Press **y** for pass, **n** for fail, **s** to skip. Shows a summary at the end.
|
|
370
|
+
|
|
371
|
+
---
|
|
372
|
+
|
|
373
|
+
### Review
|
|
374
|
+
|
|
375
|
+
```bash
|
|
376
|
+
stoa review # Review the latest spec
|
|
377
|
+
stoa review <name> # Review a specific spec
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
**Interactive.** Opens each stage for review. Accept, edit, or skip. After editing, optionally re-runs affected pipeline stages.
|
|
381
|
+
|
|
382
|
+
---
|
|
383
|
+
|
|
384
|
+
### Build & Verify
|
|
283
385
|
|
|
284
|
-
|
|
285
|
-
stoa
|
|
286
|
-
stoa
|
|
287
|
-
stoa
|
|
288
|
-
stoa
|
|
386
|
+
```bash
|
|
387
|
+
stoa build # Build the latest spec with Claude Code
|
|
388
|
+
stoa build <name> # Build a specific spec
|
|
389
|
+
stoa verify # Run blind test verification
|
|
390
|
+
stoa verify <name> # Verify a specific spec
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
Build gives you a choice: build all at once or subtask by subtask. Verify runs the scenarios interactively after the build.
|
|
394
|
+
|
|
395
|
+
---
|
|
396
|
+
|
|
397
|
+
### Guardrails & Roles
|
|
398
|
+
|
|
399
|
+
```bash
|
|
400
|
+
stoa guardrails list # List active guardrails
|
|
401
|
+
stoa guardrails show <name> # View a guardrail's content
|
|
402
|
+
stoa guardrails add <name> # Add a new guardrail
|
|
403
|
+
stoa guardrails remove <name> # Remove a guardrail
|
|
404
|
+
stoa roles list # List available roles
|
|
405
|
+
stoa roles show <name> # View a role's content
|
|
406
|
+
stoa roles add <name> # Add a new role
|
|
407
|
+
stoa roles remove <name> # Remove a role
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
Guardrails are rules injected into every refine (e.g. "don't delete existing code"). Roles are AI personas used via `--role` flag.
|
|
411
|
+
|
|
412
|
+
---
|
|
413
|
+
|
|
414
|
+
### Config
|
|
289
415
|
|
|
290
|
-
|
|
291
|
-
stoa config # View current
|
|
416
|
+
```bash
|
|
417
|
+
stoa config # View current settings
|
|
292
418
|
stoa config set apiKey <key> # Set Anthropic API key
|
|
293
|
-
stoa config set model <model> # Set model (default: claude-sonnet-4-
|
|
294
|
-
stoa config set mode <mode> # Set mode: api, claude-code, clipboard
|
|
419
|
+
stoa config set model <model> # Set model (default: claude-sonnet-4-6)
|
|
420
|
+
stoa config set mode <mode> # Set default mode: api, claude-code, clipboard
|
|
295
421
|
```
|
|
296
422
|
|
|
297
423
|
---
|
|
298
424
|
|
|
299
425
|
## Execution Modes
|
|
300
426
|
|
|
301
|
-
|
|
427
|
+
| Mode | How it works | You need | Cost |
|
|
428
|
+
|------|-------------|----------|------|
|
|
429
|
+
| `api` | Direct Anthropic API call | API key (`stoa config set apiKey`) | ~$0.05/refine |
|
|
430
|
+
| `claude-code` | Pipes to Claude Code CLI | Claude Code installed + subscription | Included in subscription |
|
|
431
|
+
| `clipboard` | Returns prompts — no AI calls | Nothing | Free |
|
|
302
432
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
433
|
+
Stoa auto-detects: API key → `api`, Claude Code in PATH → `claude-code`, otherwise → `clipboard`.
|
|
434
|
+
|
|
435
|
+
In clipboard mode, Stoa prints each stage's prompt. Paste into any AI chat (Claude, ChatGPT, Cursor) and copy the response back. Same pipeline, just manual.
|
|
436
|
+
|
|
437
|
+
---
|
|
438
|
+
|
|
439
|
+
## Keyboard Shortcuts
|
|
308
440
|
|
|
309
|
-
|
|
441
|
+
All interactive commands support these:
|
|
310
442
|
|
|
311
|
-
|
|
443
|
+
| Context | Key | Action |
|
|
444
|
+
|---------|-----|--------|
|
|
445
|
+
| Any prompt | `q` / `quit` / `exit` | Cancel and go back |
|
|
446
|
+
| Arrow key menus | `↑` `↓` or `k` `j` | Navigate |
|
|
447
|
+
| Arrow key menus | `Enter` | Select |
|
|
448
|
+
| Arrow key menus | `q` | Cancel |
|
|
449
|
+
| Post-refine menu | `b` `c` `e` `v` `q` | See table above |
|
|
450
|
+
| Scenario runner | `y` `n` `s` | Pass / Fail / Skip |
|
|
451
|
+
| Ctrl+C | Always | Force quit |
|
|
312
452
|
|
|
313
453
|
---
|
|
314
454
|
|
|
@@ -327,18 +467,24 @@ Add Stoa as an MCP server in Cursor. Create or edit `.cursor/mcp.json` in your p
|
|
|
327
467
|
}
|
|
328
468
|
```
|
|
329
469
|
|
|
330
|
-
Find the global path
|
|
331
|
-
|
|
470
|
+
Find the global path:
|
|
332
471
|
```bash
|
|
333
|
-
npm root -g
|
|
472
|
+
echo "$(npm root -g)/stoa-mcp/dist/index.js"
|
|
334
473
|
```
|
|
335
474
|
|
|
336
475
|
Then in Cursor's Agent chat:
|
|
337
|
-
|
|
338
476
|
```
|
|
339
477
|
Use the refine_task tool with title: "My App" and description: "description of what I want"
|
|
340
478
|
```
|
|
341
479
|
|
|
480
|
+
## Use with Claude Code
|
|
481
|
+
|
|
482
|
+
Stoa works directly with Claude Code. After refining:
|
|
483
|
+
|
|
484
|
+
1. Press `[b]` in the post-refine menu — launches Claude Code automatically
|
|
485
|
+
2. Or copy the spec and paste it: `claude "Read the spec in .stoa/specs/<name>/ and build it"`
|
|
486
|
+
3. Or use `stoa build` for the full guided experience
|
|
487
|
+
|
|
342
488
|
---
|
|
343
489
|
|
|
344
490
|
## How It Works
|
|
@@ -389,6 +535,12 @@ The spec references existing files by name and says "add to" instead of "rebuild
|
|
|
389
535
|
- `Fixer` — fixes bugs from failure context
|
|
390
536
|
- `Planner` — breaks down large tasks
|
|
391
537
|
|
|
538
|
+
**4 Style Presets:**
|
|
539
|
+
- `Clean` — white, minimal, Linear/Vercel (applied by default)
|
|
540
|
+
- `Dark` — dark background, violet accents, GitHub/Raycast
|
|
541
|
+
- `Warm` — cream tones, amber accents, Cal.com/Stripe
|
|
542
|
+
- `Bold` — high contrast, sharp corners, brutalist
|
|
543
|
+
|
|
392
544
|
---
|
|
393
545
|
|
|
394
546
|
## The Stoa Desktop App
|
|
@@ -402,7 +554,7 @@ The CLI is the free version. The full loop lives in the Stoa desktop app:
|
|
|
402
554
|
- Task hierarchy (parent → subtask → fix task)
|
|
403
555
|
- Dashboard with spec scores across all tasks
|
|
404
556
|
|
|
405
|
-
**Same pipeline, full GUI.** Coming soon at [stoafactory.com](https://stoafactory.
|
|
557
|
+
**Same pipeline, full GUI.** Coming soon at [stoafactory.com](https://stoafactory.dev).
|
|
406
558
|
|
|
407
559
|
---
|
|
408
560
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function runMoodboardEdit(projectDir: string): Promise<void>;
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { createInterface } from "node:readline";
|
|
5
|
+
import { syncMoodboard } from "../storage/moodboard-sync.js";
|
|
6
|
+
function writeln(text = "") {
|
|
7
|
+
process.stdout.write(text + "\n");
|
|
8
|
+
}
|
|
9
|
+
const HTML_COMMENT_RE = /<!--[\s\S]*?-->/g;
|
|
10
|
+
function parseMoodboard(notesPath) {
|
|
11
|
+
const defaults = {
|
|
12
|
+
designDirection: "",
|
|
13
|
+
colors: {},
|
|
14
|
+
layout: "",
|
|
15
|
+
typography: "",
|
|
16
|
+
componentStyle: "",
|
|
17
|
+
references: "",
|
|
18
|
+
};
|
|
19
|
+
if (!existsSync(notesPath))
|
|
20
|
+
return defaults;
|
|
21
|
+
const raw = readFileSync(notesPath, "utf-8");
|
|
22
|
+
const sections = {};
|
|
23
|
+
const parts = raw.split(/^# /m);
|
|
24
|
+
for (const part of parts) {
|
|
25
|
+
const trimmed = part.trim();
|
|
26
|
+
if (!trimmed)
|
|
27
|
+
continue;
|
|
28
|
+
const newlineIdx = trimmed.indexOf("\n");
|
|
29
|
+
if (newlineIdx === -1)
|
|
30
|
+
continue;
|
|
31
|
+
const heading = trimmed.slice(0, newlineIdx).trim();
|
|
32
|
+
const body = trimmed.slice(newlineIdx + 1).replace(HTML_COMMENT_RE, "").trim();
|
|
33
|
+
if (body)
|
|
34
|
+
sections[heading] = body;
|
|
35
|
+
}
|
|
36
|
+
// Parse colors
|
|
37
|
+
const colors = {};
|
|
38
|
+
if (sections["Colors"]) {
|
|
39
|
+
const lines = sections["Colors"].split("\n");
|
|
40
|
+
for (const line of lines) {
|
|
41
|
+
const match = line.match(/^([A-Za-z][A-Za-z0-9 /]*?)\s*:\s*(#[0-9A-Fa-f]{6})\b/);
|
|
42
|
+
if (match) {
|
|
43
|
+
colors[match[1].trim()] = match[2];
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
designDirection: sections["Design Direction"] ?? "",
|
|
49
|
+
colors,
|
|
50
|
+
layout: sections["Layout"] ?? "",
|
|
51
|
+
typography: sections["Typography"] ?? "",
|
|
52
|
+
componentStyle: sections["Component Style"] ?? "",
|
|
53
|
+
references: sections["References"] ?? "",
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
const QUIT_COMMANDS = new Set(["q", "quit", "exit"]);
|
|
57
|
+
function prompt(rl, question) {
|
|
58
|
+
return new Promise((resolve) => {
|
|
59
|
+
rl.question(question, (answer) => {
|
|
60
|
+
if (QUIT_COMMANDS.has(answer.trim().toLowerCase())) {
|
|
61
|
+
resolve(null);
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
resolve(answer);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
export async function runMoodboardEdit(projectDir) {
|
|
70
|
+
const notesPath = join(projectDir, ".stoa", "moodboard", "notes.md");
|
|
71
|
+
const current = parseMoodboard(notesPath);
|
|
72
|
+
const rl = createInterface({
|
|
73
|
+
input: process.stdin,
|
|
74
|
+
output: process.stdout,
|
|
75
|
+
});
|
|
76
|
+
writeln();
|
|
77
|
+
writeln(chalk.bold("Edit Moodboard"));
|
|
78
|
+
writeln(chalk.dim("Enter to keep current value. Type new value to replace. Type 'q' to quit."));
|
|
79
|
+
writeln();
|
|
80
|
+
let changed = false;
|
|
81
|
+
// Helper to handle a text field
|
|
82
|
+
async function editField(label, currentValue) {
|
|
83
|
+
writeln(chalk.bold(label));
|
|
84
|
+
if (currentValue) {
|
|
85
|
+
writeln(chalk.dim(` Current: ${currentValue}`));
|
|
86
|
+
}
|
|
87
|
+
const answer = await prompt(rl, chalk.cyan(" New: "));
|
|
88
|
+
if (answer === null)
|
|
89
|
+
return "quit";
|
|
90
|
+
if (answer.trim()) {
|
|
91
|
+
writeln(chalk.green(" ✓ Updated"));
|
|
92
|
+
return answer.trim();
|
|
93
|
+
}
|
|
94
|
+
writeln(chalk.dim(" ✓ Kept"));
|
|
95
|
+
return currentValue;
|
|
96
|
+
}
|
|
97
|
+
// Design Direction
|
|
98
|
+
const dir = await editField("Design Direction", current.designDirection);
|
|
99
|
+
if (dir === "quit") {
|
|
100
|
+
rl.close();
|
|
101
|
+
writeln(chalk.dim("\nCancelled."));
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (dir !== current.designDirection) {
|
|
105
|
+
current.designDirection = dir;
|
|
106
|
+
changed = true;
|
|
107
|
+
}
|
|
108
|
+
writeln();
|
|
109
|
+
// Colors
|
|
110
|
+
writeln(chalk.bold("Colors"));
|
|
111
|
+
const colorKeys = Object.keys(current.colors);
|
|
112
|
+
if (colorKeys.length > 0) {
|
|
113
|
+
for (const key of colorKeys) {
|
|
114
|
+
const hex = current.colors[key];
|
|
115
|
+
const swatch = chalk.hex(hex)("██");
|
|
116
|
+
writeln(` ${chalk.dim(key)}: ${swatch} ${chalk.dim(hex)}`);
|
|
117
|
+
const newColor = await prompt(rl, chalk.cyan(` New ${key}: `));
|
|
118
|
+
if (newColor === null) {
|
|
119
|
+
rl.close();
|
|
120
|
+
writeln(chalk.dim("\nCancelled."));
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (newColor.trim()) {
|
|
124
|
+
if (/^#[0-9A-Fa-f]{6}$/.test(newColor.trim())) {
|
|
125
|
+
current.colors[key] = newColor.trim();
|
|
126
|
+
changed = true;
|
|
127
|
+
writeln(chalk.green(" ✓ Updated"));
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
writeln(chalk.yellow(" ✗ Invalid hex (use #RRGGBB). Kept original."));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
writeln(chalk.dim(" ✓ Kept"));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
writeln(chalk.dim(" No colors defined. Add them in format: Label: #HEXVAL"));
|
|
140
|
+
}
|
|
141
|
+
// Add new color?
|
|
142
|
+
const addColor = await prompt(rl, chalk.cyan(" Add new color? (name: #hex or Enter to skip): "));
|
|
143
|
+
if (addColor === null) {
|
|
144
|
+
rl.close();
|
|
145
|
+
writeln(chalk.dim("\nCancelled."));
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (addColor.trim()) {
|
|
149
|
+
const match = addColor.match(/^([A-Za-z][A-Za-z0-9 ]*?)\s*:\s*(#[0-9A-Fa-f]{6})$/);
|
|
150
|
+
if (match) {
|
|
151
|
+
current.colors[match[1].trim()] = match[2];
|
|
152
|
+
changed = true;
|
|
153
|
+
writeln(chalk.green(` ✓ Added ${match[1].trim()}`));
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
writeln(chalk.yellow(" ✗ Format: Name: #HEXVAL"));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
writeln();
|
|
160
|
+
// Typography
|
|
161
|
+
const typo = await editField("Typography", current.typography);
|
|
162
|
+
if (typo === "quit") {
|
|
163
|
+
rl.close();
|
|
164
|
+
writeln(chalk.dim("\nCancelled."));
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (typo !== current.typography) {
|
|
168
|
+
current.typography = typo;
|
|
169
|
+
changed = true;
|
|
170
|
+
}
|
|
171
|
+
writeln();
|
|
172
|
+
// Layout
|
|
173
|
+
const layout = await editField("Layout", current.layout);
|
|
174
|
+
if (layout === "quit") {
|
|
175
|
+
rl.close();
|
|
176
|
+
writeln(chalk.dim("\nCancelled."));
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (layout !== current.layout) {
|
|
180
|
+
current.layout = layout;
|
|
181
|
+
changed = true;
|
|
182
|
+
}
|
|
183
|
+
writeln();
|
|
184
|
+
// Component Style
|
|
185
|
+
const style = await editField("Component Style", current.componentStyle);
|
|
186
|
+
if (style === "quit") {
|
|
187
|
+
rl.close();
|
|
188
|
+
writeln(chalk.dim("\nCancelled."));
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (style !== current.componentStyle) {
|
|
192
|
+
current.componentStyle = style;
|
|
193
|
+
changed = true;
|
|
194
|
+
}
|
|
195
|
+
writeln();
|
|
196
|
+
// References
|
|
197
|
+
const refs = await editField("References", current.references);
|
|
198
|
+
if (refs === "quit") {
|
|
199
|
+
rl.close();
|
|
200
|
+
writeln(chalk.dim("\nCancelled."));
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (refs !== current.references) {
|
|
204
|
+
current.references = refs;
|
|
205
|
+
changed = true;
|
|
206
|
+
}
|
|
207
|
+
rl.close();
|
|
208
|
+
writeln();
|
|
209
|
+
if (!changed) {
|
|
210
|
+
writeln(chalk.dim("No changes made."));
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
// Write notes.md
|
|
214
|
+
const colorLines = Object.entries(current.colors)
|
|
215
|
+
.map(([key, value]) => `${key}: ${value}`)
|
|
216
|
+
.join("\n");
|
|
217
|
+
const markdown = `# Design Direction
|
|
218
|
+
${current.designDirection || ""}
|
|
219
|
+
|
|
220
|
+
# Colors
|
|
221
|
+
${colorLines || ""}
|
|
222
|
+
|
|
223
|
+
# Layout
|
|
224
|
+
${current.layout || ""}
|
|
225
|
+
|
|
226
|
+
# Typography
|
|
227
|
+
${current.typography || ""}
|
|
228
|
+
|
|
229
|
+
# Component Style
|
|
230
|
+
${current.componentStyle || ""}
|
|
231
|
+
|
|
232
|
+
# References
|
|
233
|
+
${current.references || ""}
|
|
234
|
+
`;
|
|
235
|
+
writeFileSync(notesPath, markdown, "utf-8");
|
|
236
|
+
writeln(chalk.green("✓ Saved to .stoa/moodboard/notes.md"));
|
|
237
|
+
// Auto-sync tokens
|
|
238
|
+
try {
|
|
239
|
+
syncMoodboard(projectDir);
|
|
240
|
+
writeln(chalk.green("✓ Synced tokens.json"));
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
// Non-critical
|
|
244
|
+
}
|
|
245
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
function writeln(text = "") {
|
|
3
|
+
process.stdout.write(text + "\n");
|
|
4
|
+
}
|
|
5
|
+
function clearLines(count) {
|
|
6
|
+
for (let i = 0; i < count; i++) {
|
|
7
|
+
process.stdout.write("\x1B[1A\x1B[2K");
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
function renderPreview(entry) {
|
|
11
|
+
const { preset } = entry;
|
|
12
|
+
const lines = [];
|
|
13
|
+
const c = preset.colors;
|
|
14
|
+
// Color swatches line
|
|
15
|
+
const colorPairs = [
|
|
16
|
+
["Bg", c.background],
|
|
17
|
+
["Primary", c.primary],
|
|
18
|
+
["Text", c.text],
|
|
19
|
+
["Border", c.border],
|
|
20
|
+
].filter(([, v]) => v);
|
|
21
|
+
const colorLine = colorPairs
|
|
22
|
+
.map(([label, hex]) => `${label}: ${chalk.hex(hex)("██")} ${chalk.dim(hex)}`)
|
|
23
|
+
.join(" ");
|
|
24
|
+
lines.push(` ${colorLine}`);
|
|
25
|
+
lines.push(` ${chalk.dim(preset.typography.split(".")[0])}`);
|
|
26
|
+
lines.push(` ${chalk.dim(preset.componentStyle.split(".")[0])}`);
|
|
27
|
+
lines.push(` ${chalk.dim(`Like: ${preset.references}`)}`);
|
|
28
|
+
return lines;
|
|
29
|
+
}
|
|
30
|
+
function render(entries, selected) {
|
|
31
|
+
const lines = [];
|
|
32
|
+
lines.push("");
|
|
33
|
+
lines.push(chalk.bold("Choose a style preset:"));
|
|
34
|
+
lines.push("");
|
|
35
|
+
let builtinDone = false;
|
|
36
|
+
for (let i = 0; i < entries.length; i++) {
|
|
37
|
+
const entry = entries[i];
|
|
38
|
+
// Separator between built-in and custom
|
|
39
|
+
if (entry.isCustom && !builtinDone) {
|
|
40
|
+
builtinDone = true;
|
|
41
|
+
lines.push(" " + chalk.dim("─".repeat(40)));
|
|
42
|
+
}
|
|
43
|
+
const isSelected = i === selected;
|
|
44
|
+
const marker = isSelected ? chalk.cyan("● ") : " ";
|
|
45
|
+
const name = isSelected ? chalk.cyan.bold(entry.preset.name) : entry.preset.name;
|
|
46
|
+
const desc = chalk.dim(entry.preset.description);
|
|
47
|
+
const tag = entry.isCustom ? chalk.dim(" (custom)") : "";
|
|
48
|
+
lines.push(` ${marker}${name} ${desc}${tag}`);
|
|
49
|
+
// Show preview for selected
|
|
50
|
+
if (isSelected) {
|
|
51
|
+
const preview = renderPreview(entry);
|
|
52
|
+
lines.push(...preview);
|
|
53
|
+
lines.push("");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
lines.push(chalk.dim(" ↑↓ navigate Enter apply q cancel"));
|
|
57
|
+
lines.push("");
|
|
58
|
+
for (const line of lines) {
|
|
59
|
+
writeln(line);
|
|
60
|
+
}
|
|
61
|
+
return lines.length;
|
|
62
|
+
}
|
|
63
|
+
function waitForKey() {
|
|
64
|
+
return new Promise((resolve) => {
|
|
65
|
+
const { stdin } = process;
|
|
66
|
+
if (!stdin.isTTY || typeof stdin.setRawMode !== "function") {
|
|
67
|
+
resolve("q");
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const wasRaw = stdin.isRaw;
|
|
71
|
+
stdin.setRawMode(true);
|
|
72
|
+
stdin.resume();
|
|
73
|
+
stdin.once("data", (data) => {
|
|
74
|
+
stdin.setRawMode(wasRaw);
|
|
75
|
+
stdin.pause();
|
|
76
|
+
resolve(data.toString());
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
export async function pickPreset(entries) {
|
|
81
|
+
if (entries.length === 0) {
|
|
82
|
+
writeln(chalk.red("No presets found."));
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
if (!process.stdin.isTTY) {
|
|
86
|
+
writeln(chalk.red("Interactive preset picker requires a TTY terminal."));
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
let selected = 0;
|
|
90
|
+
let lastLineCount = 0;
|
|
91
|
+
// Initial render
|
|
92
|
+
lastLineCount = render(entries, selected);
|
|
93
|
+
while (true) {
|
|
94
|
+
const key = await waitForKey();
|
|
95
|
+
// Ctrl+C
|
|
96
|
+
if (key === "\x03") {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
// q to cancel
|
|
100
|
+
if (key === "q") {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
// Enter to select
|
|
104
|
+
if (key === "\r" || key === "\n") {
|
|
105
|
+
return entries[selected];
|
|
106
|
+
}
|
|
107
|
+
// Arrow keys (escape sequences)
|
|
108
|
+
if (key === "\x1B[A" || key === "k") {
|
|
109
|
+
// Up
|
|
110
|
+
if (selected > 0) {
|
|
111
|
+
selected--;
|
|
112
|
+
clearLines(lastLineCount);
|
|
113
|
+
lastLineCount = render(entries, selected);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
else if (key === "\x1B[B" || key === "j") {
|
|
117
|
+
// Down
|
|
118
|
+
if (selected < entries.length - 1) {
|
|
119
|
+
selected++;
|
|
120
|
+
clearLines(lastLineCount);
|
|
121
|
+
lastLineCount = render(entries, selected);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -3,9 +3,9 @@ import { Command } from "commander";
|
|
|
3
3
|
import chalk from "chalk";
|
|
4
4
|
import ora from "ora";
|
|
5
5
|
import { readFile, writeFile, mkdir, access } from "node:fs/promises";
|
|
6
|
-
import { basename, join } from "node:path";
|
|
6
|
+
import { basename, join, extname } from "node:path";
|
|
7
7
|
import { homedir } from "node:os";
|
|
8
|
-
import { existsSync, readFileSync, constants } from "node:fs";
|
|
8
|
+
import { existsSync, readFileSync, readdirSync, constants } from "node:fs";
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
10
10
|
import { refinePipeline } from "./core/refine.js";
|
|
11
11
|
import { computeSpecScore } from "./core/spec-score.js";
|
|
@@ -28,7 +28,10 @@ import { scanProject } from "./storage/project-scan.js";
|
|
|
28
28
|
import { syncMoodboard } from "./storage/moodboard-sync.js";
|
|
29
29
|
import { describeMoodboard } from "./storage/moodboard-describe.js";
|
|
30
30
|
import { runSpecScenarios } from "./cli/scenarios-runner.js";
|
|
31
|
-
import { spawn } from "node:child_process";
|
|
31
|
+
import { spawn, execFileSync } from "node:child_process";
|
|
32
|
+
import { listPresets, loadPreset, applyPreset, savePreset } from "./storage/moodboard-presets.js";
|
|
33
|
+
import { pickPreset } from "./cli/moodboard-picker.js";
|
|
34
|
+
import { runMoodboardEdit } from "./cli/moodboard-edit.js";
|
|
32
35
|
// ── Constants ─────────────────────────────────────────────────────────
|
|
33
36
|
const STAGE_NAMES = ["clarify", "structure", "score", "harden", "finalize"];
|
|
34
37
|
const STAGE_NAME_TO_NUMBER = {
|
|
@@ -120,6 +123,24 @@ function maskApiKey(value) {
|
|
|
120
123
|
return value;
|
|
121
124
|
return value.slice(0, 3) + "..." + value.slice(-4);
|
|
122
125
|
}
|
|
126
|
+
function detectEditor() {
|
|
127
|
+
// Prefer cursor → code → $EDITOR → open (macOS) → nano
|
|
128
|
+
try {
|
|
129
|
+
execFileSync("which", ["cursor"], { stdio: "ignore" });
|
|
130
|
+
return "cursor";
|
|
131
|
+
}
|
|
132
|
+
catch { /* */ }
|
|
133
|
+
try {
|
|
134
|
+
execFileSync("which", ["code"], { stdio: "ignore" });
|
|
135
|
+
return "code";
|
|
136
|
+
}
|
|
137
|
+
catch { /* */ }
|
|
138
|
+
if (process.env.EDITOR)
|
|
139
|
+
return process.env.EDITOR;
|
|
140
|
+
if (process.platform === "darwin")
|
|
141
|
+
return "open";
|
|
142
|
+
return "nano";
|
|
143
|
+
}
|
|
123
144
|
function copyToClipboard(text) {
|
|
124
145
|
try {
|
|
125
146
|
const child = spawn("pbcopy", { stdio: ["pipe", "ignore", "ignore"] });
|
|
@@ -568,7 +589,8 @@ program
|
|
|
568
589
|
process.stderr.write(chalk.red(`File not found: ${relativePath}. Run 'stoa init' first.`) + "\n");
|
|
569
590
|
process.exit(1);
|
|
570
591
|
}
|
|
571
|
-
|
|
592
|
+
// Detect best editor: cursor → code → $EDITOR → open (macOS) → nano
|
|
593
|
+
const editor = detectEditor();
|
|
572
594
|
const child = spawn(editor, [fullPath], { stdio: "inherit" });
|
|
573
595
|
child.on("exit", (code) => {
|
|
574
596
|
process.exit(code ?? 0);
|
|
@@ -995,7 +1017,119 @@ scenariosCmd
|
|
|
995
1017
|
// ── stoa moodboard ──────────────────────────────────────────────────
|
|
996
1018
|
const moodboardCmd = program
|
|
997
1019
|
.command("moodboard")
|
|
998
|
-
.description("Manage project moodboard (.stoa/moodboard/)")
|
|
1020
|
+
.description("Manage project moodboard (.stoa/moodboard/)")
|
|
1021
|
+
.action(() => {
|
|
1022
|
+
// No subcommand → show status
|
|
1023
|
+
const cwd = process.cwd();
|
|
1024
|
+
const notesPath = join(cwd, ".stoa", "moodboard", "notes.md");
|
|
1025
|
+
const tokensPath = join(cwd, ".stoa", "moodboard", "tokens.json");
|
|
1026
|
+
if (!existsSync(join(cwd, ".stoa"))) {
|
|
1027
|
+
writeln(chalk.red("No .stoa/ directory. Run 'stoa init' first."));
|
|
1028
|
+
process.exit(1);
|
|
1029
|
+
}
|
|
1030
|
+
writeln();
|
|
1031
|
+
writeln(chalk.bold("Moodboard Status"));
|
|
1032
|
+
writeln();
|
|
1033
|
+
// Check notes.md
|
|
1034
|
+
if (existsSync(notesPath)) {
|
|
1035
|
+
const raw = readFileSync(notesPath, "utf-8");
|
|
1036
|
+
const stripped = raw.replace(/<!--[\s\S]*?-->/g, "").replace(/^#.*$/gm, "").trim();
|
|
1037
|
+
if (stripped.length > 0) {
|
|
1038
|
+
// Extract design direction (first non-empty section)
|
|
1039
|
+
const dirMatch = raw.match(/# Design Direction\n([^\n#]+)/);
|
|
1040
|
+
if (dirMatch) {
|
|
1041
|
+
writeln(` Style: ${chalk.cyan(dirMatch[1].trim())}`);
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
else {
|
|
1045
|
+
writeln(` Style: ${chalk.dim("(empty — run 'stoa moodboard preset' to set one)")}`);
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
else {
|
|
1049
|
+
writeln(` Style: ${chalk.dim("(no notes.md)")}`);
|
|
1050
|
+
}
|
|
1051
|
+
// Check tokens
|
|
1052
|
+
if (existsSync(tokensPath)) {
|
|
1053
|
+
try {
|
|
1054
|
+
const tokens = JSON.parse(readFileSync(tokensPath, "utf-8"));
|
|
1055
|
+
const colorCount = tokens.colors ? Object.keys(tokens.colors).length : 0;
|
|
1056
|
+
writeln(` Colors: ${colorCount > 0 ? chalk.green(`${colorCount} defined`) : chalk.dim("none")}`);
|
|
1057
|
+
}
|
|
1058
|
+
catch {
|
|
1059
|
+
writeln(` Tokens: ${chalk.dim("invalid")}`);
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
else {
|
|
1063
|
+
writeln(` Tokens: ${chalk.dim("not synced")}`);
|
|
1064
|
+
}
|
|
1065
|
+
// Check images
|
|
1066
|
+
const moodboardDir = join(cwd, ".stoa", "moodboard");
|
|
1067
|
+
if (existsSync(moodboardDir)) {
|
|
1068
|
+
try {
|
|
1069
|
+
const files = readdirSync(moodboardDir);
|
|
1070
|
+
const imageExts = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]);
|
|
1071
|
+
const imageCount = files.filter((f) => imageExts.has(extname(f).toLowerCase())).length;
|
|
1072
|
+
writeln(` Images: ${imageCount > 0 ? chalk.green(`${imageCount} screenshot(s)`) : chalk.dim("none")}`);
|
|
1073
|
+
}
|
|
1074
|
+
catch {
|
|
1075
|
+
// ignore
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
writeln();
|
|
1079
|
+
writeln(chalk.bold("Commands:"));
|
|
1080
|
+
writeln(` ${chalk.cyan("stoa moodboard preset")} Switch style preset`);
|
|
1081
|
+
writeln(` ${chalk.cyan("stoa moodboard edit")} Edit interactively`);
|
|
1082
|
+
writeln(` ${chalk.cyan("stoa moodboard describe")} Extract design from screenshots`);
|
|
1083
|
+
writeln(` ${chalk.cyan("stoa moodboard sync")} Regenerate tokens.json`);
|
|
1084
|
+
writeln(` ${chalk.cyan("stoa moodboard save-preset")} Save current as reusable preset`);
|
|
1085
|
+
writeln(` ${chalk.cyan("stoa edit moodboard")} Open in editor`);
|
|
1086
|
+
writeln();
|
|
1087
|
+
});
|
|
1088
|
+
moodboardCmd
|
|
1089
|
+
.command("preset")
|
|
1090
|
+
.description("Choose a style preset for your moodboard")
|
|
1091
|
+
.argument("[name]", "Preset name (omit for interactive picker)")
|
|
1092
|
+
.action(async (name) => {
|
|
1093
|
+
const cwd = process.cwd();
|
|
1094
|
+
if (name) {
|
|
1095
|
+
// Direct apply by name
|
|
1096
|
+
const preset = loadPreset(cwd, name);
|
|
1097
|
+
if (!preset) {
|
|
1098
|
+
const available = listPresets(cwd).map((e) => e.id).join(", ");
|
|
1099
|
+
process.stderr.write(chalk.red(`Preset "${name}" not found. Available: ${available}`) + "\n");
|
|
1100
|
+
process.exit(1);
|
|
1101
|
+
}
|
|
1102
|
+
applyPreset(cwd, preset);
|
|
1103
|
+
writeln(chalk.green(`Applied "${preset.name}" preset.`));
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
// Interactive picker
|
|
1107
|
+
const entries = listPresets(cwd);
|
|
1108
|
+
if (entries.length === 0) {
|
|
1109
|
+
writeln(chalk.red("No presets found."));
|
|
1110
|
+
process.exit(1);
|
|
1111
|
+
}
|
|
1112
|
+
const selected = await pickPreset(entries);
|
|
1113
|
+
if (!selected) {
|
|
1114
|
+
writeln(chalk.dim("Cancelled."));
|
|
1115
|
+
return;
|
|
1116
|
+
}
|
|
1117
|
+
applyPreset(cwd, selected.preset);
|
|
1118
|
+
writeln(chalk.green(`Applied "${selected.preset.name}" preset.`));
|
|
1119
|
+
writeln(chalk.dim("Run 'stoa moodboard edit' to customize, or 'stoa refine' to use it."));
|
|
1120
|
+
});
|
|
1121
|
+
moodboardCmd
|
|
1122
|
+
.command("edit")
|
|
1123
|
+
.description("Edit moodboard interactively in the terminal")
|
|
1124
|
+
.action(async () => {
|
|
1125
|
+
try {
|
|
1126
|
+
await runMoodboardEdit(process.cwd());
|
|
1127
|
+
}
|
|
1128
|
+
catch (err) {
|
|
1129
|
+
process.stderr.write(chalk.red(err instanceof Error ? err.message : String(err)) + "\n");
|
|
1130
|
+
process.exit(1);
|
|
1131
|
+
}
|
|
1132
|
+
});
|
|
999
1133
|
moodboardCmd
|
|
1000
1134
|
.command("sync")
|
|
1001
1135
|
.description("Parse notes.md and generate tokens.json")
|
|
@@ -1048,6 +1182,28 @@ moodboardCmd
|
|
|
1048
1182
|
else {
|
|
1049
1183
|
mode = "clipboard";
|
|
1050
1184
|
}
|
|
1185
|
+
// Open moodboard folder so user can drop screenshots
|
|
1186
|
+
const moodboardDir = join(process.cwd(), ".stoa", "moodboard");
|
|
1187
|
+
if (existsSync(moodboardDir)) {
|
|
1188
|
+
writeln(chalk.cyan("Opening moodboard folder — drop your screenshots in..."));
|
|
1189
|
+
if (process.platform === "darwin") {
|
|
1190
|
+
spawn("open", [moodboardDir], { stdio: "ignore", detached: true });
|
|
1191
|
+
}
|
|
1192
|
+
else {
|
|
1193
|
+
spawn("xdg-open", [moodboardDir], { stdio: "ignore", detached: true });
|
|
1194
|
+
}
|
|
1195
|
+
// Wait for user to confirm
|
|
1196
|
+
if (process.stdin.isTTY) {
|
|
1197
|
+
const { createInterface } = await import("node:readline");
|
|
1198
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
1199
|
+
await new Promise((resolve) => {
|
|
1200
|
+
rl.question(chalk.dim("Press Enter when ready → "), () => {
|
|
1201
|
+
rl.close();
|
|
1202
|
+
resolve();
|
|
1203
|
+
});
|
|
1204
|
+
});
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1051
1207
|
const spinner = mode === "api" ? ora({ text: "Analyzing screenshots...", color: "cyan" }) : null;
|
|
1052
1208
|
try {
|
|
1053
1209
|
if (spinner)
|
|
@@ -1089,6 +1245,21 @@ moodboardCmd
|
|
|
1089
1245
|
process.exit(1);
|
|
1090
1246
|
}
|
|
1091
1247
|
});
|
|
1248
|
+
moodboardCmd
|
|
1249
|
+
.command("save-preset")
|
|
1250
|
+
.description("Save current moodboard as a reusable preset")
|
|
1251
|
+
.argument("<name>", "Name for the preset")
|
|
1252
|
+
.action((name) => {
|
|
1253
|
+
try {
|
|
1254
|
+
const preset = savePreset(process.cwd(), name);
|
|
1255
|
+
writeln(chalk.green(`Saved preset "${preset.name}" to .stoa/presets/${name}.json`));
|
|
1256
|
+
writeln(chalk.dim("Use 'stoa moodboard preset' to apply it in other projects."));
|
|
1257
|
+
}
|
|
1258
|
+
catch (err) {
|
|
1259
|
+
process.stderr.write(chalk.red(err instanceof Error ? err.message : String(err)) + "\n");
|
|
1260
|
+
process.exit(1);
|
|
1261
|
+
}
|
|
1262
|
+
});
|
|
1092
1263
|
// ── stoa review ──────────────────────────────────────────────────────
|
|
1093
1264
|
program
|
|
1094
1265
|
.command("review")
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export interface PresetColors {
|
|
2
|
+
background: string;
|
|
3
|
+
surface: string;
|
|
4
|
+
border: string;
|
|
5
|
+
text: string;
|
|
6
|
+
text_secondary: string;
|
|
7
|
+
primary: string;
|
|
8
|
+
primary_hover: string;
|
|
9
|
+
[key: string]: string;
|
|
10
|
+
}
|
|
11
|
+
export interface MoodboardPreset {
|
|
12
|
+
name: string;
|
|
13
|
+
description: string;
|
|
14
|
+
designDirection: string;
|
|
15
|
+
colors: PresetColors;
|
|
16
|
+
typography: string;
|
|
17
|
+
layout: string;
|
|
18
|
+
componentStyle: string;
|
|
19
|
+
references: string;
|
|
20
|
+
}
|
|
21
|
+
export interface PresetEntry {
|
|
22
|
+
id: string;
|
|
23
|
+
preset: MoodboardPreset;
|
|
24
|
+
isCustom: boolean;
|
|
25
|
+
}
|
|
26
|
+
export declare function listPresets(projectDir: string): PresetEntry[];
|
|
27
|
+
export declare function loadPreset(projectDir: string, name: string): MoodboardPreset | null;
|
|
28
|
+
export declare function presetToNotesMarkdown(preset: MoodboardPreset): string;
|
|
29
|
+
export declare function applyPreset(projectDir: string, preset: MoodboardPreset): void;
|
|
30
|
+
export declare function savePreset(projectDir: string, name: string): MoodboardPreset;
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, existsSync, writeFileSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { join, basename, extname } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { syncMoodboard } from "./moodboard-sync.js";
|
|
5
|
+
function getBuiltinPresetsDir() {
|
|
6
|
+
return fileURLToPath(new URL("../../templates/moodboard-presets/", import.meta.url));
|
|
7
|
+
}
|
|
8
|
+
function getCustomPresetsDir(projectDir) {
|
|
9
|
+
return join(projectDir, ".stoa", "presets");
|
|
10
|
+
}
|
|
11
|
+
function loadPresetFile(filePath) {
|
|
12
|
+
try {
|
|
13
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
14
|
+
return JSON.parse(raw);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export function listPresets(projectDir) {
|
|
21
|
+
const entries = [];
|
|
22
|
+
// Built-in presets
|
|
23
|
+
const builtinDir = getBuiltinPresetsDir();
|
|
24
|
+
if (existsSync(builtinDir)) {
|
|
25
|
+
const files = readdirSync(builtinDir)
|
|
26
|
+
.filter((f) => extname(f) === ".json")
|
|
27
|
+
.sort();
|
|
28
|
+
for (const file of files) {
|
|
29
|
+
const preset = loadPresetFile(join(builtinDir, file));
|
|
30
|
+
if (preset) {
|
|
31
|
+
entries.push({
|
|
32
|
+
id: basename(file, ".json"),
|
|
33
|
+
preset,
|
|
34
|
+
isCustom: false,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// Custom presets
|
|
40
|
+
const customDir = getCustomPresetsDir(projectDir);
|
|
41
|
+
if (existsSync(customDir)) {
|
|
42
|
+
const files = readdirSync(customDir)
|
|
43
|
+
.filter((f) => extname(f) === ".json")
|
|
44
|
+
.sort();
|
|
45
|
+
for (const file of files) {
|
|
46
|
+
const preset = loadPresetFile(join(customDir, file));
|
|
47
|
+
if (preset) {
|
|
48
|
+
entries.push({
|
|
49
|
+
id: basename(file, ".json"),
|
|
50
|
+
preset,
|
|
51
|
+
isCustom: true,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return entries;
|
|
57
|
+
}
|
|
58
|
+
export function loadPreset(projectDir, name) {
|
|
59
|
+
// Check custom first
|
|
60
|
+
const customPath = join(getCustomPresetsDir(projectDir), `${name}.json`);
|
|
61
|
+
if (existsSync(customPath)) {
|
|
62
|
+
return loadPresetFile(customPath);
|
|
63
|
+
}
|
|
64
|
+
// Then built-in
|
|
65
|
+
const builtinPath = join(getBuiltinPresetsDir(), `${name}.json`);
|
|
66
|
+
if (existsSync(builtinPath)) {
|
|
67
|
+
return loadPresetFile(builtinPath);
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
export function presetToNotesMarkdown(preset) {
|
|
72
|
+
const colorLines = Object.entries(preset.colors)
|
|
73
|
+
.map(([key, value]) => {
|
|
74
|
+
const label = key
|
|
75
|
+
.replace(/_/g, " ")
|
|
76
|
+
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
77
|
+
return `${label}: ${value}`;
|
|
78
|
+
})
|
|
79
|
+
.join("\n");
|
|
80
|
+
return `# Design Direction
|
|
81
|
+
${preset.designDirection}
|
|
82
|
+
|
|
83
|
+
# Colors
|
|
84
|
+
${colorLines}
|
|
85
|
+
|
|
86
|
+
# Layout
|
|
87
|
+
${preset.layout}
|
|
88
|
+
|
|
89
|
+
# Typography
|
|
90
|
+
${preset.typography}
|
|
91
|
+
|
|
92
|
+
# Component Style
|
|
93
|
+
${preset.componentStyle}
|
|
94
|
+
|
|
95
|
+
# References
|
|
96
|
+
${preset.references}
|
|
97
|
+
`;
|
|
98
|
+
}
|
|
99
|
+
export function applyPreset(projectDir, preset) {
|
|
100
|
+
const moodboardDir = join(projectDir, ".stoa", "moodboard");
|
|
101
|
+
mkdirSync(moodboardDir, { recursive: true });
|
|
102
|
+
const notesPath = join(moodboardDir, "notes.md");
|
|
103
|
+
const markdown = presetToNotesMarkdown(preset);
|
|
104
|
+
writeFileSync(notesPath, markdown, "utf-8");
|
|
105
|
+
// Auto-sync tokens
|
|
106
|
+
try {
|
|
107
|
+
syncMoodboard(projectDir);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
// Non-critical
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
export function savePreset(projectDir, name) {
|
|
114
|
+
const moodboardDir = join(projectDir, ".stoa", "moodboard");
|
|
115
|
+
const notesPath = join(moodboardDir, "notes.md");
|
|
116
|
+
if (!existsSync(notesPath)) {
|
|
117
|
+
throw new Error("No moodboard/notes.md found. Run 'stoa init' first.");
|
|
118
|
+
}
|
|
119
|
+
// Try to load tokens.json for structured data
|
|
120
|
+
const tokensPath = join(moodboardDir, "tokens.json");
|
|
121
|
+
let tokens = {};
|
|
122
|
+
if (existsSync(tokensPath)) {
|
|
123
|
+
try {
|
|
124
|
+
tokens = JSON.parse(readFileSync(tokensPath, "utf-8"));
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
// ignore
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// Parse notes.md sections
|
|
131
|
+
const raw = readFileSync(notesPath, "utf-8");
|
|
132
|
+
const sections = {};
|
|
133
|
+
const parts = raw.split(/^# /m);
|
|
134
|
+
for (const part of parts) {
|
|
135
|
+
const trimmed = part.trim();
|
|
136
|
+
if (!trimmed)
|
|
137
|
+
continue;
|
|
138
|
+
const newlineIdx = trimmed.indexOf("\n");
|
|
139
|
+
if (newlineIdx === -1)
|
|
140
|
+
continue;
|
|
141
|
+
const heading = trimmed.slice(0, newlineIdx).trim();
|
|
142
|
+
const body = trimmed.slice(newlineIdx + 1).replace(/<!--[\s\S]*?-->/g, "").trim();
|
|
143
|
+
if (body)
|
|
144
|
+
sections[heading] = body;
|
|
145
|
+
}
|
|
146
|
+
const preset = {
|
|
147
|
+
name: name.charAt(0).toUpperCase() + name.slice(1),
|
|
148
|
+
description: "Custom preset",
|
|
149
|
+
designDirection: sections["Design Direction"] ?? "",
|
|
150
|
+
colors: tokens.colors ?? {},
|
|
151
|
+
typography: sections["Typography"] ?? "",
|
|
152
|
+
layout: sections["Layout"] ?? "",
|
|
153
|
+
componentStyle: sections["Component Style"] ?? "",
|
|
154
|
+
references: sections["References"] ?? "",
|
|
155
|
+
};
|
|
156
|
+
const presetsDir = getCustomPresetsDir(projectDir);
|
|
157
|
+
mkdirSync(presetsDir, { recursive: true });
|
|
158
|
+
writeFileSync(join(presetsDir, `${name}.json`), JSON.stringify(preset, null, 2) + "\n", "utf-8");
|
|
159
|
+
return preset;
|
|
160
|
+
}
|
package/dist/storage/project.js
CHANGED
|
@@ -3,6 +3,7 @@ import { copyFile, mkdir, readdir, readFile, writeFile } from "node:fs/promises"
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { toSlug } from "../utils/slug.js";
|
|
6
|
+
import { loadPreset, applyPreset } from "./moodboard-presets.js";
|
|
6
7
|
function getTemplatesDir() {
|
|
7
8
|
return fileURLToPath(new URL("../../templates/", import.meta.url));
|
|
8
9
|
}
|
|
@@ -38,28 +39,39 @@ export async function initProject(name, type, noTemplates = false) {
|
|
|
38
39
|
await mkdir(join(stoaDir, "scenarios"), { recursive: true });
|
|
39
40
|
await mkdir(join(stoaDir, "specs"), { recursive: true });
|
|
40
41
|
await mkdir(join(stoaDir, "moodboard"), { recursive: true });
|
|
41
|
-
await
|
|
42
|
-
|
|
42
|
+
await mkdir(join(stoaDir, "presets"), { recursive: true });
|
|
43
|
+
// Apply "clean" preset as default moodboard
|
|
44
|
+
const cleanPreset = loadPreset(process.cwd(), "clean");
|
|
45
|
+
if (cleanPreset) {
|
|
46
|
+
applyPreset(process.cwd(), cleanPreset);
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
// Fallback if preset file missing
|
|
50
|
+
await writeFile(join(stoaDir, "moodboard", "notes.md"), `# Design Direction
|
|
51
|
+
Clean, modern, minimal — inspired by Linear and Vercel
|
|
43
52
|
|
|
44
53
|
# Colors
|
|
45
|
-
|
|
54
|
+
Background: #FFFFFF
|
|
55
|
+
Surface: #FAFAFA
|
|
56
|
+
Border: #E5E7EB
|
|
57
|
+
Text: #111827
|
|
58
|
+
Text Secondary: #6B7280
|
|
59
|
+
Primary: #6366F1
|
|
60
|
+
Primary Hover: #4F46E5
|
|
46
61
|
|
|
47
62
|
# Layout
|
|
48
|
-
|
|
63
|
+
Single column centered, max-width 768px. Cards for grouped content.
|
|
49
64
|
|
|
50
65
|
# Typography
|
|
51
|
-
|
|
66
|
+
Sans-serif (Inter or system font). Large bold headings, regular weight body.
|
|
52
67
|
|
|
53
68
|
# Component Style
|
|
54
|
-
|
|
69
|
+
Rounded corners (8px). Subtle borders, no heavy shadows. Filled primary buttons, ghost secondary buttons.
|
|
55
70
|
|
|
56
71
|
# References
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
# Images
|
|
60
|
-
<!-- Drop files in moodboard/ folder, describe each here -->
|
|
61
|
-
<!-- E.g. homepage-inspo.png — I want this hero layout -->
|
|
72
|
+
Linear, Notion, Vercel
|
|
62
73
|
`, "utf-8");
|
|
74
|
+
}
|
|
63
75
|
// Create context.md (brand voice + dependencies + conventions in one file)
|
|
64
76
|
await writeFile(join(stoaDir, "context.md"), `# Project Context
|
|
65
77
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Bold",
|
|
3
|
+
"description": "High contrast, strong colors, brutalist",
|
|
4
|
+
"designDirection": "Bold, high-contrast, brutalist — unapologetically opinionated",
|
|
5
|
+
"colors": {
|
|
6
|
+
"background": "#FFFFFF",
|
|
7
|
+
"surface": "#F5F5F5",
|
|
8
|
+
"border": "#000000",
|
|
9
|
+
"text": "#000000",
|
|
10
|
+
"text_secondary": "#525252",
|
|
11
|
+
"primary": "#DC2626",
|
|
12
|
+
"primary_hover": "#B91C1C"
|
|
13
|
+
},
|
|
14
|
+
"typography": "Sans-serif (Helvetica, Arial, or system). Heavy weight headings, tight letter spacing. All-caps for labels.",
|
|
15
|
+
"layout": "Single column, full-width sections. Grid-based. Tight spacing, dense information.",
|
|
16
|
+
"componentStyle": "Sharp corners (2px radius). Thick borders (2px). No shadows. High contrast buttons. Monospace for data.",
|
|
17
|
+
"references": "Bloomberg terminal, Supreme, brutalist web design"
|
|
18
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Clean",
|
|
3
|
+
"description": "White, minimal, Linear-style",
|
|
4
|
+
"designDirection": "Clean, modern, minimal — inspired by Linear and Vercel",
|
|
5
|
+
"colors": {
|
|
6
|
+
"background": "#FFFFFF",
|
|
7
|
+
"surface": "#FAFAFA",
|
|
8
|
+
"border": "#E5E7EB",
|
|
9
|
+
"text": "#111827",
|
|
10
|
+
"text_secondary": "#6B7280",
|
|
11
|
+
"primary": "#6366F1",
|
|
12
|
+
"primary_hover": "#4F46E5"
|
|
13
|
+
},
|
|
14
|
+
"typography": "Sans-serif (Inter or system font). Large bold headings, regular weight body.",
|
|
15
|
+
"layout": "Single column centered, max-width 768px. Cards for grouped content.",
|
|
16
|
+
"componentStyle": "Rounded corners (8px). Subtle borders, no heavy shadows. Filled primary buttons, ghost secondary buttons.",
|
|
17
|
+
"references": "Linear, Notion, Vercel"
|
|
18
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Dark",
|
|
3
|
+
"description": "Dark background, muted accents, like GitHub/Raycast",
|
|
4
|
+
"designDirection": "Dark, focused, minimal — inspired by Raycast and GitHub",
|
|
5
|
+
"colors": {
|
|
6
|
+
"background": "#0A0A0B",
|
|
7
|
+
"surface": "#18181B",
|
|
8
|
+
"border": "#27272A",
|
|
9
|
+
"text": "#FAFAFA",
|
|
10
|
+
"text_secondary": "#A1A1AA",
|
|
11
|
+
"primary": "#8B5CF6",
|
|
12
|
+
"primary_hover": "#7C3AED"
|
|
13
|
+
},
|
|
14
|
+
"typography": "Sans-serif (Inter or system font). High contrast text on dark backgrounds.",
|
|
15
|
+
"layout": "Single column centered, max-width 768px. Cards with subtle borders on dark surfaces.",
|
|
16
|
+
"componentStyle": "Rounded corners (8px). Faint borders, no shadows, glow accents on focus. Filled primary buttons.",
|
|
17
|
+
"references": "GitHub dark, Raycast, Arc browser"
|
|
18
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Warm",
|
|
3
|
+
"description": "Cream tones, friendly SaaS feel",
|
|
4
|
+
"designDirection": "Warm, approachable, organic — inspired by Cal.com and Stripe docs",
|
|
5
|
+
"colors": {
|
|
6
|
+
"background": "#FFFBF5",
|
|
7
|
+
"surface": "#FFF7ED",
|
|
8
|
+
"border": "#E7D5C0",
|
|
9
|
+
"text": "#1C1917",
|
|
10
|
+
"text_secondary": "#78716C",
|
|
11
|
+
"primary": "#D97706",
|
|
12
|
+
"primary_hover": "#B45309"
|
|
13
|
+
},
|
|
14
|
+
"typography": "Serif headings (Georgia or Playfair Display), sans-serif body (Inter or system). Generous line height.",
|
|
15
|
+
"layout": "Single column centered, max-width 720px. Generous whitespace. Soft grouped sections.",
|
|
16
|
+
"componentStyle": "Rounded corners (12px). Soft shadows, warm borders. Filled buttons with slight rounding. Spacious padding.",
|
|
17
|
+
"references": "Cal.com, Stripe documentation, Notion in sepia"
|
|
18
|
+
}
|