@bastani/atomic 0.8.20 → 0.8.21

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.
Files changed (124) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/builtin/intercom/package.json +1 -1
  3. package/dist/builtin/mcp/package.json +1 -1
  4. package/dist/builtin/subagents/agents/code-simplifier.md +78 -22
  5. package/dist/builtin/subagents/agents/debugger.md +4 -3
  6. package/dist/builtin/subagents/package.json +1 -1
  7. package/dist/builtin/web-access/package.json +1 -1
  8. package/dist/builtin/workflows/CHANGELOG.md +25 -0
  9. package/dist/builtin/workflows/package.json +1 -1
  10. package/dist/builtin/workflows/skills/create-spec/SKILL.md +169 -125
  11. package/dist/builtin/workflows/skills/impeccable/SKILL.md +89 -80
  12. package/dist/builtin/workflows/skills/impeccable/agents/impeccable_asset_producer.toml +92 -0
  13. package/dist/builtin/workflows/skills/impeccable/agents/impeccable_manual_edit_applier.toml +95 -0
  14. package/dist/builtin/workflows/skills/impeccable/agents/openai.yaml +4 -0
  15. package/dist/builtin/workflows/skills/impeccable/reference/adapt.md +122 -1
  16. package/dist/builtin/workflows/skills/impeccable/reference/animate.md +38 -12
  17. package/dist/builtin/workflows/skills/impeccable/reference/audit.md +5 -5
  18. package/dist/builtin/workflows/skills/impeccable/reference/bolder.md +7 -7
  19. package/dist/builtin/workflows/skills/impeccable/reference/brand.md +4 -14
  20. package/dist/builtin/workflows/skills/impeccable/reference/clarify.md +115 -1
  21. package/dist/builtin/workflows/skills/impeccable/reference/codex.md +3 -3
  22. package/dist/builtin/workflows/skills/impeccable/reference/colorize.md +109 -6
  23. package/dist/builtin/workflows/skills/impeccable/reference/craft.md +7 -7
  24. package/dist/builtin/workflows/skills/impeccable/reference/critique.md +623 -94
  25. package/dist/builtin/workflows/skills/impeccable/reference/delight.md +2 -2
  26. package/dist/builtin/workflows/skills/impeccable/reference/distill.md +2 -2
  27. package/dist/builtin/workflows/skills/impeccable/reference/document.md +16 -14
  28. package/dist/builtin/workflows/skills/impeccable/reference/extract.md +1 -1
  29. package/dist/builtin/workflows/skills/impeccable/reference/harden.md +1 -1
  30. package/dist/builtin/workflows/skills/impeccable/reference/init.md +172 -0
  31. package/dist/builtin/workflows/skills/impeccable/reference/interaction-design.md +0 -6
  32. package/dist/builtin/workflows/skills/impeccable/reference/layout.md +33 -13
  33. package/dist/builtin/workflows/skills/impeccable/reference/live.md +96 -19
  34. package/dist/builtin/workflows/skills/impeccable/reference/onboard.md +1 -1
  35. package/dist/builtin/workflows/skills/impeccable/reference/optimize.md +1 -1
  36. package/dist/builtin/workflows/skills/impeccable/reference/overdrive.md +1 -1
  37. package/dist/builtin/workflows/skills/impeccable/reference/polish.md +3 -4
  38. package/dist/builtin/workflows/skills/impeccable/reference/product.md +1 -3
  39. package/dist/builtin/workflows/skills/impeccable/reference/quieter.md +2 -2
  40. package/dist/builtin/workflows/skills/impeccable/reference/shape.md +5 -5
  41. package/dist/builtin/workflows/skills/impeccable/reference/typeset.md +158 -3
  42. package/dist/builtin/workflows/skills/impeccable/scripts/cleanup-deprecated.mjs +1 -1
  43. package/dist/builtin/workflows/skills/impeccable/scripts/command-metadata.json +2 -2
  44. package/dist/builtin/workflows/skills/impeccable/scripts/context-signals.mjs +225 -0
  45. package/dist/builtin/workflows/skills/impeccable/scripts/context.mjs +266 -0
  46. package/dist/builtin/workflows/skills/impeccable/scripts/critique-storage.mjs +17 -1
  47. package/dist/builtin/workflows/skills/impeccable/scripts/design-parser.mjs +16 -1
  48. package/dist/builtin/workflows/skills/impeccable/scripts/detect.mjs +21 -0
  49. package/dist/builtin/workflows/skills/impeccable/scripts/detector/browser/injected/index.mjs +1725 -0
  50. package/dist/builtin/workflows/skills/impeccable/scripts/detector/cli/main.mjs +244 -0
  51. package/dist/builtin/workflows/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +4543 -0
  52. package/dist/builtin/workflows/skills/impeccable/scripts/detector/detect-antipatterns.mjs +43 -0
  53. package/dist/builtin/workflows/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs +252 -0
  54. package/dist/builtin/workflows/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +535 -0
  55. package/dist/builtin/workflows/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +986 -0
  56. package/dist/builtin/workflows/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +208 -0
  57. package/dist/builtin/workflows/skills/impeccable/scripts/detector/engines/visual/screenshot-contrast.mjs +189 -0
  58. package/dist/builtin/workflows/skills/impeccable/scripts/detector/findings.mjs +12 -0
  59. package/dist/builtin/workflows/skills/impeccable/scripts/detector/node/file-system.mjs +198 -0
  60. package/dist/builtin/workflows/skills/impeccable/scripts/detector/profile/profiler.mjs +166 -0
  61. package/dist/builtin/workflows/skills/impeccable/scripts/detector/registry/antipatterns.mjs +419 -0
  62. package/dist/builtin/workflows/skills/impeccable/scripts/detector/rules/checks.mjs +2316 -0
  63. package/dist/builtin/workflows/skills/impeccable/scripts/detector/shared/color.mjs +124 -0
  64. package/dist/builtin/workflows/skills/impeccable/scripts/detector/shared/constants.mjs +101 -0
  65. package/dist/builtin/workflows/skills/impeccable/scripts/detector/shared/page.mjs +7 -0
  66. package/dist/builtin/workflows/skills/impeccable/scripts/impeccable-paths.mjs +17 -1
  67. package/dist/builtin/workflows/skills/impeccable/scripts/is-generated.mjs +2 -2
  68. package/dist/builtin/workflows/skills/impeccable/scripts/live-accept.mjs +139 -96
  69. package/dist/builtin/workflows/skills/impeccable/scripts/live-browser.js +4491 -526
  70. package/dist/builtin/workflows/skills/impeccable/scripts/live-commit-manual-edits.mjs +1241 -0
  71. package/dist/builtin/workflows/skills/impeccable/scripts/live-copy-edit-agent.mjs +683 -0
  72. package/dist/builtin/workflows/skills/impeccable/scripts/live-discard-manual-edits.mjs +51 -0
  73. package/dist/builtin/workflows/skills/impeccable/scripts/live-event-validation.mjs +136 -0
  74. package/dist/builtin/workflows/skills/impeccable/scripts/live-inject.mjs +22 -9
  75. package/dist/builtin/workflows/skills/impeccable/scripts/live-insert-ui.mjs +458 -0
  76. package/dist/builtin/workflows/skills/impeccable/scripts/live-insert.mjs +232 -0
  77. package/dist/builtin/workflows/skills/impeccable/scripts/live-manual-edit-evidence.mjs +363 -0
  78. package/dist/builtin/workflows/skills/impeccable/scripts/live-manual-edits-buffer.mjs +152 -0
  79. package/dist/builtin/workflows/skills/impeccable/scripts/live-poll.mjs +288 -110
  80. package/dist/builtin/workflows/skills/impeccable/scripts/live-resume.mjs +47 -1
  81. package/dist/builtin/workflows/skills/impeccable/scripts/live-server.mjs +1443 -100
  82. package/dist/builtin/workflows/skills/impeccable/scripts/live-session-store.mjs +17 -0
  83. package/dist/builtin/workflows/skills/impeccable/scripts/live-status.mjs +17 -3
  84. package/dist/builtin/workflows/skills/impeccable/scripts/live-wrap.mjs +216 -6
  85. package/dist/builtin/workflows/skills/impeccable/scripts/live.mjs +2 -3
  86. package/dist/builtin/workflows/skills/impeccable/scripts/palette.mjs +633 -0
  87. package/dist/builtin/workflows/skills/impeccable/scripts/pin.mjs +1 -1
  88. package/dist/builtin/workflows/src/extension/index.ts +67 -3
  89. package/dist/builtin/workflows/src/extension/render-result.ts +26 -1
  90. package/dist/builtin/workflows/src/runs/foreground/executor.ts +227 -3
  91. package/dist/builtin/workflows/src/runs/foreground/stage-runner.ts +94 -7
  92. package/dist/builtin/workflows/src/shared/stage-prompt.ts +326 -0
  93. package/dist/builtin/workflows/src/shared/stage-ui-broker.ts +62 -7
  94. package/dist/builtin/workflows/src/shared/store-types.ts +43 -0
  95. package/dist/builtin/workflows/src/shared/store.ts +37 -0
  96. package/dist/builtin/workflows/src/tui/chat-surface-message.ts +22 -4
  97. package/dist/builtin/workflows/src/tui/graph-view.ts +47 -0
  98. package/dist/builtin/workflows/src/tui/overlay-adapter.ts +43 -1
  99. package/dist/builtin/workflows/src/tui/run-detail.ts +10 -4
  100. package/dist/builtin/workflows/src/tui/stage-chat-view.ts +117 -15
  101. package/dist/builtin/workflows/src/tui/workflow-attach-pane.ts +9 -0
  102. package/dist/core/skills.d.ts.map +1 -1
  103. package/dist/core/skills.js +2 -5
  104. package/dist/core/skills.js.map +1 -1
  105. package/dist/core/system-prompt.d.ts.map +1 -1
  106. package/dist/core/system-prompt.js +11 -29
  107. package/dist/core/system-prompt.js.map +1 -1
  108. package/dist/index.d.ts +1 -0
  109. package/dist/index.d.ts.map +1 -1
  110. package/dist/index.js +3 -0
  111. package/dist/index.js.map +1 -1
  112. package/docs/quickstart.md +1 -2
  113. package/package.json +4 -4
  114. package/dist/builtin/workflows/skills/impeccable/reference/cognitive-load.md +0 -106
  115. package/dist/builtin/workflows/skills/impeccable/reference/color-and-contrast.md +0 -105
  116. package/dist/builtin/workflows/skills/impeccable/reference/heuristics-scoring.md +0 -234
  117. package/dist/builtin/workflows/skills/impeccable/reference/motion-design.md +0 -109
  118. package/dist/builtin/workflows/skills/impeccable/reference/personas.md +0 -179
  119. package/dist/builtin/workflows/skills/impeccable/reference/responsive-design.md +0 -114
  120. package/dist/builtin/workflows/skills/impeccable/reference/spatial-design.md +0 -100
  121. package/dist/builtin/workflows/skills/impeccable/reference/teach.md +0 -156
  122. package/dist/builtin/workflows/skills/impeccable/reference/typography.md +0 -159
  123. package/dist/builtin/workflows/skills/impeccable/reference/ux-writing.md +0 -107
  124. package/dist/builtin/workflows/skills/impeccable/scripts/load-context.mjs +0 -141
