thevoidforge 21.0.0 → 21.0.1

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.
@@ -0,0 +1,1231 @@
1
+ /**
2
+ * VoidForge Wizard — Vanilla JS Step Machine
3
+ * Gandalf — Setup Wizard (Three-Act Flow, v7.1)
4
+ * Act 1: 1=Vault, 2=API Key
5
+ * Act 2: 3=Project, 4=PRD, 4b=Credentials
6
+ * Act 3: 5=Operations Menu, 6=Review, 7=Create/Done
7
+ */
8
+
9
+ (function () {
10
+ 'use strict';
11
+
12
+ const TOTAL_STEPS = 7;
13
+ let currentStep = 1;
14
+
15
+ // State
16
+ const state = {
17
+ anthropicKeyStored: false,
18
+ cloudProviders: {}, // { aws: true, vercel: false, ... }
19
+ projectName: '',
20
+ projectDir: '',
21
+ projectDesc: '',
22
+ projectDomain: '',
23
+ projectHostname: '',
24
+ prdMode: 'generate', // 'generate' | 'paste' | 'skip'
25
+ prdContent: '',
26
+ generatedPrd: '',
27
+ deployTarget: '',
28
+ createdDir: '',
29
+ envGroups: [], // PRD-driven env credential groups
30
+ envCredentials: {}, // { VAR_NAME: 'value', ... }
31
+ };
32
+
33
+ // DOM refs
34
+ const $ = (sel) => document.querySelector(sel);
35
+ const $$ = (sel) => document.querySelectorAll(sel);
36
+
37
+ const progressBar = $('#progress-bar');
38
+ const stepLabel = $('#step-label');
39
+ const btnBack = $('#btn-back');
40
+ const btnNext = $('#btn-next');
41
+
42
+ // --- Navigation ---
43
+
44
+ function showStep(step) {
45
+ // ENCHANT-R2-012 + CROSS-R4-016: Determine direction for animation
46
+ const goingBack = (typeof step === 'number' && typeof currentStep === 'number' && step < currentStep) ||
47
+ (step === 4 && currentStep === '4b') ||
48
+ (step === '4b' && currentStep === 5);
49
+ $$('.step').forEach((el) => {
50
+ el.classList.add('hidden');
51
+ el.classList.remove('entering-backward');
52
+ });
53
+ const target = $(`#step-${step}`);
54
+ if (target) {
55
+ if (goingBack) target.classList.add('entering-backward');
56
+ target.classList.remove('hidden');
57
+ }
58
+
59
+ currentStep = step;
60
+
61
+ // Act-based progress: Act 1 (steps 1-2), Act 2 (steps 3-4b), Act 3 (steps 5-7)
62
+ const hasEnvStep = state.envGroups.length > 0;
63
+ const visibleSteps = hasEnvStep ? [1, 2, 3, 4, '4b', 5, 6, 7] : [1, 2, 3, 4, 5, 6, 7];
64
+ const currentIdx = visibleSteps.indexOf(step);
65
+ const totalVisible = visibleSteps.length;
66
+ const displayNum = currentIdx >= 0 ? currentIdx + 1 : (typeof step === 'number' ? step : 5);
67
+
68
+ const pct = Math.round((displayNum / totalVisible) * 100);
69
+ progressBar.style.width = `${pct}%`;
70
+ progressBar.setAttribute('aria-valuenow', String(pct));
71
+
72
+ // Show act labels instead of step numbers
73
+ let actLabel = 'Act 1 — Secure Your Forge';
74
+ if (step >= 3 && step !== 5 && step !== 6 && step !== 7) actLabel = 'Act 2 — Describe Your Vision';
75
+ if (step === '4b') actLabel = 'Act 2 — Describe Your Vision';
76
+ if (step >= 5) actLabel = 'Act 3 — Equip Your Project';
77
+ if (step === 6) actLabel = 'Review';
78
+ if (step === 7) actLabel = 'Creating';
79
+ stepLabel.textContent = actLabel;
80
+
81
+ btnBack.disabled = step === 1;
82
+
83
+ if (step === 6) {
84
+ btnNext.textContent = 'Create Project';
85
+ } else if (step === 7) {
86
+ btnNext.style.display = 'none';
87
+ btnBack.style.display = 'none';
88
+ } else if (step === '4b') {
89
+ // Step 4b has its own Store/Skip buttons, hide main nav Next
90
+ btnNext.style.display = 'none';
91
+ btnBack.style.display = '';
92
+ } else {
93
+ btnNext.textContent = 'Next';
94
+ btnNext.style.display = '';
95
+ btnBack.style.display = '';
96
+ }
97
+
98
+ // Focus first input
99
+ const firstInput = target?.querySelector('input, textarea, select');
100
+ if (firstInput) setTimeout(() => firstInput.focus(), 100);
101
+ }
102
+
103
+ function syncState() {
104
+ if (currentStep === 3) {
105
+ state.projectName = $('#project-name').value.trim();
106
+ state.projectDir = $('#project-dir').value.trim();
107
+ state.projectDesc = $('#project-desc').value.trim();
108
+ }
109
+ if (currentStep === 5) {
110
+ // Domain/hostname are now in the operations menu
111
+ state.projectDomain = $('#project-domain')?.value.trim() || '';
112
+ state.projectHostname = $('#project-hostname')?.value.trim() || '';
113
+ }
114
+ if (currentStep === 4) {
115
+ if (state.prdMode === 'paste') {
116
+ state.prdContent = $('#prd-paste').value.trim();
117
+ } else if (state.prdMode === 'generate' && state.generatedPrd) {
118
+ state.prdContent = state.generatedPrd;
119
+ } else {
120
+ state.prdContent = '';
121
+ }
122
+ }
123
+ // Deploy target is set directly by card clicks — no sync needed
124
+ }
125
+
126
+ function canAdvance() {
127
+ switch (currentStep) {
128
+ case 1: return true; // Vault unlock handled by button, not Next
129
+ case 2: return true; // API key has skip option
130
+ case 3: return state.projectName.trim() !== '' && state.projectDir.trim() !== '';
131
+ case 4: return true; // PRD optional
132
+ case 5: return true; // Operations menu — all optional
133
+ case 6: return true;
134
+ default: return false;
135
+ }
136
+ }
137
+
138
+ async function nextStep() {
139
+ syncState();
140
+ if (!canAdvance()) {
141
+ showValidationErrors();
142
+ return;
143
+ }
144
+ clearValidationErrors();
145
+
146
+ // After PRD step (4), check for env requirements then go to operations menu
147
+ if (currentStep === 4) {
148
+ const prdText = state.prdContent || state.generatedPrd || '';
149
+ if (prdText) {
150
+ const envGroups = await loadEnvRequirements(prdText);
151
+ state.envGroups = envGroups;
152
+ if (envGroups.length > 0) {
153
+ renderEnvCredentials(envGroups);
154
+ showStep('4b');
155
+ return;
156
+ }
157
+ }
158
+ // No env requirements — go to operations menu
159
+ await loadDeployTargets();
160
+ loadCloudProviders();
161
+ showStep(5);
162
+ return;
163
+ }
164
+
165
+ // After 4b, proceed to operations menu
166
+ if (currentStep === '4b') {
167
+ await loadDeployTargets();
168
+ loadCloudProviders();
169
+ showStep(5);
170
+ return;
171
+ }
172
+
173
+ if (currentStep === 6) {
174
+ createProject();
175
+ showStep(7);
176
+ return;
177
+ }
178
+
179
+ // Blueprint auto-detection: when moving from Step 3 → Step 4,
180
+ // check if docs/PRD.md already exists in the project directory
181
+ if (currentStep === 3 && state.projectDir) {
182
+ try {
183
+ const detectRes = await fetch('/api/blueprint/detect');
184
+ const detectData = await detectRes.json();
185
+ if (detectData.detected) {
186
+ const blueprintBanner = $('#blueprint-detection');
187
+ if (blueprintBanner) {
188
+ $('#blueprint-project-name').textContent = detectData.name || 'your project';
189
+ blueprintBanner.classList.remove('hidden');
190
+ // User clicks "Use my blueprint" → redirect to /blueprint flow
191
+ // User clicks "Start fresh" → continue to Step 4 normally
192
+ return; // Pause navigation until user decides
193
+ }
194
+ }
195
+ } catch { /* detection failed — proceed normally */ }
196
+ }
197
+
198
+ if (currentStep < TOTAL_STEPS) {
199
+ const nextStepNum = currentStep + 1;
200
+ if (nextStepNum === 5) await loadDeployTargets();
201
+ if (nextStepNum === 6) populateReview();
202
+ showStep(nextStepNum);
203
+ }
204
+ }
205
+
206
+ function prevStep() {
207
+ if (currentStep === 1) return;
208
+ if (currentStep === '4b') { showStep(4); return; }
209
+ if (currentStep === 5 && state.envGroups.length > 0) { showStep('4b'); return; }
210
+ if (currentStep === 5) { showStep(4); return; }
211
+ if (currentStep === 6) { showStep(5); return; }
212
+ showStep(currentStep - 1);
213
+ }
214
+
215
+ btnNext.addEventListener('click', nextStep);
216
+ btnBack.addEventListener('click', prevStep);
217
+
218
+ // Keyboard: Enter triggers contextual action
219
+ document.addEventListener('keydown', (e) => {
220
+ if (e.key !== 'Enter') return;
221
+ if (e.target.tagName === 'TEXTAREA') return;
222
+ // Don't intercept Enter on buttons, links, or selects — let native behavior handle them
223
+ if (e.target.tagName === 'BUTTON' || e.target.tagName === 'A' || e.target.tagName === 'SELECT') return;
224
+ e.preventDefault();
225
+
226
+ if (currentStep === 1) {
227
+ if (vaultPasswordInput.value) unlockVaultBtn.click();
228
+ return;
229
+ }
230
+ if (currentStep === 2) {
231
+ if (keyInput.value.trim()) validateKeyBtn.click();
232
+ else nextStep(); // skip
233
+ return;
234
+ }
235
+
236
+ if (currentStep === '4b') return; // Step 4b has its own Store/Skip buttons
237
+
238
+ if (currentStep === 4) {
239
+ const generateTab = $('#tab-generate');
240
+ const ideaField = $('#prd-idea');
241
+ if (generateTab.classList.contains('active') && ideaField.value.trim() && !state.generatedPrd) {
242
+ generatePrdBtn.click();
243
+ return;
244
+ }
245
+ }
246
+
247
+ nextStep();
248
+ });
249
+
250
+ // =============================================
251
+ // Step 1: Vault + API Key
252
+ // =============================================
253
+
254
+ const vaultPasswordInput = $('#vault-password');
255
+ const vaultStatus = $('#vault-status');
256
+ const unlockVaultBtn = $('#unlock-vault');
257
+ const toggleVaultBtn = $('#toggle-vault-visibility');
258
+ const vaultCard = $('#vault-card');
259
+ const apikeyCard = $('#apikey-card');
260
+
261
+ const keyInput = $('#anthropic-key');
262
+ const keyStatus = $('#key-status');
263
+ const validateKeyBtn = $('#validate-key');
264
+ const toggleKeyBtn = $('#toggle-key-visibility');
265
+
266
+ toggleVaultBtn.addEventListener('click', () => {
267
+ const isPassword = vaultPasswordInput.type === 'password';
268
+ vaultPasswordInput.type = isPassword ? 'text' : 'password';
269
+ toggleVaultBtn.textContent = isPassword ? 'Hide' : 'Show';
270
+ });
271
+
272
+ toggleKeyBtn.addEventListener('click', () => {
273
+ const isPassword = keyInput.type === 'password';
274
+ keyInput.type = isPassword ? 'text' : 'password';
275
+ toggleKeyBtn.textContent = isPassword ? 'Hide' : 'Show';
276
+ });
277
+
278
+ // Check vault state on load
279
+ fetch('/api/credentials/status')
280
+ .then((r) => r.json())
281
+ .then((data) => {
282
+ if (data.vaultPath) $('#vault-path').textContent = data.vaultPath;
283
+ if (data.vaultExists) {
284
+ $('#vault-password-label').textContent = 'Vault Password';
285
+ vaultPasswordInput.placeholder = 'Enter your vault password';
286
+ $('#vault-hint').textContent = 'Enter the password you used to create this vault.';
287
+ }
288
+ if (data.unlocked && data.anthropic) {
289
+ state.anthropicKeyStored = true;
290
+ vaultCard.classList.add('hidden');
291
+ apikeyCard.classList.remove('hidden');
292
+ showStatus(keyStatus, 'success', 'API key already stored in vault');
293
+ keyInput.placeholder = 'Key already stored — enter a new one to replace';
294
+ }
295
+ })
296
+ .catch(() => {});
297
+
298
+ unlockVaultBtn.addEventListener('click', async () => {
299
+ const password = vaultPasswordInput.value;
300
+ if (!password) { showStatus(vaultStatus, 'error', 'Please enter a password'); return; }
301
+ if (password.length < 8) { showStatus(vaultStatus, 'error', 'Password must be at least 8 characters'); return; }
302
+
303
+ showStatus(vaultStatus, 'loading', 'Unlocking...');
304
+ unlockVaultBtn.disabled = true;
305
+
306
+ try {
307
+ const res = await fetch('/api/credentials/unlock', {
308
+ method: 'POST',
309
+ headers: { 'Content-Type': 'application/json', 'X-VoidForge-Request': '1' },
310
+ body: JSON.stringify({ password }),
311
+ });
312
+ const data = await res.json();
313
+
314
+ if (res.ok && data.unlocked) {
315
+ // ENCHANT-R2-003 + CROSS-R4-017: Forge-lit pulse on vault unlock (force replay)
316
+ const vc = document.getElementById('vault-card');
317
+ if (vc) { vc.classList.remove('forge-lit'); void vc.offsetWidth; vc.classList.add('forge-lit'); }
318
+ if (data.anthropic) {
319
+ state.anthropicKeyStored = true;
320
+ showStatus(vaultStatus, 'success', 'Vault unlocked — API key found');
321
+ setTimeout(() => showStep(3), 900);
322
+ } else {
323
+ showStatus(vaultStatus, 'success', 'Vault unlocked');
324
+ setTimeout(() => showStep(2), 900);
325
+ }
326
+ } else {
327
+ showStatus(vaultStatus, 'error', data.error || 'Failed to unlock');
328
+ }
329
+ } catch (err) {
330
+ showStatus(vaultStatus, 'error', 'Connection error: ' + err.message);
331
+ } finally {
332
+ unlockVaultBtn.disabled = false;
333
+ }
334
+ });
335
+
336
+ validateKeyBtn.addEventListener('click', async () => {
337
+ const apiKey = keyInput.value.trim();
338
+ if (!apiKey) { showStatus(keyStatus, 'error', 'Please enter your API key'); return; }
339
+
340
+ showStatus(keyStatus, 'loading', 'Validating...');
341
+ validateKeyBtn.disabled = true;
342
+
343
+ try {
344
+ const res = await fetch('/api/credentials/anthropic', {
345
+ method: 'POST',
346
+ headers: { 'Content-Type': 'application/json', 'X-VoidForge-Request': '1' },
347
+ body: JSON.stringify({ apiKey }),
348
+ });
349
+ const data = await res.json();
350
+
351
+ if (res.ok && data.stored) {
352
+ showStatus(keyStatus, 'success', 'Key validated and stored in vault');
353
+ state.anthropicKeyStored = true;
354
+ setTimeout(() => showStep(3), 600);
355
+ } else {
356
+ showStatus(keyStatus, 'error', data.error || 'Validation failed');
357
+ }
358
+ } catch (err) {
359
+ showStatus(keyStatus, 'error', 'Connection error: ' + err.message);
360
+ } finally {
361
+ validateKeyBtn.disabled = false;
362
+ }
363
+ });
364
+
365
+ // =============================================
366
+ // Step 2: API Key + Skip
367
+ // =============================================
368
+
369
+ let providersLoaded = false;
370
+
371
+ /** Make a div act as a keyboard-accessible button */
372
+ function activateOnKeyboard(el, handler) {
373
+ el.addEventListener('click', handler);
374
+ el.addEventListener('keydown', (e) => {
375
+ if (e.key === 'Enter' || e.key === ' ') {
376
+ e.preventDefault();
377
+ handler(e);
378
+ }
379
+ });
380
+ }
381
+
382
+ // Skip API key button
383
+ $('#skip-key')?.addEventListener('click', () => {
384
+ showStep(3);
385
+ });
386
+
387
+ async function loadCloudProviders() {
388
+ if (providersLoaded) return;
389
+ providersLoaded = true;
390
+
391
+ try {
392
+ const [provRes, statusRes] = await Promise.all([
393
+ fetch('/api/cloud/providers').then((r) => r.json()),
394
+ fetch('/api/cloud/status').then((r) => r.json()),
395
+ ]);
396
+
397
+ const container = $('#cloud-providers-list');
398
+ container.innerHTML = '';
399
+
400
+ for (const provider of provRes.providers) {
401
+ const configured = statusRes.status[provider.id] || false;
402
+ state.cloudProviders[provider.id] = configured;
403
+
404
+ const card = document.createElement('div');
405
+ card.className = 'provider-card' + (configured ? ' configured' : '');
406
+ card.dataset.provider = provider.id;
407
+
408
+ const badge = configured
409
+ ? '<span class="provider-badge connected">Connected</span>'
410
+ : '<span class="provider-badge not-connected">Not connected</span>';
411
+
412
+ let fieldsHtml = '';
413
+ for (const field of provider.fields) {
414
+ fieldsHtml += `
415
+ <div class="field">
416
+ <label for="cloud-${field.key}">${field.label}</label>
417
+ <input type="${field.secret ? 'password' : 'text'}" id="cloud-${field.key}"
418
+ data-field-key="${field.key}" placeholder="${field.placeholder}" autocomplete="off">
419
+ </div>`;
420
+ }
421
+
422
+ card.innerHTML = `
423
+ <div class="provider-header" data-toggle="${provider.id}" role="button" tabindex="0" aria-expanded="false">
424
+ <div class="provider-header-left">
425
+ <span class="provider-chevron">&#9654;</span>
426
+ <div>
427
+ <div class="provider-name">${provider.name} <button class="provider-help-btn" data-help="${provider.id}" type="button" title="How to get credentials">?</button></div>
428
+ <div class="provider-desc">${provider.description}</div>
429
+ </div>
430
+ </div>
431
+ ${badge}
432
+ </div>
433
+ <div class="provider-body hidden" id="body-${provider.id}">
434
+ <div class="provider-help hidden" id="help-${provider.id}">
435
+ <button class="provider-help-close" data-close-help="${provider.id}" type="button" title="Close">&times;</button>
436
+ ${provider.help}
437
+ <a class="help-link" href="${provider.credentialUrl}" target="_blank" rel="noopener">Open ${provider.name} Credentials Page &rarr;</a>
438
+ </div>
439
+ <div class="provider-fields" id="fields-${provider.id}">
440
+ ${fieldsHtml}
441
+ <div class="btn-row">
442
+ <button class="btn btn-primary btn-small" data-validate="${provider.id}" type="button">
443
+ ${configured ? 'Update' : 'Connect'}
444
+ </button>
445
+ ${configured ? `<button class="btn btn-secondary btn-small" data-remove="${provider.id}" type="button">Remove</button>` : ''}
446
+ </div>
447
+ <div class="status-row" id="cloud-status-${provider.id}"></div>
448
+ </div>
449
+ </div>`;
450
+
451
+ container.appendChild(card);
452
+ }
453
+
454
+ // Toggle accordion on header click or keyboard (Enter/Space)
455
+ function toggleAccordion(toggle) {
456
+ const id = toggle.dataset.toggle;
457
+ const card = toggle.closest('.provider-card');
458
+ const body = $(`#body-${id}`);
459
+ const isOpen = !body.classList.contains('hidden');
460
+ body.classList.toggle('hidden');
461
+ card.classList.toggle('open', !isOpen);
462
+ toggle.setAttribute('aria-expanded', isOpen ? 'false' : 'true');
463
+ }
464
+
465
+ container.addEventListener('click', (e) => {
466
+ if (e.target.closest('[data-help]')) return;
467
+ if (e.target.closest('[data-close-help]')) return;
468
+ const toggle = e.target.closest('[data-toggle]');
469
+ if (toggle) toggleAccordion(toggle);
470
+ });
471
+
472
+ container.addEventListener('keydown', (e) => {
473
+ if (e.key !== 'Enter' && e.key !== ' ') return;
474
+ if (e.target.closest('[data-help]')) return;
475
+ if (e.target.closest('[data-close-help]')) return;
476
+ const toggle = e.target.closest('[data-toggle]');
477
+ if (toggle) {
478
+ e.preventDefault();
479
+ toggleAccordion(toggle);
480
+ }
481
+ });
482
+
483
+ // Help button toggles
484
+ container.addEventListener('click', (e) => {
485
+ const helpBtn = e.target.closest('[data-help]');
486
+ if (helpBtn) {
487
+ e.stopPropagation();
488
+ const providerId = helpBtn.dataset.help;
489
+ // Expand the accordion if it's collapsed so the help panel is visible
490
+ const body = $(`#body-${providerId}`);
491
+ const card = helpBtn.closest('.provider-card');
492
+ if (body.classList.contains('hidden')) {
493
+ body.classList.remove('hidden');
494
+ card.classList.add('open');
495
+ }
496
+ $(`#help-${providerId}`).classList.toggle('hidden');
497
+ return;
498
+ }
499
+ const closeBtn = e.target.closest('[data-close-help]');
500
+ if (closeBtn) {
501
+ e.stopPropagation();
502
+ $(`#help-${closeBtn.dataset.closeHelp}`).classList.add('hidden');
503
+ return;
504
+ }
505
+ });
506
+
507
+ // Validate buttons
508
+ container.addEventListener('click', async (e) => {
509
+ const validateBtn = e.target.closest('[data-validate]');
510
+ if (!validateBtn) return;
511
+
512
+ const providerId = validateBtn.dataset.validate;
513
+ const statusEl = $(`#cloud-status-${providerId}`);
514
+ const card = validateBtn.closest('.provider-card');
515
+
516
+ const credentials = {};
517
+ card.querySelectorAll('[data-field-key]').forEach((input) => {
518
+ if (input.value.trim()) credentials[input.dataset.fieldKey] = input.value.trim();
519
+ });
520
+
521
+ const provider = provRes.providers.find((p) => p.id === providerId);
522
+ const missing = provider.fields.filter((f) => !f.optional && !credentials[f.key]);
523
+ if (missing.length > 0) {
524
+ showStatus(statusEl, 'error', `Missing: ${missing.map((f) => f.label).join(', ')}`);
525
+ return;
526
+ }
527
+
528
+ showStatus(statusEl, 'loading', 'Validating...');
529
+ validateBtn.disabled = true;
530
+
531
+ try {
532
+ const res = await fetch('/api/cloud/validate', {
533
+ method: 'POST',
534
+ headers: { 'Content-Type': 'application/json', 'X-VoidForge-Request': '1' },
535
+ body: JSON.stringify({ provider: providerId, credentials }),
536
+ });
537
+ const data = await res.json();
538
+
539
+ if (res.ok && data.stored) {
540
+ const identity = data.identity ? ` (${data.identity})` : '';
541
+ showStatus(statusEl, 'success', `Connected${identity}`);
542
+ state.cloudProviders[providerId] = true;
543
+ card.classList.add('configured');
544
+ card.querySelector('.provider-badge').className = 'provider-badge connected';
545
+ card.querySelector('.provider-badge').textContent = 'Connected';
546
+ } else {
547
+ showStatus(statusEl, 'error', data.error || 'Validation failed');
548
+ }
549
+ } catch (err) {
550
+ showStatus(statusEl, 'error', 'Connection error: ' + err.message);
551
+ } finally {
552
+ validateBtn.disabled = false;
553
+ }
554
+ });
555
+
556
+ // Remove buttons
557
+ container.addEventListener('click', async (e) => {
558
+ const removeBtn = e.target.closest('[data-remove]');
559
+ if (!removeBtn) return;
560
+
561
+ const providerId = removeBtn.dataset.remove;
562
+ const statusEl = $(`#cloud-status-${providerId}`);
563
+ const card = removeBtn.closest('.provider-card');
564
+
565
+ try {
566
+ await fetch('/api/cloud/remove', {
567
+ method: 'POST',
568
+ headers: { 'Content-Type': 'application/json', 'X-VoidForge-Request': '1' },
569
+ body: JSON.stringify({ provider: providerId }),
570
+ });
571
+
572
+ state.cloudProviders[providerId] = false;
573
+ card.classList.remove('configured');
574
+ card.querySelector('.provider-badge').className = 'provider-badge not-connected';
575
+ card.querySelector('.provider-badge').textContent = 'Not connected';
576
+ showStatus(statusEl, 'info', 'Credentials removed');
577
+ removeBtn.remove();
578
+ } catch (err) {
579
+ showStatus(statusEl, 'error', 'Failed to remove: ' + err.message);
580
+ }
581
+ });
582
+
583
+ } catch (err) {
584
+ $('#cloud-providers-list').innerHTML = `<div class="status-row error">Failed to load providers: ${escapeHtml(err.message)}</div>`;
585
+ }
586
+ }
587
+
588
+ // Step 4b needs to re-show nav buttons properly
589
+ const origShowStep = showStep;
590
+ showStep = function (step) {
591
+ if (step === '4b') {
592
+ btnNext.style.display = 'none';
593
+ btnBack.style.display = '';
594
+ btnBack.disabled = false;
595
+ }
596
+ origShowStep(step);
597
+ };
598
+
599
+ // =============================================
600
+ // Step 3: Project Setup
601
+ // =============================================
602
+
603
+ const projectNameInput = $('#project-name');
604
+ const projectDirInput = $('#project-dir');
605
+
606
+ projectNameInput.addEventListener('input', () => {
607
+ const name = projectNameInput.value.trim();
608
+ if (name && !projectDirInput.dataset.manual) {
609
+ const slug = name.toLowerCase().replace(/[^a-z0-9\-_\s]/g, '').replace(/\s+/g, '-');
610
+ fetch('/api/project/defaults')
611
+ .then((r) => r.json())
612
+ .then((data) => {
613
+ if (!projectDirInput.dataset.manual) {
614
+ projectDirInput.value = data.baseDir + '/' + slug;
615
+ }
616
+ })
617
+ .catch(() => {});
618
+ }
619
+ state.projectName = name;
620
+ });
621
+
622
+ projectDirInput.addEventListener('input', () => {
623
+ projectDirInput.dataset.manual = 'true';
624
+ state.projectDir = projectDirInput.value.trim();
625
+ });
626
+
627
+ // =============================================
628
+ // Step 4: PRD
629
+ // =============================================
630
+
631
+ $$('.tab').forEach((tab) => {
632
+ tab.addEventListener('click', () => {
633
+ $$('.tab').forEach((t) => {
634
+ t.classList.remove('active');
635
+ t.setAttribute('aria-selected', 'false');
636
+ });
637
+ $$('.tab-panel').forEach((p) => p.classList.remove('active'));
638
+ tab.classList.add('active');
639
+ tab.setAttribute('aria-selected', 'true');
640
+ const panel = $(`#tab-${tab.dataset.tab}`);
641
+ if (panel) panel.classList.add('active');
642
+ state.prdMode = tab.dataset.tab;
643
+ });
644
+ });
645
+
646
+ const validatePrdBtn = $('#validate-prd');
647
+ const prdStatus = $('#prd-status');
648
+
649
+ validatePrdBtn.addEventListener('click', async () => {
650
+ const content = $('#prd-paste').value.trim();
651
+ if (!content) { showStatus(prdStatus, 'error', 'Paste your PRD content first'); return; }
652
+
653
+ try {
654
+ const res = await fetch('/api/prd/validate', {
655
+ method: 'POST',
656
+ headers: { 'Content-Type': 'application/json', 'X-VoidForge-Request': '1' },
657
+ body: JSON.stringify({ content }),
658
+ });
659
+ const data = await res.json();
660
+
661
+ if (data.valid) {
662
+ showStatus(prdStatus, 'success', `Valid frontmatter: ${data.frontmatter.name || 'unnamed'} (${data.frontmatter.type || 'no type'})`);
663
+ } else {
664
+ showStatus(prdStatus, 'error', data.errors.join(', '));
665
+ }
666
+ } catch (err) {
667
+ showStatus(prdStatus, 'error', 'Validation error: ' + err.message);
668
+ }
669
+ });
670
+
671
+ let cachedPrompt = null;
672
+ $('#copy-prd-prompt').addEventListener('click', async () => {
673
+ const promptCopyStatus = $('#prompt-copy-status');
674
+ try {
675
+ if (!cachedPrompt) {
676
+ showStatus(promptCopyStatus, 'loading', 'Loading prompt...');
677
+ const res = await fetch('/api/prd/prompt');
678
+ const data = await res.json();
679
+ if (!res.ok) throw new Error(data.error || 'Failed to load prompt');
680
+ cachedPrompt = data.prompt;
681
+ }
682
+ await copyToClipboard(cachedPrompt);
683
+ showStatus(promptCopyStatus, 'success', 'Prompt copied — paste it into your AI of choice, add your idea, then paste the result above');
684
+ } catch (err) {
685
+ showStatus(promptCopyStatus, 'error', 'Failed to copy: ' + err.message);
686
+ }
687
+ });
688
+
689
+ const generatePrdBtn = $('#generate-prd');
690
+ const generationOutput = $('#generation-output');
691
+ const generatedContent = $('#generated-prd-content');
692
+
693
+ generatePrdBtn.addEventListener('click', async () => {
694
+ const idea = $('#prd-idea').value.trim();
695
+ if (!idea) {
696
+ showStatus($('#generate-status'), 'error', 'Describe your idea first');
697
+ return;
698
+ }
699
+ $('#generate-status').className = 'status-row';
700
+
701
+ generatePrdBtn.disabled = true;
702
+ generatePrdBtn.textContent = 'Generating...';
703
+ generationOutput.classList.remove('hidden');
704
+ generatedContent.textContent = '';
705
+ // ENCHANT-R2-006: Add streaming cursor
706
+ generatedContent.classList.add('streaming');
707
+ state.generatedPrd = '';
708
+
709
+ try {
710
+ const res = await fetch('/api/prd/generate', {
711
+ method: 'POST',
712
+ headers: { 'Content-Type': 'application/json', 'X-VoidForge-Request': '1' },
713
+ body: JSON.stringify({
714
+ idea,
715
+ name: state.projectName,
716
+ framework: $('#pref-framework').value,
717
+ database: $('#pref-database').value,
718
+ deploy: $('#pref-deploy').value,
719
+ }),
720
+ });
721
+
722
+ const reader = res.body.getReader();
723
+ const decoder = new TextDecoder();
724
+ let buffer = '';
725
+ let wasTruncated = false;
726
+
727
+ while (true) {
728
+ const { done, value } = await reader.read();
729
+ if (done) break;
730
+
731
+ buffer += decoder.decode(value, { stream: true });
732
+ const lines = buffer.split('\n');
733
+ buffer = lines.pop() || '';
734
+
735
+ for (const line of lines) {
736
+ if (!line.startsWith('data: ')) continue;
737
+ const data = line.slice(6);
738
+ if (data === '[DONE]') continue;
739
+
740
+ try {
741
+ const parsed = JSON.parse(data);
742
+ if (parsed.text) {
743
+ state.generatedPrd += parsed.text;
744
+ generatedContent.textContent = state.generatedPrd;
745
+ generatedContent.scrollTop = generatedContent.scrollHeight;
746
+ }
747
+ if (parsed.truncated) {
748
+ wasTruncated = true;
749
+ }
750
+ if (parsed.error) {
751
+ generatedContent.textContent += '\n\nError: ' + parsed.error;
752
+ }
753
+ } catch { /* skip */ }
754
+ }
755
+ }
756
+
757
+ if (wasTruncated) {
758
+ showStatus($('#generate-status'), 'error',
759
+ 'PRD was truncated — the output hit the model token limit. Try a shorter idea description or generate again with fewer details.');
760
+ }
761
+ } catch (err) {
762
+ generatedContent.textContent += '\n\nConnection error: ' + err.message;
763
+ } finally {
764
+ // ENCHANT-R2-006: Remove streaming cursor when done
765
+ generatedContent.classList.remove('streaming');
766
+ generatePrdBtn.disabled = false;
767
+ generatePrdBtn.textContent = 'Generate PRD with Claude';
768
+ }
769
+ });
770
+
771
+ $('#copy-generated').addEventListener('click', () => {
772
+ if (!state.generatedPrd) return;
773
+ copyToClipboard(state.generatedPrd).then(() => {
774
+ $('#copy-generated').textContent = 'Copied!';
775
+ setTimeout(() => { $('#copy-generated').textContent = 'Copy'; }, 2000);
776
+ });
777
+ });
778
+
779
+ // =============================================
780
+ // Step 4b: PRD-Driven Credentials
781
+ // =============================================
782
+
783
+ async function loadEnvRequirements(prdText) {
784
+ try {
785
+ const res = await fetch('/api/prd/env-requirements', {
786
+ method: 'POST',
787
+ headers: { 'Content-Type': 'application/json', 'X-VoidForge-Request': '1' },
788
+ body: JSON.stringify({ content: prdText }),
789
+ });
790
+ const data = await res.json();
791
+ return data.groups || [];
792
+ } catch {
793
+ return [];
794
+ }
795
+ }
796
+
797
+ function renderEnvCredentials(groups) {
798
+ const container = $('#env-credentials-list');
799
+ const emptyEl = $('#env-credentials-empty');
800
+ container.innerHTML = '';
801
+
802
+ if (groups.length === 0) {
803
+ emptyEl.classList.remove('hidden');
804
+ return;
805
+ }
806
+ emptyEl.classList.add('hidden');
807
+
808
+ for (const group of groups) {
809
+ const section = document.createElement('div');
810
+ section.className = 'card';
811
+ section.style.marginBottom = '12px';
812
+
813
+ let fieldsHtml = '';
814
+ for (const field of group.fields) {
815
+ fieldsHtml += `
816
+ <div class="field">
817
+ <label for="env-${field.key}">${escapeHtml(field.label)}</label>
818
+ <input type="${field.secret ? 'password' : 'text'}" id="env-${field.key}"
819
+ data-env-key="${field.key}" placeholder="${escapeHtml(field.placeholder)}" autocomplete="off">
820
+ </div>`;
821
+ }
822
+
823
+ section.innerHTML = `
824
+ <h3>${escapeHtml(group.name)}</h3>
825
+ ${fieldsHtml}`;
826
+ container.appendChild(section);
827
+ }
828
+ }
829
+
830
+ // Store All button
831
+ $('#store-env-credentials')?.addEventListener('click', async () => {
832
+ const statusEl = $('#env-store-status');
833
+ const inputs = $$('[data-env-key]');
834
+ const credentials = {};
835
+ let count = 0;
836
+
837
+ inputs.forEach((input) => {
838
+ if (input.value.trim()) {
839
+ credentials[input.dataset.envKey] = input.value.trim();
840
+ count++;
841
+ }
842
+ });
843
+
844
+ if (count === 0) {
845
+ showStatus(statusEl, 'info', 'No credentials entered — skipping.');
846
+ proceedFromEnvStep();
847
+ return;
848
+ }
849
+
850
+ showStatus(statusEl, 'loading', `Storing ${count} credentials in vault...`);
851
+ try {
852
+ const res = await fetch('/api/credentials/env-batch', {
853
+ method: 'POST',
854
+ headers: { 'Content-Type': 'application/json', 'X-VoidForge-Request': '1' },
855
+ body: JSON.stringify({ credentials }),
856
+ });
857
+ const data = await res.json();
858
+
859
+ if (res.ok && data.stored) {
860
+ state.envCredentials = credentials;
861
+ showStatus(statusEl, 'success', `${count} credentials stored in vault`);
862
+ setTimeout(proceedFromEnvStep, 600);
863
+ } else {
864
+ showStatus(statusEl, 'error', data.error || 'Failed to store credentials');
865
+ }
866
+ } catch (err) {
867
+ showStatus(statusEl, 'error', 'Connection error: ' + err.message);
868
+ }
869
+ });
870
+
871
+ // Skip button
872
+ $('#skip-env-credentials')?.addEventListener('click', () => {
873
+ proceedFromEnvStep();
874
+ });
875
+
876
+ function proceedFromEnvStep() {
877
+ Promise.all([loadDeployTargets(), loadCloudProviders()]).then(() => showStep(5));
878
+ }
879
+
880
+ // =============================================
881
+ // Step 5: Deploy Target
882
+ // =============================================
883
+
884
+ async function loadDeployTargets() {
885
+ try {
886
+ const res = await fetch('/api/cloud/deploy-targets');
887
+ const data = await res.json();
888
+
889
+ const container = $('#deploy-targets-list');
890
+ const noteEl = $('#deploy-note');
891
+ container.innerHTML = '';
892
+
893
+ if (!state.deployTarget) {
894
+ state.deployTarget = 'docker';
895
+ }
896
+
897
+ const availableTargets = data.targets.filter((t) => t.available);
898
+ const onlyDocker = availableTargets.length === 1 && availableTargets[0].id === 'docker';
899
+
900
+ if (onlyDocker) {
901
+ state.deployTarget = 'docker';
902
+ noteEl.innerHTML = '<div class="status-row info" style="margin-top: 16px;">No cloud providers configured, so <strong>Docker (local)</strong> is preselected. You can add cloud credentials later by re-running the wizard.</div>';
903
+ } else {
904
+ noteEl.innerHTML = '';
905
+ }
906
+
907
+ for (const target of data.targets) {
908
+ const card = document.createElement('div');
909
+ card.className = 'deploy-card' + (target.available ? '' : ' unavailable');
910
+ if (state.deployTarget === target.id) card.classList.add('selected');
911
+ card.dataset.target = target.id;
912
+ card.setAttribute('role', 'radio');
913
+ card.setAttribute('aria-checked', state.deployTarget === target.id ? 'true' : 'false');
914
+ if (target.available) {
915
+ card.tabIndex = 0;
916
+ } else {
917
+ card.setAttribute('aria-disabled', 'true');
918
+ }
919
+
920
+ const badge = target.available
921
+ ? '<span class="provider-badge connected">Ready</span>'
922
+ : `<span class="provider-badge not-connected">Needs ${target.provider || 'setup'}</span>`;
923
+
924
+ card.innerHTML = `
925
+ <div class="deploy-card-header">
926
+ <span class="deploy-card-name">${target.name}</span>
927
+ ${badge}
928
+ </div>
929
+ <div class="deploy-card-desc">${target.description}</div>`;
930
+
931
+ const selectTarget = () => {
932
+ if (!target.available) return;
933
+ $$('.deploy-card').forEach((c) => {
934
+ c.classList.remove('selected');
935
+ c.setAttribute('aria-checked', 'false');
936
+ });
937
+ card.classList.add('selected');
938
+ card.setAttribute('aria-checked', 'true');
939
+ state.deployTarget = target.id;
940
+ };
941
+ activateOnKeyboard(card, selectTarget);
942
+
943
+ container.appendChild(card);
944
+ }
945
+ } catch (err) {
946
+ $('#deploy-targets-list').innerHTML = `<div class="status-row error">Failed to load targets: ${escapeHtml(err.message)}</div>`;
947
+ }
948
+ }
949
+
950
+ // =============================================
951
+ // Step 6: Review
952
+ // =============================================
953
+
954
+ function populateReview() {
955
+ $('#review-name').textContent = state.projectName;
956
+ $('#review-dir').textContent = state.projectDir;
957
+ $('#review-desc').textContent = state.projectDesc || '(not set)';
958
+ $('#review-domain').textContent = state.projectDomain || '(not set)';
959
+ $('#review-hostname').textContent = state.projectHostname || '(not set)';
960
+ const deployNames = { vps: 'VPS (AWS EC2)', vercel: 'Vercel', railway: 'Railway', cloudflare: 'Cloudflare Workers/Pages', static: 'Static (S3 + CloudFront)', docker: 'Docker (local)' };
961
+ $('#review-deploy').textContent = deployNames[state.deployTarget] || state.deployTarget || 'Docker (local)';
962
+
963
+ if (state.prdMode === 'paste' && state.prdContent) {
964
+ $('#review-prd').textContent = 'Custom PRD (pasted)';
965
+ } else if (state.prdMode === 'generate' && state.generatedPrd) {
966
+ $('#review-prd').textContent = 'Generated by Claude';
967
+ } else {
968
+ $('#review-prd').textContent = 'Default template (edit later)';
969
+ }
970
+
971
+ // Show env credentials count if any were stored
972
+ const envCount = Object.keys(state.envCredentials).length;
973
+ const envRow = $('#review-env-credentials');
974
+ if (envRow) {
975
+ envRow.textContent = envCount > 0 ? `${envCount} keys stored in vault` : 'None (add to .env later)';
976
+ }
977
+ }
978
+
979
+ // =============================================
980
+ // Step 7: Create
981
+ // =============================================
982
+
983
+ async function createProject() {
984
+ const creatingState = $('#creating-state');
985
+ const statusText = $('#create-status-text');
986
+
987
+ creatingState.classList.remove('hidden');
988
+
989
+ try {
990
+ statusText.textContent = 'Creating project files...';
991
+ let prd = state.prdContent || undefined;
992
+
993
+ const res = await fetch('/api/project/create', {
994
+ method: 'POST',
995
+ headers: { 'Content-Type': 'application/json', 'X-VoidForge-Request': '1' },
996
+ body: JSON.stringify({
997
+ name: state.projectName,
998
+ directory: state.projectDir,
999
+ description: state.projectDesc || undefined,
1000
+ domain: state.projectDomain || undefined,
1001
+ hostname: state.projectHostname || undefined,
1002
+ deploy: state.deployTarget || undefined,
1003
+ prd,
1004
+ }),
1005
+ });
1006
+
1007
+ const data = await res.json();
1008
+
1009
+ if (res.ok && data.created) {
1010
+ state.createdDir = data.directory;
1011
+ // Show done state
1012
+ creatingState.classList.add('hidden');
1013
+ $('#done-state').classList.remove('hidden');
1014
+ $('#done-details').innerHTML = `
1015
+ <p><strong>${escapeHtml(state.projectName)}</strong></p>
1016
+ <p style="color: var(--text-dim); font-family: var(--mono); font-size: 13px;">${escapeHtml(data.directory)}</p>
1017
+ <p style="color: var(--text-dim); margin-top: 8px;">${data.files.length} files created</p>
1018
+ `;
1019
+ setTimeout(() => { const h = $('#step-7-heading'); if (h) h.focus(); }, 100);
1020
+ } else {
1021
+ showCreateError('Error: ' + (data.error || 'Unknown error'));
1022
+ }
1023
+ } catch (err) {
1024
+ showCreateError('Error: ' + err.message);
1025
+ }
1026
+ }
1027
+
1028
+ function showCreateError(message) {
1029
+ const creatingState = $('#creating-state');
1030
+ const statusText = $('#create-status-text');
1031
+ creatingState.querySelector('.spinner')?.classList.add('hidden');
1032
+ statusText.textContent = message;
1033
+ statusText.style.color = 'var(--error)';
1034
+ // Show back + retry buttons so the user isn't trapped
1035
+ btnBack.style.display = '';
1036
+ btnBack.disabled = false;
1037
+ btnNext.style.display = '';
1038
+ btnNext.textContent = 'Retry';
1039
+ }
1040
+
1041
+ $('#open-tower')?.addEventListener('click', () => {
1042
+ if (state.createdDir) {
1043
+ const name = encodeURIComponent(state.projectName);
1044
+ const dir = encodeURIComponent(state.createdDir);
1045
+ window.location.href = `/tower.html?name=${name}&dir=${dir}`;
1046
+ }
1047
+ });
1048
+
1049
+ $('#open-terminal')?.addEventListener('click', () => {
1050
+ if (state.createdDir) {
1051
+ const cmd = `cd "${state.createdDir}" && claude`;
1052
+ copyToClipboard(cmd).then(() => {
1053
+ alert(`Copied to clipboard:\n\n${cmd}\n\nPaste this in your terminal.`);
1054
+ }).catch(() => {
1055
+ alert(`Run this in your terminal:\n\n${cmd}`);
1056
+ });
1057
+ }
1058
+ });
1059
+
1060
+ $('#open-finder')?.addEventListener('click', () => {
1061
+ if (state.createdDir) {
1062
+ copyToClipboard(state.createdDir).then(() => {
1063
+ alert(`Path copied. Open Finder and press Cmd+Shift+G, then paste:\n\n${state.createdDir}`);
1064
+ });
1065
+ }
1066
+ });
1067
+
1068
+ // Provisioning moved to Haku (deploy wizard) — launch with `npm run deploy`
1069
+
1070
+ // =============================================
1071
+ // Step 5: Operations Menu — Card Expand/Collapse
1072
+ // =============================================
1073
+
1074
+ $$('.ops-card-header').forEach((header) => {
1075
+ function toggle() {
1076
+ const card = header.closest('.ops-card');
1077
+ const body = card.querySelector('.ops-card-body');
1078
+ const isOpen = !body.classList.contains('hidden');
1079
+ body.classList.toggle('hidden');
1080
+ card.classList.toggle('expanded', !isOpen);
1081
+ header.setAttribute('aria-expanded', isOpen ? 'false' : 'true');
1082
+ if (!isOpen) {
1083
+ const firstInput = body.querySelector('input, select');
1084
+ if (firstInput) setTimeout(() => firstInput.focus(), 100);
1085
+ }
1086
+ }
1087
+ header.addEventListener('click', toggle);
1088
+ header.addEventListener('keydown', (e) => {
1089
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); }
1090
+ });
1091
+ });
1092
+
1093
+ // Skip All button
1094
+ $('#ops-skip-all')?.addEventListener('click', () => {
1095
+ populateReview();
1096
+ showStep(6);
1097
+ });
1098
+
1099
+ // Update header when project name is typed (Éowyn's enchantment)
1100
+ projectNameInput.addEventListener('input', () => {
1101
+ const name = projectNameInput.value.trim();
1102
+ const logo = $('.logo');
1103
+ if (name) {
1104
+ logo.textContent = 'Gandalf — ' + name;
1105
+ } else {
1106
+ logo.textContent = 'Gandalf — VoidForge Setup';
1107
+ }
1108
+ });
1109
+
1110
+ // =============================================
1111
+ // Utilities
1112
+ // =============================================
1113
+
1114
+ function showValidationErrors() {
1115
+ if (currentStep === 3) {
1116
+ const nameInput = $('#project-name');
1117
+ const dirInput = $('#project-dir');
1118
+ if (!state.projectName) nameInput.style.borderColor = 'var(--error)';
1119
+ if (!state.projectDir) dirInput.style.borderColor = 'var(--error)';
1120
+ }
1121
+ }
1122
+
1123
+ function clearValidationErrors() {
1124
+ const nameInput = $('#project-name');
1125
+ const dirInput = $('#project-dir');
1126
+ if (nameInput) nameInput.style.borderColor = '';
1127
+ if (dirInput) dirInput.style.borderColor = '';
1128
+ }
1129
+
1130
+ function escapeHtml(str) {
1131
+ const div = document.createElement('div');
1132
+ div.appendChild(document.createTextNode(str));
1133
+ return div.innerHTML;
1134
+ }
1135
+
1136
+ function showStatus(el, type, message) {
1137
+ el.className = 'status-row ' + type;
1138
+ el.textContent = message;
1139
+ }
1140
+
1141
+ function copyToClipboard(text) {
1142
+ if (navigator.clipboard && navigator.clipboard.writeText) {
1143
+ return navigator.clipboard.writeText(text);
1144
+ }
1145
+ return new Promise((resolve, reject) => {
1146
+ const ta = document.createElement('textarea');
1147
+ ta.value = text;
1148
+ ta.style.position = 'fixed';
1149
+ ta.style.opacity = '0';
1150
+ document.body.appendChild(ta);
1151
+ ta.select();
1152
+ try {
1153
+ document.execCommand('copy');
1154
+ resolve();
1155
+ } catch (e) {
1156
+ reject(e);
1157
+ } finally {
1158
+ document.body.removeChild(ta);
1159
+ }
1160
+ });
1161
+ }
1162
+
1163
+ // Blueprint detection handlers
1164
+ const btnUseBlueprint = $('#btn-use-blueprint');
1165
+ const btnStartFresh = $('#btn-start-fresh');
1166
+
1167
+ if (btnUseBlueprint) {
1168
+ btnUseBlueprint.addEventListener('click', async () => {
1169
+ // User chose blueprint path — validate and show results
1170
+ const blueprintBanner = $('#blueprint-detection');
1171
+
1172
+ try {
1173
+ const res = await fetch('/api/blueprint/validate');
1174
+ const data = await res.json();
1175
+
1176
+ if (data.valid) {
1177
+ // Show validation success in the banner itself
1178
+ if (blueprintBanner) {
1179
+ blueprintBanner.innerHTML = `
1180
+ <h2 style="margin: 0 0 0.5rem 0; color: var(--success, #4ade80);">Blueprint Validated</h2>
1181
+ <p style="margin: 0 0 0.5rem 0;">${data.summary || 'PRD is valid and ready to build.'}</p>
1182
+ <p style="margin: 0 0 1rem 0; opacity: 0.8;">Run <code>/blueprint</code> in Claude Code to provision and start building.</p>
1183
+ <button type="button" onclick="this.closest('#blueprint-detection').classList.add('hidden'); document.querySelector('#step-4').classList.remove('hidden');"
1184
+ style="padding: 0.5rem 1rem; background: transparent; color: var(--text, #ccc); border: 1px solid var(--border, #333); border-radius: 6px; cursor: pointer;">
1185
+ Continue to wizard instead
1186
+ </button>
1187
+ `;
1188
+ }
1189
+ } else {
1190
+ // Show errors but don't dead-end — let user fix or continue
1191
+ if (blueprintBanner) {
1192
+ const errors = data.frontmatterErrors?.join('<br>') || 'Unknown validation error';
1193
+ blueprintBanner.innerHTML = `
1194
+ <h2 style="margin: 0 0 0.5rem 0; color: var(--warning, #fbbf24);">Blueprint Has Issues</h2>
1195
+ <p style="margin: 0 0 0.5rem 0; font-size: 0.9rem;">${errors}</p>
1196
+ <p style="margin: 0 0 1rem 0; opacity: 0.8;">Fix these in docs/PRD.md, or continue with the wizard interview.</p>
1197
+ <button type="button" onclick="this.closest('#blueprint-detection').classList.add('hidden'); document.querySelector('#step-4').classList.remove('hidden');"
1198
+ style="padding: 0.5rem 1rem; background: transparent; color: var(--text, #ccc); border: 1px solid var(--border, #333); border-radius: 6px; cursor: pointer;">
1199
+ Continue to wizard
1200
+ </button>
1201
+ `;
1202
+ }
1203
+ }
1204
+ } catch {
1205
+ // API unavailable — still show useful guidance, never dead-end
1206
+ if (blueprintBanner) {
1207
+ blueprintBanner.innerHTML = `
1208
+ <h2 style="margin: 0 0 0.5rem 0; color: var(--accent, #5b5bf7);">Blueprint Path Available</h2>
1209
+ <p style="margin: 0 0 1rem 0;">Run <code>/blueprint</code> in Claude Code to validate your PRD and provision infrastructure.</p>
1210
+ <button type="button" onclick="this.closest('#blueprint-detection').classList.add('hidden'); document.querySelector('#step-4').classList.remove('hidden');"
1211
+ style="padding: 0.5rem 1rem; background: transparent; color: var(--text, #ccc); border: 1px solid var(--border, #333); border-radius: 6px; cursor: pointer;">
1212
+ Continue to wizard
1213
+ </button>
1214
+ `;
1215
+ }
1216
+ }
1217
+ });
1218
+ }
1219
+
1220
+ if (btnStartFresh) {
1221
+ btnStartFresh.addEventListener('click', () => {
1222
+ const blueprintBanner = $('#blueprint-detection');
1223
+ if (blueprintBanner) blueprintBanner.classList.add('hidden');
1224
+ // Continue to Step 4 (PRD interview) normally
1225
+ showStep(4);
1226
+ });
1227
+ }
1228
+
1229
+ // Init
1230
+ showStep(1);
1231
+ })();