diff --git a/plugins/shipguard/README.md b/plugins/shipguard/README.md index c256691..5fd6baa 100644 --- a/plugins/shipguard/README.md +++ b/plugins/shipguard/README.md @@ -44,6 +44,37 @@ Mark bugs directly on screenshots. The AI traces each annotation to source code | `/sg-visual-fix` | Auto-fix bugs annotated in the review dashboard | | `/sg-visual-review-stop` | Stop the review server | +### Client Validation Reports + +ShipGuard can generate client-validation HTML reports from the same visual evidence. The primary use case is simple: give a client or stakeholder a focused page where they can compare before/after screenshots, choose `Accept / Adjust / Reject`, and export comments as JSON. + +The same report can be adapted by recipient persona. Put a `report.json` in: + +```text +visual-tests/_results/change-reports//report.json +``` + +Then run `/sg-visual-review`. The dashboard builder creates: + +```text +visual-tests/_results/persona-reports//client.html +visual-tests/_results/persona-reports//product.html +visual-tests/_results/persona-reports//design.html +visual-tests/_results/persona-reports//engineering.html +``` + +Each page adapts the same change set to the recipient: + +| Audience | What it emphasizes | +|----------|--------------------| +| Client | Plain-language choices, before/after evidence, `Accept / Adjust / Reject` decisions | +| Business | Outcome, priority, residual risk | +| Product | Scope, acceptance criteria, route/test references | +| Design | UX rationale, interaction tradeoffs, visual comparison | +| Engineering | Files, tests, implementation boundaries | + +Use this when a client or stakeholder needs to validate UI direction without reading the full technical dashboard. The generated pages are static, served by the same review server, and include local comments plus JSON export. + ### Smart Annotations (Gemini-style) The review dashboard uses **draggable annotation cards** to mark visual bugs on screenshots. Click anywhere on a screenshot to place a pin, then describe the problem. diff --git a/plugins/shipguard/skills/sg-visual-review/SKILL.md b/plugins/shipguard/skills/sg-visual-review/SKILL.md index 277e83f..2e2338d 100644 --- a/plugins/shipguard/skills/sg-visual-review/SKILL.md +++ b/plugins/shipguard/skills/sg-visual-review/SKILL.md @@ -40,6 +40,74 @@ This script: 4. Matches screenshots from `visual-tests/_results/screenshots/` 5. Generates a self-contained `visual-tests/_results/review.html` (inline CSS + JS, no dependencies) 6. If `monitor-data.json` exists in `_results/`, a "Monitor" tab appears showing the Gantt timeline of the last audit +7. If change-report specs exist, generates persona-aware HTML reports under `visual-tests/_results/persona-reports/` + +### Client Validation Reports + +Use this when the report must be validated by a client or by different recipients: client, product, design, engineering, executive, or any custom audience. The generated pages are decision surfaces: before/after evidence, plain rationale, `Accept / Adjust / Reject`, free-form comments, and JSON export. + +Create a spec at: + +```text +visual-tests/_results/change-reports//report.json +``` + +Put screenshots next to it, usually under: + +```text +visual-tests/_results/change-reports//screenshots/ +``` + +Then run: + +```bash +node visual-tests/build-review.mjs --serve +``` + +ShipGuard generates: + +```text +visual-tests/_results/persona-reports/index.html +visual-tests/_results/persona-reports//index.html +visual-tests/_results/persona-reports//.html +``` + +Each audience page adapts the same evidence: +- `client` focuses on plain-language choices and validation. +- `business` focuses on outcome and residual risk. +- `product` focuses on priority, acceptance, route/test references. +- `design` focuses on UX rationale and before/after evidence. +- `engineering` focuses on files, tests, implementation boundaries. + +Each generated page includes local comments, `Accept / Adjust / Reject` decisions, and JSON export. This is the reusable ShipGuard layer; project-specific apps should consume it instead of hand-building one-off reports. + +Minimal `report.json` shape: + +```json +{ + "id": "checkout-redesign", + "title": "Checkout redesign", + "summary": "Decision report for checkout UX changes.", + "route": "/checkout", + "audiences": ["client", "product", "design", "engineering"], + "changes": [ + { + "id": "payment-summary", + "title": "Payment summary is now persistent", + "problem": "Users lost context while scrolling.", + "decision": "Keep the summary visible during payment.", + "impact": "Reduces uncertainty before confirmation.", + "choices": ["Keep sticky", "Use collapsible", "Revert"], + "tests": ["checkout/payment"], + "files": ["src/components/checkout/payment-form.tsx"], + "before": { "src": "screenshots/before.png", "caption": "Previous state" }, + "after": { "src": "screenshots/after.png", "caption": "New state" } + } + ] +} +``` + +Full example: `skills/sg-visual-review/examples/change-report.json`. ### Step 2: Open in Browser @@ -132,6 +200,7 @@ The build script and template are installed to the project: | `visual-tests/build-review.mjs` | Node.js build script | | `visual-tests/_review-template.html` | HTML template with inline CSS + JS | | `visual-tests/_results/review.html` | Generated output (not committed) | +| `visual-tests/_results/persona-reports/` | Generated audience-specific reports | ## Setup diff --git a/plugins/shipguard/skills/sg-visual-review/build-review.mjs b/plugins/shipguard/skills/sg-visual-review/build-review.mjs index a1f14b6..8fb2a2e 100644 --- a/plugins/shipguard/skills/sg-visual-review/build-review.mjs +++ b/plugins/shipguard/skills/sg-visual-review/build-review.mjs @@ -129,6 +129,8 @@ const REPORT_PATH = join(RESULTS_DIR, 'report.md'); const REGRESSIONS_PATH = join(ROOT, '_regressions.yaml'); const CONFIG_PATH = join(ROOT, '_config.yaml'); const OUTPUT_PATH = join(RESULTS_DIR, 'review.html'); +const CHANGE_REPORTS_DIR = join(RESULTS_DIR, 'change-reports'); +const PERSONA_REPORTS_DIR = join(RESULTS_DIR, 'persona-reports'); // Dynamically discover test categories by scanning subdirectories (fixes #20) const CATEGORIES = readdirSync(ROOT, { withFileTypes: true }) @@ -313,6 +315,317 @@ function getHtmlTemplate() { return readFileSync(join(ROOT, '_review-template.html'), 'utf8'); } +// ── 7. Persona-aware change reports ── +const DEFAULT_REPORT_AUDIENCES = { + client: { + id: 'client', + label: 'Client validation', + badge: 'Decision view', + focus: 'Choose what feels right and leave clear validation comments.', + sections: ['impact', 'choices', 'risk'], + }, + business: { + id: 'business', + label: 'Business stakeholder', + badge: 'Outcome view', + focus: 'Understand the business outcome, tradeoffs, and remaining risk.', + sections: ['impact', 'priority', 'risk'], + }, + product: { + id: 'product', + label: 'Product', + badge: 'Roadmap view', + focus: 'Evaluate scope, priority, acceptance criteria, and next decisions.', + sections: ['problem', 'impact', 'priority', 'tests'], + }, + design: { + id: 'design', + label: 'Design / UX', + badge: 'UX rationale', + focus: 'Review before/after evidence, interaction rationale, and visual tradeoffs.', + sections: ['problem', 'decision', 'impact', 'risk'], + }, + engineering: { + id: 'engineering', + label: 'Engineering', + badge: 'Implementation view', + focus: 'Check files, tests, technical risks, and implementation boundaries.', + sections: ['decision', 'tests', 'files', 'risk'], + }, +}; + +function escapeHtml(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function slugify(value) { + return String(value || 'report') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'report'; +} + +function readJson(path) { + return JSON.parse(readFileSync(path, 'utf8')); +} + +function asArray(value) { + if (!value) return []; + return Array.isArray(value) ? value : [value]; +} + +function normalizeAudience(value) { + if (typeof value === 'string') { + const known = DEFAULT_REPORT_AUDIENCES[value]; + return known ? { ...known } : { + id: slugify(value), + label: value, + badge: 'Custom view', + focus: 'Review the change report from this audience perspective.', + sections: ['problem', 'decision', 'impact', 'risk'], + }; + } + if (value && typeof value === 'object') { + const id = slugify(value.id || value.label || 'custom'); + const known = DEFAULT_REPORT_AUDIENCES[id] || {}; + return { + ...known, + ...value, + id, + label: value.label || known.label || id, + badge: value.badge || known.badge || 'Custom view', + focus: value.focus || known.focus || 'Review the change report from this audience perspective.', + sections: asArray(value.sections || known.sections || ['problem', 'decision', 'impact', 'risk']), + }; + } + return null; +} + +function normalizeChangeReport(id, raw) { + if (!raw || typeof raw !== 'object') throw new Error(`Invalid report.json for ${id}: expected object`); + const changes = Array.isArray(raw.changes) ? raw.changes : null; + if (!changes) throw new Error(`Invalid report.json for ${id}: changes must be an array`); + const configuredAudiences = raw.audiences || raw.personas || ['client', 'product', 'design', 'engineering']; + const audiences = asArray(configuredAudiences).map(normalizeAudience).filter(Boolean); + if (audiences.length === 0) throw new Error(`Invalid report.json for ${id}: at least one audience is required`); + return { + id: slugify(raw.id || id), + title: raw.title || id, + subtitle: raw.subtitle || raw.summary || '', + summary: raw.summary || raw.subtitle || '', + route: raw.route || raw.url || '', + generatedAt: raw.generated_at || raw.generatedAt || new Date().toISOString(), + status: raw.status || 'draft', + links: Array.isArray(raw.links) ? raw.links : [], + audiences, + changes: changes.map((change, index) => ({ + id: slugify(change.id || `change-${index + 1}`), + title: change.title || `Change ${index + 1}`, + summary: change.summary || '', + problem: change.problem || '', + decision: change.decision || change.solution || '', + impact: change.impact || '', + choices: asArray(change.choices), + priority: change.priority || change.severity || '', + risk: change.risk || change.residual_risk || '', + tests: asArray(change.tests), + files: asArray(change.files), + tags: asArray(change.tags), + before: normalizeShot(change.before), + after: normalizeShot(change.after), + })), + }; +} + +function normalizeShot(value) { + if (!value) return null; + if (typeof value === 'string') return { src: value, caption: '' }; + if (typeof value === 'object' && value.src) { + return { + src: String(value.src), + caption: value.caption || value.label || '', + alt: value.alt || value.caption || value.label || '', + }; + } + return null; +} + +function collectChangeReports() { + if (!existsSync(CHANGE_REPORTS_DIR)) return []; + const reports = []; + for (const entry of readdirSync(CHANGE_REPORTS_DIR, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const reportPath = join(CHANGE_REPORTS_DIR, entry.name, 'report.json'); + if (!existsSync(reportPath)) continue; + reports.push(normalizeChangeReport(entry.name, readJson(reportPath))); + } + return reports; +} + +function reportAssetHref(report, src) { + if (!src) return ''; + if (/^(https?:|data:|blob:)/.test(src)) return src; + const cleaned = src.replace(/^\.?\//, ''); + return `../../change-reports/${encodeURIComponent(report.id)}/${cleaned.split('/').map(encodeURIComponent).join('/')}`; +} + +function renderMaybe(label, value) { + if (!value || (Array.isArray(value) && value.length === 0)) return ''; + const body = Array.isArray(value) + ? `
    ${value.map(v => `
  • ${escapeHtml(v)}
  • `).join('')}
