diff --git a/website/next.config.ts b/website/next.config.ts index 0b1135045..6d6492bed 100644 --- a/website/next.config.ts +++ b/website/next.config.ts @@ -9,6 +9,11 @@ const nextConfig: NextConfig = { destination: "/us", permanent: false, }, + { + source: "/tools", + destination: "/us/tools", + permanent: false, + }, ]; }, }; diff --git a/website/src/__tests__/pages/static-pages.test.tsx b/website/src/__tests__/pages/static-pages.test.tsx index 2b97b5d48..3a952215c 100644 --- a/website/src/__tests__/pages/static-pages.test.tsx +++ b/website/src/__tests__/pages/static-pages.test.tsx @@ -5,6 +5,7 @@ import PrivacyPage from "../../app/[countryId]/privacy/page"; import TermsPage from "../../app/[countryId]/terms/page"; import ResearchPage from "../../app/[countryId]/research/page"; import ClaudePluginPage from "../../app/[countryId]/claude-plugin/page"; +import ToolsPage from "../../app/[countryId]/tools/page"; describe("static pages", () => { test("Donate page renders heading", async () => { @@ -49,4 +50,12 @@ describe("static pages", () => { expect(el).toBeTruthy(); expect(el.type).toBeDefined(); }); + + test("Tools page returns a valid element", async () => { + const el = await ToolsPage({ + params: Promise.resolve({ countryId: "us" }), + }); + expect(el).toBeTruthy(); + expect(el.type).toBeDefined(); + }); }); diff --git a/website/src/app/[countryId]/brand/writing/page.tsx b/website/src/app/[countryId]/brand/writing/page.tsx index 8f504ca58..edf45206b 100644 --- a/website/src/app/[countryId]/brand/writing/page.tsx +++ b/website/src/app/[countryId]/brand/writing/page.tsx @@ -372,7 +372,7 @@ export default function BrandWritingPage() { > PolicyEngine's voice is research-oriented but accessible. We explain complex policy concepts clearly while maintaining rigor. - When writing about our products, the tone can be more natural and + When writing about our tools, the tone can be more natural and conversational.

diff --git a/website/src/app/[countryId]/page.tsx b/website/src/app/[countryId]/page.tsx index 957cd79c6..b987d021f 100644 --- a/website/src/app/[countryId]/page.tsx +++ b/website/src/app/[countryId]/page.tsx @@ -1,5 +1,6 @@ import HeroSection from "@/components/home/HeroSection"; import HomeBlogPreview from "@/components/home/HomeBlogPreview"; +import HomeToolsPreview from "@/components/home/HomeToolsPreview"; import HomeTrackerPreview from "@/components/home/HomeTrackerPreview"; import OrgLogos from "@/components/home/OrgLogos"; import FeaturedResearchBanner from "@/components/home/FeaturedResearchBanner"; @@ -17,6 +18,7 @@ export default async function HomePage({
+ diff --git a/website/src/app/[countryId]/tools/page.tsx b/website/src/app/[countryId]/tools/page.tsx new file mode 100644 index 000000000..75c77b496 --- /dev/null +++ b/website/src/app/[countryId]/tools/page.tsx @@ -0,0 +1,24 @@ +import type { Metadata } from "next"; +import ToolsShowcase from "@/components/tools/ToolsShowcase"; +import { getToolsForCountry } from "@/data/tools"; + +export const metadata: Metadata = { + title: "Tools", + description: + "Interactive PolicyEngine tools, calculators, and developer tooling.", +}; + +export default async function ToolsPage({ + params, +}: { + params: Promise<{ countryId: string }>; +}) { + const { countryId } = await params; + + return ( + + ); +} diff --git a/website/src/components/Header.tsx b/website/src/components/Header.tsx index ee3a87a84..fc7070a98 100644 --- a/website/src/components/Header.tsx +++ b/website/src/components/Header.tsx @@ -561,6 +561,11 @@ export default function Header() { const navItems: NavItemSetup[] = [ { label: "Research", href: `/${countryId}/research`, hasDropdown: false }, + { label: "Tools", href: `/${countryId}/tools`, hasDropdown: false }, + { label: "Model", href: `/${countryId}/model`, hasDropdown: false }, + ...(countryId === "us" + ? [{ label: "API", href: `/${countryId}/api`, hasDropdown: false }] + : []), { label: "About", hasDropdown: true, diff --git a/website/src/components/home/HomeToolsPreview.tsx b/website/src/components/home/HomeToolsPreview.tsx new file mode 100644 index 000000000..388d50896 --- /dev/null +++ b/website/src/components/home/HomeToolsPreview.tsx @@ -0,0 +1,211 @@ +import Link from "next/link"; +import { + colors, + spacing, + typography, +} from "@policyengine/design-system/tokens"; +import { getToolsForCountry } from "@/data/tools"; + +function ActionLink({ + href, + label, + external = false, +}: { + href: string; + label: string; + external?: boolean; +}) { + const style: React.CSSProperties = { + display: "inline-flex", + alignItems: "center", + gap: "8px", + textDecoration: "none", + color: colors.primary[700], + fontWeight: typography.fontWeight.semibold, + fontSize: typography.fontSize.sm, + fontFamily: typography.fontFamily.primary, + }; + + if (external) { + return ( + + {label} → + + ); + } + + return ( + + {label} → + + ); +} + +export default function HomeToolsPreview({ + countryId, +}: { + countryId: string; +}) { + const tools = getToolsForCountry(countryId).slice(0, 3); + + if (tools.length === 0) return null; + + return ( +
+
+
+
+

+ Tools +

+

+ Open tools, not just articles. +

+

+ Explore calculators, developer tools, and analysis tools built + for real use cases. +

+
+ + View all tools → + +
+ +
+ {tools.map((tool) => ( +
+
+ {tool.kind} +
+ +

+ {tool.title} +

+ +

+ {tool.summary} +

+ + +
+ ))} +
+
+
+ ); +} diff --git a/website/src/components/tools/ToolsShowcase.tsx b/website/src/components/tools/ToolsShowcase.tsx new file mode 100644 index 000000000..e98d1f31d --- /dev/null +++ b/website/src/components/tools/ToolsShowcase.tsx @@ -0,0 +1,1006 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import Link from "next/link"; +import { IconArrowRight } from "@tabler/icons-react"; +import { + colors, + typography, +} from "@policyengine/design-system/tokens"; +import { + CATEGORY_DESCRIPTIONS, + type ToolCategory, + type ToolDefinition, + type ToolTone, +} from "@/data/tools"; + +function useInView(threshold = 0.12) { + const ref = useRef(null); + const [visible, setVisible] = useState(false); + + useEffect(() => { + const element = ref.current; + if (!element) return; + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setVisible(true); + observer.disconnect(); + } + }, + { threshold }, + ); + + observer.observe(element); + return () => observer.disconnect(); + }, [threshold]); + + return { ref, visible }; +} + +function Reveal({ + children, + delay = 0, + style, +}: { + children: React.ReactNode; + delay?: number; + style?: React.CSSProperties; +}) { + const { ref, visible } = useInView(); + + return ( +
+ {children} +
+ ); +} + +const SECTION_X: React.CSSProperties = { + paddingLeft: "6.125%", + paddingRight: "6.125%", +}; + +const CONTAINER: React.CSSProperties = { + maxWidth: 1240, + marginLeft: "auto", + marginRight: "auto", +}; + +const toneStyles: Record< + ToolTone, + { + background: string; + border: string; + glow: string; + } +> = { + teal: { + background: + "linear-gradient(135deg, rgba(230,255,250,0.98), rgba(203,250,245,0.92))", + border: "rgba(56, 178, 172, 0.28)", + glow: "rgba(49, 151, 149, 0.12)", + }, + slate: { + background: + "linear-gradient(135deg, rgba(15,23,42,0.98), rgba(30,41,59,0.96))", + border: "rgba(148, 163, 184, 0.28)", + glow: "rgba(15, 23, 42, 0.22)", + }, + amber: { + background: + "linear-gradient(135deg, rgba(255,251,235,0.98), rgba(254,243,199,0.95))", + border: "rgba(217, 119, 6, 0.22)", + glow: "rgba(245, 158, 11, 0.12)", + }, + rose: { + background: + "linear-gradient(135deg, rgba(255,241,242,0.98), rgba(255,228,230,0.94))", + border: "rgba(225, 29, 72, 0.18)", + glow: "rgba(225, 29, 72, 0.1)", + }, + sky: { + background: + "linear-gradient(135deg, rgba(240,249,255,0.98), rgba(224,242,254,0.94))", + border: "rgba(2, 132, 199, 0.18)", + glow: "rgba(2, 132, 199, 0.12)", + }, +}; + +const countryLabels: Record = { + us: "United States", + uk: "United Kingdom", + ca: "Canada", + ng: "Nigeria", + il: "Israel", +}; + +const categoryOrder: ToolCategory[] = [ + "Policy calculators", + "Developer tools", + "Emulators and analysis tools", +]; + +const terminalStyles = { + comment: { prefix: "", color: "#6B7280", prefixColor: "#6B7280" }, + command: { prefix: "$ ", color: "#E5E7EB", prefixColor: "#7DD3FC" }, + prompt: { prefix: "> ", color: "#F8FAFC", prefixColor: "#5EEAD4" }, + output: { prefix: " ", color: "#CBD5E1", prefixColor: "#CBD5E1" }, + success: { prefix: " ", color: "#86EFAC", prefixColor: "#86EFAC" }, +} as const; + +function ActionLink({ + action, +}: { + action: { label: string; href: string; external?: boolean }; +}) { + const sharedStyle: React.CSSProperties = { + display: "inline-flex", + alignItems: "center", + gap: "8px", + padding: "13px 19px", + borderRadius: "999px", + textDecoration: "none", + fontFamily: typography.fontFamily.primary, + fontSize: typography.fontSize.sm, + fontWeight: typography.fontWeight.semibold, + transition: "transform 0.18s ease, box-shadow 0.18s ease, background-color 0.18s ease", + boxShadow: "0 18px 40px -24px rgba(35,78,82,0.35)", + backdropFilter: "blur(18px)", + WebkitBackdropFilter: "blur(18px)", + }; + + if (action.external) { + return ( + + {action.label} + + + ); + } + + return ( + + {action.label} + + + ); +} + +function ToolPreviewPanel({ tool }: { tool: ToolDefinition }) { + if (tool.preview.type === "image") { + return ( +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {tool.preview.alt} +
+ ); + } + + if (tool.preview.type === "metrics") { + return ( +
+
+

+ {tool.preview.eyebrow} +

+

+ {tool.title} +

+
+
+ {tool.preview.items.map((item) => ( +
+
+ {item.label} +
+
+ {item.value} +
+
+ ))} +
+
+ ); + } + + return ( +
+
+
+ + + +
+ {tool.preview.lines.map((line, index) => { + const style = terminalStyles[line.kind]; + return ( +
+ {style.prefix} + {line.text} +
+ ); + })} +
+ ); +} + +function ToolCard({ + tool, + featured = false, +}: { + tool: ToolDefinition; + featured?: boolean; +}) { + const tone = toneStyles[tool.tone]; + const isDarkTone = tool.tone === "slate"; + const headingColor = isDarkTone ? colors.white : colors.gray[900]; + const bodyColor = isDarkTone ? "rgba(255,255,255,0.82)" : colors.text.secondary; + + return ( +
+
+
+
+
+

+ {tool.title} +

+ +

+ {tool.summary} +

+
+ +
+ +
+ +
+
+
+
+ ); +} + +function CategoryCards({ tools }: { tools: ToolDefinition[] }) { + const grouped = useMemo(() => { + return categoryOrder + .map((category) => ({ + category, + tools: tools.filter((tool) => tool.category === category), + })) + .filter((group) => group.tools.length > 0); + }, [tools]); + + return ( +
+ {grouped.map((group, index) => ( + +
+

+ {group.category} +

+

+ {CATEGORY_DESCRIPTIONS[group.category].label} +

+

+ {CATEGORY_DESCRIPTIONS[group.category].description} +

+
+ {group.tools.map((tool) => ( + + {tool.title} + + ))} +
+
+
+ ))} +
+ ); +} + +export default function ToolsShowcase({ + countryId, + tools, +}: { + countryId: string; + tools: ToolDefinition[]; +}) { + const hasTools = tools.length > 0; + const countryLabel = countryLabels[countryId] ?? "your country"; + const [spotlight, ...rest] = tools; + + return ( + <> +
+
+
+
+
+ +

+ Tools for understanding public policy +

+
+ + +

+ {hasTools + ? `A curated set of calculators, developer tooling, and analysis tools for ${countryLabel}. Built to be opened, tested, and used in real workflows.` + : `We do not have a country-specific tools lineup for ${countryLabel} yet. Research remains available now, and more tools will land here as coverage expands.`} +

+
+
+
+
+ + {hasTools && spotlight && ( +
+
+ +
+

+ Flagship tools +

+

+ Built to invite action, not passive browsing. +

+

+ These tools earn attention by making the next step obvious: + install, launch, compare, or estimate. +

+
+
+ + + + +
+
+ )} + + {hasTools && rest.length > 0 && ( +
+
+
+ {rest.map((tool, index) => ( + + + + ))} +
+
+
+ )} + + {hasTools && ( +
+
+ +
+

+ Tool context +

+

+ Use this page to launch a tool. Use research to read findings. +

+

+ Tools should feel like the next action. Research should + feel like supporting evidence and interpretation. Keeping that + distinction obvious makes the page faster to understand. +

+
+
+ +
+ +
+
+
+

+ Tools +

+

+ Built for action. +

+

+ Launch a calculator, install a tool, compare scenarios, + or test inputs directly. +

+
+ +
+

+ Research +

+

+ Built for interpretation. +

+

+ Read findings, review charts, and understand the + context behind the numbers. +

+ + Go to research + + +
+
+ +
+ {[ + { + title: "Action first", + body: "Each tool card leads with the next thing a visitor can do.", + }, + { + title: "Distinct types", + body: "Calculators, developer tools, and emulators stay visibly separate.", + }, + { + title: "Real proof", + body: "Previews show interfaces, outputs, and use cases instead of filler copy.", + }, + ].map((item) => ( +
+

+ {item.title} +

+

+ {item.body} +

+
+ ))} +
+
+
+ + + + +
+
+
+ )} + + ); +} diff --git a/website/src/data/tools.ts b/website/src/data/tools.ts new file mode 100644 index 000000000..e99efb918 --- /dev/null +++ b/website/src/data/tools.ts @@ -0,0 +1,271 @@ +import type { CountryId } from "@/lib/countries"; + +export type ToolCategory = + | "Policy calculators" + | "Developer tools" + | "Emulators and analysis tools"; + +export type ToolTone = "teal" | "slate" | "amber" | "rose" | "sky"; + +export type ToolPreview = + | { + type: "terminal"; + lines: Array<{ + kind: "comment" | "command" | "prompt" | "output" | "success"; + text: string; + }>; + } + | { + type: "metrics"; + eyebrow: string; + items: Array<{ label: string; value: string }>; + } + | { + type: "image"; + src: string; + alt: string; + badge?: string; + objectPosition?: string; + }; + +export interface ToolAction { + label: string; + href: string; + external?: boolean; +} + +export interface ToolDefinition { + slug: string; + title: string; + summary: string; + category: ToolCategory; + countryIds: Array; + primaryAction: ToolAction; + tone: ToolTone; + preview: ToolPreview; + priority: number; +} + +export const CATEGORY_DESCRIPTIONS: Record< + ToolCategory, + { label: string; description: string } +> = { + "Policy calculators": { + label: "Estimate household and policy impacts quickly.", + description: + "Public-facing tools that turn complicated tax and benefit rules into usable calculators.", + }, + "Developer tools": { + label: "Build analysis faster.", + description: + "Tools for analysts and engineers who need direct access to PolicyEngine-powered workflows.", + }, + "Emulators and analysis tools": { + label: "Compare methods and stress-test assumptions.", + description: + "Tools designed for technical analysis, policy comparison, and deeper model exploration.", + }, +}; + +const toolDefinitions: ToolDefinition[] = [ + { + slug: "claude-plugin", + title: "Claude plugin", + summary: + "Run microsimulations, model reforms, and generate analysis directly from your terminal.", + category: "Developer tools", + countryIds: ["us", "uk"], + primaryAction: { + label: "Open tool", + href: "/{countryId}/claude-plugin", + }, + tone: "slate", + preview: { + type: "terminal", + lines: [ + { kind: "comment", text: "# Ask a policy question" }, + { + kind: "prompt", + text: "Estimate the impact of expanding the Child Tax Credit", + }, + { kind: "output", text: "Running microsimulation on household microdata..." }, + { kind: "success", text: "Impact summary ready with distributional results" }, + ], + }, + priority: 10, + }, + { + slug: "taxsim", + title: "Taxsim emulator", + summary: + "Explore TAXSIM-style tax calculations in a more modern PolicyEngine interface.", + category: "Emulators and analysis tools", + countryIds: ["us"], + primaryAction: { + label: "Open tool", + href: "/us/taxsim", + }, + tone: "sky", + preview: { + type: "metrics", + eyebrow: "Compare inputs and outputs", + items: [ + { label: "Federal tax", value: "$11,420" }, + { label: "Payroll tax", value: "$6,885" }, + { label: "Effective rate", value: "18.6%" }, + ], + }, + priority: 9, + }, + { + slug: "keep-your-pay-act", + title: "Keep Your Pay Act calculator", + summary: + "Estimate how Senator Booker's proposal changes taxes, credits, and net income.", + category: "Policy calculators", + countryIds: ["us"], + primaryAction: { + label: "Open tool", + href: "/us/keep-your-pay-act", + }, + tone: "amber", + preview: { + type: "image", + src: "/assets/posts/keep-your-pay-act-calculator.png", + alt: "Keep Your Pay Act calculator interface", + objectPosition: "center top", + }, + priority: 8, + }, + { + slug: "tanf-calculator", + title: "TANF calculator", + summary: + "Check TANF eligibility and benefit amounts across all 50 states and DC.", + category: "Policy calculators", + countryIds: ["us"], + primaryAction: { + label: "Open tool", + href: "https://policyengine.github.io/tanf-calculator/", + external: true, + }, + tone: "teal", + preview: { + type: "image", + src: "/assets/posts/tanf-calculator.png", + alt: "TANF calculator interface", + objectPosition: "center top", + }, + priority: 7, + }, + { + slug: "marriage-calculator", + title: "Marriage incentive calculator", + summary: + "See how marriage changes taxes, benefits, and take-home income for a household.", + category: "Policy calculators", + countryIds: ["us", "uk"], + primaryAction: { + label: "Open tool", + href: "https://marriage-zeta-beryl.vercel.app/", + external: true, + }, + tone: "rose", + preview: { + type: "image", + src: "/assets/posts/marriage-calculator.webp", + alt: "Marriage incentive calculator charts and controls", + objectPosition: "center top", + }, + priority: 6, + }, + { + slug: "uk-student-loan-calculator", + title: "Student loan deductions calculator", + summary: + "Analyse repayments, marginal tax rates, and take-home pay for UK graduates.", + category: "Policy calculators", + countryIds: ["uk"], + primaryAction: { + label: "Open tool", + href: "https://uk-student-loan-calculator.vercel.app/", + external: true, + }, + tone: "sky", + preview: { + type: "image", + src: "/assets/posts/uk-student-loan-calculator.webp", + alt: "Student loan deductions calculator charts", + objectPosition: "center top", + }, + priority: 8, + }, + { + slug: "uk-salary-sacrifice-tool", + title: "Salary sacrifice cap analysis tool", + summary: + "Test how caps on NI-exempt pension salary sacrifice affect revenue and households.", + category: "Emulators and analysis tools", + countryIds: ["uk"], + primaryAction: { + label: "Open tool", + href: "https://policyengine.github.io/uk-salary-sacrifice-analysis/", + external: true, + }, + tone: "teal", + preview: { + type: "metrics", + eyebrow: "Test policy options", + items: [ + { label: "Revenue", value: "GBP1.4B" }, + { label: "Affected workers", value: "2.1M" }, + { label: "Median change", value: "-GBP86" }, + ], + }, + priority: 7, + }, + { + slug: "local-areas-dashboard", + title: "UK local areas dashboard", + summary: + "Explore how national policies affect constituencies and local authorities across the UK.", + category: "Emulators and analysis tools", + countryIds: ["uk"], + primaryAction: { + label: "Open tool", + href: "https://local-area.vercel.app/", + external: true, + }, + tone: "amber", + preview: { + type: "metrics", + eyebrow: "Zoom in geographically", + items: [ + { label: "Coverage", value: "650 seats" }, + { label: "Local areas", value: "370+" }, + { label: "Views", value: "Map + tables" }, + ], + }, + priority: 6, + }, +]; + +function resolveActionHref(href: string, countryId: string) { + return href.replaceAll("{countryId}", countryId); +} + +export function getToolsForCountry(countryId: string): ToolDefinition[] { + return toolDefinitions + .filter((tool) => + tool.countryIds.includes("all") || + tool.countryIds.includes(countryId as CountryId), + ) + .map((tool) => ({ + ...tool, + primaryAction: { + ...tool.primaryAction, + href: resolveActionHref(tool.primaryAction.href, countryId), + }, + })) + .sort((left, right) => right.priority - left.priority); +}