+ );
+}
diff --git a/frontend-typescript/src/components/site/SectionVisibility.tsx b/frontend-typescript/src/components/site/SectionVisibility.tsx
new file mode 100644
index 00000000..3e9a0ea4
--- /dev/null
+++ b/frontend-typescript/src/components/site/SectionVisibility.tsx
@@ -0,0 +1,12 @@
+import type { ReactNode } from "react";
+import type { SiteSection } from "@/lib/siteContent";
+import { TbcMarker } from "./TbcMarker";
+
+export function SectionVisibility({ section, children }: {
+ section: SiteSection | null;
+ children: (section: SiteSection) => ReactNode;
+}) {
+ if (!section) return null;
+ const content = children(section);
+ return section.status === "draft" ? {content} : content;
+}
diff --git a/frontend-typescript/src/components/site/SiteMarkdown.test.tsx b/frontend-typescript/src/components/site/SiteMarkdown.test.tsx
new file mode 100644
index 00000000..44db3c7f
--- /dev/null
+++ b/frontend-typescript/src/components/site/SiteMarkdown.test.tsx
@@ -0,0 +1,35 @@
+import { render, screen } from "@testing-library/react";
+import { MemoryRouter } from "react-router-dom";
+import { SiteMarkdown } from "./SiteMarkdown";
+
+function renderMarkdown(body: string) {
+ return render(
+
+ {body}
+ ,
+ );
+}
+
+describe("SiteMarkdown", () => {
+ it("renders no element for raw HTML in the body", () => {
+ const { container } = renderMarkdown("Before after.");
+
+ expect(container.querySelector("script")).not.toBeInTheDocument();
+ });
+
+ it("renders an internal link as a router link", () => {
+ renderMarkdown("[Contact us](/contact)");
+
+ const link = screen.getByRole("link", { name: "Contact us" });
+ expect(link).toHaveAttribute("href", "/contact");
+ expect(link).not.toHaveAttribute("target");
+ });
+
+ it("gives an external link rel=noopener noreferrer", () => {
+ renderMarkdown("[Example](https://example.com)");
+
+ const link = screen.getByRole("link", { name: "Example" });
+ expect(link).toHaveAttribute("href", "https://example.com");
+ expect(link).toHaveAttribute("rel", "noopener noreferrer");
+ });
+});
diff --git a/frontend-typescript/src/components/site/SiteMarkdown.tsx b/frontend-typescript/src/components/site/SiteMarkdown.tsx
new file mode 100644
index 00000000..4df223e4
--- /dev/null
+++ b/frontend-typescript/src/components/site/SiteMarkdown.tsx
@@ -0,0 +1,40 @@
+import ReactMarkdown, { defaultUrlTransform, type Components } from "react-markdown";
+import { Link } from "react-router-dom";
+
+const components: Components = {
+ a: ({ href = "", children, ...props }) => {
+ if (href.startsWith("/") || href.startsWith("#")) {
+ return (
+
+ {children}
+
+ );
+ }
+ return (
+
+ {children}
+
+ );
+ },
+};
+
+export function SiteMarkdown({
+ children,
+ extraComponents,
+ resolveHref,
+}: {
+ children: string;
+ extraComponents?: Components;
+ resolveHref?: (href: string) => string;
+}) {
+ return (
+
+ defaultUrlTransform(key === "href" && resolveHref ? resolveHref(url) : url)
+ }
+ >
+ {children}
+
+ );
+}
diff --git a/frontend-typescript/src/components/site/TbcMarker.tsx b/frontend-typescript/src/components/site/TbcMarker.tsx
new file mode 100644
index 00000000..e8802916
--- /dev/null
+++ b/frontend-typescript/src/components/site/TbcMarker.tsx
@@ -0,0 +1,12 @@
+import type { ReactNode } from "react";
+
+export function TbcMarker({ children }: { children: ReactNode }) {
+ return (
+
+
+ TBC
+
+ {children}
+
+ );
+}
diff --git a/frontend-typescript/src/content/site/README.md b/frontend-typescript/src/content/site/README.md
new file mode 100644
index 00000000..6b4180e8
--- /dev/null
+++ b/frontend-typescript/src/content/site/README.md
@@ -0,0 +1,49 @@
+# Public-site content
+
+Every public page's copy lives here as one markdown file per section, never in
+page component source. A section file has YAML frontmatter followed by an
+optional markdown body.
+
+```markdown
+---
+id: how-it-works.five-steps
+page: how-it-works
+anchor: five-steps
+title: Five steps
+status: published
+personas: [grantee, funder] # optional
+items: # optional, structured entries
+ - title: Budget
+ body: Create a structured project budget.
+---
+Optional prose body, rendered as markdown (no raw HTML).
+```
+
+## Required fields
+
+- `id` — globally unique, stable, in the form `.`.
+- `page` — one of `home`, `how-it-works`, `security`, `about`, `contact`, `legal`.
+- `anchor` — a URL fragment, unique within its page. The section's canonical
+ URL is `#`.
+- `title` — the section heading as shown to visitors.
+- `status` — `published` or `draft`.
+
+## Optional fields
+
+- `personas` — a subset of `grantee`, `funder`, `technical`.
+- `items` — a list of structured entries (cards, steps, quotes, FAQs). Each
+ item needs a `title` or `quote`, and a `body` or `attribution`.
+
+## IDs and anchors are stable
+
+Once published, a section's `id` and `anchor` don't change when only its
+wording changes — downstream consumers (including the site assistant) cite
+sections by both. Renaming or removing either is a breaking content change.
+
+## Draft lifecycle
+
+A section ships as `status: draft` when its wording isn't confirmed yet. Draft
+sections never render in a production build. In development and preview
+builds they render with a visible "TBC" marker so authors and reviewers can
+see what's still pending. Flip `status` to `published` once the wording is
+confirmed — no code change needed.
diff --git a/frontend-typescript/src/content/site/about/mission.md b/frontend-typescript/src/content/site/about/mission.md
new file mode 100644
index 00000000..37c1b1a0
--- /dev/null
+++ b/frontend-typescript/src/content/site/about/mission.md
@@ -0,0 +1,11 @@
+---
+id: about.mission
+page: about
+anchor: mission
+title: Grant management should be collaborative, not administrative
+status: published
+personas: [grantee, funder]
+---
+Instead of exchanging spreadsheets and emails, donors and nonprofits should share one transparent financial record.
+
+Open Grant Flow is building open financial infrastructure for grant-funded organisations, so their time goes into delivering impact rather than preparing financial reports.
diff --git a/frontend-typescript/src/content/site/about/open-code.md b/frontend-typescript/src/content/site/about/open-code.md
new file mode 100644
index 00000000..c21591bb
--- /dev/null
+++ b/frontend-typescript/src/content/site/about/open-code.md
@@ -0,0 +1,18 @@
+---
+id: about.open-code
+page: about
+anchor: open-code
+title: Open by design
+status: published
+personas: [technical]
+items:
+ - title: Use the hosted platform
+ body: Get started without running any infrastructure yourself.
+ - title: Self-host
+ body: Run Open Grant Flow on your own servers.
+ - title: Inspect the source code
+ body: See exactly how your data is handled.
+ - title: Contribute
+ body: Developers, designers and nonprofit-sector experts are welcome.
+---
+Open Grant Flow is open source. The code is public on [GitHub](https://github.com/arutsh/OpenGrantFlow).
diff --git a/frontend-typescript/src/content/site/about/origin.md b/frontend-typescript/src/content/site/about/origin.md
new file mode 100644
index 00000000..0062bf63
--- /dev/null
+++ b/frontend-typescript/src/content/site/about/origin.md
@@ -0,0 +1,12 @@
+---
+id: about.origin
+page: about
+anchor: origin
+title: Built from lived experience
+status: published
+---
+Open Grant Flow is founded by a software engineer who previously spent 17 years co-founding and leading nonprofit organisations in Armenia.
+
+During that time, hundreds of grant budgets, donor reports and financial reconciliations revealed the same recurring problem: **too much time is spent managing administration instead of delivering impact.**
+
+*Open Grant Flow is the platform I wish had existed.*
diff --git a/frontend-typescript/src/content/site/about/set-up.md b/frontend-typescript/src/content/site/about/set-up.md
new file mode 100644
index 00000000..b0adffcf
--- /dev/null
+++ b/frontend-typescript/src/content/site/about/set-up.md
@@ -0,0 +1,8 @@
+---
+id: about.set-up
+page: about
+anchor: set-up
+title: How we are set up
+status: draft
+---
+Open Grant Flow is being set up as a Community Interest Company (CIC), so that the platform serves the sector it was built for. Registration details will be published here once confirmed.
diff --git a/frontend-typescript/src/content/site/about/values.md b/frontend-typescript/src/content/site/about/values.md
new file mode 100644
index 00000000..956be34d
--- /dev/null
+++ b/frontend-typescript/src/content/site/about/values.md
@@ -0,0 +1,16 @@
+---
+id: about.values
+page: about
+anchor: values
+title: What we value
+status: published
+items:
+ - title: Transparency
+ body: Financial infrastructure for the nonprofit sector should be transparent and accessible. Transparency builds trust.
+ - title: Collaboration
+ body: Donors and grantees work from one shared record instead of passing files back and forth.
+ - title: Time for impact
+ body: Every hour saved on reporting is an hour returned to the work the grant was for.
+ - title: Built with the sector
+ body: Pilot Partners shape what we build, so the product fits how grant-funded organisations really work.
+---
diff --git a/frontend-typescript/src/content/site/contact/demo-request.md b/frontend-typescript/src/content/site/contact/demo-request.md
new file mode 100644
index 00000000..187cd414
--- /dev/null
+++ b/frontend-typescript/src/content/site/contact/demo-request.md
@@ -0,0 +1,8 @@
+---
+id: contact.demo-request
+page: contact
+anchor: demo-request
+title: Start a conversation
+status: published
+---
+Tell us a little about your organisation and we'll set up a short demo built around how you manage grants today.
diff --git a/frontend-typescript/src/content/site/contact/faq-time.md b/frontend-typescript/src/content/site/contact/faq-time.md
new file mode 100644
index 00000000..bde79632
--- /dev/null
+++ b/frontend-typescript/src/content/site/contact/faq-time.md
@@ -0,0 +1,8 @@
+---
+id: contact.faq-time
+page: contact
+anchor: faq-time
+title: How much time does a pilot take?
+status: draft
+---
+We aim to keep the time commitment light. Expected hours per month will be confirmed with each partner.
diff --git a/frontend-typescript/src/content/site/contact/faq.md b/frontend-typescript/src/content/site/contact/faq.md
new file mode 100644
index 00000000..68d26a3b
--- /dev/null
+++ b/frontend-typescript/src/content/site/contact/faq.md
@@ -0,0 +1,14 @@
+---
+id: contact.faq
+page: contact
+anchor: faq
+title: Frequently asked questions
+status: published
+items:
+ - title: Is Open Grant Flow free?
+ body: The code is open source, so you can self-host it for free. We also offer a hosted platform.
+ - title: Do I need to be a Pilot Partner to get a demo?
+ body: No. Anyone can request a demo; tick the pilot box only if you'd like to be considered for the programme.
+ - title: Do I have to use the AI features?
+ body: No. AI assistance is optional, and you can bring your own provider key.
+---
diff --git a/frontend-typescript/src/content/site/contact/pilot-details.md b/frontend-typescript/src/content/site/contact/pilot-details.md
new file mode 100644
index 00000000..4c6e7df5
--- /dev/null
+++ b/frontend-typescript/src/content/site/contact/pilot-details.md
@@ -0,0 +1,8 @@
+---
+id: contact.pilot-details
+page: contact
+anchor: pilot-details
+title: How the pilot runs
+status: draft
+---
+Pilots run alongside a real grant cycle. The pilot length and month-by-month schedule will be confirmed with each partner.
diff --git a/frontend-typescript/src/content/site/contact/pilot.md b/frontend-typescript/src/content/site/contact/pilot.md
new file mode 100644
index 00000000..86a0b08c
--- /dev/null
+++ b/frontend-typescript/src/content/site/contact/pilot.md
@@ -0,0 +1,11 @@
+---
+id: contact.pilot
+page: contact
+anchor: pilot
+title: Become a Pilot Partner
+status: published
+personas: [grantee, funder]
+---
+We're looking for a small number of institutional donors, grant-making foundations, international NGOs and nonprofit organisations to help shape Open Grant Flow.
+
+This is not a sales programme. It is a collaborative product development initiative.
diff --git a/frontend-typescript/src/content/site/contact/what-we-ask.md b/frontend-typescript/src/content/site/contact/what-we-ask.md
new file mode 100644
index 00000000..10b71737
--- /dev/null
+++ b/frontend-typescript/src/content/site/contact/what-we-ask.md
@@ -0,0 +1,12 @@
+---
+id: contact.what-we-ask
+page: contact
+anchor: what-we-ask
+title: What we ask
+status: published
+items:
+ - title: Use it on real work
+ body: Run at least one real grant's budget and reporting through Open Grant Flow.
+ - title: Share honest feedback
+ body: Tell us what works, what doesn't and what is missing.
+---
diff --git a/frontend-typescript/src/content/site/contact/what-you-receive.md b/frontend-typescript/src/content/site/contact/what-you-receive.md
new file mode 100644
index 00000000..2f0bf4c8
--- /dev/null
+++ b/frontend-typescript/src/content/site/contact/what-you-receive.md
@@ -0,0 +1,16 @@
+---
+id: contact.what-you-receive
+page: contact
+anchor: what-you-receive
+title: What you receive
+status: published
+items:
+ - title: Direct collaboration
+ body: Work directly with the founder.
+ - title: Early access
+ body: Use new features before they are generally available.
+ - title: Influence
+ body: Help decide what we build first.
+ - title: Discounted hosting
+ body: Discounted hosted services, when available.
+---
diff --git a/frontend-typescript/src/content/site/home/browse-instead.md b/frontend-typescript/src/content/site/home/browse-instead.md
new file mode 100644
index 00000000..6d295387
--- /dev/null
+++ b/frontend-typescript/src/content/site/home/browse-instead.md
@@ -0,0 +1,20 @@
+---
+id: home.browse-instead
+page: home
+anchor: browse-instead
+title: Not sure where to start? Browse instead.
+status: published
+items:
+ - title: How it works
+ body: The five-step financial record, from budget to audit-ready report.
+ href: /how-it-works
+ - title: Security & data
+ body: How your data is handled, hosted and kept confidential.
+ href: /security
+ - title: About
+ body: The mission, the story and the people behind Open Grant Flow.
+ href: /about
+ - title: Contact
+ body: Request a demo or ask a question — we reply ourselves.
+ href: /contact
+---
diff --git a/frontend-typescript/src/content/site/home/hero.md b/frontend-typescript/src/content/site/home/hero.md
new file mode 100644
index 00000000..7a1ffa61
--- /dev/null
+++ b/frontend-typescript/src/content/site/home/hero.md
@@ -0,0 +1,18 @@
+---
+id: home.hero
+page: home
+anchor: hero
+title: Grant management without spreadsheets.
+status: published
+personas: [grantee, funder]
+items:
+ - title: Become a Pilot Partner
+ body: Help shape Open Grant Flow as an early partner.
+ href: /contact#pilot
+ - title: See how it works
+ body: Walk through the five-step financial record.
+ href: /how-it-works
+---
+Open Grant Flow helps nonprofits and institutional donors manage the complete financial lifecycle of a grant — from budgeting and spending to reporting and audit readiness.
+
+Built from 17 years of nonprofit leadership and grant management experience.
diff --git a/frontend-typescript/src/content/site/home/pilot-teaser.md b/frontend-typescript/src/content/site/home/pilot-teaser.md
new file mode 100644
index 00000000..988fadd4
--- /dev/null
+++ b/frontend-typescript/src/content/site/home/pilot-teaser.md
@@ -0,0 +1,12 @@
+---
+id: home.pilot-teaser
+page: home
+anchor: pilot-teaser
+title: Become a Pilot Partner
+status: published
+items:
+ - title: Request a pilot
+ body: Talk to us about piloting Open Grant Flow with your organisation.
+ href: /contact#pilot
+---
+We're looking for a small number of institutional donors, grant-making foundations, international NGOs and nonprofit organisations to help shape Open Grant Flow.
diff --git a/frontend-typescript/src/content/site/home/values-summary.md b/frontend-typescript/src/content/site/home/values-summary.md
new file mode 100644
index 00000000..3e0b7c2f
--- /dev/null
+++ b/frontend-typescript/src/content/site/home/values-summary.md
@@ -0,0 +1,15 @@
+---
+id: home.values-summary
+page: home
+anchor: values-summary
+title: What we stand for
+status: published
+items:
+ - title: Transparency
+ body: Financial infrastructure for the nonprofit sector should be transparent and accessible.
+ - title: Collaboration
+ body: Donors and grantees work from one shared record instead of passing files back and forth.
+ - title: Time for impact
+ body: Every hour saved on reporting is an hour returned to the work the grant was for.
+---
+Read more about [what we value](/about#values).
diff --git a/frontend-typescript/src/content/site/home/what-you-get-back.md b/frontend-typescript/src/content/site/home/what-you-get-back.md
new file mode 100644
index 00000000..ad1528b4
--- /dev/null
+++ b/frontend-typescript/src/content/site/home/what-you-get-back.md
@@ -0,0 +1,17 @@
+---
+id: home.what-you-get-back
+page: home
+anchor: what-you-get-back
+title: What you get back
+status: published
+personas: [grantee, funder]
+items:
+ - title: Hours back every month
+ body: Stop rebuilding the same spreadsheet for every donor report.
+ - title: One shared financial record
+ body: Donors and grantees work from the same numbers, not separate files.
+ - title: Audit-ready by default
+ body: Every transaction stays connected to its receipts and approvals.
+ - title: Less email back-and-forth
+ body: Questions and corrections happen in the platform, not in inboxes.
+---
diff --git a/frontend-typescript/src/content/site/how-it-works/five-steps.md b/frontend-typescript/src/content/site/how-it-works/five-steps.md
new file mode 100644
index 00000000..08485440
--- /dev/null
+++ b/frontend-typescript/src/content/site/how-it-works/five-steps.md
@@ -0,0 +1,18 @@
+---
+id: how-it-works.five-steps
+page: how-it-works
+anchor: five-steps
+title: Five steps
+status: published
+items:
+ - title: Budget
+ body: Create a structured project budget, with optional AI assistance to build it from plain language.
+ - title: Track
+ body: Record spending against the approved budget as it happens.
+ - title: Receipts
+ body: Keep receipts and supporting documents connected to every transaction.
+ - title: Report
+ body: Generate a donor-ready financial report without rebuilding a spreadsheet from scratch.
+ - title: Audit-ready
+ body: Maintain a transparent financial history that simplifies audits and reviews.
+---
diff --git a/frontend-typescript/src/content/site/how-it-works/intro.md b/frontend-typescript/src/content/site/how-it-works/intro.md
new file mode 100644
index 00000000..1b6e7116
--- /dev/null
+++ b/frontend-typescript/src/content/site/how-it-works/intro.md
@@ -0,0 +1,8 @@
+---
+id: how-it-works.intro
+page: how-it-works
+anchor: intro
+title: One continuous financial record, from budget to report
+status: published
+---
+Every grant creates another spreadsheet, another donor template, another round of reconciliation. Open Grant Flow gives nonprofits and funders one structured record instead.
diff --git a/frontend-typescript/src/content/site/how-it-works/roadmap.md b/frontend-typescript/src/content/site/how-it-works/roadmap.md
new file mode 100644
index 00000000..6752b1ba
--- /dev/null
+++ b/frontend-typescript/src/content/site/how-it-works/roadmap.md
@@ -0,0 +1,8 @@
+---
+id: how-it-works.roadmap
+page: how-it-works
+anchor: roadmap
+title: What's next
+status: draft
+---
+Snap a receipt on your phone and have it recognised, categorised and matched to the right budget line. Pilot Partners help decide what we build first.
diff --git a/frontend-typescript/src/content/site/how-it-works/sector-validation.md b/frontend-typescript/src/content/site/how-it-works/sector-validation.md
new file mode 100644
index 00000000..2c4f9923
--- /dev/null
+++ b/frontend-typescript/src/content/site/how-it-works/sector-validation.md
@@ -0,0 +1,17 @@
+---
+id: how-it-works.sector-validation
+page: how-it-works
+anchor: sector-validation
+title: From conversations across the sector
+status: published
+items:
+ - quote: Even sophisticated internal systems can still require exporting files to grantees and bringing them back.
+ attribution: Former UK fund CEO
+ - quote: Multiple platforms solve different pieces, but not the basic cross-organisation workflow.
+ attribution: International nonprofit leader
+ - quote: A donor portal can digitise the funder side while the grantee still keeps separate local records.
+ attribution: Nonprofit / fund adviser
+ - title: From a conversation with a local nonprofit leader
+ body: One leader of a small local nonprofit told us they went looking for an alternative and couldn't find one that fit — a sign the willingness is already there, once the right tool exists.
+---
+The opportunity is interoperability, not another closed portal.
diff --git a/frontend-typescript/src/content/site/how-it-works/survey-evidence.md b/frontend-typescript/src/content/site/how-it-works/survey-evidence.md
new file mode 100644
index 00000000..c2085ffe
--- /dev/null
+++ b/frontend-typescript/src/content/site/how-it-works/survey-evidence.md
@@ -0,0 +1,10 @@
+---
+id: how-it-works.survey-evidence
+page: how-it-works
+anchor: survey-evidence
+title: What we're hearing from the sector
+status: published
+---
+In our exploratory survey of 18 nonprofits, 15 said donor reporting requirements differ between funders, and 11 estimated that donor-specific reporting adds one to three days each time. Our goal is to bring that down to hours.
+
+*Early, directional evidence from a small survey. Not a representative sample of the sector.*
diff --git a/frontend-typescript/src/content/site/how-it-works/who-funders.md b/frontend-typescript/src/content/site/how-it-works/who-funders.md
new file mode 100644
index 00000000..3603ee79
--- /dev/null
+++ b/frontend-typescript/src/content/site/how-it-works/who-funders.md
@@ -0,0 +1,15 @@
+---
+id: how-it-works.who-funders
+page: how-it-works
+anchor: who-funders
+title: For funders
+status: published
+personas: [funder]
+items:
+ - title: Clear portfolio view
+ body: See your grantees, their funded budgets, reports and attached receipts in one dashboard.
+ - title: Multi-currency visibility
+ body: Follow grants funded in different currencies without reconciling by hand.
+ - title: Lighter reporting burden
+ body: Better information for you, and less duplicated effort for the organisations you support.
+---
diff --git a/frontend-typescript/src/content/site/how-it-works/who-grantees.md b/frontend-typescript/src/content/site/how-it-works/who-grantees.md
new file mode 100644
index 00000000..e54180ef
--- /dev/null
+++ b/frontend-typescript/src/content/site/how-it-works/who-grantees.md
@@ -0,0 +1,15 @@
+---
+id: how-it-works.who-grantees
+page: how-it-works
+anchor: who-grantees
+title: For grantees
+status: published
+personas: [grantee]
+items:
+ - title: Less time on administration
+ body: Enter your budget and spending once and reuse the record when you report.
+ - title: Evidence in one place
+ body: Receipts and supporting documents stay connected to every transaction.
+ - title: Free for grassroots organisations
+ body: The smallest organisations have the least capacity to spare, so they will never be priced out.
+---
diff --git a/frontend-typescript/src/content/site/legal/privacy-contact.md b/frontend-typescript/src/content/site/legal/privacy-contact.md
new file mode 100644
index 00000000..5e4f0ecc
--- /dev/null
+++ b/frontend-typescript/src/content/site/legal/privacy-contact.md
@@ -0,0 +1,8 @@
+---
+id: legal.privacy-contact
+page: legal
+anchor: privacy-contact
+title: Contact
+status: published
+---
+Questions about this policy can be sent to [privacy@opengrantflow.com](mailto:privacy@opengrantflow.com) or through our [contact form](/contact).
diff --git a/frontend-typescript/src/content/site/legal/privacy-how-we-use.md b/frontend-typescript/src/content/site/legal/privacy-how-we-use.md
new file mode 100644
index 00000000..7b79a260
--- /dev/null
+++ b/frontend-typescript/src/content/site/legal/privacy-how-we-use.md
@@ -0,0 +1,8 @@
+---
+id: legal.privacy-how-we-use
+page: legal
+anchor: privacy-how-we-use
+title: How we use it
+status: published
+---
+We use this information solely to respond to your enquiry, to operate the hosted platform, and to improve Open Grant Flow. We do not sell your information to third parties.
diff --git a/frontend-typescript/src/content/site/legal/privacy-info-collected.md b/frontend-typescript/src/content/site/legal/privacy-info-collected.md
new file mode 100644
index 00000000..a1cab620
--- /dev/null
+++ b/frontend-typescript/src/content/site/legal/privacy-info-collected.md
@@ -0,0 +1,8 @@
+---
+id: legal.privacy-info-collected
+page: legal
+anchor: privacy-info-collected
+title: Information we collect
+status: published
+---
+When you submit our contact form, we collect your name, organisation, email address, role and message. If you use our hosted platform, we collect the account and budget information needed to provide the service.
diff --git a/frontend-typescript/src/content/site/legal/privacy-legal-status.md b/frontend-typescript/src/content/site/legal/privacy-legal-status.md
new file mode 100644
index 00000000..1cc6966c
--- /dev/null
+++ b/frontend-typescript/src/content/site/legal/privacy-legal-status.md
@@ -0,0 +1,10 @@
+---
+id: legal.privacy-legal-status
+page: legal
+anchor: privacy-legal-status
+title: Legal status
+status: published
+---
+Open Grant Flow ("we", "us") is an open-source grant financial management platform, currently in active development. This policy explains what information we collect and how we use it.
+
+Open Grant Flow is operated by its maintainer as an open-source project and is not yet a registered legal entity (company or nonprofit). This may change as the project develops.
diff --git a/frontend-typescript/src/content/site/legal/privacy-processors.md b/frontend-typescript/src/content/site/legal/privacy-processors.md
new file mode 100644
index 00000000..40d43d94
--- /dev/null
+++ b/frontend-typescript/src/content/site/legal/privacy-processors.md
@@ -0,0 +1,8 @@
+---
+id: legal.privacy-processors
+page: legal
+anchor: privacy-processors
+title: Third-party processors
+status: published
+---
+We use a small number of third-party services to operate the hosted platform: Hetzner (infrastructure, EU), Mailjet and MailerSend (transactional email, EU), Anthropic (AI-assisted features, when you supply your own API key — this involves a transfer of data outside the EU/EEA to the US), and Grafana Cloud (operational monitoring). Each only receives the data needed to provide its service.
diff --git a/frontend-typescript/src/content/site/legal/privacy-retention.md b/frontend-typescript/src/content/site/legal/privacy-retention.md
new file mode 100644
index 00000000..27241b48
--- /dev/null
+++ b/frontend-typescript/src/content/site/legal/privacy-retention.md
@@ -0,0 +1,8 @@
+---
+id: legal.privacy-retention
+page: legal
+anchor: privacy-retention
+title: How long we keep it
+status: published
+---
+We retain your information only for as long as needed to provide the service, respond to your enquiry, or meet legal and institutional record-keeping obligations. We don't yet publish a fixed retention schedule; this is being finalized as the platform matures.
diff --git a/frontend-typescript/src/content/site/legal/privacy-self-hosting.md b/frontend-typescript/src/content/site/legal/privacy-self-hosting.md
new file mode 100644
index 00000000..4d6555e9
--- /dev/null
+++ b/frontend-typescript/src/content/site/legal/privacy-self-hosting.md
@@ -0,0 +1,8 @@
+---
+id: legal.privacy-self-hosting
+page: legal
+anchor: privacy-self-hosting
+title: Self-hosting
+status: published
+---
+Open Grant Flow is open source. If you self-host the platform, your data stays on your own infrastructure and this policy does not apply to it.
diff --git a/frontend-typescript/src/content/site/legal/terms-acceptable-use.md b/frontend-typescript/src/content/site/legal/terms-acceptable-use.md
new file mode 100644
index 00000000..df5a940b
--- /dev/null
+++ b/frontend-typescript/src/content/site/legal/terms-acceptable-use.md
@@ -0,0 +1,8 @@
+---
+id: legal.terms-acceptable-use
+page: legal
+anchor: terms-acceptable-use
+title: Acceptable use
+status: published
+---
+You agree to use Open Grant Flow only for lawful purposes and not to misuse the platform or attempt to disrupt its operation.
diff --git a/frontend-typescript/src/content/site/legal/terms-changes.md b/frontend-typescript/src/content/site/legal/terms-changes.md
new file mode 100644
index 00000000..027c40ac
--- /dev/null
+++ b/frontend-typescript/src/content/site/legal/terms-changes.md
@@ -0,0 +1,8 @@
+---
+id: legal.terms-changes
+page: legal
+anchor: terms-changes
+title: Changes to these terms
+status: published
+---
+We may update these terms as the platform develops. Continued use of Open Grant Flow after changes means you accept the updated terms.
diff --git a/frontend-typescript/src/content/site/legal/terms-contact.md b/frontend-typescript/src/content/site/legal/terms-contact.md
new file mode 100644
index 00000000..07ba965f
--- /dev/null
+++ b/frontend-typescript/src/content/site/legal/terms-contact.md
@@ -0,0 +1,8 @@
+---
+id: legal.terms-contact
+page: legal
+anchor: terms-contact
+title: Contact
+status: published
+---
+Questions about these terms can be sent through our [contact form](/contact).
diff --git a/frontend-typescript/src/content/site/legal/terms-no-warranty.md b/frontend-typescript/src/content/site/legal/terms-no-warranty.md
new file mode 100644
index 00000000..cc89d483
--- /dev/null
+++ b/frontend-typescript/src/content/site/legal/terms-no-warranty.md
@@ -0,0 +1,8 @@
+---
+id: legal.terms-no-warranty
+page: legal
+anchor: terms-no-warranty
+title: No warranty
+status: published
+---
+The platform is provided "as is", without warranty of any kind, express or implied, including during this active development phase.
diff --git a/frontend-typescript/src/content/site/legal/terms-service.md b/frontend-typescript/src/content/site/legal/terms-service.md
new file mode 100644
index 00000000..aa7cf756
--- /dev/null
+++ b/frontend-typescript/src/content/site/legal/terms-service.md
@@ -0,0 +1,10 @@
+---
+id: legal.terms-service
+page: legal
+anchor: terms-service
+title: The service
+status: published
+---
+By using Open Grant Flow, you agree to these terms. If you do not agree, please do not use the platform.
+
+Open Grant Flow is open-source software, currently in active development. Features, availability and functionality may change as the platform evolves.
diff --git a/frontend-typescript/src/content/site/security/ai-providers.md b/frontend-typescript/src/content/site/security/ai-providers.md
new file mode 100644
index 00000000..f87b0cc9
--- /dev/null
+++ b/frontend-typescript/src/content/site/security/ai-providers.md
@@ -0,0 +1,8 @@
+---
+id: security.ai-providers
+page: security
+anchor: ai-providers
+title: AI providers and data
+status: published
+---
+AI assistance is optional. If you use it, you can bring your own AI provider key (BYOK) rather than depend on ours, so you choose who processes that data.
diff --git a/frontend-typescript/src/content/site/security/data-location.md b/frontend-typescript/src/content/site/security/data-location.md
new file mode 100644
index 00000000..d0b115cd
--- /dev/null
+++ b/frontend-typescript/src/content/site/security/data-location.md
@@ -0,0 +1,8 @@
+---
+id: security.data-location
+page: security
+anchor: data-location
+title: Where your data is stored
+status: draft
+---
+Hosting region and data storage details are being finalised and will be published here once confirmed.
diff --git a/frontend-typescript/src/content/site/security/gdpr.md b/frontend-typescript/src/content/site/security/gdpr.md
new file mode 100644
index 00000000..21fe633b
--- /dev/null
+++ b/frontend-typescript/src/content/site/security/gdpr.md
@@ -0,0 +1,8 @@
+---
+id: security.gdpr
+page: security
+anchor: gdpr
+title: GDPR compliance
+status: draft
+---
+The platform is designed to support GDPR-compliant deployment and strong data-protection requirements, and we are in the process of pursuing the security accreditations that will confirm our operations meet that standard.
diff --git a/frontend-typescript/src/content/site/security/high-risk.md b/frontend-typescript/src/content/site/security/high-risk.md
new file mode 100644
index 00000000..6ef181df
--- /dev/null
+++ b/frontend-typescript/src/content/site/security/high-risk.md
@@ -0,0 +1,10 @@
+---
+id: security.high-risk
+page: security
+anchor: high-risk
+title: Talk to us about your security needs
+status: published
+---
+Tell us about any high-risk context when you [request a demo](/contact). We are glad to talk through your specific security needs before you share anything with us.
+
+Have a security question we haven't answered? Ask us directly when you request a demo.
diff --git a/frontend-typescript/src/content/site/security/intro.md b/frontend-typescript/src/content/site/security/intro.md
new file mode 100644
index 00000000..aafe14b4
--- /dev/null
+++ b/frontend-typescript/src/content/site/security/intro.md
@@ -0,0 +1,8 @@
+---
+id: security.intro
+page: security
+anchor: intro
+title: Handled with the care we'd want for our own data
+status: published
+---
+For many of the organisations we work with, protecting information is tied to the safety of the people and communities behind the work. We build with that in mind from the start.
diff --git a/frontend-typescript/src/content/site/security/self-hosting.md b/frontend-typescript/src/content/site/security/self-hosting.md
new file mode 100644
index 00000000..5a1eb44b
--- /dev/null
+++ b/frontend-typescript/src/content/site/security/self-hosting.md
@@ -0,0 +1,8 @@
+---
+id: security.self-hosting
+page: security
+anchor: self-hosting
+title: Self-hosting
+status: published
+---
+Open Grant Flow is open source. You can use our hosted platform, or [self-host it](https://github.com/arutsh/OpenGrantFlow) if you prefer to keep everything in-house.
diff --git a/frontend-typescript/src/lib/brand.ts b/frontend-typescript/src/lib/brand.ts
index 6f45c6bb..0ddbf0aa 100644
--- a/frontend-typescript/src/lib/brand.ts
+++ b/frontend-typescript/src/lib/brand.ts
@@ -1,7 +1,11 @@
+// Inline-style escape hatch; the palette itself is defined in style.css @theme.
export const brand = {
- navy: "#163A5F",
- teal: "#1F8A70",
- gold: "#D9A441",
- offWhite: "#F8FAFC",
- slate: "#243447",
+ navy: "var(--color-brand-navy)",
+ teal: "var(--color-brand-teal)",
+ gold: "var(--color-brand-gold)",
+ offWhite: "var(--color-brand-off-white)",
+ slate: "var(--color-brand-slate)",
+ mist: "var(--color-brand-mist)",
+ tealTint: "var(--color-brand-teal-tint)",
+ tealTintBorder: "var(--color-brand-teal-tint-border)",
};
diff --git a/frontend-typescript/src/lib/guideDocs.ts b/frontend-typescript/src/lib/guideDocs.ts
new file mode 100644
index 00000000..c507ea47
--- /dev/null
+++ b/frontend-typescript/src/lib/guideDocs.ts
@@ -0,0 +1,56 @@
+const GUIDE_FILES = import.meta.glob(
+ ["../../../docs/user-guide/*.md", "!../../../docs/user-guide/README.md"],
+ { eager: true, query: "?raw", import: "default" },
+) as Record;
+
+const PRODUCT_FILE = import.meta.glob("../../../docs/PRODUCT.md", {
+ eager: true,
+ query: "?raw",
+ import: "default",
+}) as Record;
+
+function slugFromPath(path: string): string {
+ return path.split("/").pop()!.replace(/\.md$/, "").toLowerCase();
+}
+
+const GUIDE_DOCS = new Map([
+ ...Object.entries(GUIDE_FILES).map(([path, raw]) => [slugFromPath(path), raw] as const),
+ ...Object.values(PRODUCT_FILE).map((raw) => ["product", raw] as const),
+]);
+
+export function getGuideDoc(slug: string): string | null {
+ return GUIDE_DOCS.get(slug) ?? null;
+}
+
+const REPO_URL = "https://github.com/arutsh/OpenGrantFlow";
+
+const SLUG_BY_REPO_PATH = new Map([
+ ...Object.keys(GUIDE_FILES).map((path) => [path.replace(/^(\.\.\/)+/, ""), slugFromPath(path)] as const),
+ ["docs/PRODUCT.md", "product"],
+]);
+
+function docDir(slug: string): string {
+ return slug === "product" ? "docs" : "docs/user-guide";
+}
+
+// Doc links are written for GitHub; route ones to published guides, the rest to the repo.
+export function resolveGuideHref(href: string, slug: string): string {
+ if (/^([a-z][a-z0-9+.-]*:|#|\/)/i.test(href)) return href;
+ const [path, hash] = href.split("#");
+ const repoPath = new URL(path, `https://repo/${docDir(slug)}/`).pathname.slice(1);
+ const fragment = hash ? `#${hash}` : "";
+ const guideSlug = SLUG_BY_REPO_PATH.get(repoPath);
+ if (guideSlug) return `/guides/${guideSlug}${fragment}`;
+ if (repoPath.startsWith("docs/")) return `${REPO_URL}/blob/main/${repoPath}${fragment}`;
+ return `${REPO_URL}/${repoPath}${fragment}`;
+}
+
+// Must stay in sync with _slugify in services/ai/app/services/guide_doc_ingestion.py.
+export function slugifyHeading(text: string): string {
+ const slug = text.toLowerCase().replace(/[^a-z0-9\s-]/g, "");
+ return slug
+ .trim()
+ .replace(/\s+/g, "-")
+ .replace(/-+/g, "-")
+ .replace(/^-+|-+$/g, "");
+}
diff --git a/frontend-typescript/src/lib/siteContent.test.ts b/frontend-typescript/src/lib/siteContent.test.ts
new file mode 100644
index 00000000..cac05c1b
--- /dev/null
+++ b/frontend-typescript/src/lib/siteContent.test.ts
@@ -0,0 +1,152 @@
+import { buildSiteContent } from "./siteContent";
+
+const PUBLISHED = `---
+id: home.hero
+page: home
+anchor: hero
+title: Hero
+status: published
+---
+Hero body.`;
+
+const DRAFT = `---
+id: security.data-location
+page: security
+anchor: data-location
+title: Data location
+status: draft
+---
+TBC data location.`;
+
+describe("buildSiteContent", () => {
+ it("throws naming the file and the missing field", () => {
+ expect(() =>
+ buildSiteContent({
+ "/a.md": `---
+page: home
+anchor: hero
+title: Hero
+status: published
+---
+Body`,
+ }),
+ ).toThrow(/\/a\.md: missing required field "id"/);
+ });
+
+ it("throws on an invalid page value", () => {
+ expect(() =>
+ buildSiteContent({
+ "/a.md": `---
+id: bogus.hero
+page: not-a-page
+anchor: hero
+title: Hero
+status: published
+---
+Body`,
+ }),
+ ).toThrow(/\/a\.md: invalid "page" value "not-a-page"/);
+ });
+
+ it("throws on an invalid status value", () => {
+ expect(() =>
+ buildSiteContent({
+ "/a.md": `---
+id: home.hero
+page: home
+anchor: hero
+title: Hero
+status: pending
+---
+Body`,
+ }),
+ ).toThrow(/\/a\.md: invalid "status" value "pending"/);
+ });
+
+ it("throws on an invalid personas value", () => {
+ expect(() =>
+ buildSiteContent({
+ "/a.md": `---
+id: home.hero
+page: home
+anchor: hero
+title: Hero
+status: published
+personas: [grantee, astronaut]
+---
+Body`,
+ }),
+ ).toThrow(/\/a\.md: invalid "personas" value/);
+ });
+
+ it("throws naming both files on a duplicate id", () => {
+ expect(() =>
+ buildSiteContent({
+ "/a.md": PUBLISHED,
+ "/b.md": PUBLISHED.replace("anchor: hero", "anchor: other"),
+ }),
+ ).toThrow(/duplicate id "home\.hero" in \/a\.md, \/b\.md/);
+ });
+
+ it("throws naming both files on a duplicate anchor within the same page", () => {
+ expect(() =>
+ buildSiteContent({
+ "/a.md": PUBLISHED,
+ "/b.md": PUBLISHED.replace("id: home.hero", "id: home.hero-2"),
+ }),
+ ).toThrow(/duplicate anchor "hero" on page "home" in \/a\.md, \/b\.md/);
+ });
+
+ it("hides draft sections when isProd is true", () => {
+ const { getSection } = buildSiteContent({ "/a.md": DRAFT }, { isProd: true });
+
+ expect(getSection("security.data-location")).toBeNull();
+ });
+
+ it("shows draft sections when isProd is false", () => {
+ const { getSection } = buildSiteContent({ "/a.md": DRAFT }, { isProd: false });
+
+ const section = getSection("security.data-location");
+ expect(section?.status).toBe("draft");
+ expect(section?.title).toBe("Data location");
+ });
+
+ it("throws on an unknown section id", () => {
+ const { getSection } = buildSiteContent({ "/a.md": PUBLISHED });
+
+ expect(() => getSection("home.missing")).toThrow(
+ /Unknown site content section "home\.missing"/,
+ );
+ });
+
+ it("returns published sections unchanged in production", () => {
+ const { getSection } = buildSiteContent({ "/a.md": PUBLISHED }, { isProd: true });
+
+ const section = getSection("home.hero");
+ expect(section?.status).toBe("published");
+ expect(section?.body).toBe("Hero body.");
+ });
+
+ it("parses a file saved with a UTF-8 byte order mark", () => {
+ const { getSection } = buildSiteContent({ "/a.md": `\uFEFF${PUBLISHED}` });
+
+ expect(getSection("home.hero")?.title).toBe("Hero");
+ });
+
+ it("reports fence and YAML errors alongside other files' validation errors", () => {
+ let message = "";
+ try {
+ buildSiteContent({
+ "/no-fence.md": "id: home.hero\nBody",
+ "/bad-yaml.md": "---\nid: [unclosed\n---\nBody",
+ "/no-id.md": PUBLISHED.replace("id: home.hero\n", ""),
+ });
+ } catch (error) {
+ message = (error as Error).message;
+ }
+
+ expect(message).toMatch(/\/no-fence\.md: missing YAML frontmatter fence/);
+ expect(message).toMatch(/\/bad-yaml\.md: /);
+ expect(message).toMatch(/\/no-id\.md: missing required field "id"/);
+ });
+});
diff --git a/frontend-typescript/src/lib/siteContent.ts b/frontend-typescript/src/lib/siteContent.ts
new file mode 100644
index 00000000..f58026ba
--- /dev/null
+++ b/frontend-typescript/src/lib/siteContent.ts
@@ -0,0 +1,223 @@
+import { parse as parseYaml } from "yaml";
+
+export type SitePage =
+ | "home"
+ | "how-it-works"
+ | "security"
+ | "about"
+ | "contact"
+ | "legal";
+
+export type SitePersona = "grantee" | "funder" | "technical";
+
+export type SiteSectionStatus = "published" | "draft";
+
+export interface SiteContentItem {
+ title?: string;
+ quote?: string;
+ body?: string;
+ attribution?: string;
+ href?: string;
+ [key: string]: unknown;
+}
+
+export interface SiteSection {
+ id: string;
+ page: SitePage;
+ anchor: string;
+ title: string;
+ status: SiteSectionStatus;
+ personas?: SitePersona[];
+ items?: SiteContentItem[];
+ body: string;
+}
+
+const PAGES: SitePage[] = [
+ "home",
+ "how-it-works",
+ "security",
+ "about",
+ "contact",
+ "legal",
+];
+const STATUSES: SiteSectionStatus[] = ["published", "draft"];
+const PERSONAS: SitePersona[] = ["grantee", "funder", "technical"];
+
+interface ParsedFile {
+ path: string;
+ frontmatter: Record;
+ body: string;
+}
+
+function splitFrontmatter(raw: string): { frontmatter: unknown; body: string } {
+ const withoutBom = raw.replace(/^\uFEFF/, "");
+ const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(withoutBom);
+ if (!match) {
+ throw new Error("missing YAML frontmatter fence");
+ }
+ const [, frontmatterYaml, body] = match;
+ return { frontmatter: parseYaml(frontmatterYaml), body: body.trim() };
+}
+
+function validateItems(
+ items: unknown,
+ path: string,
+ errors: string[],
+): void {
+ if (!Array.isArray(items)) {
+ errors.push(`${path}: "items" must be a list`);
+ return;
+ }
+ items.forEach((item, index) => {
+ if (typeof item !== "object" || item === null) {
+ errors.push(`${path}: items[${index}] must be an object`);
+ return;
+ }
+ const candidate = item as SiteContentItem;
+ if (!candidate.title && !candidate.quote) {
+ errors.push(`${path}: items[${index}] must have a "title" or "quote"`);
+ }
+ if (!candidate.body && !candidate.attribution) {
+ errors.push(`${path}: items[${index}] must have a "body" or "attribution"`);
+ }
+ });
+}
+
+function validateFrontmatter(
+ frontmatter: Record,
+ path: string,
+ errors: string[],
+): void {
+ for (const field of ["id", "page", "anchor", "title", "status"] as const) {
+ if (!frontmatter[field]) {
+ errors.push(`${path}: missing required field "${field}"`);
+ }
+ }
+ if (
+ frontmatter.page !== undefined &&
+ !PAGES.includes(frontmatter.page as SitePage)
+ ) {
+ errors.push(`${path}: invalid "page" value "${String(frontmatter.page)}"`);
+ }
+ if (
+ frontmatter.status !== undefined &&
+ !STATUSES.includes(frontmatter.status as SiteSectionStatus)
+ ) {
+ errors.push(`${path}: invalid "status" value "${String(frontmatter.status)}"`);
+ }
+ if (frontmatter.personas !== undefined) {
+ const personas = frontmatter.personas;
+ const valid =
+ Array.isArray(personas) &&
+ personas.every((persona) => PERSONAS.includes(persona as SitePersona));
+ if (!valid) {
+ errors.push(
+ `${path}: invalid "personas" value "${JSON.stringify(frontmatter.personas)}"`,
+ );
+ }
+ }
+ if (frontmatter.items !== undefined) {
+ validateItems(frontmatter.items, path, errors);
+ }
+}
+
+function findDuplicates(parsed: ParsedFile[], errors: string[]): void {
+ const idToPaths = new Map();
+ const anchorToPaths = new Map();
+
+ for (const file of parsed) {
+ const id = file.frontmatter.id as string | undefined;
+ const page = file.frontmatter.page as string | undefined;
+ const anchor = file.frontmatter.anchor as string | undefined;
+ if (id) {
+ idToPaths.set(id, [...(idToPaths.get(id) ?? []), file.path]);
+ }
+ if (page && anchor) {
+ const key = `${page}::${anchor}`;
+ anchorToPaths.set(key, [...(anchorToPaths.get(key) ?? []), file.path]);
+ }
+ }
+
+ for (const [id, paths] of idToPaths) {
+ if (paths.length > 1) {
+ errors.push(`duplicate id "${id}" in ${paths.join(", ")}`);
+ }
+ }
+ for (const [key, paths] of anchorToPaths) {
+ if (paths.length > 1) {
+ const [page, anchor] = key.split("::");
+ errors.push(`duplicate anchor "${anchor}" on page "${page}" in ${paths.join(", ")}`);
+ }
+ }
+}
+
+export function buildSiteContent(
+ rawFiles: Record,
+ options: { isProd?: boolean } = {},
+) {
+ const errors: string[] = [];
+ const parsed: ParsedFile[] = [];
+
+ for (const [path, raw] of Object.entries(rawFiles)) {
+ let frontmatter: unknown;
+ let body: string;
+ try {
+ ({ frontmatter, body } = splitFrontmatter(raw));
+ } catch (error) {
+ errors.push(`${path}: ${error instanceof Error ? error.message : String(error)}`);
+ continue;
+ }
+ if (typeof frontmatter !== "object" || frontmatter === null) {
+ errors.push(`${path}: frontmatter must be a YAML mapping`);
+ continue;
+ }
+ parsed.push({ path, frontmatter: frontmatter as Record, body });
+ }
+
+ for (const file of parsed) {
+ validateFrontmatter(file.frontmatter, file.path, errors);
+ }
+ findDuplicates(parsed, errors);
+
+ if (errors.length > 0) {
+ throw new Error(`Site content validation failed:\n${errors.join("\n")}`);
+ }
+
+ const sections = new Map();
+ for (const file of parsed) {
+ const fm = file.frontmatter;
+ sections.set(fm.id as string, {
+ id: fm.id as string,
+ page: fm.page as SitePage,
+ anchor: fm.anchor as string,
+ title: fm.title as string,
+ status: fm.status as SiteSectionStatus,
+ personas: fm.personas as SitePersona[] | undefined,
+ items: fm.items as SiteContentItem[] | undefined,
+ body: file.body,
+ });
+ }
+
+ function getSection(id: string): SiteSection | null {
+ const section = sections.get(id);
+ if (!section) {
+ throw new Error(`Unknown site content section "${id}"`);
+ }
+ const isProd = options.isProd ?? import.meta.env.PROD;
+ if (section.status === "draft" && isProd) {
+ return null;
+ }
+ return section;
+ }
+
+ return { getSection, sections };
+}
+
+const RAW_FILES = import.meta.glob(
+ ["../content/site/**/*.md", "!../content/site/README.md"],
+ { eager: true, query: "?raw", import: "default" },
+) as Record;
+
+const siteContent = buildSiteContent(RAW_FILES);
+
+export const getSection = siteContent.getSection;
diff --git a/frontend-typescript/src/pages/LandingPage.test.tsx b/frontend-typescript/src/pages/LandingPage.test.tsx
deleted file mode 100644
index 722dc01b..00000000
--- a/frontend-typescript/src/pages/LandingPage.test.tsx
+++ /dev/null
@@ -1,167 +0,0 @@
-import { render, screen } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
-import { MemoryRouter, Routes, Route } from "react-router-dom";
-import { AuthProvider } from "@/context/AuthContext";
-import LandingPage from "./LandingPage";
-
-function renderAt(path: string) {
- return render(
-
-
-
- } />
- Dashboard Page} />
-
-
- ,
- );
-}
-
-describe("LandingPage", () => {
- beforeEach(() => {
- localStorage.clear();
- sessionStorage.clear();
- });
-
- it("renders the mission-driven content when unauthenticated", () => {
- renderAt("/");
-
- expect(
- screen.getByText("Grant management without spreadsheets."),
- ).toBeInTheDocument();
- expect(
- screen.getByRole("link", { name: "Become a Founding Design Partner" }),
- ).toBeInTheDocument();
- expect(
- screen.getByRole("heading", { name: "Become a Founding Design Partner" }),
- ).toBeInTheDocument();
- });
-
- it("offers a Request Demo CTA in the nav that points to the contact section", () => {
- renderAt("/");
-
- const requestDemoLinks = screen.getAllByRole("link", {
- name: "Request Demo",
- });
- expect(requestDemoLinks.length).toBeGreaterThan(0);
- expect(requestDemoLinks[0]).toHaveAttribute("href", "#contact");
- });
-
- it("renders the product demo embed", () => {
- renderAt("/");
-
- const demoFrame = screen.getByTitle(
- "OpenGrantFlow - Grant Management Without Spreadsheets",
- );
- expect(demoFrame.tagName).toBe("IFRAME");
- expect(demoFrame).toHaveAttribute(
- "src",
- expect.stringContaining(
- "https://demo.arcade.software/video/bJX6Bh5bfgTZU5E2pUSK",
- ),
- );
- expect(demoFrame).toHaveAttribute("loading", "lazy");
- });
-
- it("does not offer login/sign-up/get-started anywhere on the page", () => {
- renderAt("/");
-
- expect(screen.queryByText(/sign up/i)).not.toBeInTheDocument();
- expect(screen.queryByText(/^log in$/i)).not.toBeInTheDocument();
- expect(screen.queryByText(/get started/i)).not.toBeInTheDocument();
- });
-
- it("renders the contact form and submits to Web3Forms", async () => {
- const fetchMock = vi
- .spyOn(global, "fetch")
- .mockResolvedValue(new Response("{}", { status: 200 }));
-
- renderAt("/");
-
- expect(screen.getByLabelText("Name")).toBeInTheDocument();
- expect(screen.getByLabelText("Organisation")).toBeInTheDocument();
- expect(screen.getByLabelText("Email")).toBeInTheDocument();
- expect(screen.getByLabelText("Role")).toBeInTheDocument();
- expect(screen.getByLabelText("Message")).toBeInTheDocument();
- expect(
- screen.getByLabelText("I'd like to request a demo"),
- ).toBeInTheDocument();
-
- await userEvent.type(screen.getByLabelText("Name"), "Jane Doe");
- await userEvent.type(
- screen.getByLabelText("Organisation"),
- "Acme Foundation",
- );
- await userEvent.type(screen.getByLabelText("Email"), "jane@example.org");
- await userEvent.selectOptions(screen.getByLabelText("Role"), "Foundation");
- await userEvent.type(
- screen.getByLabelText("Message"),
- "We'd love to talk.",
- );
- await userEvent.click(screen.getByLabelText("I'd like to request a demo"));
- await userEvent.click(
- screen.getByRole("button", { name: "Let's Start a Conversation" }),
- );
-
- expect(
- await screen.findByText(/we've received your message/i),
- ).toBeInTheDocument();
-
- expect(fetchMock).toHaveBeenCalledWith(
- "https://api.web3forms.com/submit",
- expect.objectContaining({ method: "POST" }),
- );
- const body = JSON.parse(fetchMock.mock.calls[0][1]?.body as string);
- expect(body).toMatchObject({
- name: "Jane Doe",
- organisation: "Acme Foundation",
- email: "jane@example.org",
- role: "Foundation",
- message: "We'd love to talk.",
- request_demo: true,
- });
-
- fetchMock.mockRestore();
- });
-
- it("shows an error message when the contact form submission fails", async () => {
- const fetchMock = vi
- .spyOn(global, "fetch")
- .mockRejectedValue(new Error("network error"));
-
- renderAt("/");
-
- await userEvent.type(screen.getByLabelText("Name"), "Jane Doe");
- await userEvent.type(
- screen.getByLabelText("Organisation"),
- "Acme Foundation",
- );
- await userEvent.type(screen.getByLabelText("Email"), "jane@example.org");
- await userEvent.selectOptions(screen.getByLabelText("Role"), "Foundation");
- await userEvent.type(
- screen.getByLabelText("Message"),
- "We'd love to talk.",
- );
- await userEvent.click(
- screen.getByRole("button", { name: "Let's Start a Conversation" }),
- );
-
- expect(
- await screen.findByText(/something went wrong sending your message/i),
- ).toBeInTheDocument();
-
- fetchMock.mockRestore();
- });
-
- it("redirects to /dashboard when authenticated", () => {
- localStorage.setItem("token", "fake-token");
- localStorage.setItem("username", "john");
-
- renderAt("/");
-
- expect(screen.getByText("Dashboard Page")).toBeInTheDocument();
- expect(
- screen.queryByText("Grant management without spreadsheets."),
- ).not.toBeInTheDocument();
- });
-});
diff --git a/frontend-typescript/src/pages/LandingPage.tsx b/frontend-typescript/src/pages/LandingPage.tsx
deleted file mode 100644
index 6d753862..00000000
--- a/frontend-typescript/src/pages/LandingPage.tsx
+++ /dev/null
@@ -1,861 +0,0 @@
-import { useState } from "react";
-import { Navigate } from "react-router-dom";
-import { useAuth } from "@/context/AuthContext";
-import {
- Wallet,
- Activity,
- Receipt,
- FileText,
- ShieldCheck,
- ArrowRight,
- Github,
- Linkedin,
- User,
-} from "lucide-react";
-import ogfIcon from "@/assets/logos/ogf-icon.svg";
-import productMockup from "@/assets/logos/opengrantflow-mockup.png";
-import { brand } from "@/lib/brand";
-
-const NAV_LINKS = [
- { href: "#vision", label: "Vision" },
- { href: "#platform", label: "Platform" },
- { href: "#founding-partners", label: "Founding Partners" },
- { href: "#about", label: "About" },
- { href: "#contact", label: "Contact" },
-];
-
-const TONE_COLORS = {
- grey: { border: "#94A3B8", text: "#64748B" },
- orange: { border: brand.gold, text: "#92702B" },
- red: { border: "#DC2626", text: "#DC2626" },
-} as const;
-
-const WORKFLOW_STEPS: { label: string; tone: keyof typeof TONE_COLORS }[] = [
- { label: "Grant", tone: "grey" },
- { label: "Spreadsheet", tone: "grey" },
- { label: "Email", tone: "grey" },
- { label: "Updated Spreadsheet", tone: "grey" },
- { label: "Receipt Folder", tone: "grey" },
- { label: "Word Report", tone: "grey" },
- { label: "Questions", tone: "orange" },
- { label: "Corrections", tone: "orange" },
- { label: "Audit", tone: "red" },
-];
-
-const SECTOR_QUOTES = [
- {
- quote:
- "Even sophisticated internal systems can still require exporting files to grantees and bringing them back.",
- role: "Former UK fund CEO",
- },
- {
- quote:
- "Multiple platforms solve different pieces, but not the basic cross-organisation workflow.",
- role: "International nonprofit leader",
- },
- {
- quote:
- "A donor portal can digitise the funder side while the grantee still keeps separate local records.",
- role: "Nonprofit / fund adviser",
- },
-];
-
-const AUDIENCES = [
- {
- emoji: "🏛",
- title: "Institutional Donors",
- description: "Manage grant portfolios more efficiently.",
- },
- {
- emoji: "🤝",
- title: "Foundations",
- description: "Reduce reporting burden for grantees.",
- },
- {
- emoji: "🌍",
- title: "International NGOs",
- description: "Standardise financial reporting.",
- },
- {
- emoji: "🏢",
- title: "Nonprofits",
- description: "Spend less time on administration.",
- },
-];
-
-const FEATURES = [
- {
- icon: Wallet,
- title: "Budget",
- description:
- "Create structured project budgets with optional AI assistance.",
- },
- {
- icon: Activity,
- title: "Track",
- description: "Record spending against approved budget lines.",
- },
- {
- icon: Receipt,
- title: "Receipts",
- description:
- "Keep receipts and supporting documents connected to every transaction.",
- },
- {
- icon: FileText,
- title: "Report",
- description:
- "Generate donor-ready financial reports without rebuilding spreadsheets.",
- },
- {
- icon: ShieldCheck,
- title: "Audit Ready",
- description:
- "Maintain a transparent financial history that simplifies audits and reviews.",
- },
-];
-
-const OPEN_SOURCE_ITEMS = [
- "use our hosted platform",
- "self-host",
- "contribute",
- "inspect the source code",
-];
-
-const IDEAL_PARTNERS = [
- "institutional donors",
- "grant-making foundations",
- "international NGOs",
- "nonprofit organisations",
-];
-
-const PARTNER_BENEFITS = [
- "direct collaboration with the founder",
- "early access",
- "influence over product direction",
- "pilot opportunities",
- "discounted hosted services when available",
-];
-
-const ROLE_OPTIONS = [
- "Institutional Donor",
- "Foundation",
- "Nonprofit",
- "NGO",
- "Technology Partner",
- "Volunteer",
- "Other",
-];
-
-function Kicker({ children }: { children: React.ReactNode }) {
- return (
-
- Open Grant Flow helps nonprofits and institutional donors manage the
- complete financial lifecycle of a grant—from budgeting and spending to
- reporting and audit readiness.
-
-
- Built from 17 years of nonprofit leadership and grant management
- experience.
-
-
- );
-}
-
-function ProblemSection() {
- return (
-
- The Problem
-
- Every grant creates another spreadsheet.
-
-
- Grant reporting is still too complicated
-
-
-
- Many grant-funded organisations still rely on spreadsheets, donor
- templates, email threads and manual reconciliation.
-
-
- This creates unnecessary administrative work for both nonprofits and
- donors.
-
-
- Instead of focusing on delivering impact, organisations spend valuable
- time preparing financial reports.
-
-
-
-
-
- Current workflow
-
-
- {WORKFLOW_STEPS.map(({ label, tone }, i) => {
- const next = WORKFLOW_STEPS[i + 1];
- return (
-
-
- {label}
-
- {next && (
-
- )}
-
- );
- })}
-
-
-
-
-
- From conversations across the sector
-
-
-
- {SECTOR_QUOTES.map(({ quote, role }) => (
-
-
- “{quote}”
-
-
- {role}
-
-
- ))}
-
-
-
-
- One leader of a small local nonprofit told us they went looking for
- an alternative and couldn't find one that fit — a sign the
- willingness is already there, once the right tool exists.
-
-
- From a conversation with a local nonprofit leader
-
-
-
-
- The emerging opportunity: interoperability, not another closed
- portal.
-
- );
-}
diff --git a/frontend-typescript/src/pages/Legal.test.tsx b/frontend-typescript/src/pages/Legal.test.tsx
deleted file mode 100644
index 52aa3a6a..00000000
--- a/frontend-typescript/src/pages/Legal.test.tsx
+++ /dev/null
@@ -1,20 +0,0 @@
-import { render, screen } from "@testing-library/react";
-import { MemoryRouter } from "react-router-dom";
-import LegalPage from "./Legal";
-
-describe("LegalPage", () => {
- it("renders both the privacy policy and terms of service", () => {
- render(
-
-
- ,
- );
-
- expect(
- screen.getByRole("heading", { name: "Privacy Policy" }),
- ).toBeInTheDocument();
- expect(
- screen.getByRole("heading", { name: "Terms of Service" }),
- ).toBeInTheDocument();
- });
-});
diff --git a/frontend-typescript/src/pages/Legal.tsx b/frontend-typescript/src/pages/Legal.tsx
deleted file mode 100644
index efc521ed..00000000
--- a/frontend-typescript/src/pages/Legal.tsx
+++ /dev/null
@@ -1,183 +0,0 @@
-import { Link } from "react-router-dom";
-import { brand } from "@/lib/brand";
-import ogfIcon from "@/assets/logos/ogf-icon.svg";
-
-const LAST_UPDATED = "August 2026";
-
-export default function LegalPage() {
- return (
-
-
-
-
-
-
- Open Grant Flow
-
-
-
-
-
-
-
-
Privacy Policy
-
- Last updated: {LAST_UPDATED}
-
-
-
-
- Open Grant Flow ("we", "us") is an open-source grant financial
- management platform, currently in active development. This
- policy explains what information we collect and how we use it.
-
-
-
-
Legal status
-
- Open Grant Flow is operated by its maintainer as an
- open-source project and is not yet a registered legal entity
- (company or nonprofit). This may change as the project
- develops.
-
-
-
-
-
Information we collect
-
- When you submit our contact form, we collect your name,
- organisation, email address, role and message. If you use our
- hosted platform, we collect the account and budget information
- needed to provide the service.
-
-
-
-
-
How we use it
-
- We use this information solely to respond to your enquiry, to
- operate the hosted platform, and to improve Open Grant Flow.
- We do not sell your information to third parties.
-
-
-
-
-
Self-hosting
-
- Open Grant Flow is open source. If you self-host the
- platform, your data stays on your own infrastructure and this
- policy does not apply to it.
-
-
-
-
-
How long we keep it
-
- We retain your information only for as long as needed to
- provide the service, respond to your enquiry, or meet legal
- and institutional record-keeping obligations. We don't yet
- publish a fixed retention schedule; this is being finalized
- as the platform matures.
-
-
-
-
-
Third-party processors
-
- We use a small number of third-party services to operate the
- hosted platform: Hetzner (infrastructure, EU), Mailjet and
- MailerSend (transactional email, EU), Anthropic (AI-assisted
- features, when you supply your own API key — this involves a
- transfer of data outside the EU/EEA to the US), and Grafana
- Cloud (operational monitoring). Each only receives the data
- needed to provide its service.
-
- By using Open Grant Flow, you agree to these terms. If you do
- not agree, please do not use the platform.
-
-
-
-
The service
-
- Open Grant Flow is open-source software, currently in active
- development. Features, availability and functionality may
- change as the platform evolves.
-
-
-
-
-
No warranty
-
- The platform is provided "as is", without warranty of any
- kind, express or implied, including during this active
- development phase.
-
-
-
-
-
Acceptable use
-
- You agree to use Open Grant Flow only for lawful purposes and
- not to misuse the platform or attempt to disrupt its
- operation.
-
-
-
-
-
Changes to these terms
-
- We may update these terms as the platform develops. Continued
- use of Open Grant Flow after changes means you accept the
- updated terms.
-
-
-
-
-
Contact
-
- Questions about these terms can be sent through our{" "}
-
- contact form
-
- .
-
-
-
-
-
-
- );
-}
diff --git a/frontend-typescript/src/pages/site/About.test.tsx b/frontend-typescript/src/pages/site/About.test.tsx
new file mode 100644
index 00000000..e484899c
--- /dev/null
+++ b/frontend-typescript/src/pages/site/About.test.tsx
@@ -0,0 +1,60 @@
+import { render, screen } from "@testing-library/react";
+import { MemoryRouter } from "react-router-dom";
+import AboutPage from "./About";
+
+function renderPage() {
+ return render(
+
+
+ ,
+ );
+}
+
+describe("AboutPage", () => {
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ it("renders the mission, origin story, values and open-code sections", () => {
+ renderPage();
+
+ expect(
+ screen.getByRole("heading", {
+ name: "Grant management should be collaborative, not administrative",
+ }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("heading", { name: "Built from lived experience" }),
+ ).toBeInTheDocument();
+ expect(screen.getByRole("heading", { name: "What we value" })).toBeInTheDocument();
+ expect(screen.getByText("Transparency")).toBeInTheDocument();
+ expect(screen.getByRole("heading", { name: "Open by design" })).toBeInTheDocument();
+ });
+
+ it("links to the public source code with safe external-link attributes", () => {
+ renderPage();
+
+ const github = screen.getByRole("link", { name: "GitHub" });
+ expect(github).toHaveAttribute("href", "https://github.com/arutsh/OpenGrantFlow");
+ expect(github).toHaveAttribute("rel", "noopener noreferrer");
+ });
+
+ it("shows the draft CIC set-up section with a TBC marker outside production", () => {
+ vi.stubEnv("PROD", false);
+
+ renderPage();
+
+ expect(screen.getByText("How we are set up")).toBeInTheDocument();
+ expect(screen.getByText("TBC")).toBeInTheDocument();
+ });
+
+ it("hides the draft CIC set-up section in production", () => {
+ vi.stubEnv("PROD", true);
+
+ renderPage();
+
+ expect(screen.queryByText("How we are set up")).not.toBeInTheDocument();
+ expect(screen.queryByText(/Community Interest Company/)).not.toBeInTheDocument();
+ expect(screen.queryByText("TBC")).not.toBeInTheDocument();
+ });
+});
diff --git a/frontend-typescript/src/pages/site/About.tsx b/frontend-typescript/src/pages/site/About.tsx
new file mode 100644
index 00000000..abfef06c
--- /dev/null
+++ b/frontend-typescript/src/pages/site/About.tsx
@@ -0,0 +1,110 @@
+import { User } from "lucide-react";
+import { getSection, type SiteSection } from "@/lib/siteContent";
+import { SiteMarkdown } from "@/components/site/SiteMarkdown";
+import { SectionVisibility } from "@/components/site/SectionVisibility";
+
+function CardGrid({ section }: { section: SiteSection }) {
+ return (
+
+ >
+ );
+}
diff --git a/frontend-typescript/src/pages/site/publicRoutes.test.tsx b/frontend-typescript/src/pages/site/publicRoutes.test.tsx
new file mode 100644
index 00000000..c9726ecf
--- /dev/null
+++ b/frontend-typescript/src/pages/site/publicRoutes.test.tsx
@@ -0,0 +1,149 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import App from "@/App";
+
+function makeFakeJwt(payload: Record): string {
+ const header = btoa(JSON.stringify({ alg: "none", typ: "JWT" }));
+ const body = btoa(JSON.stringify(payload));
+ return `${header}.${body}.signature`;
+}
+
+function renderAppAt(path: string) {
+ window.history.pushState({}, "", path);
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ });
+ return render(
+
+
+ ,
+ );
+}
+
+function signInAsVerifiedUser() {
+ localStorage.setItem(
+ "token",
+ makeFakeJwt({ user_id: "u1", email_verified: true }),
+ );
+ localStorage.setItem("username", "jane@example.com");
+}
+
+describe("public site routing", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ sessionStorage.clear();
+ });
+
+ it.each([false, true])(
+ "keeps About, Contact and demo navigation public (authenticated: %s)",
+ async (authenticated) => {
+ if (authenticated) signInAsVerifiedUser();
+ renderAppAt("/security");
+
+ await userEvent.click(screen.getAllByRole("link", { name: "About" })[0]);
+ expect(window.location.pathname).toBe("/about");
+ expect(screen.getByRole("heading", { name: "Built from lived experience" })).toBeInTheDocument();
+
+ await userEvent.click(screen.getAllByRole("link", { name: "Contact" })[0]);
+ expect(window.location.pathname).toBe("/contact");
+ expect(screen.getByRole("heading", { name: "Start a conversation" })).toBeInTheDocument();
+
+ await userEvent.click(screen.getAllByRole("link", { name: "Security" })[0]);
+ await userEvent.click(screen.getByRole("link", { name: "Request Demo" }));
+ expect(window.location.pathname).toBe("/contact");
+ expect(screen.getByRole("textbox", { name: "Name" })).toBeInTheDocument();
+
+ await userEvent.click(screen.getAllByRole("link", { name: "Security" })[0]);
+ await userEvent.click(screen.getByRole("link", { name: "request a demo" }));
+ expect(window.location.pathname).toBe("/contact");
+ expect(screen.getByRole("textbox", { name: "Name" })).toBeInTheDocument();
+ },
+ );
+
+ it.each(["/", "/legal"])(
+ "renders %s inside the shared public header and footer",
+ async (path) => {
+ renderAppAt(path);
+
+ expect(
+ await screen.findByRole("navigation", { name: "Primary" }),
+ ).toBeInTheDocument();
+ expect(screen.getAllByRole("banner")).toHaveLength(1);
+ expect(screen.getAllByRole("contentinfo")).toHaveLength(1);
+ },
+ );
+
+ it.each([
+ ["/about", "Built from lived experience", "About · Open Grant Flow"],
+ ["/contact", "Start a conversation", "Contact · Open Grant Flow"],
+ ])("renders %s directly with its title (anonymous and authenticated)", async (path, heading, title) => {
+ for (const authenticated of [false, true]) {
+ localStorage.clear();
+ if (authenticated) signInAsVerifiedUser();
+ const { unmount } = renderAppAt(path);
+
+ expect(await screen.findByRole("heading", { name: heading })).toBeInTheDocument();
+ expect(window.location.pathname).toBe(path);
+ expect(document.title).toBe(title);
+ unmount();
+ }
+ });
+
+ it("points every Request Demo CTA in the shared header to /contact", async () => {
+ renderAppAt("/about");
+ await userEvent.click(screen.getByRole("button", { name: "Toggle navigation menu" }));
+
+ const ctas = screen.getAllByRole("link", { name: "Request Demo" });
+ expect(ctas.length).toBeGreaterThanOrEqual(2);
+ for (const cta of ctas) {
+ expect(cta).toHaveAttribute("href", "/contact");
+ }
+ });
+
+ it("renders How it works directly for an anonymous visitor, with its title", async () => {
+ renderAppAt("/how-it-works");
+
+ await waitFor(() => {
+ expect(
+ screen.getByText("One continuous financial record, from budget to report"),
+ ).toBeInTheDocument();
+ });
+ expect(document.title).toBe("How it works · Open Grant Flow");
+ });
+
+ it("renders How it works for an authenticated visitor without redirecting", async () => {
+ signInAsVerifiedUser();
+
+ renderAppAt("/how-it-works");
+
+ await waitFor(() => {
+ expect(
+ screen.getByText("One continuous financial record, from budget to report"),
+ ).toBeInTheDocument();
+ });
+ });
+
+ it("renders Security & data directly for an anonymous visitor, with its title", async () => {
+ renderAppAt("/security");
+
+ await waitFor(() => {
+ expect(
+ screen.getByText("Handled with the care we'd want for our own data"),
+ ).toBeInTheDocument();
+ });
+ expect(document.title).toBe("Security & data · Open Grant Flow");
+ });
+
+ it("renders Security & data for an authenticated visitor without redirecting", async () => {
+ signInAsVerifiedUser();
+
+ renderAppAt("/security");
+
+ await waitFor(() => {
+ expect(
+ screen.getByText("Handled with the care we'd want for our own data"),
+ ).toBeInTheDocument();
+ });
+ });
+});
diff --git a/frontend-typescript/src/pages/site/sectionLifecycle.test.tsx b/frontend-typescript/src/pages/site/sectionLifecycle.test.tsx
new file mode 100644
index 00000000..3399c46e
--- /dev/null
+++ b/frontend-typescript/src/pages/site/sectionLifecycle.test.tsx
@@ -0,0 +1,77 @@
+import type { ComponentType } from "react";
+import { render, screen } from "@testing-library/react";
+import { MemoryRouter } from "react-router-dom";
+import { AuthProvider } from "@/context/AuthContext";
+import { buildSiteContent, getSection, type SitePage } from "@/lib/siteContent";
+import HomePage from "./Home";
+import HowItWorksPage from "./HowItWorks";
+import SecurityPage from "./Security";
+import AboutPage from "./About";
+import ContactPage from "./Contact";
+import LegalPage from "./Legal";
+
+vi.mock("@/lib/siteContent", async (importOriginal) => ({
+ ...await importOriginal(),
+ getSection: vi.fn(),
+}));
+
+const files = import.meta.glob(
+ ["../../content/site/**/*.md", "!../../content/site/README.md"],
+ { eager: true, query: "?raw", import: "default" },
+) as Record;
+const sections = [...buildSiteContent(files).sections.values()];
+const PAGES: Partial> = {
+ home: HomePage,
+ "how-it-works": HowItWorksPage,
+ security: SecurityPage,
+ about: AboutPage,
+ contact: ContactPage,
+ legal: LegalPage,
+};
+
+describe.each(sections)("$id publication lifecycle", (section) => {
+ it.each([false, true])("handles draft content (production: %s)", (isProd) => {
+ const changed = Object.fromEntries(Object.entries(files).map(([path, raw]) => [
+ path,
+ raw.includes(`id: ${section.id}\n`)
+ ? raw.replace(/status: (published|draft)/, "status: draft")
+ : raw.replace(/status: (published|draft)/, "status: published"),
+ ]));
+ vi.mocked(getSection).mockImplementation(buildSiteContent(changed, { isProd }).getSection);
+ const Page = PAGES[section.page];
+ if (!Page) throw new Error(`No page component mapped for "${section.page}"`);
+ render();
+
+ if (isProd) {
+ expect(document.getElementById(section.anchor)).not.toBeInTheDocument();
+ expect(screen.queryByText("TBC")).not.toBeInTheDocument();
+ } else {
+ expect(document.getElementById(section.anchor)).toBeInTheDocument();
+ expect(screen.getByText("TBC")).toBeInTheDocument();
+ }
+ });
+});
+
+it.each([false, true])("removes TBC after publishing security sections (production: %s)", (isProd) => {
+ const published = Object.fromEntries(Object.entries(files).map(([path, raw]) => [
+ path, raw.replace("status: draft", "status: published"),
+ ]));
+ vi.mocked(getSection).mockImplementation(buildSiteContent(published, { isProd }).getSection);
+ render();
+ expect(screen.getByText("GDPR compliance")).toBeInTheDocument();
+ expect(screen.getByText("Where your data is stored")).toBeInTheDocument();
+ expect(screen.queryByText("TBC")).not.toBeInTheDocument();
+});
+
+it("renders every sector-validation anecdote, not just the first", () => {
+ const withSecondAnecdote = Object.fromEntries(Object.entries(files).map(([path, raw]) => [
+ path,
+ raw.includes("id: how-it-works.sector-validation\n")
+ ? raw.replace("items:\n", "items:\n - title: Second source\n body: Second anecdote body.\n")
+ : raw,
+ ]));
+ vi.mocked(getSection).mockImplementation(buildSiteContent(withSecondAnecdote).getSection);
+ render();
+ expect(screen.getByText("Second anecdote body.")).toBeInTheDocument();
+ expect(screen.getByText(/One leader of a small local nonprofit/)).toBeInTheDocument();
+});
diff --git a/frontend-typescript/src/style.css b/frontend-typescript/src/style.css
index 85e85c12..f69f43ad 100644
--- a/frontend-typescript/src/style.css
+++ b/frontend-typescript/src/style.css
@@ -1,5 +1,17 @@
@import "tailwindcss";
+/* Brand palette: generates bg-brand-*, text-brand-*, border-brand-* utilities. */
+@theme {
+ --color-brand-navy: #163A5F;
+ --color-brand-teal: #1F8A70;
+ --color-brand-gold: #D9A441;
+ --color-brand-off-white: #F8FAFC;
+ --color-brand-slate: #243447;
+ --color-brand-mist: #EEF2F6;
+ --color-brand-teal-tint: #EDF6F3;
+ --color-brand-teal-tint-border: #CDE7DE;
+}
+
/* Type-scale tokens from the budget-report-frontend mockup
(openspec/changes/budget-report-frontend, ticket #160). Adopted going
forward on new/touched components — not retrofitted across the whole app
diff --git a/openspec/changes/ai-feat-site-assistant-retrieval-evals/.openspec.yaml b/openspec/changes/ai-feat-380-site-assistant-retrieval-evals/.openspec.yaml
similarity index 69%
rename from openspec/changes/ai-feat-site-assistant-retrieval-evals/.openspec.yaml
rename to openspec/changes/ai-feat-380-site-assistant-retrieval-evals/.openspec.yaml
index caf86712..48665c76 100644
--- a/openspec/changes/ai-feat-site-assistant-retrieval-evals/.openspec.yaml
+++ b/openspec/changes/ai-feat-380-site-assistant-retrieval-evals/.openspec.yaml
@@ -3,4 +3,4 @@ created: 2026-09-29
author: Norair Arutshyan
priority: medium
depends_on:
- - frontend-feat-public-site-content
+ - frontend-feat-372-public-site-content
diff --git a/openspec/changes/ai-feat-380-site-assistant-retrieval-evals/design.md b/openspec/changes/ai-feat-380-site-assistant-retrieval-evals/design.md
new file mode 100644
index 00000000..cc95acea
--- /dev/null
+++ b/openspec/changes/ai-feat-380-site-assistant-retrieval-evals/design.md
@@ -0,0 +1,45 @@
+# Design
+
+## Context
+
+See proposal.md for motivation. Additional facts that shape the approach:
+
+- `frontend-typescript/src/content/site/**` sections already have globally unique `id` (`.`), a canonical URL (`#`), optional `personas`, and a `status` of `published`/`draft` (see that directory's README.md). This is most of the `site_chunks` schema already.
+- `docs/user-guide/*.md` (`ngo-guide.md`, `donor-guide.md`) and `docs/PRODUCT.md` have no frontmatter and no public URL today.
+- `.github/workflows/deploy.yml` is a single SSH job that runs `docker compose up --build` for the whole stack on push to `main`, gated by `paths-ignore` that excludes `**/*.md`, `docs/**`, `frontend-typescript/**`. A content-only merge is invisible to it.
+- `.github/workflows/ai.yml` runs `pytest tests/ -v` for the ai service with no database service (existing tests use sqlite), so this change adds a `postgres:15` service to `ai-test` and passes it to `ts_rank_cd`-dependent tests as `AI_TEST_POSTGRES_URL` (those tests skip locally when it's unset, but never in CI). The job is gated by a `dorny/paths-filter` step scoped to `services/ai/**` and `shared/**`.
+- `chat-fix-prompt-injection-provenance`'s `scripts/eval_prompt_injection.py` is an opt-in, not-in-CI live eval against real providers. This change's harness is the opposite shape — deterministic, no LLM, CI-gating — but reuses its reporting layout (per-category breakdown, a committed baseline file) so the repo has one eval-report shape.
+
+## Goals / Non-Goals
+
+**Goals:**
+- A self-contained, deterministic retrieval layer and eval harness that needs no LLM and no live API calls.
+- Regressions in retrieval quality fail CI the same way a broken test does.
+- Guide docs become citable with a real URL without duplicating their content into `site-content`.
+
+**Non-Goals:**
+- Embeddings, hybrid search, or any ranking signal beyond Postgres full-text search — tracked as a later experiment (see proposal.md).
+- Generation/answering quality — this change stops at ranked chunks, not LLM answers.
+- A general-purpose docs-publishing system. `/guides/` renders `docs/user-guide/*.md` and `docs/PRODUCT.md` (as `product`) only; it does not become a CMS.
+
+## Decisions
+
+1. **`site_chunks` row per section, keyed by `content_id`.** For `site-content`, one row per published section file, copying `id` → `content_id`, `#` → `url`, `personas`, and the rendered title/body text. For guide docs, one row per H2 section: `content_id` is `.` (mirroring the `.` shape), `url` is `/guides/#`, `personas` is empty (guides aren't persona-scoped today).
+ - *Alternative considered:* chunk guide docs by fixed token windows. Rejected — H2 sections are already a natural retrieval unit and keep citations human-readable, matching how `site-content` sections work.
+2. **`/guides/` is a new frontend route**, `slug` being the guide doc's filename stem (`ngo-guide`, `donor-guide`), plus `product` for `docs/PRODUCT.md` so its indexed chunks cite a live URL. It reads the raw markdown via a Vite `?raw` import (same pattern already used for other static content) and renders it with the existing section-body markdown renderer. No new backend endpoint; the content ships in the frontend bundle like everything else under `src/content/`.
+ - This is a `public-site` capability change, but `public-site` doesn't exist in `openspec/specs/` yet — it's still a delta inside the not-yet-archived `frontend-feat-372-public-site-content`. This change's tasks therefore add the route directly (small, mechanical) rather than writing a spec delta against a capability that doesn't exist in the main tree yet. Once `frontend-feat-372-public-site-content` archives, `/guides/` should be folded into `public-site`'s spec as a follow-up — noted so it isn't lost.
+3. **`content_version` is a hash of every indexed field** (`sha256` over the chunk's text, URL, title, heading and personas), so a metadata-only edit such as a changed anchor or persona list still reaches the row. Ingestion upserts by `content_id` and only rewrites a row (and its generated `tsvector`) when the hash changes; rows whose `content_id` no longer exists in the source (unpublished or deleted section) are deleted. This makes ingestion idempotent and cheap to run on every startup.
+4. **Retrieval ranking:** `ts_rank_cd(tsvector, query)` as the base score; when a persona is supplied and the chunk has `personas`, a multiplicative boost (`1.0` no-match vs content with personas set, `1.2` match) nudges matching chunks up without hard-filtering, so a grantee's question can still surface an unscoped (no-persona) chunk.
+5. **Golden dataset format:** a single YAML file, `services/ai/tests/evals/data/golden_dataset.yaml`, each entry `{id, question, expected_content_ids: [...], persona, category, answerable: bool}`. `answerable: false` entries carry `expected_content_ids: []`.
+6. **Unanswerable scoring:** a configurable score threshold (initial value set from the observed score distribution once the dataset exists, recorded in the baseline file). A query is "correctly flagged unanswerable" when its top result's `ts_rank_cd` score falls below the threshold; the eval reports unanswerable accuracy as its own metric, not blended into Recall/MRR (which are computed over answerable questions only).
+7. **CI gate:** a baseline file `services/ai/tests/evals/baseline.yaml` holding the floor per metric (overall and per category), e.g. `recall_at_3: 0.9`. The eval harness is a `pytest` module (`services/ai/tests/evals/test_retrieval_evals.py`) that asserts every reported metric against its floor, so it runs as part of the existing `pytest tests/ -v` step in `ai.yml` — no new CI job. Raising a floor after a genuine improvement is a one-line edit to `baseline.yaml` in the same PR.
+8. **Reindex trigger in prod:** the ai service runs ingestion once at FastAPI startup (idempotent per Decision 3, so it's cheap even when nothing changed). Because a content-only push never reaches `deploy.yml`, a new dedicated workflow (`reindex-content.yml`) triggers on pushes to `main` touching `frontend-typescript/src/content/site/**`, `docs/user-guide/**`, or `docs/PRODUCT.md`, and, over SSH, resets the server checkout to `origin/main` then restarts only the `ai` container (`docker compose restart ai`) so the startup hook re-runs against fresh content. The prod `ai` service bind-mounts the three content paths read-only, so the restart sees the pulled files rather than the copy baked into the image. It does not rebuild images, so it's fast and doesn't race `deploy.yml`'s full rebuild.
+ - *Alternative considered:* a protected `/internal/reindex` HTTP endpoint callable from CI. Rejected for now — restarting the container is simpler and the ingestion is already idempotent and fast; an endpoint can be added later if restart latency becomes a problem.
+9. **`ai.yml`'s `paths-filter`** gains the same three content paths, so the eval gate (and the rest of the ai test suite) runs on content-only PRs too.
+
+## Risks / Trade-offs
+
+- [Guide-doc H2 chunking produces uneven chunk sizes, hurting ranking for very short or very long sections] → the golden dataset's guide-doc questions will surface this during tuning; no mitigation beyond the eval loop itself.
+- [`ts_rank_cd` lexical search misses paraphrased questions that share no vocabulary with the source text] → expected and acceptable for this change (see proposal.md's embeddings follow-up); the golden dataset should still include a few such questions so the baseline honestly reflects this gap rather than hiding it.
+- [Restarting the `ai` container on every content push adds a brief availability gap for that service] → acceptable; it's scoped to the `ai` container only (chat/budget/users keep serving), and restarts are fast since no image rebuild happens.
+- [Unanswerable-question threshold is picked from an initial dataset and may need retuning as the dataset grows] → it lives in the committed baseline file precisely so retuning is a reviewable one-line diff, not a code change.
diff --git a/openspec/changes/ai-feat-site-assistant-retrieval-evals/proposal.md b/openspec/changes/ai-feat-380-site-assistant-retrieval-evals/proposal.md
similarity index 52%
rename from openspec/changes/ai-feat-site-assistant-retrieval-evals/proposal.md
rename to openspec/changes/ai-feat-380-site-assistant-retrieval-evals/proposal.md
index e62be2be..be031081 100644
--- a/openspec/changes/ai-feat-site-assistant-retrieval-evals/proposal.md
+++ b/openspec/changes/ai-feat-380-site-assistant-retrieval-evals/proposal.md
@@ -2,15 +2,14 @@
The site assistant must answer from Open Grant Flow's own pages. Answer quality can't exceed what retrieval returns, so retrieval is built and measured on its own, before any LLM is involved. This change adds ingestion, lexical retrieval and a deterministic evaluation harness that gates CI. Retrieval regressions then become test failures instead of subjective impressions.
-> **Status: skeleton.** The open questions below must be resolved before specs, design and tasks are written.
-
## What Changes
-- **Ingestion:** reads the `site-content` section files (published only) plus `docs/user-guide/*.md` and `docs/PRODUCT.md`. It writes one row per section into a new `site_chunks` table in the ai database with `content_id`, `url`, `page_title`, `heading`, `personas`, `text`, `content_version`, and a generated `tsvector`. Guide docs have no frontmatter, so their chunks come from headings.
+- **Ingestion:** reads the `site-content` section files (published only) plus `docs/user-guide/*.md` and `docs/PRODUCT.md`. It writes one row per section into a new `site_chunks` table in the ai database with `content_id`, `url`, `page_title`, `heading`, `personas`, `text`, `content_version`, and a generated `tsvector`. Guide docs have no frontmatter, so their chunks are split by H2 heading and published at `/guides/` (extending `public-site`) so each chunk cites a real public URL.
- **Retrieval:** Postgres full-text search ranked with `ts_rank_cd`. There is an optional persona boost (grantee / funder / technical, matching the redesign's toggle). No LLM and no embeddings.
-- **Golden dataset:** about 50 realistic questions, each with its expected `content_id`(s), tagged by persona and category, including unanswerable questions that expect no confident match.
-- **Eval harness:** reports Recall@1/3/5 and MRR per category. It includes a **full-context baseline**: how many tokens the whole corpus costs versus top-k, so retrieval has to justify itself.
-- **CI gate:** retrieval evals run on every PR that touches content, the retriever or the dataset. The build fails when a metric drops below the committed baseline minus a tolerance.
+- **Golden dataset:** about 50 realistic questions, authored by the user, each with its expected `content_id`(s), tagged by persona and category, including unanswerable questions that expect no confident match.
+- **Eval harness:** reports Recall@1/3/5 and MRR per category, plus unanswerable-question accuracy via a retrieval-score threshold (below threshold = correctly flagged unanswerable). It includes a **full-context baseline**: how many tokens the whole corpus costs versus top-k, so retrieval has to justify itself.
+- **CI gate:** retrieval evals run on every PR that touches content, the retriever or the dataset. The build fails when a metric drops below a committed absolute floor per metric (e.g. Recall@3 ≥ 0.9).
+- **Reindexing:** the ai service reindexes on startup; a dedicated GitHub Actions workflow triggered on content paths also reindexes/redeploys, since `deploy.yml` currently ignores `**/*.md`, `docs/**` and `frontend-typescript/**` and a content-only merge would otherwise never reach prod.
- **Embeddings / hybrid search:** designed in as a later experiment inside this harness (a sibling `chunk_embeddings(chunk_id, embedding_model, embedding)` table so models can be compared side by side), not built here.
## Capabilities
@@ -26,16 +25,10 @@ The site assistant must answer from Open Grant Flow's own pages. Answer quality
## Impact
- **Depends on:** `frontend-feat-public-site-content` (hard; it is the content contract).
+- **Extends:** `public-site` with new `/guides/` public routes for guide docs, so guide chunks have a citable URL.
- **Related:** `chat-fix-prompt-injection-provenance`, whose opt-in live-eval layout should be reused so there is one eval-harness shape across the repo.
-- **ai service:** new `site_chunks` model and Alembic migration, an ingestion command, a retrieval service, and `tests/evals/`. `Dockerfile` must `COPY` the content and docs directories.
+- **ai service:** new `site_chunks` model and Alembic migration, an ingestion command run on startup, a retrieval service, and `tests/evals/`. `Dockerfile` must `COPY` the content and docs directories.
- **Python dependencies:** `pyyaml` (frontmatter). No vector dependency yet.
- **Infra:** none for lexical search. The later `pgvector/pgvector:pg15` image swap is recorded, not done.
-- **CI:** `ai.yml` already runs Postgres, so retrieval evals need no new service. The workflow needs path triggers for `frontend-typescript/src/content/site/**` and `docs/user-guide/**`.
-
-## Open Questions
-
-1. **When does reindexing run in prod?** `deploy.yml` ignores `**/*.md`, `docs/**` and `frontend-typescript/**`, so a content-only merge never redeploys the ai service. Options: index on ai startup plus a manual trigger; a dedicated workflow on content paths; or a Celery task. Leaning toward index on startup plus a content-path workflow.
-2. **Guide-doc chunking:** split by H2 or H3, and what canonical URL do guide chunks cite? They have no public page yet. Should they be published, e.g. `/guides/`, which would extend `public-site`?
-3. **Who writes the golden dataset?** You alone, or should some questions be collected from real prospects or pilot partners, to avoid an author-biased set?
-4. **Gate tolerance:** an absolute floor (e.g. Recall@3 ≥ 0.9), a delta from baseline (e.g. −3 pts), or both?
-5. **Should unanswerable questions be scored here** (via a retrieval-score threshold) or only in the generation change?
+- **CI:** `ai.yml`'s `ai-test` job gains a `postgres:15` service (it had none; unit tests ran on sqlite), exposed to Postgres-only tests as `AI_TEST_POSTGRES_URL`. The workflow needs path triggers for `frontend-typescript/src/content/site/**`, `docs/user-guide/**` and `docs/PRODUCT.md`.
+- **Deploy:** new/updated workflow to reindex on content-path changes, since `deploy.yml` doesn't trigger on those paths today.
diff --git a/openspec/changes/ai-feat-380-site-assistant-retrieval-evals/specs/retrieval-evaluation/spec.md b/openspec/changes/ai-feat-380-site-assistant-retrieval-evals/specs/retrieval-evaluation/spec.md
new file mode 100644
index 00000000..8559471b
--- /dev/null
+++ b/openspec/changes/ai-feat-380-site-assistant-retrieval-evals/specs/retrieval-evaluation/spec.md
@@ -0,0 +1,58 @@
+# Spec Delta
+
+## Purpose
+
+Measures `site-retrieval`'s quality against a committed golden dataset and fails CI when quality regresses, so retrieval changes are judged by evidence instead of impressions.
+
+## ADDED Requirements
+
+### Requirement: Golden dataset format
+The system SHALL define a golden dataset as a list of entries, each with a unique `id`, a `question`, `expected_content_ids` (empty when `answerable` is `false`), a `persona`, a `category`, and an `answerable` boolean.
+
+#### Scenario: Answerable entry has expected content
+- **WHEN** a golden dataset entry has `answerable: true`
+- **THEN** it carries at least one `content_id` in `expected_content_ids`
+
+#### Scenario: Unanswerable entry has no expected content
+- **WHEN** a golden dataset entry has `answerable: false`
+- **THEN** `expected_content_ids` is empty
+
+### Requirement: Recall and MRR reported per category
+The system SHALL compute Recall@1, Recall@3, Recall@5, and Mean Reciprocal Rank (MRR) over the dataset's answerable entries, both overall and broken down per `category`.
+
+#### Scenario: Recall@k counts a hit
+- **WHEN** an answerable entry's top-`k` results include at least one of its `expected_content_ids`
+- **THEN** it counts as a hit for Recall@k
+
+#### Scenario: MRR uses the first relevant rank
+- **WHEN** an answerable entry's first matching result appears at rank `r`
+- **THEN** that entry contributes `1/r` to MRR
+
+### Requirement: Unanswerable accuracy reported separately
+The system SHALL report, for entries with `answerable: false`, the fraction whose top result's score falls below a committed threshold, as a metric distinct from Recall/MRR.
+
+#### Scenario: Correctly flagged unanswerable
+- **WHEN** an `answerable: false` entry's top result score is below the threshold
+- **THEN** it counts toward unanswerable accuracy
+
+#### Scenario: False positive on unanswerable entry
+- **WHEN** an `answerable: false` entry's top result score is at or above the threshold
+- **THEN** it does not count toward unanswerable accuracy, and is distinguishable in the report from a correct flag
+
+### Requirement: Full-context token baseline reported
+The system SHALL report the token cost of the entire indexed corpus alongside the token cost of a typical top-k retrieval, so retrieval's savings are visible in the eval report.
+
+#### Scenario: Full-context baseline present in report
+- **WHEN** an eval run completes
+- **THEN** the report includes the whole-corpus token count and the average top-k token count for the run
+
+### Requirement: CI gate enforces a committed floor per metric
+The system SHALL compare every reported metric (overall and per category) against a floor recorded in a committed baseline file, and SHALL fail the run when any metric falls below its floor.
+
+#### Scenario: Metric at or above floor passes
+- **WHEN** a reported metric is greater than or equal to its baseline floor
+- **THEN** the gate passes for that metric
+
+#### Scenario: Metric below floor fails the run
+- **WHEN** a reported metric is below its baseline floor
+- **THEN** the eval run fails
diff --git a/openspec/changes/ai-feat-380-site-assistant-retrieval-evals/specs/site-knowledge-index/spec.md b/openspec/changes/ai-feat-380-site-assistant-retrieval-evals/specs/site-knowledge-index/spec.md
new file mode 100644
index 00000000..9c6429c3
--- /dev/null
+++ b/openspec/changes/ai-feat-380-site-assistant-retrieval-evals/specs/site-knowledge-index/spec.md
@@ -0,0 +1,43 @@
+# Spec Delta
+
+## Purpose
+
+Keeps a queryable, provenance-tagged index of the site's own content (public pages and user-guide docs) in sync with its sources, so retrieval always answers from what's actually published.
+
+## ADDED Requirements
+
+### Requirement: Published site-content sections are indexed
+The system SHALL index every `status: published` section file under `frontend-typescript/src/content/site/**` as one `site_chunks` row, carrying `content_id` (the section's `id`), `url` (`#`), `page_title`, `heading`, `personas`, and the rendered text.
+
+#### Scenario: Published section indexed
+- **WHEN** ingestion runs and a section file has `status: published`
+- **THEN** a `site_chunks` row exists with that section's `content_id`, `url`, and text
+
+#### Scenario: Draft section excluded
+- **WHEN** ingestion runs and a section file has `status: draft`
+- **THEN** no `site_chunks` row is created or kept for that section's `content_id`
+
+### Requirement: Guide docs are chunked by H2 and indexed
+The system SHALL split each file under `docs/user-guide/*.md` into one chunk per top-level (H2) section, writing a `site_chunks` row per chunk with `content_id` in the form `.` and `url` in the form `/guides/#`.
+
+#### Scenario: Guide doc split into chunks
+- **WHEN** ingestion processes `docs/user-guide/ngo-guide.md` containing multiple `##` headings
+- **THEN** one `site_chunks` row is created per `##` heading, each with a distinct `content_id` and `url`
+
+### Requirement: Ingestion is idempotent and prunes stale chunks
+The system SHALL upsert `site_chunks` rows keyed by `content_id`, rewriting a row only when the hash of its indexed fields (`content_version`) changes, and SHALL delete rows whose `content_id` no longer exists among published sources.
+
+#### Scenario: Unchanged content is not rewritten
+- **WHEN** ingestion runs twice in a row with no source changes
+- **THEN** the second run's `content_version` values are unchanged from the first run
+
+#### Scenario: Unpublished section is removed from the index
+- **WHEN** a previously published section's `status` changes to `draft` or the section file is deleted
+- **THEN** the next ingestion run deletes that section's `site_chunks` row
+
+### Requirement: Ingestion runs on ai service startup
+The system SHALL run ingestion once during FastAPI application startup, before the service accepts traffic.
+
+#### Scenario: Startup triggers ingestion
+- **WHEN** the ai service process starts
+- **THEN** ingestion completes and `site_chunks` reflects the current published sources before the first request is served
diff --git a/openspec/changes/ai-feat-380-site-assistant-retrieval-evals/specs/site-retrieval/spec.md b/openspec/changes/ai-feat-380-site-assistant-retrieval-evals/specs/site-retrieval/spec.md
new file mode 100644
index 00000000..c703cab6
--- /dev/null
+++ b/openspec/changes/ai-feat-380-site-assistant-retrieval-evals/specs/site-retrieval/spec.md
@@ -0,0 +1,36 @@
+# Spec Delta
+
+## Purpose
+
+Turns a natural-language query into a ranked list of indexed site sections, using Postgres full-text search with an optional persona boost — no LLM, no embeddings.
+
+## ADDED Requirements
+
+### Requirement: Query returns ranked chunks
+The system SHALL accept a free-text query and return `site_chunks` ranked by `ts_rank_cd` against the chunk's `tsvector`, most relevant first.
+
+#### Scenario: Query ranks matching chunks above non-matching ones
+- **WHEN** a query shares vocabulary with chunk A's text but not chunk B's
+- **THEN** chunk A ranks above chunk B in the returned results
+
+#### Scenario: No matching chunks
+- **WHEN** a query matches no chunk's `tsvector`
+- **THEN** the system returns an empty result list
+
+### Requirement: Persona boost on matching chunks
+The system SHALL accept an optional persona (`grantee`, `funder`, or `technical`) and, when supplied, SHALL apply a multiplicative score boost to chunks whose `personas` includes it, without excluding chunks that have no `personas` or a non-matching one.
+
+#### Scenario: Persona-matching chunk is boosted
+- **WHEN** a query is issued with persona `grantee` and two chunks score equally on `ts_rank_cd`, one tagged `personas: [grantee]` and one with no `personas`
+- **THEN** the `grantee`-tagged chunk ranks above the untagged chunk
+
+#### Scenario: No persona supplied
+- **WHEN** a query is issued with no persona
+- **THEN** ranking uses `ts_rank_cd` alone, with no boost applied
+
+### Requirement: Result count is bounded
+The system SHALL accept a caller-supplied `top_k` and SHALL return at most `top_k` results.
+
+#### Scenario: top_k limits results
+- **WHEN** a query matches more chunks than `top_k`
+- **THEN** only the `top_k` highest-ranked chunks are returned
diff --git a/openspec/changes/ai-feat-380-site-assistant-retrieval-evals/tasks.md b/openspec/changes/ai-feat-380-site-assistant-retrieval-evals/tasks.md
new file mode 100644
index 00000000..a77784c5
--- /dev/null
+++ b/openspec/changes/ai-feat-380-site-assistant-retrieval-evals/tasks.md
@@ -0,0 +1,46 @@
+# Tasks
+
+
+## Tracking
+
+Parent issue: [#380](https://github.com/arutsh/OpenGrantFlow/issues/380) · [Project board](https://github.com/users/arutsh/projects/8)
+
+| Group | Sub-issue | Branch | State |
+| --- | --- | --- | --- |
+| 1. Data model and ingestion | [#381](https://github.com/arutsh/OpenGrantFlow/issues/381) | `AI/feat/Issue-381/site-assistant-retrieval-evals-group1` | open |
+| 2. Retrieval service | [#382](https://github.com/arutsh/OpenGrantFlow/issues/382) | `AI/feat/Issue-382/site-assistant-retrieval-evals-group2` | open |
+| 3. Guide-doc public route | [#383](https://github.com/arutsh/OpenGrantFlow/issues/383) | `AI/feat/Issue-383/site-assistant-retrieval-evals-group3` | open |
+| 4. Golden dataset and eval harness | [#384](https://github.com/arutsh/OpenGrantFlow/issues/384) | `AI/feat/Issue-384/site-assistant-retrieval-evals-group4` | open |
+| 5. CI gate wiring | [#385](https://github.com/arutsh/OpenGrantFlow/issues/385) | `AI/feat/Issue-385/site-assistant-retrieval-evals-group5` | open |
+
+_Generated by `scripts/flow.py sync ai-feat-380-site-assistant-retrieval-evals` — do not edit by hand._
+
+
+## 1. Data model and ingestion — Issue #381
+
+- [x] 1.1 Add `SiteChunkModel` (`content_id`, `url`, `page_title`, `heading`, `personas`, `text`, `content_version`, generated `tsvector`) and an Alembic migration with a GIN index on the `tsvector` column; verify `alembic upgrade head` runs clean against a fresh ai database
+- [x] 1.2 Add `pyyaml` to the ai service's dependencies; verify it imports in the service's venv
+- [x] 1.3 Implement the site-content ingester: parse frontmatter from `frontend-typescript/src/content/site/**/*.md`, skip `status: draft`, upsert `site_chunks` by `content_id` using a hash-based `content_version`, delete rows for sources no longer published; add unit tests covering publish/draft/update/delete, and verify `pytest tests/test_site_content_ingestion.py -v` passes
+- [x] 1.4 Implement the guide-doc ingester: split `docs/user-guide/*.md` and `docs/PRODUCT.md` by H2 heading into chunks with `content_id`/`url` per design.md Decision 1, feeding the same upsert/delete path as 1.3; add unit tests and verify `pytest tests/test_guide_doc_ingestion.py -v` passes
+- [x] 1.5 Wire ingestion into FastAPI startup (idempotent, both sources) and update `Dockerfile` to `COPY` `frontend-typescript/src/content/site` and `docs/user-guide` (+ `docs/PRODUCT.md`) into the image; verify the service starts locally and `site_chunks` is populated
+
+## 2. Retrieval service — Issue #382
+
+- [x] 2.1 Implement the retrieval function: `ts_rank_cd`-ranked query against `site_chunks.tsvector`, bounded by `top_k`; add unit tests for ranking and the `top_k` bound, and verify `pytest tests/test_site_retrieval.py -v` passes
+- [x] 2.2 Add the persona boost per design.md Decision 4; add a unit test asserting a persona-matching chunk outranks an equal-scoring untagged chunk, and verify it passes
+
+## 3. Guide-doc public route — Issue #383
+
+- [x] 3.1 Add the `/guides/` frontend route that reads a `docs/user-guide/.md` file (or `docs/PRODUCT.md` for `product`) via `?raw` import and renders it with the existing section-body markdown renderer; verify `npm run build` succeeds and the route renders both guide docs and `product` locally
+- [x] 3.2 Verify (manually or with a Playwright check if the e2e suite already covers similar routes) that `/guides/ngo-guide#` and `/guides/donor-guide#` anchors land on the right section
+
+## 4. Golden dataset and eval harness — Issue #384
+
+- [x] 4.1 Write `services/ai/tests/evals/data/golden_dataset.yaml` with ~50 entries per design.md Decision 5, covering every persona/category and including unanswerable entries; verify it loads and validates against the expected schema in a quick script/test
+- [x] 4.2 Implement the eval harness (`services/ai/tests/evals/test_retrieval_evals.py`): compute Recall@1/3/5 and MRR per category, unanswerable accuracy via the score threshold, and the full-context token baseline; verify it runs end-to-end against a seeded test database and prints a per-category report
+- [x] 4.3 Run the harness against the real ingested corpus, record the resulting metrics, and write `services/ai/tests/evals/baseline.yaml` with the floor per metric (including the unanswerable-score threshold) per design.md Decision 7; verify `pytest services/ai/tests/evals/ -v` passes against this baseline
+
+## 5. CI gate wiring — Issue #385
+
+- [x] 5.1 Add `frontend-typescript/src/content/site/**`, `docs/user-guide/**`, and `docs/PRODUCT.md` to `ai.yml`'s `paths-filter`; verify a content-only diff triggers the `ai-test` job in a draft PR
+- [x] 5.2 Add `.github/workflows/reindex-content.yml` triggering on push to `main` for the same three paths, resetting the server checkout to `origin/main` then restarting the `ai` container over SSH per design.md Decision 8; verify the workflow's YAML is valid (`actionlint` or equivalent) and document the manual verification step (watch the next content-only merge restart the container) in the PR description
diff --git a/openspec/changes/frontend-feat-372-public-site-content/design.md b/openspec/changes/frontend-feat-372-public-site-content/design.md
index fb87cf94..57891a3c 100644
--- a/openspec/changes/frontend-feat-372-public-site-content/design.md
+++ b/openspec/changes/frontend-feat-372-public-site-content/design.md
@@ -22,7 +22,6 @@
- A CMS, or content editing outside git.
- Server-side rendering or prerendering for SEO. Worth revisiting later; it does not affect the content contract.
- The assistant UI or any AI behaviour.
-- Moving `/legal` copy into content files. It has its own spec (`privacy-policy`) and is not in the assistant's planned corpus.
## Decisions
@@ -64,12 +63,16 @@ A layout route wraps the five pages and renders the header (with a mobile menu t
### D9. Legacy anchor redirects on Home
Existing inbound links such as `/#contact` and `/#about` would otherwise land at the top of Home. Home maps the known legacy hashes to their new pages: `#contact`→`/contact`, `#about` and `#vision`→`/about`, `#platform` and `#problem`→`/how-it-works`, `#founding-partners`→`/contact#pilot`.
+### D10. `/legal` follows the same one-file-per-section contract, split at subsection granularity
+`page: legal` content files use the same D2 rule: one file per subsection, not one file for all of Privacy or all of Terms — e.g. `legal.privacy-legal-status`, `legal.privacy-retention`, `legal.privacy-processors`, `legal.privacy-contact`, `legal.terms-service`, `legal.terms-no-warranty`. This gives the assistant a citable ID per compliance fact instead of one undifferentiated page, which is the reason to migrate `/legal` at all. `Legal.tsx`'s `#privacy` and `#terms` anchors stay as the two on-page groupings; the route itself is unaffected.
+
## Risks / Trade-offs
- [Rewriting copy loses the current spec'd content, such as the anecdote] → The `landing-page` delta keeps the sector-validation requirement, including the anecdote, now placed on How it works. The page tests assert it.
- [Authors break an ID or anchor that later changes cite] → The spec treats that as a breaking content change. Once the retrieval change lands, its golden dataset will fail on a dangling ID, so the loss is caught by CI, not by visitors.
- [Draft text is visible in the bundle] → Accepted (D6). The repo is public.
- [No prerendering hurts SEO for five new URLs] → Same as today's single page. Deferred.
+- [Migrating compliance-sensitive legal copy risks wording drift] → Legal content files copy `Legal.tsx`'s existing text verbatim, no rewrite. A render test asserts the migrated sections still satisfy the `privacy-policy` spec's exact-wording scenarios (retention posture, subprocessor list, contact address, legal-status statement).
## Migration Plan
diff --git a/openspec/changes/frontend-feat-372-public-site-content/proposal.md b/openspec/changes/frontend-feat-372-public-site-content/proposal.md
index 297b5fac..82472e6e 100644
--- a/openspec/changes/frontend-feat-372-public-site-content/proposal.md
+++ b/openspec/changes/frontend-feat-372-public-site-content/proposal.md
@@ -4,7 +4,8 @@ The public site is one 861-line `LandingPage.tsx`, and all its copy is hard-code
## What Changes
-- Split the landing page into five public routes: `/`, `/how-it-works`, `/security`, `/about`, `/contact`. They share a header (with a mobile menu), a footer and per-page ``s. `/legal` is unchanged.
+- Split the landing page into five public routes: `/`, `/how-it-works`, `/security`, `/about`, `/contact`. They share a header (with a mobile menu), a footer and per-page ``s.
+- `/legal`'s copy moves into content files too, under the same one-file-per-section contract, so it's part of the assistant's retrievable corpus. Its route and in-page anchors (`#privacy`, `#terms`) don't change — only where the copy lives.
- Move all public-page copy into markdown files with YAML frontmatter under `frontend-typescript/src/content/site/`, **one file per section**. Frontmatter carries a stable `id`, `page`, `anchor`, `title`, optional `personas`, a `status` (`published` | `draft`) and optional structured `items` (cards, steps, quotes, FAQs).
- Page components own the layout. Copy comes from the content loader by section ID, so a copy edit never touches TSX.
- `draft` sections render only in non-production builds, with a visible "TBC" marker. They are never rendered in production. This is how the redesign's unresolved TBC items ship safely.
@@ -24,7 +25,7 @@ The public site is one 861-line `LandingPage.tsx`, and all its copy is hard-code
## Impact
-- **Frontend:** `LandingPage.tsx` is replaced by `src/pages/site/*`, `src/content/site/**` and `src/lib/siteContent.ts`. `App.tsx` gains public routes, registered before the `*` → `/dashboard` catch-all. `LandingPage.test.tsx` is replaced by per-page tests and the content validation test.
+- **Frontend:** `LandingPage.tsx` is replaced by `src/pages/site/*`, `src/content/site/**` and `src/lib/siteContent.ts`. `App.tsx` gains public routes, registered before the `*` → `/dashboard` catch-all. `LandingPage.test.tsx` is replaced by per-page tests and the content validation test. `Legal.tsx`/`Legal.test.tsx` are replaced the same way, on the same content loader.
- **New npm dependencies:** `react-markdown` (renders section bodies with no raw-HTML passthrough) and `yaml` (a browser-safe frontmatter parser). `gray-matter` is avoided because it needs Node `Buffer` polyfills under Vite.
- **Hosting:** Vercel (`vercel.json`) already rewrites every path to `index.html`, and so does `nginx.frontend.conf` (`try_files`), so no gateway change is needed.
- **Downstream:** `ai-feat-site-assistant-retrieval-evals` depends on this change and reads the same content files.
diff --git a/openspec/changes/frontend-feat-372-public-site-content/specs/site-content/spec.md b/openspec/changes/frontend-feat-372-public-site-content/specs/site-content/spec.md
index 544cc2b6..043b314a 100644
--- a/openspec/changes/frontend-feat-372-public-site-content/specs/site-content/spec.md
+++ b/openspec/changes/frontend-feat-372-public-site-content/specs/site-content/spec.md
@@ -7,7 +7,7 @@ Defines how public-site copy is authored: as versioned, individually addressable
## ADDED Requirements
### Requirement: Public-site copy is stored as one content file per section
-All copy on the public pages (Home, How it works, Security & data, About, Contact) SHALL be stored in repository content files, one file per page section. It SHALL NOT be embedded in page component source. Each file SHALL consist of YAML frontmatter followed by an optional markdown body.
+All copy on the public pages (Home, How it works, Security & data, About, Contact, Legal) SHALL be stored in repository content files, one file per page section. It SHALL NOT be embedded in page component source. Each file SHALL consist of YAML frontmatter followed by an optional markdown body.
#### Scenario: Copy edit without code change
- **WHEN** an author changes the wording of a public-page section
@@ -21,7 +21,7 @@ All copy on the public pages (Home, How it works, Security & data, About, Contac
### Requirement: Section frontmatter carries provenance metadata
Every section file's frontmatter SHALL include:
- `id`: a globally unique, stable identifier in the form `.`
-- `page`: one of `home`, `how-it-works`, `security`, `about`, `contact`
+- `page`: one of `home`, `how-it-works`, `security`, `about`, `contact`, `legal`
- `anchor`: a URL fragment, unique within its page
- `title`: the section heading as shown to visitors
- `status`: `published` or `draft`
diff --git a/openspec/changes/frontend-feat-372-public-site-content/tasks.md b/openspec/changes/frontend-feat-372-public-site-content/tasks.md
index 53ef6ce5..b8b7b3c9 100644
--- a/openspec/changes/frontend-feat-372-public-site-content/tasks.md
+++ b/openspec/changes/frontend-feat-372-public-site-content/tasks.md
@@ -18,31 +18,39 @@ _Generated by `scripts/flow.py sync frontend-feat-372-public-site-content` — d
## 1. Content contract, loader and first content-driven pages (How it works, Security & data) — Issue #373
-- [ ] 1.0 Run `scripts/flow.py start frontend-feat-public-site-content 1` to move this group to In Progress and create its branch before starting any other work in this group.
-- [ ] 1.1 Add `react-markdown` and `yaml` to `frontend-typescript/package.json`; verify `npm ci` and `npx vitest run` still pass.
-- [ ] 1.2 Implement `src/lib/siteContent.ts` (D4, D6): eager `?raw` glob of `src/content/site/**/*.md`, frontmatter split, contract validation, `getSection(id)` throwing on unknown IDs, and draft filtering by `import.meta.env.PROD`. Verify with `siteContent.test.ts` covering: missing field, bad `page`/`status`/`personas` value, duplicate `id`, duplicate anchor on the same page, draft hidden when PROD is stubbed true and shown when false.
-- [ ] 1.3 Add a `SiteMarkdown` component (D7) and a `TbcMarker` wrapper. Verify with tests that raw `