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
1 change: 1 addition & 0 deletions apps/e2e/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"test": "playwright test"
},
"devDependencies": {
"@axe-core/playwright": "^4.10.2",
"@playwright/test": "^1.61.0"
}
}
106 changes: 106 additions & 0 deletions apps/e2e/tests/accessibility.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { expect, test } from "@playwright/test";
import { createNote, expectNoA11yViolations, gotoCreate, revealNote } from "./helpers";

// Accessibility regression gate. Each test drives the app into a distinct UI
// state with the shared flow helpers, waits for that state to settle, then runs
// axe-core over the live DOM (WCAG 2.0/2.1 A + AA). Kept serial via the suite's
// `workers: 1` config so single-read/burn notes don't contend.

test.describe("create page", () => {
test("initial state has no a11y violations", async ({ page }) => {
await gotoCreate(page);
await expectNoA11yViolations(page, "create page (initial)");
});

test("password field + strength meter has no a11y violations", async ({ page }) => {
await gotoCreate(page);
await page.locator("#password").fill("Tr0ub4dour&3xample");
// The strength meter only renders once the field is non-empty.
await expect(page.locator("#password")).toHaveValue("Tr0ub4dour&3xample");
await expectNoA11yViolations(page, "create page (password + strength)");
});

test("password generator (secret tab) has no a11y violations", async ({ page }) => {
await gotoCreate(page);
// The "secret" content mode is labelled "Password" in the UI.
await page.getByRole("tab", { name: /password/i }).click();
// The generator auto-fills a password on mount; wait for it before scanning.
await expect(page.getByRole("button", { name: /regenerate/i })).toBeVisible();
await expectNoA11yViolations(page, "create page (password generator)");
});

test("markdown editor (write + preview) has no a11y violations", async ({ page }) => {
await gotoCreate(page);
await page.getByRole("tab", { name: /markdown/i }).click();
const editor = page.getByPlaceholder(/markdown/i);
await expect(editor).toBeVisible();
await editor.fill("# Heading\n\nSome **bold** body text.");
await expectNoA11yViolations(page, "markdown editor (write)");

await page.getByRole("button", { name: /preview/i }).click();
// Wait for the debounced render to produce the prose output.
await expect(page.locator(".prose")).toBeVisible();
await expectNoA11yViolations(page, "markdown editor (preview)");
});
});

test.describe("success view", () => {
test("share screen (with QR + manage details) has no a11y violations", async ({ page }) => {
await createNote(page, { text: `a11y success ${Date.now()}`, password: "hunter2-secret" });
await expect(page.getByTestId("share-url")).toBeVisible();
await expectNoA11yViolations(page, "success view (initial)");

// Reveal the QR image and expand the delete/manage section, then re-scan.
await page.getByRole("button", { name: /QR Code/i }).click();
await expect(page.getByRole("img", { name: /QR code for the share link/i })).toBeVisible();
await page.locator("#manage-url").scrollIntoViewIfNeeded();
await page.locator("summary").click();
await expect(page.locator("#manage-url")).toBeVisible();
await expectNoA11yViolations(page, "success view (QR + manage expanded)");
});
});

test.describe("note view", () => {
test("burn-warning gate has no a11y violations", async ({ page, context }) => {
const shareUrl = await createNote(page, { text: `a11y burn ${Date.now()}` });
const reader = await context.newPage();
await reader.goto(shareUrl);
await expect(reader.getByRole("button", { name: /I understand, continue/i })).toBeEnabled({
timeout: 30_000,
});
await expectNoA11yViolations(reader, "note view (burn gate)");
});

test("password gate has no a11y violations", async ({ page, context }) => {
const shareUrl = await createNote(page, {
text: `a11y pw-gate ${Date.now()}`,
password: "open-sesame-123",
maxReads: 3,
});
const reader = await context.newPage();
await reader.goto(shareUrl);
// maxReads > 1 means no burn gate — the password field is shown immediately.
await expect(reader.locator("#decrypt-password")).toBeVisible({ timeout: 30_000 });
await expectNoA11yViolations(reader, "note view (password gate)");
});

test("decrypted text content has no a11y violations", async ({ page, context }) => {
const secret = `a11y decrypted ${Date.now()}`;
const shareUrl = await createNote(page, { text: secret, maxReads: 3 });
const reader = await context.newPage();
await revealNote(reader, shareUrl);
await expect(reader.getByTestId("note-text")).toHaveText(secret, { timeout: 15_000 });
await expectNoA11yViolations(reader, "note view (decrypted)");
});

test("decrypted markdown content has no a11y violations", async ({ page, context }) => {
const shareUrl = await createNote(page, {
markdown: "# Title\n\nBody with a [link](https://example.com).",
maxReads: 3,
});
const reader = await context.newPage();
await revealNote(reader, shareUrl);
await expect(reader.locator(".prose")).toBeVisible({ timeout: 15_000 });
await expectNoA11yViolations(reader, "note view (decrypted markdown)");
});
});
37 changes: 37 additions & 0 deletions apps/e2e/tests/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { basename } from "node:path";
import AxeBuilder from "@axe-core/playwright";
import { expect, type Page } from "@playwright/test";

