diff --git a/src/accessibility.test.ts b/src/accessibility.test.ts new file mode 100644 index 0000000..8cf3834 --- /dev/null +++ b/src/accessibility.test.ts @@ -0,0 +1,101 @@ +// @ts-expect-error - node fs in vitest environment +import fs from "node:fs"; +// @ts-expect-error - node path in vitest environment +import path from "node:path"; +import { describe, it, expect } from "vitest"; + +declare const process: { cwd: () => string }; + +// WCAG relative luminance calculation +function hslToRgb(h: number, s: number, l: number): [number, number, number] { + s /= 100; + l /= 100; + const c = (1 - Math.abs(2 * l - 1)) * s; + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); + const m = l - c / 2; + let r = 0, + g = 0, + b = 0; + if (0 <= h && h < 60) [r, g, b] = [c, x, 0]; + else if (60 <= h && h < 120) [r, g, b] = [x, c, 0]; + else if (120 <= h && h < 180) [r, g, b] = [0, c, x]; + else if (180 <= h && h < 240) [r, g, b] = [0, x, c]; + else if (240 <= h && h < 300) [r, g, b] = [x, 0, c]; + else if (300 <= h && h < 360) [r, g, b] = [c, 0, x]; + return [(r + m) * 255, (g + m) * 255, (b + m) * 255]; +} + +function getLuminance(r: number, g: number, b: number): number { + const [rs, gs, bs] = [r, g, b].map((v) => { + v /= 255; + return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); + }); + return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs; +} + +function getContrastRatio(rgb1: [number, number, number], rgb2: [number, number, number]): number { + const lum1 = getLuminance(...rgb1); + const lum2 = getLuminance(...rgb2); + const brightest = Math.max(lum1, lum2); + const darkest = Math.min(lum1, lum2); + return (brightest + 0.05) / (darkest + 0.05); +} + +describe("Accessibility Standards", () => { + const cssPath = path.resolve(process.cwd(), "src/index.css"); + const indexCss = fs.readFileSync(cssPath, "utf-8"); + + it("ensures dark mode borders hit at least 3:1 contrast ratio against background", () => { + // Extract dark mode border and background + const darkSectionMatch = indexCss.match(/\.dark\s*\{([^}]+)\}/); + expect(darkSectionMatch).toBeTruthy(); + const darkSection = darkSectionMatch![1]; + + const borderMatch = darkSection.match(/--border:\s*(\d+)\s+(\d+)%\s+(\d+)%/); + const bgMatch = darkSection.match(/--background:\s*(\d+)\s+(\d+)%\s+(\d+)%/); + + expect(borderMatch).toBeTruthy(); + expect(bgMatch).toBeTruthy(); + + const borderHsl: [number, number, number] = [ + parseInt(borderMatch![1], 10), + parseInt(borderMatch![2], 10), + parseInt(borderMatch![3], 10), + ]; + const bgHsl: [number, number, number] = [ + parseInt(bgMatch![1], 10), + parseInt(bgMatch![2], 10), + parseInt(bgMatch![3], 10), + ]; + + const borderRgb = hslToRgb(...borderHsl); + const bgRgb = hslToRgb(...bgHsl); + const contrastRatio = getContrastRatio(borderRgb, bgRgb); + + expect(contrastRatio).toBeGreaterThanOrEqual(3.0); + }); + + it("includes prefers-reduced-motion block in index.css", () => { + expect(indexCss).toContain("@media (prefers-reduced-motion: reduce)"); + expect(indexCss).toMatch(/animation-duration:\s*0\.01ms/); + expect(indexCss).toMatch(/transition-duration:\s*0\.01ms/); + }); + + it("does not have text below 12px in source files", () => { + const sourceFiles = import.meta.glob("./**/*.{tsx,ts,css}", { + query: "?raw", + import: "default", + eager: true, + }); + const sub12pxRegex = /text-\[(?:[0-9]|1[0-1])px\]/; + + const offendingFiles: string[] = []; + for (const [filePath, content] of Object.entries(sourceFiles)) { + if (!filePath.includes("accessibility.test.ts") && sub12pxRegex.test(content)) { + offendingFiles.push(filePath); + } + } + + expect(offendingFiles).toEqual([]); + }); +}); diff --git a/src/components/prompts/PromptCard.test.tsx b/src/components/prompts/PromptCard.test.tsx index 5d3c220..7bca073 100644 --- a/src/components/prompts/PromptCard.test.tsx +++ b/src/components/prompts/PromptCard.test.tsx @@ -1,11 +1,14 @@ -import { render, screen } from "@testing-library/react"; -import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; import { MemoryRouter } from "react-router-dom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { PromptCard } from "./PromptCard"; +let mockUser: { id: string } | null = null; +let mockProfile: { id: string } | null = null; + vi.mock("@/hooks/useAuth", () => ({ - useAuth: () => ({ user: null, profile: null, loading: false }), + useAuth: () => ({ user: mockUser, profile: mockProfile, loading: false }), })); vi.mock("@/hooks/use-toast", () => ({ @@ -52,6 +55,11 @@ describe("PromptCard", () => { tags: ["portrait", "realistic"], }; + beforeEach(() => { + mockUser = null; + mockProfile = null; + }); + it("renders accuracy rating when provided explicitly with ratingCount > 0", () => { renderPromptCard({ ...baseProps, @@ -71,4 +79,45 @@ describe("PromptCard", () => { expect(ratingElement).toBeInTheDocument(); expect(ratingElement).toHaveTextContent("Not rated"); }); + + it("renders mobile menu trigger with legible overlay styling without hardcoded text-black", () => { + renderPromptCard(baseProps); + + const [mobileTrigger] = screen.getAllByLabelText("More options"); + expect(mobileTrigger).toBeInTheDocument(); + expect(mobileTrigger.className).toContain("rounded-full"); + expect(mobileTrigger.className).toContain("backdrop-blur-sm"); + expect(mobileTrigger.className).not.toContain("text-black"); + }); + + it("opens delete confirmation in an accessible Radix dialog for prompt owner", async () => { + mockUser = { id: "creator-1" }; + mockProfile = { id: "creator-1" }; + renderPromptCard(baseProps); + + // Click desktop dropdown trigger (second "More options" button) to view owner actions + const [, desktopTrigger] = screen.getAllByLabelText("More options"); + fireEvent.pointerDown(desktopTrigger, { button: 0, ctrlKey: false }); + fireEvent.keyDown(desktopTrigger, { key: "ArrowDown" }); + + const deleteMenuItem = screen.getByText("Delete"); + fireEvent.click(deleteMenuItem); + + // Radix dialog should be present with role="dialog" + const dialog = screen.getByRole("dialog"); + expect(dialog).toBeInTheDocument(); + expect(screen.getByText("Delete Prompt?")).toBeInTheDocument(); + expect( + screen.getByText("This will permanently delete this prompt and its image. This cannot be undone.") + ).toBeInTheDocument(); + + const cancelButton = screen.getByRole("button", { name: "Cancel" }); + const deleteButton = screen.getByRole("button", { name: "Delete" }); + expect(cancelButton).toBeInTheDocument(); + expect(deleteButton).toBeInTheDocument(); + + // Clicking cancel should dismiss the dialog + fireEvent.click(cancelButton); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); }); diff --git a/src/components/prompts/PromptCard.tsx b/src/components/prompts/PromptCard.tsx index 3d39b21..cf39ee4 100644 --- a/src/components/prompts/PromptCard.tsx +++ b/src/components/prompts/PromptCard.tsx @@ -25,6 +25,15 @@ import { DrawerTitle, DrawerTrigger, } from "@/components/ui/drawer"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; interface PromptCardProps { id: string; @@ -257,10 +266,10 @@ export function PromptCard({ @@ -619,7 +628,7 @@ export function PromptCard({ aria-label="Not yet rated" > - Not rated + Not rated )} @@ -641,32 +650,32 @@ export function PromptCard({ /> {/* Delete Confirmation Dialog */} - {showDeleteDialog && ( -
setShowDeleteDialog(false)}> -
e.stopPropagation()}> -

