From f7f2cae9d09e51bb1bb31010a4d644e21a710d7f Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 12 Jun 2026 09:24:02 -0700 Subject: [PATCH] FN-6237: apply frontend UX criteria as workflow policy Move frontend UX criteria out of triage self-instructions and into deterministic workflow policy. - Add a shared frontend UX policy helper that matches frontend file scopes and injects the byte-equivalent checklist idempotently. - Apply the policy during triage finalization and persist the updated PROMPT.md when applicable. - Replace the legacy prompt-based injection instructions with a pointer to the deterministic policy. - Cover path matching, insertion behavior, and checklist parity with focused core tests. - Add a minor changeset for the published Fusion package. Files changed: .changeset/fn-6237-frontend-ux-policy.md | 5 + .../core/src/__tests__/frontend-ux-policy.test.ts | 119 +++++++++++++++++ packages/core/src/agent-prompts.ts | 29 +--- packages/core/src/frontend-ux-policy.ts | 148 +++++++++++++++++++++ packages/core/src/index.ts | 1 + packages/engine/src/triage.ts | 19 ++- 6 files changed, 291 insertions(+), 30 deletions(-) Fusion-Task-Id: FN-6237 Fusion-Task-Lineage: 7ac95064-9ec6-4760-8609-20c35075aca1 --- .changeset/fn-6237-frontend-ux-policy.md | 5 + .../src/__tests__/frontend-ux-policy.test.ts | 119 ++++++++++++++ packages/core/src/agent-prompts.ts | 29 +--- packages/core/src/frontend-ux-policy.ts | 148 ++++++++++++++++++ packages/core/src/index.ts | 1 + packages/engine/src/triage.ts | 19 ++- 6 files changed, 291 insertions(+), 30 deletions(-) create mode 100644 .changeset/fn-6237-frontend-ux-policy.md create mode 100644 packages/core/src/__tests__/frontend-ux-policy.test.ts create mode 100644 packages/core/src/frontend-ux-policy.ts diff --git a/.changeset/fn-6237-frontend-ux-policy.md b/.changeset/fn-6237-frontend-ux-policy.md new file mode 100644 index 0000000000..af14ffc440 --- /dev/null +++ b/.changeset/fn-6237-frontend-ux-policy.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Move Frontend UX criteria injection from AI self-instructions into deterministic engine-applied workflow policy, preserving the byte-equivalent checklist and idempotent insertion behavior. diff --git a/packages/core/src/__tests__/frontend-ux-policy.test.ts b/packages/core/src/__tests__/frontend-ux-policy.test.ts new file mode 100644 index 0000000000..298bc96b00 --- /dev/null +++ b/packages/core/src/__tests__/frontend-ux-policy.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; +import { + FRONTEND_UX_CRITERIA_SECTION, + applyFrontendUxCriteria, + matchesFrontendUxPath, +} from "../frontend-ux-policy.js"; +import { WORKFLOW_STEP_TEMPLATES } from "../types.js"; + +const EXACT_FRONTEND_UX_CRITERIA = `## Frontend UX Criteria + +- [ ] **Design tokens only** — no hardcoded \`px\` values except \`0\`, no hardcoded hex/rgb colors; use CSS custom properties (\`--color-*\`, \`--spacing-*\`, etc.) +- [ ] **Icon sizing** — match the surrounding component's icon size convention (default lucide size unless the local pattern already uses an explicit \`size={N}\`) +- [ ] **Semantic color tokens for status** — use \`--color-error\` for stderr/error states, \`--color-warning\` for starting/pending states; never hardcode status colors +- [ ] **Component reuse** — reach for existing classes (\`.btn\`, \`.btn-icon\`, \`.card\`, \`.input\`) before writing one-off styles +- [ ] **Responsive scaffolding** — add \`@media (max-width: 768px)\` overrides for any new layout; verify mobile usability +- [ ] **Single canonical nav destination** — each route must appear in exactly one of: Header primary nav, Header overflow menu, or MobileNavBar More; no duplicates across all three +- [ ] **Status-indicator dot convention** — use the existing \`.status-dot\` pattern (size, border, animation) rather than custom dot styling +- [ ] **Visual hierarchy preserved** — new elements must not disrupt heading levels, content flow, or information architecture established in the surrounding page +`; + +function promptWithFileScope(paths: string[]): string { + return `# Task: FN-0000 - Example + +## Mission + +Implement the requested change without disturbing surrounding behavior. + +## File Scope + +${paths.map((path) => `- \`${path}\``).join("\n")} + +## Acceptance Criteria + +- Works as expected +`; +} + +function extractInsertedCriteria(prompt: string): string { + const start = prompt.indexOf("## Frontend UX Criteria"); + expect(start).toBeGreaterThanOrEqual(0); + const rest = prompt.slice(start); + expect(rest.slice(FRONTEND_UX_CRITERIA_SECTION.length)).toMatch(/^\n## File Scope/); + return rest.slice(0, FRONTEND_UX_CRITERIA_SECTION.length); +} + +describe("frontend UX policy", () => { + it("preserves the byte-exact criteria section fixture", () => { + expect(FRONTEND_UX_CRITERIA_SECTION).toBe(EXACT_FRONTEND_UX_CRITERIA); + expect(FRONTEND_UX_CRITERIA_SECTION.endsWith("\n")).toBe(true); + expect(FRONTEND_UX_CRITERIA_SECTION.endsWith("\n\n")).toBe(false); + }); + + it.each([ + ["dashboard package", "packages/dashboard/src/server.ts"], + ["app components", "packages/plugin/app/components/Button.tsx"], + ["app hooks", "packages/plugin/app/hooks/useThing.ts"], + ["app css", "packages/plugin/app/layout.css"], + ["app tsx", "packages/plugin/app/routes.tsx"], + ])("injects exactly once after Mission for %s scope", (_label, path) => { + const original = promptWithFileScope([path]); + const injected = applyFrontendUxCriteria(original); + + expect(injected).toContain(FRONTEND_UX_CRITERIA_SECTION); + expect(extractInsertedCriteria(injected)).toBe(FRONTEND_UX_CRITERIA_SECTION); + expect(injected.match(/## Frontend UX Criteria/g)).toHaveLength(1); + expect(injected).toMatch(/## Mission\n\nImplement the requested change without disturbing surrounding behavior\.\n\n## Frontend UX Criteria\n\n- \[ \] \*\*Design tokens only\*\*/); + expect(applyFrontendUxCriteria(injected)).toBe(injected); + }); + + it.each([ + ["backend", "packages/engine/src/triage.ts"], + ["config json", "package.json"], + ["eslint config", "eslint.config.mjs"], + ["docs", "docs/dashboard-guide.md"], + ["dashboard src css excluded from rule 4 but matched by dashboard package", "packages/dashboard/src/styles.css", true], + ["component css covered by component rule, not rule 4", "packages/plugin/app/components/Button.css", true], + ])("matches the expected frontend classification for %s", (_label, path, expected = false) => { + expect(matchesFrontendUxPath(path)).toBe(expected); + }); + + it("does not inject for backend-only, config-only, or docs-only file scopes", () => { + for (const path of ["packages/engine/src/triage.ts", "package.json", "eslint.config.mjs", "docs/testing.md"]) { + const original = promptWithFileScope([path]); + expect(applyFrontendUxCriteria(original)).toBe(original); + } + }); + + it("uses caller-provided file scope paths without reparsing prompt markdown", () => { + const promptWithoutFileScope = `# Task: FN-0000 - Example + +## Mission + +Implement dashboard UI. + +## Acceptance Criteria + +- Works as expected +`; + + const injected = applyFrontendUxCriteria(promptWithoutFileScope, ["packages/dashboard/app/routes.tsx"]); + + expect(injected).toContain(FRONTEND_UX_CRITERIA_SECTION); + expect(injected.match(/## Frontend UX Criteria/g)).toHaveLength(1); + }); + + it("keeps checklist tokens aligned with the frontend UX design persona", () => { + const persona = WORKFLOW_STEP_TEMPLATES.find((template) => template.id === "frontend-ux-design"); + expect(persona?.name).toBe("Frontend UX Design"); + expect(persona?.prompt).toContain("design tokens"); + expect(persona?.prompt).toContain("Component Reuse"); + expect(persona?.prompt).toContain("Responsive Behavior"); + expect(persona?.prompt).toContain("Visual Hierarchy"); + + expect(FRONTEND_UX_CRITERIA_SECTION).toContain("Design tokens only"); + expect(FRONTEND_UX_CRITERIA_SECTION).toContain("Component reuse"); + expect(FRONTEND_UX_CRITERIA_SECTION).toContain("Responsive scaffolding"); + expect(FRONTEND_UX_CRITERIA_SECTION).toContain("Visual hierarchy preserved"); + }); +}); diff --git a/packages/core/src/agent-prompts.ts b/packages/core/src/agent-prompts.ts index fb9f6a125b..178684dc61 100644 --- a/packages/core/src/agent-prompts.ts +++ b/packages/core/src/agent-prompts.ts @@ -508,34 +508,7 @@ If the task targets a different task ID (audit, forensic walk, historical reconc - \`.fusion/\` is gitignored, so a fresh worktree from \`main\` does **not** include \`.fusion/tasks/{TARGET_ID}/\` or \`.fusion/fusion.db\`. The running worktree's own \`.fusion/\` (if present) is scratch/session state for the running task only, not source of truth. - Prefer \`fn_task_get\` / \`fn_task_list\` when the target task ID is known; fall back to project-root filesystem reads only when tools cannot provide needed evidence. -## Frontend UX Criteria Injection - - - -If the derived **File Scope** touches any of the following paths: -- \`packages/dashboard/**\` -- \`packages/*/app/components/**\` -- \`packages/*/app/hooks/**\` -- Any \`*.css\` or \`*.tsx\` file inside a dashboard-like package - -…then **PREPEND** a \`## Frontend UX Criteria\` section to the generated PROMPT.md, placed immediately after the \`## Mission\` section. - -Use this exact checklist (keep it verbatim — do not expand or reorder): - -\`\`\`markdown -## Frontend UX Criteria - -- [ ] **Design tokens only** — no hardcoded \`px\` values except \`0\`, no hardcoded hex/rgb colors; use CSS custom properties (\`--color-*\`, \`--spacing-*\`, etc.) -- [ ] **Icon sizing** — match the surrounding component's icon size convention (default lucide size unless the local pattern already uses an explicit \`size={N}\`) -- [ ] **Semantic color tokens for status** — use \`--color-error\` for stderr/error states, \`--color-warning\` for starting/pending states; never hardcode status colors -- [ ] **Component reuse** — reach for existing classes (\`.btn\`, \`.btn-icon\`, \`.card\`, \`.input\`) before writing one-off styles -- [ ] **Responsive scaffolding** — add \`@media (max-width: 768px)\` overrides for any new layout; verify mobile usability -- [ ] **Single canonical nav destination** — each route must appear in exactly one of: Header primary nav, Header overflow menu, or MobileNavBar More; no duplicates across all three -- [ ] **Status-indicator dot convention** — use the existing \`.status-dot\` pattern (size, border, animation) rather than custom dot styling -- [ ] **Visual hierarchy preserved** — new elements must not disrupt heading levels, content flow, or information architecture established in the surrounding page -\`\`\` - -Only inject this section when the task genuinely touches frontend UI. Omit it for backend-only, config-only, or documentation-only tasks.`;; +`;; // FN-6235: single source for the built-in reviewer policy; the engine REVIEWER_SYSTEM_PROMPT duplicate was removed. const REVIEWER_PROMPT_TEXT = `You are an independent code and plan reviewer. diff --git a/packages/core/src/frontend-ux-policy.ts b/packages/core/src/frontend-ux-policy.ts new file mode 100644 index 0000000000..a1114e4576 --- /dev/null +++ b/packages/core/src/frontend-ux-policy.ts @@ -0,0 +1,148 @@ +/** + * Frontend UX criteria policy for generated task specifications. + * + * The checklist mirrors the `frontend-ux-design` reviewer persona in + * packages/core/src/types.ts. Keep this policy byte-equivalent with the legacy + * triage prompt checklist and idempotent when applied to generated PROMPT.md + * content. + * + * Rule 4 deterministically expands the legacy "Any CSS or TSX file inside a + * dashboard-like package" rule: match files under a package `app/` directory + * ending in `.css` or `.tsx`, excluding the `components/` and `hooks/` subtrees + * already covered by rules 2 and 3. + */ +export const FRONTEND_UX_PATH_GLOBS = [ + "packages/dashboard/**", + "packages/*/app/components/**", + "packages/*/app/hooks/**", + "packages/*/app/**/*.css", + "packages/*/app/**/*.tsx", +] as const; + +/** + * Byte-exact Frontend UX Criteria section copied from the legacy triage prompt. + * The section intentionally ends with exactly one trailing newline. + */ +export const FRONTEND_UX_CRITERIA_SECTION = `## Frontend UX Criteria + +- [ ] **Design tokens only** — no hardcoded \`px\` values except \`0\`, no hardcoded hex/rgb colors; use CSS custom properties (\`--color-*\`, \`--spacing-*\`, etc.) +- [ ] **Icon sizing** — match the surrounding component's icon size convention (default lucide size unless the local pattern already uses an explicit \`size={N}\`) +- [ ] **Semantic color tokens for status** — use \`--color-error\` for stderr/error states, \`--color-warning\` for starting/pending states; never hardcode status colors +- [ ] **Component reuse** — reach for existing classes (\`.btn\`, \`.btn-icon\`, \`.card\`, \`.input\`) before writing one-off styles +- [ ] **Responsive scaffolding** — add \`@media (max-width: 768px)\` overrides for any new layout; verify mobile usability +- [ ] **Single canonical nav destination** — each route must appear in exactly one of: Header primary nav, Header overflow menu, or MobileNavBar More; no duplicates across all three +- [ ] **Status-indicator dot convention** — use the existing \`.status-dot\` pattern (size, border, animation) rather than custom dot styling +- [ ] **Visual hierarchy preserved** — new elements must not disrupt heading levels, content flow, or information architecture established in the surrounding page +`; + +const FRONTEND_UX_HEADING = "## Frontend UX Criteria"; + +/** + * Pure deterministic injection helper. When `fileScopePaths` is omitted, the + * helper parses `## File Scope` from the supplied prompt markdown using the same + * section shape as the engine triage parser. + */ +export function applyFrontendUxCriteria(promptMarkdown: string, fileScopePaths?: string[]): string { + if (promptMarkdown.includes(FRONTEND_UX_HEADING)) { + return promptMarkdown; + } + + const paths = fileScopePaths ?? parseFileScopeFromPromptMarkdown(promptMarkdown); + if (!paths.some((path) => matchesFrontendUxPath(path))) { + return promptMarkdown; + } + + return insertFrontendUxCriteriaAfterMission(promptMarkdown); +} + +export function matchesFrontendUxPath(path: string): boolean { + const normalized = normalizePath(path); + if (!normalized) return false; + + if (matchGlob(normalized, FRONTEND_UX_PATH_GLOBS[0])) return true; + if (matchGlob(normalized, FRONTEND_UX_PATH_GLOBS[1])) return true; + if (matchGlob(normalized, FRONTEND_UX_PATH_GLOBS[2])) return true; + + const isAppCssOrTsx = matchGlob(normalized, FRONTEND_UX_PATH_GLOBS[3]) + || matchGlob(normalized, FRONTEND_UX_PATH_GLOBS[4]); + if (!isAppCssOrTsx) return false; + + return !matchGlob(normalized, FRONTEND_UX_PATH_GLOBS[1]) + && !matchGlob(normalized, FRONTEND_UX_PATH_GLOBS[2]); +} + +function parseFileScopeFromPromptMarkdown(text: string): string[] { + const match = text.match(/^##\s+File Scope\s*\n([\s\S]*?)(?=^##\s+|$)/m); + if (!match) return []; + + const entries: string[] = []; + for (const line of match[1].split("\n")) { + const cleaned = normalizeFileScopeLine(line); + if (cleaned) entries.push(cleaned); + } + return entries; +} + +function normalizeFileScopeLine(line: string): string { + let cleaned = line.trim(); + if (!cleaned || cleaned.startsWith("