diff --git a/.fallow/cache.bin b/.fallow/cache.bin deleted file mode 100644 index c8829cf8..00000000 Binary files a/.fallow/cache.bin and /dev/null differ diff --git a/.fallow/churn.bin b/.fallow/churn.bin deleted file mode 100644 index 22aeb8b4..00000000 Binary files a/.fallow/churn.bin and /dev/null differ diff --git a/.fallowrc.json b/.fallowrc.json index 2bc21c94..a41b5d00 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -1,27 +1,13 @@ { "$schema": "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json", "ignorePatterns": [ - "posts/**/*.mdx", "scripts/capture-screenshots.ts", - "src/app/resume/head.tsx", "src/components/ProjectCard.tsx", - "src/components/providers/PostHogProvider.tsx", "src/hooks/useKeyboardNavigation.ts", "src/hooks/useProjectScroll.ts", "src/lib/awardsTypes.ts" ], "ignoreDependencies": [ - "@mdx-js/loader", - "@mdx-js/react", - "@next/mdx", - "@vercel/speed-insights", - "gray-matter", - "postcss-load-config", - "remark-frontmatter", - "remark-html", - "remark-parse", - "remark-stringify", - "tailwindcss-animate", - "unified" + "tailwindcss-animate" ] } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..79bc5acd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,21 @@ +name: CI + +on: + pull_request: + workflow_dispatch: + +jobs: + checks: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm privacy:scan + - run: pnpm lint + - run: pnpm exec tsc --noEmit + - run: pnpm test diff --git a/.gitignore b/.gitignore index 11fda14c..14f98050 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,7 @@ squirrel.toml # local test artifacts .playwright-mcp/ /skillscan-product-pass.png + +# tool caches and local QA captures +/.fallow/ +/artifacts/ diff --git a/artifacts/maxwell-os/adventure-1440x900.png b/artifacts/maxwell-os/adventure-1440x900.png deleted file mode 100644 index e53478df..00000000 Binary files a/artifacts/maxwell-os/adventure-1440x900.png and /dev/null differ diff --git a/artifacts/maxwell-os/adventure.png b/artifacts/maxwell-os/adventure.png deleted file mode 100644 index 2b1122b3..00000000 Binary files a/artifacts/maxwell-os/adventure.png and /dev/null differ diff --git a/artifacts/maxwell-os/ants-1440x900.png b/artifacts/maxwell-os/ants-1440x900.png deleted file mode 100644 index 1bfa1b46..00000000 Binary files a/artifacts/maxwell-os/ants-1440x900.png and /dev/null differ diff --git a/artifacts/maxwell-os/ants.png b/artifacts/maxwell-os/ants.png deleted file mode 100644 index bf4f4464..00000000 Binary files a/artifacts/maxwell-os/ants.png and /dev/null differ diff --git a/artifacts/maxwell-os/desktop-1440x900.png b/artifacts/maxwell-os/desktop-1440x900.png deleted file mode 100644 index 863c296b..00000000 Binary files a/artifacts/maxwell-os/desktop-1440x900.png and /dev/null differ diff --git a/artifacts/maxwell-os/desktop.png b/artifacts/maxwell-os/desktop.png deleted file mode 100644 index 43e46bc9..00000000 Binary files a/artifacts/maxwell-os/desktop.png and /dev/null differ diff --git a/artifacts/maxwell-os/mobile-390x844.png b/artifacts/maxwell-os/mobile-390x844.png deleted file mode 100644 index e38af711..00000000 Binary files a/artifacts/maxwell-os/mobile-390x844.png and /dev/null differ diff --git a/artifacts/maxwell-os/office-1440x900.png b/artifacts/maxwell-os/office-1440x900.png deleted file mode 100644 index 4e52528f..00000000 Binary files a/artifacts/maxwell-os/office-1440x900.png and /dev/null differ diff --git a/artifacts/maxwell-os/office.png b/artifacts/maxwell-os/office.png deleted file mode 100644 index 21a9ea6f..00000000 Binary files a/artifacts/maxwell-os/office.png and /dev/null differ diff --git a/next.config.mjs b/next.config.mjs index 91d92e7d..c61d1198 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -18,15 +18,7 @@ const nextConfig = { experimental: { optimizePackageImports: [ "@radix-ui/react-dialog", - "@radix-ui/react-label", - "@radix-ui/react-scroll-area", - "@radix-ui/react-select", - "@radix-ui/react-separator", - "@radix-ui/react-slot", - "@radix-ui/react-switch", - "@radix-ui/react-tabs", - "@radix-ui/react-toggle", - "@radix-ui/react-toggle-group", + "@radix-ui/react-visually-hidden", "lucide-react", "framer-motion", ], diff --git a/package.json b/package.json index a7119f7f..6fc136ef 100644 --- a/package.json +++ b/package.json @@ -39,12 +39,11 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "swr": "^2.4.2", - "tailwind-merge": "^2.4.0", - "zod": "^3.23.8" + "tailwind-merge": "^2.4.0" }, "devDependencies": { "@playwright/test": "^1.57.0", - "@types/node": "^20", + "@types/node": "^24.13.3", "@types/react": "^18", "@types/react-dom": "^18", "eslint": "^9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 13e70f86..af8922b8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,16 +56,13 @@ importers: tailwind-merge: specifier: ^2.4.0 version: 2.4.0 - zod: - specifier: ^3.23.8 - version: 3.23.8 devDependencies: '@playwright/test': specifier: ^1.57.0 version: 1.57.0 '@types/node': - specifier: ^20 - version: 20.14.10 + specifier: ^24.13.3 + version: 24.13.3 '@types/react': specifier: ^18 version: 18.3.3 @@ -871,8 +868,8 @@ packages: '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} - '@types/node@20.14.10': - resolution: {integrity: sha512-MdiXf+nDuMvY0gJKxyfZ7/6UFsETO7mGKF54MVD/ekJS6HdFtpZFBgrh6Pseu64XTb2MLyFPlbW6hj8HYRQNOQ==} + '@types/node@24.13.3': + resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} '@types/pako@2.0.4': resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==} @@ -2636,8 +2633,8 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} - undici-types@5.26.5: - resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} unrs-resolver@1.12.2: resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==} @@ -2736,9 +2733,6 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 - zod@3.23.8: - resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} - zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -3369,9 +3363,9 @@ snapshots: '@types/json5@0.0.29': {} - '@types/node@20.14.10': + '@types/node@24.13.3': dependencies: - undici-types: 5.26.5 + undici-types: 7.18.2 '@types/pako@2.0.4': {} @@ -5429,7 +5423,7 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 - undici-types@5.26.5: {} + undici-types@7.18.2: {} unrs-resolver@1.12.2: dependencies: @@ -5565,6 +5559,4 @@ snapshots: dependencies: zod: 3.25.76 - zod@3.23.8: {} - zod@3.25.76: {} diff --git a/public/manifest.json b/public/manifest.json index 72c4635a..d814e7f4 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -25,7 +25,7 @@ "shortcuts": [ { "name": "Projects", - "url": "/projects", + "url": "/#projects", "description": "View my work" }, { diff --git a/scripts/assert-canon-feed-fresh.mjs b/scripts/assert-canon-feed-fresh.mjs index 76f5e5bf..016ee571 100644 --- a/scripts/assert-canon-feed-fresh.mjs +++ b/scripts/assert-canon-feed-fresh.mjs @@ -5,6 +5,9 @@ import { readFileSync } from "node:fs"; const FEED_PATH = "src/lib/canonFeed.ts"; const DAY_MS = 24 * 60 * 60 * 1000; const maxAgeDays = Number.parseInt(process.env.CANON_FEED_MAX_AGE_DAYS ?? "2", 10); +// A stale feed is a warning by default: the Canon cron on the mini can stall, and +// that must not block deploying unrelated code. Set CANON_FEED_STRICT=1 to fail. +const strict = process.env.CANON_FEED_STRICT === "1"; function fail(message) { console.error(`[canon:fresh] ${message}`); @@ -37,10 +40,12 @@ if (Number.isNaN(now.getTime())) { const ageDays = Math.max(0, Math.floor((now.getTime() - syncedDate.getTime()) / DAY_MS)); if (ageDays > maxAgeDays) { - fail( + const message = `${FEED_PATH} source data is ${ageDays} days old (synced ${freshnessDate}). ` + - "Regenerate it with `bun run scripts/export-folio-feed.ts` from the Canon repo.", - ); + "Regenerate it with `bun run scripts/export-folio-feed.ts` from the Canon repo."; + if (strict) fail(message); + console.warn(`[canon:fresh] WARNING: ${message}`); + process.exit(0); } process.stdout.write(`[canon:fresh] ${FEED_PATH} source synced ${freshnessDate}; age ${ageDays}d <= ${maxAgeDays}d.\n`); diff --git a/src/app/api/artstyle/generate/route.ts b/src/app/api/artstyle/generate/route.ts deleted file mode 100644 index 5d7639b6..00000000 --- a/src/app/api/artstyle/generate/route.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { NextRequest } from "next/server"; -import { z } from "zod"; -import { - createId, - parseJsonRequest, - parseOptionalPrompt, -} from "@/lib/artstyle/common"; -import { - createLocalGeneratedRecipe, - normalizeGeneratedRecipe, -} from "@/lib/artstyle/generateRecipe"; - -const RequestSchema = z.object({ - prompt: z.string().max(500).optional(), - shaderOnly: z.boolean().optional(), -}); - -export async function POST(req: NextRequest) { - try { - const parsedRequest = await parseJsonRequest(req, RequestSchema); - if (!parsedRequest.ok) { - return parsedRequest.response; - } - - const prompt = parseOptionalPrompt(parsedRequest.data.prompt); - const id = createId(10); - - return Response.json({ - id, - name: "Generated", - prompt, - recipe: createLocalGeneratedRecipe(prompt), - }); - } catch { - return Response.json( - normalizeGeneratedRecipe( - { - id: "local", - name: "Generated", - prompt: "", - recipe: [ - { - type: "shaderTemplate", - template: "voronoi-fade", - uniforms: { colorA: "#ffd1dc", colorB: "#120a1a", cells: 10 }, - }, - ], - }, - "local", - "" - ) - ); - } -} diff --git a/src/app/api/artstyle/route.ts b/src/app/api/artstyle/route.ts deleted file mode 100644 index 4f590712..00000000 --- a/src/app/api/artstyle/route.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { NextRequest } from "next/server"; -import { z } from "zod"; -import { - ART_STYLE_ALLOWED, - cheapLocalArtStyleMap, - parseJsonRequest, - parseOptionalPrompt, - type ArtStyle, -} from "@/lib/artstyle/common"; - -const RequestSchema = z.object({ - prompt: z.string().max(500).optional(), -}); - -function resolveStyleChoice( - style: string | undefined, - localStyle: ArtStyle | null -): ArtStyle { - if (style && ART_STYLE_ALLOWED.includes(style as ArtStyle)) { - return style as ArtStyle; - } - return localStyle ?? "ai"; -} - -export async function POST(req: NextRequest) { - try { - const parsedRequest = await parseJsonRequest(req, RequestSchema); - if (!parsedRequest.ok) { - return parsedRequest.response; - } - - const prompt = parseOptionalPrompt(parsedRequest.data.prompt); - const localStyle = cheapLocalArtStyleMap(prompt); - return Response.json({ style: resolveStyleChoice(undefined, localStyle) }); - } catch { - return Response.json({ style: "ai" }, { status: 200 }); - } -} diff --git a/src/app/api/artstyle/shader/route.ts b/src/app/api/artstyle/shader/route.ts deleted file mode 100644 index c81c5b6e..00000000 --- a/src/app/api/artstyle/shader/route.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { NextRequest } from "next/server"; -import { z } from "zod"; -import { - createId, - createSeed, - parseJsonRequest, - parseOptionalPrompt, -} from "@/lib/artstyle/common"; -import { - buildCatchallShaderRecipe, - buildLocalShaderRecipe, -} from "@/lib/artstyle/shaderRecipe"; - -const RequestSchema = z.object({ - prompt: z.string().max(500).optional(), - seed: z.number().int().finite().optional(), -}); - -export async function POST(req: NextRequest) { - try { - const parsedRequest = await parseJsonRequest(req, RequestSchema); - if (!parsedRequest.ok) { - return parsedRequest.response; - } - - const prompt = parseOptionalPrompt(parsedRequest.data.prompt); - const id = createId(10); - const seed = createSeed(parsedRequest.data.seed); - - return Response.json(buildLocalShaderRecipe(id, prompt, seed)); - } catch { - return Response.json(buildCatchallShaderRecipe(createId(10))); - } -} diff --git a/src/app/case-study/[slug]/page.tsx b/src/app/case-study/[slug]/page.tsx index ee26bdb4..42782338 100644 --- a/src/app/case-study/[slug]/page.tsx +++ b/src/app/case-study/[slug]/page.tsx @@ -1,4 +1,5 @@ import { Metadata } from "next"; +import { notFound } from "next/navigation"; import { getCaseStudy, getAllCaseStudySlugs, @@ -65,6 +66,10 @@ export default async function CaseStudyPage({ params }: PageProps) { const { slug } = await params; const study = getCaseStudy(slug); + if (!study) { + notFound(); + } + const structuredData = study ? { "@context": "https://schema.org", diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 323746a4..5dc1d8d8 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -58,7 +58,9 @@ export const metadata: Metadata = { creator: "@internetmaxwell", }, alternates: { - canonical: "https://dev.maxwellyoung.info", + // "./" resolves to the current route, so every page canonicalises to itself + // unless it sets its own. A fixed URL here made every page claim the homepage. + canonical: "./", }, robots: { index: true, diff --git a/src/app/resume/head.tsx b/src/app/resume/head.tsx deleted file mode 100644 index 5d704cd7..00000000 --- a/src/app/resume/head.tsx +++ /dev/null @@ -1,23 +0,0 @@ -export default function Head() { - return ( - <> -