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/components/StageInfoDialog.tsx b/apps/web/src/components/StageInfoDialog.tsx
new file mode 100644
index 0000000..5260a28
--- /dev/null
+++ b/apps/web/src/components/StageInfoDialog.tsx
@@ -0,0 +1,70 @@
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { STAGES, type Stage } from '@/components/StageBadge';
+import { cn } from '@/lib/utils';
+
+interface StageInfoDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ /** The project's current stage; highlighted in the list. */
+ currentStage?: string;
+}
+
+// Stage order matches the rank from specs/behaviors/project-stages.md.
+const ORDERED_STAGES: readonly Stage[] = [
+ 'commenting',
+ 'bootstrapping',
+ 'prototyping',
+ 'testing',
+ 'maintaining',
+ 'drifting',
+ 'hibernating',
+];
+
+export function StageInfoDialog({ open, onOpenChange, currentStage }: StageInfoDialogProps) {
+ return (
+
+ );
+}
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/src/screens/ProjectDetail.tsx b/apps/web/src/screens/ProjectDetail.tsx
index 4347cd7..7a27e7f 100644
--- a/apps/web/src/screens/ProjectDetail.tsx
+++ b/apps/web/src/screens/ProjectDetail.tsx
@@ -4,6 +4,7 @@ import { useQuery } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { MarkdownView } from '@/components/MarkdownView';
import { StageProgressBar, StageBadge } from '@/components/StageBadge';
+import { StageInfoDialog } from '@/components/StageInfoDialog';
import { TagChip } from '@/components/TagChip';
import { PersonAvatar } from '@/components/PersonAvatar';
import { ActivityCard, mergeActivity, type ActivityItem } from '@/components/ActivityCard';
@@ -26,6 +27,15 @@ function commitmentLabel(hours: number | null): string {
return `~${hours} hrs/week`;
}
+function isGithubUrl(url: string): boolean {
+ try {
+ const u = new URL(url);
+ return u.hostname === 'github.com' || u.hostname.endsWith('.github.com');
+ } catch {
+ return false;
+ }
+}
+
export function ProjectDetail({ anchor }: ProjectDetailProps = {}) {
const params = useParams();
const slug = params['slug']!;
@@ -40,6 +50,7 @@ export function ProjectDetail({ anchor }: ProjectDetailProps = {}) {
const [manageMembersOpen, setManageMembersOpen] = useState(false);
const [interestRole, setInterestRole] = useState(null);
const [fillRole, setFillRole] = useState(null);
+ const [stageInfoOpen, setStageInfoOpen] = useState(false);
// Allow ?openModal=help-wanted (from /help-wanted "Post a role" picker).
// Use the state-sync pattern so we don't trigger a cascading re-render.
@@ -403,6 +414,19 @@ export function ProjectDetail({ anchor }: ProjectDetailProps = {}) {
>
Copy link
+
@@ -424,10 +448,39 @@ export function ProjectDetail({ anchor }: ProjectDetailProps = {}) {
Stage:
+
+
+
+
+ {/* Footer link — Edit on GitHub when developersUrl is a github.com URL */}
+ {project.links.developersUrl && isGithubUrl(project.links.developersUrl) && (
+
+ )}
+
+
{
+ 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');
+ });
+});
diff --git a/apps/web/tests/ProjectDetail.test.tsx b/apps/web/tests/ProjectDetail.test.tsx
index 827b417..d0e4027 100644
--- a/apps/web/tests/ProjectDetail.test.tsx
+++ b/apps/web/tests/ProjectDetail.test.tsx
@@ -99,4 +99,96 @@ describe('ProjectDetail', () => {
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',
+ );
+ });
});
diff --git a/plans/screen-gaps-phase1.md b/plans/screen-gaps-phase1.md
new file mode 100644
index 0000000..969b6a6
--- /dev/null
+++ b/plans/screen-gaps-phase1.md
@@ -0,0 +1,145 @@
+---
+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
+
+## 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 `