Skip to content

Commit 57a3ed4

Browse files
committed
Ship craft refresh, launched filter, ecosystem rail, and regression tooling
1 parent 707f27f commit 57a3ed4

24 files changed

Lines changed: 1188 additions & 390 deletions

.github/workflows/ci.yml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches:
6+
- "**"
7+
pull_request:
8+
9+
jobs:
10+
verify:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- name: Checkout
14+
uses: actions/checkout@v4
15+
16+
- name: Setup Node
17+
uses: actions/setup-node@v4
18+
with:
19+
node-version: 20
20+
cache: npm
21+
22+
- name: Install dependencies
23+
run: npm ci
24+
25+
- name: Verify project metadata and types/tests
26+
run: npm run verify

AGENTS.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# AGENTS.md
2+
3+
## Mission
4+
Maintain a minimal, high-signal portfolio that converts three paths cleanly:
5+
1. Hiring Maxwell full-time
6+
2. Hiring Maxwell for focused sprint work
7+
3. Hiring ninetynine digital for broader studio scope
8+
9+
## Operating Priorities
10+
1. Keep visual density low. Additions must stay compact and intentional.
11+
2. Never ship broken links or dead CTAs.
12+
3. Favor measurable proof (outcomes, role clarity, shipped work) over decorative copy.
13+
4. Preserve in-progress work visibility without pretending it is launched.
14+
15+
## Required Checks Before Push
16+
Run:
17+
- `pnpm run build`
18+
- `pnpm run check:projects`
19+
- `pnpm run check:site`
20+
21+
Optional visual regression:
22+
- `pnpm run check:site:screenshots`
23+
- To set a new visual baseline:
24+
- `pnpm run check:site:screenshots -- --update-baseline`
25+
26+
## Deployment
27+
Production deploy command:
28+
- `vercel --prod --yes`
29+
30+
After deploy:
31+
1. Confirm alias assignment succeeded.
32+
2. Re-run `pnpm run check:site -- --base https://dev.maxwellyoung.info`.
33+
3. Log broken routes immediately and patch before next content iteration.
34+
35+
## Project Data Rules
36+
1. Use `releaseStage` as source of truth for launch state.
37+
2. Only show live links for:
38+
- `releaseStage: "released"`, or
39+
- explicit preview exceptions with `allowPreviewLink: true`.
40+
3. Projects without live links must render a clear pending state (no fake CTA).
41+
42+
## High-Leverage Roadmap (Active)
43+
1. `Launched` filter in projects view for fast employer scan.
44+
2. Compact ecosystem rail near homepage hero.
45+
3. Automated route + screenshot regression checks in `scripts/site-regression-check.mjs`.