` + : `

${escapeHtml(value)}

`; + return `
${escapeHtml(label)}${body}
`; +} + +function renderShot(report, label, shot) { + if (!shot) { + return `
${escapeHtml(label)}No screenshot provided
`; + } + const href = reportAssetHref(report, shot.src); + return ` + +
${escapeHtml(label)}${escapeHtml(shot.caption || '')}
+ ${escapeHtml(shot.alt || shot.caption || label)} +
`; +} + +function audienceFacts(change, audience) { + const sections = new Set(audience.sections || []); + const facts = []; + if (sections.has('problem')) facts.push(renderMaybe('Problem', change.problem)); + if (sections.has('decision')) facts.push(renderMaybe('Decision', change.decision)); + if (sections.has('impact')) facts.push(renderMaybe('Expected impact', change.impact)); + if (sections.has('choices')) facts.push(renderMaybe('Choices to validate', change.choices)); + if (sections.has('priority')) facts.push(renderMaybe('Priority', change.priority)); + if (sections.has('tests')) facts.push(renderMaybe('Tests / routes', change.tests)); + if (sections.has('files')) facts.push(renderMaybe('Files', change.files)); + if (sections.has('risk')) facts.push(renderMaybe('Residual risk', change.risk)); + return facts.filter(Boolean).join(''); +} + +function renderAudienceReport(report, audience) { + const title = `${report.title} - ${audience.label}`; + const storageKey = `shipguard:persona-report:${report.id}:${audience.id}`; + const changeCards = report.changes.map(change => ` +
+
+
+

