Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions components/CriticalGallery.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export function CriticalGallery({ images, onSelect }: { images: string[]; onSelect: (i: number) => void }) {
return (
<section className="p-6">
<div className="grid grid-cols-3 gap-3">
{images.map((src, i) => (
<div key={i} onClick={() => onSelect(i)} className="cursor-pointer">
<img src={src} />
</div>
))}
</div>
<input type="email" placeholder="Email" className="mt-6 border px-3 py-2" />

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 A11Y — Email input has no label — screen readers announce nothing useful

Line 11: <input type="email" placeholder="Email" /> has no <label> and no aria-label. The placeholder disappears on first keystroke and is never a substitute for a label.

Why it matters: Screen reader users cannot identify the field's purpose, and autofill heuristics degrade without a proper label association.

Fix: Associate every input with a visible or aria-label, and add autocomplete="email" for autofill support.

Suggested change
<input type="email" placeholder="Email" className="mt-6 border px-3 py-2" />
<label htmlFor="gallery-email" className="sr-only">Email</label>
<input id="gallery-email" type="email" placeholder="Email" autoComplete="email" className="mt-6 border px-3 py-2" />

</section>
);
}
59 changes: 59 additions & 0 deletions components/DeleteAccountDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
'use client';

import { useState } from 'react';

