From 9596320e178212dade6f5d2c14a500591f8d0d0b Mon Sep 17 00:00:00 2001
From: Chris Alfano
Date: Sat, 30 May 2026 11:00:35 -0400
Subject: [PATCH 1/4] chore(plans): open screen-gaps-phase1 (in-progress)
#83 is an umbrella for spec-vs-implementation gaps across several
detail screens. Splitting into phased plans so reviewers aren't
sitting on a single 1000-line PR. This phase covers SPA-only quick
wins on ProjectDetail (Share-to-Slack button, Edit-on-GitHub footer
link, "What does this stage mean?" modal) and the /contact mailto
page. PersonDetail email/slackHandle, /pages/:slug content, and the
buzz-new form get their own plans.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
plans/screen-gaps-phase1.md | 101 ++++++++++++++++++++++++++++++++++++
1 file changed, 101 insertions(+)
create mode 100644 plans/screen-gaps-phase1.md
diff --git a/plans/screen-gaps-phase1.md b/plans/screen-gaps-phase1.md
new file mode 100644
index 0000000..9a60b6f
--- /dev/null
+++ b/plans/screen-gaps-phase1.md
@@ -0,0 +1,101 @@
+---
+status: in-progress
+depends: []
+specs:
+ - specs/screens/project-detail.md
+ - specs/behaviors/project-stages.md
+ - specs/behaviors/app-shell.md
+issues: [83]
+---
+
+# Plan: screen-gaps phase 1 — ProjectDetail + /contact wins
+
+## Scope
+
+[#83](https://github.com/CodeForPhilly/codeforphilly-ng/issues/83) is an
+umbrella covering several spec-vs-implementation gaps across detail
+screens. This plan closes the **SPA-only** quick wins so reviewers
+aren't sitting on a single 1000-line PR:
+
+1. **ProjectDetail "Share to Slack" button** — adds a button next to
+ the existing "Copy link" that copies a pre-formatted Slack message
+ to the clipboard (project title + URL).
+2. **ProjectDetail "Edit on GitHub" footer link** — shown when
+ `developersUrl` is a github.com URL. Small, muted link.
+3. **ProjectDetail "What does this stage mean?" link + modal** —
+ beside the Stage row in the Info sidebar; opens a dialog with the
+ canonical descriptions from [behaviors/project-stages.md](../specs/behaviors/project-stages.md).
+4. **`/contact` mailto link** — replaces the ``
+ placeholder with the minimum the spec requires (`mailto:hello@codeforphilly.org`).
+
+The Home "Start a Project" routing for signed-in users is **already in
+place** (`apps/web/src/screens/Home.tsx:134`) — no work needed there
+despite the audit listing it.
+
+Out of scope for this phase (separate plans):
+
+- **PersonDetail `email` + `slackHandle`** — needs a serializer
+ change to surface `PrivateProfile.email` for self/staff and `Person.slackHandle`
+ for everyone. Cross-cuts backend + frontend; treated as its own plan.
+- **`/pages/:slug` content-files**
+ ([behaviors/app-shell.md](../specs/behaviors/app-shell.md) Mission/Leadership/CoC/Hackathons)
+ — needs `apps/web/src/content/pages/` directory with markdown files
+ and the route to read+render. Treated as its own plan.
+- **`/projects/:slug/buzz/new` create form** — needs a real form
+ hooked into `POST /api/projects/:slug/buzz`. Treated as its own plan.
+
+Closes only the SPA quick-wins of [#83](https://github.com/CodeForPhilly/codeforphilly-ng/issues/83).
+Each of the deferred pieces gets its own plan.
+
+## Implements
+
+- [screens/project-detail.md](../specs/screens/project-detail.md) — Share/Info sidebar items + footer "Edit on GitHub" link.
+- [behaviors/project-stages.md](../specs/behaviors/project-stages.md) — modal renders the canonical descriptions.
+- [behaviors/app-shell.md](../specs/behaviors/app-shell.md) — `/contact` minimum is the mailto link.
+
+## Approach
+
+### 1. Share to Slack
+
+A second button in the existing Share sidebar (line 392-407 of `ProjectDetail.tsx`). On click, copies a pre-formatted message to the clipboard:
+
+```text
+Check out on Code for Philly: https://codeforphilly.org/projects/
+```
+
+Per spec: "opens a system share or copies a pre-formatted Slack message". Copying is the simpler shape and works in every browser without a native share API.
+
+### 2. Edit on GitHub
+
+Below the Info sidebar (the spec says "Footer"), but visually it fits at the bottom of the sidebar — a small muted link. Render only when `project.links.developersUrl` matches `https://github.com/...`. URL: same as `developersUrl`.
+
+### 3. Stage modal
+
+Beside the "Stage:" row in the Info sidebar, a "What does this stage mean?" link button opens a `
+
+
+
+
+ {/* Footer link — Edit on GitHub when developersUrl is a github.com URL */}
+ {project.links.developersUrl && isGithubUrl(project.links.developersUrl) && (
+
+
+ Edit on GitHub →
+
+
+ )}
+
+
{
const chatLink = screen.getByRole('link', { name: /chat channel/i });
expect(chatLink).toHaveAttribute('href', '/chat?channel=sample');
});
+
+ it('renders the Share to Slack button alongside Copy link', async () => {
+ renderScreen(
+
+
+ } />
+
+ ,
+ { initialEntries: ['/projects/sample-project'] },
+ );
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: /^copy link$/i })).toBeInTheDocument();
+ });
+ expect(screen.getByRole('button', { name: /share to slack/i })).toBeInTheDocument();
+ });
+
+ it('renders "What does this stage mean?" link', async () => {
+ renderScreen(
+
+
+ } />
+
+ ,
+ { initialEntries: ['/projects/sample-project'] },
+ );
+
+ await waitFor(() => {
+ expect(
+ screen.getByRole('button', { name: /what does this stage mean\?/i }),
+ ).toBeInTheDocument();
+ });
+ });
+
+ it('does not render Edit on GitHub when developersUrl is absent', async () => {
+ renderScreen(
+
+
+ } />
+
+ ,
+ { initialEntries: ['/projects/sample-project'] },
+ );
+
+ await waitFor(() => {
+ expect(screen.getByRole('heading', { name: 'Sample Project', level: 1 })).toBeInTheDocument();
+ });
+ expect(screen.queryByRole('link', { name: /edit on github/i })).not.toBeInTheDocument();
+ });
+
+ it('renders Edit on GitHub when developersUrl is a github.com URL', async () => {
+ // Override the fetch mock for this case to add a github developersUrl.
+ vi.spyOn(globalThis, 'fetch').mockImplementation(((input: string) => {
+ if (input.startsWith('/api/auth/me')) return Promise.resolve(new Response(null, { status: 404 }));
+ if (input.includes('/api/projects/sample-project/updates')) {
+ return Promise.resolve(new Response(JSON.stringify(mockPaginated([])), { status: 200, headers: { 'content-type': 'application/json' } }));
+ }
+ if (input.includes('/api/projects/sample-project/buzz')) {
+ return Promise.resolve(new Response(JSON.stringify(mockPaginated([])), { status: 200, headers: { 'content-type': 'application/json' } }));
+ }
+ if (input.includes('/api/projects/sample-project/help-wanted')) {
+ return Promise.resolve(new Response(JSON.stringify(mockPaginated([])), { status: 200, headers: { 'content-type': 'application/json' } }));
+ }
+ if (input.startsWith('/api/projects/sample-project')) {
+ const projectWithGithub = {
+ ...PROJECT,
+ links: { ...PROJECT.links, developersUrl: 'https://github.com/codeforphilly/sample-project' },
+ };
+ return Promise.resolve(
+ new Response(JSON.stringify(mockOk(projectWithGithub)), { status: 200, headers: { 'content-type': 'application/json' } }),
+ );
+ }
+ return Promise.resolve(new Response(null, { status: 404 }));
+ }) as typeof fetch);
+
+ renderScreen(
+
+
+ } />
+
+ ,
+ { initialEntries: ['/projects/sample-project'] },
+ );
+
+ await waitFor(() => {
+ expect(screen.getByRole('link', { name: /edit on github/i })).toBeInTheDocument();
+ });
+ expect(screen.getByRole('link', { name: /edit on github/i })).toHaveAttribute(
+ 'href',
+ 'https://github.com/codeforphilly/sample-project',
+ );
+ });
});
From 791546718e302128712e9f65d86ce2ddccab24af Mon Sep 17 00:00:00 2001
From: Chris Alfano
Date: Sat, 30 May 2026 11:03:56 -0400
Subject: [PATCH 3/4] feat(web): /contact renders a real page (mailto + Slack
link)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Replaces the ComingSoon placeholder at /contact with the minimum
specs/behaviors/app-shell.md requires — a mailto link to
hello@codeforphilly.org and a pointer to the /chat redirect for
real-time conversation.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
apps/web/src/App.tsx | 3 ++-
apps/web/src/pages/Contact.tsx | 24 ++++++++++++++++++++++++
apps/web/tests/Contact.test.tsx | 19 +++++++++++++++++++
3 files changed, 45 insertions(+), 1 deletion(-)
create mode 100644 apps/web/src/pages/Contact.tsx
create mode 100644 apps/web/tests/Contact.test.tsx
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index 91e557d..07cec08 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -24,6 +24,7 @@ import { TagDetail } from '@/screens/TagDetail';
import { Volunteer } from '@/screens/Volunteer';
import { Sponsor } from '@/screens/Sponsor';
import { ComingSoon } from '@/pages/ComingSoon';
+import { Contact } from '@/pages/Contact';
import { NotFound } from '@/pages/NotFound';
import { LoginPlaceholder } from '@/pages/LoginPlaceholder';
import { AccountClaim } from '@/pages/AccountClaim';
@@ -61,7 +62,7 @@ const router = createBrowserRouter([
{ path: '/account', element: },
{ path: '/search', element: },
{ path: '/pages/:slug', element: },
- { path: '/contact', element: },
+ { path: '/contact', element: },
{ path: '/login', element: },
{ path: '/account-claim', element: },
{ path: '/account-claim/by-password', element: },
diff --git a/apps/web/src/pages/Contact.tsx b/apps/web/src/pages/Contact.tsx
new file mode 100644
index 0000000..aac7da3
--- /dev/null
+++ b/apps/web/src/pages/Contact.tsx
@@ -0,0 +1,24 @@
+export function Contact() {
+ return (
+
+ );
+}
diff --git a/apps/web/tests/Contact.test.tsx b/apps/web/tests/Contact.test.tsx
new file mode 100644
index 0000000..d6ca095
--- /dev/null
+++ b/apps/web/tests/Contact.test.tsx
@@ -0,0 +1,19 @@
+import { describe, expect, it } from 'vitest';
+import { screen } from '@testing-library/react';
+import { renderScreen } from './test-utils.js';
+import { Contact } from '../src/pages/Contact.js';
+
+describe('Contact', () => {
+ it('renders a mailto link to hello@codeforphilly.org', () => {
+ renderScreen(, { initialEntries: ['/contact'] });
+ expect(screen.getByRole('heading', { name: /^contact$/i })).toBeInTheDocument();
+ const mailtoLink = screen.getByRole('link', { name: /hello@codeforphilly\.org/i });
+ expect(mailtoLink).toHaveAttribute('href', 'mailto:hello@codeforphilly.org');
+ });
+
+ it('links to /chat for real-time chat', () => {
+ renderScreen(, { initialEntries: ['/contact'] });
+ const slackLink = screen.getByRole('link', { name: /slack workspace/i });
+ expect(slackLink).toHaveAttribute('href', '/chat');
+ });
+});
From 4b58e29a988a170bc56ce6f5616b9b4223dd38a1 Mon Sep 17 00:00:00 2001
From: Chris Alfano
Date: Sat, 30 May 2026 11:23:19 -0400
Subject: [PATCH 4/4] chore(plans): close out screen-gaps-phase1 (PR #102)
All 5 validation checkboxes ticked. Notes covers the STAGES-as-source-
of-truth observation, the Home audit-was-outdated note, the URL-based
GitHub detection rationale, and the copy-only-not-Web-Share choice.
Follow-ups split out the deferred #83 work into named plans: PersonDetail
email/slackHandle (phase 2), /pages/:slug content rendering (static-pages),
and /projects/:slug/buzz/new form (buzz-new-form).
Co-Authored-By: Claude Opus 4.7 (1M context)
---
plans/screen-gaps-phase1.md | 60 ++++++++++++++++++++++++++++++++-----
1 file changed, 52 insertions(+), 8 deletions(-)
diff --git a/plans/screen-gaps-phase1.md b/plans/screen-gaps-phase1.md
index 9a60b6f..969b6a6 100644
--- a/plans/screen-gaps-phase1.md
+++ b/plans/screen-gaps-phase1.md
@@ -1,11 +1,12 @@
---
-status: in-progress
+status: done
depends: []
specs:
- specs/screens/project-detail.md
- specs/behaviors/project-stages.md
- specs/behaviors/app-shell.md
issues: [83]
+pr: 102
---
# Plan: screen-gaps phase 1 — ProjectDetail + /contact wins
@@ -81,11 +82,11 @@ Replace `` at the `/contact` route with a simple page rendering a
## Validation
-- [ ] ProjectDetail renders both "Copy link" and "Share to Slack" buttons.
-- [ ] ProjectDetail renders "Edit on GitHub" only when `developersUrl` is a github.com URL.
-- [ ] "What does this stage mean?" opens a modal listing all seven stages with descriptions, highlighting the current stage.
-- [ ] `/contact` is no longer a ComingSoon page — renders mailto link.
-- [ ] `npm run type-check && npm run lint && npm test` clean.
+- [x] ProjectDetail renders both "Copy link" and "Share to Slack" buttons.
+- [x] ProjectDetail renders "Edit on GitHub" only when `developersUrl` is a github.com URL.
+- [x] "What does this stage mean?" opens a modal listing all seven stages with descriptions, highlighting the current stage.
+- [x] `/contact` is no longer a ComingSoon page — renders mailto link.
+- [x] `npm run type-check && npm run lint && npm test` clean.
## Risks / unknowns
@@ -94,8 +95,51 @@ Replace `` at the `/contact` route with a simple page rendering a
## Notes
-_(filled at done time)_
+Three commits: plan-open, ProjectDetail enhancements, Contact page.
+
+Surprises:
+
+- **Stage descriptions already in `STAGES` constant.** `StageBadge.tsx`
+ exports a `STAGES` record that already mirrors
+ `specs/behaviors/project-stages.md` exactly — labels, descriptions,
+ ranks, progress percentages, colors. The new modal just reads from
+ this constant rather than introducing a parallel copy. The spec→
+ code drift risk is bounded: any update to the spec only needs to
+ touch one place in the SPA.
+- **Home "Start a Project" was already correct.** The #83 audit
+ listed it as a gap (the card should route signed-in users to
+ `/projects/create`), but the code at `apps/web/src/screens/Home.tsx:134`
+ already does exactly that. Marked the audit note as outdated in
+ the plan rather than removing it — useful context for future-me
+ to understand why no Home change appears in this PR.
+- **GitHub URL detection via `new URL()`.** The naive
+ `developersUrl.startsWith('https://github.com/')` check would miss
+ `http://github.com/...` and accept `https://github.com.evil.com`.
+ `URL.parse` + hostname check (`'github.com' || endsWith('.github.com')`)
+ is the safer shape.
+- **Web Share API not used.** Spec said "system share or copy". I
+ considered `navigator.share` (gated by capability detection +
+ fallback to copy) but the copy-only path is simpler, works in
+ every browser, and the difference is invisible to users on
+ desktop. If mobile usage starts mattering, swap in the Share API
+ with copy as fallback.
## Follow-ups
-_(filled at done time)_
+- **Phase 2 — PersonDetail email + slackHandle.** Cross-cuts the API
+ serializer (`apps/api/src/services/serializers/person.ts` needs to
+ surface `PrivateProfile.email` for self/staff and `Person.slackHandle`
+ for everyone) and the screen. Separate plan because it touches the
+ permissions model. *Deferred to plan* — `plans/screen-gaps-phase2.md`
+ to-be-written.
+- **Phase 3 — `/pages/:slug` content rendering.** Requires
+ `apps/web/src/content/pages/` directory with markdown for Mission,
+ Leadership, CoC, Hackathons + a route that reads + renders them.
+ *Deferred to plan* — `plans/static-pages.md` to-be-written.
+- **Phase 4 — `/projects/:slug/buzz/new` create form.** Needs a real
+ form bound to `POST /api/projects/:slug/buzz`. The API endpoint
+ exists; just the SPA form is missing. *Deferred to plan* —
+ `plans/buzz-new-form.md` to-be-written.
+- **Web Share API integration.** The Share-to-Slack button could
+ use `navigator.share` on mobile with copy fallback. *None* — not
+ worth the gate logic until mobile usage data warrants it.