diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 3ccc9936..bae55c16 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -1,42 +1,53 @@ -name: cd +name: validate-and-deploy on: push: - branches: - - main + branches: [main] + pull_request: + +permissions: + contents: read jobs: - cd: + validate: runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Install pnpm - uses: pnpm/action-setup@v4 + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 with: run_install: false - - - name: Install Node.js - uses: actions/setup-node@v4 + - uses: actions/setup-node@v4 with: node-version: 22 - cache: 'pnpm' - - - name: Install dependencies - run: pnpm install - - # https://github.com/nuxt/content/issues/3249 - - name: Rebuild native modules - run: pnpm rebuild better-sqlite3 - - - name: Generate static files - run: pnpm generate + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm typecheck + - run: pnpm test + - run: pnpm build + - run: pnpm exec playwright install --with-deps chromium + - run: pnpm test:e2e --project=server + - run: pnpm generate + - run: pnpm test:output + - run: pnpm test:e2e --project=static + - uses: actions/upload-artifact@v4 + with: + name: site + path: .output/public + include-hidden-files: true - - name: Deploy - uses: peaceiris/actions-gh-pages@v3 + deploy: + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + needs: validate + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + name: site + path: public + - uses: peaceiris/actions-gh-pages@v3 with: github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: ./dist + publish_dir: ./public cname: colton.foo diff --git a/.gitignore b/.gitignore index 9e6924db..063d240c 100644 --- a/.gitignore +++ b/.gitignore @@ -6,5 +6,8 @@ node_modules .output .env dist +public/images/generated +playwright-report +test-results .data/content/contents.sqlite diff --git a/README.md b/README.md index f0730fe1..1ffd9a13 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # cmpadden.github.io -A personal website built with Nuxt 4, Vue, and Tailwind CSS. Routes are file-based in `pages/`; reusable layouts and UI live in `layouts/` and `components/`. Blog posts are Markdown files in `content/blog/`, parsed by `modules/content.ts` and served through the generated article API. Server endpoints and feed/sitemap routes are under `server/`. +A personal website built with Nuxt 4, Vue, and Tailwind CSS. Routes are file-based in `pages/`; reusable layouts and UI live in `layouts/` and `components/`. Blog posts are Markdown files in `content/blog/`, parsed by `modules/content.ts` and served through the generated article API. Server endpoints and feed/sitemap routes are under `server/`. Public types and site/route metadata live in `shared/`. The existing root-level Nuxt app structure is intentional; moving it under `app/` is optional organization, not a performance optimization. ## Setup @@ -16,7 +16,13 @@ pnpm install pnpm dev ``` -The development site is available at http://localhost:3000. +The development site is available at http://localhost:3000. Drafts are excluded by default. To preview a draft by its slug locally, explicitly opt in: + +```bash +NUXT_PREVIEW_DRAFTS=1 pnpm dev +``` + +Production builds always omit drafts, even if this environment variable is set. `modules/content/manifest.ts` validates frontmatter and owns publication policy; prerender routes, article APIs, feeds, sitemap, and legacy redirects all use it. `shared/site.ts` is the source of truth for the production origin and static page routes. ## Build and static generation @@ -26,22 +32,19 @@ pnpm generate pnpm preview ``` -## Experiment thumbnails +Build, generate, and dev automatically create responsive image derivatives in `public/images/generated/` (ignored by Git). GitHub Pages deploys `.output/public`; it does not run a Nitro server. Legacy `/articles/…` and `/atom/` URLs therefore have generated HTML redirects as well as Nitro HTTP redirects. The feed is `/atom.xml` so static hosting serves it as XML. Robots and sitemap are generated from shared site metadata. -Experiment thumbnail context and output paths live in `data/experiments.mjs`. Generate an image from that metadata with an optional art-direction adjustment: +## Validation ```bash -OPENAI_API_KEY=... pnpm generate:experiment-thumbnail -- \ - --experiment "photos.colton.boo" \ - --direction "make the exposure bands diagonal and sparse" +pnpm typecheck +pnpm test # publication/SEO unit tests +pnpm exec playwright install chromium # once locally +pnpm build +pnpm test:e2e --project=server # production Nitro + browser/API tests +pnpm generate +pnpm test:output # generated publication/asset assertions +pnpm test:e2e --project=static # real static hosting, without SPA fallback ``` -Use `--dry-run` to review the resolved prompt without calling the OpenAI Images API. - -## Layout conventions - -- `default`: site frame with Header and a scrollable content region. -- `light`: Header and centered page content, without the default frame. -- `empty`: no shared chrome, for standalone experiences. - -Page-specific layout selection uses `definePageMeta({ layout: "light" })` or `layout: "empty"`. +CI runs this sequence on pull requests and main; only a successful main build is deployed. Browser tests mock audio/MIDI devices to check cleanup, pending permissions, and hot-plugging without requiring hardware. Real-device behavior should still be smoke-tested when changing the experiments. diff --git a/assets/css/tailwind.css b/assets/css/tailwind.css index c8541f3b..d6e060c7 100644 --- a/assets/css/tailwind.css +++ b/assets/css/tailwind.css @@ -22,6 +22,16 @@ code { } @layer base { + [id] { scroll-margin-top: 5rem; } + + @media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + scroll-behavior: auto !important; + transition-duration: 0s !important; + animation-duration: 0s !important; + } + } + :root { color-scheme: light; --site-background: #f3f6f6; @@ -44,6 +54,7 @@ code { @font-face { font-family: Mafins; src: url("/fonts/Mafins.otf"); + font-display: swap; } @font-face { @@ -87,7 +98,7 @@ code { } .noise-bg::before { - background-image: url("/images/noise.png"); + background-image: url("/images/generated/noise.png"); background-repeat: repeat; content: ""; filter: grayscale(1) contrast(var(--noise-contrast, 1.15)); diff --git a/components/ExperimentCoverImage.vue b/components/ExperimentCoverImage.vue index ffe7977d..74506346 100644 --- a/components/ExperimentCoverImage.vue +++ b/components/ExperimentCoverImage.vue @@ -8,28 +8,35 @@ const props = withDefaults( { loading: "lazy" }, ); -const isLoaded = ref(false); -const image = ref(null); - -onMounted(() => { - if (image.value?.complete) { - isLoaded.value = true; - } -}); +const generatedBase = computed(() => + props.src.startsWith("/images/previews/") + ? props.src + .replace("/images/previews/", "/images/generated/") + .replace(/\.[^.]+$/, "") + : undefined, +); +const srcset = computed(() => + generatedBase.value + ? [320, 640, 960] + .map((width) => `${generatedBase.value}-${width}.webp ${width}w`) + .join(", ") + : undefined, +); diff --git a/components/Header.vue b/components/Header.vue index e7f23d90..b62edf89 100644 --- a/components/Header.vue +++ b/components/Header.vue @@ -1,5 +1,8 @@ diff --git a/pages/experiments/matrix.vue b/pages/experiments/matrix.vue index b1c84030..0c855582 100644 --- a/pages/experiments/matrix.vue +++ b/pages/experiments/matrix.vue @@ -1,5 +1,6 @@ - - - - diff --git a/pages/index.vue b/pages/index.vue index 02056d35..16142d27 100644 --- a/pages/index.vue +++ b/pages/index.vue @@ -1,7 +1,7 @@