Delete Prompt?

-

+

+ + + Delete Prompt? + This will permanently delete this prompt and its image. This cannot be undone. -

-
- - -
-
-
- )} + + + + + + + + ); } \ No newline at end of file diff --git a/src/components/prompts/SharePromptDialog.tsx b/src/components/prompts/SharePromptDialog.tsx index 6356c08..c8decc6 100644 --- a/src/components/prompts/SharePromptDialog.tsx +++ b/src/components/prompts/SharePromptDialog.tsx @@ -199,7 +199,7 @@ export function SharePromptDialog({ > - + {target.name} @@ -214,7 +214,7 @@ export function SharePromptDialog({ - + More diff --git a/src/index.css b/src/index.css index bc41914..25ec516 100644 --- a/src/index.css +++ b/src/index.css @@ -114,8 +114,8 @@ --destructive: 0 50% 45%; --destructive-foreground: 38 45% 90%; - --border: 220 12% 16%; - --input: 220 12% 16%; + --border: 220 12% 40%; + --input: 220 12% 40%; --ring: 38 50% 55%; --sidebar-background: 220 15% 8%; @@ -124,10 +124,21 @@ --sidebar-primary-foreground: 220 15% 6%; --sidebar-accent: 220 12% 14%; --sidebar-accent-foreground: 38 45% 85%; - --sidebar-border: 220 12% 16%; + --sidebar-border: 220 12% 40%; --sidebar-ring: 38 50% 55%; } + @media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } + } + * { @apply border-border; box-sizing: border-box; diff --git a/src/pages/PromptDetail.tsx b/src/pages/PromptDetail.tsx index 5fce845..f53ce18 100644 --- a/src/pages/PromptDetail.tsx +++ b/src/pages/PromptDetail.tsx @@ -403,7 +403,7 @@ export default function PromptDetail() { > {accuracyRating.toFixed(1)} - ({ratingCount}) + ({ratingCount}) ) : ( - Not rated + Not rated )} @@ -490,7 +490,7 @@ export default function PromptDetail() {

Prompt Accuracy Rating

-

+

How consistently this prompt delivers the expected result

@@ -503,7 +503,7 @@ export default function PromptDetail() { {accuracyRating.toFixed(1)} / 5.0 -
+
{ratingCount} {ratingCount === 1 ? 'rating' : 'ratings'}
@@ -512,7 +512,7 @@ export default function PromptDetail() {
Not yet rated
-
+
Be the first to rate