${escapeHtml(change.title)}

+

${escapeHtml(change.summary || change.impact || change.problem || '')}

+
+ ${change.tags.length ? `
${change.tags.map(tag => `${escapeHtml(tag)}`).join('')}
` : ''} +
+
+ ${renderShot(report, 'Before', change.before)} + ${renderShot(report, 'After', change.after)} +
+
${audienceFacts(change, audience)}
+
+ +
+ + + +
+
+
`).join(''); + + const links = report.links.map(link => { + if (!link || !link.href) return ''; + return `${escapeHtml(link.label || link.href)}`; + }).join(''); + + return ` + + + + +${escapeHtml(title)} + + + +
+
ShipGuard${escapeHtml(audience.badge)}
+

${escapeHtml(title)}

+

${escapeHtml(audience.focus)}

+
+
${report.changes.length}changes to review
+
${escapeHtml(report.status)}report status
+
${escapeHtml(report.route || 'n/a')}route / flow
+
${escapeHtml(new Date(report.generatedAt).toISOString().slice(0, 10))}generated
+
+
+
+
+

Report context

+

${escapeHtml(report.summary || report.subtitle || 'No summary provided.')}

+
+ All audiences + + + ${links} +
+
+
${changeCards}
+ +
+ + +`; +} + +function renderAudienceIndex(report) { + const links = report.audiences.map(audience => ` + + ${escapeHtml(audience.label)} + ${escapeHtml(audience.focus)} + `).join(''); + return `${escapeHtml(report.title)} - ShipGuard audiences

