Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
1bde312
Give the focus ring and green text colours that pass on navy and in d…
claude Oct 9, 2026
eb45ba5
Add a skip link and main landmark, and move focus to the heading afte…
claude Oct 9, 2026
6924668
Make the commune switcher a plain show-and-hide list
claude Oct 9, 2026
e4bd395
Keep a focused control in a window clear of the sticky action row
claude Oct 9, 2026
d7911be
Name dates, states and repeated buttons for screen readers
claude Oct 9, 2026
14ef679
Show why a member action is unavailable instead of hiding it in a too…
claude Oct 9, 2026
6b2ffb1
Give Operations a pressed-button view switch and an Undo that takes f…
claude Oct 9, 2026
f8eaa8e
Show a caught error once, and style the alert strip and form error line
claude Oct 9, 2026
d1d98e0
Tie form errors to their fields and ask before discarding typed text
claude Oct 9, 2026
9f2512f
Give signed-out pages landmarks, an h1 on phones and darker grey text
claude Oct 9, 2026
b3efcbb
Give the sidebar name and reminder summary 24px targets
claude Oct 9, 2026
edbd762
Give phone chips, switches and Hub controls a 44px touch area
claude Oct 9, 2026
08b4438
Keep the note reader's details a valid description list
claude Oct 9, 2026
7a4ce24
Stop Settings and Places skipping a heading level
claude Oct 9, 2026
cdb7c57
Test that the last admin's actions say why they are unavailable
claude Oct 9, 2026
21bd46e
Keep the sign-in link's 24px target from moving the form
claude Oct 9, 2026
912cfd2
Keep the usual focus ring in the search window on phones
claude Oct 9, 2026
d0217e5
Keep the filter field and button above overflowing chips on phones
claude Oct 9, 2026
4fee063
Make the settings side column a plain container inside the main landmark
claude Oct 9, 2026
7ee2891
Check heading order on Settings and Places for skipped levels only
claude Oct 9, 2026
381193d
Close a dialog in step when Chrome will not let it ask first
claude Oct 9, 2026
49a8fc7
Make the password group heading an h2 like the groups beside it
claude Oct 9, 2026
264be8c
Say that Contact us opens a new tab
claude Oct 9, 2026
759029e
Record the accessibility fixes in the docs
claude Oct 9, 2026
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
47 changes: 43 additions & 4 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, type ReactNode } from "react";
import { useEffect, useRef, useState, type MouseEvent, type ReactNode } from "react";
import { diagnosticsEnabled } from "./live/diagnostics";
import { Redirect, Route, Switch } from "wouter";
import { LoginPage } from "./auth/Login";
Expand Down Expand Up @@ -111,9 +111,10 @@ function AppUnavailable() {
}

