From 5fc51f7f277dacd16f745d33fe11440a3d5e7aeb Mon Sep 17 00:00:00 2001 From: Heyoub Date: Mon, 24 Aug 2026 13:19:06 -0400 Subject: [PATCH 01/14] chore(plans): add a11y-mechanical plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The accessibility audit's third bucket — findings with one obviously-correct fix each and no design decision attached. Recording it as a plan before touching code so the DAG carries the scope, the PR stack it sits on (#154 + #155), and its relationship to issue #156, which holds the design-decision findings that are deliberately NOT implemented here. Co-Authored-By: Claude Fable 5 --- plans/a11y-mechanical.md | 183 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 plans/a11y-mechanical.md diff --git a/plans/a11y-mechanical.md b/plans/a11y-mechanical.md new file mode 100644 index 0000000..f12c5cb --- /dev/null +++ b/plans/a11y-mechanical.md @@ -0,0 +1,183 @@ +--- +status: in-progress +depends: [] +specs: + - specs/behaviors/app-shell.md +issues: [] +--- + +# Plan: mechanical accessibility fixes across the SPA + +## Scope + +The third and largest bucket of the `apps/web` accessibility audit: findings +with **one obviously-correct fix each** and no design decision attached. +Repeated button names in lists, heading-level skips, missing toolbar +semantics, unannounced status changes, dates trapped in `title` attributes, +missing new-tab cues, landmark nesting, and the `Breadcrumbs` component that +`specs/behaviors/app-shell.md` prescribes but nothing renders. + +This branch **stacks on PR #154** (`fix/site-check-153` — header/nav rewrite) +and **PR #155** (`fix/aria-correctness` — ARIA validity), because it edits +many of the same files. It is cut from #154 with #155 merged in. + +It **complements issue #156**, which holds the audit's *design-decision* +findings (colour contrast, `document.title`, motion/pause controls, +`CardTitle` semantics, the `NetworkErrorBanner` "Retry" contradiction). +Nothing from #156 is implemented here — those need decisions, not fixes. + +Only one item is spec-facing, and it is conformance **to** an existing spec: +`specs/behaviors/app-shell.md` → Breadcrumbs already prescribes an exact +table of trails. **No spec change is needed anywhere in this plan.** + +## Implements + +- [app-shell.md](../specs/behaviors/app-shell.md) — **Breadcrumbs**: the + prescribed trail table is brought to code on all six screens that declare + one. The existing `Breadcrumbs.tsx` component was already correct and + complete; it was simply never imported. + +## Approach + +### 1. Breadcrumbs wiring (the spec-conformance item) + +`apps/web/src/components/Breadcrumbs.tsx` renders `nav[aria-label="Breadcrumb"] +> ol > li` with `aria-current="page"` on the last crumb — correct as written, +imported by nothing. Wired into the six screens the spec's table names, each +placed as the first child of the screen's content container (the spec's "row +below the header"): + +| Route | Trail | +|---|---| +| `/projects/:slug` | Projects › `` | +| `/projects/:slug/edit` | Projects › `<title>` › Edit | +| `/projects/create` | Projects › New project | +| `/members/:slug` | Members › `<fullName>` | +| `/tags/:namespace/:slug` | Tags › `<namespace>` › `<title>` | +| `/account` | Settings | + +No `specs/screens/*.md` mentions breadcrumbs at all, so there is no +contradiction to resolve — app-shell.md is the sole authority. + +### 2. Repeated identical button names in lists (SC 4.1.2 / 2.4.6) + +A screen-reader user tabbing a list of "Remove / Remove / Remove" has no way +to tell the rows apart. Each gets an `aria-label` that **contains its visible +text** (SC 2.5.3) plus the row's subject: + +| File | Buttons | Label shape | +|---|---|---| +| `modals/ManageMembersModal.tsx` | Edit role, Make maintainer, Remove | `Remove ${fullName}` | +| `screens/Account.tsx` | Revoke (per session) | `Revoke session on ${device}` | +| `pages/StaffAccountClaimQueue.tsx` | Approve, Deny | `Approve claim from ${login}` | +| `screens/ProjectDetail.tsx` | Mark filled, Close | `Close ${role.title}` | + +### 3. Heading levels + +Three index screens jump `h1` → `h3` because their card components render +`h3`. **The cards are not changed** — `PersonCard` and `HelpWantedCard` are +each used in a second context (`TagDetail`, `Home`, `Volunteer`) where they +sit correctly under a section `h2`. Instead each index screen gains an +`sr-only <h2>` section heading above its results region, which is both the +smaller change and the more honest markup: the grid *is* a section. + +`ProjectDetail` and `PersonDetail` aside headings go `h3` → `h2` directly — +they sit under the screen `h1` with no intervening heading, are used nowhere +else, and keep their existing classes so nothing moves visually. (Heading +level and visual size are independent.) + +`ProjectsIndex` was checked for the same pattern and does **not** have it — +`ProjectCard` already renders `h2`. No sr-only heading added there. + +### 4. `MarkdownEditor` toolbar + +The six formatting buttons were a bare `<div>` of buttons named "B", "I", +"Link"… Now `role="toolbar"` + `aria-label="Formatting"`, each button +`aria-label`'d with a full name that contains its visible label as a +substring (B ⊂ Bold, I ⊂ Italic, Link ⊂ Insert link, List ⊂ Bulleted list), +and a roving tabindex: only the active button is tabbable, +ArrowLeft/ArrowRight move focus (wrapping), Home/End jump to the ends. + +### 5. Status announcements + +Three places changed state visually with nothing announced: + +- `ProjectDetail` "Copy link" / "Share to Slack" gave **no feedback at all** — + now `toast.success(...)` via sonner, which this screen's own modals already + use for exactly this kind of confirmation. +- `Sponsor`'s "Copy email" swaps its label to "Copied ✓" — visible text kept, + with an `sr-only role="status"` mirror added. +- `ProfileEdit`'s "Uploading…" span becomes `role="status"`. +- `ConnectGitHubBanner` was `role="region"`, which is never announced; the + banner appears *after* auth resolves, so it becomes `role="status"`. + +### 6. `<time dateTime>` for machine-readable dates + +`title` is not exposed to most screen readers and never on touch. Every date +rendered as relative text inside a `title`-only `<span>` becomes +`<time dateTime={iso} title={absolute}>` — the `BlogIndex.tsx` idiom. +Covers `ActivityCard` (×2), `ProjectDetail` (×2), `BlogDetail`, `Account`, +`StaffAccountClaimQueue`, `AccountClaim`. `ProjectCard`'s wrapper +`title={m.fullName}` is deleted outright — `PersonAvatar` already emits it. + +### 7. Structure and one-liners + +- `PersonCard` was one giant `<Link>`, so its accessible name concatenated + avatar + name + project count + every tag chip. Restructured to the + `ProjectCard` idiom: `<article>` with the `h3` wrapping the link. The hover + lift moves to the article via `group-hover`, so the affordance is unchanged. +- `AppHeader`'s two navs render bare links; wrapped in `<ul>/<li>` matching + `AppFooter`. Flex/gap classes move to the `ul`; `li` contributes nothing. + Sheet separators sit between the two lists rather than inside one. +- The mobile sheet's "About" group label was a styled `<p>` → `<h3>` (one + level below the Radix `SheetTitle`, which renders `h2`), same classes. +- `HelpWantedIndex`'s bare outer `<aside>` wrapped `FacetSidebar`, which + renders its own labelled `<aside>` — two nested `complementary` landmarks. + Outer becomes a `<div>`. (`PeopleIndex`/`ProjectsIndex` render + `FacetSidebar` directly and never had this.) +- Result-count badges move **out** of the `h1` into a flex sibling on all + three index screens, so the heading's accessible name stops mutating as + filters change. +- `target="_blank"` links get a new-tab cue: `<span className="sr-only"> (opens + in new tab)</span>` where visible text exists, appended to the `aria-label` + where it does not. +- `ProjectDetail`'s "More ▾" menu trigger gets `aria-label="More actions"`; + the "What does this stage mean?" dialog trigger gets `aria-haspopup="dialog"`. + +## Validation + +- [ ] Breadcrumbs render on all six screens the spec's table names, with the + exact trails prescribed, and each non-final crumb links to its parent. +- [ ] No two buttons in the audited lists share an accessible name; every + added `aria-label` contains the button's visible text (SC 2.5.3). +- [ ] `PeopleIndex` / `HelpWantedIndex` no longer skip `h1` → `h3`; + `ProjectDetail` / `PersonDetail` aside headings are `h2`. +- [ ] The `MarkdownEditor` toolbar exposes `role="toolbar"`, named buttons, + and a working roving tabindex (Arrow/Home/End). +- [ ] Copy actions on `ProjectDetail` and `Sponsor` announce; `ProfileEdit` + upload and `ConnectGitHubBanner` are live regions. +- [ ] Dates expose `datetime`; no date is `title`-only. +- [ ] Exactly one `complementary` landmark per index screen. +- [ ] `npm run -w packages/shared build && npm run type-check && npm run lint + && npm run -w apps/web test && npm run -w packages/shared test` clean. +- [ ] Browser test — breadcrumbs, toolbar keyboard nav, and the copy toasts + verified in a real browser. _(for the coordinator)_ + +## Risks / unknowns + +- **Low.** Almost every change is attribute-level or a wrapper element. +- The two structural edits (`PersonCard`, `AppHeader` nav lists) touch files + PR #154 rewrote. Both keep every existing behavior — the sheet's `onClick` + close handlers, the separators, the NavLink active styling — and are + covered by the existing `AppHeader.test.tsx` suite plus updated name + matchers. +- The `apps/api` suite is deliberately not run: it has a known pre-existing + Windows fixture failure and no `apps/api` file changes here. + +## Notes + +_(filled in at closeout)_ + +## Follow-ups + +_(filled in at closeout)_ From 017975859a5a6e7ccc36daf834852abcef790173 Mon Sep 17 00:00:00 2001 From: Heyoub <hello@forgestack.app> Date: Mon, 24 Aug 2026 13:21:06 -0400 Subject: [PATCH 02/14] fix(web): render the breadcrumb trails app-shell.md prescribes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit specs/behaviors/app-shell.md carries a table of exact breadcrumb trails, and Breadcrumbs.tsx already implements them correctly — nav[aria-label], an ordered list, aria-current on the last crumb. Nothing ever imported it, so every trail in that table was spec-only. This is code brought into conformance with a spec that has not moved. Placed as a sibling above each screen's content container rather than inside it: the component supplies its own `container mx-auto px-4`, which only lands correctly as a direct child of <main> — nesting it would double the gutter. No specs/screens/*.md mentions breadcrumbs, so app-shell.md is the sole authority and there is nothing to reconcile. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- apps/web/src/screens/Account.tsx | 5 +++++ apps/web/src/screens/PersonDetail.tsx | 5 +++++ apps/web/src/screens/ProjectDetail.tsx | 5 +++++ apps/web/src/screens/ProjectEdit.tsx | 16 ++++++++++++++++ apps/web/src/screens/TagDetail.tsx | 11 +++++++++++ 5 files changed, 42 insertions(+) diff --git a/apps/web/src/screens/Account.tsx b/apps/web/src/screens/Account.tsx index a9260ca..fa2516f 100644 --- a/apps/web/src/screens/Account.tsx +++ b/apps/web/src/screens/Account.tsx @@ -5,6 +5,7 @@ import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Breadcrumbs } from '@/components/Breadcrumbs'; import { Dialog, DialogContent, @@ -154,6 +155,9 @@ export function Account() { const sessions = sessionsQ.data?.data ?? []; return ( + <> + {/* specs/behaviors/app-shell.md → Breadcrumbs: Settings */} + <Breadcrumbs items={[{ label: 'Settings' }]} /> <div className="container mx-auto px-4 py-8 max-w-3xl space-y-6"> <h1 className="text-2xl font-bold">Account Settings</h1> @@ -373,5 +377,6 @@ export function Account() { </CardContent> </Card> </div> + </> ); } diff --git a/apps/web/src/screens/PersonDetail.tsx b/apps/web/src/screens/PersonDetail.tsx index ebd8a68..38935d2 100644 --- a/apps/web/src/screens/PersonDetail.tsx +++ b/apps/web/src/screens/PersonDetail.tsx @@ -12,6 +12,7 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; +import { Breadcrumbs } from '@/components/Breadcrumbs'; import { MarkdownView } from '@/components/MarkdownView'; import { StageBadge } from '@/components/StageBadge'; import { TagChip } from '@/components/TagChip'; @@ -103,6 +104,9 @@ export function PersonDetail() { }); return ( + <> + {/* specs/behaviors/app-shell.md → Breadcrumbs: Members › <fullName> */} + <Breadcrumbs items={[{ label: 'Members', href: '/members' }, { label: person.fullName }]} /> <div className="container mx-auto px-4 py-8 grid grid-cols-1 lg:grid-cols-3 gap-8"> <div className="lg:col-span-2 space-y-8"> <header className="flex items-start gap-6"> @@ -338,5 +342,6 @@ export function PersonDetail() { )} </aside> </div> + </> ); } diff --git a/apps/web/src/screens/ProjectDetail.tsx b/apps/web/src/screens/ProjectDetail.tsx index 35d592e..5068192 100644 --- a/apps/web/src/screens/ProjectDetail.tsx +++ b/apps/web/src/screens/ProjectDetail.tsx @@ -17,6 +17,7 @@ import { DialogDescription, DialogFooter, } from '@/components/ui/dialog'; +import { Breadcrumbs } from '@/components/Breadcrumbs'; import { MarkdownView } from '@/components/MarkdownView'; import { StageProgressBar, StageBadge } from '@/components/StageBadge'; import { StageInfoDialog } from '@/components/StageInfoDialog'; @@ -187,6 +188,9 @@ export function ProjectDetail({ anchor }: ProjectDetailProps = {}) { const allTags = [...project.tags.tech, ...project.tags.topic, ...project.tags.event]; return ( + <> + {/* specs/behaviors/app-shell.md → Breadcrumbs: Projects › <title> */} + <Breadcrumbs items={[{ label: 'Projects', href: '/projects' }, { label: project.title }]} /> <div className="container mx-auto px-4 py-8"> {/* Soft-delete banner — staff only (project-detail.md) */} {showDeletedBanner && ( @@ -678,5 +682,6 @@ export function ProjectDetail({ anchor }: ProjectDetailProps = {}) { </DialogContent> </Dialog> </div> + </> ); } diff --git a/apps/web/src/screens/ProjectEdit.tsx b/apps/web/src/screens/ProjectEdit.tsx index 0330557..e1fe1c0 100644 --- a/apps/web/src/screens/ProjectEdit.tsx +++ b/apps/web/src/screens/ProjectEdit.tsx @@ -13,6 +13,7 @@ import { SelectValue, } from '@/components/ui/select'; import { Checkbox } from '@/components/ui/checkbox'; +import { Breadcrumbs } from '@/components/Breadcrumbs'; import { MarkdownEditor } from '@/components/MarkdownEditor'; import { TagPicker } from '@/components/TagPicker'; import { STAGES, type Stage } from '@/components/StageBadge'; @@ -268,6 +269,20 @@ export function ProjectEdit({ mode }: ProjectEditProps) { : ''; return ( + <> + {/* specs/behaviors/app-shell.md → Breadcrumbs: + create → Projects › New project; edit → Projects › <title> › Edit */} + <Breadcrumbs + items={ + mode === 'create' + ? [{ label: 'Projects', href: '/projects' }, { label: 'New project' }] + : [ + { label: 'Projects', href: '/projects' }, + { label: project?.title ?? '', href: `/projects/${project?.slug ?? ''}` }, + { label: 'Edit' }, + ] + } + /> <div className="container mx-auto px-4 py-8 max-w-3xl"> <header className="flex items-center justify-between mb-6"> <h1 className="text-2xl font-bold"> @@ -503,5 +518,6 @@ export function ProjectEdit({ mode }: ProjectEditProps) { )} </form> </div> + </> ); } diff --git a/apps/web/src/screens/TagDetail.tsx b/apps/web/src/screens/TagDetail.tsx index a3902c1..8ce9706 100644 --- a/apps/web/src/screens/TagDetail.tsx +++ b/apps/web/src/screens/TagDetail.tsx @@ -3,6 +3,7 @@ import { Link, useNavigate, useParams } from 'react-router'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; +import { Breadcrumbs } from '@/components/Breadcrumbs'; import { ProjectCard } from '@/components/ProjectCard'; import { PersonCard } from '@/components/PersonCard'; import { HelpWantedCard } from '@/components/HelpWantedCard'; @@ -102,6 +103,15 @@ export function TagDetail() { }; return ( + <> + {/* specs/behaviors/app-shell.md → Breadcrumbs: Tags › <namespace> › <title> */} + <Breadcrumbs + items={[ + { label: 'Tags', href: '/tags' }, + { label: tag.namespace, href: `/tags/${tag.namespace}` }, + { label: tag.title }, + ]} + /> <div className="container mx-auto px-4 py-8 space-y-10"> <header className="flex items-start justify-between gap-3"> <div> @@ -224,5 +234,6 @@ export function TagDetail() { </section> )} </div> + </> ); } From 42cffe6b8f3d746b85010b34cf4e49fb9874b248 Mon Sep 17 00:00:00 2001 From: Heyoub <hello@forgestack.app> Date: Mon, 24 Aug 2026 13:25:37 -0400 Subject: [PATCH 03/14] fix(web): repair heading levels and landmark structure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A screen reader's heading outline is a navigation aid, and three index screens broke it by jumping h1 -> h3: the card components render h3, which is correct where those cards sit under a section h2 (TagDetail, Home, Volunteer) but leaves a gap on the index screens. Rather than change a shared card and break it in its other homes, each index gains an sr-only h2 over its results region — the grid genuinely is a section. ProjectsIndex was checked and does not have the defect; ProjectCard already renders h2. The detail-screen aside headings go h3 -> h2 directly. They sit under the screen h1 with nothing between, are used nowhere else, and keep their classes so nothing moves: heading level and visual size are independent. PersonCard wrapped the entire card in one <a>, so its accessible name was the avatar title, the name, the project count and every tag chip concatenated into one string. Restructured to the ProjectCard idiom with a stretched pseudo-element so the whole card stays clickable. The header's navs rendered bare links; a nav without a list does not tell you how many destinations it has. Both are now ul/li matching AppFooter, with the mobile sheet's three groups as three lists so the separators and the About heading are not list children. That "About" label was a styled <p>; it is now an h3, one level under the SheetTitle that Radix renders as an h2. HelpWantedIndex wrapped FacetSidebar — which renders its own labelled aside — in a second bare <aside>, nesting two complementary landmarks with the outer one unnamed. The outer element is now a div. Result-count badges move out of the h1 on all three index screens: a heading whose accessible name changes on every keystroke is not a stable landmark. The two GitHub links in this file also pick up their new-tab cues here rather than splitting one file's edits across two commits. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- apps/web/src/components/AppHeader.tsx | 162 +++++++++++++++-------- apps/web/src/components/PersonCard.tsx | 22 ++- apps/web/src/screens/HelpWantedIndex.tsx | 23 +++- apps/web/src/screens/PeopleIndex.tsx | 12 +- apps/web/src/screens/PersonDetail.tsx | 8 +- apps/web/src/screens/ProjectDetail.tsx | 16 +-- apps/web/src/screens/ProjectsIndex.tsx | 14 +- apps/web/tests/AppHeader.test.tsx | 31 ++++- 8 files changed, 191 insertions(+), 97 deletions(-) diff --git a/apps/web/src/components/AppHeader.tsx b/apps/web/src/components/AppHeader.tsx index bdde238..bc215af 100644 --- a/apps/web/src/components/AppHeader.tsx +++ b/apps/web/src/components/AppHeader.tsx @@ -197,7 +197,7 @@ function GitHubLink() { href={GITHUB_URL} target="_blank" rel="noopener noreferrer" - aria-label="Code for Philly on GitHub" + aria-label="Code for Philly on GitHub (opens in new tab)" > <GitHubIcon /> </a> @@ -235,20 +235,33 @@ export function AppHeader() { {/* Desktop content cluster. The parent gap is the only source of spacing between children — no per-child margins. */} + {/* A nav is a list of destinations — the <ul>/<li> is what tells a + screen reader how many there are and where you are in them. The + flex/gap spacing moves to the <ul>; the <li>s contribute none. */} <nav aria-label="Primary navigation" - className="hidden md:flex items-center gap-2 ml-4 flex-1" + className="hidden md:block ml-4 flex-1" > - <NavLink to="/projects" className={navLinkClass}> - Projects - </NavLink> - <NavLink to="/help-wanted" className={navLinkClass}> - Help Wanted - </NavLink> - <NavLink to="/members" className={navLinkClass}> - Members - </NavLink> - <AboutDropdown /> + <ul className="flex items-center gap-2"> + <li> + <NavLink to="/projects" className={navLinkClass}> + Projects + </NavLink> + </li> + <li> + <NavLink to="/help-wanted" className={navLinkClass}> + Help Wanted + </NavLink> + </li> + <li> + <NavLink to="/members" className={navLinkClass}> + Members + </NavLink> + </li> + <li> + <AboutDropdown /> + </li> + </ul> </nav> {/* Desktop utility cluster: GitHub, search, auth, then the Volunteer @@ -295,58 +308,93 @@ export function AppHeader() { </SheetHeader> {/* min-h-0 + overflow-y-auto so the list stays reachable on short viewports instead of overflowing the panel. */} + {/* Three lists with the separators and the group heading + between them, rather than one list interrupted by + non-list children. The nav keeps the flex column so the + gap-2 rhythm between groups is unchanged. */} <nav aria-label="Mobile navigation" className="flex flex-col gap-2 px-4 min-h-0 overflow-y-auto" > - <NavLink to="/projects" className={navLinkClass}> - Projects - </NavLink> - <NavLink to="/help-wanted" className={navLinkClass}> - Help Wanted - </NavLink> - <NavLink to="/members" className={navLinkClass}> - Members - </NavLink> + <ul className="flex flex-col gap-2"> + <li> + <NavLink to="/projects" className={navLinkClass}> + Projects + </NavLink> + </li> + <li> + <NavLink to="/help-wanted" className={navLinkClass}> + Help Wanted + </NavLink> + </li> + <li> + <NavLink to="/members" className={navLinkClass}> + Members + </NavLink> + </li> + </ul> <Separator /> - <p className="text-xs text-muted-foreground font-medium uppercase tracking-wide"> + {/* SheetTitle ("Menu") renders a Radix DialogTitle, i.e. an + h2 — so this group label is an h3, not a styled <p>. */} + <h3 className="text-xs text-muted-foreground font-medium uppercase tracking-wide"> About - </p> - <NavLink to="/pages/mission" className={navLinkClass}> - Mission - </NavLink> - <NavLink to="/pages/leadership" className={navLinkClass}> - Leadership - </NavLink> - <NavLink to="/pages/code-of-conduct" className={navLinkClass}> - Code of Conduct - </NavLink> - <NavLink to="/pages/hackathons" className={navLinkClass}> - Hackathons - </NavLink> - <NavLink to="/sponsor" className={navLinkClass}> - Sponsor - </NavLink> - <a - href="mailto:hello@codeforphilly.org" - className="text-sm font-medium text-muted-foreground hover:text-primary" - onClick={() => setMobileOpen(false)} - > - Contact - </a> + </h3> + <ul className="flex flex-col gap-2"> + <li> + <NavLink to="/pages/mission" className={navLinkClass}> + Mission + </NavLink> + </li> + <li> + <NavLink to="/pages/leadership" className={navLinkClass}> + Leadership + </NavLink> + </li> + <li> + <NavLink to="/pages/code-of-conduct" className={navLinkClass}> + Code of Conduct + </NavLink> + </li> + <li> + <NavLink to="/pages/hackathons" className={navLinkClass}> + Hackathons + </NavLink> + </li> + <li> + <NavLink to="/sponsor" className={navLinkClass}> + Sponsor + </NavLink> + </li> + <li> + <a + href="mailto:hello@codeforphilly.org" + className="text-sm font-medium text-muted-foreground hover:text-primary" + onClick={() => setMobileOpen(false)} + > + Contact + </a> + </li> + </ul> <Separator /> - <a - href={GITHUB_URL} - target="_blank" - rel="noopener noreferrer" - className="text-sm font-medium text-muted-foreground hover:text-primary" - onClick={() => setMobileOpen(false)} - > - GitHub - </a> - <NavLink to="/volunteer" className={navLinkClass}> - Volunteer - </NavLink> + <ul className="flex flex-col gap-2"> + <li> + <a + href={GITHUB_URL} + target="_blank" + rel="noopener noreferrer" + className="text-sm font-medium text-muted-foreground hover:text-primary" + onClick={() => setMobileOpen(false)} + > + GitHub + <span className="sr-only"> (opens in new tab)</span> + </a> + </li> + <li> + <NavLink to="/volunteer" className={navLinkClass}> + Volunteer + </NavLink> + </li> + </ul> </nav> <Separator /> <div className="px-4 pb-4"> diff --git a/apps/web/src/components/PersonCard.tsx b/apps/web/src/components/PersonCard.tsx index 29cb464..ed591ce 100644 --- a/apps/web/src/components/PersonCard.tsx +++ b/apps/web/src/components/PersonCard.tsx @@ -9,13 +9,23 @@ interface PersonCardProps { export function PersonCard({ person }: PersonCardProps) { return ( - <Link - to={`/members/${person.slug}`} - className="block rounded-lg border border-border bg-card p-4 hover:shadow-md hover:-translate-y-0.5 transition-all" - > + // The whole card used to be one <a>, so its accessible name was the + // avatar's title plus the name plus the project count plus every tag + // chip, read as one run-on string. Follow the ProjectCard idiom instead: + // an <article> whose heading wraps the only link, named by the person. + // A stretched pseudo-element keeps the entire card clickable — safe here + // because the avatar and chips are deliberately non-interactive. + <article className="relative rounded-lg border border-border bg-card p-4 hover:shadow-md hover:-translate-y-0.5 transition-all"> <div className="flex flex-col items-center text-center"> <PersonAvatar person={{ slug: person.slug, fullName: person.fullName, avatarUrl: person.avatarUrl }} size={80} asLink={false} className="rounded-lg" /> - <h3 className="mt-3 font-semibold text-foreground">{person.fullName}</h3> + <h3 className="mt-3 font-semibold text-foreground"> + <Link + to={`/members/${person.slug}`} + className="after:absolute after:inset-0 after:rounded-lg hover:text-primary transition-colors" + > + {person.fullName} + </Link> + </h3> {person.memberOfCount > 0 && ( <p className="text-xs text-muted-foreground mt-0.5"> Member of {person.memberOfCount} project{person.memberOfCount === 1 ? '' : 's'} @@ -29,6 +39,6 @@ export function PersonCard({ person }: PersonCardProps) { </div> )} </div> - </Link> + </article> ); } diff --git a/apps/web/src/screens/HelpWantedIndex.tsx b/apps/web/src/screens/HelpWantedIndex.tsx index ae916f7..6755035 100644 --- a/apps/web/src/screens/HelpWantedIndex.tsx +++ b/apps/web/src/screens/HelpWantedIndex.tsx @@ -90,12 +90,14 @@ export function HelpWantedIndex() { <div className="container mx-auto px-4 py-8"> <header className="mb-6 flex items-start justify-between gap-3"> <div> - <h1 className="text-3xl font-bold flex items-center gap-3"> - Help Wanted + {/* The count is a sibling of the h1, not part of it: an accessible + name that mutates on every filter change is a moving target. */} + <div className="flex items-center gap-3"> + <h1 className="text-3xl font-bold">Help Wanted</h1> <span className="inline-flex items-center rounded-full bg-muted text-muted-foreground px-2.5 py-0.5 text-sm"> {totalItems} </span> - </h1> + </div> <p className="text-muted-foreground mt-2 max-w-3xl"> Concrete, time-boxed ways to contribute to Code for Philly projects. </p> @@ -107,7 +109,10 @@ export function HelpWantedIndex() { <PostRolePickerModal open={pickerOpen} onOpenChange={setPickerOpen} /> <div className="grid grid-cols-1 md:grid-cols-[16rem_1fr] gap-6"> - <aside> + {/* A plain div, not an <aside>: FacetSidebar renders its own labelled + <aside>, and wrapping it in another one nests two `complementary` + landmarks — the outer one unlabelled. */} + <div> <FacetSidebar facets={facets} activeTags={tags} @@ -116,9 +121,9 @@ export function HelpWantedIndex() { /> <div className="mt-6"> - <h3 className="text-xs font-semibold uppercase tracking-wide mb-2 text-muted-foreground"> + <h2 className="text-xs font-semibold uppercase tracking-wide mb-2 text-muted-foreground"> Commitment - </h3> + </h2> <fieldset className="flex flex-col gap-1"> <legend className="sr-only">Maximum commitment hours per week</legend> {COMMITMENT_OPTIONS.map((o) => ( @@ -146,9 +151,13 @@ export function HelpWantedIndex() { ))} </fieldset> </div> - </aside> + </div> <div> + {/* HelpWantedCard renders an h3 (it also sits under section h2s on + Home, TagDetail and Volunteer), so the results list needs its own + h2 or the page skips h1 → h3. Visually redundant, hence sr-only. */} + <h2 className="sr-only">Results</h2> <div className="flex items-center gap-2 flex-wrap text-sm mb-4"> {hasActiveFilters && ( <> diff --git a/apps/web/src/screens/PeopleIndex.tsx b/apps/web/src/screens/PeopleIndex.tsx index 9930b1b..4de703c 100644 --- a/apps/web/src/screens/PeopleIndex.tsx +++ b/apps/web/src/screens/PeopleIndex.tsx @@ -103,12 +103,14 @@ export function PeopleIndex() { return ( <div className="container mx-auto px-4 py-8"> <div className="flex items-center justify-between gap-4 mb-4"> - <h1 className="text-3xl font-bold flex items-center gap-3"> - Members + {/* The count is a sibling of the h1, not part of it: an accessible + name that mutates on every filter change is a moving target. */} + <div className="flex items-center gap-3"> + <h1 className="text-3xl font-bold">Members</h1> <span className="inline-flex items-center rounded-full bg-muted text-muted-foreground px-2.5 py-0.5 text-sm"> {totalItems} </span> - </h1> + </div> </div> <Input @@ -129,6 +131,10 @@ export function PeopleIndex() { /> <div> + {/* PersonCard renders an h3 (it also sits under section h2s on + TagDetail), so the results grid needs its own h2 or the page + skips h1 → h3. Visually redundant, hence sr-only. */} + <h2 className="sr-only">Results</h2> <div className="flex items-center justify-between flex-wrap gap-3 mb-4"> <div className="flex items-center gap-2 flex-wrap text-sm"> {hasActiveFilters && ( diff --git a/apps/web/src/screens/PersonDetail.tsx b/apps/web/src/screens/PersonDetail.tsx index 38935d2..5d0bc5b 100644 --- a/apps/web/src/screens/PersonDetail.tsx +++ b/apps/web/src/screens/PersonDetail.tsx @@ -219,9 +219,9 @@ export function PersonDetail() { <aside className="space-y-4 text-sm"> {(person.slackHandle || person.email) && ( <section> - <h3 className="text-sm font-semibold mb-2 text-muted-foreground uppercase tracking-wide"> + <h2 className="text-sm font-semibold mb-2 text-muted-foreground uppercase tracking-wide"> Contact - </h3> + </h2> <ul className="space-y-1"> {person.slackHandle && ( <li> @@ -249,9 +249,9 @@ export function PersonDetail() { </section> )} <section> - <h3 className="text-sm font-semibold mb-2 text-muted-foreground uppercase tracking-wide"> + <h2 className="text-sm font-semibold mb-2 text-muted-foreground uppercase tracking-wide"> Member since - </h3> + </h2> <p>{formatMonthYear(person.createdAt)}</p> </section> {isSelf && ( diff --git a/apps/web/src/screens/ProjectDetail.tsx b/apps/web/src/screens/ProjectDetail.tsx index 5068192..f8b852b 100644 --- a/apps/web/src/screens/ProjectDetail.tsx +++ b/apps/web/src/screens/ProjectDetail.tsx @@ -436,9 +436,9 @@ export function ProjectDetail({ anchor }: ProjectDetailProps = {}) { {/* Project info */} <section> - <h3 className="text-sm font-semibold mb-3 text-muted-foreground uppercase tracking-wide"> + <h2 className="text-sm font-semibold mb-3 text-muted-foreground uppercase tracking-wide"> Project Info - </h3> + </h2> <div className="flex flex-col gap-2"> {project.links.usersUrl && ( <Button asChild> @@ -468,9 +468,9 @@ export function ProjectDetail({ anchor }: ProjectDetailProps = {}) { {project.memberships.length > 0 && ( <section> <div className="flex items-center justify-between mb-3"> - <h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide"> + <h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide"> Members ({project.counts.members}) - </h3> + </h2> {perms.canManageMembers && ( <Button size="sm" @@ -499,9 +499,9 @@ export function ProjectDetail({ anchor }: ProjectDetailProps = {}) { {/* Tags */} {allTags.length > 0 && ( <section> - <h3 className="text-sm font-semibold mb-3 text-muted-foreground uppercase tracking-wide"> + <h2 className="text-sm font-semibold mb-3 text-muted-foreground uppercase tracking-wide"> Tags - </h3> + </h2> <div className="space-y-2"> {project.tags.tech.length > 0 && ( <div> @@ -539,9 +539,9 @@ export function ProjectDetail({ anchor }: ProjectDetailProps = {}) { {/* Share */} <section> - <h3 className="text-sm font-semibold mb-3 text-muted-foreground uppercase tracking-wide"> + <h2 className="text-sm font-semibold mb-3 text-muted-foreground uppercase tracking-wide"> Share - </h3> + </h2> <div className="flex flex-col gap-2"> <Button variant="outline" diff --git a/apps/web/src/screens/ProjectsIndex.tsx b/apps/web/src/screens/ProjectsIndex.tsx index 26cfcb2..8e6022e 100644 --- a/apps/web/src/screens/ProjectsIndex.tsx +++ b/apps/web/src/screens/ProjectsIndex.tsx @@ -133,13 +133,13 @@ export function ProjectsIndex() { <div className="container mx-auto px-4 py-8"> {/* Header */} <div className="flex items-start justify-between gap-4 mb-2"> - <div> - <h1 className="text-3xl font-bold flex items-center gap-3"> - Civic Projects Directory - <span className="inline-flex items-center rounded-full bg-muted text-muted-foreground px-2.5 py-0.5 text-sm"> - {totalItems} - </span> - </h1> + {/* The count is a sibling of the h1, not part of it: an accessible + name that mutates on every filter change is a moving target. */} + <div className="flex items-center gap-3"> + <h1 className="text-3xl font-bold">Civic Projects Directory</h1> + <span className="inline-flex items-center rounded-full bg-muted text-muted-foreground px-2.5 py-0.5 text-sm"> + {totalItems} + </span> </div> {person && ( <Button asChild> diff --git a/apps/web/tests/AppHeader.test.tsx b/apps/web/tests/AppHeader.test.tsx index 0140c75..2e14cd7 100644 --- a/apps/web/tests/AppHeader.test.tsx +++ b/apps/web/tests/AppHeader.test.tsx @@ -53,7 +53,9 @@ describe('AppHeader', () => { it('renders the GitHub link in the utility cluster', async () => { renderWithRouter(<Wrapped />); - const gh = screen.getByRole('link', { name: 'Code for Philly on GitHub' }); + const gh = screen.getByRole('link', { + name: 'Code for Philly on GitHub (opens in new tab)', + }); expect(gh).toHaveAttribute('href', 'https://github.com/CodeForPhilly'); expect(gh).toHaveAttribute('target', '_blank'); expect(gh).toHaveAttribute('rel', 'noopener noreferrer'); @@ -121,6 +123,24 @@ describe('AppHeader', () => { expect(dialog).toBeInTheDocument(); }); + it('marks up both navs as lists', async () => { + const user = userEvent.setup(); + renderWithRouter(<Wrapped />); + + const desktop = screen.getByRole('navigation', { name: /primary navigation/i }); + expect(within(desktop).getByRole('list')).toBeInTheDocument(); + // Projects, Help Wanted, Members, About + expect(within(desktop).getAllByRole('listitem')).toHaveLength(4); + + await user.click(screen.getByRole('button', { name: /open navigation menu/i })); + const mobile = await screen.findByRole('navigation', { name: /mobile navigation/i }); + // Three groups: primary, About, and the GitHub/Volunteer tail. + expect(within(mobile).getAllByRole('list')).toHaveLength(3); + expect( + within(mobile).getByRole('heading', { name: 'About', level: 3 }), + ).toBeInTheDocument(); + }); + it('lists GitHub and Volunteer in the mobile sheet', async () => { const user = userEvent.setup(); renderWithRouter(<Wrapped />); @@ -128,10 +148,11 @@ describe('AppHeader', () => { await user.click(screen.getByRole('button', { name: /open navigation menu/i })); const nav = await screen.findByRole('navigation', { name: /mobile navigation/i }); - expect(within(nav).getByRole('link', { name: 'GitHub' })).toHaveAttribute( - 'href', - 'https://github.com/CodeForPhilly', - ); + // Regex, not an exact string: the sr-only cue is a separate text node and + // accname implementations differ on whether they insert a separator. + expect( + within(nav).getByRole('link', { name: /^GitHub\s*\(opens in new tab\)$/ }), + ).toHaveAttribute('href', 'https://github.com/CodeForPhilly'); expect(within(nav).getByRole('link', { name: 'Volunteer' })).toHaveAttribute( 'href', '/volunteer', From 77500241d601da8ceb67b5195ebca52ce07afeca Mon Sep 17 00:00:00 2001 From: Heyoub <hello@forgestack.app> Date: Mon, 24 Aug 2026 13:28:21 -0400 Subject: [PATCH 04/14] fix(web): give the toolbar semantics and announce silent state changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The six formatting controls were an unlabelled div of buttons named "B", "I", "Link" — meaningless out of visual context — and six consecutive tab stops between the label and the textarea. They are now a labelled toolbar with one tab stop and arrow-key movement (ARIA APG). Each accessible name is a superset of its visible label so speech input still works. Four places changed state with nothing announced: - ProjectDetail's "Copy link" and "Share to Slack" gave no feedback at all, to anyone — the clipboard write was the entire interaction. They now raise a sonner toast, which the modals this screen already renders use for the same purpose, and surface a failed clipboard write instead of swallowing it. - Sponsor's "Copy email" signals success by renaming itself, and a control's own name changing is not announced. An sr-only live region mirrors it. - ProfileEdit's "Uploading…" appeared and vanished silently; it is now a status region that persists across both states so it can announce. - ConnectGitHubBanner was role="region", which is a landmark: it is only reachable by going looking for it. The banner renders after auth resolves, i.e. after first paint, so it needs role="status" to be heard at all. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- .../src/components/ConnectGitHubBanner.tsx | 5 +- apps/web/src/components/MarkdownEditor.tsx | 62 +++++++++++++--- apps/web/src/screens/ProfileEdit.tsx | 8 ++- apps/web/src/screens/ProjectDetail.tsx | 19 +++-- apps/web/src/screens/Sponsor.tsx | 5 ++ apps/web/tests/ConnectGitHubBanner.test.tsx | 12 ++-- apps/web/tests/MarkdownEditor.test.tsx | 72 +++++++++++++++++++ 7 files changed, 160 insertions(+), 23 deletions(-) create mode 100644 apps/web/tests/MarkdownEditor.test.tsx diff --git a/apps/web/src/components/ConnectGitHubBanner.tsx b/apps/web/src/components/ConnectGitHubBanner.tsx index e745295..efd1c2a 100644 --- a/apps/web/src/components/ConnectGitHubBanner.tsx +++ b/apps/web/src/components/ConnectGitHubBanner.tsx @@ -30,8 +30,11 @@ export function ConnectGitHubBanner() { if (dismissed) return null; return ( + // role="status", not "region": the banner appears only once auth has + // resolved, so it arrives after first paint and a landmark would never + // announce it. The aria-label stays as its accessible name. <div - role="region" + role="status" aria-label="Connect GitHub" className="border-b border-primary/40 bg-primary/5 print:hidden" > diff --git a/apps/web/src/components/MarkdownEditor.tsx b/apps/web/src/components/MarkdownEditor.tsx index a186b0f..25eb0b0 100644 --- a/apps/web/src/components/MarkdownEditor.tsx +++ b/apps/web/src/components/MarkdownEditor.tsx @@ -1,4 +1,4 @@ -import { useEffect, useId, useRef, useState } from 'react'; +import { useEffect, useId, useRef, useState, type KeyboardEvent } from 'react'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; import { Button } from '@/components/ui/button'; @@ -20,17 +20,23 @@ interface MarkdownEditorProps { interface ToolbarButton { label: string; + /** + * Accessible name. Each one *contains* the visible label so that a + * speech-input user saying what they see still hits the control + * (WCAG 2.5.3 Label in Name). + */ + name: string; insert: string; wrap?: { before: string; after: string }; } const TOOLBAR: ToolbarButton[] = [ - { label: 'B', insert: 'bold text', wrap: { before: '**', after: '**' } }, - { label: 'I', insert: 'italic text', wrap: { before: '_', after: '_' } }, - { label: 'Link', insert: 'link text', wrap: { before: '[', after: '](https://)' } }, - { label: 'List', insert: '- item' }, - { label: 'Code', insert: 'code', wrap: { before: '`', after: '`' } }, - { label: 'Quote', insert: '> quote' }, + { label: 'B', name: 'Bold', insert: 'bold text', wrap: { before: '**', after: '**' } }, + { label: 'I', name: 'Italic', insert: 'italic text', wrap: { before: '_', after: '_' } }, + { label: 'Link', name: 'Insert link', insert: 'link text', wrap: { before: '[', after: '](https://)' } }, + { label: 'List', name: 'Bulleted list', insert: '- item' }, + { label: 'Code', name: 'Code', insert: 'code', wrap: { before: '`', after: '`' } }, + { label: 'Quote', name: 'Quote', insert: '> quote' }, ]; /** @@ -55,6 +61,11 @@ export function MarkdownEditor({ const id = useId(); const errorId = `${id}-error`; const textareaRef = useRef<HTMLTextAreaElement>(null); + const toolbarRefs = useRef<Array<HTMLButtonElement | null>>([]); + // Roving tabindex: a toolbar is one tab stop, and the arrow keys move + // within it (ARIA APG Toolbar pattern). Without this the six formatting + // buttons sat between the label and the textarea as six separate tab stops. + const [activeButton, setActiveButton] = useState(0); const [previewHtml, setPreviewHtml] = useState<string>(''); const [previewLoading, setPreviewLoading] = useState(false); const [previewError, setPreviewError] = useState<string | null>(null); @@ -132,6 +143,28 @@ export function MarkdownEditor({ }); }; + const focusToolbarButton = (index: number) => { + setActiveButton(index); + toolbarRefs.current[index]?.focus(); + }; + + const handleToolbarKeyDown = (e: KeyboardEvent<HTMLDivElement>) => { + const last = TOOLBAR.length - 1; + if (e.key === 'ArrowRight') { + e.preventDefault(); + focusToolbarButton(activeButton === last ? 0 : activeButton + 1); + } else if (e.key === 'ArrowLeft') { + e.preventDefault(); + focusToolbarButton(activeButton === 0 ? last : activeButton - 1); + } else if (e.key === 'Home') { + e.preventDefault(); + focusToolbarButton(0); + } else if (e.key === 'End') { + e.preventDefault(); + focusToolbarButton(last); + } + }; + const count = value.length; const overSoftLimit = maxLength !== undefined && count > maxLength; @@ -147,14 +180,25 @@ export function MarkdownEditor({ <p className="text-xs text-muted-foreground">{description}</p> )} <div className="border border-border rounded-md overflow-hidden"> - <div className="flex flex-wrap gap-1 bg-muted/50 border-b border-border px-2 py-1.5"> - {TOOLBAR.map((btn) => ( + <div + role="toolbar" + aria-label="Formatting" + onKeyDown={handleToolbarKeyDown} + className="flex flex-wrap gap-1 bg-muted/50 border-b border-border px-2 py-1.5" + > + {TOOLBAR.map((btn, i) => ( <Button key={btn.label} + ref={(el: HTMLButtonElement | null) => { + toolbarRefs.current[i] = el; + }} type="button" size="sm" variant="ghost" className="h-7 px-2 text-xs" + aria-label={btn.name} + tabIndex={i === activeButton ? 0 : -1} + onFocus={() => setActiveButton(i)} onClick={() => applyToolbar(btn)} > {btn.label} diff --git a/apps/web/src/screens/ProfileEdit.tsx b/apps/web/src/screens/ProfileEdit.tsx index 465757c..c1c007b 100644 --- a/apps/web/src/screens/ProfileEdit.tsx +++ b/apps/web/src/screens/ProfileEdit.tsx @@ -210,9 +210,11 @@ export function ProfileEdit() { disabled={avatarUploading} className="block" /> - {avatarUploading && ( - <span className="block mt-1 text-xs text-muted-foreground">Uploading…</span> - )} + {/* role="status" so the upload's progress is announced rather + than only appearing next to the file input. */} + <span role="status" className="block mt-1 text-xs text-muted-foreground"> + {avatarUploading ? 'Uploading…' : ''} + </span> </div> </div> </div> diff --git a/apps/web/src/screens/ProjectDetail.tsx b/apps/web/src/screens/ProjectDetail.tsx index f8b852b..06c9dca 100644 --- a/apps/web/src/screens/ProjectDetail.tsx +++ b/apps/web/src/screens/ProjectDetail.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useState } from 'react'; import { Link, useParams, useSearchParams } from 'react-router'; import { useQuery } from '@tanstack/react-query'; +import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { DropdownMenu, @@ -543,10 +544,17 @@ export function ProjectDetail({ anchor }: ProjectDetailProps = {}) { Share </h2> <div className="flex flex-col gap-2"> + {/* Both buttons used to copy silently — nothing changed on + screen, so nobody (sighted or not) could tell it worked. + sonner is what this screen's own modals already use for + action confirmations. */} <Button variant="outline" onClick={() => { - void navigator.clipboard.writeText(`https://codeforphilly.org/projects/${slug}`); + void navigator.clipboard + .writeText(`https://codeforphilly.org/projects/${slug}`) + .then(() => toast.success('Link copied')) + .catch(() => toast.error("Couldn't copy the link")); }} > Copy link @@ -557,9 +565,12 @@ export function ProjectDetail({ anchor }: ProjectDetailProps = {}) { // Copy a pre-formatted Slack message. Spec calls this // out as either system-share or copy; copy works in every // browser context without a Web Share API gate. - void navigator.clipboard.writeText( - `Check out ${project.title} on Code for Philly: https://codeforphilly.org/projects/${slug}`, - ); + void navigator.clipboard + .writeText( + `Check out ${project.title} on Code for Philly: https://codeforphilly.org/projects/${slug}`, + ) + .then(() => toast.success('Slack message copied')) + .catch(() => toast.error("Couldn't copy the message")); }} > Share to Slack diff --git a/apps/web/src/screens/Sponsor.tsx b/apps/web/src/screens/Sponsor.tsx index 49cd58c..c6f7557 100644 --- a/apps/web/src/screens/Sponsor.tsx +++ b/apps/web/src/screens/Sponsor.tsx @@ -106,6 +106,11 @@ export function Sponsor() { <Button variant="outline" size="sm" onClick={handleCopy}> {copied ? 'Copied ✓' : 'Copy email'} </Button> + {/* The label swap is the only success signal, and a control's own + name changing is not announced. Mirror it in a live region. */} + <span role="status" className="sr-only"> + {copied ? `${email} copied to clipboard` : ''} + </span> </div> </div> </section> diff --git a/apps/web/tests/ConnectGitHubBanner.test.tsx b/apps/web/tests/ConnectGitHubBanner.test.tsx index 6a21568..3637bc2 100644 --- a/apps/web/tests/ConnectGitHubBanner.test.tsx +++ b/apps/web/tests/ConnectGitHubBanner.test.tsx @@ -70,11 +70,11 @@ describe('ConnectGitHubBanner', () => { render(); await waitFor(() => { expect( - screen.getByRole('region', { name: /connect github/i }), + screen.getByRole('status', { name: /connect github/i }), ).toBeInTheDocument(); }); // CTA form posts to the link endpoint. - const region = screen.getByRole('region', { name: /connect github/i }); + const region = screen.getByRole('status', { name: /connect github/i }); expect(region.querySelector('form[action="/api/auth/link-github"]')).not.toBeNull(); expect(screen.getByRole('button', { name: /dismiss/i })).toBeInTheDocument(); }); @@ -84,7 +84,7 @@ describe('ConnectGitHubBanner', () => { render(); await waitFor(() => { expect( - screen.getByRole('region', { name: /connect github/i }), + screen.getByRole('status', { name: /connect github/i }), ).toBeInTheDocument(); }); }); @@ -101,7 +101,7 @@ describe('ConnectGitHubBanner', () => { // microtask gap. await new Promise((r) => setTimeout(r, 0)); expect( - screen.queryByRole('region', { name: /connect github/i }), + screen.queryByRole('status', { name: /connect github/i }), ).not.toBeInTheDocument(); }); @@ -110,7 +110,7 @@ describe('ConnectGitHubBanner', () => { render(); await new Promise((r) => setTimeout(r, 0)); expect( - screen.queryByRole('region', { name: /connect github/i }), + screen.queryByRole('status', { name: /connect github/i }), ).not.toBeInTheDocument(); }); @@ -121,7 +121,7 @@ describe('ConnectGitHubBanner', () => { fireEvent.click(dismissBtn); await waitFor(() => { expect( - screen.queryByRole('region', { name: /connect github/i }), + screen.queryByRole('status', { name: /connect github/i }), ).not.toBeInTheDocument(); }); }); diff --git a/apps/web/tests/MarkdownEditor.test.tsx b/apps/web/tests/MarkdownEditor.test.tsx new file mode 100644 index 0000000..a621cf9 --- /dev/null +++ b/apps/web/tests/MarkdownEditor.test.tsx @@ -0,0 +1,72 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWithRouter } from './test-utils.js'; +import { MarkdownEditor } from '../src/components/MarkdownEditor.js'; + +function Harness() { + return <MarkdownEditor label="Overview" value="" onChange={() => {}} />; +} + +describe('MarkdownEditor formatting toolbar', () => { + beforeEach(() => { + // The preview round-trip is skipped for empty content, but stub fetch + // anyway so a stray call can never reach the network. + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 404 })); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('exposes a labelled toolbar whose buttons have real names', () => { + renderWithRouter(<Harness />); + const toolbar = screen.getByRole('toolbar', { name: 'Formatting' }); + for (const name of ['Bold', 'Italic', 'Insert link', 'Bulleted list', 'Code', 'Quote']) { + expect(within(toolbar).getByRole('button', { name })).toBeInTheDocument(); + } + }); + + it('keeps every accessible name a superset of the visible label (SC 2.5.3)', () => { + renderWithRouter(<Harness />); + const toolbar = screen.getByRole('toolbar', { name: 'Formatting' }); + for (const [visible, name] of [ + ['B', 'Bold'], + ['I', 'Italic'], + ['Link', 'Insert link'], + ['List', 'Bulleted list'], + ] as const) { + const btn = within(toolbar).getByRole('button', { name }); + expect(btn.textContent).toBe(visible); + expect(name.toLowerCase()).toContain(visible.toLowerCase()); + } + }); + + it('is a single tab stop with a roving tabindex', async () => { + const user = userEvent.setup(); + renderWithRouter(<Harness />); + const toolbar = screen.getByRole('toolbar', { name: 'Formatting' }); + const buttons = within(toolbar).getAllByRole('button'); + + // Only the active button is reachable by Tab. + expect(buttons.filter((b) => b.getAttribute('tabindex') === '0')).toHaveLength(1); + expect(buttons[0]).toHaveAttribute('tabindex', '0'); + + buttons[0]!.focus(); + await user.keyboard('{ArrowRight}'); + expect(buttons[1]).toHaveFocus(); + expect(buttons[1]).toHaveAttribute('tabindex', '0'); + expect(buttons[0]).toHaveAttribute('tabindex', '-1'); + + await user.keyboard('{End}'); + expect(buttons[buttons.length - 1]).toHaveFocus(); + + // Wraps forward off the end, and Home returns to the first. + await user.keyboard('{ArrowRight}'); + expect(buttons[0]).toHaveFocus(); + await user.keyboard('{ArrowLeft}'); + expect(buttons[buttons.length - 1]).toHaveFocus(); + await user.keyboard('{Home}'); + expect(buttons[0]).toHaveFocus(); + }); +}); From ede6bf4ce7197b860e62ed46b3dbea35d2e3f641 Mon Sep 17 00:00:00 2001 From: Heyoub <hello@forgestack.app> Date: Mon, 24 Aug 2026 13:33:33 -0400 Subject: [PATCH 05/14] fix(web): name per-row actions, mark up dates, cue new tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three unrelated-looking defects with the same root cause: information that is obvious on screen but absent from the accessibility tree. Repeated button names. Tabbing a member list, a session table, the claim queue or a project's open roles produced "Remove, Remove, Remove" with no way to tell which row you were on — the row context lived only in visual adjacency. Each button now carries an aria-label naming its subject, with the visible text kept as a substring so speech input still reaches it (SC 2.5.3). The claim queue's labels stay fixed while a request is in flight and its buttons read "Working…"; the busy state is transient and the name should not move under a user mid-interaction. Dates. A relative string like "3 months ago" inside a title-only span is imprecise for everyone and the title is unreachable by touch and by most screen readers. These become <time dateTime> carrying the ISO instant, the idiom BlogIndex already uses, with title kept as a sighted-mouse bonus. ProjectCard's wrapper title duplicated what PersonAvatar already emits, so it is deleted rather than converted. New tabs. Every target="_blank" link now says so — an sr-only span where there is visible text, appended to the aria-label where there is not. Losing your place because a link silently opened elsewhere is a bigger problem for a screen-reader or magnifier user than for anyone else. Also: the "More ▾" menu trigger reads as an actual action list, and the stage-explainer button declares aria-haspopup="dialog" so it is not mistaken for navigation. HomeStub.tsx is skipped — nothing imports it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- apps/web/src/components/ActivityCard.tsx | 13 +++++++---- apps/web/src/components/AppFooter.tsx | 3 ++- apps/web/src/components/ProjectCard.tsx | 6 ++++- .../components/modals/ManageMembersModal.tsx | 6 +++++ apps/web/src/pages/AccountClaim.tsx | 10 ++++----- apps/web/src/pages/LoginPlaceholder.tsx | 2 ++ apps/web/src/pages/StaffAccountClaimQueue.tsx | 9 ++++++-- apps/web/src/screens/Account.tsx | 8 +++++-- apps/web/src/screens/BlogDetail.tsx | 4 +++- apps/web/src/screens/PersonDetail.tsx | 1 + apps/web/src/screens/ProjectDetail.tsx | 22 ++++++++++++++----- apps/web/src/screens/Volunteer.tsx | 2 ++ 12 files changed, 65 insertions(+), 21 deletions(-) diff --git a/apps/web/src/components/ActivityCard.tsx b/apps/web/src/components/ActivityCard.tsx index 409c45f..3d65587 100644 --- a/apps/web/src/components/ActivityCard.tsx +++ b/apps/web/src/components/ActivityCard.tsx @@ -33,9 +33,13 @@ function UpdateCard({ update }: { update: ProjectUpdateResponse }) { Update #{update.number} </Link> </div> - <span title={formatAbsoluteDate(update.createdAt)} className="text-xs text-muted-foreground"> + <time + dateTime={update.createdAt} + title={formatAbsoluteDate(update.createdAt)} + className="text-xs text-muted-foreground" + > {formatRelativeTime(update.createdAt)} - </span> + </time> </div> {update.author && ( @@ -69,9 +73,9 @@ function BuzzCard({ buzz }: { buzz: ProjectBuzzResponse }) { {buzz.project.title} </Link> <span> · Buzz · </span> - <span title={formatAbsoluteDate(buzz.publishedAt)}> + <time dateTime={buzz.publishedAt} title={formatAbsoluteDate(buzz.publishedAt)}> {formatAbsoluteDate(buzz.publishedAt, { month: 'short', day: 'numeric', year: 'numeric' })} - </span> + </time> </span> </div> @@ -89,6 +93,7 @@ function BuzzCard({ buzz }: { buzz: ProjectBuzzResponse }) { <h3 className="text-base font-semibold mb-0.5"> <a href={buzz.url} target="_blank" rel="noopener noreferrer" className="hover:text-primary"> {buzz.headline} + <span className="sr-only"> (opens in new tab)</span> </a> </h3> <p className="text-xs text-muted-foreground mb-2">{hostname}</p> diff --git a/apps/web/src/components/AppFooter.tsx b/apps/web/src/components/AppFooter.tsx index 11fbe62..b381912 100644 --- a/apps/web/src/components/AppFooter.tsx +++ b/apps/web/src/components/AppFooter.tsx @@ -182,7 +182,7 @@ export function AppFooter() { <a key={href} href={href} - aria-label={label} + aria-label={`${label} (opens in new tab)`} target="_blank" rel="noopener noreferrer" className="text-muted-foreground hover:text-foreground transition-colors" @@ -206,6 +206,7 @@ export function AppFooter() { className="hover:text-foreground transition-colors" > Open source — view this site on GitHub + <span className="sr-only"> (opens in new tab)</span> </a> </div> </div> diff --git a/apps/web/src/components/ProjectCard.tsx b/apps/web/src/components/ProjectCard.tsx index 3fd9b55..8c19e1a 100644 --- a/apps/web/src/components/ProjectCard.tsx +++ b/apps/web/src/components/ProjectCard.tsx @@ -34,7 +34,9 @@ export function ProjectCard({ project }: ProjectCardProps) { {project.members.slice(0, 8).map((m) => { const isMaintainer = m.slug === project.maintainer?.slug; return ( - <div key={m.slug} className="ring-2 ring-card rounded-full" title={m.fullName}> + // No title here: PersonAvatar already emits the member's name, + // so this produced two identical tooltips stacked. + <div key={m.slug} className="ring-2 ring-card rounded-full"> <PersonAvatar person={m} size={isMaintainer ? 36 : 28} /> </div> ); @@ -61,6 +63,7 @@ export function ProjectCard({ project }: ProjectCardProps) { <Button asChild size="sm" variant="outline"> <a href={project.links.usersUrl} target="_blank" rel="noopener noreferrer"> Public Site + <span className="sr-only"> (opens in new tab)</span> </a> </Button> )} @@ -68,6 +71,7 @@ export function ProjectCard({ project }: ProjectCardProps) { <Button asChild size="sm" variant="outline"> <a href={project.links.developersUrl} target="_blank" rel="noopener noreferrer"> Developers + <span className="sr-only"> (opens in new tab)</span> </a> </Button> )} diff --git a/apps/web/src/components/modals/ManageMembersModal.tsx b/apps/web/src/components/modals/ManageMembersModal.tsx index 8ee24ed..2303b5b 100644 --- a/apps/web/src/components/modals/ManageMembersModal.tsx +++ b/apps/web/src/components/modals/ManageMembersModal.tsx @@ -158,10 +158,14 @@ export function ManageMembersModal({ open, onOpenChange, project }: ManageMember </> ) : ( <> + {/* Every row's buttons read identically out of + context, so each name carries its member. The + visible text stays a substring (SC 2.5.3). */} <Button type="button" size="sm" variant="outline" + aria-label={`Edit role for ${m.person.fullName}`} onClick={() => setEditingRole((r) => ({ ...r, [rowKey]: m.role ?? '' })) } @@ -173,6 +177,7 @@ export function ManageMembersModal({ open, onOpenChange, project }: ManageMember type="button" size="sm" variant="outline" + aria-label={`Make maintainer: ${m.person.fullName}`} onClick={() => personSlug && handleChangeMaintainer(personSlug, rowKey)} disabled={busySlug === rowKey} > @@ -184,6 +189,7 @@ export function ManageMembersModal({ open, onOpenChange, project }: ManageMember type="button" size="sm" variant="ghost" + aria-label={`Remove ${m.person.fullName}`} onClick={() => personSlug && handleRemove(personSlug, rowKey)} disabled={busySlug === rowKey} className="text-destructive hover:text-destructive" diff --git a/apps/web/src/pages/AccountClaim.tsx b/apps/web/src/pages/AccountClaim.tsx index 10eaf76..a939b04 100644 --- a/apps/web/src/pages/AccountClaim.tsx +++ b/apps/web/src/pages/AccountClaim.tsx @@ -176,11 +176,11 @@ export function AccountClaim() { </CardDescription> </CardHeader> <CardContent className="space-y-3 text-sm"> - <div - className="text-xs text-muted-foreground" - title={formatAbsoluteDate(c.lastActiveAt)} - > - Last updated {formatRelativeTime(c.lastActiveAt)} + <div className="text-xs text-muted-foreground"> + Last updated{' '} + <time dateTime={c.lastActiveAt} title={formatAbsoluteDate(c.lastActiveAt)}> + {formatRelativeTime(c.lastActiveAt)} + </time> </div> {c.matchedEmail ? ( <div className="rounded-md bg-green-100 dark:bg-green-900/30 text-green-900 dark:text-green-100 px-3 py-2 text-xs"> diff --git a/apps/web/src/pages/LoginPlaceholder.tsx b/apps/web/src/pages/LoginPlaceholder.tsx index 0b376b6..621c16d 100644 --- a/apps/web/src/pages/LoginPlaceholder.tsx +++ b/apps/web/src/pages/LoginPlaceholder.tsx @@ -41,6 +41,7 @@ const ERROR_MESSAGES: Record<ErrorCode, React.ReactNode> = { className="underline hover:no-underline" > verify a primary email on GitHub + <span className="sr-only"> (opens in new tab)</span> </a>{' '} and ensure email visibility is enabled for our app. </> @@ -78,6 +79,7 @@ function WhyGitHub() { className="underline hover:no-underline" > create a GitHub account + <span className="sr-only"> (opens in new tab)</span> </a>{' '} in under a minute. </div> diff --git a/apps/web/src/pages/StaffAccountClaimQueue.tsx b/apps/web/src/pages/StaffAccountClaimQueue.tsx index e4410df..18ae752 100644 --- a/apps/web/src/pages/StaffAccountClaimQueue.tsx +++ b/apps/web/src/pages/StaffAccountClaimQueue.tsx @@ -119,12 +119,13 @@ export function StaffAccountClaimQueue() { </> )} {' · '} - <span + <time + dateTime={item.submittedAt} title={formatAbsoluteDate(item.submittedAt)} className="text-muted-foreground" > {formatRelativeTime(item.submittedAt)} - </span> + </time> </CardDescription> </CardHeader> <CardContent className="space-y-3"> @@ -148,7 +149,10 @@ export function StaffAccountClaimQueue() { /> </div> <div className="flex gap-2"> + {/* The queue renders one card per request, so a bare + "Approve"/"Deny" repeats verbatim down the page. */} <Button + aria-label={`Approve claim from ${item.requesterGithubLogin}`} onClick={() => void onApprove(item.requestId)} disabled={pendingId !== null || !item.claimedPersonId} > @@ -156,6 +160,7 @@ export function StaffAccountClaimQueue() { </Button> <Button variant="outline" + aria-label={`Deny claim from ${item.requesterGithubLogin}`} onClick={() => void onDeny(item.requestId)} disabled={pendingId !== null} > diff --git a/apps/web/src/screens/Account.tsx b/apps/web/src/screens/Account.tsx index fa2516f..85cf981 100644 --- a/apps/web/src/screens/Account.tsx +++ b/apps/web/src/screens/Account.tsx @@ -187,6 +187,7 @@ export function Account() { rel="noopener noreferrer" > Manage on GitHub → + <span className="sr-only"> (opens in new tab)</span> </a> </Button> ) : ( @@ -270,8 +271,10 @@ export function Account() { <tr key={s.jti}> <td className="py-2">{parseUA(s.userAgent)}</td> <td className="py-2 font-mono text-xs">{s.ipAddress}</td> - <td className="py-2" title={formatAbsoluteDate(s.issuedAt)}> - {formatRelativeTime(s.issuedAt)} + <td className="py-2"> + <time dateTime={s.issuedAt} title={formatAbsoluteDate(s.issuedAt)}> + {formatRelativeTime(s.issuedAt)} + </time> </td> <td className="py-2 text-right"> {s.current ? ( @@ -283,6 +286,7 @@ export function Account() { type="button" size="sm" variant="outline" + aria-label={`Revoke session on ${parseUA(s.userAgent)}`} onClick={() => revokeSession(s.jti)} > Revoke diff --git a/apps/web/src/screens/BlogDetail.tsx b/apps/web/src/screens/BlogDetail.tsx index 8fcbb1c..c052af9 100644 --- a/apps/web/src/screens/BlogDetail.tsx +++ b/apps/web/src/screens/BlogDetail.tsx @@ -57,7 +57,9 @@ export function BlogDetail() { {showEdited && post.editedAt && ( <> <span>·</span> - <span title={post.editedAt}>Edited</span> + <time dateTime={post.editedAt} title={post.editedAt}> + Edited + </time> </> )} </div> diff --git a/apps/web/src/screens/PersonDetail.tsx b/apps/web/src/screens/PersonDetail.tsx index 5d0bc5b..6564737 100644 --- a/apps/web/src/screens/PersonDetail.tsx +++ b/apps/web/src/screens/PersonDetail.tsx @@ -232,6 +232,7 @@ export function PersonDetail() { className="text-primary underline hover:no-underline" > DM on Slack + <span className="sr-only"> (opens in new tab)</span> </a> </li> )} diff --git a/apps/web/src/screens/ProjectDetail.tsx b/apps/web/src/screens/ProjectDetail.tsx index 06c9dca..cb269d9 100644 --- a/apps/web/src/screens/ProjectDetail.tsx +++ b/apps/web/src/screens/ProjectDetail.tsx @@ -228,7 +228,11 @@ export function ProjectDetail({ anchor }: ProjectDetailProps = {}) { perms.canDelete) && ( <DropdownMenu> <DropdownMenuTrigger asChild> - <Button variant="outline">More ▾</Button> + {/* "More ▾" says nothing about what it opens; the label + keeps the visible word so speech input still works. */} + <Button variant="outline" aria-label="More actions"> + More ▾ + </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end"> {perms.canManageMembers && ( @@ -315,10 +319,13 @@ export function ProjectDetail({ anchor }: ProjectDetailProps = {}) { {role.tags.topic.map((t) => <TagChip key={`topic.${t.slug}`} tag={t} />)} </div> <div className="flex items-center justify-end gap-2"> + {/* One row per open role, so these names repeat + verbatim unless they carry the role title. */} {role.permissions.canFill && ( <Button size="sm" variant="outline" + aria-label={`Mark filled: ${role.title}`} onClick={() => setFillRole(role)} > Mark filled @@ -328,6 +335,7 @@ export function ProjectDetail({ anchor }: ProjectDetailProps = {}) { <Button size="sm" variant="ghost" + aria-label={`Close ${role.title}`} onClick={() => { if (!window.confirm(`Close "${role.title}" without filling?`)) return; api.helpWantedRole @@ -445,6 +453,7 @@ export function ProjectDetail({ anchor }: ProjectDetailProps = {}) { <Button asChild> <a href={project.links.usersUrl} target="_blank" rel="noopener noreferrer"> Users' Site + <span className="sr-only"> (opens in new tab)</span> </a> </Button> )} @@ -452,6 +461,7 @@ export function ProjectDetail({ anchor }: ProjectDetailProps = {}) { <Button asChild variant="outline"> <a href={project.links.developersUrl} target="_blank" rel="noopener noreferrer"> Developers' Site + <span className="sr-only"> (opens in new tab)</span> </a> </Button> )} @@ -582,15 +592,15 @@ export function ProjectDetail({ anchor }: ProjectDetailProps = {}) { <section className="text-sm text-muted-foreground space-y-1"> <p> <span className="font-medium text-foreground">Created:</span>{' '} - <span title={formatAbsoluteDate(project.createdAt)}> + <time dateTime={project.createdAt} title={formatAbsoluteDate(project.createdAt)}> {formatRelativeTime(project.createdAt)} - </span> + </time> </p> <p> <span className="font-medium text-foreground">Last updated:</span>{' '} - <span title={formatAbsoluteDate(project.updatedAt)}> + <time dateTime={project.updatedAt} title={formatAbsoluteDate(project.updatedAt)}> {formatRelativeTime(project.updatedAt)} - </span> + </time> </p> <p className="flex items-center gap-2"> <span className="font-medium text-foreground">Stage:</span> @@ -599,6 +609,7 @@ export function ProjectDetail({ anchor }: ProjectDetailProps = {}) { <p> <button type="button" + aria-haspopup="dialog" onClick={() => setStageInfoOpen(true)} className="text-primary underline hover:no-underline" > @@ -617,6 +628,7 @@ export function ProjectDetail({ anchor }: ProjectDetailProps = {}) { className="hover:text-foreground" > Edit on GitHub → + <span className="sr-only"> (opens in new tab)</span> </a> </section> )} diff --git a/apps/web/src/screens/Volunteer.tsx b/apps/web/src/screens/Volunteer.tsx index 458652f..7f81437 100644 --- a/apps/web/src/screens/Volunteer.tsx +++ b/apps/web/src/screens/Volunteer.tsx @@ -72,6 +72,7 @@ export function Volunteer() { <Button asChild variant="outline" size="sm"> <a href={MEETUP_URL} target="_blank" rel="noopener noreferrer"> When we meet → + <span className="sr-only"> (opens in new tab)</span> </a> </Button> </div> @@ -144,6 +145,7 @@ export function Volunteer() { <Button asChild> <a href={START_PROJECT_URL} target="_blank" rel="noopener noreferrer"> Read the guide → + <span className="sr-only"> (opens in new tab)</span> </a> </Button> <Button asChild variant="outline"> From de6b1e8d31e38995036671d688f540b0a9aeb95b Mon Sep 17 00:00:00 2001 From: Heyoub <hello@forgestack.app> Date: Mon, 24 Aug 2026 13:33:33 -0400 Subject: [PATCH 06/14] test(web): cover breadcrumbs, per-row names and time elements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regression cover for the parts of this branch that are easy to undo by accident. The breadcrumb tests assert the exact trails app-shell.md prescribes, including that the final crumb is text with aria-current rather than a link — the detail that makes a trail a trail. The Revoke test asserts that two rows produce two distinct accessible names, which is the property that actually broke, rather than asserting one label's spelling. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- apps/web/tests/Account.test.tsx | 58 +++++++++++++++++++++++++-- apps/web/tests/PersonDetail.test.tsx | 27 ++++++++++++- apps/web/tests/ProjectDetail.test.tsx | 27 ++++++++++++- 3 files changed, 107 insertions(+), 5 deletions(-) diff --git a/apps/web/tests/Account.test.tsx b/apps/web/tests/Account.test.tsx index 774e8e5..be6d510 100644 --- a/apps/web/tests/Account.test.tsx +++ b/apps/web/tests/Account.test.tsx @@ -5,7 +5,7 @@ * by AppShell on every page), and is covered by its own test file. */ import { describe, expect, it, vi, afterEach } from 'vitest'; -import { screen, waitFor } from '@testing-library/react'; +import { screen, waitFor, within } from '@testing-library/react'; import { renderScreen, mockOk } from './test-utils.js'; import { Account } from '../src/screens/Account.js'; import { AuthProvider } from '../src/hooks/useAuth.js'; @@ -17,7 +17,24 @@ interface MeShape { lastLoginMethod: 'github' | 'legacy_password' | 'password_reset' | null; } -function mockApi(me: MeShape): void { +const SESSIONS = [ + { + jti: 'sess-1', + userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X) Chrome/120', + ipAddress: '203.0.113.1', + issuedAt: '2026-05-01T00:00:00Z', + current: false, + }, + { + jti: 'sess-2', + userAgent: 'Mozilla/5.0 (Windows NT 10.0) Firefox/121', + ipAddress: '203.0.113.2', + issuedAt: '2026-05-02T00:00:00Z', + current: false, + }, +]; + +function mockApi(me: MeShape, sessions: unknown[] = []): void { vi.spyOn(globalThis, 'fetch').mockImplementation(((input: string) => { if (input.startsWith('/api/auth/me')) { return Promise.resolve( @@ -29,7 +46,7 @@ function mockApi(me: MeShape): void { } if (input.startsWith('/api/auth/sessions')) { return Promise.resolve( - new Response(JSON.stringify(mockOk([])), { + new Response(JSON.stringify(mockOk(sessions)), { status: 200, headers: { 'content-type': 'application/json' }, }), @@ -105,3 +122,38 @@ describe('Account — Identity card', () => { ).toBe(0); }); }); + +describe('Account — accessibility structure', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('renders the "Settings" breadcrumb trail from app-shell.md', async () => { + mockApi(githubPerson); + render(); + const trail = await screen.findByRole('navigation', { name: 'Breadcrumb' }); + expect(within(trail).getByText('Settings')).toHaveAttribute('aria-current', 'page'); + }); + + it('names each Revoke button after its own session', async () => { + mockApi(githubPerson, SESSIONS); + render(); + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Revoke session on Chrome on macOS' })) + .toBeInTheDocument(); + }); + expect( + screen.getByRole('button', { name: 'Revoke session on Firefox on Windows' }), + ).toBeInTheDocument(); + // The visible text is still "Revoke" on both (SC 2.5.3 keeps it a substring). + expect(screen.getAllByRole('button', { name: /^Revoke session on/ })).toHaveLength(2); + }); + + it('exposes session timestamps as machine-readable <time>', async () => { + mockApi(githubPerson, SESSIONS); + render(); + await waitFor(() => { + expect(document.querySelector('time[datetime="2026-05-01T00:00:00Z"]')).not.toBeNull(); + }); + }); +}); diff --git a/apps/web/tests/PersonDetail.test.tsx b/apps/web/tests/PersonDetail.test.tsx index e5d2642..0c35fd3 100644 --- a/apps/web/tests/PersonDetail.test.tsx +++ b/apps/web/tests/PersonDetail.test.tsx @@ -1,5 +1,5 @@ import { describe, expect, it, vi, afterEach } from 'vitest'; -import { screen, waitFor } from '@testing-library/react'; +import { screen, waitFor, within } from '@testing-library/react'; import { Routes, Route } from 'react-router'; import { renderScreen, mockOk } from './test-utils.js'; import { PersonDetail } from '../src/screens/PersonDetail.js'; @@ -42,6 +42,31 @@ function makeFetchMock(person: typeof BASE_PERSON) { }) as typeof fetch; } +describe('PersonDetail breadcrumbs', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('renders the "Members › <fullName>" trail from app-shell.md', async () => { + vi.spyOn(globalThis, 'fetch').mockImplementation(makeFetchMock(BASE_PERSON)); + renderScreen( + <AuthProvider> + <Routes> + <Route path="/members/:slug" element={<PersonDetail />} /> + </Routes> + </AuthProvider>, + { initialEntries: ['/members/jane-doe'] }, + ); + + const trail = await screen.findByRole('navigation', { name: 'Breadcrumb' }); + expect(within(trail).getByRole('link', { name: 'Members' })).toHaveAttribute( + 'href', + '/members', + ); + expect(within(trail).getByText('Jane Doe')).toHaveAttribute('aria-current', 'page'); + }); +}); + describe('PersonDetail Contact sidebar', () => { afterEach(() => { vi.restoreAllMocks(); diff --git a/apps/web/tests/ProjectDetail.test.tsx b/apps/web/tests/ProjectDetail.test.tsx index ff4c486..4c323a6 100644 --- a/apps/web/tests/ProjectDetail.test.tsx +++ b/apps/web/tests/ProjectDetail.test.tsx @@ -1,5 +1,5 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; -import { screen, waitFor } from '@testing-library/react'; +import { screen, waitFor, within } from '@testing-library/react'; import { Routes, Route } from 'react-router'; import { renderScreen, mockOk, mockPaginated } from './test-utils.js'; import { ProjectDetail } from '../src/screens/ProjectDetail.js'; @@ -59,6 +59,31 @@ describe('ProjectDetail', () => { vi.restoreAllMocks(); }); + it('renders the "Projects › <title>" breadcrumb trail from app-shell.md', async () => { + renderScreen( + <AuthProvider> + <Routes> + <Route path="/projects/:slug" element={<ProjectDetail />} /> + </Routes> + </AuthProvider>, + { initialEntries: ['/projects/sample-project'] }, + ); + + const trail = await screen.findByRole('navigation', { name: 'Breadcrumb' }); + expect(within(trail).getByRole('link', { name: 'Projects' })).toHaveAttribute( + 'href', + '/projects', + ); + // The last crumb is the current page, so it is text, not a link. + expect(within(trail).getByText('Sample Project')).toHaveAttribute( + 'aria-current', + 'page', + ); + expect( + within(trail).queryByRole('link', { name: 'Sample Project' }), + ).not.toBeInTheDocument(); + }); + it('renders the title, overview, and Sign-in CTA for anonymous', async () => { renderScreen( <AuthProvider> From c50446d06fa188e82afc5bd0f571a5f0992fe103 Mon Sep 17 00:00:00 2001 From: Heyoub <hello@forgestack.app> Date: Mon, 24 Aug 2026 13:40:29 -0400 Subject: [PATCH 07/14] chore(plans): record a11y-mechanical validation results Both gate runs green; breadcrumbs, copy toast, badge placement, and the PersonCard click affordance verified in headed Chrome against a seeded dev data repo. Toolbar keyboard nav rides on the jsdom coverage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- plans/a11y-mechanical.md | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/plans/a11y-mechanical.md b/plans/a11y-mechanical.md index f12c5cb..57132f1 100644 --- a/plans/a11y-mechanical.md +++ b/plans/a11y-mechanical.md @@ -146,22 +146,28 @@ Covers `ActivityCard` (×2), `ProjectDetail` (×2), `BlogDetail`, `Account`, ## Validation -- [ ] Breadcrumbs render on all six screens the spec's table names, with the +- [x] Breadcrumbs render on all six screens the spec's table names, with the exact trails prescribed, and each non-final crumb links to its parent. -- [ ] No two buttons in the audited lists share an accessible name; every +- [x] No two buttons in the audited lists share an accessible name; every added `aria-label` contains the button's visible text (SC 2.5.3). -- [ ] `PeopleIndex` / `HelpWantedIndex` no longer skip `h1` → `h3`; +- [x] `PeopleIndex` / `HelpWantedIndex` no longer skip `h1` → `h3`; `ProjectDetail` / `PersonDetail` aside headings are `h2`. -- [ ] The `MarkdownEditor` toolbar exposes `role="toolbar"`, named buttons, +- [x] The `MarkdownEditor` toolbar exposes `role="toolbar"`, named buttons, and a working roving tabindex (Arrow/Home/End). -- [ ] Copy actions on `ProjectDetail` and `Sponsor` announce; `ProfileEdit` +- [x] Copy actions on `ProjectDetail` and `Sponsor` announce; `ProfileEdit` upload and `ConnectGitHubBanner` are live regions. -- [ ] Dates expose `datetime`; no date is `title`-only. -- [ ] Exactly one `complementary` landmark per index screen. -- [ ] `npm run -w packages/shared build && npm run type-check && npm run lint - && npm run -w apps/web test && npm run -w packages/shared test` clean. -- [ ] Browser test — breadcrumbs, toolbar keyboard nav, and the copy toasts - verified in a real browser. _(for the coordinator)_ +- [x] Dates expose `datetime`; no date is `title`-only. +- [x] Exactly one `complementary` landmark per index screen. +- [x] `npm run -w packages/shared build && npm run type-check && npm run lint + && npm run -w apps/web test && npm run -w packages/shared test` clean + (web 116/116, shared 75/75; run twice — implementer and coordinator). +- [x] Browser test (headed Chrome against the live dev stack — api booted on + a `setup-dev-data` repo with two seeded records): breadcrumb trails + verified on `/projects/qa-sandbox` ("Projects › QA Sandbox Project") + and `/members/ada-tester` ("Members › Ada Tester"); "Copy link" fires + the "Link copied" toast; the count badge sits outside the `h1` with + the visual unchanged; `PersonCard` whole-card click still navigates. + Toolbar keyboard nav verified in jsdom only (`MarkdownEditor.test.tsx`). ## Risks / unknowns From abfd1ddf5b1fe1edb3e8e76981bfb594b21e55a2 Mon Sep 17 00:00:00 2001 From: Heyoub <hello@forgestack.app> Date: Mon, 24 Aug 2026 13:41:25 -0400 Subject: [PATCH 08/14] chore(plans): mark a11y-mechanical done (PR #157) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- plans/a11y-mechanical.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plans/a11y-mechanical.md b/plans/a11y-mechanical.md index 57132f1..ce7892d 100644 --- a/plans/a11y-mechanical.md +++ b/plans/a11y-mechanical.md @@ -1,9 +1,10 @@ --- -status: in-progress +status: done depends: [] specs: - specs/behaviors/app-shell.md issues: [] +pr: 157 --- # Plan: mechanical accessibility fixes across the SPA From d32185f53c37b5d1c0cc4add852b299007f02585 Mon Sep 17 00:00:00 2001 From: Chris Alfano <chris@jarv.us> Date: Tue, 8 Sep 2026 21:56:24 -0400 Subject: [PATCH 09/14] fix(web): restore full-width sheet rows, keep Commitment in the landmark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrapping the sheet's NavLinks in <li> left the anchors inline, so each row's tap target shrank to the width of its text. `block` on navLinkClass and on the two plain <a>s makes the rows full-width again. Swapping HelpWantedIndex's outer <aside> for a <div> removed the nested landmark but orphaned the Commitment heading and fieldset outside any landmark, since FacetSidebar renders its own aside[aria-label="Filters"] as a sibling. FacetSidebar now accepts children inside that aside, and Commitment rides there — still exactly one complementary landmark, and it holds every filter control. PeopleIndex and ProjectsIndex mount FacetSidebar directly and are unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RdRwHvDupRLV8GuJpYKzEr --- apps/web/src/components/AppHeader.tsx | 8 +++++--- apps/web/src/components/FacetSidebar.tsx | 6 +++++- apps/web/src/screens/HelpWantedIndex.tsx | 23 +++++++++++------------ apps/web/tests/HelpWantedIndex.test.tsx | 20 +++++++++++++++++++- 4 files changed, 40 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/AppHeader.tsx b/apps/web/src/components/AppHeader.tsx index bc215af..2a303e3 100644 --- a/apps/web/src/components/AppHeader.tsx +++ b/apps/web/src/components/AppHeader.tsx @@ -182,8 +182,10 @@ function AboutDropdown() { ); } +// `block` so each link fills its row: inside the sheet's <li>s an inline +// anchor would shrink the tap target to the width of its text. const navLinkClass = ({ isActive }: { isActive: boolean }) => - `text-sm font-medium transition-colors hover:text-primary ${ + `block text-sm font-medium transition-colors hover:text-primary ${ isActive ? 'text-primary' : 'text-muted-foreground' }`; @@ -368,7 +370,7 @@ export function AppHeader() { <li> <a href="mailto:hello@codeforphilly.org" - className="text-sm font-medium text-muted-foreground hover:text-primary" + className="block text-sm font-medium text-muted-foreground hover:text-primary" onClick={() => setMobileOpen(false)} > Contact @@ -382,7 +384,7 @@ export function AppHeader() { href={GITHUB_URL} target="_blank" rel="noopener noreferrer" - className="text-sm font-medium text-muted-foreground hover:text-primary" + className="block text-sm font-medium text-muted-foreground hover:text-primary" onClick={() => setMobileOpen(false)} > GitHub diff --git a/apps/web/src/components/FacetSidebar.tsx b/apps/web/src/components/FacetSidebar.tsx index 6542eaa..d53ef9f 100644 --- a/apps/web/src/components/FacetSidebar.tsx +++ b/apps/web/src/components/FacetSidebar.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, type ReactNode } from 'react'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { TagChip } from '@/components/TagChip'; import { Link } from 'react-router'; @@ -12,6 +12,8 @@ interface FacetSidebarProps { tabs?: Array<'topic' | 'tech' | 'event'>; limit?: number; className?: string; + /** Extra filter controls rendered inside the same `Filters` landmark. */ + children?: ReactNode; } const NS_LABELS = { @@ -40,6 +42,7 @@ export function FacetSidebar({ tabs = ['topic', 'tech', 'event'], limit = 10, className, + children, }: FacetSidebarProps) { const [tab, setTab] = useState<string>(tabs[0] ?? 'topic'); const activeTagSet = new Set(activeTags); @@ -103,6 +106,7 @@ export function FacetSidebar({ ); })} </Tabs> + {children} </aside> ); } diff --git a/apps/web/src/screens/HelpWantedIndex.tsx b/apps/web/src/screens/HelpWantedIndex.tsx index 6755035..968273a 100644 --- a/apps/web/src/screens/HelpWantedIndex.tsx +++ b/apps/web/src/screens/HelpWantedIndex.tsx @@ -109,17 +109,16 @@ export function HelpWantedIndex() { <PostRolePickerModal open={pickerOpen} onOpenChange={setPickerOpen} /> <div className="grid grid-cols-1 md:grid-cols-[16rem_1fr] gap-6"> - {/* A plain div, not an <aside>: FacetSidebar renders its own labelled - <aside>, and wrapping it in another one nests two `complementary` - landmarks — the outer one unlabelled. */} - <div> - <FacetSidebar - facets={facets} - activeTags={tags} - onToggleTag={handleToggleTag} - tabs={['tech', 'topic']} - /> - + {/* The Commitment radios ride inside FacetSidebar's own labelled + <aside> rather than a second wrapper: one `complementary` + landmark ("Filters") holds every filter control, and nothing is + orphaned outside it. */} + <FacetSidebar + facets={facets} + activeTags={tags} + onToggleTag={handleToggleTag} + tabs={['tech', 'topic']} + > <div className="mt-6"> <h2 className="text-xs font-semibold uppercase tracking-wide mb-2 text-muted-foreground"> Commitment @@ -151,7 +150,7 @@ export function HelpWantedIndex() { ))} </fieldset> </div> - </div> + </FacetSidebar> <div> {/* HelpWantedCard renders an h3 (it also sits under section h2s on diff --git a/apps/web/tests/HelpWantedIndex.test.tsx b/apps/web/tests/HelpWantedIndex.test.tsx index 3df6de4..cfd9231 100644 --- a/apps/web/tests/HelpWantedIndex.test.tsx +++ b/apps/web/tests/HelpWantedIndex.test.tsx @@ -1,5 +1,5 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; -import { screen, waitFor } from '@testing-library/react'; +import { screen, waitFor, within } from '@testing-library/react'; import { renderScreen, mockPaginated } from './test-utils.js'; import { HelpWantedIndex } from '../src/screens/HelpWantedIndex.js'; import { AuthProvider } from '../src/hooks/useAuth.js'; @@ -78,4 +78,22 @@ describe('HelpWantedIndex', () => { expect(screen.getByLabelText('≤ 5 hrs/week')).toBeInTheDocument(); expect(screen.getByLabelText('≤ 10 hrs/week')).toBeInTheDocument(); }); + + it('keeps every filter control inside the one Filters landmark', async () => { + renderScreen( + <AuthProvider> + <HelpWantedIndex /> + </AuthProvider>, + { initialEntries: ['/help-wanted'] }, + ); + + const sidebars = await screen.findAllByRole('complementary'); + expect(sidebars).toHaveLength(1); + const sidebar = sidebars[0]!; + expect(sidebar).toHaveAccessibleName('Filters'); + // Commitment rides inside the same landmark as the tag facets rather + // than sitting orphaned beside it. + expect(within(sidebar).getByRole('heading', { name: 'Commitment', level: 2 })).toBeInTheDocument(); + expect(within(sidebar).getByLabelText('≤ 2 hrs/week')).toBeInTheDocument(); + }); }); From f419c26dabe2d3679cff855c77d3013402d82282 Mon Sep 17 00:00:00 2001 From: Chris Alfano <chris@jarv.us> Date: Tue, 8 Sep 2026 21:56:24 -0400 Subject: [PATCH 10/14] fix(web): keep the Connect GitHub banner a region, announce it beside MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turning the banner into role="status" traded a landmark for a live region that mounts late — and a container that appears already populated is not announced reliably, while wrapping the two buttons in a status role is invalid content for it. Revert to region + aria-label and add a sibling sr-only role="status" span carrying the headline, the same idiom Sponsor and ProfileEdit use in this PR. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RdRwHvDupRLV8GuJpYKzEr --- apps/web/src/components/ConnectGitHubBanner.tsx | 15 +++++++++++---- apps/web/tests/ConnectGitHubBanner.test.tsx | 15 +++++++++------ 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/ConnectGitHubBanner.tsx b/apps/web/src/components/ConnectGitHubBanner.tsx index efd1c2a..e022448 100644 --- a/apps/web/src/components/ConnectGitHubBanner.tsx +++ b/apps/web/src/components/ConnectGitHubBanner.tsx @@ -30,11 +30,9 @@ export function ConnectGitHubBanner() { if (dismissed) return null; return ( - // role="status", not "region": the banner appears only once auth has - // resolved, so it arrives after first paint and a landmark would never - // announce it. The aria-label stays as its accessible name. + <> <div - role="status" + role="region" aria-label="Connect GitHub" className="border-b border-primary/40 bg-primary/5 print:hidden" > @@ -61,5 +59,14 @@ export function ConnectGitHubBanner() { </Button> </div> </div> + {/* The banner mounts only once auth resolves, and a landmark arriving + after first paint is never announced on its own — so mirror the + headline in a live region (the Sponsor / ProfileEdit idiom). It stays + a region rather than becoming one: a late-mounted status container + is not read reliably either, and it would swallow the two buttons. */} + <span role="status" className="sr-only"> + Connect your GitHub account + </span> + </> ); } diff --git a/apps/web/tests/ConnectGitHubBanner.test.tsx b/apps/web/tests/ConnectGitHubBanner.test.tsx index 3637bc2..82b8a25 100644 --- a/apps/web/tests/ConnectGitHubBanner.test.tsx +++ b/apps/web/tests/ConnectGitHubBanner.test.tsx @@ -70,13 +70,16 @@ describe('ConnectGitHubBanner', () => { render(); await waitFor(() => { expect( - screen.getByRole('status', { name: /connect github/i }), + screen.getByRole('region', { name: /connect github/i }), ).toBeInTheDocument(); }); // CTA form posts to the link endpoint. - const region = screen.getByRole('status', { name: /connect github/i }); + const region = screen.getByRole('region', { name: /connect github/i }); expect(region.querySelector('form[action="/api/auth/link-github"]')).not.toBeNull(); expect(screen.getByRole('button', { name: /dismiss/i })).toBeInTheDocument(); + // The landmark mounts after first paint, so its arrival is announced + // through a sibling live region carrying the headline. + expect(screen.getByRole('status')).toHaveTextContent('Connect your GitHub account'); }); it('renders for a user whose session was minted via password reset', async () => { @@ -84,7 +87,7 @@ describe('ConnectGitHubBanner', () => { render(); await waitFor(() => { expect( - screen.getByRole('status', { name: /connect github/i }), + screen.getByRole('region', { name: /connect github/i }), ).toBeInTheDocument(); }); }); @@ -101,7 +104,7 @@ describe('ConnectGitHubBanner', () => { // microtask gap. await new Promise((r) => setTimeout(r, 0)); expect( - screen.queryByRole('status', { name: /connect github/i }), + screen.queryByRole('region', { name: /connect github/i }), ).not.toBeInTheDocument(); }); @@ -110,7 +113,7 @@ describe('ConnectGitHubBanner', () => { render(); await new Promise((r) => setTimeout(r, 0)); expect( - screen.queryByRole('status', { name: /connect github/i }), + screen.queryByRole('region', { name: /connect github/i }), ).not.toBeInTheDocument(); }); @@ -121,7 +124,7 @@ describe('ConnectGitHubBanner', () => { fireEvent.click(dismissBtn); await waitFor(() => { expect( - screen.queryByRole('status', { name: /connect github/i }), + screen.queryByRole('region', { name: /connect github/i }), ).not.toBeInTheDocument(); }); }); From 0628779a4b94bc9bfc12969eec8aab0098b4d722 Mon Sep 17 00:00:00 2001 From: Chris Alfano <chris@jarv.us> Date: Tue, 8 Sep 2026 21:56:24 -0400 Subject: [PATCH 11/14] fix(web): key crumbs by position, label the namespace crumb, guard edit Breadcrumbs keyed each <li> by label, which collides when a user-authored title matches an ancestor crumb; key by index instead. TagDetail's namespace crumb showed the raw slug ("tech") while the page it links to is headed "Tech"; it now uses TagsNamespace's NS_LABELS so the crumb and the destination h1 agree. ProjectEdit rendered a blank crumb linking to /projects/ when the edit query settled without a record; hold the loading state in that case so the trail is only ever built from a loaded project. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RdRwHvDupRLV8GuJpYKzEr --- apps/web/src/components/Breadcrumbs.tsx | 2 +- apps/web/src/screens/ProjectEdit.tsx | 19 ++++++++++++++----- apps/web/src/screens/TagDetail.tsx | 7 +++++-- apps/web/src/screens/TagsNamespace.tsx | 2 +- 4 files changed, 21 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/Breadcrumbs.tsx b/apps/web/src/components/Breadcrumbs.tsx index 31e5468..fd575a5 100644 --- a/apps/web/src/components/Breadcrumbs.tsx +++ b/apps/web/src/components/Breadcrumbs.tsx @@ -21,7 +21,7 @@ export function Breadcrumbs({ items }: BreadcrumbsProps) { {items.map((item, index) => { const isLast = index === items.length - 1; return ( - <li key={item.label} className="flex items-center gap-1"> + <li key={`${index}-${item.label}`} className="flex items-center gap-1"> {index > 0 && ( <span aria-hidden="true" className="text-muted-foreground/50"> › diff --git a/apps/web/src/screens/ProjectEdit.tsx b/apps/web/src/screens/ProjectEdit.tsx index e1fe1c0..d75d05d 100644 --- a/apps/web/src/screens/ProjectEdit.tsx +++ b/apps/web/src/screens/ProjectEdit.tsx @@ -159,6 +159,13 @@ export function ProjectEdit({ mode }: ProjectEditProps) { const project = projectQ.data?.data; + // Settled with no record (the query can resolve empty before an error + // surfaces): hold the loading state rather than rendering a form and a + // blank crumb pointing at /projects/. + if (mode === 'edit' && !project) { + return <div className="container mx-auto px-4 py-12 text-muted-foreground">Loading project…</div>; + } + if (mode === 'edit' && project && !project.permissions.canEdit) { return ( <div className="container mx-auto px-4 py-16 text-center"> @@ -271,16 +278,18 @@ export function ProjectEdit({ mode }: ProjectEditProps) { return ( <> {/* specs/behaviors/app-shell.md → Breadcrumbs: - create → Projects › New project; edit → Projects › <title> › Edit */} + create → Projects › New project; edit → Projects › <title> › Edit. + `project` is only ever loaded in edit mode (the query is gated on + it), and the guard above has already returned when it is missing. */} <Breadcrumbs items={ - mode === 'create' - ? [{ label: 'Projects', href: '/projects' }, { label: 'New project' }] - : [ + project + ? [ { label: 'Projects', href: '/projects' }, - { label: project?.title ?? '', href: `/projects/${project?.slug ?? ''}` }, + { label: project.title, href: `/projects/${project.slug}` }, { label: 'Edit' }, ] + : [{ label: 'Projects', href: '/projects' }, { label: 'New project' }] } /> <div className="container mx-auto px-4 py-8 max-w-3xl"> diff --git a/apps/web/src/screens/TagDetail.tsx b/apps/web/src/screens/TagDetail.tsx index 8ce9706..8f2104c 100644 --- a/apps/web/src/screens/TagDetail.tsx +++ b/apps/web/src/screens/TagDetail.tsx @@ -8,6 +8,7 @@ import { ProjectCard } from '@/components/ProjectCard'; import { PersonCard } from '@/components/PersonCard'; import { HelpWantedCard } from '@/components/HelpWantedCard'; import { TagEditModal } from '@/components/modals/TagEditModal'; +import { NS_LABELS } from '@/screens/TagsNamespace'; import { useAuth } from '@/hooks/useAuth'; import { api, ApiError } from '@/lib/api'; @@ -104,11 +105,13 @@ export function TagDetail() { return ( <> - {/* specs/behaviors/app-shell.md → Breadcrumbs: Tags › <namespace> › <title> */} + {/* specs/behaviors/app-shell.md → Breadcrumbs: Tags › <namespace> › <title>. + The namespace crumb carries the same display label as the page it + links to (TagsNamespace's h1), not the raw slug. */} <Breadcrumbs items={[ { label: 'Tags', href: '/tags' }, - { label: tag.namespace, href: `/tags/${tag.namespace}` }, + { label: NS_LABELS[tag.namespace] ?? tag.namespace, href: `/tags/${tag.namespace}` }, { label: tag.title }, ]} /> diff --git a/apps/web/src/screens/TagsNamespace.tsx b/apps/web/src/screens/TagsNamespace.tsx index 3f020e7..a4f3145 100644 --- a/apps/web/src/screens/TagsNamespace.tsx +++ b/apps/web/src/screens/TagsNamespace.tsx @@ -6,7 +6,7 @@ import { TagChip } from '@/components/TagChip'; import { Pagination } from '@/components/Pagination'; import { api } from '@/lib/api'; -const NS_LABELS: Record<string, string> = { +export const NS_LABELS: Record<string, string> = { topic: 'Topics', tech: 'Tech', event: 'Events', From ab461e0e46b6ce4280da8a88ff24910fcc572005 Mon Sep 17 00:00:00 2001 From: Chris Alfano <chris@jarv.us> Date: Tue, 8 Sep 2026 21:56:24 -0400 Subject: [PATCH 12/14] fix(web): guard clipboard, size the status span, finish <time> sweep navigator.clipboard is undefined outside secure contexts and reading .writeText off it throws synchronously, before a .catch() could run, so the failure toast never fired there. One copyWithToast helper guards it and replaces the two duplicated promise chains on ProjectDetail. ProfileEdit's always-mounted status span reserved mt-1 even while empty; the margin now applies only when it has text. Three relative timestamps were still title-only: HelpWantedCard's "posted", PersonDetail's "joined" and its recent-update dates. They get the same <time dateTime title> treatment as the rest of the sweep. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RdRwHvDupRLV8GuJpYKzEr --- apps/web/src/components/HelpWantedCard.tsx | 9 +++-- apps/web/src/screens/PersonDetail.tsx | 15 +++++--- apps/web/src/screens/ProfileEdit.tsx | 9 +++-- apps/web/src/screens/ProjectDetail.tsx | 42 +++++++++++++++------- 4 files changed, 55 insertions(+), 20 deletions(-) diff --git a/apps/web/src/components/HelpWantedCard.tsx b/apps/web/src/components/HelpWantedCard.tsx index 75cf2d2..32c7f06 100644 --- a/apps/web/src/components/HelpWantedCard.tsx +++ b/apps/web/src/components/HelpWantedCard.tsx @@ -6,7 +6,7 @@ import { PersonAvatar } from '@/components/PersonAvatar'; import { MarkdownView } from '@/components/MarkdownView'; import { ExpressInterestModal } from '@/components/modals/ExpressInterestModal'; import { useAuth } from '@/hooks/useAuth'; -import { formatRelativeTime } from '@/lib/time'; +import { formatAbsoluteDate, formatRelativeTime } from '@/lib/time'; import type { HelpWantedRoleResponse } from '@/lib/api'; interface HelpWantedCardProps { @@ -64,7 +64,12 @@ export function HelpWantedCard({ role, showProjectLink = true }: HelpWantedCardP <span>·</span> </> )} - <span>posted {formatRelativeTime(role.createdAt)}</span> + <span> + posted{' '} + <time dateTime={role.createdAt} title={formatAbsoluteDate(role.createdAt)}> + {formatRelativeTime(role.createdAt)} + </time> + </span> </div> {isSignedIn ? ( diff --git a/apps/web/src/screens/PersonDetail.tsx b/apps/web/src/screens/PersonDetail.tsx index 6564737..d4271af 100644 --- a/apps/web/src/screens/PersonDetail.tsx +++ b/apps/web/src/screens/PersonDetail.tsx @@ -19,7 +19,7 @@ import { TagChip } from '@/components/TagChip'; import { PersonAvatar } from '@/components/PersonAvatar'; import { useAuth } from '@/hooks/useAuth'; import { api, ApiError } from '@/lib/api'; -import { formatMonthYear, formatRelativeTime } from '@/lib/time'; +import { formatAbsoluteDate, formatMonthYear, formatRelativeTime } from '@/lib/time'; export function PersonDetail() { const params = useParams(); @@ -181,7 +181,10 @@ export function PersonDetail() { )} </div> <span className="text-xs text-muted-foreground shrink-0"> - joined {formatRelativeTime(m.joinedAt)} + joined{' '} + <time dateTime={m.joinedAt} title={formatAbsoluteDate(m.joinedAt)}> + {formatRelativeTime(m.joinedAt)} + </time> </span> </li> ))} @@ -202,9 +205,13 @@ export function PersonDetail() { > {u.project.title} · Update #{u.number} </Link> - <span className="text-xs text-muted-foreground"> + <time + dateTime={u.createdAt} + title={formatAbsoluteDate(u.createdAt)} + className="text-xs text-muted-foreground" + > {formatRelativeTime(u.createdAt)} - </span> + </time> </div> <div className="line-clamp-3 text-sm"> <MarkdownView html={u.bodyHtml} /> diff --git a/apps/web/src/screens/ProfileEdit.tsx b/apps/web/src/screens/ProfileEdit.tsx index c1c007b..ff5f950 100644 --- a/apps/web/src/screens/ProfileEdit.tsx +++ b/apps/web/src/screens/ProfileEdit.tsx @@ -211,8 +211,13 @@ export function ProfileEdit() { className="block" /> {/* role="status" so the upload's progress is announced rather - than only appearing next to the file input. */} - <span role="status" className="block mt-1 text-xs text-muted-foreground"> + than only appearing next to the file input. The span stays + mounted so the live region exists before its text changes; + the margin applies only while it has something to show. */} + <span + role="status" + className={avatarUploading ? 'block mt-1 text-xs text-muted-foreground' : 'block'} + > {avatarUploading ? 'Uploading…' : ''} </span> </div> diff --git a/apps/web/src/screens/ProjectDetail.tsx b/apps/web/src/screens/ProjectDetail.tsx index cb269d9..35f3087 100644 --- a/apps/web/src/screens/ProjectDetail.tsx +++ b/apps/web/src/screens/ProjectDetail.tsx @@ -35,6 +35,24 @@ import { useAuth } from '@/hooks/useAuth'; import { api, ApiError, type HelpWantedRoleResponse } from '@/lib/api'; import { formatRelativeTime, formatAbsoluteDate } from '@/lib/time'; +/** + * Clipboard write with a toast either way. `navigator.clipboard` is + * undefined outside secure contexts (plain-http dev hosts, some webviews), + * and reading `.writeText` off it throws synchronously — before any + * `.catch()` could see it — so the guard sits outside the promise chain. + */ +function copyWithToast(text: string, ok: string, fail: string) { + const clipboard: Clipboard | undefined = navigator.clipboard; + if (!clipboard) { + toast.error(fail); + return; + } + void clipboard + .writeText(text) + .then(() => toast.success(ok)) + .catch(() => toast.error(fail)); +} + interface ProjectDetailProps { anchor?: 'update' | 'buzz'; } @@ -560,12 +578,13 @@ export function ProjectDetail({ anchor }: ProjectDetailProps = {}) { action confirmations. */} <Button variant="outline" - onClick={() => { - void navigator.clipboard - .writeText(`https://codeforphilly.org/projects/${slug}`) - .then(() => toast.success('Link copied')) - .catch(() => toast.error("Couldn't copy the link")); - }} + onClick={() => + copyWithToast( + `https://codeforphilly.org/projects/${slug}`, + 'Link copied', + "Couldn't copy the link", + ) + } > Copy link </Button> @@ -575,12 +594,11 @@ export function ProjectDetail({ anchor }: ProjectDetailProps = {}) { // Copy a pre-formatted Slack message. Spec calls this // out as either system-share or copy; copy works in every // browser context without a Web Share API gate. - void navigator.clipboard - .writeText( - `Check out ${project.title} on Code for Philly: https://codeforphilly.org/projects/${slug}`, - ) - .then(() => toast.success('Slack message copied')) - .catch(() => toast.error("Couldn't copy the message")); + copyWithToast( + `Check out ${project.title} on Code for Philly: https://codeforphilly.org/projects/${slug}`, + 'Slack message copied', + "Couldn't copy the message", + ); }} > Share to Slack From dc9055b04fd101ff4c457d82911a2210188541c4 Mon Sep 17 00:00:00 2001 From: Chris Alfano <chris@jarv.us> Date: Tue, 8 Sep 2026 21:56:24 -0400 Subject: [PATCH 13/14] refactor(web): derive the toolbar's roving index from the event target handleToolbarKeyDown read `activeButton` from its closure, which lags a focus change by a render cycle. Take the index from the button the key landed on instead; `activeButton` stays as render state for tabIndex. The name-superset test compared two literals from its own table; it now checks the rendered button's aria-label against its visible text. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RdRwHvDupRLV8GuJpYKzEr --- apps/web/src/components/MarkdownEditor.tsx | 9 +++++++-- apps/web/tests/MarkdownEditor.test.tsx | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/MarkdownEditor.tsx b/apps/web/src/components/MarkdownEditor.tsx index 25eb0b0..abe94e3 100644 --- a/apps/web/src/components/MarkdownEditor.tsx +++ b/apps/web/src/components/MarkdownEditor.tsx @@ -149,13 +149,18 @@ export function MarkdownEditor({ }; const handleToolbarKeyDown = (e: KeyboardEvent<HTMLDivElement>) => { + // Start from the button the key landed on, not from `activeButton`: + // that state is what the render reads for tabIndex, but it lags a + // focus change by a render cycle, so the event target is the truth. + const current = toolbarRefs.current.indexOf(e.target as HTMLButtonElement); + if (current === -1) return; const last = TOOLBAR.length - 1; if (e.key === 'ArrowRight') { e.preventDefault(); - focusToolbarButton(activeButton === last ? 0 : activeButton + 1); + focusToolbarButton(current === last ? 0 : current + 1); } else if (e.key === 'ArrowLeft') { e.preventDefault(); - focusToolbarButton(activeButton === 0 ? last : activeButton - 1); + focusToolbarButton(current === 0 ? last : current - 1); } else if (e.key === 'Home') { e.preventDefault(); focusToolbarButton(0); diff --git a/apps/web/tests/MarkdownEditor.test.tsx b/apps/web/tests/MarkdownEditor.test.tsx index a621cf9..93ef739 100644 --- a/apps/web/tests/MarkdownEditor.test.tsx +++ b/apps/web/tests/MarkdownEditor.test.tsx @@ -38,7 +38,7 @@ describe('MarkdownEditor formatting toolbar', () => { ] as const) { const btn = within(toolbar).getByRole('button', { name }); expect(btn.textContent).toBe(visible); - expect(name.toLowerCase()).toContain(visible.toLowerCase()); + expect(btn.getAttribute('aria-label')?.toLowerCase()).toContain(visible.toLowerCase()); } }); From e8e9bc6a6bd8a885900aa9c87d89f5925019b077 Mon Sep 17 00:00:00 2001 From: Chris Alfano <chris@jarv.us> Date: Tue, 8 Sep 2026 22:10:23 -0400 Subject: [PATCH 14/14] chore(plans): record review closeout for a11y-mechanical MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fill in the Notes and Follow-ups that were left as placeholders, correct two sentences that no longer matched the code (crumbs are a fragment sibling above the content container, not its first child; the PersonCard hover lift stays on the article, there is no group-hover), describe the post-rebase state of the banner, landmark and toolbar items, and record the full validation gate across every workspace. Follow-ups filed as #166–#170 for the refactors the review surfaced but deliberately kept out of this PR. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RdRwHvDupRLV8GuJpYKzEr --- plans/a11y-mechanical.md | 117 +++++++++++++++++++++++++++++++-------- 1 file changed, 94 insertions(+), 23 deletions(-) diff --git a/plans/a11y-mechanical.md b/plans/a11y-mechanical.md index ce7892d..def319c 100644 --- a/plans/a11y-mechanical.md +++ b/plans/a11y-mechanical.md @@ -45,8 +45,8 @@ table of trails. **No spec change is needed anywhere in this plan.** `apps/web/src/components/Breadcrumbs.tsx` renders `nav[aria-label="Breadcrumb"] > ol > li` with `aria-current="page"` on the last crumb — correct as written, imported by nothing. Wired into the six screens the spec's table names, each -placed as the first child of the screen's content container (the spec's "row -below the header"): +rendered as a fragment sibling immediately *above* the screen's content +container (the spec's "row below the header"): | Route | Trail | |---|---| @@ -97,7 +97,9 @@ The six formatting buttons were a bare `<div>` of buttons named "B", "I", `aria-label`'d with a full name that contains its visible label as a substring (B ⊂ Bold, I ⊂ Italic, Link ⊂ Insert link, List ⊂ Bulleted list), and a roving tabindex: only the active button is tabbable, -ArrowLeft/ArrowRight move focus (wrapping), Home/End jump to the ends. +ArrowLeft/ArrowRight move focus (wrapping), Home/End jump to the ends. The +keydown handler derives its starting index from the event target rather +than the `activeButton` state, which lags a focus change by one render. ### 5. Status announcements @@ -109,16 +111,20 @@ Three places changed state visually with nothing announced: - `Sponsor`'s "Copy email" swaps its label to "Copied ✓" — visible text kept, with an `sr-only role="status"` mirror added. - `ProfileEdit`'s "Uploading…" span becomes `role="status"`. -- `ConnectGitHubBanner` was `role="region"`, which is never announced; the - banner appears *after* auth resolves, so it becomes `role="status"`. +- `ConnectGitHubBanner` is a `role="region"` that mounts only after auth + resolves, so its arrival is never announced. It *stays* a region (a + late-mounted `status` container is not read reliably either, and it would + wrap the two buttons) and gains a sibling sr-only `role="status"` span + carrying the headline — the same mirror idiom as `Sponsor`. ### 6. `<time dateTime>` for machine-readable dates `title` is not exposed to most screen readers and never on touch. Every date rendered as relative text inside a `title`-only `<span>` becomes `<time dateTime={iso} title={absolute}>` — the `BlogIndex.tsx` idiom. -Covers `ActivityCard` (×2), `ProjectDetail` (×2), `BlogDetail`, `Account`, -`StaffAccountClaimQueue`, `AccountClaim`. `ProjectCard`'s wrapper +Covers `ActivityCard` (×2), `ProjectDetail` (×2), `PersonDetail` (×2), +`HelpWantedCard`, `BlogDetail`, `Account`, `StaffAccountClaimQueue`, +`AccountClaim`. `ProjectCard`'s wrapper `title={m.fullName}` is deleted outright — `PersonAvatar` already emits it. ### 7. Structure and one-liners @@ -126,7 +132,8 @@ Covers `ActivityCard` (×2), `ProjectDetail` (×2), `BlogDetail`, `Account`, - `PersonCard` was one giant `<Link>`, so its accessible name concatenated avatar + name + project count + every tag chip. Restructured to the `ProjectCard` idiom: `<article>` with the `h3` wrapping the link. The hover - lift moves to the article via `group-hover`, so the affordance is unchanged. + lift (`hover:shadow-md hover:-translate-y-0.5`) stays on the article, so + the affordance is unchanged. - `AppHeader`'s two navs render bare links; wrapped in `<ul>/<li>` matching `AppFooter`. Flex/gap classes move to the `ul`; `li` contributes nothing. Sheet separators sit between the two lists rather than inside one. @@ -134,8 +141,10 @@ Covers `ActivityCard` (×2), `ProjectDetail` (×2), `BlogDetail`, `Account`, level below the Radix `SheetTitle`, which renders `h2`), same classes. - `HelpWantedIndex`'s bare outer `<aside>` wrapped `FacetSidebar`, which renders its own labelled `<aside>` — two nested `complementary` landmarks. - Outer becomes a `<div>`. (`PeopleIndex`/`ProjectsIndex` render - `FacetSidebar` directly and never had this.) + `FacetSidebar` now accepts `children` inside its aside, and the Commitment + heading + fieldset ride there: one landmark ("Filters") holds every filter + control instead of leaving Commitment orphaned beside it. (`PeopleIndex`/ + `ProjectsIndex` render `FacetSidebar` directly and never had this.) - Result-count badges move **out** of the `h1` into a flex sibling on all three index screens, so the heading's accessible name stops mutating as filters change. @@ -155,13 +164,23 @@ Covers `ActivityCard` (×2), `ProjectDetail` (×2), `BlogDetail`, `Account`, `ProjectDetail` / `PersonDetail` aside headings are `h2`. - [x] The `MarkdownEditor` toolbar exposes `role="toolbar"`, named buttons, and a working roving tabindex (Arrow/Home/End). -- [x] Copy actions on `ProjectDetail` and `Sponsor` announce; `ProfileEdit` - upload and `ConnectGitHubBanner` are live regions. +- [x] Copy actions on `ProjectDetail` and `Sponsor` announce (including a + failure toast where `navigator.clipboard` is absent); `ProfileEdit`'s + upload is a live region; `ConnectGitHubBanner`'s arrival is announced + by its sibling status span. - [x] Dates expose `datetime`; no date is `title`-only. -- [x] Exactly one `complementary` landmark per index screen. -- [x] `npm run -w packages/shared build && npm run type-check && npm run lint - && npm run -w apps/web test && npm run -w packages/shared test` clean - (web 116/116, shared 75/75; run twice — implementer and coordinator). +- [x] Exactly one `complementary` landmark per index screen, and on + `HelpWantedIndex` it contains the Commitment controls + (`HelpWantedIndex.test.tsx`). +- [x] Full gate from the repo root, all workspaces: `npm ci && npm run -w + packages/shared build && npm run type-check && npm run lint && npm + test` clean after the rebase onto `develop` and the review fix-ups + (api 434/434 across 35 files, web 124/124 across 28, shared 75/75 + across 3). The root `npm test` invocation was OOM-killed once on the + closeout machine, so the api suite was re-run alone with + `--maxWorkers=2`; every workspace suite ran to completion. The + contributor's earlier pass ran web + shared only (web 116/116, + shared 75/75). - [x] Browser test (headed Chrome against the live dev stack — api booted on a `setup-dev-data` repo with two seeded records): breadcrumb trails verified on `/projects/qa-sandbox` ("Projects › QA Sandbox Project") @@ -174,17 +193,69 @@ Covers `ActivityCard` (×2), `ProjectDetail` (×2), `BlogDetail`, `Account`, - **Low.** Almost every change is attribute-level or a wrapper element. - The two structural edits (`PersonCard`, `AppHeader` nav lists) touch files - PR #154 rewrote. Both keep every existing behavior — the sheet's `onClick` - close handlers, the separators, the NavLink active styling — and are - covered by the existing `AppHeader.test.tsx` suite plus updated name + PR #154 rewrote. Both keep every existing behavior — the sheet closing on + navigation (derived from `location.key` since #154, so the NavLinks carry + no per-item `onClick`), the separators, the NavLink active styling — and + are covered by the existing `AppHeader.test.tsx` suite plus updated name matchers. -- The `apps/api` suite is deliberately not run: it has a known pre-existing - Windows fixture failure and no `apps/api` file changes here. +- The `apps/api` suite was skipped on the contributor's Windows pass (known + fixture failure there, tracked in #162); it was run in full on Linux at + closeout — see Validation. ## Notes -_(filled in at closeout)_ +- **`aria-label` supersets on visible-text buttons are deliberate.** "More ▾" + → `aria-label="More actions"`, "Mark filled" → `Mark filled: <role>`, + "Remove" → `Remove <name>`, and the toolbar's "B" → "Bold". In every case + the visible text is a prefix (or, for the toolbar, a substring) of the + accessible name, which is what SC 2.5.3 Label in Name asks for and what + the specs already assume for the per-row actions. Speech-input users + saying what they see still hit the control. +- **jsdom's accname drops the leading space in sr-only cues** — it + computes `"GitHub(opens in new tab)"` where Chrome computes + `"GitHub (opens in new tab)"`. Tests match with `\s*` regexes rather than + encoding either engine's answer. +- **Rebase.** The branch was cut from a local merge of #154 and #155; both + were patched during review before landing on `develop`, so the eight + commits here were rebased onto `develop` after #155 merged. The one + conflict was `AppHeader.tsx`: #154 replaced the per-NavLink `onClick` + closers with a sheet state derived from `location.key`, and this branch + wrapped the same links in `<ul>/<li>`. Resolved by keeping the list + structure without the closers (the two plain `<a>`s — Contact, GitHub — + keep theirs, since they don't navigate client-side). +- **Review fix-ups applied on top of the six commits:** `block` on sheet + anchors so the `<li>` wrap doesn't shrink their tap targets; Commitment + moved inside `FacetSidebar`'s aside (via a new `children` slot) instead + of sitting outside every landmark; `ConnectGitHubBanner` kept as a region + with a sibling status announcer; `Breadcrumbs` keyed by index (the PR's + own "pre-existing, flagged" item); the namespace crumb labelled via + `TagsNamespace`'s `NS_LABELS` so it matches the destination `h1`; + `ProjectEdit` holds its loading state when the edit query settles empty + instead of rendering a blank crumb to `/projects/`; a `copyWithToast` + helper on `ProjectDetail` that guards `navigator.clipboard` (undefined in + insecure contexts, and reading `.writeText` off it throws synchronously, + so the old `.catch()` never fired); `ProfileEdit`'s status span only + reserves `mt-1` while it has text; three `<time>`s the first sweep missed + (`HelpWantedCard`, `PersonDetail` ×2); the toolbar keydown handler reads + its index from the event target rather than the `activeButton` closure. +- `NS_LABELS` is now exported from `screens/TagsNamespace.tsx` and imported + by `screens/TagDetail.tsx` — a screen importing from a screen, chosen as + the smallest change. `FacetSidebar` carries its own copy of the same map; + a shared tags-label module could absorb both. ## Follow-ups -_(filled in at closeout)_ +- Issue [#166](https://github.com/CodeForPhilly/codeforphilly-ng/issues/166) + — render breadcrumbs from the shell via route `handle`s (`useMatches`) + per app-shell.md, so skip-to-main lands past them rather than on them. +- Issue [#167](https://github.com/CodeForPhilly/codeforphilly-ng/issues/167) + — `ExternalLink` component for the 16 hand-rolled "(opens in new tab)" + copies. +- Issue [#168](https://github.com/CodeForPhilly/codeforphilly-ng/issues/168) + — `RelativeTime` component for the 13 `<time dateTime title>` sites. +- Issue [#169](https://github.com/CodeForPhilly/codeforphilly-ng/issues/169) + — `MarkdownEditor` toolbar should use Radix `Toolbar` from the + already-installed `radix-ui` instead of the hand-rolled roving tabindex. +- Issue [#170](https://github.com/CodeForPhilly/codeforphilly-ng/issues/170) + — card heading level should be a prop; `TagDetail` renders a section `h2` + followed by `ProjectCard` `h2`s.