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 `` (shadcn) listing all seven stages with their canonical descriptions from [behaviors/project-stages.md](../specs/behaviors/project-stages.md). Highlights the project's current stage. + +Stage descriptions go into a small constant in `apps/web/src/lib/project-stages.ts` (or wherever `StageBadge` lives) so they stay co-located with rendering. + +### 4. /contact + +Replace `` at the `/contact` route with a simple page rendering a heading + `mailto:hello@codeforphilly.org` link, matching the existing `Sponsor`/static page styling. + +## 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. + +## Risks / unknowns + +- **`navigator.clipboard.writeText` availability.** Modern browsers all support it in secure contexts; the existing Copy link button already uses it, so no new risk. +- **Stage modal copy drift.** The descriptions are duplicated between the spec and the SPA constant. If they diverge, the modal would lie. Mitigation: the constant cites the spec section in a comment so future-me knows where the source of truth lives. + +## Notes + +_(filled at done time)_ + +## Follow-ups + +_(filled at done time)_ From 921539f3b397bb80e84d9c2c17f76465b8a51e04 Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Sat, 30 May 2026 11:03:49 -0400 Subject: [PATCH 2/4] feat(web): ProjectDetail Share-to-Slack, stage modal, Edit-on-GitHub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes three spec gaps on the project detail screen (specs/screens/project-detail.md): - **Share to Slack** button next to Copy link. Copies a pre-formatted message to the clipboard ("Check out on Code for Philly: <url>"). Spec offers either system-share or copy-message; copy works in every browser without a Web Share API gate. - **"What does this stage mean?"** link in the Info sidebar opens a StageInfoDialog listing all seven stages with their canonical descriptions (already in STAGES, sourced from specs/behaviors/project-stages.md). Current stage highlighted. - **Edit on GitHub** footer link, shown only when developersUrl is a github.com URL. Small, muted styling per spec. The new StageInfoDialog component reads from the existing STAGES constant — descriptions stay co-located with rendering rather than being duplicated. 4 new tests: Share-to-Slack button presence, stage-info link presence, Edit-on-GitHub absent for non-github developersUrl, Edit-on-GitHub present + href correct for github developersUrl. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- apps/web/src/components/StageInfoDialog.tsx | 70 ++++++++++++++++ apps/web/src/screens/ProjectDetail.tsx | 53 ++++++++++++ apps/web/tests/ProjectDetail.test.tsx | 92 +++++++++++++++++++++ 3 files changed, 215 insertions(+) create mode 100644 apps/web/src/components/StageInfoDialog.tsx 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 ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-xl"> + <DialogHeader> + <DialogTitle>What does each stage mean?</DialogTitle> + <DialogDescription> + Every project carries one of seven lifecycle stages. See{' '} + <span className="italic">specs/behaviors/project-stages.md</span> for the canonical + source. + </DialogDescription> + </DialogHeader> + + <ul className="space-y-3 mt-2"> + {ORDERED_STAGES.map((s) => { + const meta = STAGES[s]; + const isCurrent = s === currentStage; + return ( + <li + key={s} + className={cn( + 'rounded-md border px-3 py-2', + isCurrent + ? 'border-primary bg-primary/5' + : 'border-border bg-card', + )} + > + <div className="flex items-baseline gap-2"> + <span className="font-semibold">{meta.label}</span> + {isCurrent && ( + <span className="text-xs text-primary">(this project)</span> + )} + </div> + <p className="text-sm text-muted-foreground">{meta.description}</p> + </li> + ); + })} + </ul> + </DialogContent> + </Dialog> + ); +} 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<HelpWantedRoleResponse | null>(null); const [fillRole, setFillRole] = useState<HelpWantedRoleResponse | null>(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 </Button> + <Button + variant="outline" + onClick={() => { + // 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}`, + ); + }} + > + Share to Slack + </Button> </div> </section> @@ -424,10 +448,39 @@ export function ProjectDetail({ anchor }: ProjectDetailProps = {}) { <span className="font-medium text-foreground">Stage:</span> <StageBadge stage={project.stage} /> </p> + <p> + <button + type="button" + onClick={() => setStageInfoOpen(true)} + className="text-primary underline hover:no-underline" + > + What does this stage mean? + </button> + </p> </section> + + {/* Footer link — Edit on GitHub when developersUrl is a github.com URL */} + {project.links.developersUrl && isGithubUrl(project.links.developersUrl) && ( + <section className="text-xs text-muted-foreground pt-2 border-t border-border"> + <a + href={project.links.developersUrl} + target="_blank" + rel="noopener noreferrer" + className="hover:text-foreground" + > + Edit on GitHub → + </a> + </section> + )} </aside> </div> + <StageInfoDialog + open={stageInfoOpen} + onOpenChange={setStageInfoOpen} + currentStage={project.stage} + /> + <PostUpdateModal open={updateModalOpen} onOpenChange={setUpdateModalOpen} 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( + <AuthProvider> + <Routes> + <Route path="/projects/:slug" element={<ProjectDetail />} /> + </Routes> + </AuthProvider>, + { 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( + <AuthProvider> + <Routes> + <Route path="/projects/:slug" element={<ProjectDetail />} /> + </Routes> + </AuthProvider>, + { 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( + <AuthProvider> + <Routes> + <Route path="/projects/:slug" element={<ProjectDetail />} /> + </Routes> + </AuthProvider>, + { 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( + <AuthProvider> + <Routes> + <Route path="/projects/:slug" element={<ProjectDetail />} /> + </Routes> + </AuthProvider>, + { 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 <chris@jarv.us> 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) <noreply@anthropic.com> --- 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: <Account /> }, { path: '/search', element: <SearchRedirect /> }, { path: '/pages/:slug', element: <ComingSoon /> }, - { path: '/contact', element: <ComingSoon /> }, + { path: '/contact', element: <Contact /> }, { path: '/login', element: <LoginPlaceholder /> }, { path: '/account-claim', element: <AccountClaim /> }, { path: '/account-claim/by-password', element: <AccountClaimByPassword /> }, 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 ( + <div className="container mx-auto px-4 py-12 max-w-2xl"> + <h1 className="text-3xl font-bold mb-4">Contact</h1> + <p className="text-muted-foreground leading-relaxed"> + Email us at{' '} + <a + href="mailto:hello@codeforphilly.org" + className="text-primary underline hover:no-underline" + > + hello@codeforphilly.org + </a> + . + </p> + <p className="text-muted-foreground mt-4"> + For real-time chat, join our{' '} + <a href="/chat" className="text-primary underline hover:no-underline"> + Slack workspace + </a> + . + </p> + </div> + ); +} 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(<Contact />, { 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(<Contact />, { 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 <chris@jarv.us> 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) <noreply@anthropic.com> --- 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 `<ComingSoon />` 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 `<ComingSoon />` 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.