export interface CreateNoteOptions {
Expand Down Expand Up @@ -138,3 +139,39 @@ export async function revealNote(
}
await reveal.click();
}

/**
* Runs axe-core against the current page state and asserts there are no WCAG
* 2.0/2.1 A or AA violations. The caller is responsible for first navigating to —
* and waiting for — the UI state it wants to audit (axe analyses the live DOM at
* call time, so a stable, fully-hydrated state is required for a meaningful scan).
*
* `best-practice` rules are intentionally left out: the gate covers the WCAG
* success criteria only, which keeps it actionable and stable across axe releases.
* On failure each violation is printed as `[impact] rule-id: help` followed by the
* offending node selectors, so a CI log points straight at the rule and element.
*/
export async function expectNoA11yViolations(page: Page, label: string): Promise<void> {
// Settle any in-flight mount transitions before scanning: Svelte's fade/fly use
// the Web Animations API, and a half-faded element reports washed-out colors that
// trip color-contrast. Finishing each animation snaps it to its end (full-opacity)
// state, making the scan deterministic regardless of machine speed.
await page.evaluate(() => {
for (const animation of document.getAnimations()) animation.finish();
});

const { violations } = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze();

const summary = violations
.map(
(v) =>
` [${v.impact}] ${v.id}: ${v.help}\n ${v.nodes
.map((n) => n.target.join(" "))
.join("\n ")}`,
)
.join("\n");

expect(violations, `a11y violations on "${label}":\n${summary}`).toEqual([]);
}
7 changes: 7 additions & 0 deletions apps/web/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@
--muted: #a7adbd;
--muted-2: #838a9e;
--accent: oklch(0.68 0.2 25);
/* Darker accent for solid buttons with white text: the bright `--accent`
only reaches 3.15:1 against white, below the WCAG AA 4.5:1 threshold for
normal-size text, so CTAs use this (#dc2626 → 4.84:1) instead. */
--accent-strong: #dc2626;
--accent-ink: #ffffff;
--accent-soft: oklch(0.68 0.2 25 / 0.16);
--accent-ring: oklch(0.68 0.2 25 / 0.4);
Expand All @@ -54,6 +58,9 @@
--muted: #4e4b42;
--muted-2: #70695e;
--accent: oklch(0.58 0.22 25);
/* See dark-mode note above — white text on a solid accent button needs the
darker shade to clear 4.5:1. */
--accent-strong: #dc2626;
--accent-ink: #ffffff;
--accent-soft: oklch(0.58 0.22 25 / 0.10);
--accent-ring: oklch(0.58 0.22 25 / 0.30);
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/lib/components/PasswordGenerator.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ function charColor(ch: string): string {
max="64"
bind:value={length}
onchange={generate}
aria-label={t("password_length")}
class="flex-1 cursor-pointer"
style:accent-color="var(--accent)"
/>
Expand Down Expand Up @@ -127,7 +128,7 @@ function charColor(ch: string): string {
onclick={copyPassword}
disabled={!value}
class="inline-flex items-center gap-1.5 rounded-lg border transition-colors disabled:opacity-50"
style:background="var(--accent)"
style:background="var(--accent-strong)"
style:border-color="transparent"
style:color="var(--accent-ink)"
style:padding="8px 14px"
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/lib/components/SuccessView.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ async function copy(text: string, setFlag: (v: boolean) => void) {
type="button"
onclick={() => copy(shareUrl, (v) => (copied = v))}
class="inline-flex items-center gap-1.5 rounded-lg border-0 transition-all"
style:background="var(--accent)"
style:background="var(--accent-strong)"
style:color="var(--accent-ink)"
style:padding="12px 18px"
style:font-size="13px"
Expand Down Expand Up @@ -364,6 +364,7 @@ async function copy(text: string, setFlag: (v: boolean) => void) {
type="text"
readonly
value={manageUrl}
aria-label={t("delete_label")}
class="min-w-0 flex-1 rounded-lg border outline-none"
style:background="var(--bg-2)"
style:border-color="var(--line)"
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/routes/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ const appUrl = $derived(config.appUrl || "https://secret.larger.io");
<a
href="#main-content"
class="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-50 focus:rounded focus:px-4 focus:py-2"
style:background="var(--accent)"
style:background="var(--accent-strong)"
style:color="var(--accent-ink)"
>
{t("skip_to_content")}
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/routes/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,7 @@ const TABS: {
type="submit"
disabled={!mounted || isSubmitting || !canSubmit}
class="inline-flex w-full items-center justify-center gap-2 rounded-xl border-0 transition-all disabled:cursor-not-allowed disabled:opacity-50"
style:background="var(--accent)"
style:background="var(--accent-strong)"
style:color="var(--accent-ink)"
style:padding="16px 24px"
style:font-size="15px"
Expand Down
8 changes: 4 additions & 4 deletions apps/web/src/routes/note/[id]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ async function handleDecrypt() {
<a
href="/"
class="inline-flex items-center gap-2 rounded-lg transition-colors"
style:background="var(--accent)"
style:background="var(--accent-strong)"
style:color="var(--accent-ink)"
style:padding="12px 18px"
style:font-size="14px"
Expand Down Expand Up @@ -264,7 +264,7 @@ async function handleDecrypt() {
onclick={acceptBurn}
disabled={!mounted}
class="inline-flex items-center gap-1.5 rounded-lg border-0 transition-all disabled:cursor-not-allowed disabled:opacity-50"
style:background="var(--accent)"
style:background="var(--accent-strong)"
style:color="var(--accent-ink)"
style:padding="10px 16px"
style:font-size="13px"
Expand Down Expand Up @@ -341,7 +341,7 @@ async function handleDecrypt() {
onclick={handleDecrypt}
disabled={!mounted}
class="mb-6 inline-flex w-full items-center justify-center gap-2 rounded-xl border-0 transition-all disabled:cursor-not-allowed disabled:opacity-50"
style:background="var(--accent)"
style:background="var(--accent-strong)"
style:color="var(--accent-ink)"
style:padding="16px 24px"
style:font-size="15px"
Expand Down Expand Up @@ -547,7 +547,7 @@ async function handleDecrypt() {
<a
href="/"
class="mt-4 inline-flex items-center gap-2 rounded-lg transition-colors"
style:background="var(--accent)"
style:background="var(--accent-strong)"
style:color="var(--accent-ink)"
style:padding="12px 18px"
style:font-size="14px"
Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/routes/note/[id]/manage/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ async function handleDelete() {
<a
href="/"
class="mt-4 inline-flex items-center gap-2 rounded-lg transition-colors"
style:background="var(--accent)"
style:background="var(--accent-strong)"
style:color="var(--accent-ink)"
style:padding="12px 18px"
style:font-size="14px"
Expand Down Expand Up @@ -91,7 +91,7 @@ async function handleDelete() {
<a
href="/"
class="mt-4 inline-flex items-center gap-2 rounded-lg transition-colors"
style:background="var(--accent)"
style:background="var(--accent-strong)"
style:color="var(--accent-ink)"
style:padding="12px 18px"
style:font-size="14px"
Expand All @@ -116,7 +116,7 @@ async function handleDelete() {
<a
href="/"
class="mt-4 inline-flex items-center gap-2 rounded-lg transition-colors"
style:background="var(--accent)"
style:background="var(--accent-strong)"
style:color="var(--accent-ink)"
style:padding="12px 18px"
style:font-size="14px"
Expand Down
19 changes: 19 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading