diff --git a/scripts/build-frames-site.mjs b/scripts/build-frames-site.mjs index 9ed51f6d..6e4b2bf5 100644 --- a/scripts/build-frames-site.mjs +++ b/scripts/build-frames-site.mjs @@ -31,6 +31,107 @@ const escapeHtml = (s) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] ); +// Where approval decisions are recorded. A GitHub issue is the durable, auditable +// record; the design-approval workflow (or a maintainer) reads it and flips the +// per-flow `approved` in the manifest. Prefilled via plain query params — the only +// form of issue prefill that is 100% reliable (dropdown prefill is not). +const REPO_SLUG = 'izzywdev/FuzeFront'; +const PAGES_BASE = 'https://izzywdev.github.io/FuzeFront'; + +/** The per-flow list is the source of truth for approval: `build.flows`. (Some + * legacy manifests carried approvable entries under `frames`; fall back to that + * only if `build.flows` is absent, so the two never disagree silently.) */ +function flowsOf(manifest) { + const bf = manifest?.build?.flows; + if (Array.isArray(bf) && bf.length) return bf; + const legacy = Array.isArray(manifest?.frames) + ? manifest.frames.filter((f) => typeof f.approved === 'boolean') + : []; + return legacy; +} + +/** New-issue URL that records a decision for one flow. Approve and Reject differ + * only in the prefilled `decision:` line, so the reviewer's click IS the record. */ +function approvalHref(slug, flow, decision, stamp) { + const flowId = flow.id ?? flow.orchestrator ?? 'flow'; + const title = `design-approval: ${decision} ${slug} / ${flowId}`; + const body = [ + '', + '', + '```yaml', + `feature: ${slug}`, + `flow: ${flowId}`, + `route: ${flow.route ?? ''}`, + `decision: ${decision}`, + `stamp: ${stamp ?? ''}`, + '```', + '', + `Frames: ${PAGES_BASE}/${slug}/`, + '', + decision === 'reject' ? '**Reason for rejection (required):**' : '_Optional note:_', + '', + ].join('\n'); + const q = new URLSearchParams({ title, body, labels: 'design-approval' }); + return `https://github.com/${REPO_SLUG}/issues/new?${q.toString()}`; +} + +/** Fixed approval bar injected into every published frame of a feature, so the + * reviewer can approve/reject from wherever they are in the flow — not only the + * index. Derived entirely from the manifest's flow list. */ +function renderApprovalBar(slug, manifest) { + const flows = flowsOf(manifest); + const stamp = manifest?.stamp ? String(manifest.stamp).slice(0, 12) : ''; + if (!flows.length) { + return `
No flows declared in manifest.build.flows — nothing to approve.
`; + } + const rows = flows + .map((f) => { + const id = escapeHtml(f.id ?? f.orchestrator ?? 'flow'); + const done = f.approved === true; + const state = done + ? `approved${f.approvedBy ? ' · ' + escapeHtml(f.approvedBy) : ''}` + : `pending`; + const actions = done + ? '' + : `Approve` + + `Reject`; + return `
${id}${state}${actions}
`; + }) + .join(''); + return `
+
+ Review · ${escapeHtml(slug)} approve or reject each flow ↓ +
${rows}
+
+
+`; +} + +/** Inject the approval bar into an HTML document just before (or append + * if none). Idempotent-ish: skips if already present. */ +function injectApprovalBar(html, bar) { + if (html.includes('data-ff-approve')) return html; + if (html.includes('')) return html.replace('', `${bar}\n`); + return html + bar; +} + /** Feature directories present on disk — the source of truth. `_`-prefixed dirs * (e.g. `_template`) are scaffolding, not features awaiting review. */ async function discoverFeatures() { @@ -65,8 +166,9 @@ async function discoverFeatures() { * model) or the legacy top-level `approved`. Reported, never asserted. */ function approvalOf(manifest) { if (!manifest) return { label: 'no manifest', state: 'unknown' }; - const flows = Array.isArray(manifest.frames) ? manifest.frames : []; - const perFlow = flows.filter((f) => typeof f.approved === 'boolean'); + // Per-flow approval lives in build.flows (the source of truth); flowsOf falls + // back to the legacy `frames` shape so old and new manifests read consistently. + const perFlow = flowsOf(manifest).filter((f) => typeof f.approved === 'boolean'); if (perFlow.length > 0) { const yes = perFlow.filter((f) => f.approved).length; if (yes === perFlow.length) return { label: `approved (${yes}/${perFlow.length} flows)`, state: 'approved' }; @@ -189,9 +291,19 @@ async function main() { } const features = await discoverFeatures(); await mkdir(outDir, { recursive: true }); - // Copy every feature directory verbatim (frames reference tokens.css relatively). + // Copy every feature directory, then inject the approval bar into each published + // .html so the reviewer can approve/reject any flow from wherever they are. for (const f of features) { - await cp(path.join(framesDir, f.slug), path.join(outDir, f.slug), { recursive: true }); + const dest = path.join(outDir, f.slug); + await cp(path.join(framesDir, f.slug), dest, { recursive: true }); + const bar = renderApprovalBar(f.slug, f.manifest); + const htmlFiles = (await readdir(dest, { withFileTypes: true })) + .filter((e) => e.isFile() && e.name.endsWith('.html')) + .map((e) => path.join(dest, e.name)); + for (const file of htmlFiles) { + const html = await readFile(file, 'utf8'); + await writeFile(file, injectApprovalBar(html, bar), 'utf8'); + } } await writeFile(path.join(outDir, 'index.html'), renderIndex(features), 'utf8'); // Jekyll would otherwise skip `_`-prefixed paths and mangle the output.