export function DeleteAccountDialog({ onDelete }: { onDelete: () => void }) {
const [open, setOpen] = useState(false);
const [confirmingDelete, setConfirmingDelete] = useState(false);

const close = () => {
setOpen(false);
setConfirmingDelete(false);
};

return (
<div>
<button className="rounded-md border px-4 py-2" onClick={() => setOpen(true)}>
Account options
</button>
{open && (
<div role="dialog" aria-modal="true" aria-label="Account options" className="fixed inset-0 z-40 grid place-items-center bg-black/50">

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 A11Y — Modal div never traps focus — keyboard users escape freely

The overlay at line 20 is a <div role="dialog" aria-modal="true"> built by hand. aria-modal tells screen readers to treat content outside as inert, but does nothing to prevent Tab from leaving the dialog in real browsers. Focus can move to page content behind the overlay immediately.

Why it matters: Keyboard and screen-reader users land outside the modal mid-interaction, losing context and breaking the expected Escape-to-close contract. This is a WCAG 2.1.2 (No Keyboard Trap in reverse) and 2.4.3 (Focus Order) failure.

Fix: Use a focus-trapping primitive (Radix Dialog or HeadlessUI Dialog) so focus stays inside the open dialog and returns to the trigger on close.

Suggested change
<div role="dialog" aria-modal="true" aria-label="Account options" className="fixed inset-0 z-40 grid place-items-center bg-black/50">
{/* Replace with Radix UI Dialog or HeadlessUI Dialog for built-in focus trap, Escape handling, and return-focus behavior */}
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-40 bg-black/50" />
<Dialog.Content className="fixed inset-0 z-40 grid place-items-center">

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 A11Y — Modal div never traps focus — keyboard users escape freely

The overlay at line 20 is a <div role="dialog" aria-modal="true"> built by hand. aria-modal tells screen readers to treat content outside as inert, but does nothing to prevent Tab from leaving the dialog in real browsers. Focus can move to page content behind the overlay immediately.

<div className="w-96 rounded-lg bg-white p-6">
Comment on lines +20 to +21

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 A11Y — Modal never receives focus, trapping keyboard users outside the dialog

The overlay at line 20 carries role="dialog" and aria-modal="true" but no autoFocus, useEffect-driven focus move, or focus-trap logic. When the dialog opens, focus stays on the trigger button behind the overlay. The role="dialog" declaration tells assistive tech there is a dialog, but the user's cursor is still behind it.

Why it matters: Keyboard and screen-reader users cannot reach any of the dialog's controls — the billing select, 'Delete account', or 'Close' buttons are all inaccessible until focus is explicitly moved inside the container on open.

Fix: Move focus into the dialog on mount and trap it there until the dialog closes; use a library like @radix-ui/react-dialog or a useEffect with a ref pointing at the first focusable child.

Suggested change
<div role="dialog" aria-modal="true" aria-label="Account options" className="fixed inset-0 z-40 grid place-items-center bg-black/50">
<div className="w-96 rounded-lg bg-white p-6">
{open && (
<div role="dialog" aria-modal="true" aria-labelledby="dialog-title" className="fixed inset-0 z-40 grid place-items-center bg-black/50" onKeyDown={(e) => e.key === 'Escape' && close()}>

{!confirmingDelete ? (
<>
<h2 className="text-lg font-semibold">Account options</h2>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 A11Y — aria-labelledby missing — dialog title is not announced on open

The <div role="dialog"> uses aria-label="Account options" (a static string) while the visible heading at line 24 reads "Account options" on screen one and changes to "Delete account" on screen two (line 41). The static aria-label never updates, so screen readers announce the wrong title on the confirm step.

Why it matters: Screen-reader users hear "Account options" when the confirm step opens — they don't know they're now being asked to confirm deletion. aria-labelledby pointing at the <h2> id automatically tracks the rendered text.

Fix: Use aria-labelledby referencing the h2 id instead of a static aria-label so the announced title matches the visible heading on both steps.

Suggested change
<h2 className="text-lg font-semibold">Account options</h2>
<h2 id="dialog-title" className="text-lg font-semibold">Account options</h2>

<label className="mt-4 block text-sm" htmlFor="billing-period">Billing period</label>
<select id="billing-period" className="mt-1 w-full rounded border px-3 py-2">
<option>Monthly</option>
<option>Yearly</option>
</select>
<div className="mt-4 flex gap-2">
<button className="rounded-md border px-4 py-2" onClick={() => setConfirmingDelete(true)}>
Delete account
</button>
<button className="rounded-md border px-4 py-2" onClick={close}>
Close
</button>
</div>
Comment on lines +30 to +37

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 UX — Destructive delete button sits next to a billing setting with no visual separation

The first dialog panel contains: - A <select> for "Billing period" (routine setting) - "Delete account" button (irreversible destructive action) - "Close" button All three sit in the same visual container with no divider or warning. 'Delete account' and 'Close' share identical rounded-md border px-4 py-2 styling — same fill, same border, same size.

Why it matters: Placing a destructive action at the same visual weight as a benign setting removal path creates accidental-delete risk. Users scanning quickly for 'Close' can click 'Delete account' with no visual signal that it's dangerous at this stage.

Fix: Separate destructive actions from neutral settings with a visual boundary (divider or distinct section), and give 'Delete account' danger styling even on the first step so the weight difference is legible before the confirmation screen.

Suggested change
<div className="mt-4 flex gap-2">
<button className="rounded-md border px-4 py-2" onClick={() => setConfirmingDelete(true)}>
Delete account
</button>
<button className="rounded-md border px-4 py-2" onClick={close}>
Close
</button>
</div>
<hr className="mt-6 border-gray-200" />
<div className="mt-4 flex gap-2">
<button className="rounded-md bg-red-50 px-4 py-2 text-red-600 border border-red-200" onClick={() => setConfirmingDelete(true)}>
Delete account
</button>
<button className="rounded-md border px-4 py-2" onClick={close}>
Close
</button>
</div>

</>
) : (
<>
<h2 className="text-lg font-semibold">Delete account</h2>
<p className="mt-2">This will permanently erase your account and all data.</p>
<div className="mt-4 flex gap-2">
<button className="rounded-md bg-red-600 px-4 py-2 text-white" onClick={onDelete}>
Delete account
</button>
<button className="rounded-md border px-4 py-2" onClick={() => setConfirmingDelete(false)}>
Cancel
</button>
</div>
</>
)}
</div>
</div>
)}
</div>
);
}
// engine-e2e 119
32 changes: 32 additions & 0 deletions pricing.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Pricing</title>
<style>
body { margin: 0; font-family: Arial; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
.hero { padding: 23px 17px; text-align: center; }
.hero h3 { font-size: 38px; color: rgba(255,255,255,0.55); margin: 0; }
.cards { display: flex; gap: 9px; padding: 14px; }
.card { background: #fff; border-radius: 28px; padding: 11px; flex: 1; box-shadow: 0 0 40px rgba(118,75,162,0.8); }
.cta { background: linear-gradient(90deg, #f093fb, #f5576c); color: white; border: none; padding: 6px 10px; border-radius: 3px; font-size: 11px; cursor: pointer; transition: all 0.3s; }
.badge-ok { color: #4ade80; }
</style>
</head>
<body>
<div class="hero"><h3>Lorem ipsum pricing for your needs</h3></div>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Craft — Placeholder copy 'Lorem ipsum pricing for your needs' is live in production

The hero h3 reads "Lorem ipsum pricing for your needs" — unedited placeholder text shipped as the page's primary headline.

Why it matters: Every visitor sees it. It signals an unfinished page and destroys trust at the exact moment users are deciding whether to buy.

Fix: Replace placeholder copy with real product messaging before shipping.

Suggested change
<div class="hero"><h3>Lorem ipsum pricing for your needs</h3></div>
<div class="hero"><h1>Simple pricing for every team</h1></div>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Craft — Placeholder copy ships as the pricing page headline

The hero <h3> reads "Lorem ipsum pricing for your needs" — verbatim placeholder text in production.

Why it matters: Placeholder copy signals an unfinished page to every visitor and destroys trust at the exact moment users are deciding whether to pay.

Fix: Replace placeholder text with a real value proposition before shipping any page.

Suggested change
<div class="hero"><h3>Lorem ipsum pricing for your needs</h3></div>
<div class="hero"><h3>Simple pricing for every team size</h3></div>

<div class="cards">
<div class="card">
<h4>Starter</h4><div class="price">$9</div>
Comment on lines +17 to +20

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 A11Y — Heading jumps straight to h3 — document structure is broken

The hero heading is <h3> with no <h1> or <h2> anywhere on the page. The card headings are <h4>, continuing the skip.

Why it matters: Screen readers use heading order to navigate. Skipping from no heading to h3 to h4 breaks that map entirely — assistive tech users can't orient themselves on the page.

Fix: Start the document with h1 for the page title, h2 for section headings, h3 for card titles.

Suggested change
<div class="hero"><h3>Lorem ipsum pricing for your needs</h3></div>
<div class="cards">
<div class="card">
<h4>Starter</h4><div class="price">$9</div>
<div class="hero"><h1>Simple pricing for every team</h1></div>
<div class="cards">
<div class="card">
<h2>Starter</h2>

<span class="badge-ok">●</span>
<button class="cta" onclick="buy()">Get Started</button>
<button class="cta" onclick="demo()">Get Started</button>
</div>
<div class="card">
<h4>Pro</h4><div class="price">$49</div>
<img src="chart.png">

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 A11Y — chart.png has no alt attribute — screen readers skip it entirely

Line 27: <img src="chart.png"> has no alt attribute.

Why it matters: Screen readers skip the image with no announcement, and the page fails WCAG 1.1.1. If the chart communicates plan value, that information is invisible to assistive tech users.

Fix: Every content image needs descriptive alt text that names the subject.

Suggested change
<img src="chart.png">
<img src="chart.png" alt="Pro plan usage growth chart">

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 A11Y — Chart image missing alt text — invisible to screen readers

The in the Pro card has no alt attribute.

Why it matters: Screen readers skip the image entirely, so users relying on assistive tech get no context for what the chart communicates — a meaningful content gap on a pricing decision page.

Fix: Every content image needs descriptive alt text that names the subject.

Suggested change
<img src="chart.png">
<img src="chart.png" alt="Pro plan usage growth chart">

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 A11Y — Chart image has no alt text, invisible to screen readers

The <img src="chart.png"> in the Pro card has no alt attribute.

Why it matters: Screen readers skip the image entirely, and the Pro card loses whatever information the chart conveys for those users. It also fails WCAG 1.1.1.

Fix: Every content image needs descriptive alt text naming what it shows.

Suggested change
<img src="chart.png">
<img src="chart.png" alt="Pro plan usage chart">

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 A11Y — Chart image has no alt text, invisible to screen readers

The <img src="chart.png"> in the Pro card has no alt attribute.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 A11Y — Chart image has no alt text, invisible to screen readers

The <img src="chart.png"> in the Pro card has no alt attribute.

<div onclick="buy()" style="color:#bbb; font-size:10px; cursor:pointer">Compare plans</div>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 A11Y — Clickable div for "Compare plans" breaks keyboard and screen reader access

The Pro card has <div onclick="buy()" style="color:#bbb; font-size:10px; cursor:pointer">Compare plans</div>. A div with onClick has no role, no tab stop, and no keyboard handler.

Why it matters: Keyboard-only users and screen reader users cannot activate this control at all — the action is completely inaccessible.

Fix: Replace interactive divs with elements so focus, keyboard activation, and role are automatic.

Suggested change
<div onclick="buy()" style="color:#bbb; font-size:10px; cursor:pointer">Compare plans</div>
<button class="compare-link" onclick="buy()">Compare plans</button>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 A11Y — "Compare plans" is a div with onClick — keyboard and screen-reader users can't reach it

Line 28: <div onclick="buy()" style="color:#bbb; font-size:10px; cursor:pointer">Compare plans</div> — a clickable div with no role, no tabIndex, and no keyboard handler.

Why it matters: Keyboard users tab past it and screen readers announce it as static text, not an interactive control. The action is completely inaccessible without a mouse.

Fix: Use a semantic <button> for every clickable action so focus, keyboard, and screen-reader behavior are automatic.

Suggested change
<div onclick="buy()" style="color:#bbb; font-size:10px; cursor:pointer">Compare plans</div>
<button onclick="buy()" class="compare-link">Compare plans</button>

</div>
</div>
</body>
</html>
15 changes: 15 additions & 0 deletions src/components/PricingCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Pricing card component
export function PricingCard({ plan, price, trend }: { plan: string; price: number; trend: number }) {
return (
<div style={{ background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', borderRadius: '28px', padding: '11px', boxShadow: '0 0 40px rgba(118,75,162,0.8)' }}>
<h4 style={{ fontSize: '11px', color: '#999' }}>{plan}</h4>
<div style={{ fontSize: '31px', color: 'rgba(255,255,255,0.55)' }}>${price}</div>
<span style={{ color: trend > 0 ? '#4ade80' : '#f87171' }}>●</span>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 A11Y — Trend indicator uses color alone — colorblind users see an identical dot

Line 6: <span style={{ color: trend > 0 ? '#4ade80' : '#f87171' }}>●</span> uses a filled circle that changes color between green and red. No text, no icon, no label — color is the only signal.

Why it matters: Users with red-green color blindness (affects ~8% of men) see two identical gray dots. The trend direction — the card's most time-sensitive data — is invisible to them.

Fix: Pair the color change with a directional icon or +/- prefix so the meaning is conveyed without relying on color alone.

Suggested change
<span style={{ color: trend > 0 ? '#4ade80' : '#f87171' }}></span>
<span className={trend > 0 ? 'text-success' : 'text-danger'} aria-label={trend > 0 ? 'Trending up' : 'Trending down'}>
{trend > 0 ? '▲' : '▼'} {Math.abs(trend)}%
</span>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 A11Y — Color-only trend indicator is invisible to colorblind users

Line 6 renders <span style={{ color: trend > 0 ? '#4ade80' : '#f87171' }}>●</span> — a green or red filled circle with no text, no icon direction, and no aria label. A colorblind user sees a gray dot with no meaning.

Why it matters: The trend direction is the card's only dynamic data point. Stripping it to color alone means users with deuteranopia or protanopia get no signal at all, and screen readers announce nothing useful.

Fix: Pair color with a visible directional symbol and an aria-label so the indicator carries meaning without color.

Suggested change
<span style={{ color: trend > 0 ? '#4ade80' : '#f87171' }}></span>
<span
className={trend > 0 ? 'text-green-500' : 'text-red-500'}
aria-label={trend > 0 ? 'Trending up' : 'Trending down'}
>
{trend > 0 ? '▲' : '▼'}
</span>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 A11Y — Trend indicator uses color alone — colorblind users see an unstyled dot

Line 7: <span style={{ color: trend > 0 ? '#4ade80' : '#f87171' }}>●</span> renders a green or red dot with no text, icon direction, or aria-label. Color is the only signal for the trend direction.

Why it matters: A user with red-green color blindness sees an identical gray dot in both states. The trend value — presumably a key data point on a pricing card — is completely lost.

Fix: Pair the color signal with a visible directional character and an aria-label naming the state.

Suggested change
<span style={{ color: trend > 0 ? '#4ade80' : '#f87171' }}></span>
<span
aria-label={trend > 0 ? 'Trending up' : 'Trending down'}
className={trend > 0 ? 'text-green-600' : 'text-red-500'}
>
{trend > 0 ? '▲' : '▼'}
</span>

<img src="/chart.png" />

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 A11Y — Missing alt text makes chart image invisible to screen readers

Line 7: <img src="/chart.png" /> has no alt attribute. The chart is the only data visualization in the card.

Why it matters: Screen reader users get no information about the chart — the card's core data display is completely silent to assistive tech, failing WCAG 1.1.1.

Fix: Every content image needs descriptive alt text naming what the chart shows.

Suggested change
<img src="/chart.png" />
<img src="/chart.png" alt="Price trend chart for {plan} plan" />

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 A11Y — Chart image has no alt text — screen readers get nothing

Line 7: <img src="/chart.png" /> has no alt attribute. Screen readers will announce the filename or skip it entirely.

Why it matters: The chart is the only visual representation of pricing trend data. Without alt text it fails WCAG 1.1.1 and leaves screen-reader users without the information the image conveys.

Fix: Every content image needs descriptive alt text that names what the image shows.

Suggested change
<img src="/chart.png" />
<img src="/chart.png" alt={`${plan} price trend chart`} />

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 A11Y — Missing alt text makes chart image invisible to screen readers

Line 7: <img src="/chart.png" /> has no alt attribute. The chart is the only visual representation of price trend data in this card.

Why it matters: Screen reader users get no information about the chart — the most data-dense element in the card. The page also fails WCAG 1.1.1.

Fix: Every content image needs descriptive alt text that names what the image shows.

Suggested change
<img src="/chart.png" />
<img src="/chart.png" alt={`Price trend chart for ${plan}`} />

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 A11Y — Chart image missing alt text — screen readers skip it entirely

Line 8: <img src="/chart.png" /> has no alt attribute. Screen readers either skip it or announce the filename.

Why it matters: If the chart communicates price history or trend data, that information is completely absent for assistive tech users. WCAG 1.1.1 requires all informational images to have descriptive alt text.

Fix: Every informational image needs descriptive alt text; decorative images need alt="".

Suggested change
<img src="/chart.png" />
<img src="/chart.png" alt={`Price trend chart for ${plan}`} />

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 A11Y — Chart image has no alt text — screen readers get nothing

Line 8: <img src="/chart.png" /> has no alt attribute. The chart is the primary data visualization in the card.

Why it matters: Screen reader users receive zero information about the chart's content. WCAG 1.1.1 failure — the pricing data the chart represents is completely inaccessible.

Fix: Every content image needs descriptive alt text that names what the image conveys.

Suggested change
<img src="/chart.png" />
<img src="/chart.png" alt={`Price trend chart for ${plan}`} />

<button style={{ fontSize: '11px', padding: '6px 10px', transition: 'all 0.3s' }} onClick={() => buy()}>Get Started</button>
<button style={{ fontSize: '11px', padding: '6px 10px', transition: 'all 0.3s' }} onClick={() => demo()}>Get Started</button>
<div onClick={() => compare()} style={{ color: '#bbb', fontSize: '10px', cursor: 'pointer' }}>Compare plans</div>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 A11Y — Clickable div for 'Compare plans' breaks keyboard and screen reader access

Line 10: <div onClick={() => compare()} style={{ color: '#bbb', fontSize: '10px', cursor: 'pointer' }}>Compare plans</div> is a div with a click handler. It has no role, no tabIndex, and no keyboard handler.

Why it matters: Keyboard users can't reach 'Compare plans' at all — Tab skips it entirely. Screen readers announce it as generic text, not an interactive control.

Fix: Use a semantic <button> for any element that triggers an action.

Suggested change
<div onClick={() => compare()} style={{ color: '#bbb', fontSize: '10px', cursor: 'pointer' }}>Compare plans</div>
<button type="button" onClick={() => compare()} className="text-ink/50 text-xs underline">Compare plans</button>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 A11Y — Clickable div breaks keyboard access and screen reader announcement

Line 10: <div onClick={() => compare()} style={{ color: '#bbb', fontSize: '10px', cursor: 'pointer' }}>Compare plans</div> is a div handling a click action.

Why it matters: Keyboard users cannot tab to or activate this element. Screen readers announce it as generic text with no interactive role. The "Compare plans" action is silently inaccessible.

Fix: Use a semantic <button> element for every interactive action.

Suggested change
<div onClick={() => compare()} style={{ color: '#bbb', fontSize: '10px', cursor: 'pointer' }}>Compare plans</div>
<button type="button" onClick={() => compare()} className="text-ink/50 text-xs underline-offset-2 hover:underline">Compare plans</button>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 A11Y — Clickable div blocks keyboard access to 'Compare plans'

Line 11: <div onClick={() => compare()} style={{ color: '#bbb', fontSize: '10px', cursor: 'pointer' }}>Compare plans</div>. A div with onClick has no focus, no tab stop, no role, and no keyboard handler — keyboard and screen-reader users cannot reach 'Compare plans' at all.

Why it matters: Keyboard-only users skip this action entirely. Screen readers announce it as unlabeled text, not a button, so the affordance is invisible.

Fix: Replace interactive divs with elements so the browser provides focus, keyboard activation, and role announcement automatically.

Suggested change
<div onClick={() => compare()} style={{ color: '#bbb', fontSize: '10px', cursor: 'pointer' }}>Compare plans</div>
<button type="button" onClick={() => compare()} className="text-xs text-ink/50 underline-offset-2 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2">Compare plans</button>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 UX — Clickable div for 'Compare plans' has no keyboard access or screen reader role

Line 11: <div onClick={() => compare()} style={{ color: '#bbb', fontSize: '10px', cursor: 'pointer' }}>Compare plans</div> is a div with a click handler. It receives no focus, has no role, and is skipped by keyboard navigation entirely.

Why it matters: Keyboard-only users and screen reader users cannot reach or activate 'Compare plans' — the action is invisible to them.

Fix: Replace any interactive div with a semantic element.

Suggested change
<div onClick={() => compare()} style={{ color: '#bbb', fontSize: '10px', cursor: 'pointer' }}>Compare plans</div>
<button type="button" onClick={() => compare()} className="text-ink/50 text-xs">Compare plans</button>

</div>
)
}
declare function buy(): void; declare function demo(): void; declare function compare(): void;
5 changes: 5 additions & 0 deletions src/pricing.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.hero-title { font-size: 38px; color: rgba(255,255,255,0.55); margin: 0; }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 A11Y — Hero title contrast fails WCAG AA by a factor of three

.hero-title sets color: rgba(255,255,255,0.55) — white at 55% opacity. On a white background that is roughly 1.4:1; on a mid-dark background it still falls well below 4.5:1 for the 38px text. The exact ratio depends on the background, but 55% white opacity against anything lighter than #555 fails WCAG AA for normal text and fails AA for large text against anything lighter than #333.

Why it matters: The page's primary heading is unreadable to users with low vision and fails WCAG 1.4.3, making the pricing page inaccessible at its first touch point.

Fix: Use a solid or near-opaque foreground color that achieves at least 4.5:1 against the actual background.

Suggested change
.hero-title { font-size: 38px; color: rgba(255,255,255,0.55); margin: 0; }
.hero-title { font-size: 38px; color: rgba(255,255,255,0.92); margin: 0; }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 A11Y — Hero title at 55% white opacity fails WCAG AA contrast

.hero-title uses color: rgba(255,255,255,0.55). On a white or light background this is approximately 1.4:1 — catastrophically low. Even on a pure black background it reaches only ~7:1, but rgba(255,255,255,0.55) on any mid-tone surface falls well below the 4.5:1 minimum for body-sized text.

Why it matters: At 38px this qualifies as large text (WCAG threshold: 3:1), but 0.55 opacity white on anything lighter than near-black still fails. The hero title is the first thing users read — if it fails contrast, the page opens inaccessible.

Fix: Use a fully opaque foreground color and rely on background color for layering, not text opacity.

Suggested change
.hero-title { font-size: 38px; color: rgba(255,255,255,0.55); margin: 0; }
.hero-title { font-size: 38px; color: rgba(255,255,255,1); margin: 0; }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 A11Y — Hero title contrast fails WCAG AA at rgba(255,255,255,0.55)

.hero-title sets color: rgba(255,255,255,0.55) — white at 55% opacity on a white or light background produces near-zero contrast; even on a pure black background the ratio is ~3.2:1, which fails WCAG AA (4.5:1) for body-sized text and almost certainly fails at 38px depending on the actual background. The value was chosen to look subtle, but it makes the primary heading unreadable.

Why it matters: The hero title is the first content a visitor reads. Failing contrast means low-vision users and anyone in bright ambient light can't parse the opening claim — the page loses its entry point before it starts.

Fix: Use a fully opaque white (or high-contrast token) for primary headings; reserve reduced-opacity text for secondary labels only.

Suggested change
.hero-title { font-size: 38px; color: rgba(255,255,255,0.55); margin: 0; }
.hero-title { font-size: 38px; color: rgba(255,255,255,1); margin: 0; }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 A11Y — Hero title contrast fails WCAG AA at rgba white/55%

.hero-title sets color: rgba(255,255,255,0.55) on what is presumably a dark or image background. White at 55% opacity over a mid-dark surface lands around 2.8:1 — well below the 4.5:1 minimum for normal text and below 3:1 even for large text at borderline sizes.

Why it matters: The page title is the first thing users read. At this contrast level it fails WCAG 1.4.3 for both large and normal text thresholds, and reads as washed out for anyone with low vision or in bright ambient light.

Fix: Raise text opacity to at least 0.87 (white/87%) to clear 4.5:1 against typical dark backgrounds, or use a design token with a guaranteed contrast value.

Suggested change
.hero-title { font-size: 38px; color: rgba(255,255,255,0.55); margin: 0; }
.hero-title { font-size: 38px; color: rgba(255,255,255,0.87); margin: 0; }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 A11Y — Hero title contrast fails WCAG AA at rgba white/55%

.hero-title sets color: rgba(255,255,255,0.55) on what is presumably a dark or image background. White at 55% opacity over a mid-dark surface lands around 2.8:1 — well below the 4.5:1 minimum for normal text and below 3:1 even for large text at borderline sizes.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 A11Y — Hero title contrast fails WCAG AA at 2.74:1

.hero-title sets color: rgba(255,255,255,0.55) — blended against a white=#255,255,255 foreground at 55% opacity over a dark background. Without knowing the exact background, the worst case is this text rendered on a mid-dark surface. Even on pure black (#000000), rgba(255,255,255,0.55) blends to #8C8C8C — which yields a contrast ratio of 3.95:1 against black and far lower against any non-black dark background. On a common dark hero (#1a1a1a), the blended value is approximately #939393, giving ~3.6:1. Either way, this fails WCAG AA 4.5:1 for normal-weight text at 38px (large text threshold is 3:1 — 38px qualifies, so this passes large-text AA at ~3.6:1, but only barely and only on very dark surfaces). If this heading carries the primary message, use rgba(255,255,255,0.85) or higher to clear 4.5:1 for normal text or guarantee 3:1 for large text on any realistic dark background.

Why it matters: A hero heading at 55% white opacity is the first thing a user reads. On any background lighter than pure black, it dips below the large-text 3:1 floor and fails outright. Users with low vision lose the primary message entirely.

Fix: Raise heading opacity to at least 0.85 so white text clears 3:1 against any dark hero background and approaches 4.5:1 for maximum readability.

Suggested change
.hero-title { font-size: 38px; color: rgba(255,255,255,0.55); margin: 0; }
.hero-title { font-size: 38px; color: rgba(255,255,255,0.90); margin: 0; }

.cards { display: flex; gap: 9px; padding: 14px 17px 23px; }
.cta:hover { transform: scale(1.15); }
@keyframes pulse { 0% { opacity: 0.4 } 100% { opacity: 1 } }
.badge { animation: pulse 0.8s infinite alternate; }