sdtk-design-kit 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  `sdtk-design-kit` is the public CLI package for SDTK-DESIGN.
4
4
 
5
- Package version in this source snapshot: `0.3.1`
5
+ Package version in this source snapshot: `0.4.0`
6
6
  CLI command: `sdtk-design`
7
7
 
8
8
  SDTK-DESIGN is a local-first MVP design planner and reviewer. It turns either a rough MVP idea or explicit SDTK-SPEC design artifacts into reviewable design docs, a static prototype, visual review evidence, and an SDTK-CODE handoff.
@@ -60,20 +60,33 @@ sdtk-design screens
60
60
  sdtk-design wireframe --screen landing
61
61
  sdtk-design system --style minimal-saas
62
62
  sdtk-design prototype
63
+ sdtk-design preview
64
+ sdtk-design styles
63
65
  sdtk-design review --artifact docs/design/prototype/index.html
64
66
  sdtk-design handoff
65
67
  sdtk-design status
66
68
  ```
67
69
 
70
+ `preview` starts a local Preview Studio (browser): it renders the prototype screens, lets you click/shift-click elements to annotate them with a note, and (via the **Tokens** panel) live-preview design-token tweaks on the `:root` CSS variables a screen declares. On "Send to agent" it shows a confirm summary, writes `docs/design/feedback/DESIGN_FEEDBACK_<timestamp>.md` (schema `sdtk.design.feedback.v1`), then a copy-ready instruction to apply it. Run the `design-prototype` skill afterward to apply that scoped feedback. The studio serves the prototype read-only, writes only under `docs/design/feedback/`, and never runs the agent itself.
71
+
72
+ `styles` opens the same studio focused on the **Style Gallery** — a visual menu of the category-oriented style presets (swatches + type sample + summary). Click a card to copy its `start --style <preset>` command. It needs no prototype (pick a style before generating) and is choose-only: it copies a command, it writes nothing.
73
+
68
74
  Visual style presets:
69
75
 
70
76
  ```text
71
- minimal-saas
72
- premium-dashboard
73
- bold-founder
74
- warm-editorial
77
+ minimal-saas SaaS & Productivity
78
+ premium-dashboard Dashboard & Data
79
+ bold-founder Marketing & Launch
80
+ warm-editorial Editorial & Content
81
+ ecommerce-retail E-Commerce & Retail
82
+ fintech-trust Fintech & Data
83
+ editorial-content Editorial & Content
75
84
  ```
76
85
 
86
+ Presets are category-oriented: pick the one closest to the product you are
87
+ designing (for example `ecommerce-retail` for a storefront). They are
88
+ unbranded SDTK-original style directions, not copies of any third-party brand.
89
+
77
90
  ## Outputs
78
91
 
79
92
  Human-facing artifacts are written under `docs/design/`, including:
@@ -85,6 +98,7 @@ DESIGN_SYSTEM.md
85
98
  DESIGN_HANDOFF.md
86
99
  prototype/index.html
87
100
  reviews/DESIGN_REVIEW_YYYYMMDD.md
101
+ feedback/DESIGN_FEEDBACK_YYYYMMDDThhmmss.md
88
102
  wireframes/
89
103
  ```
