diff --git a/apps/web/package.json b/apps/web/package.json
index 42c3189..63418c2 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -29,6 +29,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.16.0",
+ "marked": "^18.0.4",
"radix-ui": "^1.4.3",
"react": "^19.2.6",
"react-dom": "^19.2.6",
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index 07cec08..2102113 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -25,6 +25,7 @@ import { Volunteer } from '@/screens/Volunteer';
import { Sponsor } from '@/screens/Sponsor';
import { ComingSoon } from '@/pages/ComingSoon';
import { Contact } from '@/pages/Contact';
+import { StaticPage } from '@/pages/StaticPage';
import { NotFound } from '@/pages/NotFound';
import { LoginPlaceholder } from '@/pages/LoginPlaceholder';
import { AccountClaim } from '@/pages/AccountClaim';
@@ -61,7 +62,7 @@ const router = createBrowserRouter([
{ path: '/sponsor', element: },
{ path: '/account', element: },
{ path: '/search', element: },
- { path: '/pages/:slug', element: },
+ { path: '/pages/:slug', element: },
{ path: '/contact', element: },
{ path: '/login', element: },
{ path: '/account-claim', element: },
diff --git a/apps/web/src/content/pages/code-of-conduct.md b/apps/web/src/content/pages/code-of-conduct.md
new file mode 100644
index 0000000..e1c2219
--- /dev/null
+++ b/apps/web/src/content/pages/code-of-conduct.md
@@ -0,0 +1,36 @@
+# Code of Conduct
+
+Code for Philly is committed to providing a welcoming, inclusive
+experience for everyone who participates in our community —
+regardless of background, identity, or experience level.
+
+## Expected behavior
+
+- **Be respectful.** Disagreement on ideas is fine; personal attacks
+ are not.
+- **Be inclusive.** Use language that welcomes people from different
+ backgrounds; avoid jargon when introducing yourself and your work.
+- **Be patient with newcomers.** Everyone was new once.
+- **Give credit.** Acknowledge others' contributions, especially in
+ team projects.
+
+## Unacceptable behavior
+
+- Harassment, discrimination, or exclusionary jokes targeting any
+ protected class.
+- Unwanted sexual attention or advances.
+- Doxxing or sharing others' private information without consent.
+- Sustained disruption of meetings, channels, or events.
+
+## Reporting
+
+If you witness or experience a violation, please contact the
+leadership team at
+[hello@codeforphilly.org](mailto:hello@codeforphilly.org). Reports
+will be handled discreetly and acted on quickly.
+
+---
+
+*This page is awaiting its full content port from the legacy site.
+See [issue tracking](https://github.com/CodeForPhilly/codeforphilly-ng/issues/83)
+for status.*
diff --git a/apps/web/src/content/pages/hackathons.md b/apps/web/src/content/pages/hackathons.md
new file mode 100644
index 0000000..cd59b9d
--- /dev/null
+++ b/apps/web/src/content/pages/hackathons.md
@@ -0,0 +1,35 @@
+# Hackathons
+
+Code for Philly hosts and co-hosts hackathons throughout the year —
+weekend-long events where volunteers form teams around civic-tech
+ideas, prototype together, and demo to the community.
+
+## What to expect
+
+- **Open to all.** No prior experience required. We pair newcomers
+ with mentors so everyone can contribute meaningfully.
+- **Real-world themes.** Each event picks a focus — housing,
+ transit, public health, accessibility — sourced from community
+ partners with actual problems to solve.
+- **Free.** Sponsors cover venue, food, and prizes. Bring a laptop.
+
+## Recent events
+
+We're rebuilding this section as we relaunch the directory. For an
+up-to-date calendar, watch the
+[#announcements channel in Slack](/chat?channel=announcements) or
+sign up for our newsletter.
+
+## Want to organize one?
+
+Reach out at
+[hello@codeforphilly.org](mailto:hello@codeforphilly.org). We have
+playbooks for venue selection, sponsor outreach, judging panels, and
+post-event followups.
+
+---
+
+*This page is awaiting its full content port from the legacy site,
+including the event archive. See
+[issue tracking](https://github.com/CodeForPhilly/codeforphilly-ng/issues/83)
+for status.*
diff --git a/apps/web/src/content/pages/leadership.md b/apps/web/src/content/pages/leadership.md
new file mode 100644
index 0000000..8e5fa8a
--- /dev/null
+++ b/apps/web/src/content/pages/leadership.md
@@ -0,0 +1,24 @@
+# Leadership
+
+Code for Philly is run by a volunteer leadership team that handles
+the organizational work — fiscal sponsorship, sponsor relationships,
+event logistics, and the shared infrastructure every project depends
+on (this directory, our Slack, our meeting spaces).
+
+Day-to-day project decisions stay with each project's maintainers.
+Leadership exists to make those projects easier to run, not to direct
+them.
+
+## Get in touch
+
+- [Email us](mailto:hello@codeforphilly.org) for partnership,
+ sponsorship, or press inquiries.
+- For operational questions about a specific project, the project's
+ Slack channel is the fastest path.
+
+---
+
+*This page is awaiting its full content port from the legacy site,
+including the current leadership roster. See
+[issue tracking](https://github.com/CodeForPhilly/codeforphilly-ng/issues/83)
+for status.*
diff --git a/apps/web/src/content/pages/mission.md b/apps/web/src/content/pages/mission.md
new file mode 100644
index 0000000..eab3260
--- /dev/null
+++ b/apps/web/src/content/pages/mission.md
@@ -0,0 +1,28 @@
+# Mission
+
+Code for Philly is a civic-tech community building software that
+improves life in the Philadelphia region. We're volunteer-run,
+project-driven, and welcoming to anyone — technologist or not — who
+wants to put their skills toward public good.
+
+## What we do
+
+We organize around **projects** in this directory: groups of volunteers
+collaborating on tools, datasets, and services that fill gaps the
+public sector or private market hasn't reached. Projects pick their
+own scope, tooling, and cadence; we provide the meeting space, the
+collaboration channels, and an audience.
+
+## Where to start
+
+- Browse [projects](/projects) to find one whose mission resonates.
+- Read [Help Wanted](/help-wanted) to see what specific skills active
+ projects are looking for.
+- Join our [Slack workspace](/chat) — every project has a channel,
+ and `#welcome` is the place to introduce yourself.
+
+---
+
+*This page is awaiting its full content port from the legacy site.
+See [issue tracking](https://github.com/CodeForPhilly/codeforphilly-ng/issues/83)
+for status.*
diff --git a/apps/web/src/pages/StaticPage.tsx b/apps/web/src/pages/StaticPage.tsx
new file mode 100644
index 0000000..5c80f14
--- /dev/null
+++ b/apps/web/src/pages/StaticPage.tsx
@@ -0,0 +1,74 @@
+/**
+ * Static content pages — `/pages/:slug`.
+ *
+ * Content lives in `apps/web/src/content/pages/*.md` and is loaded as
+ * raw text at build time via Vite's `import.meta.glob`. We use `marked`
+ * to parse the markdown source on render. This is safe to do client-
+ * side because the content is bundle-time-static — it can't be
+ * influenced by users.
+ *
+ * Per specs/behaviors/app-shell.md → "The /pages/* URLs serve static
+ * content pages authored as MDX/Markdown in the code repo".
+ */
+import { useMemo } from 'react';
+import { useParams } from 'react-router';
+import { marked } from 'marked';
+import { NotFound } from '@/pages/NotFound';
+import { cn } from '@/lib/utils';
+
+// Eagerly load all .md files in the content/pages directory as raw text.
+// Vite turns this into a slug→source map at build time.
+const RAW_PAGES = import.meta.glob('../content/pages/*.md', {
+ query: '?raw',
+ import: 'default',
+ eager: true,
+}) as Record;
+
+// Strip the directory + extension so the map is keyed by slug only.
+const PAGES: Record = Object.fromEntries(
+ Object.entries(RAW_PAGES).map(([path, source]) => {
+ const match = /\/([^/]+)\.md$/.exec(path);
+ return [match?.[1] ?? path, source];
+ }),
+);
+
+// Marked config: GFM features (tables, autolinks) on; no breaks-on-newline
+// because the content is hand-authored markdown, not chat input.
+marked.setOptions({ gfm: true, breaks: false });
+
+export function StaticPage() {
+ const { slug } = useParams<{ slug: string }>();
+ const source = slug ? PAGES[slug] : undefined;
+
+ const html = useMemo(() => {
+ if (!source) return null;
+ // marked.parse() returns string | Promise. With async: false
+ // (the default in v18+ for synchronous extensions), it's a string.
+ return marked.parse(source, { async: false }) as string;
+ }, [source]);
+
+ if (!source || html === null) return ;
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/tests/StaticPage.test.tsx b/apps/web/tests/StaticPage.test.tsx
new file mode 100644
index 0000000..663fd6b
--- /dev/null
+++ b/apps/web/tests/StaticPage.test.tsx
@@ -0,0 +1,57 @@
+import { describe, expect, it } from 'vitest';
+import { screen } from '@testing-library/react';
+import { Routes, Route } from 'react-router';
+import { renderScreen } from './test-utils.js';
+import { StaticPage } from '../src/pages/StaticPage.js';
+
+describe('StaticPage', () => {
+ function renderAt(path: string) {
+ renderScreen(
+
+ } />
+ ,
+ { initialEntries: [path] },
+ );
+ }
+
+ it('renders the Mission page H1', () => {
+ renderAt('/pages/mission');
+ expect(screen.getByRole('heading', { name: /^mission$/i, level: 1 })).toBeInTheDocument();
+ });
+
+ it('renders the Leadership page', () => {
+ renderAt('/pages/leadership');
+ expect(screen.getByRole('heading', { name: /^leadership$/i, level: 1 })).toBeInTheDocument();
+ });
+
+ it('renders the Code of Conduct page', () => {
+ renderAt('/pages/code-of-conduct');
+ expect(
+ screen.getByRole('heading', { name: /^code of conduct$/i, level: 1 }),
+ ).toBeInTheDocument();
+ });
+
+ it('renders the Hackathons page', () => {
+ renderAt('/pages/hackathons');
+ expect(screen.getByRole('heading', { name: /^hackathons$/i, level: 1 })).toBeInTheDocument();
+ });
+
+ it('renders NotFound for an unknown slug', () => {
+ renderAt('/pages/nonexistent-page');
+ // The NotFound screen renders a recognizable not-found message.
+ expect(screen.getByText(/page not found|not found/i)).toBeInTheDocument();
+ });
+
+ it('renders markdown headings as semantic h2/h3', () => {
+ renderAt('/pages/mission');
+ // The Mission page has a "What we do" h2.
+ expect(screen.getByRole('heading', { name: /what we do/i, level: 2 })).toBeInTheDocument();
+ });
+
+ it('renders inline links from markdown', () => {
+ renderAt('/pages/mission');
+ // [projects](/projects) → an projects.
+ const link = screen.getByRole('link', { name: /^projects$/ });
+ expect(link).toHaveAttribute('href', '/projects');
+ });
+});
diff --git a/package-lock.json b/package-lock.json
index 711e23f..7bca4f1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -74,6 +74,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.16.0",
+ "marked": "^18.0.4",
"radix-ui": "^1.4.3",
"react": "^19.2.6",
"react-dom": "^19.2.6",
@@ -10731,6 +10732,18 @@
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
+ "node_modules/marked": {
+ "version": "18.0.4",
+ "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.4.tgz",
+ "integrity": "sha512-c/BTaKzg0G6ezQx97DAkYU7k0HM6ys0FqYeKBL6hlBByZwy+ycA1+f0vDdjMHKKeEjdgkx0GOv9Il6D+85cOqA==",
+ "license": "MIT",
+ "bin": {
+ "marked": "bin/marked.js"
+ },
+ "engines": {
+ "node": ">= 20"
+ }
+ },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
diff --git a/plans/screen-gaps-phase3.md b/plans/screen-gaps-phase3.md
new file mode 100644
index 0000000..61e486c
--- /dev/null
+++ b/plans/screen-gaps-phase3.md
@@ -0,0 +1,121 @@
+---
+status: done
+depends: [screen-gaps-phase2]
+specs:
+ - specs/behaviors/app-shell.md
+issues: [83]
+pr: 104
+---
+
+# Plan: screen-gaps phase 3 — static `/pages/:slug` content
+
+## Scope
+
+[#83](https://github.com/CodeForPhilly/codeforphilly-ng/issues/83) phase 3 — closes the `` placeholders on `/pages/mission`, `/pages/leadership`, `/pages/code-of-conduct`, `/pages/hackathons` per [behaviors/app-shell.md](../specs/behaviors/app-shell.md):
+
+> "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."
+
+This plan ships the *plumbing* (content directory, markdown→HTML build-time renderer, the `/pages/:slug` route). The actual copy is **placeholder** that calls itself out — porting the legacy laddr-site text is a content task, not an engineering one. Filed as a follow-up.
+
+## Implements
+
+- [behaviors/app-shell.md](../specs/behaviors/app-shell.md) — `/pages/*` URL pattern + content authoring location.
+
+## Approach
+
+### 1. Dependency
+
+Add `marked` to `apps/web` for client-side markdown rendering. Choice rationale:
+
+- Static pages are **not user content** — the CLAUDE.md "no client markdown" rule explicitly applies to user-supplied content (bios, project overviews, blog bodies). Build-time-static content is essentially JSX.
+- `marked` is tiny (~30 KB min+gz) and battle-tested.
+- The alternative — using `@cfp/shared`'s `renderMarkdown` server-side via a build-time Vite plugin — adds more tooling than the v1 needs.
+
+No DOMPurify dance: zero XSS surface on content that lives in the bundle.
+
+### 2. Content files
+
+`apps/web/src/content/pages/`:
+
+- `mission.md`
+- `leadership.md`
+- `code-of-conduct.md`
+- `hackathons.md`
+
+Each carries a placeholder body that names itself ("This page's content hasn't been ported from the legacy site yet — see [issue ref] to help.") plus an H1 + a paragraph. Real copy ports from codeforphilly.org as a content PR.
+
+### 3. Renderer + route
+
+`apps/web/src/pages/StaticPage.tsx`:
+
+- `import.meta.glob('@/content/pages/*.md', { query: '?raw', import: 'default', eager: true })` builds a slug → markdown source map at build time.
+- The component reads `:slug` from the route, looks up the matching source, parses with `marked`, and renders inside a `prose` typographic container.
+- Unknown slug → ``.
+
+`apps/web/src/App.tsx`:
+
+- Replace `{ path: '/pages/:slug', element: }` with ``.
+
+### 4. Styling
+
+Reuse the existing typographic styles from `MarkdownView.tsx` (a `prose` container with tailwind targeting for headings, lists, code, blockquotes). DRY by extracting to a shared `MarkdownContent` wrapper, or just copy the class list — copy is cheaper for v1.
+
+### 5. Tests
+
+`apps/web/tests/StaticPage.test.tsx`:
+
+- Renders the H1 from `mission.md`.
+- Renders an unknown slug as NotFound.
+- Renders all four bundled pages (smoke).
+
+## Validation
+
+- [x] `npm install marked` lands as its own commit.
+- [x] `apps/web/src/content/pages/` has 4 markdown files.
+- [x] `/pages/mission`, `/pages/leadership`, `/pages/code-of-conduct`, `/pages/hackathons` all render their content.
+- [x] `/pages/nonexistent` renders the NotFound screen.
+- [x] `npm run type-check && npm run lint && npm test` clean.
+
+## Risks / unknowns
+
+- **`import.meta.glob` is Vite-specific.** Confirmed by the existing codebase using Vite — same mechanism would need a polyfill or alternative if we ever switched bundlers. Out of scope to worry about.
+- **Bundle size.** `marked` adds ~30 KB. Acceptable for static-content rendering. If bundle pressure becomes a concern later, swap to a Vite plugin that pre-renders to HTML at build time and import the HTML directly.
+- **Placeholder content** is honest about being placeholder, but a casual visitor will still see "this hasn't been ported yet" on real pages. Trade-off: ship the plumbing now so the spec is satisfied; content PR follows.
+
+## Notes
+
+Three commits: plan-open, `npm install marked` (with the exact
+command in the body, per the generated-files-commit-first convention),
+content + StaticPage + tests.
+
+Surprises:
+
+- **`marked.parse` is sync-by-default in v18.** Earlier versions
+ returned `string | Promise` depending on extension config;
+ v18+ defaults to sync unless a custom async extension is registered.
+ The `{ async: false }` arg is belt-and-suspenders.
+- **The `prose` class duplication.** `MarkdownView.tsx` and
+ `StaticPage.tsx` carry similar Tailwind `prose` configs. Considered
+ extracting to a shared wrapper, but the consumers diverge subtly:
+ `MarkdownView` is for compact embedded markdown (project overviews,
+ update bodies) and uses `prose-sm`; `StaticPage` is for full-width
+ documentation and uses `prose-sm sm:prose-base`. Plus heading scales
+ differ. Three-similar-lines vs. premature abstraction — kept the
+ copy.
+- **No DOMPurify dance.** Static-page content is build-time-static
+ source; no XSS surface. `dangerouslySetInnerHTML` is the right tool
+ here even though the name reads scary.
+
+## Follow-ups
+
+- **Port real copy from the legacy site.** Each of the four pages
+ carries placeholder text that names itself as such. The real text
+ lives at `codeforphilly.org/site-root/pages/`. *Tracked as* —
+ content-PR task; will file a tracking issue when content review
+ has someone owning it.
+- **Phase 4 — `/projects/:slug/buzz/new` form** stays the last open
+ piece of [#83](https://github.com/CodeForPhilly/codeforphilly-ng/issues/83). *Deferred to plan* — `plans/buzz-new-form.md`.
+- **MDX upgrade.** If `/pages/leadership` ever needs to render
+ embedded React components (e.g., a live leadership-roster card),
+ swap to `@mdx-js/rollup`. *None* for v1 — pure markdown is
+ sufficient.