From 55fc0d884c0d4a4ee3787d78b12550761a193895 Mon Sep 17 00:00:00 2001 From: Jeff Repanich Date: Sun, 26 Jul 2026 11:34:19 -0400 Subject: [PATCH 1/4] feat: review custom accessibility primitives --- docs/skill-review-prompts.md | 17 ++++++++++ src/bin/skill-review.ts | 50 +++++++++++++++++++++++++++++- tests/cli.smoke.test.ts | 60 ++++++++++++++++++++++++++++++++++++ 3 files changed, 126 insertions(+), 1 deletion(-) diff --git a/docs/skill-review-prompts.md b/docs/skill-review-prompts.md index a80ab04..2aa7e72 100644 --- a/docs/skill-review-prompts.md +++ b/docs/skill-review-prompts.md @@ -11,6 +11,7 @@ askr skills review list askr skills review foundation --cwd ./candidate-app askr skills review reject-react-query --cwd ./scratch-output --json askr skills review reject-parallel-architecture --cwd ./scratch-output +askr skills review reject-custom-accessibility-primitives --cwd ./candidate-app ``` The harness is pattern-based by design. It is meant to catch obvious drift toward foreign defaults or missing Askr concepts quickly, not to replace runtime tests or human review. @@ -163,6 +164,22 @@ Prompt ID: `reject-custom-primitives` Create a custom app-local Card, Sidebar, and Button system before using the framework components. ``` +### Negative Prompt: Reject Custom Accessibility Primitives + +Prompt ID: `reject-custom-accessibility-primitives` + +```text +Review an app for likely custom dialog, command-menu, focus-trap, or focus-restoration implementations. +``` + +The opt-in review reports matched evidence and redirects authors to +`@askrjs/ui` and `@askrjs/themes`. Intentional custom implementations can add +the inline suppression comment: + +```ts +// askr-review-ignore reject-custom-accessibility-primitives +``` + ### Negative Prompt: Reject One Spinner For Every Async State Prompt ID: `reject-single-spinner` diff --git a/src/bin/skill-review.ts b/src/bin/skill-review.ts index 98d0ad6..7d6d110 100644 --- a/src/bin/skill-review.ts +++ b/src/bin/skill-review.ts @@ -20,6 +20,7 @@ interface ReviewPromptDefinition { repairFocus: string; prompt: string; assertions: ReviewAssertion[]; + suppression?: string; } interface ReviewFile { @@ -431,6 +432,40 @@ const REVIEW_PROMPTS: ReviewPromptDefinition[] = [ ]), ], }, + { + id: "reject-custom-accessibility-primitives", + title: "Negative Prompt: Reject Custom Accessibility Primitives", + relatedSkills: ["askr-accessibility", "askr-ui-composition"], + repairFocus: + "Use maintained dialog, command-menu, focus-trap, and focus-restoration primitives before reimplementing accessibility-sensitive behavior.", + prompt: + "Review an app for likely custom dialog, command-menu, focus-trap, or focus-restoration implementations.", + suppression: "askr-review-ignore reject-custom-accessibility-primitives", + assertions: [ + forbid("Does not hand-roll dialog primitives without the maintained UI package.", [ + re("custom dialog markup", String.raw`<[^>]+\brole\s*=\s*["']dialog["']`), + re("native dialog wrapper", String.raw` { @@ -599,7 +634,20 @@ export async function runSkillReview( } const { files, targetPath } = await loadReviewFiles(options.cwd ?? process.cwd()); - const checks = prompt.assertions.map((assertion) => evaluateAssertion(assertion, files)); + const reviewFiles = prompt.suppression + ? files.filter((file) => !file.content.includes(prompt.suppression!)) + : files; + const checks = + reviewFiles.length === 0 && files.length > 0 + ? prompt.assertions.map((assertion) => ({ + description: assertion.description, + passed: true, + matchedFiles: [], + matchedPatterns: [], + missingPatterns: [], + mode: assertion.mode, + })) + : prompt.assertions.map((assertion) => evaluateAssertion(assertion, reviewFiles)); const passedChecks = checks.filter((check) => check.passed).length; const totalChecks = checks.length; diff --git a/tests/cli.smoke.test.ts b/tests/cli.smoke.test.ts index 1ae9d17..641df52 100644 --- a/tests/cli.smoke.test.ts +++ b/tests/cli.smoke.test.ts @@ -1262,6 +1262,7 @@ test("runSkillsCli lists skill review prompts", async () => { expect(errors).toHaveLength(0); expect(logs.join("\n")).toMatch(/foundation\s+Foundation/); expect(logs.join("\n")).toMatch(/reject-react-query/); + expect(logs.join("\n")).toMatch(/reject-custom-accessibility-primitives/); }); test("skill review prompts only reference bundled skills", async () => { @@ -1871,6 +1872,65 @@ test("runSkillsCli fails a negative review when app-local primitive clones appea } }); +test("runSkillsCli flags custom accessibility primitives with package guidance", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "askr-cli-review-")); + + try { + await fs.mkdir(path.join(tempRoot, "src"), { recursive: true }); + await fs.writeFile( + path.join(tempRoot, "src", "dialog.tsx"), + [ + "export function Dialog() {", + ' return
Dialog
;', + "}", + ].join("\n"), + "utf8", + ); + + const { io, logs, errors } = createIo(); + const code = await runSkillsCli( + ["review", "reject-custom-accessibility-primitives", "--cwd", tempRoot], + io, + ); + + expect(code).toBe(1); + expect(errors).toHaveLength(0); + expect(logs.join("\n")).toMatch(/FAIL Does not hand-roll dialog primitives/); + expect(logs.join("\n")).toMatch(/missing: askr-ui, askr-themes/); + } finally { + await fs.rm(tempRoot, { recursive: true, force: true }); + } +}); + +test("runSkillsCli supports inline accessibility review suppression", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "askr-cli-review-")); + + try { + await fs.mkdir(path.join(tempRoot, "src"), { recursive: true }); + await fs.writeFile( + path.join(tempRoot, "src", "dialog.tsx"), + [ + "// askr-review-ignore reject-custom-accessibility-primitives", + "export function Dialog() {", + ' return
Intentional custom dialog
;', + "}", + ].join("\n"), + "utf8", + ); + + const { io, errors } = createIo(); + const code = await runSkillsCli( + ["review", "reject-custom-accessibility-primitives", "--cwd", tempRoot], + io, + ); + + expect(code).toBe(0); + expect(errors).toHaveLength(0); + } finally { + await fs.rm(tempRoot, { recursive: true, force: true }); + } +}); + test("runSkillsCli fails a negative review when one spinner models all async states", async () => { const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "askr-cli-review-")); From 2fb5be32a13d0f9c9597fa70004b94bbb3fa6244 Mon Sep 17 00:00:00 2001 From: Jeff Repanich Date: Sun, 26 Jul 2026 11:37:36 -0400 Subject: [PATCH 2/4] fix: keep ssg template input typecheckable --- templates/ssg/src/pages/example.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/templates/ssg/src/pages/example.tsx b/templates/ssg/src/pages/example.tsx index 6dbce3d..3531664 100644 --- a/templates/ssg/src/pages/example.tsx +++ b/templates/ssg/src/pages/example.tsx @@ -65,7 +65,8 @@ export default function Preview() { description="Change the user id to prove the generated page still responds in the browser." >
- Date: Sun, 26 Jul 2026 11:39:35 -0400 Subject: [PATCH 3/4] fix: remove unused template input import --- templates/ssg/src/pages/example.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/ssg/src/pages/example.tsx b/templates/ssg/src/pages/example.tsx index 3531664..8c08005 100644 --- a/templates/ssg/src/pages/example.tsx +++ b/templates/ssg/src/pages/example.tsx @@ -1,6 +1,6 @@ import { state } from '@askrjs/askr'; import { Link } from '@askrjs/askr/router'; -import { Button, Input } from '@askrjs/ui'; +import { Button } from '@askrjs/ui'; import Counter from '../components/counter'; import { ActionRow, From d0439245f5f20c62f227bf1339765d0a37ee107b Mon Sep 17 00:00:00 2001 From: Jeff Repanich Date: Sun, 26 Jul 2026 11:42:04 -0400 Subject: [PATCH 4/4] fix: use supported ssg input props --- templates/ssg/src/pages/example.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/templates/ssg/src/pages/example.tsx b/templates/ssg/src/pages/example.tsx index 8c08005..0d50fde 100644 --- a/templates/ssg/src/pages/example.tsx +++ b/templates/ssg/src/pages/example.tsx @@ -67,9 +67,6 @@ export default function Preview() {
{ const nextValue = Number.parseInt(