function ProductShell() {
const { screen, navMode, moreOpen, native, dark, window: win, notes, noteKey, apiError, clearApiError, contentError, hub, refresh } = useApp();
const { screen, navMode, moreOpen, native, dark, window: win, notes, noteKey, apiError, clearApiError, contentError, hub, refresh, searchTarget } = useApp();
const noteReader = screen === "notes" && win === "note" && Boolean(notes[noteKey]);
const modalOpen = Boolean(win) && !noteReader;
usePageFocus(screen, hub.id, Boolean(win) || Boolean(searchTarget));
return (
<div
className={cx(
Expand All @@ -128,15 +129,16 @@ function ProductShell() {
)}
>
<div className="a-app" inert={modalOpen}>
<a className="a-skip-link" href="#main-content" onClick={skipToContent}>Skip to content</a>
<Sidebar screen={screen} />
<div className="a-main">
<Header screen={screen} />
{diagnosticsEnabled() ? <ConnectionCheck /> : null}
{apiError ? <div className="a-api-error" role="alert">{isConnectionFailure(apiError) ? <>Connection interrupted. Your last loaded view is still available.<button type="button" onClick={() => void refresh()}>Try again</button></> : apiError}<button type="button" onClick={clearApiError} aria-label="Dismiss error">×</button></div> : null}
{contentError && hub.activityState === "ready" ? <div className="a-api-error" role="alert">Could not refresh this commune. Showing its last loaded activity.<button type="button" onClick={() => void refresh()}>Try again</button></div> : null}
<div className="a-content">
<main className="a-content" id="main-content">
<Screen screen={screen} />
</div>
</main>
</div>
</div>
<div inert={modalOpen} style={{ display: "contents" }}>
Expand All @@ -148,6 +150,43 @@ function ProductShell() {
);
}

// Focus moves to an element that only holds it until the next step away.
function focusOnce(element: HTMLElement) {
if (!element.hasAttribute("tabindex")) {
element.tabIndex = -1;
element.addEventListener("blur", () => element.removeAttribute("tabindex"), { once: true });
}
element.focus({ preventScroll: true });
}

function skipToContent(event: MouseEvent<HTMLAnchorElement>) {
const main = document.getElementById("main-content");
if (!main) return;
event.preventDefault();
focusOnce(main);
}

// After moving to another page or commune, start at the new page's heading
// rather than on a control that has gone. A window, the note reader or a
// search result places focus itself; if one was opening, wait until it closes
// and only step in when focus would otherwise be lost.
function usePageFocus(screen: ScreenId, hubId: string, held: boolean) {
const shown = useRef({ screen, hubId });
const waiting = useRef(false);
useEffect(() => {
const previous = shown.current;
shown.current = { screen, hubId };
const moved = previous.screen !== screen || (Boolean(previous.hubId) && previous.hubId !== hubId);
if (moved) waiting.current = true;
if (!waiting.current || held) return;
waiting.current = false;
const active = document.activeElement;
if (!moved && active && active !== document.body) return;
const heading = document.querySelector<HTMLElement>(".a-header-title h1");
if (heading) focusOnce(heading);
}, [screen, hubId, held]);
}

function ConnectionCheck() {
const { hubId } = useApp();
const [running, setRunning] = useState(false);
Expand Down
21 changes: 12 additions & 9 deletions apps/web/src/auth/AuthLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export function AuthLayout({ children }: { children: ReactNode }) {
const year = new Date().getFullYear();
return (
<div className="commune-auth-shell">
<div className="commune-auth-brand">
<header className="commune-auth-brand">
<div className="commune-auth-brand-top">
<img src="/logo.png" alt="Commune" width={44} height={44} />
<span className="commune-auth-word">Commune</span>
Expand All @@ -16,26 +16,29 @@ export function AuthLayout({ children }: { children: ReactNode }) {
<p>Track group spending, settle balances, and keep shared money clear.</p>
</div>
<p className="commune-auth-copy">© {year} Commune</p>
</div>
</header>

<div className="commune-auth-form-side">
<div className="commune-auth-mobile-header">
{/* Phones hide the brand panel, so its heading is repeated here for
screen readers: every page keeps one h1. */}
<header className="commune-auth-mobile-header">
<img src="/logo.png" alt="" width={32} height={32} />
<span>Commune</span>
<em>for people who do life, together.</em>
</div>
<div className="commune-auth-form-wrap">{children}</div>
<div className="commune-auth-desktop-footer">
<h1 className="sr-only">Effortlessly manage your shared expenses.</h1>
</header>
<main className="commune-auth-form-wrap">{children}</main>
<footer className="commune-auth-desktop-footer">
<Link href="/privacy">Privacy</Link>
<Link href="/terms">Terms</Link>
</div>
<div className="commune-auth-mobile-footer">
</footer>
<footer className="commune-auth-mobile-footer">
<span>© {year} Commune</span>
<span className="commune-auth-legal-links">
<Link href="/privacy">Privacy</Link>
<Link href="/terms">Terms</Link>
</span>
</div>
</footer>
</div>
</div>
);
Expand Down
13 changes: 7 additions & 6 deletions apps/web/src/auth/Login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@ import { LIMITS } from "../data/limits";
import { keepSearch } from "../nav";
import { GoogleMark } from "../ui/Icon";
import { readStored, removeStored } from "../live/storage";
import { useFormError } from "../ui/components";

export function LoginPage() {
const { signInWithPassword, signInWithGoogle, signOutUnconfirmed, retrySignOut, sessionNotice } = useApp();
const [, setLocation] = useLocation();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const { error, setError, invalid, messageProps } = useFormError();
const [signOutRetry, setSignOutRetry] = useState<"idle" | "busy" | "failed">("idle");

async function onRetrySignOut() {
Expand All @@ -25,7 +26,7 @@ export function LoginPage() {
event.preventDefault();
setError("");
if (!email.includes("@") || !password) {
setError("Enter your email and password.");
setError("Enter your email and password.", email.includes("@") ? "login-password" : "login-email");
return;
}
setBusy(true);
Expand Down Expand Up @@ -68,18 +69,18 @@ export function LoginPage() {
</div>
) : sessionNotice && !error ? <div className="commune-auth-alert" role="status">{sessionNotice}</div> : null}
{error || new URLSearchParams(window.location.search).has("oauth")
? <div className="commune-auth-alert" role="alert">{error || "Google sign-in did not complete. Please try again."}</div>
? <div className="commune-auth-alert" role="alert" {...messageProps}>{error || "Google sign-in did not complete. Please try again."}</div>
: null}
<form onSubmit={onSubmit}>
<div className="commune-auth-field">
<label htmlFor="login-email">Email</label>
<input id="login-email" type="email" autoComplete="email" required maxLength={LIMITS.email} value={email} onChange={(event) => setEmail(event.target.value)} />
<input id="login-email" type="email" autoComplete="email" required maxLength={LIMITS.email} value={email} onChange={(event) => setEmail(event.target.value)} {...invalid("login-email")} />
</div>
<div className="commune-auth-field">
<label htmlFor="login-password">Password</label>
<input id="login-password" type="password" autoComplete="current-password" required maxLength={128} value={password} onChange={(event) => setPassword(event.target.value)} />
<input id="login-password" type="password" autoComplete="current-password" required maxLength={128} value={password} onChange={(event) => setPassword(event.target.value)} {...invalid("login-password")} />
</div>
<p className="commune-auth-foot"><Link href={keepSearch("/forgot-password")} className="commune-auth-link">Forgot your password?</Link></p>
<p className="commune-auth-foot commune-auth-solo"><Link href={keepSearch("/forgot-password")} className="commune-auth-link">Forgot your password?</Link></p>
<button className="commune-auth-btn" type="submit" disabled={busy}>
{busy ? "Signing in…" : "Sign in"}
</button>
Expand Down
31 changes: 21 additions & 10 deletions apps/web/src/auth/Signup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { LIMITS } from "../data/limits";
import { keepSearch } from "../nav";
import { GoogleMark } from "../ui/Icon";
import { readStored, removeStored } from "../live/storage";
import { useFormError } from "../ui/components";

export function SignupPage() {
const { signUpWithPassword, verifyEmailCode, signInWithPassword, signInWithGoogle } = useApp();
Expand All @@ -14,15 +15,24 @@ export function SignupPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const { error, setError, invalid, messageProps } = useFormError();
const [verificationPending, setVerificationPending] = useState(false);
const [code, setCode] = useState("");

async function onSubmit(event: FormEvent) {
event.preventDefault();
setError("");
if (!name.trim() || !email.includes("@") || password.length < 12) {
setError("Enter your name, a valid email and a password of at least 12 characters.");
// The first field to fix is named, marked and focused.
if (!name.trim()) {
setError("Enter your name.", "su-name");
return;
}
if (!email.includes("@")) {
setError("Enter a valid email address.", "su-email");
return;
}
if (password.length < 12) {
setError("Use at least 12 characters for your password.", "su-password");
return;
}
setBusy(true);
Expand Down Expand Up @@ -77,7 +87,7 @@ export function SignupPage() {
<p className="commune-auth-lede">{verificationPending
? `Enter the code sent to ${email}. If your message has a verification link, open it and then sign in.`
: "Your expenses and membership will be shared securely with the people you invite."}</p>
{error ? <div className="commune-auth-alert" role="alert">{error}</div> : null}
{error ? <div className="commune-auth-alert" role="alert" {...messageProps}>{error}</div> : null}
{verificationPending ? <form onSubmit={onVerify}>
<div className="commune-auth-field">
<label htmlFor="su-code">Verification code</label>
Expand All @@ -90,15 +100,16 @@ export function SignupPage() {
</form> : <form onSubmit={onSubmit}>
<div className="commune-auth-field">
<label htmlFor="su-name">Name</label>
<input id="su-name" autoComplete="name" required maxLength={LIMITS.userName} value={name} onChange={(event) => setName(event.target.value)} />
<input id="su-name" autoComplete="name" required maxLength={LIMITS.userName} value={name} onChange={(event) => setName(event.target.value)} {...invalid("su-name")} />
</div>
<div className="commune-auth-field">
<label htmlFor="su-email">Email</label>
<input id="su-email" type="email" autoComplete="email" required maxLength={LIMITS.email} value={email} onChange={(event) => setEmail(event.target.value)} />
<input id="su-email" type="email" autoComplete="email" required maxLength={LIMITS.email} value={email} onChange={(event) => setEmail(event.target.value)} {...invalid("su-email")} />
</div>
<div className="commune-auth-field">
<label htmlFor="su-password">Password</label>
<input id="su-password" type="password" autoComplete="new-password" minLength={12} maxLength={128} required value={password} onChange={(event) => setPassword(event.target.value)} />
<input id="su-password" type="password" autoComplete="new-password" minLength={12} maxLength={128} required value={password} onChange={(event) => setPassword(event.target.value)} {...invalid("su-password", "su-password-hint")} />
<p className="commune-auth-hint" id="su-password-hint">At least 12 characters.</p>
</div>
<button className="commune-auth-btn" type="submit" disabled={busy}>
{busy ? "Creating account…" : "Create account"}
Expand Down Expand Up @@ -128,7 +139,7 @@ export function VerifyEmailPage() {
const [code, setCode] = useState("");
const [busy, setBusy] = useState(false);
const [done, setDone] = useState(false);
const [error, setError] = useState("");
const { error, setError, messageProps } = useFormError();

async function onSubmit(event: FormEvent) {
event.preventDefault();
Expand All @@ -148,7 +159,7 @@ export function VerifyEmailPage() {
<h2>Verify your email</h2>
<p className="commune-auth-lede">{done ? "Your email is verified. Sign in to continue." :
"Enter the code from your verification email. If you received a link instead, open that link."}</p>
{error ? <div className="commune-auth-alert" role="alert">{error}</div> : null}
{error ? <div className="commune-auth-alert" role="alert" {...messageProps}>{error}</div> : null}
{!done ? <form onSubmit={onSubmit}>
<div className="commune-auth-field">
<label htmlFor="verify-email">Email</label>
Expand All @@ -164,6 +175,6 @@ export function VerifyEmailPage() {
{busy ? "Verifying…" : "Verify email"}
</button>
</form> : null}
<p className="commune-auth-foot"><Link href={keepSearch("/login")} className="commune-auth-link">Back to sign in</Link></p>
<p className="commune-auth-foot commune-auth-solo"><Link href={keepSearch("/login")} className="commune-auth-link">Back to sign in</Link></p>
</div></AuthLayout>;
}
6 changes: 3 additions & 3 deletions apps/web/src/screens/Funds.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -571,13 +571,13 @@ export function FundsScreen() {
variant="secondary"
onClick={() => open({ kind: "view", fundId: item.id })}
>
View fund
View fund{" "}<span className="c-visually-hidden">{item.name}</span>
</Button>
{!my && !item.archivedAt && (
<Button
onClick={() => open({ kind: "pledge", fundId: item.id })}
>
Make a pledge
Make a pledge{" "}<span className="c-visually-hidden">to {item.name}</span>
</Button>
)}
{item.archivedAt && isAdmin && (
Expand All @@ -590,7 +590,7 @@ export function FundsScreen() {
)
}
>
Restore
Restore{" "}<span className="c-visually-hidden">{item.name}</span>
</Button>
)}
</div>
Expand Down
7 changes: 4 additions & 3 deletions apps/web/src/screens/Home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ function PhotoFace({ hub, focus, children }: { hub: LiveHub; focus?: boolean; ch
);
}

function PlaceCard({ hub, focus = false }: { hub: LiveHub; focus?: boolean }) {
function PlaceCard({ hub, focus = false, level = 3 }: { hub: LiveHub; focus?: boolean; level?: 2 | 3 }) {
const { go } = useApp();
const balance = hub.oweAmount.replace(/\.00\b/, "");
const hasBalance = hub.moves.some((move) => !move.even);
Expand All @@ -25,7 +25,7 @@ function PlaceCard({ hub, focus = false }: { hub: LiveHub; focus?: boolean }) {
<button className={cx("a-place", focus && "a-focus-lead")} type="button" onClick={() => go("hub", { hub: hub.id })}>
<PhotoFace hub={hub} focus={focus}>
<span className="type">{hub.type}{memberCount == null ? "" : ` · ${memberCount} ${memberCount === 1 ? "person" : "people"}`}</span>
{focus ? <h2>{hub.name}</h2> : <h3>{hub.name}</h3>}
{focus || level === 2 ? <h2>{hub.name}</h2> : <h3>{hub.name}</h3>}
{(hub.type === "Trip" ? hub.destination : hub.address) ? <p className="a-place-location">{hub.type === "Trip" ? hub.destination : hub.address}</p> : null}
<p>{hub.status}</p>
{focus ? (
Expand Down Expand Up @@ -104,7 +104,8 @@ export function PlacesScreen() {
return (
<>
<div className="a-toolbar"><button className="a-btn" type="button" onClick={() => go("commune-new")}>New commune</button></div>
<div className="a-place-grid">{places.map((hub) => <PlaceCard key={hub.id} hub={hub} />)}</div>
{/* The cards sit straight under the page title, so each name is an h2. */}
<div className="a-place-grid">{places.map((hub) => <PlaceCard key={hub.id} hub={hub} level={2} />)}</div>
</>
);
}
Expand Down
12 changes: 12 additions & 0 deletions apps/web/src/screens/Hub.css
Original file line number Diff line number Diff line change
Expand Up @@ -187,3 +187,15 @@
.c-hub-announcement-seen { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px; margin-top: 4px; color: var(--hub-muted); font-size: 12px; }
.c-hub-announcement-done { color: var(--hub-action); font-weight: 500; }
.c-hub-announcement-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; color: var(--hub-muted); font-size: 12px; }

/* The task box sits in a label, which phones widen to a 44px touch area. */
.c-hub-check { display: flex; flex: none; }
/* Phones: links, View buttons and task boxes reach 44px through an invisible
touch area, so the cards keep their look. */
@media (max-width: 560px) {
.c-hub-link, .c-hub-attention-end button { position: relative; }
.c-hub-link::before, .c-hub-attention-end button::before { content: ""; position: absolute; inset: -9px 0; }
.c-hub-attention-end button::before { inset: -8px 0; }
.c-hub-task-feedback .c-hub-link::before { content: none; }
.c-hub-check { align-items: center; width: 44px; height: 44px; margin: -13.5px -15px -13.5px -12px; padding-left: 12px; }
}
Loading