${escapeHtml(report.title)}

${escapeHtml(report.summary || 'Choose the audience-specific view to review this change report.')}

${links}
`; +} + +function renderReportsIndex(generated) { + const links = generated.map(item => `${escapeHtml(item.title)}${item.audiences} audience views`).join(''); + return `ShipGuard Persona Reports

ShipGuard Persona Reports

Audience-specific reports generated from change-report specs.

${links || '

No reports generated.

'}
`; +} + +function generatePersonaReports() { + const reports = collectChangeReports(); + if (reports.length === 0) return 0; + mkdirSync(PERSONA_REPORTS_DIR, { recursive: true }); + const generated = []; + for (const report of reports) { + const outDir = join(PERSONA_REPORTS_DIR, report.id); + mkdirSync(outDir, { recursive: true }); + writeFileSync(join(outDir, 'index.html'), renderAudienceIndex(report), 'utf8'); + for (const audience of report.audiences) { + writeFileSync(join(outDir, `${audience.id}.html`), renderAudienceReport(report, audience), 'utf8'); + } + generated.push({ id: report.id, title: report.title, audiences: report.audiences.length }); + } + writeFileSync(join(PERSONA_REPORTS_DIR, 'index.html'), renderReportsIndex(generated), 'utf8'); + return generated.reduce((sum, item) => sum + item.audiences + 1, 1); +} + // ── Main ── console.log('Building Visual review page...'); @@ -426,6 +739,11 @@ if (!process.argv.includes('--stop')) { console.log(` Thumbnails: ${thumbCount}/${tests.filter(t => t.screenshot).length}`); } +const personaReportCount = generatePersonaReports(); +if (personaReportCount > 0) { + console.log(` Persona reports: ${personaReportCount} pages`); +} + // ── Server PID file ── const PID_FILE = join(RESULTS_DIR, '.server.pid'); diff --git a/plugins/shipguard/skills/sg-visual-review/examples/change-report.json b/plugins/shipguard/skills/sg-visual-review/examples/change-report.json new file mode 100644 index 0000000..38efda6 --- /dev/null +++ b/plugins/shipguard/skills/sg-visual-review/examples/change-report.json @@ -0,0 +1,54 @@ +{ + "id": "checkout-redesign", + "title": "Checkout redesign", + "summary": "Decision report for the checkout UX changes. Each audience receives a focused view of the same evidence.", + "route": "/checkout", + "status": "ready-for-review", + "audiences": [ + "client", + "product", + "design", + "engineering" + ], + "links": [ + { + "label": "Open Visual Review", + "href": "../../review.html" + } + ], + "changes": [ + { + "id": "payment-summary", + "title": "Payment summary is now persistent", + "summary": "The basket summary remains visible while the user edits payment details.", + "problem": "Users lost context when scrolling between payment fields and basket details.", + "decision": "Keep the summary visible in the right column on desktop and above the payment form on mobile.", + "impact": "Reduces uncertainty before payment confirmation.", + "choices": [ + "Keep the sticky summary", + "Use a collapsible summary on mobile", + "Return to the previous compact basket" + ], + "priority": "High", + "risk": "Mobile height must be checked on small devices.", + "tests": [ + "checkout/payment" + ], + "files": [ + "src/components/checkout/payment-form.tsx" + ], + "tags": [ + "conversion", + "trust" + ], + "before": { + "src": "screenshots/payment-summary-before.png", + "caption": "Previous checkout without persistent summary" + }, + "after": { + "src": "screenshots/payment-summary-after.png", + "caption": "New checkout with persistent summary" + } + } + ] +}