+ {/* Desktop utility cluster: GitHub, search, auth, then the Volunteer
+ CTA pinned rightmost (specs/behaviors/app-shell.md). */}
+
+
-
+
{loading && results.length === 0 && (
Searching…
diff --git a/apps/web/src/components/icons/GitHubIcon.tsx b/apps/web/src/components/icons/GitHubIcon.tsx
new file mode 100644
index 0000000..6862c4e
--- /dev/null
+++ b/apps/web/src/components/icons/GitHubIcon.tsx
@@ -0,0 +1,15 @@
+/** GitHub mark, sized to sit inline with text and icon buttons. */
+export function GitHubIcon() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/src/pages/HomeStub.tsx b/apps/web/src/pages/HomeStub.tsx
deleted file mode 100644
index 63afc92..0000000
--- a/apps/web/src/pages/HomeStub.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-export function HomeStub() {
- return (
-
-
- Code for Philly is being rebuilt
-
-
- We are modernizing the platform. Check back soon for the full
- experience. In the meantime, you can browse our projects below.
-
-
-
- );
-}
diff --git a/apps/web/src/pages/LoginPlaceholder.tsx b/apps/web/src/pages/LoginPlaceholder.tsx
index 5b82177..581185a 100644
--- a/apps/web/src/pages/LoginPlaceholder.tsx
+++ b/apps/web/src/pages/LoginPlaceholder.tsx
@@ -10,6 +10,7 @@ import {
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
+import { GitHubIcon } from '@/components/icons/GitHubIcon';
import { useAuth } from '@/hooks/useAuth';
import { api, ApiError } from '@/lib/api';
@@ -46,21 +47,6 @@ const ERROR_MESSAGES: Record
= {
),
};
-function GitHubIcon() {
- return (
-
-
-
- );
-}
-
function WhyGitHub() {
const [open, setOpen] = useState(false);
diff --git a/apps/web/src/screens/Volunteer.tsx b/apps/web/src/screens/Volunteer.tsx
index 8b58cf9..458652f 100644
--- a/apps/web/src/screens/Volunteer.tsx
+++ b/apps/web/src/screens/Volunteer.tsx
@@ -5,10 +5,11 @@ import { HelpWantedCard } from '@/components/HelpWantedCard';
import { useAuth } from '@/hooks/useAuth';
import { api } from '@/lib/api';
-const HACK_NIGHT_URL =
- 'https://codeforphilly.gitbook.io/projects/contributing-to-projects/hack-night-program-details';
+// The codeforphilly.gitbook.io space is gone (404 "Content owner not found"),
+// so both of these point at the surviving live equivalents.
+const MEETUP_URL = 'https://www.meetup.com/Code-for-Philly/';
const START_PROJECT_URL =
- 'https://codeforphilly.gitbook.io/projects/creating-new-partnerships/first-steps';
+ 'https://github.com/CodeForPhilly/partnerships/blob/master/creating-new-partnerships/first-steps.md';
export function Volunteer() {
const { person } = useAuth();
@@ -69,7 +70,7 @@ export function Volunteer() {
We meet weekly. Bring your laptop, or just yourself.
-
+
When we meet →
diff --git a/apps/web/tests/AppFooter.test.tsx b/apps/web/tests/AppFooter.test.tsx
index 6b0eb49..8924baf 100644
--- a/apps/web/tests/AppFooter.test.tsx
+++ b/apps/web/tests/AppFooter.test.tsx
@@ -17,7 +17,7 @@ describe('AppFooter', () => {
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute(
'href',
- 'https://github.com/CodeForPhilly/codeforphilly-rewrite',
+ 'https://github.com/CodeForPhilly/codeforphilly-ng',
);
});
diff --git a/apps/web/tests/AppHeader.test.tsx b/apps/web/tests/AppHeader.test.tsx
index 5b58ff4..9f3c3c3 100644
--- a/apps/web/tests/AppHeader.test.tsx
+++ b/apps/web/tests/AppHeader.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 userEvent from '@testing-library/user-event';
import { renderWithRouter } from './test-utils.js';
import { AppHeader } from '../src/components/AppHeader.js';
@@ -38,10 +38,25 @@ describe('AppHeader', () => {
it('renders primary nav links', async () => {
renderWithRouter( );
+ const nav = screen.getByRole('navigation', { name: /primary navigation/i });
expect(screen.getByRole('link', { name: 'Projects' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Help Wanted' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Members' })).toBeInTheDocument();
- expect(screen.getByRole('link', { name: 'Volunteer' })).toBeInTheDocument();
+ expect(within(nav).getByRole('button', { name: 'About' })).toBeInTheDocument();
+
+ // The Volunteer CTA lives in the utility cluster, not the content nav —
+ // it is the rightmost header element (specs/behaviors/app-shell.md).
+ const volunteer = screen.getByRole('link', { name: 'Volunteer' });
+ expect(volunteer).toHaveAttribute('href', '/volunteer');
+ expect(nav).not.toContainElement(volunteer);
+ });
+
+ it('renders the GitHub link in the utility cluster', async () => {
+ renderWithRouter( );
+ const gh = screen.getByRole('link', { name: 'Code for Philly on GitHub' });
+ expect(gh).toHaveAttribute('href', 'https://github.com/CodeForPhilly');
+ expect(gh).toHaveAttribute('target', '_blank');
+ expect(gh).toHaveAttribute('rel', 'noopener noreferrer');
});
it('shows Sign in button(s) when anonymous', async () => {
@@ -59,7 +74,8 @@ describe('AppHeader', () => {
const user = userEvent.setup();
renderWithRouter( );
- const aboutBtn = screen.getByRole('button', { name: /about menu/i });
+ // The trigger's visible text is its accessible name — no aria-label.
+ const aboutBtn = screen.getByRole('button', { name: 'About' });
await user.click(aboutBtn);
await waitFor(() => {
@@ -75,6 +91,8 @@ describe('AppHeader', () => {
const hamburger = screen.getByRole('button', { name: /open navigation menu/i });
expect(hamburger).toBeInTheDocument();
+ // aria-expanded is supplied by Radix's Dialog.Trigger, not hand-written.
+ expect(hamburger).toHaveAttribute('aria-expanded', 'false');
// Open
await user.click(hamburger);
@@ -83,6 +101,7 @@ describe('AppHeader', () => {
// Sheet content includes "Mobile navigation" aria-label
expect(screen.getByRole('navigation', { name: /mobile navigation/i })).toBeInTheDocument();
});
+ expect(hamburger).toHaveAttribute('aria-expanded', 'true');
// Close via Escape key
await user.keyboard('{Escape}');
@@ -91,4 +110,64 @@ describe('AppHeader', () => {
expect(screen.queryByRole('navigation', { name: /mobile navigation/i })).not.toBeInTheDocument();
});
});
+
+ it('gives the mobile sheet dialog an accessible name', async () => {
+ const user = userEvent.setup();
+ renderWithRouter( );
+
+ await user.click(screen.getByRole('button', { name: /open navigation menu/i }));
+
+ const dialog = await screen.findByRole('dialog', { name: 'Menu' });
+ expect(dialog).toBeInTheDocument();
+ });
+
+ it('lists GitHub and Volunteer in the mobile sheet', async () => {
+ const user = userEvent.setup();
+ renderWithRouter( );
+
+ 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',
+ );
+ expect(within(nav).getByRole('link', { name: 'Volunteer' })).toHaveAttribute(
+ 'href',
+ '/volunteer',
+ );
+ });
+
+ it('closes the mobile sheet when a sheet link navigates', async () => {
+ const user = userEvent.setup();
+ renderWithRouter( );
+
+ await user.click(screen.getByRole('button', { name: /open navigation menu/i }));
+ const nav = await screen.findByRole('navigation', { name: /mobile navigation/i });
+
+ await user.click(within(nav).getByRole('link', { name: 'Members' }));
+
+ await waitFor(() => {
+ expect(screen.queryByRole('dialog', { name: 'Menu' })).not.toBeInTheDocument();
+ });
+ });
+
+ it('closes the mobile sheet when the inline search navigates', async () => {
+ const user = userEvent.setup();
+ renderWithRouter( );
+
+ await user.click(screen.getByRole('button', { name: /open navigation menu/i }));
+ const dialog = await screen.findByRole('dialog', { name: 'Menu' });
+
+ // Scope to the sheet: jsdom applies no breakpoints, so the desktop
+ // search box is in the DOM too.
+ await user.type(
+ within(dialog).getByRole('searchbox', { name: /search the site/i }),
+ 'civic{Enter}',
+ );
+
+ await waitFor(() => {
+ expect(screen.queryByRole('dialog', { name: 'Menu' })).not.toBeInTheDocument();
+ });
+ });
});
diff --git a/apps/web/tests/Volunteer.test.tsx b/apps/web/tests/Volunteer.test.tsx
new file mode 100644
index 0000000..af17c99
--- /dev/null
+++ b/apps/web/tests/Volunteer.test.tsx
@@ -0,0 +1,90 @@
+import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import { renderScreen, mockPaginated } from './test-utils.js';
+import { Volunteer } from '../src/screens/Volunteer.js';
+import { AuthProvider } from '../src/hooks/useAuth.js';
+
+const MEETUP_URL = 'https://www.meetup.com/Code-for-Philly/';
+const START_PROJECT_URL =
+ 'https://github.com/CodeForPhilly/partnerships/blob/master/creating-new-partnerships/first-steps.md';
+
+describe('Volunteer', () => {
+ beforeEach(() => {
+ vi.spyOn(globalThis, 'fetch').mockImplementation(((input: string) => {
+ if (input.startsWith('/api/auth/me')) {
+ return Promise.resolve(new Response(null, { status: 404 }));
+ }
+ if (input.startsWith('/api/projects')) {
+ return Promise.resolve(
+ new Response(JSON.stringify(mockPaginated([], { totalItems: 268 })), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ }),
+ );
+ }
+ return Promise.resolve(
+ new Response(JSON.stringify(mockPaginated([])), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ }),
+ );
+ }) as typeof fetch);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ function renderVolunteer() {
+ return renderScreen(
+
+
+ ,
+ );
+ }
+
+ it('renders the hero headline', async () => {
+ renderVolunteer();
+ expect(
+ screen.getByRole('heading', {
+ name: /volunteer with code for philly/i,
+ level: 1,
+ }),
+ ).toBeInTheDocument();
+ // Let AuthProvider's /api/auth/me fetch settle before the test returns.
+ await screen.findByText(/browse 268 active projects/i);
+ });
+
+ it('points "When we meet →" at the live Meetup group, not the dead GitBook page', async () => {
+ renderVolunteer();
+ const link = screen.getByRole('link', { name: /when we meet/i });
+ expect(link).toHaveAttribute('href', MEETUP_URL);
+ expect(link).toHaveAttribute('target', '_blank');
+ expect(link).toHaveAttribute('rel', 'noopener noreferrer');
+ await screen.findByText(/browse 268 active projects/i);
+ });
+
+ it('points "Read the guide →" at the partnerships repo, not the dead GitBook page', async () => {
+ renderVolunteer();
+ const link = screen.getByRole('link', { name: /read the guide/i });
+ expect(link).toHaveAttribute('href', START_PROJECT_URL);
+ expect(link).toHaveAttribute('target', '_blank');
+ expect(link).toHaveAttribute('rel', 'noopener noreferrer');
+ await screen.findByText(/browse 268 active projects/i);
+ });
+
+ it('has no codeforphilly.gitbook.io links anywhere on the screen', async () => {
+ const { container } = renderVolunteer();
+
+ // Wait for the live project count so the fully-settled DOM is asserted on.
+ await waitFor(() => {
+ expect(screen.getByText(/browse 268 active projects/i)).toBeInTheDocument();
+ });
+
+ const hrefs = Array.from(container.querySelectorAll('a')).map(
+ (a) => a.getAttribute('href') ?? '',
+ );
+ expect(hrefs.filter((h) => h.includes('gitbook.io'))).toHaveLength(0);
+ expect(container.innerHTML).not.toContain('codeforphilly.gitbook.io');
+ });
+});
diff --git a/docs/operations/cutover-announcement.md b/docs/operations/cutover-announcement.md
index 6fd73cc..1adf51b 100644
--- a/docs/operations/cutover-announcement.md
+++ b/docs/operations/cutover-announcement.md
@@ -116,7 +116,7 @@ What you can do now:
- Use the Slack workspace as normal; SSO is now backed by our own IdP
Bugs / weirdness: file an issue on
-https://github.com/CodeForPhilly/codeforphilly-rewrite/issues or just reply
+https://github.com/CodeForPhilly/codeforphilly-ng/issues or just reply
here.
```
diff --git a/plans/site-check-153.md b/plans/site-check-153.md
new file mode 100644
index 0000000..599f5ed
--- /dev/null
+++ b/plans/site-check-153.md
@@ -0,0 +1,228 @@
+---
+status: done
+depends: []
+specs:
+ - specs/behaviors/app-shell.md
+ - specs/screens/volunteer.md
+issues: [153]
+pr: 154
+---
+
+# Plan: site check — header order, mobile sheet padding, dead outbound links
+
+## Scope
+
+Issue [#153](https://github.com/CodeForPhilly/codeforphilly-ng/issues/153)
+("Site check for desktop & mobile") collects a walkthrough of the live site on
+both breakpoints. Four of its items are shippable now; one is blocked (see
+Follow-ups).
+
+What ships:
+
+- **Desktop header reorder** (spec-governed). The Volunteer CTA leaves the
+ content nav and becomes the rightmost element of the header, after the auth
+ control; About joins the left cluster's text links; a GitHub icon link is
+ added to the right cluster.
+- **Mobile sheet padding + accessible name.** The sheet's nav and search sat
+ flush against the panel edge. Fixed with the intended shadcn structure
+ (`SheetHeader` + `SheetTitle`) plus explicit horizontal padding — which also
+ gives the underlying Radix dialog the accessible name it was missing.
+- **Header ARIA cleanups.** Three defects surfaced by an accessibility pass over
+ the header, done here because this plan rewrites the same file.
+- **Dead outbound links** (spec-governed). The whole `codeforphilly.gitbook.io`
+ space returns 404 "Content owner not found"; `Volunteer.tsx`'s two remaining
+ GitBook links are repointed at live equivalents. Same class of defect as
+ [`home-start-project-cta`](home-start-project-cta.md) (PR #128), which fixed
+ the Home screen's copy of the same dead URL.
+- **Footer repo URL.** The "view this site on GitHub" link still pointed at
+ `codeforphilly-rewrite`; the repo is `codeforphilly-ng` and the old URL only
+ resolves through GitHub's rename redirect.
+
+Explicitly out of scope:
+
+- **Replacing the Home hero's Volunteer CTA with a mailing-list invite** (also
+ recommended by #153) — blocked, see Follow-ups. `Home.tsx` is untouched.
+- Any other visual restyle of the header. The Volunteer button keeps its
+ existing green treatment; only its position changes.
+
+## Implements
+
+- [app-shell.md](../specs/behaviors/app-shell.md) — "Center / right at ≥ md"
+ split into a left content cluster and a right utility cluster, with the new
+ item order; "Auth controls" repositioned second-from-right; GitHub link added
+ to the right cluster and to the mobile sheet; the sheet's accessible name
+ added under Accessibility.
+- [volunteer.md](../specs/screens/volunteer.md) — "Show up to meetups" card
+ links to the Meetup group; "Start your own project" band links to the
+ `CodeForPhilly/partnerships` first-steps guide. Both replace dead GitBook
+ URLs.
+
+## Approach
+
+### 1. Spec changes first (specops — source of truth leads)
+
+`specs/behaviors/app-shell.md` and `specs/screens/volunteer.md` both prescribed
+the current (wrong) state, so they lead. Header spacing is deliberately *not*
+specced — [specs/README.md:49](../specs/README.md) puts spacing outside spec
+scope — so the mobile-sheet padding fix carries no spec change.
+
+### 2. `apps/web/src/components/AppHeader.tsx`
+
+- Left ``: Projects, Help Wanted, Members, About ▾. `gap-2` replaces
+ `gap-1` + per-child `ml-1`, so the parent gap is the single source of spacing
+ at the same effective density (4px + 4px → 8px).
+- Right cluster: GitHub icon link → SearchBox → AuthControls → Volunteer button.
+ New hand-rolled `GitHubIcon` SVG follows the file's existing icon convention
+ (`ChevronDownIcon` / `MenuIcon`); path data copied from `LoginPlaceholder.tsx`.
+- Mobile sheet: `SheetHeader` + `SheetTitle` ("Menu") replace the `pt-8` hack;
+ nav and search get `px-4`. Mobile item order mirrors the new desktop order,
+ with a GitHub row added and Volunteer last.
+- ARIA: `aria-hidden` replaces `aria-label` on the roleless loading-skeleton
+ div; the hand-written `aria-expanded` comes off the `SheetTrigger` (Radix
+ `Dialog.Trigger` supplies it); `aria-label="About menu"` comes off the About
+ trigger so its visible text is the accessible name. The account-menu
+ `aria-label` **stays** — below `sm` the person's name span is `display:none`,
+ so that label is the only accessible name there.
+
+### 3. `apps/web/src/screens/Volunteer.tsx`
+
+`HACK_NIGHT_URL` → `MEETUP_URL` = `https://www.meetup.com/Code-for-Philly/`
+(the same target the footer's Meetup social icon already uses).
+`START_PROJECT_URL` → the `CodeForPhilly/partnerships` first-steps markdown,
+which is the surviving source of the retired GitBook page. Both stay external.
+
+### 4. `apps/web/src/components/AppFooter.tsx`
+
+One-line repo URL swap to `codeforphilly-ng`.
+
+### 5. Tests
+
+- `AppHeader.test.tsx` — new nav shape, About queried by its visible text, the
+ GitHub link's label + href, Radix still supplying `aria-expanded`, and the
+ sheet dialog's accessible name.
+- `Volunteer.test.tsx` (new) — both CTA hrefs plus a dead-link regression
+ assertion that no `codeforphilly.gitbook.io` URL survives anywhere in the
+ rendered document, mirroring the idiom from `Home.test.tsx`.
+- `AppFooter.test.tsx` — updated repo URL.
+
+## Validation
+
+- [x] Specs updated before code: app-shell header clusters + volunteer link targets.
+- [x] Desktop header order is Projects · Help Wanted · Members · About ▾ … GitHub · Search · Sign in · Volunteer, with Volunteer rightmost and still green.
+- [x] No `ml-1` spacing hacks remain among the header nav's children.
+- [x] GitHub link is icon-only, labelled "Code for Philly on GitHub", and opens `https://github.com/CodeForPhilly` in a new tab with `rel="noopener noreferrer"`.
+- [x] Mobile sheet has a "Menu" title, horizontal padding on nav + search, and no `pt-8`; the title does not collide with the close button.
+- [x] The sheet dialog exposes an accessible name; Radix still supplies `aria-expanded` on the trigger.
+- [x] Loading skeleton uses `aria-hidden`; About trigger's accessible name is its visible text; account-menu label retained.
+- [x] Every mobile sheet item closes the sheet on click, including Contact.
+- [x] No `codeforphilly.gitbook.io` URL remains in `apps/web/src`.
+- [x] Footer "view this site on GitHub" points at `codeforphilly-ng`.
+- [x] Both replacement URLs return 200 and carry the expected content.
+- [x] `npm run -w packages/shared build`, `npm run type-check`, and `npm run lint` clean.
+- [x] `npm test` clean for the workspaces this plan touches: web 98/98, shared 75/75.
+- [x] `npm test` clean for **all** workspaces — api 434/434 on Linux at review (the Windows dev box could not run it, see Notes). Two web files (`ProjectEdit`, `ExpressInterestModal`, neither touched here) hit 5s timeouts under full-suite load and pass when run alone.
+- [ ] Browser test: desktop header order + mobile sheet padding at < md, both breakpoints.
+
+## Risks
+
+- **Low, but layout-shaped.** Moving Volunteer out of the flex-1 nav and into
+ the `ml-auto` cluster changes how much room the SearchBox has to expand at
+ narrow desktop widths. Watched by the browser-test criterion above rather
+ than by a unit test — jsdom has no layout.
+- **`aria-expanded` regression risk.** Removing the hand-written attribute is
+ only safe because Radix supplies its own; asserted in the header test so a
+ future primitive swap can't silently drop it.
+
+## Notes
+
+- **Browser criterion left unchecked.** What was exercised: desktop header
+ order, spacing, GitHub icon, and Volunteer-rightmost in headed Chrome at
+ 1400px; the sheet opened via its CSS-hidden trigger at desktop width ("Menu"
+ title, padded nav/search, no title/close collision, Escape closes it); and,
+ at review, headless Chrome at 768/1023/1024px for the tablet band (below).
+ What was not: the sheet and the < md header bar at an actual < md viewport.
+ The sheet is not a fixed-width portal — `ui/sheet.tsx` sizes it
+ `w-3/4` of the viewport capped at `sm:max-w-sm` — so its desktop-width
+ rendering is not evidence for < md. Same gap `web-shell.md:109` recorded;
+ covered by issue [#16](https://github.com/CodeForPhilly/codeforphilly-ng/issues/16).
+- **Sheet closes on navigation, not per item.** Review found the eleven
+ hand-wired `onClick={() => setMobileOpen(false)}` closers missed the inline
+ SearchBox's Enter path. Replaced with one rule: `mobileOpen` is derived as
+ "opened at the current `location.key`", so any client-side navigation
+ (NavLink, search Enter, same-path re-click) closes it. Derived during render
+ rather than a `useLocation` effect because `react-hooks/set-state-in-effect`
+ (in the plugin's v7 recommended set) flags the effect form — same
+ state-sync pattern `ProjectEdit.tsx` uses. Explicit closers remain only on
+ the two anchors that don't change location (Contact `mailto:`, GitHub
+ new-tab). Two tests cover a NavLink click and the search Enter path.
+- **Tablet band (768–1023px) overflowed.** Tailwind's `container` is 768px
+ wide across that whole range (736px content box). Measured at 768: logo 128
+ - nav 297 + gaps/margins 48 + utility cluster 387 = 860, so the document
+ scrolled to 845px and "Help Wanted" wrapped. Fix: GitHub icon link is
+ `hidden lg:inline-flex` (the sheet keeps its own row) and the desktop
+ SearchBox is `w-24 lg:w-48 lg:focus-within:w-72`. Its results dropdown is
+ now anchored `right-0 min-w-72` instead of `left-0 right-0`, so it stays
+ 288px wide over the narrow input — the same width it had at `lg` before.
+ After: 728px used at 768 (no scroll, one-line nav); 1024 fits with the icon
+ and 192px search restored. Signed-in accounts still overflow this band
+ (pre-existing, see Follow-ups).
+- **Mobile sheet dialog.** Radix warned on every open about a missing
+ description; a visually-hidden `SheetDescription` ("Site navigation") now
+ satisfies `aria-describedby`. The `className="w-72"` on `SheetContent` was
+ dead (the primitive's `data-[side=right]:w-3/4` / `sm:max-w-sm` win) and is
+ gone; the plan's earlier "fixed `w-72`" claim was wrong.
+- **Utility landmark.** The desktop utility cluster is ``
+ so Volunteer and GitHub stay inside a navigation landmark after leaving
+ "Primary navigation"; app-shell.md's Accessibility section names both.
+- **`GitHubIcon` lifted** to `apps/web/src/components/icons/GitHubIcon.tsx`;
+ the byte-identical copies in `AppHeader.tsx` and `LoginPlaceholder.tsx` now
+ import it. (Approach §2 above describes the copy as originally planned.)
+- **`HomeStub.tsx` removed** here after all — zero importers (grep-verified),
+ dead since scaffold. The same stale `codeforphilly-rewrite` GitHub URL was
+ also repointed in `specs/architecture.md`, `specs/behaviors/storage.md`, and
+ `docs/operations/cutover-announcement.md` (a hard 404, unlike the k8s
+ namespace / package name uses of the old slug, which are left alone).
+- **`Volunteer.test.tsx` act warnings.** Three synchronous tests returned
+ before `AuthProvider`'s `/api/auth/me` fetch resolved; each now awaits the
+ settled DOM like the fourth test.
+- **`apps/api` tests do not pass on Windows, independent of this plan.**
+ `apps/api` finishes 3 failed | 30 passed (33 files), 10 failed | 413 passed
+ (423 tests) — the ten spread across `scrub-data.test.ts` (4),
+ `internal-reload.test.ts` (4), and `store.test.ts` (2), on a tree where
+ `git diff develop..HEAD -- apps/api packages/` is empty — this branch touches
+ no API code. Checking out `develop` and re-running `store.test.ts` there
+ reproduces its 2 failed | 11 passed exactly. The mechanism is
+ POSIX-isms in the test fixtures: `store.test.ts` injects a write failure by
+ pointing the private store at `/dev/null/impossible-path` and asserting the
+ transaction rejects, but on Windows that is an ordinary creatable directory,
+ so the write succeeds and the expected throw never happens. They reproduce
+ with the files run alone, so it is not test-runner contention. CI runs the
+ same gate on Linux, where the fixture behaves as intended. Worth a
+ cross-platform fixture cleanup if Windows dev boxes are to be supported;
+ filed under Follow-ups.
+
+## Follow-ups
+
+- **Tracked as: blocked — hero "mailing list invite" CTA.** Issue #153
+ recommends replacing the Home hero's Volunteer CTA with a mailing-list
+ invite. There is no anonymous mailing-list mechanism anywhere in the repo:
+ newsletter subscription exists only as an auth-gated checkbox on `/account`
+ (writing `PrivateProfile.newsletter`), and a public signup surface is
+ explicitly deferred — [app-shell.md:140](../specs/behaviors/app-shell.md)
+ lists "Newsletter signup (defer)" in the footer's Connect column, and
+ [deferred.md:100-104](../specs/deferred.md) defers the whole newsletter
+ sending pipeline with a promotion path (`/api/newsletter/send`, Resend-backed
+ worker, unsubscribe tokens). Building an anonymous-capture CTA ahead of that
+ spec would invent unspecified behavior. `Home.tsx` is deliberately untouched
+ here; the CTA swap should follow the newsletter spec work, not precede it.
+
+- Issue [#162](https://github.com/CodeForPhilly/codeforphilly-ng/issues/162) — make
+ the `apps/api` test fixtures cross-platform. The `/dev/null` failure-injection
+ idiom (and whatever the other seven failures share) makes the API suite
+ unrunnable on a Windows dev box, so the documented validation gate can only be
+ completed on Linux or in CI. See Notes for the mechanism.
+
+- Issue [#163](https://github.com/CodeForPhilly/codeforphilly-ng/issues/163) — the
+ signed-in header (avatar + full name + caret) still overflows the 768–1023px
+ band; predates this plan and needs a breakpoint the shared `AuthControls`
+ can apply to the desktop cluster only.
diff --git a/specs/architecture.md b/specs/architecture.md
index 1dbff64..d80b0bc 100644
--- a/specs/architecture.md
+++ b/specs/architecture.md
@@ -135,9 +135,9 @@ The web and api share Zod schemas for every request/response shape and every rec
The "no moving pieces" promise: a contributor needs git, Node, and two clones. No Docker compose, no database to install, no migrations to run.
```bash
-git clone https://github.com/CodeForPhilly/codeforphilly-rewrite.git
+git clone https://github.com/CodeForPhilly/codeforphilly-ng.git
git clone https://github.com/CodeForPhilly/codeforphilly-data-snapshot.git ../codeforphilly-data
-cd codeforphilly-rewrite
+cd codeforphilly-ng
npm install
npm run dev # api + web concurrently with watch
```
diff --git a/specs/behaviors/app-shell.md b/specs/behaviors/app-shell.md
index 67659f3..95c0b52 100644
--- a/specs/behaviors/app-shell.md
+++ b/specs/behaviors/app-shell.md
@@ -35,16 +35,31 @@ Sticky at the top of the viewport. Background opaque, slight shadow on scroll.
### Center / right at ≥ md
-Primary nav, items in this order:
+Two clusters. The **content cluster** sits next to the logo; the **utility
+cluster** is pinned to the right edge and carries the outbound link, search,
+auth, and the call to action.
+
+Content cluster, items in this order:
| Item | Target | Style |
| ---- | ------ | ----- |
| Projects | `/projects` | text link |
| Help Wanted | `/help-wanted` | text link |
| Members | `/members` | text link |
-| Volunteer | `/volunteer` | button (success, filled) — emphasized because it's the call to action |
| About ▾ | dropdown | text link with caret |
+
+Utility cluster, items in this order (left to right):
+
+| Item | Target | Style |
+| ---- | ------ | ----- |
+| GitHub | `https://github.com/CodeForPhilly` | icon-only external link, accessible name "Code for Philly on GitHub", opens in a new tab |
| Search 🔍 | inline expand | icon button |
+| Sign in / account menu | see [Auth controls](#auth-controls) | button / avatar dropdown |
+| Volunteer | `/volunteer` | button (success, filled) — emphasized because it's the call to action |
+
+**Volunteer is the rightmost element in the header.** It sits after the auth
+control rather than among the content links so the call to action reads as the
+header's terminal step, not as one more section.
### About dropdown
@@ -57,7 +72,11 @@ Primary nav, items in this order:
The `/pages/*` URLs serve **static content pages** authored as MDX/Markdown in the code repo (`apps/web/src/content/pages/`). They have no per-page screen spec — the content is the spec. Source copy ports from `codeforphilly.org/site-root/pages/` in the legacy repo.
-### Auth controls (rightmost)
+### Auth controls
+
+Second from the right in the utility cluster — between Search and the Volunteer
+button. On mobile the auth control sits in the header bar itself, outside the
+sheet.
- **Anonymous:** "Sign in" (primary button) → `/login`. There is no separate "Sign up" button — sign-in and sign-up are the same flow once GitHub OAuth is specified (first sign-in creates the account).
- **User:** Avatar + name dropdown:
@@ -75,7 +94,9 @@ The `/pages/*` URLs serve **static content pages** authored as MDX/Markdown in t
### Mobile (< md)
-Header collapses to: logo + hamburger menu + auth control. Hamburger opens a sheet (right-side overlay) with all nav items stacked vertically. Search is inside the sheet, not inline.
+Header collapses to: logo + hamburger menu + auth control. Hamburger opens a sheet (right-side overlay) with all nav items stacked vertically — the content-cluster links, the About items under an "About" label, the GitHub link, and Volunteer last, mirroring the desktop order. Search is inside the sheet, not inline. The auth control stays in the header bar, outside the sheet.
+
+Every item in the sheet closes the sheet when activated.
## Search
@@ -168,6 +189,9 @@ It does not block initial paint waiting on `me`. Auth controls render skeletons
- Skip link at the very top: "Skip to main content" → focuses the `` element
- All dropdowns are keyboard-navigable
- The mobile sheet traps focus while open and returns it to the trigger on close
+- The mobile sheet is a dialog with the accessible name "Menu"
+- At ≥ md the header exposes two navigation landmarks: "Primary navigation" (the content cluster) and "Utility" (GitHub, search, auth, Volunteer)
+- Every icon-only control carries an accessible name; controls with visible text use that text as their accessible name rather than duplicating it in a label
## Print
diff --git a/specs/behaviors/storage.md b/specs/behaviors/storage.md
index 334668e..b5d8a3f 100644
--- a/specs/behaviors/storage.md
+++ b/specs/behaviors/storage.md
@@ -84,7 +84,7 @@ A scrubbed snapshot of the data repo is published as a public tag (e.g., `snapsh
An `apps/api/scripts/scrub-data.ts` in the code repo produces the snapshot. The contributor bootstrap is:
```bash
-git clone https://github.com/CodeForPhilly/codeforphilly-rewrite.git
+git clone https://github.com/CodeForPhilly/codeforphilly-ng.git
git clone --bare https://github.com/CodeForPhilly/codeforphilly-data-snapshot.git ../codeforphilly-data
npm install
npm run dev # api + web boot, data already there
diff --git a/specs/screens/volunteer.md b/specs/screens/volunteer.md
index 661925d..2bf03e1 100644
--- a/specs/screens/volunteer.md
+++ b/specs/screens/volunteer.md
@@ -27,7 +27,7 @@ Three cards in a row at ≥ md, stacked below:
1. **Join Slack** — "We coordinate everything in our Slack workspace." Button: "Open Slack →" → `/chat`
2. **Pick a project** — "Browse 268 active projects and find one that matches your interests." Button: "Browse projects →" → `/projects`
-3. **Show up to meetups** — "We meet weekly. Bring your laptop, or just yourself." Button: "When we meet →" → external link (currently the GitBook hack-night-program-details URL)
+3. **Show up to meetups** — "We meet weekly. Bring your laptop, or just yourself." Button: "When we meet →" → external link to the Meetup group (`https://www.meetup.com/Code-for-Philly/`), where upcoming hack nights are listed. Same target as the footer's Meetup social link.
The "268" is read from a cheap call to `GET /api/projects?perPage=1` and rendered live; falls back to "hundreds of" if the call fails.
@@ -48,7 +48,7 @@ Static content emphasizing the non-developer roles (designers, project managers,
Footer-style band at the bottom: "Have an idea? Start your own project."
-- Link to the external GitBook "creating-new-partnerships/first-steps" page (matches current codeforphilly.org)
+- Link to the external "first steps" partnership guide (`https://github.com/CodeForPhilly/partnerships/blob/master/creating-new-partnerships/first-steps.md`) — the surviving canonical source of the retired GitBook page of the same name
- Secondary link "or create one on the site →" to `/projects/create` (signed-in) or `/login?return=/projects/create` (anonymous)
## Actions