Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
cc038b9
fix(analytics): forward $raw_user_agent and $host for cookieless inge…
biggest-littlest Aug 15, 2026
985bd2a
chore(git): reconcile main before cookieless promotion
scttbnsn Aug 15, 2026
3b6d9cd
chore(config): remove the stale Cursor rules (#54)
scttbnsn Aug 16, 2026
30cdd8e
docs(readme): describe what this repo is and how it deploys (#55)
scttbnsn Aug 16, 2026
89bad5b
chore(git): reconcile main before docs promotion
scttbnsn Aug 16, 2026
d8aec70
chore(gitignore): ignore the root .vercel link and history-backup bun…
scttbnsn Aug 26, 2026
f50fadb
feat(analytics): capture $pageleave and send $pathname (#60)
scttbnsn Aug 26, 2026
1a40882
fix(seo): repair JSON-LD logo 404 and double-slash base URLs
scttbnsn Aug 28, 2026
e1f7a85
build(deps): bump next to ^16.2.11 to clear all 35 Dependabot alerts
scttbnsn Aug 28, 2026
ac19fbb
build(deps): regenerate next-env.d.ts for next 16.3
scttbnsn Aug 28, 2026
faea446
docs(roadmap): track web-analytics table coverage follow-ups (ops X37)
scttbnsn Aug 28, 2026
25e49cd
chore: ignore .claude/ with a tracked line (#59)
scttbnsn Aug 28, 2026
9025b2f
docs(roadmap): point acquisition-data item at the ops analytics standard
scttbnsn Aug 28, 2026
f4add91
chore(git): reconcile main before analytics and hardening promotion
scttbnsn Aug 28, 2026
5aade3b
docs(roadmap): pageleave ratio is structural; note the bot-detection …
scttbnsn Aug 28, 2026
b34f329
chore(config): add .planning/ to .gitignore (#62)
scttbnsn Sep 7, 2026
b620263
chore(git): reconcile main before the .planning gitignore promotion
scttbnsn Sep 7, 2026
2d3ccab
fix(analytics): preserve validated referring hostnames (#64)
scttbnsn Sep 13, 2026
16c153c
chore(git): reconcile main before the referrer reporting promotion
scttbnsn Sep 13, 2026
4493004
fix(site): restore hydration and reject invalid signup bodies (#66)
scttbnsn Sep 13, 2026
cac45c0
chore(git): reconcile main before the hydration repair promotion
scttbnsn Sep 13, 2026
14478e5
feat(analytics): add project, newsletter and client error events with…
scttbnsn Oct 22, 2026
488e736
feat(site): ship the Catalogue redesign with a support page (#69)
scttbnsn Oct 22, 2026
410e3d5
chore(git): reconcile main before the Catalogue promotion
scttbnsn Oct 22, 2026
cc9da03
fix(site): address promotion review on theme fallback, support meta a…
scttbnsn Oct 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 21 additions & 8 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,15 +60,28 @@ ships with the next promotion — the items below are what's left after that.
(portwing's landed), so a Playwright canary's silence proves nothing
either way. Verify with a real browser or by reading the deployed chunk
for the property names.
- [ ] Acquisition data: decided org-wide in `CodesWhat/ops`
- [x] Acquisition data: decided org-wide in `CodesWhat/ops`
`standards/analytics.md` ("Acquisition data and consent", as of
`c161ebc`) — that file is the authority. Shape: `save_campaign_params:
true` with `gclid`/`fbclid`/`msclkid` excluded; `save_referrer: true`
with the sanitizer forwarding `$referring_domain` only (drop it unless
it's a bare hostname, never copy `$referrer`); geo stays off (cookieless
strips the IP upstream, PostHog #48660). No banner needed. Implement
AFTER the `$pathname`/`$pageleave` promotion is verified in production,
so the two changes are separately attributable.
`c161ebc`) — that file is the authority. Landed: `save_campaign_params:
true` with `mask_personal_data_properties: true` (masks
`gclid`/`fbclid`/`msclkid` and the rest of the SDK's per-click ids);
`save_referrer: true` with the sanitizer forwarding `$referring_domain`
only (bare hostname, never `$referrer`). The sanitizer forwards only
`utm_source` (github, x, bluesky, linkedin, reddit, hackernews,
newsletter, producthunt), `utm_medium` (social, email, referral,
community) and `utm_campaign` (`/^[a-z0-9-]{1,40}$/`); everything else is
dropped. The vocabulary is pending Scott's approval. Geo stays off
(cookieless strips the IP upstream, PostHog #48660). No banner needed.
- [x] `$web_vitals` carries `$pathname` from the sanitized path
- [x] `cta activated` for the project cards: `project_<slug>` ids, placement
`projects`. `cta activated` had zero rows in 120 days on codeswhat.com;
reproduced in a browser and the capture, sanitizer and send path all work,
so it was low traffic (76 pageviews) rather than a bug. Check it fires after
deploy.
- [x] `newsletter submitted` (`result`, plus `error_class` from
invalid_email / rate_limited / unavailable / network on error)
- [x] `client error` (`error_kind`, allowlisted `error_name`, sanitized path;
max 5 per page load; `capture_exceptions` stays off)

## Telemetry and badge audit (2026-08-14)

Expand Down
25 changes: 23 additions & 2 deletions frontend/README-VERCEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,12 @@ a local CLI deployment is not the routine production path.

After promotion, confirm the production deployment references the intended
commit. Open the canonical domain and verify the theme toggle changes theme
and the client scene mounts. Build success and analytics events alone do not
establish that client initialization worked.
and persists after reload, the specimen plates render, and `/support` shows its
buy links. Build success and analytics events alone do not establish that client
initialization worked.

`npm run og` regenerates the share cards in `public/og*` with playwright-core
and the locally installed Google Chrome; it isn't part of the build.

## Rendering and security headers

Expand Down Expand Up @@ -55,6 +59,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
Expand Down
13 changes: 13 additions & 0 deletions frontend/app/(site)/layout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Shell>
{children}
<Footer />
</Shell>
);
}
260 changes: 260 additions & 0 deletions frontend/app/(site)/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<main id="main" className={styles.main}>
<section className={styles.hero} aria-labelledby="hero-title">
<div>
<p className={styles.eyebrow}>Open-source tools, and a developer for hire</p>
<h1 id="hero-title" className={styles.display}>
One developer. A lot of agents. <em>Small, sharp</em> tools.
</h1>
<p className={styles.lede}>
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.
</p>
<div className={styles.actions}>
<a className={styles.btnLime} href="#specimens">
Meet the specimens
</a>
<a className={styles.btnLine} href="#services">
Work with me
</a>
</div>
</div>

<figure className={styles.figure} aria-label="The collection">
<div className={styles.figureGrid}>
{SPECIMENS.map((s) => (
<Sprite key={s.slug} slug={s.slug} scale={2} label={s.alt} />
))}
</div>
<figcaption className={styles.figureCap}>
<b>Fig. 1</b>
The collection, as of this morning. None have escaped.
</figcaption>
</figure>
</section>

<section id="specimens" className={styles.section} aria-labelledby="specimens-title">
<header className={styles.sectionHead}>
<p className={styles.eyebrow}>Section I</p>
<h2 id="specimens-title" className={styles.h2}>
The specimens, <em>filed and labelled.</em>
</h2>
<p className={styles.sectionLede}>
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.
</p>
</header>

<ul className={styles.plates}>
{SPECIMENS.map((specimen, i) => (
<li key={specimen.slug}>
<article className={styles.plate} aria-labelledby={`plate-${specimen.slug}`}>
<div className={styles.portrait}>
<Sprite
slug={specimen.slug}
scale={5}
fit={i === 0 ? 288 : 176}
label={specimen.alt}
/>
</div>
<div className={styles.plateBody}>
<p className={styles.plateNo}>
Plate {specimen.no} · {specimen.kind}
</p>
<h3 id={`plate-${specimen.slug}`} className={styles.plateName}>
<Link href={`/projects/${specimen.slug}`}>{specimen.name}</Link>
</h3>
<p className={styles.latin}>{specimen.latin}</p>
<dl className={styles.field}>
<div>
<dt>Habitat</dt>
<dd>{specimen.habitat}</dd>
</div>
<div>
<dt>Diet</dt>
<dd>{specimen.diet}</dd>
</div>
<div>
<dt>Temperament</dt>
<dd>{specimen.temperament}</dd>
</div>
</dl>
<div className={styles.plateFoot}>
<span className={styles.chip}>
{specimen.license} · {specimen.language}
</span>
<Link href={`/projects/${specimen.slug}`}>Field notes</Link>
<TrackedCtaLink
ctaId={`project_${specimen.slug}`}
placement="projects"
href={specimen.site}
target="_blank"
rel="noopener noreferrer"
>
Visit
<span className={styles.srOnly}> {specimen.name} (opens in a new tab)</span>
</TrackedCtaLink>
</div>
</div>
</article>
</li>
))}
</ul>
</section>

<section id="services" className={styles.section} aria-labelledby="services-title">
<header className={`${styles.sectionHead} ${styles.headRow}`}>
<div>
<p className={styles.eyebrow}>Section II</p>
<h2 id="services-title" className={styles.h2}>
CodesWhat? <em>Anything for you.</em>
</h2>
<p className={styles.sectionLede}>
The projects above are only what I have open-sourced. If you are building something,
I can help you ship it faster.
</p>
</div>
<aside className={styles.dotCard} aria-label="Commissions note">
<p className={styles.eyebrow}>Commissions</p>
<p className={styles.dotCardBig}>Taken on, one at a time.</p>
</aside>
</header>
<ol className={styles.services}>
{SERVICES.map((service, i) => (
<li key={service.title}>
<span className={styles.bigNo} aria-hidden="true">
{String(i + 1).padStart(2, "0")}
</span>
<h3 className={styles.h3}>{service.title}</h3>
<p>{service.body}</p>
</li>
))}
</ol>
<div className={styles.actions}>
<a className={styles.btnLime} href="mailto:hello@codeswhat.com">
Tell me what you need help with
</a>
</div>
</section>

<section id="upkeep" className={styles.section} aria-labelledby="upkeep-title">
<div className={styles.fund}>
<div>
<p className={styles.eyebrow}>Section III</p>
<h2 id="upkeep-title" className={`${styles.h2} ${styles.h2Small}`}>
Want to support the work? <em>Here's how.</em>
</h2>
</div>
<div>
<p className={styles.fundText}>
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.
</p>
<div className={styles.actions}>
<SupportLink
className={styles.btnLine}
href={GITHUB_SPONSORS_URL}
ctaId="sponsors_github"
target="_blank"
rel="noopener noreferrer"
>
Sponsor on GitHub
<span className={styles.srOnly}> (opens in a new tab)</span>
</SupportLink>
<Link className={styles.btnLine} href="/support">
Other ways to give
</Link>
</div>
</div>
</div>
</section>
</main>
</>
);
}
Loading
Loading