Skip to content
Merged
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
28 changes: 17 additions & 11 deletions canvas/src/app/orgs/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ export default function OrgsPage() {
if (error) {
return (
<Shell>
<p className="text-red-400">Error: {error}</p>
<p role="alert" className="text-red-400">Error: {error}</p>
<button
onClick={() => window.location.reload()}
className="mt-4 rounded bg-zinc-800 px-4 py-2 text-sm text-zinc-200 hover:bg-zinc-700"
Expand Down Expand Up @@ -151,10 +151,10 @@ export default function OrgsPage() {

function CheckoutBanner() {
return (
<div className="mb-6 rounded-lg border border-emerald-700 bg-emerald-950 p-4">
<div role="status" aria-live="polite" className="mb-6 rounded-lg border border-emerald-700 bg-emerald-950 p-4">
<p className="text-sm text-emerald-200">
βœ“ Payment confirmed. Your workspace is spinning up now β€” this page
refreshes automatically when it's ready.
<span aria-hidden="true">βœ“</span> Payment confirmed. Your workspace is spinning up now β€” this page
refreshes automatically when it&apos;s ready.
</p>
</div>
);
Expand Down Expand Up @@ -364,28 +364,34 @@ function CreateOrgForm({ onCreated }: { onCreated: (slug: string) => void }) {

return (
<form onSubmit={submit} className="space-y-3">
<label className="block">
<span className="text-sm text-zinc-300">Slug (URL)</span>
<div>
<label htmlFor="org-slug" className="block text-sm text-zinc-300">Slug (URL)</label>
<input
id="org-slug"
value={slug}
onChange={(e) => setSlug(e.target.value.toLowerCase())}
pattern="^[a-z][a-z0-9-]{2,31}$"
placeholder="acme"
required
aria-describedby="org-slug-hint"
className="mt-1 w-full rounded border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100"
/>
</label>
<label className="block">
<span className="text-sm text-zinc-300">Display name</span>
<p id="org-slug-hint" className="mt-1 text-xs text-zinc-500">
Lowercase letters, numbers, and hyphens only. Cannot be changed later.
</p>
</div>
<div>
<label htmlFor="org-name" className="block text-sm text-zinc-300">Display name</label>
<input
id="org-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Acme Corp"
required
className="mt-1 w-full rounded border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100"
/>
</label>
{err && <p className="text-sm text-red-400">{err}</p>}
</div>
{err && <p role="alert" className="text-sm text-red-400">{err}</p>}
<button
type="submit"
disabled={submitting}
Expand Down
16 changes: 14 additions & 2 deletions canvas/src/components/ConsoleModal.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { api } from "@/lib/api";
import { showToast } from "@/components/Toaster";
Expand All @@ -27,11 +27,21 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [mounted, setMounted] = useState(false);
const closeButtonRef = useRef<HTMLButtonElement>(null);

useEffect(() => {
setMounted(true);
}, []);

// Focus close button when modal opens
useEffect(() => {
if (!open) return;
const raf = requestAnimationFrame(() => {
closeButtonRef.current?.focus();
});
return () => cancelAnimationFrame(raf);
}, [open]);

useEffect(() => {
if (!open) return;
let ignore = false;
Expand Down Expand Up @@ -80,7 +90,7 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop

return createPortal(
<div className="fixed inset-0 z-[9999] flex items-center justify-center">
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
<div aria-hidden="true" className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
<div
role="dialog"
aria-modal="true"
Expand All @@ -99,6 +109,7 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop
)}
</div>
<button
ref={closeButtonRef}
onClick={onClose}
aria-label="Close"
className="text-zinc-400 hover:text-zinc-100 text-sm px-2"
Expand All @@ -115,6 +126,7 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop
)}
{!loading && error && (
<div
role="alert"
className="text-[12px] text-amber-300 bg-amber-950/30 border border-amber-900/40 rounded px-3 py-2"
data-testid="console-error"
>
Expand Down
1 change: 0 additions & 1 deletion canvas/src/components/ConversationTraceModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,6 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
<Dialog.Content
className="fixed inset-0 z-[60] flex items-center justify-center p-4"
aria-label="Conversation trace"
aria-describedby={undefined}
>
{/* Modal panel */}
<div className="relative bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl max-w-[700px] w-full max-h-[85vh] flex flex-col overflow-hidden">
Expand Down
1 change: 1 addition & 0 deletions canvas/src/components/CookieConsent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export function CookieConsent() {
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="cookie-consent-title"
aria-describedby="cookie-consent-body"
className="fixed bottom-0 left-0 right-0 z-[9999] border-t border-zinc-800 bg-zinc-950/95 backdrop-blur-sm p-4 shadow-[0_-4px_12px_rgba(0,0,0,0.4)]"
Expand Down
2 changes: 1 addition & 1 deletion canvas/src/components/DeleteCascadeConfirmDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export function DeleteCascadeConfirmDialog({
return createPortal(
<div className="fixed inset-0 z-[9999] flex items-center justify-center">
{/* Backdrop */}
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onCancel} />
<div aria-hidden="true" className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onCancel} />

{/* Dialog */}
<div
Expand Down
7 changes: 4 additions & 3 deletions canvas/src/components/MissingKeysModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ export function MissingKeysModal({
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
aria-hidden="true"
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
onClick={onCancel}
/>
Expand All @@ -151,8 +152,8 @@ export function MissingKeysModal({
{/* Header */}
<div className="px-5 py-4 border-b border-zinc-800">
<div className="flex items-center gap-2 mb-1">
<div className="w-5 h-5 rounded-md bg-amber-600/20 border border-amber-500/30 flex items-center justify-center">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<div className="w-5 h-5 rounded-md bg-amber-600/20 border border-amber-500/30 flex items-center justify-center" aria-hidden="true">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path
d="M6 1L11 10H1L6 1Z"
stroke="#fbbf24"
Expand Down Expand Up @@ -191,7 +192,7 @@ export function MissingKeysModal({
</div>
{entry.saved && (
<span className="text-[9px] text-emerald-400 bg-emerald-900/30 px-1.5 py-0.5 rounded flex items-center gap-1">
<svg width="8" height="8" viewBox="0 0 8 8" fill="none">
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
<path d="M1.5 4L3.5 6L6.5 2" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
Saved
Expand Down
6 changes: 3 additions & 3 deletions canvas/src/components/ProvisioningTimeout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -196,8 +196,8 @@ export function ProvisioningTimeout({
>
<div className="flex items-start gap-3">
{/* Warning icon */}
<div className="w-8 h-8 rounded-lg bg-amber-600/20 border border-amber-500/30 flex items-center justify-center shrink-0 mt-0.5">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<div aria-hidden="true" className="w-8 h-8 rounded-lg bg-amber-600/20 border border-amber-500/30 flex items-center justify-center shrink-0 mt-0.5">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M8 2L14 13H2L8 2Z"
stroke="#fbbf24"
Expand Down Expand Up @@ -252,7 +252,7 @@ export function ProvisioningTimeout({
{/* Cancel confirmation dialog */}
{confirmingCancel && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/60" onClick={() => setConfirmingCancel(null)} />
<div aria-hidden="true" className="absolute inset-0 bg-black/60" onClick={() => setConfirmingCancel(null)} />
<div className="relative bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl p-5 max-w-[340px] w-full mx-4">
<h3 className="text-sm font-semibold text-zinc-100 mb-2">
Cancel deployment?
Expand Down
15 changes: 10 additions & 5 deletions canvas/src/components/TermsGate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,14 @@ export function TermsGate({ children }: { children: React.ReactNode }) {
<>
{children}
{status === "pending" && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-zinc-950/80 backdrop-blur-sm">
<div className="mx-4 max-w-lg rounded-lg border border-zinc-700 bg-zinc-900 p-6 shadow-xl">
<h2 className="text-lg font-semibold text-white">Terms &amp; conditions</h2>
<div aria-hidden="true" className="fixed inset-0 z-50 flex items-center justify-center bg-zinc-950/80 backdrop-blur-sm">
<div
role="dialog"
aria-modal="true"
aria-labelledby="terms-dialog-title"
className="mx-4 max-w-lg rounded-lg border border-zinc-700 bg-zinc-900 p-6 shadow-xl"
>
<h2 id="terms-dialog-title" className="text-lg font-semibold text-white">Terms &amp; conditions</h2>
<p className="mt-3 text-sm text-zinc-300">
Before you create an organization, please review our{" "}
<a href="/legal/terms" className="text-sky-400 underline" target="_blank" rel="noreferrer">
Expand All @@ -94,7 +99,7 @@ export function TermsGate({ children }: { children: React.ReactNode }) {
<p className="mt-3 text-xs text-zinc-500">
By agreeing you acknowledge that workspace data is stored in AWS us-east-2 (Ohio, United States).
</p>
{error && <p className="mt-3 text-sm text-red-400">{error}</p>}
{error && <p role="alert" className="mt-3 text-sm text-red-400">{error}</p>}
<div className="mt-5 flex justify-end gap-2">
<button
onClick={accept}
Expand All @@ -108,7 +113,7 @@ export function TermsGate({ children }: { children: React.ReactNode }) {
</div>
)}
{status === "error" && (
<div className="fixed bottom-4 left-4 right-4 mx-auto max-w-md rounded border border-red-800 bg-red-950 p-3 text-sm text-red-200">
<div role="alert" className="fixed bottom-4 left-4 right-4 mx-auto max-w-md rounded border border-red-800 bg-red-950 p-3 text-sm text-red-200">
Couldn&apos;t check terms status: {error ?? "unknown error"}
</div>
)}
Expand Down
51 changes: 51 additions & 0 deletions canvas/src/components/__tests__/ConsoleModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,54 @@ describe("ConsoleModal", () => {
expect(onClose).toHaveBeenCalled();
});
});

// ── WCAG 2.1 dialog accessibility ─────────────────────────────────────────────

describe("ConsoleModal β€” WCAG 2.1 dialog accessibility", () => {
it("renders role=dialog when open", async () => {
mockGet.mockResolvedValueOnce({ output: "" });
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={() => {}} />);
await waitFor(() => expect(screen.queryByRole("dialog")).toBeTruthy());
});

it("dialog has aria-modal='true' (WCAG 2.1 SC 1.3.2)", async () => {
mockGet.mockResolvedValueOnce({ output: "" });
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={() => {}} />);
const dialog = await waitFor(() => screen.getByRole("dialog"));
expect(dialog.getAttribute("aria-modal")).toBe("true");
});

it("dialog has aria-labelledby pointing to the title", async () => {
mockGet.mockResolvedValueOnce({ output: "" });
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={() => {}} />);
const dialog = await waitFor(() => screen.getByRole("dialog"));
const labelledBy = dialog.getAttribute("aria-labelledby");
expect(labelledBy).toBeTruthy();
const titleEl = document.getElementById(labelledBy!);
expect(titleEl?.textContent?.trim()).toBe("EC2 console output");
});

it("backdrop div has aria-hidden='true' so screen readers skip it (WCAG 4.1.2)", async () => {
mockGet.mockResolvedValueOnce({ output: "" });
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={() => {}} />);
const backdrop = document.querySelector('[aria-hidden="true"]');
expect(backdrop).toBeTruthy();
expect(backdrop?.className).toContain("bg-black");
});

it("error div has role=alert (WCAG 4.1.3)", async () => {
mockGet.mockRejectedValueOnce(new Error("GET /workspaces/ws-1/console: 404 Not Found"));
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={() => {}} />);
const alert = await waitFor(() => screen.getByRole("alert"));
expect(alert).toBeTruthy();
expect(alert.textContent).toMatch(/No EC2 instance found/i);
});

it("Close button has accessible name via aria-label", async () => {
mockGet.mockResolvedValueOnce({ output: "" });
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={() => {}} />);
// Two close buttons: X icon (aria-label="Close") and text "Close" button
const closeBtns = await waitFor(() => screen.getAllByRole("button", { name: /close/i }));
expect(closeBtns.length).toBeGreaterThanOrEqual(1);
});
});
Loading
Loading