cap-pro 1.0.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/.claude-plugin/README.md +26 -0
- package/.claude-plugin/marketplace.json +24 -0
- package/.claude-plugin/plugin.json +24 -0
- package/LICENSE +21 -0
- package/README.ja-JP.md +834 -0
- package/README.ko-KR.md +823 -0
- package/README.md +806 -0
- package/README.pt-BR.md +452 -0
- package/README.zh-CN.md +800 -0
- package/agents/cap-architect.md +269 -0
- package/agents/cap-brainstormer.md +207 -0
- package/agents/cap-curator.md +276 -0
- package/agents/cap-debugger.md +365 -0
- package/agents/cap-designer.md +246 -0
- package/agents/cap-historian.md +464 -0
- package/agents/cap-migrator.md +291 -0
- package/agents/cap-prototyper.md +197 -0
- package/agents/cap-validator.md +308 -0
- package/bin/install.js +5433 -0
- package/cap/bin/cap-tools.cjs +853 -0
- package/cap/bin/lib/arc-scanner.cjs +344 -0
- package/cap/bin/lib/cap-affinity-engine.cjs +862 -0
- package/cap/bin/lib/cap-anchor.cjs +228 -0
- package/cap/bin/lib/cap-annotation-writer.cjs +340 -0
- package/cap/bin/lib/cap-checkpoint.cjs +434 -0
- package/cap/bin/lib/cap-cluster-detect.cjs +945 -0
- package/cap/bin/lib/cap-cluster-display.cjs +52 -0
- package/cap/bin/lib/cap-cluster-format.cjs +245 -0
- package/cap/bin/lib/cap-cluster-helpers.cjs +295 -0
- package/cap/bin/lib/cap-cluster-io.cjs +212 -0
- package/cap/bin/lib/cap-completeness.cjs +540 -0
- package/cap/bin/lib/cap-deps.cjs +583 -0
- package/cap/bin/lib/cap-design-families.cjs +332 -0
- package/cap/bin/lib/cap-design.cjs +966 -0
- package/cap/bin/lib/cap-divergence-detector.cjs +400 -0
- package/cap/bin/lib/cap-doctor.cjs +752 -0
- package/cap/bin/lib/cap-feature-map-internals.cjs +19 -0
- package/cap/bin/lib/cap-feature-map-migrate.cjs +335 -0
- package/cap/bin/lib/cap-feature-map-monorepo.cjs +885 -0
- package/cap/bin/lib/cap-feature-map-shard.cjs +315 -0
- package/cap/bin/lib/cap-feature-map.cjs +1943 -0
- package/cap/bin/lib/cap-fitness-score.cjs +1075 -0
- package/cap/bin/lib/cap-impact-analysis.cjs +652 -0
- package/cap/bin/lib/cap-learn-review.cjs +1072 -0
- package/cap/bin/lib/cap-learning-signals.cjs +627 -0
- package/cap/bin/lib/cap-loader.cjs +227 -0
- package/cap/bin/lib/cap-logger.cjs +57 -0
- package/cap/bin/lib/cap-memory-bridge.cjs +764 -0
- package/cap/bin/lib/cap-memory-confidence.cjs +452 -0
- package/cap/bin/lib/cap-memory-dir.cjs +987 -0
- package/cap/bin/lib/cap-memory-engine.cjs +698 -0
- package/cap/bin/lib/cap-memory-extends.cjs +398 -0
- package/cap/bin/lib/cap-memory-graph.cjs +790 -0
- package/cap/bin/lib/cap-memory-migrate.cjs +2015 -0
- package/cap/bin/lib/cap-memory-pin.cjs +183 -0
- package/cap/bin/lib/cap-memory-platform.cjs +490 -0
- package/cap/bin/lib/cap-memory-prune.cjs +707 -0
- package/cap/bin/lib/cap-memory-schema.cjs +812 -0
- package/cap/bin/lib/cap-migrate-tags.cjs +309 -0
- package/cap/bin/lib/cap-migrate.cjs +540 -0
- package/cap/bin/lib/cap-pattern-apply.cjs +1203 -0
- package/cap/bin/lib/cap-pattern-pipeline.cjs +1034 -0
- package/cap/bin/lib/cap-plugin-manifest.cjs +80 -0
- package/cap/bin/lib/cap-realtime-affinity.cjs +399 -0
- package/cap/bin/lib/cap-reconcile.cjs +570 -0
- package/cap/bin/lib/cap-research-gate.cjs +218 -0
- package/cap/bin/lib/cap-scope-filter.cjs +402 -0
- package/cap/bin/lib/cap-semantic-pipeline.cjs +1038 -0
- package/cap/bin/lib/cap-session-extract.cjs +987 -0
- package/cap/bin/lib/cap-session.cjs +445 -0
- package/cap/bin/lib/cap-snapshot-linkage.cjs +963 -0
- package/cap/bin/lib/cap-stack-docs.cjs +646 -0
- package/cap/bin/lib/cap-tag-observer.cjs +371 -0
- package/cap/bin/lib/cap-tag-scanner.cjs +1766 -0
- package/cap/bin/lib/cap-telemetry.cjs +466 -0
- package/cap/bin/lib/cap-test-audit.cjs +1438 -0
- package/cap/bin/lib/cap-thread-migrator.cjs +307 -0
- package/cap/bin/lib/cap-thread-synthesis.cjs +545 -0
- package/cap/bin/lib/cap-thread-tracker.cjs +519 -0
- package/cap/bin/lib/cap-trace.cjs +399 -0
- package/cap/bin/lib/cap-trust-mode.cjs +336 -0
- package/cap/bin/lib/cap-ui-design-editor.cjs +642 -0
- package/cap/bin/lib/cap-ui-mind-map.cjs +712 -0
- package/cap/bin/lib/cap-ui-thread-nav.cjs +693 -0
- package/cap/bin/lib/cap-ui.cjs +1245 -0
- package/cap/bin/lib/cap-upgrade.cjs +1028 -0
- package/cap/bin/lib/cli/arg-helpers.cjs +49 -0
- package/cap/bin/lib/cli/frontmatter-router.cjs +31 -0
- package/cap/bin/lib/cli/init-router.cjs +68 -0
- package/cap/bin/lib/cli/phase-router.cjs +102 -0
- package/cap/bin/lib/cli/state-router.cjs +61 -0
- package/cap/bin/lib/cli/template-router.cjs +37 -0
- package/cap/bin/lib/cli/uat-router.cjs +29 -0
- package/cap/bin/lib/cli/validation-router.cjs +26 -0
- package/cap/bin/lib/cli/verification-router.cjs +31 -0
- package/cap/bin/lib/cli/workstream-router.cjs +39 -0
- package/cap/bin/lib/commands.cjs +961 -0
- package/cap/bin/lib/config.cjs +467 -0
- package/cap/bin/lib/convention-reader.cjs +258 -0
- package/cap/bin/lib/core.cjs +1241 -0
- package/cap/bin/lib/feature-aggregator.cjs +423 -0
- package/cap/bin/lib/frontmatter.cjs +337 -0
- package/cap/bin/lib/init.cjs +1443 -0
- package/cap/bin/lib/manifest-generator.cjs +383 -0
- package/cap/bin/lib/milestone.cjs +253 -0
- package/cap/bin/lib/model-profiles.cjs +69 -0
- package/cap/bin/lib/monorepo-context.cjs +226 -0
- package/cap/bin/lib/monorepo-migrator.cjs +509 -0
- package/cap/bin/lib/phase.cjs +889 -0
- package/cap/bin/lib/profile-output.cjs +989 -0
- package/cap/bin/lib/profile-pipeline.cjs +540 -0
- package/cap/bin/lib/roadmap.cjs +330 -0
- package/cap/bin/lib/security.cjs +394 -0
- package/cap/bin/lib/session-manager.cjs +292 -0
- package/cap/bin/lib/skeleton-generator.cjs +179 -0
- package/cap/bin/lib/state.cjs +1032 -0
- package/cap/bin/lib/template.cjs +231 -0
- package/cap/bin/lib/test-detector.cjs +62 -0
- package/cap/bin/lib/uat.cjs +283 -0
- package/cap/bin/lib/verify.cjs +889 -0
- package/cap/bin/lib/workspace-detector.cjs +371 -0
- package/cap/bin/lib/workstream.cjs +492 -0
- package/cap/commands/gsd/workstreams.md +63 -0
- package/cap/references/arc-standard.md +315 -0
- package/cap/references/cap-agent-architecture.md +101 -0
- package/cap/references/cap-gitignore-template +9 -0
- package/cap/references/cap-zero-deps.md +158 -0
- package/cap/references/checkpoints.md +778 -0
- package/cap/references/continuation-format.md +249 -0
- package/cap/references/contract-test-templates.md +312 -0
- package/cap/references/feature-map-template.md +25 -0
- package/cap/references/git-integration.md +295 -0
- package/cap/references/git-planning-commit.md +38 -0
- package/cap/references/model-profiles.md +174 -0
- package/cap/references/phase-numbering.md +126 -0
- package/cap/references/planning-config.md +202 -0
- package/cap/references/property-test-templates.md +316 -0
- package/cap/references/security-test-templates.md +347 -0
- package/cap/references/session-template.json +8 -0
- package/cap/references/tdd.md +263 -0
- package/cap/references/user-profiling.md +681 -0
- package/cap/references/verification-patterns.md +612 -0
- package/cap/templates/UAT.md +265 -0
- package/cap/templates/claude-md.md +175 -0
- package/cap/templates/codebase/architecture.md +255 -0
- package/cap/templates/codebase/concerns.md +310 -0
- package/cap/templates/codebase/conventions.md +307 -0
- package/cap/templates/codebase/integrations.md +280 -0
- package/cap/templates/codebase/stack.md +186 -0
- package/cap/templates/codebase/structure.md +285 -0
- package/cap/templates/codebase/testing.md +480 -0
- package/cap/templates/config.json +44 -0
- package/cap/templates/context.md +352 -0
- package/cap/templates/continue-here.md +78 -0
- package/cap/templates/copilot-instructions.md +7 -0
- package/cap/templates/debug-subagent-prompt.md +91 -0
- package/cap/templates/discussion-log.md +63 -0
- package/cap/templates/milestone-archive.md +123 -0
- package/cap/templates/milestone.md +115 -0
- package/cap/templates/phase-prompt.md +610 -0
- package/cap/templates/planner-subagent-prompt.md +117 -0
- package/cap/templates/project.md +186 -0
- package/cap/templates/requirements.md +231 -0
- package/cap/templates/research-project/ARCHITECTURE.md +204 -0
- package/cap/templates/research-project/FEATURES.md +147 -0
- package/cap/templates/research-project/PITFALLS.md +200 -0
- package/cap/templates/research-project/STACK.md +120 -0
- package/cap/templates/research-project/SUMMARY.md +170 -0
- package/cap/templates/research.md +552 -0
- package/cap/templates/roadmap.md +202 -0
- package/cap/templates/state.md +176 -0
- package/cap/templates/summary.md +364 -0
- package/cap/templates/user-preferences.md +498 -0
- package/cap/templates/verification-report.md +322 -0
- package/cap/workflows/add-phase.md +112 -0
- package/cap/workflows/add-tests.md +351 -0
- package/cap/workflows/add-todo.md +158 -0
- package/cap/workflows/audit-milestone.md +340 -0
- package/cap/workflows/audit-uat.md +109 -0
- package/cap/workflows/autonomous.md +891 -0
- package/cap/workflows/check-todos.md +177 -0
- package/cap/workflows/cleanup.md +152 -0
- package/cap/workflows/complete-milestone.md +767 -0
- package/cap/workflows/diagnose-issues.md +231 -0
- package/cap/workflows/discovery-phase.md +289 -0
- package/cap/workflows/discuss-phase-assumptions.md +653 -0
- package/cap/workflows/discuss-phase.md +1049 -0
- package/cap/workflows/do.md +104 -0
- package/cap/workflows/execute-phase.md +846 -0
- package/cap/workflows/execute-plan.md +514 -0
- package/cap/workflows/fast.md +105 -0
- package/cap/workflows/forensics.md +265 -0
- package/cap/workflows/health.md +181 -0
- package/cap/workflows/help.md +660 -0
- package/cap/workflows/insert-phase.md +130 -0
- package/cap/workflows/list-phase-assumptions.md +178 -0
- package/cap/workflows/list-workspaces.md +56 -0
- package/cap/workflows/manager.md +362 -0
- package/cap/workflows/map-codebase.md +377 -0
- package/cap/workflows/milestone-summary.md +223 -0
- package/cap/workflows/new-milestone.md +486 -0
- package/cap/workflows/new-project.md +1250 -0
- package/cap/workflows/new-workspace.md +237 -0
- package/cap/workflows/next.md +97 -0
- package/cap/workflows/node-repair.md +92 -0
- package/cap/workflows/note.md +156 -0
- package/cap/workflows/pause-work.md +176 -0
- package/cap/workflows/plan-milestone-gaps.md +273 -0
- package/cap/workflows/plan-phase.md +857 -0
- package/cap/workflows/plant-seed.md +169 -0
- package/cap/workflows/pr-branch.md +129 -0
- package/cap/workflows/profile-user.md +449 -0
- package/cap/workflows/progress.md +507 -0
- package/cap/workflows/quick.md +757 -0
- package/cap/workflows/remove-phase.md +155 -0
- package/cap/workflows/remove-workspace.md +90 -0
- package/cap/workflows/research-phase.md +82 -0
- package/cap/workflows/resume-project.md +326 -0
- package/cap/workflows/review.md +228 -0
- package/cap/workflows/session-report.md +146 -0
- package/cap/workflows/settings.md +283 -0
- package/cap/workflows/ship.md +228 -0
- package/cap/workflows/stats.md +60 -0
- package/cap/workflows/transition.md +671 -0
- package/cap/workflows/ui-phase.md +298 -0
- package/cap/workflows/ui-review.md +161 -0
- package/cap/workflows/update.md +323 -0
- package/cap/workflows/validate-phase.md +170 -0
- package/cap/workflows/verify-phase.md +254 -0
- package/cap/workflows/verify-work.md +637 -0
- package/commands/cap/annotate.md +165 -0
- package/commands/cap/brainstorm.md +393 -0
- package/commands/cap/checkpoint.md +106 -0
- package/commands/cap/completeness.md +94 -0
- package/commands/cap/continue.md +72 -0
- package/commands/cap/debug.md +588 -0
- package/commands/cap/deps.md +169 -0
- package/commands/cap/design.md +479 -0
- package/commands/cap/init.md +354 -0
- package/commands/cap/iterate.md +249 -0
- package/commands/cap/learn.md +459 -0
- package/commands/cap/memory.md +275 -0
- package/commands/cap/migrate-feature-map.md +91 -0
- package/commands/cap/migrate-memory.md +108 -0
- package/commands/cap/migrate-tags.md +91 -0
- package/commands/cap/migrate.md +131 -0
- package/commands/cap/prototype.md +510 -0
- package/commands/cap/reconcile.md +121 -0
- package/commands/cap/review.md +360 -0
- package/commands/cap/save.md +72 -0
- package/commands/cap/scan.md +404 -0
- package/commands/cap/start.md +356 -0
- package/commands/cap/status.md +118 -0
- package/commands/cap/test-audit.md +262 -0
- package/commands/cap/test.md +394 -0
- package/commands/cap/trace.md +133 -0
- package/commands/cap/ui.md +167 -0
- package/hooks/dist/cap-check-update.js +115 -0
- package/hooks/dist/cap-context-monitor.js +185 -0
- package/hooks/dist/cap-learn-review-hook.js +114 -0
- package/hooks/dist/cap-learning-hook.js +192 -0
- package/hooks/dist/cap-memory.js +299 -0
- package/hooks/dist/cap-prompt-guard.js +97 -0
- package/hooks/dist/cap-statusline.js +157 -0
- package/hooks/dist/cap-tag-observer.js +115 -0
- package/hooks/dist/cap-version-check.js +112 -0
- package/hooks/dist/cap-workflow-guard.js +175 -0
- package/hooks/hooks.json +55 -0
- package/package.json +58 -0
- package/scripts/base64-scan.sh +262 -0
- package/scripts/build-hooks.js +93 -0
- package/scripts/cap-removal-checklist.md +202 -0
- package/scripts/prompt-injection-scan.sh +199 -0
- package/scripts/run-tests.cjs +181 -0
- package/scripts/secret-scan.sh +227 -0
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
// @cap-context CAP v5 F-068 Visual Design Editor for DESIGN.md — FIRST edit capability in CAP-UI.
|
|
2
|
+
// @cap-context Respects read-only invariants from F-065..F-067 while adding DESIGN.md-specific write endpoints and UI.
|
|
3
|
+
// @cap-decision(F-068/D1) Edit scope is STRICTLY DESIGN.md. FEATURE-MAP.md and Memory stay read-only — enforced at route layer AND here (functions only accept DESIGN.md content strings / write to DESIGN.md).
|
|
4
|
+
// @cap-decision(F-068/D2) Atomic writes: temp file + fs.renameSync. Crash-safe — either the old file or the new full content exists, never a truncated mid-write state.
|
|
5
|
+
// @cap-decision(F-068/D3) Git-friendly line-level edits: applyColorEdit / applySpacingEdit / applyComponentEdit perform surgical single-line edits so `git diff` shows exactly one changed line per user edit.
|
|
6
|
+
// @cap-decision(F-068/D4) Path-traversal guard: checkContainment enforces targetPath lies inside projectRoot. Used by atomicWriteDesign AND by createSnapshot (F-065 hand-off).
|
|
7
|
+
// @cap-decision(F-068/D5) Zero external deps. No color-picker lib, no React. HTML5 <input type="color"> + <input type="range"> cover AC-2/AC-3 natively.
|
|
8
|
+
// @cap-decision(F-068/D6) UI toggled via `editable` flag. When editable=false, buildEditorSection returns empty string so byte-identical read-only snapshots remain unchanged for existing F-065 tests.
|
|
9
|
+
// @cap-constraint Zero external dependencies — node builtins only (fs, path).
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
// @cap-feature(feature:F-068) Visual Design Editor — DESIGN.md line-level edits, atomic writes, path-traversal guard, editor UI.
|
|
14
|
+
|
|
15
|
+
const fs = require('node:fs');
|
|
16
|
+
const path = require('node:path');
|
|
17
|
+
|
|
18
|
+
const designLib = require('./cap-design.cjs');
|
|
19
|
+
|
|
20
|
+
const DESIGN_FILE = 'DESIGN.md';
|
|
21
|
+
|
|
22
|
+
// --- HTML escape (local copy — same pattern as sibling modules) ------------
|
|
23
|
+
function escapeHtml(v) {
|
|
24
|
+
if (v === null || v === undefined) return '';
|
|
25
|
+
return String(v)
|
|
26
|
+
.replace(/&/g, '&')
|
|
27
|
+
.replace(/</g, '<')
|
|
28
|
+
.replace(/>/g, '>')
|
|
29
|
+
.replace(/"/g, '"')
|
|
30
|
+
.replace(/'/g, ''');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ===========================================================================
|
|
34
|
+
// Path containment + atomic write
|
|
35
|
+
// ===========================================================================
|
|
36
|
+
|
|
37
|
+
// @cap-todo(ac:F-068/AC-5) Path-traversal guard protects the atomic writer.
|
|
38
|
+
// @cap-todo(ac:F-068/AC-6) Same guard is reused by cap-ui.createSnapshot (F-065 hand-off carried into F-068).
|
|
39
|
+
/**
|
|
40
|
+
* Throw if `targetPath` escapes `projectRoot`.
|
|
41
|
+
* Uses path.resolve + a prefix check that tolerates symlinks on the project side.
|
|
42
|
+
*
|
|
43
|
+
* @param {string} projectRoot - Absolute project root directory.
|
|
44
|
+
* @param {string} targetPath - Absolute or relative path to check.
|
|
45
|
+
* @throws {Error} with message including "path traversal" when containment fails.
|
|
46
|
+
*/
|
|
47
|
+
function checkContainment(projectRoot, targetPath) {
|
|
48
|
+
if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
|
|
49
|
+
throw new Error('checkContainment: projectRoot must be a non-empty string');
|
|
50
|
+
}
|
|
51
|
+
if (typeof targetPath !== 'string' || targetPath.length === 0) {
|
|
52
|
+
throw new Error('checkContainment: targetPath must be a non-empty string');
|
|
53
|
+
}
|
|
54
|
+
const root = path.resolve(projectRoot);
|
|
55
|
+
const abs = path.resolve(root, targetPath);
|
|
56
|
+
// Both must be normalized. A `..` or symlink attack produces `abs` outside `root`.
|
|
57
|
+
// Require abs === root OR abs starts with root + separator so `/tmp/foo` does not contain `/tmp/foobar`.
|
|
58
|
+
const rootWithSep = root.endsWith(path.sep) ? root : root + path.sep;
|
|
59
|
+
if (abs !== root && !abs.startsWith(rootWithSep)) {
|
|
60
|
+
throw new Error(`path traversal: ${targetPath} resolves outside projectRoot (${root})`);
|
|
61
|
+
}
|
|
62
|
+
return abs;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// @cap-todo(ac:F-068/AC-5) atomicWriteDesign: temp-file + rename pattern so DESIGN.md is never truncated mid-write.
|
|
66
|
+
/**
|
|
67
|
+
* Atomically write `content` to <projectRoot>/DESIGN.md.
|
|
68
|
+
* @param {string} projectRoot - Absolute project root.
|
|
69
|
+
* @param {string} content - Full DESIGN.md content.
|
|
70
|
+
* @returns {{ path: string, bytes: number }} absolute path written + bytes.
|
|
71
|
+
* @throws {Error} on containment violation or write failure.
|
|
72
|
+
*/
|
|
73
|
+
function atomicWriteDesign(projectRoot, content) {
|
|
74
|
+
if (typeof content !== 'string') {
|
|
75
|
+
throw new Error('atomicWriteDesign: content must be a string');
|
|
76
|
+
}
|
|
77
|
+
const target = checkContainment(projectRoot, DESIGN_FILE);
|
|
78
|
+
// @cap-risk Temp path must also be inside projectRoot — otherwise a rename across devices would fail silently.
|
|
79
|
+
const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
|
|
80
|
+
fs.writeFileSync(tmp, content, 'utf8');
|
|
81
|
+
try {
|
|
82
|
+
fs.renameSync(tmp, target);
|
|
83
|
+
} catch (err) {
|
|
84
|
+
// Clean up the temp file on rename failure; re-throw.
|
|
85
|
+
try { fs.unlinkSync(tmp); } catch { /* ignore */ }
|
|
86
|
+
throw err;
|
|
87
|
+
}
|
|
88
|
+
return { path: target, bytes: Buffer.byteLength(content, 'utf8') };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ===========================================================================
|
|
92
|
+
// Line-level, git-friendly edit primitives (pure functions)
|
|
93
|
+
// ===========================================================================
|
|
94
|
+
|
|
95
|
+
// @cap-todo(ac:F-068/AC-2) applyColorEdit: change exactly one color token bullet line.
|
|
96
|
+
// @cap-todo(ac:F-068/AC-5) Only the matched bullet line is rewritten; all other bytes preserved.
|
|
97
|
+
/**
|
|
98
|
+
* Rewrite the color-token bullet with the given DT-NNN id to `value`.
|
|
99
|
+
* Preserves key name, trailing `(id: DT-NNN)` suffix, and surrounding whitespace.
|
|
100
|
+
*
|
|
101
|
+
* @param {string} designMdContent - Existing DESIGN.md content.
|
|
102
|
+
* @param {{id: string, value: string}} edit - `id` is the DT-NNN to locate; `value` is the new color value (e.g. "#ff6600").
|
|
103
|
+
* @returns {string} New DESIGN.md content.
|
|
104
|
+
* @throws {Error} when id is not found, value is missing, or input is not a string.
|
|
105
|
+
*/
|
|
106
|
+
function applyColorEdit(designMdContent, edit) {
|
|
107
|
+
if (typeof designMdContent !== 'string') throw new Error('applyColorEdit: content must be a string');
|
|
108
|
+
if (!edit || typeof edit.id !== 'string' || typeof edit.value !== 'string') {
|
|
109
|
+
throw new Error('applyColorEdit: edit.id and edit.value required');
|
|
110
|
+
}
|
|
111
|
+
_validateId(edit.id, /^DT-\d{3,}$/, 'DT-NNN');
|
|
112
|
+
_validateColorValue(edit.value);
|
|
113
|
+
|
|
114
|
+
const lines = designMdContent.split('\n');
|
|
115
|
+
// Match: "- key: <value> (id: DT-NNN)". Tolerant of extra whitespace in value region.
|
|
116
|
+
const target = new RegExp(`^(-\\s+[^:]+:\\s*)(.+?)(\\s*\\(id:\\s*${_escapeReg(edit.id)}\\)\\s*)$`);
|
|
117
|
+
let hit = -1;
|
|
118
|
+
for (let i = 0; i < lines.length; i++) {
|
|
119
|
+
if (target.test(lines[i])) { hit = i; break; }
|
|
120
|
+
}
|
|
121
|
+
if (hit === -1) {
|
|
122
|
+
throw new Error(`applyColorEdit: token ${edit.id} not found in DESIGN.md`);
|
|
123
|
+
}
|
|
124
|
+
lines[hit] = lines[hit].replace(target, (_m, prefix, _oldValue, suffix) => `${prefix}${edit.value}${suffix}`);
|
|
125
|
+
return lines.join('\n');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// @cap-todo(ac:F-068/AC-3) applySpacingEdit: change spacing or typography scale arrays with byte-level care.
|
|
129
|
+
/**
|
|
130
|
+
* Rewrite spacing / typography scale line with a new numeric array.
|
|
131
|
+
* Matches three supported shapes:
|
|
132
|
+
* - "- scale: [4, 8, 16]" (under ### Spacing or ### Typography)
|
|
133
|
+
* - "- family: \"...\"" (under ### Typography — renamed value)
|
|
134
|
+
* - "- familyMono: \"...\"" (under ### Typography — renamed value)
|
|
135
|
+
* The `id` field is the KIND: "spacing.scale" | "typography.scale" | "typography.family" | "typography.familyMono".
|
|
136
|
+
*
|
|
137
|
+
* @param {string} designMdContent
|
|
138
|
+
* @param {{id: string, value: string | number[]}} edit
|
|
139
|
+
* @returns {string}
|
|
140
|
+
*/
|
|
141
|
+
function applySpacingEdit(designMdContent, edit) {
|
|
142
|
+
if (typeof designMdContent !== 'string') throw new Error('applySpacingEdit: content must be a string');
|
|
143
|
+
if (!edit || typeof edit.id !== 'string') throw new Error('applySpacingEdit: edit.id required');
|
|
144
|
+
|
|
145
|
+
const lines = designMdContent.split('\n');
|
|
146
|
+
let inSpacing = false;
|
|
147
|
+
let inTypography = false;
|
|
148
|
+
|
|
149
|
+
function flushHeader(trimmed) {
|
|
150
|
+
if (trimmed === '### Spacing') { inSpacing = true; inTypography = false; return true; }
|
|
151
|
+
if (trimmed === '### Typography') { inSpacing = false; inTypography = true; return true; }
|
|
152
|
+
if (trimmed.startsWith('### ') || trimmed.startsWith('## ')) { inSpacing = false; inTypography = false; return true; }
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// @cap-todo(ac:F-068/AC-5) Exactly one line rewritten; return early after hit.
|
|
157
|
+
for (let i = 0; i < lines.length; i++) {
|
|
158
|
+
const trimmed = lines[i].trim();
|
|
159
|
+
if (flushHeader(trimmed)) continue;
|
|
160
|
+
|
|
161
|
+
if (edit.id === 'spacing.scale' && inSpacing) {
|
|
162
|
+
const m = lines[i].match(/^(-\s+scale:\s*)\[([^\]]*)\](.*)$/);
|
|
163
|
+
if (m) {
|
|
164
|
+
lines[i] = `${m[1]}[${_formatScale(edit.value)}]${m[3]}`;
|
|
165
|
+
return lines.join('\n');
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (edit.id === 'typography.scale' && inTypography) {
|
|
169
|
+
const m = lines[i].match(/^(-\s+scale:\s*)\[([^\]]*)\](.*)$/);
|
|
170
|
+
if (m) {
|
|
171
|
+
lines[i] = `${m[1]}[${_formatScale(edit.value)}]${m[3]}`;
|
|
172
|
+
return lines.join('\n');
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (edit.id === 'typography.family' && inTypography) {
|
|
176
|
+
const m = lines[i].match(/^(-\s+family:\s*)"([^"]*)"(.*)$/);
|
|
177
|
+
if (m) {
|
|
178
|
+
lines[i] = `${m[1]}"${_asString(edit.value)}"${m[3]}`;
|
|
179
|
+
return lines.join('\n');
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (edit.id === 'typography.familyMono' && inTypography) {
|
|
183
|
+
const m = lines[i].match(/^(-\s+familyMono:\s*)"([^"]*)"(.*)$/);
|
|
184
|
+
if (m) {
|
|
185
|
+
lines[i] = `${m[1]}"${_asString(edit.value)}"${m[3]}`;
|
|
186
|
+
return lines.join('\n');
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
throw new Error(`applySpacingEdit: no match for ${edit.id}`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// @cap-todo(ac:F-068/AC-4) applyComponentEdit: add or remove a variant from a component block.
|
|
194
|
+
/**
|
|
195
|
+
* Add or remove a variant from the variants list of a component (DC-NNN).
|
|
196
|
+
* @param {string} designMdContent
|
|
197
|
+
* @param {{id: string, variant: string, action: 'add'|'remove'}} edit
|
|
198
|
+
* @returns {string}
|
|
199
|
+
*/
|
|
200
|
+
function applyComponentEdit(designMdContent, edit) {
|
|
201
|
+
if (typeof designMdContent !== 'string') throw new Error('applyComponentEdit: content must be a string');
|
|
202
|
+
if (!edit || typeof edit.id !== 'string' || typeof edit.variant !== 'string' || (edit.action !== 'add' && edit.action !== 'remove')) {
|
|
203
|
+
throw new Error('applyComponentEdit: edit.id, edit.variant, edit.action required');
|
|
204
|
+
}
|
|
205
|
+
_validateId(edit.id, /^DC-\d{3,}$/, 'DC-NNN');
|
|
206
|
+
const variant = edit.variant.trim();
|
|
207
|
+
if (!/^[a-zA-Z0-9_-]{1,32}$/.test(variant)) {
|
|
208
|
+
throw new Error(`applyComponentEdit: variant "${edit.variant}" contains invalid characters (allowed: [a-zA-Z0-9_-], max 32 chars)`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const lines = designMdContent.split('\n');
|
|
212
|
+
// Locate the component header line carrying (id: DC-NNN), then find the first "- variants: [...]" line after it
|
|
213
|
+
// before the next ### / ## boundary.
|
|
214
|
+
const headerRe = new RegExp(`^###\\s+.*\\(id:\\s*${_escapeReg(edit.id)}\\)\\s*$`);
|
|
215
|
+
let headerIdx = -1;
|
|
216
|
+
for (let i = 0; i < lines.length; i++) {
|
|
217
|
+
if (headerRe.test(lines[i])) { headerIdx = i; break; }
|
|
218
|
+
}
|
|
219
|
+
if (headerIdx === -1) {
|
|
220
|
+
throw new Error(`applyComponentEdit: component ${edit.id} not found`);
|
|
221
|
+
}
|
|
222
|
+
let variantsIdx = -1;
|
|
223
|
+
for (let i = headerIdx + 1; i < lines.length; i++) {
|
|
224
|
+
const trimmed = lines[i].trim();
|
|
225
|
+
if (trimmed.startsWith('### ') || trimmed.startsWith('## ')) break;
|
|
226
|
+
if (/^-\s+variants:\s*\[/.test(lines[i])) { variantsIdx = i; break; }
|
|
227
|
+
}
|
|
228
|
+
if (variantsIdx === -1) {
|
|
229
|
+
throw new Error(`applyComponentEdit: variants list for ${edit.id} not found`);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const m = lines[variantsIdx].match(/^(-\s+variants:\s*)\[([^\]]*)\](.*)$/);
|
|
233
|
+
if (!m) throw new Error(`applyComponentEdit: could not parse variants line for ${edit.id}`);
|
|
234
|
+
|
|
235
|
+
const prefix = m[1];
|
|
236
|
+
const inside = m[2];
|
|
237
|
+
const tail = m[3];
|
|
238
|
+
const existing = inside.split(',').map(s => s.trim()).filter(s => s.length > 0);
|
|
239
|
+
|
|
240
|
+
let next;
|
|
241
|
+
if (edit.action === 'add') {
|
|
242
|
+
if (existing.includes(variant)) return designMdContent; // no-op, zero-diff
|
|
243
|
+
next = existing.concat([variant]);
|
|
244
|
+
} else {
|
|
245
|
+
if (!existing.includes(variant)) return designMdContent; // no-op
|
|
246
|
+
next = existing.filter(v => v !== variant);
|
|
247
|
+
}
|
|
248
|
+
lines[variantsIdx] = `${prefix}[${next.join(', ')}]${tail}`;
|
|
249
|
+
return lines.join('\n');
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ===========================================================================
|
|
253
|
+
// Editor UI — rendered only when `editable` is true
|
|
254
|
+
// ===========================================================================
|
|
255
|
+
|
|
256
|
+
// @cap-todo(ac:F-068/AC-1) Editor section is gated on `editable`. When false → empty string → no UI drift.
|
|
257
|
+
// @cap-todo(ac:F-068/AC-2) Color-picker per DT-NNN token with type color.
|
|
258
|
+
// @cap-todo(ac:F-068/AC-3) Range slider for spacing/typography scales.
|
|
259
|
+
// @cap-todo(ac:F-068/AC-4) Variant add/remove per DC-NNN component.
|
|
260
|
+
/**
|
|
261
|
+
* Build the DESIGN.md editor section. Returns '' when editable=false.
|
|
262
|
+
* @param {{ designMd: string|null, designData?: Object, editable: boolean }} params
|
|
263
|
+
* @returns {string} HTML
|
|
264
|
+
*/
|
|
265
|
+
function buildEditorSection(params) {
|
|
266
|
+
const editable = !!(params && params.editable);
|
|
267
|
+
if (!editable) return '';
|
|
268
|
+
const designMd = (params && params.designMd) || null;
|
|
269
|
+
if (!designMd) {
|
|
270
|
+
return `
|
|
271
|
+
<section class="cap-section" id="design-editor" data-cap-editor="1">
|
|
272
|
+
<h2>Design Editor</h2>
|
|
273
|
+
<p class="empty">No DESIGN.md found. Run /cap:design --new before editing.</p>
|
|
274
|
+
</section>`;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const ids = safeParseDesignIds(designMd);
|
|
278
|
+
const tokenRows = [];
|
|
279
|
+
// @cap-decision(F-068/D7) Only bullets recognisable as "- key: #HEX" get a color-picker.
|
|
280
|
+
// Non-hex bullets degrade to a read-only display (no widget) so /review and arbitrary string tokens still render.
|
|
281
|
+
for (const id of ids.tokens) {
|
|
282
|
+
const info = ids.byToken[id];
|
|
283
|
+
if (!info) continue;
|
|
284
|
+
const value = (info.value || '').trim();
|
|
285
|
+
const isColor = /^#[0-9a-f]{3,8}$/i.test(value);
|
|
286
|
+
if (isColor) {
|
|
287
|
+
tokenRows.push(` <tr data-editor-row="color" data-design-id="${escapeHtml(id)}">
|
|
288
|
+
<td>${escapeHtml(id)}</td>
|
|
289
|
+
<td>${escapeHtml(info.key || '')}</td>
|
|
290
|
+
<td><input type="color" value="${escapeHtml(value)}" class="de-color" data-design-id="${escapeHtml(id)}" aria-label="Color picker for ${escapeHtml(info.key || id)}"></td>
|
|
291
|
+
<td><code class="de-value" data-design-id="${escapeHtml(id)}">${escapeHtml(value)}</code></td>
|
|
292
|
+
</tr>`);
|
|
293
|
+
} else {
|
|
294
|
+
tokenRows.push(` <tr data-editor-row="token-readonly" data-design-id="${escapeHtml(id)}">
|
|
295
|
+
<td>${escapeHtml(id)}</td>
|
|
296
|
+
<td>${escapeHtml(info.key || '')}</td>
|
|
297
|
+
<td class="empty">(non-color token — edit via DESIGN.md)</td>
|
|
298
|
+
<td><code>${escapeHtml(value)}</code></td>
|
|
299
|
+
</tr>`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const compRows = [];
|
|
304
|
+
for (const id of ids.components) {
|
|
305
|
+
const info = ids.byComponent[id];
|
|
306
|
+
if (!info) continue;
|
|
307
|
+
const variants = safeExtractVariants(designMd, id);
|
|
308
|
+
const variantPills = variants.map(v =>
|
|
309
|
+
`<span class="de-variant-pill" data-variant="${escapeHtml(v)}"><code>${escapeHtml(v)}</code> <button type="button" class="de-variant-remove" data-design-id="${escapeHtml(id)}" data-variant="${escapeHtml(v)}" aria-label="Remove variant ${escapeHtml(v)}">×</button></span>`
|
|
310
|
+
).join(' ');
|
|
311
|
+
compRows.push(` <tr data-editor-row="component" data-design-id="${escapeHtml(id)}">
|
|
312
|
+
<td>${escapeHtml(id)}</td>
|
|
313
|
+
<td>${escapeHtml(info.name || '')}</td>
|
|
314
|
+
<td class="de-variants-cell" data-design-id="${escapeHtml(id)}">${variantPills || '<span class="empty">(no variants)</span>'}</td>
|
|
315
|
+
<td>
|
|
316
|
+
<input type="text" class="de-variant-input" data-design-id="${escapeHtml(id)}" placeholder="new variant" aria-label="New variant for ${escapeHtml(info.name || id)}" maxlength="32">
|
|
317
|
+
<button type="button" class="de-variant-add tn-btn" data-design-id="${escapeHtml(id)}">Add</button>
|
|
318
|
+
</td>
|
|
319
|
+
</tr>`);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const scaleEditors = buildScaleEditors(designMd);
|
|
323
|
+
|
|
324
|
+
return `
|
|
325
|
+
<section class="cap-section" id="design-editor" data-cap-editor="1">
|
|
326
|
+
<h2>Design Editor <span class="de-badge">edit mode</span></h2>
|
|
327
|
+
<p class="de-hint">Edits write directly to <code>DESIGN.md</code>. Ctrl+C the server to stop the session.</p>
|
|
328
|
+
<div id="de-status" class="de-status" role="status" aria-live="polite"></div>
|
|
329
|
+
|
|
330
|
+
<h3>Color Tokens</h3>
|
|
331
|
+
${tokenRows.length
|
|
332
|
+
? `<table class="ac-table de-table"><thead><tr><th>ID</th><th>Key</th><th>Picker</th><th>Value</th></tr></thead><tbody>\n${tokenRows.join('\n')}\n </tbody></table>`
|
|
333
|
+
: '<p class="empty">No DT-NNN color tokens found.</p>'}
|
|
334
|
+
|
|
335
|
+
<h3>Scales</h3>
|
|
336
|
+
${scaleEditors || '<p class="empty">No scales found in DESIGN.md.</p>'}
|
|
337
|
+
|
|
338
|
+
<h3>Components</h3>
|
|
339
|
+
${compRows.length
|
|
340
|
+
? `<table class="ac-table de-table"><thead><tr><th>ID</th><th>Name</th><th>Variants</th><th>Add</th></tr></thead><tbody>\n${compRows.join('\n')}\n </tbody></table>`
|
|
341
|
+
: '<p class="empty">No DC-NNN components found.</p>'}
|
|
342
|
+
</section>`;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function buildScaleEditors(designMd) {
|
|
346
|
+
const parts = [];
|
|
347
|
+
const spacing = extractScale(designMd, 'Spacing', 'scale');
|
|
348
|
+
if (spacing) {
|
|
349
|
+
parts.push(scaleRow('spacing.scale', 'Spacing', spacing));
|
|
350
|
+
}
|
|
351
|
+
const typoScale = extractScale(designMd, 'Typography', 'scale');
|
|
352
|
+
if (typoScale) {
|
|
353
|
+
parts.push(scaleRow('typography.scale', 'Typography — font size scale', typoScale));
|
|
354
|
+
}
|
|
355
|
+
return parts.join('\n');
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function scaleRow(id, label, values) {
|
|
359
|
+
const base = Math.max(...values, 1);
|
|
360
|
+
const sliders = values.map((v, idx) =>
|
|
361
|
+
`<label class="de-slider">
|
|
362
|
+
<span class="de-slider-label">[${idx}]</span>
|
|
363
|
+
<input type="range" min="0" max="${Math.max(128, Math.round(base * 2))}" step="1" value="${v}" class="de-scale" data-design-id="${escapeHtml(id)}" data-scale-idx="${idx}">
|
|
364
|
+
<output class="de-scale-out" data-design-id="${escapeHtml(id)}" data-scale-idx="${idx}">${v}</output>
|
|
365
|
+
</label>`
|
|
366
|
+
).join('\n');
|
|
367
|
+
return `<div class="de-scale-row" data-design-id="${escapeHtml(id)}">
|
|
368
|
+
<div class="de-scale-title">${escapeHtml(label)} (<code>${escapeHtml(id)}</code>)</div>
|
|
369
|
+
${sliders}
|
|
370
|
+
</div>`;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function extractScale(designMd, sectionName, key) {
|
|
374
|
+
const lines = String(designMd || '').split('\n');
|
|
375
|
+
let inSection = false;
|
|
376
|
+
for (let i = 0; i < lines.length; i++) {
|
|
377
|
+
const trimmed = lines[i].trim();
|
|
378
|
+
if (trimmed === `### ${sectionName}`) { inSection = true; continue; }
|
|
379
|
+
if (inSection && (trimmed.startsWith('### ') || trimmed.startsWith('## '))) return null;
|
|
380
|
+
if (!inSection) continue;
|
|
381
|
+
const m = lines[i].match(new RegExp(`^-\\s+${_escapeReg(key)}:\\s*\\[([^\\]]*)\\]`));
|
|
382
|
+
if (m) {
|
|
383
|
+
const parts = m[1].split(',').map(s => parseFloat(s.trim())).filter(n => Number.isFinite(n));
|
|
384
|
+
if (parts.length > 0) return parts;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function safeExtractVariants(designMd, componentId) {
|
|
391
|
+
try {
|
|
392
|
+
const lines = String(designMd || '').split('\n');
|
|
393
|
+
const headerRe = new RegExp(`^###\\s+.*\\(id:\\s*${_escapeReg(componentId)}\\)\\s*$`);
|
|
394
|
+
let headerIdx = -1;
|
|
395
|
+
for (let i = 0; i < lines.length; i++) {
|
|
396
|
+
if (headerRe.test(lines[i])) { headerIdx = i; break; }
|
|
397
|
+
}
|
|
398
|
+
if (headerIdx === -1) return [];
|
|
399
|
+
for (let i = headerIdx + 1; i < lines.length; i++) {
|
|
400
|
+
const trimmed = lines[i].trim();
|
|
401
|
+
if (trimmed.startsWith('### ') || trimmed.startsWith('## ')) break;
|
|
402
|
+
const m = lines[i].match(/^-\s+variants:\s*\[([^\]]*)\]/);
|
|
403
|
+
if (m) return m[1].split(',').map(s => s.trim()).filter(Boolean);
|
|
404
|
+
}
|
|
405
|
+
return [];
|
|
406
|
+
} catch {
|
|
407
|
+
return [];
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function safeParseDesignIds(designMd) {
|
|
412
|
+
try { return designLib.parseDesignIds(designMd) || { tokens: [], components: [], byToken: {}, byComponent: {} }; }
|
|
413
|
+
catch { return { tokens: [], components: [], byToken: {}, byComponent: {} }; }
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// ===========================================================================
|
|
417
|
+
// Editor CSS + JS (composed by cap-ui only when editable)
|
|
418
|
+
// ===========================================================================
|
|
419
|
+
|
|
420
|
+
// @cap-todo(ac:F-068/AC-1) Editor CSS composed into buildCss() only when editable.
|
|
421
|
+
function buildEditorCss() {
|
|
422
|
+
return `
|
|
423
|
+
section.cap-section#design-editor { max-width: 100%; }
|
|
424
|
+
#design-editor .de-badge {
|
|
425
|
+
display: inline-block;
|
|
426
|
+
margin-left: 8px;
|
|
427
|
+
padding: 1px 6px;
|
|
428
|
+
background: var(--accent);
|
|
429
|
+
color: var(--bg);
|
|
430
|
+
font-size: 10px;
|
|
431
|
+
border-radius: 2px;
|
|
432
|
+
text-transform: uppercase;
|
|
433
|
+
letter-spacing: 0.06em;
|
|
434
|
+
}
|
|
435
|
+
#design-editor .de-hint { color: var(--fg-muted); font-size: 12px; margin: 4px 0 10px; }
|
|
436
|
+
#design-editor .de-status {
|
|
437
|
+
min-height: 18px;
|
|
438
|
+
font-size: 12px;
|
|
439
|
+
padding: 4px 8px;
|
|
440
|
+
margin: 6px 0 10px;
|
|
441
|
+
border-radius: 2px;
|
|
442
|
+
color: var(--fg-muted);
|
|
443
|
+
}
|
|
444
|
+
#design-editor .de-status.is-ok { color: var(--state-tested); background: #d8ead9; }
|
|
445
|
+
#design-editor .de-status.is-err { color: var(--accent); background: #fff4ea; border: 1px solid var(--accent-muted); }
|
|
446
|
+
#design-editor table.de-table th { white-space: nowrap; }
|
|
447
|
+
#design-editor .de-color { width: 44px; height: 28px; border: 1px solid var(--border); cursor: pointer; padding: 0; background: var(--bg-card); }
|
|
448
|
+
#design-editor .de-value { color: var(--fg); }
|
|
449
|
+
#design-editor .de-variants-cell { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }
|
|
450
|
+
#design-editor .de-variant-pill {
|
|
451
|
+
display: inline-flex;
|
|
452
|
+
gap: 4px;
|
|
453
|
+
align-items: center;
|
|
454
|
+
background: var(--border);
|
|
455
|
+
padding: 1px 4px;
|
|
456
|
+
border-radius: 2px;
|
|
457
|
+
font-size: 11px;
|
|
458
|
+
}
|
|
459
|
+
#design-editor .de-variant-remove {
|
|
460
|
+
background: transparent;
|
|
461
|
+
border: none;
|
|
462
|
+
color: var(--accent);
|
|
463
|
+
cursor: pointer;
|
|
464
|
+
font-size: 14px;
|
|
465
|
+
line-height: 1;
|
|
466
|
+
padding: 0 2px;
|
|
467
|
+
}
|
|
468
|
+
#design-editor .de-variant-remove:hover { color: #9c4530; }
|
|
469
|
+
#design-editor .de-variant-input {
|
|
470
|
+
background: var(--bg-card);
|
|
471
|
+
border: 1px solid var(--border);
|
|
472
|
+
color: var(--fg);
|
|
473
|
+
font-family: var(--mono);
|
|
474
|
+
font-size: 12px;
|
|
475
|
+
padding: 2px 6px;
|
|
476
|
+
border-radius: 2px;
|
|
477
|
+
width: 160px;
|
|
478
|
+
}
|
|
479
|
+
#design-editor .de-scale-row {
|
|
480
|
+
border: 1px solid var(--border);
|
|
481
|
+
background: var(--bg-card);
|
|
482
|
+
border-radius: 3px;
|
|
483
|
+
padding: 8px 10px;
|
|
484
|
+
margin: 6px 0;
|
|
485
|
+
}
|
|
486
|
+
#design-editor .de-scale-title { font-size: 12px; color: var(--fg-muted); margin-bottom: 6px; }
|
|
487
|
+
#design-editor .de-slider { display: inline-flex; align-items: center; gap: 6px; margin: 3px 10px 3px 0; font-size: 11px; color: var(--fg-muted); }
|
|
488
|
+
#design-editor .de-slider-label { min-width: 22px; }
|
|
489
|
+
#design-editor .de-slider input[type=range] { width: 160px; }
|
|
490
|
+
#design-editor .de-slider output { color: var(--fg); font-variant-numeric: tabular-nums; min-width: 30px; text-align: right; }
|
|
491
|
+
`.trim();
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// @cap-todo(ac:F-068/AC-1) Editor JS composed into buildClientJs() only when editable.
|
|
495
|
+
// @cap-decision(F-068/D8) Uses fetch(PUT/DELETE) to /api/design/* endpoints. Endpoint error responses surface via #de-status.
|
|
496
|
+
// @cap-decision(F-068/D9) After each successful write the server pushes an SSE 'change' event — full-page reload picks up latest DESIGN.md content.
|
|
497
|
+
function buildEditorJs() {
|
|
498
|
+
return `
|
|
499
|
+
(function(){
|
|
500
|
+
var root = document.getElementById('design-editor');
|
|
501
|
+
if (!root) return;
|
|
502
|
+
var status = document.getElementById('de-status');
|
|
503
|
+
|
|
504
|
+
function flash(kind, msg) {
|
|
505
|
+
if (!status) return;
|
|
506
|
+
status.textContent = msg;
|
|
507
|
+
status.classList.remove('is-ok');
|
|
508
|
+
status.classList.remove('is-err');
|
|
509
|
+
if (kind === 'ok') status.classList.add('is-ok');
|
|
510
|
+
if (kind === 'err') status.classList.add('is-err');
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function send(method, url, body) {
|
|
514
|
+
return fetch(url, {
|
|
515
|
+
method: method,
|
|
516
|
+
headers: { 'Content-Type': 'application/json' },
|
|
517
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
518
|
+
}).then(function(res){
|
|
519
|
+
return res.text().then(function(text){
|
|
520
|
+
var payload = null;
|
|
521
|
+
try { payload = text ? JSON.parse(text) : null; } catch (_e) { payload = { error: text }; }
|
|
522
|
+
if (!res.ok) {
|
|
523
|
+
var msg = (payload && payload.error) ? payload.error : ('HTTP ' + res.status);
|
|
524
|
+
throw new Error(msg);
|
|
525
|
+
}
|
|
526
|
+
return payload;
|
|
527
|
+
});
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// --- Color picker ---
|
|
532
|
+
root.addEventListener('change', function(e){
|
|
533
|
+
var el = e.target;
|
|
534
|
+
if (el && el.classList && el.classList.contains('de-color')) {
|
|
535
|
+
var id = el.getAttribute('data-design-id');
|
|
536
|
+
var value = el.value;
|
|
537
|
+
send('PUT', '/api/design/color/' + encodeURIComponent(id), { value: value }).then(function(){
|
|
538
|
+
flash('ok', 'Updated ' + id + ' → ' + value);
|
|
539
|
+
var view = root.querySelector('code.de-value[data-design-id="' + id + '"]');
|
|
540
|
+
if (view) view.textContent = value;
|
|
541
|
+
}).catch(function(err){ flash('err', err.message || 'write failed'); });
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
if (el && el.classList && el.classList.contains('de-scale')) {
|
|
545
|
+
var sid = el.getAttribute('data-design-id');
|
|
546
|
+
var idx = parseInt(el.getAttribute('data-scale-idx'), 10);
|
|
547
|
+
var out = root.querySelector('output.de-scale-out[data-design-id="' + sid + '"][data-scale-idx="' + idx + '"]');
|
|
548
|
+
if (out) out.textContent = el.value;
|
|
549
|
+
// Collect the full scale array for this id.
|
|
550
|
+
var inputs = root.querySelectorAll('input.de-scale[data-design-id="' + sid + '"]');
|
|
551
|
+
var arr = [];
|
|
552
|
+
for (var i = 0; i < inputs.length; i++) arr.push(parseFloat(inputs[i].value));
|
|
553
|
+
send('PUT', '/api/design/spacing/' + encodeURIComponent(sid), { value: arr }).then(function(){
|
|
554
|
+
flash('ok', 'Updated ' + sid);
|
|
555
|
+
}).catch(function(err){ flash('err', err.message || 'write failed'); });
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
// Live-preview while dragging the slider (no PUT until 'change' fires on release).
|
|
560
|
+
root.addEventListener('input', function(e){
|
|
561
|
+
var el = e.target;
|
|
562
|
+
if (el && el.classList && el.classList.contains('de-scale')) {
|
|
563
|
+
var sid = el.getAttribute('data-design-id');
|
|
564
|
+
var idx = el.getAttribute('data-scale-idx');
|
|
565
|
+
var out = root.querySelector('output.de-scale-out[data-design-id="' + sid + '"][data-scale-idx="' + idx + '"]');
|
|
566
|
+
if (out) out.textContent = el.value;
|
|
567
|
+
}
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
// --- Variant add/remove ---
|
|
571
|
+
root.addEventListener('click', function(e){
|
|
572
|
+
var el = e.target;
|
|
573
|
+
if (el && el.classList && el.classList.contains('de-variant-add')) {
|
|
574
|
+
var id = el.getAttribute('data-design-id');
|
|
575
|
+
var input = root.querySelector('input.de-variant-input[data-design-id="' + id + '"]');
|
|
576
|
+
if (!input || !input.value) return;
|
|
577
|
+
var name = input.value;
|
|
578
|
+
send('PUT', '/api/design/component/' + encodeURIComponent(id), { action: 'add', variant: name }).then(function(){
|
|
579
|
+
flash('ok', 'Added variant ' + name + ' to ' + id);
|
|
580
|
+
input.value = '';
|
|
581
|
+
}).catch(function(err){ flash('err', err.message || 'write failed'); });
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
if (el && el.classList && el.classList.contains('de-variant-remove')) {
|
|
585
|
+
var id2 = el.getAttribute('data-design-id');
|
|
586
|
+
var name2 = el.getAttribute('data-variant');
|
|
587
|
+
send('DELETE', '/api/design/component/' + encodeURIComponent(id2) + '/variant/' + encodeURIComponent(name2)).then(function(){
|
|
588
|
+
flash('ok', 'Removed variant ' + name2 + ' from ' + id2);
|
|
589
|
+
}).catch(function(err){ flash('err', err.message || 'write failed'); });
|
|
590
|
+
}
|
|
591
|
+
});
|
|
592
|
+
})();
|
|
593
|
+
`.trim();
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// ===========================================================================
|
|
597
|
+
// Internal helpers
|
|
598
|
+
// ===========================================================================
|
|
599
|
+
|
|
600
|
+
function _escapeReg(s) { return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
|
|
601
|
+
function _validateId(id, re, label) { if (!re.test(id)) throw new Error(`invalid id "${id}" (expected ${label})`); }
|
|
602
|
+
function _validateColorValue(v) {
|
|
603
|
+
// Accept hex #RGB, #RRGGBB, #RRGGBBAA. Anything else is rejected at the library layer so malformed input
|
|
604
|
+
// never lands in DESIGN.md. (The route layer repeats this check for defense-in-depth.)
|
|
605
|
+
if (!/^#[0-9a-f]{3,8}$/i.test(v)) throw new Error(`invalid color value "${v}" (expected #RGB / #RRGGBB / #RRGGBBAA)`);
|
|
606
|
+
}
|
|
607
|
+
function _formatScale(value) {
|
|
608
|
+
if (Array.isArray(value)) {
|
|
609
|
+
const cleaned = value.map(v => {
|
|
610
|
+
if (typeof v === 'number' && Number.isFinite(v)) return v;
|
|
611
|
+
const n = Number(v);
|
|
612
|
+
if (Number.isFinite(n)) return n;
|
|
613
|
+
throw new Error(`invalid scale entry: ${JSON.stringify(v)}`);
|
|
614
|
+
});
|
|
615
|
+
return cleaned.join(', ');
|
|
616
|
+
}
|
|
617
|
+
if (typeof value === 'string') {
|
|
618
|
+
// Accept "4, 8, 16" style strings for convenience.
|
|
619
|
+
const parts = value.split(',').map(s => parseFloat(s.trim())).filter(n => Number.isFinite(n));
|
|
620
|
+
return parts.join(', ');
|
|
621
|
+
}
|
|
622
|
+
throw new Error('scale value must be an array of numbers');
|
|
623
|
+
}
|
|
624
|
+
function _asString(value) {
|
|
625
|
+
if (typeof value === 'string') return value.replace(/"/g, '\\"');
|
|
626
|
+
throw new Error('expected string value');
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
module.exports = {
|
|
630
|
+
DESIGN_FILE,
|
|
631
|
+
checkContainment,
|
|
632
|
+
atomicWriteDesign,
|
|
633
|
+
applyColorEdit,
|
|
634
|
+
applySpacingEdit,
|
|
635
|
+
applyComponentEdit,
|
|
636
|
+
buildEditorSection,
|
|
637
|
+
buildEditorCss,
|
|
638
|
+
buildEditorJs,
|
|
639
|
+
// Helpers exported for tests.
|
|
640
|
+
_extractScale: extractScale,
|
|
641
|
+
_extractVariants: safeExtractVariants,
|
|
642
|
+
};
|