package.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,13 @@
77
"build": "next build --webpack",
88
"start": "next start",
99
"lint": "eslint .",
10-
"test": "node --import tsx --test src/**/*.test.ts"
10+
"test": "node --import tsx --test src/**/*.test.ts",
11+
"typecheck": "tsc --noEmit",
12+
"check:projects": "tsx scripts/check-project-metadata.ts",
13+
"check:craft": "node scripts/check-craft-guards.mjs",
14+
"check:site": "node scripts/site-regression-check.mjs",
15+
"check:site:screenshots": "node scripts/site-regression-check.mjs --screenshots",
16+
"verify": "npm run check:projects && npm run test && npm run typecheck"
1117
},
1218
"dependencies": {
1319
"@hookform/resolvers": "^3.9.0",

scripts/check-craft-guards.mjs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
#!/usr/bin/env node
2+
import { readFileSync } from "node:fs";
3+
import { globSync } from "node:fs";
4+
5+
const files = [
6+
...globSync("src/components/craft/**/*.tsx"),
7+
...globSync("src/app/craft/**/*.tsx"),
8+
];
9+
10+
const failures = [];
11+
12+
for (const file of files) {
13+
const source = readFileSync(file, "utf8");
14+
15+
const hasClassTransitionAll =
16+
/className\s*=\s*["'`][^"'`]*transition-all/.test(source) ||
17+
/className\s*=\s*{[^}]*transition-all/.test(source);
18+
19+
if (hasClassTransitionAll) {
20+
failures.push(`${file}: contains transition-all`);
21+
}
22+
23+
if (source.includes("exit={") && !source.includes("AnimatePresence")) {
24+
failures.push(`${file}: has exit animations without AnimatePresence`);
25+
}
26+
}
27+
28+
if (failures.length > 0) {
29+
console.error("Craft guard checks failed:");
30+
for (const failure of failures) {
31+
console.error(`- ${failure}`);
32+
}
33+
process.exit(1);
34+
}
35+
36+
console.log("Craft guard checks passed.");

scripts/check-project-metadata.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { projects } from "../src/lib/projects";
2+
3+
type Issue = {
4+
slug?: string;
5+
message: string;
6+
};
7+
8+
const issues: Issue[] = [];
9+
const seenSlugs = new Set<string>();
10+
11+
for (const project of projects) {
12+
if (seenSlugs.has(project.slug)) {
13+
issues.push({ slug: project.slug, message: "Duplicate slug." });
14+
}
15+
seenSlugs.add(project.slug);
16+
17+
if (project.link !== project.links?.live) {
18+
issues.push({
19+
slug: project.slug,
20+
message: "Legacy `link` must mirror `links.live`.",
21+
});
22+
}
23+
24+
if (project.codeLink !== project.links?.repo) {
25+
issues.push({
26+
slug: project.slug,
27+
message: "Legacy `codeLink` must mirror `links.repo`.",
28+
});
29+
}
30+
31+
if (
32+
project.releaseStage === "in-progress" &&
33+
!project.allowPreviewLink &&
34+
project.links?.live
35+
) {
36+
issues.push({
37+
slug: project.slug,
38+
message:
39+
"In-progress projects cannot expose a live link unless `allowPreviewLink` is true.",
40+
});
41+
}
42+
43+
if (project.releaseStage === "planned" && project.links?.live) {
44+
issues.push({
45+
slug: project.slug,
46+
message: "Planned projects cannot expose a live link.",
47+
});
48+
}
49+
}
50+
51+
if (issues.length > 0) {
52+
console.error("Project metadata check failed:");
53+
for (const issue of issues) {
54+
const prefix = issue.slug ? `[${issue.slug}]` : "[project]";
55+
console.error(`- ${prefix} ${issue.message}`);
56+
}
57+
process.exit(1);
58+
}
59+
60+
console.log(`Project metadata check passed (${projects.length} projects).`);
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"hashes": {}
3+
}

scripts/site-regression-check.mjs

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
#!/usr/bin/env node
2+
3+
import { createHash } from "node:crypto";
4+
import fs from "node:fs/promises";
5+
import path from "node:path";
6+
import process from "node:process";
7+
8+
const args = new Set(process.argv.slice(2));
9+
const getArgValue = (flag, fallback) => {
10+
const idx = process.argv.indexOf(flag);
11+
if (idx === -1 || idx + 1 >= process.argv.length) return fallback;
12+
return process.argv[idx + 1];
13+
};
14+
15+
const BASE_URL = getArgValue("--base", process.env.BASE_URL || "http://localhost:3000");
16+
const UPDATE_BASELINE = args.has("--update-baseline");
17+
const TAKE_SCREENSHOTS = args.has("--screenshots");
18+
const OUT_DIR = path.resolve("artifacts/site-regression");
19+
const BASELINE_FILE = path.resolve("scripts/site-regression-baseline.json");
20+
21+
const ROUTES = ["/projects", "/for-hiring-managers", "/work-with-me"];
22+
const VIEWPORTS = [
23+
{ name: "desktop", width: 1440, height: 900 },
24+
{ name: "mobile", width: 390, height: 844 },
25+
];
26+
27+
function keyFor(route, viewportName) {
28+
return `${route}|${viewportName}`;
29+
}
30+
31+
function sha256(content) {
32+
return createHash("sha256").update(content).digest("hex");
33+
}
34+
35+
async function ensureDir(dir) {
36+
await fs.mkdir(dir, { recursive: true });
37+
}
38+
39+
async function loadBaseline() {
40+
try {
41+
const raw = await fs.readFile(BASELINE_FILE, "utf8");
42+
const parsed = JSON.parse(raw);
43+
return parsed && typeof parsed === "object" ? parsed : { hashes: {} };
44+
} catch {
45+
return { hashes: {} };
46+
}
47+
}
48+
49+
async function saveBaseline(data) {
50+
await fs.writeFile(BASELINE_FILE, `${JSON.stringify(data, null, 2)}\n`, "utf8");
51+
}
52+
53+
async function runStatusChecks() {
54+
const results = [];
55+
for (const route of ROUTES) {
56+
const url = `${BASE_URL}${route}`;
57+
try {
58+
const res = await fetch(url, { redirect: "follow" });
59+
results.push({
60+
route,
61+
url,
62+
status: res.status,
63+
ok: res.status >= 200 && res.status < 400,
64+
});
65+
} catch (error) {
66+
results.push({
67+
route,
68+
url,
69+
status: 0,
70+
ok: false,
71+
error: String(error),
72+
});
73+
}
74+
}
75+
return results;
76+
}
77+
78+
async function runScreenshotChecks(baseline) {
79+
const { chromium } = await import("playwright");
80+
const browser = await chromium.launch({ headless: true });
81+
const screenshotHashes = {};
82+
const mismatches = [];
83+
const captureDir = path.join(OUT_DIR, new Date().toISOString().replace(/[:.]/g, "-"));
84+
await ensureDir(captureDir);
85+
86+
try {
87+
for (const viewport of VIEWPORTS) {
88+
const context = await browser.newContext({
89+
viewport: { width: viewport.width, height: viewport.height },
90+
deviceScaleFactor: 1,
91+
});
92+
const page = await context.newPage();
93+
94+
for (const route of ROUTES) {
95+
const url = `${BASE_URL}${route}`;
96+
await page.goto(url, { waitUntil: "networkidle", timeout: 45_000 });
97+
const fileName = `${route.replace(/\//g, "_").replace(/^_/, "") || "home"}-${viewport.name}.png`;
98+
const filePath = path.join(captureDir, fileName);
99+
await page.screenshot({ path: filePath, fullPage: true });
100+
const image = await fs.readFile(filePath);
101+
const hash = sha256(image);
102+
const key = keyFor(route, viewport.name);
103+
screenshotHashes[key] = hash;
104+
105+
const baselineHash = baseline.hashes?.[key];
106+
if (baselineHash && baselineHash !== hash) {
107+
mismatches.push({
108+
key,
109+
route,
110+
viewport: viewport.name,
111+
baselineHash,
112+
currentHash: hash,
113+
filePath,
114+
});
115+
}
116+
}
117+
118+
await context.close();
119+
}
120+
} finally {
121+
await browser.close();
122+
}
123+
124+
return { screenshotHashes, mismatches, captureDir };
125+
}
126+
127+
function printStatus(results) {
128+
console.log("\nSite status checks");
129+
for (const r of results) {
130+
if (r.ok) {
131+
console.log(` OK ${r.status} ${r.route}`);
132+
} else {
133+
console.log(` FAIL ${r.status} ${r.route}${r.error ? ` (${r.error})` : ""}`);
134+
}
135+
}
136+
}
137+
138+
function printMismatches(mismatches) {
139+
if (!mismatches.length) {
140+
console.log("\nScreenshot diff");
141+
console.log(" OK no screenshot hash regressions");
142+
return;
143+
}
144+
console.log("\nScreenshot diff");
145+
for (const m of mismatches) {
146+
console.log(` FAIL ${m.route} (${m.viewport})`);
147+
console.log(` baseline: ${m.baselineHash}`);
148+
console.log(` current : ${m.currentHash}`);
149+
}
150+
}
151+
152+
async function main() {
153+
console.log(`Running site regression checks against ${BASE_URL}`);
154+
const statusResults = await runStatusChecks();
155+
printStatus(statusResults);
156+
157+
const baseline = await loadBaseline();
158+
let screenshotResult = { screenshotHashes: {}, mismatches: [], captureDir: null };
159+
160+
if (TAKE_SCREENSHOTS) {
161+
screenshotResult = await runScreenshotChecks(baseline);
162+
printMismatches(screenshotResult.mismatches);
163+
if (screenshotResult.captureDir) {
164+
console.log(`\nScreenshots saved: ${screenshotResult.captureDir}`);
165+
}
166+
} else {
167+
console.log("\nScreenshot diff");
168+
console.log(" SKIP run with --screenshots to capture and compare");
169+
}
170+
171+
if (UPDATE_BASELINE && TAKE_SCREENSHOTS) {
172+
await saveBaseline({ hashes: screenshotResult.screenshotHashes });
173+
console.log(`\nBaseline updated: ${BASELINE_FILE}`);
174+
}
175+
176+
const statusOk = statusResults.every((r) => r.ok);
177+
const screenshotsOk = !TAKE_SCREENSHOTS || screenshotResult.mismatches.length === 0;
178+
process.exit(statusOk && screenshotsOk ? 0 : 1);
179+
}
180+
181+
main().catch((error) => {
182+
console.error(error);
183+
process.exit(1);
184+
});

0 commit comments

Comments
 (0)