diff --git a/frontend/README-VERCEL.md b/frontend/README-VERCEL.md
index 17e647c..b115f3c 100644
--- a/frontend/README-VERCEL.md
+++ b/frontend/README-VERCEL.md
@@ -55,6 +55,23 @@ unset in Preview and Development so those deployments emit no analytics:
| `NEXT_PUBLIC_POSTHOG_HOST` | `https://e.codeswhat.com` |
| `NEXT_PUBLIC_POSTHOG_UI_HOST` | `https://us.posthog.com` |
+### Support page
+
+`/support` reads `CODESWHAT_SUPPORT_LINKS`, a server-side JSON object mapping tier
+id to a Stripe Payment Link. The six ids are `monthly-5`, `monthly-10`,
+`monthly-25`, `monthly-100`, `once-10` and `once-50`:
+
+```
+CODESWHAT_SUPPORT_LINKS={"monthly-5":"https://buy.stripe.com/...","monthly-10":"https://buy.stripe.com/...","monthly-25":"https://buy.stripe.com/...","monthly-100":"https://buy.stripe.com/...","once-10":"https://buy.stripe.com/...","once-50":"https://buy.stripe.com/..."}
+```
+
+Only `https://buy.stripe.com/...` and `https://donate.stripe.com/...` links with a
+path and no credentials are accepted. Unknown ids and invalid links are dropped
+with a server warning, and any tier without a link renders as "Opening soon".
+The page is rendered per request, so changing the variable needs a redeploy but
+no rebuild of page code. Costs marked PLACEHOLDER in `lib/support.ts` are for the
+maintainer to fill in.
+
## Domain Configuration
1. Go to your Vercel project settings
diff --git a/frontend/app/(site)/layout.tsx b/frontend/app/(site)/layout.tsx
new file mode 100644
index 0000000..75048ef
--- /dev/null
+++ b/frontend/app/(site)/layout.tsx
@@ -0,0 +1,13 @@
+import { Footer } from "@/components/catalogue/Footer";
+import { Shell } from "@/components/catalogue/Shell";
+
+// The Catalogue shell wraps every public route. The dev-only /studio capture pages sit
+// outside this group so their transparent canvases are not painted over by the paper.
+export default function SiteLayout({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+
+ );
+}
diff --git a/frontend/app/(site)/page.tsx b/frontend/app/(site)/page.tsx
new file mode 100644
index 0000000..dc88eb0
--- /dev/null
+++ b/frontend/app/(site)/page.tsx
@@ -0,0 +1,260 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import styles from "@/components/catalogue/catalogue.module.css";
+import { Sprite } from "@/components/catalogue/Sprite";
+import { SupportLink } from "@/components/catalogue/SupportLink";
+import { TrackedCtaLink } from "@/components/catalogue/TrackedCtaLink";
+import { BASE_URL, GITHUB_URL, OG_IMAGE, PROJECTS, SITE_CONFIG } from "@/lib/site-config";
+import { SERVICES, SPECIMENS } from "@/lib/specimens";
+import { GITHUB_SPONSORS_URL } from "@/lib/support";
+
+const title = `${SITE_CONFIG.name}: ${SITE_CONFIG.tagline}`;
+
+export const metadata: Metadata = {
+ title,
+ description: SITE_CONFIG.description,
+ alternates: { canonical: "/" },
+ openGraph: {
+ title,
+ description: SITE_CONFIG.description,
+ url: BASE_URL,
+ siteName: SITE_CONFIG.name,
+ type: "website",
+ images: [OG_IMAGE],
+ },
+ twitter: {
+ card: "summary_large_image",
+ title,
+ description: SITE_CONFIG.description,
+ site: SITE_CONFIG.twitterCreator,
+ creator: SITE_CONFIG.twitterCreator,
+ images: [OG_IMAGE],
+ },
+};
+
+// schema.org graph — Organization + WebSite + Person (founder) + one
+// SoftwareSourceCode per project. This is the crawlable/answer-engine backbone,
+// server-rendered.
+const jsonLd = {
+ "@context": "https://schema.org",
+ "@graph": [
+ {
+ "@type": "Organization",
+ "@id": `${BASE_URL}/#org`,
+ name: SITE_CONFIG.name,
+ url: BASE_URL,
+ logo: `${BASE_URL}${SITE_CONFIG.logo}`,
+ description: SITE_CONFIG.description,
+ founder: { "@id": `${BASE_URL}/#scott` },
+ sameAs: [GITHUB_URL, SITE_CONFIG.twitterUrl],
+ contactPoint: {
+ "@type": "ContactPoint",
+ email: "hello@codeswhat.com",
+ contactType: "customer support",
+ },
+ },
+ {
+ "@type": "WebSite",
+ "@id": `${BASE_URL}/#website`,
+ url: BASE_URL,
+ name: SITE_CONFIG.name,
+ publisher: { "@id": `${BASE_URL}/#org` },
+ },
+ {
+ "@type": "Person",
+ "@id": `${BASE_URL}/#scott`,
+ name: SITE_CONFIG.author.name,
+ url: SITE_CONFIG.author.url,
+ },
+ ...PROJECTS.map((p) => ({
+ "@type": "SoftwareSourceCode",
+ name: p.name,
+ description: p.description,
+ codeRepository: p.href,
+ author: { "@id": `${BASE_URL}/#org` },
+ })),
+ ],
+};
+
+export default function Home() {
+ return (
+ <>
+
+
+
+
+
Open-source tools, and a developer for hire
+
+ One developer. A lot of agents. Small, sharp tools.
+
+
+ CodesWhat is Scott Benson, building open-source tools with a crowd of coding agents.
+ Each one does a single job, in the open, with nothing phoning home. They're filed
+ below as specimens. When you need something built, the same crew takes commissions.
+
+
+ Fig. 1
+ The collection, as of this morning. None have escaped.
+
+
+
+
+
+
+
Section I
+
+ The specimens, filed and labelled.
+
+
+ Eight projects, each drawn from its own mascot, with a Latin name that fits and field
+ notes that are mostly true. Open a plate for the full write-up.
+
+
+
+
+ {SPECIMENS.map((specimen, i) => (
+
+
+
+
+
+
+
+ Plate {specimen.no} · {specimen.kind}
+
+
+ {specimen.name}
+
+
{specimen.latin}
+
+
+
Habitat
+
{specimen.habitat}
+
+
+
Diet
+
{specimen.diet}
+
+
+
Temperament
+
{specimen.temperament}
+
+
+
+
+ {specimen.license} · {specimen.language}
+
+ Field notes
+
+ Visit
+ {specimen.name} (opens in a new tab)
+
+
+
+
+
+ ))}
+
+
+
+
+
+
+
Section II
+
+ CodesWhat? Anything for you.
+
+
+ The projects above are only what I have open-sourced. If you are building something,
+ I can help you ship it faster.
+
+ If these tools save you time and you'd like to chip in, you can sponsor me on GitHub
+ or give by card. Either way it goes toward hosting, domains and CI for every
+ project, monthly or one-time, whatever works for you.
+
+
+
+ Sponsor on GitHub
+ (opens in a new tab)
+
+
+ Other ways to give
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/frontend/app/(site)/projects/[slug]/page.tsx b/frontend/app/(site)/projects/[slug]/page.tsx
new file mode 100644
index 0000000..18c35af
--- /dev/null
+++ b/frontend/app/(site)/projects/[slug]/page.tsx
@@ -0,0 +1,167 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import styles from "@/components/catalogue/catalogue.module.css";
+import { Sprite } from "@/components/catalogue/Sprite";
+import { SupportLink } from "@/components/catalogue/SupportLink";
+import { TrackedCtaLink } from "@/components/catalogue/TrackedCtaLink";
+import { BASE_URL, SITE_CONFIG } from "@/lib/site-config";
+import { bySlug, SPECIMENS } from "@/lib/specimens";
+import { SYSTEMA } from "@/lib/support";
+
+export const dynamicParams = false;
+
+export function generateStaticParams() {
+ return SPECIMENS.map((s) => ({ slug: s.slug }));
+}
+
+export async function generateMetadata({
+ params,
+}: {
+ params: Promise<{ slug: string }>;
+}): Promise {
+ const s = bySlug((await params).slug);
+ if (!s) return {};
+ const title = `${s.name}, ${s.latin} | ${SITE_CONFIG.name}`;
+ const description = `${s.tagline}. Field notes, links and upkeep for ${s.name}.`;
+ const image = {
+ url: `/og/${s.slug}.png`,
+ width: 1200,
+ height: 630,
+ alt: `${s.name}, ${s.latin}: ${s.tagline}`,
+ };
+ return {
+ title,
+ description,
+ alternates: { canonical: `/projects/${s.slug}` },
+ openGraph: {
+ title,
+ description,
+ url: `${BASE_URL}/projects/${s.slug}`,
+ siteName: SITE_CONFIG.name,
+ type: "website",
+ images: [image],
+ },
+ twitter: { card: "summary_large_image", title, description, images: [image] },
+ };
+}
+
+export default async function SpecimenPage({ params }: { params: Promise<{ slug: string }> }) {
+ const s = bySlug((await params).slug);
+ if (!s) notFound();
+ const i = SPECIMENS.findIndex((x) => x.slug === s.slug);
+ const prev = SPECIMENS[(i + SPECIMENS.length - 1) % SPECIMENS.length];
+ const next = SPECIMENS[(i + 1) % SPECIMENS.length];
+
+ return (
+
+
+ ← All specimens
+
+
+
+
+
+
+
+
+ Plate {s.no} · {s.kind}
+
+
+ {s.name}
+
+
{s.latin}
+
{s.about}
+
+
{s.license}
+
{s.language}
+
{s.kind}
+
+
+
+ Visit the site
+ (opens in a new tab)
+
+ {s.repo ? (
+
+ Repository
+
+ ) : null}
+ {s.docs ? (
+
+ Docs
+
+ ) : null}
+
+
+
+
+
+
+
Field notes
+
+ What to look for.
+
+
+
+
+
Habitat
+
{s.habitat}
+
+
+
Diet
+
{s.diet}
+
+
+
Temperament
+
{s.temperament}
+
+
+
Marks
+
{s.marks}
+
+
+
Handling
+
{s.handling}
+
+
+
+
+
+ Upkeep
+ {s.upkeep}
+ {s.monthly === null ? (
+ Support
+ ) : (
+
+ Its support page
+ (opens in a new tab)
+
+ )}
+
+
+
+
+ );
+}
diff --git a/frontend/app/(site)/support/page.tsx b/frontend/app/(site)/support/page.tsx
new file mode 100644
index 0000000..9df2d06
--- /dev/null
+++ b/frontend/app/(site)/support/page.tsx
@@ -0,0 +1,274 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import styles from "@/components/catalogue/catalogue.module.css";
+import { Supporters } from "@/components/catalogue/Supporters";
+import { SupportLink } from "@/components/catalogue/SupportLink";
+import { BASE_URL, PROJECTS, SITE_CONFIG } from "@/lib/site-config";
+import {
+ GITHUB_SPONSORS_URL,
+ PROJECT_COSTS,
+ SHARED_COSTS,
+ type SupportProjectId,
+ SYSTEMA,
+ supportCtaId,
+ supportLinksFrom,
+ tiersWith,
+} from "@/lib/support";
+
+const description =
+ "Back CodesWhat through GitHub Sponsors or a Stripe payment link, see what each project costs to run, and what happens to a project that can't cover its costs.";
+
+const SUPPORT_OG = {
+ url: "/og/support.png",
+ width: 1200,
+ height: 630,
+ alt: "Support CodesWhat: what the projects cost to run, and how to help",
+};
+
+export const metadata: Metadata = {
+ title: `Support ${SITE_CONFIG.name}`,
+ description,
+ alternates: { canonical: "/support" },
+ openGraph: {
+ title: `Support ${SITE_CONFIG.name}`,
+ description,
+ url: `${BASE_URL}/support`,
+ siteName: SITE_CONFIG.name,
+ type: "website",
+ images: [SUPPORT_OG],
+ },
+ twitter: {
+ card: "summary_large_image",
+ title: `Support ${SITE_CONFIG.name}`,
+ description,
+ images: [SUPPORT_OG],
+ },
+};
+
+// read per request so the links can change without a rebuild
+export const dynamic = "force-dynamic";
+
+export default function SupportPage() {
+ const tiers = tiersWith(supportLinksFrom(process.env.CODESWHAT_SUPPORT_LINKS, console.warn));
+
+ return (
+
+
+ ← Back to the catalogue
+
+
+
+
+
Support
+
+ What it costs, and how to help.
+
+
+ These projects are free and open source, and they cost real money and time to keep
+ going. Everything below goes into one pool that covers whichever project needs it most.
+ Each project's running cost is listed further down.
+
+
+
+
+
+
+
One pool
+
+ Ways to chip in.
+
+
+ GitHub Sponsors is the easiest way to give if you're on GitHub. Not on GitHub? Pay by
+ card through Stripe instead. The tiers match.
+
+
+
+
Same tiers, pay through GitHub. It goes to the same pool.
+
+ Sponsor CodesWhat on GitHub
+ (opens in a new tab)
+
+
+ Stripe handles card payments on its own pages. This site loads nothing from Stripe, and a
+ link only opens when you click it. Supporting a project buys no say in its roadmap.
+
+ );
+}
diff --git a/frontend/components/catalogue/Sprite.tsx b/frontend/components/catalogue/Sprite.tsx
new file mode 100644
index 0000000..7706723
--- /dev/null
+++ b/frontend/components/catalogue/Sprite.tsx
@@ -0,0 +1,66 @@
+import { type Palette, SPRITES } from "./sprites";
+
+const BASE: Palette = { k: "var(--ink)" };
+
+/** Longest side across the whole set, so one integer scale fits every sprite and they all share an art-pixel size. */
+const SET_MAX = Math.max(
+ ...Object.values(SPRITES).flatMap((a) => [a.rows.length, ...a.rows.map((r) => r.length)]),
+);
+
+/**
+ * Draws a row-string sprite as whole-pixel SVG paths, one path per colour. Width and height are
+ * the grid size times an integer scale, so every art pixel lands on whole CSS pixels.
+ */
+export function Sprite({
+ slug,
+ scale,
+ fit,
+ label,
+ decorative = false,
+ className,
+}: {
+ slug: string;
+ scale: number;
+ /** when set, the largest whole-number scale that keeps the biggest sprite in the set inside a fit x fit box, so every sprite uses the same scale */
+ fit?: number;
+ label: string;
+ decorative?: boolean;
+ className?: string;
+}) {
+ const art = SPRITES[slug];
+ if (!art) throw new Error(`no sprite for ${slug}`);
+ const colors = { ...BASE, ...art.palette };
+ const w = Math.max(...art.rows.map((r) => r.length));
+ const h = art.rows.length;
+ const px = fit ? Math.max(1, Math.floor(fit / SET_MAX)) : scale;
+ const paths = new Map();
+ art.rows.forEach((row, y) => {
+ let x = 0;
+ while (x < row.length) {
+ const ch = row[x];
+ let end = x + 1;
+ while (end < row.length && row[end] === ch) end++;
+ if (ch !== "." && colors[ch]) {
+ paths.set(ch, `${paths.get(ch) ?? ""}M${x} ${y}h${end - x}v1h-${end - x}z`);
+ }
+ x = end;
+ }
+ });
+ return (
+
+ );
+}
diff --git a/frontend/components/catalogue/SupportLink.tsx b/frontend/components/catalogue/SupportLink.tsx
new file mode 100644
index 0000000..3eb9311
--- /dev/null
+++ b/frontend/components/catalogue/SupportLink.tsx
@@ -0,0 +1,18 @@
+"use client";
+
+import type { AnchorHTMLAttributes } from "react";
+import { TrackedCtaLink } from "@/components/catalogue/TrackedCtaLink";
+
+type SupportLinkProps = Omit, "href"> & {
+ href: string;
+ /** fixed analytics vocabulary id, e.g. sponsors_github or support_monthly-5 */
+ ctaId: string;
+};
+
+/**
+ * A support or funding link. Fires `cta activated` with a fixed cta_id and the
+ * support placement only; the href never reaches analytics.
+ */
+export function SupportLink({ ctaId, ...props }: SupportLinkProps) {
+ return ;
+}
diff --git a/frontend/components/catalogue/Supporters.tsx b/frontend/components/catalogue/Supporters.tsx
new file mode 100644
index 0000000..04b0642
--- /dev/null
+++ b/frontend/components/catalogue/Supporters.tsx
@@ -0,0 +1,46 @@
+import Image from "next/image";
+import Link from "next/link";
+import { companies, people } from "@/lib/supporters";
+import styles from "./catalogue.module.css";
+
+/** Supporters list for /support. Renders one invitation line while the list is empty. */
+export function Supporters() {
+ if (companies.length === 0 && people.length === 0) {
+ return (
+
+ No supporters yet. Pick a tier and yours could be the first name
+ here.
+
- );
-}
diff --git a/frontend/components/site/FloatingSceneCanvas.tsx b/frontend/components/site/FloatingSceneCanvas.tsx
deleted file mode 100644
index 5ff7545..0000000
--- a/frontend/components/site/FloatingSceneCanvas.tsx
+++ /dev/null
@@ -1,234 +0,0 @@
-"use client";
-
-import { Environment, Lightformer } from "@react-three/drei";
-import { Canvas, useFrame } from "@react-three/fiber";
-import { type ReactNode, type RefObject, Suspense, useEffect, useRef, useState } from "react";
-import type * as THREE from "three";
-import { CodesWhatCoin } from "@/components/site/scene/BoardObjects";
-import { MylarBalloon } from "@/components/site/scene/balloons/MylarBalloon";
-import { ContactMarble } from "@/components/site/scene/ContactMarble";
-
-type Floater = {
- key: string;
- url: string;
- /** scroll fraction (0=top, 1=bottom) where this balloon sits centered on
- * screen — tuned so each one rises into view alongside its own card. */
- focus: number;
- /** horizontal slot: negative = left column, positive = right column */
- x: number;
- z: number;
- size: number;
- tint: string;
- phase: number;
- behavior?: "swim" | "walk" | "flap";
- spin?: number;
- glanceFrames?: string[];
- blinkFrame?: string;
-};
-
-/**
- * Mascot balloons parked next to their cards. The four project balloons cluster
- * around the Projects section (top row higher, bottom row lower, left/right by
- * column) so the hero stays clear for the spinning coin; the headshot lives down
- * by the About section.
- */
-const FLOATERS: Floater[] = [
- // top-left card — the guard dog: bouncy steps, keeps turning to look around
- // (dropped below the section heading so it sits beside its card)
- {
- key: "sockguard",
- url: "/logos/sockguard-balloon.png",
- focus: 0.29,
- x: -3.3,
- z: -0.8,
- size: 2.2,
- tint: "#f3ede4",
- phase: 0,
- behavior: "walk",
- },
- // top-right card — the whale: glides side to side, turns around at each end
- // (dropped so it swims lower on its card)
- {
- key: "drydock",
- url: "/logos/drydock-balloon.png",
- focus: 0.32,
- x: 3.3,
- z: -1.2,
- size: 2.4,
- tint: "#e6eef7",
- phase: 1.7,
- behavior: "swim",
- },
- // bottom-left card — the bird: waves back and forth in place, like flapping
- // (dropped so its head lands under the card heading)
- {
- key: "portwing",
- url: "/logos/portwing-balloon.png",
- focus: 0.43,
- x: -2.6,
- z: -1.0,
- size: 2.0,
- tint: "#eef0f4",
- phase: 3.2,
- behavior: "flap",
- },
- // bottom-right card — the key: spins in place like the coin, pushed out to
- // the card's right edge (half on, half off) so more of it shows
- {
- key: "portkey",
- url: "/logos/portkey-balloon.png",
- focus: 0.43,
- x: 3.8,
- z: -1.2,
- size: 2.0,
- tint: "#e9edf6",
- phase: 5.5,
- spin: 0.4,
- },
- // the headshot — sits with the "Who's behind it" section, facing forward
- {
- key: "scott",
- url: "/logos/scott-balloon.png",
- focus: 0.79,
- x: 3.8,
- z: -0.4,
- size: 2.6,
- tint: "#eef1f7",
- phase: 4.6,
- glanceFrames: [
- "/logos/scott-look-left.png",
- "/logos/scott-look-right.png",
- "/logos/scott-look-up.png",
- "/logos/scott-look-down.png",
- ],
- blinkFrame: "/logos/scott-blink.png",
- },
-];
-
-// how far (world units) a floater travels per full page scroll — big enough
-// that each one drops out of frame once you scroll past its section.
-const SPAN = 20;
-
-/**
- * Parks its children at a scroll-driven height: centered on screen when the
- * page scroll reaches `focus`, sliding up and out as you scroll past. Each
- * floater owns one of these so it tracks its own section independently.
- */
-function ScrollFloat({
- scroll,
- focus,
- x,
- z,
- restY = 0,
- span = SPAN,
- children,
-}: {
- scroll: RefObject;
- focus: number;
- x: number;
- z: number;
- restY?: number;
- span?: number;
- children: ReactNode;
-}) {
- const group = useRef(null);
- const started = useRef(false);
-
- useFrame(() => {
- if (!group.current) return;
- const targetY = restY + (scroll.current - focus) * span;
- if (!started.current) {
- // snap on the first frame so nothing animates in from center on load
- group.current.position.y = targetY;
- started.current = true;
- } else {
- group.current.position.y += (targetY - group.current.position.y) * 0.08;
- }
- });
-
- return (
-
- {children}
-
- );
-}
-
-/** Tracks the `.dark` class on so the 3D layer can invert its brand
- * colors in dark mode (set by ThemeToggle / the init script in layout.tsx). */
-function useIsDark() {
- const [dark, setDark] = useState(true);
- useEffect(() => {
- const el = document.documentElement;
- const update = () => setDark(el.classList.contains("dark"));
- update();
- const observer = new MutationObserver(update);
- observer.observe(el, { attributes: true, attributeFilter: ["class"] });
- return () => observer.disconnect();
- }, []);
- return dark;
-}
-
-function Scene() {
- const scroll = useRef(0);
- const dark = useIsDark();
-
- useEffect(() => {
- const onScroll = () => {
- const max = document.documentElement.scrollHeight - window.innerHeight;
- scroll.current = max > 0 ? Math.min(1, Math.max(0, window.scrollY / max)) : 0;
- };
- onScroll();
- window.addEventListener("scroll", onScroll, { passive: true });
- window.addEventListener("resize", onScroll, { passive: true });
- return () => {
- window.removeEventListener("scroll", onScroll);
- window.removeEventListener("resize", onScroll);
- };
- }, []);
-
- return (
- <>
- {/* the spinning coin owns the hero — sits up high, drifts out as you scroll */}
-
-
-
-
- {FLOATERS.map(({ key, focus, x, z, ...m }) => (
-
-
-
- ))}
-
- {/* the clear glass "@" marble — stays hidden through the projects, rises
- in around the "Who's behind it" section, then rides a wide orbit
- around the "Get notified" card and darts away from the cursor */}
-
-
-
- >
- );
-}
-
-/** Transparent, fixed WebGL layer that composites over the aurora background. */
-export default function FloatingSceneCanvas() {
- return (
-
- );
-}
diff --git a/frontend/components/site/SiteBackground.tsx b/frontend/components/site/SiteBackground.tsx
deleted file mode 100644
index 041e85a..0000000
--- a/frontend/components/site/SiteBackground.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-/**
- * Fixed site background. Off-white with a faint lime/teal glow in light mode,
- * near-black with an indigo/blue glow in dark mode (matching the inverted
- * brand accent — the coin turns indigo, so the wash does too). The glow fades
- * out below ~the fold. No grid, no grain — just the wash behind the 3D layer.
- */
-export function SiteBackground() {
- return (
-
- {/* base — off-white in light, near-black in dark */}
-
-
- {/* light-mode glow (softer tint that still reads on white) */}
-
-
- {/* dark-mode glow (indigo/blue on black — the inverted brand accent) */}
-
-
- );
-}
diff --git a/frontend/components/site/SiteFooter.tsx b/frontend/components/site/SiteFooter.tsx
deleted file mode 100644
index ed19c85..0000000
--- a/frontend/components/site/SiteFooter.tsx
+++ /dev/null
@@ -1,65 +0,0 @@
-import { ArrowUpRight, Github } from "lucide-react";
-import type { CSSProperties } from "react";
-import { TrackedCtaLink } from "@/components/site/TrackedCtaLink";
-import { Wordmark } from "@/components/site/Wordmark";
-import { GITHUB_URL, PROJECTS, SITE_CONFIG } from "@/lib/site-config";
-
-/**
- * Lightweight dark CodesWhat footer — deliberately NOT the drydock SaaS footer.
- * Just the projects, the maker credit, GitHub, and a copyright line.
- */
-export function SiteFooter() {
- const year = new Date().getFullYear();
- return (
-
- );
-}
diff --git a/frontend/components/site/SiteHeader.tsx b/frontend/components/site/SiteHeader.tsx
deleted file mode 100644
index 1f3c1e3..0000000
--- a/frontend/components/site/SiteHeader.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-import { Github } from "lucide-react";
-import Link from "next/link";
-import { ThemeToggle } from "@/components/site/ThemeToggle";
-import { TrackedCtaLink } from "@/components/site/TrackedCtaLink";
-import { Wordmark } from "@/components/site/Wordmark";
-import { GITHUB_URL, SITE_CONFIG } from "@/lib/site-config";
-
-const navLinkCn =
- "text-sm text-fg-muted transition-colors hover:text-fg focus-visible:outline-none focus-visible:text-brand-hover";
-
-/**
- * Sticky, translucent dark header — concept-shell chrome (logo + lime-tracked
- * wordmark left, nav + GitHub right, blurred hairline border below).
- */
-export function SiteHeader() {
- return (
-
-
-
-
-
-
-
-
- );
-}
diff --git a/frontend/components/site/ThemeToggle.tsx b/frontend/components/site/ThemeToggle.tsx
deleted file mode 100644
index a077da5..0000000
--- a/frontend/components/site/ThemeToggle.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-"use client";
-
-import { Moon, Sun } from "lucide-react";
-import { useEffect, useState } from "react";
-
-/**
- * Header light/dark switch. Toggles the `dark` class on and persists the
- * choice to localStorage (read back by the no-flash init script in layout.tsx).
- * Dark is the default; this is the opt-in to light.
- */
-export function ThemeToggle() {
- const [mounted, setMounted] = useState(false);
- const [isDark, setIsDark] = useState(true);
-
- useEffect(() => {
- setIsDark(document.documentElement.classList.contains("dark"));
- setMounted(true);
- }, []);
-
- const toggle = () => {
- const next = !document.documentElement.classList.contains("dark");
- document.documentElement.classList.toggle("dark", next);
- try {
- localStorage.theme = next ? "dark" : "light";
- } catch {}
- setIsDark(next);
- };
-
- return (
-
- );
-}
diff --git a/frontend/components/site/ThemedToaster.tsx b/frontend/components/site/ThemedToaster.tsx
deleted file mode 100644
index 2a55d11..0000000
--- a/frontend/components/site/ThemedToaster.tsx
+++ /dev/null
@@ -1,45 +0,0 @@
-"use client";
-
-import { useEffect, useState } from "react";
-import { Toaster } from "sonner";
-
-/**
- * Sonner toasts themed to follow the `.dark` class on (set by the header
- * toggle and the no-flash script in layout.tsx) rather than the OS
- * prefers-color-scheme. Without this, a light-OS user with dark selected (the
- * app default) would get light-themed toasts over a dark shell.
- */
-export function ThemedToaster() {
- // default dark to match the app's dark-by-default shell (avoids a flash)
- const [dark, setDark] = useState(true);
- useEffect(() => {
- const el = document.documentElement;
- const update = () => setDark(el.classList.contains("dark"));
- update();
- const observer = new MutationObserver(update);
- observer.observe(el, { attributes: true, attributeFilter: ["class"] });
- return () => observer.disconnect();
- }, []);
-
- return (
-
- );
-}
diff --git a/frontend/components/site/Wordmark.tsx b/frontend/components/site/Wordmark.tsx
deleted file mode 100644
index 3270794..0000000
--- a/frontend/components/site/Wordmark.tsx
+++ /dev/null
@@ -1,16 +0,0 @@
-import { Geist_Mono } from "next/font/google";
-
-// Geist Mono — the go-to, and monospace keeps the terminal feel of the wordmark.
-const geistMono = Geist_Mono({ subsets: ["latin"] });
-
-/**
- * Text-only header wordmark: `codeswhat?` in mono, lowercase, with the "?" in
- * lime (echoing the C?W logo mark). No disc, no cursor — the type is the mark.
- */
-export function Wordmark() {
- return (
-
- codeswhat?
-
- );
-}
diff --git a/frontend/components/site/scene/ContactMarble.tsx b/frontend/components/site/scene/ContactMarble.tsx
deleted file mode 100644
index 169db53..0000000
--- a/frontend/components/site/scene/ContactMarble.tsx
+++ /dev/null
@@ -1,182 +0,0 @@
-"use client";
-
-import { Billboard } from "@react-three/drei";
-import { useFrame } from "@react-three/fiber";
-import { useContext, useEffect, useMemo, useRef } from "react";
-import * as THREE from "three";
-import { OrbColliderContext } from "./collisionContext";
-
-/**
- * ContactMarble — a clear glass marble with an "@" suspended inside (opaque, so
- * it reads through the refractive glass just like the logo marbles). It slowly
- * orbits the centerpiece (with a gentle vertical bob) and, on click, calls
- * onContact to open the site's email/contact modal.
- */
-export function ContactMarble({
- center = [0, 1.7, 0],
- orbitRadius = 4.3,
- orbitSpeed = 0.25,
- bob = 0.28,
- radius = 0.68,
- onContact,
- flee = false,
-}: {
- center?: [number, number, number];
- orbitRadius?: number;
- orbitSpeed?: number;
- bob?: number;
- radius?: number;
- onContact?: () => void;
- /** dart away from the cursor when it comes near (the canvas is
- * pointer-events-none, so the cursor is tracked on window instead) */
- flee?: boolean;
-}) {
- const group = useRef(null);
- const texture = useMemo(() => makeAtTexture(), []);
- const planeSize = radius * 1.05;
- const collider = useContext(OrbColliderContext);
-
- // normalized cursor (-1..1), and the eased "run away" offset it produces
- const pointer = useRef({ x: 0, y: 0, seen: false });
- const fleeOffset = useRef({ x: 0, y: 0 });
- const worldPos = useMemo(() => new THREE.Vector3(), []);
-
- // let balloons see us as a collider; stop deflecting them once we unmount
- useEffect(() => {
- if (!collider) return;
- collider.radius = radius;
- return () => {
- collider.active = false;
- };
- }, [collider, radius]);
-
- useEffect(() => {
- if (!flee) return;
- const onMove = (e: PointerEvent) => {
- pointer.current.x = (e.clientX / window.innerWidth) * 2 - 1;
- pointer.current.y = -(e.clientY / window.innerHeight) * 2 + 1;
- pointer.current.seen = true;
- };
- const onLeave = () => {
- pointer.current.seen = false;
- };
- window.addEventListener("pointermove", onMove, { passive: true });
- window.addEventListener("pointerout", onLeave);
- return () => {
- window.removeEventListener("pointermove", onMove);
- window.removeEventListener("pointerout", onLeave);
- };
- }, [flee]);
-
- useFrame((state) => {
- const t = state.clock.elapsedTime;
- const a = t * orbitSpeed;
- if (!group.current) return;
-
- const ox = center[0] + Math.cos(a) * orbitRadius;
- const oy = center[1] + Math.sin(t * 0.9) * bob;
- const oz = center[2] + Math.sin(a) * orbitRadius;
- group.current.position.set(ox, oy, oz);
-
- // repel from the cursor: map the pointer onto the marble's plane, and if
- // it gets close, shove the marble radially away (eased in and out)
- let targetX = 0;
- let targetY = 0;
- if (flee && pointer.current.seen) {
- group.current.getWorldPosition(worldPos);
- const cam = state.camera as THREE.PerspectiveCamera;
- const dist = cam.position.z - worldPos.z;
- const halfH = Math.tan((cam.fov * Math.PI) / 180 / 2) * dist;
- const halfW = halfH * cam.aspect;
- const mx = cam.position.x + pointer.current.x * halfW;
- const my = cam.position.y + pointer.current.y * halfH;
- const dx = worldPos.x - mx;
- const dy = worldPos.y - my;
- const d = Math.hypot(dx, dy);
- if (d < orbitRadius) {
- const push = (orbitRadius - d) / orbitRadius;
- const mag = orbitRadius * 0.9 * push;
- targetX = (dx / (d || 1e-3)) * mag;
- targetY = (dy / (d || 1e-3)) * mag;
- }
- }
- fleeOffset.current.x += (targetX - fleeOffset.current.x) * 0.18;
- fleeOffset.current.y += (targetY - fleeOffset.current.y) * 0.18;
- group.current.position.x = ox + fleeOffset.current.x;
- group.current.position.y = oy + fleeOffset.current.y;
-
- if (collider) {
- collider.position.copy(group.current.position);
- collider.radius = radius;
- collider.active = true;
- }
- });
-
- return (
-
- {/* biome-ignore lint/a11y/noStaticElementInteractions: r3f mesh, not DOM */}
- {
- e.stopPropagation();
- onContact?.();
- }}
- onPointerOver={(e) => {
- e.stopPropagation();
- document.body.style.cursor = "pointer";
- }}
- onPointerOut={(e) => {
- e.stopPropagation();
- document.body.style.cursor = "default";
- }}
- >
-
-
-
- {/* a single "@" suspended inside, billboarded to always face the camera */}
-
-
-
-
-
-
-
- );
-}
-
-function makeAtTexture(): THREE.CanvasTexture {
- const s = 512;
- const canvas = document.createElement("canvas");
- canvas.width = s;
- canvas.height = s;
- const ctx = canvas.getContext("2d");
- if (ctx) {
- ctx.clearRect(0, 0, s, s);
- ctx.fillStyle = "#12151d";
- ctx.textAlign = "center";
- ctx.textBaseline = "middle";
- ctx.font = "800 360px ui-monospace, SFMono-Regular, Menlo, monospace";
- ctx.fillText("@", s / 2, s / 2 + 18);
- }
- const texture = new THREE.CanvasTexture(canvas);
- texture.colorSpace = THREE.SRGBColorSpace;
- texture.anisotropy = 8;
- return texture;
-}
diff --git a/frontend/components/site/sections/About.tsx b/frontend/components/site/sections/About.tsx
deleted file mode 100644
index 00b571b..0000000
--- a/frontend/components/site/sections/About.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-import { ArrowUpRight } from "lucide-react";
-import { SITE_CONFIG } from "@/lib/site-config";
-
-/**
- * About — one short, honest paragraph on black. Carries the Person link (Scott
- * Benson) in real HTML so the maker is crawlable and matches the JSON-LD.
- */
-export function About() {
- return (
-
-
-
- The developer
-
-
- One developer, a lot of agents
-
-
- {SITE_CONFIG.name} is{" "}
-
- {SITE_CONFIG.author.name}
-
- {" "}
- — one developer finding out how far agentic coding can go. These tools are the result:
- built fast, shipped in the open, and yours to self-host. No telemetry, no lock-in, no
- roadmap theater.
-
-
-
- );
-}
diff --git a/frontend/components/site/sections/Contact.tsx b/frontend/components/site/sections/Contact.tsx
deleted file mode 100644
index db4f593..0000000
--- a/frontend/components/site/sections/Contact.tsx
+++ /dev/null
@@ -1,35 +0,0 @@
-import { EmailSignupForm } from "@/components/EmailSignupForm";
-
-/**
- * Contact — the email signup, server-rendered inline on a dark card (not hidden
- * behind a modal) so it works without JS and gives lead capture a real home.
- */
-export function Contact() {
- return (
-
-
-
- {/* spacer so the floating coin has room to breathe above the fold */}
-
-
- );
-}
diff --git a/frontend/components/site/sections/Projects.tsx b/frontend/components/site/sections/Projects.tsx
deleted file mode 100644
index 2acfcc2..0000000
--- a/frontend/components/site/sections/Projects.tsx
+++ /dev/null
@@ -1,59 +0,0 @@
-import { ArrowUpRight } from "lucide-react";
-import { TrackedCtaLink } from "@/components/site/TrackedCtaLink";
-import { PROJECTS } from "@/lib/site-config";
-
-/**
- * Projects — the crawlable heart of the page on black. Every project ships a
- * real name, tagline, and description in server-rendered HTML (the SEO/AEO
- * backbone), while its mascot balloon floats past in the 3D layer behind it.
- */
-export function Projects() {
- return (
-
-
-
-
- Open source software
-
-
- Tools I actually use
-
-
- Containers, security, automation. Each one built because I needed it and couldn't find
- it.
-
-
-
-
- {PROJECTS.map((project) => (
-
-
-
-
-
{project.name}
-
{project.tagline}
-
-
-
-
{project.description}
-
- {project.status}
-
-
-
- ))}
-
-
-
- );
-}
diff --git a/frontend/components/site/sections/Services.tsx b/frontend/components/site/sections/Services.tsx
deleted file mode 100644
index 103240a..0000000
--- a/frontend/components/site/sections/Services.tsx
+++ /dev/null
@@ -1,64 +0,0 @@
-import { ArrowRight } from "lucide-react";
-
-/**
- * Services — the "work with me" pitch on black. Server-rendered like the other
- * sections so it's crawlable. Counterpart to Projects: those are the things I've
- * shipped, this is what I can do for you.
- */
-const OFFERINGS = [
- {
- title: "Zero to one, fast",
- body: "Idea to a shipped product in weeks, not quarters. Prototypes and MVPs that actually hold up.",
- },
- {
- title: "Get good with AI tools",
- body: "Learn to use AI coding agents for real work — the workflows that stick, not the demos.",
- },
- {
- title: "Automate the busywork",
- body: "Internal tools and automation for the repetitive stuff, built to fit how you already work.",
- },
-];
-
-export function Services() {
- return (
-
-
-
-
- Work with me
-
-
- CodesWhat? Anything for you.
-
-
- The projects above are just what I've open-sourced. If you're building something, I can
- help you ship it faster.
-
+ ${footer("A CodesWhat project")}`),
+ };
+ }),
+];
- console.log(`wrote ${OUT}`);
+await mkdir(path.join(publicDir, "og"), { recursive: true });
+const browser = await chromium.launch({ channel: "chrome" });
+try {
+ const pg = await browser.newPage({ viewport: { width: 1200, height: 630 }, deviceScaleFactor: 1 });
+ for (const card of cards) {
+ await pg.setContent(card.html);
+ await pg.evaluate(() => document.fonts.ready);
+ const fits = await pg.evaluate(() =>
+ [...document.querySelectorAll("h1, .name, .sub, .latin")].every(
+ (el) => el.scrollWidth <= el.clientWidth + 1 && el.scrollHeight <= el.clientHeight + 1 &&
+ el.getBoundingClientRect().bottom < 590,
+ ),
+ );
+ if (!fits) throw new Error(`Headline overflows its box: ${path.basename(card.out)}`);
+ await pg.screenshot({ path: card.out });
+ const { size } = await stat(card.out);
+ if (size > MAX_BYTES) throw new Error(`${path.basename(card.out)} is ${size} bytes, over ${MAX_BYTES}`);
+ console.log(`Rendered ${path.relative(publicDir, card.out)} (${(size / 1024).toFixed(0)} KB)`);
+ }
+} finally {
+ await browser.close();
}
-
-main().catch((err) => {
- console.error(err);
- process.exit(1);
-});
diff --git a/frontend/scripts/og-fonts/OFL-Instrument-Sans.txt b/frontend/scripts/og-fonts/OFL-Instrument-Sans.txt
new file mode 100644
index 0000000..9a39a8e
--- /dev/null
+++ b/frontend/scripts/og-fonts/OFL-Instrument-Sans.txt
@@ -0,0 +1,93 @@
+Copyright 2022 The Instrument Sans Project Authors (https://github.com/Instrument/instrument-sans) InstrumentSans-Italic[wdth,wght].ttf: Copyright 2022 The Instrument Sans Project Authors (https://github.com/Instrument/instrument-sans)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/frontend/scripts/og-fonts/OFL-Instrument-Serif.txt b/frontend/scripts/og-fonts/OFL-Instrument-Serif.txt
new file mode 100644
index 0000000..a39b7e0
--- /dev/null
+++ b/frontend/scripts/og-fonts/OFL-Instrument-Serif.txt
@@ -0,0 +1,93 @@
+Copyright 2022 The Instrument Serif Project Authors (https://github.com/Instrument/instrument-serif) InstrumentSerif-Italic.ttf: Copyright 2022 The Instrument Serif Project Authors (https://github.com/Instrument/instrument-serif)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/frontend/scripts/og-fonts/OFL-JetBrains-Mono.txt b/frontend/scripts/og-fonts/OFL-JetBrains-Mono.txt
new file mode 100644
index 0000000..8f7ed67
--- /dev/null
+++ b/frontend/scripts/og-fonts/OFL-JetBrains-Mono.txt
@@ -0,0 +1,93 @@
+Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) JetBrainsMono-Italic[wght].ttf: Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/frontend/scripts/og-fonts/instrument-sans-latin-wght-normal.woff2 b/frontend/scripts/og-fonts/instrument-sans-latin-wght-normal.woff2
new file mode 100644
index 0000000..8611e41
Binary files /dev/null and b/frontend/scripts/og-fonts/instrument-sans-latin-wght-normal.woff2 differ
diff --git a/frontend/scripts/og-fonts/instrument-serif-latin-400-italic.woff2 b/frontend/scripts/og-fonts/instrument-serif-latin-400-italic.woff2
new file mode 100644
index 0000000..197abca
Binary files /dev/null and b/frontend/scripts/og-fonts/instrument-serif-latin-400-italic.woff2 differ
diff --git a/frontend/scripts/og-fonts/instrument-serif-latin-400-normal.woff2 b/frontend/scripts/og-fonts/instrument-serif-latin-400-normal.woff2
new file mode 100644
index 0000000..0ad6971
Binary files /dev/null and b/frontend/scripts/og-fonts/instrument-serif-latin-400-normal.woff2 differ
diff --git a/frontend/scripts/og-fonts/jetbrains-mono-latin-wght-normal.woff2 b/frontend/scripts/og-fonts/jetbrains-mono-latin-wght-normal.woff2
new file mode 100644
index 0000000..cd5102a
Binary files /dev/null and b/frontend/scripts/og-fonts/jetbrains-mono-latin-wght-normal.woff2 differ
diff --git a/frontend/test/posthog-source.test.mjs b/frontend/test/posthog-source.test.mjs
index 97b1eb4..294d00b 100644
--- a/frontend/test/posthog-source.test.mjs
+++ b/frontend/test/posthog-source.test.mjs
@@ -86,17 +86,28 @@ test("the cookieless envelope keeps the fields PostHog's server hash requires",
assert.match(privacy, /cookieless_missing_user_agent|cookieless server-hash/u);
});
-test("every project card has an allowlisted project_ CTA id", async () => {
- const config = await read("lib/site-config.ts");
- const privacy = await read("lib/posthog-privacy.ts");
- const projects = await read("components/site/sections/Projects.tsx");
- const slugs = [...config.matchAll(/^\s+slug: "([^"]+)",$/gmu)].map((match) => match[1]);
- assert.ok(slugs.length > 0);
- for (const slug of slugs) {
- assert.ok(privacy.includes(`"project_${slug}"`), `project_${slug} is not in ALLOWED_CTA_IDS`);
+test("every project has an allowlisted project_ CTA id and a tracked link", async () => {
+ const { PROJECT_SLUGS } = await import("../lib/project-slugs.ts");
+ const { PROJECTS } = await import("../lib/site-config.ts");
+ const { SPECIMENS } = await import("../lib/specimens.ts");
+ const { ALLOWED_CTA_IDS, ALLOWED_ROUTES } = await import("../lib/posthog-privacy.ts");
+ assert.deepEqual(
+ SPECIMENS.map((specimen) => specimen.slug),
+ [...PROJECT_SLUGS],
+ "specimens and the analytics slug list must stay in step",
+ );
+ for (const project of PROJECTS) {
+ assert.ok(PROJECT_SLUGS.includes(project.slug), `${project.slug} is not in PROJECT_SLUGS`);
+ }
+ for (const slug of PROJECT_SLUGS) {
+ assert.ok(ALLOWED_CTA_IDS.includes(`project_${slug}`), `project_${slug} is not allowlisted`);
+ assert.ok(ALLOWED_ROUTES.includes(`/projects/${slug}`), `/projects/${slug} is not a route`);
+ }
+ for (const file of ["app/(site)/page.tsx", "app/(site)/projects/[slug]/page.tsx"]) {
+ const source = await read(file);
+ assert.match(source, /]*ctaId=\{`project_\$\{[\w.]+\}`\}/u, file);
+ assert.match(source, /placement="projects"/u, file);
}
- assert.match(projects, /]*ctaId=\{`project_\$\{project\.slug\}`\}/u);
- assert.match(projects, /placement="projects"/u);
});
test("client error reporting sends no message, stack, filename or URL", async () => {
@@ -110,7 +121,7 @@ test("client error reporting sends no message, stack, filename or URL", async ()
});
test("newsletter submissions report a fixed result and error class only", async () => {
- const form = await read("components/EmailSignupForm.tsx");
+ const form = await read("components/catalogue/EmailSignupForm.tsx");
const calls = [...form.matchAll(/posthog\.capture\("newsletter submitted", \{[^}]*\}\)/gu)];
assert.equal(calls.length, 3);
for (const [call] of calls) assert.doesNotMatch(call, /email|message|data\./iu);
diff --git a/frontend/test/posthog.test.ts b/frontend/test/posthog.test.ts
index 896e0df..ff72703 100644
--- a/frontend/test/posthog.test.ts
+++ b/frontend/test/posthog.test.ts
@@ -80,7 +80,18 @@ const COOKIELESS_HASH_PROPERTIES = {
};
test("route sanitization only returns the finite public route manifest", () => {
- assert.deepEqual(ALLOWED_ROUTES, ["/"]);
+ assert.deepEqual(ALLOWED_ROUTES, [
+ "/",
+ "/support",
+ "/projects/sockguard",
+ "/projects/drydock",
+ "/projects/portwing",
+ "/projects/portkey-admin-mcp",
+ "/projects/mouthtype",
+ "/projects/idlescreen",
+ "/projects/careerrat",
+ "/projects/systema-machinae",
+ ]);
assert.equal(sanitizeRoute("/?utm_source=secret#private"), "/");
assert.equal(sanitizeRoute("/"), "/");
assert.equal(sanitizeRoute("/not-a-route?token=secret"), "/_other");
@@ -223,13 +234,59 @@ test("$pathname never diverges from the allowlisted path", () => {
}
});
-test("CTA events are limited to the initial GitHub placements", () => {
+test("project detail routes are bounded to known slugs", () => {
+ assert.equal(sanitizeRoute("/projects/drydock"), "/projects/drydock");
+ assert.equal(sanitizeRoute("/projects/drydock?utm_source=x#top"), "/projects/drydock");
+ assert.equal(sanitizeRoute("/projects/systema-machinae"), "/projects/systema-machinae");
+ for (const rawPath of [
+ "/projects",
+ "/projects/",
+ "/projects/unknown",
+ "/projects/drydock/",
+ "/projects/drydock/extra",
+ "/projects/DRYDOCK",
+ "/projects/drydock%2Fx",
+ "/projects/../support-secret",
+ "https://codeswhat.com/projects/drydock",
+ "//evil.example/projects/drydock",
+ ]) {
+ assert.equal(sanitizeRoute(rawPath), "/_other", rawPath);
+ }
+ const pageview = sanitizeEvent({
+ event: "$pageview",
+ properties: { ...ENVELOPE, path: "/projects/careerrat?secret=1" },
+ });
+ assert.ok(pageview);
+ assert.equal(pageview.properties.path, "/projects/careerrat");
+ assert.equal(pageview.properties.$pathname, "/projects/careerrat");
+ assert.equal(pageview.properties.$current_url, "https://codeswhat.com/projects/careerrat");
+ const unknown = sanitizeEvent({
+ event: "$pageview",
+ properties: { ...ENVELOPE, path: "/projects/not-a-project" },
+ });
+ assert.ok(unknown);
+ assert.equal(unknown.properties.path, "/_other");
+});
+
+test("CTA events are limited to the fixed vocabulary", () => {
assert.deepEqual(ALLOWED_CTA_IDS, [
"github_org",
+ "sponsors_github",
+ "support_systema",
+ "support_monthly-5",
+ "support_monthly-10",
+ "support_monthly-25",
+ "support_monthly-100",
+ "support_once-10",
+ "support_once-50",
"project_sockguard",
"project_drydock",
"project_portwing",
"project_portkey-admin-mcp",
+ "project_mouthtype",
+ "project_idlescreen",
+ "project_careerrat",
+ "project_systema-machinae",
]);
assert.deepEqual(
sanitizeEvent({
@@ -459,7 +516,7 @@ test("a CTA event shaped like the browser sends it (no path property) survives t
test("project CTAs need a project_ id with the projects placement", () => {
const accepted = ALLOWED_CTA_IDS.filter((id) => id.startsWith("project_"));
- assert.equal(accepted.length, 4);
+ assert.equal(accepted.length, 8);
for (const ctaId of accepted) {
assert.ok(
sanitizeEvent({
@@ -495,6 +552,61 @@ test("project CTAs need a project_ id with the projects placement", () => {
}
});
+test("support CTAs need the support placement and only run on /support", () => {
+ const supportIds = ALLOWED_CTA_IDS.filter(
+ (id) => id === "sponsors_github" || id.startsWith("support_"),
+ );
+ assert.equal(supportIds.length, 8);
+ for (const ctaId of supportIds) {
+ const result = sanitizeEvent({
+ event: "cta activated",
+ properties: {
+ ...ENVELOPE,
+ path: "/support",
+ cta_id: ctaId,
+ placement: "support",
+ },
+ });
+ assert.ok(result, `${ctaId} was dropped`);
+ assert.equal(result.properties.cta_id, ctaId);
+ assert.equal(result.properties.placement, "support");
+ assert.equal(result.properties.path, "/support");
+ for (const placement of ["header", "hero", "footer", "projects", ""]) {
+ assert.equal(
+ sanitizeEvent({
+ event: "cta activated",
+ properties: { ...ENVELOPE, cta_id: ctaId, placement },
+ }),
+ null,
+ `${ctaId} accepted with ${placement}`,
+ );
+ }
+ }
+ for (const ctaId of [
+ "support_",
+ "support_monthly-7",
+ "support_monthly-5?x=1",
+ "sponsors_other",
+ ]) {
+ assert.equal(
+ sanitizeEvent({
+ event: "cta activated",
+ properties: { ...ENVELOPE, cta_id: ctaId, placement: "support" },
+ }),
+ null,
+ ctaId,
+ );
+ }
+ assert.equal(
+ sanitizeEvent({
+ event: "cta activated",
+ properties: { ...ENVELOPE, cta_id: "github_org", placement: "support" },
+ }),
+ null,
+ );
+ assert.equal(sanitizeRoute("/support?utm_source=x"), "/support");
+});
+
test("newsletter submitted keeps only a result and a fixed error class", () => {
const success = sanitizeEvent({
event: "newsletter submitted",
diff --git a/frontend/test/scene.test.mjs b/frontend/test/scene.test.mjs
index f336be6..9251dd6 100644
--- a/frontend/test/scene.test.mjs
+++ b/frontend/test/scene.test.mjs
@@ -1,29 +1,75 @@
import assert from "node:assert/strict";
-import { existsSync, readFileSync } from "node:fs";
+import { existsSync, readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import test from "node:test";
const root = process.cwd();
-const MYLAR = "components/site/scene/balloons/MylarBalloon.tsx";
+const MYLAR = "components/studio/balloons/MylarBalloon.tsx";
const readMylar = () => readFileSync(join(root, MYLAR), "utf8");
-test("the live 3D scene components exist", () => {
- for (const file of [
- "components/site/FloatingSceneCanvas.tsx",
- "components/site/scene/BoardObjects.tsx",
- "components/site/scene/ContactMarble.tsx",
- "components/site/scene/collisionContext.ts",
- "components/site/scene/balloons/MylarBalloon.tsx",
- "components/site/scene/balloons/types.ts",
- ]) {
+const SCENE_FILES = [
+ "components/studio/BalloonStudio.tsx",
+ "components/studio/BoardObjects.tsx",
+ "components/studio/CoinStudio.tsx",
+ "components/studio/captureRig.tsx",
+ "components/studio/collisionContext.ts",
+ "components/studio/balloons/MylarBalloon.tsx",
+ "components/studio/balloons/types.ts",
+];
+
+const importsThree = (source) => /from "(three|@react-three\/[^"]+)"/.test(source);
+
+function sourceFiles(dir) {
+ const found = [];
+ for (const entry of readdirSync(join(root, dir), { withFileTypes: true })) {
+ const path = join(dir, entry.name);
+ if (entry.isDirectory()) found.push(...sourceFiles(path));
+ else if (/\.(ts|tsx)$/.test(entry.name)) found.push(path);
+ }
+ return found;
+}
+
+test("the 3D asset-capture components live under components/studio", () => {
+ for (const file of SCENE_FILES) {
assert.equal(existsSync(join(root, file)), true, `${file} should exist`);
}
+ assert.equal(existsSync(join(root, "components/site")), false, "the old site tree is gone");
+});
+
+test("the shipped site never imports three or react-three", () => {
+ const shipped = [
+ ...sourceFiles("app").filter((file) => !file.startsWith(join("app", "studio"))),
+ ...sourceFiles("components/catalogue"),
+ ...sourceFiles("lib"),
+ "instrumentation-client.ts",
+ "proxy.ts",
+ ];
+ assert.ok(shipped.length > 10);
+ for (const file of shipped) {
+ assert.equal(
+ importsThree(readFileSync(join(root, file), "utf8")),
+ false,
+ `${file} imports three`,
+ );
+ }
});
-test("three is installed for the WebGL scene", () => {
+test("the root layout renders no floating scene", () => {
+ const layout = readFileSync(join(root, "app/layout.tsx"), "utf8");
+ const siteLayout = readFileSync(join(root, "app/(site)/layout.tsx"), "utf8");
+ for (const source of [layout, siteLayout]) {
+ assert.doesNotMatch(source, /FloatingScene|SiteBackground|