90
104
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sdtk-design-kit",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "Local-first MVP design planner and reviewer for SDTK workspaces.",
5
5
  "bin": {
6
6
  "sdtk-design": "bin/sdtk-design.js"
@@ -1,6 +1,17 @@
1
1
  ---
2
2
  name: design-prototype
3
3
  description: Generate high-fidelity standalone HTML prototypes from an SDTK-DESIGN generation manifest, design system, design tokens, and per-screen briefs.
4
+ sdtk:
5
+ preview:
6
+ entry: sdtk-design preview
7
+ parameters:
8
+ - { name: --accent, type: color }
9
+ - { name: --primary, type: color }
10
+ - { name: --surface, type: color }
11
+ - { name: --text, type: color }
12
+ - { name: --radius-card, type: spacing, min: 0, max: 24 }
13
+ - { name: --space-section, type: spacing, min: 8, max: 96 }
14
+ capabilities_required: [surgical_edit]
4
15
  ---
5
16
 
6
17
  # Design Prototype
@@ -265,6 +276,17 @@ For each screen with multiple `<nav>` elements, verify before output:
265
276
 
266
277
  If any check fails, fix the CSS before emitting the HTML file.
267
278
 
279
+ ## Consume design feedback (BK-275)
280
+
281
+ Use this when the user asks you to apply Preview Studio feedback (the `sdtk-design preview` annotate→send loop).
282
+
283
+ 1. Read the **newest** `docs/design/feedback/DESIGN_FEEDBACK_*.md` (sort by filename timestamp; the name is `DESIGN_FEEDBACK_<YYYYMMDDThhmmss>.md`). Its front-matter is `schema: sdtk.design.feedback.v1`.
284
+ 2. Parse the `<attached-preview-comments>` block. Each numbered entry names a `targetKind` (`element`, `pod`, or `token`), and — for element/pod — a `screen` (with its `file:` path), a `selector`, a `stable-id`, a `position`, and a `comment`. Pod entries list `member.N` sub-targets. **Token entries** instead name a `token` (a CSS custom property such as `--accent`), an `oldValue`, a `newValue`, a `scope: global`, and a `comment`.
285
+ 3. **Hard scope — obey it literally.** For `element`/`pod` marks, change ONLY the elements named by selector / stable-id / position, in ONLY the named screen file(s) under `docs/design/prototype/screens/`. Do NOT modify sibling screens, parent layout, global CSS, `DESIGN_SYSTEM.md`, or `DESIGN_TOKENS.json` — even if you notice issues there. Surface any out-of-scope observation as a short note in your reply instead of editing it. If a request cannot be satisfied without touching outside the scope, ask the user first.
286
+ - **`token` marks are the one deliberate exception** to "do not touch tokens": apply the named `token` change globally to its source of truth — update the value in `docs/design/DESIGN_TOKENS.json` if the token maps to a token field there, and/or the `:root { --token: … }` declaration shared by the screens. Change ONLY the named token(s) to the given `newValue`; do not retune other tokens, palettes, or unmarked screens. If a token has no clear source of truth, ask before editing.
287
+ 4. When you (re)write a screen, **stamp `data-sdtk-id="<stable id>"` on the stable structural elements you touch** (headers, cards, sections, nav, primary CTAs). This is best-effort and never required, but it lets future Preview Studio marks re-anchor by id instead of by DOM-path.
288
+ 5. After editing, keep each screen a complete standalone `<!doctype html>` document under ~1000 lines, exactly as elsewhere in this skill. Do not delete the feedback file.
289
+
268
290
  ## Boundaries
269
291
 
270
292
  - Brand, palette, typography, spacing, mood, and component direction come only from `docs/design/DESIGN_SYSTEM.md` and `docs/design/DESIGN_TOKENS.json`.
@@ -14,6 +14,8 @@ Usage:
14
14
  sdtk-design wireframe --screen landing
15
15
  sdtk-design system --style minimal-saas
16
16
  sdtk-design prototype
17
+ sdtk-design preview
18
+ sdtk-design styles
17
19
  sdtk-design review --artifact docs/design/prototype/index.html
18
20
  sdtk-design handoff
19
21
  sdtk-design status
@@ -57,6 +59,8 @@ Command status:
57
59
  wireframe Available.
58
60
  system Available.
59
61
  prototype Available SPEC-driven prototype manifest launcher.
62
+ preview Available local Preview Studio (render screens + element-level annotate to feedback).
63
+ styles Available visual Style Gallery (browse + copy a "start --style" command).
60
64
  handoff Available.
61
65
  review Available for local markdown and static HTML artifacts.
62
66
  start Available beginner facade.
@@ -0,0 +1,195 @@
1
+ "use strict";
2
+
3
+ // BK-275 — `sdtk-design preview`: serve the Preview Studio (rendered prototype
4
+ // screens + element-level annotate→feedback loop) from a local server. Reuses
5
+ // the sdtk-wiki viewer rail pattern; writes nothing except via the studio's
6
+ // /api/feedback endpoint (which lands in docs/design/feedback/).
7
+
8
+ const fs = require("fs");
9
+ const path = require("path");
10
+ const { spawn } = require("child_process");
11
+ const { parseFlags } = require("../lib/args");
12
+ const { describeDesignPaths, resolveProjectPath } = require("../lib/design-paths");
13
+ const { ValidationError } = require("../lib/errors");
14
+ const { DEFAULT_STYLE, styleCatalog } = require("../lib/style-presets");
15
+ const {
16
+ startDesignServer,
17
+ findFreePort,
18
+ waitForServer,
19
+ } = require("../lib/design-server");
20
+
21
+ const PREVIEW_FLAG_DEFS = {
22
+ help: { type: "boolean" },
23
+ "project-path": { type: "string" },
24
+ port: { type: "string" },
25
+ "no-open": { type: "boolean" },
26
+ };
27
+
28
+ const DEFAULT_HOST = "127.0.0.1";
29
+ const DEFAULT_PORT = 3210;
30
+ const STUDIO_BOOTSTRAP_MARKER = "/*__SDTK_STUDIO_BOOTSTRAP__*/";
31
+
32
+ function cmdPreviewHelp() {
33
+ console.log(`SDTK-DESIGN Preview Studio
34
+
35
+ Usage:
36
+ sdtk-design preview [--project-path <path>] [--port <n>] [--no-open]
37
+
38
+ Example:
39
+ sdtk-design preview
40
+ sdtk-design preview --port 3210 --no-open
41
+
42
+ Reads:
43
+ docs/design/prototype/.manifest.json (run sdtk-design prototype first)
44
+ docs/design/prototype/screens/*.html (skill-owned screen HTML)
45
+
46
+ In the studio:
47
+ Annotate — click/shift-click elements to mark them with a note.
48
+ Tokens — live-preview design-token tweaks on the :root CSS variables a screen
49
+ declares (no file is written); "Add changes to queue" turns them into marks.
50
+ Send to agent — shows a confirm summary, writes the feedback file, then a
51
+ copy-ready instruction to apply it (the studio never runs the agent itself).
52
+
53
+ Creates (only via the in-browser "Send to agent" action):
54
+ docs/design/feedback/DESIGN_FEEDBACK_<timestamp>.md (schema sdtk.design.feedback.v1)
55
+
56
+ Safety:
57
+ Local server on 127.0.0.1 only; no external network.
58
+ Serves the prototype directory read-only; writes only under docs/design/feedback.
59
+ Never shells out to an agent; token live-preview is in-browser only (no file write).
60
+ Does not generate or modify screen HTML, the design system, tokens, .sdtk/atlas, or SDTK-WIKI output.
61
+ In WSL2/Docker, use --no-open and tunnel the printed URL (e.g. cloudflared).`);
62
+ return 0;
63
+ }
64
+
65
+ function loadStudioHtml(manifest) {
66
+ const assetPath = path.join(__dirname, "..", "..", "assets", "design-studio.html");
67
+ const template = fs.readFileSync(assetPath, "utf-8");
68
+ // Escape "<" so a screen title containing "</script>" (or "<!--") cannot break
69
+ // out of the inline <script> the bootstrap is injected into.
70
+ const json = JSON.stringify({
71
+ schema: "sdtk.design.feedback.v1",
72
+ manifest: { screens: (manifest.screens || []).map((s) => ({ screenId: s.screenId, title: s.title || s.screenId, role: s.role || "" })) },
73
+ }).replace(/</g, "\\u003c");
74
+ const bootstrap = `window.__SDTK_STUDIO__ = ${json};`;
75
+ // Use a function replacer so "$" sequences in the JSON are inserted literally
76
+ // (String.replace honors $& / $` / $' patterns in a string replacement).
77
+ if (template.includes(STUDIO_BOOTSTRAP_MARKER)) {
78
+ return template.replace(STUDIO_BOOTSTRAP_MARKER, () => bootstrap);
79
+ }
80
+ // Defensive: if the marker is missing, inject before </head> so the studio still boots.
81
+ return template.replace("</head>", () => `<script>${bootstrap}</script>\n</head>`);
82
+ }
83
+
84
+ function tryOpenBrowser(url) {
85
+ const platform = process.platform;
86
+ const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
87
+ const args = platform === "win32" ? ["/c", "start", "", url] : [url];
88
+ try {
89
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
90
+ child.on("error", () => {});
91
+ child.unref();
92
+ } catch (_) {
93
+ // Best-effort only; non-fatal (WSL2/Docker has no browser — use the printed URL).
94
+ }
95
+ }
96
+
97
+ async function runDesignPreview({ projectPath, port, noOpen = false }) {
98
+ const resolvedProjectPath = resolveProjectPath(projectPath || process.cwd());
99
+ if (!fs.existsSync(resolvedProjectPath) || !fs.statSync(resolvedProjectPath).isDirectory()) {
100
+ throw new ValidationError(`--project-path is not a valid directory: ${resolvedProjectPath}. No server started.`);
101
+ }
102
+ const paths = describeDesignPaths(resolvedProjectPath);
103
+ if (!fs.existsSync(paths.prototypeManifestPath)) {
104
+ throw new ValidationError(
105
+ "Missing docs/design/prototype/.manifest.json. Run sdtk-design prototype first. No server started."
106
+ );
107
+ }
108
+ let manifest;
109
+ try {
110
+ manifest = JSON.parse(fs.readFileSync(paths.prototypeManifestPath, "utf-8"));
111
+ } catch (err) {
112
+ throw new ValidationError(`Could not read prototype manifest: ${err.message}. No server started.`);
113
+ }
114
+ const screenCount = Array.isArray(manifest.screens) ? manifest.screens.length : 0;
115
+
116
+ const studioHtml = loadStudioHtml(manifest);
117
+
118
+ let chosenPort = DEFAULT_PORT;
119
+ if (port !== undefined && port !== "") {
120
+ const parsed = Number.parseInt(port, 10);
121
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
122
+ throw new ValidationError(`Invalid --port: ${port}. No server started.`);
123
+ }
124
+ chosenPort = parsed;
125
+ } else {
126
+ chosenPort = await findFreePort(DEFAULT_HOST, DEFAULT_PORT);
127
+ }
128
+
129
+ // The studio reads the design-prototype skill's optional `sdtk:` front-matter
130
+ // (preview parameters) to filter which token sliders to show. Best-effort:
131
+ // the kit-bundled SKILL.md if present, else the endpoint returns {}.
132
+ const skillMetaPath = path.join(
133
+ __dirname, "..", "..", "skills", "design-prototype", "SKILL.md"
134
+ );
135
+
136
+ const { server, url } = await startDesignServer({
137
+ host: DEFAULT_HOST,
138
+ port: chosenPort,
139
+ projectPath: resolvedProjectPath,
140
+ studioHtml,
141
+ skillMetaPath: fs.existsSync(skillMetaPath) ? skillMetaPath : undefined,
142
+ styles: styleCatalog(),
143
+ defaultStyle: DEFAULT_STYLE,
144
+ });
145
+ await waitForServer(DEFAULT_HOST, chosenPort);
146
+
147
+ return { server, url, screenCount, projectPath: resolvedProjectPath, noOpen };
148
+ }
149
+
150
+ async function cmdPreview(args) {
151
+ const { flags, positionals } = parseFlags(args || [], PREVIEW_FLAG_DEFS);
152
+ if (flags.help) return cmdPreviewHelp();
153
+ if (positionals.length > 0) {
154
+ throw new ValidationError(
155
+ `Unsupported preview argument: ${positionals.join(" ")}. Use sdtk-design preview [--project-path <path>] [--port <n>] [--no-open]. No server started.`
156
+ );
157
+ }
158
+
159
+ const result = await runDesignPreview({
160
+ projectPath: flags["project-path"],
161
+ port: flags.port,
162
+ noOpen: Boolean(flags["no-open"]),
163
+ });
164
+
165
+ console.log(`[design] Preview Studio: ${result.url}`);
166
+ console.log(`[design] Serving ${result.screenCount} screen(s) from docs/design/prototype/`);
167
+ console.log("[design] Annotate elements, then \"Send to agent\" writes docs/design/feedback/DESIGN_FEEDBACK_<timestamp>.md");
168
+ console.log("[design] Then run the design-prototype skill to apply the scoped feedback.");
169
+ if (result.noOpen) {
170
+ console.log("[design] --no-open: not launching a browser. Open the URL above (tunnel it on WSL2/Docker).");
171
+ } else {
172
+ tryOpenBrowser(result.url);
173
+ }
174
+ console.log("[design] Press Ctrl+C to stop the preview server.");
175
+
176
+ return new Promise((resolve) => {
177
+ const shutdown = () => {
178
+ try { result.server.close(); } catch (_) {}
179
+ resolve(0);
180
+ };
181
+ process.on("SIGINT", shutdown);
182
+ process.on("SIGTERM", shutdown);
183
+ });
184
+ }
185
+
186
+ module.exports = {
187
+ DEFAULT_HOST,
188
+ DEFAULT_PORT,
189
+ STUDIO_BOOTSTRAP_MARKER,
190
+ cmdPreview,
191
+ cmdPreviewHelp,
192
+ loadStudioHtml,
193
+ runDesignPreview,
194
+ tryOpenBrowser,
195
+ };
@@ -0,0 +1,140 @@
1
+ "use strict";
2
+
3
+ // BK-277 P-B — `sdtk-design styles`: open the Preview Studio focused on the
4
+ // visual Style Gallery so the user can browse style directions and copy the
5
+ // `start --style <preset>` command BEFORE generating. Unlike `preview`, it does
6
+ // NOT require a prototype manifest (you pick a style first). Read-only / choose-
7
+ // only: it never generates or mutates anything — the gallery just copies a
8
+ // command to the clipboard.
9
+
10
+ const fs = require("fs");
11
+ const path = require("path");
12
+ const { parseFlags } = require("../lib/args");
13
+ const { describeDesignPaths, resolveProjectPath } = require("../lib/design-paths");
14
+ const { ValidationError } = require("../lib/errors");
15
+ const { DEFAULT_STYLE, styleCatalog } = require("../lib/style-presets");
16
+ const { startDesignServer, findFreePort, waitForServer } = require("../lib/design-server");
17
+ const { DEFAULT_HOST, DEFAULT_PORT, loadStudioHtml, tryOpenBrowser } = require("./preview");
18
+
19
+ const STYLES_FLAG_DEFS = {
20
+ help: { type: "boolean" },
21
+ "project-path": { type: "string" },
22
+ port: { type: "string" },
23
+ "no-open": { type: "boolean" },
24
+ };
25
+
26
+ function cmdStylesHelp() {
27
+ console.log(`SDTK-DESIGN Style Gallery
28
+
29
+ Usage:
30
+ sdtk-design styles [--project-path <path>] [--port <n>] [--no-open]
31
+
32
+ Example:
33
+ sdtk-design styles
34
+ sdtk-design styles --no-open
35
+
36
+ What it does:
37
+ Opens the Preview Studio focused on the visual Style Gallery. Browse the
38
+ category-oriented style presets (e.g. ecommerce-retail) as cards — swatches,
39
+ a type sample, and a summary — and click one to copy its
40
+ "sdtk-design start --idea ... --style <preset>" command. Does NOT require a
41
+ prototype; pick a style before you generate.
42
+
43
+ Reads:
44
+ Nothing required. If docs/design/prototype/.manifest.json exists, its screens
45
+ are also previewable; otherwise only the gallery is shown.
46
+
47
+ Creates:
48
+ Nothing. The gallery is choose-only — it copies a command, it does not write.
49
+
50
+ Safety:
51
+ Local server on 127.0.0.1 only; no external network. Never shells out.
52
+ In WSL2/Docker, use --no-open and tunnel the printed URL (e.g. cloudflared).`);
53
+ return 0;
54
+ }
55
+
56
+ async function runDesignStyles({ projectPath, port, noOpen = false }) {
57
+ const resolvedProjectPath = resolveProjectPath(projectPath || process.cwd());
58
+ if (!fs.existsSync(resolvedProjectPath) || !fs.statSync(resolvedProjectPath).isDirectory()) {
59
+ throw new ValidationError(`--project-path is not a valid directory: ${resolvedProjectPath}. No server started.`);
60
+ }
61
+ const paths = describeDesignPaths(resolvedProjectPath);
62
+
63
+ // The gallery works without a prototype; load screens only if a manifest exists.
64
+ let manifest = { screens: [] };
65
+ if (fs.existsSync(paths.prototypeManifestPath)) {
66
+ try {
67
+ manifest = JSON.parse(fs.readFileSync(paths.prototypeManifestPath, "utf-8"));
68
+ } catch (_) {
69
+ manifest = { screens: [] }; // a malformed manifest must not block the gallery
70
+ }
71
+ }
72
+ const studioHtml = loadStudioHtml(manifest);
73
+
74
+ let chosenPort = DEFAULT_PORT;
75
+ if (port !== undefined && port !== "") {
76
+ const parsed = Number.parseInt(port, 10);
77
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
78
+ throw new ValidationError(`Invalid --port: ${port}. No server started.`);
79
+ }
80
+ chosenPort = parsed;
81
+ } else {
82
+ chosenPort = await findFreePort(DEFAULT_HOST, DEFAULT_PORT);
83
+ }
84
+
85
+ const skillMetaPath = path.join(__dirname, "..", "..", "skills", "design-prototype", "SKILL.md");
86
+
87
+ const { server, url } = await startDesignServer({
88
+ host: DEFAULT_HOST,
89
+ port: chosenPort,
90
+ projectPath: resolvedProjectPath,
91
+ studioHtml,
92
+ skillMetaPath: fs.existsSync(skillMetaPath) ? skillMetaPath : undefined,
93
+ styles: styleCatalog(),
94
+ defaultStyle: DEFAULT_STYLE,
95
+ });
96
+ await waitForServer(DEFAULT_HOST, chosenPort);
97
+
98
+ const galleryUrl = `${url}#styles`;
99
+ return { server, url: galleryUrl, styleCount: styleCatalog().length, projectPath: resolvedProjectPath, noOpen };
100
+ }
101
+
102
+ async function cmdStyles(args) {
103
+ const { flags, positionals } = parseFlags(args || [], STYLES_FLAG_DEFS);
104
+ if (flags.help) return cmdStylesHelp();
105
+ if (positionals.length > 0) {
106
+ throw new ValidationError(
107
+ `Unsupported styles argument: ${positionals.join(" ")}. Use sdtk-design styles [--project-path <path>] [--port <n>] [--no-open]. No server started.`
108
+ );
109
+ }
110
+
111
+ const result = await runDesignStyles({
112
+ projectPath: flags["project-path"],
113
+ port: flags.port,
114
+ noOpen: Boolean(flags["no-open"]),
115
+ });
116
+
117
+ console.log(`[design] Style Gallery: ${result.url}`);
118
+ console.log(`[design] ${result.styleCount} style preset(s). Click a card to copy its start command.`);
119
+ if (result.noOpen) {
120
+ console.log("[design] --no-open: not launching a browser. Open the URL above (tunnel it on WSL2/Docker).");
121
+ } else {
122
+ tryOpenBrowser(result.url);
123
+ }
124
+ console.log("[design] Press Ctrl+C to stop the server.");
125
+
126
+ return new Promise((resolve) => {
127
+ const shutdown = () => {
128
+ try { result.server.close(); } catch (_) {}
129
+ resolve(0);
130
+ };
131
+ process.on("SIGINT", shutdown);
132
+ process.on("SIGTERM", shutdown);
133
+ });
134
+ }
135
+
136
+ module.exports = {
137
+ cmdStyles,
138
+ cmdStylesHelp,
139
+ runDesignStyles,
140
+ };
@@ -118,6 +118,9 @@ function systemContent(briefContent, screenMapContent, style) {
118
118
  "- Avoid nested cards and decorative gradient backgrounds.",
119
119
  `- Preset density rule: ${preset.density}`,
120
120
  "",
121
+ ...(preset.elevation
122
+ ? ["## Depth & Elevation", "", `- ${preset.elevation}`, ""]
123
+ : []),
121
124
  "## Table / Dashboard Density",
122
125
  "",
123
126
  `- ${preset.dashboard}`,
@@ -134,6 +137,9 @@ function systemContent(briefContent, screenMapContent, style) {
134
137
  "",
135
138
  ...linesForBullets(preset.components),
136
139
  "",
140
+ ...(preset.dosAndDonts && preset.dosAndDonts.length
141
+ ? ["## Do's and Don'ts", "", ...linesForBullets(preset.dosAndDonts), ""]
142
+ : []),
137
143
  "## Mobile Layout Rules",
138
144
  "",
139
145
  `- ${preset.mobile}`,
package/src/index.js CHANGED
@@ -4,6 +4,8 @@ const { cmdBrief } = require("./commands/brief");
4
4
  const { cmdHandoff } = require("./commands/handoff");
5
5
  const { cmdHelp } = require("./commands/help");
6
6
  const { cmdInit } = require("./commands/init");
7
+ const { cmdPreview } = require("./commands/preview");
8
+ const { cmdStyles } = require("./commands/styles");
7
9
  const { cmdPrototype } = require("./commands/prototype");
8
10
  const { cmdReview } = require("./commands/review");
9
11
  const { cmdScreens } = require("./commands/screens");
@@ -71,6 +73,12 @@ async function run(argv) {
71
73
  if (command === "prototype") {
72
74
  return cmdPrototype(args);
73
75
  }
76
+ if (command === "preview") {
77
+ return cmdPreview(args);
78
+ }
79
+ if (command === "styles") {
80
+ return cmdStyles(args);
81
+ }
74
82
  if (command === "start") {
75
83
  return cmdStart(args);
76
84
  }
@@ -0,0 +1,437 @@
1
+ "use strict";
2
+
3
+ // BK-275 — SDTK-DESIGN Preview Studio local server.
4
+ // Fork-minimal, dependency-free static server (Node stdlib only). Modeled on the
5
+ // sdtk-wiki viewer runner pattern but stripped to: static-serve the prototype
6
+ // directory, serve the single-file studio at "/" and "/studio", and accept ONE
7
+ // POST endpoint (/api/feedback) that writes a scoped feedback artifact to a
8
+ // local file. It NEVER shells out to an agent (manual-apply boundary, BK-275 D3).
9
+
10
+ const fs = require("fs");
11
+ const http = require("http");
12
+ const net = require("net");
13
+ const path = require("path");
14
+ const { describeDesignPaths, isPathInsideOrEqual } = require("./design-paths");
15
+
16
+ const JSON_HEADERS = {
17
+ "Content-Type": "application/json; charset=utf-8",
18
+ "Cache-Control": "no-cache",
19
+ };
20
+
21
+ const MIME_TYPES = {
22
+ ".html": "text/html; charset=utf-8",
23
+ ".js": "application/javascript; charset=utf-8",
24
+ ".json": "application/json; charset=utf-8",
25
+ ".css": "text/css; charset=utf-8",
26
+ ".svg": "image/svg+xml",
27
+ ".png": "image/png",
28
+ ".jpg": "image/jpeg",
29
+ ".jpeg": "image/jpeg",
30
+ ".gif": "image/gif",
31
+ ".webp": "image/webp",
32
+ ".ico": "image/x-icon",
33
+ ".woff": "font/woff",
34
+ ".woff2": "font/woff2",
35
+ ".ttf": "font/ttf",
36
+ ".md": "text/plain; charset=utf-8",
37
+ ".txt": "text/plain; charset=utf-8",
38
+ };
39
+
40
+ const MAX_BODY_BYTES = 2 * 1024 * 1024; // 2 MB cap for a feedback batch.
41
+ const HEALTH_CHECK_RETRIES = 20;
42
+ const HEALTH_CHECK_INTERVAL_MS = 150;
43
+
44
+ const HARD_SCOPE_PREAMBLE =
45
+ "Hard scope: change ONLY the elements identified below by screen / selector / stable-id / position. " +
46
+ "Do NOT modify sibling screens, parent layout, global CSS, design tokens, or unrelated rules even if you " +
47
+ "notice issues there — surface those as a follow-up note instead of editing them. If a request cannot be " +
48
+ "satisfied without touching outside this scope, ask the user before proceeding.";
49
+
50
+ function isPortOpen(host, port) {
51
+ return new Promise((resolve) => {
52
+ const socket = net.createConnection({ host, port, timeout: 500 });
53
+ socket.once("connect", () => {
54
+ socket.destroy();
55
+ resolve(true);
56
+ });
57
+ socket.once("error", () => resolve(false));
58
+ socket.once("timeout", () => {
59
+ socket.destroy();
60
+ resolve(false);
61
+ });
62
+ });
63
+ }
64
+
65
+ async function findFreePort(host, startPort) {
66
+ for (let p = startPort; p < startPort + 20; p += 1) {
67
+ const busy = await isPortOpen(host, p);
68
+ if (!busy) return p;
69
+ }
70
+ throw new Error(
71
+ `Could not find a free port in range ${startPort}-${startPort + 19}. ` +
72
+ "Stop unused preview servers or pass a different --port."
73
+ );
74
+ }
75
+
76
+ async function waitForServer(host, port) {
77
+ for (let i = 0; i < HEALTH_CHECK_RETRIES; i += 1) {
78
+ const ok = await isPortOpen(host, port);
79
+ if (ok) return;
80
+ await new Promise((r) => setTimeout(r, HEALTH_CHECK_INTERVAL_MS));
81
+ }
82
+ throw new Error(`SDTK-DESIGN preview server did not start on http://${host}:${port}`);
83
+ }
84
+
85
+ function feedbackStamp(date = new Date()) {
86
+ const pad = (n) => String(n).padStart(2, "0");
87
+ return (
88
+ `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}` +
89
+ `T${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`
90
+ );
91
+ }
92
+
93
+ function trimText(value, max) {
94
+ const text = String(value == null ? "" : value).replace(/\s+/g, " ").trim();
95
+ return text.length > max ? `${text.slice(0, max - 3)}...` : text;
96
+ }
97
+
98
+ function normalizePosition(input) {
99
+ const finite = (v) => (Number.isFinite(v) ? Math.round(v) : 0);
100
+ const pos = input || {};
101
+ return { x: finite(pos.x), y: finite(pos.y), width: finite(pos.width), height: finite(pos.height) };
102
+ }
103
+
104
+ function formatComputedStyle(style) {
105
+ if (!style || typeof style !== "object") return "";
106
+ return Object.keys(style)
107
+ .map((key) => {
108
+ const value = style[key];
109
+ return value ? `${key}: ${trimText(value, 80)}` : null;
110
+ })
111
+ .filter(Boolean)
112
+ .join("; ");
113
+ }
114
+
115
+ function renderTargetLines(target, indent = "") {
116
+ const t = target || {};
117
+ const pos = normalizePosition(t.position);
118
+ const lines = [
119
+ `${indent}selector: ${trimText(t.selector, 200) || "(none)"}`,
120
+ `${indent}label: ${trimText(t.label, 120) || "(unlabeled)"}`,
121
+ `${indent}position: x${pos.x} y${pos.y} ${pos.width}x${pos.height}`,
122
+ `${indent}currentText: ${trimText(t.currentText, 160) || "(empty)"}`,
123
+ `${indent}htmlHint: ${trimText(t.htmlHint, 200) || "(none)"}`,
124
+ `${indent}computedStyle: ${formatComputedStyle(t.computedStyle) || "(none)"}`,
125
+ ];
126
+ return lines;
127
+ }
128
+
129
+ // Pure, exported — the unit-test target. Ports the shape of Open Design's
130
+ // comments.ts#renderCommentAttachmentContext into SDTK screen/file vocabulary.
131
+ function compileFeedbackMarkdown(marks, options = {}) {
132
+ const list = Array.isArray(marks) ? marks : [];
133
+ const generatedAt = options.generatedAt || new Date().toISOString();
134
+ const manifestRelPath = options.manifestRelPath || "docs/design/prototype/.manifest.json";
135
+
136
+ const out = [];
137
+ out.push("---");
138
+ out.push("schema: sdtk.design.feedback.v1");
139
+ out.push(`generatedAt: ${generatedAt}`);
140
+ out.push(`prototypeManifest: ${manifestRelPath}`);
141
+ out.push(`markCount: ${list.length}`);
142
+ out.push("---");
143
+ out.push("");
144
+ out.push("<attached-preview-comments>");
145
+ out.push(HARD_SCOPE_PREAMBLE);
146
+
147
+ list.forEach((mark, index) => {
148
+ const rawKind = mark && mark.kind;
149
+ const kind = rawKind === "pod" ? "pod" : rawKind === "token" ? "token" : "element";
150
+
151
+ // Token marks are GLOBAL design-token changes (not anchored to one screen):
152
+ // render the token name/old/new + scope instead of an element descriptor.
153
+ if (kind === "token") {
154
+ const tokenName = trimText(mark && mark.token, 120) || `token-${index + 1}`;
155
+ out.push("");
156
+ out.push(`${index + 1}. ${tokenName}`);
157
+ out.push(` targetKind: token`);
158
+ out.push(` scope: ${trimText(mark && mark.scope, 40) || "global"}`);
159
+ out.push(` token: ${tokenName}`);
160
+ out.push(` oldValue: ${trimText(mark && mark.oldValue, 120) || "(unknown)"}`);
161
+ out.push(` newValue: ${trimText(mark && mark.newValue, 120) || "(unset)"}`);
162
+ const tNote = trimText(mark && mark.note, 600);
163
+ out.push(` comment: ${tNote || "(no note — apply the token change above)"}`);
164
+ return;
165
+ }
166
+
167
+ const screenId = trimText(mark && mark.screenId, 120) || "(unknown-screen)";
168
+ const stableId = trimText(mark && (mark.target && mark.target.stableId), 200) ||
169
+ trimText(mark && mark.stableId, 200) || `mark-${index + 1}`;
170
+ out.push("");
171
+ out.push(`${index + 1}. ${stableId}`);
172
+ out.push(` targetKind: ${kind}`);
173
+ out.push(` screen: ${screenId} (file: docs/design/prototype/screens/${screenId}.html)`);
174
+ if (kind === "pod") {
175
+ const members = Array.isArray(mark.members) ? mark.members : [];
176
+ out.push(` memberCount: ${members.length}`);
177
+ members.slice(0, 12).forEach((member, mi) => {
178
+ const mStable = trimText(member && member.stableId, 200) || `member-${mi + 1}`;
179
+ out.push(` member.${mi + 1}: ${mStable}`);
180
+ renderTargetLines(member, " ").forEach((line) => out.push(line));
181
+ });
182
+ } else {
183
+ renderTargetLines(mark && mark.target, " ").forEach((line) => out.push(line));
184
+ }
185
+ const note = trimText(mark && mark.note, 600);
186
+ out.push(` comment: ${note || "(no note — see target)"}`);
187
+ });
188
+
189
+ out.push("</attached-preview-comments>");
190
+ out.push("");
191
+ return out.join("\n");
192
+ }
193
+
194
+ // Parse the optional `sdtk:` block from a SKILL.md YAML front-matter. Tolerant
195
+ // by contract: returns {} on anything unexpected (never throws). Supports the
196
+ // documented shape only — preview.entry, parameters (inline-object list items),
197
+ // capabilities_required (inline [list]). A full YAML parser is intentionally
198
+ // NOT a dependency.
199
+ function parseInlineObject(line) {
200
+ // "- { name: accent, type: color, min: 0, max: 24 }" -> { name, type, min, max }
201
+ // Strips from the last "}" so a trailing YAML comment ("} # note") is ignored.
202
+ // NOTE: values must be comma-free scalars (the schema only uses name/type/min/max).
203
+ const body = line.replace(/^\s*-\s*\{/, "").replace(/\}[^}]*$/, "");
204
+ const obj = {};
205
+ body.split(",").forEach((pair) => {
206
+ const idx = pair.indexOf(":");
207
+ if (idx === -1) return;
208
+ const key = pair.slice(0, idx).trim();
209
+ let value = pair.slice(idx + 1).trim().replace(/^['"]|['"]$/g, "");
210
+ if (!key) return;
211
+ if ((key === "min" || key === "max") && value !== "" && Number.isFinite(Number(value))) {
212
+ value = Number(value);
213
+ }
214
+ obj[key] = value;
215
+ });
216
+ return obj;
217
+ }
218
+
219
+ function parseSkillMeta(skillMdText) {
220
+ try {
221
+ // Strip a leading UTF-8 BOM (editors add it; fs 'utf-8' does not remove it).
222
+ const text = String(skillMdText || "").replace(/^\uFEFF/, "");
223
+ const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
224
+ if (!fm) return {};
225
+ const lines = fm[1].split(/\r?\n/);
226
+ const startIdx = lines.findIndex((l) => /^sdtk:\s*$/.test(l));
227
+ if (startIdx === -1) return {};
228
+ const block = [];
229
+ for (let i = startIdx + 1; i < lines.length; i += 1) {
230
+ const line = lines[i];
231
+ if (line.trim() === "") continue;
232
+ if (/^\s/.test(line)) block.push(line);
233
+ else break; // dedented back to top level → end of sdtk block
234
+ }
235
+ const meta = {};
236
+ const parameters = [];
237
+ let inParameters = false;
238
+ for (const line of block) {
239
+ const trimmed = line.trim();
240
+ const indent = line.length - line.replace(/^\s+/, "").length;
241
+ if (/^preview:\s*$/.test(trimmed)) { inParameters = false; continue; }
242
+ const entryMatch = /^entry:\s*(.+)$/.exec(trimmed);
243
+ if (entryMatch) {
244
+ meta.preview = meta.preview || {};
245
+ meta.preview.entry = entryMatch[1].trim().replace(/^['"]|['"]$/g, "");
246
+ continue;
247
+ }
248
+ if (/^parameters:\s*$/.test(trimmed)) { inParameters = true; continue; }
249
+ const capsMatch = /^capabilities_required:\s*\[(.*)\]\s*$/.exec(trimmed);
250
+ if (capsMatch) {
251
+ meta.capabilitiesRequired = capsMatch[1]
252
+ .split(",").map((c) => c.trim().replace(/^['"]|['"]$/g, "")).filter(Boolean);
253
+ inParameters = false;
254
+ continue;
255
+ }
256
+ if (inParameters && /^-\s*\{.*\}\s*(#.*)?$/.test(trimmed) && indent >= 4) {
257
+ const obj = parseInlineObject(line);
258
+ if (obj.name) parameters.push(obj);
259
+ continue;
260
+ }
261
+ if (trimmed && !/^-/.test(trimmed) && indent <= 2) inParameters = false;
262
+ }
263
+ if (parameters.length) meta.parameters = parameters;
264
+ return meta;
265
+ } catch (_) {
266
+ return {};
267
+ }
268
+ }
269
+
270
+ function serveStatic(req, res, paths) {
271
+ let urlPath;
272
+ try {
273
+ urlPath = decodeURIComponent((req.url || "/").split("?")[0]);
274
+ } catch (_) {
275
+ // Malformed percent-encoding must not crash the server.
276
+ res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
277
+ res.end("Bad request");
278
+ return;
279
+ }
280
+ const rel = urlPath.replace(/^\/+/, "");
281
+ const candidate = path.join(paths.prototypePath, rel);
282
+ if (!isPathInsideOrEqual(candidate, paths.prototypePath)) {
283
+ res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
284
+ res.end("Not found");
285
+ return;
286
+ }
287
+ fs.stat(candidate, (err, stat) => {
288
+ if (err || !stat.isFile()) {
289
+ res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
290
+ res.end("Not found");
291
+ return;
292
+ }
293
+ const ext = path.extname(candidate).toLowerCase();
294
+ res.writeHead(200, { "Content-Type": MIME_TYPES[ext] || "application/octet-stream", "Cache-Control": "no-cache" });
295
+ fs.createReadStream(candidate).pipe(res);
296
+ });
297
+ }
298
+
299
+ function handleFeedbackPost(req, res, paths) {
300
+ let body = "";
301
+ let aborted = false;
302
+ req.on("data", (chunk) => {
303
+ if (aborted) return;
304
+ body += chunk;
305
+ if (body.length > MAX_BODY_BYTES) {
306
+ aborted = true;
307
+ res.writeHead(413, JSON_HEADERS);
308
+ res.end(JSON.stringify({ ok: false, error: "Feedback body too large." }));
309
+ req.destroy();
310
+ }
311
+ });
312
+ req.on("end", () => {
313
+ if (aborted) return;
314
+ let payload;
315
+ try {
316
+ payload = body ? JSON.parse(body) : {};
317
+ } catch (_) {
318
+ res.writeHead(400, JSON_HEADERS);
319
+ res.end(JSON.stringify({ ok: false, error: "Invalid JSON body." }));
320
+ return;
321
+ }
322
+ const marks = Array.isArray(payload.marks) ? payload.marks : [];
323
+ if (marks.length === 0) {
324
+ res.writeHead(400, JSON_HEADERS);
325
+ res.end(JSON.stringify({ ok: false, error: "No marks to send." }));
326
+ return;
327
+ }
328
+ const generatedAt = new Date();
329
+ const markdown = compileFeedbackMarkdown(marks, {
330
+ generatedAt: generatedAt.toISOString(),
331
+ manifestRelPath: "docs/design/prototype/.manifest.json",
332
+ });
333
+ const feedbackDir = path.join(paths.designDocsPath, "feedback");
334
+ const relPath = `docs/design/feedback/DESIGN_FEEDBACK_${feedbackStamp(generatedAt)}.md`;
335
+ const outPath = path.join(paths.projectPath, relPath);
336
+ if (!isPathInsideOrEqual(outPath, paths.designDocsPath)) {
337
+ res.writeHead(400, JSON_HEADERS);
338
+ res.end(JSON.stringify({ ok: false, error: "Refusing to write outside docs/design." }));
339
+ return;
340
+ }
341
+ try {
342
+ fs.mkdirSync(feedbackDir, { recursive: true });
343
+ fs.writeFileSync(outPath, `${markdown}`, "utf-8");
344
+ } catch (err) {
345
+ res.writeHead(500, JSON_HEADERS);
346
+ res.end(JSON.stringify({ ok: false, error: `Could not write feedback: ${err.message}` }));
347
+ return;
348
+ }
349
+ res.writeHead(200, JSON_HEADERS);
350
+ res.end(JSON.stringify({ ok: true, path: relPath }));
351
+ });
352
+ }
353
+
354
+ function loadSkillMeta(skillMetaPath) {
355
+ if (!skillMetaPath) return {};
356
+ try {
357
+ return parseSkillMeta(fs.readFileSync(skillMetaPath, "utf-8"));
358
+ } catch (_) {
359
+ // Missing/unreadable SKILL.md must not break the studio — fall back to {}.
360
+ return {};
361
+ }
362
+ }
363
+
364
+ function startDesignServer({ host = "127.0.0.1", port, projectPath, studioHtml, skillMetaPath, styles, defaultStyle }) {
365
+ const paths = describeDesignPaths(projectPath);
366
+ const styleCatalog = Array.isArray(styles) ? styles : [];
367
+ return new Promise((resolve, reject) => {
368
+ const server = http.createServer((req, res) => {
369
+ const method = (req.method || "GET").toUpperCase();
370
+ const pathOnly = (req.url || "/").split("?")[0];
371
+
372
+ if (pathOnly === "/" || pathOnly === "/studio" || pathOnly === "/studio/") {
373
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-cache" });
374
+ res.end(studioHtml);
375
+ return;
376
+ }
377
+ if (pathOnly === "/api/health") {
378
+ res.writeHead(200, JSON_HEADERS);
379
+ res.end(JSON.stringify({ ok: true, projectPath: paths.projectPath }));
380
+ return;
381
+ }
382
+ if (pathOnly === "/api/styles") {
383
+ if (method !== "GET") {
384
+ res.writeHead(405, JSON_HEADERS);
385
+ res.end(JSON.stringify({ ok: false, error: "Method not allowed." }));
386
+ return;
387
+ }
388
+ // Read-only style catalog for the visual gallery (display-safe fields only).
389
+ res.writeHead(200, JSON_HEADERS);
390
+ res.end(JSON.stringify({
391
+ ok: true,
392
+ defaultStyle: defaultStyle || (styleCatalog.length ? styleCatalog[0].name : null),
393
+ styles: styleCatalog,
394
+ }));
395
+ return;
396
+ }
397
+ if (pathOnly === "/api/skill-meta") {
398
+ if (method !== "GET") {
399
+ res.writeHead(405, JSON_HEADERS);
400
+ res.end(JSON.stringify({ ok: false, error: "Method not allowed." }));
401
+ return;
402
+ }
403
+ // Read-only; tolerant — never 500 on a malformed/absent SKILL.md.
404
+ res.writeHead(200, JSON_HEADERS);
405
+ res.end(JSON.stringify({ ok: true, sdtk: loadSkillMeta(skillMetaPath) }));
406
+ return;
407
+ }
408
+ if (pathOnly === "/api/feedback") {
409
+ if (method !== "POST") {
410
+ res.writeHead(405, JSON_HEADERS);
411
+ res.end(JSON.stringify({ ok: false, error: "Method not allowed." }));
412
+ return;
413
+ }
414
+ handleFeedbackPost(req, res, paths);
415
+ return;
416
+ }
417
+ serveStatic(req, res, paths);
418
+ });
419
+
420
+ server.on("error", reject);
421
+ server.listen(port, host, () => {
422
+ resolve({ server, port, url: `http://${host}:${port}/` });
423
+ });
424
+ });
425
+ }
426
+
427
+ module.exports = {
428
+ HARD_SCOPE_PREAMBLE,
429
+ MAX_BODY_BYTES,
430
+ compileFeedbackMarkdown,
431
+ feedbackStamp,
432
+ findFreePort,
433
+ isPortOpen,
434
+ parseSkillMeta,
435
+ startDesignServer,
436
+ waitForServer,
437
+ };
@@ -7,6 +7,7 @@ const DEFAULT_STYLE = "minimal-saas";
7
7
  const STYLE_PRESETS = {
8
8
  "minimal-saas": {
9
9
  label: "Minimal SaaS",
10
+ category: "SaaS & Productivity",
10
11
  summary: "Clean solo-founder SaaS default with practical hierarchy, quiet surfaces, and low-decoration components.",
11
12
  typography: [
12
13
  "Use system UI, Segoe UI, Roboto, Helvetica, Arial, sans-serif.",
@@ -36,6 +37,7 @@ const STYLE_PRESETS = {
36
37
  },
37
38
  "premium-dashboard": {
38
39
  label: "Premium Dashboard",
40
+ category: "Dashboard & Data",
39
41
  summary: "Dashboard-first MVP style with stronger density, metrics, cards, tables, and command surfaces.",
40
42
  typography: [
41
43
  "Use Inter-like system sans with tabular numerals for metrics.",
@@ -65,6 +67,7 @@ const STYLE_PRESETS = {
65
67
  },
66
68
  "bold-founder": {
67
69
  label: "Bold Founder",
70
+ category: "Marketing & Launch",
68
71
  summary: "High-contrast launch style for marketing-heavy MVPs with decisive type and CTA hierarchy.",
69
72
  typography: [
70
73
  "Use a heavy display face fallback stack: Impact, Arial Black, Inter, system-ui.",
@@ -94,6 +97,7 @@ const STYLE_PRESETS = {
94
97
  },
95
98
  "warm-editorial": {
96
99
  label: "Warm Editorial",
100
+ category: "Editorial & Content",
97
101
  summary: "Softer consultant/service-product style with restrained serif-led hierarchy and warm surfaces.",
98
102
  typography: [
99
103
  "Use Georgia, Charter, or Times New Roman for display; use system sans for UI labels and forms.",
@@ -121,6 +125,122 @@ const STYLE_PRESETS = {
121
125
  "Tag row: forest-accent labels with readable text.",
122
126
  ],
123
127
  },
128
+ "ecommerce-retail": {
129
+ label: "E-Commerce Retail",
130
+ category: "E-Commerce & Retail",
131
+ summary: "Product-forward storefront style with photography-led cards, a single confident accent for CTA and price, and a sticky cart/checkout rail.",
132
+ typography: [
133
+ "Use a clean geometric/grotesque system sans: system-ui, Segoe UI, Roboto, Helvetica, Arial, sans-serif.",
134
+ "Use a 36/26/20/15/13px hierarchy; keep product names at 15-16px medium weight and prices at 18-20px bold.",
135
+ "Use tabular numerals for prices and quantities; keep letter spacing at 0 except optional 0.06em uppercase micro-labels for badges (SALE, NEW).",
136
+ ],
137
+ colors: [
138
+ "`color-bg`: #FFFFFF for the product canvas; let photography carry the visual weight.",
139
+ "`color-surface`: #FFFFFF for product cards on a #F6F7F9 alternating page tint.",
140
+ "`color-text`: #1A1D21 for product names, prices, and primary copy.",
141
+ "`color-muted`: #6B7280 for secondary metadata, shipping notes, and review counts.",
142
+ "`color-primary`: #4338CA indigo for the add-to-cart / checkout CTA and active filters.",
143
+ "`color-accent`: #E8590C pumpkin for price emphasis, sale tags, and the wishlist heart.",
144
+ "`color-success`: #1B873F in-stock, `color-warning`: #B7791F low-stock, `color-danger`: #C2330E out-of-stock or error.",
145
+ "`color-border`: #E6E8EB for quiet card edges, dividers, and input outlines.",
146
+ ],
147
+ surface: "Use white product cards with 14-20px corner radius on a soft page tint; let product imagery sit edge-to-edge with gentle rounding. Prefer hairline borders over heavy shadows; reserve a soft lift only for the sticky cart panel.",
148
+ density: "Use a responsive product grid (2-4 columns) with 16-20px gutters and generous whitespace around hero and category bands; keep cart/line-item rows compact at 12-14px.",
149
+ cta: "One dominant filled primary CTA per surface (Add to cart / Checkout); secondary actions (wishlist, compare) stay outlined or icon-only. Keep price and CTA visually paired.",
150
+ dashboard: "For seller/order views, use order-status pills with text, a compact line-item table, and clear fulfillment states; do not borrow heavy analytics chrome onto the storefront.",
151
+ mobile: "Collapse the grid to two then one column under 760px; pin the add-to-cart / total bar to the bottom on product and cart screens; keep tap targets at least 44px.",
152
+ accessibility: "Never signal stock or price state by color alone — pair with text and icons; maintain 4.5:1 contrast on price and CTA text; keep focus rings visible on cards, filters, and quantity steppers.",
153
+ elevation: "Keep most surfaces flat with hairline borders; use a single soft shadow (0 8px 24px rgba(16,24,40,0.10)) only for the sticky cart/checkout rail and modal overlays; product cards lift only on hover, not at rest.",
154
+ dosAndDonts: [
155
+ "Do let product photography be the hero; keep chrome quiet around it.",
156
+ "Do pair price and the add-to-cart CTA so the buying decision is one glance.",
157
+ "Don't use more than one accent color; reserve the accent for price and sale signals.",
158
+ "Don't stack nested cards or decorative gradients that compete with product imagery.",
159
+ ],
160
+ components: [
161
+ "Product card: image, name, price, rating/review count, add-to-cart.",
162
+ "Cart line item: thumbnail, name, variant, quantity stepper, line total, remove.",
163
+ "Sticky order summary: subtotal, shipping, total, primary checkout CTA.",
164
+ "Filter rail: category, price range, in-stock toggle with applied-filter chips.",
165
+ ],
166
+ },
167
+ "fintech-trust": {
168
+ label: "Fintech Trust",
169
+ category: "Fintech & Data",
170
+ summary: "Trust-forward financial style with dense data surfaces, tabular numerals, a restrained palette, and strong status semantics.",
171
+ typography: [
172
+ "Use a precise system sans with tabular numerals enabled everywhere numbers appear: system-ui, Inter, Segoe UI, Roboto, sans-serif.",
173
+ "Use a 30/22/18/14/12px hierarchy with heavier labels and compact metadata; align figures on the decimal.",
174
+ "Reserve monospace for account IDs, reference numbers, and timestamps only.",
175
+ ],
176
+ colors: [
177
+ "`color-bg`: #F4F6FB for the application background.",
178
+ "`color-surface`: #FFFFFF for balance cards, statements, and tables.",
179
+ "`color-text`: #0F172A for balances, labels, and primary copy.",
180
+ "`color-muted`: #5A6678 for secondary metadata and helper text.",
181
+ "`color-primary`: #1E3A8A deep navy for primary actions and active states.",
182
+ "`color-accent`: #0E7C66 teal for positive balance highlights and one chart series.",
183
+ "`color-success`: #1B873F credit/up, `color-warning`: #B7791F pending/review, `color-danger`: #C2330E debit/down or error.",
184
+ "`color-border`: #DCE2EC for table rules, card edges, and input outlines.",
185
+ ],
186
+ surface: "Use structured white cards and tables with crisp 8-10px radius on a cool page tint; keep rules and dividers hairline. Money and status must read instantly; avoid decorative surfaces that obscure figures.",
187
+ density: "Use high density: 10-12px table row gaps, aligned numeric columns with right-aligned figures, and scannable statement rows; keep summary cards calm with one headline figure each.",
188
+ cta: "Primary CTA should be compact, explicit, and reassuring (Transfer, Confirm payment); destructive or irreversible actions require a clear confirm step.",
189
+ dashboard: "Lead with 3-4 balance/KPI cards (tabular figures + trend), then a transactions table with status pills; keep gains/losses signed and color-paired with text.",
190
+ mobile: "Stack balance cards to one column; keep transaction rows readable without horizontal scroll; pin the primary action for the active account.",
191
+ accessibility: "Never signal up/down or credit/debit by color alone — pair with sign, arrow, and text; maintain 4.5:1 contrast on figures; keep focus rings visible on dense controls and table actions.",
192
+ elevation: "Keep surfaces flat and trustworthy with hairline borders; use a single restrained shadow (0 1px 2px rgba(15,23,42,0.08)) for cards and a slightly stronger lift only for modals/confirm dialogs; avoid playful depth.",
193
+ dosAndDonts: [
194
+ "Do use tabular numerals and align figures so balances are scannable.",
195
+ "Do pair every up/down or credit/debit signal with a sign, arrow, and text.",
196
+ "Don't decorate money surfaces with gradients or imagery that reduce legibility.",
197
+ "Don't hide the active account's primary action below the fold on mobile.",
198
+ ],
199
+ components: [
200
+ "Balance card: account label, balance (tabular), signed trend, last-updated.",
201
+ "Transaction row: date, payee, category, signed amount, status pill.",
202
+ "Confirm dialog: amount, recipient, fee, irreversible-action confirm.",
203
+ "Status pill: text label plus color (cleared, pending, failed).",
204
+ ],
205
+ },
206
+ "editorial-content": {
207
+ label: "Editorial Content",
208
+ category: "Editorial & Content",
209
+ summary: "Reading-first content style with a narrow measure, serif-led hierarchy, warm surfaces, and minimal chrome for articles, blogs, and marketplace listings.",
210
+ typography: [
211
+ "Use a readable serif for headings and long-form body (Georgia, Charter, 'Iowan Old Style', Times New Roman, serif); use system sans for UI labels, nav, and forms.",
212
+ "Use a 44/30/22/18/14px hierarchy with generous line-height (1.6-1.7) for body and a comfortable 66-72ch reading measure.",
213
+ "Use serif for narrative and pull quotes; keep captions, bylines, and metadata in small sans at 13-14px.",
214
+ ],
215
+ colors: [
216
+ "`color-bg`: #FBF9F5 warm paper background.",
217
+ "`color-surface`: #FFFFFF for elevated cards and media frames.",
218
+ "`color-text`: #201C17 near-black ink for body and headings.",
219
+ "`color-muted`: #857D72 for bylines, dates, and secondary metadata.",
220
+ "`color-primary`: #9A3412 burnt-sienna for primary links, CTAs, and active nav.",
221
+ "`color-accent`: #2F5B4F forest for tags, section markers, and dividers.",
222
+ "`color-border`: rgba(32,28,23,0.12) for restrained rules and card edges.",
223
+ ],
224
+ surface: "Use warm paper backgrounds with a single centered reading column; frame media edge-to-edge with gentle rounding. Prefer rules and whitespace over cards; reserve cards for related-content and listing grids.",
225
+ density: "Use generous vertical rhythm for article body (paragraph spacing ~1em, section spacing ~2.5em); use a calm 2-3 column grid with 24px gutters for listing/related content.",
226
+ cta: "Primary CTA (Subscribe, Read more, Contact seller) uses the sienna link/fill; keep it understated and inline with the reading flow rather than dominant.",
227
+ dashboard: "For author/listing management, use readable rows and notes-first cards rather than heavy analytics chrome; surface publish status and last-edit clearly.",
228
+ mobile: "Keep the reading measure narrow and centered; stack listing cards to one column; reduce section spacing by about one third while preserving body line-height.",
229
+ accessibility: "Warm palettes still need contrast checks — keep body text at 4.5:1 on paper; never rely on sienna/forest color alone for links or tags, underline or label them; keep focus visible.",
230
+ elevation: "Stay almost flat: use hairline rules and whitespace for structure; reserve a soft shadow (0 6px 20px rgba(32,28,23,0.08)) only for related-content cards and image lightboxes.",
231
+ dosAndDonts: [
232
+ "Do hold a 66-72ch measure and generous line-height for long-form readability.",
233
+ "Do use serif for narrative and sans for UI labels and forms.",
234
+ "Don't widen body text to full-bleed or crowd paragraphs together.",
235
+ "Don't signal links by color alone on warm surfaces — underline or label them.",
236
+ ],
237
+ components: [
238
+ "Article hero: serif headline, byline, date, lead image with caption.",
239
+ "Reading body: 66-72ch measure, pull quotes, inline media with captions.",
240
+ "Listing card: media, title, short excerpt, tag row, price or read-time.",
241
+ "Tag row: forest-accent labels with readable text and clear focus.",
242
+ ],
243
+ },
124
244
  };
125
245
 
126
246
  function availableStyleNames() {
@@ -141,10 +261,50 @@ function getStylePreset(style) {
141
261
  return STYLE_PRESETS[resolveStyleName(style)];
142
262
  }
143
263
 
264
+ // Extract the 6-digit hex swatches a preset declares, in author order, so a
265
+ // visual gallery can render a color row without re-parsing the prose. Captures
266
+ // every hex token across all `colors[]` lines (a single line may declare more
267
+ // than one role, e.g. success/warning/danger), skips rgba()/named values, and
268
+ // de-duplicates while preserving first-seen order.
269
+ function paletteSwatches(preset) {
270
+ const swatches = [];
271
+ const seen = new Set();
272
+ for (const line of preset.colors || []) {
273
+ const matches = line.match(/#[0-9a-fA-F]{6}\b/g) || [];
274
+ for (const hex of matches) {
275
+ const value = hex.toUpperCase();
276
+ if (!seen.has(value)) {
277
+ seen.add(value);
278
+ swatches.push(value);
279
+ }
280
+ }
281
+ }
282
+ return swatches;
283
+ }
284
+
285
+ // Read-only serialization of the preset catalog for the (future) Style Gallery
286
+ // endpoint. Carries only display-safe fields — no file paths, no secrets.
287
+ function styleCatalog() {
288
+ return availableStyleNames().map((name) => {
289
+ const preset = STYLE_PRESETS[name];
290
+ return {
291
+ name,
292
+ label: preset.label,
293
+ category: preset.category || "General",
294
+ summary: preset.summary,
295
+ swatches: paletteSwatches(preset),
296
+ typographySample: (preset.typography && preset.typography[0]) || "",
297
+ component: (preset.components && preset.components[0]) || "",
298
+ };
299
+ });
300
+ }
301
+
144
302
  module.exports = {
145
303
  DEFAULT_STYLE,
146
304
  STYLE_PRESETS,
147
305
  availableStyleNames,
148
306
  getStylePreset,
149
307
  resolveStyleName,
308
+ paletteSwatches,
309
+ styleCatalog,
150
310
  };