@@ -0,0 +1,124 @@
1
+ // ─── Section 2: Color Utilities ─────────────────────────────────────────────
2
+
3
+ function isNeutralColor(color) {
4
+ if (!color || color === 'transparent') return true;
5
+
6
+ // rgb/rgba — use channel spread. Threshold 30 ≈ 11.7% of the 0–255 range.
7
+ const rgb = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
8
+ if (rgb) {
9
+ return (Math.max(+rgb[1], +rgb[2], +rgb[3]) - Math.min(+rgb[1], +rgb[2], +rgb[3])) < 30;
10
+ }
11
+
12
+ // oklch()/lch() — chroma is the second numeric component.
13
+ // oklch chroma is ~0–0.4 in sRGB gamut; >= 0.02 reads as tinted, not gray.
14
+ // lch chroma is ~0–150; >= 3 reads as tinted. jsdom emits both formats
15
+ // literally (it does NOT convert them to rgb).
16
+ const oklch = color.match(/oklch\(\s*[\d.]+%?\s*([\d.-]+)/i);
17
+ if (oklch) return parseFloat(oklch[1]) < 0.02;
18
+ const lch = color.match(/lch\(\s*[\d.]+%?\s*([\d.-]+)/i);
19
+ if (lch) return parseFloat(lch[1]) < 3;
20
+
21
+ // oklab()/lab() — a and b are signed axes; chroma = sqrt(a² + b²).
22
+ // oklab a/b are ~-0.4..0.4, threshold 0.02. lab a/b are ~-128..127, threshold 3.
23
+ const oklab = color.match(/oklab\(\s*[\d.]+%?\s*([\d.-]+)\s+([\d.-]+)/i);
24
+ if (oklab) {
25
+ const a = parseFloat(oklab[1]), b = parseFloat(oklab[2]);
26
+ return Math.hypot(a, b) < 0.02;
27
+ }
28
+ const lab = color.match(/lab\(\s*[\d.]+%?\s*([\d.-]+)\s+([\d.-]+)/i);
29
+ if (lab) {
30
+ const a = parseFloat(lab[1]), b = parseFloat(lab[2]);
31
+ return Math.hypot(a, b) < 3;
32
+ }
33
+
34
+ // hsl/hsla — saturation is the second numeric component (percent).
35
+ // Modern jsdom usually converts hsl() to rgb, but handle it directly for
36
+ // safety across versions and for any engine that preserves the format.
37
+ const hsl = color.match(/hsla?\(\s*[\d.-]+\s*,?\s*([\d.]+)%/i);
38
+ if (hsl) return parseFloat(hsl[1]) < 10;
39
+
40
+ // hwb(hue whiteness% blackness%) — a pixel is fully gray when
41
+ // whiteness + blackness >= 100; chroma-like saturation = 1 - (w+b)/100.
42
+ const hwb = color.match(/hwb\(\s*[\d.-]+\s+([\d.]+)%\s+([\d.]+)%/i);
43
+ if (hwb) {
44
+ const w = parseFloat(hwb[1]), b = parseFloat(hwb[2]);
45
+ return (1 - Math.min(100, w + b) / 100) < 0.1;
46
+ }
47
+
48
+ // Unknown / unrecognized format — err on the side of DETECTING rather
49
+ // than silently skipping. This is the opposite of the previous default,
50
+ // which was the root cause of the oklch bug.
51
+ return false;
52
+ }
53
+
54
+ function parseRgb(color) {
55
+ if (!color || color === 'transparent') return null;
56
+ const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
57
+ if (!m) return null;
58
+ return { r: +m[1], g: +m[2], b: +m[3], a: m[4] !== undefined ? +m[4] : 1 };
59
+ }
60
+
61
+ function relativeLuminance({ r, g, b }) {
62
+ const [rs, gs, bs] = [r / 255, g / 255, b / 255].map(c =>
63
+ c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
64
+ );
65
+ return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
66
+ }
67
+
68
+ function contrastRatio(c1, c2) {
69
+ const l1 = relativeLuminance(c1);
70
+ const l2 = relativeLuminance(c2);
71
+ return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
72
+ }
73
+
74
+ function parseGradientColors(bgImage) {
75
+ if (!bgImage || !bgImage.includes('gradient')) return [];
76
+ const colors = [];
77
+ for (const m of bgImage.matchAll(/rgba?\([^)]+\)/g)) {
78
+ const c = parseRgb(m[0]);
79
+ if (c) colors.push(c);
80
+ }
81
+ for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) {
82
+ const h = m[1];
83
+ if (h.length === 6) {
84
+ colors.push({ r: parseInt(h.slice(0,2),16), g: parseInt(h.slice(2,4),16), b: parseInt(h.slice(4,6),16), a: 1 });
85
+ } else {
86
+ colors.push({ r: parseInt(h[0]+h[0],16), g: parseInt(h[1]+h[1],16), b: parseInt(h[2]+h[2],16), a: 1 });
87
+ }
88
+ }
89
+ return colors;
90
+ }
91
+
92
+ function hasChroma(c, threshold = 30) {
93
+ if (!c) return false;
94
+ return (Math.max(c.r, c.g, c.b) - Math.min(c.r, c.g, c.b)) >= threshold;
95
+ }
96
+
97
+ function getHue(c) {
98
+ if (!c) return 0;
99
+ const r = c.r / 255, g = c.g / 255, b = c.b / 255;
100
+ const max = Math.max(r, g, b), min = Math.min(r, g, b);
101
+ if (max === min) return 0;
102
+ const d = max - min;
103
+ let h;
104
+ if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
105
+ else if (max === g) h = ((b - r) / d + 2) / 6;
106
+ else h = ((r - g) / d + 4) / 6;
107
+ return Math.round(h * 360);
108
+ }
109
+
110
+ function colorToHex(c) {
111
+ if (!c) return '?';
112
+ return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join('');
113
+ }
114
+
115
+ export {
116
+ isNeutralColor,
117
+ parseRgb,
118
+ relativeLuminance,
119
+ contrastRatio,
120
+ parseGradientColors,
121
+ hasChroma,
122
+ getHue,
123
+ colorToHex,
124
+ };
@@ -0,0 +1,101 @@
1
+ // ─── Section 1: Constants ───────────────────────────────────────────────────
2
+
3
+ const SAFE_TAGS = new Set([
4
+ 'blockquote', 'nav', 'a', 'input', 'textarea', 'select',
5
+ 'pre', 'code', 'span', 'th', 'td', 'tr', 'li', 'label',
6
+ 'button', 'hr', 'html', 'head', 'body', 'script', 'style',
7
+ 'link', 'meta', 'title', 'br', 'img', 'svg', 'path', 'circle',
8
+ 'rect', 'line', 'polyline', 'polygon', 'g', 'defs', 'use',
9
+ ]);
10
+
11
+ // Per-check safe-tags override for the border (side-tab / border-accent)
12
+ // rule. We intentionally re-allow <label> here because card-shaped clickable
13
+ // labels (e.g. .checklist-item wrapping a checkbox + content) are one of the
14
+ // canonical side-tab anti-pattern shapes and must be detected. The rule's
15
+ // other preconditions (non-neutral color, width >= 2px on a single side,
16
+ // radius > 0 or width >= 3, element size >= 20x20 in the browser path)
17
+ // already filter out plain inline form labels so this does not introduce
18
+ // false positives. See modern-color-borders.html for the test matrix.
19
+ const BORDER_SAFE_TAGS = new Set(
20
+ [...SAFE_TAGS].filter(t => t !== 'label')
21
+ );
22
+
23
+ const OVERUSED_FONTS = new Set([
24
+ // Older monoculture (still ubiquitous):
25
+ 'inter', 'roboto', 'open sans', 'lato', 'montserrat', 'arial', 'helvetica',
26
+ // Newer monoculture (the Anthropic-skill / Vercel / GitHub default wave):
27
+ 'fraunces', 'instrument sans', 'instrument serif',
28
+ 'geist', 'geist sans', 'geist mono',
29
+ 'mona sans',
30
+ 'plus jakarta sans', 'space grotesk', 'recoleta',
31
+ ]);
32
+
33
+ // Brand-associated fonts: don't flag these as "overused" on the brand's own domains.
34
+ // Keys are font names, values are arrays of hostname suffixes where the font is allowed.
35
+ const GOOGLE_DOMAINS = [
36
+ 'google.com', 'youtube.com', 'android.com', 'chromium.org',
37
+ 'chrome.com', 'web.dev', 'gstatic.com', 'firebase.google.com',
38
+ ];
39
+ const VERCEL_DOMAINS = ['vercel.com', 'nextjs.org', 'v0.app'];
40
+ const GITHUB_DOMAINS = ['github.com', 'githubnext.com'];
41
+ const BRAND_FONT_DOMAINS = {
42
+ 'roboto': GOOGLE_DOMAINS,
43
+ 'google sans': GOOGLE_DOMAINS,
44
+ 'product sans': GOOGLE_DOMAINS,
45
+ 'geist': VERCEL_DOMAINS,
46
+ 'geist sans': VERCEL_DOMAINS,
47
+ 'geist mono': VERCEL_DOMAINS,
48
+ 'mona sans': GITHUB_DOMAINS,
49
+ };
50
+
51
+ function isBrandFontOnOwnDomain(font) {
52
+ if (typeof location === 'undefined') return false;
53
+ const allowed = BRAND_FONT_DOMAINS[font];
54
+ if (!allowed) return false;
55
+ const host = location.hostname.toLowerCase();
56
+ return allowed.some(suffix => host === suffix || host.endsWith('.' + suffix));
57
+ }
58
+
59
+ const GENERIC_FONTS = new Set([
60
+ 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy',
61
+ 'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
62
+ '-apple-system', 'blinkmacsystemfont', 'segoe ui',
63
+ 'inherit', 'initial', 'unset', 'revert',
64
+ ]);
65
+
66
+ // WCAG large text thresholds are defined in points: 18pt normal text and
67
+ // 14pt bold text. Browsers expose font-size in CSS pixels at 96px per inch.
68
+ const WCAG_LARGE_TEXT_PX = 18 * (96 / 72);
69
+ const WCAG_LARGE_BOLD_TEXT_PX = 14 * (96 / 72);
70
+
71
+ // Serif faces that show up in italic-display heroes. The rule also fires when
72
+ // the primary face is unknown but the stack ends in the generic `serif` token,
73
+ // which catches custom/private faces with a serif fallback.
74
+ const KNOWN_SERIF_FONTS = new Set([
75
+ 'fraunces', 'recoleta', 'newsreader', 'playfair display', 'playfair',
76
+ 'cormorant', 'cormorant garamond', 'garamond', 'eb garamond',
77
+ 'tiempos', 'tiempos headline', 'tiempos text',
78
+ 'lora', 'vollkorn', 'spectral',
79
+ 'source serif pro', 'source serif 4', 'source serif',
80
+ 'ibm plex serif', 'merriweather',
81
+ 'libre caslon', 'libre baskerville', 'baskerville',
82
+ 'georgia', 'times new roman', 'times',
83
+ 'dm serif display', 'dm serif text',
84
+ 'instrument serif', 'gt sectra', 'ogg', 'canela',
85
+ 'freight display', 'freight text',
86
+ ]);
87
+
88
+ export {
89
+ SAFE_TAGS,
90
+ BORDER_SAFE_TAGS,
91
+ OVERUSED_FONTS,
92
+ GOOGLE_DOMAINS,
93
+ VERCEL_DOMAINS,
94
+ GITHUB_DOMAINS,
95
+ BRAND_FONT_DOMAINS,
96
+ isBrandFontOnOwnDomain,
97
+ GENERIC_FONTS,
98
+ WCAG_LARGE_TEXT_PX,
99
+ WCAG_LARGE_BOLD_TEXT_PX,
100
+ KNOWN_SERIF_FONTS,
101
+ };
@@ -0,0 +1,7 @@
1
+ /** Check if content looks like a full page (not a component/partial) */
2
+ function isFullPage(content) {
3
+ const stripped = content.replace(/<!--[\s\S]*?-->/g, '');
4
+ return /<!doctype\s|<html[\s>]|<head[\s>]/i.test(stripped);
5
+ }
6
+
7
+ export { isFullPage };
@@ -64,7 +64,12 @@ export function getLegacyLiveServerPath(cwd = process.cwd()) {
64
64
  export function readLiveServerInfo(cwd = process.cwd()) {
65
65
  for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) {
66
66
  try {
67
- return { info: JSON.parse(fs.readFileSync(filePath, 'utf-8')), path: filePath };
67
+ const info = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
68
+ if (info && typeof info.pid === 'number' && !isLiveServerPidReachable(info.pid)) {
69
+ try { fs.unlinkSync(filePath); } catch {}
70
+ continue;
71
+ }
72
+ return { info, path: filePath };
68
73
  } catch {
69
74
  /* try next */
70
75
  }
@@ -72,6 +77,17 @@ export function readLiveServerInfo(cwd = process.cwd()) {
72
77
  return null;
73
78
  }
74
79
 
80
+ export function isLiveServerPidReachable(pid) {
81
+ try {
82
+ process.kill(pid, 0);
83
+ return true;
84
+ } catch (err) {
85
+ // ESRCH means "no such process". EPERM means the process exists but this
86
+ // user cannot signal it, so the live server info is still valid.
87
+ return err?.code !== 'ESRCH';
88
+ }
89
+ }
90
+
75
91
  export function writeLiveServerInfo(cwd = process.cwd(), info) {
76
92
  const filePath = getLiveServerPath(cwd);
77
93
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
@@ -13,7 +13,7 @@
13
13
  * within the first ~300 characters — catches non-git projects.
14
14
  */
15
15
 
16
- import { execFileSync } from 'node:child_process';
16
+ import { execSync } from 'node:child_process';
17
17
  import fs from 'node:fs';
18
18
  import path from 'node:path';
19
19
 
@@ -41,7 +41,7 @@ export function isGeneratedFile(filePath, options = {}) {
41
41
 
42
42
  function isGitIgnored(absPath, cwd) {
43
43
  try {
44
- execFileSync('git', ['check-ignore', '--quiet', '--', absPath], {
44
+ execSync(`git check-ignore --quiet ${JSON.stringify(absPath)}`, {
45
45
  cwd,
46
46
  stdio: 'ignore',
47
47
  });
@@ -16,6 +16,7 @@
16
16
  import fs from 'node:fs';
17
17
  import path from 'node:path';
18
18
  import { isGeneratedFile } from './is-generated.mjs';
19
+ import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live-manual-edits-buffer.mjs';
19
20
 
20
21
  const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
21
22
 
@@ -38,19 +39,22 @@ Modes:
38
39
  Required:
39
40
  --id SESSION_ID Session ID of the variant wrapper
40
41
 
42
+ Options:
43
+ --page-url URL Current browser page URL; scopes staged copy-edit cleanup
44
+
41
45
  Output (JSON):
42
46
  { handled, file, carbonize }`);
43
47
  process.exit(0);
44
48
  }
45
49
 
46
50
  const id = argVal(args, '--id');
47
- const variantNumRaw = argVal(args, '--variant');
51
+ const variantNum = argVal(args, '--variant');
48
52
  const paramValuesRaw = argVal(args, '--param-values');
53
+ const pageUrl = argVal(args, '--page-url');
49
54
  const isDiscard = args.includes('--discard');
50
55
 
51
56
  if (!id) { console.error('Missing --id'); process.exit(1); }
52
- if (!isDiscard && !variantNumRaw) { console.error('Need --discard or --variant N'); process.exit(1); }
53
- const variantNum = isDiscard ? null : parseVariantNumber(variantNumRaw);
57
+ if (!isDiscard && !variantNum) { console.error('Need --discard or --variant N'); process.exit(1); }
54
58
 
55
59
  let paramValues = null;
56
60
  if (paramValuesRaw) {
@@ -87,16 +91,88 @@ Output (JSON):
87
91
  console.log(JSON.stringify({ handled: true, file: relFile, carbonize: false, ...result }));
88
92
  } else {
89
93
  const result = handleAccept(id, variantNum, lines, targetFile, paramValues);
94
+ const acceptedOriginalText = result.acceptedOriginalText || '';
95
+ delete result.acceptedOriginalText;
90
96
  // Single-line attention-grabber when cleanup is required. The full
91
97
  // five-step checklist lives in reference/live.md (loaded once per
92
98
  // session); repeating it per-event would waste tokens.
93
99
  if (result.carbonize) {
94
100
  result.todo = 'REQUIRED before next poll: carbonize cleanup in ' + relFile + '. See reference/live.md "Required after accept".';
95
101
  }
102
+ // Scrub stash entries whose text appeared inside the just-replaced
103
+ // original wrap block. The accept embodies those manual edits (wrap was
104
+ // buffer-aware), so only those scoped ops are redundant.
105
+ if (result.handled !== false) {
106
+ try {
107
+ scrubManualEditsAgainstOriginalBlock(acceptedOriginalText, process.cwd(), pageUrl);
108
+ } catch {
109
+ // Non-fatal; the buffer stays as-is and the user can discard later.
110
+ }
111
+ }
96
112
  console.log(JSON.stringify({ handled: true, file: relFile, ...result }));
97
113
  }
98
114
  }
99
115
 
116
+ /**
117
+ * After a variant accept rewrites one wrapper, drop only buffer ops whose
118
+ * text appeared inside that wrapper's original block. The previous file-wide
119
+ * scrub dropped unrelated staged edits from other components/files whenever
120
+ * their originalText wasn't present in the just-accepted file.
121
+ *
122
+ * Match both originalText and newText because live-wrap rewrites the original
123
+ * preview block to reflect pending manual edits before variants are generated.
124
+ */
125
+ function scrubManualEditsAgainstOriginalBlock(originalBlockText, cwd = process.cwd(), pageUrl = null) {
126
+ const originalBlock = String(originalBlockText || '');
127
+ if (!originalBlock) return;
128
+ if (!pageUrl) return;
129
+ const buffer = readManualEditsBuffer(cwd);
130
+ if (buffer.entries.length === 0) return;
131
+ let mutated = false;
132
+ for (const entry of buffer.entries) {
133
+ if (entry.pageUrl !== pageUrl) continue;
134
+ const before = entry.ops.length;
135
+ entry.ops = entry.ops.filter((op) => {
136
+ return !manualEditOpAppearsInBlock(op, originalBlock);
137
+ });
138
+ if (entry.ops.length !== before) mutated = true;
139
+ }
140
+ buffer.entries = buffer.entries.filter((entry) => entry.ops.length > 0);
141
+ if (mutated) writeManualEditsBuffer(cwd, buffer);
142
+ }
143
+
144
+ function manualEditOpAppearsInBlock(op, originalBlock) {
145
+ const candidates = [op?.newText, op?.originalText]
146
+ .filter((text) => typeof text === 'string' && text.length > 0);
147
+ return candidates.some((text) => originalBlockHasExactManualText(originalBlock, text));
148
+ }
149
+
150
+ function originalBlockHasExactManualText(originalBlock, text) {
151
+ const needle = normalizeManualEditText(text);
152
+ if (!needle) return false;
153
+ return manualEditTextSegments(originalBlock).some((segment) => segment === needle);
154
+ }
155
+
156
+ function manualEditTextSegments(source) {
157
+ return String(source || '')
158
+ .replace(/<[^>]*>/g, '\n')
159
+ .replace(/\{\/\*[\s\S]*?\*\/\}/g, '\n')
160
+ .replace(/<!--[\s\S]*?-->/g, '\n')
161
+ .split(/\n+/)
162
+ .map(normalizeManualEditText)
163
+ .filter(Boolean);
164
+ }
165
+
166
+ function normalizeManualEditText(text) {
167
+ return String(text || '').replace(/\s+/g, ' ').trim();
168
+ }
169
+
170
+ // Compatibility export for older tests/callers. The unsafe file-wide scrub was
171
+ // removed; callers must pass accepted original-block text for scoped cleanup.
172
+ function scrubManualEditsAgainstFile(_targetFile, cwd = process.cwd(), originalBlockText = '', pageUrl = null) {
173
+ return scrubManualEditsAgainstOriginalBlock(originalBlockText, cwd, pageUrl);
174
+ }
175
+
100
176
  // ---------------------------------------------------------------------------
101
177
  // Discard
102
178
  // ---------------------------------------------------------------------------
@@ -147,6 +223,7 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
147
223
  // Extract the chosen variant's inner content
148
224
  const variantContent = extractVariant(lines, block, variantNum);
149
225
  if (!variantContent) return { handled: false, error: 'Variant ' + variantNum + ' not found' };
226
+ const originalContent = extractOriginal(lines, block);
150
227
 
151
228
  // Extract CSS block if present
152
229
  const cssContent = extractCss(lines, block, id);
@@ -205,7 +282,7 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
205
282
  ];
206
283
  fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
207
284
 
208
- return { carbonize: needsCarbonize };
285
+ return { carbonize: needsCarbonize, acceptedOriginalText: originalContent.join('\n') };
209
286
  }
210
287
 
211
288
  // ---------------------------------------------------------------------------
@@ -227,7 +304,7 @@ function findMarkerBlock(id, lines) {
227
304
  if (lines[i].includes(endPattern)) { end = i; break; }
228
305
  }
229
306
 
230
- return (start !== -1 && end !== -1) ? { start, end } : null;
307
+ return (start !== -1 && end !== -1) ? { start, end, id } : null;
231
308
  }
232
309
 
233
310
  /**
@@ -254,11 +331,14 @@ function expandReplaceRange(block, lines, isJsx) {
254
331
  // Walk back for the wrapper `<div data-impeccable-variants="..."` opener.
255
332
  // The attr may sit on a continuation line of a multi-line opening tag, so
256
333
  // also walk to the line that actually contains `<div`.
257
- for (let i = start - 1; i >= Math.max(0, start - 12); i--) {
258
- if (/data-impeccable-variants=/.test(lines[i])) {
334
+ for (let i = start - 1; i >= 0; i--) {
335
+ if (isVariantEndMarkerLine(lines[i], block.id)) break;
336
+ if (hasVariantWrapperAttr(lines[i], block.id)) {
259
337
  let opener = i;
260
- while (opener > 0 && !/<div\b/.test(lines[opener])) opener--;
261
- start = opener;
338
+ while (opener > 0 && !/<div\b/.test(lines[opener]) && !isVariantEndMarkerLine(lines[opener], block.id)) {
339
+ opener--;
340
+ }
341
+ if (/<div\b/.test(lines[opener])) start = opener;
262
342
  break;
263
343
  }
264
344
  }
@@ -296,6 +376,19 @@ function expandReplaceRange(block, lines, isJsx) {
296
376
  return { start, end };
297
377
  }
298
378
 
379
+ function escapeRegExp(value) {
380
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
381
+ }
382
+
383
+ function isVariantEndMarkerLine(line, id) {
384
+ return new RegExp('impeccable-variants-end\\s+' + escapeRegExp(id) + '(?:\\s|--|\\*/|$)').test(line);
385
+ }
386
+
387
+ function hasVariantWrapperAttr(line, id) {
388
+ const escaped = escapeRegExp(id);
389
+ return new RegExp(`data-impeccable-variants\\s*=\\s*(?:"${escaped}"|'${escaped}'|\\{["']${escaped}["']\\})`).test(line);
390
+ }
391
+
299
392
  /**
300
393
  * Join wrapper lines into a single string with `<style>` elements removed so
301
394
  * marker matching and div-depth tracking aren't confused by:
@@ -308,92 +401,50 @@ function expandReplaceRange(block, lines, isJsx) {
308
401
  function stripStyleAndJoin(lines, block) {
309
402
  const out = [];
310
403
  let inStyle = false;
311
-
312
404
  for (let i = block.start; i <= block.end; i++) {
313
- const { text, stillInStyle } = stripStyleSegmentsFromLine(lines[i], inStyle);
314
- inStyle = stillInStyle;
315
- if (text !== null) out.push(text);
316
- }
317
-
318
- return out.join('\n');
319
- }
320
-
321
- function stripStyleSegmentsFromLine(line, startsInStyle) {
322
- let remaining = line;
323
- let output = '';
324
- let inStyle = startsInStyle;
325
-
326
- while (remaining.length > 0) {
327
- const lower = remaining.toLowerCase();
328
-
329
- if (inStyle) {
330
- const closeIdx = lower.indexOf('</style');
331
- if (closeIdx === -1) return { text: output || null, stillInStyle: true };
332
- const closeEnd = lower.indexOf('>', closeIdx);
333
- if (closeEnd === -1) return { text: output || null, stillInStyle: true };
334
- remaining = remaining.slice(closeEnd + 1);
335
- inStyle = false;
336
- continue;
337
- }
338
-
339
- const openIdx = lower.indexOf('<style');
340
- if (openIdx === -1) {
341
- output += remaining;
342
- remaining = '';
343
- break;
344
- }
345
-
346
- output += remaining.slice(0, openIdx);
347
- const openEnd = lower.indexOf('>', openIdx);
348
- if (openEnd === -1) break;
349
-
350
- const opener = remaining.slice(openIdx, openEnd + 1);
351
- if (/\/\s*>$/.test(opener)) {
352
- remaining = remaining.slice(openEnd + 1);
353
- continue;
354
- }
355
-
356
- const afterOpen = remaining.slice(openEnd + 1);
357
- const afterOpenLower = afterOpen.toLowerCase();
358
- const sameLineClose = afterOpenLower.indexOf('</style');
359
- if (sameLineClose === -1) {
360
- inStyle = true;
361
- remaining = '';
362
- break;
363
- }
364
-
365
- const closeEnd = afterOpenLower.indexOf('>', sameLineClose);
366
- if (closeEnd === -1) {
367
- inStyle = true;
368
- remaining = '';
369
- break;
405
+ let line = lines[i];
406
+
407
+ if (!inStyle) {
408
+ // Strip any complete <style> elements on this line (self-closed or
409
+ // same-line-closed), including their body content.
410
+ line = line
411
+ .replace(/<style\b[^>]*>[\s\S]*?<\/style\s*>/g, '')
412
+ .replace(/<style\b[^>]*\/\s*>/g, '');
413
+
414
+ // If a <style> opener remains (multi-line body starts here), strip from
415
+ // the opener to end-of-line and flip into skip mode.
416
+ const openerIdx = line.search(/<style\b/);
417
+ if (openerIdx !== -1) {
418
+ line = line.slice(0, openerIdx);
419
+ inStyle = true;
420
+ }
421
+ out.push(line);
422
+ } else {
423
+ // In multi-line style body; drop everything until we see </style>.
424
+ const closeIdx = line.search(/<\/style\s*>/);
425
+ if (closeIdx !== -1) {
426
+ inStyle = false;
427
+ out.push(line.slice(closeIdx).replace(/<\/style\s*>/, ''));
428
+ }
429
+ // else: skip line entirely
370
430
  }
371
- remaining = afterOpen.slice(closeEnd + 1);
372
431
  }
373
-
374
- return { text: output, stillInStyle: inStyle };
432
+ return out.join('\n');
375
433
  }
376
434
 
377
435
  /**
378
- * Find the inner content of `<TAG ...attrName="attrValue"...>…</TAG>` inside
379
- * `text`, handling nested same-tag elements via depth counting. Returns the
380
- * inner string (may be empty), or null if not found.
436
+ * Find the inner content of `<TAG ...attrMatch...>…</TAG>` inside `text`,
437
+ * handling nested same-tag elements via depth counting. `attrMatch` is a
438
+ * regex source fragment that must appear inside the opener tag.
439
+ * Returns the inner string (may be empty), or null if not found.
381
440
  */
382
- function extractInnerByAttr(text, attrName, attrValue) {
383
- const attrNeedle = attrName + '="' + attrValue + '"';
384
- const attrIdx = text.indexOf(attrNeedle);
385
- if (attrIdx === -1) return null;
386
-
387
- const openStart = text.lastIndexOf('<', attrIdx);
388
- const openEnd = text.indexOf('>', attrIdx);
389
- if (openStart === -1 || openEnd === -1 || openStart > attrIdx || openEnd < attrIdx) return null;
390
-
391
- const opener = text.slice(openStart, openEnd + 1);
392
- const openMatch = opener.match(/^<([A-Za-z][A-Za-z0-9]*)\b/);
441
+ function extractInnerByAttr(text, attrMatch) {
442
+ const openerRe = new RegExp('<([A-Za-z][A-Za-z0-9]*)\\b[^>]*' + attrMatch + '[^>]*>');
443
+ const openMatch = text.match(openerRe);
393
444
  if (!openMatch) return null;
394
445
 
395
446
  const tagName = openMatch[1];
396
- const innerStart = openEnd + 1;
447
+ const innerStart = openMatch.index + openMatch[0].length;
397
448
 
398
449
  // Match any opener or closer of this tag name after innerStart.
399
450
  // (Does not match self-closing <TAG … />, which doesn't contribute to depth.)
@@ -421,7 +472,7 @@ function extractInnerByAttr(text, attrName, attrValue) {
421
472
  */
422
473
  function extractOriginal(lines, block) {
423
474
  const text = stripStyleAndJoin(lines, block);
424
- const inner = extractInnerByAttr(text, 'data-impeccable-variant', 'original');
475
+ const inner = extractInnerByAttr(text, 'data-impeccable-variant="original"');
425
476
  if (inner === null) return [];
426
477
  return inner.split('\n');
427
478
  }
@@ -432,7 +483,7 @@ function extractOriginal(lines, block) {
432
483
  */
433
484
  function extractVariant(lines, block, variantNum) {
434
485
  const text = stripStyleAndJoin(lines, block);
435
- const inner = extractInnerByAttr(text, 'data-impeccable-variant', String(variantNum));
486
+ const inner = extractInnerByAttr(text, 'data-impeccable-variant="' + variantNum + '"');
436
487
  if (inner === null) return null;
437
488
  const result = inner.split('\n');
438
489
  // Collapse a lone empty leading/trailing line (common after string splice).
@@ -629,18 +680,10 @@ function argVal(args, flag) {
629
680
  return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;
630
681
  }
631
682
 
632
- function parseVariantNumber(value) {
633
- if (!/^(?:0|[1-9]\d{0,2})$/.test(value ?? '')) {
634
- console.error('Invalid --variant value; expected an integer from 0 to 999');
635
- process.exit(1);
636
- }
637
- return Number(value);
638
- }
639
-
640
683
  // Auto-execute when run directly
641
684
  const _running = process.argv[1];
642
685
  if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs/')) {
643
686
  acceptCli();
644
687
  }
645
688
 
646
- export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax };
689
+ export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock };