diff --git a/.github/workflows/backfill-previews.yml b/.github/workflows/backfill-previews.yml deleted file mode 100644 index f86a5550..00000000 --- a/.github/workflows/backfill-previews.yml +++ /dev/null @@ -1,119 +0,0 @@ -name: Backfill Media Previews - -# One-shot, manually-triggered workflow that runs scripts/backfill-previews.ts -# against the production database. Idempotent — skips Media docs whose -# preview.ascii is already 288 chars long. Safe to re-run. -# -# Why this exists: -# PR #140 added preview.color and preview.ascii fields with a beforeChange -# hook that fires on create + re-upload. Existing Media docs uploaded -# before #140 stay NULL on these fields and render the broken yellow -# placeholder fallback. This workflow backfills them. -# -# Operational notes: -# - Defaults to dry-run. Author must explicitly set dry_run=false to write. -# - Sets I_KNOW_THIS_IS_NOT_PROD=1 so the script's assertNonProductionDatabase -# guard allows the prod DB host (mirrors scripts/seed-test-db.ts:16). -# - Reuses the same DATABASE_URL secret (SUPABASE_SESSION_URL) and -# PAYLOAD_SECRET as deploy.yml. The script fetches Media files via their -# public GCS URLs, so no GCS auth is needed for reads. -# - Concurrency-locked so two clicks from the Actions UI cannot stomp on -# each other. - -on: - workflow_dispatch: - inputs: - dry_run: - description: 'Dry run (true = no writes; false = live backfill)' - type: boolean - required: true - default: true - -permissions: - contents: read - -concurrency: - group: backfill-previews-production - cancel-in-progress: false - -jobs: - backfill: - runs-on: ubuntu-latest - environment: production - timeout-minutes: 10 - steps: - - uses: actions/checkout@v6 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v6 - with: - node-version: 22 - cache: pnpm - - run: pnpm install --frozen-lockfile - - # Defense in depth: the script's assertNonProductionDatabase guard is - # bypassed below for production. To prevent a future fork from accidentally - # pointing this workflow at an unexpected host (e.g. someone copies this - # file, swaps the script ref, but forgets to update DATABASE_URL), assert - # that the resolved DB hostname matches the expected Supabase pattern - # before invoking the bypass. - - name: Assert DB host is the expected prod host - env: - DATABASE_URL: ${{ secrets.SUPABASE_SESSION_URL }} - run: | - host=$(node -e 'console.log(new URL(process.env.DATABASE_URL).hostname)') - case "$host" in - *.supabase.co|*.supabase.com|*.pooler.supabase.com) - echo "OK: $host" ;; - *) - echo "::error::Unexpected DB host: $host"; exit 1 ;; - esac - - - name: Run backfill - env: - DATABASE_URL: ${{ secrets.SUPABASE_SESSION_URL }} - PAYLOAD_SECRET: ${{ secrets.PAYLOAD_SECRET }} - NEXT_PUBLIC_SERVER_URL: ${{ vars.NEXT_PUBLIC_SERVER_URL }} - I_KNOW_THIS_IS_NOT_PROD: '1' - DRY_RUN_INPUT: ${{ inputs.dry_run }} - # Fail-safe shell branch: any value of DRY_RUN_INPUT that is not exactly - # "false" (e.g. "", "True", "1", malformed runner serialization) defaults - # to dry-run. Matches the L14 comment "Author must explicitly set - # dry_run=false to write". - run: | - if [ "$DRY_RUN_INPUT" = "false" ]; then - echo "::warning::Running LIVE — will update Media docs in production." - pnpm tsx scripts/backfill-previews.ts - else - echo "::notice::Running in DRY RUN mode (no writes)." - pnpm tsx scripts/backfill-previews.ts --dry-run - fi - - # After a live backfill: (a) verify every Media doc has a populated - # 288-char preview.ascii — fail loudly if not, since the script swallows - # per-doc decode errors as `failed++` and exits 0; (b) warm individual - # post pages so direct landings (RSS, social, search) get fresh HTML - # instead of stale yellow-fallback prerenders for up to an hour. - - name: Verify previews and warm post pages - if: ${{ inputs.dry_run != true }} - env: - PROD_URL: ${{ vars.NEXT_PUBLIC_SERVER_URL }} - run: | - echo "Asserting all media docs have populated previews…" - missing=$(curl -fsS "$PROD_URL/api/media?limit=0&depth=0&select%5Bpreview%5D=true" \ - | jq '[.docs[] | select((.preview.ascii // "") | length != 288)] | length') - if [ "$missing" -ne 0 ]; then - echo "::error::$missing media doc(s) still lack a populated 288-char preview.ascii" - exit 1 - fi - echo "All media docs verified." - - echo "Warming /posts and /…" - curl -fsS -o /dev/null -w "%{http_code} %{url_effective}\n" "$PROD_URL/posts" || true - curl -fsS -o /dev/null -w "%{http_code} %{url_effective}\n" "$PROD_URL/" || true - - echo "Warming individual post pages…" - curl -fsS "$PROD_URL/api/posts?where%5Bstatus%5D%5Bequals%5D=published&limit=0&depth=0&select%5Bslug%5D=true" \ - | jq -r '.docs[].slug' \ - | while read -r slug; do - curl -fsS -o /dev/null -w "%{http_code} /posts/$slug\n" "$PROD_URL/posts/$slug" || true - done diff --git a/CONTENT_MODEL.md b/CONTENT_MODEL.md index 185f4729..e152de9c 100644 --- a/CONTENT_MODEL.md +++ b/CONTENT_MODEL.md @@ -89,7 +89,7 @@ Taxonomy labels applied to posts. Source: `src/collections/Tags.ts`. ### 5) Media -Image uploads with auto-generated preview data. Source: `src/collections/Media.ts`. +Image uploads managed by Payload. Source: `src/collections/Media.ts`. **Access:** public read; write requires authentication. @@ -99,12 +99,6 @@ Image uploads with auto-generated preview data. Source: `src/collections/Media.t - `alt` — text, required - `caption` — textarea -- `lqip` — text, read-only; legacy AVIF data-URL placeholder written during the PR #140 → #246 deploy window. Transitional: will be removed once all documents have a populated `preview`. Tracked in #246. -- `preview` — group field (auto-generated content-aware preview, populated on upload or by backfill) with sub-fields: - - `color` — text, read-only; dominant color as `#rrggbb` - - `ascii` — text, read-only; 24×12 luminance halftone (288 chars, row-major) - -Both `lqip` and `preview` hooks (`generateLqipHook`, `generatePreviewHook`) run on `beforeChange`. The `preview` fields are canonical post-PR-#140; `lqip` is dual-written only during the cutover window. --- diff --git a/migrations/20260601_000000_drop_media_lqip_preview.ts b/migrations/20260601_000000_drop_media_lqip_preview.ts new file mode 100644 index 00000000..f77b50e9 --- /dev/null +++ b/migrations/20260601_000000_drop_media_lqip_preview.ts @@ -0,0 +1,45 @@ +import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres' + +/** + * Database Migration: Drop Media lqip + preview columns + * + * Removes the columns backing the now-deleted image-preview experiment from + * the `media` table: + * + * - `lqip` — legacy AVIF low-quality-image-placeholder data URL + * - `preview_color` — dominant color (`#rrggbb`) for the ASCII preview + * - `preview_ascii` — 24×12 luminance halftone (288 chars) + * + * The ASCII/LQIP placeholder experiment was removed (issue #441); heroes now + * render plain `next/image` with a CSS spinner, so none of these columns have + * a consumer. This also completes #246's "drop the lqip column". + * + * Uses DROP COLUMN IF EXISTS for idempotency — safe to run against databases + * where the columns were never added (e.g. a fresh DB created with + * `push: true`) or were already dropped manually. + * + * The existing add-column migrations (20260425_220000_add_media_lqip, + * 20260428_010142_add_media_preview) are intentionally left untouched; this is + * a new forward migration layered on top of them. + * + * Down migration: re-adds the three columns as nullable varchars (reversible + * shape-wise; the previously-stored placeholder values are not restored). + */ + +export async function up({ db }: MigrateUpArgs): Promise { + await db.execute(sql` + ALTER TABLE "media" + DROP COLUMN IF EXISTS "lqip", + DROP COLUMN IF EXISTS "preview_color", + DROP COLUMN IF EXISTS "preview_ascii"; + `) +} + +export async function down({ db }: MigrateDownArgs): Promise { + await db.execute(sql` + ALTER TABLE "media" + ADD COLUMN IF NOT EXISTS "lqip" varchar, + ADD COLUMN IF NOT EXISTS "preview_color" varchar, + ADD COLUMN IF NOT EXISTS "preview_ascii" varchar; + `) +} diff --git a/migrations/index.ts b/migrations/index.ts index 83efc2c4..be6d90a1 100644 --- a/migrations/index.ts +++ b/migrations/index.ts @@ -12,6 +12,7 @@ import * as migration_20260425_220000_add_media_lqip from './20260425_220000_add import * as migration_20260428_010142_add_media_preview from './20260428_010142_add_media_preview'; import * as migration_20260517_184638_dedicated_date_modified from './20260517_184638_dedicated_date_modified'; import * as migration_20260519_000221_add_posts_schema_type_and_steps from './20260519_000221_add_posts_schema_type_and_steps'; +import * as migration_20260601_000000_drop_media_lqip_preview from './20260601_000000_drop_media_lqip_preview'; export const migrations = [ { @@ -84,4 +85,9 @@ export const migrations = [ down: migration_20260519_000221_add_posts_schema_type_and_steps.down, name: '20260519_000221_add_posts_schema_type_and_steps', }, + { + up: migration_20260601_000000_drop_media_lqip_preview.up, + down: migration_20260601_000000_drop_media_lqip_preview.down, + name: '20260601_000000_drop_media_lqip_preview', + }, ]; diff --git a/package.json b/package.json index 40e5f17d..483951f2 100644 --- a/package.json +++ b/package.json @@ -24,8 +24,6 @@ "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", "test:e2e:debug": "playwright test --debug", - "backfill:lqip": "tsx scripts/backfill-lqip.ts", - "backfill:previews": "tsx scripts/backfill-previews.ts", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/scripts/backfill-lqip.ts b/scripts/backfill-lqip.ts deleted file mode 100644 index 3186af8d..00000000 --- a/scripts/backfill-lqip.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * Backfill LQIP (Low-Quality Image Placeholder) for existing Media docs. - * - * INTENDED FOR ONE-SHOT POST-DEPLOY INVOCATION against the Google Cloud Storage bucket. - * DO NOT run this in CI or as part of the deploy pipeline. - * - * Usage: - * npx tsx scripts/backfill-lqip.ts # live run - * npx tsx scripts/backfill-lqip.ts --dry-run # preview without writes - * - * Prerequisites: - * - DATABASE_URL and PAYLOAD_SECRET in environment (e.g. via .env.local) - * - GCS_BUCKET - * - GCS_HMAC_ACCESS_KEY - * - GCS_HMAC_SECRET - * - * Review the --dry-run output before the live run. - */ - -import 'dotenv/config' -import { getPayload } from 'payload' -import config from '../src/payload.config' -import { generateLqipDataUrl } from '../src/lib/lqip/encode' - -const DRY_RUN = process.argv.includes('--dry-run') -const SLEEP_MS = 200 - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)) -} - -async function main() { - console.log(`[backfill-lqip] Starting${DRY_RUN ? ' (DRY RUN — no writes)' : ''}…`) - - const payload = await getPayload({ config }) - - const all = await payload.find({ - collection: 'media', - pagination: false, - where: { - lqip: { exists: false }, - }, - }) - - const pending = all.docs - const total = pending.length - - console.log(`[backfill-lqip] ${total} doc(s) without LQIP (of ${all.totalDocs} total media matched)`) - - let succeeded = 0 - let failed = 0 - - for (let i = 0; i < pending.length; i++) { - const doc = pending[i] - const label = `[${i + 1}/${total}] id=${doc.id} filename=${doc.filename ?? '(none)'}` - - if (!doc.url) { - console.warn(`${label} — SKIP: no url`) - failed++ - continue - } - - if (DRY_RUN) { - console.log(`${label} — would generate LQIP from ${doc.url}`) - continue - } - - try { - const response = await fetch(doc.url) - if (!response.ok) { - throw new Error(`HTTP ${response.status} fetching ${doc.url}`) - } - const arrayBuffer = await response.arrayBuffer() - const lqip = await generateLqipDataUrl(Buffer.from(arrayBuffer)) - - await payload.update({ - collection: 'media', - id: doc.id, - data: { lqip }, - }) - - console.log(`${label} — generated (${lqip.length} chars)`) - succeeded++ - } catch (err) { - console.error( - `${label} — FAILED: ${err instanceof Error ? err.message : String(err)}` - ) - failed++ - } - - // Respect GCS request quotas between iterations. - if (i < pending.length - 1) { - await sleep(SLEEP_MS) - } - } - - if (!DRY_RUN) { - console.log( - `[backfill-lqip] Done. succeeded=${succeeded} failed=${failed} total=${total}` - ) - } else { - console.log(`[backfill-lqip] Dry run complete. ${total} doc(s) would be processed.`) - } - - process.exit(0) -} - -main().catch((err) => { - console.error('[backfill-lqip] Fatal error:', err) - process.exit(1) -}) diff --git a/scripts/backfill-previews.ts b/scripts/backfill-previews.ts deleted file mode 100644 index 9405ed85..00000000 --- a/scripts/backfill-previews.ts +++ /dev/null @@ -1,156 +0,0 @@ -/** - * Backfill content-aware previews (preview.color + preview.ascii) for - * existing Media docs. - * - * INTENDED FOR ONE-SHOT POST-DEPLOY INVOCATION against a development / - * test database, OR against production with the explicit env-var override. - * DO NOT run this in CI or as part of the deploy pipeline. - * - * Usage: - * npx tsx scripts/backfill-previews.ts # live run (asserts non-prod) - * npx tsx scripts/backfill-previews.ts --dry-run # preview without writes - * - * Production override (mirrors scripts/seed-test-db.ts:16 convention): - * I_KNOW_THIS_IS_NOT_PROD=1 npx tsx scripts/backfill-previews.ts - * - * Idempotency: skips docs whose `preview.ascii` is already 288 chars long. - * - * Re-entrancy: passes `context.skipPreviewHook = true` on every update so - * the hook does not re-fire and refetch / regenerate the same buffer. - * - * Prerequisites: - * - DATABASE_URL and PAYLOAD_SECRET in environment (e.g. via .env.local) - * - Network access to the Media URLs (doc.url) - */ - -import 'dotenv/config' -import { getPayload } from 'payload' -import config from '../src/payload.config' -import { generatePreview, ASCII_LENGTH } from '../src/lib/preview/encode' - -const DRY_RUN = process.argv.includes('--dry-run') -const SLEEP_MS = 200 - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)) -} - -function assertNonProductionDatabase(): void { - const raw = process.env.DATABASE_URL - if (!raw) throw new Error('DATABASE_URL is not set') - - let host: string - try { - host = new URL(raw).hostname - } catch { - throw new Error('DATABASE_URL is not a valid URL') - } - - const allowed = - /^(localhost|127\.0\.0\.1|::1)$/.test(host) || /(^|[-.])test([-.]|$)/.test(host) - if (!allowed) { - throw new Error( - `backfill-previews refuses to run against host ${host}. ` + - `This script writes to every Media doc that lacks a populated preview. ` + - `Point DATABASE_URL at a local/test-tier database (host must be ` + - `localhost/127.0.0.1/::1 or contain a "test" segment), or set ` + - `I_KNOW_THIS_IS_NOT_PROD=1 to override (used for the one-shot ` + - `post-deploy run against production).`, - ) - } -} - -async function main(): Promise { - if (process.env.I_KNOW_THIS_IS_NOT_PROD !== '1') { - assertNonProductionDatabase() - } - - console.log(`[backfill-previews] Starting${DRY_RUN ? ' (DRY RUN — no writes)' : ''}…`) - - const payload = await getPayload({ config }) - - // Fetch all media docs; filter client-side because Payload's `where` operators - // for nested group fields with text comparisons can vary by adapter, and we - // want a length-based idempotency check that's universally consistent. - const all = await payload.find({ - collection: 'media', - pagination: false, - }) - - const pending = all.docs.filter((doc) => { - const ascii = (doc as { preview?: { ascii?: string | null } }).preview?.ascii - return !ascii || ascii.length !== ASCII_LENGTH - }) - const total = pending.length - - console.log( - `[backfill-previews] ${total} doc(s) need backfill (of ${all.totalDocs} total media)`, - ) - - let succeeded = 0 - let failed = 0 - - for (let i = 0; i < pending.length; i++) { - const doc = pending[i] - const label = `[${i + 1}/${total}] id=${doc.id} filename=${doc.filename ?? '(none)'}` - - if (!doc.url) { - console.warn(`${label} — SKIP: no url`) - failed++ - continue - } - - if (DRY_RUN) { - console.log(`${label} — would generate preview from ${doc.url}`) - continue - } - - try { - const response = await fetch(doc.url) - if (!response.ok) { - throw new Error(`HTTP ${response.status} fetching ${doc.url}`) - } - const arrayBuffer = await response.arrayBuffer() - const preview = await generatePreview(Buffer.from(arrayBuffer)) - - await payload.update({ - collection: 'media', - id: doc.id, - data: { preview }, - // Skip the beforeChange hook — we already have the preview; running - // the hook would just re-decode the same buffer (which we don't have - // here anyway) and warn about the missing req.file. - context: { skipPreviewHook: true }, - }) - - console.log(`${label} — generated color=${preview.color} ascii.len=${preview.ascii.length}`) - succeeded++ - } catch (err) { - console.error( - `${label} — FAILED: ${err instanceof Error ? err.message : String(err)}`, - ) - failed++ - } - - if (i < pending.length - 1) { - await sleep(SLEEP_MS) - } - } - - if (!DRY_RUN) { - console.log( - `[backfill-previews] Done. succeeded=${succeeded} failed=${failed} total=${total}`, - ) - } else { - console.log( - `[backfill-previews] Dry run complete. ${total} doc(s) would be processed.`, - ) - } - - process.exit(0) -} - -main().catch((err) => { - console.error('[backfill-previews] Fatal error:', err) - process.exit(1) -}) diff --git a/src/app/(frontend)/posts/[slug]/page.tsx b/src/app/(frontend)/posts/[slug]/page.tsx index 7e44bda5..b8a9bd79 100644 --- a/src/app/(frontend)/posts/[slug]/page.tsx +++ b/src/app/(frontend)/posts/[slug]/page.tsx @@ -183,8 +183,8 @@ export default async function PostPage({ params }: PostPageProps) {
onLoad. - See ImageWithAsciiPreview.tsx for the wrapper - contract (data-loaded attribute on the wrapper). + HERO SPINNER (image hero loading state) + A standard centered spinner sits behind the two + stacked theme variants in ThemeAwareHero; the + opaque `object-cover` image paints over it once + the active variant's bytes arrive. Zero-JS, so + the hero stays a server component. ────────────────────────────────────────────── */ -.ascii-preview-wrapper { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - overflow: hidden; -} - -/* Dominant-color background. Visible from byte 0; sits behind both the - ASCII layer and the image. Falls back to surface when --preview-color - is not set (missing color metadata). */ -.ascii-preview-bg { - position: absolute; - inset: 0; - background-color: var(--preview-color, var(--surface)); +.hero-spinner { + /* Behind both variants (which establish their own stacking via + fill/object-cover); the loaded image is opaque and hides the spinner. */ z-index: 0; } -/* The 24×12 luminance halftone. One
 child of the wrapper.
-   Aria-hidden — purely decorative, real alt text comes from the . */
-.ascii-preview-layer {
-  position: absolute;
-  inset: 0;
-  margin: 0;
-  z-index: 1;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  font-family: var(--font-mono-stack);
-  font-size: 10px;
-  line-height: 1;
-  letter-spacing: 0;
-  color: var(--text-tertiary);
-  opacity: 0.55;
-  white-space: pre;
-  text-align: center;
-  pointer-events: none;
-  user-select: none;
-  /* Smooth out the crossfade to the loaded image. */
-  transition: opacity 200ms ease-out;
-}
-
-.dark .ascii-preview-layer {
-  opacity: 0.4;
-}
-
-/* The real  sits on top of both background and ASCII.
-   It starts at opacity 0 and fades in once data-loaded flips. */
-.ascii-preview-img {
-  position: absolute;
-  inset: 0;
-  width: 100%;
-  height: 100%;
-  object-fit: cover;
-  z-index: 2;
-  opacity: 0;
-  transition: opacity 200ms ease-out;
-}
-
-.ascii-preview-wrapper[data-loaded="true"] .ascii-preview-layer {
-  opacity: 0;
-}
-
-.ascii-preview-wrapper[data-loaded="true"] .ascii-preview-img {
-  opacity: 1;
-}
-
-@media (prefers-reduced-motion: reduce) {
-  /* Skip the crossfade entirely — once the image loads it appears in place. */
-  .ascii-preview-layer,
-  .ascii-preview-img {
-    transition: none !important;
-  }
-}
-
 /* ──────────────────────────────────────────────
    MERMAID — DARK MODE CONTRAST
    Sequence-diagram rect bands have hardcoded
diff --git a/src/collections/Media.ts b/src/collections/Media.ts
index d16e05ba..432689b5 100644
--- a/src/collections/Media.ts
+++ b/src/collections/Media.ts
@@ -1,6 +1,4 @@
 import type { CollectionConfig } from 'payload'
-import { generateLqipHook } from '@/lib/hooks/generate-lqip'
-import { generatePreviewHook } from '@/lib/hooks/generate-preview'
 
 export const Media: CollectionConfig = {
   slug: 'media',
@@ -13,15 +11,6 @@ export const Media: CollectionConfig = {
     update: ({ req: { user } }) => !!user,
     delete: ({ req: { user } }) => !!user,
   },
-  hooks: {
-    // Both hooks run during PR 1's two-step deploy window: the new ASCII
-    // preview is the canonical render path going forward, but lqip is still
-    // written so existing render fallbacks continue to function during the
-    // window between PR 1 deploy and the post-deploy backfill run. Both
-    // hooks fail-soft on bad inputs and never block the upload.
-    // The lqip hook + field will be removed in PR 2.
-    beforeChange: [generateLqipHook, generatePreviewHook],
-  },
   upload: {
     staticDir: 'public/media',
     imageSizes: [
@@ -42,40 +31,5 @@ export const Media: CollectionConfig = {
       name: 'caption',
       type: 'textarea',
     },
-    {
-      name: 'lqip',
-      type: 'text',
-      admin: {
-        readOnly: true,
-        description:
-          'Legacy AVIF data URL placeholder. Used as a fallback during the PR 1 → PR 2 deploy window. Will be removed once all docs have a populated preview.',
-      },
-    },
-    {
-      name: 'preview',
-      type: 'group',
-      admin: {
-        description:
-          'Auto-generated content-aware preview. Populated on upload (or by backfill).',
-      },
-      fields: [
-        {
-          name: 'color',
-          type: 'text',
-          admin: {
-            readOnly: true,
-            description: 'Dominant color (#rrggbb).',
-          },
-        },
-        {
-          name: 'ascii',
-          type: 'text',
-          admin: {
-            readOnly: true,
-            description: '24×12 luminance halftone (288 chars, row-major).',
-          },
-        },
-      ],
-    },
   ],
 }
diff --git a/src/components/AsciiPreviewImage.tsx b/src/components/AsciiPreviewImage.tsx
deleted file mode 100644
index da64ce5d..00000000
--- a/src/components/AsciiPreviewImage.tsx
+++ /dev/null
@@ -1,81 +0,0 @@
-"use client"
-
-import Image from "next/image"
-import { useRef, type CSSProperties } from "react"
-
-/**
- * Convert absolute URLs from our own server to relative paths.
- * Mirrors OptimizedImage's helper — required so Next.js's image loader
- * resolves locally in dev. Duplicated rather than imported because
- * OptimizedImage is server-only and importing from a server file into a
- * client file is fine, but the helper is small and self-contained.
- */
-function toRelativeSrc(src: string): string {
-  const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL
-  if (serverUrl && src.startsWith(serverUrl)) {
-    return src.slice(serverUrl.length)
-  }
-  return src
-}
-
-interface AsciiPreviewImageProps {
-  src: string
-  alt: string
-  className?: string
-  sizes?: string
-  style?: CSSProperties
-  fetchPriority?: "high" | "low" | "auto"
-  priority?: boolean
-}
-
-/**
- * Small client-only wrapper around  that flips a sibling's
- * data-loaded attribute on the wrapper element when the image finishes
- * loading. This is the minimal client-component slice of
- * : the surrounding wrapper, the dominant-color
- * background, and the static ASCII 
 all stay server-rendered so
- * Frame 0 (background + halftone) is paintable on the very first byte.
- *
- * The opacity transition itself lives in CSS
- * (`[data-loaded="true"] > .ascii-layer { opacity: 0 }` etc.) so this
- * component only needs to (a) render the  and (b) toggle the
- * single attribute when load resolves.
- */
-export function AsciiPreviewImage({
-  src,
-  alt,
-  className = "",
-  sizes,
-  style,
-  fetchPriority,
-  priority = false,
-}: AsciiPreviewImageProps) {
-  // Use a ref to climb to the parent wrapper rather than passing a setter
-  // down. The wrapper is the closest element with the [data-loaded] attr.
-  const imgRef = useRef(null)
-
-  return (
-    {alt} {
-        // Climb to the nearest ancestor with the data-loaded attribute and
-        // flip it. Using a DOM lookup (rather than an event handler that
-        // forwards into a React state setter on a parent server component)
-        // keeps this surgical: no parent state, no rerender.
-        const el = imgRef.current
-        if (!el) return
-        const wrapper = el.closest('[data-loaded]') as HTMLElement | null
-        if (wrapper) wrapper.dataset.loaded = "true"
-      }}
-    />
-  )
-}
diff --git a/src/components/ImageWithAsciiPreview.tsx b/src/components/ImageWithAsciiPreview.tsx
deleted file mode 100644
index e4569e3f..00000000
--- a/src/components/ImageWithAsciiPreview.tsx
+++ /dev/null
@@ -1,138 +0,0 @@
-import type { CSSProperties } from "react"
-import { AsciiPreviewImage } from "@/components/AsciiPreviewImage"
-import { ASCII_COLS, ASCII_LENGTH, ASCII_ROWS } from "@/lib/preview/encode"
-
-interface ImageWithAsciiPreviewProps {
-  src: string
-  alt: string
-  className?: string
-  sizes?: string
-  style?: CSSProperties
-  fetchPriority?: "high" | "low" | "auto"
-  priority?: boolean
-  /**
-   * Content-aware preview from the Media collection's `preview` group.
-   * `color` is `#rrggbb`; `ascii` is exactly 288 chars (24×12, row-major).
-   * Either may be missing — the component degrades:
-   *   - missing color → `var(--surface)` background
-   *   - missing or wrong-length ascii → no 
 rendered
-   * Both failure modes are calmer than the original LQIP yellow flood.
-   */
-  preview?: { color?: string | null; ascii?: string | null } | null
-}
-
-/**
- * Tailwind utility tokens whose effect is `display: …` — including their
- * variant-prefixed forms (`dark:hidden`, `md:flex`, etc.). We propagate
- * these to the inner  so `getComputedStyle().display` reflects
- * theme visibility, not just the wrapper's. The wrapper keeps the same
- * tokens too, so the entire stack (background + ASCII + image) hides
- * together — propagation only adds the inner-element invariant the
- * theme-aware-hero E2E spec asserts on the .
- */
-const DISPLAY_TOKEN_RE =
-  /^(?:[a-z][\w-]*:)?(?:hidden|block|inline|inline-block|flex|inline-flex|grid|inline-grid|contents|table|table-cell|table-row|flow-root|list-item)$/
-
-function partitionDisplayClasses(className: string): string {
-  return className
-    .split(/\s+/)
-    .filter((tok) => tok && DISPLAY_TOKEN_RE.test(tok))
-    .join(" ")
-}
-
-/**
- * Validates and splits an ASCII halftone string into one row per array
- * element, preserving original character order (row-major). Returns null
- * when the input is missing or wrong length so the caller can omit the
- * 
 entirely.
- */
-function splitAsciiRows(ascii?: string | null): string[] | null {
-  if (!ascii || ascii.length !== ASCII_LENGTH) return null
-  const rows: string[] = []
-  for (let r = 0; r < ASCII_ROWS; r++) {
-    rows.push(ascii.slice(r * ASCII_COLS, (r + 1) * ASCII_COLS))
-  }
-  return rows
-}
-
-/**
- * Renders an image with a content-aware ASCII halftone preview painted
- * underneath it. The preview is server-rendered so Frame 0 is visible
- * the instant the wrapper paints (no JS required, no SVG filter chain,
- * no transparent-PNG fallback that could turn yellow under
- * Next.js's blur SVG filter).
- *
- * Component split (per bot review #139 finding B1):
- *  - This component is a Server Component. It renders the
- *    `
` wrapper, the dominant-color background, - * and the static ASCII `
`. Frame 0 is paintable from SSR HTML.
- *  - The actual `` (with `onLoad`) lives in ,
- *    a small `"use client"` child that toggles `data-loaded="true"` on
- *    its nearest ancestor with that attribute.
- *  - CSS in globals.css drives the opacity transition off the
- *    `[data-loaded]` attribute selector — so the reveal is style-only.
- *
- * If you change the wrapper's data attribute name, also update:
- *  - `AsciiPreviewImage.tsx` (the closest('[data-loaded]') selector)
- *  - `globals.css` (`.ascii-preview-wrapper` rules under [data-loaded])
- */
-export function ImageWithAsciiPreview({
-  src,
-  alt,
-  className = "",
-  sizes,
-  style,
-  fetchPriority,
-  priority,
-  preview,
-}: ImageWithAsciiPreviewProps) {
-  const color = preview?.color ?? undefined
-  const rows = splitAsciiRows(preview?.ascii)
-
-  // Tailwind utility merge — wrapper holds the [data-loaded] attribute that
-  // CSS uses to drive both the ASCII fade-out and the image fade-in.
-  const wrapperClass = `ascii-preview-wrapper ${className}`.trim()
-
-  // The E2E theme-aware-hero spec asserts `display` on the inner , not
-  // the wrapper. Without forwarding `hidden` / `dark:hidden` / `dark:block`
-  // to the , hiding the wrapper leaves the descendant  reporting
-  // `display: block` to getComputedStyle. Propagate just the display tokens.
-  const imgDisplayClass = partitionDisplayClasses(className)
-  const imgClass = `ascii-preview-img ${imgDisplayClass}`.trim()
-
-  return (
-    
- {/* Background fill — sits at z=0 behind both the ASCII layer and the image. */} - - ) -} diff --git a/src/components/OptimizedImage.tsx b/src/components/OptimizedImage.tsx deleted file mode 100644 index 60d7fa26..00000000 --- a/src/components/OptimizedImage.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import Image from "next/image"; -import type { CSSProperties } from "react"; - -// 1×1 transparent PNG — permanent fallback for Media docs without a populated lqip. -const FALLBACK_BLUR_DATA_URL = - "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mN8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="; - -interface CommonProps { - src: string; - alt: string; - priority?: boolean; - fetchPriority?: "high" | "low" | "auto"; - className?: string; - sizes?: string; - style?: CSSProperties; - /** - * Controls the Next.js Image placeholder strategy. - * - "blur" (default): shows a blur-up animation while loading; blurDataURL - * falls back to the 1×1 transparent PNG when not supplied. - * - "empty": no placeholder; use for non-hero images that don't need blur-up. - */ - placeholder?: "blur" | "empty"; - /** Per-image AVIF LQIP data URL. Only used when placeholder="blur". */ - blurDataURL?: string; -} - -type OptimizedImageProps = CommonProps & - ( - | { fill: true; width?: never; height?: never } - | { fill?: false; width: number; height: number } - ); - -/** - * Optimized image component using Next.js Image - * - * Provides automatic optimization, responsive images, and lazy loading. - * Use priority={true} for LCP images (hero images, featured post images). - * - * Performance benefits: - * - Automatic WebP/AVIF conversion (30-50% smaller) - * - Responsive srcset generation (correct size per device) - * - Lazy loading (images load as user scrolls) - * - Blur placeholder (prevents layout shift) - * - * @example - * ```tsx - * - * ``` - */ -/** - * Convert absolute URLs from our own server to relative paths. - * Payload generates full URLs using NEXT_PUBLIC_SERVER_URL, but in dev - * the images are served locally — relative paths let Next.js resolve them. - */ -function toRelativeSrc(src: string): string { - const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL; - if (serverUrl && src.startsWith(serverUrl)) { - return src.slice(serverUrl.length); - } - return src; -} - -export function OptimizedImage(props: OptimizedImageProps) { - const { - src, - alt, - priority = false, - fetchPriority, - className = "", - sizes, - style, - placeholder = "blur", - blurDataURL, - } = props; - - const resolvedBlurDataURL = - placeholder === "blur" ? (blurDataURL ?? FALLBACK_BLUR_DATA_URL) : undefined; - - const dimensionProps = props.fill - ? { fill: true as const } - : { width: props.width, height: props.height }; - - return ( - {alt} - ); -} diff --git a/src/components/ThemeAwareHero.tsx b/src/components/ThemeAwareHero.tsx index 73c0a633..95e1803e 100644 --- a/src/components/ThemeAwareHero.tsx +++ b/src/components/ThemeAwareHero.tsx @@ -1,8 +1,6 @@ -import { OptimizedImage } from "@/components/OptimizedImage"; -import { ImageWithAsciiPreview } from "@/components/ImageWithAsciiPreview"; +import Image from "next/image"; import { isMediaObject } from "@/lib/types/media"; import type { Media } from "@/payload-types"; -import { ASCII_LENGTH } from "@/lib/preview/encode"; interface ThemeAwareHeroProps { light: number | Media | null | undefined; @@ -30,24 +28,16 @@ function focalPointStyle( } /** - * True when a media doc has a usable preview (color present AND ascii is the - * canonical 288 chars). Either alone is non-rendering, but both must be valid - * for the new ImageWithAsciiPreview path to win the dual-read. - * - * Truth table (per bot review #139 finding I2): - * preview present + lqip present → ImageWithAsciiPreview (preview wins) - * preview present + lqip missing → ImageWithAsciiPreview (preview wins) - * preview missing + lqip present → OptimizedImage with blur (legacy lqip) - * preview missing + lqip missing → OptimizedImage with FALLBACK 1×1 PNG - * (existing pre-#138 behavior — calmer - * than the original yellow flood for - * freshly-uploaded docs because the new - * hook also writes preview at upload) + * Convert absolute URLs from our own server to relative paths. + * Payload generates full URLs using NEXT_PUBLIC_SERVER_URL, but in dev + * the images are served locally — relative paths let Next.js resolve them. */ -function hasUsablePreview(media: Media): boolean { - const ascii = media.preview?.ascii ?? ""; - const color = media.preview?.color ?? ""; - return color.length > 0 && ascii.length === ASCII_LENGTH; +function toRelativeSrc(src: string): string { + const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL; + if (serverUrl && src.startsWith(serverUrl)) { + return src.slice(serverUrl.length); + } + return src; } /** @@ -60,17 +50,17 @@ function hasUsablePreview(media: Media): boolean { * crossfades between them automatically. We intentionally do NOT add any * `transition-opacity` or fade CSS here on the wrapper — a CSS transition * would compete with the View Transitions snapshot crossfade and degrade the - * animation. The ASCII reveal transition lives on the inner ASCII layer - * (.ascii-preview-layer), not on either of the two variant wrappers. + * animation. * * The parent `relative` wrapper holds an `aspect-ratio` matching the source * images so swapping which child is `display: none` never causes layout shift. * - * Per-variant render path: - * - If `preview` is populated (color + 288-char ascii) → ImageWithAsciiPreview - * - Else (legacy / not-yet-backfilled) → OptimizedImage with placeholder="blur" - * using the legacy `lqip` field (or falling back to the 1×1 PNG default - * inside OptimizedImage if neither is present). + * Loading state: a CSS-only spinner sits in the center of the positioned + * wrapper, rendered BEHIND both images (the images are `object-cover` + `fill`, + * so the opaque loaded image paints over the spinner). This keeps the + * component a zero-JS server component — no `onLoad` handler, no client + * boundary — while still showing a standard centered spinner during the + * brief window before the active variant's bytes arrive. * * LCP note: both children are `loading="lazy"` by default with * `fetchPriority="high"`. The browser correctly skips lazy-loading the @@ -94,59 +84,39 @@ export function ThemeAwareHero({ const height = light.height || 630; const aspectStyle = { aspectRatio: aspectRatio ?? `${width} / ${height}` }; const imgStyle = focalPointStyle(focalPoint); - - const lightUsesPreview = hasUsablePreview(light); - const darkUsesPreview = hasUsablePreview(dark); + const resolvedSizes = + sizes || "(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"; return (
- {lightUsesPreview ? ( - - ) : ( - - )} - {darkUsesPreview ? ( - - ) : ( - - )} + {/* Centered loading spinner, painted over by the opaque image once loaded. */} + ); } diff --git a/src/lib/hooks/generate-lqip.ts b/src/lib/hooks/generate-lqip.ts deleted file mode 100644 index 14c1b546..00000000 --- a/src/lib/hooks/generate-lqip.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { CollectionBeforeChangeHook } from 'payload' -import { generateLqipDataUrl } from '@/lib/lqip/encode' - -/** - * Payload beforeChange hook for the Media collection. - * - * On create-only operations: reads the uploaded file buffer, generates a - * 24×12 AVIF LQIP data URL, and attaches it as `lqip` on the document. - * - * Fail-soft: any error (missing buffer, non-image, sharp failure) logs a - * warning and returns data UNCHANGED. Never throws or blocks the upload. - */ -export const generateLqipHook: CollectionBeforeChangeHook = async ({ - data, - req, - operation, -}) => { - // Only run on create — skip metadata-only updates. - if (operation !== 'create') { - return data - } - - // Guard: need a file buffer. - const file = req.file - if (!file?.data) { - req.payload.logger.warn('[generate-lqip] No file buffer on request — skipping LQIP generation') - return data - } - - // Guard: must be an image mimeType. - const mimeType = file.mimetype ?? data?.mimeType ?? '' - if (!mimeType.startsWith('image/')) { - req.payload.logger.warn( - `[generate-lqip] Non-image mimeType "${mimeType}" — skipping LQIP generation` - ) - return data - } - - try { - const lqip = await generateLqipDataUrl(Buffer.from(file.data)) - return { ...data, lqip } - } catch (err) { - req.payload.logger.warn( - `[generate-lqip] Failed to generate LQIP: ${err instanceof Error ? err.message : String(err)}` - ) - return data - } -} diff --git a/src/lib/hooks/generate-preview.ts b/src/lib/hooks/generate-preview.ts deleted file mode 100644 index e7c0d54c..00000000 --- a/src/lib/hooks/generate-preview.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { CollectionBeforeChangeHook } from 'payload' -import { generatePreview } from '@/lib/preview/encode' - -/** - * Payload beforeChange hook for the Media collection. - * - * Computes a content-aware preview from the uploaded file buffer: - * - `preview.color` — dominant `#rrggbb` (~7 bytes) - * - `preview.ascii` — 24×12 luminance halftone (288 chars) - * - * Runs on: - * - `create` (always, when a file buffer is present) - * - `update` when a file buffer is present (admin re-upload). Metadata-only - * edits (no req.file) are skipped silently. - * - * Backfill scripts MUST set `req.context.skipPreviewHook = true` on their - * payload.update() calls to avoid an infinite hook → backfill loop. - * - * Fail-soft: any decode error logs a warning and returns data UNCHANGED. - * Never throws or blocks the upload. - */ -export const generatePreviewHook: CollectionBeforeChangeHook = async ({ - data, - req, - operation, - context, -}) => { - // Backfill / re-entrancy guard. - if (context && (context as { skipPreviewHook?: boolean }).skipPreviewHook) { - return data - } - - const file = req.file - - // Metadata-only update with no file buffer — quietly skip. - if (operation === 'update' && !file?.data) { - return data - } - - // Need a file buffer to do anything useful. - if (!file?.data) { - req.payload.logger.warn( - '[generate-preview] No file buffer on request — skipping preview generation', - ) - return data - } - - const mimeType = file.mimetype ?? data?.mimeType ?? '' - if (!mimeType.startsWith('image/')) { - req.payload.logger.warn( - `[generate-preview] Non-image mimeType "${mimeType}" — skipping preview generation`, - ) - return data - } - - try { - const preview = await generatePreview(Buffer.from(file.data)) - return { ...data, preview } - } catch (err) { - req.payload.logger.warn( - `[generate-preview] Failed to generate preview: ${ - err instanceof Error ? err.message : String(err) - }`, - ) - return data - } -} diff --git a/src/lib/lqip/encode.ts b/src/lib/lqip/encode.ts deleted file mode 100644 index d3be9d2c..00000000 --- a/src/lib/lqip/encode.ts +++ /dev/null @@ -1,22 +0,0 @@ -import sharp from 'sharp' - -/** - * Generate a low-quality image placeholder (LQIP) data URL from a buffer. - * - * Resizes to 24×12 px, encodes as AVIF at quality 30, and returns a - * base64 data URL suitable for next/image's blurDataURL prop. - * - * Throws on error — callers are responsible for fail-soft handling. - */ -export async function generateLqipDataUrl(buffer: Buffer): Promise { - const avif = await sharp(buffer) - // fit: 'cover' is deliberate — it mirrors the consumer container's - // object-fit: cover (ThemeAwareHero), so the LQIP previews the same - // crop the user will see. fit: 'inside' would preview content that - // won't be rendered at hero render time. - .resize(24, 12, { fit: 'cover' }) - .avif({ quality: 30 }) - .toBuffer() - - return `data:image/avif;base64,${avif.toString('base64')}` -} diff --git a/src/lib/preview/encode.ts b/src/lib/preview/encode.ts deleted file mode 100644 index 8bcada8c..00000000 --- a/src/lib/preview/encode.ts +++ /dev/null @@ -1,66 +0,0 @@ -import sharp from 'sharp' - -/** - * 10-character luminance ramp from densest (lowest luminance) to sparsest - * (highest luminance). Each greyscale byte (0–255) is bucketed by - * `Math.floor(lum / 26)` → values 0..9 → index into this ramp. - * - * Index 0 is space (heavy black areas read as empty cells, which keeps - * the dominant background color visible). Indices 1..9 progressively - * brighten with characters of decreasing visual weight. - */ -export const ASCII_RAMP = ' .:-=+*#%@' - -/** Cells across in the ASCII halftone grid. */ -export const ASCII_COLS = 24 -/** Cells down in the ASCII halftone grid. */ -export const ASCII_ROWS = 12 -/** Total ASCII chars: ASCII_COLS × ASCII_ROWS = 288. */ -export const ASCII_LENGTH = ASCII_COLS * ASCII_ROWS - -const LUM_BUCKET = Math.ceil(256 / ASCII_RAMP.length) // 26 - -/** - * Generate a content-aware preview from an image buffer. - * - * Returns: - * - `color`: the image's dominant color as `#rrggbb` (~7 bytes), derived - * from a 1×1 average resize. - * - `ascii`: a 24×12 luminance halftone (288 chars row-major) drawn from - * {@link ASCII_RAMP}. Each cell maps a greyscale luminance byte 0..255 - * to an index `Math.floor(lum / 26)` into the ramp. - * - * Both passes use `fit: 'cover'` to mirror the consumer container's - * `object-fit: cover` (ThemeAwareHero) — so the preview matches the crop - * the user actually sees. - * - * Throws on sharp errors. Callers are responsible for fail-soft handling. - */ -export async function generatePreview(buffer: Buffer): Promise<{ color: string; ascii: string }> { - // 1×1 average → 3 RGB bytes → "#rrggbb" - const colorBuf = await sharp(buffer) - .resize(1, 1, { fit: 'cover' }) - .removeAlpha() - .raw() - .toBuffer() - const color = `#${[colorBuf[0], colorBuf[1], colorBuf[2]] - .map((c) => c.toString(16).padStart(2, '0')) - .join('')}` - - // 24×12 greyscale → 288 luminance bytes → ramp chars - const lumBuf = await sharp(buffer) - .resize(ASCII_COLS, ASCII_ROWS, { fit: 'cover' }) - .greyscale() - .removeAlpha() - .raw() - .toBuffer() - - let ascii = '' - for (let i = 0; i < ASCII_LENGTH; i++) { - const lum = lumBuf[i] ?? 0 - const idx = Math.min(ASCII_RAMP.length - 1, Math.floor(lum / LUM_BUCKET)) - ascii += ASCII_RAMP[idx] - } - - return { color, ascii } -} diff --git a/src/payload-types.ts b/src/payload-types.ts index 3691b099..36183e89 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -159,23 +159,6 @@ export interface Media { id: number; alt: string; caption?: string | null; - /** - * Legacy AVIF data URL placeholder. Used as a fallback during the PR 1 → PR 2 deploy window. Will be removed once all docs have a populated preview. - */ - lqip?: string | null; - /** - * Auto-generated content-aware preview. Populated on upload (or by backfill). - */ - preview?: { - /** - * Dominant color (#rrggbb). - */ - color?: string | null; - /** - * 24×12 luminance halftone (288 chars, row-major). - */ - ascii?: string | null; - }; updatedAt: string; createdAt: string; url?: string | null; @@ -491,13 +474,6 @@ export interface UsersSelect { export interface MediaSelect { alt?: T; caption?: T; - lqip?: T; - preview?: - | T - | { - color?: T; - ascii?: T; - }; updatedAt?: T; createdAt?: T; url?: T; diff --git a/tests/e2e/visual/hero-loading.spec.ts b/tests/e2e/visual/hero-loading.spec.ts deleted file mode 100644 index 4c89e85b..00000000 --- a/tests/e2e/visual/hero-loading.spec.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { test, expect } from '../fixtures' - -/** - * E2E visual regression test: hero image loading state on /posts. - * - * This is the targeted regression test for the original bug — Next.js's - * `placeholder="blur"` SVG filter chain rendered a 1×1 transparent PNG - * fallback as bright olive (dark mode) / highlighter-yellow (light mode) - * for ~0.5–4s during loading. The bug existed for ~9 months because - * nothing tested for it. - * - * The assertion is colour-band-based, not snapshot-based: scan the hero - * area for any pixel whose colour sits in the yellow band defined as - * (R ≈ G > B and R > 200, with tolerance). If any such pixel is found - * during the loading window, the test fails. - * - * The test: - * 1. Throttles the network to "Slow 3G"-ish speeds so the hero takes - * long enough to load that we can sample it mid-fade. - * 2. Navigates to /posts. - * 3. Captures a screenshot of the post-listing hero region BEFORE the - * real images have finished loading. - * 4. Walks every pixel and counts those in the yellow band. - * 5. Fails if more than a tiny epsilon (1‰ to absorb anti-aliasing - * against legitimate yellows in the page chrome) of pixels are - * yellow. - * - * The new ASCII halftone path eliminates the SVG-blur codepath entirely - * for backfilled docs. For the dual-read fallback (lqip path) on - * non-backfilled docs, the test still runs against whatever the live - * placeholder is, ensuring no regression sneaks in via OptimizedImage. - */ - -test.describe('Hero loading state — yellow flood regression', () => { - test('no pixels in the yellow placeholder band on /posts during loading', async ({ - page, - }) => { - // Slow the loading enough to capture mid-fade. We don't need to be - // strict about the throttle profile — anything slower than fibre - // gives the placeholder a visible window. - const client = await page.context().newCDPSession(page) - await client.send('Network.enable') - await client.send('Network.emulateNetworkConditions', { - offline: false, - downloadThroughput: (200 * 1024) / 8, // ~200 kbit/s - uploadThroughput: (50 * 1024) / 8, - latency: 100, - }) - - // Navigate but do NOT wait for networkidle — we want the loading - // state, not the loaded state. - await page.goto('/posts', { waitUntil: 'commit' }) - - // Wait just long enough for the layout to settle so the hero region - // is positioned, but short enough that the actual hasn't - // finished its byte stream. - await page.waitForSelector('h1', { timeout: 5000 }) - await page.waitForTimeout(300) - - // Screenshot the visible viewport (which contains the first PostCard - // hero). We don't scope to a single locator because the bug repaints - // the whole image rectangle — full-viewport sample is the safer net. - const png = await page.screenshot({ - animations: 'disabled', - type: 'png', - fullPage: false, - }) - - // Decode the PNG inline using the browser. Playwright doesn't ship a - // PNG decoder for Node and we don't want to add `pngjs` as a dep just - // for this test, so we hand the buffer back to the page and decode - // via Canvas. - const yellowFraction = await page.evaluate(async (b64) => { - const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)) - const blob = new Blob([bytes], { type: 'image/png' }) - const bitmap = await createImageBitmap(blob) - const canvas = document.createElement('canvas') - canvas.width = bitmap.width - canvas.height = bitmap.height - const ctx = canvas.getContext('2d')! - ctx.drawImage(bitmap, 0, 0) - const { data } = ctx.getImageData(0, 0, bitmap.width, bitmap.height) - - let yellowCount = 0 - const total = data.length / 4 - for (let i = 0; i < data.length; i += 4) { - const r = data[i] - const g = data[i + 1] - const b = data[i + 2] - // Yellow band per bug definition: R high, G high, R ≈ G, both > B. - const isYellow = - r > 200 && - g > 200 && - Math.abs(r - g) <= 20 && - r - b > 60 - if (isYellow) yellowCount++ - } - return yellowCount / total - }, png.toString('base64')) - - // Tolerance: 0.1% of pixels. The page chrome (links, accent colors, - // potential text-glitch flashes) can include genuine yellow-ish - // pixels at the edge cases of the band. The bug repainted entire - // hero rectangles (typically ~20–40% of the viewport), well above - // any reasonable tolerance threshold. - expect(yellowFraction).toBeLessThan(0.001) - }) -}) diff --git a/tests/unit/components/ImageWithAsciiPreview.test.tsx b/tests/unit/components/ImageWithAsciiPreview.test.tsx deleted file mode 100644 index ca4a7327..00000000 --- a/tests/unit/components/ImageWithAsciiPreview.test.tsx +++ /dev/null @@ -1,215 +0,0 @@ -import { describe, it, expect, vi } from "vitest" -import { render } from "@testing-library/react" -import React from "react" -import { ImageWithAsciiPreview } from "@/components/ImageWithAsciiPreview" -import { ASCII_LENGTH, ASCII_ROWS } from "@/lib/preview/encode" - -// Mock next/image — same surface as the existing ThemeAwareHero test mocks. -vi.mock("next/image", () => ({ - default: React.forwardRef) => void - [key: string]: unknown - }>(function MockImage( - { - src, - alt, - className, - fill, - priority, - fetchPriority, - sizes, - style, - placeholder, - onLoad, - ...rest - }, - ref, - ) { - void rest - void placeholder - return React.createElement("img", { - ref, - src, - alt, - className, - style, - onLoad, - "data-fill": fill ? "true" : undefined, - "data-priority": priority ? "true" : undefined, - "data-fetch-priority": fetchPriority, - "data-sizes": sizes, - }) - }), -})) - -const VALID_ASCII = ".".repeat(ASCII_LENGTH) - -describe("ImageWithAsciiPreview", () => { - it("renders the wrapper with data-loaded='false' on first paint", () => { - const { container } = render( - , - ) - const wrapper = container.querySelector(".ascii-preview-wrapper") as HTMLElement - expect(wrapper).toBeTruthy() - expect(wrapper.getAttribute("data-loaded")).toBe("false") - }) - - it("wires the dominant color into the wrapper as a CSS custom property", () => { - const { container } = render( - , - ) - const wrapper = container.querySelector(".ascii-preview-wrapper") as HTMLElement - expect(wrapper.style.getPropertyValue("--preview-color")).toBe("#1e2530") - }) - - it("renders 12 newline-separated rows in the ASCII
", () => {
-    const { container } = render(
-      ,
-    )
-    const pre = container.querySelector("pre.ascii-preview-layer") as HTMLPreElement
-    expect(pre).toBeTruthy()
-    const rowCount = pre.textContent!.split("\n").length
-    expect(rowCount).toBe(ASCII_ROWS)
-  })
-
-  it("omits the ASCII 
 when ascii is the wrong length (defensive)", () => {
-    const { container } = render(
-      ,
-    )
-    expect(container.querySelector("pre.ascii-preview-layer")).toBeNull()
-    // Background still renders so the wrapper is never transparent.
-    expect(container.querySelector(".ascii-preview-bg")).toBeTruthy()
-  })
-
-  it("omits the ASCII 
 when ascii is missing", () => {
-    const { container } = render(
-      ,
-    )
-    expect(container.querySelector("pre.ascii-preview-layer")).toBeNull()
-  })
-
-  it("uses the surface fallback when color is missing (no inline custom property)", () => {
-    const { container } = render(
-      ,
-    )
-    const wrapper = container.querySelector(".ascii-preview-wrapper") as HTMLElement
-    expect(wrapper.style.getPropertyValue("--preview-color")).toBe("")
-  })
-
-  it("marks the ASCII layer aria-hidden (decorative — not for screen readers)", () => {
-    const { container } = render(
-      ,
-    )
-    const pre = container.querySelector("pre.ascii-preview-layer")
-    expect(pre?.getAttribute("aria-hidden")).toBe("true")
-    const bg = container.querySelector(".ascii-preview-bg")
-    expect(bg?.getAttribute("aria-hidden")).toBe("true")
-  })
-
-  it("preserves alt text on the underlying  for assistive tech", () => {
-    const { container } = render(
-      ,
-    )
-    const img = container.querySelector("img")
-    expect(img?.getAttribute("alt")).toBe("A neural network diagram")
-  })
-
-  it("propagates dark:hidden from className to the inner ", () => {
-    const { container } = render(
-      ,
-    )
-    const wrapper = container.querySelector(".ascii-preview-wrapper") as HTMLElement
-    const img = container.querySelector("img") as HTMLImageElement
-    expect(wrapper.className).toContain("dark:hidden")
-    expect(img.className).toContain("dark:hidden")
-  })
-
-  it("propagates 'hidden dark:block' (off-by-default variant) to the inner ", () => {
-    const { container } = render(
-      ,
-    )
-    const img = container.querySelector("img") as HTMLImageElement
-    expect(img.className).toContain("hidden")
-    expect(img.className).toContain("dark:block")
-  })
-
-  it("does not propagate non-display utilities (e.g. object-cover) to the inner ", () => {
-    const { container } = render(
-      ,
-    )
-    const img = container.querySelector("img") as HTMLImageElement
-    expect(img.className).toContain("dark:hidden")
-    expect(img.className).not.toContain("object-cover")
-  })
-
-  it("flips data-loaded to 'true' on the wrapper when the image fires onLoad", () => {
-    const { container } = render(
-      ,
-    )
-    const wrapper = container.querySelector(".ascii-preview-wrapper") as HTMLElement
-    const img = container.querySelector("img") as HTMLImageElement
-    expect(wrapper.getAttribute("data-loaded")).toBe("false")
-    img.dispatchEvent(new Event("load"))
-    expect(wrapper.getAttribute("data-loaded")).toBe("true")
-  })
-})
diff --git a/tests/unit/components/OptimizedImage.test.tsx b/tests/unit/components/OptimizedImage.test.tsx
deleted file mode 100644
index 55b5fa03..00000000
--- a/tests/unit/components/OptimizedImage.test.tsx
+++ /dev/null
@@ -1,96 +0,0 @@
-import { describe, it, expect, vi } from 'vitest'
-import { render } from '@testing-library/react'
-import React from 'react'
-import { OptimizedImage } from '@/components/OptimizedImage'
-
-// Mock next/image so we can assert prop pass-through without the full Next pipeline.
-vi.mock('next/image', () => ({
-  default: ({
-    src,
-    alt,
-    blurDataURL,
-    placeholder,
-    className,
-    fill,
-    priority,
-    fetchPriority,
-    sizes,
-    width,
-    height,
-    style,
-    ...rest
-  }: {
-    src: string
-    alt: string
-    blurDataURL?: string
-    placeholder?: string
-    className?: string
-    fill?: boolean
-    priority?: boolean
-    fetchPriority?: string
-    sizes?: string
-    width?: number
-    height?: number
-    style?: React.CSSProperties
-    [key: string]: unknown
-  }) => {
-    void rest
-    return React.createElement('img', {
-      src,
-      alt,
-      className,
-      style,
-      'data-blur-data-url': blurDataURL,
-      'data-placeholder': placeholder,
-      'data-fill': fill ? 'true' : undefined,
-      'data-priority': priority ? 'true' : undefined,
-      'data-fetch-priority': fetchPriority,
-      'data-sizes': sizes,
-      'data-width': width,
-      'data-height': height,
-    })
-  },
-}))
-
-const FALLBACK_1X1_PNG =
-  'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mN8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=='
-
-describe('OptimizedImage blurDataURL prop', () => {
-  it('forwards a custom blurDataURL prop to next/image', () => {
-    const { container } = render(
-      
-    )
-    const img = container.querySelector('img')
-    expect(img?.getAttribute('data-blur-data-url')).toBe('data:foo')
-  })
-
-  it('uses the 1×1 transparent PNG fallback when blurDataURL is not provided', () => {
-    const { container } = render(
-      
-    )
-    const img = container.querySelector('img')
-    expect(img?.getAttribute('data-blur-data-url')).toBe(FALLBACK_1X1_PNG)
-  })
-
-  it('uses the 1×1 PNG fallback when blurDataURL is explicitly undefined', () => {
-    const { container } = render(
-      
-    )
-    const img = container.querySelector('img')
-    expect(img?.getAttribute('data-blur-data-url')).toBe(FALLBACK_1X1_PNG)
-  })
-
-  it('does not pass blurDataURL when placeholder="empty"', () => {
-    const { container } = render(
-      
-    )
-    const img = container.querySelector('img')
-    // placeholder="empty" must not materialise a blur data URL
-    expect(img?.getAttribute('data-blur-data-url')).toBeNull()
-  })
-})
diff --git a/tests/unit/components/OptimizedImage.type.test.tsx b/tests/unit/components/OptimizedImage.type.test.tsx
deleted file mode 100644
index 58731e0f..00000000
--- a/tests/unit/components/OptimizedImage.type.test.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-import { describe, it, expect } from "vitest";
-import { OptimizedImage } from "@/components/OptimizedImage";
-
-describe("OptimizedImage type contract", () => {
-  it("accepts fill mode without dimensions", () => {
-    const _el = ;
-    expect(_el).toBeDefined();
-  });
-
-  it("accepts explicit dimensions without fill", () => {
-    const _el = ;
-    expect(_el).toBeDefined();
-  });
-
-  it("type-level: rejects fill + explicit dimensions", () => {
-    // @ts-expect-error — fill=true disallows width/height
-    const _el = ;
-    expect(_el).toBeDefined();
-  });
-
-  it("type-level: rejects neither fill nor dimensions", () => {
-    // @ts-expect-error — must provide either fill OR width+height
-    const _el = ;
-    expect(_el).toBeDefined();
-  });
-});
diff --git a/tests/unit/components/ThemeAwareHero.test.tsx b/tests/unit/components/ThemeAwareHero.test.tsx
index 24e0172c..960615a0 100644
--- a/tests/unit/components/ThemeAwareHero.test.tsx
+++ b/tests/unit/components/ThemeAwareHero.test.tsx
@@ -19,8 +19,6 @@ vi.mock("next/image", () => ({
     width,
     height,
     style,
-    blurDataURL,
-    placeholder,
     ...rest
   }: {
     src: string;
@@ -33,14 +31,11 @@ vi.mock("next/image", () => ({
     width?: number;
     height?: number;
     style?: React.CSSProperties;
-    blurDataURL?: string;
-    placeholder?: string;
     [key: string]: unknown;
   }) => {
     // Strip out Next.js-only props that would generate React warnings on an
     // intrinsic  element; preserve the props we actually want to assert.
     void rest;
-    void placeholder;
     return React.createElement("img", {
       src,
       alt,
@@ -52,7 +47,6 @@ vi.mock("next/image", () => ({
       "data-sizes": sizes,
       "data-width": width,
       "data-height": height,
-      "data-blur-data-url": blurDataURL,
     });
   },
 }));
@@ -98,6 +92,7 @@ describe("ThemeAwareHero", () => {
 
     const lightImg = container.querySelector('img[src="/media/post-light.png"]');
     expect(lightImg?.className).toContain("dark:hidden");
+    expect(lightImg?.className).toContain("object-cover");
   });
 
   it("applies hidden + dark:block to the dark variant", () => {
@@ -108,6 +103,20 @@ describe("ThemeAwareHero", () => {
     const darkImg = container.querySelector('img[src="/media/post-dark.png"]');
     expect(darkImg?.className).toContain("hidden");
     expect(darkImg?.className).toContain("dark:block");
+    expect(darkImg?.className).toContain("object-cover");
+  });
+
+  it("renders a centered loading spinner behind the images", () => {
+    const { container } = render(
+      
+    );
+
+    // A decorative spinner element exists inside the positioned wrapper and is
+    // aria-hidden (the images carry the real alt text).
+    const spinner = container.querySelector(".hero-spinner");
+    expect(spinner).toBeTruthy();
+    expect(spinner?.getAttribute("aria-hidden")).toBe("true");
+    expect(spinner?.querySelector(".animate-spin")).toBeTruthy();
   });
 
   it("does NOT apply transition-opacity or fade animation CSS (View Transitions compliance)", () => {
@@ -331,174 +340,4 @@ describe("ThemeAwareHero", () => {
       expect((img as HTMLElement).style.objectPosition).toBe("0% 0%");
     });
   });
-
-  // --- LQIP tests ---
-
-  it("passes light.lqip as blurDataURL to the light ", () => {
-    const lightWithLqip = makeMedia({
-      ...lightMedia,
-      lqip: "data:image/avif;base64,lightlqip",
-    });
-    const { container } = render(
-      
-    );
-    const lightImg = container.querySelector(
-      'img[src="/media/post-light.png"]'
-    ) as HTMLElement;
-    expect(lightImg.getAttribute("data-blur-data-url")).toBe(
-      "data:image/avif;base64,lightlqip"
-    );
-  });
-
-  it("passes both light.lqip and dark.lqip to their respective  children", () => {
-    const lightWithLqip = makeMedia({
-      ...lightMedia,
-      lqip: "data:image/avif;base64,lightlqip",
-    });
-    const darkWithLqip = makeMedia({
-      ...darkMedia,
-      lqip: "data:image/avif;base64,darklqip",
-    });
-    const { container } = render(
-      
-    );
-    const lightImg = container.querySelector(
-      'img[src="/media/post-light.png"]'
-    ) as HTMLElement;
-    const darkImg = container.querySelector(
-      'img[src="/media/post-dark.png"]'
-    ) as HTMLElement;
-    expect(lightImg.getAttribute("data-blur-data-url")).toBe(
-      "data:image/avif;base64,lightlqip"
-    );
-    expect(darkImg.getAttribute("data-blur-data-url")).toBe(
-      "data:image/avif;base64,darklqip"
-    );
-  });
-
-  it("renders without custom blurDataURL when neither light nor dark has lqip (default fallback)", () => {
-    // When lqip is absent, ThemeAwareHero passes undefined → OptimizedImage
-    // uses its own 1×1 PNG default. The rendered img should NOT have a
-    // custom AVIF data URL.
-    const { container } = render(
-      
-    );
-    const images = container.querySelectorAll("img");
-    images.forEach((img) => {
-      const val = img.getAttribute("data-blur-data-url");
-      // OptimizedImage's fallback 1×1 PNG — not a custom AVIF lqip
-      expect(val).not.toMatch(/^data:image\/avif/);
-    });
-  });
-
-  // --- Dual-read tests (PR 1 truth table per bot review #139 finding I2) ---
-  //
-  //   preview present + lqip present  → ImageWithAsciiPreview wins
-  //   preview present + lqip missing  → ImageWithAsciiPreview wins
-  //   preview missing + lqip present  → OptimizedImage with blur (legacy)
-  //   preview missing + lqip missing  → OptimizedImage with FALLBACK 1×1 PNG
-  //
-  // The first two cases produce a wrapper with the .ascii-preview-wrapper
-  // marker class and the data-loaded attribute. The latter two do not.
-
-  const VALID_ASCII = ".".repeat(288);
-
-  it("uses ImageWithAsciiPreview when preview.color and 288-char preview.ascii are both present", () => {
-    const lightWithPreview = makeMedia({
-      ...lightMedia,
-      preview: { color: "#1e2530", ascii: VALID_ASCII },
-    });
-    const { container } = render(
-      
-    );
-    // The new wrapper is detectable by its class + data-loaded attribute.
-    expect(container.querySelector('.ascii-preview-wrapper[data-loaded="false"]')).toBeTruthy();
-    // The dominant color is wired in as a CSS custom property on the wrapper.
-    const wrapper = container.querySelector(".ascii-preview-wrapper") as HTMLElement;
-    expect(wrapper.style.getPropertyValue("--preview-color")).toBe("#1e2530");
-  });
-
-  it("preview wins over lqip when both are present (preview path used)", () => {
-    const lightBoth = makeMedia({
-      ...lightMedia,
-      lqip: "data:image/avif;base64,lightlqip",
-      preview: { color: "#1e2530", ascii: VALID_ASCII },
-    });
-    const { container } = render(
-      
-    );
-    // Light variant: new wrapper exists; the legacy blur data URL is NOT
-    // attached to the light  (because the lqip path is bypassed).
-    expect(container.querySelector(".ascii-preview-wrapper")).toBeTruthy();
-    const lightImg = container.querySelector(
-      'img[src="/media/post-light.png"]'
-    ) as HTMLElement | null;
-    // Light img exists (rendered by AsciiPreviewImage) but with no blur prop.
-    expect(lightImg).toBeTruthy();
-    expect(lightImg!.getAttribute("data-blur-data-url")).toBeNull();
-  });
-
-  it("falls back to OptimizedImage with lqip blur when preview is missing but lqip is present", () => {
-    const lightOnlyLqip = makeMedia({
-      ...lightMedia,
-      lqip: "data:image/avif;base64,lightlqip",
-    });
-    const { container } = render(
-      
-    );
-    expect(container.querySelector(".ascii-preview-wrapper")).toBeNull();
-    const lightImg = container.querySelector(
-      'img[src="/media/post-light.png"]'
-    ) as HTMLElement;
-    expect(lightImg.getAttribute("data-blur-data-url")).toBe(
-      "data:image/avif;base64,lightlqip"
-    );
-  });
-
-  it("falls back to OptimizedImage when preview.ascii length is wrong (validation fails closed)", () => {
-    const lightBadAscii = makeMedia({
-      ...lightMedia,
-      preview: { color: "#1e2530", ascii: "too-short" },
-    });
-    const { container } = render(
-      
-    );
-    // Wrong length → not a usable preview → OptimizedImage path.
-    expect(container.querySelector(".ascii-preview-wrapper")).toBeNull();
-  });
-
-  it("falls back to OptimizedImage when preview.color is missing (validation fails closed)", () => {
-    const lightNoColor = makeMedia({
-      ...lightMedia,
-      preview: { color: "", ascii: VALID_ASCII },
-    });
-    const { container } = render(
-      
-    );
-    expect(container.querySelector(".ascii-preview-wrapper")).toBeNull();
-  });
-
-  it("dual-read independently per variant (light uses preview, dark uses lqip)", () => {
-    const lightWithPreview = makeMedia({
-      ...lightMedia,
-      preview: { color: "#1e2530", ascii: VALID_ASCII },
-    });
-    const darkWithLqip = makeMedia({
-      ...darkMedia,
-      lqip: "data:image/avif;base64,darklqip",
-    });
-    const { container } = render(
-      
-    );
-    // Exactly one wrapper (light variant).
-    const wrappers = container.querySelectorAll(".ascii-preview-wrapper");
-    expect(wrappers).toHaveLength(1);
-    // Dark variant still gets its lqip on the .
-    const darkImg = container.querySelector(
-      'img[src="/media/post-dark.png"]'
-    ) as HTMLElement;
-    expect(darkImg.getAttribute("data-blur-data-url")).toBe(
-      "data:image/avif;base64,darklqip"
-    );
-  });
 });
diff --git a/tests/unit/lib/hooks/generate-lqip.test.ts b/tests/unit/lib/hooks/generate-lqip.test.ts
deleted file mode 100644
index 347b3c68..00000000
--- a/tests/unit/lib/hooks/generate-lqip.test.ts
+++ /dev/null
@@ -1,100 +0,0 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest'
-import { generateLqipHook } from '@/lib/hooks/generate-lqip'
-import type { CollectionBeforeChangeHook } from 'payload'
-
-// ---------------------------------------------------------------------------
-// Minimal valid 2×2 red PNG buffer (real, not mocked — so sharp can decode it)
-// Generated via: sharp({ create: { width: 2, height: 2, channels: 3, background: { r: 255, g: 0, b: 0 } } }).png().toBuffer()
-// ---------------------------------------------------------------------------
-const MINIMAL_PNG = Buffer.from(
-  '89504e470d0a1a0a0000000d4948445200000002000000020802000000fdd49a730000000970485973000003e8000003e801b57b526b0000001349444154789c63f8cfc0f09f018cff333000001fee03fd351b00330000000049454e44ae426082',
-  'hex'
-)
-
-// ---------------------------------------------------------------------------
-// Helper: build a minimal fake Payload hook args object
-// ---------------------------------------------------------------------------
-type HookArgs = Parameters[0]
-
-function makeArgs(overrides: Partial & { file?: { data?: Buffer | null; mimetype?: string } | null } = {}): HookArgs {
-  const { file, ...rest } = overrides
-  const warnSpy = vi.fn()
-  return {
-    data: {},
-    req: {
-      file: file === undefined
-        ? { data: MINIMAL_PNG, mimetype: 'image/png', name: 'test.png', size: MINIMAL_PNG.length }
-        : file,
-      payload: {
-        logger: { warn: warnSpy },
-      },
-    } as unknown as HookArgs['req'],
-    operation: 'create',
-    collection: {} as HookArgs['collection'],
-    context: {},
-    ...rest,
-  } as unknown as HookArgs
-}
-
-function getWarnSpy(args: HookArgs): ReturnType {
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  return (args.req.payload.logger as any).warn
-}
-
-// ---------------------------------------------------------------------------
-// Tests
-// ---------------------------------------------------------------------------
-
-describe('generateLqipHook', () => {
-  beforeEach(() => {
-    vi.clearAllMocks()
-  })
-
-  it('generates a non-empty data URL prefixed data:image/avif;base64, for a valid PNG buffer', async () => {
-    const args = makeArgs()
-    const result = await generateLqipHook(args)
-    expect(result).toHaveProperty('lqip')
-    expect(typeof result.lqip).toBe('string')
-    expect(result.lqip).toMatch(/^data:image\/avif;base64,/)
-    expect((result.lqip as string).length).toBeGreaterThan(30)
-  })
-
-  it('returns data UNCHANGED when operation === "update" (skip on update)', async () => {
-    const args = makeArgs({ operation: 'update' as HookArgs['operation'] })
-    const result = await generateLqipHook(args)
-    expect(result).not.toHaveProperty('lqip')
-    // warn should NOT have been called — this is a deliberate skip, not an error
-    expect(getWarnSpy(args)).not.toHaveBeenCalled()
-  })
-
-  it('returns data UNCHANGED + logs warning when buffer is missing (no req.file)', async () => {
-    const args = makeArgs({ file: null })
-    const result = await generateLqipHook(args)
-    expect(result).not.toHaveProperty('lqip')
-    expect(getWarnSpy(args)).toHaveBeenCalledOnce()
-    expect(getWarnSpy(args).mock.calls[0][0]).toMatch(/no file buffer/i)
-  })
-
-  it('returns data UNCHANGED + logs warning when buffer is missing (req.file.data is null)', async () => {
-    const args = makeArgs({ file: { data: null, mimetype: 'image/png' } })
-    const result = await generateLqipHook(args)
-    expect(result).not.toHaveProperty('lqip')
-    expect(getWarnSpy(args)).toHaveBeenCalledOnce()
-  })
-
-  it('returns data UNCHANGED + logs warning when buffer is malformed (cannot be decoded by sharp)', async () => {
-    const args = makeArgs({ file: { data: Buffer.from('not-a-real-image'), mimetype: 'image/png' } })
-    const result = await generateLqipHook(args)
-    expect(result).not.toHaveProperty('lqip')
-    expect(getWarnSpy(args)).toHaveBeenCalledOnce()
-    expect(getWarnSpy(args).mock.calls[0][0]).toMatch(/failed to generate/i)
-  })
-
-  it('returns data UNCHANGED + logs warning when mimeType is not image/*', async () => {
-    const args = makeArgs({ file: { data: MINIMAL_PNG, mimetype: 'application/pdf' } })
-    const result = await generateLqipHook(args)
-    expect(result).not.toHaveProperty('lqip')
-    expect(getWarnSpy(args)).toHaveBeenCalledOnce()
-    expect(getWarnSpy(args).mock.calls[0][0]).toMatch(/non-image mimetype/i)
-  })
-})
diff --git a/tests/unit/lib/hooks/generate-preview.test.ts b/tests/unit/lib/hooks/generate-preview.test.ts
deleted file mode 100644
index d1618a87..00000000
--- a/tests/unit/lib/hooks/generate-preview.test.ts
+++ /dev/null
@@ -1,115 +0,0 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest'
-import sharp from 'sharp'
-import { generatePreviewHook } from '@/lib/hooks/generate-preview'
-import type { CollectionBeforeChangeHook } from 'payload'
-import { ASCII_LENGTH } from '@/lib/preview/encode'
-
-// Real 16×16 red PNG so sharp can decode it during the test.
-async function realPng(): Promise {
-  return sharp({
-    create: {
-      width: 16,
-      height: 16,
-      channels: 3,
-      background: { r: 200, g: 100, b: 50 },
-    },
-  })
-    .png()
-    .toBuffer()
-}
-
-let pngBuffer: Buffer
-
-type HookArgs = Parameters[0]
-
-function makeArgs(
-  overrides: Partial & {
-    file?: { data?: Buffer | null; mimetype?: string } | null
-  } = {},
-): HookArgs {
-  const { file, ...rest } = overrides
-  const warnSpy = vi.fn()
-  return {
-    data: {},
-    req: {
-      file:
-        file === undefined
-          ? { data: pngBuffer, mimetype: 'image/png', name: 'test.png', size: pngBuffer.length }
-          : file,
-      payload: {
-        logger: { warn: warnSpy },
-      },
-    } as unknown as HookArgs['req'],
-    operation: 'create',
-    collection: {} as HookArgs['collection'],
-    context: {},
-    ...rest,
-  } as unknown as HookArgs
-}
-
-function getWarnSpy(args: HookArgs): ReturnType {
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  return (args.req.payload.logger as any).warn
-}
-
-beforeEach(async () => {
-  vi.clearAllMocks()
-  pngBuffer = await realPng()
-})
-
-describe('generatePreviewHook', () => {
-  it('attaches a `preview` group with valid color + ascii on create with a file buffer', async () => {
-    const args = makeArgs()
-    const result = await generatePreviewHook(args)
-
-    expect(result).toHaveProperty('preview')
-    const preview = (result as { preview: { color: string; ascii: string } }).preview
-    expect(preview.color).toMatch(/^#[0-9a-f]{6}$/)
-    expect(preview.ascii).toHaveLength(ASCII_LENGTH)
-  })
-
-  it('regenerates the preview on update when a file buffer is present (e.g. admin re-upload)', async () => {
-    const args = makeArgs({ operation: 'update' as HookArgs['operation'] })
-    const result = await generatePreviewHook(args)
-    expect(result).toHaveProperty('preview')
-  })
-
-  it('skips on update without a file (metadata-only edit) — returns data unchanged, no warning', async () => {
-    const args = makeArgs({ file: null, operation: 'update' as HookArgs['operation'] })
-    const result = await generatePreviewHook(args)
-    expect(result).not.toHaveProperty('preview')
-    expect(getWarnSpy(args)).not.toHaveBeenCalled()
-  })
-
-  it('returns data unchanged + warns when file.data is null', async () => {
-    const args = makeArgs({ file: { data: null, mimetype: 'image/png' } })
-    const result = await generatePreviewHook(args)
-    expect(result).not.toHaveProperty('preview')
-    expect(getWarnSpy(args)).toHaveBeenCalledOnce()
-  })
-
-  it('returns data unchanged + warns when mimeType is non-image (e.g. pdf)', async () => {
-    const args = makeArgs({ file: { data: pngBuffer, mimetype: 'application/pdf' } })
-    const result = await generatePreviewHook(args)
-    expect(result).not.toHaveProperty('preview')
-    expect(getWarnSpy(args)).toHaveBeenCalledOnce()
-    expect(getWarnSpy(args).mock.calls[0][0]).toMatch(/non-image mimetype/i)
-  })
-
-  it('returns data unchanged + warns when buffer cannot be decoded by sharp', async () => {
-    const args = makeArgs({
-      file: { data: Buffer.from('not-an-image'), mimetype: 'image/png' },
-    })
-    const result = await generatePreviewHook(args)
-    expect(result).not.toHaveProperty('preview')
-    expect(getWarnSpy(args)).toHaveBeenCalledOnce()
-    expect(getWarnSpy(args).mock.calls[0][0]).toMatch(/failed to generate/i)
-  })
-
-  it('respects the skipPreviewHook context flag (skip silently — used by backfill)', async () => {
-    const args = makeArgs({ context: { skipPreviewHook: true } as HookArgs['context'] })
-    const result = await generatePreviewHook(args)
-    expect(result).not.toHaveProperty('preview')
-    expect(getWarnSpy(args)).not.toHaveBeenCalled()
-  })
-})
diff --git a/tests/unit/lib/preview/encode.test.ts b/tests/unit/lib/preview/encode.test.ts
deleted file mode 100644
index fee7ac33..00000000
--- a/tests/unit/lib/preview/encode.test.ts
+++ /dev/null
@@ -1,76 +0,0 @@
-import { describe, it, expect } from 'vitest'
-import sharp from 'sharp'
-import { generatePreview, ASCII_RAMP } from '@/lib/preview/encode'
-
-// Solid red 16×16 PNG buffer — gives a deterministic dominant color and
-// uniform luminance, so we can assert exact properties of the output.
-async function solidColorPng(r: number, g: number, b: number, width = 16, height = 16): Promise {
-  return sharp({
-    create: {
-      width,
-      height,
-      channels: 3,
-      background: { r, g, b },
-    },
-  })
-    .png()
-    .toBuffer()
-}
-
-describe('generatePreview', () => {
-  it('returns { color, ascii } with a 7-char hex color and 288-char ASCII string', async () => {
-    const png = await solidColorPng(120, 80, 200)
-    const result = await generatePreview(png)
-
-    expect(result).toHaveProperty('color')
-    expect(result).toHaveProperty('ascii')
-    expect(result.color).toMatch(/^#[0-9a-f]{6}$/)
-    expect(result.ascii).toHaveLength(288)
-  })
-
-  it('produces a color matching the input (within sharp resize tolerance)', async () => {
-    const png = await solidColorPng(255, 0, 0)
-    const { color } = await generatePreview(png)
-    // Solid red — first hex pair should dominate.
-    expect(color).toMatch(/^#f[ef][0-9a-f]{4}$/)
-  })
-
-  it('uses only characters from the ramp (and produces 288 of them)', async () => {
-    const png = await solidColorPng(127, 127, 127)
-    const { ascii } = await generatePreview(png)
-
-    expect(ascii).toHaveLength(288)
-    // Every char must come from the ramp.
-    const rampSet = new Set(ASCII_RAMP)
-    for (const ch of ascii) {
-      expect(rampSet.has(ch)).toBe(true)
-    }
-  })
-
-  it('exports an ASCII_RAMP of exactly 10 chars (one per luminance bucket)', () => {
-    // Mapping function buckets luminance into Math.floor(lum / 26),
-    // which yields values 0..9 (since 255/26 == 9.8). The ramp must
-    // therefore cover all 10 buckets.
-    expect(ASCII_RAMP).toHaveLength(10)
-  })
-
-  it('encodes a black image as the first ramp char (densest, lowest luminance)', async () => {
-    const png = await solidColorPng(0, 0, 0)
-    const { ascii } = await generatePreview(png)
-    // Bucket 0 → first ramp char (space). Every cell should match.
-    const expected = ASCII_RAMP[0].repeat(288)
-    expect(ascii).toBe(expected)
-  })
-
-  it('encodes a white image as the last ramp char (sparsest, highest luminance)', async () => {
-    const png = await solidColorPng(255, 255, 255)
-    const { ascii } = await generatePreview(png)
-    const expected = ASCII_RAMP[9].repeat(288)
-    expect(ascii).toBe(expected)
-  })
-
-  it('throws on a buffer that sharp cannot decode (caller must fail-soft)', async () => {
-    const bogus = Buffer.from('not-a-real-image-buffer')
-    await expect(generatePreview(bogus)).rejects.toThrow()
-  })
-})