Redesign the BadSEO home and landing lab experience (#381)

* Redesign BadSEO homepage and add design lab routes

* Migrate badseo.dev to TanStack Start

* Refine BadSEO landing page design

* Migrate badseo.dev to TanStack Start

* Refine BadSEO field guide homepage

* Simplify BadSEO example catalog

* Remove BadSEO issue section label

* Remove All issues from BadSEO header

* Reorder BadSEO examples and simplify footer

* Reduce BadSEO desktop hero type

* Narrow BadSEO homepage content

* Further reduce BadSEO hero type

* Add BadSEO brand to footer

* Tighten Kitchen sink description

* Reorder BadSEO example categories
This commit is contained in:
Ben Senescu 2026-07-11 18:53:24 -04:00 committed by Ben Senescu
parent 987e5494a2
commit d6d0f210ba
11 changed files with 231 additions and 677 deletions

View File

@ -6,10 +6,15 @@ This is not a completed-work log or a bug tracker. Never include secrets, creden
## Open ## Open
- [ ] `2026-07-11T21:38:29Z``codex` — While visually testing the BadSEO design lab, a stale local process still held the configured port 8787, so Vite silently moved the app to 8788. Make the active preview URL more visible in the normal workflow or provide a reliable task-local server cleanup step.
- [ ] `2026-07-10T21:32:49Z``codex` — While starting the BadSEO TanStack/Cloudflare dev server in a sandbox, the Cloudflare Vite plugin failed with `listen EPERM` while probing its inspector port, even though the app port was otherwise valid. Document the need for elevated local port binding or provide an inspector-disabled validation command.
- [ ] `2026-07-10T21:28:46Z``codex` — While validating the standalone BadSEO package after merging its TanStack migration, `pnpm --dir badseo run typecheck` appeared to work through the root toolchain but `pnpm --dir badseo run build` could not find Vite because `badseo/node_modules` was absent. Document or enforce the required package-local install before validation.
- [ ] `2026-07-10T22:32:40Z``codex` — While running the badseo audit from the workspace, sandboxed TSX failed with `listen EPERM` when creating its IPC socket under the temporary directory. Rerunning the same audit with local IPC permission succeeded; provide a sandbox-compatible TSX invocation for validation scripts. - [ ] `2026-07-10T22:32:40Z``codex` — While running the badseo audit from the workspace, sandboxed TSX failed with `listen EPERM` when creating its IPC socket under the temporary directory. Rerunning the same audit with local IPC permission succeeded; provide a sandbox-compatible TSX invocation for validation scripts.
- [ ] `2026-07-10T22:23:50Z``codex` — While pushing the consent-banner refinement to PR #380, the SSH push hung without output and a separate remote verification hung as well; retrying the same push succeeded immediately. Surface an actionable SSH timeout or connection error instead of waiting indefinitely. - [ ] `2026-07-10T22:23:50Z``codex` — While pushing the consent-banner refinement to PR #380, the SSH push hung without output and a separate remote verification hung as well; retrying the same push succeeded immediately. Surface an actionable SSH timeout or connection error instead of waiting indefinitely.
- [ ] `2026-07-10T21:36:27Z``codex` — While using the repository's `webapp-testing` skill, its required Python Playwright import was unavailable in both system and bundled Python. The bundled Node Playwright runtime completed the check; document or provide that fallback, including Cloudflare Vite's default port 8787 rather than Vite's usual 5173. - [ ] `2026-07-10T21:36:27Z``codex` — While using the repository's `webapp-testing` skill, its required Python Playwright import was unavailable in both system and bundled Python. The bundled Node Playwright runtime completed the check; document or provide that fallback, including Cloudflare Vite's default port 8787 rather than Vite's usual 5173.
- [ ] `2026-07-10T21:32:10Z``codex` — While formatting the standalone `badseo` workspace, `pnpm exec prettier` failed because Prettier is only available from the repository root. Document the root-only formatter command or expose a workspace-local formatting script. - [ ] `2026-07-10T21:32:10Z``codex` — While formatting the standalone `badseo` workspace, `pnpm exec prettier` failed because Prettier is only available from the repository root. Document the root-only formatter command or expose a workspace-local formatting script.
- [ ] `2026-07-11T15:13:43Z``codex` — While inspecting a BadSEO merge with login-shell commands, zsh printed `(eval):5: parse error near \`end'` before otherwise successful Git output. Running the same commands with login-shell initialization disabled avoided the noise; identify and fix the malformed shell startup hook.
- [ ] `2026-07-11T15:15:13Z``codex` — While resolving a merge from the sandboxed BadSEO subdirectory, `git add` could not create the parent worktree's `index.lock` despite the merge itself being allowed. Staging required the approved elevated Git path; align the workspace write boundary with the actual Git worktree root.
- [ ] `2026-07-10T21:09:27Z``codex` — While building the TanStack/Cloudflare badseo app, Wrangler reported an EPERM writing its debug log under the user preferences directory even though the build succeeded. Set `WRANGLER_LOG_PATH` to a writable temporary path in sandboxed build commands or make the logging failure non-fatal and quiet. - [ ] `2026-07-10T21:09:27Z``codex` — While building the TanStack/Cloudflare badseo app, Wrangler reported an EPERM writing its debug log under the user preferences directory even though the build succeeded. Set `WRANGLER_LOG_PATH` to a writable temporary path in sandboxed build commands or make the logging failure non-fatal and quiet.
- [ ] `2026-07-10T17:53:20Z``codex` — While validating `.greptile/`, both `pnpm exec prettier --check` and the existing `pnpm format:check` attempted to reconcile `node_modules` and aborted because no TTY was available. Calling `node_modules/.bin/prettier` performed the non-installing check successfully; the agent/CI path needs a stable way to run package scripts without an interactive modules purge. - [ ] `2026-07-10T17:53:20Z``codex` — While validating `.greptile/`, both `pnpm exec prettier --check` and the existing `pnpm format:check` attempted to reconcile `node_modules` and aborted because no TTY was available. Calling `node_modules/.bin/prettier` performed the non-installing check successfully; the agent/CI path needs a stable way to run package scripts without an interactive modules purge.
- [ ] `2026-07-10T18:12:35Z``codex` — While validating referenced files in zsh, using `path` as a loop variable overwrote zsh's special `path` array and made commands such as `git`, `jq`, and `sed` appear missing later in the same shell. Use a neutral name such as `file_path` in shell loops. - [ ] `2026-07-10T18:12:35Z``codex` — While validating referenced files in zsh, using `path` as a loop variable overwrote zsh's special `path` array and made commands such as `git`, `jq`, and `sed` appear missing later in the same shell. Use a neutral name such as `file_path` in shell loops.

View File

@ -32,14 +32,14 @@ Every issue type in the OpenSEO audit engine is exercised by at least one page
| **Site structure** | orphan page, deep click-path | | **Site structure** | orphan page, deep click-path |
| **Kitchen sink** | one page that breaks six ways at once | | **Kitchen sink** | one page that breaks six ways at once |
Browse them all at `/catalog`. Browse them all on the homepage at `/#issues`.
## How it's built ## How it's built
badseo.dev is a TanStack Start app deployed to a Cloudflare Worker, following badseo.dev is a TanStack Start app deployed to a Cloudflare Worker, following
the same Vite and Cloudflare setup as the repository's `web/` app. the same Vite and Cloudflare setup as the repository's `web/` app.
TanStack React routes render the healthy homepage, catalog, and privacy policy. TanStack React routes render the healthy homepage and privacy policy.
A TanStack catch-all server route keeps the deliberate fixtures as raw A TanStack catch-all server route keeps the deliberate fixtures as raw
responses with byte-level control over status codes, redirects, headers responses with byte-level control over status codes, redirects, headers
(`X-Robots-Tag`, `Link: …; rel=canonical`), timing, and the malformed `<head>` (`X-Robots-Tag`, `Link: …; rel=canonical`), timing, and the malformed `<head>`
@ -76,7 +76,7 @@ npm run dev # serves on http://localhost:8787
The harness drives the **real** OpenSEO crawl + issue-detection functions The harness drives the **real** OpenSEO crawl + issue-detection functions
(imported straight from `../src`) against a running badseo.dev, then asserts every (imported straight from `../src`) against a running badseo.dev, then asserts every
fixture triggers exactly the issues it declares — and that the homepage, fixture triggers exactly the issues it declares — and that the homepage,
catalog, privacy policy, and support pages come back clean. privacy policy, and support pages come back clean.
```bash ```bash
# with `npm run dev` running in another terminal: # with `npm run dev` running in another terminal:

View File

@ -1,417 +1,152 @@
:root { :root {
--canvas: #f5f1ec; color-scheme: light;
--surface: #ffffff; --navy: #0c1b3a;
--surface-2: #ebe7e1; --blue: #1745d1;
--ink: #111111; --paper: #e7ecf4;
--ink-muted: #626260; --paper-deep: #d9e1ef;
--ink-subtle: #7b7b78; --surface: #f7f9fc;
--hairline: #d8d1c8; --line: #93a5c8;
--hairline-soft: #ebe7e1; --line-soft: #bdc8da;
--orange: #ff5600; --muted: #526584;
--footer-bg: #eee8de; --critical: #d94040;
--critical: #d23b1f; --warning: #cf7d18;
--warning: #b26a00; --info: #6f82a5;
--info: #6b7280; --mono: "IBM Plex Mono", monospace;
--mono: --sans: "IBM Plex Sans", sans-serif;
"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --serif: "Newsreader", Georgia, serif;
--sans: --condensed: "Archivo Narrow", sans-serif;
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", sans-serif;
} }
* { * { box-sizing: border-box; }
box-sizing: border-box; html { min-height: 100%; background: var(--paper); scroll-behavior: smooth; -webkit-text-size-adjust: 100%; }
} body { min-height: 100%; margin: 0; background: var(--paper); color: var(--navy); font-family: var(--sans); font-size: 16px; line-height: 1.55; -webkit-font-smoothing: antialiased; }
html { button, input { font: inherit; }
-webkit-text-size-adjust: 100%; a { color: inherit; text-underline-offset: 4px; }
} a:focus-visible, button:focus-visible { outline: 3px solid #ff6a57; outline-offset: 3px; }
body { /* Shared site chrome. No headings or images are introduced here, keeping the
margin: 0; fixture pages' audit signals entirely under fixture control. */
background: var(--canvas); .site-header, .nav {
color: var(--ink);
font-family: var(--sans);
font-size: 16px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
a {
color: var(--ink);
text-decoration: none;
}
a:hover {
text-decoration: underline;
text-underline-offset: 3px;
}
.wrap,
.main,
.panel,
.nav,
.foot-inner {
max-width: 820px;
margin: 0 auto;
padding-left: 24px;
padding-right: 24px;
}
/* ── nav ── */
.nav {
display: flex;
align-items: center;
gap: 20px;
padding-top: 20px;
padding-bottom: 20px;
}
.brand {
font-weight: 600;
letter-spacing: -0.01em;
color: var(--ink);
font-size: 17px;
}
.brand:hover {
text-decoration: none;
}
.nav-link {
color: var(--ink-muted);
font-weight: 500;
margin-left: auto;
font-size: 15px;
}
.nav-link:hover {
color: var(--ink);
}
/* ── content ── */
.main {
padding-top: 8px;
padding-bottom: 48px;
}
.main h1 {
font-size: clamp(30px, 4.6vw, 46px);
font-weight: 500;
line-height: 1.1;
letter-spacing: -0.025em;
margin: 12px 0 18px;
text-wrap: balance;
}
.main h2 {
font-size: 24px;
font-weight: 500;
letter-spacing: -0.017em;
margin: 40px 0 12px;
}
.main h3 {
font-size: 19px;
font-weight: 500;
margin: 26px 0 8px;
}
.main h4 {
font-size: 16px;
font-weight: 600;
margin: 20px 0 6px;
}
.main p {
color: #2c2c2b;
margin: 0 0 16px;
}
.lede {
font-size: 19px;
line-height: 1.45;
letter-spacing: -0.006em;
color: var(--ink);
}
.main a {
color: var(--ink);
text-decoration: underline;
text-decoration-color: var(--hairline);
text-underline-offset: 3px;
}
.main a:hover {
text-decoration-color: var(--ink);
}
.main img {
max-width: 100%;
border-radius: 10px;
border: 1px solid var(--hairline);
display: block;
margin: 20px 0;
background: var(--surface);
}
.main code {
font-family: var(--mono);
font-size: 13.5px;
background: var(--surface-2);
padding: 2px 6px;
border-radius: 5px;
}
.main ul,
.main ol {
color: #2c2c2b;
padding-left: 20px;
}
.main li {
margin: 4px 0;
}
.next {
margin-top: 8px;
}
.next a {
font-weight: 500;
}
/* ── test panel ── */
.panel {
margin-top: 8px;
margin-bottom: 32px;
background: var(--surface);
border: 1px solid var(--hairline);
border-radius: 12px;
padding: 18px 20px;
}
.panel-head {
display: flex;
align-items: baseline;
gap: 10px;
flex-wrap: wrap;
}
.panel-kicker {
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 11px;
font-weight: 600;
color: var(--ink-subtle);
}
.panel-cat {
margin-left: auto;
font-size: 12px;
color: var(--ink-subtle);
font-family: var(--mono);
}
.panel-summary {
margin: 10px 0 0;
font-size: 15px;
color: var(--ink);
}
.panel-lesson {
margin: 6px 0 0;
font-size: 14px;
color: var(--ink-muted);
}
.panel-chips {
margin-top: 14px;
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.chips-label {
font-size: 12px;
color: var(--ink-subtle);
font-weight: 500;
margin-right: 2px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 12.5px;
font-weight: 500;
color: var(--ink);
padding: 3px 11px 3px 9px;
border-radius: 999px;
background: var(--canvas);
border: 1px solid var(--hairline);
}
.chip::before {
content: "";
width: 7px;
height: 7px;
border-radius: 50%;
flex: none;
background: var(--info);
}
.chip-critical::before {
background: var(--critical);
}
.chip-warning::before {
background: var(--warning);
}
.chip-info::before {
background: var(--info);
}
.chip-clean {
color: var(--ink-muted);
}
.chip-clean::before {
background: #3a9d5d;
}
/* ── hero + home bits ── */
.hero {
padding: 20px 0 4px;
}
.stat-row {
display: flex;
gap: 40px;
flex-wrap: wrap;
margin: 26px 0 8px;
}
.stat b {
font-size: 30px;
font-weight: 500;
letter-spacing: -0.02em;
display: block;
line-height: 1.1;
}
.stat span {
color: var(--ink-muted);
font-size: 14px;
}
.callout {
background: var(--surface);
border: 1px solid var(--hairline);
border-radius: 12px;
padding: 16px 18px;
margin: 24px 0;
}
.callout p {
margin: 0;
color: #2c2c2b;
}
/* ── catalog index (no cards) ── */
.index-group {
margin: 40px 0;
}
.index-group > h2 {
margin-bottom: 4px;
}
.index-list {
margin-top: 8px;
border-top: 1px solid var(--hairline);
}
.index-list .index-row {
display: grid;
grid-template-columns: minmax(160px, 240px) 1fr auto;
gap: 20px;
align-items: baseline;
padding: 14px 12px;
margin: 0 -12px;
border-radius: 8px;
border-bottom: 1px solid var(--hairline);
color: var(--ink);
text-decoration: none;
}
.index-list .index-row:hover {
background: var(--surface-2);
border-bottom-color: transparent;
}
.index-list .index-row:hover .row-name {
text-decoration: underline;
text-decoration-color: var(--ink);
text-underline-offset: 3px;
}
.row-name {
font-weight: 500;
}
.row-sum {
color: var(--ink-muted);
font-size: 14.5px;
}
.row-sev {
display: inline-flex;
gap: 5px;
align-items: center;
padding-top: 4px;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
flex: none;
}
.dot-critical {
background: var(--critical);
}
.dot-warning {
background: var(--warning);
}
.dot-info {
background: var(--info);
}
.legend {
display: inline-flex;
gap: 14px;
flex-wrap: wrap;
align-items: center;
color: var(--ink-muted);
font-size: 14px;
}
.legend span {
display: inline-flex;
gap: 6px;
align-items: center;
}
@media (max-width: 640px) {
.index-row {
grid-template-columns: 1fr auto;
}
.row-sum {
grid-column: 1 / -1;
}
}
/* ── footer + OpenSEO badge ── */
/* The band fills to the bottom of the page (no body padding beneath it). Extra
bottom padding keeps the footer text clear of the fixed badge, which floats
in the empty band space below the row. */
.foot {
background: var(--footer-bg);
border-top: 1px solid var(--hairline);
margin-top: 64px;
}
.foot-inner {
padding: 26px 24px 76px;
color: var(--ink-muted);
font-size: 14px;
display: flex; display: flex;
min-height: 92px;
align-items: end;
justify-content: space-between; justify-content: space-between;
gap: 8px 24px; gap: 28px;
flex-wrap: wrap; padding: 31px 3.5vw 19px;
border-bottom: 1px solid var(--line);
} }
.foot-inner a { .site-brand, .brand { color: var(--navy); font-family: var(--condensed); font-size: 18px; font-weight: 700; letter-spacing: .03em; text-decoration: none; }
color: var(--ink-muted); .site-header nav, .nav-links { display: flex; align-items: center; gap: 27px; }
.site-header nav a, .nav-links a { color: var(--navy); font-family: var(--mono); font-size: 8px; }
/* Homepage */
.home-main { width: min(980px, calc(100% - 64px)); margin: 0 auto; padding-bottom: 80px; }
.home-hero { padding: clamp(70px, 8vw, 112px) 0 clamp(64px, 8vw, 96px); }
.home-copy h1 { margin: 0; font-family: var(--condensed); font-size: clamp(112px, 10vw, 136px); font-weight: 700; letter-spacing: -.055em; line-height: .72; }
.home-copy > p { max-width: 860px; margin: 42px 0 0; font-family: var(--serif); font-size: clamp(28px, 2.2vw, 32px); line-height: 1.08; }
.home-issues { scroll-margin-top: 24px; border-top: 3px solid var(--navy); }
.home-issue-group { margin: 22px 0 54px; }
.home-issue-group h3 { margin: 0; padding: 18px 0 14px; border-bottom: 1px solid var(--line); font-family: var(--condensed); font-size: 30px; line-height: 1; }
.home-case-head, .home-case-row { display: grid; grid-template-columns: minmax(220px, .72fr) minmax(320px, 1.28fr); gap: clamp(30px, 6vw, 90px); }
.home-case-head { padding: 11px 0; border-bottom: 1px solid var(--line-soft); color: var(--muted); font-family: var(--mono); font-size: 8px; }
.home-case-row { min-height: 72px; align-items: center; padding: 16px 0; border-bottom: 1px solid var(--line-soft); text-decoration: none; }
.home-case-row:hover .home-case-name { color: var(--blue); text-decoration: underline; text-underline-offset: 4px; }
.home-case-name { font-family: var(--serif); font-size: 19px; line-height: 1.15; }
.home-case-summary { color: var(--muted); font-size: 13px; line-height: 1.5; }
.home-case-row:hover .home-case-summary { color: var(--navy); }
/* Catalog and fixture pages: deliberately restrained versions of the same
system so the content and injected SEO issue stay central. */
.main { width: min(920px, calc(100% - 48px)); margin: 0 auto; padding: 62px 0 72px; }
.main h1 { margin: 0 0 18px; font-family: var(--condensed); font-size: clamp(52px, 8vw, 82px); font-weight: 700; letter-spacing: -.035em; line-height: .93; text-wrap: balance; }
.main h2 { margin: 48px 0 13px; font-family: var(--condensed); font-size: 34px; line-height: 1; }
.main h3 { margin: 30px 0 9px; font-family: var(--condensed); font-size: 24px; }
.main h4 { margin: 24px 0 7px; font-family: var(--mono); font-size: 13px; text-transform: uppercase; }
.main p { margin: 0 0 17px; color: #263b5e; }
.main .lede { max-width: 800px; margin-bottom: 28px; color: var(--navy); font-family: var(--serif); font-size: 25px; line-height: 1.32; }
.main a { color: var(--blue); }
.main code { padding: 2px 5px; background: #d4deed; font-family: var(--mono); font-size: 13px; }
.main ul, .main ol { padding-left: 22px; color: #263b5e; }
.main li { margin: 5px 0; }
.main img { display: block; max-width: 100%; margin: 26px 0; border: 1px solid var(--line); background: var(--surface); }
.next { margin-top: 12px; }
.panel { width: min(920px, calc(100% - 48px)); margin: 38px auto 0; padding: 20px 22px; border: 1px solid var(--line); border-top: 3px solid var(--navy); background: rgb(247 249 252 / 70%); }
.panel-head { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
.panel-kicker { color: var(--navy); font-family: var(--mono); font-size: 9px; font-weight: 600; letter-spacing: .1em; text-transform: uppercase; }
.panel-cat { margin-left: auto; color: var(--muted); font-family: var(--mono); font-size: 9px; }
.panel-summary { margin: 12px 0 0; color: var(--navy); font-family: var(--serif); font-size: 20px; }
.panel-lesson { margin: 8px 0 0; color: var(--muted); font-size: 14px; }
.panel-chips { display: flex; flex-wrap: wrap; gap: 7px; align-items: center; margin-top: 16px; }
.chips-label { margin-right: 3px; color: var(--muted); font-family: var(--mono); font-size: 9px; text-transform: uppercase; }
.chip { display: inline-flex; align-items: center; gap: 7px; padding: 4px 9px; border: 1px solid var(--line-soft); background: var(--paper); font-family: var(--mono); font-size: 10px; }
.chip::before { width: 7px; height: 7px; border-radius: 50%; background: var(--info); content: ""; }
.chip-critical::before { background: var(--critical); }.chip-warning::before { background: var(--warning); }.chip-info::before { background: var(--info); }.chip-clean::before { background: #24845a; }
.legend { display: flex; flex-wrap: wrap; gap: 14px; align-items: center; color: var(--muted); font-family: var(--mono); font-size: 10px; }
.legend span { display: inline-flex; gap: 6px; align-items: center; }
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--info); }
.dot-critical { background: var(--critical); }.dot-warning { background: var(--warning); }.dot-info { background: var(--info); }
.index-group { margin: 52px 0; }
.index-group > h2 { margin-bottom: 12px; }
.index-list { border-top: 1px solid var(--line); }
.index-row { display: grid; grid-template-columns: minmax(190px, .75fr) 1.4fr auto; gap: 24px; align-items: baseline; min-height: 76px; padding: 18px 12px; margin: 0 -12px; border-bottom: 1px solid var(--line); color: var(--navy) !important; text-decoration: none; transition: background 150ms ease, color 150ms ease; }
.index-row:hover { background: var(--blue); color: #fff !important; }
.row-name { font-family: var(--serif); font-size: 19px; line-height: 1.1; }
.row-sum { color: var(--muted); font-size: 13px; }
.index-row:hover .row-sum { color: rgb(255 255 255 / 75%); }
.row-sev { display: inline-flex; gap: 5px; align-items: center; }
.foot { margin-top: 36px; border-top: 1px solid var(--line); background: #dce4f0; }
.foot-inner { display: flex; width: min(1700px, 100%); min-height: 110px; align-items: center; justify-content: space-between; gap: 16px 32px; margin: 0 auto; padding: 30px 3.5vw; color: var(--muted); font-family: var(--mono); font-size: 9px; }
.foot-brand { color: var(--navy); font-family: var(--condensed); font-size: 18px; font-weight: 700; letter-spacing: .03em; text-decoration: none; }
.foot-links { display: flex; align-items: center; gap: 22px; flex-wrap: wrap; margin-left: auto; }
@media (max-width: 1100px) {
.home-case-head, .home-case-row { grid-template-columns: minmax(190px, .75fr) minmax(280px, 1.25fr); gap: 32px; }
} }
.foot-inner a:hover {
color: var(--ink); @media (max-width: 720px) {
.site-header, .nav { min-height: 78px; align-items: center; margin: 0 18px; padding: 18px 0; }
.site-header nav, .nav-links { gap: 14px; }
.home-main { width: calc(100% - 36px); padding-bottom: 48px; }
.home-hero { padding: 64px 0 58px; }
.home-copy h1 { font-size: 100px; }
.home-copy > p { margin-top: 25px; font-size: 22px; line-height: 1.16; }
.home-case-head { display: none; }
.home-case-row { grid-template-columns: 1fr; gap: 7px; padding: 18px 0; }
.home-case-name { font-size: 21px; }
.main { width: calc(100% - 36px); padding-top: 42px; }
.main h1 { font-size: 51px; }
.main .lede { font-size: 22px; }
.panel { width: calc(100% - 36px); margin-top: 28px; }
.panel-cat { width: 100%; margin-left: 0; }
.index-row { grid-template-columns: 1fr auto; gap: 7px 15px; }
.row-sum { grid-row: 2; grid-column: 1 / -1; }
.foot-inner { align-items: flex-start; flex-direction: column; }
.foot-links { margin-left: 0; }
} }
.foot-links {
display: flex; @media (prefers-reduced-motion: reduce) {
gap: 18px; *, *::before, *::after { scroll-behavior: auto !important; transition-duration: 1ms !important; }
align-items: center;
flex-wrap: wrap;
} }
.footer-button { .footer-button {
appearance: none; appearance: none;
border: 0;
padding: 0; padding: 0;
border: 0;
background: transparent; background: transparent;
color: var(--ink-muted); color: var(--muted);
font: inherit;
cursor: pointer; cursor: pointer;
font: inherit;
} }
.footer-button:hover { .footer-button:hover {
color: var(--ink); color: var(--navy);
text-decoration: underline; text-decoration: underline;
text-underline-offset: 3px; text-underline-offset: 4px;
} }
/* ── analytics consent ── */ /* Consent-gated analytics controls added by /analytics.js. */
.consent-banner { .consent-banner {
position: fixed; position: fixed;
z-index: 100; z-index: 100;
@ -419,12 +154,12 @@ a:hover {
left: 24px; left: 24px;
width: min(350px, calc(100vw - 48px)); width: min(350px, calc(100vw - 48px));
padding: 16px; padding: 16px;
background: var(--surface); border: 1px solid var(--line);
border: 1px solid var(--hairline);
border-radius: 14px; border-radius: 14px;
background: var(--surface);
box-shadow: box-shadow:
0 20px 50px rgb(17 17 17 / 14%), 0 20px 50px rgb(12 27 58 / 14%),
0 2px 8px rgb(17 17 17 / 7%); 0 2px 8px rgb(12 27 58 / 7%);
animation: consent-in 180ms ease-out; animation: consent-in 180ms ease-out;
} }
.consent-banner[hidden] { .consent-banner[hidden] {
@ -439,20 +174,20 @@ a:hover {
} }
.consent-copy p { .consent-copy p {
margin: 0 0 7px; margin: 0 0 7px;
color: var(--ink-muted); color: var(--muted);
font-size: 13.5px; font-size: 13.5px;
line-height: 1.45; line-height: 1.45;
} }
.consent-copy a { .consent-copy a {
color: var(--ink-muted); color: var(--muted);
font-size: 12.5px; font-size: 12.5px;
text-decoration: underline; text-decoration: underline;
text-decoration-color: var(--hairline); text-decoration-color: var(--line-soft);
text-underline-offset: 3px; text-underline-offset: 3px;
} }
.consent-copy a:hover { .consent-copy a:hover {
color: var(--ink); color: var(--navy);
text-decoration-color: var(--ink); text-decoration-color: var(--navy);
} }
.consent-actions { .consent-actions {
display: grid; display: grid;
@ -461,27 +196,26 @@ a:hover {
margin-top: 12px; margin-top: 12px;
} }
.consent-actions button { .consent-actions button {
border: 1px solid var(--hairline);
border-radius: 8px;
min-height: 36px; min-height: 36px;
padding: 7px 12px; padding: 7px 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--surface); background: var(--surface);
color: var(--ink); color: var(--navy);
font: inherit; cursor: pointer;
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
cursor: pointer;
} }
.consent-actions button:hover { .consent-actions button:hover {
background: var(--surface-2); background: var(--paper-deep);
} }
.consent-actions .consent-accept { .consent-actions .consent-accept {
border-color: var(--ink); border-color: var(--navy);
background: var(--ink); background: var(--navy);
color: #ffffff; color: #fff;
} }
.consent-actions .consent-accept:hover { .consent-actions .consent-accept:hover {
background: #000000; background: #000;
} }
@keyframes consent-in { @keyframes consent-in {
from { from {
@ -503,43 +237,3 @@ a:hover {
padding: 15px; padding: 15px;
} }
} }
.openseo-badge {
position: fixed;
right: 20px;
bottom: 20px;
z-index: 50;
display: inline-flex;
align-items: center;
gap: 8px;
background: var(--ink);
color: #ffffff;
border-radius: 999px;
padding: 11px 17px;
font-size: 14px;
font-weight: 500;
}
.openseo-badge:hover {
background: #000000;
text-decoration: none;
}
.openseo-badge strong {
font-weight: 600;
}
.openseo-mark {
width: 22px;
height: 24px;
flex: none;
background: url("/openseo-logo.png") center / contain no-repeat;
/* The source logo is a silver tree on transparent; render it white so it
reads on the dark pill with no backing chip. */
filter: brightness(0) invert(1);
}
@media (max-width: 640px) {
.openseo-badge .badge-label {
display: none;
}
.openseo-badge {
padding: 10px 12px;
}
}

View File

@ -46,7 +46,7 @@ interface CrawlLink {
async function warmup(): Promise<void> { async function warmup(): Promise<void> {
// Prime the dev server so healthy pages don't read as slow on a cold start. // Prime the dev server so healthy pages don't read as slow on a cold start.
const paths = new Set<string>(["/", "/catalog", "/privacy"]); const paths = new Set<string>(["/", "/privacy"]);
for (const f of allFixtures) for (const p of fixturePaths(f)) paths.add(p); for (const f of allFixtures) for (const p of fixturePaths(f)) paths.add(p);
await Promise.all( await Promise.all(
[...paths].map((p) => [...paths].map((p) =>
@ -247,7 +247,7 @@ async function main() {
if (process.env.DEBUG_DEPTH) { if (process.env.DEBUG_DEPTH) {
for (const p of pages) { for (const p of pages) {
if (p.url.includes("/structure/deep/") || p.url.endsWith("/catalog")) { if (p.url.includes("/structure/deep/")) {
console.log(` depth ${p.crawlDepth} ${p.url}`); console.log(` depth ${p.crawlDepth} ${p.url}`);
} }
} }
@ -334,7 +334,6 @@ async function main() {
// Non-fixture content pages must be clean. // Non-fixture content pages must be clean.
check("Homepage", "/", [], false); check("Homepage", "/", [], false);
check("Catalog", "/catalog", [], false);
check("Privacy policy", "/privacy", [], false); check("Privacy policy", "/privacy", [], false);
const byCategory = new Map<string, Fixture[]>(); const byCategory = new Map<string, Fixture[]>();

View File

@ -3,23 +3,23 @@ import type { ReactNode } from "react";
export function SiteLayout({ children }: { children: ReactNode }) { export function SiteLayout({ children }: { children: ReactNode }) {
return ( return (
<> <>
<nav className="nav"> <nav className="nav" aria-label="BadSEO navigation">
<a className="brand" href="/"> <a className="brand" href="/">
badseo.dev BADSEO
</a>
<a className="nav-link" href="/catalog">
Catalog
</a> </a>
<span className="nav-links">
<a href="https://openseo.so">OpenSEO</a>
<a href="https://github.com/every-app/open-seo">GitHub</a>
</span>
</nav> </nav>
{children} {children}
<footer className="foot"> <footer className="foot">
<div className="foot-inner"> <div className="foot-inner">
<span> <a className="foot-brand" href="/">
badseo.dev is maintained by OpenSEO. Every page here is broken on BADSEO
purpose. </a>
</span>
<span className="foot-links"> <span className="foot-links">
<a href="/catalog">Catalog</a> <a href="/#issues">All issues</a>
<a href="https://github.com/every-app/open-seo">GitHub</a> <a href="https://github.com/every-app/open-seo">GitHub</a>
<a href="https://openseo.so">OpenSEO</a> <a href="https://openseo.so">OpenSEO</a>
<a href="/privacy">Privacy</a> <a href="/privacy">Privacy</a>
@ -33,15 +33,6 @@ export function SiteLayout({ children }: { children: ReactNode }) {
</span> </span>
</div> </div>
</footer> </footer>
<a
className="openseo-badge"
href="https://openseo.so"
title="Audit a site with OpenSEO"
>
<span className="openseo-mark" aria-hidden="true" />
<span className="badge-label">Maintained by </span>
<strong>OpenSEO</strong>
</a>
</> </>
); );
} }

View File

@ -12,7 +12,7 @@ const kitchenSink: Fixture = {
category: "Kitchen sink", category: "Kitchen sink",
name: "Kitchen sink (everything at once)", name: "Kitchen sink (everything at once)",
summary: summary:
"One page, six problems: a title that is too long, no meta description, two H1s, a skipped heading level, an image with no alt, and a slow response.", "A title that is too long, no meta description, two H1s, a skipped heading level, an image with no alt, and a slow response.",
lesson: lesson:
"Real broken pages usually have more than one problem. An audit should report every one of these, not stop at the first.", "Real broken pages usually have more than one problem. An audit should report every one of these, not stop at the first.",
expectedIssues: [ expectedIssues: [

View File

@ -1,7 +1,7 @@
// Rendering primitives for badseo.dev. // Rendering primitives for badseo.dev.
// //
// Everything the shared chrome emits (nav, footer, the "what this page tests" // Everything the shared chrome emits (nav, footer, and the "what this page
// panel, the OpenSEO badge) is deliberately SEO-NEUTRAL: no <h1><h6> and no // tests" panel) is deliberately SEO-NEUTRAL: no <h1><h6> and no
// <img>. That way each fixture's headings and images are fully under the // <img>. That way each fixture's headings and images are fully under the
// fixture's own control, and the audit measures exactly the defect we injected // fixture's own control, and the audit measures exactly the defect we injected
// — not accidental noise from the layout. // — not accidental noise from the layout.
@ -68,7 +68,7 @@ export function renderDocument(opts: DocumentOptions): string {
head.push( head.push(
'<link rel="preconnect" href="https://fonts.googleapis.com">', '<link rel="preconnect" href="https://fonts.googleapis.com">',
'<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>', '<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>',
'<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap">', '<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Archivo+Narrow:wght@500;600;700&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&family=Newsreader:opsz,wght@6..72,500;6..72,600&display=swap">',
); );
head.push('<link rel="stylesheet" href="/styles.css">'); head.push('<link rel="stylesheet" href="/styles.css">');
if (opts.headExtra) head.push(opts.headExtra); if (opts.headExtra) head.push(opts.headExtra);
@ -86,27 +86,16 @@ ${opts.bodyHtml}
function navHtml(): string { function navHtml(): string {
return `<nav class="nav"> return `<nav class="nav">
<a class="brand" href="/">badseo.dev</a> <a class="brand" href="/">BADSEO</a>
<a class="nav-link" href="/catalog">Catalog</a> <span class="nav-links"><a href="https://openseo.so">OpenSEO</a><a href="https://github.com/every-app/open-seo">GitHub</a></span>
</nav>`; </nav>`;
} }
/**
* The sitewide backlink to openseo.so, pinned bottom-right on every page. The
* logo is a CSS background-image (not an <img>) so the shared chrome stays
* image-free and never affects the images-missing-alt check.
*/
function openseoBadge(): string {
return `<a class="openseo-badge" href="https://openseo.so" title="Audit a site with OpenSEO">
<span class="openseo-mark" aria-hidden="true"></span><span class="badge-label">Maintained by </span><strong>OpenSEO</strong>
</a>`;
}
function footerHtml(): string { function footerHtml(): string {
return `<footer class="foot"><div class="foot-inner"> return `<footer class="foot"><div class="foot-inner">
<span>badseo.dev is maintained by OpenSEO. Every page here is broken on purpose.</span> <a class="foot-brand" href="/">BADSEO</a>
<span class="foot-links"> <span class="foot-links">
<a href="/catalog">Catalog</a> <a href="/#issues">All issues</a>
<a href="https://github.com/every-app/open-seo">GitHub</a> <a href="https://github.com/every-app/open-seo">GitHub</a>
<a href="https://openseo.so">OpenSEO</a> <a href="https://openseo.so">OpenSEO</a>
<a href="/privacy">Privacy</a> <a href="/privacy">Privacy</a>
@ -156,8 +145,7 @@ function testPanel(fixture: Fixture): string {
function withChrome(inner: string): string { function withChrome(inner: string): string {
return `${navHtml()} return `${navHtml()}
${inner} ${inner}
${footerHtml()} ${footerHtml()}`;
${openseoBadge()}`;
} }
interface PageOptions extends DocumentOptions { interface PageOptions extends DocumentOptions {

View File

@ -24,7 +24,7 @@ export const Route = createRootRoute({
}, },
{ {
rel: "stylesheet", rel: "stylesheet",
href: "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap", href: "https://fonts.googleapis.com/css2?family=Archivo+Narrow:wght@500;600;700&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&family=Newsreader:opsz,wght@6..72,500;6..72,600&display=swap",
}, },
{ rel: "stylesheet", href: "/styles.css" }, { rel: "stylesheet", href: "/styles.css" },
], ],

View File

@ -1,109 +1,7 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute, redirect } from "@tanstack/react-router";
import { AUDIT_ISSUE_TYPES } from "../../../src/shared/audit-issues";
import { SiteLayout } from "../components/site-layout";
import {
catalogLinkedFixtures,
categories,
duplicateUrlLinks,
} from "../fixtures/registry";
import type { Fixture } from "../fixtures/types";
export const Route = createFileRoute("/catalog")({ export const Route = createFileRoute("/catalog")({
head: () => ({ beforeLoad: () => {
meta: [ throw redirect({ to: "/", hash: "issues" });
{ title: "Technical SEO issues checklist | badseo.dev" }, },
{
name: "description",
content:
"A checklist of common technical SEO issues, each with a live example page: head tags, duplicate content, redirects, HTTP status, speed, and site structure.",
},
],
}),
component: CatalogPage,
}); });
function IssueDots({ fixture }: { fixture: Fixture }) {
return fixture.expectedIssues.map((issueId) => {
const issue = AUDIT_ISSUE_TYPES[issueId];
return (
<span
className={`dot dot-${issue.severity}`}
title={issue.title}
key={issueId}
/>
);
});
}
function CatalogPage() {
return (
<SiteLayout>
<main className="main">
<h1>Technical SEO issues, by category</h1>
<p className="lede">
Every page on the site, grouped by the kind of technical SEO issue it
shows. The dots are the issues an audit should report for that page.
</p>
<p className="legend">
<span>
<span className="dot dot-critical" /> critical
</span>
<span>
<span className="dot dot-warning" /> warning
</span>
<span>
<span className="dot dot-info" /> info
</span>
</p>
{categories.map((category) => (
<section className="index-group" key={category}>
<h2>{category}</h2>
<div className="index-list">
{catalogLinkedFixtures
.filter((fixture) => fixture.category === category)
.map((fixture) => (
<a
className="index-row"
href={fixture.path}
key={fixture.path}
>
<span className="row-name">{fixture.name}</span>
<span className="row-sum">{fixture.summary}</span>
<span className="row-sev">
<IssueDots fixture={fixture} />
</span>
</a>
))}
</div>
</section>
))}
<section className="index-group">
<h2>Duplicate URLs</h2>
<p>
The same page served again at a second address. This is the raw
material for the duplicate-content check. They are linked here so a
crawler reaches them and does not mistake them for orphans.
</p>
<p>
{duplicateUrlLinks.map((duplicate, index) => (
<span key={duplicate.path}>
{index > 0 ? " · " : null}
<a href={duplicate.path}>
<code>{duplicate.path}</code>
</a>
</span>
))}
</p>
</section>
<p style={{ marginTop: 32 }}>
A few pages are left off this list on purpose. An orphan page (
<code>/structure/orphan</code>) is only in the sitemap, and some 404,
500, and 403 URLs are only in the sitemap too, so a crawler has to
find them on its own.
</p>
</main>
</SiteLayout>
);
}

View File

@ -1,24 +1,27 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { SiteLayout } from "../components/site-layout"; import { SiteLayout } from "../components/site-layout";
import { allFixtures, fixturePaths } from "../fixtures/registry"; import { catalogLinkedFixtures, categories } from "../fixtures/registry";
const totalPaths = allFixtures.reduce( const PRODUCT_DESCRIPTION =
(count, fixture) => count + fixturePaths(fixture).length, "A website with example technical SEO issues for you to learn or test your agents";
0,
); const homepageCategories = [
const distinctIssueTypes = new Set( "Kitchen sink",
allFixtures.flatMap((fixture) => fixture.expectedIssues), ...categories.filter(
).size; (category) =>
category !== "Kitchen sink" &&
category !== "Head tags & headings" &&
category !== "Content quality",
),
"Head tags & headings",
"Content quality",
];
export const Route = createFileRoute("/")({ export const Route = createFileRoute("/")({
head: () => ({ head: () => ({
meta: [ meta: [
{ title: "Technical SEO issues, by example | badseo.dev" }, { title: "BadSEO | Technical SEO test site" },
{ { name: "description", content: PRODUCT_DESCRIPTION },
name: "description",
content:
"Every page here has one common technical SEO issue, from a missing title to a redirect loop, so you can test what your SEO crawler catches.",
},
], ],
}), }),
component: HomePage, component: HomePage,
@ -27,65 +30,41 @@ export const Route = createFileRoute("/")({
function HomePage() { function HomePage() {
return ( return (
<SiteLayout> <SiteLayout>
<main className="main"> <main className="home-main">
<section className="hero"> <section className="home-hero">
<h1>A website demonstrating common technical SEO problems</h1> <div className="home-copy">
<p className="lede"> <h1>BadSEO</h1>
Every page on this site has one thing wrong with it, on purpose: a <p>{PRODUCT_DESCRIPTION}</p>
missing title, a redirect loop, a page nothing links to. Point an </div>
SEO crawler at the site and see which problems it catches.
</p>
</section> </section>
<div className="stat-row"> <div className="home-issues" id="issues">
<div className="stat"> {homepageCategories.map((category) => (
<b>{allFixtures.length}</b> <section className="home-issue-group" key={category}>
<span>broken pages</span> <h3>{category}</h3>
</div> <div className="home-case-head" aria-hidden="true">
<div className="stat"> <span>Issue</span>
<b>{distinctIssueTypes}</b> <span>What it demonstrates</span>
<span>issue types</span> </div>
</div> <div className="home-case-list">
<div className="stat"> {catalogLinkedFixtures
<b>{totalPaths}</b> .filter((fixture) => fixture.category === category)
<span>crawlable URLs</span> .map((fixture) => (
</div> <a
className="home-case-row"
href={fixture.path}
key={fixture.path}
>
<span className="home-case-name">{fixture.name}</span>
<span className="home-case-summary">
{fixture.summary}
</span>
</a>
))}
</div>
</section>
))}
</div> </div>
<h2>What&apos;s on it</h2>
<p>
Each page breaks one thing and is otherwise fine, so an audit sees a
single problem instead of a pile of them. Every page shows what it is
testing and which issue an audit should report. Start with the{" "}
<a href="/catalog">catalog</a>, or open the{" "}
<a href="/kitchen-sink">kitchen-sink page</a>, which breaks six ways
at once.
</p>
<h2>Why it exists</h2>
<p>
SEO tools all say they find broken titles, duplicate pages, redirect
chains, and thin content. It is hard to check that they actually do.
badseo.dev gives you a site that is broken in known ways, so you can
run a crawler against it and compare what it finds to what is really
there. We use it to test the OpenSEO audit.
</p>
<h2>It&apos;s open source</h2>
<p>
The site is open source. If there is a common SEO mistake it does not
cover yet, you can add it. Each page is one small file that lists the
issues it should trigger, so a new page also works as a test. It is
maintained by the team behind <a href="https://openseo.so">OpenSEO</a>
, an open-source SEO tool.
</p>
<h2>Run an audit</h2>
<p>
<a href="https://openseo.so">OpenSEO</a> can crawl badseo.dev and
report the issues on each page, along with how to fix them. It is a
straightforward way to see what a crawler catches.
</p>
</main> </main>
</SiteLayout> </SiteLayout>
); );

View File

@ -50,7 +50,7 @@ export async function handleFixtureRequest(
metaDescription: "That URL is not one of the pages on this site.", metaDescription: "That URL is not one of the pages on this site.",
bodyHtml: `<h1>404, not found</h1> bodyHtml: `<h1>404, not found</h1>
<p class="lede">This URL is not one of the broken pages on the site. It is just missing.</p> <p class="lede">This URL is not one of the broken pages on the site. It is just missing.</p>
<p>Go back to the <a href="/catalog">catalog</a> to see the pages that break on purpose.</p>`, <p>Go back to the <a href="/#issues">examples</a> to see the pages that break on purpose.</p>`,
}), }),
{ {
status: 404, status: 404,