From d35eb3534654a658ef507c4a6fea0882c06cd20d Mon Sep 17 00:00:00 2001
From: Cooper Beaman <55124795+lowestprime@users.noreply.github.com>
Date: Sat, 11 Jul 2026 05:55:56 -0700
Subject: [PATCH 01/43] feat(data): normalize piece media and commerce policies
---
site/app/api/shop/local-reservation/route.ts | 3 +-
site/lib/actions.ts | 96 ++-
site/lib/catalog.ts | 37 +-
site/lib/database-migrations.test.mts | 80 +++
site/lib/database-migrations.ts | 279 ++++++++
site/lib/db.ts | 600 +++++++++++++++---
site/lib/media-reference-transaction.test.mts | 77 +++
site/lib/media.ts | 54 +-
site/lib/piece-model.test.mts | 63 ++
site/lib/piece-model.ts | 125 ++++
site/lib/seed.ts | 2 +-
site/package.json | 2 +-
12 files changed, 1305 insertions(+), 113 deletions(-)
create mode 100644 site/lib/database-migrations.test.mts
create mode 100644 site/lib/database-migrations.ts
create mode 100644 site/lib/media-reference-transaction.test.mts
create mode 100644 site/lib/piece-model.test.mts
create mode 100644 site/lib/piece-model.ts
diff --git a/site/app/api/shop/local-reservation/route.ts b/site/app/api/shop/local-reservation/route.ts
index d2a4dc1..3ece770 100644
--- a/site/app/api/shop/local-reservation/route.ts
+++ b/site/app/api/shop/local-reservation/route.ts
@@ -4,6 +4,7 @@ import { getCurrentUser } from "@/lib/auth";
import { createDraftOrder, getPiece, getSiteSettings, listCartItems } from "@/lib/db";
import { getDropoffDriveMinutes, getFulfillmentSummary, getWoodshopZip, pieceShippingEnabled } from "@/lib/catalog";
import { calculateCheckoutTotals } from "@/lib/payments";
+import { pieceCanEnterCart } from "@/lib/piece-model";
function redirectTo(path: string, request: Request) {
return NextResponse.redirect(new URL(path, request.url));
@@ -34,7 +35,7 @@ export async function POST(request: Request) {
const invalidItems: string[] = [];
const pieces = cartItems.flatMap((item) => {
const piece = getPiece(item.pieceSlug);
- if (!piece || piece.priceCents == null) {
+ if (!piece || !pieceCanEnterCart(piece) || piece.priceCents == null || item.quantity > piece.inventoryCount) {
invalidItems.push(item.pieceSlug);
return [];
}
diff --git a/site/lib/actions.ts b/site/lib/actions.ts
index 8a0ad1c..8408cd2 100644
--- a/site/lib/actions.ts
+++ b/site/lib/actions.ts
@@ -45,7 +45,9 @@ import {
setPasswordResetToken,
updateProject,
deleteMediaRecordAndReferences,
+ finishMediaRenameHistory,
renameMediaRecordAndReferences,
+ startMediaRenameHistory,
type CommissionTypeRecord,
type MediaAssignmentFilter,
type MediaAiFilter,
@@ -59,12 +61,22 @@ import {
type UserRecord
} from "@/lib/db";
import { clearSession, createPasswordHash, createSession, getCurrentUser, requireAdmin, requireUser, verifyLogin } from "@/lib/auth";
-import { persistGeneratedMedia, persistUploadedMedia, renameMediaAsset, deleteMediaAsset, resolveMediaPath } from "@/lib/media";
+import {
+ finalizeStagedMediaDeletion,
+ moveMediaAsset,
+ persistGeneratedMedia,
+ persistUploadedMedia,
+ previewMediaRenamePath,
+ resolveMediaPath,
+ restoreStagedMediaAsset,
+ stageMediaAssetDeletion
+} from "@/lib/media";
import { calculateCheckoutTotals, createEasyPostShippingLabel, createStripeCheckoutSession, createStripeInvoice, stripeIsConfigured } from "@/lib/payments";
import { sendNotificationEmail, summarizeEmailFailure } from "@/lib/notifications";
import { createCleanedBackgroundVariant, getAiServiceStatus } from "@/lib/ai-services";
import { buildMediaVerificationQueue, type MediaMatchCandidate } from "@/lib/media-audit";
import { categoryKey, normalizePieceCategories, type PieceCategoryIcon } from "@/lib/categories";
+import { getPieceInquiryMode, getPiecePriceMode, getPieceReviewsMode, pieceCanEnterCart } from "@/lib/piece-model";
function revalidatePagePaths(slug: string) {
revalidatePath("/", "layout");
revalidatePath("/");
@@ -433,6 +445,10 @@ export async function addToCartAction(formData: FormData) {
const user = await getCurrentUser();
const pieceSlug = requiredField(formData.get("pieceSlug"), "Piece");
const quantity = Math.max(1, parseInteger(formData.get("quantity"), 1));
+ const piece = getPiece(pieceSlug);
+ if (!piece || !pieceCanEnterCart(piece) || quantity > piece.inventoryCount) {
+ redirect(`/shop?error=${encodeURIComponent("This piece is not available for fixed-price reservation.")}`);
+ }
const { saveCartItem } = await import("@/lib/db");
saveCartItem({ cartToken, userEmail: user?.email ?? null, pieceSlug, quantity, options: parseJsonField(formData.get("optionsJson"), {}) });
revalidatePath("/shop");
@@ -456,7 +472,7 @@ export async function startCheckoutAction(formData: FormData) {
const invalidItems: string[] = [];
const lines = cartItems.flatMap((item) => {
const piece = getPiece(item.pieceSlug);
- if (!piece || piece.priceCents == null) {
+ if (!piece || !pieceCanEnterCart(piece) || piece.priceCents == null || item.quantity > piece.inventoryCount) {
invalidItems.push(item.pieceSlug);
return [];
}
@@ -943,21 +959,24 @@ export async function savePieceAction(formData: FormData) {
const slug = requiredField(formData.get("slug"), "Piece slug");
const current = getPiece(slug);
const pieceJson = optionalField(formData.get("pieceJson"));
+ const priceMode = (formData.has("priceMode") ? optionalField(formData.get("priceMode")) : current ? getPiecePriceMode(current) : "not-listed") as PieceRecord["priceMode"];
+ const inquiryMode = (formData.has("inquiryMode") ? optionalField(formData.get("inquiryMode")) : current ? getPieceInquiryMode(current) : "disabled") as PieceRecord["inquiryMode"];
+ const reviewsMode = (formData.has("reviewsMode") ? optionalField(formData.get("reviewsMode")) : current ? getPieceReviewsMode(current) : "hidden") as PieceRecord["reviewsMode"];
savePiece(pieceJson
? parseJsonField(formData.get("pieceJson"), current!)
: {
slug,
- title: optionalField(formData.get("title")) || current?.title || slug,
- subtitle: optionalField(formData.get("subtitle")) || current?.subtitle || "",
+ title: formData.has("title") ? requiredField(formData.get("title"), "Piece title") : current?.title || slug,
+ subtitle: formData.has("subtitle") ? optionalField(formData.get("subtitle")) : current?.subtitle || "",
category: optionalField(formData.get("category")) || current?.category || "Tables",
status: (optionalField(formData.get("pieceStatus")) || current?.status || "commission") as PieceRecord["status"],
publicationStatus: (optionalField(formData.get("publicationStatus")) || current?.publicationStatus || "draft") as PieceRecord["publicationStatus"],
- availabilityLabel: optionalField(formData.get("availabilityLabel")) || current?.availabilityLabel || "",
- summary: optionalField(formData.get("summary")) || current?.summary || "",
- story: optionalField(formData.get("story")) || current?.story || "",
- details: parseListField(formData.get("detailsText")).length > 0 ? parseListField(formData.get("detailsText")) : current?.details || [],
- tags: parseListField(formData.get("tagsText")).length > 0 ? parseListField(formData.get("tagsText")) : current?.tags || [],
- materials: parseListField(formData.get("materialsText")).length > 0 ? parseListField(formData.get("materialsText")) : current?.materials || [],
+ availabilityLabel: formData.has("availabilityLabel") ? optionalField(formData.get("availabilityLabel")) : current?.availabilityLabel || "",
+ summary: formData.has("summary") ? optionalField(formData.get("summary")) : current?.summary || "",
+ story: formData.has("story") ? optionalField(formData.get("story")) : current?.story || "",
+ details: formData.has("detailsText") ? parseListField(formData.get("detailsText")) : current?.details || [],
+ tags: formData.has("tagsText") ? parseListField(formData.get("tagsText")) : current?.tags || [],
+ materials: formData.has("materialsText") ? parseListField(formData.get("materialsText")) : current?.materials || [],
dimensions: parseOptionalInteger(formData.get("width")) == null && parseOptionalInteger(formData.get("depth")) == null && parseOptionalInteger(formData.get("height")) == null
? current?.dimensions || null
: {
@@ -966,17 +985,26 @@ export async function savePieceAction(formData: FormData) {
height: parseOptionalInteger(formData.get("height")) ?? current?.dimensions?.height ?? 0,
unit: "in" as const
},
- priceCents: parseOptionalInteger(formData.get("priceCents")) ?? current?.priceCents ?? null,
+ priceCents: formData.has("priceCents") ? parseOptionalInteger(formData.get("priceCents")) : current?.priceCents ?? null,
+ priceMode,
+ publicPriceLabel: formData.has("publicPriceLabel") ? optionalField(formData.get("publicPriceLabel")) || null : current?.publicPriceLabel ?? null,
+ internalEstimateCents: formData.has("internalEstimateCents") ? parseOptionalInteger(formData.get("internalEstimateCents")) : current?.internalEstimateCents ?? null,
+ inquiryMode,
+ reviewsMode,
+ processSectionTitle: formData.has("processSectionTitle") ? optionalField(formData.get("processSectionTitle")) || "Build record" : current?.processSectionTitle ?? "Build record",
+ processSectionIntro: formData.has("processSectionIntro") ? optionalField(formData.get("processSectionIntro")) : current?.processSectionIntro ?? "",
+ visualizerTemplate: formData.has("visualizerTemplate") ? optionalField(formData.get("visualizerTemplate")) || null : current?.visualizerTemplate ?? null,
+ commissionTypeSlug: formData.has("commissionTypeSlug") ? optionalField(formData.get("commissionTypeSlug")) || null : current?.commissionTypeSlug ?? null,
inventoryCount: parseInteger(formData.get("inventoryCount"), current?.inventoryCount ?? 0),
leadTimeDays: parseInteger(formData.get("leadTimeDays"), current?.leadTimeDays ?? 0),
- mediaPaths: parseListField(formData.get("mediaPathsText")).length > 0 ? parseListField(formData.get("mediaPathsText")) : current?.mediaPaths || [],
+ mediaPaths: formData.has("mediaPathsText") ? parseListField(formData.get("mediaPathsText")) : current?.mediaPaths || [],
featuredRank: parseInteger(formData.get("featuredRank"), current?.featuredRank ?? 99),
ownerEmail: optionalField(formData.get("ownerEmail")) || current?.ownerEmail || "woodsmithbb@proton.me",
metadata: {
...(current?.metadata || {}),
verifiedMedia: parseBooleanField(formData.get("verifiedMedia")),
publicMediaLimit: parseInteger(formData.get("publicMediaLimit"), Number(current?.metadata?.publicMediaLimit ?? 4)),
- fulfillmentOptions: parseListField(formData.get("fulfillmentText")).length > 0 ? parseListField(formData.get("fulfillmentText")) : current?.metadata?.fulfillmentOptions ?? [],
+ fulfillmentOptions: formData.has("fulfillmentText") ? parseListField(formData.get("fulfillmentText")) : current?.metadata?.fulfillmentOptions ?? [],
mediaReviewRequired: parseBooleanField(formData.get("mediaReviewRequired"))
}
});
@@ -1336,13 +1364,24 @@ export async function uploadMediaAction(_: unknown, formData: FormData): Promise
export async function renameMediaAction(_: unknown, formData: FormData): Promise {
try {
- await requireAdmin();
+ const admin = await requireAdmin();
const previousPath = requiredField(formData.get("relativePath"), "Media path");
- const nextRelativePath = renameMediaAsset(
- previousPath,
- requiredField(formData.get("baseName"), "New name")
- );
- const affected = renameMediaRecordAndReferences(previousPath, nextRelativePath);
+ const nextRelativePath = previewMediaRenamePath(previousPath, requiredField(formData.get("baseName"), "New name"));
+ if (nextRelativePath === previousPath) return { ok: true, kind: "rename", previousPath, relativePath: nextRelativePath };
+ const historyId = startMediaRenameHistory(previousPath, nextRelativePath, admin.email);
+ moveMediaAsset(previousPath, nextRelativePath);
+ let affected;
+ try {
+ affected = renameMediaRecordAndReferences(previousPath, nextRelativePath, { actorEmail: admin.email, historyId });
+ } catch (error) {
+ try {
+ moveMediaAsset(nextRelativePath, previousPath);
+ finishMediaRenameHistory(historyId, "rolled-back", error instanceof Error ? error.message : String(error));
+ } catch (rollbackError) {
+ finishMediaRenameHistory(historyId, "failed", `Reference update failed and file rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`);
+ }
+ throw error;
+ }
revalidateMediaSurfaces(affected);
return { ok: true, kind: "rename", previousPath, relativePath: nextRelativePath };
} catch (error) {
@@ -1352,10 +1391,23 @@ export async function renameMediaAction(_: unknown, formData: FormData): Promise
export async function deleteMediaAction(_: unknown, formData: FormData): Promise {
try {
- await requireAdmin();
+ const admin = await requireAdmin();
const relativePath = requiredField(formData.get("relativePath"), "Media path");
- deleteMediaAsset(relativePath);
- const affected = deleteMediaRecordAndReferences(relativePath);
+ const staged = stageMediaAssetDeletion(relativePath);
+ let affected;
+ try {
+ affected = deleteMediaRecordAndReferences(relativePath, admin.email);
+ } catch (error) {
+ restoreStagedMediaAsset(staged);
+ throw error;
+ }
+ try {
+ finalizeStagedMediaDeletion(staged);
+ } catch (error) {
+ restoreStagedMediaAsset(staged);
+ refreshMediaLibrary();
+ throw error;
+ }
revalidateMediaSurfaces(affected);
return { ok: true, kind: "delete", relativePath };
} catch (error) {
diff --git a/site/lib/catalog.ts b/site/lib/catalog.ts
index 0e9ae1c..c8003df 100644
--- a/site/lib/catalog.ts
+++ b/site/lib/catalog.ts
@@ -1,4 +1,14 @@
-import type { PieceRecord } from "@/lib/db";
+import { listPieceMediaLinks, type PieceRecord } from "@/lib/db";
+import {
+ getPieceInquiryMode,
+ getPiecePriceMode,
+ getPiecePublicPriceLabel,
+ getPieceReviewsMode,
+ pieceAcceptsReviews,
+ pieceAllowsInquiry,
+ pieceCanEnterCart,
+ pieceDisplaysReviews
+} from "@/lib/piece-model";
import { defaultPieceCategories, normalizePieceCategories, pieceCategoryKey, type PieceCategoryDefinition } from "@/lib/categories";
export function getPortfolioCategories(value?: unknown) {
@@ -12,17 +22,34 @@ export function getPiecePortfolioCategory(
return pieceCategoryKey(piece.category, categories);
}
-export function getDisplayMediaPaths(piece: Pick) {
+export function getDisplayMediaPaths(piece: Pick) {
const preferredLimit = Number(piece.metadata?.publicMediaLimit ?? (piece.status === "inventory" ? 5 : 4));
const safeLimit = Number.isFinite(preferredLimit) ? Math.max(1, Math.min(12, Math.round(preferredLimit))) : 4;
- return piece.mediaPaths.slice(0, safeLimit);
+ const links = listPieceMediaLinks(piece.slug, { publicOnly: true, roles: ["hero", "gallery", "detail", "context"] });
+ const normalized = [...new Set(links.map((link) => link.relativePath))];
+ return (normalized.length > 0 ? normalized : piece.mediaPaths).slice(0, safeLimit);
}
-export function hasVerifiedMedia(piece: Pick) {
- if (piece.mediaPaths.length === 0) return false;
+export function getPieceProcessMediaLinks(piece: Pick) {
+ return listPieceMediaLinks(piece.slug, { publicOnly: true, roles: ["process", "drawing", "plan", "installation"] });
+}
+
+export function hasVerifiedMedia(piece: Pick) {
+ if (getDisplayMediaPaths(piece).length === 0) return false;
return piece.metadata?.verifiedMedia !== false;
}
+export {
+ getPieceInquiryMode,
+ getPiecePriceMode,
+ getPiecePublicPriceLabel,
+ getPieceReviewsMode,
+ pieceAcceptsReviews,
+ pieceAllowsInquiry,
+ pieceCanEnterCart,
+ pieceDisplaysReviews
+};
+
export function pieceShippingEnabled(piece: Pick) {
return Boolean(piece.metadata?.shippingEnabled || piece.metadata?.shipEligible || piece.metadata?.shippingAllowed);
}
diff --git a/site/lib/database-migrations.test.mts b/site/lib/database-migrations.test.mts
new file mode 100644
index 0000000..0e9413f
--- /dev/null
+++ b/site/lib/database-migrations.test.mts
@@ -0,0 +1,80 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { DatabaseSync } from "node:sqlite";
+import { applySchemaMigrations } from "./database-migrations.ts";
+
+function fixtureDatabase(options: { omitPriceColumn?: boolean } = {}) {
+ const directory = mkdtempSync(path.join(tmpdir(), "woodsmith-migration-"));
+ const db = new DatabaseSync(path.join(directory, "fixture.sqlite"));
+ db.exec(`
+ PRAGMA foreign_keys = ON;
+ CREATE TABLE pieces (
+ slug TEXT PRIMARY KEY,
+ status TEXT NOT NULL,
+ publication_status TEXT NOT NULL,
+ availability_label TEXT NOT NULL DEFAULT '',
+ ${options.omitPriceColumn ? "" : "price_cents INTEGER,"}
+ media_paths_json TEXT NOT NULL DEFAULT '[]',
+ metadata_json TEXT NOT NULL DEFAULT '{}'
+ ) STRICT;
+ CREATE TABLE media_items (
+ relative_path TEXT PRIMARY KEY
+ ) STRICT;
+ `);
+ return { db, directory };
+}
+
+test("schema migrations are additive, idempotent, and preserve legacy truth", () => {
+ const { db, directory } = fixtureDatabase();
+ try {
+ db.prepare(`INSERT INTO media_items (relative_path) VALUES (?)`).run("Furniture/pastry-table/hero.jpg");
+ const insertPiece = db.prepare(`
+ INSERT INTO pieces (slug, status, publication_status, availability_label, price_cents, media_paths_json, metadata_json)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ `);
+ insertPiece.run("pastry-table", "inventory", "published", "Available", -1, JSON.stringify(["Furniture/pastry-table/hero.jpg", "missing.jpg"]), JSON.stringify({ verifiedMedia: true }));
+ insertPiece.run("fixed-piece", "inventory", "published", "Available", 125000, "[]", "{}");
+ insertPiece.run("archive-piece", "archive", "published", "Unavailable", null, "[]", "{}");
+
+ const first = applySchemaMigrations(db);
+ const second = applySchemaMigrations(db);
+ assert.equal(first.quickCheckBefore, "ok");
+ assert.equal(first.quickCheckAfter, "ok");
+ assert.deepEqual(first.applied.map((entry) => entry.version), [1, 2, 3]);
+ assert.equal(second.applied.length, 0);
+
+ const policies = (db.prepare(`SELECT slug, price_mode AS priceMode, price_cents AS priceCents, inquiry_mode AS inquiryMode, reviews_mode AS reviewsMode FROM pieces ORDER BY slug`).all() as Array>).map((row) => ({ ...row }));
+ assert.deepEqual(policies, [
+ { slug: "archive-piece", priceMode: "not-listed", priceCents: null, inquiryMode: "related-commission", reviewsMode: "display-and-accept" },
+ { slug: "fixed-piece", priceMode: "fixed", priceCents: 125000, inquiryMode: "exact-piece", reviewsMode: "display-and-accept" },
+ { slug: "pastry-table", priceMode: "contact-for-price", priceCents: null, inquiryMode: "exact-piece", reviewsMode: "display-and-accept" }
+ ]);
+
+ const links = (db.prepare(`SELECT piece_slug AS pieceSlug, relative_path AS relativePath, role, display_order AS displayOrder, is_public AS public FROM piece_media_links`).all() as Array>).map((row) => ({ ...row }));
+ assert.deepEqual(links, [{ pieceSlug: "pastry-table", relativePath: "Furniture/pastry-table/hero.jpg", role: "hero", displayOrder: 0, public: 1 }]);
+ const report = db.prepare(`SELECT report_json AS reportJson FROM schema_migrations WHERE version = 3`).get() as { reportJson: string };
+ assert.equal(JSON.parse(report.reportJson).missingCount, 1);
+ } finally {
+ db.close();
+ rmSync(directory, { recursive: true, force: true });
+ }
+});
+
+test("a failing migration rolls back its schema changes and ledger row", () => {
+ const { db, directory } = fixtureDatabase({ omitPriceColumn: true });
+ try {
+ assert.throws(() => applySchemaMigrations(db), /price_cents/);
+ const versions = db.prepare(`SELECT version FROM schema_migrations ORDER BY version`).all() as Array<{ version: number }>;
+ assert.deepEqual(versions.map((row) => row.version), [1]);
+ const columns = db.prepare(`PRAGMA table_info(pieces)`).all() as Array<{ name: string }>;
+ assert.equal(columns.some((column) => column.name === "price_mode"), false);
+ const quick = db.prepare(`PRAGMA quick_check`).get() as Record;
+ assert.equal(String(Object.values(quick)[0]), "ok");
+ } finally {
+ db.close();
+ rmSync(directory, { recursive: true, force: true });
+ }
+});
diff --git a/site/lib/database-migrations.ts b/site/lib/database-migrations.ts
new file mode 100644
index 0000000..5e97432
--- /dev/null
+++ b/site/lib/database-migrations.ts
@@ -0,0 +1,279 @@
+import { randomUUID } from "node:crypto";
+import type { DatabaseSync } from "node:sqlite";
+import {
+ inferLegacyInquiryMode,
+ inferLegacyPriceMode,
+ inferLegacyReviewsMode,
+ normalizeInquiryMode,
+ normalizePriceMode,
+ normalizeReviewsMode
+} from "./piece-model.ts";
+
+type MigrationReport = Record;
+
+type Migration = {
+ version: number;
+ name: string;
+ checksum: string;
+ apply: (db: DatabaseSync) => MigrationReport;
+};
+
+export type SchemaMigrationResult = {
+ applied: Array<{ version: number; name: string; report: MigrationReport }>;
+ quickCheckBefore: string;
+ quickCheckAfter: string;
+};
+
+function nowIso() {
+ return new Date().toISOString();
+}
+
+function quickCheck(db: DatabaseSync) {
+ const row = db.prepare("PRAGMA quick_check").get() as Record | undefined;
+ return String(Object.values(row ?? {})[0] ?? "unknown");
+}
+
+function readJson(value: unknown, fallback: T): T {
+ if (typeof value !== "string" || !value) return fallback;
+ try {
+ return JSON.parse(value) as T;
+ } catch {
+ return fallback;
+ }
+}
+
+function tableColumns(db: DatabaseSync, table: string) {
+ const rows = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name?: unknown }>;
+ return new Set(rows.map((row) => String(row.name ?? "")));
+}
+
+function addColumn(db: DatabaseSync, table: string, column: string, definition: string) {
+ if (tableColumns(db, table).has(column)) return false;
+ db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
+ return true;
+}
+
+const migrations: Migration[] = [
+ {
+ version: 1,
+ name: "normalized-piece-media-and-audit-ledgers",
+ checksum: "2026-07-piece-media-audit-v1",
+ apply(db) {
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS piece_media_links (
+ id TEXT PRIMARY KEY,
+ piece_slug TEXT NOT NULL,
+ relative_path TEXT NOT NULL,
+ role TEXT NOT NULL CHECK (role IN ('hero', 'gallery', 'detail', 'context', 'process', 'drawing', 'plan', 'installation', 'source', 'private-project')),
+ stage TEXT,
+ occurred_at TEXT,
+ title TEXT NOT NULL DEFAULT '',
+ caption TEXT NOT NULL DEFAULT '',
+ technical_note TEXT NOT NULL DEFAULT '',
+ alt_override TEXT,
+ display_order INTEGER NOT NULL DEFAULT 0,
+ is_public INTEGER NOT NULL DEFAULT 0 CHECK (is_public IN (0, 1)),
+ legacy_synced INTEGER NOT NULL DEFAULT 0 CHECK (legacy_synced IN (0, 1)),
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL,
+ FOREIGN KEY (piece_slug) REFERENCES pieces(slug) ON UPDATE CASCADE ON DELETE CASCADE,
+ FOREIGN KEY (relative_path) REFERENCES media_items(relative_path) ON UPDATE CASCADE ON DELETE CASCADE
+ ) STRICT;
+
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_piece_media_unique_role_stage
+ ON piece_media_links(piece_slug, relative_path, role, IFNULL(stage, ''));
+ CREATE INDEX IF NOT EXISTS idx_piece_media_piece_order
+ ON piece_media_links(piece_slug, is_public, role, display_order);
+ CREATE INDEX IF NOT EXISTS idx_piece_media_path
+ ON piece_media_links(relative_path);
+
+ CREATE TABLE IF NOT EXISTS admin_edit_audit (
+ id TEXT PRIMARY KEY,
+ actor_email TEXT,
+ entity_type TEXT NOT NULL,
+ entity_key TEXT NOT NULL,
+ operation TEXT NOT NULL,
+ before_json TEXT NOT NULL DEFAULT 'null',
+ after_json TEXT NOT NULL DEFAULT 'null',
+ request_id TEXT,
+ reverted_by_id TEXT,
+ created_at TEXT NOT NULL,
+ FOREIGN KEY (reverted_by_id) REFERENCES admin_edit_audit(id)
+ ) STRICT;
+ CREATE INDEX IF NOT EXISTS idx_admin_edit_audit_entity
+ ON admin_edit_audit(entity_type, entity_key, created_at DESC);
+
+ CREATE TABLE IF NOT EXISTS media_rename_history (
+ id TEXT PRIMARY KEY,
+ previous_path TEXT NOT NULL,
+ next_path TEXT,
+ status TEXT NOT NULL CHECK (status IN ('planned', 'completed', 'rolled-back', 'failed', 'deleted')),
+ actor_email TEXT,
+ error TEXT,
+ rollback_of TEXT,
+ created_at TEXT NOT NULL,
+ completed_at TEXT,
+ FOREIGN KEY (rollback_of) REFERENCES media_rename_history(id)
+ ) STRICT;
+ CREATE INDEX IF NOT EXISTS idx_media_rename_previous
+ ON media_rename_history(previous_path, created_at DESC);
+ CREATE INDEX IF NOT EXISTS idx_media_rename_next
+ ON media_rename_history(next_path, created_at DESC);
+ `);
+
+ return { tables: ["piece_media_links", "admin_edit_audit", "media_rename_history"] };
+ }
+ },
+ {
+ version: 2,
+ name: "typed-piece-policy-fields",
+ checksum: "2026-07-piece-policy-v1",
+ apply(db) {
+ const added: string[] = [];
+ const add = (column: string, definition: string) => {
+ if (addColumn(db, "pieces", column, definition)) added.push(column);
+ };
+
+ add("price_mode", "TEXT NOT NULL DEFAULT 'not-listed' CHECK (price_mode IN ('fixed', 'not-listed', 'contact-for-price', 'determined-after-approval', 'determined-at-order-completion'))");
+ add("public_price_label", "TEXT");
+ add("internal_estimate_cents", "INTEGER");
+ add("inquiry_mode", "TEXT NOT NULL DEFAULT 'disabled' CHECK (inquiry_mode IN ('disabled', 'exact-piece', 'custom-pattern', 'related-commission'))");
+ add("reviews_mode", "TEXT NOT NULL DEFAULT 'hidden' CHECK (reviews_mode IN ('hidden', 'display-approved', 'display-and-accept'))");
+ add("process_section_title", "TEXT NOT NULL DEFAULT 'Build record'");
+ add("process_section_intro", "TEXT NOT NULL DEFAULT ''");
+ add("visualizer_template", "TEXT");
+ add("commission_type_slug", "TEXT");
+
+ const pieces = db.prepare(`
+ SELECT slug, status, publication_status AS publicationStatus,
+ availability_label AS availabilityLabel, price_cents AS priceCents,
+ metadata_json AS metadataJson
+ FROM pieces
+ `).all() as Array>;
+
+ let fixed = 0;
+ let notListed = 0;
+ let contact = 0;
+ let approval = 0;
+ for (const piece of pieces) {
+ const metadata = readJson>(piece.metadataJson, {});
+ const source = {
+ status: String(piece.status) as "inventory" | "commission" | "archive",
+ publicationStatus: String(piece.publicationStatus) as "published" | "draft" | "archived",
+ availabilityLabel: String(piece.availabilityLabel ?? ""),
+ priceCents: piece.priceCents == null ? null : Number(piece.priceCents)
+ };
+ const priceMode = normalizePriceMode(metadata.priceMode, inferLegacyPriceMode(source));
+ const inquiryMode = normalizeInquiryMode(metadata.inquiryMode, inferLegacyInquiryMode(source));
+ const reviewsMode = normalizeReviewsMode(metadata.reviewsMode, inferLegacyReviewsMode(source));
+ const safePrice = priceMode === "fixed" && Number(source.priceCents) > 0 ? source.priceCents : null;
+
+ db.prepare(`
+ UPDATE pieces
+ SET price_mode = ?, price_cents = ?, public_price_label = ?, internal_estimate_cents = ?,
+ inquiry_mode = ?, reviews_mode = ?, process_section_title = ?, process_section_intro = ?,
+ visualizer_template = ?, commission_type_slug = ?
+ WHERE slug = ?
+ `).run(
+ priceMode,
+ safePrice,
+ typeof metadata.publicPriceLabel === "string" ? metadata.publicPriceLabel : null,
+ Number.isInteger(Number(metadata.internalEstimateCents)) && Number(metadata.internalEstimateCents) >= 0 ? Number(metadata.internalEstimateCents) : null,
+ inquiryMode,
+ reviewsMode,
+ typeof metadata.processSectionTitle === "string" && metadata.processSectionTitle.trim() ? metadata.processSectionTitle : "Build record",
+ typeof metadata.processSectionIntro === "string" ? metadata.processSectionIntro : "",
+ typeof metadata.visualizerTemplate === "string" ? metadata.visualizerTemplate : null,
+ typeof metadata.commissionTypeSlug === "string" ? metadata.commissionTypeSlug : null,
+ String(piece.slug)
+ );
+
+ if (priceMode === "fixed") fixed += 1;
+ else if (priceMode === "contact-for-price") contact += 1;
+ else if (priceMode === "determined-after-approval") approval += 1;
+ else notListed += 1;
+ }
+
+ return { addedColumns: added, migratedPieces: pieces.length, priceModes: { fixed, notListed, contact, approval } };
+ }
+ },
+ {
+ version: 3,
+ name: "legacy-piece-media-link-backfill",
+ checksum: "2026-07-piece-media-backfill-v1",
+ apply(db) {
+ const pieces = db.prepare(`SELECT slug, media_paths_json AS mediaPathsJson, metadata_json AS metadataJson FROM pieces`).all() as Array>;
+ const mediaExists = db.prepare("SELECT 1 AS present FROM media_items WHERE relative_path = ? LIMIT 1");
+ const insert = db.prepare(`
+ INSERT OR IGNORE INTO piece_media_links (
+ id, piece_slug, relative_path, role, stage, occurred_at, title, caption,
+ technical_note, alt_override, display_order, is_public, legacy_synced, created_at, updated_at
+ ) VALUES (?, ?, ?, ?, NULL, NULL, '', '', '', NULL, ?, ?, 1, ?, ?)
+ `);
+ let inserted = 0;
+ const missing: Array<{ pieceSlug: string; relativePath: string }> = [];
+ const timestamp = nowIso();
+
+ for (const piece of pieces) {
+ const mediaPaths = readJson(piece.mediaPathsJson, []).filter((value) => typeof value === "string" && value.trim());
+ const metadata = readJson>(piece.metadataJson, {});
+ const isPublic = metadata.verifiedMedia !== false ? 1 : 0;
+ mediaPaths.forEach((relativePath, index) => {
+ if (!mediaExists.get(relativePath)) {
+ missing.push({ pieceSlug: String(piece.slug), relativePath });
+ return;
+ }
+ const result = insert.run(randomUUID(), String(piece.slug), relativePath, index === 0 ? "hero" : "gallery", index, isPublic, timestamp, timestamp);
+ inserted += Number(result.changes ?? 0);
+ });
+ }
+
+ return { inserted, missing, missingCount: missing.length };
+ }
+ }
+];
+
+export function applySchemaMigrations(db: DatabaseSync): SchemaMigrationResult {
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS schema_migrations (
+ version INTEGER PRIMARY KEY,
+ name TEXT NOT NULL,
+ checksum TEXT NOT NULL,
+ report_json TEXT NOT NULL DEFAULT '{}',
+ applied_at TEXT NOT NULL
+ ) STRICT;
+ `);
+
+ const quickCheckBefore = quickCheck(db);
+ if (quickCheckBefore !== "ok") throw new Error(`SQLite quick_check failed before migration: ${quickCheckBefore}`);
+
+ const applied: SchemaMigrationResult["applied"] = [];
+ const current = db.prepare("SELECT version, name, checksum FROM schema_migrations").all() as Array>;
+ const byVersion = new Map(current.map((row) => [Number(row.version), row]));
+
+ for (const migration of migrations) {
+ const existing = byVersion.get(migration.version);
+ if (existing) {
+ if (String(existing.name) !== migration.name || String(existing.checksum) !== migration.checksum) {
+ throw new Error(`Migration ${migration.version} identity does not match the applied migration ledger.`);
+ }
+ continue;
+ }
+
+ db.exec("BEGIN IMMEDIATE");
+ try {
+ const report = migration.apply(db);
+ db.prepare(`INSERT INTO schema_migrations (version, name, checksum, report_json, applied_at) VALUES (?, ?, ?, ?, ?)`)
+ .run(migration.version, migration.name, migration.checksum, JSON.stringify(report), nowIso());
+ db.exec("COMMIT");
+ applied.push({ version: migration.version, name: migration.name, report });
+ } catch (error) {
+ db.exec("ROLLBACK");
+ throw error;
+ }
+ }
+
+ const quickCheckAfter = quickCheck(db);
+ if (quickCheckAfter !== "ok") throw new Error(`SQLite quick_check failed after migration: ${quickCheckAfter}`);
+ return { applied, quickCheckBefore, quickCheckAfter };
+}
diff --git a/site/lib/db.ts b/site/lib/db.ts
index 71e1c5f..3c9ee4e 100644
--- a/site/lib/db.ts
+++ b/site/lib/db.ts
@@ -1,4 +1,4 @@
-import { accessSync, constants as fsConstants, mkdirSync } from "node:fs";
+import { accessSync, constants as fsConstants, existsSync, mkdirSync } from "node:fs";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { DatabaseSync } from "node:sqlite";
@@ -9,9 +9,21 @@ import {
seedPosts,
seedProfiles,
siteSettingsSeed
-} from "@/lib/seed";
-import { scanMediaLibrary } from "@/lib/media";
-import { normalizePieceCategories } from "@/lib/categories";
+} from "./seed.ts";
+import { scanMediaLibrary } from "./media.ts";
+import { normalizePieceCategories } from "./categories.ts";
+import { applySchemaMigrations } from "./database-migrations.ts";
+import {
+ getPieceInquiryMode,
+ getPiecePriceMode,
+ getPieceReviewsMode,
+ normalizeInquiryMode,
+ normalizePriceMode,
+ normalizeReviewsMode,
+ type InquiryMode,
+ type PriceMode,
+ type ReviewsMode
+} from "./piece-model.ts";
export type UserRole = "admin" | "woodworker" | "customer";
export type PublicationStatus = "published" | "draft" | "archived";
@@ -70,6 +82,15 @@ export type PieceRecord = {
materials: string[];
dimensions: { width: number; depth: number; height: number; unit: "in" } | null;
priceCents: number | null;
+ priceMode?: PriceMode;
+ publicPriceLabel?: string | null;
+ internalEstimateCents?: number | null;
+ inquiryMode?: InquiryMode;
+ reviewsMode?: ReviewsMode;
+ processSectionTitle?: string;
+ processSectionIntro?: string;
+ visualizerTemplate?: string | null;
+ commissionTypeSlug?: string | null;
inventoryCount: number;
leadTimeDays: number;
mediaPaths: string[];
@@ -80,6 +101,52 @@ export type PieceRecord = {
updatedAt: string;
};
+export const PIECE_MEDIA_ROLES = ["hero", "gallery", "detail", "context", "process", "drawing", "plan", "installation", "source", "private-project"] as const;
+export type PieceMediaRole = (typeof PIECE_MEDIA_ROLES)[number];
+
+export type PieceMediaLinkRecord = {
+ id: string;
+ pieceSlug: string;
+ relativePath: string;
+ role: PieceMediaRole;
+ stage: string | null;
+ occurredAt: string | null;
+ title: string;
+ caption: string;
+ technicalNote: string;
+ altOverride: string | null;
+ displayOrder: number;
+ public: boolean;
+ legacySynced: boolean;
+ createdAt: string;
+ updatedAt: string;
+};
+
+export type AdminEditAuditRecord = {
+ id: string;
+ actorEmail: string | null;
+ entityType: string;
+ entityKey: string;
+ operation: string;
+ before: unknown;
+ after: unknown;
+ requestId: string | null;
+ revertedById: string | null;
+ createdAt: string;
+};
+
+export type MediaRenameHistoryRecord = {
+ id: string;
+ previousPath: string;
+ nextPath: string | null;
+ status: "planned" | "completed" | "rolled-back" | "failed" | "deleted";
+ actorEmail: string | null;
+ error: string | null;
+ rollbackOf: string | null;
+ createdAt: string;
+ completedAt: string | null;
+};
+
export type PostRecord = {
slug: string;
title: string;
@@ -266,11 +333,31 @@ let database: DatabaseSync | null = null;
let initialized = false;
let activeDataDir: string | null = null;
let activeDatabasePath: string | null = null;
+let transactionDepth = 0;
function nowIso() {
return new Date().toISOString();
}
+export function withDatabaseTransaction(work: (db: DatabaseSync) => T): T {
+ const db = getDatabase();
+ const depth = transactionDepth;
+ const savepoint = `woodsmith_${depth}`;
+ transactionDepth += 1;
+ db.exec(depth === 0 ? "BEGIN IMMEDIATE" : `SAVEPOINT ${savepoint}`);
+ try {
+ const result = work(db);
+ db.exec(depth === 0 ? "COMMIT" : `RELEASE SAVEPOINT ${savepoint}`);
+ return result;
+ } catch (error) {
+ db.exec(depth === 0 ? "ROLLBACK" : `ROLLBACK TO SAVEPOINT ${savepoint}`);
+ if (depth > 0) db.exec(`RELEASE SAVEPOINT ${savepoint}`);
+ throw error;
+ } finally {
+ transactionDepth -= 1;
+ }
+}
+
function toBoolean(value: unknown) {
return Number(value) === 1;
}
@@ -304,11 +391,14 @@ function getDatabase() {
mkdirSync(dataDir, { recursive: true });
activeDataDir = dataDir;
activeDatabasePath = path.join(dataDir, "woodsmith.sqlite");
+ const databaseExisted = existsSync(activeDatabasePath);
database = new DatabaseSync(activeDatabasePath);
- database.exec(`
+ try {
+ database.exec(`
PRAGMA journal_mode = WAL;
- PRAGMA foreign_keys = OFF;
+ PRAGMA foreign_keys = ON;
+ PRAGMA busy_timeout = 5000;
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
@@ -561,16 +651,38 @@ function getDatabase() {
CREATE INDEX IF NOT EXISTS idx_media_piece_slug ON media_items(piece_slug);
CREATE INDEX IF NOT EXISTS idx_media_project_reference ON media_items(project_reference);
CREATE INDEX IF NOT EXISTS idx_embedding_cache_kind ON embedding_cache(kind);
- `);
+ `);
+
+ if (!initialized) {
+ ensureUserVerificationColumns(database);
+ const seededVersionBeforeInitialization = getSeededVersion(database);
+ seedDefaultContent(database);
+ syncMediaLibraryIntoDatabase(database, {
+ applySeedAssignments: !databaseExisted && seededVersionBeforeInitialization === 0
+ });
+ applySchemaMigrations(database);
+ initialized = true;
+ }
- if (!initialized) {
- ensureUserVerificationColumns(database);
- seedDefaultContent(database);
- syncMediaLibraryIntoDatabase(database);
- initialized = true;
+ return database;
+ } catch (error) {
+ database.close();
+ database = null;
+ initialized = false;
+ activeDataDir = null;
+ activeDatabasePath = null;
+ throw error;
}
+}
- return database;
+export function closeDatabaseForTests() {
+ if (process.env.NODE_ENV !== "test") throw new Error("Database reset is available only in the test environment.");
+ database?.close();
+ database = null;
+ initialized = false;
+ activeDataDir = null;
+ activeDatabasePath = null;
+ transactionDepth = 0;
}
function ensureUserVerificationColumns(db: DatabaseSync) {
@@ -629,6 +741,7 @@ export type RuntimePersistenceStatus = {
quickCheck: string;
journalMode: string;
seededVersion: number;
+ schemaVersion: number;
};
export function getRuntimePersistenceStatus(): RuntimePersistenceStatus {
@@ -646,6 +759,7 @@ export function getRuntimePersistenceStatus(): RuntimePersistenceStatus {
const quickCheckRow = db.prepare(`PRAGMA quick_check`).get() as Record | undefined;
const journalModeRow = db.prepare(`PRAGMA journal_mode`).get() as Record | undefined;
+ const schemaVersionRow = db.prepare(`SELECT COALESCE(MAX(version), 0) AS version FROM schema_migrations`).get() as { version?: unknown } | undefined;
return {
dataRoot,
@@ -654,7 +768,8 @@ export function getRuntimePersistenceStatus(): RuntimePersistenceStatus {
dataRootWritable,
quickCheck: String(Object.values(quickCheckRow ?? {})[0] ?? "unknown"),
journalMode: String(Object.values(journalModeRow ?? {})[0] ?? "unknown"),
- seededVersion: getSeededVersion(db)
+ seededVersion: getSeededVersion(db),
+ schemaVersion: Number(schemaVersionRow?.version ?? 0)
};
}
@@ -1085,7 +1200,7 @@ function seedDefaultContent(db: DatabaseSync) {
}
}
-function syncMediaLibraryIntoDatabase(db: DatabaseSync) {
+function syncMediaLibraryIntoDatabase(db: DatabaseSync, options: { applySeedAssignments?: boolean } = {}) {
const scanned = scanMediaLibrary();
for (const media of scanned) {
@@ -1173,21 +1288,23 @@ function syncMediaLibraryIntoDatabase(db: DatabaseSync) {
});
}
- for (const piece of seedPieces) {
- for (const relativePath of piece.mediaPaths) {
- db.prepare(`UPDATE media_items SET piece_slug = COALESCE(piece_slug, ?), reviewed = 1 WHERE relative_path = ?`).run(piece.slug, relativePath);
+ if (options.applySeedAssignments) {
+ for (const piece of seedPieces) {
+ for (const relativePath of piece.mediaPaths) {
+ db.prepare(`UPDATE media_items SET piece_slug = COALESCE(piece_slug, ?), reviewed = 1 WHERE relative_path = ?`).run(piece.slug, relativePath);
+ }
}
- }
- for (const post of seedPosts) {
- if (post.coverMediaPath) {
- db.prepare(`UPDATE media_items SET post_slug = COALESCE(post_slug, ?), reviewed = 1 WHERE relative_path = ?`).run(post.slug, post.coverMediaPath);
+ for (const post of seedPosts) {
+ if (post.coverMediaPath) {
+ db.prepare(`UPDATE media_items SET post_slug = COALESCE(post_slug, ?), reviewed = 1 WHERE relative_path = ?`).run(post.slug, post.coverMediaPath);
+ }
}
- }
- for (const page of seedPages) {
- if (page.heroMediaPath) {
- db.prepare(`UPDATE media_items SET page_slug = COALESCE(page_slug, ?), reviewed = 1 WHERE relative_path = ?`).run(page.slug, page.heroMediaPath);
+ for (const page of seedPages) {
+ if (page.heroMediaPath) {
+ db.prepare(`UPDATE media_items SET page_slug = COALESCE(page_slug, ?), reviewed = 1 WHERE relative_path = ?`).run(page.slug, page.heroMediaPath);
+ }
}
}
}
@@ -1230,6 +1347,17 @@ function mapPage(row: Record): PageRecord {
}
function mapPiece(row: Record): PieceRecord {
+ const metadata = readJson>(row.metadataJson, {});
+ const policySource = {
+ status: row.status as PieceStatus,
+ publicationStatus: row.publicationStatus as PublicationStatus,
+ availabilityLabel: String(row.availabilityLabel ?? ""),
+ priceCents: row.priceCents == null ? null : Number(row.priceCents),
+ priceMode: row.priceMode ? normalizePriceMode(row.priceMode) : null,
+ inquiryMode: row.inquiryMode ? normalizeInquiryMode(row.inquiryMode) : null,
+ reviewsMode: row.reviewsMode ? normalizeReviewsMode(row.reviewsMode) : null,
+ metadata
+ };
return {
slug: String(row.slug),
title: String(row.title),
@@ -1244,13 +1372,22 @@ function mapPiece(row: Record): PieceRecord {
tags: readJson(row.tagsJson, []),
materials: readJson(row.materialsJson, []),
dimensions: readJson(row.dimensionsJson, null),
- priceCents: row.priceCents == null ? null : Number(row.priceCents),
+ priceCents: policySource.priceCents != null && policySource.priceCents > 0 ? policySource.priceCents : null,
+ priceMode: getPiecePriceMode(policySource),
+ publicPriceLabel: row.publicPriceLabel ? String(row.publicPriceLabel) : null,
+ internalEstimateCents: row.internalEstimateCents == null ? null : Math.max(0, Number(row.internalEstimateCents)),
+ inquiryMode: getPieceInquiryMode(policySource),
+ reviewsMode: getPieceReviewsMode(policySource),
+ processSectionTitle: String(row.processSectionTitle ?? "Build record"),
+ processSectionIntro: String(row.processSectionIntro ?? ""),
+ visualizerTemplate: row.visualizerTemplate ? String(row.visualizerTemplate) : null,
+ commissionTypeSlug: row.commissionTypeSlug ? String(row.commissionTypeSlug) : null,
inventoryCount: Number(row.inventoryCount ?? 0),
leadTimeDays: Number(row.leadTimeDays ?? 0),
mediaPaths: readJson(row.mediaPathsJson, []),
featuredRank: Number(row.featuredRank ?? 999),
ownerEmail: row.ownerEmail ? String(row.ownerEmail) : null,
- metadata: readJson(row.metadataJson, {}),
+ metadata,
createdAt: String(row.createdAt),
updatedAt: String(row.updatedAt)
};
@@ -1776,25 +1913,61 @@ export function deletePage(slug: string) {
export function listPieces(includeDraft = false) {
const db = getDatabase();
const query = includeDraft
- ? `SELECT slug, title, subtitle, category, status, publication_status AS publicationStatus, availability_label AS availabilityLabel, summary, story, details_json AS detailsJson, tags_json AS tagsJson, materials_json AS materialsJson, dimensions_json AS dimensionsJson, price_cents AS priceCents, inventory_count AS inventoryCount, lead_time_days AS leadTimeDays, media_paths_json AS mediaPathsJson, featured_rank AS featuredRank, owner_email AS ownerEmail, metadata_json AS metadataJson, created_at AS createdAt, updated_at AS updatedAt FROM pieces ORDER BY featured_rank ASC, title ASC`
- : `SELECT slug, title, subtitle, category, status, publication_status AS publicationStatus, availability_label AS availabilityLabel, summary, story, details_json AS detailsJson, tags_json AS tagsJson, materials_json AS materialsJson, dimensions_json AS dimensionsJson, price_cents AS priceCents, inventory_count AS inventoryCount, lead_time_days AS leadTimeDays, media_paths_json AS mediaPathsJson, featured_rank AS featuredRank, owner_email AS ownerEmail, metadata_json AS metadataJson, created_at AS createdAt, updated_at AS updatedAt FROM pieces WHERE publication_status = 'published' ORDER BY featured_rank ASC, title ASC`;
+ ? `SELECT slug, title, subtitle, category, status, publication_status AS publicationStatus, availability_label AS availabilityLabel, summary, story, details_json AS detailsJson, tags_json AS tagsJson, materials_json AS materialsJson, dimensions_json AS dimensionsJson, price_cents AS priceCents, price_mode AS priceMode, public_price_label AS publicPriceLabel, internal_estimate_cents AS internalEstimateCents, inquiry_mode AS inquiryMode, reviews_mode AS reviewsMode, process_section_title AS processSectionTitle, process_section_intro AS processSectionIntro, visualizer_template AS visualizerTemplate, commission_type_slug AS commissionTypeSlug, inventory_count AS inventoryCount, lead_time_days AS leadTimeDays, media_paths_json AS mediaPathsJson, featured_rank AS featuredRank, owner_email AS ownerEmail, metadata_json AS metadataJson, created_at AS createdAt, updated_at AS updatedAt FROM pieces ORDER BY featured_rank ASC, title ASC`
+ : `SELECT slug, title, subtitle, category, status, publication_status AS publicationStatus, availability_label AS availabilityLabel, summary, story, details_json AS detailsJson, tags_json AS tagsJson, materials_json AS materialsJson, dimensions_json AS dimensionsJson, price_cents AS priceCents, price_mode AS priceMode, public_price_label AS publicPriceLabel, internal_estimate_cents AS internalEstimateCents, inquiry_mode AS inquiryMode, reviews_mode AS reviewsMode, process_section_title AS processSectionTitle, process_section_intro AS processSectionIntro, visualizer_template AS visualizerTemplate, commission_type_slug AS commissionTypeSlug, inventory_count AS inventoryCount, lead_time_days AS leadTimeDays, media_paths_json AS mediaPathsJson, featured_rank AS featuredRank, owner_email AS ownerEmail, metadata_json AS metadataJson, created_at AS createdAt, updated_at AS updatedAt FROM pieces WHERE publication_status = 'published' ORDER BY featured_rank ASC, title ASC`;
return (db.prepare(query).all() as Record[]).map(mapPiece);
}
export function getPiece(slug: string) {
const db = getDatabase();
- const row = db.prepare(`SELECT slug, title, subtitle, category, status, publication_status AS publicationStatus, availability_label AS availabilityLabel, summary, story, details_json AS detailsJson, tags_json AS tagsJson, materials_json AS materialsJson, dimensions_json AS dimensionsJson, price_cents AS priceCents, inventory_count AS inventoryCount, lead_time_days AS leadTimeDays, media_paths_json AS mediaPathsJson, featured_rank AS featuredRank, owner_email AS ownerEmail, metadata_json AS metadataJson, created_at AS createdAt, updated_at AS updatedAt FROM pieces WHERE slug = ? LIMIT 1`).get(slug) as Record | undefined;
+ const row = db.prepare(`SELECT slug, title, subtitle, category, status, publication_status AS publicationStatus, availability_label AS availabilityLabel, summary, story, details_json AS detailsJson, tags_json AS tagsJson, materials_json AS materialsJson, dimensions_json AS dimensionsJson, price_cents AS priceCents, price_mode AS priceMode, public_price_label AS publicPriceLabel, internal_estimate_cents AS internalEstimateCents, inquiry_mode AS inquiryMode, reviews_mode AS reviewsMode, process_section_title AS processSectionTitle, process_section_intro AS processSectionIntro, visualizer_template AS visualizerTemplate, commission_type_slug AS commissionTypeSlug, inventory_count AS inventoryCount, lead_time_days AS leadTimeDays, media_paths_json AS mediaPathsJson, featured_rank AS featuredRank, owner_email AS ownerEmail, metadata_json AS metadataJson, created_at AS createdAt, updated_at AS updatedAt FROM pieces WHERE slug = ? LIMIT 1`).get(slug) as Record | undefined;
return row ? mapPiece(row) : null;
}
+function synchronizeLegacyPieceMediaLinks(db: DatabaseSync, pieceSlug: string, mediaPaths: string[], metadata: Record) {
+ const paths = [...new Set(mediaPaths.map((value) => String(value).trim()).filter(Boolean))];
+ const timestamp = nowIso();
+ const isPublic = metadata.verifiedMedia !== false ? 1 : 0;
+ const exists = db.prepare("SELECT 1 AS present FROM media_items WHERE relative_path = ? LIMIT 1");
+
+ db.prepare("DELETE FROM piece_media_links WHERE piece_slug = ? AND legacy_synced = 1").run(pieceSlug);
+ const insert = db.prepare(`
+ INSERT OR IGNORE INTO piece_media_links (
+ id, piece_slug, relative_path, role, stage, occurred_at, title, caption,
+ technical_note, alt_override, display_order, is_public, legacy_synced, created_at, updated_at
+ ) VALUES (?, ?, ?, ?, NULL, NULL, '', '', '', NULL, ?, ?, 1, ?, ?)
+ `);
+
+ paths.forEach((relativePath, index) => {
+ if (!exists.get(relativePath)) return;
+ insert.run(randomUUID(), pieceSlug, relativePath, index === 0 ? "hero" : "gallery", index, isPublic, timestamp, timestamp);
+ });
+}
+
export function savePiece(input: Omit) {
- const db = getDatabase();
+ return withDatabaseTransaction((db) => {
const existing = getPiece(input.slug);
const timestamp = nowIso();
+ const priceMode = normalizePriceMode(input.priceMode ?? input.metadata.priceMode, getPiecePriceMode(input));
+ const inquiryMode = normalizeInquiryMode(input.inquiryMode ?? input.metadata.inquiryMode, getPieceInquiryMode(input));
+ const reviewsMode = normalizeReviewsMode(input.reviewsMode ?? input.metadata.reviewsMode, getPieceReviewsMode(input));
+ const priceCents = priceMode === "fixed" && Number(input.priceCents) > 0 ? Math.round(Number(input.priceCents)) : null;
+ const publicPriceLabel = String(input.publicPriceLabel ?? input.metadata.publicPriceLabel ?? "").trim() || null;
+ const internalEstimateCents = input.internalEstimateCents == null
+ ? (Number.isInteger(Number(input.metadata.internalEstimateCents)) && Number(input.metadata.internalEstimateCents) >= 0 ? Number(input.metadata.internalEstimateCents) : null)
+ : Math.max(0, Math.round(Number(input.internalEstimateCents)));
+ const metadata = {
+ ...input.metadata,
+ priceMode,
+ inquiryMode,
+ reviewsMode,
+ ...(publicPriceLabel ? { publicPriceLabel } : {}),
+ ...(internalEstimateCents == null ? {} : { internalEstimateCents })
+ };
clearSeedTombstone(db, "piece", input.slug);
db.prepare(`
- INSERT INTO pieces (slug, title, subtitle, category, status, publication_status, availability_label, summary, story, details_json, tags_json, materials_json, dimensions_json, price_cents, inventory_count, lead_time_days, media_paths_json, featured_rank, owner_email, metadata_json, created_at, updated_at)
- VALUES (:slug, :title, :subtitle, :category, :status, :publicationStatus, :availabilityLabel, :summary, :story, :detailsJson, :tagsJson, :materialsJson, :dimensionsJson, :priceCents, :inventoryCount, :leadTimeDays, :mediaPathsJson, :featuredRank, :ownerEmail, :metadataJson, :createdAt, :updatedAt)
+ INSERT INTO pieces (slug, title, subtitle, category, status, publication_status, availability_label, summary, story, details_json, tags_json, materials_json, dimensions_json, price_cents, price_mode, public_price_label, internal_estimate_cents, inquiry_mode, reviews_mode, process_section_title, process_section_intro, visualizer_template, commission_type_slug, inventory_count, lead_time_days, media_paths_json, featured_rank, owner_email, metadata_json, created_at, updated_at)
+ VALUES (:slug, :title, :subtitle, :category, :status, :publicationStatus, :availabilityLabel, :summary, :story, :detailsJson, :tagsJson, :materialsJson, :dimensionsJson, :priceCents, :priceMode, :publicPriceLabel, :internalEstimateCents, :inquiryMode, :reviewsMode, :processSectionTitle, :processSectionIntro, :visualizerTemplate, :commissionTypeSlug, :inventoryCount, :leadTimeDays, :mediaPathsJson, :featuredRank, :ownerEmail, :metadataJson, :createdAt, :updatedAt)
ON CONFLICT(slug) DO UPDATE SET
title = excluded.title,
subtitle = excluded.subtitle,
@@ -1809,6 +1982,15 @@ export function savePiece(input: Omit) {
materials_json = excluded.materials_json,
dimensions_json = excluded.dimensions_json,
price_cents = excluded.price_cents,
+ price_mode = excluded.price_mode,
+ public_price_label = excluded.public_price_label,
+ internal_estimate_cents = excluded.internal_estimate_cents,
+ inquiry_mode = excluded.inquiry_mode,
+ reviews_mode = excluded.reviews_mode,
+ process_section_title = excluded.process_section_title,
+ process_section_intro = excluded.process_section_intro,
+ visualizer_template = excluded.visualizer_template,
+ commission_type_slug = excluded.commission_type_slug,
inventory_count = excluded.inventory_count,
lead_time_days = excluded.lead_time_days,
media_paths_json = excluded.media_paths_json,
@@ -1830,23 +2012,190 @@ export function savePiece(input: Omit) {
tagsJson: writeJson(input.tags),
materialsJson: writeJson(input.materials),
dimensionsJson: writeJson(input.dimensions),
- priceCents: input.priceCents,
+ priceCents,
+ priceMode,
+ publicPriceLabel,
+ internalEstimateCents,
+ inquiryMode,
+ reviewsMode,
+ processSectionTitle: String(input.processSectionTitle ?? input.metadata.processSectionTitle ?? "Build record").trim() || "Build record",
+ processSectionIntro: String(input.processSectionIntro ?? input.metadata.processSectionIntro ?? ""),
+ visualizerTemplate: String(input.visualizerTemplate ?? input.metadata.visualizerTemplate ?? "").trim() || null,
+ commissionTypeSlug: String(input.commissionTypeSlug ?? input.metadata.commissionTypeSlug ?? "").trim() || null,
inventoryCount: input.inventoryCount,
leadTimeDays: input.leadTimeDays,
mediaPathsJson: writeJson(input.mediaPaths),
featuredRank: input.featuredRank,
ownerEmail: input.ownerEmail ?? null,
- metadataJson: writeJson(input.metadata),
+ metadataJson: writeJson(metadata),
createdAt: existing?.createdAt ?? timestamp,
updatedAt: timestamp
});
+ synchronizeLegacyPieceMediaLinks(db, input.slug, input.mediaPaths, metadata);
+ });
}
export function deletePiece(slug: string) {
+ withDatabaseTransaction((db) => {
+ db.prepare(`DELETE FROM pieces WHERE slug = ?`).run(slug);
+ db.prepare(`UPDATE media_items SET piece_slug = NULL WHERE piece_slug = ?`).run(slug);
+ recordSeedTombstone(db, "piece", slug);
+ });
+}
+
+function mapPieceMediaLink(row: Record): PieceMediaLinkRecord {
+ return {
+ id: String(row.id),
+ pieceSlug: String(row.pieceSlug),
+ relativePath: String(row.relativePath),
+ role: row.role as PieceMediaRole,
+ stage: row.stage ? String(row.stage) : null,
+ occurredAt: row.occurredAt ? String(row.occurredAt) : null,
+ title: String(row.title ?? ""),
+ caption: String(row.caption ?? ""),
+ technicalNote: String(row.technicalNote ?? ""),
+ altOverride: row.altOverride ? String(row.altOverride) : null,
+ displayOrder: Number(row.displayOrder ?? 0),
+ public: toBoolean(row.public),
+ legacySynced: toBoolean(row.legacySynced),
+ createdAt: String(row.createdAt),
+ updatedAt: String(row.updatedAt)
+ };
+}
+
+export function listPieceMediaLinks(pieceSlug: string, options: { publicOnly?: boolean; roles?: PieceMediaRole[] } = {}) {
const db = getDatabase();
- db.prepare(`DELETE FROM pieces WHERE slug = ?`).run(slug);
- db.prepare(`UPDATE media_items SET piece_slug = NULL WHERE piece_slug = ?`).run(slug);
- recordSeedTombstone(db, "piece", slug);
+ const clauses = ["piece_slug = ?"];
+ const params: Array = [pieceSlug];
+ if (options.publicOnly) clauses.push("is_public = 1");
+ if (options.roles?.length) {
+ clauses.push(`role IN (${options.roles.map(() => "?").join(", ")})`);
+ params.push(...options.roles);
+ }
+ const rows = db.prepare(`
+ SELECT id, piece_slug AS pieceSlug, relative_path AS relativePath, role, stage,
+ occurred_at AS occurredAt, title, caption, technical_note AS technicalNote,
+ alt_override AS altOverride, display_order AS displayOrder, is_public AS public,
+ legacy_synced AS legacySynced, created_at AS createdAt, updated_at AS updatedAt
+ FROM piece_media_links
+ WHERE ${clauses.join(" AND ")}
+ ORDER BY CASE role WHEN 'hero' THEN 0 WHEN 'gallery' THEN 1 WHEN 'detail' THEN 2 WHEN 'context' THEN 3 ELSE 4 END,
+ display_order ASC, created_at ASC
+ `).all(...params) as Record[];
+ return rows.map(mapPieceMediaLink);
+}
+
+export type PieceMediaLinkInput = Omit & {
+ id?: string;
+};
+
+export function replacePieceMediaLinks(pieceSlug: string, links: PieceMediaLinkInput[], actorEmail: string | null = null) {
+ return withDatabaseTransaction((db) => {
+ if (!getPiece(pieceSlug)) throw new Error(`Piece '${pieceSlug}' does not exist.`);
+ const before = listPieceMediaLinks(pieceSlug);
+ const mediaExists = db.prepare("SELECT 1 AS present FROM media_items WHERE relative_path = ? LIMIT 1");
+ const seen = new Set();
+ for (const link of links) {
+ if (!PIECE_MEDIA_ROLES.includes(link.role)) throw new Error(`Unsupported piece media role '${link.role}'.`);
+ if (!mediaExists.get(link.relativePath)) throw new Error(`Media '${link.relativePath}' does not exist.`);
+ const identity = `${link.relativePath}\u0000${link.role}\u0000${link.stage ?? ""}`;
+ if (seen.has(identity)) throw new Error(`Duplicate media role '${link.role}' for '${link.relativePath}'.`);
+ seen.add(identity);
+ }
+
+ db.prepare("DELETE FROM piece_media_links WHERE piece_slug = ?").run(pieceSlug);
+ const insert = db.prepare(`
+ INSERT INTO piece_media_links (
+ id, piece_slug, relative_path, role, stage, occurred_at, title, caption,
+ technical_note, alt_override, display_order, is_public, legacy_synced, created_at, updated_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
+ `);
+ const timestamp = nowIso();
+ links.forEach((link, index) => {
+ insert.run(
+ link.id || randomUUID(), pieceSlug, link.relativePath, link.role, link.stage ?? null,
+ link.occurredAt ?? null, link.title ?? "", link.caption ?? "", link.technicalNote ?? "",
+ link.altOverride ?? null, Number.isFinite(link.displayOrder) ? Math.round(link.displayOrder) : index,
+ link.public ? 1 : 0, timestamp, timestamp
+ );
+ db.prepare("UPDATE media_items SET piece_slug = COALESCE(piece_slug, ?), updated_at = ? WHERE relative_path = ?")
+ .run(pieceSlug, timestamp, link.relativePath);
+ });
+
+ const legacyPaths = links
+ .filter((link) => link.public && ["hero", "gallery", "detail", "context"].includes(link.role))
+ .sort((left, right) => (left.role === "hero" ? -1 : right.role === "hero" ? 1 : left.displayOrder - right.displayOrder))
+ .map((link) => link.relativePath);
+ db.prepare("UPDATE pieces SET media_paths_json = ?, updated_at = ? WHERE slug = ?")
+ .run(writeJson([...new Set(legacyPaths)]), timestamp, pieceSlug);
+
+ const after = listPieceMediaLinks(pieceSlug);
+ recordAdminEditAudit({ actorEmail, entityType: "piece-media", entityKey: pieceSlug, operation: "replace", before, after });
+ return after;
+ });
+}
+
+export function recordAdminEditAudit(input: {
+ actorEmail?: string | null;
+ entityType: string;
+ entityKey: string;
+ operation: string;
+ before?: unknown;
+ after?: unknown;
+ requestId?: string | null;
+ revertedById?: string | null;
+}) {
+ const db = getDatabase();
+ const id = randomUUID();
+ db.prepare(`
+ INSERT INTO admin_edit_audit (
+ id, actor_email, entity_type, entity_key, operation, before_json, after_json,
+ request_id, reverted_by_id, created_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `).run(
+ id,
+ input.actorEmail?.toLowerCase() ?? null,
+ input.entityType,
+ input.entityKey,
+ input.operation,
+ writeJson(input.before ?? null),
+ writeJson(input.after ?? null),
+ input.requestId ?? null,
+ input.revertedById ?? null,
+ nowIso()
+ );
+ return id;
+}
+
+export function listAdminEditAudit(options: { entityType?: string; entityKey?: string; limit?: number } = {}) {
+ const db = getDatabase();
+ const clauses: string[] = [];
+ const params: Array = [];
+ if (options.entityType) { clauses.push("entity_type = ?"); params.push(options.entityType); }
+ if (options.entityKey) { clauses.push("entity_key = ?"); params.push(options.entityKey); }
+ const limit = Math.max(1, Math.min(250, Math.round(options.limit ?? 50)));
+ params.push(limit);
+ const rows = db.prepare(`
+ SELECT id, actor_email AS actorEmail, entity_type AS entityType, entity_key AS entityKey,
+ operation, before_json AS beforeJson, after_json AS afterJson, request_id AS requestId,
+ reverted_by_id AS revertedById, created_at AS createdAt
+ FROM admin_edit_audit
+ ${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""}
+ ORDER BY created_at DESC
+ LIMIT ?
+ `).all(...params) as Record[];
+ return rows.map((row): AdminEditAuditRecord => ({
+ id: String(row.id),
+ actorEmail: row.actorEmail ? String(row.actorEmail) : null,
+ entityType: String(row.entityType),
+ entityKey: String(row.entityKey),
+ operation: String(row.operation),
+ before: readJson(row.beforeJson, null),
+ after: readJson(row.afterJson, null),
+ requestId: row.requestId ? String(row.requestId) : null,
+ revertedById: row.revertedById ? String(row.revertedById) : null,
+ createdAt: String(row.createdAt)
+ }));
}
export function listPosts(includeDraft = false) {
@@ -1961,9 +2310,14 @@ export function deleteCommissionType(slug: string) {
function mediaJunkPathClauses() {
return [
"lower(relative_path) NOT LIKE '%@eadir%'",
+ "lower(relative_path) NOT LIKE '%@synoeastream%'",
+ "lower(relative_path) NOT LIKE '%.woodsmith-trash%'",
"lower(relative_path) NOT LIKE '%synofile_thumb%'",
"lower(file_name) NOT IN ('synoindex_media_info', '.ds_store', 'thumbs.db')",
- "lower(file_name) NOT LIKE '._%'"
+ "lower(file_name) NOT LIKE '._%'",
+ "lower(file_name) NOT LIKE 'synophoto_%'",
+ "lower(file_name) NOT LIKE 'synoindex_%'",
+ "lower(file_name) NOT LIKE '~rf%'"
];
}
@@ -2159,7 +2513,67 @@ function replaceMediaPathInList(values: string[], previousPath: string, nextPath
return [...new Set(nextValues)];
}
-function rewriteMediaReferences(previousPath: string, nextPath: string | null) {
+function replaceMediaPathDeep(value: unknown, previousPath: string, nextPath: string | null): unknown {
+ if (typeof value === "string") return value === previousPath ? nextPath : value;
+ if (Array.isArray(value)) {
+ return value
+ .map((entry) => replaceMediaPathDeep(entry, previousPath, nextPath))
+ .filter((entry) => entry !== null);
+ }
+ if (value && typeof value === "object") {
+ return Object.fromEntries(Object.entries(value).flatMap(([key, entry]) => {
+ const replaced = replaceMediaPathDeep(entry, previousPath, nextPath);
+ return replaced === null ? [] : [[key, replaced]];
+ }));
+ }
+ return value;
+}
+
+function rewriteJsonReferences(db: DatabaseSync, previousPath: string, nextPath: string | null) {
+ const specs = [
+ { table: "settings", key: "key", columns: ["value"] },
+ { table: "projects", key: "reference", columns: ["estimator_json", "options_json", "shipping_address_json", "billing_address_json"] },
+ { table: "project_updates", key: "id", columns: ["attachments_json"] },
+ { table: "media_items", key: "relative_path", columns: ["metadata_json"] },
+ { table: "embedding_cache", key: "key", columns: ["metadata_json"] }
+ ] as const;
+
+ for (const spec of specs) {
+ const rows = db.prepare(`SELECT ${spec.key} AS rowKey, ${spec.columns.join(", ")} FROM ${spec.table}`).all() as Record[];
+ for (const row of rows) {
+ for (const column of spec.columns) {
+ const current = readJson(row[column], null);
+ const next = replaceMediaPathDeep(current, previousPath, nextPath);
+ if (writeJson(next) === writeJson(current)) continue;
+ db.prepare(`UPDATE ${spec.table} SET ${column} = ? WHERE ${spec.key} = ?`).run(writeJson(next), String(row.rowKey));
+ }
+ }
+ }
+}
+
+function rewritePieceMediaLinkPaths(db: DatabaseSync, previousPath: string, nextPath: string | null) {
+ if (!nextPath) {
+ db.prepare("DELETE FROM piece_media_links WHERE relative_path = ?").run(previousPath);
+ return;
+ }
+
+ const rows = db.prepare(`
+ SELECT id, piece_slug AS pieceSlug, role, stage
+ FROM piece_media_links
+ WHERE relative_path = ?
+ `).all(previousPath) as Array>;
+ for (const row of rows) {
+ const duplicate = db.prepare(`
+ SELECT id FROM piece_media_links
+ WHERE piece_slug = ? AND relative_path = ? AND role = ? AND IFNULL(stage, '') = IFNULL(?, '')
+ LIMIT 1
+ `).get(String(row.pieceSlug), nextPath, String(row.role), row.stage == null ? null : String(row.stage)) as { id?: unknown } | undefined;
+ if (duplicate) db.prepare("DELETE FROM piece_media_links WHERE id = ?").run(String(row.id));
+ else db.prepare("UPDATE piece_media_links SET relative_path = ?, updated_at = ? WHERE id = ?").run(nextPath, nowIso(), String(row.id));
+ }
+}
+
+function rewriteMediaReferences(db: DatabaseSync, previousPath: string, nextPath: string | null) {
const affectedPieceSlugs: string[] = [];
const affectedPostSlugs: string[] = [];
const affectedPageSlugs: string[] = [];
@@ -2167,38 +2581,31 @@ function rewriteMediaReferences(previousPath: string, nextPath: string | null) {
for (const piece of listPieces(true)) {
if (!piece.mediaPaths.includes(previousPath)) continue;
const nextMediaPaths = replaceMediaPathInList(piece.mediaPaths, previousPath, nextPath);
- savePiece({ ...piece, mediaPaths: nextMediaPaths });
+ db.prepare("UPDATE pieces SET media_paths_json = ?, updated_at = ? WHERE slug = ?").run(writeJson(nextMediaPaths), nowIso(), piece.slug);
affectedPieceSlugs.push(piece.slug);
}
+ rewritePieceMediaLinkPaths(db, previousPath, nextPath);
+
for (const post of listPosts(true)) {
if (post.coverMediaPath !== previousPath) continue;
- savePost({ ...post, coverMediaPath: nextPath });
+ db.prepare("UPDATE posts SET cover_media_path = ?, updated_at = ? WHERE slug = ?").run(nextPath, nowIso(), post.slug);
affectedPostSlugs.push(post.slug);
}
for (const page of listPages(true)) {
if (page.heroMediaPath !== previousPath) continue;
- savePage({ ...page, heroMediaPath: nextPath });
+ db.prepare("UPDATE pages SET hero_media_path = ?, updated_at = ? WHERE slug = ?").run(nextPath, nowIso(), page.slug);
affectedPageSlugs.push(page.slug);
}
for (const user of listUsers()) {
if (user.avatarPath !== previousPath) continue;
- saveUserProfile({
- originalEmail: user.email,
- email: user.email,
- role: user.role,
- displayName: user.displayName,
- headline: user.headline,
- bio: user.bio,
- avatarPath: nextPath,
- publicProfile: user.publicProfile,
- links: user.links,
- metadata: user.metadata
- });
+ db.prepare("UPDATE users SET avatar_path = ?, updated_at = ? WHERE lower(email) = lower(?)").run(nextPath, nowIso(), user.email);
}
+ rewriteJsonReferences(db, previousPath, nextPath);
+
return {
pieceSlugs: [...new Set(affectedPieceSlugs)],
postSlugs: [...new Set(affectedPostSlugs)],
@@ -2206,17 +2613,54 @@ function rewriteMediaReferences(previousPath: string, nextPath: string | null) {
};
}
-export function renameMediaRecordAndReferences(previousPath: string, nextPath: string) {
+export function startMediaRenameHistory(previousPath: string, nextPath: string | null, actorEmail: string | null = null, status: MediaRenameHistoryRecord["status"] = "planned") {
+ const db = getDatabase();
+ const id = randomUUID();
+ db.prepare(`
+ INSERT INTO media_rename_history (id, previous_path, next_path, status, actor_email, error, rollback_of, created_at, completed_at)
+ VALUES (?, ?, ?, ?, ?, NULL, NULL, ?, ?)
+ `).run(id, previousPath, nextPath, status, actorEmail?.toLowerCase() ?? null, nowIso(), status === "planned" ? null : nowIso());
+ return id;
+}
+
+export function finishMediaRenameHistory(id: string, status: MediaRenameHistoryRecord["status"], error: string | null = null) {
+ const db = getDatabase();
+ db.prepare("UPDATE media_rename_history SET status = ?, error = ?, completed_at = ? WHERE id = ?")
+ .run(status, error, nowIso(), id);
+}
+
+export function listMediaRenameHistory(limit = 100) {
+ const db = getDatabase();
+ const rows = db.prepare(`
+ SELECT id, previous_path AS previousPath, next_path AS nextPath, status, actor_email AS actorEmail,
+ error, rollback_of AS rollbackOf, created_at AS createdAt, completed_at AS completedAt
+ FROM media_rename_history ORDER BY created_at DESC LIMIT ?
+ `).all(Math.max(1, Math.min(500, Math.round(limit)))) as Record[];
+ return rows.map((row): MediaRenameHistoryRecord => ({
+ id: String(row.id),
+ previousPath: String(row.previousPath),
+ nextPath: row.nextPath ? String(row.nextPath) : null,
+ status: row.status as MediaRenameHistoryRecord["status"],
+ actorEmail: row.actorEmail ? String(row.actorEmail) : null,
+ error: row.error ? String(row.error) : null,
+ rollbackOf: row.rollbackOf ? String(row.rollbackOf) : null,
+ createdAt: String(row.createdAt),
+ completedAt: row.completedAt ? String(row.completedAt) : null
+ }));
+}
+
+export function renameMediaRecordAndReferences(previousPath: string, nextPath: string, options: { actorEmail?: string | null; historyId?: string | null } = {}) {
if (previousPath === nextPath) {
return { pieceSlugs: [], postSlugs: [], pageSlugs: [] };
}
- const db = getDatabase();
- const previous = getMedia(previousPath);
-
- syncMediaLibraryIntoDatabase(db);
+ const historyId = options.historyId ?? startMediaRenameHistory(previousPath, nextPath, options.actorEmail ?? null);
+ try {
+ const affected = withDatabaseTransaction((db) => {
+ const previous = getMedia(previousPath);
+ syncMediaLibraryIntoDatabase(db);
+ if (!getMedia(nextPath)) throw new Error(`Renamed media '${nextPath}' was not found during reference synchronization.`);
- if (previous) {
- saveMediaMetadata({
+ if (previous) saveMediaMetadata({
relativePath: nextPath,
altText: previous.altText,
pieceSlug: previous.pieceSlug,
@@ -2230,18 +2674,32 @@ export function renameMediaRecordAndReferences(previousPath: string, nextPath: s
reviewed: previous.reviewed,
tags: previous.tags,
metadata: previous.metadata
+ });
+
+ const result = rewriteMediaReferences(db, previousPath, nextPath);
+ db.prepare(`DELETE FROM media_items WHERE relative_path = ?`).run(previousPath);
+ return result;
});
+ finishMediaRenameHistory(historyId, "completed");
+ return affected;
+ } catch (error) {
+ finishMediaRenameHistory(historyId, "failed", error instanceof Error ? error.message : String(error));
+ throw error;
}
-
- db.prepare(`DELETE FROM media_items WHERE relative_path = ?`).run(previousPath);
- return rewriteMediaReferences(previousPath, nextPath);
}
-export function deleteMediaRecordAndReferences(relativePath: string) {
- const db = getDatabase();
- const affected = rewriteMediaReferences(relativePath, null);
- db.prepare(`DELETE FROM media_items WHERE relative_path = ?`).run(relativePath);
- return affected;
+export function deleteMediaRecordAndReferences(relativePath: string, actorEmail: string | null = null) {
+ const historyId = startMediaRenameHistory(relativePath, null, actorEmail, "deleted");
+ try {
+ return withDatabaseTransaction((db) => {
+ const affected = rewriteMediaReferences(db, relativePath, null);
+ db.prepare(`DELETE FROM media_items WHERE relative_path = ?`).run(relativePath);
+ return affected;
+ });
+ } catch (error) {
+ finishMediaRenameHistory(historyId, "failed", error instanceof Error ? error.message : String(error));
+ throw error;
+ }
}
export function refreshMediaLibrary() {
diff --git a/site/lib/media-reference-transaction.test.mts b/site/lib/media-reference-transaction.test.mts
new file mode 100644
index 0000000..f3cc671
--- /dev/null
+++ b/site/lib/media-reference-transaction.test.mts
@@ -0,0 +1,77 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import path from "node:path";
+
+test("media rename rewrites legacy and normalized references transactionally", async () => {
+ const root = mkdtempSync(path.join(tmpdir(), "woodsmith-reference-"));
+ const dataRoot = path.join(root, "data");
+ const mediaRoot = path.join(root, "media");
+ const originalPath = "Furniture/test-piece/original.jpg";
+ const nextPath = "Furniture/test-piece/renamed.jpg";
+ mkdirSync(path.join(mediaRoot, "Furniture", "test-piece"), { recursive: true });
+ writeFileSync(path.join(mediaRoot, ...originalPath.split("/")), Buffer.from("fixture"));
+ process.env.NODE_ENV = "test";
+ process.env.DATA_ROOT = dataRoot;
+ process.env.MEDIA_ROOT = mediaRoot;
+
+ const db = await import("./db.ts");
+ const media = await import("./media.ts");
+ try {
+ db.getRuntimePersistenceStatus();
+ db.savePiece({
+ slug: "test-piece",
+ title: "Test Piece",
+ subtitle: "",
+ category: "Objects",
+ status: "archive",
+ publicationStatus: "published",
+ availabilityLabel: "Unavailable",
+ summary: "",
+ story: "",
+ details: [],
+ tags: [],
+ materials: [],
+ dimensions: null,
+ priceCents: null,
+ priceMode: "not-listed",
+ inquiryMode: "disabled",
+ reviewsMode: "hidden",
+ inventoryCount: 0,
+ leadTimeDays: 0,
+ mediaPaths: [originalPath],
+ featuredRank: 999,
+ ownerEmail: null,
+ metadata: { verifiedMedia: true }
+ });
+ db.savePage({
+ slug: "rename-fixture",
+ title: "Rename fixture",
+ navLabel: "Rename fixture",
+ status: "draft",
+ intro: "",
+ body: "",
+ layout: "document",
+ sections: [],
+ heroMediaPath: originalPath
+ });
+
+ const historyId = db.startMediaRenameHistory(originalPath, nextPath, "admin@example.com");
+ media.moveMediaAsset(originalPath, nextPath);
+ const affected = db.renameMediaRecordAndReferences(originalPath, nextPath, { actorEmail: "admin@example.com", historyId });
+
+ assert.deepEqual(affected.pieceSlugs, ["test-piece"]);
+ assert.deepEqual(affected.pageSlugs, ["rename-fixture"]);
+ assert.equal(existsSync(path.join(mediaRoot, ...originalPath.split("/"))), false);
+ assert.equal(existsSync(path.join(mediaRoot, ...nextPath.split("/"))), true);
+ assert.deepEqual(db.getPiece("test-piece")?.mediaPaths, [nextPath]);
+ assert.deepEqual(db.listPieceMediaLinks("test-piece").map((link) => link.relativePath), [nextPath]);
+ assert.equal(db.getPage("rename-fixture")?.heroMediaPath, nextPath);
+ assert.equal(db.listMediaRenameHistory(1)[0]?.status, "completed");
+ assert.equal(db.getRuntimePersistenceStatus().quickCheck, "ok");
+ } finally {
+ db.closeDatabaseForTests();
+ rmSync(root, { recursive: true, force: true });
+ }
+});
diff --git a/site/lib/media.ts b/site/lib/media.ts
index edfb12f..dda4168 100644
--- a/site/lib/media.ts
+++ b/site/lib/media.ts
@@ -4,7 +4,7 @@ import { randomUUID } from "node:crypto";
const IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".webp", ".avif", ".gif", ".svg", ".bmp", ".heic", ".heif", ".tif", ".tiff"]);
const VIDEO_EXTENSIONS = new Set([".mp4", ".mov", ".m4v", ".webm"]);
-const IGNORED_MEDIA_FILE_NAMES = new Set(["synoindex_media_info", ".ds_store", "thumbs.db"]);
+const IGNORED_MEDIA_FILE_NAMES = new Set(["synoindex_media_info", ".ds_store", "thumbs.db"]);
const DEFAULT_MEDIA_ROOT = "/app/pics";
export type MediaKind = "image" | "video" | "other";
@@ -62,15 +62,15 @@ export function detectMediaKind(fileName: string): MediaKind {
return "other";
}
-function shouldIgnoreMediaEntry(name: string) {
- const normalized = name.toLowerCase();
- if (IGNORED_MEDIA_FILE_NAMES.has(normalized) || normalized.startsWith("._")) {
- return true;
- }
- if (normalized === "@eadir") {
- return true;
- }
- if (normalized.includes("synofile_thumb")) {
+function shouldIgnoreMediaEntry(name: string) {
+ const normalized = name.toLowerCase();
+ if (IGNORED_MEDIA_FILE_NAMES.has(normalized) || normalized.startsWith("._") || normalized.startsWith("~rf")) {
+ return true;
+ }
+ if (normalized === "@eadir" || normalized === "@synoeastream" || normalized === ".woodsmith-trash") {
+ return true;
+ }
+ if (normalized.startsWith("synophoto_") || normalized.startsWith("synoindex_") || normalized.includes("synofile_thumb")) {
return true;
}
return false;
@@ -136,7 +136,7 @@ function walkMedia(directory: string, root: string, output: MediaScanRecord[]) {
const stats = statSync(absolutePath);
const relativePath = normalizeRelativePath(path.relative(root, absolutePath));
const rpLower = relativePath.toLowerCase();
- if (rpLower.includes("@eadir") || rpLower.includes("synofile_thumb")) {
+ if (rpLower.includes("@eadir") || rpLower.includes("@synoeastream") || rpLower.includes("/.woodsmith-trash/") || rpLower.includes("synofile_thumb") || /(^|\/)synophoto_/i.test(relativePath) || /(^|\/)synoindex_/i.test(relativePath)) {
continue;
}
@@ -204,6 +204,11 @@ export function persistGeneratedMedia(base64Image: string, folder = "generated",
}
export function renameMediaAsset(relativePath: string, nextBaseName: string) {
+ const nextPath = previewMediaRenamePath(relativePath, nextBaseName);
+ return moveMediaAsset(relativePath, nextPath);
+}
+
+export function previewMediaRenamePath(relativePath: string, nextBaseName: string) {
const currentAbsolutePath = resolveMediaPath(relativePath);
const parsed = path.parse(currentAbsolutePath);
const baseName = slugify(nextBaseName) || `media-${randomUUID().slice(0, 8)}`;
@@ -215,9 +220,34 @@ export function renameMediaAsset(relativePath: string, nextBaseName: string) {
if (existsSync(targetAbsolutePath)) {
throw new Error(`A media file named '${path.basename(targetAbsolutePath)}' already exists in this folder.`);
}
- renameSync(currentAbsolutePath, targetAbsolutePath);
return normalizeRelativePath(path.relative(mediaRoot, targetAbsolutePath));
}
+
+export function moveMediaAsset(relativePath: string, nextRelativePath: string) {
+ const currentAbsolutePath = resolveMediaPath(relativePath);
+ const targetAbsolutePath = resolveMediaPath(nextRelativePath);
+ if (path.resolve(targetAbsolutePath) === path.resolve(currentAbsolutePath)) return normalizeRelativePath(nextRelativePath);
+ if (existsSync(targetAbsolutePath)) throw new Error(`A media file named '${path.basename(targetAbsolutePath)}' already exists in this folder.`);
+ mkdirSync(path.dirname(targetAbsolutePath), { recursive: true });
+ renameSync(currentAbsolutePath, targetAbsolutePath);
+ return normalizeRelativePath(nextRelativePath);
+}
+
+export function stageMediaAssetDeletion(relativePath: string) {
+ const parsed = path.posix.parse(normalizeRelativePath(relativePath));
+ const stagedPath = `.woodsmith-trash/${randomUUID()}-${parsed.base}`;
+ moveMediaAsset(relativePath, stagedPath);
+ return { originalPath: normalizeRelativePath(relativePath), stagedPath };
+}
+
+export function restoreStagedMediaAsset(input: { originalPath: string; stagedPath: string }) {
+ return moveMediaAsset(input.stagedPath, input.originalPath);
+}
+
+export function finalizeStagedMediaDeletion(input: { stagedPath: string }) {
+ const absolutePath = resolveMediaPath(input.stagedPath);
+ rmSync(absolutePath, { force: true });
+}
export function deleteMediaAsset(relativePath: string) {
const absolutePath = resolveMediaPath(relativePath);
diff --git a/site/lib/piece-model.test.mts b/site/lib/piece-model.test.mts
new file mode 100644
index 0000000..0a980d5
--- /dev/null
+++ b/site/lib/piece-model.test.mts
@@ -0,0 +1,63 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import {
+ getPieceInquiryMode,
+ getPiecePriceMode,
+ getPiecePublicPriceLabel,
+ getPieceReviewsMode,
+ pieceAcceptsReviews,
+ pieceAllowsInquiry,
+ pieceCanEnterCart,
+ pieceDisplaysReviews,
+ type PiecePolicySource,
+ type PriceMode
+} from "./piece-model.ts";
+
+function piece(overrides: Partial = {}): PiecePolicySource {
+ return {
+ status: "inventory",
+ publicationStatus: "published",
+ availabilityLabel: "Available",
+ priceCents: 125_000,
+ inventoryCount: 1,
+ metadata: {},
+ ...overrides
+ };
+}
+
+test("fixed-price cart eligibility requires public positive inventory", () => {
+ assert.equal(pieceCanEnterCart(piece({ priceMode: "fixed" })), true);
+ assert.equal(pieceCanEnterCart(piece({ priceMode: "fixed", priceCents: 0 })), false);
+ assert.equal(pieceCanEnterCart(piece({ priceMode: "fixed", inventoryCount: 0 })), false);
+ assert.equal(pieceCanEnterCart(piece({ priceMode: "contact-for-price" })), false);
+ assert.equal(pieceCanEnterCart(piece({ priceMode: "fixed", publicationStatus: "draft" })), false);
+});
+
+test("all pricing modes resolve without interpreting sentinels as money", () => {
+ const modes: PriceMode[] = [
+ "fixed",
+ "not-listed",
+ "contact-for-price",
+ "determined-after-approval",
+ "determined-at-order-completion"
+ ];
+ for (const mode of modes) assert.equal(getPiecePriceMode(piece({ priceMode: mode })), mode);
+ assert.equal(getPiecePriceMode(piece({ status: "archive", priceCents: -1, priceMode: null })), "not-listed");
+ assert.equal(getPiecePriceMode(piece({ status: "inventory", priceCents: -1, priceMode: null })), "contact-for-price");
+ assert.equal(getPiecePublicPriceLabel(piece({ priceMode: "contact-for-price" })), "Contact for price");
+ assert.equal(getPiecePublicPriceLabel(piece({ priceMode: "not-listed" })), null);
+});
+
+test("inquiry and review modes are independent from legacy status", () => {
+ const source = piece({ inquiryMode: "disabled", reviewsMode: "hidden" });
+ assert.equal(getPieceInquiryMode(source), "disabled");
+ assert.equal(pieceAllowsInquiry(source), false);
+ assert.equal(getPieceReviewsMode(source), "hidden");
+ assert.equal(pieceDisplaysReviews(source), false);
+ assert.equal(pieceAcceptsReviews(source), false);
+
+ const enabled = piece({ inquiryMode: "related-commission", reviewsMode: "display-and-accept" });
+ assert.equal(pieceAllowsInquiry(enabled), true);
+ assert.equal(pieceDisplaysReviews(enabled), true);
+ assert.equal(pieceAcceptsReviews(enabled), true);
+});
diff --git a/site/lib/piece-model.ts b/site/lib/piece-model.ts
new file mode 100644
index 0000000..19bc79a
--- /dev/null
+++ b/site/lib/piece-model.ts
@@ -0,0 +1,125 @@
+export const PRICE_MODES = [
+ "fixed",
+ "not-listed",
+ "contact-for-price",
+ "determined-after-approval",
+ "determined-at-order-completion"
+] as const;
+
+export type PriceMode = (typeof PRICE_MODES)[number];
+
+export const INQUIRY_MODES = [
+ "disabled",
+ "exact-piece",
+ "custom-pattern",
+ "related-commission"
+] as const;
+
+export type InquiryMode = (typeof INQUIRY_MODES)[number];
+
+export const REVIEWS_MODES = [
+ "hidden",
+ "display-approved",
+ "display-and-accept"
+] as const;
+
+export type ReviewsMode = (typeof REVIEWS_MODES)[number];
+
+export type PiecePolicySource = {
+ status: "inventory" | "commission" | "archive";
+ publicationStatus?: "published" | "draft" | "archived";
+ availabilityLabel?: string;
+ priceCents: number | null;
+ priceMode?: PriceMode | null;
+ publicPriceLabel?: string | null;
+ inquiryMode?: InquiryMode | null;
+ reviewsMode?: ReviewsMode | null;
+ inventoryCount?: number;
+ metadata?: Record;
+};
+
+function oneOf(value: unknown, values: T): value is T[number] {
+ return typeof value === "string" && (values as readonly string[]).includes(value);
+}
+
+export function normalizePriceMode(value: unknown, fallback: PriceMode = "not-listed"): PriceMode {
+ return oneOf(value, PRICE_MODES) ? value : fallback;
+}
+
+export function normalizeInquiryMode(value: unknown, fallback: InquiryMode = "disabled"): InquiryMode {
+ return oneOf(value, INQUIRY_MODES) ? value : fallback;
+}
+
+export function normalizeReviewsMode(value: unknown, fallback: ReviewsMode = "hidden"): ReviewsMode {
+ return oneOf(value, REVIEWS_MODES) ? value : fallback;
+}
+
+export function inferLegacyPriceMode(source: Pick): PriceMode {
+ if (typeof source.priceCents === "number" && source.priceCents > 0) return "fixed";
+ if (source.status === "commission") return "determined-after-approval";
+
+ const availability = String(source.availabilityLabel ?? "").toLowerCase();
+ if (source.status === "inventory" && /(available|request|inquir|ask|contact)/.test(availability)) {
+ return "contact-for-price";
+ }
+
+ return "not-listed";
+}
+
+export function inferLegacyInquiryMode(source: Pick): InquiryMode {
+ if (source.publicationStatus === "archived") return "disabled";
+ if (source.status === "inventory") return "exact-piece";
+ if (source.status === "commission") return "custom-pattern";
+ return source.publicationStatus === "published" ? "related-commission" : "disabled";
+}
+
+export function inferLegacyReviewsMode(source: Pick): ReviewsMode {
+ return source.publicationStatus === "published" ? "display-and-accept" : "hidden";
+}
+
+export function getPiecePriceMode(piece: PiecePolicySource): PriceMode {
+ const explicit = piece.priceMode ?? piece.metadata?.priceMode;
+ return normalizePriceMode(explicit, inferLegacyPriceMode(piece));
+}
+
+export function getPieceInquiryMode(piece: PiecePolicySource): InquiryMode {
+ const explicit = piece.inquiryMode ?? piece.metadata?.inquiryMode;
+ return normalizeInquiryMode(explicit, inferLegacyInquiryMode(piece));
+}
+
+export function getPieceReviewsMode(piece: PiecePolicySource): ReviewsMode {
+ const explicit = piece.reviewsMode ?? piece.metadata?.reviewsMode;
+ return normalizeReviewsMode(explicit, inferLegacyReviewsMode(piece));
+}
+
+export function getPiecePublicPriceLabel(piece: PiecePolicySource): string | null {
+ const override = String(piece.publicPriceLabel ?? piece.metadata?.publicPriceLabel ?? "").trim();
+ if (override) return override;
+
+ const mode = getPiecePriceMode(piece);
+ if (mode === "contact-for-price") return "Contact for price";
+ if (mode === "determined-after-approval") return "Pricing follows design approval";
+ if (mode === "determined-at-order-completion") return "Final price determined at completion";
+ return null;
+}
+
+export function pieceCanEnterCart(piece: PiecePolicySource): boolean {
+ return piece.publicationStatus === "published"
+ && piece.status === "inventory"
+ && getPiecePriceMode(piece) === "fixed"
+ && typeof piece.priceCents === "number"
+ && piece.priceCents > 0
+ && Number(piece.inventoryCount ?? 0) > 0;
+}
+
+export function pieceDisplaysReviews(piece: PiecePolicySource): boolean {
+ return getPieceReviewsMode(piece) !== "hidden";
+}
+
+export function pieceAcceptsReviews(piece: PiecePolicySource): boolean {
+ return getPieceReviewsMode(piece) === "display-and-accept";
+}
+
+export function pieceAllowsInquiry(piece: PiecePolicySource): boolean {
+ return getPieceInquiryMode(piece) !== "disabled";
+}
diff --git a/site/lib/seed.ts b/site/lib/seed.ts
index ecfe966..3df66b9 100644
--- a/site/lib/seed.ts
+++ b/site/lib/seed.ts
@@ -633,4 +633,4 @@ export const seedProfiles: SeedProfile[] = [
metadata: { showOnAboutPage: true, developer: true }
}
];
-import { defaultPieceCategories } from "@/lib/categories";
+import { defaultPieceCategories } from "./categories.ts";
diff --git a/site/package.json b/site/package.json
index a479726..29dd8d8 100644
--- a/site/package.json
+++ b/site/package.json
@@ -8,7 +8,7 @@
"start": "node --experimental-sqlite .next/standalone/server.js",
"lint": "eslint app components lib",
"typecheck": "tsc --noEmit",
- "test": "node --experimental-strip-types --test lib/media-scoring.test.mts"
+ "test": "node --experimental-strip-types --test lib/media-scoring.test.mts lib/piece-model.test.mts lib/database-migrations.test.mts lib/media-reference-transaction.test.mts"
},
"dependencies": {
"@react-three/drei": "^10.7.7",
From 30c87f6564c52ed43de6871fbd61f890e5c8e82a Mon Sep 17 00:00:00 2001
From: Cooper Beaman <55124795+lowestprime@users.noreply.github.com>
Date: Sat, 11 Jul 2026 05:57:57 -0700
Subject: [PATCH 02/43] docs: record sitewide UX and data audit
---
docs/sitewide-ux-overhaul-audit-20260711.md | 178 ++++++++++++++++++++
1 file changed, 178 insertions(+)
create mode 100644 docs/sitewide-ux-overhaul-audit-20260711.md
diff --git a/docs/sitewide-ux-overhaul-audit-20260711.md b/docs/sitewide-ux-overhaul-audit-20260711.md
new file mode 100644
index 0000000..a6234ef
--- /dev/null
+++ b/docs/sitewide-ux-overhaul-audit-20260711.md
@@ -0,0 +1,178 @@
+# Beaman Woodworks Sitewide UX and Data Audit
+
+Date: 2026-07-11
+Branch: `codex/sitewide-studio-ux-commission-overhaul-20260711`
+Baseline commit: `81b35c4b97d464a388560c13c1526b78c10afa45`
+
+This document records the verified baseline used for the sitewide overhaul. Documentation claims were treated as hypotheses and checked against source, a disposable SQLite backup, supplied screenshots, and read-only production requests.
+
+## Evidence
+
+- Supplied screenshot 1, Studio category editor: 1773 x 1417 source pixels.
+- Supplied screenshot 2, footer: 3703 x 1119 source pixels.
+- Supplied screenshot 3, Portfolio filters: 3703 x 1247 source pixels.
+- Supplied screenshot 4, home services/process section: 3703 x 2295 source pixels.
+- Production origin checked read-only: `https://woodmat.ch`.
+- Production headers reported Next.js behind Cloudflare with private/no-store HTML responses.
+- Repository baseline: Next.js 16.2.10, React 19.2.0, TypeScript 5.8.3, ESLint 9.39.4, Node `node:sqlite`, Three.js 0.183.2, React Three Fiber 9.5.0, Drei 10.7.7.
+- Disposable SQLite backup passed `PRAGMA quick_check` before and after migration testing.
+- Source media inventory supplied by the user includes Synology `@eaDir`, `SYNOPHOTO_*`, `SYNOINDEX_*`, Motion Photo metadata, and recovery/temp artifacts that must never be surfaced.
+
+## Architecture Baseline
+
+| Area | Verified implementation | Risk or discrepancy |
+| --- | --- | --- |
+| Routing | Next App Router under `site/app`; dynamic public page, piece, media, request, and account routes | Route-level loading/error coverage is inconsistent |
+| Rendering | Server components by default; client components for theme, header behavior, inline editing, media desk, lightbox, and visualizer | Global `force-dynamic` avoids stale SQLite content but increases repeated database work |
+| Styling | `globals.css`, `refinements.css`, `brand-emblem.css`, then `ui-repair.css` | Repeated contracts and late `!important` rules make spacing and header behavior difficult to reason about |
+| Persistence | SQLite at `DATA_ROOT/woodsmith.sqlite`, WAL mode, seed tombstones, direct NAS mount | Baseline had no schema migration ledger and startup seed-media synchronization could reverse manual decisions |
+| Media | Direct writable `MEDIA_ROOT=/app/pics`, synchronous scan, SQLite metadata, paged Media Desk | Piece relations were duplicated between `pieces.media_paths_json` and `media_items.piece_slug`; no roles/stages |
+| Authentication | Admin, woodworker, and customer roles; cookie-backed sessions; email verification/reset fields | Guest project access still relies on reference plus email in URL and requires a separate security slice |
+| Inline editing | Public edit targets plus hard-coded API allowlists | Not typed, structural fields missing, multi-patch saves non-atomic, no durable audit trail at baseline |
+| Commerce | Cart, totals, optional Stripe/EasyPost, graceful provider degradation | Nullable/negative prices could reach public/cart flows; no typed pricing policy at baseline |
+| Commission preview | SVG and CSS pseudo-3D with estimator fields | Installed R3F/Drei/Three stack was unused; hidden fields were client-authoritative |
+| Deployment | Synology Compose mounts data, media, and Next image cache; Cloudflare canonical origin | A container started outside authoritative Compose can still create disposable image-local data |
+
+## Route Matrix
+
+| Surface | Baseline state | Required correction |
+| --- | --- | --- |
+| `/` | Public hero, featured work, inert service cards, Process promotion | Linked services; commissioned-build section; compact responsive rhythm |
+| `/portfolio` | Query-deep-linked filters and all published pieces | Compact icon rail, extensible icons, pagination/load-more, stable image sizing |
+| `/portfolio/[slug]` | Gallery, unconditional inquiry and reviews, ambiguous price | Typed policy gating, normalized gallery, optional build record |
+| `/shop` | Inventory pieces and unconditional reserve controls | Fixed-price-only cart controls and distinct inquiry-only work |
+| `/shop/cart` | Silently filters null-priced items | Reject non-fixed entries at every server boundary and explain invalid lines |
+| `/commissions` | One long contact form with CSS/SVG pseudo-3D | Primary multi-step journey and dynamically loaded real R3F preview with SVG fallback |
+| `/commissions/status` | Reference/email lookup | Preserve behavior while replacing URL email capability in security slice |
+| `/contact` | Duplicates commission form | Concise general contact and handoff to primary commission flow |
+| `/about` | Public people/contact content | Preserve credits, use structured editable contact/footer models |
+| `/care-and-warranty` | Dynamic page | Preserve and make structural content editable |
+| `/search` | Public/private search boundary | Preserve authorization; add policy-aware labels and normalized media metadata |
+| `/process`, `/process/[slug]` | Optional archive | Retain archive, remove home promotion, separate process media from finished galleries |
+| `/account/*` | Signup, login, verify, forgot/reset, profile, projects | Preserve; later harden draft/resume and guest project access |
+| `/requests/[reference]` | Buyer/project dossier | Preserve privacy checks; add high-entropy guest capability in security slice |
+| `/studio/login` | Admin entry | Preserve noindex and session protections |
+| `/studio` | One panel at a time, but many panels mount every editor expanded | Master-detail editors, in-place saves, visual media relations, consistent dirty/saved states |
+| `/media/[...slug]` | Full-resolution mounted media route | Reject all sidecar/temp paths even when directly requested |
+| 404/loading/error | Custom 404 plus generic loading | Add route-level actionable error states and preserve compact shell |
+
+## Studio Panel Matrix
+
+| Panel | Baseline finding | Priority |
+| --- | --- | --- |
+| Overview | Useful persistence and provider summary; oversized/redundant metrics | Medium |
+| Settings | Partial brand/home fields only | High |
+| Pages | Every page fully expanded; raw hero path | Critical |
+| Pieces | Every piece fully expanded; raw gallery paths; no typed public policy controls | Critical |
+| Categories | Five-value native icon select; no order/visibility/custom SVG | Critical |
+| Custom | Commission types lack category/template/range constraints | High |
+| People | Raw avatar path in Studio | High |
+| Process | Raw cover path and long editor list | High |
+| Media | Strong in-place three-pane foundation and guided trainer | Preserve and extend |
+| Projects | Existing stages and attachments | Add visual role/stage relations and stronger access capability |
+| Orders | Provider-aware operations | Enforce typed fixed-price policy |
+| Reviews | Admin moderation exists | Gate public display/submission per piece |
+| Notifications | Durable queue exists | Preserve provider-degradation truth |
+
+## Screenshot Defects
+
+1. Portfolio filters use large pill containers, circular count badges, fragile CSS `` icons, excessive wrap height, and weak count hierarchy.
+2. Category editing exposes a giant native select with only Table, Bench, Stepstool, Cabinet, and Object choices. It lacks a visual gallery, custom SVG, public preview, order, and visibility.
+3. Home service cards are inert articles and expose a private dashboard as a public service. The next section promotes Process rather than commissioned builds.
+4. Footer contact lines use literal dot separators inside layout rules that can split each token into its own row. GitHub is duplicated and most footer structure is hard-coded.
+
+## Data Findings
+
+- Baseline local production-like backup contained 26 pieces, 426 indexed media rows, 10 projects, 7 pages, 2 users, no orders, and no reviews.
+- Legacy piece JSON contained 196 media references. Three paths had no indexed media row; those were reported and left unmapped.
+- Nine pieces used negative price sentinels and one used zero. They were not valid public money values.
+- Piece/media assignment was duplicated and frequently inconsistent between piece JSON and media rows.
+- Startup reapplied seed media relationships and forced `reviewed=1`; this could reverse an administrator's later unassignment or review decision.
+- Related writes had no transaction wrapper, referential integrity was disabled, and destructive media operations lacked a durable rename ledger.
+- WAL is part of the acknowledged database state. Backups must use SQLite online backup or include WAL/SHM consistently; copying only the main file is not sufficient while live.
+
+## Data Slice Implemented
+
+Commit `d35eb35` introduces:
+
+- `schema_migrations` with identity checks and per-migration reports;
+- `piece_media_links` with role, stage, date, caption, alt override, order, public state, and compatibility provenance;
+- typed price, inquiry, and review policy columns and helpers;
+- `admin_edit_audit` and `media_rename_history`;
+- non-destructive legacy media backfill;
+- conservative pricing conversion: positive values become fixed, available inventory sentinels become contact-for-price, all other sentinels become not-listed;
+- fixed-price-only cart enforcement at server actions and local reservation API;
+- transactional reference rewrites with physical rename rollback and staged deletion;
+- sidecar/temp filtering expansion;
+- startup seed assignments limited to a genuinely new database;
+- disposable migration, rollback, policy, and rename tests.
+
+Production-like disposable migration result: 26 pieces retained, 193 normalized valid links, three stale links reported, zero destructive legacy-column removal, schema version 3, and `PRAGMA quick_check=ok` after two application starts.
+
+## Accessibility Baseline
+
+- Present: semantic landmarks, labels on most controls, lightbox Escape/close/zoom, reduced-motion rule, no observed 320/390 page overflow in read-only live checks.
+- Missing or incomplete: skip link/main target, consistent `:focus-visible`, active primary navigation state, modal focus trap/inert background, keyboard panning, structural-editor keyboard alternatives, contextual error association, and 200 percent reflow evidence.
+- Header focus listener was attached globally and could reveal the sticky header when focus moved anywhere on the page.
+
+## Performance Baseline
+
+- Portfolio renders every published piece and raw originals, producing very long mobile pages and oversized downloads.
+- Homepage can render too many featured cards.
+- Media Desk pages the main library but builds its verification queue from the full image library.
+- Studio Pages/Pieces/Process mount every editor at once.
+- Existing pseudo-3D is small but not functionally sufficient; true Three.js must be dynamically isolated from unrelated routes.
+- Four overlapping CSS files increase override cost and visual regression risk.
+
+## Documentation Discrepancies
+
+- Documentation claimed visual media pickers were in Page, Piece, and Process editors; source still used raw path fields and `MediaPicker` had no consumer.
+- PLANS described the guided media trainer as both validation-pending and deployed/live-verified in different sections.
+- Documentation described visual/semantic search capabilities more strongly than provider configuration and current public UI support justified.
+- Persistence documentation was directionally correct about the mount, but did not explain startup seed reassignment or intentional-empty form semantics.
+- The visualizer was labeled 3D despite rendering CSS geometry and SVG.
+
+## Requirement-to-File Map
+
+| Requirement | Primary implementation areas |
+| --- | --- |
+| Design system and density | `site/app/globals.css`, `site/app/refinements.css`, `site/app/ui-repair.css`, shared shell components |
+| Inline editing | `site/components/inline-edit-*`, `site/app/api/studio/inline-edit/route.ts`, typed registry and audit helpers |
+| Visual media selection | `site/components/media-picker.tsx`, Studio editors, media load actions |
+| Normalized piece media/process | `site/lib/db.ts`, `site/lib/catalog.ts`, piece page and Studio Piece editor |
+| Category icons | `site/lib/categories.ts`, shared SVG component, Portfolio and Studio category editor |
+| Media operations | `site/components/studio-media-workspace.tsx`, `site/lib/actions.ts`, `site/lib/media.ts`, sidecar |
+| Pricing/inquiry/reviews | `site/lib/piece-model.ts`, Shop/cart APIs, piece page, Studio Piece editor |
+| Home/commissions/footer | home route, commissions flow, site chrome, structured settings |
+| True 3D | dynamically imported R3F client surface, generator registry, SVG fallback |
+| Deployment/data safety | Docker/Compose, migration tests, backup and rollback documentation |
+
+## Implementation and Validation Sequence
+
+1. Establish migration-safe normalized data and typed public policy. Completed in `d35eb35`.
+2. Consolidate design tokens and public shell; repair footer, services, home commission positioning, and Portfolio density.
+3. Implement extensible shared SVG category icons and visual category administration.
+4. Connect a paged, focus-safe media library dialog to Page, Piece, Process, profile, and project editors; remove raw paths from normal workflows.
+5. Convert long Studio panels to searchable master-detail editors and in-place action forms.
+6. Render normalized process/build records and policy-gated pricing, inquiry, and reviews.
+7. Extend Media Desk batch operations, rename previews, roles/stages, and provider-safe derivatives.
+8. Replace pseudo-3D with dynamically loaded R3F generators and retain an honest scale-drawing fallback.
+9. Replace inline-edit conditionals with a typed registry and atomic audited patch application.
+10. Run static, disposable-database, browser, accessibility, performance, Docker, backup, candidate, rollback, and live deployment gates.
+
+## Validation Matrix
+
+| Gate | Baseline/data-slice status |
+| --- | --- |
+| TypeScript | Passed after data slice |
+| ESLint | Passed after data slice |
+| Focused Node tests | 11 passed; SQLite API/module warnings documented |
+| Production build | Passed after data slice |
+| Disposable migration twice | Passed; schema version remained 3 |
+| SQLite quick check | Passed before/after |
+| Destructive production mutation | Not performed |
+| Public browser matrix | Pending redesigned surfaces |
+| Admin browser matrix | Pending redesigned surfaces |
+| Accessibility matrix | Pending redesigned surfaces |
+| Performance comparison | Pending redesigned surfaces |
+| Docker candidate/deploy | Pending all implementation and safety gates |
From 959a2ca52d8195bf3b9781056796c6a23fec6be6 Mon Sep 17 00:00:00 2001
From: Cooper Beaman <55124795+lowestprime@users.noreply.github.com>
Date: Sat, 11 Jul 2026 06:17:29 -0700
Subject: [PATCH 03/43] feat(categories): add visual icon management
---
site/app/globals.css | 224 ++++++++++++++++++---
site/app/portfolio/page.tsx | 7 +-
site/app/studio/page.tsx | 25 +--
site/components/category-icon.tsx | 40 ++++
site/components/site-chrome.tsx | 16 +-
site/components/studio-category-editor.tsx | 130 ++++++++++++
site/lib/actions.ts | 112 ++++++++---
site/lib/catalog.ts | 2 +-
site/lib/categories.ts | 87 ++++++--
site/lib/category-icons.test.mts | 44 ++++
site/lib/category-icons.ts | 159 +++++++++++++++
site/package.json | 2 +-
12 files changed, 733 insertions(+), 115 deletions(-)
create mode 100644 site/components/category-icon.tsx
create mode 100644 site/components/studio-category-editor.tsx
create mode 100644 site/lib/category-icons.test.mts
create mode 100644 site/lib/category-icons.ts
diff --git a/site/app/globals.css b/site/app/globals.css
index ad11d35..a4d4004 100644
--- a/site/app/globals.css
+++ b/site/app/globals.css
@@ -1853,35 +1853,38 @@ img.cleanup-subject-isolate {
height: 0.42rem;
}
-.portfolio-filter-row {
- display: flex;
- flex-wrap: wrap;
- gap: 0.85rem;
- margin: 2rem 0;
-}
-
-.portfolio-filter-pill {
- display: inline-flex;
- align-items: center;
- gap: 0.8rem;
- min-height: 3rem;
- padding: 0.75rem 1rem;
- border: 1px solid var(--line);
- border-radius: var(--radius-pill);
- background: color-mix(in srgb, var(--bg-elevated) 84%, transparent);
- color: var(--muted);
-}
+.portfolio-filter-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.42rem;
+ margin: 1.15rem 0 1.4rem;
+}
+
+.portfolio-filter-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.52rem;
+ min-height: 2.75rem;
+ padding: 0.42rem 0.62rem;
+ border: 1px solid var(--line);
+ border-radius: 0.72rem;
+ background: color-mix(in srgb, var(--bg-elevated) 84%, transparent);
+ color: var(--muted);
+ font-size: 0.86rem;
+ line-height: 1;
+ text-decoration: none;
+}
.portfolio-filter-pill strong {
display: inline-grid;
place-items: center;
- min-width: 1.6rem;
- height: 1.6rem;
- padding-inline: 0.35rem;
+ min-width: 1.35rem;
+ height: 1.35rem;
+ padding-inline: 0.28rem;
border-radius: var(--radius-pill);
background: color-mix(in srgb, var(--accent) 12%, transparent);
color: var(--accent);
- font-size: 0.78rem;
+ font-size: 0.7rem;
line-height: 1;
}
@@ -1891,18 +1894,179 @@ img.cleanup-subject-isolate {
background: color-mix(in srgb, var(--accent) 10%, var(--bg-elevated));
}
-.portfolio-filter-mark {
+.portfolio-filter-mark {
display: inline-grid;
place-items: center;
- width: 1.9rem;
- height: 1.9rem;
- border-radius: 50%;
+ width: 1.55rem;
+ height: 1.55rem;
+ border-radius: 0.42rem;
background: color-mix(in srgb, var(--accent) 16%, transparent);
color: var(--accent);
- font-size: 0.82rem;
-}
-
-.shop-detail-list {
+}
+
+.category-icon-svg {
+ display: block;
+ width: 1.12rem;
+ height: 1.12rem;
+ flex: 0 0 auto;
+ overflow: visible;
+}
+
+.category-icon-custom > svg {
+ display: block;
+ width: 100%;
+ height: 100%;
+}
+
+@media (max-width: 640px) {
+ .portfolio-filter-row {
+ flex-wrap: nowrap;
+ margin-inline: calc(var(--page-gutter, 1rem) * -1);
+ padding: 0 var(--page-gutter, 1rem) 0.4rem;
+ overflow-x: auto;
+ overscroll-behavior-inline: contain;
+ scrollbar-width: thin;
+ scroll-snap-type: inline proximity;
+ }
+
+ .portfolio-filter-pill {
+ flex: 0 0 auto;
+ scroll-snap-align: start;
+ }
+}
+
+.category-editor-grid {
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 30rem), 1fr));
+ align-items: start;
+}
+
+.category-editor {
+ display: grid;
+ gap: 0.85rem;
+ padding: 1rem;
+}
+
+.category-editor-head {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.category-editor-head h3,
+.category-editor-head p {
+ margin: 0;
+}
+
+.category-editor-preview {
+ display: grid;
+ place-items: center;
+ width: 2.75rem;
+ height: 2.75rem;
+ border: 1px solid color-mix(in srgb, var(--accent) 34%, var(--line));
+ border-radius: 0.72rem;
+ background: color-mix(in srgb, var(--accent) 10%, var(--bg-elevated));
+ color: var(--accent);
+}
+
+.category-editor-preview .category-icon-svg {
+ width: 1.65rem;
+ height: 1.65rem;
+}
+
+.category-icon-fieldset {
+ min-width: 0;
+ margin: 0;
+ padding: 0;
+ border: 0;
+}
+
+.category-icon-fieldset legend {
+ margin-bottom: 0.45rem;
+ font-size: 0.8rem;
+ color: var(--muted);
+}
+
+.category-icon-gallery {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(5.25rem, 1fr));
+ gap: 0.38rem;
+}
+
+.category-icon-choice {
+ position: relative;
+ display: grid;
+ grid-template-columns: 1.2rem 1fr;
+ align-items: center;
+ gap: 0.4rem;
+ min-height: 2.75rem;
+ padding: 0.38rem 0.48rem;
+ border: 1px solid var(--line);
+ border-radius: 0.65rem;
+ background: var(--bg-elevated);
+ color: var(--muted);
+ cursor: pointer;
+}
+
+.category-icon-choice:has(input:focus-visible) {
+ outline: 2px solid var(--focus, var(--accent));
+ outline-offset: 2px;
+}
+
+.category-icon-choice.is-selected {
+ border-color: color-mix(in srgb, var(--accent) 52%, var(--line));
+ background: color-mix(in srgb, var(--accent) 10%, var(--bg-elevated));
+ color: var(--text);
+}
+
+.category-icon-choice input {
+ position: absolute;
+ opacity: 0;
+ pointer-events: none;
+}
+
+.category-icon-choice span {
+ font-size: 0.72rem;
+ line-height: 1.15;
+}
+
+.category-custom-icon,
+.category-delete-panel {
+ padding: 0.65rem 0.72rem;
+ border: 1px solid var(--line);
+ border-radius: 0.72rem;
+}
+
+.category-custom-icon > summary,
+.category-delete-panel > summary {
+ cursor: pointer;
+ font-size: 0.82rem;
+}
+
+.category-custom-icon-controls,
+.category-editor-actions {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.55rem;
+ margin-top: 0.65rem;
+}
+
+.category-icon-upload input {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ opacity: 0;
+}
+
+.form-status.success {
+ color: var(--success, #5f8b68);
+}
+
+.form-status.error {
+ color: var(--danger, #b66a5d);
+}
+
+.shop-detail-list {
display: grid;
gap: 0.55rem;
margin: 0;
diff --git a/site/app/portfolio/page.tsx b/site/app/portfolio/page.tsx
index 7da969b..4e1e626 100644
--- a/site/app/portfolio/page.tsx
+++ b/site/app/portfolio/page.tsx
@@ -1,7 +1,8 @@
import type { Metadata } from "next";
import Link from "next/link";
import { connection } from "next/server";
-import { CategoryIcon, PageIntro, PageSection, PieceCard, Shell } from "@/components/site-chrome";
+import { CategoryIcon } from "@/components/category-icon";
+import { PageIntro, PageSection, PieceCard, Shell } from "@/components/site-chrome";
import { inlineEditAttrs } from "@/components/inline-editable";
import { getPiecePortfolioCategory, getPortfolioCategories } from "@/lib/catalog";
import { getPage, getSiteSettings, listPieces } from "@/lib/db";
@@ -17,7 +18,7 @@ export default async function PortfolioPage({ searchParams }: { searchParams: Pr
const { category } = await searchParams;
const page = getPage("portfolio");
const categories = getPortfolioCategories(getSiteSettings().pieceCategories);
- const portfolioCategories = [{ key: "all", label: "All pieces", icon: "all" as const, aliases: [] }, ...categories];
+ const portfolioCategories = [{ key: "all", label: "All pieces", icon: "all" as const, iconName: "all" as const, iconType: "builtin" as const, customIconSvg: null, aliases: [], sortOrder: -1, visible: true }, ...categories];
const selectedCategory = portfolioCategories.some((item) => item.key === category) ? String(category) : "all";
const allPieces = listPieces();
const pieces = allPieces.filter((piece) => selectedCategory === "all" || getPiecePortfolioCategory(piece, categories) === selectedCategory);
@@ -52,7 +53,7 @@ export default async function PortfolioPage({ searchParams }: { searchParams: Pr
href={href}
key={item.key}
>
-
+
{item.label}
{counts.get(item.key) ?? 0}
diff --git a/site/app/studio/page.tsx b/site/app/studio/page.tsx
index d5de965..68ab648 100644
--- a/site/app/studio/page.tsx
+++ b/site/app/studio/page.tsx
@@ -62,6 +62,7 @@ import { getAiServiceStatus } from "@/lib/ai-services";
import { PageIntro, PageSection, Shell } from "@/components/site-chrome";
import { StudioScrollRestore } from "@/components/studio-form";
import { StudioMediaWorkspace } from "@/components/studio-media-workspace";
+import { StudioCategoryEditor } from "@/components/studio-category-editor";
import { normalizePieceCategories, type PieceCategoryDefinition } from "@/lib/categories";
const STUDIO_MEDIA_PAGE_SIZE = 48;
@@ -156,28 +157,6 @@ function PieceEditor({ piece, categories, highlight = false }: { piece: Omit
- Portfolio category
{category.label} {category.icon}
-
- {!isNew ? (
-
- ) : null}
-
- );
-}
-
function PostEditor({ post, highlight = false }: { post: Omit; highlight?: boolean }) {
return (
@@ -487,7 +466,7 @@ export default async function StudioPage({
{currentPanel === "pages" ? Pages
Public pages Titles, intros, body copy, and hero media. Saving here writes to {persistence.databasePath} and revalidates the live route.
: null}
{currentPanel === "pieces" ? Pieces
Portfolio and shop pieces Categories, availability, media assignments, shop asking price, and metadata.
{pieces.map((piece) =>
)}
: null}
- {currentPanel === "categories" ? Categories
Portfolio filters Manage the category labels, matching terms, and icon styles used by the public portfolio and piece editor.
{categories.map((category) =>
)}
: null}
+ {currentPanel === "categories" ? Categories
Portfolio filters Choose a furniture icon, import a safe custom SVG, and control order and visibility without editing code.
{categories.map((category) => )}
: null}
{currentPanel === "custom" ? Custom work
Contact workflow types Material menus, estimator defaults, and active custom request categories.
{commissionTypes.map((item) => )}
: null}
{currentPanel === "people" ? People
Accounts and public profiles Rename profiles, replace contact emails, and remove accounts directly from the dashboard.
{users.map((user) => )}
: null}
{currentPanel === "process" ? Process
Process notes and references Markdown body, cover images, external links, and publication state.
: null}
diff --git a/site/components/category-icon.tsx b/site/components/category-icon.tsx
new file mode 100644
index 0000000..4ebb6c7
--- /dev/null
+++ b/site/components/category-icon.tsx
@@ -0,0 +1,40 @@
+import type { SVGProps } from "react";
+import { normalizeBuiltinCategoryIcon, type BuiltinCategoryIconName } from "@/lib/category-icons";
+import type { PieceCategoryDefinition } from "@/lib/categories";
+
+type IconProps = SVGProps & {
+ name?: string | null;
+ category?: Pick | null;
+ label?: string;
+};
+
+function BuiltinGeometry({ name }: { name: BuiltinCategoryIconName }) {
+ if (name === "all") return <> >;
+ if (name === "table") return <> >;
+ if (name === "desk") return <> >;
+ if (name === "side-table") return <> >;
+ if (name === "bench") return <> >;
+ if (name === "chair") return <> >;
+ if (name === "stool") return <> >;
+ if (name === "cabinet") return <> >;
+ if (name === "shelf") return <> >;
+ if (name === "door") return <> >;
+ if (name === "bed") return <> >;
+ if (name === "frame") return <> >;
+ if (name === "board") return <> >;
+ if (name === "easel") return <> >;
+ if (name === "clock") return <> >;
+ return <> >;
+}
+
+export function CategoryIcon({ name, category, label, className = "", ...props }: IconProps) {
+ if (category?.iconType === "custom" && category.customIconSvg) {
+ return ;
+ }
+ const iconName = normalizeBuiltinCategoryIcon(category?.iconName ?? name);
+ return (
+
+
+
+ );
+}
diff --git a/site/components/site-chrome.tsx b/site/components/site-chrome.tsx
index 6bd6d5b..02fdce9 100644
--- a/site/components/site-chrome.tsx
+++ b/site/components/site-chrome.tsx
@@ -4,10 +4,11 @@ import { cookies } from "next/headers";
import { ThemeToggle } from "@/components/theme-toggle";
import { HeaderSearch } from "@/components/header-search";
import { HeaderShell } from "@/components/header-shell";
+import { CategoryIcon as SharedCategoryIcon } from "@/components/category-icon";
import { EditableText, inlineEditAttrs, type InlineEditTarget } from "@/components/inline-editable";
import { avatarGradientStyle } from "@/lib/avatar";
import { getDisplayMediaPaths, hasVerifiedMedia } from "@/lib/catalog";
-import { pieceCategoryIcon, type PieceCategoryDefinition, type PieceCategoryIcon } from "@/lib/categories";
+import { findPieceCategory, pieceCategoryIcon, type PieceCategoryDefinition } from "@/lib/categories";
import { formatDate, formatLeadTime, resolveAssetUrl, toMediaUrl } from "@/lib/format";
import { getCurrentUser } from "@/lib/auth";
import { getBandwidthSnapshot, getMedia, getSiteSettings, listCartItems, listPages, type PieceRecord, type PostRecord, type ProjectRecord } from "@/lib/db";
@@ -41,15 +42,7 @@ function EditGlyph() {
return ;
}
-export function CategoryIcon({ category, icon }: { category: string; icon?: PieceCategoryIcon | "all" }) {
- const key = icon ?? pieceCategoryIcon(category);
- if (key === "all") return ;
- if (key === "tables") return ;
- if (key === "benches") return ;
- if (key === "stepstools") return ;
- if (key === "cabinets") return ;
- return ;
-}
+export { CategoryIcon } from "@/components/category-icon";
const RESERVED_NAV_SLUGS = new Set(["home", "portfolio", "shop", "journal", "process", "commissions", "requests", "studio", "about", "account", "search", "media", "contact"]);
@@ -114,7 +107,8 @@ export function PieceCard({ piece, categories }: { piece: PieceRecord; categorie
const firstImage = getDisplayMediaPaths(piece)[0];
const verified = hasVerifiedMedia(piece);
const media = firstImage ? getMedia(firstImage) : null;
- return {firstImage ? : Media under review
} {piece.category}{verified ? "Verified photography" : "Photography in progress"}
{piece.title} {piece.summary}
{piece.availabilityLabel} Updated {formatDate(piece.updatedAt)}
;
+ const category = findPieceCategory(piece.category, categories);
+ return {firstImage ? : Media under review
} {category?.label ?? piece.category}{verified ? "Verified photography" : "Photography in progress"}
{piece.title} {piece.summary}
{piece.availabilityLabel} Updated {formatDate(piece.updatedAt)}
;
}
export function PostCard({ post }: { post: PostRecord }) {
return {post.publishedAt ? formatDate(post.publishedAt) : "Draft"} {post.sourceUrl ? "Reference" : "Behind the scenes"}
{post.title}{post.excerpt}
;
diff --git a/site/components/studio-category-editor.tsx b/site/components/studio-category-editor.tsx
new file mode 100644
index 0000000..dc68d52
--- /dev/null
+++ b/site/components/studio-category-editor.tsx
@@ -0,0 +1,130 @@
+"use client";
+
+import { useActionState, useState, type ChangeEvent } from "react";
+import { CategoryIcon } from "@/components/category-icon";
+import { BUILTIN_CATEGORY_ICONS, sanitizeCategoryIconSvg, type BuiltinCategoryIconName } from "@/lib/category-icons";
+import type { PieceCategoryDefinition } from "@/lib/categories";
+
+export type CategoryActionState = {
+ status: "idle" | "success" | "error";
+ message: string;
+ categoryKey?: string;
+};
+
+type CategoryAction = (previousState: CategoryActionState, formData: FormData) => Promise;
+
+const initialState: CategoryActionState = { status: "idle", message: "" };
+
+export function StudioCategoryEditor({
+ category,
+ categories,
+ deleteAction,
+ isNew = false,
+ saveAction
+}: {
+ category: PieceCategoryDefinition;
+ categories: PieceCategoryDefinition[];
+ deleteAction: CategoryAction;
+ isNew?: boolean;
+ saveAction: CategoryAction;
+}) {
+ const [saveState, saveFormAction, saving] = useActionState(saveAction, initialState);
+ const [deleteState, deleteFormAction, deleting] = useActionState(deleteAction, initialState);
+ const [iconType, setIconType] = useState(category.iconType);
+ const [iconName, setIconName] = useState(category.iconName);
+ const [customIconSvg, setCustomIconSvg] = useState(category.customIconSvg ?? "");
+
+ let safeCustomIcon: string | null = null;
+ let customIconError = "";
+ if (iconType === "custom" && customIconSvg.trim()) {
+ try {
+ safeCustomIcon = sanitizeCategoryIconSvg(customIconSvg);
+ } catch (error) {
+ customIconError = error instanceof Error ? error.message : "The custom icon could not be read.";
+ }
+ }
+
+ const previewCategory = {
+ ...category,
+ icon: iconName,
+ iconName,
+ iconType: iconType === "custom" && safeCustomIcon ? "custom" as const : "builtin" as const,
+ customIconSvg: safeCustomIcon
+ };
+
+ async function importSvg(event: ChangeEvent) {
+ const file = event.currentTarget.files?.[0];
+ if (!file) return;
+ if (file.size > 12_000) {
+ setCustomIconSvg("");
+ return;
+ }
+ setCustomIconSvg(await file.text());
+ setIconType("custom");
+ }
+
+ return (
+
+
+
+
+
+ {!isNew ? (
+
+ Delete or consolidate
+
+
+ Move assigned pieces first Delete only when unused {categories.filter((entry) => entry.key !== category.key).map((entry) => {entry.label} )}
+ {deleting ? "Deleting…" : "Delete category"}
+ {deleteState.message ? {deleteState.message}
: null}
+
+
+ ) : null}
+
+ );
+}
diff --git a/site/lib/actions.ts b/site/lib/actions.ts
index 8408cd2..a710c08 100644
--- a/site/lib/actions.ts
+++ b/site/lib/actions.ts
@@ -44,8 +44,10 @@ import {
setPasswordHash,
setPasswordResetToken,
updateProject,
+ withDatabaseTransaction,
deleteMediaRecordAndReferences,
finishMediaRenameHistory,
+ recordAdminEditAudit,
renameMediaRecordAndReferences,
startMediaRenameHistory,
type CommissionTypeRecord,
@@ -75,7 +77,8 @@ import { calculateCheckoutTotals, createEasyPostShippingLabel, createStripeCheck
import { sendNotificationEmail, summarizeEmailFailure } from "@/lib/notifications";
import { createCleanedBackgroundVariant, getAiServiceStatus } from "@/lib/ai-services";
import { buildMediaVerificationQueue, type MediaMatchCandidate } from "@/lib/media-audit";
-import { categoryKey, normalizePieceCategories, type PieceCategoryIcon } from "@/lib/categories";
+import { categoryKey, normalizePieceCategories } from "@/lib/categories";
+import { normalizeBuiltinCategoryIcon, sanitizeCategoryIconSvg } from "@/lib/category-icons";
import { getPieceInquiryMode, getPiecePriceMode, getPieceReviewsMode, pieceCanEnterCart } from "@/lib/piece-model";
function revalidatePagePaths(slug: string) {
revalidatePath("/", "layout");
@@ -846,58 +849,96 @@ export async function saveSiteSettingsAction(formData: FormData) {
redirect("/studio?panel=settings&saved=settings");
}
-export async function savePieceCategoryAction(formData: FormData) {
- await requireAdmin();
+export type CategoryActionState = { status: "idle" | "success" | "error"; message: string; categoryKey?: string };
+
+export async function savePieceCategoryAction(_: CategoryActionState, formData: FormData): Promise {
+ const currentAdmin = await requireAdmin();
const settings = getSiteSettings();
const originalKey = categoryKey(optionalField(formData.get("originalKey")));
const label = requiredField(formData.get("label"), "Category label");
const key = categoryKey(optionalField(formData.get("key")) || label);
- const allowedIcons = new Set(["tables", "benches", "stepstools", "cabinets", "objects"]);
- const requestedIcon = optionalField(formData.get("icon")) as PieceCategoryIcon;
- const icon = allowedIcons.has(requestedIcon) ? requestedIcon : "objects";
+ const iconName = normalizeBuiltinCategoryIcon(formData.get("iconName") ?? formData.get("icon"));
+ const requestedIconType = optionalField(formData.get("iconType"));
const aliases = parseListField(formData.get("aliasesText"));
if (!key || key === "all") {
- redirect("/studio?panel=categories&error=category-key");
+ return { status: "error", message: "Use a category key other than 'all'." };
}
const categories = normalizePieceCategories(settings.pieceCategories);
+ if (originalKey && originalKey !== key && categories.some((category) => category.key === key)) {
+ return { status: "error", message: "Another category already uses that key." };
+ }
const existingIndex = categories.findIndex((category) => category.key === originalKey || category.key === key);
const previous = existingIndex >= 0 ? categories[existingIndex] : null;
- const nextCategory = { key, label, icon, aliases };
+ let customIconSvg: string | null = null;
+ if (requestedIconType === "custom") {
+ try {
+ customIconSvg = sanitizeCategoryIconSvg(requiredField(formData.get("customIconSvg"), "Custom category SVG"));
+ } catch (error) {
+ return { status: "error", message: error instanceof Error ? error.message : "The custom category icon is invalid." };
+ }
+ }
+ const sortOrderInput = Number(formData.get("sortOrder"));
+ const sortOrder = Number.isFinite(sortOrderInput)
+ ? Math.max(0, Math.min(9999, Math.round(sortOrderInput)))
+ : previous?.sortOrder ?? categories.length * 10;
+ const visible = formData.has("visibilityControlled") ? formData.has("visible") : previous?.visible ?? true;
+ const nextCategory = {
+ key,
+ label,
+ icon: iconName,
+ iconName,
+ iconType: customIconSvg ? "custom" as const : "builtin" as const,
+ customIconSvg,
+ aliases,
+ sortOrder,
+ visible
+ };
const nextCategories = existingIndex >= 0
? categories.map((category, index) => index === existingIndex ? nextCategory : category)
: [...categories, nextCategory];
- saveSiteSettings({ ...settings, pieceCategories: nextCategories });
-
- if (previous && (previous.key !== key || previous.label !== label)) {
- for (const piece of listPieces(true)) {
- const currentCategory = piece.category.trim().toLowerCase();
- if (currentCategory === previous.key.toLowerCase() || currentCategory === previous.label.toLowerCase()) {
- savePiece({ ...piece, category: label });
- revalidatePieceSurfaces(piece.slug);
+ const affectedPieceSlugs: string[] = [];
+ withDatabaseTransaction(() => {
+ saveSiteSettings({ ...settings, pieceCategories: nextCategories });
+ if (previous && (previous.key !== key || previous.label !== label)) {
+ for (const piece of listPieces(true)) {
+ const currentCategory = piece.category.trim().toLowerCase();
+ if (currentCategory === previous.key.toLowerCase() || currentCategory === previous.label.toLowerCase()) {
+ savePiece({ ...piece, category: label });
+ affectedPieceSlugs.push(piece.slug);
+ }
}
}
- }
+ recordAdminEditAudit({
+ actorEmail: currentAdmin.email,
+ entityType: "piece-category",
+ entityKey: key,
+ operation: previous ? "update" : "create",
+ before: previous,
+ after: nextCategory
+ });
+ });
+ affectedPieceSlugs.forEach(revalidatePieceSurfaces);
revalidatePath("/portfolio");
revalidatePath("/studio");
- redirect(`/studio?panel=categories&saved=category&category=${encodeURIComponent(key)}`);
+ return { status: "success", message: previous ? "Category saved." : "Category added.", categoryKey: key };
}
-export async function deletePieceCategoryAction(formData: FormData) {
- await requireAdmin();
+export async function deletePieceCategoryAction(_: CategoryActionState, formData: FormData): Promise {
+ const currentAdmin = await requireAdmin();
const settings = getSiteSettings();
const key = categoryKey(requiredField(formData.get("key"), "Category key"));
const categories = normalizePieceCategories(settings.pieceCategories);
if (categories.length <= 1) {
- redirect("/studio?panel=categories&error=category-last");
+ return { status: "error", message: "At least one portfolio category must remain available." };
}
const category = categories.find((entry) => entry.key === key);
if (!category) {
- redirect("/studio?panel=categories&error=category-missing");
+ return { status: "error", message: "The requested portfolio category could not be found." };
}
const replacementKey = categoryKey(optionalField(formData.get("replacementKey")));
@@ -908,20 +949,29 @@ export async function deletePieceCategoryAction(formData: FormData) {
});
if (affectedPieces.length > 0 && !replacement) {
- redirect(`/studio?panel=categories&error=category-in-use&category=${encodeURIComponent(key)}`);
+ return { status: "error", message: "This category still has pieces. Choose a replacement before deleting it." };
}
- if (replacement) {
- for (const piece of affectedPieces) {
- savePiece({ ...piece, category: replacement.label });
- revalidatePieceSurfaces(piece.slug);
+ withDatabaseTransaction(() => {
+ if (replacement) {
+ for (const piece of affectedPieces) {
+ savePiece({ ...piece, category: replacement.label });
+ }
}
- }
-
- saveSiteSettings({ ...settings, pieceCategories: categories.filter((entry) => entry.key !== key) });
+ saveSiteSettings({ ...settings, pieceCategories: categories.filter((entry) => entry.key !== key) });
+ recordAdminEditAudit({
+ actorEmail: currentAdmin.email,
+ entityType: "piece-category",
+ entityKey: key,
+ operation: "delete",
+ before: category,
+ after: replacement ? { replacementKey: replacement.key } : null
+ });
+ });
+ affectedPieces.forEach((piece) => revalidatePieceSurfaces(piece.slug));
revalidatePath("/portfolio");
revalidatePath("/studio");
- redirect("/studio?panel=categories&deleted=category");
+ return { status: "success", message: "Category deleted.", categoryKey: key };
}
export async function savePageAction(formData: FormData) {
diff --git a/site/lib/catalog.ts b/site/lib/catalog.ts
index c8003df..122929f 100644
--- a/site/lib/catalog.ts
+++ b/site/lib/catalog.ts
@@ -12,7 +12,7 @@ import {
import { defaultPieceCategories, normalizePieceCategories, pieceCategoryKey, type PieceCategoryDefinition } from "@/lib/categories";
export function getPortfolioCategories(value?: unknown) {
- return normalizePieceCategories(value ?? defaultPieceCategories);
+ return normalizePieceCategories(value ?? defaultPieceCategories).filter((category) => category.visible);
}
export function getPiecePortfolioCategory(
diff --git a/site/lib/categories.ts b/site/lib/categories.ts
index 348fbd8..7cc5b93 100644
--- a/site/lib/categories.ts
+++ b/site/lib/categories.ts
@@ -1,21 +1,48 @@
-export type PieceCategoryIcon = "tables" | "benches" | "stepstools" | "cabinets" | "objects";
+import {
+ normalizeBuiltinCategoryIcon,
+ sanitizeCategoryIconSvg,
+ type BuiltinCategoryIconName
+} from "./category-icons.ts";
+
+export type PieceCategoryIcon = BuiltinCategoryIconName;
+export type PieceCategoryIconType = "builtin" | "custom";
export type PieceCategoryDefinition = {
key: string;
label: string;
icon: PieceCategoryIcon;
+ iconType: PieceCategoryIconType;
+ iconName: PieceCategoryIcon;
+ customIconSvg: string | null;
aliases: string[];
+ sortOrder: number;
+ visible: boolean;
};
-export const defaultPieceCategories: PieceCategoryDefinition[] = [
- { key: "tables", label: "Tables", icon: "tables", aliases: ["table", "desk", "pastry table", "end table"] },
- { key: "benches", label: "Benches", icon: "benches", aliases: ["bench", "hallway bench"] },
- { key: "stepstools", label: "Stepstools", icon: "stepstools", aliases: ["stepstool", "step stool", "stool", "footstool"] },
- { key: "cabinets", label: "Cabinets", icon: "cabinets", aliases: ["cabinet", "pantry", "spice rack", "rack"] },
- { key: "objects", label: "Objects", icon: "objects", aliases: ["object", "tray", "small object"] }
+const defaultCategoryInput: Array> = [
+ { key: "tables", label: "Tables", iconName: "table", aliases: ["table", "dining", "pastry table"] },
+ { key: "desks", label: "Desks", iconName: "desk", aliases: ["desk", "writing desk", "scientist desk", "scientists desk"] },
+ { key: "benches", label: "Benches", iconName: "bench", aliases: ["bench", "hallway bench", "outdoor bench"] },
+ { key: "chairs", label: "Chairs", iconName: "chair", aliases: ["chair", "seat"] },
+ { key: "stools", label: "Stools", iconName: "stool", aliases: ["stepstool", "step stool", "stool", "footstool"] },
+ { key: "cabinets", label: "Cabinets", iconName: "cabinet", aliases: ["cabinet", "pantry", "cupboard"] },
+ { key: "shelves", label: "Shelves & racks", iconName: "shelf", aliases: ["shelf", "shelves", "rack", "spice rack"] },
+ { key: "doors", label: "Doors", iconName: "door", aliases: ["door", "entry door", "deck door"] },
+ { key: "beds", label: "Beds & platforms", iconName: "bed", aliases: ["bed", "platform", "bed platform"] },
+ { key: "frames", label: "Frames & mirrors", iconName: "frame", aliases: ["frame", "mirror", "portrait frame"] },
+ { key: "boards", label: "Boards & trays", iconName: "board", aliases: ["cutting board", "board", "tray"] },
+ { key: "easels", label: "Easels", iconName: "easel", aliases: ["easel", "artist easel"] },
+ { key: "objects", label: "Objects", iconName: "object", aliases: ["object", "clock", "small object", "other"] }
];
-const categoryIcons = new Set(["tables", "benches", "stepstools", "cabinets", "objects"]);
+export const defaultPieceCategories: PieceCategoryDefinition[] = defaultCategoryInput.map((category, index) => ({
+ ...category,
+ icon: category.iconName,
+ iconType: "builtin",
+ customIconSvg: null,
+ sortOrder: index * 10,
+ visible: true
+}));
export function categoryKey(value: string) {
return value
@@ -31,23 +58,53 @@ function cleanAliases(value: unknown) {
return [...new Set(value.map((entry) => String(entry).trim()).filter(Boolean))];
}
+function normalizeCustomIcon(value: unknown) {
+ if (typeof value !== "string" || !value.trim()) return null;
+ try {
+ return sanitizeCategoryIconSvg(value);
+ } catch {
+ return null;
+ }
+}
+
+function cloneDefaults() {
+ return defaultPieceCategories.map((category) => ({ ...category, aliases: [...category.aliases] }));
+}
+
export function normalizePieceCategories(value: unknown): PieceCategoryDefinition[] {
- if (!Array.isArray(value)) return defaultPieceCategories.map((category) => ({ ...category, aliases: [...category.aliases] }));
+ if (!Array.isArray(value)) return cloneDefaults();
const seen = new Set();
- const normalized = value.flatMap((entry) => {
+ const normalized = value.flatMap((entry, index) => {
if (!entry || typeof entry !== "object") return [];
const record = entry as Record;
const key = categoryKey(String(record.key ?? record.label ?? ""));
const label = String(record.label ?? "").trim();
if (!key || key === "all" || !label || seen.has(key)) return [];
seen.add(key);
- const iconCandidate = String(record.icon ?? key) as PieceCategoryIcon;
- const icon = categoryIcons.has(iconCandidate) ? iconCandidate : "objects";
- return [{ key, label, icon, aliases: cleanAliases(record.aliases) }];
+
+ const customIconSvg = normalizeCustomIcon(record.customIconSvg);
+ const iconType: PieceCategoryIconType = record.iconType === "custom" && customIconSvg ? "custom" : "builtin";
+ const iconName = normalizeBuiltinCategoryIcon(record.iconName ?? record.icon ?? key);
+ const rawOrder = Number(record.sortOrder);
+ const sortOrder = Number.isFinite(rawOrder) ? Math.max(0, Math.min(9999, Math.round(rawOrder))) : index * 10;
+
+ return [{
+ key,
+ label,
+ icon: iconName,
+ iconType,
+ iconName,
+ customIconSvg: iconType === "custom" ? customIconSvg : null,
+ aliases: cleanAliases(record.aliases),
+ sortOrder,
+ visible: record.visible !== false
+ } satisfies PieceCategoryDefinition];
});
- return normalized.length > 0 ? normalized : defaultPieceCategories.map((category) => ({ ...category, aliases: [...category.aliases] }));
+ return normalized.length > 0
+ ? normalized.sort((left, right) => left.sortOrder - right.sortOrder || left.label.localeCompare(right.label))
+ : cloneDefaults();
}
function normalizedText(value: string | null | undefined) {
@@ -72,5 +129,5 @@ export function pieceCategoryKey(value: string | null | undefined, categories: P
}
export function pieceCategoryIcon(value: string | null | undefined, categories: PieceCategoryDefinition[] = defaultPieceCategories): PieceCategoryIcon {
- return findPieceCategory(value, categories)?.icon ?? "objects";
+ return findPieceCategory(value, categories)?.iconName ?? "object";
}
diff --git a/site/lib/category-icons.test.mts b/site/lib/category-icons.test.mts
new file mode 100644
index 0000000..285f313
--- /dev/null
+++ b/site/lib/category-icons.test.mts
@@ -0,0 +1,44 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { normalizeBuiltinCategoryIcon, sanitizeCategoryIconSvg } from "./category-icons.ts";
+import { normalizePieceCategories } from "./categories.ts";
+
+test("legacy and expanded built-in category icon names normalize safely", () => {
+ assert.equal(normalizeBuiltinCategoryIcon("tables"), "table");
+ assert.equal(normalizeBuiltinCategoryIcon("stepstools"), "stool");
+ assert.equal(normalizeBuiltinCategoryIcon("desk"), "desk");
+ assert.equal(normalizeBuiltinCategoryIcon("not-an-icon"), "object");
+});
+
+test("safe custom category SVG is reduced to the supported shape vocabulary", () => {
+ const output = sanitizeCategoryIconSvg(`
+
+
+
+
+
+
+ `);
+ assert.match(String(output), /^ {
+ assert.throws(() => sanitizeCategoryIconSvg(` `), /unsupported element/);
+ assert.throws(() => sanitizeCategoryIconSvg(` `), /unsafe attribute/);
+ assert.throws(() => sanitizeCategoryIconSvg(` `), /structurally balanced/);
+ assert.throws(() => sanitizeCategoryIconSvg(` `), /attribute 'class'/);
+});
+
+test("category definitions preserve order and visibility while sanitizing custom icons", () => {
+ const categories = normalizePieceCategories([
+ { key: "objects", label: "Objects", icon: "objects", aliases: [], sortOrder: 20, visible: false },
+ { key: "desks", label: "Desks", iconName: "desk", iconType: "custom", customIconSvg: ` `, aliases: ["writing desk"], sortOrder: 10 }
+ ]);
+ assert.equal(categories[0].key, "desks");
+ assert.equal(categories[0].iconType, "custom");
+ assert.match(String(categories[0].customIconSvg), / = {
+ tables: "table",
+ benches: "bench",
+ stepstools: "stool",
+ cabinets: "cabinet",
+ objects: "object"
+};
+
+export function normalizeBuiltinCategoryIcon(value: unknown, fallback: BuiltinCategoryIconName = "object"): BuiltinCategoryIconName {
+ const key = String(value ?? "").trim().toLowerCase();
+ if (key in LEGACY_ICON_MAP) return LEGACY_ICON_MAP[key];
+ return (BUILTIN_CATEGORY_ICON_NAMES as readonly string[]).includes(key) ? key as BuiltinCategoryIconName : fallback;
+}
+
+const ALLOWED_TAGS = new Set(["g", "path", "rect", "circle", "line", "polyline", "polygon", "ellipse"]);
+const ALLOWED_ATTRIBUTES = new Set([
+ "d", "x", "y", "x1", "y1", "x2", "y2", "width", "height", "rx", "ry",
+ "cx", "cy", "r", "points", "fill", "stroke", "stroke-width", "stroke-linecap",
+ "stroke-linejoin", "stroke-miterlimit", "fill-rule", "clip-rule", "opacity", "transform"
+]);
+
+function safeAttribute(name: string, value: string) {
+ if (!ALLOWED_ATTRIBUTES.has(name)) return null;
+ if (/url\s*\(|javascript:|data:|https?:|#(?![0-9a-f]{3,8}\b)/i.test(value)) return null;
+ if (name === "fill" || name === "stroke") {
+ return /^(none|currentColor|#[0-9a-f]{3,8})$/i.test(value) ? value : null;
+ }
+ if (name === "fill-rule" || name === "clip-rule") return /^(nonzero|evenodd)$/i.test(value) ? value : null;
+ if (name === "stroke-linecap") return /^(butt|round|square)$/i.test(value) ? value : null;
+ if (name === "stroke-linejoin") return /^(arcs|bevel|miter|miter-clip|round)$/i.test(value) ? value : null;
+ if (name === "d") return /^[MmLlHhVvCcSsQqTtAaZz0-9eE+.,\s-]+$/.test(value) ? value : null;
+ if (name === "points") return /^[0-9eE+.,\s-]+$/.test(value) ? value : null;
+ if (name === "transform") return /^(?:(?:translate|scale|rotate|matrix)\([0-9eE+.,\s-]+\)\s*)+$/.test(value) ? value : null;
+ return /^[0-9eE+.,\s-]+$/.test(value) ? value : null;
+}
+
+export function sanitizeCategoryIconSvg(input: string) {
+ const source = input.trim();
+ if (!source) return null;
+ if (source.length > 12_000) throw new Error("Custom category SVG exceeds the 12 KB limit.");
+ if (/<\/?(?:script|style|foreignObject|iframe|object|embed|image|use|a)\b/i.test(source)) {
+ throw new Error("Custom category SVG contains an unsupported element.");
+ }
+ if (/\bon[a-z]+\s*=|\b(?:href|xlink:href|style)\s*=|]*\bviewBox\s*=\s*["']\s*([0-9.+-]+)\s+([0-9.+-]+)\s+([0-9.+-]+)\s+([0-9.+-]+)\s*["'][^>]*>/i);
+ const viewBox = viewBoxMatch
+ ? viewBoxMatch.slice(1, 5).map(Number)
+ : [0, 0, 48, 48];
+ if (viewBox.some((value) => !Number.isFinite(value)) || viewBox[2] <= 0 || viewBox[3] <= 0 || viewBox[2] > 4096 || viewBox[3] > 4096) {
+ throw new Error("Custom category SVG has an invalid viewBox.");
+ }
+
+ if (!/^$/i.test(source)) {
+ throw new Error("Custom category SVG must contain one complete svg element.");
+ }
+
+ const elements: string[] = [];
+ const tagPattern = /<\/?([a-z][a-z0-9-]*)\b([^>]*)>/gi;
+ let match: RegExpExecArray | null;
+ let cursor = 0;
+ let rootOpen = false;
+ let rootClosed = false;
+ let groupDepth = 0;
+ while ((match = tagPattern.exec(source))) {
+ if (source.slice(cursor, match.index).trim()) {
+ throw new Error("Custom category SVG contains unsupported text or markup.");
+ }
+ cursor = tagPattern.lastIndex;
+ const tag = match[1].toLowerCase();
+ const closing = match[0].startsWith("");
+ if (tag === "svg") {
+ if (!closing) {
+ if (rootOpen || rootClosed || elements.length > 0) throw new Error("Custom category SVG contains a nested svg element.");
+ rootOpen = true;
+ } else {
+ if (!rootOpen || rootClosed || groupDepth !== 0) throw new Error("Custom category SVG is not structurally balanced.");
+ rootClosed = true;
+ }
+ continue;
+ }
+ if (!rootOpen || rootClosed) throw new Error("Custom category SVG contains content outside its root element.");
+ if (!ALLOWED_TAGS.has(tag)) throw new Error(`Custom category SVG element '${tag}' is not allowed.`);
+ if (closing) {
+ if (tag !== "g" || groupDepth === 0) throw new Error(`Custom category SVG has an invalid closing '${tag}' element.`);
+ groupDepth -= 1;
+ elements.push("");
+ continue;
+ }
+
+ const attributes: string[] = [];
+ const attributePattern = /([a-z][a-z0-9:-]*)\s*=\s*["']([^"']*)["']/gi;
+ let attribute: RegExpExecArray | null;
+ let unparsedAttributes = match[2].replace(/\/\s*$/, "");
+ while ((attribute = attributePattern.exec(match[2]))) {
+ const name = attribute[1].toLowerCase();
+ const value = safeAttribute(name, attribute[2].trim());
+ if (value == null) throw new Error(`Custom category SVG attribute '${name}' is not allowed.`);
+ attributes.push(`${name}="${value}"`);
+ unparsedAttributes = unparsedAttributes.replace(attribute[0], "");
+ }
+ if (unparsedAttributes.trim()) throw new Error("Custom category SVG contains an unsupported or malformed attribute.");
+ const selfClosing = tag !== "g" || /\/\s*>$/.test(match[0]);
+ if (tag === "g" && !selfClosing) groupDepth += 1;
+ elements.push(`<${tag}${attributes.length ? ` ${attributes.join(" ")}` : ""}${selfClosing ? " />" : ">"}`);
+ }
+
+ if (source.slice(cursor).trim() || !rootClosed || groupDepth !== 0) throw new Error("Custom category SVG is not structurally complete.");
+ if (elements.length === 0 || elements.length > 96) throw new Error("Custom category SVG does not contain a supported shape set.");
+ return `${elements.join("")} `;
+}
diff --git a/site/package.json b/site/package.json
index 29dd8d8..da2db08 100644
--- a/site/package.json
+++ b/site/package.json
@@ -8,7 +8,7 @@
"start": "node --experimental-sqlite .next/standalone/server.js",
"lint": "eslint app components lib",
"typecheck": "tsc --noEmit",
- "test": "node --experimental-strip-types --test lib/media-scoring.test.mts lib/piece-model.test.mts lib/database-migrations.test.mts lib/media-reference-transaction.test.mts"
+ "test": "node --experimental-strip-types --test lib/media-scoring.test.mts lib/piece-model.test.mts lib/database-migrations.test.mts lib/media-reference-transaction.test.mts lib/category-icons.test.mts"
},
"dependencies": {
"@react-three/drei": "^10.7.7",
From 8b2a39b3cbd56e13d98a1eb311d52051cad79fcd Mon Sep 17 00:00:00 2001
From: Cooper Beaman <55124795+lowestprime@users.noreply.github.com>
Date: Sat, 11 Jul 2026 10:59:02 -0700
Subject: [PATCH 04/43] feat(studio): add visual content and media workflows
---
site/app/contact/page.tsx | 39 +-
site/app/page.tsx | 34 +-
site/app/portfolio/[slug]/page.tsx | 61 +-
site/app/shop/page.tsx | 17 +-
site/app/studio/page.tsx | 113 +++-
site/app/ui-repair.css | 694 ++++++++++++++++++++++
site/components/inline-editable.tsx | 4 +-
site/components/media-picker.tsx | 278 +++++----
site/components/piece-media-editor.tsx | 95 +++
site/components/site-chrome.tsx | 28 +-
site/components/site-structure-editor.tsx | 118 ++++
site/lib/actions.ts | 101 +++-
site/lib/db.ts | 3 +
site/lib/piece-media.test.mts | 19 +
site/lib/piece-media.ts | 71 +++
site/lib/seed.ts | 95 ++-
site/lib/site-structure.test.mts | 39 ++
site/lib/site-structure.ts | 129 ++++
site/lib/visual-audit.ts | 29 +
site/package.json | 2 +-
20 files changed, 1740 insertions(+), 229 deletions(-)
create mode 100644 site/components/piece-media-editor.tsx
create mode 100644 site/components/site-structure-editor.tsx
create mode 100644 site/lib/piece-media.test.mts
create mode 100644 site/lib/piece-media.ts
create mode 100644 site/lib/site-structure.test.mts
create mode 100644 site/lib/site-structure.ts
create mode 100644 site/lib/visual-audit.ts
diff --git a/site/app/contact/page.tsx b/site/app/contact/page.tsx
index 2a1ebcd..56ca2e4 100644
--- a/site/app/contact/page.tsx
+++ b/site/app/contact/page.tsx
@@ -1,8 +1,10 @@
-import type { Metadata } from "next";
-import Link from "next/link";
-import { ContactRequestForm } from "@/components/forms";
-import { PageIntro, PageSection, Shell } from "@/components/site-chrome";
-import { getBandwidthSnapshot, getSiteSettings, listCommissionTypes } from "@/lib/db";
+import type { Metadata } from "next";
+import Link from "next/link";
+import { connection } from "next/server";
+import { ContactRequestForm } from "@/components/forms";
+import { PageIntro, PageSection, Shell } from "@/components/site-chrome";
+import { pieceAllowsInquiry } from "@/lib/catalog";
+import { getBandwidthSnapshot, getPiece, getSiteSettings, listCommissionTypes } from "@/lib/db";
export const metadata: Metadata = {
title: "Contact Beaman Woodworks",
@@ -10,9 +12,13 @@ export const metadata: Metadata = {
openGraph: { title: "Contact | Beaman Woodworks", description: "Contact the Beaman Woodworks woodshop." }
};
-export default function ContactPage() {
- const site = getSiteSettings();
- const bandwidth = getBandwidthSnapshot();
+export default async function ContactPage({ searchParams }: { searchParams: Promise<{ piece?: string; error?: string }> }) {
+ await connection();
+ const { piece: pieceSlug = "", error = "" } = await searchParams;
+ const site = getSiteSettings();
+ const bandwidth = getBandwidthSnapshot();
+ const requestedPiece = pieceSlug ? getPiece(pieceSlug) : null;
+ const selectedPiece = requestedPiece && pieceAllowsInquiry(requestedPiece) ? requestedPiece : undefined;
return (
@@ -22,7 +28,7 @@ export default function ContactPage() {
title="Ask about a custom piece"
copy="Use the intake form below to send a message directly to the woodshop. Every inquiry is read personally — replies come from a real human, usually within a business day."
/>
-
+
Prefer email? Reach us at {site.builderEmail} . You can also
{" "}
open the full custom-work form
@@ -30,12 +36,15 @@ export default function ContactPage() {
or
{" "}
check the status of an existing project.
-
-
+
+ {error ? {error}
: null}
+ {pieceSlug && !selectedPiece ? That piece is not currently accepting inquiries. You can still send a general custom-work request below.
: null}
+
);
diff --git a/site/app/page.tsx b/site/app/page.tsx
index f7f195a..18d9c5b 100644
--- a/site/app/page.tsx
+++ b/site/app/page.tsx
@@ -1,9 +1,9 @@
import type { Metadata } from "next";
import Link from "next/link";
import { connection } from "next/server";
-import { PageIntro, PageSection, PieceCard, PostCard, SectionHeading, Shell } from "@/components/site-chrome";
+import { PageIntro, PageSection, PieceCard, SectionHeading, Shell } from "@/components/site-chrome";
import { inlineEditAttrs } from "@/components/inline-editable";
-import { getPage, getSiteSettings, listPieces, listPosts } from "@/lib/db";
+import { getPage, getSiteSettings, listPieces } from "@/lib/db";
import { getPortfolioCategories } from "@/lib/catalog";
export const metadata: Metadata = {
@@ -21,7 +21,7 @@ export default async function HomePage() {
const pieces = listPieces().filter((piece) => featuredSlugs.has(piece.slug)).sort((left, right) => left.featuredRank - right.featuredRank);
const hero = site.homeSections.find((section) => section.key === "hero") as Record | undefined;
const services = site.homeSections.find((section) => section.key === "services") as Record | undefined;
- const processNotes = listPosts().slice(0, 2);
+ const homeServices = [...site.homeServices].filter((service) => service.visible).sort((left, right) => left.order - right.order);
const heroCopy = home?.intro || String(hero?.copy ?? "");
const servicesCopy = home?.body || String(services?.copy ?? "The public site handles portfolio, shop, process notes, and buyer communication while the private dashboard manages content, media, project stages, inventory, invoices, and shipping workflows.");
@@ -69,23 +69,31 @@ export default async function HomePage() {
}}
/>
-
Portfolio Finished work with verified photography, materials, and dimensional notes.
-
Shop Available pieces with asking prices, tax at checkout, and pickup, delivery, or shipping review.
-
Custom work Contact-first intake, lead-time guidance, and project tracking once a request is accepted.
-
Private dashboard Content, media, inventory, reviews, orders, and project updates managed from the browser.
+ {homeServices.map((service, index) => (
+
+
{String(index + 1).padStart(2, "0")}
+
{service.title}
+
{service.body}
+
{service.linkLabel}→
+
+ ))}
-
+
- {processNotes.map((post) =>
)}
+
+ 01 Send the brief Describe the intended use, room, approximate size, material preferences, and timing.
+ 02 Review the scope The woodshop confirms fit, current capacity, likely fulfillment method, and what still needs measuring.
+ 03 Approve the project A detailed estimate and project reference are prepared before the build enters the active queue.
+
- Read more process notes
Ask about a custom piece
+ Check a project
diff --git a/site/app/portfolio/[slug]/page.tsx b/site/app/portfolio/[slug]/page.tsx
index e104ec7..9f82477 100644
--- a/site/app/portfolio/[slug]/page.tsx
+++ b/site/app/portfolio/[slug]/page.tsx
@@ -1,14 +1,14 @@
import type { Metadata } from "next";
+import Link from "next/link";
import { connection } from "next/server";
import { notFound } from "next/navigation";
import { MediaLightbox } from "@/components/lightbox";
import { ContactRequestForm, ReviewForm } from "@/components/forms";
import { inlineEditAttrs } from "@/components/inline-editable";
-import { getDisplayMediaPaths, getFulfillmentOptions } from "@/lib/catalog";
+import { getDisplayMediaPaths, getFulfillmentOptions, getPieceProcessMediaLinks, pieceAcceptsReviews, pieceAllowsInquiry, pieceDisplaysReviews } from "@/lib/catalog";
import { PageSection, ShareLinks, Shell } from "@/components/site-chrome";
import { getBandwidthSnapshot, getMedia, getPiece, listCommissionTypes, listReviews } from "@/lib/db";
-import { addToCartAction } from "@/lib/actions";
-import { formatDate, formatDimensions, formatLeadTime, formatMoney, toMediaUrl } from "@/lib/format";
+import { formatDate, formatDimensions, formatLeadTime, toMediaUrl } from "@/lib/format";
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise {
const { slug } = await params;
@@ -37,7 +37,10 @@ export default async function PiecePage({ params }: { params: Promise<{ slug: st
const siteUrl = (process.env.NEXT_PUBLIC_SITE_URL || process.env.SITE_URL || "http://127.0.0.1:3000").replace(/\/$/, "");
const bandwidth = getBandwidthSnapshot();
- const reviews = listReviews(piece.slug).filter((review) => review.status === "published");
+ const showReviews = pieceDisplaysReviews(piece);
+ const acceptReviews = pieceAcceptsReviews(piece);
+ const allowInquiry = pieceAllowsInquiry(piece);
+ const reviews = showReviews ? listReviews(piece.slug).filter((review) => review.status === "published") : [];
const mediaItems = getDisplayMediaPaths(piece).map((path) => {
const media = getMedia(path);
return {
@@ -49,7 +52,13 @@ export default async function PiecePage({ params }: { params: Promise<{ slug: st
cleanupMode: typeof media?.metadata.cleanupMode === "string" ? media.metadata.cleanupMode : undefined
};
});
- const fulfillment = getFulfillmentOptions(piece);
+ const fulfillment = getFulfillmentOptions(piece);
+ const processLinks = getPieceProcessMediaLinks(piece);
+ const processMediaItems = processLinks.flatMap((link) => {
+ const media = getMedia(link.relativePath);
+ if (!media) return [];
+ return [{ src: toMediaUrl(link.relativePath), alt: link.altOverride || media.altText || link.caption || piece.title, kind: media.kind === "video" ? "video" as const : "image" as const, focalX: media.focalX, focalY: media.focalY, zoom: media.zoom }];
+ });
return (
@@ -73,15 +82,11 @@ export default async function PiecePage({ params }: { params: Promise<{ slug: st
{piece.status === "inventory" ? (
- Asking price
- {piece.priceCents == null ? "Available by request" : formatMoney(piece.priceCents)}
+ Availability
+ {piece.availabilityLabel}
{Math.max(0, piece.inventoryCount)} available
-
-
-
- Reserve piece
-
+
View shop details
) : null}
@@ -93,23 +98,31 @@ export default async function PiecePage({ params }: { params: Promise<{ slug: st
Archival media is being verified for this piece before additional images are shown publicly.
)}
-
-
-
+
+
+ {processLinks.length > 0 ?
+ Build record
{piece.processSectionTitle || "Build record"} {piece.processSectionIntro ?
{piece.processSectionIntro}
: null}
+
+
{processLinks.map((link, index) => {String(index + 1).padStart(2, "0")} {link.stage || link.title || link.role.replace("-", " ")} {link.occurredAt ?
{formatDate(link.occurredAt)} : null}{link.caption ?
{link.caption}
: null}
)}
+ {processMediaItems.length > 0 ?
: null}
+
+ : null}
+
+ {allowInquiry ?
{piece.status === "inventory" ? "Ask about this piece" : "Use this piece as the starting point for custom work"}
{piece.status === "inventory" ? "Use this contact form if you want to reserve the current build, confirm delivery options, or ask for a related variation. Checkout details stay in the shop." : "Custom work begins with a direct note about the room, intended use, timing, and material preferences. The private project workflow takes over after the initial review."}
-
-
-
-
- Reviews
+ queueCount={bandwidth.activeProjects}
+ />
+ : null}
+
+ {showReviews ?
+ Reviews
{reviews.length > 0 ? reviews.map((review) => (
@@ -119,8 +132,8 @@ export default async function PiecePage({ params }: { params: Promise<{ slug: st
)) :
No approved reviews are published for this piece yet.
}
-
-
+ {acceptReviews ? : null}
+ : null}
);
}
diff --git a/site/app/shop/page.tsx b/site/app/shop/page.tsx
index 7bed85f..013950b 100644
--- a/site/app/shop/page.tsx
+++ b/site/app/shop/page.tsx
@@ -2,11 +2,11 @@ import type { Metadata } from "next";
import Link from "next/link";
import { connection } from "next/server";
import { addToCartAction } from "@/lib/actions";
-import { getDisplayMediaPaths, getFulfillmentOptions, getFulfillmentSummary, pieceShippingEnabled } from "@/lib/catalog";
+import { getDisplayMediaPaths, getFulfillmentOptions, getFulfillmentSummary, getPiecePublicPriceLabel, pieceAllowsInquiry, pieceCanEnterCart, pieceShippingEnabled } from "@/lib/catalog";
import { PageIntro, PageSection, Shell } from "@/components/site-chrome";
import { inlineEditAttrs } from "@/components/inline-editable";
import { getPage, listPieces } from "@/lib/db";
-import { formatLeadTime, formatMoney, toMediaUrl } from "@/lib/format";
+import { formatLeadTime, toMediaUrl } from "@/lib/format";
export const metadata: Metadata = {
title: "Shop",
@@ -38,16 +38,19 @@ export default async function ShopPage() {
const firstImage = getDisplayMediaPaths(piece)[0];
const fulfillment = getFulfillmentOptions(piece);
const shippingEnabled = pieceShippingEnabled(piece);
+ const canAddToCart = pieceCanEnterCart(piece);
+ const priceLabel = getPiecePublicPriceLabel(piece);
+ const canAsk = pieceAllowsInquiry(piece);
return (
-
+
{firstImage ? : Media under review
}
{piece.category} {piece.inventoryCount} available
{piece.title}
{piece.summary}
-
Asking price {piece.priceCents == null ? "Available by request" : formatMoney(piece.priceCents)}
+
Asking price {priceLabel ?? "Not publicly listed"}
Lead time {formatLeadTime(piece.leadTimeDays)}
Fulfillment {fulfillment.join(" / ")}
Shipping {shippingEnabled ? "Enabled for this piece" : "Disabled by default"}
@@ -56,11 +59,11 @@ export default async function ShopPage() {
{getFulfillmentSummary(piece)}
Exact pickup/drop-off details stay private until buyer eligibility and consent are confirmed.
-
+ {canAddToCart ?
- Reserve piece
-
+ Add to cart
+ : canAsk ? Ask about this piece : Not accepting inquiries }
diff --git a/site/app/studio/page.tsx b/site/app/studio/page.tsx
index 68ab648..9355a74 100644
--- a/site/app/studio/page.tsx
+++ b/site/app/studio/page.tsx
@@ -26,6 +26,7 @@ import {
saveProjectAction,
saveReviewAdminAction,
saveSiteSettingsAction,
+ saveSiteStructureAction,
saveUserProfileAdminAction,
uploadMediaAction
} from "@/lib/actions";
@@ -33,6 +34,7 @@ import { requireAdmin } from "@/lib/auth";
import Link from "next/link";
import {
countMedia,
+ getMedia,
getSiteSettings,
getStudioDashboardSummary,
getRuntimePersistenceStatus,
@@ -42,6 +44,7 @@ import {
listNotifications,
listOrders,
listPages,
+ listPieceMediaLinks,
listPieces,
listPosts,
listProjects,
@@ -63,7 +66,12 @@ import { PageIntro, PageSection, Shell } from "@/components/site-chrome";
import { StudioScrollRestore } from "@/components/studio-form";
import { StudioMediaWorkspace } from "@/components/studio-media-workspace";
import { StudioCategoryEditor } from "@/components/studio-category-editor";
+import { SiteStructureEditor } from "@/components/site-structure-editor";
+import { MediaPicker, type MediaPickerItem } from "@/components/media-picker";
+import { PieceMediaEditor } from "@/components/piece-media-editor";
import { normalizePieceCategories, type PieceCategoryDefinition } from "@/lib/categories";
+import { getPieceInquiryMode, getPiecePriceMode, getPieceReviewsMode } from "@/lib/piece-model";
+import { visualAuditRequestAuthorized } from "@/lib/visual-audit";
const STUDIO_MEDIA_PAGE_SIZE = 48;
const STUDIO_PANELS = ["overview", "settings", "pages", "pieces", "categories", "custom", "people", "process", "media", "projects", "orders", "reviews", "notifications"] as const;
@@ -86,6 +94,15 @@ function toDomId(prefix: string, value: string) {
return `${prefix}-${value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "item"}`;
}
+function StudioMasterList({ items, newHref, newLabel, selectedKey }: { items: Array<{ key: string; label: string; meta: string; href: string }>; newHref: string; newLabel: string; selectedKey: string }) {
+ return (
+
+ + {newLabel}
+ {items.map((item) => {item.label} {item.meta} )}
+
+ );
+}
+
function studioMessage(code: string) {
const messages: Record = {
"cannot-delete-current-user": "The profile currently signed in cannot be deleted.",
@@ -99,7 +116,7 @@ function studioMessage(code: string) {
return messages[code] ?? code;
}
-function PageEditor({ page, highlight = false }: { page: Omit; highlight?: boolean }) {
+function PageEditor({ page, mediaItems, highlight = false }: { page: Omit; mediaItems: MediaPickerItem[]; highlight?: boolean }) {
return (
@@ -113,7 +130,7 @@ function PageEditor({ page, highlight = false }: { page: Omit
Status Published Draft Archived
-
+
Save page
@@ -122,7 +139,7 @@ function PageEditor({ page, highlight = false }: { page: Omit; categories: PieceCategoryDefinition[]; highlight?: boolean }) {
+function PieceEditor({ piece, categories, mediaItems, mediaLinks, highlight = false }: { piece: Omit; categories: PieceCategoryDefinition[]; mediaItems: MediaPickerItem[]; mediaLinks: ReturnType; highlight?: boolean }) {
const categoryValues = new Set(categories.map((category) => category.label));
return (
@@ -141,13 +158,19 @@ function PieceEditor({ piece, categories, highlight = false }: { piece: OmitPublication Published Draft Archived
+
+ Price display Fixed asking price Contact for price Not listed After project approval At order completion
+ Inquiry behavior Ask about this piece Related custom work No inquiries
+ Reviews Display and accept Display only Hidden
+
-
+
-
+
+
@@ -157,7 +180,7 @@ function PieceEditor({ piece, categories, highlight = false }: { piece: Omit; highlight?: boolean }) {
+function PostEditor({ post, mediaItems, highlight = false }: { post: Omit; mediaItems: MediaPickerItem[]; highlight?: boolean }) {
return (
@@ -168,7 +191,8 @@ function PostEditor({ post, highlight = false }: { post: Omit
-
+
+
Publication Published Draft Archived
@@ -295,6 +319,7 @@ export default async function StudioPage({
user?: string;
email?: string;
category?: string;
+ audit?: string;
}>;
}) {
const currentAdmin = await requireAdmin();
@@ -321,9 +346,13 @@ export default async function StudioPage({
piece: pieceHighlight = "",
post: postHighlight = "",
user: userHighlight = "",
+ audit = "",
email = "",
category: categoryHighlight = ""
} = await searchParams;
+ const includeAllAuditRecords =
+ audit === "all" &&
+ await visualAuditRequestAuthorized();
const currentPanel: StudioPanel =
STUDIO_PANELS.includes(requestedPanel as StudioPanel) ? requestedPanel as StudioPanel
: cleaned || assigned || uploaded || renamed || refreshed || mediaQuery || saved === "media" || deleted === "media" || error.startsWith("media-") || error.startsWith("cleanup-") ? "media"
@@ -367,11 +396,64 @@ export default async function StudioPage({
const media = currentPanel === "media" ? listMedia({ ...mediaFilters, limit: STUDIO_MEDIA_PAGE_SIZE, offset: mediaOffset }) : [];
const verificationMedia = currentPanel === "media" ? listMedia({ includeUnreviewed: true }) : [];
const verificationQueue = currentPanel === "media" ? buildMediaVerificationQueue(pieces, verificationMedia.filter((m) => m.kind === "image")) : [];
- const projects = currentPanel === "projects" ? listProjects(true).slice(0, 20) : [];
- const projectMedia = currentPanel === "projects" ? listMediaForProjectReferences(projects.map((p) => p.reference)) : [];
- const orders = currentPanel === "orders" ? listOrders().slice(0, 20) : [];
- const reviews = currentPanel === "reviews" ? listReviews().slice(0, 20) : [];
- const notifications = currentPanel === "notifications" ? listNotifications().slice(0, 20) : [];
+ const projects = currentPanel === "projects"
+ ? (
+ includeAllAuditRecords
+ ? listProjects(true)
+ : listProjects(true).slice(0, 20)
+ )
+ : [];
+
+ const projectMedia = currentPanel === "projects"
+ ? listMediaForProjectReferences(
+ projects.map(project => project.reference)
+ )
+ : [];
+
+ const orders = currentPanel === "orders"
+ ? (
+ includeAllAuditRecords
+ ? listOrders()
+ : listOrders().slice(0, 20)
+ )
+ : [];
+
+ const reviews = currentPanel === "reviews"
+ ? (
+ includeAllAuditRecords
+ ? listReviews()
+ : listReviews().slice(0, 20)
+ )
+ : [];
+
+ const notifications = currentPanel === "notifications"
+ ? (
+ includeAllAuditRecords
+ ? listNotifications()
+ : listNotifications().slice(0, 20)
+ )
+ : [];
+ const editingPage = currentPanel === "pages"
+ ? pageHighlight === "new-page-draft" ? pageDraft() : pages.find((page) => page.slug === pageHighlight) ?? pages[0] ?? pageDraft()
+ : null;
+ const editingPiece = currentPanel === "pieces"
+ ? pieceHighlight === "new-piece-draft" ? pieceDraft(currentAdmin.email) : pieces.find((piece) => piece.slug === pieceHighlight) ?? pieces[0] ?? pieceDraft(currentAdmin.email)
+ : null;
+ const editingPost = currentPanel === "process"
+ ? postHighlight === "new-process-entry" ? postDraft(currentAdmin.email) : posts.find((post) => post.slug === postHighlight) ?? posts[0] ?? postDraft(currentAdmin.email)
+ : null;
+ const editingPieceLinks = editingPiece && editingPiece.slug !== "new-piece-draft"
+ ? listPieceMediaLinks(editingPiece.slug).filter((link) => link.role !== "private-project")
+ : [];
+ const linkedDisplayPaths = editingPieceLinks.filter((link) => ["hero", "gallery", "detail", "context"].includes(link.role)).map((link) => link.relativePath);
+ const editingPiecePaths = linkedDisplayPaths.length > 0 ? linkedDisplayPaths : editingPiece?.mediaPaths ?? [];
+ const editingPieceMediaPaths = [...new Set([...editingPiecePaths, ...editingPieceLinks.map((link) => link.relativePath)])];
+ const editorMediaPaths = [...new Set([
+ ...(editingPage?.heroMediaPath ? [editingPage.heroMediaPath] : []),
+ ...editingPieceMediaPaths,
+ ...(editingPost?.coverMediaPath ? [editingPost.coverMediaPath] : [])
+ ])];
+ const editorMediaItems = editorMediaPaths.map((relativePath) => getMedia(relativePath)).filter((item): item is NonNullable> => Boolean(item));
const panelHref = (panel: StudioPanel, extras?: Record) => {
const params = new URLSearchParams({ panel });
if (panel === "media") {
@@ -461,15 +543,16 @@ export default async function StudioPage({
Save settings
+
) : null}
- {currentPanel === "pages" ? Pages
Public pages Titles, intros, body copy, and hero media. Saving here writes to {persistence.databasePath} and revalidates the live route.
: null}
- {currentPanel === "pieces" ? Pieces
Portfolio and shop pieces Categories, availability, media assignments, shop asking price, and metadata.
{pieces.map((piece) =>
)}
: null}
+ {currentPanel === "pages" && editingPage ? Pages
Public pages Select one record, edit it in place, and choose hero media visually from the mounted library.
({ key: page.slug, label: page.title, meta: page.status, href: panelHref("pages", { page: page.slug }) }))} newHref={panelHref("pages", { page: "new-page-draft" })} newLabel="New page" selectedKey={editingPage.slug} /> : null}
+ {currentPanel === "pieces" && editingPiece ? Pieces
Portfolio and shop pieces Pricing, inquiry and review policies, inventory, fulfillment, and normalized visual media assignment.
({ key: piece.slug, label: piece.title, meta: `${piece.publicationStatus} · ${piece.status}`, href: panelHref("pieces", { piece: piece.slug }) }))} newHref={panelHref("pieces", { piece: "new-piece-draft" })} newLabel="New piece" selectedKey={editingPiece.slug} /> : null}
{currentPanel === "categories" ? Categories
Portfolio filters Choose a furniture icon, import a safe custom SVG, and control order and visibility without editing code.
{categories.map((category) => )}
: null}
{currentPanel === "custom" ? Custom work
Contact workflow types Material menus, estimator defaults, and active custom request categories.
{commissionTypes.map((item) => )}
: null}
{currentPanel === "people" ? People
Accounts and public profiles Rename profiles, replace contact emails, and remove accounts directly from the dashboard.
{users.map((user) => )}
: null}
- {currentPanel === "process" ? Process
Process notes and references Markdown body, cover images, external links, and publication state.
: null}
+ {currentPanel === "process" && editingPost ? Process
Process notes and references Select one note, edit Markdown and source details, and choose its cover media visually.
({ key: post.slug, label: post.title, meta: post.publicationStatus, href: panelHref("process", { post: post.slug }) }))} newHref={panelHref("process", { post: "new-process-entry" })} newLabel="New process note" selectedKey={editingPost.slug} /> : null}
{currentPanel === "media" ? (
diff --git a/site/app/ui-repair.css b/site/app/ui-repair.css
index 5b5ebdc..0e244f0 100644
--- a/site/app/ui-repair.css
+++ b/site/app/ui-repair.css
@@ -1585,6 +1585,700 @@ textarea {
margin-block: 0.35rem;
}
+/* Structured public navigation surfaces. */
+.service-card-link {
+ position: relative;
+ min-height: 10.5rem !important;
+ grid-template-rows: auto auto 1fr auto;
+ color: var(--text);
+ text-decoration: none;
+ transition: border-color 160ms ease, transform 160ms ease, background-color 160ms ease;
+}
+
+.service-card-link:hover {
+ transform: translateY(-2px);
+ border-color: color-mix(in srgb, var(--accent) 48%, var(--line));
+ background: color-mix(in srgb, var(--accent) 6%, var(--bg-elevated));
+}
+
+.service-card-link:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 3px;
+}
+
+.service-card-link h3,
+.service-card-link p {
+ margin: 0;
+}
+
+.service-card-index {
+ color: var(--accent);
+ font-size: 0.68rem;
+ letter-spacing: 0.14em;
+}
+
+.service-card-cta {
+ display: inline-flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.65rem;
+ padding-top: 0.65rem;
+ border-top: 1px solid var(--line);
+ color: var(--text);
+ font-size: 0.78rem;
+}
+
+.commission-path {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 0;
+ margin: 1rem 0 1.25rem;
+ padding: 0;
+ list-style: none;
+ border-block: 1px solid var(--line);
+}
+
+.commission-path li {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ gap: 0.72rem;
+ padding: 1rem;
+}
+
+.commission-path li + li {
+ border-left: 1px solid var(--line);
+}
+
+.commission-path > li > span {
+ color: var(--accent);
+ font-size: 0.7rem;
+ letter-spacing: 0.12em;
+}
+
+.commission-path h3,
+.commission-path p {
+ margin: 0;
+}
+
+.commission-path h3 {
+ font-size: 1.05rem;
+}
+
+.commission-path p {
+ margin-top: 0.35rem;
+ color: var(--muted);
+ font-size: 0.82rem;
+}
+
+.site-footer {
+ margin-top: clamp(2.5rem, 6vw, 5rem);
+}
+
+.footer-grid {
+ grid-template-columns: minmax(12rem, 1.35fr) repeat(3, minmax(10rem, 1fr));
+ gap: clamp(1rem, 3vw, 2.5rem);
+ padding-block: clamp(1.5rem, 4vw, 3rem);
+}
+
+.footer-intro,
+.footer-group {
+ min-width: 0;
+}
+
+.footer-intro .footer-copy {
+ max-width: 34ch;
+}
+
+.footer-item-list {
+ display: grid;
+ gap: 0.5rem;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.footer-item-list a,
+.footer-static-item {
+ display: grid;
+ gap: 0.08rem;
+ min-width: 0;
+ color: var(--text);
+ text-decoration: none;
+}
+
+.footer-item-list a:hover strong {
+ text-decoration: underline;
+ text-underline-offset: 0.2em;
+}
+
+.footer-item-list span {
+ color: var(--muted);
+ font-size: 0.68rem;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+
+.footer-item-list strong {
+ overflow-wrap: anywhere;
+ font-size: 0.78rem;
+ font-weight: 500;
+}
+
+.site-structure-editor,
+.structure-editor-section,
+.structure-editor-list,
+.structure-editor-fields,
+.footer-editor-items {
+ display: grid;
+ gap: 0.65rem;
+}
+
+.site-structure-editor {
+ margin-top: 0.75rem;
+}
+
+.structure-editor-row {
+ border: 1px solid var(--line);
+ border-radius: 0.72rem;
+ background: color-mix(in srgb, var(--bg-card) 65%, transparent);
+}
+
+.structure-editor-row > summary {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 0.75rem;
+ min-height: 2.75rem;
+ padding: 0.55rem 0.72rem;
+ color: var(--text);
+ cursor: pointer;
+}
+
+.structure-editor-fields {
+ padding: 0 0.72rem 0.72rem;
+ border-top: 1px solid var(--line);
+}
+
+.structure-row-meta {
+ color: var(--muted);
+ font-size: 0.72rem;
+}
+
+.structure-row-actions,
+.structure-order-buttons,
+.structure-save-bar {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.45rem;
+}
+
+.structure-row-actions {
+ justify-content: flex-end;
+}
+
+.structure-order-buttons .icon-button {
+ display: inline-grid;
+ place-items: center;
+ width: 2.25rem;
+ height: 2.25rem;
+ border: 1px solid var(--line);
+ border-radius: 0.58rem;
+ background: var(--bg-elevated);
+ color: var(--text);
+}
+
+.footer-editor-item {
+ display: grid;
+ gap: 0.5rem;
+ padding: 0.65rem;
+ border: 1px solid var(--line);
+ border-radius: 0.65rem;
+}
+
+.structure-save-bar {
+ position: sticky;
+ bottom: 0.65rem;
+ z-index: 4;
+ justify-content: space-between;
+ padding: 0.65rem;
+ border: 1px solid var(--line);
+ border-radius: 0.75rem;
+ background: color-mix(in srgb, var(--bg) 92%, transparent);
+ box-shadow: var(--shadow);
+}
+
+.studio-master-detail {
+ display: grid;
+ grid-template-columns: minmax(12rem, 16rem) minmax(0, 1fr);
+ gap: 0.75rem;
+ align-items: start;
+}
+
+.studio-master-list {
+ position: sticky;
+ top: var(--studio-scroll-offset);
+ display: grid;
+ gap: 0.28rem;
+ max-height: calc(100vh - var(--studio-scroll-offset) - 1rem);
+ padding: 0.35rem;
+ overflow: auto;
+ border: 1px solid var(--line);
+ border-radius: 0.75rem;
+ background: var(--bg-elevated);
+}
+
+.studio-master-create,
+.studio-master-item {
+ display: grid;
+ gap: 0.12rem;
+ min-height: 2.75rem;
+ padding: 0.48rem 0.58rem;
+ border: 1px solid transparent;
+ border-radius: 0.58rem;
+ color: var(--text);
+ text-decoration: none;
+}
+
+.studio-master-create {
+ place-items: center start;
+ border-color: var(--line);
+ color: var(--accent);
+}
+
+.studio-master-item span {
+ color: var(--muted);
+ font-size: 0.68rem;
+}
+
+.studio-master-item.is-active,
+.studio-master-create.is-active {
+ border-color: color-mix(in srgb, var(--accent) 48%, var(--line));
+ background: color-mix(in srgb, var(--accent) 10%, var(--bg-card));
+}
+
+.media-picker {
+ display: grid;
+ gap: 0.55rem;
+ min-width: 0;
+}
+
+.media-picker-head,
+.media-picker-actions,
+.media-picker-pagination,
+.media-picker-search-row {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.media-picker-head {
+ justify-content: space-between;
+}
+
+.media-picker-head p {
+ margin: 0.18rem 0 0;
+}
+
+.media-picker-strip {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr));
+ gap: 0.45rem;
+ min-height: 3rem;
+}
+
+.media-picker-strip.is-empty {
+ display: block;
+ padding: 0.65rem;
+ border: 1px dashed var(--line);
+ border-radius: 0.65rem;
+}
+
+.media-picker-chip {
+ position: relative;
+ display: grid;
+ grid-template-columns: 4.5rem minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 0.5rem;
+ min-width: 0;
+ padding: 0.35rem 2rem 0.35rem 0.35rem;
+ border: 1px solid var(--line);
+ border-radius: 0.62rem;
+ background: var(--bg-card);
+}
+
+.media-picker-chip-image,
+.media-picker-chip-fallback {
+ width: 4.5rem;
+ height: 3.25rem;
+ border-radius: 0.42rem;
+ object-fit: cover;
+}
+
+.media-picker-chip-fallback {
+ display: grid;
+ place-items: center;
+ background: var(--bg-soft);
+ color: var(--muted);
+ font-size: 0.65rem;
+}
+
+.media-picker-chip-copy {
+ display: grid;
+ min-width: 0;
+}
+
+.media-picker-chip-copy strong,
+.media-picker-chip-copy small {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.media-picker-chip-copy small {
+ color: var(--muted);
+}
+
+.media-picker-chip-actions {
+ display: grid;
+ gap: 0.18rem;
+}
+
+.media-picker-chip-actions button,
+.media-picker-remove {
+ display: grid;
+ place-items: center;
+ width: 1.55rem;
+ height: 1.55rem;
+ padding: 0;
+ border: 1px solid var(--line);
+ border-radius: 0.38rem;
+ background: var(--bg-elevated);
+ color: var(--text);
+}
+
+.media-picker-remove {
+ position: absolute;
+ top: 0.28rem;
+ right: 0.28rem;
+}
+
+.media-picker-shell {
+ position: fixed;
+ inset: 0;
+ z-index: 1200;
+ display: grid;
+ place-items: center;
+ padding: 1rem;
+}
+
+.media-picker-backdrop {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ border: 0;
+ background: rgb(0 0 0 / 78%);
+}
+
+.media-picker-dialog {
+ position: relative;
+ display: grid;
+ grid-template-rows: auto auto minmax(0, 1fr) auto;
+ width: min(76rem, 96vw);
+ height: min(52rem, 92vh);
+ overflow: hidden;
+ border: 1px solid var(--line);
+ border-radius: 1rem;
+ background: var(--bg);
+ box-shadow: 0 2rem 6rem rgb(0 0 0 / 45%);
+}
+
+.media-picker-toolbar,
+.media-picker-controls,
+.media-picker-pagination {
+ padding: 0.7rem 0.85rem;
+ border-bottom: 1px solid var(--line);
+}
+
+.media-picker-toolbar {
+ display: flex;
+ justify-content: space-between;
+ align-items: start;
+ gap: 1rem;
+}
+
+.media-picker-toolbar h3,
+.media-picker-toolbar p {
+ margin: 0;
+}
+
+.media-picker-close {
+ position: static !important;
+ flex: 0 0 auto;
+}
+
+.media-picker-controls {
+ display: grid;
+ grid-template-columns: minmax(14rem, 1fr) minmax(10rem, 0.35fr) auto;
+ gap: 0.65rem;
+ align-items: end;
+}
+
+.media-picker-search-row input {
+ flex: 1 1 auto;
+}
+
+.media-picker-count {
+ margin: 0 0 0.48rem;
+ color: var(--muted);
+ white-space: nowrap;
+}
+
+.media-picker-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(9.5rem, 1fr));
+ align-content: start;
+ gap: 0.48rem;
+ min-height: 0;
+ padding: 0.65rem;
+ overflow: auto;
+}
+
+.media-picker-card {
+ display: grid;
+ grid-template-rows: auto 1fr;
+ min-width: 0;
+ overflow: hidden;
+ border: 1px solid var(--line);
+ border-radius: 0.65rem;
+ background: var(--bg-card);
+ color: var(--text);
+ text-align: left;
+}
+
+.media-picker-card.is-selected {
+ border-color: var(--accent);
+ box-shadow: inset 0 0 0 1px var(--accent);
+}
+
+.media-picker-card-media {
+ position: relative;
+ aspect-ratio: 4 / 3;
+ overflow: hidden;
+ background: var(--bg-soft);
+}
+
+.media-picker-card-media img {
+ object-fit: cover;
+}
+
+.media-picker-card-body {
+ display: grid;
+ gap: 0.12rem;
+ min-width: 0;
+ padding: 0.45rem;
+}
+
+.media-picker-card-body strong,
+.media-picker-card-body p,
+.media-picker-card-body small {
+ margin: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.media-picker-card-body p,
+.media-picker-card-body small {
+ color: var(--muted);
+ font-size: 0.66rem;
+}
+
+.media-picker-pagination {
+ justify-content: flex-end;
+ border-top: 1px solid var(--line);
+ border-bottom: 0;
+}
+
+.piece-media-editor,
+.piece-media-relation-list,
+.piece-media-relation-fields {
+ display: grid;
+ gap: 0.65rem;
+}
+
+.piece-media-editor {
+ padding-block: 0.35rem;
+}
+
+.piece-media-relations {
+ padding: 0.65rem;
+ border: 1px solid var(--line);
+ border-radius: 0.72rem;
+}
+
+.piece-media-relations > summary {
+ cursor: pointer;
+ color: var(--text);
+}
+
+.piece-media-relation-list {
+ margin-top: 0.65rem;
+}
+
+.piece-media-relation {
+ display: grid;
+ grid-template-columns: 6rem minmax(0, 1fr);
+ gap: 0.65rem;
+ padding: 0.55rem;
+ border: 1px solid var(--line);
+ border-radius: 0.62rem;
+}
+
+.piece-media-relation-preview {
+ position: relative;
+ min-height: 5rem;
+ overflow: hidden;
+ border-radius: 0.45rem;
+ background: var(--bg-soft);
+}
+
+.piece-media-relation-preview img {
+ object-fit: cover;
+}
+
+.piece-process-layout {
+ display: grid;
+ grid-template-columns: minmax(14rem, 0.65fr) minmax(0, 1.35fr);
+ gap: 1rem;
+ align-items: start;
+}
+
+.piece-process-timeline {
+ display: grid;
+ gap: 0;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ border-top: 1px solid var(--line);
+}
+
+.piece-process-timeline li {
+ display: grid;
+ grid-template-columns: 2rem minmax(0, 1fr);
+ gap: 0.65rem;
+ padding: 0.75rem 0;
+ border-bottom: 1px solid var(--line);
+}
+
+.piece-process-timeline > li > span {
+ color: var(--accent);
+ font-size: 0.7rem;
+}
+
+.piece-process-timeline strong,
+.piece-process-timeline time {
+ display: block;
+}
+
+.piece-process-timeline time,
+.piece-process-timeline p {
+ color: var(--muted);
+ font-size: 0.76rem;
+}
+
+.piece-process-timeline p {
+ margin: 0.25rem 0 0;
+}
+
+.piece-process-carousel {
+ display: grid;
+ grid-auto-flow: column;
+ grid-auto-columns: minmax(13rem, 72%);
+ gap: 0.55rem;
+ overflow-x: auto;
+ scroll-snap-type: x mandatory;
+}
+
+.piece-process-carousel .media-card {
+ scroll-snap-align: start;
+}
+
+@media (max-width: 720px) {
+ .studio-master-detail {
+ grid-template-columns: 1fr;
+ }
+
+ .studio-master-list {
+ position: static;
+ display: flex;
+ max-height: none;
+ overflow-x: auto;
+ }
+
+ .studio-master-create,
+ .studio-master-item {
+ flex: 0 0 10rem;
+ }
+
+ .media-picker-shell {
+ padding: 0;
+ }
+
+ .media-picker-dialog {
+ width: 100vw;
+ height: 100dvh;
+ border-radius: 0;
+ }
+
+ .media-picker-controls {
+ grid-template-columns: 1fr;
+ }
+
+ .media-picker-count {
+ display: none;
+ }
+
+ .media-picker-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .media-picker-pagination {
+ justify-content: space-between;
+ }
+
+ .piece-media-relation,
+ .piece-process-layout {
+ grid-template-columns: 1fr;
+ }
+
+ .piece-media-relation-preview {
+ aspect-ratio: 16 / 9;
+ }
+}
+
+@media (max-width: 900px) {
+ .footer-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+@media (max-width: 640px) {
+ .commission-path {
+ grid-template-columns: 1fr;
+ }
+
+ .commission-path li + li {
+ border-top: 1px solid var(--line);
+ border-left: 0;
+ }
+
+ .footer-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
.persistence-status-card dd {
max-width: 100%;
overflow: hidden;
diff --git a/site/components/inline-editable.tsx b/site/components/inline-editable.tsx
index d2554ea..458b53c 100644
--- a/site/components/inline-editable.tsx
+++ b/site/components/inline-editable.tsx
@@ -1,4 +1,4 @@
-import type { ElementType, HTMLAttributes, ReactNode } from "react";
+import { createElement, type ElementType, type HTMLAttributes, type ReactNode } from "react";
export type InlineEditResource = "settings" | "homeSection" | "page" | "piece" | "post" | "user";
@@ -33,5 +33,5 @@ export function EditableText({
target?: InlineEditTarget;
}) {
const props = inlineEditAttrs(target);
- return {children} ;
+ return createElement(Component, { ...props, className }, children);
}
diff --git a/site/components/media-picker.tsx b/site/components/media-picker.tsx
index f6661e7..47c9df2 100644
--- a/site/components/media-picker.tsx
+++ b/site/components/media-picker.tsx
@@ -1,6 +1,8 @@
"use client";
-import { useEffect, useMemo, useState } from "react";
+import Image from "next/image";
+import { useDeferredValue, useEffect, useRef, useState, useTransition, type KeyboardEvent } from "react";
+import type { MediaPageRequest, MediaPageResult } from "@/lib/actions";
import { cn, toMediaUrl } from "@/lib/format";
export type MediaPickerItem = {
@@ -18,195 +20,211 @@ export type MediaPickerItem = {
};
type MediaPickerProps = {
- items: MediaPickerItem[];
+ items?: MediaPickerItem[];
label: string;
name: string;
selectionMode?: "single" | "multiple";
defaultValue?: string | string[] | null;
helperText?: string;
maxSelections?: number;
+ loadPageAction?: (request: MediaPageRequest) => Promise;
+ onSelectionChange?: (paths: string[]) => void;
};
function initialSelection(value: string | string[] | null | undefined, multiple: boolean) {
if (multiple) {
- if (Array.isArray(value)) {
- return value.filter(Boolean);
- }
- if (typeof value === "string") {
- return value
- .split(/\r?\n|,/g)
- .map((entry) => entry.trim())
- .filter(Boolean);
- }
+ if (Array.isArray(value)) return [...new Set(value.filter(Boolean))];
+ if (typeof value === "string") return [...new Set(value.split(/\r?\n|,/g).map((entry) => entry.trim()).filter(Boolean))];
return [];
}
-
- if (Array.isArray(value)) {
- return value[0] ?? "";
- }
-
+ if (Array.isArray(value)) return value[0] ?? "";
return typeof value === "string" ? value : "";
}
+function mergeItems(current: MediaPickerItem[], incoming: MediaPickerItem[]) {
+ const map = new Map(current.map((item) => [item.relativePath, item]));
+ incoming.forEach((item) => map.set(item.relativePath, item));
+ return [...map.values()];
+}
+
export function MediaPicker({
- items,
+ items = [],
label,
name,
selectionMode = "single",
defaultValue = null,
helperText,
- maxSelections
+ maxSelections,
+ loadPageAction,
+ onSelectionChange
}: MediaPickerProps) {
const multiple = selectionMode === "multiple";
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
+ const deferredQuery = useDeferredValue(query);
const [folder, setFolder] = useState("all");
const [selection, setSelection] = useState(initialSelection(defaultValue, multiple));
+ const [knownItems, setKnownItems] = useState(items);
+ const [browserItems, setBrowserItems] = useState(items);
+ const [page, setPage] = useState(1);
+ const [pageSize, setPageSize] = useState(48);
+ const [total, setTotal] = useState(items.length);
+ const [loadError, setLoadError] = useState("");
+ const [loading, startTransition] = useTransition();
+ const openerRef = useRef(null);
+ const dialogRef = useRef(null);
+ const searchRef = useRef(null);
useEffect(() => {
setSelection(initialSelection(defaultValue, multiple));
}, [defaultValue, multiple]);
- const folders = useMemo(() => ["all", ...new Set(items.map((item) => item.folder))], [items]);
- const selectedPaths = multiple ? selection as string[] : (selection ? [selection as string] : []);
- const selectedItems = selectedPaths
- .map((relativePath) => items.find((item) => item.relativePath === relativePath))
- .filter((item): item is MediaPickerItem => Boolean(item));
-
- const filteredItems = useMemo(() => {
- const search = query.trim().toLowerCase();
- return items.filter((item) => {
- if (folder !== "all" && item.folder !== folder) {
- return false;
- }
+ useEffect(() => {
+ setKnownItems((current) => mergeItems(current, items));
+ }, [items]);
- if (!search) {
- return true;
+ useEffect(() => {
+ if (!open) return;
+ const previousOverflow = document.body.style.overflow;
+ document.body.style.overflow = "hidden";
+ searchRef.current?.focus();
+ const onKeyDown = (event: globalThis.KeyboardEvent) => {
+ if (event.key === "Escape") {
+ event.preventDefault();
+ setOpen(false);
+ requestAnimationFrame(() => openerRef.current?.focus());
}
-
- const haystack = [
- item.fileName,
- item.relativePath,
- item.altText,
- item.folder,
- item.pieceSlug ?? "",
- item.postSlug ?? "",
- item.pageSlug ?? "",
- item.tags.join(" ")
- ].join(" ").toLowerCase();
-
- return haystack.includes(search);
+ if (event.key === "Tab" && dialogRef.current) {
+ const focusable = [...dialogRef.current.querySelectorAll('button:not([disabled]), input:not([disabled]), select:not([disabled]), [href], [tabindex]:not([tabindex="-1"])')];
+ const first = focusable[0];
+ const last = focusable.at(-1);
+ if (event.shiftKey && document.activeElement === first) {
+ event.preventDefault();
+ last?.focus();
+ } else if (!event.shiftKey && document.activeElement === last) {
+ event.preventDefault();
+ first?.focus();
+ }
+ }
+ };
+ window.addEventListener("keydown", onKeyDown);
+ return () => {
+ document.body.style.overflow = previousOverflow;
+ window.removeEventListener("keydown", onKeyDown);
+ };
+ }, [open]);
+
+ const selectedPaths = multiple ? selection as string[] : selection ? [selection as string] : [];
+ const itemByPath = new Map(knownItems.map((item) => [item.relativePath, item]));
+ const folders = ["all", ...new Set(browserItems.map((item) => item.folder).filter(Boolean))];
+ const visibleItems = browserItems.filter((item) => folder === "all" || item.folder === folder);
+ const totalPages = Math.max(1, Math.ceil(total / pageSize));
+
+ function loadBrowserPage(nextPage: number, nextQuery = deferredQuery) {
+ if (!loadPageAction) return;
+ setLoadError("");
+ startTransition(() => {
+ void loadPageAction({ page: nextPage, pageSize, query: nextQuery, kind: "all", assignment: "all", aiFilter: "all" })
+ .then((result) => {
+ const nextItems = result.items as MediaPickerItem[];
+ setBrowserItems(nextItems);
+ setKnownItems((current) => mergeItems(current, nextItems));
+ setPage(result.page);
+ setPageSize(result.pageSize);
+ setTotal(result.total);
+ setFolder("all");
+ })
+ .catch((error: unknown) => setLoadError(error instanceof Error ? error.message : "The media library could not be loaded."));
});
- }, [folder, items, query]);
+ }
+
+ function openBrowser() {
+ setOpen(true);
+ loadBrowserPage(1, "");
+ }
+
+ function closeBrowser() {
+ setOpen(false);
+ requestAnimationFrame(() => openerRef.current?.focus());
+ }
function toggleItem(relativePath: string) {
if (!multiple) {
setSelection(relativePath);
- setOpen(false);
+ onSelectionChange?.([relativePath]);
+ closeBrowser();
return;
}
+ const current = selection as string[];
+ if (current.includes(relativePath)) {
+ const next = current.filter((entry) => entry !== relativePath);
+ setSelection(next);
+ onSelectionChange?.(next);
+ return;
+ }
+ if (maxSelections && current.length >= maxSelections) return;
+ const next = [...current, relativePath];
+ setSelection(next);
+ onSelectionChange?.(next);
+ }
- setSelection((currentValue) => {
- const current = currentValue as string[];
- if (current.includes(relativePath)) {
- return current.filter((entry) => entry !== relativePath);
- }
-
- if (maxSelections && current.length >= maxSelections) {
- return [...current.slice(current.length - maxSelections + 1), relativePath];
- }
-
- return [...current, relativePath];
- });
+ function moveSelected(index: number, direction: -1 | 1) {
+ if (!multiple) return;
+ const current = [...selection as string[]];
+ const target = index + direction;
+ if (target < 0 || target >= current.length) return;
+ [current[index], current[target]] = [current[target], current[index]];
+ setSelection(current);
+ onSelectionChange?.(current);
}
- function clearSelection() {
- setSelection(multiple ? [] : "");
+ function onSearchKeyDown(event: KeyboardEvent) {
+ if (event.key !== "Enter") return;
+ event.preventDefault();
+ loadBrowserPage(1, query);
}
return (
- {multiple ? (
-
- ) : (
-
- )}
+ {multiple ?
:
}
-
-
{label}
- {helperText ?
{helperText}
: null}
-
-
- setOpen(true)} type="button">Browse library
- {selectedPaths.length > 0 ? Clear : null}
-
+
{label} {helperText ?
{helperText}
: null}
+
Browse library {selectedPaths.length > 0 ? { setSelection(multiple ? [] : ""); onSelectionChange?.([]); }} type="button">Clear : null}
+
- {selectedItems.length > 0 ? selectedItems.map((item) => (
-
setOpen(true)}
- type="button"
- >
- {item.kind === "image"
- ?
- : {item.kind.toUpperCase()} }
-
- {item.fileName}
- {item.folder}
-
-
- )) :
No media selected.
}
+ {selectedPaths.length > 0 ? selectedPaths.map((relativePath, index) => {
+ const item = itemByPath.get(relativePath);
+ return (
+
+ {item?.kind === "image" ? : {item?.kind?.toUpperCase() || "MEDIA"} }
+ {item?.fileName || relativePath.split("/").pop()} {item?.folder || relativePath}
+ {multiple ? moveSelected(index, -1)} type="button">↑ moveSelected(index, 1)} type="button">↓ : null}
+ toggleItem(relativePath)} type="button">×
+
+ );
+ }) :
No media selected.
}
{open ? (
-
setOpen(false)} type="button" />
-
-
-
-
{label}
-
Browse every indexed file from the mounted media library and assign it visually without typing paths.
-
-
setOpen(false)} type="button">✕
-
+
+
+
Browse the writable mounted library. Selection is saved with the content record; no path entry is required.
×
-
- Filter
- setQuery(event.target.value)} placeholder="Search filename, alt text, tags, or assignment" type="search" value={query} />
-
-
- Folder
- setFolder(event.target.value)} value={folder}>
- {folders.map((entry) => {entry === "all" ? "All folders" : entry} )}
-
-
+
Search setQuery(event.target.value)} placeholder="Filename, folder, tag, or assignment" ref={searchRef} type="search" value={query} /> loadBrowserPage(1, query)} type="button">Search
+
Folder on this page setFolder(event.target.value)} value={folder}>{folders.map((entry) => {entry === "all" ? "All folders" : entry} )}
+
{loading ? "Loading..." : `${total.toLocaleString()} indexed files`}
-
- {filteredItems.map((item) => {
+ {loadError ?
{loadError}
: null}
+
+ {visibleItems.map((item) => {
const selected = selectedPaths.includes(item.relativePath);
- return (
-
toggleItem(item.relativePath)}
- type="button"
- >
-
- {item.kind === "image"
- ?
- :
{item.kind.toUpperCase()} }
-
-
-
{item.fileName}
-
{item.folder}
-
{item.pieceSlug || item.pageSlug || item.postSlug || "Unassigned"}
-
-
- );
+ return
toggleItem(item.relativePath)} type="button">{item.kind === "image" ? : {item.kind.toUpperCase()} }
{item.fileName} {item.folder}
{item.pieceSlug || item.pageSlug || item.postSlug || "Unassigned"}{item.reviewed ? " · reviewed" : " · review needed"} ;
})}
+ {!loading && visibleItems.length === 0 ?
No media matches this page and folder filter.
: null}
+
loadBrowserPage(page - 1, deferredQuery)} type="button">Previous Page {page} of {totalPages} = totalPages} onClick={() => loadBrowserPage(page + 1, deferredQuery)} type="button">Next {multiple ? Use {selectedPaths.length} selected : null}
) : null}
diff --git a/site/components/piece-media-editor.tsx b/site/components/piece-media-editor.tsx
new file mode 100644
index 0000000..18cdaf0
--- /dev/null
+++ b/site/components/piece-media-editor.tsx
@@ -0,0 +1,95 @@
+"use client";
+
+import Image from "next/image";
+import { useState } from "react";
+import { MediaPicker, type MediaPickerItem } from "@/components/media-picker";
+import type { MediaPageRequest, MediaPageResult } from "@/lib/actions";
+import type { PieceMediaLinkRecord } from "@/lib/db";
+import { toMediaUrl } from "@/lib/format";
+import type { EditablePieceMediaRole, NormalizedPieceMediaLink } from "@/lib/piece-media";
+
+const DISPLAY_ROLES: EditablePieceMediaRole[] = ["hero", "gallery", "detail", "context"];
+const BUILD_ROLES: EditablePieceMediaRole[] = ["process", "drawing", "plan", "installation", "source"];
+
+function editableLink(link: PieceMediaLinkRecord): NormalizedPieceMediaLink {
+ return {
+ relativePath: link.relativePath,
+ role: link.role === "private-project" ? "source" : link.role,
+ stage: link.stage,
+ occurredAt: link.occurredAt,
+ title: link.title,
+ caption: link.caption,
+ technicalNote: link.technicalNote,
+ altOverride: link.altOverride,
+ displayOrder: link.displayOrder,
+ public: link.public
+ };
+}
+
+export function PieceMediaEditor({
+ items,
+ legacyPaths,
+ links: initialLinks,
+ loadPageAction
+}: {
+ items: MediaPickerItem[];
+ legacyPaths: string[];
+ links: PieceMediaLinkRecord[];
+ loadPageAction: (request: MediaPageRequest) => Promise
;
+}) {
+ const normalizedInitialLinks = initialLinks.map(editableLink);
+ const initialDisplayPaths = new Set(normalizedInitialLinks.filter((link) => DISPLAY_ROLES.includes(link.role)).map((link) => link.relativePath));
+ const legacyLinks = legacyPaths.filter((path) => !initialDisplayPaths.has(path)).map((relativePath, index): NormalizedPieceMediaLink => ({ relativePath, role: initialDisplayPaths.size === 0 && index === 0 ? "hero" : "gallery", stage: null, occurredAt: null, title: "", caption: "", technicalNote: "", altOverride: null, displayOrder: index, public: true }));
+ const [links, setLinks] = useState([...normalizedInitialLinks, ...legacyLinks]);
+ const itemMap = new Map(items.map((item) => [item.relativePath, item]));
+ const displayPaths = links.filter((link) => DISPLAY_ROLES.includes(link.role)).sort((left, right) => left.displayOrder - right.displayOrder).map((link) => link.relativePath);
+ const buildPaths = links.filter((link) => BUILD_ROLES.includes(link.role)).sort((left, right) => left.displayOrder - right.displayOrder).map((link) => link.relativePath);
+ const serializedLinks = links.map((link, index) => ({ ...link, displayOrder: index }));
+
+ function synchronizeGroup(groupRoles: EditablePieceMediaRole[], paths: string[], defaultRole: EditablePieceMediaRole) {
+ setLinks((current) => {
+ const retained = current.filter((link) => !groupRoles.includes(link.role));
+ const previous = current.filter((link) => groupRoles.includes(link.role));
+ const nextGroup = paths.map((relativePath, index): NormalizedPieceMediaLink => {
+ const existing = previous.find((link) => link.relativePath === relativePath);
+ const role = defaultRole === "gallery"
+ ? index === 0 ? "hero" : existing && DISPLAY_ROLES.includes(existing.role) && existing.role !== "hero" ? existing.role : "gallery"
+ : existing && BUILD_ROLES.includes(existing.role) ? existing.role : defaultRole;
+ return existing
+ ? { ...existing, role, displayOrder: index }
+ : { relativePath, role, stage: null, occurredAt: null, title: "", caption: "", technicalNote: "", altOverride: null, displayOrder: index, public: false };
+ });
+ return [...nextGroup, ...retained].map((link, index) => ({ ...link, displayOrder: index }));
+ });
+ }
+
+ function patchLink(index: number, patch: Partial) {
+ setLinks((current) => current.map((link, currentIndex) => currentIndex === index ? { ...link, ...patch } : link));
+ }
+
+ return (
+
+
+ synchronizeGroup(DISPLAY_ROLES, paths, "gallery")} selectionMode="multiple" />
+ synchronizeGroup(BUILD_ROLES, paths, "process")} selectionMode="multiple" />
+
+ {links.length > 0 ? Roles, captions, stages, and publication {links.map((link, index) => {
+ const item = itemMap.get(link.relativePath);
+ const buildRole = BUILD_ROLES.includes(link.role);
+ return
+ {item?.kind === "image" ? : {item?.kind || "media"} }
+
+ ;
+ })}
: null}
+
+ );
+}
diff --git a/site/components/site-chrome.tsx b/site/components/site-chrome.tsx
index 02fdce9..d962d01 100644
--- a/site/components/site-chrome.tsx
+++ b/site/components/site-chrome.tsx
@@ -46,12 +46,6 @@ export { CategoryIcon } from "@/components/category-icon";
const RESERVED_NAV_SLUGS = new Set(["home", "portfolio", "shop", "journal", "process", "commissions", "requests", "studio", "about", "account", "search", "media", "contact"]);
-type RepoLabelSettings = { repoLabel?: unknown };
-
-function getRepoLabel(site: RepoLabelSettings) {
- return typeof site.repoLabel === "string" && site.repoLabel.trim() ? site.repoLabel : "GitHub repository";
-}
-
export async function SiteHeader() {
const site = getSiteSettings();
const user = await getViewer();
@@ -91,9 +85,27 @@ export async function SiteHeader() {
export function SiteFooter() {
const site = getSiteSettings();
- const repoLabel = getRepoLabel(site as unknown as RepoLabelSettings);
+ const groups = [...site.footer.groups].filter((group) => group.visible).sort((left, right) => left.order - right.order);
return (
-
+
+
+ {site.footer.introHeading}
{site.footer.introBody}
+ {groups.map((group) => (
+
+ {group.heading}
+
+ {[...group.items].filter((item) => item.visible).sort((left, right) => left.order - right.order).map((item) => {
+ const content = <>{item.label} {item.value} >;
+ if (item.type === "internal-link") return {content} ;
+ if (item.type === "email") return {content} ;
+ if (item.type === "external-link") return {content} ;
+ return {content} ;
+ })}
+
+
+ ))}
+
+
);
}
diff --git a/site/components/site-structure-editor.tsx b/site/components/site-structure-editor.tsx
new file mode 100644
index 0000000..6f67d9e
--- /dev/null
+++ b/site/components/site-structure-editor.tsx
@@ -0,0 +1,118 @@
+"use client";
+
+import { useActionState, useState } from "react";
+import type { FooterConfiguration, FooterGroupDefinition, FooterItemDefinition, HomeServiceDefinition } from "@/lib/seed";
+
+type ActionState = { status: "idle" | "success" | "error"; message: string };
+type SaveAction = (previousState: ActionState, formData: FormData) => Promise;
+
+const initialState: ActionState = { status: "idle", message: "" };
+
+function nextId(prefix: string) {
+ return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`;
+}
+
+function moveItem(items: T[], index: number, direction: -1 | 1) {
+ const target = index + direction;
+ if (target < 0 || target >= items.length) return items;
+ const next = [...items];
+ [next[index], next[target]] = [next[target], next[index]];
+ return next;
+}
+
+function OrderedButtons({ index, length, label, onMove }: { index: number; length: number; label: string; onMove: (direction: -1 | 1) => void }) {
+ return (
+
+ onMove(-1)} type="button">↑
+ onMove(1)} type="button">↓
+
+ );
+}
+
+export function SiteStructureEditor({ footer: initialFooter, homeServices: initialServices, saveAction }: { footer: FooterConfiguration; homeServices: HomeServiceDefinition[]; saveAction: SaveAction }) {
+ const [state, formAction, pending] = useActionState(saveAction, initialState);
+ const [footer, setFooter] = useState(initialFooter);
+ const [services, setServices] = useState(initialServices);
+
+ const serializedServices = services.map((service, index) => ({ ...service, order: index * 10 }));
+ const serializedFooter: FooterConfiguration = {
+ ...footer,
+ groups: footer.groups.map((group, groupIndex) => ({
+ ...group,
+ order: groupIndex * 10,
+ items: group.items.map((item, itemIndex) => ({ ...item, order: itemIndex * 10 }))
+ }))
+ };
+
+ function patchService(index: number, patch: Partial) {
+ setServices((current) => current.map((service, serviceIndex) => serviceIndex === index ? { ...service, ...patch } : service));
+ }
+
+ function patchGroup(index: number, patch: Partial) {
+ setFooter((current) => ({ ...current, groups: current.groups.map((group, groupIndex) => groupIndex === index ? { ...group, ...patch } : group) }));
+ }
+
+ function patchFooterItem(groupIndex: number, itemIndex: number, patch: Partial) {
+ setFooter((current) => ({
+ ...current,
+ groups: current.groups.map((group, currentGroupIndex) => currentGroupIndex === groupIndex
+ ? { ...group, items: group.items.map((item, currentItemIndex) => currentItemIndex === itemIndex ? { ...item, ...patch } : item) }
+ : group)
+ }));
+ }
+
+ return (
+
+
+
+
+
+ Homepage
Service links Every card has a visible destination and can be reordered or hidden.
setServices((current) => [...current, { id: nextId("service"), title: "New service", body: "Describe what visitors will find.", href: "/contact", linkLabel: "Open", visible: true, order: current.length * 10 }])} type="button">Add service
+
+ {services.map((service, index) => (
+
+ {service.title} {service.visible ? "Visible" : "Hidden"}
+
+
+ ))}
+
+
+
+
+ Site footer
Groups and links Edit headings, contact details, credits, and link behavior without punctuation separators.
setFooter((current) => ({ ...current, groups: [...current.groups, { id: nextId("group"), heading: "New group", visible: true, order: current.groups.length * 10, items: [{ id: nextId("item"), label: "Label", value: "Value", url: "", type: "text", visible: true, newTab: false, order: 0 }] }] }))} type="button">Add group
+ Footer heading setFooter((current) => ({ ...current, introHeading: event.target.value }))} value={footer.introHeading} />Footer introduction setFooter((current) => ({ ...current, introBody: event.target.value }))} rows={2} value={footer.introBody} />
+
+ {footer.groups.map((group, groupIndex) => (
+
+ {group.heading} {group.items.length} item{group.items.length === 1 ? "" : "s"}
+
+
Group heading patchGroup(groupIndex, { heading: event.target.value })} value={group.heading} /> patchGroup(groupIndex, { visible: event.target.checked })} type="checkbox" />Visible
+
+ {group.items.map((item, itemIndex) => (
+
+
+ Label patchFooterItem(groupIndex, itemIndex, { label: event.target.value })} value={item.label} />
+ Displayed value patchFooterItem(groupIndex, itemIndex, { value: event.target.value })} value={item.value} />
+ Type patchFooterItem(groupIndex, itemIndex, { type: event.target.value as FooterItemDefinition["type"] })} value={item.type}>Text Site link External link Email
+
+ {item.type !== "text" ? Destination patchFooterItem(groupIndex, itemIndex, { url: event.target.value })} placeholder={item.type === "email" ? "mailto:name@example.com" : "/page or https://example.com"} value={item.url} /> : null}
+ patchFooterItem(groupIndex, itemIndex, { visible: event.target.checked })} type="checkbox" />Visible {item.type === "external-link" ? patchFooterItem(groupIndex, itemIndex, { newTab: event.target.checked })} type="checkbox" />New tab : null} patchGroup(groupIndex, { items: moveItem(group.items, itemIndex, direction) })} /> patchGroup(groupIndex, { items: group.items.filter((_, currentIndex) => currentIndex !== itemIndex) })} type="button">Remove item
+
+ ))}
+
+
patchGroup(groupIndex, { items: [...group.items, { id: nextId("item"), label: "Label", value: "Value", url: "", type: "text", visible: true, newTab: false, order: group.items.length * 10 }] })} type="button">Add item setFooter((current) => ({ ...current, groups: moveItem(current.groups, groupIndex, direction) }))} /> setFooter((current) => ({ ...current, groups: current.groups.filter((_, currentIndex) => currentIndex !== groupIndex) }))} type="button">Remove group
+
+
+ ))}
+
+
+
+ {pending ? "Saving..." : "Save homepage and footer"} {state.message ?
{state.message}
: null}
+
+ );
+}
diff --git a/site/lib/actions.ts b/site/lib/actions.ts
index a710c08..9da5f4b 100644
--- a/site/lib/actions.ts
+++ b/site/lib/actions.ts
@@ -26,6 +26,7 @@ import {
getUserByVerificationToken,
listCartItems,
listMedia,
+ listPieceMediaLinks,
listPieces,
markEmailVerified,
patchMediaMetadata,
@@ -49,6 +50,7 @@ import {
finishMediaRenameHistory,
recordAdminEditAudit,
renameMediaRecordAndReferences,
+ replacePieceMediaLinks,
startMediaRenameHistory,
type CommissionTypeRecord,
type MediaAssignmentFilter,
@@ -79,7 +81,9 @@ import { createCleanedBackgroundVariant, getAiServiceStatus } from "@/lib/ai-ser
import { buildMediaVerificationQueue, type MediaMatchCandidate } from "@/lib/media-audit";
import { categoryKey, normalizePieceCategories } from "@/lib/categories";
import { normalizeBuiltinCategoryIcon, sanitizeCategoryIconSvg } from "@/lib/category-icons";
-import { getPieceInquiryMode, getPiecePriceMode, getPieceReviewsMode, pieceCanEnterCart } from "@/lib/piece-model";
+import { normalizeFooterConfiguration, normalizeHomeServices } from "@/lib/site-structure";
+import { normalizePieceMediaLinks } from "@/lib/piece-media";
+import { getPieceInquiryMode, getPiecePriceMode, getPieceReviewsMode, pieceAcceptsReviews, pieceAllowsInquiry, pieceCanEnterCart } from "@/lib/piece-model";
function revalidatePagePaths(slug: string) {
revalidatePath("/", "layout");
revalidatePath("/");
@@ -564,11 +568,19 @@ export async function submitContactRequestAction(formData: FormData) {
const leadTimeDays = parseInteger(formData.get("leadTimeDays"), 0);
const aiPreviewPath = optionalField(formData.get("aiPreviewPath"));
+ const requestedPieceSlug = optionalField(formData.get("pieceSlug")) || null;
+ if (requestedPieceSlug) {
+ const requestedPiece = getPiece(requestedPieceSlug);
+ if (!requestedPiece || !pieceAllowsInquiry(requestedPiece)) {
+ redirect(`/contact?error=${encodeURIComponent("This piece is not currently accepting inquiries.")}`);
+ }
+ }
+
const reference = createProject({
userEmail: user?.email ?? null,
guestName,
guestEmail,
- pieceSlug: optionalField(formData.get("pieceSlug")) || null,
+ pieceSlug: requestedPieceSlug,
commissionTypeSlug: requestType || null,
kind: "commission",
status: "Request received",
@@ -665,11 +677,18 @@ export async function submitCommissionAction(formData: FormData) {
const estimatedTotalCents = parseInteger(formData.get("estimatedTotalCents"), 0);
const leadTimeDays = parseInteger(formData.get("leadTimeDays"), 0);
const aiPreviewPath = optionalField(formData.get("aiPreviewPath"));
+ const requestedPieceSlug = optionalField(formData.get("pieceSlug")) || null;
+ if (requestedPieceSlug) {
+ const requestedPiece = getPiece(requestedPieceSlug);
+ if (!requestedPiece || !pieceAllowsInquiry(requestedPiece)) {
+ redirect(`/commissions?error=${encodeURIComponent("This piece is not currently accepting custom requests.")}`);
+ }
+ }
const reference = createProject({
userEmail: user?.email ?? null,
guestName,
guestEmail,
- pieceSlug: optionalField(formData.get("pieceSlug")) || null,
+ pieceSlug: requestedPieceSlug,
commissionTypeSlug: optionalField(formData.get("commissionTypeSlug")) || null,
kind: "commission",
status: "Brief received",
@@ -762,6 +781,10 @@ export async function submitProjectReplyAction(formData: FormData) {
export async function submitReviewAction(formData: FormData) {
const pieceSlug = requiredField(formData.get("pieceSlug"), "Piece");
+ const piece = getPiece(pieceSlug);
+ if (!piece || !pieceAcceptsReviews(piece)) {
+ redirect(`/portfolio/${encodeURIComponent(pieceSlug)}?error=${encodeURIComponent("Reviews are not open for this piece.")}`);
+ }
const reviewerName = requiredField(formData.get("reviewerName"), "Your name");
const rating = parseInteger(formData.get("rating"), 5);
saveReview({
@@ -849,6 +872,34 @@ export async function saveSiteSettingsAction(formData: FormData) {
redirect("/studio?panel=settings&saved=settings");
}
+export type SiteStructureActionState = { status: "idle" | "success" | "error"; message: string };
+
+export async function saveSiteStructureAction(_: SiteStructureActionState, formData: FormData): Promise {
+ const currentAdmin = await requireAdmin();
+ const existing = getSiteSettings();
+ try {
+ const footer = normalizeFooterConfiguration(JSON.parse(requiredField(formData.get("footerJson"), "Footer configuration")) as unknown);
+ const homeServices = normalizeHomeServices(JSON.parse(requiredField(formData.get("homeServicesJson"), "Homepage services")) as unknown);
+ withDatabaseTransaction(() => {
+ saveSiteSettings({ ...existing, footer, homeServices });
+ recordAdminEditAudit({
+ actorEmail: currentAdmin.email,
+ entityType: "site-structure",
+ entityKey: "home-footer",
+ operation: "update",
+ before: { footer: existing.footer, homeServices: existing.homeServices },
+ after: { footer, homeServices }
+ });
+ });
+ revalidatePath("/", "layout");
+ revalidatePath("/");
+ revalidatePath("/studio");
+ return { status: "success", message: "Homepage links and footer saved." };
+ } catch (error) {
+ return { status: "error", message: error instanceof Error ? error.message : "Site structure could not be saved." };
+ }
+}
+
export type CategoryActionState = { status: "idle" | "success" | "error"; message: string; categoryKey?: string };
export async function savePieceCategoryAction(_: CategoryActionState, formData: FormData): Promise {
@@ -1005,13 +1056,20 @@ export async function deletePageAction(formData: FormData) {
}
export async function savePieceAction(formData: FormData) {
- await requireAdmin();
+ const currentAdmin = await requireAdmin();
const slug = requiredField(formData.get("slug"), "Piece slug");
const current = getPiece(slug);
const pieceJson = optionalField(formData.get("pieceJson"));
+ const submittedMediaLinks = formData.has("mediaLinksJson")
+ ? normalizePieceMediaLinks(JSON.parse(requiredField(formData.get("mediaLinksJson"), "Piece media relations")) as unknown)
+ : null;
+ const selectedMediaPaths = submittedMediaLinks
+ ? submittedMediaLinks.filter((link) => ["hero", "gallery", "detail", "context"].includes(link.role)).map((link) => link.relativePath)
+ : formData.has("mediaPathsText") ? [...new Set(parseListField(formData.get("mediaPathsText")))] : null;
const priceMode = (formData.has("priceMode") ? optionalField(formData.get("priceMode")) : current ? getPiecePriceMode(current) : "not-listed") as PieceRecord["priceMode"];
const inquiryMode = (formData.has("inquiryMode") ? optionalField(formData.get("inquiryMode")) : current ? getPieceInquiryMode(current) : "disabled") as PieceRecord["inquiryMode"];
const reviewsMode = (formData.has("reviewsMode") ? optionalField(formData.get("reviewsMode")) : current ? getPieceReviewsMode(current) : "hidden") as PieceRecord["reviewsMode"];
+ withDatabaseTransaction(() => {
savePiece(pieceJson
? parseJsonField(formData.get("pieceJson"), current!)
: {
@@ -1047,7 +1105,7 @@ export async function savePieceAction(formData: FormData) {
commissionTypeSlug: formData.has("commissionTypeSlug") ? optionalField(formData.get("commissionTypeSlug")) || null : current?.commissionTypeSlug ?? null,
inventoryCount: parseInteger(formData.get("inventoryCount"), current?.inventoryCount ?? 0),
leadTimeDays: parseInteger(formData.get("leadTimeDays"), current?.leadTimeDays ?? 0),
- mediaPaths: formData.has("mediaPathsText") ? parseListField(formData.get("mediaPathsText")) : current?.mediaPaths || [],
+ mediaPaths: (selectedMediaPaths ?? current?.mediaPaths) || [],
featuredRank: parseInteger(formData.get("featuredRank"), current?.featuredRank ?? 99),
ownerEmail: optionalField(formData.get("ownerEmail")) || current?.ownerEmail || "woodsmithbb@proton.me",
metadata: {
@@ -1058,6 +1116,39 @@ export async function savePieceAction(formData: FormData) {
mediaReviewRequired: parseBooleanField(formData.get("mediaReviewRequired"))
}
});
+ if (submittedMediaLinks) {
+ const privateLinks = listPieceMediaLinks(slug).filter((link) => link.role === "private-project");
+ const gatedLinks = submittedMediaLinks.map((link) => {
+ const media = getMedia(link.relativePath);
+ if (!media) throw new Error(`Selected media '${link.relativePath}' is no longer indexed.`);
+ return { ...link, public: link.public && media.reviewed };
+ });
+ replacePieceMediaLinks(slug, [...gatedLinks, ...privateLinks], currentAdmin.email);
+ } else if (selectedMediaPaths) {
+ const preservedLinks = listPieceMediaLinks(slug).filter((link) => !["hero", "gallery", "detail", "context"].includes(link.role));
+ const currentDisplayLinks = listPieceMediaLinks(slug).filter((link) => ["hero", "gallery", "detail", "context"].includes(link.role));
+ const publishRequested = parseBooleanField(formData.get("verifiedMedia"));
+ const displayLinks = selectedMediaPaths.map((relativePath, index) => {
+ const media = getMedia(relativePath);
+ if (!media) throw new Error(`Selected media '${relativePath}' is no longer indexed.`);
+ const role = index === 0 ? "hero" as const : "gallery" as const;
+ const existingLink = currentDisplayLinks.find((link) => link.relativePath === relativePath);
+ return {
+ relativePath,
+ role,
+ stage: null,
+ occurredAt: existingLink?.occurredAt ?? null,
+ title: existingLink?.title ?? "",
+ caption: existingLink?.caption ?? "",
+ technicalNote: existingLink?.technicalNote ?? "",
+ altOverride: existingLink?.altOverride ?? null,
+ displayOrder: index,
+ public: publishRequested && media.reviewed
+ };
+ });
+ replacePieceMediaLinks(slug, [...displayLinks, ...preservedLinks], currentAdmin.email);
+ }
+ });
revalidatePieceSurfaces(slug);
redirect(`/studio?panel=pieces&saved=piece&piece=${encodeURIComponent(slug)}`);
}
diff --git a/site/lib/db.ts b/site/lib/db.ts
index 3c9ee4e..7db7113 100644
--- a/site/lib/db.ts
+++ b/site/lib/db.ts
@@ -12,6 +12,7 @@ import {
} from "./seed.ts";
import { scanMediaLibrary } from "./media.ts";
import { normalizePieceCategories } from "./categories.ts";
+import { safeFooterConfiguration, safeHomeServices } from "./site-structure.ts";
import { applySchemaMigrations } from "./database-migrations.ts";
import {
getPieceInquiryMode,
@@ -1572,6 +1573,8 @@ export function getSiteSettings() {
...fallback,
...activeSettings,
navigation: Array.isArray(stored.navigation) ? stored.navigation : fallback.navigation,
+ footer: safeFooterConfiguration((stored as SiteSettings & { footer?: unknown }).footer, fallback.footer),
+ homeServices: safeHomeServices((stored as SiteSettings & { homeServices?: unknown }).homeServices, [...fallback.homeServices]),
pieceCategories: normalizePieceCategories((stored as SiteSettings & { pieceCategories?: unknown }).pieceCategories)
};
}
diff --git a/site/lib/piece-media.test.mts b/site/lib/piece-media.test.mts
new file mode 100644
index 0000000..21029fe
--- /dev/null
+++ b/site/lib/piece-media.test.mts
@@ -0,0 +1,19 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { normalizePieceMediaLinks } from "./piece-media.ts";
+
+test("piece media relations preserve roles, order, stages, and normalized dates", () => {
+ const links = normalizePieceMediaLinks([
+ { relativePath: "Furniture/build-2.jpg", role: "process", stage: "Joinery", occurredAt: "2026-05-02", displayOrder: 20, public: true, caption: "Dry fit" },
+ { relativePath: "Furniture/hero.jpg", role: "hero", displayOrder: 0, public: true, altOverride: "Finished table" }
+ ]);
+ assert.deepEqual(links.map((link) => link.role), ["hero", "process"]);
+ assert.equal(links[1].stage, "Joinery");
+ assert.match(String(links[1].occurredAt), /^2026-05-02T/);
+});
+
+test("piece media relations reject traversal, duplicate heroes, and unknown roles", () => {
+ assert.throws(() => normalizePieceMediaLinks([{ relativePath: "../secret.jpg", role: "gallery" }]), /safe mounted-library/);
+ assert.throws(() => normalizePieceMediaLinks([{ relativePath: "a.jpg", role: "hero" }, { relativePath: "b.jpg", role: "hero" }]), /only one hero/);
+ assert.throws(() => normalizePieceMediaLinks([{ relativePath: "a.jpg", role: "thumbnail" }]), /Unsupported/);
+});
diff --git a/site/lib/piece-media.ts b/site/lib/piece-media.ts
new file mode 100644
index 0000000..bbe3de6
--- /dev/null
+++ b/site/lib/piece-media.ts
@@ -0,0 +1,71 @@
+export const EDITABLE_PIECE_MEDIA_ROLES = ["hero", "gallery", "detail", "context", "process", "drawing", "plan", "installation", "source"] as const;
+export type EditablePieceMediaRole = (typeof EDITABLE_PIECE_MEDIA_ROLES)[number];
+
+export type NormalizedPieceMediaLink = {
+ relativePath: string;
+ role: EditablePieceMediaRole;
+ stage: string | null;
+ occurredAt: string | null;
+ title: string;
+ caption: string;
+ technicalNote: string;
+ altOverride: string | null;
+ displayOrder: number;
+ public: boolean;
+};
+
+function optionalText(value: unknown, maxLength: number) {
+ const normalized = String(value ?? "").trim();
+ if (normalized.length > maxLength) throw new Error(`Media relation text exceeds ${maxLength} characters.`);
+ return normalized;
+}
+
+function relativePath(value: unknown) {
+ const normalized = String(value ?? "").trim().replace(/\\/g, "/");
+ if (!normalized || normalized.startsWith("/") || normalized.includes("../") || normalized.includes("\0")) {
+ throw new Error("Media relations require a safe mounted-library relative path.");
+ }
+ return normalized;
+}
+
+function occurredAt(value: unknown) {
+ const normalized = String(value ?? "").trim();
+ if (!normalized) return null;
+ const parsed = new Date(normalized);
+ if (Number.isNaN(parsed.getTime())) throw new Error("Build-record dates must be valid dates.");
+ return parsed.toISOString();
+}
+
+export function normalizePieceMediaLinks(value: unknown): NormalizedPieceMediaLink[] {
+ if (!Array.isArray(value)) throw new Error("Piece media relations must be a list.");
+ if (value.length > 64) throw new Error("A piece cannot contain more than 64 media relations.");
+ const seen = new Set();
+ let heroCount = 0;
+ const links = value.map((entry, index): NormalizedPieceMediaLink => {
+ if (!entry || typeof entry !== "object") throw new Error("Each piece media relation must be an object.");
+ const record = entry as Record;
+ const role = String(record.role ?? "gallery") as EditablePieceMediaRole;
+ if (!(EDITABLE_PIECE_MEDIA_ROLES as readonly string[]).includes(role)) throw new Error(`Unsupported piece media role '${role}'.`);
+ if (role === "hero") heroCount += 1;
+ const path = relativePath(record.relativePath);
+ const stage = optionalText(record.stage, 100) || null;
+ const identity = `${path}\0${role}\0${stage ?? ""}`;
+ if (seen.has(identity)) throw new Error(`Duplicate '${role}' relation for '${path}'.`);
+ seen.add(identity);
+ const parsedOrder = Number(record.displayOrder);
+ return {
+ relativePath: path,
+ role,
+ stage,
+ occurredAt: occurredAt(record.occurredAt),
+ title: optionalText(record.title, 160),
+ caption: optionalText(record.caption, 1000),
+ technicalNote: optionalText(record.technicalNote, 1000),
+ altOverride: optionalText(record.altOverride, 400) || null,
+ displayOrder: Number.isFinite(parsedOrder) ? Math.max(0, Math.min(9999, Math.round(parsedOrder))) : index,
+ public: record.public === true
+ };
+ });
+ if (heroCount > 1) throw new Error("A piece can have only one hero image.");
+ return links.sort((left, right) => left.displayOrder - right.displayOrder);
+}
diff --git a/site/lib/seed.ts b/site/lib/seed.ts
index 3df66b9..85e95f2 100644
--- a/site/lib/seed.ts
+++ b/site/lib/seed.ts
@@ -57,7 +57,7 @@ export type SeedCommissionType = {
active: boolean;
};
-export type SeedProfile = {
+export type SeedProfile = {
email: string;
displayName: string;
role: "admin" | "woodworker" | "customer";
@@ -66,8 +66,43 @@ export type SeedProfile = {
publicProfile: boolean;
avatarPath?: string;
links: Array<{ label: string; url: string }>;
- metadata: Record;
-};
+ metadata: Record;
+};
+
+export type HomeServiceDefinition = {
+ id: string;
+ title: string;
+ body: string;
+ href: string;
+ linkLabel: string;
+ visible: boolean;
+ order: number;
+};
+
+export type FooterItemDefinition = {
+ id: string;
+ label: string;
+ value: string;
+ url: string;
+ type: "text" | "internal-link" | "external-link" | "email";
+ visible: boolean;
+ newTab: boolean;
+ order: number;
+};
+
+export type FooterGroupDefinition = {
+ id: string;
+ heading: string;
+ visible: boolean;
+ order: number;
+ items: FooterItemDefinition[];
+};
+
+export type FooterConfiguration = {
+ introHeading: string;
+ introBody: string;
+ groups: FooterGroupDefinition[];
+};
export const siteSettingsSeed = {
brandName: "Beaman Woodworks",
@@ -81,7 +116,7 @@ export const siteSettingsSeed = {
developerHeadline: "Website Developer",
notificationForwardEmail: "wbeaman1@gmail.com",
repoUrl: "https://x.gd/woodsmith_git",
- socialLinks: [
+ socialLinks: [
{ label: "Instagram", url: "" },
{ label: "Pinterest", url: "" },
{ label: "GitHub", url: "https://x.gd/woodsmith_git" }
@@ -93,6 +128,42 @@ export const siteSettingsSeed = {
{ label: "About", href: "/about" },
{ label: "Contact", href: "/contact" }
],
+ footer: {
+ introHeading: "Beaman Woodworks",
+ introBody: "Furniture, cabinetry, and small-batch work made in the Beaman woodshop.",
+ groups: [
+ {
+ id: "woodshop-contact",
+ heading: "Woodshop contact",
+ visible: true,
+ order: 10,
+ items: [
+ { id: "builder", label: "Master Builder", value: "William Beaman", url: "", type: "text", visible: true, newTab: false, order: 10 },
+ { id: "builder-email", label: "Email", value: "woodsmithbb@proton.me", url: "mailto:woodsmithbb@proton.me", type: "email", visible: true, newTab: false, order: 20 }
+ ]
+ },
+ {
+ id: "website-credit",
+ heading: "Website",
+ visible: true,
+ order: 20,
+ items: [
+ { id: "developer", label: "Design & development", value: "Cooper Beaman", url: "", type: "text", visible: true, newTab: false, order: 10 },
+ { id: "developer-email", label: "Email", value: "cooperbeaman@proton.me", url: "mailto:cooperbeaman@proton.me", type: "email", visible: true, newTab: false, order: 20 }
+ ]
+ },
+ {
+ id: "links",
+ heading: "Information",
+ visible: true,
+ order: 30,
+ items: [
+ { id: "care", label: "Care & warranty", value: "Care & warranty", url: "/care-and-warranty", type: "internal-link", visible: true, newTab: false, order: 10 },
+ { id: "repository", label: "Website source", value: "GitHub repository", url: "https://x.gd/woodsmith_git", type: "external-link", visible: true, newTab: true, order: 20 }
+ ]
+ }
+ ]
+ } as FooterConfiguration,
pieceCategories: defaultPieceCategories,
homepageFeaturedMode: "manual",
homepageFeaturedPieceSlugs: ["hallway-bench", "pastry-table", "pantry-cabinets", "footstool"],
@@ -125,7 +196,7 @@ export const siteSettingsSeed = {
replyTo: "woodsmithbb@proton.me",
forwardTo: "wbeaman1@gmail.com"
},
- homeSections: [
+ homeSections: [
{
key: "hero",
eyebrow: "Beaman Woodworks",
@@ -144,10 +215,16 @@ export const siteSettingsSeed = {
key: "bandwidth",
eyebrow: "Current bandwidth",
title: "Lead time updates follow the live build queue.",
- copy: "Capacity, work in progress, and lead-time guidance update from active projects so buyers can see the current workload before reaching out."
- }
- ]
-} as const;
+ copy: "Capacity, work in progress, and lead-time guidance update from active projects so buyers can see the current workload before reaching out."
+ }
+ ],
+ homeServices: [
+ { id: "portfolio", title: "Portfolio", body: "Finished pieces with verified photography, materials, dimensions, and build notes.", href: "/portfolio", linkLabel: "Browse finished work", visible: true, order: 10 },
+ { id: "shop", title: "Shop", body: "Available pieces with asking prices and clearly stated pickup, delivery, or shipping options.", href: "/shop", linkLabel: "See available pieces", visible: true, order: 20 },
+ { id: "custom", title: "Custom work", body: "Send room, use, size, material, and timing details for review before a quote is prepared.", href: "/contact", linkLabel: "Start an inquiry", visible: true, order: 30 },
+ { id: "care", title: "Care & warranty", body: "Practical finish care, repair support, and warranty information for completed work.", href: "/care-and-warranty", linkLabel: "Read care guidance", visible: true, order: 40 }
+ ] as HomeServiceDefinition[]
+} as const;
const furniture = (file: string) => `Furniture/${file}`;
const cabinets = (file: string) => `Cabinets/${file}`;
diff --git a/site/lib/site-structure.test.mts b/site/lib/site-structure.test.mts
new file mode 100644
index 0000000..0506182
--- /dev/null
+++ b/site/lib/site-structure.test.mts
@@ -0,0 +1,39 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { normalizeFooterConfiguration, normalizeHomeServices, normalizeSiteLink } from "./site-structure.ts";
+
+test("site links accept internal, http(s), and email targets while rejecting executable schemes", () => {
+ assert.equal(normalizeSiteLink("/contact", "internal-link"), "/contact");
+ assert.equal(normalizeSiteLink("maker@example.com", "email"), "mailto:maker@example.com");
+ assert.equal(normalizeSiteLink("https://example.com/work", "external-link"), "https://example.com/work");
+ assert.throws(() => normalizeSiteLink("javascript:alert(1)", "external-link"), /Links must/);
+ assert.throws(() => normalizeSiteLink("//host.invalid/path", "internal-link"), /Links must/);
+});
+
+test("footer configuration validates, orders, and normalizes link behavior", () => {
+ const footer = normalizeFooterConfiguration({
+ introHeading: "Beaman Woodworks",
+ introBody: "Built in the woodshop.",
+ groups: [{
+ id: "contact",
+ heading: "Contact",
+ visible: true,
+ order: 20,
+ items: [
+ { id: "email", label: "Email", value: "maker@example.com", type: "email", visible: true, order: 20 },
+ { id: "care", label: "Care", value: "Care guide", url: "/care", type: "internal-link", visible: true, order: 10 }
+ ]
+ }]
+ });
+ assert.deepEqual(footer.groups[0].items.map((item) => item.id), ["care", "email"]);
+ assert.equal(footer.groups[0].items[1].url, "mailto:maker@example.com");
+});
+
+test("homepage service definitions are ordered and reject unsafe destinations", () => {
+ const services = normalizeHomeServices([
+ { id: "shop", title: "Shop", body: "Available work", href: "/shop", linkLabel: "Open shop", visible: true, order: 20 },
+ { id: "portfolio", title: "Portfolio", body: "Past work", href: "/portfolio", linkLabel: "Browse", visible: true, order: 10 }
+ ]);
+ assert.deepEqual(services.map((service) => service.id), ["portfolio", "shop"]);
+ assert.throws(() => normalizeHomeServices([{ id: "bad", title: "Bad", body: "", href: "data:text/html,x", linkLabel: "Open", visible: true, order: 1 }]), /Links must/);
+});
diff --git a/site/lib/site-structure.ts b/site/lib/site-structure.ts
new file mode 100644
index 0000000..203ad30
--- /dev/null
+++ b/site/lib/site-structure.ts
@@ -0,0 +1,129 @@
+import type { FooterConfiguration, FooterGroupDefinition, FooterItemDefinition, HomeServiceDefinition } from "./seed.ts";
+
+function text(value: unknown, label: string, maxLength = 240) {
+ const normalized = String(value ?? "").trim();
+ if (!normalized) throw new Error(`${label} is required.`);
+ if (normalized.length > maxLength) throw new Error(`${label} exceeds ${maxLength} characters.`);
+ return normalized;
+}
+
+function optionalText(value: unknown, maxLength = 800) {
+ const normalized = String(value ?? "").trim();
+ if (normalized.length > maxLength) throw new Error(`Content exceeds ${maxLength} characters.`);
+ return normalized;
+}
+
+function identifier(value: unknown, fallback: string) {
+ const normalized = String(value ?? fallback).toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64);
+ return normalized || fallback;
+}
+
+function order(value: unknown, fallback: number) {
+ const parsed = Number(value);
+ return Number.isFinite(parsed) ? Math.max(0, Math.min(9999, Math.round(parsed))) : fallback;
+}
+
+export function normalizeSiteLink(value: unknown, type: FooterItemDefinition["type"] | "service") {
+ const candidate = String(value ?? "").trim();
+ if (type === "text") return "";
+ if (!candidate) throw new Error("A destination is required for each linked item.");
+ if ((type === "internal-link" || type === "service") && candidate.startsWith("/") && !candidate.startsWith("//")) return candidate;
+ if (type === "email") {
+ const email = candidate.replace(/^mailto:/i, "").trim();
+ if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return `mailto:${email}`;
+ throw new Error("Email links must contain a valid email address.");
+ }
+ if (type === "external-link" || type === "service") {
+ try {
+ const url = new URL(candidate);
+ if (url.protocol === "https:" || url.protocol === "http:") return url.toString();
+ } catch {
+ // Report one consistent validation message below.
+ }
+ }
+ throw new Error("Links must use a site-relative path, http(s) URL, or valid email destination.");
+}
+
+function normalizeFooterItem(value: unknown, index: number): FooterItemDefinition {
+ if (!value || typeof value !== "object") throw new Error("Each footer item must be a valid object.");
+ const record = value as Record;
+ const type = ["text", "internal-link", "external-link", "email"].includes(String(record.type))
+ ? String(record.type) as FooterItemDefinition["type"]
+ : "text";
+ return {
+ id: identifier(record.id, `item-${index + 1}`),
+ label: text(record.label, "Footer item label", 80),
+ value: text(record.value, "Footer item value", 240),
+ url: normalizeSiteLink(record.url || (type === "email" ? record.value : ""), type),
+ type,
+ visible: record.visible !== false,
+ newTab: type === "external-link" && record.newTab === true,
+ order: order(record.order, index * 10)
+ };
+}
+
+function normalizeFooterGroup(value: unknown, index: number): FooterGroupDefinition {
+ if (!value || typeof value !== "object") throw new Error("Each footer group must be a valid object.");
+ const record = value as Record;
+ if (!Array.isArray(record.items)) throw new Error("Each footer group needs an item list.");
+ if (record.items.length > 24) throw new Error("A footer group cannot contain more than 24 items.");
+ const items = record.items.map(normalizeFooterItem);
+ if (new Set(items.map((item) => item.id)).size !== items.length) throw new Error("Footer item identifiers must be unique within a group.");
+ return {
+ id: identifier(record.id, `group-${index + 1}`),
+ heading: text(record.heading, "Footer group heading", 80),
+ visible: record.visible !== false,
+ order: order(record.order, index * 10),
+ items: items.sort((left, right) => left.order - right.order)
+ };
+}
+
+export function normalizeFooterConfiguration(value: unknown): FooterConfiguration {
+ if (!value || typeof value !== "object") throw new Error("Footer configuration is missing.");
+ const record = value as Record;
+ if (!Array.isArray(record.groups) || record.groups.length === 0) throw new Error("Add at least one footer group.");
+ if (record.groups.length > 12) throw new Error("The footer cannot contain more than 12 groups.");
+ const groups = record.groups.map(normalizeFooterGroup);
+ if (new Set(groups.map((group) => group.id)).size !== groups.length) throw new Error("Footer group identifiers must be unique.");
+ return {
+ introHeading: text(record.introHeading, "Footer heading", 100),
+ introBody: optionalText(record.introBody, 600),
+ groups: groups.sort((left, right) => left.order - right.order)
+ };
+}
+
+export function normalizeHomeServices(value: unknown): HomeServiceDefinition[] {
+ if (!Array.isArray(value) || value.length === 0) throw new Error("Add at least one homepage service.");
+ if (value.length > 12) throw new Error("The homepage cannot contain more than 12 service links.");
+ const services = value.map((entry, index): HomeServiceDefinition => {
+ if (!entry || typeof entry !== "object") throw new Error("Each homepage service must be a valid object.");
+ const record = entry as Record;
+ return {
+ id: identifier(record.id, `service-${index + 1}`),
+ title: text(record.title, "Service title", 100),
+ body: optionalText(record.body, 500),
+ href: normalizeSiteLink(record.href, "service"),
+ linkLabel: text(record.linkLabel, "Service link label", 100),
+ visible: record.visible !== false,
+ order: order(record.order, index * 10)
+ };
+ });
+ if (new Set(services.map((service) => service.id)).size !== services.length) throw new Error("Homepage service identifiers must be unique.");
+ return services.sort((left, right) => left.order - right.order);
+}
+
+export function safeFooterConfiguration(value: unknown, fallback: FooterConfiguration) {
+ try {
+ return normalizeFooterConfiguration(value);
+ } catch {
+ return fallback;
+ }
+}
+
+export function safeHomeServices(value: unknown, fallback: HomeServiceDefinition[]) {
+ try {
+ return normalizeHomeServices(value);
+ } catch {
+ return fallback;
+ }
+}
diff --git a/site/lib/visual-audit.ts b/site/lib/visual-audit.ts
new file mode 100644
index 0000000..066caad
--- /dev/null
+++ b/site/lib/visual-audit.ts
@@ -0,0 +1,29 @@
+import { createHash, timingSafeEqual } from "node:crypto";
+import { headers } from "next/headers";
+
+const AUDIT_TOKEN_HEADER = "x-woodsmith-audit-token";
+
+function digest(value: string) {
+ return createHash("sha256").update(value).digest();
+}
+
+export function visualAuditTokenValid(
+ candidate: string | null | undefined
+) {
+ const configured = process.env.VISUAL_AUDIT_TOKEN?.trim() ?? "";
+ const received = candidate?.trim() ?? "";
+
+ if (!configured || !received) {
+ return false;
+ }
+
+ return timingSafeEqual(digest(configured), digest(received));
+}
+
+export async function visualAuditRequestAuthorized() {
+ const requestHeaders = await headers();
+
+ return visualAuditTokenValid(
+ requestHeaders.get(AUDIT_TOKEN_HEADER)
+ );
+}
\ No newline at end of file
diff --git a/site/package.json b/site/package.json
index da2db08..635097e 100644
--- a/site/package.json
+++ b/site/package.json
@@ -8,7 +8,7 @@
"start": "node --experimental-sqlite .next/standalone/server.js",
"lint": "eslint app components lib",
"typecheck": "tsc --noEmit",
- "test": "node --experimental-strip-types --test lib/media-scoring.test.mts lib/piece-model.test.mts lib/database-migrations.test.mts lib/media-reference-transaction.test.mts lib/category-icons.test.mts"
+ "test": "node --experimental-strip-types --test lib/media-scoring.test.mts lib/piece-model.test.mts lib/database-migrations.test.mts lib/media-reference-transaction.test.mts lib/category-icons.test.mts lib/site-structure.test.mts lib/piece-media.test.mts"
},
"dependencies": {
"@react-three/drei": "^10.7.7",
From 9e143dad61f54e33a5c2b5a0eb4c99aaafa956a7 Mon Sep 17 00:00:00 2001
From: Cooper Beaman <55124795+lowestprime@users.noreply.github.com>
Date: Sat, 11 Jul 2026 11:23:23 -0700
Subject: [PATCH 05/43] feat(commissions): add proportional 3D planning preview
---
site/app/globals.css | 188 +++++++++++++++-
site/components/commission-scene.tsx | 307 ++++++++++++++++++++++++++
site/components/visualizer.tsx | 315 ++++++++-------------------
site/lib/estimator.test.mts | 52 +++++
site/lib/estimator.ts | 120 +++++++---
site/package.json | 2 +-
6 files changed, 715 insertions(+), 269 deletions(-)
create mode 100644 site/components/commission-scene.tsx
create mode 100644 site/lib/estimator.test.mts
diff --git a/site/app/globals.css b/site/app/globals.css
index a4d4004..dcaf5d3 100644
--- a/site/app/globals.css
+++ b/site/app/globals.css
@@ -786,8 +786,10 @@ h3 {
grid-template-columns: minmax(0, 1.05fr) minmax(18rem, 0.95fr);
}
-.visualizer-stage-3d {
- position: relative;
+.visualizer-stage-3d {
+ position: relative;
+ min-width: 0;
+ width: 100%;
min-height: 28rem;
overflow: hidden;
border: 1px solid var(--line);
@@ -796,8 +798,133 @@ h3 {
linear-gradient(180deg, rgba(255, 255, 255, 0.08), transparent 36%),
radial-gradient(circle at 50% 88%, rgba(0, 0, 0, 0.28), transparent 44%),
var(--bg-card);
- perspective: 58rem;
-}
+ perspective: 58rem;
+}
+
+.commission-scene-shell {
+ display: grid;
+ grid-template-rows: auto minmax(22rem, 1fr) auto auto;
+ min-height: 28rem;
+ min-width: 0;
+ width: 100%;
+ max-width: 100%;
+ height: 100%;
+ outline: none;
+}
+
+.commission-scene-shell:focus-visible {
+ box-shadow: inset 0 0 0 2px var(--focus, var(--accent));
+}
+
+.commission-scene-toolbar {
+ position: relative;
+ z-index: 2;
+ display: flex;
+ min-width: 0;
+ max-width: 100%;
+ flex-wrap: wrap;
+ gap: 0.35rem;
+ padding: 0.55rem;
+ border-bottom: 1px solid var(--line);
+ background: color-mix(in srgb, var(--bg-elevated) 92%, transparent);
+}
+
+.commission-scene-toolbar button {
+ min-height: 2rem;
+ padding: 0.32rem 0.58rem;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-pill);
+ background: var(--bg-soft);
+ color: var(--muted);
+ font-size: 0.76rem;
+ line-height: 1;
+}
+
+.commission-scene-toolbar button:hover,
+.commission-scene-toolbar button.is-active,
+.commission-scene-toolbar button[aria-pressed="true"] {
+ border-color: color-mix(in srgb, var(--accent) 56%, var(--line));
+ background: color-mix(in srgb, var(--accent) 13%, var(--bg-elevated));
+ color: var(--text);
+}
+
+.commission-scene-canvas {
+ min-height: 22rem;
+ min-width: 0;
+ width: 100%;
+ max-width: 100%;
+ background: #0d0c0a;
+}
+
+.commission-scene-canvas canvas {
+ display: block;
+ min-height: 22rem;
+ touch-action: none;
+}
+
+.scene-dimension-label {
+ display: block;
+ width: max-content;
+ max-width: 14rem;
+ padding: 0.3rem 0.5rem;
+ border: 1px solid rgba(242, 216, 171, 0.38);
+ border-radius: var(--radius-pill);
+ background: rgba(13, 12, 10, 0.82);
+ color: #f2d8ab;
+ font-family: var(--font-display);
+ font-size: 0.76rem;
+ line-height: 1;
+ white-space: nowrap;
+}
+
+.commission-scene-help,
+.commission-scene-equivalent,
+.commission-scene-export-status {
+ margin: 0;
+ padding-inline: 0.7rem;
+ color: var(--muted);
+ font-size: 0.78rem;
+ line-height: 1.45;
+ overflow-wrap: anywhere;
+}
+
+.commission-scene-help {
+ padding-top: 0.55rem;
+}
+
+.commission-scene-equivalent {
+ color: var(--text);
+}
+
+.commission-scene-export-status {
+ min-height: 1.5rem;
+ padding-bottom: 0.55rem;
+}
+
+.visualizer-static-fallback,
+.visualizer-scene-loading {
+ display: grid;
+ place-items: center;
+ align-content: center;
+ gap: var(--space-3);
+ min-height: 28rem;
+ padding: var(--space-4);
+ text-align: center;
+}
+
+.visualizer-static-fallback > div,
+.visualizer-static-fallback svg {
+ display: block;
+ width: 100%;
+ max-height: 25rem;
+}
+
+.visualizer-static-fallback p,
+.visualizer-type-description {
+ margin: 0;
+ color: var(--muted);
+ font-size: 0.84rem;
+}
.visualizer-floor-grid {
position: absolute;
@@ -911,7 +1038,7 @@ h3 {
gap: var(--space-3);
}
-.compact-estimate {
+.compact-estimate {
border-top: 1px solid var(--line);
padding-top: var(--space-3);
}
@@ -1860,6 +1987,57 @@ img.cleanup-subject-isolate {
margin: 1.15rem 0 1.4rem;
}
+@media (max-width: 980px) {
+ .visualizer-3d-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .visualizer-stage-3d,
+ .commission-scene-shell,
+ .visualizer-static-fallback,
+ .visualizer-scene-loading {
+ min-height: 25rem;
+ }
+}
+
+@media (max-width: 640px) {
+ .custom-visualizer-3d {
+ gap: var(--space-3);
+ padding: 0.55rem;
+ }
+
+ .visualizer-panel-heading {
+ grid-template-columns: 1fr;
+ }
+
+ .visualizer-panel-heading > span {
+ width: fit-content;
+ }
+
+ .commission-scene-toolbar {
+ flex-wrap: nowrap;
+ overflow-x: auto;
+ overscroll-behavior-inline: contain;
+ scrollbar-width: thin;
+ }
+
+ .commission-scene-toolbar button {
+ flex: 0 0 auto;
+ }
+
+ .visualizer-stage-3d,
+ .commission-scene-shell,
+ .visualizer-static-fallback,
+ .visualizer-scene-loading {
+ min-height: 22rem;
+ }
+
+ .commission-scene-canvas,
+ .commission-scene-canvas canvas {
+ min-height: 18rem;
+ }
+}
+
.portfolio-filter-pill {
display: inline-flex;
align-items: center;
diff --git a/site/components/commission-scene.tsx b/site/components/commission-scene.tsx
new file mode 100644
index 0000000..a6352b8
--- /dev/null
+++ b/site/components/commission-scene.tsx
@@ -0,0 +1,307 @@
+"use client";
+
+import { Component, useEffect, useRef, useState, type ErrorInfo, type KeyboardEvent, type ReactNode } from "react";
+import { Canvas, useThree } from "@react-three/fiber";
+import { ContactShadows, Edges, Grid, Html, OrbitControls, OrthographicCamera, PerspectiveCamera } from "@react-three/drei";
+import { Vector3 } from "three";
+import {
+ normalizeVisualizerState,
+ resolveVisualizerTemplate,
+ type VisualizerState
+} from "@/lib/estimator";
+
+type ViewPreset = "isometric" | "front" | "side" | "top";
+type CameraCommandKind = "rotate-left" | "rotate-right" | "zoom-in" | "zoom-out" | "reset";
+type CameraCommand = { id: number; kind: CameraCommandKind } | null;
+type Position = [number, number, number];
+
+const INCH = 0.0254;
+
+const MATERIAL_COLORS: Record = {
+ "White Oak": "#c9ad7d",
+ "Black Walnut": "#5b3c2b",
+ Walnut: "#5b3c2b",
+ Cherry: "#9d563f",
+ Maple: "#ddd1b5",
+ "Hard Maple": "#ddd1b5",
+ "White maple": "#eee5d3",
+ "Bird's-eye maple": "#e8dbc0",
+ "Phenolic resin top": "#171717",
+ "Stone top": "#929296",
+ "Paint-grade hardwood": "#d4d3ce"
+};
+
+function clamp(value: number, min: number, max: number) {
+ return Math.max(min, Math.min(max, value));
+}
+
+function Part({ color, position, size }: { color: string; position: Position; size: Position }) {
+ return (
+
+
+
+
+
+ );
+}
+
+function FurnitureModel({ state }: { state: VisualizerState }) {
+ const normalized = normalizeVisualizerState(state);
+ const width = normalized.width * INCH;
+ const depth = normalized.depth * INCH;
+ const height = normalized.height * INCH;
+ const board = clamp(Math.min(width, depth) * 0.075, 0.025, 0.07);
+ const rail = clamp(board * 1.35, 0.035, 0.085);
+ const leg = clamp(Math.min(width, depth) * 0.09, 0.035, 0.09);
+ const color = MATERIAL_COLORS[normalized.material] ?? "#c9ad7d";
+ const accent = normalized.material.includes("Phenolic") ? "#d8c9a5" : color;
+ const template = resolveVisualizerTemplate(normalized.kind);
+ const parts: ReactNode[] = [];
+ const add = (key: string, position: Position, size: Position, partColor = color) => parts.push( );
+
+ if (["table", "bench", "stool"].includes(template)) {
+ const topHeight = template === "stool" ? Math.max(board * 1.3, 0.04) : board;
+ add("top", [0, height - topHeight / 2, 0], [width, topHeight, depth], normalized.material.includes("Phenolic") || normalized.material.includes("Stone") ? MATERIAL_COLORS[normalized.material] : color);
+ const insetX = Math.max(leg, width / 2 - leg * 1.8);
+ const insetZ = Math.max(leg, depth / 2 - leg * 1.8);
+ [[-insetX, -insetZ], [insetX, -insetZ], [-insetX, insetZ], [insetX, insetZ]].forEach(([x, z], index) => add(`leg-${index}`, [x, (height - topHeight) / 2, z], [leg, height - topHeight, leg], accent));
+ if (normalized.shelves > 0) add("lower-shelf", [0, height * 0.28, 0], [width - leg * 2.2, board * 0.72, depth - leg * 1.8], accent);
+ for (let index = 0; index < Math.min(4, normalized.drawers); index += 1) {
+ const drawerWidth = (width - leg * 3) / Math.max(1, Math.min(4, normalized.drawers));
+ add(`drawer-${index}`, [-width / 2 + leg * 1.5 + drawerWidth * (index + 0.5), height - topHeight - rail * 0.7, -depth / 2 + rail * 0.45], [drawerWidth * 0.9, rail, rail * 0.5], accent);
+ }
+ } else if (template === "cabinet") {
+ add("left", [-width / 2 + board / 2, height / 2, 0], [board, height, depth]);
+ add("right", [width / 2 - board / 2, height / 2, 0], [board, height, depth]);
+ add("top", [0, height - board / 2, 0], [width, board, depth]);
+ add("bottom", [0, board / 2, 0], [width, board, depth]);
+ add("back", [0, height / 2, depth / 2 - board * 0.25], [width - board * 2, height - board * 2, board * 0.5], accent);
+ const shelfCount = Math.max(1, Math.min(8, normalized.shelves));
+ for (let index = 1; index <= shelfCount; index += 1) add(`shelf-${index}`, [0, height * index / (shelfCount + 1), 0], [width - board * 2, board * 0.7, depth - board], accent);
+ add("door-left", [-width * 0.245, height / 2, -depth / 2 - board * 0.15], [width * 0.48, height - board * 2, board * 0.65], accent);
+ add("door-right", [width * 0.245, height / 2, -depth / 2 - board * 0.15], [width * 0.48, height - board * 2, board * 0.65], accent);
+ } else if (template === "shelf") {
+ add("left", [-width / 2 + board / 2, height / 2, 0], [board, height, depth]);
+ add("right", [width / 2 - board / 2, height / 2, 0], [board, height, depth]);
+ const shelfCount = Math.max(2, Math.min(8, normalized.shelves || 3));
+ for (let index = 0; index < shelfCount; index += 1) add(`shelf-${index}`, [0, board / 2 + (height - board) * index / (shelfCount - 1), 0], [width, board, depth], accent);
+ } else if (template === "chair") {
+ const seatHeight = height * 0.47;
+ add("seat", [0, seatHeight, 0], [width, board * 1.25, depth]);
+ [[-1, -1], [1, -1], [-1, 1], [1, 1]].forEach(([x, z], index) => add(`leg-${index}`, [x * (width / 2 - leg), seatHeight / 2, z * (depth / 2 - leg)], [leg, seatHeight, leg], accent));
+ add("back-left", [-width / 2 + leg, (height + seatHeight) / 2, depth / 2 - leg], [leg, height - seatHeight, leg], accent);
+ add("back-right", [width / 2 - leg, (height + seatHeight) / 2, depth / 2 - leg], [leg, height - seatHeight, leg], accent);
+ add("back-rail", [0, height - rail, depth / 2 - leg], [width, rail, board], accent);
+ } else if (template === "door") {
+ add("door", [0, height / 2, 0], [width, height, board * 1.5]);
+ add("top-rail", [0, height - rail * 1.5, -board], [width * 0.84, rail, board * 0.5], accent);
+ add("mid-rail", [0, height * 0.48, -board], [width * 0.84, rail, board * 0.5], accent);
+ } else if (template === "bed") {
+ const frameHeight = Math.min(height * 0.45, 0.55);
+ add("left-rail", [-width / 2 + board, frameHeight, 0], [board * 1.4, rail, depth], accent);
+ add("right-rail", [width / 2 - board, frameHeight, 0], [board * 1.4, rail, depth], accent);
+ add("head", [0, height / 2, depth / 2 - board], [width, height, board * 1.4], color);
+ add("foot", [0, frameHeight, -depth / 2 + board], [width, rail * 1.4, board * 1.4], color);
+ for (let index = 1; index < 7; index += 1) add(`slat-${index}`, [0, frameHeight, -depth / 2 + depth * index / 7], [width - board * 3, board * 0.55, board], accent);
+ } else if (template === "frame") {
+ add("top", [0, height - board / 2, 0], [width, board, board]);
+ add("bottom", [0, board / 2, 0], [width, board, board]);
+ add("left", [-width / 2 + board / 2, height / 2, 0], [board, height, board]);
+ add("right", [width / 2 - board / 2, height / 2, 0], [board, height, board]);
+ } else if (template === "easel") {
+ add("left", [-width * 0.24, height / 2, 0], [leg, height, leg], accent);
+ add("right", [width * 0.24, height / 2, 0], [leg, height, leg], accent);
+ add("ledge", [0, height * 0.34, -depth * 0.2], [width * 0.82, board, depth * 0.55], color);
+ add("canvas", [0, height * 0.63, 0], [width * 0.68, height * 0.48, board * 0.5], "#e7dfcf");
+ } else if (template === "board") {
+ add("board", [0, Math.max(board, height / 2), 0], [width, Math.max(board, Math.min(height, board * 1.4)), depth], color);
+ } else if (template === "clock") {
+ add("case", [0, height / 2, 0], [width, height, Math.max(board, depth)], color);
+ add("face", [0, height * 0.62, -Math.max(board, depth) / 2 - 0.004], [width * 0.68, height * 0.42, board * 0.24], "#e8dfc9");
+ } else {
+ add("body", [0, Math.max(board, height / 2), 0], [width, Math.max(board, height), depth], color);
+ }
+
+ return {parts} ;
+}
+
+function CameraRig({ command, preset, size }: { command: CameraCommand; preset: ViewPreset; size: number }) {
+ const { camera, invalidate, size: viewportSize } = useThree();
+ useEffect(() => {
+ const portraitScale = clamp(viewportSize.height / Math.max(1, viewportSize.width), 1, 2.2);
+ const distance = Math.max(2.6, size * 2.2 * portraitScale);
+ const positions: Record = {
+ isometric: [distance, distance * 0.78, distance],
+ front: [0, distance * 0.38, distance],
+ side: [distance, distance * 0.38, 0],
+ top: [0.001, distance, 0.001]
+ };
+ camera.position.set(...positions[preset]);
+ if ("isOrthographicCamera" in camera && camera.isOrthographicCamera) {
+ camera.zoom = clamp(Math.min(viewportSize.width, viewportSize.height) / (Math.max(size, 0.1) * 1.65), 20, 240);
+ }
+ camera.lookAt(0, size * 0.3, 0);
+ camera.updateProjectionMatrix();
+ invalidate();
+ }, [camera, invalidate, preset, size, viewportSize.height, viewportSize.width]);
+
+ useEffect(() => {
+ if (!command) return;
+ const target = new Vector3(0, Math.min(size * 0.3, 0.8), 0);
+ const offset = camera.position.clone().sub(target);
+
+ if (command.kind === "rotate-left" || command.kind === "rotate-right") {
+ offset.applyAxisAngle(new Vector3(0, 1, 0), command.kind === "rotate-left" ? Math.PI / 12 : -Math.PI / 12);
+ camera.position.copy(target.clone().add(offset));
+ } else if (command.kind === "zoom-in" || command.kind === "zoom-out") {
+ if ("isOrthographicCamera" in camera && camera.isOrthographicCamera) {
+ camera.zoom = clamp(camera.zoom * (command.kind === "zoom-in" ? 1.16 : 0.86), 0.35, 5);
+ } else {
+ offset.multiplyScalar(command.kind === "zoom-in" ? 0.84 : 1.16);
+ camera.position.copy(target.clone().add(offset));
+ }
+ } else {
+ const portraitScale = clamp(viewportSize.height / Math.max(1, viewportSize.width), 1, 2.2);
+ const distance = Math.max(2.6, size * 2.2 * portraitScale);
+ camera.position.set(distance, distance * 0.78, distance);
+ if ("isOrthographicCamera" in camera && camera.isOrthographicCamera) {
+ camera.zoom = clamp(Math.min(viewportSize.width, viewportSize.height) / (Math.max(size, 0.1) * 1.65), 20, 240);
+ }
+ }
+
+ camera.lookAt(target);
+ camera.updateProjectionMatrix();
+ invalidate();
+ }, [camera, command, invalidate, size, viewportSize.height, viewportSize.width]);
+ return null;
+}
+
+function Dimensions({ state }: { state: VisualizerState }) {
+ const normalized = normalizeVisualizerState(state);
+ const depth = normalized.depth * INCH;
+ const height = normalized.height * INCH;
+ return {normalized.width}" W x {normalized.depth}" D x {normalized.height}" H 12 in grid ;
+}
+
+class SceneErrorBoundary extends Component<{ children: ReactNode; fallback: ReactNode }, { failed: boolean }> {
+ state = { failed: false };
+ static getDerivedStateFromError() { return { failed: true }; }
+ componentDidCatch(error: Error, info: ErrorInfo) { console.error("Commission 3D preview failed", error, info.componentStack); }
+ render() { return this.state.failed ? this.props.fallback : this.props.children; }
+}
+
+function supportsWebGl() {
+ try {
+ const canvas = document.createElement("canvas");
+ return Boolean(canvas.getContext("webgl2") || canvas.getContext("webgl"));
+ } catch {
+ return false;
+ }
+}
+
+export default function CommissionScene3D({ state, fallbackSvg }: { state: VisualizerState; fallbackSvg: string }) {
+ const [available, setAvailable] = useState(null);
+ const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
+ const [reducedMotionOverride, setReducedMotionOverride] = useState(false);
+ const [preset, setPreset] = useState("isometric");
+ const [orthographic, setOrthographic] = useState(false);
+ const [showDimensions, setShowDimensions] = useState(true);
+ const [cameraCommand, setCameraCommand] = useState(null);
+ const [exportStatus, setExportStatus] = useState("");
+ const canvasRef = useRef(null);
+ const normalizedState = normalizeVisualizerState(state);
+ const size = Math.max(normalizedState.width, normalizedState.depth, normalizedState.height) * INCH;
+
+ useEffect(() => {
+ setAvailable(supportsWebGl());
+ const media = window.matchMedia("(prefers-reduced-motion: reduce)");
+ const updateMotionPreference = () => setPrefersReducedMotion(media.matches);
+ updateMotionPreference();
+ media.addEventListener("change", updateMotionPreference);
+ return () => media.removeEventListener("change", updateMotionPreference);
+ }, []);
+
+ const fallback =
Interactive 3D is unavailable in this browser. The deterministic proportional drawing remains part of the request.
;
+ if (available === false) return fallback;
+ if (available == null) return Preparing interactive preview...
;
+ if (prefersReducedMotion && !reducedMotionOverride) {
+ return
Your reduced-motion preference is active, so the deterministic scale drawing is shown by default.
setReducedMotionOverride(true)} type="button">Enable interactive 3D ;
+ }
+
+ function issueCameraCommand(kind: CameraCommandKind) {
+ if (kind === "reset") setPreset("isometric");
+ setCameraCommand((current) => ({ id: (current?.id ?? 0) + 1, kind }));
+ }
+
+ function exportSnapshot() {
+ const canvas = canvasRef.current;
+ if (!canvas) {
+ setExportStatus("The interactive preview is not ready to export.");
+ return;
+ }
+ canvas.toBlob((blob) => {
+ if (!blob) {
+ setExportStatus("The browser could not create a preview image.");
+ return;
+ }
+ const objectUrl = URL.createObjectURL(blob);
+ const anchor = document.createElement("a");
+ anchor.href = objectUrl;
+ anchor.download = `beaman-woodworks-${normalizedState.kind.replace(/[^a-z0-9-]+/gi, "-")}-concept.png`;
+ anchor.click();
+ URL.revokeObjectURL(objectUrl);
+ setExportStatus("PNG preview exported.");
+ }, "image/png");
+ }
+
+ function handleKeyDown(event: KeyboardEvent) {
+ const commandByKey: Record = {
+ ArrowLeft: "rotate-left",
+ ArrowRight: "rotate-right",
+ "+": "zoom-in",
+ "=": "zoom-in",
+ "-": "zoom-out",
+ Home: "reset"
+ };
+ const command = commandByKey[event.key];
+ if (!command) return;
+ event.preventDefault();
+ issueCameraCommand(command);
+ }
+
+ return (
+
+
+
+ {(["isometric", "front", "side", "top"] as ViewPreset[]).map((view) => setPreset(view)} type="button">{view} )}
+ setOrthographic((value) => !value)} type="button">{orthographic ? "Perspective" : "Orthographic"}
+ setShowDimensions((value) => !value)} type="button">Dimensions
+ issueCameraCommand("rotate-left")} type="button">Rotate left
+ issueCameraCommand("rotate-right")} type="button">Rotate right
+ issueCameraCommand("zoom-in")} type="button">Zoom +
+ issueCameraCommand("zoom-out")} type="button">Zoom -
+ issueCameraCommand("reset")} type="button">Reset view
+ Export PNG
+
+
+
{ canvasRef.current = gl.domElement; }} shadows="basic">
+
+
+
+
+
+
+ {showDimensions ? : null}
+
+
+
+
+
+
+
Drag or use touch to orbit. Use the wheel, pinch gesture, toolbar, or keyboard + / - to zoom; arrow keys rotate; Home resets the view.
+
Conceptual proportional preview: {normalizedState.width} by {normalizedState.depth} by {normalizedState.height} inches; {normalizedState.material}; {normalizedState.drawers} drawer{normalizedState.drawers === 1 ? "" : "s"}; {normalizedState.shelves} shelf/shelves. This preview is not a fabrication drawing or final quote.
+
{exportStatus}
+
+
+ );
+}
diff --git a/site/components/visualizer.tsx b/site/components/visualizer.tsx
index d1beedb..f36ba8c 100644
--- a/site/components/visualizer.tsx
+++ b/site/components/visualizer.tsx
@@ -1,9 +1,22 @@
"use client";
-import { useMemo, useState, useTransition, type CSSProperties } from "react";
-import { calculateEstimate, defaultVisualizerState, type VisualizerState } from "@/lib/estimator";
+import dynamic from "next/dynamic";
+import { useMemo, useState, useTransition } from "react";
+import {
+ calculateEstimate,
+ defaultVisualizerState,
+ normalizeVisualizerState,
+ resolveVisualizerTemplate,
+ VISUALIZER_LIMITS,
+ type VisualizerState
+} from "@/lib/estimator";
import { formatLeadTime, formatMoney } from "@/lib/format";
+const CommissionScene3D = dynamic(() => import("@/components/commission-scene"), {
+ ssr: false,
+ loading: () => Loading interactive 3D preview...
+});
+
type CommissionTypeOption = {
slug: string;
label: string;
@@ -12,6 +25,14 @@ type CommissionTypeOption = {
defaultDimensions: { width: number; depth: number; height: number; unit: "in" };
};
+const FALLBACK_COMMISSION_TYPE: CommissionTypeOption = {
+ slug: "other-custom-work",
+ label: "Other custom work",
+ description: "A custom form developed from your dimensions and requirements.",
+ materialOptions: ["White Oak", "Walnut", "Cherry", "Maple"],
+ defaultDimensions: { width: 48, depth: 20, height: 30, unit: "in" }
+};
+
const materialColors: Record = {
"White Oak": { top: "#c9ad7d", left: "#9f8257", right: "#7e6543", accent: "#f2d8ab" },
Walnut: { top: "#704e39", left: "#5d3f2d", right: "#432d21", accent: "#ba8e68" },
@@ -33,21 +54,13 @@ function getPalette(material: string) {
return materialColors[material] ?? materialColors.default;
}
-type VisualizerStyle = CSSProperties & Record<"--piece-w" | "--piece-d" | "--piece-h" | "--top-color" | "--front-color" | "--side-color" | "--accent-color", string>;
-
-function dimensionsForStyle(state: VisualizerState) {
- return {
- width: `${clamp(state.width / 6, 4.5, 16)}rem`,
- depth: `${clamp(state.depth / 5.5, 3.2, 11)}rem`,
- height: `${clamp(state.height / 7, 2.6, 13)}rem`
- };
-}
-
function renderIsometricSvg(state: VisualizerState) {
- const width = clamp(state.width, 8, 144);
- const depth = clamp(state.depth, 4, 48);
- const height = clamp(state.height, 6, 96);
- const palette = getPalette(state.material);
+ const normalized = normalizeVisualizerState(state);
+ const width = normalized.width;
+ const depth = normalized.depth;
+ const height = normalized.height;
+ const palette = getPalette(normalized.material);
+ const template = resolveVisualizerTemplate(normalized.kind);
const scale = Math.min(4.2, 220 / Math.max(width, depth));
const originX = 250;
const originY = 190;
@@ -66,19 +79,19 @@ function renderIsometricSvg(state: VisualizerState) {
const sideDropLeft = [topBackLeft[0], topBackLeft[1] + dropY];
const sideDropRight = [topBackRight[0], topBackRight[1] + dropY];
- const legs = state.kind === "pantry-cabinets"
- ? []
- : [
+ const legs = ["table", "bench", "stool", "chair"].includes(template)
+ ? [
{ x: topFrontLeft[0] + 18, y: topFrontLeft[1] + 10 },
{ x: topFrontRight[0] - 22, y: topFrontRight[1] + 10 },
{ x: topBackLeft[0] + 18, y: topBackLeft[1] + 10 },
{ x: topBackRight[0] - 22, y: topBackRight[1] + 10 }
- ];
+ ]
+ : [];
- const drawerVisible = state.drawers > 0 && ["scientists-desk", "end-table", "pastry-table"].includes(state.kind);
- const shelfVisible = state.shelves > 0 && !["spice-rack"].includes(state.kind);
- const rackVisible = state.kind === "spice-rack";
- const cabinetVisible = state.kind === "pantry-cabinets";
+ const drawerVisible = normalized.drawers > 0 && template === "table";
+ const shelfVisible = normalized.shelves > 0 && !["shelf", "cabinet"].includes(template);
+ const rackVisible = template === "shelf";
+ const cabinetVisible = template === "cabinet";
const polygon = (points: number[][]) => points.map((point) => point.map((value) => value.toFixed(1)).join(",")).join(" ");
@@ -106,7 +119,7 @@ function renderIsometricSvg(state: VisualizerState) {
${cabinetVisible ? ` ` : ""}
${drawerVisible ? ` ` : ""}
${shelfVisible ? ` ` : ""}
- ${rackVisible ? Array.from({ length: Math.max(2, state.shelves || 3) }, (_, index) => ` `).join("") : ""}
+ ${rackVisible ? Array.from({ length: Math.min(8, Math.max(2, normalized.shelves || 3)) }, (_, index) => ` `).join("") : ""}
${legs.map((leg) => ` `).join("")}
@@ -125,41 +138,54 @@ function renderIsometricSvg(state: VisualizerState) {
return svg.trim();
}
+function downloadSvg(svg: string, kind: string) {
+ const blob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" });
+ const objectUrl = URL.createObjectURL(blob);
+ const anchor = document.createElement("a");
+ anchor.href = objectUrl;
+ anchor.download = `beaman-woodworks-${kind.replace(/[^a-z0-9-]+/gi, "-")}-scale-drawing.svg`;
+ anchor.click();
+ URL.revokeObjectURL(objectUrl);
+}
+
export function CustomWorkVisualizer3D({ commissionTypes, bandwidthLeadTimeDays, queueCount }: {
commissionTypes: CommissionTypeOption[];
bandwidthLeadTimeDays: number;
queueCount: number;
}) {
- const [selectedSlug, setSelectedSlug] = useState(commissionTypes[0]?.slug ?? "hallway-bench");
- const selectedType = useMemo(() => commissionTypes.find((type) => type.slug === selectedSlug) ?? commissionTypes[0], [commissionTypes, selectedSlug]);
- const [state, setState] = useState(defaultVisualizerState((commissionTypes[0]?.slug as VisualizerState["kind"]) ?? "hallway-bench"));
- const [rotation, setRotation] = useState(32);
+ const availableTypes = commissionTypes.length > 0 ? commissionTypes : [FALLBACK_COMMISSION_TYPE];
+ const [selectedSlug, setSelectedSlug] = useState(availableTypes[0].slug);
+ const selectedType = availableTypes.find((type) => type.slug === selectedSlug) ?? availableTypes[0];
+ const [state, setState] = useState(defaultVisualizerState(availableTypes[0].slug));
const [isGenerating, startGeneration] = useTransition();
const [renderedPreview, setRenderedPreview] = useState<{ url: string; relativePath?: string; message: string } | null>(null);
- const syncedState = useMemo(() => ({
+ const syncedState = useMemo(() => normalizeVisualizerState({
...state,
- kind: (selectedType?.slug ?? state.kind) as VisualizerState["kind"]
- }), [selectedType, state]);
+ kind: selectedType.slug
+ }), [selectedType.slug, state]);
const estimate = useMemo(() => calculateEstimate(syncedState, queueCount, bandwidthLeadTimeDays), [bandwidthLeadTimeDays, queueCount, syncedState]);
const svg = useMemo(() => renderIsometricSvg(syncedState), [syncedState]);
- const palette = getPalette(syncedState.material);
- const sized = dimensionsForStyle(syncedState);
- const modelStyle: VisualizerStyle = {
- "--piece-w": sized.width,
- "--piece-d": sized.depth,
- "--piece-h": sized.height,
- "--top-color": palette.top,
- "--front-color": palette.left,
- "--side-color": palette.right,
- "--accent-color": palette.accent,
- transform: `rotateX(62deg) rotateZ(${rotation}deg)`
- };
+ const submissionOptions = useMemo(() => ({
+ schemaVersion: 1,
+ category: selectedType.slug,
+ material: syncedState.material,
+ joinery: syncedState.joinery,
+ drawers: syncedState.drawers,
+ shelves: syncedState.shelves,
+ renderer: "react-three-fiber",
+ previewKind: "conceptual-proportional",
+ fabricationReady: false
+ }), [selectedType.slug, syncedState.drawers, syncedState.joinery, syncedState.material, syncedState.shelves]);
function update(key: K, value: VisualizerState[K]) {
setState((current) => ({ ...current, [key]: value }));
}
+ function updateNumber(key: "width" | "depth" | "height" | "drawers" | "shelves", value: number) {
+ setState((current) => normalizeVisualizerState({ ...current, [key]: value }));
+ }
+
function generatePhotorealisticPreview() {
setRenderedPreview({ url: "", message: "Generating preview..." });
startGeneration(async () => {
@@ -199,27 +225,16 @@ export function CustomWorkVisualizer3D({ commissionTypes, bandwidthLeadTimeDays,
-
3D preview
-
Scale the first idea before sending the request
+
Conceptual 3D preview
+
Check proportion and dimensions before sending the request
+
The model is a proportional planning aid, not a fabrication drawing, final design, or quote.
{formatLeadTime(estimate.leadTimeDays)} lead time
-
-
-
-
-
-
-
-
-
- {syncedState.drawers > 0 ? : null}
- {syncedState.shelves > 0 ? Array.from({ length: Math.min(5, syncedState.shelves) }, (_, index) => ) : null}
-
-
0 12 24 in
+
@@ -228,11 +243,11 @@ export function CustomWorkVisualizer3D({ commissionTypes, bandwidthLeadTimeDays,
{
- const nextType = commissionTypes.find((type) => type.slug === event.target.value) ?? commissionTypes[0];
+ const nextType = availableTypes.find((type) => type.slug === event.target.value) ?? availableTypes[0];
setSelectedSlug(nextType.slug);
setState((current) => ({
...current,
- kind: nextType.slug as VisualizerState["kind"],
+ kind: nextType.slug,
material: nextType.materialOptions[0] ?? current.material,
width: nextType.defaultDimensions.width,
depth: nextType.defaultDimensions.depth,
@@ -243,9 +258,10 @@ export function CustomWorkVisualizer3D({ commissionTypes, bandwidthLeadTimeDays,
}}
value={selectedType?.slug}
>
- {commissionTypes.map((type) => {type.label} )}
+ {availableTypes.map((type) => {type.label} )}
+
{selectedType.description}
Primary material
update("material", event.target.value)} value={syncedState.material}>
@@ -253,14 +269,13 @@ export function CustomWorkVisualizer3D({ commissionTypes, bandwidthLeadTimeDays,
- Width update("width", Number(event.target.value || 0))} type="number" value={syncedState.width} />
- Depth update("depth", Number(event.target.value || 0))} type="number" value={syncedState.depth} />
- Height update("height", Number(event.target.value || 0))} type="number" value={syncedState.height} />
+ Width (in) updateNumber("width", Number(event.target.value))} required step="0.25" type="number" value={syncedState.width} />
+ Depth (in) updateNumber("depth", Number(event.target.value))} required step="0.25" type="number" value={syncedState.depth} />
+ Height (in) updateNumber("height", Number(event.target.value))} required step="0.25" type="number" value={syncedState.height} />
-
-
Drawers update("drawers", Number(event.target.value || 0))} type="number" value={syncedState.drawers} />
-
Shelves update("shelves", Number(event.target.value || 0))} type="number" value={syncedState.shelves} />
-
Rotate setRotation(Number(event.target.value || 0))} type="range" value={rotation} />
+
+ Drawers updateNumber("drawers", Number(event.target.value))} step="1" type="number" value={syncedState.drawers} />
+ Shelves updateNumber("shelves", Number(event.target.value))} step="1" type="number" value={syncedState.shelves} />
Joinery direction
@@ -277,11 +292,13 @@ export function CustomWorkVisualizer3D({ commissionTypes, bandwidthLeadTimeDays,
Labor {estimate.laborHours} hrs
Estimated total {formatMoney(estimate.totalCents)}
+ This planning range uses current material allowances, shop labor, overhead, markup, and the live project queue. William confirms the quote after reviewing the request.
+ downloadSvg(svg, syncedState.kind)} type="button">Download scale drawing
{isGenerating ? "Generating..." : "Generate photorealistic preview"}
-
{renderedPreview?.message ?? "Optional AI rendering activates only when the deployment has image-model credentials enabled."}
+
{renderedPreview?.message ?? "Optional image-model rendering is separate from the deterministic scale model and activates only when configured."}
{renderedPreview?.url ?
: null}
@@ -295,165 +312,9 @@ export function CustomWorkVisualizer3D({ commissionTypes, bandwidthLeadTimeDays,
-
+
);
}
-
-export function CommissionVisualizerFields({ commissionTypes, bandwidthLeadTimeDays, bandwidthPercent, queueCount }: {
- commissionTypes: CommissionTypeOption[];
- bandwidthLeadTimeDays: number;
- bandwidthPercent: number;
- queueCount: number;
-}) {
- const [selectedSlug, setSelectedSlug] = useState(commissionTypes[0]?.slug ?? "hallway-bench");
- const selectedType = useMemo(() => commissionTypes.find((type) => type.slug === selectedSlug) ?? commissionTypes[0], [commissionTypes, selectedSlug]);
- const [state, setState] = useState(defaultVisualizerState((commissionTypes[0]?.slug as VisualizerState["kind"]) ?? "hallway-bench"));
-
- const syncedState = useMemo(() => ({
- ...state,
- kind: (selectedType?.slug ?? state.kind) as VisualizerState["kind"]
- }), [selectedType, state]);
-
- const estimate = useMemo(() => calculateEstimate(syncedState, queueCount, bandwidthLeadTimeDays), [bandwidthLeadTimeDays, queueCount, syncedState]);
- const svg = useMemo(() => renderIsometricSvg(syncedState), [syncedState]);
-
- function update(key: K, value: VisualizerState[K]) {
- setState((current) => ({ ...current, [key]: value }));
- }
-
- return (
-
-
-
- Bandwidth {bandwidthPercent}%
- Queue {queueCount} live builds
- Lead time {formatLeadTime(estimate.leadTimeDays)}
-
-
-
- To-scale preview
- {selectedType?.label ?? "Custom piece"}
-
-
-
-
-
- Project intent
-
- Commission type
- {
- const nextType = commissionTypes.find((type) => type.slug === event.target.value) ?? commissionTypes[0];
- setSelectedSlug(nextType.slug);
- setState((current) => ({
- ...current,
- kind: nextType.slug as VisualizerState["kind"],
- material: nextType.materialOptions[0] ?? current.material,
- width: nextType.defaultDimensions.width,
- depth: nextType.defaultDimensions.depth,
- height: nextType.defaultDimensions.height,
- drawers: nextType.slug === "scientists-desk" ? 1 : current.drawers,
- shelves: nextType.slug === "pantry-cabinets" ? 4 : nextType.slug === "spice-rack" ? 3 : current.shelves
- }));
- }}
- value={selectedType?.slug}
- >
- {commissionTypes.map((type) => (
- {type.label}
- ))}
-
-
-
- Project brief
-
-
-
-
-
- Materiality
-
- Primary material
- update("material", event.target.value)} value={syncedState.material}>
- {(selectedType?.materialOptions ?? [syncedState.material]).map((option) => (
- {option}
- ))}
-
-
-
- Joinery
- update("joinery", event.target.value)} value={syncedState.joinery}>
- Mortise and tenon
- Exposed dovetail
- Half-lap
- Pinned frame
- Concealed joinery
-
-
-
-
-
-
-
- Live estimate
-
-
Material {formatMoney(estimate.materialCostCents)}
-
Labor ({estimate.laborHours} hrs) {formatMoney(estimate.laborCostCents)}
-
Overhead {formatMoney(estimate.overheadCostCents)}
-
Markup {formatMoney(estimate.markupCostCents)}
-
Projected total {formatMoney(estimate.totalCents)}
-
Lead time {formatLeadTime(estimate.leadTimeDays)}
-
-
- Additional notes
- update("notes", event.target.value)} placeholder="Finish preferences, hardware, special requirements..." rows={3} value={syncedState.notes} />
-
-
- update("includeVisualization", event.target.checked)} type="checkbox" value="1" />
- Include this live preview with the submitted brief
-
-
- Reference images or sketches
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
diff --git a/site/lib/estimator.test.mts b/site/lib/estimator.test.mts
new file mode 100644
index 0000000..78d6d4d
--- /dev/null
+++ b/site/lib/estimator.test.mts
@@ -0,0 +1,52 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import {
+ calculateEstimate,
+ defaultVisualizerState,
+ normalizeVisualizerState,
+ resolveVisualizerTemplate,
+ VISUALIZER_LIMITS
+} from "./estimator.ts";
+
+test("commission estimates include materials, labor, overhead, markup, and queue-aware lead time", () => {
+ const estimate = calculateEstimate({ ...defaultVisualizerState("dining-room-table"), material: "White Oak", drawers: 2, shelves: 1 }, 4, 70);
+ assert.ok(estimate.materialCostCents > 0);
+ assert.ok(estimate.laborCostCents > 0);
+ assert.ok(estimate.overheadCostCents > 0);
+ assert.ok(estimate.markupCostCents > 0);
+ assert.equal(estimate.totalCents, estimate.materialCostCents + estimate.laborCostCents + estimate.overheadCostCents + estimate.markupCostCents);
+ assert.ok(estimate.leadTimeDays > 70);
+});
+
+test("future commission types receive safe visualizer and estimator defaults", () => {
+ const state = defaultVisualizerState("custom-wall-console");
+ assert.equal(state.kind, "custom-wall-console");
+ assert.deepEqual([state.width, state.depth, state.height], [48, 20, 30]);
+ assert.ok(calculateEstimate(state, 0, 30).totalCents > 0);
+});
+
+test("commission templates cover known forms and retain a generic fallback", () => {
+ assert.equal(resolveVisualizerTemplate("Scientists Desk"), "table");
+ assert.equal(resolveVisualizerTemplate("stepstool"), "stool");
+ assert.equal(resolveVisualizerTemplate("pantry cupboard"), "cabinet");
+ assert.equal(resolveVisualizerTemplate("wall bookcase"), "shelf");
+ assert.equal(resolveVisualizerTemplate("unclassified sculptural form"), "object");
+});
+
+test("unsafe dimensions and counts are normalized before estimating", () => {
+ const normalized = normalizeVisualizerState({
+ ...defaultVisualizerState("other-custom-work"),
+ width: Number.NaN,
+ depth: -40,
+ height: 9999,
+ drawers: 2.6,
+ shelves: 999
+ });
+
+ assert.equal(normalized.width, VISUALIZER_LIMITS.width.min);
+ assert.equal(normalized.depth, VISUALIZER_LIMITS.depth.min);
+ assert.equal(normalized.height, VISUALIZER_LIMITS.height.max);
+ assert.equal(normalized.drawers, 3);
+ assert.equal(normalized.shelves, VISUALIZER_LIMITS.shelves.max);
+ assert.ok(calculateEstimate(normalized, 0, 21).totalCents > 0);
+});
diff --git a/site/lib/estimator.ts b/site/lib/estimator.ts
index b22313a..ad48df6 100644
--- a/site/lib/estimator.ts
+++ b/site/lib/estimator.ts
@@ -1,13 +1,19 @@
-export type VisualizerKind =
- | "dining-room-table"
- | "end-table"
- | "scientists-desk"
- | "footstool"
- | "spice-rack"
- | "pantry-cabinets"
- | "pastry-table"
- | "hallway-bench"
- | "other-custom-work";
+export type VisualizerKind = string;
+
+export type VisualizerTemplate =
+ | "table"
+ | "bench"
+ | "stool"
+ | "cabinet"
+ | "shelf"
+ | "chair"
+ | "door"
+ | "bed"
+ | "frame"
+ | "board"
+ | "easel"
+ | "clock"
+ | "object";
export type VisualizerState = {
kind: VisualizerKind;
@@ -51,7 +57,7 @@ const MATERIAL_RATES: Record = {
default: 1850
};
-const BASE_HOURS: Record = {
+const BASE_HOURS: Record = {
"dining-room-table": 72,
"end-table": 24,
"scientists-desk": 48,
@@ -63,7 +69,7 @@ const BASE_HOURS: Record = {
"other-custom-work": 36
};
-const BASE_MARKUP: Record = {
+const BASE_MARKUP: Record = {
"dining-room-table": 0.22,
"end-table": 0.18,
"scientists-desk": 0.22,
@@ -75,35 +81,77 @@ const BASE_MARKUP: Record = {
"other-custom-work": 0.2
};
-const JOINERY_MULTIPLIER: Record = {
+const JOINERY_MULTIPLIER: Record = {
"Exposed dovetail": 1.16,
"Mortise and tenon": 1.1,
"Half-lap": 1.04,
"Pinned frame": 1.07,
"Concealed joinery": 1,
default: 1
-};
-
-export function clampNumber(value: number, min: number, max: number) {
- return Math.max(min, Math.min(max, value));
-}
-
-export function calculateBoardFeet(width: number, depth: number, height: number, kind: VisualizerKind) {
- const volumeFactor = kind === "pantry-cabinets" ? 1.8 : kind === "spice-rack" ? 0.35 : 1;
- return Number((((width * depth * Math.max(height, 1)) / 144) * volumeFactor * 0.14).toFixed(2));
-}
+};
+
+export const VISUALIZER_LIMITS = {
+ width: { min: 4, max: 240 },
+ depth: { min: 2, max: 120 },
+ height: { min: 2, max: 144 },
+ drawers: { min: 0, max: 24 },
+ shelves: { min: 0, max: 24 }
+} as const;
-export function calculateEstimate(input: VisualizerState, activeQueueCount = 6, currentLeadTimeDays = 84): EstimateBreakdown {
- const boardFeet = calculateBoardFeet(input.width, input.depth, input.height, input.kind);
- const rate = MATERIAL_RATES[input.material] ?? MATERIAL_RATES.default;
- const materialCostCents = Math.round(boardFeet * rate + input.drawers * 8500 + input.shelves * 3200);
- const baseHours = BASE_HOURS[input.kind] ?? BASE_HOURS["other-custom-work"];
- const joineryFactor = JOINERY_MULTIPLIER[input.joinery] ?? JOINERY_MULTIPLIER.default;
- const sizeFactor = Math.max(0.7, (input.width * input.depth) / (48 * 24));
- const laborHours = Number((baseHours * joineryFactor * sizeFactor + input.drawers * 4 + input.shelves * 1.5).toFixed(1));
+export function clampNumber(value: number, min: number, max: number) {
+ const finiteValue = Number.isFinite(value) ? value : min;
+ return Math.max(min, Math.min(max, finiteValue));
+}
+
+export function normalizeVisualizerState(input: VisualizerState): VisualizerState {
+ return {
+ ...input,
+ width: clampNumber(input.width, VISUALIZER_LIMITS.width.min, VISUALIZER_LIMITS.width.max),
+ depth: clampNumber(input.depth, VISUALIZER_LIMITS.depth.min, VISUALIZER_LIMITS.depth.max),
+ height: clampNumber(input.height, VISUALIZER_LIMITS.height.min, VISUALIZER_LIMITS.height.max),
+ drawers: Math.round(clampNumber(input.drawers, VISUALIZER_LIMITS.drawers.min, VISUALIZER_LIMITS.drawers.max)),
+ shelves: Math.round(clampNumber(input.shelves, VISUALIZER_LIMITS.shelves.min, VISUALIZER_LIMITS.shelves.max))
+ };
+}
+
+export function resolveVisualizerTemplate(kind: VisualizerKind): VisualizerTemplate {
+ const value = kind.trim().toLowerCase();
+ if (/cabinet|pantry|cupboard|wardrobe|credenza/.test(value)) return "cabinet";
+ if (/shelf|rack|bookcase/.test(value)) return "shelf";
+ if (/chair|seat/.test(value)) return "chair";
+ if (/stool|step/.test(value)) return "stool";
+ if (/bench|settle/.test(value)) return "bench";
+ if (/door|gate|screen/.test(value)) return "door";
+ if (/bed|platform/.test(value)) return "bed";
+ if (/frame|mirror/.test(value)) return "frame";
+ if (/board|tray|platter/.test(value)) return "board";
+ if (/easel|stand/.test(value)) return "easel";
+ if (/clock/.test(value)) return "clock";
+ if (/table|desk|console|island/.test(value)) return "table";
+ return "object";
+}
+
+export function calculateBoardFeet(width: number, depth: number, height: number, kind: VisualizerKind) {
+ const safeWidth = clampNumber(width, VISUALIZER_LIMITS.width.min, VISUALIZER_LIMITS.width.max);
+ const safeDepth = clampNumber(depth, VISUALIZER_LIMITS.depth.min, VISUALIZER_LIMITS.depth.max);
+ const safeHeight = clampNumber(height, VISUALIZER_LIMITS.height.min, VISUALIZER_LIMITS.height.max);
+ const template = resolveVisualizerTemplate(kind);
+ const volumeFactor = template === "cabinet" ? 1.8 : template === "shelf" ? 0.35 : 1;
+ return Number((((safeWidth * safeDepth * safeHeight) / 144) * volumeFactor * 0.14).toFixed(2));
+}
+
+export function calculateEstimate(input: VisualizerState, activeQueueCount = 6, currentLeadTimeDays = 84): EstimateBreakdown {
+ const normalized = normalizeVisualizerState(input);
+ const boardFeet = calculateBoardFeet(normalized.width, normalized.depth, normalized.height, normalized.kind);
+ const rate = MATERIAL_RATES[normalized.material] ?? MATERIAL_RATES.default;
+ const materialCostCents = Math.round(boardFeet * rate + normalized.drawers * 8500 + normalized.shelves * 3200);
+ const baseHours = BASE_HOURS[normalized.kind] ?? BASE_HOURS["other-custom-work"];
+ const joineryFactor = JOINERY_MULTIPLIER[normalized.joinery] ?? JOINERY_MULTIPLIER.default;
+ const sizeFactor = Math.max(0.7, (normalized.width * normalized.depth) / (48 * 24));
+ const laborHours = Number((baseHours * joineryFactor * sizeFactor + normalized.drawers * 4 + normalized.shelves * 1.5).toFixed(1));
const laborCostCents = Math.round(laborHours * 7500);
const overheadCostCents = Math.round((materialCostCents + laborCostCents) * 0.11);
- const markupRate = BASE_MARKUP[input.kind] ?? BASE_MARKUP["other-custom-work"];
+ const markupRate = BASE_MARKUP[normalized.kind] ?? BASE_MARKUP["other-custom-work"];
const markupCostCents = Math.round((materialCostCents + laborCostCents + overheadCostCents) * markupRate);
const totalCents = materialCostCents + laborCostCents + overheadCostCents + markupCostCents;
const queueContribution = activeQueueCount * 5;
@@ -126,7 +174,7 @@ export function calculateEstimate(input: VisualizerState, activeQueueCount = 6,
}
export function defaultVisualizerState(kind: VisualizerKind = "hallway-bench"): VisualizerState {
- const dimensions: Record = {
+ const dimensions: Record = {
"dining-room-table": [84, 40, 30],
"end-table": [22, 22, 24],
"scientists-desk": [48, 24, 30],
@@ -138,7 +186,7 @@ export function defaultVisualizerState(kind: VisualizerKind = "hallway-bench"):
"other-custom-work": [48, 20, 30]
};
- const materials: Record = {
+ const materials: Record = {
"dining-room-table": "White Oak",
"end-table": "Walnut",
"scientists-desk": "Phenolic resin top",
@@ -150,11 +198,11 @@ export function defaultVisualizerState(kind: VisualizerKind = "hallway-bench"):
"other-custom-work": "White Oak"
};
- const [width, depth, height] = dimensions[kind];
+ const [width, depth, height] = dimensions[kind] ?? dimensions["other-custom-work"];
return {
kind,
- material: materials[kind],
+ material: materials[kind] ?? materials["other-custom-work"],
joinery: kind === "pantry-cabinets" ? "Pinned frame" : "Mortise and tenon",
width,
depth,
diff --git a/site/package.json b/site/package.json
index 635097e..dcc6281 100644
--- a/site/package.json
+++ b/site/package.json
@@ -8,7 +8,7 @@
"start": "node --experimental-sqlite .next/standalone/server.js",
"lint": "eslint app components lib",
"typecheck": "tsc --noEmit",
- "test": "node --experimental-strip-types --test lib/media-scoring.test.mts lib/piece-model.test.mts lib/database-migrations.test.mts lib/media-reference-transaction.test.mts lib/category-icons.test.mts lib/site-structure.test.mts lib/piece-media.test.mts"
+ "test": "node --experimental-strip-types --test lib/media-scoring.test.mts lib/piece-model.test.mts lib/database-migrations.test.mts lib/media-reference-transaction.test.mts lib/category-icons.test.mts lib/site-structure.test.mts lib/piece-media.test.mts lib/estimator.test.mts"
},
"dependencies": {
"@react-three/drei": "^10.7.7",
From 17431436b41ae76ff871b9d08cef1bfceb9ef4b9 Mon Sep 17 00:00:00 2001
From: Cooper Beaman <55124795+lowestprime@users.noreply.github.com>
Date: Sun, 12 Jul 2026 15:08:49 -0700
Subject: [PATCH 06/43] fix(commissions): harden 3D camera lifecycle
---
site/components/commission-scene.tsx | 98 ++++++++++++++++------------
1 file changed, 56 insertions(+), 42 deletions(-)
diff --git a/site/components/commission-scene.tsx b/site/components/commission-scene.tsx
index a6352b8..ebc3e33 100644
--- a/site/components/commission-scene.tsx
+++ b/site/components/commission-scene.tsx
@@ -1,9 +1,9 @@
"use client";
-import { Component, useEffect, useRef, useState, type ErrorInfo, type KeyboardEvent, type ReactNode } from "react";
+import { Component, useEffect, useRef, useState, useSyncExternalStore, type ErrorInfo, type KeyboardEvent, type ReactNode } from "react";
import { Canvas, useThree } from "@react-three/fiber";
import { ContactShadows, Edges, Grid, Html, OrbitControls, OrthographicCamera, PerspectiveCamera } from "@react-three/drei";
-import { Vector3 } from "three";
+import { Vector3, type Camera, type OrthographicCamera as ThreeOrthographicCamera } from "three";
import {
normalizeVisualizerState,
resolveVisualizerTemplate,
@@ -14,6 +14,7 @@ type ViewPreset = "isometric" | "front" | "side" | "top";
type CameraCommandKind = "rotate-left" | "rotate-right" | "zoom-in" | "zoom-out" | "reset";
type CameraCommand = { id: number; kind: CameraCommandKind } | null;
type Position = [number, number, number];
+type ProjectionCamera = Camera & { updateProjectionMatrix: () => void };
const INCH = 0.0254;
@@ -35,6 +36,37 @@ function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value));
}
+function isOrthographicCamera(camera: Camera): camera is ThreeOrthographicCamera {
+ return "isOrthographicCamera" in camera && camera.isOrthographicCamera === true;
+}
+
+function positionCamera(camera: Camera, position: Position, target: Vector3, zoom?: number) {
+ camera.position.set(...position);
+ if (zoom !== undefined && isOrthographicCamera(camera)) camera.zoom = zoom;
+ camera.lookAt(target);
+ (camera as ProjectionCamera).updateProjectionMatrix();
+}
+
+function applyCameraCommand(camera: Camera, command: CameraCommandKind, target: Vector3, resetPosition: Position, resetZoom: number) {
+ const offset = camera.position.clone().sub(target);
+ if (command === "rotate-left" || command === "rotate-right") {
+ offset.applyAxisAngle(new Vector3(0, 1, 0), command === "rotate-left" ? Math.PI / 12 : -Math.PI / 12);
+ camera.position.copy(target.clone().add(offset));
+ } else if (command === "zoom-in" || command === "zoom-out") {
+ if (isOrthographicCamera(camera)) {
+ camera.zoom = clamp(camera.zoom * (command === "zoom-in" ? 1.16 : 0.86), 0.35, 240);
+ } else {
+ offset.multiplyScalar(command === "zoom-in" ? 0.84 : 1.16);
+ camera.position.copy(target.clone().add(offset));
+ }
+ } else {
+ camera.position.set(...resetPosition);
+ if (isOrthographicCamera(camera)) camera.zoom = resetZoom;
+ }
+ camera.lookAt(target);
+ (camera as ProjectionCamera).updateProjectionMatrix();
+}
+
function Part({ color, position, size }: { color: string; position: Position; size: Position }) {
return (
@@ -136,41 +168,18 @@ function CameraRig({ command, preset, size }: { command: CameraCommand; preset:
side: [distance, distance * 0.38, 0],
top: [0.001, distance, 0.001]
};
- camera.position.set(...positions[preset]);
- if ("isOrthographicCamera" in camera && camera.isOrthographicCamera) {
- camera.zoom = clamp(Math.min(viewportSize.width, viewportSize.height) / (Math.max(size, 0.1) * 1.65), 20, 240);
- }
- camera.lookAt(0, size * 0.3, 0);
- camera.updateProjectionMatrix();
+ const zoom = clamp(Math.min(viewportSize.width, viewportSize.height) / (Math.max(size, 0.1) * 1.65), 20, 240);
+ positionCamera(camera, positions[preset], new Vector3(0, size * 0.3, 0), zoom);
invalidate();
}, [camera, invalidate, preset, size, viewportSize.height, viewportSize.width]);
useEffect(() => {
if (!command) return;
const target = new Vector3(0, Math.min(size * 0.3, 0.8), 0);
- const offset = camera.position.clone().sub(target);
-
- if (command.kind === "rotate-left" || command.kind === "rotate-right") {
- offset.applyAxisAngle(new Vector3(0, 1, 0), command.kind === "rotate-left" ? Math.PI / 12 : -Math.PI / 12);
- camera.position.copy(target.clone().add(offset));
- } else if (command.kind === "zoom-in" || command.kind === "zoom-out") {
- if ("isOrthographicCamera" in camera && camera.isOrthographicCamera) {
- camera.zoom = clamp(camera.zoom * (command.kind === "zoom-in" ? 1.16 : 0.86), 0.35, 5);
- } else {
- offset.multiplyScalar(command.kind === "zoom-in" ? 0.84 : 1.16);
- camera.position.copy(target.clone().add(offset));
- }
- } else {
- const portraitScale = clamp(viewportSize.height / Math.max(1, viewportSize.width), 1, 2.2);
- const distance = Math.max(2.6, size * 2.2 * portraitScale);
- camera.position.set(distance, distance * 0.78, distance);
- if ("isOrthographicCamera" in camera && camera.isOrthographicCamera) {
- camera.zoom = clamp(Math.min(viewportSize.width, viewportSize.height) / (Math.max(size, 0.1) * 1.65), 20, 240);
- }
- }
-
- camera.lookAt(target);
- camera.updateProjectionMatrix();
+ const portraitScale = clamp(viewportSize.height / Math.max(1, viewportSize.width), 1, 2.2);
+ const distance = Math.max(2.6, size * 2.2 * portraitScale);
+ const resetZoom = clamp(Math.min(viewportSize.width, viewportSize.height) / (Math.max(size, 0.1) * 1.65), 20, 240);
+ applyCameraCommand(camera, command.kind, target, [distance, distance * 0.78, distance], resetZoom);
invalidate();
}, [camera, command, invalidate, size, viewportSize.height, viewportSize.width]);
return null;
@@ -199,9 +208,23 @@ function supportsWebGl() {
}
}
+function subscribeToWebGl() {
+ return () => undefined;
+}
+
+function getReducedMotionSnapshot() {
+ return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+}
+
+function subscribeToReducedMotion(callback: () => void) {
+ const media = window.matchMedia("(prefers-reduced-motion: reduce)");
+ media.addEventListener("change", callback);
+ return () => media.removeEventListener("change", callback);
+}
+
export default function CommissionScene3D({ state, fallbackSvg }: { state: VisualizerState; fallbackSvg: string }) {
- const [available, setAvailable] = useState(null);
- const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
+ const available = useSyncExternalStore(subscribeToWebGl, supportsWebGl, () => null);
+ const prefersReducedMotion = useSyncExternalStore(subscribeToReducedMotion, getReducedMotionSnapshot, () => false);
const [reducedMotionOverride, setReducedMotionOverride] = useState(false);
const [preset, setPreset] = useState("isometric");
const [orthographic, setOrthographic] = useState(false);
@@ -212,15 +235,6 @@ export default function CommissionScene3D({ state, fallbackSvg }: { state: Visua
const normalizedState = normalizeVisualizerState(state);
const size = Math.max(normalizedState.width, normalizedState.depth, normalizedState.height) * INCH;
- useEffect(() => {
- setAvailable(supportsWebGl());
- const media = window.matchMedia("(prefers-reduced-motion: reduce)");
- const updateMotionPreference = () => setPrefersReducedMotion(media.matches);
- updateMotionPreference();
- media.addEventListener("change", updateMotionPreference);
- return () => media.removeEventListener("change", updateMotionPreference);
- }, []);
-
const fallback =
Interactive 3D is unavailable in this browser. The deterministic proportional drawing remains part of the request.
;
if (available === false) return fallback;
if (available == null) return Preparing interactive preview...
;
From fdf020db7b60c0c1045a1b6d23c0a06934c12cfe Mon Sep 17 00:00:00 2001
From: Cooper Beaman <55124795+lowestprime@users.noreply.github.com>
Date: Sun, 12 Jul 2026 15:12:24 -0700
Subject: [PATCH 07/43] feat(qa): add deterministic two-mode visual archive
---
.dockerignore | 9 +
.env.example | 5 +
.gitignore | 10 +
Dockerfile | 16 +
PLANS.md | 14 +
README.md | 15 +-
admin.md | 10 +-
docker-compose.synology.yml | 2 +
docker-compose.visual-audit-lab.yml | 117 +
docker-compose.visual-audit-live.yml | 47 +
docs/sitewide-ux-overhaul-audit-20260711.md | 2 +
docs/visual-archive.md | 225 ++
site/app/api/visual-audit/inventory/route.ts | 197 ++
site/lib/visual-audit-policy.ts | 6 +
site/lib/visual-audit-security.test.mts | 21 +
site/lib/visual-audit-token.ts | 16 +
site/lib/visual-audit.ts | 26 +-
site/package.json | 2 +-
site/proxy.ts | 32 +-
synology-nas-deploy.md | 27 +-
visual-audit/Dockerfile | 16 +
visual-audit/package-lock.json | 683 ++++++
visual-audit/package.json | 27 +
visual-audit/scripts/prepare-live-secrets.sh | 99 +
visual-audit/scripts/prepare-snapshot-lab.sh | 84 +
visual-audit/scripts/prune-audits.sh | 29 +
visual-audit/scripts/run-live-audit.sh | 50 +
visual-audit/scripts/run-snapshot-lab.sh | 63 +
visual-audit/src/capture.ts | 363 ++++
visual-audit/src/config.ts | 91 +
visual-audit/src/diff.ts | 119 ++
visual-audit/src/inventory.ts | 262 +++
visual-audit/src/policy.test.ts | 22 +
visual-audit/src/policy.ts | 16 +
visual-audit/src/readiness.ts | 143 ++
visual-audit/src/report.ts | 299 +++
visual-audit/src/run.ts | 2002 ++++++++++++++++++
visual-audit/src/tiling.test.ts | 26 +
visual-audit/src/tiling.ts | 34 +
visual-audit/src/types.ts | 172 ++
visual-audit/src/util.ts | 75 +
visual-audit/src/validate.ts | 330 +++
visual-audit/tsconfig.json | 20 +
woodsmith_DeepWiki_Merged_03222026.md | 19 +-
44 files changed, 5823 insertions(+), 20 deletions(-)
create mode 100644 docker-compose.visual-audit-lab.yml
create mode 100644 docker-compose.visual-audit-live.yml
create mode 100644 docs/visual-archive.md
create mode 100644 site/app/api/visual-audit/inventory/route.ts
create mode 100644 site/lib/visual-audit-policy.ts
create mode 100644 site/lib/visual-audit-security.test.mts
create mode 100644 site/lib/visual-audit-token.ts
create mode 100644 visual-audit/Dockerfile
create mode 100644 visual-audit/package-lock.json
create mode 100644 visual-audit/package.json
create mode 100755 visual-audit/scripts/prepare-live-secrets.sh
create mode 100755 visual-audit/scripts/prepare-snapshot-lab.sh
create mode 100755 visual-audit/scripts/prune-audits.sh
create mode 100755 visual-audit/scripts/run-live-audit.sh
create mode 100755 visual-audit/scripts/run-snapshot-lab.sh
create mode 100644 visual-audit/src/capture.ts
create mode 100644 visual-audit/src/config.ts
create mode 100644 visual-audit/src/diff.ts
create mode 100644 visual-audit/src/inventory.ts
create mode 100644 visual-audit/src/policy.test.ts
create mode 100644 visual-audit/src/policy.ts
create mode 100644 visual-audit/src/readiness.ts
create mode 100644 visual-audit/src/report.ts
create mode 100644 visual-audit/src/run.ts
create mode 100644 visual-audit/src/tiling.test.ts
create mode 100644 visual-audit/src/tiling.ts
create mode 100644 visual-audit/src/types.ts
create mode 100644 visual-audit/src/util.ts
create mode 100644 visual-audit/src/validate.ts
create mode 100644 visual-audit/tsconfig.json
diff --git a/.dockerignore b/.dockerignore
index 0d05712..e5f2c2d 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -3,6 +3,8 @@
node_modules
site/node_modules
site/.next
+visual-audit/node_modules
+visual-audit/dist
.output
.sst
.tanstack
@@ -17,6 +19,7 @@ site/data/media-ai-cache/
.env
.env.local
.env.*.local
+.visual-audit-lab.env
#recycle
@eaDir
**/@eaDir
@@ -24,4 +27,10 @@ site/data/media-ai-cache/
pics/
cache/
releases/
+archive/
+design/
+output/
+visual-audits/
+visual-audit-lab/
+secrets/
wsl_build_nas_deploy_commands.md
diff --git a/.env.example b/.env.example
index 8192189..09517d3 100644
--- a/.env.example
+++ b/.env.example
@@ -17,6 +17,11 @@ DATA_ROOT=/app/site/data
STUDIO_PASSWORD=change-this-before-public
SESSION_SECRET=replace-with-a-long-random-secret
+# Authenticated visual-audit inventory access.
+# Generate independently from STUDIO_PASSWORD and SESSION_SECRET.
+VISUAL_AUDIT_TOKEN=
+VISUAL_AUDIT_MAX_RECORDS=5000
+
# Payment infrastructure
STRIPE_SECRET_KEY=
STRIPE_PUBLISHABLE_KEY=
diff --git a/.gitignore b/.gitignore
index 44f23f8..5acc94b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -52,3 +52,13 @@ releases/
cloudflared/*.json
cloudflared/config.yml
wsl_build_nas_deploy_commands.md
+
+# Visual audit
+visual-audit/node_modules/
+visual-audit/dist/
+visual-audit/.auth/
+visual-audit/tmp/
+visual-audits/
+visual-audit-lab/
+.visual-audit-lab.env
+secrets/
diff --git a/Dockerfile b/Dockerfile
index 609e95d..2c6b1ca 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,20 +1,33 @@
FROM node:22-bookworm-slim AS builder
+
+ARG WOODSMITH_BUILD_SHA=unknown
+
WORKDIR /app/site
+
ENV NEXT_TELEMETRY_DISABLED=1
+ENV WOODSMITH_BUILD_SHA=${WOODSMITH_BUILD_SHA}
COPY site/package.json ./package.json
COPY site/package-lock.json ./package-lock.json
+
RUN npm ci
COPY site ./
+
RUN npm run build
+
FROM node:22-bookworm-slim AS runner
+
+ARG WOODSMITH_BUILD_SHA=unknown
+
WORKDIR /app/site
+
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3002
ENV HOSTNAME=0.0.0.0
+ENV WOODSMITH_BUILD_SHA=${WOODSMITH_BUILD_SHA}
RUN groupadd --system --gid 1001 nextjs
RUN useradd --system --uid 1001 --gid 1001 nextjs
@@ -22,10 +35,13 @@ RUN useradd --system --uid 1001 --gid 1001 nextjs
COPY --from=builder --chown=nextjs:nextjs /app/site/.next/standalone ./
COPY --from=builder --chown=nextjs:nextjs /app/site/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nextjs /app/site/public ./public
+
RUN mkdir -p /app/site/data \
&& chown nextjs:nextjs /app/site/data \
&& chmod -R a+rX /app/site/public /app/site/.next/static
USER nextjs
+
EXPOSE 3002
+
CMD ["node", "--experimental-sqlite", "server.js"]
diff --git a/PLANS.md b/PLANS.md
index 08a2da6..e5157ca 100644
--- a/PLANS.md
+++ b/PLANS.md
@@ -1,5 +1,19 @@
# PLANS.md
+## 2026-07-11 Sitewide UX, Data, Commission, And Visual Archive Overhaul
+
+- Status: IN PROGRESS ON FEATURE BRANCH; NOT DEPLOYED
+- Branch: `codex/sitewide-studio-ux-commission-overhaul-20260711`
+
+| Slice | Status | Evidence |
+|------|--------|----------|
+| Additive data model and compatibility | DONE / COMMITTED | `d35eb35` adds the migration ledger, normalized piece media, typed commerce policies, edit/rename history, compatibility synchronization, transactional reference rewriting, and disposable-database tests. |
+| Audit and category design | DONE / COMMITTED | `30c87f6` records the evidence audit; `959a2ca` adds managed visual category icons and tests. |
+| Structured Studio and public content | DONE / COMMITTED | `8b2a39b` adds structured footer/home services, typed public pricing/inquiry/review behavior, visual media selection, compact Page/Piece/Process editing, media roles, and public build records. |
+| Conceptual proportional 3D preview | DONE / COMMITTED | `9e143da` adds route-local R3F templates, view/camera controls, exact dimensions, deterministic SVG/text fallbacks, demand rendering, and estimator tests. |
+| Deterministic visual archive | IMPLEMENTED / LOCALLY VALIDATED; NAS RUNTIME VALIDATION PENDING | The QA slice adds protected bounded inventory, strict live-readonly and isolated snapshot-lab modes, pinned Playwright Docker, route/link reconciliation, required high-resolution profiles, overlapping page/nested-scroll tiles with seam checks, deep UI state capture, restricted/redacted HTML/PDF, checksums, diffs, locks, and retention. Local tests, typecheck, lint, build, Compose validation, and a disposable-data smoke archive pass. The local Docker daemon is unavailable, so candidate-image and NAS smoke/full evidence remain required before deployment. |
+| Remaining product work | PENDING | Typed atomic inline editing, secure resumable multi-step commissions, transactional batch media operations/rollback, derivative-only cleanup, accessibility/performance completion, final docs, archive evidence, release, rollback, and deployment gates remain active. |
+
## 2026-07-05 Local-First Media AI, Header, and Persistence Pass
- Status: LIVE DEPLOYED AND VERIFIED ON SYNOLOGY
diff --git a/README.md b/README.md
index f3ad5c2..f0fe4cb 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@ Woodsmith is a self-hosted Next.js application for the Beaman Woodworks company
- Portfolio category filtering managed through editable labels, matching terms, and icon styles in the Woodshop dashboard
- Shop pages with asking-price language, cart totals, coupon handling, tax estimate, pickup/delivery/shipping labels, and Stripe checkout plumbing
- Process writing under the dedicated `/process` archive, with markdown content and optional source-credit links for outside references
-- Contact-first custom work requests with attachments, lead-time context, material preferences, project tracking, and an optional live procedural 3D scale preview
+- Contact-first custom work requests with attachments, lead-time context, material preferences, project tracking, and an optional route-local React Three Fiber conceptual scale preview
- Optional server-side OpenAI image-model previews for custom work when `OPENAI_API_KEY` and `ENABLE_PUBLIC_AI_RENDERING=true` are configured; generated previews are stored back into `/app/pics`
- Buyer account pages for signup, login, password reset, profile editing, profile images, and account-linked projects
- Private Woodshop dashboard with focused workspace tabs for editing settings, pages, pieces, custom work types, users, media, process notes, projects, orders, reviews, and notifications through structured browser forms
@@ -29,13 +29,14 @@ Woodsmith is a self-hosted Next.js application for the Beaman Woodworks company
- Programmatic Beaman Woodworks favicon and brand mark
- Safe profile administration for renaming accounts, replacing legacy developer emails, and deleting non-current users from the dashboard
- Compact auto-hide navigation that keeps the public and dashboard workspaces usable on narrow and desktop viewports
+- A pinned two-mode Playwright visual archive with protected source/database/link inventory, read-only production capture, isolated snapshot-lab states, overlapping high-resolution tiles, HTML/PDF reports, checksums, and baseline comparisons
## 📃 Production Notes
- Persistence uses `node:sqlite`, which emits Node's experimental warning during build and runtime.
- Studio overview reports the active `DATA_ROOT`, SQLite `quick_check`, journal mode, and seed version so rebuild-safe persistence can be verified from the browser. Seed upgrades are non-destructive for existing Studio-edited records.
- `/journal` and `/journal/[slug]` now redirect to Process. New public writing should be published as Process notes.
-- The public custom work flow is contact-first and includes a credential-free procedural 3D scale preview. The older SVG renderer remains for stored visualization snapshots. Optional photorealistic preview generation is available only when explicitly configured with a server-side OpenAI key and feature flag.
+- The public custom work flow is contact-first and includes a credential-free, dynamically loaded React Three Fiber conceptual proportional preview. A deterministic SVG drawing remains available for fallback, printing, and submitted snapshots. Optional photorealistic preview generation is available only when explicitly configured with a server-side OpenAI key and feature flag.
- The public site exposes admin-only edit controls when an admin is signed in. Mapped text and link fields save in place without a page reload; structural changes remain available through the linked `/studio` editor.
- Scientist Desk remains published without photos until the correct black phenolic resin top, birds-eye maple rails, and white maple legs media are verified.
- New piece records can be created without guessed photos. Media should be assigned only after review in the Woodshop dashboard.
@@ -50,6 +51,8 @@ Woodsmith is a self-hosted Next.js application for the Beaman Woodworks company
- `design/Beaman_Woodworks_V2_Google_Stitch_Beta/`: Beaman Woodworks 2.0 prototypes audited for layout, theme, and dashboard direction
- `ITC_New_Rennie_Mackintosh_Complete_Family_Pack/`: source font assets for the site typography
- `docker-compose.synology.yml`: Synology runtime model
+- `visual-audit/`: pinned Playwright capture, report, comparison, validation, and NAS automation package
+- `docs/visual-archive.md`: private live-readonly and snapshot-lab operating guide
- `synology-nas-deploy.md`: deployment and NAS operations guide
- `admin.md`: private Woodshop dashboard manual
- `woodsmith_DeepWiki_Merged_03222026.md`: codebase architecture reference
@@ -82,6 +85,11 @@ Required for a secure deployment:
- `NEXT_PUBLIC_SITE_URL`
- `DATA_ROOT` (production: `/app/site/data`)
+Required only when the private visual archive is enabled:
+
+- `VISUAL_AUDIT_TOKEN` and optional `VISUAL_AUDIT_MAX_RECORDS`
+- private ignored secret files prepared by `visual-audit/scripts/prepare-live-secrets.sh`
+
Local-first media AI configuration:
- `AI_PROVIDER`, `AI_ANALYSIS_PROVIDER`, `AI_EMBEDDING_PROVIDER`, `AI_FALLBACK_PROVIDER`
@@ -135,6 +143,8 @@ Public:
- `/process/[slug]`
- `/commissions`
- `/commissions/status`
+- `/contact`
+- `/care-and-warranty`
- `/about`
- `/search`
@@ -169,3 +179,4 @@ Use these docs together:
- `synology-nas-deploy.md`
- `admin.md`
- `woodsmith_DeepWiki_Merged_03222026.md`
+- `docs/visual-archive.md`
diff --git a/admin.md b/admin.md
index 02727c2..e7ed7f2 100644
--- a/admin.md
+++ b/admin.md
@@ -25,7 +25,7 @@ Changes save into the mounted SQLite data store, revalidate the matching public
### Portfolio and shop pieces
-The Pieces section can add drafts, update titles and descriptions, set category tabs, revise materials and tags, assign media paths, control publication status, manage inventory count, set asking-price data for shop items, and mark whether media has been verified.
+The Pieces section can add drafts, update titles and descriptions, set category tabs, revise materials and tags, select and order media visually by role, control publication status, manage inventory count, set asking-price data for shop items, and mark whether media has been verified. Raw path entry is not the normal workflow.
The Categories section manages the public portfolio filters. Each category has a stable key, public label, matching terms, and icon style. Categories can be renamed safely; deletion requires that assigned pieces are either absent or reassigned to another category.
@@ -120,8 +120,14 @@ Password resets, verification links, project updates, contact requests, and comm
### Custom work contact
-The public custom work page collects contact details, location, budget, requested piece type, preferred material, pickup/delivery/shipping preference, attachments, an optional 3D scale preview, and a written brief. It creates a private project record and redirects the buyer to a reference page. If `OPENAI_API_KEY` and `ENABLE_PUBLIC_AI_RENDERING=true` are configured, the visualizer can also generate a photorealistic preview and attach it only when the buyer chooses to include the preview.
+The public custom work page collects contact details, location, budget, requested piece type, preferred material, pickup/delivery/shipping preference, attachments, an optional conceptual proportional preview, and a written brief. The preview uses a dynamically loaded React Three Fiber scene with perspective/orthographic and front/side/top/isometric controls, while the deterministic SVG drawing remains available if WebGL or motion is unavailable. It creates a private project record and redirects the buyer to a reference page. If `OPENAI_API_KEY` and `ENABLE_PUBLIC_AI_RENDERING=true` are configured, the visualizer can also generate a photorealistic preview and attach it only when the buyer chooses to include the preview.
+## Private visual archive
+
+The visual archive is an operational QA tool, not a Studio content panel. It inventories public and authenticated routes, captures the final rendered interfaces, and produces restricted PNG/HTML/PDF evidence. Production capture is read-only at both browser and server layers. Save, upload, rename, delete, invoice, shipping, email, and model actions are captured only against an isolated SQLite/media clone.
+
+Use [`docs/visual-archive.md`](docs/visual-archive.md) for secret preparation, smoke/full runs, snapshot-lab setup, validation, retention, and post-deployment gates. Never upload the restricted archive or its authentication state to public CI or Git.
+
### Shop checkout
The cart calculates subtotal, coupon discount, tax estimate, shipping estimate, and total. If Stripe is configured, the app creates a hosted Checkout Session. If Stripe is not configured, checkout stops at a configuration-needed state.
diff --git a/docker-compose.synology.yml b/docker-compose.synology.yml
index 61aa7bc..41f1d77 100644
--- a/docker-compose.synology.yml
+++ b/docker-compose.synology.yml
@@ -18,6 +18,8 @@ services:
DATA_ROOT: "/app/site/data"
STUDIO_PASSWORD: "${STUDIO_PASSWORD}"
SESSION_SECRET: "${SESSION_SECRET}"
+ VISUAL_AUDIT_TOKEN: "${VISUAL_AUDIT_TOKEN}"
+ VISUAL_AUDIT_MAX_RECORDS: "${VISUAL_AUDIT_MAX_RECORDS:-5000}"
STRIPE_SECRET_KEY: "${STRIPE_SECRET_KEY}"
STRIPE_PUBLISHABLE_KEY: "${STRIPE_PUBLISHABLE_KEY}"
EASYPOST_API_KEY: "${EASYPOST_API_KEY}"
diff --git a/docker-compose.visual-audit-lab.yml b/docker-compose.visual-audit-lab.yml
new file mode 100644
index 0000000..95762e4
--- /dev/null
+++ b/docker-compose.visual-audit-lab.yml
@@ -0,0 +1,117 @@
+services:
+ woodsmith-audit-lab:
+ image: woodsmith:prod
+ container_name: woodsmith-audit-lab
+ restart: "no"
+ user: "${PUID:?PUID must be set}:${PGID:?PGID must be set}"
+ read_only: true
+ environment:
+ NODE_ENV: production
+ NEXT_TELEMETRY_DISABLED: "1"
+ PORT: "3002"
+ HOSTNAME: 0.0.0.0
+ SELF_HOSTED: "true"
+ SITE_URL: http://woodsmith-audit-lab:3002
+ NEXT_PUBLIC_SITE_URL: http://woodsmith-audit-lab:3002
+ MEDIA_ROOT: /app/pics
+ DATA_ROOT: /app/site/data
+ STUDIO_PASSWORD: "${AUDIT_LAB_STUDIO_PASSWORD:?AUDIT_LAB_STUDIO_PASSWORD must be set}"
+ SESSION_SECRET: "${AUDIT_LAB_SESSION_SECRET:?AUDIT_LAB_SESSION_SECRET must be set}"
+ VISUAL_AUDIT_TOKEN_FILE: /run/secrets/audit_token
+ VISUAL_AUDIT_MAX_RECORDS: "${VISUAL_AUDIT_MAX_RECORDS:-5000}"
+ STRIPE_SECRET_KEY: ""
+ STRIPE_PUBLISHABLE_KEY: ""
+ EASYPOST_API_KEY: ""
+ SMTP_HOST: ""
+ SMTP_USER: ""
+ SMTP_PASSWORD: ""
+ OPENAI_API_KEY: ""
+ ENABLE_PUBLIC_AI_RENDERING: "false"
+ ENABLE_AI_BACKGROUND_CLEANUP: "false"
+ ENABLE_AI_MEDIA_ANALYSIS: "false"
+ ENABLE_EMBEDDING_SEARCH: "false"
+ ENABLE_LOCAL_IMAGE_EMBEDDINGS: "false"
+ ENABLE_GEMINI_FALLBACK: "false"
+ AI_PROVIDER: disabled
+ AI_ANALYSIS_PROVIDER: disabled
+ AI_EMBEDDING_PROVIDER: disabled
+ AI_FALLBACK_PROVIDER: disabled
+ LOCAL_AI_SIDECAR_URL: http://127.0.0.1:9
+ OLLAMA_BASE_URL: http://127.0.0.1:9
+ expose:
+ - "3002"
+ volumes:
+ - "${AUDIT_LAB_DATA_DIR:?AUDIT_LAB_DATA_DIR must be set}:/app/site/data:rw"
+ - "${AUDIT_LAB_MEDIA_DIR:?AUDIT_LAB_MEDIA_DIR must be set}:/app/pics:rw"
+ tmpfs:
+ - /tmp:rw,noexec,nosuid,nodev,size=128m,mode=1777
+ - /app/site/.next/cache:rw,noexec,nosuid,nodev,size=128m,mode=700
+ secrets:
+ - audit_token
+ security_opt:
+ - no-new-privileges:true
+ cap_drop:
+ - ALL
+ networks:
+ - audit-lab
+ healthcheck:
+ test: ["CMD", "node", "--experimental-sqlite", "-e", "const{DatabaseSync}=require('node:sqlite');const d=new DatabaseSync('/app/site/data/woodsmith.sqlite',{readOnly:true});const ok=d.prepare('PRAGMA quick_check').all().some(r=>r.quick_check==='ok');d.close();if(!ok)process.exit(1);fetch('http://127.0.0.1:3002/studio/login').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
+ interval: 5s
+ timeout: 5s
+ retries: 30
+
+ visual-audit:
+ build:
+ context: .
+ dockerfile: visual-audit/Dockerfile
+ container_name: woodsmith-visual-audit-lab-runner
+ init: true
+ ipc: host
+ read_only: true
+ user: "${PUID:?PUID must be set}:${PGID:?PGID must be set}"
+ depends_on:
+ woodsmith-audit-lab:
+ condition: service_healthy
+ environment:
+ TARGET_MODE: snapshot-lab
+ BASE_URL: http://woodsmith-audit-lab:3002
+ TARGET_COMMIT_SHA: "${TARGET_COMMIT_SHA:?TARGET_COMMIT_SHA must be set}"
+ AUDIT_RUN_ID: "${AUDIT_RUN_ID:?AUDIT_RUN_ID must be set}"
+ AUDIT_SCOPE: "${AUDIT_SCOPE:-full}"
+ AUDIT_RESUME: "${AUDIT_RESUME:-true}"
+ RUN_OUTPUT_ROOT: /output
+ REPO_ROOT: /workspace
+ AUDIT_TMP_ROOT: /audit-tmp
+ APPROVED_BASELINE_ROOT: "${APPROVED_BASELINE_ROOT:-}"
+ WOODSMITH_ADMIN_EMAIL: "${WOODSMITH_ADMIN_EMAIL:-woodsmithbb@proton.me}"
+ ADMIN_PASSWORD_FILE: /run/secrets/admin_password
+ AUDIT_TOKEN_FILE: /run/secrets/audit_token
+ MAX_FULL_PAGE_DEVICE_HEIGHT: "${MAX_FULL_PAGE_DEVICE_HEIGHT:-50000}"
+ MAX_STITCHED_SEGMENT_HEIGHT: "${MAX_STITCHED_SEGMENT_HEIGHT:-60000}"
+ AUDIT_STRICT_DIAGNOSTICS: "${AUDIT_STRICT_DIAGNOSTICS:-true}"
+ volumes:
+ - ./:/workspace:ro
+ - /volume2/docker_ssd/woodsmith/visual-audits:/output:rw
+ tmpfs:
+ - /audit-tmp:rw,noexec,nosuid,nodev,size=64m,mode=700
+ - /tmp:rw,noexec,nosuid,nodev,size=256m,mode=1777
+ secrets:
+ - admin_password
+ - audit_token
+ security_opt:
+ - no-new-privileges:true
+ cap_drop:
+ - ALL
+ networks:
+ - audit-lab
+ restart: "no"
+
+secrets:
+ admin_password:
+ file: ./secrets/woodsmith_audit_lab_password
+ audit_token:
+ file: ./secrets/woodsmith_visual_audit_token
+
+networks:
+ audit-lab:
+ internal: true
diff --git a/docker-compose.visual-audit-live.yml b/docker-compose.visual-audit-live.yml
new file mode 100644
index 0000000..6ddbfe2
--- /dev/null
+++ b/docker-compose.visual-audit-live.yml
@@ -0,0 +1,47 @@
+services:
+ visual-audit:
+ build:
+ context: .
+ dockerfile: visual-audit/Dockerfile
+ container_name: woodsmith-visual-audit
+ init: true
+ ipc: host
+ read_only: true
+ user: "${PUID:?PUID must be set}:${PGID:?PGID must be set}"
+ environment:
+ TARGET_MODE: live-readonly
+ BASE_URL: "${VISUAL_AUDIT_BASE_URL:-https://woodmat.ch}"
+ TARGET_COMMIT_SHA: "${TARGET_COMMIT_SHA:?TARGET_COMMIT_SHA must be set}"
+ AUDIT_RUN_ID: "${AUDIT_RUN_ID:?AUDIT_RUN_ID must be set}"
+ AUDIT_SCOPE: "${AUDIT_SCOPE:-full}"
+ AUDIT_RESUME: "${AUDIT_RESUME:-true}"
+ RUN_OUTPUT_ROOT: /output
+ REPO_ROOT: /workspace
+ AUDIT_TMP_ROOT: /audit-tmp
+ APPROVED_BASELINE_ROOT: "${APPROVED_BASELINE_ROOT:-}"
+ WOODSMITH_ADMIN_EMAIL: "${WOODSMITH_ADMIN_EMAIL:-woodsmithbb@proton.me}"
+ ADMIN_PASSWORD_FILE: /run/secrets/admin_password
+ AUDIT_TOKEN_FILE: /run/secrets/audit_token
+ MAX_FULL_PAGE_DEVICE_HEIGHT: "${MAX_FULL_PAGE_DEVICE_HEIGHT:-50000}"
+ MAX_STITCHED_SEGMENT_HEIGHT: "${MAX_STITCHED_SEGMENT_HEIGHT:-60000}"
+ AUDIT_STRICT_DIAGNOSTICS: "${AUDIT_STRICT_DIAGNOSTICS:-true}"
+ volumes:
+ - ./:/workspace:ro
+ - /volume2/docker_ssd/woodsmith/visual-audits:/output:rw
+ tmpfs:
+ - /audit-tmp:rw,noexec,nosuid,nodev,size=64m,mode=700
+ - /tmp:rw,noexec,nosuid,nodev,size=256m,mode=1777
+ secrets:
+ - admin_password
+ - audit_token
+ security_opt:
+ - no-new-privileges:true
+ cap_drop:
+ - ALL
+ restart: "no"
+
+secrets:
+ admin_password:
+ file: ./secrets/woodsmith_audit_admin_password
+ audit_token:
+ file: ./secrets/woodsmith_visual_audit_token
diff --git a/docs/sitewide-ux-overhaul-audit-20260711.md b/docs/sitewide-ux-overhaul-audit-20260711.md
index a6234ef..c9378b1 100644
--- a/docs/sitewide-ux-overhaul-audit-20260711.md
+++ b/docs/sitewide-ux-overhaul-audit-20260711.md
@@ -2,6 +2,8 @@
Date: 2026-07-11
Branch: `codex/sitewide-studio-ux-commission-overhaul-20260711`
+
+Rendered verification for this overhaul is now provided by the pinned two-mode system documented in `docs/visual-archive.md`. Its evidence contract covers source/database/rendered-link inventory, read-only live capture, isolated mutation-state capture, required themes and viewports, deep Studio/media/inline-edit/visualizer states, overlapping raw tiles, stitched surfaces, restricted/redacted HTML and PDF reports, checksums, and baseline comparison. Deployment remains gated on a passing final-candidate smoke and full archive.
Baseline commit: `81b35c4b97d464a388560c13c1526b78c10afa45`
This document records the verified baseline used for the sitewide overhaul. Documentation claims were treated as hypotheses and checked against source, a disposable SQLite backup, supplied screenshots, and read-only production requests.
diff --git a/docs/visual-archive.md b/docs/visual-archive.md
new file mode 100644
index 0000000..84d95ed
--- /dev/null
+++ b/docs/visual-archive.md
@@ -0,0 +1,225 @@
+# Deterministic Visual Archive
+
+The visual archive is a private, repository-integrated Playwright system for rendered-browser QA and long-term evidence. It uses pinned Playwright 1.61.0 and Sharp 0.35.3, records the deployed commit, and reconciles routes from source files, the protected SQLite inventory, and rendered same-origin links.
+
+Generated archives are runtime artifacts. They must remain outside Git and private unless the redacted edition has been reviewed for sharing.
+
+## Safety Model
+
+The two modes are intentionally separate:
+
+- `live-readonly` authenticates to the deployed site, then blocks every unsafe same-origin and cross-origin request in Playwright. Same-origin requests also carry `x-woodsmith-audit-readonly: 1`, which makes `site/proxy.ts` reject unsafe methods with HTTP 409. The one Studio login POST is the explicit authentication exception.
+- `snapshot-lab` runs against a verified SQLite `VACUUM INTO` clone and a reflink or full-copy media tree. Its Docker network is internal, provider keys are empty, paid/local model providers are disabled, and the production data/media paths are rejected.
+
+The inventory endpoint requires both normal admin authentication and a separate audit token. It returns bounded route-driving metadata and counts, not users, customer contact details, notification bodies, payment data, reset tokens, or session state. The runner adds the audit token only to the same-origin inventory endpoint and authenticated `audit=all` Studio pages.
+
+Secrets, storage state, SQLite files, media, raw tiles, PNGs, HTML, PDFs, traces, and reports are ignored by Git. Authentication state is held under the runner tmpfs and removed in `finally`.
+
+## One-Time NAS Setup
+
+Run from the single authoritative checkout:
+
+```bash
+cd /volume2/docker_ssd/woodsmith
+```
+
+Set `VISUAL_AUDIT_TOKEN` in `.env` to one independently generated 64-character hexadecimal value. Keep the value single-quoted if needed. Then prepare the private Docker secret files:
+
+```bash
+chmod 700 visual-audit/scripts/prepare-live-secrets.sh
+visual-audit/scripts/prepare-live-secrets.sh
+```
+
+The script obtains `STUDIO_PASSWORD` from the running `woodsmith` container, parses the token from `.env`, writes both files atomically with mode 600, and prints only nonsecret status. It does not use `read -p`, so it is safe when launched from zsh. It also does not use `docker compose config --environment`; the installed Synology Compose build does not support that option, and that failed approach must not be retried.
+
+Required private files:
+
+```text
+secrets/woodsmith_audit_admin_password
+secrets/woodsmith_visual_audit_token
+```
+
+Create the private output root:
+
+```bash
+install -d -m 700 /volume2/docker_ssd/woodsmith/visual-audits
+```
+
+## Static Validation
+
+Use explicit nonsecret placeholders for Compose parsing. Do not render secret-bearing Compose output into logs.
+
+```bash
+cd /volume2/docker_ssd/woodsmith
+
+export TARGET_COMMIT_SHA="$(git rev-parse HEAD)"
+export AUDIT_RUN_ID="config-check-$(printf '%s' "$TARGET_COMMIT_SHA" | cut -c1-8)"
+
+npm run typecheck
+npm run test
+npm run lint
+npm run build
+npm --prefix visual-audit test
+
+docker compose --env-file .env \
+ -f docker-compose.synology.yml \
+ config --quiet
+
+docker compose --env-file .env \
+ -f docker-compose.visual-audit-live.yml \
+ config --quiet
+
+docker compose --env-file .env \
+ --env-file .visual-audit-lab.env \
+ -f docker-compose.visual-audit-lab.yml \
+ config --quiet
+```
+
+The lab Compose check requires a prepared `.visual-audit-lab.env`. If no lab has been prepared, validate with temporary nonsecret placeholder variables and nonproduction directories instead.
+
+NAS candidate and production images must report the audited commit. Build them with:
+
+```bash
+docker buildx build \
+ --platform linux/amd64 \
+ --build-arg WOODSMITH_BUILD_SHA="$(git rev-parse HEAD)" \
+ -t woodsmith:prod \
+ --load .
+```
+
+The runner permits an unstamped image only for a loopback development smoke. It rejects missing or mismatched build identity for remote and snapshot-lab targets.
+
+## Live Read-Only Smoke
+
+Always run a smoke archive before the full archive. One exported `AUDIT_RUN_ID` must be shared by capture, comparison, report, and validation.
+
+```bash
+cd /volume2/docker_ssd/woodsmith
+
+export TARGET_COMMIT_SHA="$(git rev-parse HEAD)"
+export AUDIT_RUN_ID="smoke-$(date -u '+%Y%m%dT%H%M%SZ')-$(printf '%s' "$TARGET_COMMIT_SHA" | cut -c1-8)"
+export AUDIT_SCOPE=smoke
+export AUDIT_RESUME=true
+
+visual-audit/scripts/run-live-audit.sh
+```
+
+The smoke is accepted only when:
+
+- the inventory endpoint is hidden without the token and denies a token-only unauthenticated request;
+- a read-only unsafe request returns HTTP 409;
+- the manifest reports zero successful unsafe requests;
+- capture, comparison, report, and validation use the same run ID;
+- `validation.json` reports `passed: true`.
+
+Resume an interrupted run by preserving the same `AUDIT_RUN_ID`, `TARGET_COMMIT_SHA`, scope, and output directory.
+
+## Full Live Archive
+
+```bash
+cd /volume2/docker_ssd/woodsmith
+
+export TARGET_COMMIT_SHA="$(git rev-parse HEAD)"
+export AUDIT_RUN_ID="full-$(date -u '+%Y%m%dT%H%M%SZ')-$(printf '%s' "$TARGET_COMMIT_SHA" | cut -c1-8)"
+export AUDIT_SCOPE=full
+export AUDIT_RESUME=true
+
+visual-audit/scripts/run-live-audit.sh
+```
+
+Full mode covers dark and light themes at:
+
+- 1440 x 900, 1280 x 800, and 1024 x 768 desktop profiles;
+- 1024 x 1366 tablet at DPR 2;
+- 430 x 932, 390 x 844, 375 x 812, and 320 x 720 mobile profiles at DPR 3;
+- 2560 x 1440 archival desktop at DPR 2.
+
+Deep archival-dark capture opens disclosures, lightboxes, media pickers, inline editing, Studio editors, media inspectors, validation states, visualizer boundaries, and the element atlas. Long pages and independently scrollable surfaces use 12 percent overlapping raw tiles, stitched PNGs, tile manifests, and image-correlation seam checks.
+
+## Snapshot Lab
+
+Prepare a new isolated lab for every mutation-state run:
+
+```bash
+cd /volume2/docker_ssd/woodsmith
+visual-audit/scripts/prepare-snapshot-lab.sh
+visual-audit/scripts/run-snapshot-lab.sh
+```
+
+Preparation performs an online SQLite backup with `VACUUM INTO`, verifies `PRAGMA quick_check`, copies the database into a unique lab root, and creates a Btrfs reflink media copy when supported. If reflinks are unavailable, it performs a full `rsync -a` copy. Hardlinks are forbidden because lab rename/delete operations must not alter production originals.
+
+Both lab mounts contain a matching run marker. The runner rejects missing markers, production paths, mismatched run IDs, and unavailable clones. The lab container health check verifies the cloned database before capture begins. The internal Docker network blocks outbound provider access.
+
+Do not run `docker compose down -v`; the scripts use ordinary `down` and preserve bind-mounted evidence for review.
+
+## Artifacts
+
+Each restricted run directory uses mode 700 and contains mode-600 files:
+
+```text
+visual-audits//
+ coverage-plan.json
+ manifest.json
+ comparison.json
+ validation.json
+ checksums.json
+ checksums.sha256
+ png/
+ report/index.html
+ report/print.html
+ report/report-index.json
+ woodmat-visual-atlas.pdf
+ shareable/index.html
+ shareable/manifest.redacted.json
+ shareable/woodmat-visual-atlas-redacted.pdf
+```
+
+Raw tiles and tile manifests live beside their stitched capture. The HTML report is searchable and includes linked contents. The PDF uses selectable text, Chromium outlines/bookmarks, and bounded image slices so tall pages remain readable. The PNG and raw-tile archive remains the highest-resolution source of truth.
+
+The shareable edition includes anonymous, nonsensitive captures only. Review it before moving it outside the restricted NAS archive.
+
+## Validation Contract
+
+`dist/validate.js` fails the run for:
+
+- incomplete or truncated inventory;
+- missing route/theme/viewport combinations;
+- discovered same-origin links not captured;
+- missing deep states that were present in the rendered surface inventory;
+- unexpected status codes, redirects, console/page errors, request failures, broken media, or horizontal overflow;
+- invalid, blank, missing, or duplicate PNG captures;
+- raw-tile coverage gaps or seam-correlation failures;
+- missing HTML contents targets, PDFs, PDF pages, or bookmark trees;
+- commit/run/mode mismatches;
+- successful unsafe live requests;
+- exact secret values found in any output artifact;
+- permissive archive directory modes on Linux.
+
+Checksums cover the final report, validation record, PNGs, raw tiles, manifests, HTML, PDFs, and comparison output.
+
+## Scheduling And Retention
+
+Use Synology Task Scheduler as `root` after deployment and after the first manual full run succeeds:
+
+```bash
+AUDIT_SCOPE=full /volume2/docker_ssd/woodsmith/visual-audit/scripts/run-live-audit.sh \
+ >> /volume2/docker_ssd/woodsmith/visual-audits/scheduler.log 2>&1
+```
+
+The run script uses a lock directory to prevent overlap. Retention is dry-run by default:
+
+```bash
+AUDIT_RETENTION_DAYS=90 visual-audit/scripts/prune-audits.sh
+```
+
+After reviewing the listed paths:
+
+```bash
+AUDIT_RETENTION_DAYS=90 visual-audit/scripts/prune-audits.sh --apply
+```
+
+Retention refuses roots outside `/volume2/docker_ssd/woodsmith/visual-audits` and refuses periods shorter than 30 days.
+
+## Release Gate
+
+Do not deploy merely to obtain screenshots. The application candidate must first pass the normal database backup, `quick_check`, no-embedded-database, disposable-data, mounted media/data/cache, route, log, rollback-image, and public canonical-origin gates. Run the smoke archive against the promoted candidate, then the full live archive and isolated lab archive. Keep the previous image and database backup until the post-deployment archive validates.
diff --git a/site/app/api/visual-audit/inventory/route.ts b/site/app/api/visual-audit/inventory/route.ts
new file mode 100644
index 0000000..75f437a
--- /dev/null
+++ b/site/app/api/visual-audit/inventory/route.ts
@@ -0,0 +1,197 @@
+import type { NextRequest } from "next/server";
+import { NextResponse } from "next/server";
+
+import { getCurrentUser } from "@/lib/auth";
+import {
+ countMedia,
+ listNotifications,
+ listOrders,
+ listPages,
+ listPieces,
+ listPosts,
+ listProjects,
+ listReviews,
+ listUsers
+} from "@/lib/db";
+import { visualAuditTokenValid } from "@/lib/visual-audit";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+const STUDIO_PANELS = [
+ "overview",
+ "settings",
+ "pages",
+ "pieces",
+ "categories",
+ "custom",
+ "people",
+ "process",
+ "media",
+ "projects",
+ "orders",
+ "reviews",
+ "notifications"
+] as const;
+
+const STATIC_ROUTES = [
+ "/",
+ "/about",
+ "/contact",
+ "/portfolio",
+ "/shop",
+ "/shop/cart",
+ "/process",
+ "/commissions",
+ "/commissions/status",
+ "/search",
+ "/account/signup",
+ "/account/login",
+ "/account/forgot",
+ "/account/reset",
+ "/account/verify",
+ "/account/profile",
+ "/account/projects",
+ "/studio/login",
+ "/studio"
+] as const;
+
+const LEGACY_ROUTES = [
+ "/journal"
+] as const;
+
+const INVENTORY_RECORD_LIMIT = Math.min(
+ 5_000,
+ Math.max(100, Number.parseInt(process.env.VISUAL_AUDIT_MAX_RECORDS ?? "5000", 10) || 5_000)
+);
+
+export async function GET(request: NextRequest) {
+ if (
+ !visualAuditTokenValid(
+ request.headers.get("x-woodsmith-audit-token")
+ )
+ ) {
+ return NextResponse.json(
+ { error: "Not found." },
+ {
+ status: 404,
+ headers: { "cache-control": "no-store" }
+ }
+ );
+ }
+
+ const user = await getCurrentUser();
+
+ if (!user || user.role !== "admin") {
+ return NextResponse.json(
+ { error: "Admin authentication is required." },
+ {
+ status: 401,
+ headers: { "cache-control": "no-store" }
+ }
+ );
+ }
+
+ const pages = listPages(true);
+ const pieces = listPieces(true);
+ const posts = listPosts(true);
+ const projects = listProjects(true);
+ const orders = listOrders();
+ const reviews = listReviews();
+ const notifications = listNotifications();
+ const users = listUsers();
+ const mediaCount = countMedia({ includeUnreviewed: true });
+ const truncatedCollections: string[] = [];
+
+ function bounded(name: string, records: T[]) {
+ if (records.length > INVENTORY_RECORD_LIMIT) truncatedCollections.push(name);
+ return records.slice(0, INVENTORY_RECORD_LIMIT);
+ }
+
+ return NextResponse.json(
+ {
+ schemaVersion: 1,
+ generatedAt: new Date().toISOString(),
+ buildSha: process.env.WOODSMITH_BUILD_SHA ?? "unknown",
+
+ staticRoutes: STATIC_ROUTES,
+ legacyRoutes: LEGACY_ROUTES,
+ studioPanels: STUDIO_PANELS,
+
+ dynamicPatterns: [
+ "/[slug]",
+ "/portfolio/[slug]",
+ "/process/[slug]",
+ "/requests/[reference]",
+ "/studio/request/[reference]",
+ "/account/verify/[token]"
+ ],
+
+ pages: bounded("pages", pages).map((page) => ({
+ slug: page.slug,
+ title: page.title,
+ status: page.status
+ })),
+
+ pieces: bounded("pieces", pieces).map((piece) => ({
+ slug: piece.slug,
+ title: piece.title,
+ publicationStatus: piece.publicationStatus,
+ status: piece.status
+ })),
+
+ posts: bounded("posts", posts).map((post) => ({
+ slug: post.slug,
+ title: post.title,
+ publicationStatus: post.publicationStatus
+ })),
+
+ projects: bounded("projects", projects).map((project) => ({
+ reference: project.reference,
+ status: project.status,
+ stage: project.stage
+ })),
+
+ orders: bounded("orders", orders).map((order) => ({
+ orderNumber: order.orderNumber,
+ status: order.status,
+ paymentStatus: order.paymentStatus
+ })),
+
+ reviews: bounded("reviews", reviews).map((review) => ({
+ id: review.id,
+ pieceSlug: review.pieceSlug,
+ status: review.status
+ })),
+
+ notifications: bounded("notifications", notifications).map((notification) => ({
+ id: notification.id,
+ status: notification.status
+ })),
+
+ counts: {
+ pages: pages.length,
+ pieces: pieces.length,
+ posts: posts.length,
+ projects: projects.length,
+ orders: orders.length,
+ reviews: reviews.length,
+ notifications: notifications.length,
+ users: users.length,
+ media: mediaCount
+ },
+
+ limits: {
+ recordsPerCollection: INVENTORY_RECORD_LIMIT,
+ truncatedCollections
+ }
+ },
+ {
+ headers: {
+ "cache-control": "no-store, no-cache, must-revalidate",
+ pragma: "no-cache",
+ "x-content-type-options": "nosniff"
+ }
+ }
+ );
+}
diff --git a/site/lib/visual-audit-policy.ts b/site/lib/visual-audit-policy.ts
new file mode 100644
index 0000000..f31205e
--- /dev/null
+++ b/site/lib/visual-audit-policy.ts
@@ -0,0 +1,6 @@
+export function isVisualAuditReadOnlyMutation(
+ readOnlyHeader: string | null | undefined,
+ method: string
+) {
+ return readOnlyHeader === "1" && !["GET", "HEAD", "OPTIONS"].includes(method.toUpperCase());
+}
diff --git a/site/lib/visual-audit-security.test.mts b/site/lib/visual-audit-security.test.mts
new file mode 100644
index 0000000..73c74a5
--- /dev/null
+++ b/site/lib/visual-audit-security.test.mts
@@ -0,0 +1,21 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { isVisualAuditReadOnlyMutation } from "./visual-audit-policy.ts";
+import { constantTimeVisualAuditTokenMatch } from "./visual-audit-token.ts";
+
+test("visual audit token matching rejects missing and nonmatching values", () => {
+ assert.equal(constantTimeVisualAuditTokenMatch("audit-secret", "audit-secret"), true);
+ assert.equal(constantTimeVisualAuditTokenMatch("audit-secret", "wrong-secret"), false);
+ assert.equal(constantTimeVisualAuditTokenMatch("audit-secret", ""), false);
+ assert.equal(constantTimeVisualAuditTokenMatch("", "audit-secret"), false);
+});
+
+test("server read-only policy blocks every unsafe method only when requested", () => {
+ assert.equal(isVisualAuditReadOnlyMutation("1", "GET"), false);
+ assert.equal(isVisualAuditReadOnlyMutation("1", "HEAD"), false);
+ assert.equal(isVisualAuditReadOnlyMutation("1", "OPTIONS"), false);
+ assert.equal(isVisualAuditReadOnlyMutation("1", "POST"), true);
+ assert.equal(isVisualAuditReadOnlyMutation("1", "patch"), true);
+ assert.equal(isVisualAuditReadOnlyMutation(null, "DELETE"), false);
+});
diff --git a/site/lib/visual-audit-token.ts b/site/lib/visual-audit-token.ts
new file mode 100644
index 0000000..74c73ec
--- /dev/null
+++ b/site/lib/visual-audit-token.ts
@@ -0,0 +1,16 @@
+import { createHash, timingSafeEqual } from "node:crypto";
+
+function digest(value: string) {
+ return createHash("sha256").update(value).digest();
+}
+
+export function constantTimeVisualAuditTokenMatch(
+ configured: string | null | undefined,
+ candidate: string | null | undefined
+) {
+ const expected = configured?.trim() ?? "";
+ const received = candidate?.trim() ?? "";
+
+ if (!expected || !received) return false;
+ return timingSafeEqual(digest(expected), digest(received));
+}
diff --git a/site/lib/visual-audit.ts b/site/lib/visual-audit.ts
index 066caad..87fde0d 100644
--- a/site/lib/visual-audit.ts
+++ b/site/lib/visual-audit.ts
@@ -1,23 +1,31 @@
-import { createHash, timingSafeEqual } from "node:crypto";
+import { readFileSync } from "node:fs";
import { headers } from "next/headers";
+import { constantTimeVisualAuditTokenMatch } from "@/lib/visual-audit-token";
+
const AUDIT_TOKEN_HEADER = "x-woodsmith-audit-token";
-function digest(value: string) {
- return createHash("sha256").update(value).digest();
+function configuredToken() {
+ const file = process.env.VISUAL_AUDIT_TOKEN_FILE?.trim();
+
+ if (file) {
+ try {
+ return readFileSync(file, "utf8").trim();
+ } catch {
+ return "";
+ }
+ }
+
+ return process.env.VISUAL_AUDIT_TOKEN?.trim() ?? "";
}
export function visualAuditTokenValid(
candidate: string | null | undefined
) {
- const configured = process.env.VISUAL_AUDIT_TOKEN?.trim() ?? "";
+ const configured = configuredToken();
const received = candidate?.trim() ?? "";
- if (!configured || !received) {
- return false;
- }
-
- return timingSafeEqual(digest(configured), digest(received));
+ return constantTimeVisualAuditTokenMatch(configured, received);
}
export async function visualAuditRequestAuthorized() {
diff --git a/site/package.json b/site/package.json
index dcc6281..f0e1632 100644
--- a/site/package.json
+++ b/site/package.json
@@ -8,7 +8,7 @@
"start": "node --experimental-sqlite .next/standalone/server.js",
"lint": "eslint app components lib",
"typecheck": "tsc --noEmit",
- "test": "node --experimental-strip-types --test lib/media-scoring.test.mts lib/piece-model.test.mts lib/database-migrations.test.mts lib/media-reference-transaction.test.mts lib/category-icons.test.mts lib/site-structure.test.mts lib/piece-media.test.mts lib/estimator.test.mts"
+ "test": "node --experimental-strip-types --test lib/media-scoring.test.mts lib/piece-model.test.mts lib/database-migrations.test.mts lib/media-reference-transaction.test.mts lib/category-icons.test.mts lib/site-structure.test.mts lib/piece-media.test.mts lib/estimator.test.mts lib/visual-audit-security.test.mts"
},
"dependencies": {
"@react-three/drei": "^10.7.7",
diff --git a/site/proxy.ts b/site/proxy.ts
index f869f84..0d53179 100644
--- a/site/proxy.ts
+++ b/site/proxy.ts
@@ -1,19 +1,47 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
+import { isVisualAuditReadOnlyMutation } from "@/lib/visual-audit-policy";
+
const CANONICAL_ORIGIN = "https://woodmat.ch";
const LEGACY_HOSTS = new Set(["www.woodmat.ch"]);
+const AUDIT_READ_ONLY_HEADER = "x-woodsmith-audit-readonly";
+
export function proxy(request: NextRequest) {
const host = request.headers.get("host")?.toLowerCase();
+
if (host && LEGACY_HOSTS.has(host)) {
const url = request.nextUrl.clone();
- return NextResponse.redirect(new URL(`${url.pathname}${url.search}`, CANONICAL_ORIGIN), 308);
+
+ return NextResponse.redirect(
+ new URL(`${url.pathname}${url.search}`, CANONICAL_ORIGIN),
+ 308
+ );
+ }
+
+ if (isVisualAuditReadOnlyMutation(request.headers.get(AUDIT_READ_ONLY_HEADER), request.method)) {
+ return NextResponse.json(
+ {
+ error: "Visual-audit read-only mode blocked a state-changing request.",
+ method: request.method,
+ pathname: request.nextUrl.pathname
+ },
+ {
+ status: 409,
+ headers: {
+ "cache-control": "no-store",
+ "x-woodsmith-audit-blocked": "1"
+ }
+ }
+ );
}
return NextResponse.next();
}
export const config = {
- matcher: ["/((?!_next/static|_next/image|favicon.ico|robots.txt|site.webmanifest).*)"]
+ matcher: [
+ "/((?!_next/static|_next/image|favicon.ico|robots.txt|site.webmanifest).*)"
+ ]
};
diff --git a/synology-nas-deploy.md b/synology-nas-deploy.md
index 1462403..676efc4 100644
--- a/synology-nas-deploy.md
+++ b/synology-nas-deploy.md
@@ -151,7 +151,11 @@ ChatGPT Plus is not an API backend. Gemini and OpenAI require their own API cred
```bash
cd /mnt/woodsmith
-docker buildx build --platform linux/amd64 -t woodsmith:prod --load .
+docker buildx build \
+ --platform linux/amd64 \
+ --build-arg WOODSMITH_BUILD_SHA="$(git rev-parse HEAD)" \
+ -t woodsmith:prod \
+ --load .
```
Optional local container smoke test:
@@ -259,6 +263,27 @@ curl -I http://127.0.0.1:3002/studio/login
docker compose -f docker-compose.synology.yml logs --tail=200 woodsmith
```
+## Post-deployment visual archive
+
+After the candidate passes the normal database, mount, route, and log checks, run the deterministic live-readonly smoke before the full archive:
+
+```bash
+cd /volume2/docker_ssd/woodsmith
+visual-audit/scripts/prepare-live-secrets.sh
+
+export TARGET_COMMIT_SHA="$(git rev-parse HEAD)"
+export AUDIT_RUN_ID="smoke-$(date -u '+%Y%m%dT%H%M%SZ')-$(printf '%s' "$TARGET_COMMIT_SHA" | cut -c1-8)"
+export AUDIT_SCOPE=smoke
+
+visual-audit/scripts/run-live-audit.sh
+```
+
+The same run ID is used by capture, baseline comparison, report generation, and validation. Do not use `docker compose config --environment`; the Synology Compose build does not support it. The secret preparation script is noninteractive and zsh-safe, reads the active Studio password without displaying it, and writes ignored mode-600 files.
+
+Mutation-dependent success/error states require `visual-audit/scripts/prepare-snapshot-lab.sh` followed by `visual-audit/scripts/run-snapshot-lab.sh`. The lab uses a `VACUUM INTO` database clone, reflink/full-copy media, verified run markers, an internal Docker network, and disabled external integrations. It never mounts production data or media read-write.
+
+Full commands, artifacts, permissions, retention, and acceptance criteria are in [`docs/visual-archive.md`](docs/visual-archive.md).
+
## Backup guidance
Because the dashboard can mutate the shared media library, back up these paths together:
diff --git a/visual-audit/Dockerfile b/visual-audit/Dockerfile
new file mode 100644
index 0000000..b52be8e
--- /dev/null
+++ b/visual-audit/Dockerfile
@@ -0,0 +1,16 @@
+FROM mcr.microsoft.com/playwright:v1.61.0-noble
+
+WORKDIR /audit
+
+ENV NODE_ENV=production
+ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
+ENV HOME=/tmp
+
+COPY visual-audit/package.json ./package.json
+COPY visual-audit/package-lock.json ./package-lock.json
+RUN npm ci
+
+COPY visual-audit/ ./
+RUN npm run build
+
+ENTRYPOINT ["node", "dist/run.js"]
diff --git a/visual-audit/package-lock.json b/visual-audit/package-lock.json
new file mode 100644
index 0000000..161a50f
--- /dev/null
+++ b/visual-audit/package-lock.json
@@ -0,0 +1,683 @@
+{
+ "name": "woodsmith-visual-audit",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "woodsmith-visual-audit",
+ "version": "1.0.0",
+ "license": "ISC",
+ "dependencies": {
+ "playwright": "1.61.0",
+ "sharp": "0.35.3"
+ },
+ "devDependencies": {
+ "@types/node": "22.15.0",
+ "typescript": "5.8.3"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.11.2",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
+ "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@img/colour": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
+ "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@img/sharp-darwin-arm64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
+ "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-arm64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-darwin-x64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
+ "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-x64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-freebsd-wasm32": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
+ "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "dependencies": {
+ "@img/sharp-wasm32": "0.35.3"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
+ "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-x64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
+ "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
+ "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
+ "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-ppc64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
+ "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-riscv64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
+ "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-s390x": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
+ "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-x64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
+ "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
+ "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
+ "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
+ "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
+ "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linux-ppc64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
+ "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-ppc64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linux-riscv64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
+ "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-riscv64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linux-s390x": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
+ "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-s390x": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linux-x64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
+ "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-x64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
+ "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-x64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
+ "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-x64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-wasm32": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
+ "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.11.1"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-webcontainers-wasm32": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
+ "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "@img/sharp-wasm32": "0.35.3"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-arm64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
+ "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-ia32": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
+ "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-x64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
+ "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "22.15.0",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.0.tgz",
+ "integrity": "sha512-99S8dWD2DkeE6PBaEDw+In3aar7hdoBvjyJMR6vaKBTzpvR0P00ClzJMOoVrj9D2+Sy/YCwACYHnBTpMhg1UCA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.61.0",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz",
+ "integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.61.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.61.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz",
+ "integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==",
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/sharp": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
+ "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@img/colour": "^1.1.0",
+ "detect-libc": "^2.1.2",
+ "semver": "^7.8.5"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "0.35.3",
+ "@img/sharp-darwin-x64": "0.35.3",
+ "@img/sharp-freebsd-wasm32": "0.35.3",
+ "@img/sharp-libvips-darwin-arm64": "1.3.2",
+ "@img/sharp-libvips-darwin-x64": "1.3.2",
+ "@img/sharp-libvips-linux-arm": "1.3.2",
+ "@img/sharp-libvips-linux-arm64": "1.3.2",
+ "@img/sharp-libvips-linux-ppc64": "1.3.2",
+ "@img/sharp-libvips-linux-riscv64": "1.3.2",
+ "@img/sharp-libvips-linux-s390x": "1.3.2",
+ "@img/sharp-libvips-linux-x64": "1.3.2",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
+ "@img/sharp-libvips-linuxmusl-x64": "1.3.2",
+ "@img/sharp-linux-arm": "0.35.3",
+ "@img/sharp-linux-arm64": "0.35.3",
+ "@img/sharp-linux-ppc64": "0.35.3",
+ "@img/sharp-linux-riscv64": "0.35.3",
+ "@img/sharp-linux-s390x": "0.35.3",
+ "@img/sharp-linux-x64": "0.35.3",
+ "@img/sharp-linuxmusl-arm64": "0.35.3",
+ "@img/sharp-linuxmusl-x64": "0.35.3",
+ "@img/sharp-webcontainers-wasm32": "0.35.3",
+ "@img/sharp-win32-arm64": "0.35.3",
+ "@img/sharp-win32-ia32": "0.35.3",
+ "@img/sharp-win32-x64": "0.35.3"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD",
+ "optional": true
+ },
+ "node_modules/typescript": {
+ "version": "5.8.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
+ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "dev": true,
+ "license": "MIT"
+ }
+ }
+}
diff --git a/visual-audit/package.json b/visual-audit/package.json
new file mode 100644
index 0000000..61a9ff6
--- /dev/null
+++ b/visual-audit/package.json
@@ -0,0 +1,27 @@
+{
+ "name": "woodsmith-visual-audit",
+ "version": "1.0.0",
+ "description": "Deterministic two-mode visual archive for Beaman Woodworks",
+ "scripts": {
+ "test": "npm run build && node --test dist/*.test.js",
+ "build": "tsc -p tsconfig.json",
+ "capture": "npm run build && node dist/run.js",
+ "report": "npm run build && node dist/report.js",
+ "validate": "npm run build && node dist/validate.js",
+ "compare": "npm run build && node dist/diff.js",
+ "all": "npm run capture && npm run compare && npm run report && npm run validate"
+ },
+ "keywords": [],
+ "author": "",
+ "license": "ISC",
+ "type": "module",
+ "private": true,
+ "dependencies": {
+ "playwright": "1.61.0",
+ "sharp": "0.35.3"
+ },
+ "devDependencies": {
+ "@types/node": "22.15.0",
+ "typescript": "5.8.3"
+ }
+}
diff --git a/visual-audit/scripts/prepare-live-secrets.sh b/visual-audit/scripts/prepare-live-secrets.sh
new file mode 100755
index 0000000..7975305
--- /dev/null
+++ b/visual-audit/scripts/prepare-live-secrets.sh
@@ -0,0 +1,99 @@
+#!/usr/bin/env bash
+set -Eeuo pipefail
+
+ROOT="/volume2/docker_ssd/woodsmith"
+cd "$ROOT"
+umask 077
+
+mkdir -p secrets
+chmod 700 secrets
+
+docker inspect woodsmith --format '{{json .Config.Env}}' \
+| OUTPUT_PATH='secrets/woodsmith_audit_admin_password' python3 -c '
+import json
+import os
+import tempfile
+import sys
+
+output = os.environ["OUTPUT_PATH"]
+environment = json.load(sys.stdin)
+matches = [entry.split("=", 1)[1] for entry in environment if entry.startswith("STUDIO_PASSWORD=")]
+if len(matches) != 1 or not matches[0]:
+ raise SystemExit(f"Expected one non-empty STUDIO_PASSWORD in the running woodsmith container; found {len(matches)}.")
+
+directory = os.path.dirname(output) or "."
+descriptor, temporary = tempfile.mkstemp(prefix=".woodsmith-audit-admin.", dir=directory, text=True)
+try:
+ os.fchmod(descriptor, 0o600)
+ with os.fdopen(descriptor, "w", encoding="utf-8", newline="") as handle:
+ handle.write(matches[0])
+ handle.flush()
+ os.fsync(handle.fileno())
+ os.replace(temporary, output)
+ os.chmod(output, 0o600)
+except BaseException:
+ try:
+ os.close(descriptor)
+ except OSError:
+ pass
+ try:
+ os.unlink(temporary)
+ except FileNotFoundError:
+ pass
+ raise
+'
+
+OUTPUT_PATH='secrets/woodsmith_visual_audit_token' python3 -c '
+from pathlib import Path
+import os
+import re
+import tempfile
+
+output = Path(os.environ["OUTPUT_PATH"])
+pattern = re.compile(r"^[ \t]*VISUAL_AUDIT_TOKEN[ \t]*=[ \t]*(?P[\x27\x22]?)(?P[0-9A-Fa-f]{64})(?P=quote)[ \t]*(?:\#[^\r\n]*)?$")
+matches = []
+for line_number, line in enumerate(Path(".env").read_text(encoding="utf-8").splitlines(), start=1):
+ if not re.match(r"^[ \t]*VISUAL_AUDIT_TOKEN[ \t]*=", line):
+ continue
+ match = pattern.fullmatch(line)
+ if not match:
+ raise SystemExit(f"VISUAL_AUDIT_TOKEN on line {line_number} is not one 64-character hexadecimal token.")
+ matches.append(match.group("token"))
+if len(matches) != 1:
+ raise SystemExit(f"Expected one VISUAL_AUDIT_TOKEN in .env; found {len(matches)}.")
+
+descriptor, temporary_name = tempfile.mkstemp(prefix=".woodsmith-audit-token.", dir=output.parent, text=True)
+temporary = Path(temporary_name)
+try:
+ os.fchmod(descriptor, 0o600)
+ with os.fdopen(descriptor, "w", encoding="utf-8", newline="") as handle:
+ handle.write(matches[0])
+ handle.flush()
+ os.fsync(handle.fileno())
+ os.replace(temporary, output)
+ os.chmod(output, 0o600)
+except BaseException:
+ try:
+ os.close(descriptor)
+ except OSError:
+ pass
+ temporary.unlink(missing_ok=True)
+ raise
+'
+
+python3 - <<'PY'
+from pathlib import Path
+import stat
+
+files = [
+ Path("secrets/woodsmith_audit_admin_password"),
+ Path("secrets/woodsmith_visual_audit_token"),
+]
+for path in files:
+ if not path.is_file() or path.stat().st_size <= 0:
+ raise SystemExit(f"Missing or empty private audit secret: {path}")
+ mode = stat.S_IMODE(path.stat().st_mode)
+ if mode != 0o600:
+ raise SystemExit(f"Audit secret has mode {mode:03o}, expected 600: {path}")
+print("Visual-audit secret files are non-empty and mode 600.")
+PY
diff --git a/visual-audit/scripts/prepare-snapshot-lab.sh b/visual-audit/scripts/prepare-snapshot-lab.sh
new file mode 100755
index 0000000..626c11c
--- /dev/null
+++ b/visual-audit/scripts/prepare-snapshot-lab.sh
@@ -0,0 +1,84 @@
+#!/usr/bin/env bash
+set -Eeuo pipefail
+
+ROOT="/volume2/docker_ssd/woodsmith"
+MEDIA_SOURCE="/volume1/homes/Cooper/Photos/Dad_Woodworking_09262025"
+LAB_RUN_ID="${LAB_RUN_ID:-$(date -u '+%Y%m%dT%H%M%SZ')}"
+LAB_ROOT="${ROOT}/visual-audit-lab/${LAB_RUN_ID}"
+LAB_DATA="${LAB_ROOT}/data"
+LAB_MEDIA_ROOT="/volume1/homes/Cooper/visual-audit-lab/${LAB_RUN_ID}"
+LAB_MEDIA="${LAB_MEDIA_ROOT}/pics"
+BACKUP_HOST_PATH="${ROOT}/site/data/backups/visual-audit-lab-${LAB_RUN_ID}.sqlite"
+BACKUP_CONTAINER_PATH="/app/site/data/backups/visual-audit-lab-${LAB_RUN_ID}.sqlite"
+
+cd "$ROOT"
+umask 077
+
+if [[ -e "$LAB_ROOT" || -e "$LAB_MEDIA_ROOT" ]]; then
+ printf 'Refusing to overwrite an existing snapshot lab: %s\n' "$LAB_RUN_ID" >&2
+ exit 1
+fi
+
+if [[ ! -d "$MEDIA_SOURCE" || ! -s secrets/woodsmith_visual_audit_token ]]; then
+ printf '%s\n' "Production media or audit-token secret is unavailable." >&2
+ exit 1
+fi
+
+mkdir -p "$LAB_DATA" "$LAB_MEDIA"
+chmod 700 "$LAB_ROOT" "$LAB_MEDIA_ROOT" "$LAB_DATA" "$LAB_MEDIA"
+
+docker exec -i -e BACKUP_PATH="$BACKUP_CONTAINER_PATH" woodsmith node --experimental-sqlite - <<'NODE'
+const fs = require("node:fs");
+const { DatabaseSync } = require("node:sqlite");
+const source = "/app/site/data/woodsmith.sqlite";
+const destination = process.env.BACKUP_PATH;
+if (!destination) throw new Error("BACKUP_PATH is missing.");
+if (fs.existsSync(destination)) throw new Error("Refusing to overwrite an existing lab backup.");
+const database = new DatabaseSync(source);
+database.exec(`VACUUM INTO '${destination.replaceAll("'", "''")}'`);
+database.close();
+const verification = new DatabaseSync(destination, { readOnly: true });
+const result = verification.prepare("PRAGMA quick_check").all();
+verification.close();
+if (!result.some((row) => row.quick_check === "ok")) throw new Error("Snapshot-lab database quick_check failed.");
+console.log("Snapshot-lab database quick_check: ok");
+NODE
+
+install -m 600 "$BACKUP_HOST_PATH" "${LAB_DATA}/woodsmith.sqlite"
+printf '%s\n' "$LAB_RUN_ID" > "${LAB_DATA}/.woodsmith-visual-audit-lab"
+chmod 600 "${LAB_DATA}/.woodsmith-visual-audit-lab"
+
+test_file="$(find "$MEDIA_SOURCE" -type f -print -quit)"
+if [[ -z "$test_file" ]]; then
+ printf '%s\n' "Production media source contains no files." >&2
+ exit 1
+fi
+
+if cp --reflink=always "$test_file" "${LAB_MEDIA}/.reflink-test" 2>/dev/null; then
+ rm -f -- "${LAB_MEDIA}/.reflink-test"
+ cp -a --reflink=always "${MEDIA_SOURCE}/." "${LAB_MEDIA}/"
+else
+ rm -f -- "${LAB_MEDIA}/.reflink-test"
+ rsync -a "${MEDIA_SOURCE}/" "${LAB_MEDIA}/"
+fi
+
+printf '%s\n' "$LAB_RUN_ID" > "${LAB_MEDIA}/.woodsmith-visual-audit-lab"
+chmod 600 "${LAB_MEDIA}/.woodsmith-visual-audit-lab"
+
+LAB_STUDIO_PASSWORD="$(openssl rand -hex 36)"
+LAB_SESSION_SECRET="$(openssl rand -hex 48)"
+
+printf '%s' "$LAB_STUDIO_PASSWORD" > secrets/woodsmith_audit_lab_password
+chmod 600 secrets/woodsmith_audit_lab_password
+
+cat > .visual-audit-lab.env <&2
+ exit 1
+fi
+
+mapfile -d '' candidates < <(find "$resolved" -mindepth 1 -maxdepth 1 -type d -mtime "+$DAYS" -print0)
+printf 'Archives older than %s days: %s\n' "$DAYS" "${#candidates[@]}"
+
+if [[ "$MODE" != "--apply" ]]; then
+ printf '%s\n' "Dry run only. Re-run with --apply after reviewing storage and retention requirements."
+ exit 0
+fi
+
+for candidate in "${candidates[@]}"; do
+ target="$(readlink -f "$candidate")"
+ if [[ "$target" != "$resolved"/* ]]; then
+ printf 'Refusing path outside archive root: %s\n' "$target" >&2
+ exit 1
+ fi
+ rm -rf --one-file-system -- "$target"
+done
diff --git a/visual-audit/scripts/run-live-audit.sh b/visual-audit/scripts/run-live-audit.sh
new file mode 100755
index 0000000..27f1b5c
--- /dev/null
+++ b/visual-audit/scripts/run-live-audit.sh
@@ -0,0 +1,50 @@
+#!/usr/bin/env bash
+set -Eeuo pipefail
+
+ROOT="/volume2/docker_ssd/woodsmith"
+OUTPUT="${ROOT}/visual-audits"
+LOCK="/tmp/woodsmith-visual-audit.lock"
+
+cd "$ROOT"
+umask 077
+
+if ! mkdir "$LOCK" 2>/dev/null; then
+ printf '%s\n' "Another Woodsmith visual audit is already running."
+ exit 0
+fi
+
+cleanup() { rmdir "$LOCK" 2>/dev/null || true; }
+trap cleanup EXIT
+
+for required_file in .env secrets/woodsmith_audit_admin_password secrets/woodsmith_visual_audit_token; do
+ if [[ ! -s "$required_file" ]]; then
+ printf 'Missing or empty required audit file: %s\n' "$required_file" >&2
+ exit 1
+ fi
+done
+
+TARGET_COMMIT_SHA="$(git rev-parse HEAD)"
+COMMIT_SHORT="$(printf '%s' "$TARGET_COMMIT_SHA" | cut -c1-8)"
+AUDIT_RUN_ID="${AUDIT_RUN_ID:-full-$(date -u '+%Y%m%dT%H%M%SZ')-${COMMIT_SHORT}}"
+
+export TARGET_COMMIT_SHA AUDIT_RUN_ID
+export AUDIT_SCOPE="${AUDIT_SCOPE:-full}"
+export AUDIT_RESUME="${AUDIT_RESUME:-true}"
+
+mkdir -p "$OUTPUT"
+chmod 700 "$OUTPUT"
+
+compose=(docker compose --env-file .env -f docker-compose.visual-audit-live.yml)
+"${compose[@]}" build visual-audit
+"${compose[@]}" run --rm visual-audit
+"${compose[@]}" run --rm --entrypoint node visual-audit dist/diff.js
+"${compose[@]}" run --rm --entrypoint node visual-audit dist/report.js
+"${compose[@]}" run --rm --entrypoint node visual-audit dist/validate.js
+
+chmod -R go-rwx "${OUTPUT}/${AUDIT_RUN_ID}"
+
+if [[ "${AUDIT_RETENTION_APPLY:-false}" == "true" ]]; then
+ AUDIT_RETENTION_DAYS="${AUDIT_RETENTION_DAYS:-90}" visual-audit/scripts/prune-audits.sh --apply
+fi
+
+printf 'Completed visual audit: %s/%s\n' "$OUTPUT" "$AUDIT_RUN_ID"
diff --git a/visual-audit/scripts/run-snapshot-lab.sh b/visual-audit/scripts/run-snapshot-lab.sh
new file mode 100755
index 0000000..ee42ef4
--- /dev/null
+++ b/visual-audit/scripts/run-snapshot-lab.sh
@@ -0,0 +1,63 @@
+#!/usr/bin/env bash
+set -Eeuo pipefail
+
+ROOT="/volume2/docker_ssd/woodsmith"
+cd "$ROOT"
+umask 077
+
+LOCK="/tmp/woodsmith-visual-audit-lab.lock"
+if ! mkdir "$LOCK" 2>/dev/null; then
+ printf '%s\n' "Another Woodsmith snapshot-lab audit is already running."
+ exit 0
+fi
+cleanup_lock() { rmdir "$LOCK" 2>/dev/null || true; }
+trap cleanup_lock EXIT
+
+for required_file in .env .visual-audit-lab.env secrets/woodsmith_audit_lab_password secrets/woodsmith_visual_audit_token; do
+ if [[ ! -s "$required_file" ]]; then
+ printf 'Missing or empty required snapshot-lab file: %s\n' "$required_file" >&2
+ exit 1
+ fi
+done
+
+set -a
+source ./.visual-audit-lab.env
+set +a
+
+for directory in "${AUDIT_LAB_DATA_DIR:?}" "${AUDIT_LAB_MEDIA_DIR:?}"; do
+ resolved="$(readlink -f "$directory")"
+ if [[ ! -d "$resolved" || "$resolved" == "/volume1/homes/Cooper/Photos/Dad_Woodworking_09262025" || "$resolved" == "/volume2/docker_ssd/woodsmith/site/data" ]]; then
+ printf 'Refusing unsafe snapshot-lab mount: %s\n' "$directory" >&2
+ exit 1
+ fi
+done
+
+for marker in "${AUDIT_LAB_DATA_DIR}/.woodsmith-visual-audit-lab" "${AUDIT_LAB_MEDIA_DIR}/.woodsmith-visual-audit-lab"; do
+ if [[ ! -s "$marker" || "$(<"$marker")" != "${LAB_RUN_ID:?}" ]]; then
+ printf 'Refusing unverified snapshot-lab mount; marker mismatch: %s\n' "$marker" >&2
+ exit 1
+ fi
+done
+
+TARGET_COMMIT_SHA="$(git rev-parse HEAD)"
+COMMIT_SHORT="$(printf '%s' "$TARGET_COMMIT_SHA" | cut -c1-8)"
+AUDIT_RUN_ID="${AUDIT_RUN_ID:-lab-$(date -u '+%Y%m%dT%H%M%SZ')-${COMMIT_SHORT}}"
+export TARGET_COMMIT_SHA AUDIT_RUN_ID
+export AUDIT_SCOPE="${AUDIT_SCOPE:-full}"
+export AUDIT_RESUME="${AUDIT_RESUME:-true}"
+
+compose=(docker compose --env-file .env --env-file .visual-audit-lab.env -f docker-compose.visual-audit-lab.yml)
+cleanup() {
+ "${compose[@]}" down >/dev/null 2>&1 || true
+ cleanup_lock
+}
+trap cleanup EXIT
+
+"${compose[@]}" up -d woodsmith-audit-lab
+"${compose[@]}" run --rm visual-audit
+"${compose[@]}" run --rm --entrypoint node visual-audit dist/diff.js
+"${compose[@]}" run --rm --entrypoint node visual-audit dist/report.js
+"${compose[@]}" run --rm --entrypoint node visual-audit dist/validate.js
+
+chmod -R go-rwx "/volume2/docker_ssd/woodsmith/visual-audits/${AUDIT_RUN_ID}"
+printf 'Completed snapshot-lab audit: %s\n' "$AUDIT_RUN_ID"
diff --git a/visual-audit/src/capture.ts b/visual-audit/src/capture.ts
new file mode 100644
index 0000000..099bac0
--- /dev/null
+++ b/visual-audit/src/capture.ts
@@ -0,0 +1,363 @@
+import fs from "node:fs/promises";
+import path from "node:path";
+
+import sharp from "sharp";
+import type { Locator, Page } from "playwright";
+
+import { config } from "./config.js";
+import { overlappingPositions, positionsIntersectingRange } from "./tiling.js";
+import type { SegmentRecord, TileManifest, TileRecord } from "./types.js";
+import { ensureDirectory, relativeTo, safeName, writeJsonAtomic } from "./util.js";
+
+type Dimensions = { width: number; height: number; deviceScaleFactor: number };
+
+async function pageDimensions(page: Page): Promise {
+ return page.evaluate(() => ({
+ width: Math.max(document.documentElement.scrollWidth, document.body.scrollWidth),
+ height: Math.max(document.documentElement.scrollHeight, document.body.scrollHeight),
+ deviceScaleFactor: window.devicePixelRatio || 1
+ }));
+}
+
+async function neutralizeFixedSurfaces(page: Page) {
+ await page.evaluate(() => {
+ const style = document.createElement("style");
+ style.id = "woodsmith-visual-audit-neutralize";
+ style.textContent = `
+ html { scroll-behavior: auto !important; }
+ *, *::before, *::after { animation: none !important; transition: none !important; caret-color: transparent !important; }
+ [data-audit-original-position="fixed"] { position: absolute !important; }
+ [data-audit-original-position="sticky"] { position: relative !important; inset: auto !important; }
+ `;
+ document.head.append(style);
+ document.querySelectorAll("body *").forEach((element) => {
+ const position = getComputedStyle(element).position;
+ if (position === "fixed" || position === "sticky") element.dataset.auditOriginalPosition = position;
+ });
+ });
+}
+
+async function restoreFixedSurfaces(page: Page) {
+ await page.evaluate(() => {
+ document.getElementById("woodsmith-visual-audit-neutralize")?.remove();
+ document.querySelectorAll("[data-audit-original-position]").forEach((element) => delete element.dataset.auditOriginalPosition);
+ window.scrollTo(0, 0);
+ });
+}
+
+async function cropForSegment(input: {
+ buffer: Buffer;
+ sourceX: number;
+ sourceY: number;
+ viewportWidth: number;
+ viewportHeight: number;
+ rangeStartY: number;
+ rangeEndY: number;
+ sourceWidth: number;
+ sourceHeight: number;
+}) {
+ const metadata = await sharp(input.buffer).metadata();
+ const pixelWidth = metadata.width ?? input.viewportWidth;
+ const pixelHeight = metadata.height ?? input.viewportHeight;
+ const scaleX = pixelWidth / Math.max(1, input.viewportWidth);
+ const scaleY = pixelHeight / Math.max(1, input.viewportHeight);
+ const intersectionLeft = Math.max(0, input.sourceX);
+ const intersectionRight = Math.min(input.sourceWidth, input.sourceX + input.viewportWidth);
+ const intersectionTop = Math.max(input.rangeStartY, input.sourceY);
+ const intersectionBottom = Math.min(input.rangeEndY, input.sourceY + input.viewportHeight, input.sourceHeight);
+
+ if (intersectionRight <= intersectionLeft || intersectionBottom <= intersectionTop) return null;
+
+ const left = Math.max(0, Math.round((intersectionLeft - input.sourceX) * scaleX));
+ const top = Math.max(0, Math.round((intersectionTop - input.sourceY) * scaleY));
+ const width = Math.min(pixelWidth - left, Math.max(1, Math.round((intersectionRight - intersectionLeft) * scaleX)));
+ const height = Math.min(pixelHeight - top, Math.max(1, Math.round((intersectionBottom - intersectionTop) * scaleY)));
+ const buffer = left === 0 && top === 0 && width === pixelWidth && height === pixelHeight
+ ? input.buffer
+ : await sharp(input.buffer).extract({ left, top, width, height }).png().toBuffer();
+
+ return {
+ buffer,
+ left: Math.round(intersectionLeft * scaleX),
+ top: Math.round((intersectionTop - input.rangeStartY) * scaleY),
+ width,
+ height,
+ scaleX,
+ scaleY
+ };
+}
+
+async function stitchVerticalPage(page: Page, outputDirectory: string, baseName: string) {
+ const dimensions = await pageDimensions(page);
+ const viewport = page.viewportSize();
+ if (!viewport) throw new Error("A fixed viewport is required for tiled capture.");
+
+ const rawRoot = path.join(outputDirectory, "raw", safeName(baseName));
+ await ensureDirectory(rawRoot);
+ const maxSegmentCssHeight = Math.max(viewport.height, Math.floor(config.maxStitchedSegmentHeight / dimensions.deviceScaleFactor));
+ const positions = overlappingPositions(dimensions.height, viewport.height);
+ const outputFiles: string[] = [];
+ const segments: SegmentRecord[] = [];
+
+ await neutralizeFixedSurfaces(page);
+ try {
+ for (let segmentStart = 0, segmentIndex = 0; segmentStart < dimensions.height; segmentStart += maxSegmentCssHeight, segmentIndex += 1) {
+ const segmentEnd = Math.min(dimensions.height, segmentStart + maxSegmentCssHeight);
+ const composites: Array<{ input: Buffer; left: number; top: number }> = [];
+ const tileRecords: TileRecord[] = [];
+ const seen = new Set();
+ let pixelWidth = 0;
+
+ for (const requestedY of positionsIntersectingRange(positions, viewport.height, segmentStart, segmentEnd)) {
+ const actualY = await page.evaluate(async (scrollY) => {
+ window.scrollTo(0, scrollY);
+ await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
+ return window.scrollY;
+ }, requestedY);
+ if (seen.has(actualY)) continue;
+ seen.add(actualY);
+
+ const buffer = await page.screenshot({ type: "png", scale: "device", animations: "disabled", caret: "hide" });
+ const metadata = await sharp(buffer).metadata();
+ const rawFile = path.join(rawRoot, `segment-${String(segmentIndex + 1).padStart(3, "0")}-tile-${String(tileRecords.length + 1).padStart(4, "0")}.png`);
+ await fs.writeFile(rawFile, buffer, { mode: 0o600 });
+ tileRecords.push({
+ file: relativeTo(config.runRoot, rawFile),
+ x: 0,
+ y: Math.round(actualY * dimensions.deviceScaleFactor),
+ width: metadata.width ?? Math.round(viewport.width * dimensions.deviceScaleFactor),
+ height: metadata.height ?? Math.round(viewport.height * dimensions.deviceScaleFactor)
+ });
+
+ const cropped = await cropForSegment({
+ buffer,
+ sourceX: 0,
+ sourceY: actualY,
+ viewportWidth: viewport.width,
+ viewportHeight: viewport.height,
+ rangeStartY: segmentStart,
+ rangeEndY: segmentEnd,
+ sourceWidth: viewport.width,
+ sourceHeight: dimensions.height
+ });
+ if (!cropped) continue;
+ composites.push({ input: cropped.buffer, left: 0, top: cropped.top });
+ pixelWidth = Math.max(pixelWidth, cropped.width);
+ }
+
+ const pixelHeight = Math.max(1, Math.round((segmentEnd - segmentStart) * dimensions.deviceScaleFactor));
+ const outputFile = path.join(outputDirectory, `${baseName}__stitched-${String(segmentIndex + 1).padStart(3, "0")}.png`);
+ await sharp({ create: { width: pixelWidth, height: pixelHeight, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 1 } } })
+ .composite(composites)
+ .png({ compressionLevel: 9 })
+ .toFile(outputFile);
+ await fs.chmod(outputFile, 0o600).catch(() => undefined);
+ outputFiles.push(outputFile);
+ segments.push({
+ file: relativeTo(config.runRoot, outputFile),
+ startY: Math.round(segmentStart * dimensions.deviceScaleFactor),
+ width: pixelWidth,
+ height: pixelHeight,
+ tiles: tileRecords
+ });
+ }
+ } finally {
+ await restoreFixedSurfaces(page);
+ }
+
+ const tileManifest: TileManifest = {
+ kind: "page",
+ createdAt: new Date().toISOString(),
+ sourceWidth: Math.round(viewport.width * dimensions.deviceScaleFactor),
+ sourceHeight: Math.round(dimensions.height * dimensions.deviceScaleFactor),
+ deviceScaleFactor: dimensions.deviceScaleFactor,
+ segments
+ };
+ await writeJsonAtomic(path.join(outputDirectory, `${baseName}__tiles.json`), tileManifest);
+ return outputFiles;
+}
+
+async function captureScrollableContainers(page: Page, outputDirectory: string, baseName: string) {
+ await neutralizeFixedSurfaces(page);
+ try {
+ await page.evaluate(() => {
+ document.querySelectorAll("body *").forEach((element) => {
+ delete element.dataset.auditScrollCandidate;
+ const style = getComputedStyle(element);
+ const rect = element.getBoundingClientRect();
+ const scrollableY = /(auto|scroll)/.test(style.overflowY) && element.scrollHeight > element.clientHeight + 4;
+ const scrollableX = /(auto|scroll)/.test(style.overflowX) && element.scrollWidth > element.clientWidth + 4;
+ if (
+ (scrollableX || scrollableY) &&
+ rect.width >= 120 &&
+ rect.height >= 80 &&
+ rect.width <= window.innerWidth * 1.2 &&
+ rect.height <= window.innerHeight * 1.2
+ ) {
+ element.dataset.auditScrollCandidate = "true";
+ }
+ });
+ });
+
+ const candidates = page.locator('[data-audit-scroll], [data-audit-scroll-candidate="true"]');
+ const count = await candidates.count();
+ const dimensions = await pageDimensions(page);
+ const files: string[] = [];
+ let captured = 0;
+
+ try {
+ for (let index = 0; index < count; index += 1) {
+ const locator = candidates.nth(index);
+ await locator.scrollIntoViewIfNeeded().catch(() => undefined);
+ const info = await locator.evaluate((element, candidateIndex) => {
+ if (!(element instanceof HTMLElement)) return null;
+ const style = getComputedStyle(element);
+ const scrollableY = /(auto|scroll)/.test(style.overflowY) && element.scrollHeight > element.clientHeight + 4;
+ const scrollableX = /(auto|scroll)/.test(style.overflowX) && element.scrollWidth > element.clientWidth + 4;
+ if (!scrollableX && !scrollableY) return null;
+ const rect = element.getBoundingClientRect();
+ if (rect.width < 120 || rect.height < 80 || rect.width > window.innerWidth * 1.2 || rect.height > window.innerHeight * 1.2) return null;
+ return {
+ id: element.dataset.auditId || `${element.tagName.toLowerCase()}-${candidateIndex + 1}`,
+ clientWidth: element.clientWidth,
+ clientHeight: element.clientHeight,
+ scrollWidth: element.scrollWidth,
+ scrollHeight: element.scrollHeight,
+ scrollLeft: element.scrollLeft,
+ scrollTop: element.scrollTop,
+ clipX: rect.left + window.scrollX + element.clientLeft,
+ clipY: rect.top + window.scrollY + element.clientTop
+ };
+ }, index).catch(() => null);
+ if (!info) continue;
+
+ const rawRoot = path.join(outputDirectory, "raw", `${safeName(baseName)}-scroll-${String(captured + 1).padStart(3, "0")}`);
+ await ensureDirectory(rawRoot);
+ const xPositions = overlappingPositions(info.scrollWidth, info.clientWidth);
+ const yPositions = overlappingPositions(info.scrollHeight, info.clientHeight);
+ const maxSegmentCssHeight = Math.max(info.clientHeight, Math.floor(config.maxStitchedSegmentHeight / dimensions.deviceScaleFactor));
+ const segments: SegmentRecord[] = [];
+
+ try {
+ for (let segmentStart = 0, segmentIndex = 0; segmentStart < info.scrollHeight; segmentStart += maxSegmentCssHeight, segmentIndex += 1) {
+ const segmentEnd = Math.min(info.scrollHeight, segmentStart + maxSegmentCssHeight);
+ const composites: Array<{ input: Buffer; left: number; top: number }> = [];
+ const tileRecords: TileRecord[] = [];
+ const seen = new Set();
+
+ for (const requestedY of positionsIntersectingRange(yPositions, info.clientHeight, segmentStart, segmentEnd)) {
+ for (const requestedX of xPositions) {
+ const actual = await locator.evaluate((element, position) => {
+ element.scrollTo(position.x, position.y);
+ return new Promise<{ x: number; y: number }>((resolve) => requestAnimationFrame(() => resolve({ x: element.scrollLeft, y: element.scrollTop })));
+ }, { x: requestedX, y: requestedY });
+ const seenKey = `${actual.x}:${actual.y}`;
+ if (seen.has(seenKey)) continue;
+ seen.add(seenKey);
+
+ const buffer = await page.screenshot({
+ type: "png",
+ scale: "device",
+ animations: "disabled",
+ caret: "hide",
+ clip: { x: info.clipX, y: info.clipY, width: info.clientWidth, height: info.clientHeight }
+ });
+ const metadata = await sharp(buffer).metadata();
+ const rawFile = path.join(rawRoot, `segment-${String(segmentIndex + 1).padStart(3, "0")}-tile-${String(tileRecords.length + 1).padStart(4, "0")}.png`);
+ await fs.writeFile(rawFile, buffer, { mode: 0o600 });
+ tileRecords.push({
+ file: relativeTo(config.runRoot, rawFile),
+ x: Math.round(actual.x * dimensions.deviceScaleFactor),
+ y: Math.round(actual.y * dimensions.deviceScaleFactor),
+ width: metadata.width ?? Math.round(info.clientWidth * dimensions.deviceScaleFactor),
+ height: metadata.height ?? Math.round(info.clientHeight * dimensions.deviceScaleFactor)
+ });
+
+ const cropped = await cropForSegment({
+ buffer,
+ sourceX: actual.x,
+ sourceY: actual.y,
+ viewportWidth: info.clientWidth,
+ viewportHeight: info.clientHeight,
+ rangeStartY: segmentStart,
+ rangeEndY: segmentEnd,
+ sourceWidth: info.scrollWidth,
+ sourceHeight: info.scrollHeight
+ });
+ if (!cropped) continue;
+ composites.push({ input: cropped.buffer, left: cropped.left, top: cropped.top });
+ }
+ }
+
+ const outputWidth = Math.max(1, Math.round(info.scrollWidth * dimensions.deviceScaleFactor));
+ const outputHeight = Math.max(1, Math.round((segmentEnd - segmentStart) * dimensions.deviceScaleFactor));
+ const outputFile = path.join(outputDirectory, `${baseName}__scroll-${String(captured + 1).padStart(3, "0")}-${safeName(info.id)}__stitched-${String(segmentIndex + 1).padStart(3, "0")}.png`);
+ await sharp({ create: { width: outputWidth, height: outputHeight, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 1 } } })
+ .composite(composites)
+ .png({ compressionLevel: 9 })
+ .toFile(outputFile);
+ await fs.chmod(outputFile, 0o600).catch(() => undefined);
+ files.push(outputFile);
+ segments.push({
+ file: relativeTo(config.runRoot, outputFile),
+ startY: Math.round(segmentStart * dimensions.deviceScaleFactor),
+ width: outputWidth,
+ height: outputHeight,
+ tiles: tileRecords
+ });
+ }
+ } finally {
+ await locator.evaluate((element, original) => element.scrollTo(original.left, original.top), { left: info.scrollLeft, top: info.scrollTop }).catch(() => undefined);
+ }
+
+ const tileManifest: TileManifest = {
+ kind: "scroll-container",
+ createdAt: new Date().toISOString(),
+ sourceWidth: Math.round(info.scrollWidth * dimensions.deviceScaleFactor),
+ sourceHeight: Math.round(info.scrollHeight * dimensions.deviceScaleFactor),
+ deviceScaleFactor: dimensions.deviceScaleFactor,
+ segments
+ };
+ await writeJsonAtomic(path.join(outputDirectory, `${baseName}__scroll-${String(captured + 1).padStart(3, "0")}-${safeName(info.id)}__tiles.json`), tileManifest);
+ captured += 1;
+ }
+ } finally {
+ await page.evaluate(() => {
+ document.querySelectorAll('[data-audit-scroll-candidate="true"]').forEach((element) => delete element.dataset.auditScrollCandidate);
+ }).catch(() => undefined);
+ }
+
+ return files;
+ } finally {
+ await restoreFixedSurfaces(page);
+ }
+}
+
+export async function capturePageSurface(page: Page, outputDirectory: string, baseName: string, fullPage: boolean) {
+ await ensureDirectory(outputDirectory);
+ const dimensions = await pageDimensions(page);
+ const outputFile = path.join(outputDirectory, `${baseName}__${fullPage ? "full" : "viewport"}.png`);
+
+ let files: string[];
+ if (!fullPage) {
+ await page.screenshot({ path: outputFile, type: "png", fullPage: false, scale: "device", animations: "disabled", caret: "hide" });
+ files = [outputFile];
+ } else if (dimensions.height * dimensions.deviceScaleFactor <= config.maxFullPageDeviceHeight) {
+ await page.screenshot({ path: outputFile, type: "png", fullPage: true, scale: "device", animations: "disabled", caret: "hide" });
+ files = [outputFile];
+ } else {
+ files = await stitchVerticalPage(page, outputDirectory, baseName);
+ }
+
+ if (fullPage) files.push(...await captureScrollableContainers(page, outputDirectory, baseName));
+ await Promise.all(files.map((file) => fs.chmod(file, 0o600).catch(() => undefined)));
+ return files;
+}
+
+export async function captureElement(locator: Locator, outputDirectory: string, baseName: string) {
+ await ensureDirectory(outputDirectory);
+ const outputFile = path.join(outputDirectory, `${baseName}__element.png`);
+ await locator.screenshot({ path: outputFile, type: "png", scale: "device", animations: "disabled", caret: "hide", timeout: 15_000 });
+ await fs.chmod(outputFile, 0o600).catch(() => undefined);
+ return [outputFile];
+}
diff --git a/visual-audit/src/config.ts b/visual-audit/src/config.ts
new file mode 100644
index 0000000..587ecd0
--- /dev/null
+++ b/visual-audit/src/config.ts
@@ -0,0 +1,91 @@
+import fs from "node:fs";
+import path from "node:path";
+
+import type { AuditScope, TargetMode, ViewportProfile } from "./types.js";
+
+function required(name: string) {
+ const value = process.env[name]?.trim();
+ if (!value) throw new Error(`${name} must be set.`);
+ return value;
+}
+
+function positiveInteger(name: string, fallback: number) {
+ const value = Number.parseInt(process.env[name] ?? "", 10);
+ return Number.isFinite(value) && value > 0 ? value : fallback;
+}
+
+function booleanValue(name: string, fallback: boolean) {
+ const value = process.env[name]?.trim().toLowerCase();
+ if (!value) return fallback;
+ return ["1", "true", "yes", "on"].includes(value);
+}
+
+function readSecret(name: string) {
+ const file = required(name);
+ const value = fs.readFileSync(file, "utf8").trim();
+ if (!value) throw new Error(`${name} points to an empty secret file.`);
+ return value;
+}
+
+function safeRunId(value: string) {
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{2,127}$/.test(value)) {
+ throw new Error("AUDIT_RUN_ID must contain only letters, numbers, dots, underscores, and hyphens.");
+ }
+ return value;
+}
+
+const targetMode = required("TARGET_MODE") as TargetMode;
+if (!["live-readonly", "snapshot-lab"].includes(targetMode)) {
+ throw new Error("TARGET_MODE must be live-readonly or snapshot-lab.");
+}
+
+const scope = (process.env.AUDIT_SCOPE?.trim() || "full") as AuditScope;
+if (!["smoke", "full"].includes(scope)) throw new Error("AUDIT_SCOPE must be smoke or full.");
+
+const baseUrl = new URL(required("BASE_URL"));
+const loopbackHost = ["127.0.0.1", "localhost", "::1"].includes(baseUrl.hostname);
+if (targetMode === "live-readonly" && baseUrl.protocol !== "https:" && !loopbackHost) {
+ throw new Error("live-readonly mode requires an HTTPS BASE_URL.");
+}
+
+const runId = safeRunId(required("AUDIT_RUN_ID"));
+const outputRoot = path.resolve(required("RUN_OUTPUT_ROOT"));
+const tmpRoot = path.resolve(process.env.AUDIT_TMP_ROOT?.trim() || path.join("/tmp", `woodsmith-audit-${runId}`));
+const browserChannel = process.env.AUDIT_BROWSER_CHANNEL?.trim();
+if (browserChannel && !["chrome", "msedge"].includes(browserChannel)) {
+ throw new Error("AUDIT_BROWSER_CHANNEL may be chrome or msedge when set.");
+}
+
+export const config = {
+ targetMode,
+ scope,
+ baseUrl: baseUrl.toString().replace(/\/$/, ""),
+ expectedCommit: required("TARGET_COMMIT_SHA"),
+ runId,
+ outputRoot,
+ runRoot: path.join(outputRoot, runId),
+ repoRoot: path.resolve(required("REPO_ROOT")),
+ adminEmail: required("WOODSMITH_ADMIN_EMAIL"),
+ adminPassword: readSecret("ADMIN_PASSWORD_FILE"),
+ auditToken: readSecret("AUDIT_TOKEN_FILE"),
+ authStatePath: path.join(tmpRoot, "auth", "state.json"),
+ tmpRoot,
+ resume: booleanValue("AUDIT_RESUME", true),
+ maxFullPageDeviceHeight: positiveInteger("MAX_FULL_PAGE_DEVICE_HEIGHT", 50_000),
+ maxStitchedSegmentHeight: positiveInteger("MAX_STITCHED_SEGMENT_HEIGHT", 60_000),
+ baselineRoot: process.env.APPROVED_BASELINE_ROOT?.trim() ? path.resolve(process.env.APPROVED_BASELINE_ROOT) : null,
+ strictDiagnostics: booleanValue("AUDIT_STRICT_DIAGNOSTICS", true),
+ browserChannel: browserChannel as "chrome" | "msedge" | undefined
+} as const;
+
+export const viewports: ViewportProfile[] = [
+ { name: "desktop-1440", width: 1440, height: 900, deviceScaleFactor: 1, isMobile: false, archival: false },
+ { name: "desktop-1280", width: 1280, height: 800, deviceScaleFactor: 1, isMobile: false, archival: false },
+ { name: "desktop-1024", width: 1024, height: 768, deviceScaleFactor: 1, isMobile: false, archival: false },
+ { name: "tablet-portrait", width: 1024, height: 1366, deviceScaleFactor: 2, isMobile: false, archival: false },
+ { name: "mobile-430", width: 430, height: 932, deviceScaleFactor: 3, isMobile: true, archival: false },
+ { name: "mobile-390", width: 390, height: 844, deviceScaleFactor: 3, isMobile: true, archival: false },
+ { name: "mobile-375", width: 375, height: 812, deviceScaleFactor: 3, isMobile: true, archival: false },
+ { name: "mobile-320", width: 320, height: 720, deviceScaleFactor: 3, isMobile: true, archival: false },
+ { name: "desktop-archival", width: 2560, height: 1440, deviceScaleFactor: 2, isMobile: false, archival: true }
+];
diff --git a/visual-audit/src/diff.ts b/visual-audit/src/diff.ts
new file mode 100644
index 0000000..c13a47d
--- /dev/null
+++ b/visual-audit/src/diff.ts
@@ -0,0 +1,119 @@
+import fs from "node:fs/promises";
+import path from "node:path";
+
+import sharp from "sharp";
+
+import { config } from "./config.js";
+import type { RunManifest } from "./types.js";
+import { ensureDirectory, exists, writeJsonAtomic } from "./util.js";
+
+async function main() {
+ const output = path.join(config.runRoot, "comparison.json");
+ if (!config.baselineRoot) {
+ await writeJsonAtomic(output, { comparedAt: new Date().toISOString(), status: "not-configured", differences: [] });
+ return;
+ }
+
+ const baselineManifestFile = path.join(config.baselineRoot, "manifest.json");
+ const currentManifestFile = path.join(config.runRoot, "manifest.json");
+ if (!await exists(baselineManifestFile)) throw new Error("APPROVED_BASELINE_ROOT does not contain manifest.json.");
+
+ const baseline = JSON.parse(await fs.readFile(baselineManifestFile, "utf8")) as RunManifest;
+ const current = JSON.parse(await fs.readFile(currentManifestFile, "utf8")) as RunManifest;
+ const baselineByKey = new Map(baseline.captures.map((capture) => [capture.key, capture]));
+ const currentByKey = new Map(current.captures.map((capture) => [capture.key, capture]));
+ const diffRoot = path.join(config.runRoot, "diff");
+ await ensureDirectory(diffRoot);
+ const differences: Array> = [];
+
+ for (const capture of current.captures) {
+ const previous = baselineByKey.get(capture.key);
+ if (!previous || capture.files.length === 0 || previous.files.length === 0) {
+ differences.push({ key: capture.key, status: previous ? "missing-current-file" : "new-capture" });
+ continue;
+ }
+
+ const currentFile = path.join(config.runRoot, capture.files[0]!);
+ const baselineFile = path.join(config.baselineRoot, previous.files[0]!);
+ if (!await exists(currentFile) || !await exists(baselineFile)) {
+ differences.push({ key: capture.key, status: "missing-file" });
+ continue;
+ }
+
+ const currentMetadata = await sharp(currentFile).metadata();
+ const baselineMetadata = await sharp(baselineFile).metadata();
+ if (currentMetadata.width !== baselineMetadata.width || currentMetadata.height !== baselineMetadata.height) {
+ differences.push({ key: capture.key, status: "dimension-change", current: [currentMetadata.width, currentMetadata.height], baseline: [baselineMetadata.width, baselineMetadata.height] });
+ continue;
+ }
+
+ const currentRaw = await sharp(currentFile).removeAlpha().raw().toBuffer();
+ const baselineRaw = await sharp(baselineFile).removeAlpha().raw().toBuffer();
+ let changed = 0;
+ let absoluteDifference = 0;
+ for (let index = 0; index < currentRaw.length; index += 3) {
+ const delta = Math.max(
+ Math.abs((currentRaw[index] ?? 0) - (baselineRaw[index] ?? 0)),
+ Math.abs((currentRaw[index + 1] ?? 0) - (baselineRaw[index + 1] ?? 0)),
+ Math.abs((currentRaw[index + 2] ?? 0) - (baselineRaw[index + 2] ?? 0))
+ );
+ absoluteDifference += delta;
+ if (delta > 18) changed += 1;
+ }
+ const pixels = Math.max(1, currentRaw.length / 3);
+ const changedRatio = changed / pixels;
+ const meanAbsoluteDifference = absoluteDifference / pixels / 255;
+ const status = changedRatio > 0.015 ? "changed" : "within-threshold";
+
+ if (status === "changed") {
+ const diffFile = path.join(diffRoot, `${String(differences.length + 1).padStart(5, "0")}.png`);
+ await sharp(currentFile).composite([{ input: baselineFile, blend: "difference" }]).png().toFile(diffFile);
+ await fs.chmod(diffFile, 0o600).catch(() => undefined);
+ differences.push({ key: capture.key, status, changedRatio, meanAbsoluteDifference, diffFile: path.relative(config.runRoot, diffFile).split(path.sep).join("/") });
+ } else {
+ differences.push({ key: capture.key, status, changedRatio, meanAbsoluteDifference });
+ }
+ }
+
+ for (const capture of baseline.captures) {
+ if (!currentByKey.has(capture.key)) differences.push({ key: capture.key, status: "removed-capture" });
+ }
+
+ const routeKey = (route: RunManifest["routes"][number]) => `${route.auth}::${route.route}::${route.theme}::${route.viewport}`;
+ const baselineRoutes = new Set(baseline.routes.map(routeKey));
+ const currentRoutes = new Set(current.routes.map(routeKey));
+ const addedRoutes = [...currentRoutes].filter((route) => !baselineRoutes.has(route)).sort();
+ const removedRoutes = [...baselineRoutes].filter((route) => !currentRoutes.has(route)).sort();
+ const diagnosticKey = (diagnostic: RunManifest["diagnostics"][number]) => `${diagnostic.type}::${diagnostic.route}::${diagnostic.message}`;
+ const baselineDiagnostics = new Set(baseline.diagnostics.filter((item) => !item.expected).map(diagnosticKey));
+ const newDiagnostics = current.diagnostics
+ .filter((item) => !item.expected && !baselineDiagnostics.has(diagnosticKey(item)))
+ .map((item) => ({ type: item.type, route: item.route, message: item.message }));
+
+ await writeJsonAtomic(output, {
+ comparedAt: new Date().toISOString(),
+ status: "compared",
+ baselineRunId: baseline.runId,
+ currentRunId: current.runId,
+ summary: {
+ compared: differences.length,
+ changed: differences.filter((item) => item.status === "changed").length,
+ newCaptures: differences.filter((item) => item.status === "new-capture").length,
+ removedCaptures: differences.filter((item) => item.status === "removed-capture").length,
+ dimensionChanges: differences.filter((item) => item.status === "dimension-change").length,
+ addedRoutes: addedRoutes.length,
+ removedRoutes: removedRoutes.length,
+ newDiagnostics: newDiagnostics.length
+ },
+ environment: {
+ browserChanged: baseline.browserVersion !== current.browserVersion,
+ modeChanged: baseline.mode !== current.mode,
+ scopeChanged: baseline.scope !== current.scope
+ },
+ routes: { added: addedRoutes, removed: removedRoutes },
+ newDiagnostics,
+ differences
+ });
+}
+
+await main();
diff --git a/visual-audit/src/inventory.ts b/visual-audit/src/inventory.ts
new file mode 100644
index 0000000..32bf32d
--- /dev/null
+++ b/visual-audit/src/inventory.ts
@@ -0,0 +1,262 @@
+import fs from "node:fs/promises";
+import path from "node:path";
+
+import {
+ request,
+ type Browser
+} from "playwright";
+
+import { config } from "./config.js";
+import type { Inventory } from "./types.js";
+import { unique } from "./util.js";
+
+async function walk(directory: string): Promise {
+ const entries = await fs.readdir(
+ directory,
+ { withFileTypes: true }
+ );
+
+ const files: string[] = [];
+
+ for (const entry of entries) {
+ const absolute = path.join(
+ directory,
+ entry.name
+ );
+
+ if (entry.isDirectory()) {
+ files.push(...await walk(absolute));
+ } else {
+ files.push(absolute);
+ }
+ }
+
+ return files;
+}
+
+function sourceRouteFromPage(
+ appRoot: string,
+ pageFile: string
+) {
+ const relative = path
+ .relative(appRoot, pageFile)
+ .split(path.sep)
+ .join("/");
+
+ const directory = relative.replace(
+ /\/?page\.(tsx|ts|jsx|js)$/,
+ ""
+ );
+
+ if (!directory) {
+ return "/";
+ }
+
+ const segments = directory
+ .split("/")
+ .filter(segment =>
+ !segment.startsWith("(") &&
+ !segment.startsWith("@")
+ );
+
+ return `/${segments.join("/")}`;
+}
+
+export async function discoverSourceRoutes() {
+ const appRoot = path.join(
+ config.repoRoot,
+ "site",
+ "app"
+ );
+
+ const files = await walk(appRoot);
+
+ const routes = files
+ .filter(file =>
+ /[\\/]page\.(tsx|ts|jsx|js)$/.test(file)
+ )
+ .map(file =>
+ sourceRouteFromPage(appRoot, file)
+ );
+
+ return {
+ staticRoutes: unique(
+ routes.filter(route =>
+ !route.includes("[")
+ )
+ ).sort(),
+
+ dynamicPatterns: unique(
+ routes.filter(route =>
+ route.includes("[")
+ )
+ ).sort()
+ };
+}
+
+export async function fetchInventory(
+ browser: Browser,
+ storageStatePath: string
+) {
+ void browser;
+
+ const context = await request.newContext({
+ baseURL: config.baseUrl,
+ storageState: storageStatePath,
+ extraHTTPHeaders: {
+ "x-woodsmith-audit-token":
+ config.auditToken
+ }
+ });
+
+ try {
+ const response = await context.get(
+ "/api/visual-audit/inventory"
+ );
+
+ if (!response.ok()) {
+ throw new Error(
+ `Inventory endpoint returned HTTP ${response.status()}: ${await response.text()}`
+ );
+ }
+
+ return await response.json() as Inventory;
+ } finally {
+ await context.dispose();
+ }
+}
+
+export function buildRoutes(
+ inventory: Inventory,
+ source: Awaited<
+ ReturnType
+ >
+) {
+ if (inventory.limits.truncatedCollections.length > 0) {
+ throw new Error(`Inventory is incomplete because bounded collections were truncated: ${inventory.limits.truncatedCollections.join(", ")}`);
+ }
+
+ const sourcePublicRoutes = source.staticRoutes.filter(route => {
+ return ![
+ "/studio",
+ "/account/profile",
+ "/account/projects"
+ ].some(prefix => route === prefix || route.startsWith(`${prefix}/`));
+ });
+
+ const emptyAndErrorRoutes = [
+ "/__visual-audit-route-not-found__",
+ "/search?q=__visual_audit_no_results__",
+ "/shop/cart",
+ "/commissions/status",
+ "/account/verify"
+ ];
+
+ const publicRoutes = unique([
+ ...sourcePublicRoutes,
+ ...inventory.staticRoutes,
+ ...inventory.legacyRoutes,
+ ...emptyAndErrorRoutes,
+
+ ...inventory.pages.filter(page => page.status === "published").map(page =>
+ `/${encodeURIComponent(page.slug)}`
+ ),
+
+ ...inventory.pieces.filter(piece => piece.publicationStatus === "published").map(piece =>
+ `/portfolio/${encodeURIComponent(
+ piece.slug
+ )}`
+ ),
+
+ ...inventory.posts.filter(post => post.publicationStatus === "published").map(post =>
+ `/process/${encodeURIComponent(
+ post.slug
+ )}`
+ )
+ ]).sort();
+
+ const studioRoutes = inventory.studioPanels.map(
+ panel => {
+ const allRecords = [
+ "projects",
+ "orders",
+ "reviews",
+ "notifications"
+ ].includes(panel);
+
+ return `/studio?panel=${encodeURIComponent(
+ panel
+ )}${allRecords ? "&audit=all" : ""}`;
+ }
+ );
+
+ const privateProjectRoutes =
+ inventory.projects.flatMap(project => [
+ `/requests/${encodeURIComponent(
+ project.reference
+ )}`,
+
+ `/studio/request/${encodeURIComponent(
+ project.reference
+ )}`
+ ]);
+
+ const snapshotLabRoutes =
+ config.targetMode === "snapshot-lab"
+ ? [
+ "/studio?panel=settings&saved=settings",
+
+ "/studio?panel=pages&saved=page",
+ "/studio?panel=pages&deleted=page",
+
+ "/studio?panel=pieces&saved=piece",
+ "/studio?panel=pieces&deleted=piece",
+
+ "/studio?panel=categories&saved=category",
+ "/studio?panel=categories&deleted=category",
+ "/studio?panel=categories&error=category-in-use",
+
+ "/studio?panel=custom&saved=commission-type",
+ "/studio?panel=custom&deleted=commission-type",
+
+ "/studio?panel=people&saved=user",
+ "/studio?panel=people&deleted=user",
+ "/studio?panel=people&error=cannot-delete-current-user",
+ "/studio?panel=people&error=cannot-delete-last-admin",
+
+ "/studio?panel=process&saved=post",
+ "/studio?panel=process&deleted=post",
+
+ "/studio?panel=media&saved=media",
+ "/studio?panel=media&deleted=media",
+ "/studio?panel=media&uploaded=audit-example.png",
+ "/studio?panel=media&renamed=audit-example.png",
+ "/studio?panel=media&cleaned=audit-example-cleaned.png",
+ "/studio?panel=media&assigned=audit-example.png",
+ "/studio?panel=media&refreshed=1",
+ "/studio?panel=media&error=media-audit-example",
+
+ "/studio?panel=reviews&saved=review",
+ "/studio?panel=reviews&deleted=review"
+ ]
+ : [];
+
+ return {
+ publicRoutes,
+ adminRoutes: unique([
+ ...publicRoutes,
+ ...source.staticRoutes,
+ ...studioRoutes,
+ ...privateProjectRoutes,
+ ...snapshotLabRoutes,
+ ...inventory.pages.map(page => `/${encodeURIComponent(page.slug)}`),
+ ...inventory.pieces.map(piece => `/portfolio/${encodeURIComponent(piece.slug)}`),
+ ...inventory.posts.map(post => `/process/${encodeURIComponent(post.slug)}`)
+ ]).sort(),
+
+ unresolvedPatterns: unique([
+ ...source.dynamicPatterns,
+ ...inventory.dynamicPatterns
+ ]).sort()
+ };
+}
diff --git a/visual-audit/src/policy.test.ts b/visual-audit/src/policy.test.ts
new file mode 100644
index 0000000..9120855
--- /dev/null
+++ b/visual-audit/src/policy.test.ts
@@ -0,0 +1,22 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { auditTokenEligible, isSameOrigin, isUnsafeMethod } from "./policy.js";
+
+test("audit token eligibility is limited to protected same-origin inventory surfaces", () => {
+ const base = "https://woodmat.ch";
+ assert.equal(auditTokenEligible("https://woodmat.ch/api/visual-audit/inventory", base), true);
+ assert.equal(auditTokenEligible("https://woodmat.ch/studio?panel=orders&audit=all", base), true);
+ assert.equal(auditTokenEligible("https://woodmat.ch/studio?panel=orders", base), false);
+ assert.equal(auditTokenEligible("https://example.com/api/visual-audit/inventory", base), false);
+ assert.equal(auditTokenEligible("https://woodmat.ch/", base), false);
+});
+
+test("read-only policy distinguishes safe methods and origins", () => {
+ assert.equal(isUnsafeMethod("GET"), false);
+ assert.equal(isUnsafeMethod("options"), false);
+ assert.equal(isUnsafeMethod("POST"), true);
+ assert.equal(isUnsafeMethod("DELETE"), true);
+ assert.equal(isSameOrigin("https://woodmat.ch/shop", "https://woodmat.ch"), true);
+ assert.equal(isSameOrigin("https://cdn.example.com/image.jpg", "https://woodmat.ch"), false);
+});
diff --git a/visual-audit/src/policy.ts b/visual-audit/src/policy.ts
new file mode 100644
index 0000000..8772cc3
--- /dev/null
+++ b/visual-audit/src/policy.ts
@@ -0,0 +1,16 @@
+const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
+
+export function isUnsafeMethod(method: string) {
+ return !SAFE_METHODS.has(method.toUpperCase());
+}
+
+export function isSameOrigin(requestUrl: string | URL, baseUrl: string | URL) {
+ return new URL(requestUrl).origin === new URL(baseUrl).origin;
+}
+
+export function auditTokenEligible(requestUrl: string | URL, baseUrl: string | URL) {
+ const request = new URL(requestUrl, baseUrl);
+ if (request.origin !== new URL(baseUrl).origin) return false;
+ return request.pathname === "/api/visual-audit/inventory" ||
+ (request.pathname === "/studio" && request.searchParams.get("audit") === "all");
+}
diff --git a/visual-audit/src/readiness.ts b/visual-audit/src/readiness.ts
new file mode 100644
index 0000000..2d4328f
--- /dev/null
+++ b/visual-audit/src/readiness.ts
@@ -0,0 +1,143 @@
+import type { Page } from "playwright";
+
+const READINESS_CSS = `
+ *, *::before, *::after {
+ animation-delay: 0s !important;
+ animation-duration: 0s !important;
+ animation-iteration-count: 1 !important;
+ transition-delay: 0s !important;
+ transition-duration: 0s !important;
+ scroll-behavior: auto !important;
+ caret-color: transparent !important;
+ }
+ html { scroll-behavior: auto !important; }
+`;
+
+async function triggerLazyContent(page: Page) {
+ await page.evaluate(async () => {
+ const pause = () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
+ const originalWindow = { x: window.scrollX, y: window.scrollY };
+ const documentHeight = Math.max(document.documentElement.scrollHeight, document.body.scrollHeight);
+ const step = Math.max(320, Math.floor(window.innerHeight * 0.78));
+
+ for (let y = 0; y < documentHeight; y += step) {
+ window.scrollTo(0, y);
+ await pause();
+ }
+ window.scrollTo(0, documentHeight);
+ await pause();
+
+ const scrollables = Array.from(document.querySelectorAll("body *")).filter((element) => {
+ const style = getComputedStyle(element);
+ return (
+ (/(auto|scroll)/.test(style.overflowY) && element.scrollHeight > element.clientHeight + 4) ||
+ (/(auto|scroll)/.test(style.overflowX) && element.scrollWidth > element.clientWidth + 4)
+ );
+ });
+
+ for (const element of scrollables) {
+ const original = { left: element.scrollLeft, top: element.scrollTop };
+ const verticalStep = Math.max(160, Math.floor(element.clientHeight * 0.78));
+ const horizontalStep = Math.max(160, Math.floor(element.clientWidth * 0.78));
+
+ for (let top = 0; top < element.scrollHeight; top += verticalStep) {
+ for (let left = 0; left < element.scrollWidth; left += horizontalStep) {
+ element.scrollTo(left, top);
+ await pause();
+ }
+ }
+
+ element.scrollTo(original.left, original.top);
+ }
+
+ window.scrollTo(originalWindow.x, originalWindow.y);
+ await pause();
+ });
+}
+
+async function settleMedia(page: Page) {
+ await page.evaluate(async () => {
+ await document.fonts.ready;
+
+ await Promise.all(Array.from(document.images).map(async (image) => {
+ if (!image.complete) {
+ await new Promise((resolve) => {
+ const done = () => resolve();
+ image.addEventListener("load", done, { once: true });
+ image.addEventListener("error", done, { once: true });
+ window.setTimeout(done, 10_000);
+ });
+ }
+ if (image.complete && image.naturalWidth > 0) await image.decode().catch(() => undefined);
+ }));
+
+ await Promise.all(Array.from(document.querySelectorAll("video")).map((video) => {
+ if (video.readyState >= 1 || video.error) return Promise.resolve();
+ return new Promise((resolve) => {
+ const done = () => resolve();
+ video.addEventListener("loadedmetadata", done, { once: true });
+ video.addEventListener("error", done, { once: true });
+ window.setTimeout(done, 5_000);
+ });
+ }));
+ });
+}
+
+async function waitForStableLayout(page: Page) {
+ let previous = "";
+ let stableSamples = 0;
+
+ for (let attempt = 0; attempt < 24; attempt += 1) {
+ const current = await page.evaluate(() => JSON.stringify({
+ width: Math.max(document.documentElement.scrollWidth, document.body.scrollWidth),
+ height: Math.max(document.documentElement.scrollHeight, document.body.scrollHeight),
+ images: Array.from(document.images).filter((image) => image.complete).length,
+ dialogs: document.querySelectorAll('[role="dialog"],dialog[open]').length
+ }));
+
+ if (current === previous) stableSamples += 1;
+ else {
+ previous = current;
+ stableSamples = 0;
+ }
+
+ if (stableSamples >= 2) return;
+ await page.waitForTimeout(125);
+ }
+
+ throw new Error("Visual readiness did not reach three consecutive stable layout samples.");
+}
+
+export async function waitForVisualReady(page: Page) {
+ await page.locator("body").waitFor({ state: "visible", timeout: 30_000 });
+ await page.locator("main").first().waitFor({ state: "attached", timeout: 30_000 }).catch(() => undefined);
+ await page.addStyleTag({ content: READINESS_CSS });
+
+ await triggerLazyContent(page);
+ await settleMedia(page);
+ await waitForStableLayout(page);
+
+ await page.waitForFunction(() => {
+ const visibleBusy = Array.from(document.querySelectorAll('[aria-busy="true"], .loading-pulse')).some((element) => {
+ const style = getComputedStyle(element);
+ const rect = element.getBoundingClientRect();
+ return style.display !== "none" && style.visibility !== "hidden" && rect.width > 1 && rect.height > 1;
+ });
+ return !visibleBusy;
+ }, { timeout: 15_000 });
+
+ await page.evaluate(() => {
+ const candidates = Array.from(document.querySelectorAll([
+ "a", "button", "input", "textarea", "select", "summary", "details", "dialog",
+ "[role]", "[data-media-path]", "[data-inline-edit-resource]"
+ ].join(",")));
+ candidates.forEach((element, index) => {
+ if (element.dataset.auditId) return;
+ const semantic = element.getAttribute("aria-label") || element.getAttribute("name") || element.id || element.tagName.toLowerCase();
+ element.dataset.auditId = `${semantic.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "element"}-${String(index + 1).padStart(5, "0")}`;
+ });
+ window.scrollTo(0, 0);
+ });
+
+ await page.waitForTimeout(100);
+}
diff --git a/visual-audit/src/report.ts b/visual-audit/src/report.ts
new file mode 100644
index 0000000..6039d02
--- /dev/null
+++ b/visual-audit/src/report.ts
@@ -0,0 +1,299 @@
+import { createHash } from "node:crypto";
+import fs from "node:fs/promises";
+import path from "node:path";
+import { pathToFileURL } from "node:url";
+
+import { chromium } from "playwright";
+import sharp from "sharp";
+
+import { config } from "./config.js";
+import type { CaptureRecord, RunManifest } from "./types.js";
+import { ensureDirectory, escapeHtml, exists, safeName, writeJsonAtomic } from "./util.js";
+
+const manifestFile = path.join(config.runRoot, "manifest.json");
+
+function redact(value: string) {
+ return value
+ .replace(/[A-Z]{2,8}-\d{4,}-[A-Z0-9-]+/gi, "[project-reference]")
+ .replace(/[A-Z]{2,8}-\d{5,}/gi, "[record-reference]")
+ .replace(/[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}/g, "[email]")
+ .replace(/\/requests\/[^/?#\s]+/g, "/requests/[private]")
+ .replace(/\/studio\/request\/[^/?#\s]+/g, "/studio/request/[private]");
+}
+
+function captureAnchor(capture: CaptureRecord) {
+ return `capture-${createHash("sha256").update(capture.key).digest("hex").slice(0, 16)}`;
+}
+
+function captureCard(capture: CaptureRecord, imageSources: string[], redacted: boolean, lazy: boolean) {
+ const route = redacted ? redact(capture.route) : capture.route;
+ const fileFigures = imageSources.map((source, index) => `
+
+
+ ${escapeHtml(capture.files[Math.min(index, capture.files.length - 1)] ?? source)}
+ `).join("");
+ return `
+ ${escapeHtml(route)}
+ ${escapeHtml(capture.state)}
+
+ Auth ${escapeHtml(capture.auth)}
+ Theme ${escapeHtml(capture.theme)}
+ Viewport ${escapeHtml(capture.viewport)}
+ Status ${escapeHtml(capture.status ?? "unknown")}
+
+ ${fileFigures}
+ `;
+}
+
+function tableOfContents(captures: CaptureRecord[], redacted: boolean) {
+ const seen = new Set();
+ const links: string[] = [];
+ for (const capture of captures) {
+ const route = redacted ? redact(capture.route) : capture.route;
+ const key = `${route}::${capture.auth}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+ links.push(`${escapeHtml(route)} ${escapeHtml(capture.auth)} `);
+ }
+ return `Contents ${links.join("")} `;
+}
+
+function htmlDocument(input: {
+ manifest: RunManifest;
+ captures: CaptureRecord[];
+ cards: string;
+ redacted: boolean;
+ comparison: unknown;
+ print: boolean;
+}) {
+ const diagnostics = input.manifest.diagnostics
+ .filter((item) => !item.expected)
+ .map((item) => input.redacted ? { ...item, route: redact(item.route), message: redact(item.message) } : item);
+ return `
+
+
+
+
+ Woodmat Visual Atlas ${escapeHtml(input.manifest.runId)}
+
+
+
+
+ Beaman Woodworks QA archive${input.redacted ? " · shareable redacted edition" : " · restricted edition"}
+ Woodmat Visual Atlas
+
+
Run ${escapeHtml(input.manifest.runId)}
+
Mode ${escapeHtml(input.manifest.mode)}
+
Commit ${escapeHtml(input.manifest.deployedCommit)}
+
Captures ${input.captures.length}
+
Routes ${new Set(input.captures.map((capture) => `${capture.auth}:${capture.route}`)).size}
+
Unexpected diagnostics ${diagnostics.length}
+
+ ${input.redacted ? 'Private routes, authenticated captures, account details, and customer references are excluded from this edition.
' : ""}
+ ${input.print ? "" : 'Search captures '}
+
+
+ ${tableOfContents(input.captures, input.redacted)}
+ ${input.cards || "No captures were eligible for this report.
"}
+ Diagnostics ${escapeHtml(JSON.stringify(diagnostics, null, 2))}
+ Baseline comparison ${escapeHtml(JSON.stringify(input.comparison, null, 2))}
+
+ ${input.print ? "" : ``}
+
+`;
+}
+
+async function createPrintSlices(source: string, outputRoot: string, sequence: number) {
+ const metadata = await sharp(source).metadata();
+ if (!metadata.width || !metadata.height) return [];
+ const targetWidth = Math.min(2400, metadata.width);
+ const scale = targetWidth / metadata.width;
+ const resizedHeight = Math.max(1, Math.round(metadata.height * scale));
+ const resized = await sharp(source).resize({ width: targetWidth, height: resizedHeight, fit: "fill" }).png().toBuffer();
+ const outputs: string[] = [];
+
+ for (let top = 0, part = 1; top < resizedHeight; top += 1550, part += 1) {
+ const height = Math.min(1550, resizedHeight - top);
+ const output = path.join(outputRoot, `${String(sequence).padStart(7, "0")}-${String(part).padStart(4, "0")}.jpg`);
+ await sharp(resized)
+ .extract({ left: 0, top, width: targetWidth, height })
+ .flatten({ background: "#ffffff" })
+ .jpeg({ quality: 92, chromaSubsampling: "4:4:4", mozjpeg: true })
+ .toFile(output);
+ await fs.chmod(output, 0o600).catch(() => undefined);
+ outputs.push(output);
+ }
+ return outputs;
+}
+
+async function createPdf(htmlFile: string, outputFile: string) {
+ const browser = await chromium.launch({
+ headless: true,
+ chromiumSandbox: false,
+ ...(config.browserChannel ? { channel: config.browserChannel } : {})
+ });
+ try {
+ const page = await browser.newPage({ viewport: { width: 1800, height: 1200 } });
+ await page.goto(pathToFileURL(htmlFile).href, { waitUntil: "load" });
+ await page.emulateMedia({ media: "screen" });
+ await page.evaluate(async () => {
+ await document.fonts.ready;
+ await Promise.all(Array.from(document.images).map((image) => image.decode().catch(() => undefined)));
+ });
+ await page.pdf({
+ path: outputFile,
+ format: "A3",
+ landscape: true,
+ printBackground: true,
+ tagged: true,
+ outline: true,
+ preferCSSPageSize: true,
+ margin: { top: "8mm", right: "8mm", bottom: "8mm", left: "8mm" }
+ });
+ await fs.chmod(outputFile, 0o600).catch(() => undefined);
+ } finally {
+ await browser.close();
+ }
+}
+
+async function main() {
+ const manifest = JSON.parse(await fs.readFile(manifestFile, "utf8")) as RunManifest;
+ const reportRoot = path.join(config.runRoot, "report");
+ const restrictedPrintAssets = path.join(reportRoot, "print-assets");
+ const shareableRoot = path.join(config.runRoot, "shareable");
+ const shareableAssets = path.join(shareableRoot, "assets");
+ const shareablePrintAssets = path.join(shareableRoot, "print-assets");
+ await Promise.all([reportRoot, restrictedPrintAssets, shareableRoot, shareableAssets, shareablePrintAssets].map((directory) => ensureDirectory(directory)));
+
+ const comparisonFile = path.join(config.runRoot, "comparison.json");
+ const comparison = await exists(comparisonFile) ? JSON.parse(await fs.readFile(comparisonFile, "utf8")) as unknown : { status: "No approved baseline configured." };
+ const restrictedCards = manifest.captures.map((capture) => captureCard(capture, capture.files.map((file) => `../${file}`), false, true)).join("\n");
+ const restrictedHtml = path.join(reportRoot, "index.html");
+ await fs.writeFile(restrictedHtml, htmlDocument({ manifest, captures: manifest.captures, cards: restrictedCards, redacted: false, comparison, print: false }), { encoding: "utf8", mode: 0o600 });
+
+ const shareableCaptures = manifest.captures.filter((capture) => !capture.sensitive && capture.auth === "anonymous");
+ const shareableCards: string[] = [];
+ let shareableAssetIndex = 0;
+ for (const capture of shareableCaptures) {
+ const sources: string[] = [];
+ for (const relativeFile of capture.files) {
+ const source = path.join(config.runRoot, relativeFile);
+ shareableAssetIndex += 1;
+ const destination = path.join(shareableAssets, `${String(shareableAssetIndex).padStart(7, "0")}-${path.extname(relativeFile) ? path.basename(relativeFile) : `${safeName(capture.state)}.png`}`);
+ await fs.copyFile(source, destination);
+ await fs.chmod(destination, 0o600).catch(() => undefined);
+ sources.push(`assets/${path.basename(destination)}`);
+ }
+ shareableCards.push(captureCard(capture, sources, true, true));
+ }
+
+ const shareableManifest = {
+ schemaVersion: manifest.schemaVersion,
+ runId: manifest.runId,
+ startedAt: manifest.startedAt,
+ completedAt: manifest.completedAt,
+ mode: manifest.mode,
+ scope: manifest.scope,
+ deployedCommit: manifest.deployedCommit,
+ captureCount: shareableCaptures.length,
+ routes: [...new Set(shareableCaptures.map((capture) => redact(capture.route)))].sort(),
+ exclusions: manifest.exclusions
+ };
+ await writeJsonAtomic(path.join(shareableRoot, "manifest.redacted.json"), shareableManifest);
+ const shareableHtml = path.join(shareableRoot, "index.html");
+ await fs.writeFile(shareableHtml, htmlDocument({ manifest, captures: shareableCaptures, cards: shareableCards.join("\n"), redacted: true, comparison: { status: "Comparison details are restricted." }, print: false }), { encoding: "utf8", mode: 0o600 });
+
+ const restrictedPrintCards: string[] = [];
+ const shareablePrintCards: string[] = [];
+ let restrictedPrintPages = 0;
+ let shareablePrintPages = 0;
+ let sequence = 0;
+ for (const capture of manifest.captures) {
+ const sources: string[] = [];
+ for (const relativeFile of capture.files) {
+ sequence += 1;
+ const slices = await createPrintSlices(path.join(config.runRoot, relativeFile), restrictedPrintAssets, sequence);
+ sources.push(...slices.map((file) => `print-assets/${path.basename(file)}`));
+ restrictedPrintPages += slices.length;
+ }
+ restrictedPrintCards.push(captureCard(capture, sources, false, false));
+ }
+
+ sequence = 0;
+ for (const capture of shareableCaptures) {
+ const sources: string[] = [];
+ for (const relativeFile of capture.files) {
+ sequence += 1;
+ const slices = await createPrintSlices(path.join(config.runRoot, relativeFile), shareablePrintAssets, sequence);
+ sources.push(...slices.map((file) => `print-assets/${path.basename(file)}`));
+ shareablePrintPages += slices.length;
+ }
+ shareablePrintCards.push(captureCard(capture, sources, true, false));
+ }
+
+ const restrictedPrintHtml = path.join(reportRoot, "print.html");
+ const shareablePrintHtml = path.join(shareableRoot, "print.html");
+ await fs.writeFile(restrictedPrintHtml, htmlDocument({ manifest, captures: manifest.captures, cards: restrictedPrintCards.join("\n"), redacted: false, comparison, print: true }), { encoding: "utf8", mode: 0o600 });
+ await fs.writeFile(shareablePrintHtml, htmlDocument({ manifest, captures: shareableCaptures, cards: shareablePrintCards.join("\n"), redacted: true, comparison: { status: "Comparison details are restricted." }, print: true }), { encoding: "utf8", mode: 0o600 });
+
+ await createPdf(restrictedPrintHtml, path.join(config.runRoot, "woodmat-visual-atlas.pdf"));
+ await createPdf(shareablePrintHtml, path.join(shareableRoot, "woodmat-visual-atlas-redacted.pdf"));
+ await writeJsonAtomic(path.join(reportRoot, "report-index.json"), {
+ runId: manifest.runId,
+ restrictedHtml: "report/index.html",
+ restrictedPrintHtml: "report/print.html",
+ restrictedPdf: "woodmat-visual-atlas.pdf",
+ shareableHtml: "shareable/index.html",
+ shareablePrintHtml: "shareable/print.html",
+ shareablePdf: "shareable/woodmat-visual-atlas-redacted.pdf",
+ captureCount: manifest.captures.length,
+ shareableCaptureCount: shareableCaptures.length,
+ restrictedPrintPages,
+ shareablePrintPages
+ });
+}
+
+await main();
diff --git a/visual-audit/src/run.ts b/visual-audit/src/run.ts
new file mode 100644
index 0000000..a437f24
--- /dev/null
+++ b/visual-audit/src/run.ts
@@ -0,0 +1,2002 @@
+import { createHash } from "node:crypto";
+import fs from "node:fs/promises";
+import path from "node:path";
+
+import {
+ chromium,
+ type Browser,
+ type BrowserContext,
+ type Locator,
+ type Page
+} from "playwright";
+
+import {
+ captureElement,
+ capturePageSurface
+} from "./capture.js";
+import {
+ config,
+ viewports
+} from "./config.js";
+import {
+ buildRoutes,
+ discoverSourceRoutes,
+ fetchInventory
+} from "./inventory.js";
+import { waitForVisualReady } from "./readiness.js";
+import { auditTokenEligible, isUnsafeMethod } from "./policy.js";
+import type {
+ AuthState,
+ CaptureRecord,
+ DiagnosticRecord,
+ Inventory,
+ RouteResult,
+ RunManifest,
+ ThemeMode,
+ ViewportProfile
+} from "./types.js";
+import {
+ ensureDirectory,
+ exists,
+ relativeTo,
+ safeName,
+ unique,
+ writeJsonAtomic
+} from "./util.js";
+
+const manifestFile = path.join(
+ config.runRoot,
+ "manifest.json"
+);
+
+const captureRoot = path.join(
+ config.runRoot,
+ "png"
+);
+
+let manifest: RunManifest;
+const preAuthenticationDiagnostics: DiagnosticRecord[] = [];
+let preAuthenticationUnsafeBlocks = 0;
+
+const coverageExclusions = [
+ { surface: "Third-party origins", reason: "Recorded as network diagnostics only; the archive never sends credentials or audit tokens cross-origin." },
+ { surface: "Admin authentication POST", reason: "The single Studio login submission is the only live unsafe request allowed before the read-only capture context exists." },
+ { surface: "Successful production mutations", reason: "Forbidden in live-readonly mode and captured only against the isolated snapshot lab." },
+ { surface: "Fabrication-ready 3D output", reason: "The public renderer is explicitly a conceptual proportional planning preview." },
+ { surface: "Unconfigured provider success states", reason: "Payment, shipping, email, and model-provider success states require provider fixtures and remain disabled in snapshot-lab mode." }
+] as const;
+
+function now() {
+ return new Date().toISOString();
+}
+
+function deepCount(total: number, smokeLimit: number) {
+ return config.scope === "smoke" ? Math.min(total, smokeLimit) : total;
+}
+
+function captureKey(input: {
+ auth: AuthState;
+ route: string;
+ theme: ThemeMode;
+ viewport: string;
+ state: string;
+}) {
+ return [
+ input.auth,
+ input.route,
+ input.theme,
+ input.viewport,
+ input.state
+ ].join("::");
+}
+
+async function persistManifest() {
+ manifest.completedKeys = unique(
+ manifest.captures.map(
+ capture => capture.key
+ )
+ );
+
+ const routes = new Map();
+ for (const route of manifest.routes) {
+ routes.set(`${route.auth}::${route.route}::${route.theme}::${route.viewport}`, route);
+ }
+ manifest.routes = [...routes.values()];
+
+ await writeJsonAtomic(
+ manifestFile,
+ manifest
+ );
+}
+
+async function loadOrCreateManifest(
+ browserVersion: string,
+ inventory: Inventory
+) {
+ if (
+ config.resume &&
+ await exists(manifestFile)
+ ) {
+ const existing = JSON.parse(
+ await fs.readFile(manifestFile, "utf8")
+ ) as RunManifest;
+
+ if (
+ existing.runId !== config.runId ||
+ existing.mode !== config.targetMode ||
+ existing.baseUrl !== config.baseUrl ||
+ existing.expectedCommit !== config.expectedCommit
+ ) {
+ throw new Error("AUDIT_RESUME refused to combine output from a different run, mode, origin, or commit.");
+ }
+
+ return {
+ ...existing,
+ completedAt: null,
+ scope: existing.scope ?? config.scope,
+ discoveredLinks: existing.discoveredLinks ?? [],
+ exclusions: existing.exclusions ?? [...coverageExclusions],
+ security: existing.security ?? {
+ sameOriginUnsafeRequestsBlocked: 0,
+ successfulUnsafeRequests: 0,
+ tokenEligibleRequests: 0,
+ crossOriginRequests: 0
+ }
+ };
+ }
+
+ return {
+ schemaVersion: 2,
+ runId: config.runId,
+ startedAt: now(),
+ completedAt: null,
+ mode: config.targetMode,
+ scope: config.scope,
+ baseUrl: config.baseUrl,
+ expectedCommit: config.expectedCommit,
+ deployedCommit: inventory.buildSha,
+ browserVersion,
+ inventory,
+ captures: [],
+ routes: [],
+ diagnostics: [],
+ completedKeys: [],
+ discoveredLinks: [],
+ exclusions: [...coverageExclusions],
+ security: {
+ sameOriginUnsafeRequestsBlocked: 0,
+ successfulUnsafeRequests: 0,
+ tokenEligibleRequests: 0,
+ crossOriginRequests: 0
+ }
+ } satisfies RunManifest;
+}
+
+function attachDiagnostics(
+ page: Page,
+ route: string
+) {
+ page.on("console", message => {
+ if (
+ ["error", "warning"].includes(
+ message.type()
+ )
+ ) {
+ const text = `${message.type()}: ${message.text()}`;
+ manifest.diagnostics.push({
+ timestamp: now(),
+ type: "console",
+ route,
+ message: text,
+ expected: /THREE\.THREE\.Clock: This module has been deprecated/.test(text)
+ });
+ }
+ });
+
+ page.on("pageerror", error => {
+ manifest.diagnostics.push({
+ timestamp: now(),
+ type: "pageerror",
+ route,
+ message: error.stack || error.message
+ });
+ });
+
+ page.on("requestfailed", request => {
+ const failure =
+ request.failure()?.errorText ||
+ "unknown request failure";
+
+ const method =
+ request.method().toUpperCase();
+
+ if (
+ failure.includes("ERR_BLOCKED_BY_CLIENT") &&
+ !["GET", "HEAD", "OPTIONS"].includes(method)
+ ) {
+ manifest.diagnostics.push({
+ timestamp: now(),
+ type: "mutation-blocked",
+ route,
+ message: `${method} ${request.url()}`
+ });
+
+ return;
+ }
+
+ manifest.diagnostics.push({
+ timestamp: now(),
+ type: "requestfailed",
+ route,
+ message: `${method} ${request.url()} — ${failure}`
+ });
+ });
+
+ page.on("response", response => {
+ const request = response.request();
+ const method = request.method().toUpperCase();
+
+ if (
+ isUnsafeMethod(method) &&
+ response.status() < 400
+ ) {
+ manifest.security.successfulUnsafeRequests += 1;
+ manifest.diagnostics.push({
+ timestamp: now(),
+ type: "security",
+ route,
+ message: `Successful ${method} request returned HTTP ${response.status()}: ${response.url()}`,
+ expected: config.targetMode === "snapshot-lab"
+ });
+ }
+
+ if (response.status() >= 400) {
+ manifest.diagnostics.push({
+ timestamp: now(),
+ type:
+ response.headers()[
+ "x-woodsmith-audit-blocked"
+ ] === "1"
+ ? "mutation-blocked"
+ : "http-error",
+ route,
+ message:
+ `${response.status()} ` +
+ `${request.method()} ${response.url()}`
+ });
+ }
+ });
+}
+
+async function authenticateAdmin(
+ browser: Browser
+) {
+ await ensureDirectory(
+ path.dirname(config.authStatePath)
+ );
+
+ const context = await browser.newContext({
+ baseURL: config.baseUrl,
+ viewport: {
+ width: 1440,
+ height: 1000
+ },
+ locale: "en-US",
+ timezoneId: "America/Los_Angeles",
+ serviceWorkers: "block"
+ });
+
+ const targetOrigin = new URL(config.baseUrl).origin;
+ let allowLoginSubmission = false;
+
+ await context.route("**/*", async (route) => {
+ const request = route.request();
+ const method = request.method().toUpperCase();
+ let requestUrl: URL;
+
+ try {
+ requestUrl = new URL(request.url());
+ } catch {
+ await route.continue();
+ return;
+ }
+
+ const sameOrigin = requestUrl.origin === targetOrigin;
+ const isLoginSubmission =
+ sameOrigin &&
+ requestUrl.pathname === "/studio/login" &&
+ method === "POST" &&
+ allowLoginSubmission;
+
+ if (isUnsafeMethod(method) && !isLoginSubmission) {
+ preAuthenticationUnsafeBlocks += 1;
+ preAuthenticationDiagnostics.push({
+ timestamp: now(),
+ type: "mutation-blocked",
+ route: requestUrl.pathname,
+ message: `Authentication guard blocked ${sameOrigin ? "same-origin" : "cross-origin"} ${method} ${requestUrl.origin}${requestUrl.pathname}`,
+ expected: true
+ });
+ await route.abort("blockedbyclient");
+ return;
+ }
+
+ if (isLoginSubmission) {
+ allowLoginSubmission = false;
+ await route.continue();
+ return;
+ }
+
+ if (sameOrigin) {
+ await route.continue({
+ headers: {
+ ...request.headers(),
+ "x-woodsmith-audit-readonly": "1"
+ }
+ });
+ return;
+ }
+
+ await route.continue();
+ });
+
+ const page = await context.newPage();
+
+ try {
+ await page.goto(
+ "/studio/login",
+ {
+ waitUntil: "domcontentloaded",
+ timeout: 45_000
+ }
+ );
+
+ await page
+ .getByLabel("Email")
+ .fill(config.adminEmail);
+
+ await page
+ .getByLabel("Password")
+ .fill(config.adminPassword);
+
+ allowLoginSubmission = true;
+
+ await Promise.all([
+ page.waitForURL(
+ /\/studio(?:\?|$)/,
+ { timeout: 45_000 }
+ ),
+
+ page
+ .getByRole(
+ "button",
+ { name: "Enter dashboard" }
+ )
+ .click()
+ ]);
+
+ await page
+ .locator('[data-studio-root="true"]')
+ .waitFor({
+ state: "visible",
+ timeout: 30_000
+ });
+
+ await context.storageState({
+ path: config.authStatePath
+ });
+ } finally {
+ await context.close();
+ }
+}
+
+async function createCaptureContext(
+ browser: Browser,
+ auth: AuthState,
+ profile: ViewportProfile,
+ theme: ThemeMode,
+ options: { reducedMotion?: "reduce" | "no-preference"; disableWebGl?: boolean } = {}
+) {
+ const context = await browser.newContext({
+ baseURL: config.baseUrl,
+ ...(auth === "admin" ? { storageState: config.authStatePath } : {}),
+
+ viewport: {
+ width: profile.width,
+ height: profile.height
+ },
+
+ deviceScaleFactor:
+ profile.deviceScaleFactor,
+
+ isMobile: profile.isMobile,
+ hasTouch: profile.isMobile,
+
+ locale: "en-US",
+ timezoneId: "America/Los_Angeles",
+ reducedMotion: options.reducedMotion ?? "no-preference",
+ colorScheme: theme,
+ serviceWorkers: "block"
+ });
+
+ await context.addCookies([
+ {
+ name: "beaman-theme",
+ value: theme,
+ url: config.baseUrl,
+ sameSite: "Lax",
+ secure:
+ config.baseUrl.startsWith("https://")
+ }
+ ]);
+
+ await context.addInitScript(
+ selectedTheme => {
+ window.localStorage.setItem(
+ "beaman-theme",
+ selectedTheme
+ );
+ },
+ theme
+ );
+
+ if (options.disableWebGl) {
+ await context.addInitScript(() => {
+ const original = HTMLCanvasElement.prototype.getContext;
+ HTMLCanvasElement.prototype.getContext = function getContext(this: HTMLCanvasElement, contextId: string, ...args: unknown[]) {
+ if (contextId === "webgl" || contextId === "webgl2" || contextId === "experimental-webgl") return null;
+ return original.call(this, contextId, ...args as []) as RenderingContext | null;
+ } as typeof HTMLCanvasElement.prototype.getContext;
+ });
+ }
+
+ const targetOrigin =
+ new URL(config.baseUrl).origin;
+
+ await context.route(
+ "**/*",
+ async route => {
+ const request = route.request();
+ const method =
+ request.method().toUpperCase();
+
+ let requestUrl: URL;
+
+ try {
+ requestUrl = new URL(request.url());
+ } catch {
+ await route.continue();
+ return;
+ }
+
+ if (requestUrl.origin !== targetOrigin) {
+ manifest.security.crossOriginRequests += 1;
+
+ if (
+ config.targetMode === "live-readonly" &&
+ isUnsafeMethod(method)
+ ) {
+ manifest.diagnostics.push({
+ timestamp: now(),
+ type: "mutation-blocked",
+ route: request.url(),
+ message: `Client route guard blocked cross-origin ${method} ${request.url()}`,
+ expected: true
+ });
+ await route.abort("blockedbyclient");
+ return;
+ }
+
+ await route.continue();
+ return;
+ }
+
+ const headers: Record = {
+ ...request.headers()
+ };
+
+ const tokenEligible = auditTokenEligible(requestUrl, config.baseUrl);
+
+ if (tokenEligible) {
+ headers["x-woodsmith-audit-token"] = config.auditToken;
+ manifest.security.tokenEligibleRequests += 1;
+ }
+
+ if (
+ config.targetMode ===
+ "live-readonly"
+ ) {
+ headers[
+ "x-woodsmith-audit-readonly"
+ ] = "1";
+
+ if (
+ isUnsafeMethod(method)
+ ) {
+ manifest.diagnostics.push({
+ timestamp: now(),
+ type: "mutation-blocked",
+ route: request.url(),
+ message:
+ `Client route guard blocked ` +
+ `${method} ${request.url()}`,
+ expected: true
+ });
+
+ manifest.security.sameOriginUnsafeRequestsBlocked += 1;
+
+ await route.abort(
+ "blockedbyclient"
+ );
+
+ return;
+ }
+ }
+
+ await route.continue({
+ headers
+ });
+ }
+ );
+
+ return context;
+}
+
+async function captureFormValidationStates(input: {
+ page: Page;
+ auth: AuthState;
+ route: string;
+ theme: ThemeMode;
+ profile: ViewportProfile;
+ status: number | null;
+}) {
+ if (
+ config.targetMode !== "snapshot-lab"
+ ) {
+ return;
+ }
+
+ const forms =
+ input.page.locator("form");
+
+ const count = deepCount(await forms.count(), 4);
+
+ for (
+ let index = 0;
+ index < count;
+ index += 1
+ ) {
+ const form = forms.nth(index);
+
+ if (
+ !await form
+ .isVisible()
+ .catch(() => false)
+ ) {
+ continue;
+ }
+
+ const requiredField =
+ form.locator(
+ [
+ "input[required]",
+ "textarea[required]",
+ "select[required]"
+ ].join(",")
+ ).first();
+
+ const submit =
+ form.locator(
+ 'button[type="submit"],input[type="submit"]'
+ ).first();
+
+ if (
+ !await requiredField
+ .isVisible()
+ .catch(() => false) ||
+ !await submit
+ .isVisible()
+ .catch(() => false)
+ ) {
+ continue;
+ }
+
+ await requiredField.evaluate(
+ element => {
+ (
+ element as HTMLInputElement
+ ).setCustomValidity(
+ "Required visual-audit validation state."
+ );
+ }
+ );
+
+ await submit.click();
+
+ await saveCapture({
+ ...input,
+ state:
+ `form-${String(index + 1)
+ .padStart(4, "0")}-validation`,
+ locator: form
+ });
+
+ await requiredField.evaluate(
+ element => {
+ (
+ element as HTMLInputElement
+ ).setCustomValidity("");
+ }
+ );
+ }
+}
+
+function routeLabel(route: string) {
+ const url = new URL(
+ route,
+ config.baseUrl
+ );
+
+ const query = [...url.searchParams.keys()].sort().join("-");
+ const privatePath = url.pathname.replace(
+ /^\/(requests|studio\/request|account\/(?:reset|verify))\/[^/]+/i,
+ "/$1/[private]"
+ );
+ const digest = createHash("sha256")
+ .update(`${url.pathname}${url.search}`)
+ .digest("hex")
+ .slice(0, 10);
+
+ return safeName(
+ `${safeName(privatePath).slice(0, 28)}-${query ? safeName(query).slice(0, 20) : "default"}-${digest}`
+ );
+}
+
+async function collectPageEvidence(page: Page, route: string) {
+ const evidence = await page.evaluate((targetOrigin) => {
+ const sensitiveParameters = new Set(["token", "code", "secret", "key", "password", "email"]);
+ const links = Array.from(document.querySelectorAll("a[href]")).flatMap((anchor) => {
+ try {
+ const url = new URL(anchor.href, window.location.href);
+ if (url.origin !== targetOrigin) return [];
+ if ([...url.searchParams.keys()].some((key) => sensitiveParameters.has(key.toLowerCase()))) return [];
+ if (["mailto:", "tel:", "javascript:", "data:"].includes(url.protocol)) return [];
+ if (url.pathname.startsWith("/_next/") || url.pathname.startsWith("/api/") || url.pathname.startsWith("/media/")) return [];
+ if (/\.(?:png|jpe?g|webp|gif|svg|pdf|zip|xml|json)$/i.test(url.pathname)) return [];
+ return [`${url.pathname}${url.search}`];
+ } catch {
+ return [];
+ }
+ });
+
+ const visible = (element: Element) => {
+ if (!(element instanceof HTMLElement)) return false;
+ const rect = element.getBoundingClientRect();
+ const style = getComputedStyle(element);
+ return rect.width > 1 && rect.height > 1 && style.display !== "none" && style.visibility !== "hidden";
+ };
+
+ const scrollContainers = Array.from(document.querySelectorAll("body *")).filter((element) => {
+ const style = getComputedStyle(element);
+ return visible(element) && (
+ (/(auto|scroll)/.test(style.overflowY) && element.scrollHeight > element.clientHeight + 4) ||
+ (/(auto|scroll)/.test(style.overflowX) && element.scrollWidth > element.clientWidth + 4)
+ );
+ }).length;
+
+ const surfaces = {
+ details: Array.from(document.querySelectorAll("details")).filter(visible).length,
+ lightboxOpeners: Array.from(document.querySelectorAll("button.media-card")).filter(visible).length,
+ mediaPickerOpeners: Array.from(document.querySelectorAll("button")).filter((element) => visible(element) && element.textContent?.trim() === "Browse library").length,
+ inlineEditLinks: Array.from(document.querySelectorAll("a.section-edit-link")).filter(visible).length,
+ studioCards: Array.from(document.querySelectorAll(".studio-editor-card")).filter(visible).length,
+ mediaCards: Array.from(document.querySelectorAll("[data-media-path]")).filter(visible).length,
+ validationForms: Array.from(document.querySelectorAll("form")).filter((form) => visible(form) && Boolean(form.querySelector("input[required],textarea[required],select[required]"))).length,
+ interactiveElements: Array.from(document.querySelectorAll("a,button,input,textarea,select,summary,[role=button],[role=tab],[aria-pressed]")).filter(visible).length,
+ scrollContainers,
+ visualizer: Array.from(document.querySelectorAll("[role=region]")).some((element) => visible(element) && element.getAttribute("aria-label") === "Interactive conceptual furniture preview")
+ };
+
+ const brokenMedia = [
+ ...Array.from(document.images).filter((image) => image.complete && image.naturalWidth === 0).map((image) => image.currentSrc || image.src || "image-without-source"),
+ ...Array.from(document.querySelectorAll("video")).filter((video) => Boolean(video.error)).map((video) => video.currentSrc || video.src || "video-without-source")
+ ];
+
+ const documentWidth = Math.max(document.documentElement.scrollWidth, document.body.scrollWidth);
+ const overflow = documentWidth > window.innerWidth + 2
+ ? Array.from(document.querySelectorAll("body *"))
+ .filter((element) => {
+ const rect = element.getBoundingClientRect();
+ return rect.right > window.innerWidth + 2 || rect.left < -2;
+ })
+ .slice(0, 12)
+ .map((element) => element.dataset.auditId || element.id || element.tagName.toLowerCase())
+ : [];
+
+ return { links: [...new Set(links)], brokenMedia, overflow, documentWidth, viewportWidth: window.innerWidth, surfaces };
+ }, new URL(config.baseUrl).origin);
+
+ manifest.discoveredLinks = unique([...manifest.discoveredLinks, ...evidence.links]).sort();
+
+ for (const source of evidence.brokenMedia) {
+ manifest.diagnostics.push({ timestamp: now(), type: "broken-media", route, message: source });
+ }
+
+ if (evidence.overflow.length > 0) {
+ manifest.diagnostics.push({
+ timestamp: now(),
+ type: "horizontal-overflow",
+ route,
+ message: `Document width ${evidence.documentWidth}px exceeds viewport ${evidence.viewportWidth}px; candidates: ${evidence.overflow.join(", ")}`
+ });
+ }
+
+ return evidence;
+}
+
+async function saveCapture(input: {
+ page: Page;
+ auth: AuthState;
+ route: string;
+ theme: ThemeMode;
+ profile: ViewportProfile;
+ state: string;
+ status: number | null;
+ fullPage?: boolean;
+ locator?: Locator;
+ sensitive?: boolean;
+}) {
+ const key = captureKey({
+ auth: input.auth,
+ route: input.route,
+ theme: input.theme,
+ viewport: input.profile.name,
+ state: input.state
+ });
+
+ if (
+ config.resume &&
+ manifest.completedKeys.includes(key)
+ ) {
+ return;
+ }
+
+ const outputDirectory = path.join(
+ captureRoot,
+ input.auth,
+ input.profile.name,
+ input.theme,
+ routeLabel(input.route)
+ );
+
+ const baseName = `${safeName(input.state).slice(0, 40)}-${createHash("sha256")
+ .update(key)
+ .digest("hex")
+ .slice(0, 12)}`;
+
+ let files: string[];
+ try {
+ files = input.locator
+ ? await captureElement(input.locator, outputDirectory, baseName)
+ : await capturePageSurface(input.page, outputDirectory, baseName, input.fullPage ?? true);
+ } catch (error) {
+ manifest.diagnostics.push({
+ timestamp: now(),
+ type: "pageerror",
+ route: input.route,
+ message: `Capture state ${input.state} failed: ${error instanceof Error ? error.message : String(error)}`
+ });
+ return;
+ }
+
+ const dimensions = await input.page.evaluate(() => ({
+ width: Math.max(document.documentElement.scrollWidth, document.body.scrollWidth),
+ height: Math.max(document.documentElement.scrollHeight, document.body.scrollHeight)
+ })).catch(() => ({ width: input.profile.width, height: input.profile.height }));
+
+ const record: CaptureRecord = {
+ key,
+ createdAt: now(),
+ auth: input.auth,
+ route: input.route,
+ finalUrl: input.page.url(),
+ theme: input.theme,
+ viewport: input.profile.name,
+ state: input.state,
+ status: input.status,
+ files: files.map(file =>
+ relativeTo(config.runRoot, file)
+ ),
+ width: dimensions.width,
+ height: dimensions.height,
+ deviceScaleFactor:
+ input.profile.deviceScaleFactor,
+ sensitive:
+ input.sensitive ??
+ input.auth !== "anonymous"
+ };
+
+ manifest.captures.push(record);
+ manifest.completedKeys.push(key);
+
+ await persistManifest();
+}
+
+async function captureHeaderStates(input: {
+ page: Page;
+ auth: AuthState;
+ route: string;
+ theme: ThemeMode;
+ profile: ViewportProfile;
+ status: number | null;
+}) {
+ const header =
+ input.page.locator("header").first();
+
+ if (!await header.isVisible().catch(() => false)) {
+ return;
+ }
+
+ await input.page.evaluate(() => {
+ window.scrollTo(
+ 0,
+ Math.max(
+ window.innerHeight,
+ 900
+ )
+ );
+ });
+
+ await input.page.waitForTimeout(180);
+
+ await saveCapture({
+ ...input,
+ state: "header-after-scroll-down",
+ fullPage: false
+ });
+
+ await input.page.mouse.wheel(0, -400);
+ await input.page.waitForTimeout(180);
+
+ await saveCapture({
+ ...input,
+ state: "header-after-scroll-up",
+ fullPage: false
+ });
+
+ await input.page.evaluate(() => {
+ window.scrollTo(0, 0);
+ });
+}
+
+async function captureDetailsStates(input: {
+ page: Page;
+ auth: AuthState;
+ route: string;
+ theme: ThemeMode;
+ profile: ViewportProfile;
+ status: number | null;
+}) {
+ const details =
+ input.page.locator("details");
+
+ const count = deepCount(await details.count(), 8);
+
+ if (count === 0) {
+ return;
+ }
+
+ await details.evaluateAll(nodes => {
+ nodes.forEach(node => {
+ (node as HTMLDetailsElement).open = true;
+ });
+ });
+
+ await input.page.waitForTimeout(100);
+
+ await saveCapture({
+ ...input,
+ state: "all-details-open",
+ fullPage: true
+ });
+
+ for (let index = 0; index < count; index += 1) {
+ const item = details.nth(index);
+
+ if (!await item.isVisible().catch(() => false)) {
+ continue;
+ }
+
+ await saveCapture({
+ ...input,
+ state:
+ `details-${String(index + 1)
+ .padStart(3, "0")}-open`,
+ locator: item
+ });
+ }
+}
+
+async function captureLightboxes(input: {
+ page: Page;
+ auth: AuthState;
+ route: string;
+ theme: ThemeMode;
+ profile: ViewportProfile;
+ status: number | null;
+}) {
+ const openers =
+ input.page.locator("button.media-card");
+
+ const count = deepCount(await openers.count(), 4);
+
+ for (let index = 0; index < count; index += 1) {
+ const opener = openers.nth(index);
+
+ if (!await opener.isVisible().catch(() => false)) {
+ continue;
+ }
+
+ await opener.click();
+
+ const dialog =
+ input.page.locator(
+ '.lightbox-shell[role="dialog"]'
+ );
+
+ await dialog.waitFor({
+ state: "visible",
+ timeout: 10_000
+ });
+
+ await saveCapture({
+ ...input,
+ state:
+ `lightbox-${String(index + 1)
+ .padStart(4, "0")}-100-percent`,
+ fullPage: false
+ });
+
+ if (index === 0) {
+ const previous = dialog.getByRole("button", { name: "Previous image" });
+ const next = dialog.getByRole("button", { name: "Next image" });
+ if (await previous.isVisible().catch(() => false) && await next.isVisible().catch(() => false)) {
+ await previous.click();
+ await saveCapture({ ...input, state: "lightbox-previous-boundary", fullPage: false });
+ await next.click();
+ await saveCapture({ ...input, state: "lightbox-next-boundary", fullPage: false });
+ }
+ }
+
+ const zoomIn =
+ dialog.getByRole(
+ "button",
+ { name: "Zoom in" }
+ );
+
+ if (
+ await zoomIn.isVisible().catch(() => false)
+ ) {
+ for (let click = 0; click < 4; click += 1) {
+ await zoomIn.click();
+ }
+
+ await saveCapture({
+ ...input,
+ state:
+ `lightbox-${String(index + 1)
+ .padStart(4, "0")}-200-percent`,
+ fullPage: false
+ });
+
+ for (let click = 0; click < 8; click += 1) {
+ await zoomIn.click();
+ }
+
+ await saveCapture({
+ ...input,
+ state:
+ `lightbox-${String(index + 1)
+ .padStart(4, "0")}-400-percent`,
+ fullPage: false
+ });
+
+ const stage = dialog.locator(".lightbox-stage");
+ const box = await stage.boundingBox();
+ if (box) {
+ const centerX = box.x + box.width / 2;
+ const centerY = box.y + box.height / 2;
+ await input.page.mouse.move(centerX, centerY);
+ await input.page.mouse.down();
+ await input.page.mouse.move(centerX + box.width * 0.28, centerY + box.height * 0.28, { steps: 8 });
+ await input.page.mouse.up();
+ await saveCapture({ ...input, state: `lightbox-${String(index + 1).padStart(4, "0")}-pan-boundary`, fullPage: false });
+ }
+ }
+
+ if (index === 0) await input.page.keyboard.press("Escape");
+ else await dialog.getByRole("button", { name: "Close image preview" }).click();
+
+ await dialog.waitFor({
+ state: "hidden",
+ timeout: 10_000
+ });
+ }
+}
+
+async function captureInlineEditing(input: {
+ page: Page;
+ auth: AuthState;
+ route: string;
+ theme: ThemeMode;
+ profile: ViewportProfile;
+ status: number | null;
+}) {
+ if (input.auth !== "admin") {
+ return;
+ }
+
+ const editLinks =
+ input.page.locator(
+ "a.section-edit-link"
+ );
+
+ const linkCount = deepCount(await editLinks.count(), 4);
+
+ for (
+ let sectionIndex = 0;
+ sectionIndex < linkCount;
+ sectionIndex += 1
+ ) {
+ const link =
+ editLinks.nth(sectionIndex);
+
+ if (!await link.isVisible().catch(() => false)) {
+ continue;
+ }
+
+ await link.click();
+
+ const assistant =
+ input.page.locator(
+ ".inline-edit-hint"
+ );
+
+ await assistant.waitFor({
+ state: "visible",
+ timeout: 10_000
+ });
+
+ await saveCapture({
+ ...input,
+ state:
+ `inline-section-${String(sectionIndex + 1)
+ .padStart(3, "0")}-active`,
+ fullPage: false
+ });
+
+ const editable =
+ input.page.locator(
+ 'section[data-inline-editing="true"] ' +
+ ".inline-editable-active"
+ );
+
+ const editableCount =
+ await editable.count();
+
+ for (
+ let fieldIndex = 0;
+ fieldIndex < editableCount;
+ fieldIndex += 1
+ ) {
+ const field =
+ editable.nth(fieldIndex);
+
+ if (!await field.isVisible().catch(() => false)) {
+ continue;
+ }
+
+ await field.click();
+
+ await saveCapture({
+ ...input,
+ state:
+ `inline-section-${String(sectionIndex + 1)
+ .padStart(3, "0")}` +
+ `-field-${String(fieldIndex + 1)
+ .padStart(3, "0")}-selected`,
+ fullPage: false
+ });
+
+ const urlField = await field.evaluate(
+ element =>
+ element instanceof HTMLAnchorElement &&
+ Boolean(
+ element.dataset.inlineEditUrlField
+ )
+ );
+
+ if (urlField) {
+ await assistant
+ .getByRole(
+ "button",
+ { name: "Edit URL" }
+ )
+ .click();
+
+ const dialog =
+ input.page.locator(
+ '.inline-url-dialog[role="dialog"]'
+ );
+
+ await dialog.waitFor({
+ state: "visible"
+ });
+
+ await saveCapture({
+ ...input,
+ state:
+ `inline-section-${String(sectionIndex + 1)
+ .padStart(3, "0")}` +
+ `-field-${String(fieldIndex + 1)
+ .padStart(3, "0")}-url-dialog`,
+ fullPage: false
+ });
+
+ await dialog
+ .getByRole(
+ "button",
+ { name: "Cancel" }
+ )
+ .click();
+ }
+ }
+
+ await assistant
+ .getByRole(
+ "button",
+ { name: "Cancel" }
+ )
+ .click();
+ }
+}
+
+async function captureStudioCards(input: {
+ page: Page;
+ auth: AuthState;
+ route: string;
+ theme: ThemeMode;
+ profile: ViewportProfile;
+ status: number | null;
+}) {
+ if (
+ input.auth !== "admin" ||
+ !input.page.url().includes("/studio")
+ ) {
+ return;
+ }
+
+ const cards =
+ input.page.locator(
+ ".studio-editor-card"
+ );
+
+ const count = deepCount(await cards.count(), 8);
+
+ for (let index = 0; index < count; index += 1) {
+ const card = cards.nth(index);
+
+ if (!await card.isVisible().catch(() => false)) {
+ continue;
+ }
+
+ await saveCapture({
+ ...input,
+ state:
+ `studio-editor-${String(index + 1)
+ .padStart(4, "0")}`,
+ locator: card
+ });
+ }
+}
+
+async function captureMediaPageItems(input: {
+ page: Page;
+ auth: AuthState;
+ route: string;
+ theme: ThemeMode;
+ profile: ViewportProfile;
+ status: number | null;
+}) {
+ if (
+ input.auth !== "admin" ||
+ !input.page.url().includes(
+ "panel=media"
+ )
+ ) {
+ return;
+ }
+
+ const cards =
+ input.page.locator(
+ "[data-media-path]"
+ );
+
+ const count = deepCount(await cards.count(), 6);
+
+ for (let index = 0; index < count; index += 1) {
+ const card = cards.nth(index);
+
+ if (!await card.isVisible().catch(() => false)) {
+ continue;
+ }
+
+ await card.click();
+
+ const inspector =
+ input.page.locator(
+ ".studio-media-inspector"
+ );
+
+ await inspector.waitFor({
+ state: "visible"
+ });
+
+ await saveCapture({
+ ...input,
+ state:
+ `media-inspector-${String(index + 1)
+ .padStart(4, "0")}`,
+ locator: inspector
+ });
+
+ await inspector
+ .locator("details")
+ .evaluateAll(nodes => {
+ nodes.forEach(node => {
+ (node as HTMLDetailsElement).open = true;
+ });
+ });
+
+ await saveCapture({
+ ...input,
+ state:
+ `media-inspector-${String(index + 1)
+ .padStart(4, "0")}-expanded`,
+ locator: inspector
+ });
+
+ const preview =
+ inspector.locator("button.media-card");
+
+ if (
+ await preview.isVisible().catch(() => false)
+ ) {
+ await preview.click();
+
+ const dialog =
+ input.page.locator(
+ '.lightbox-shell[role="dialog"]'
+ );
+
+ await dialog.waitFor({
+ state: "visible"
+ });
+
+ await saveCapture({
+ ...input,
+ state:
+ `media-inspector-${String(index + 1)
+ .padStart(4, "0")}-lightbox`,
+ fullPage: false
+ });
+
+ await dialog
+ .getByRole(
+ "button",
+ { name: "Close image preview" }
+ )
+ .click();
+ }
+ }
+
+ if (input.profile.isMobile) {
+ for (
+ const pane of [
+ "Tools",
+ "Library",
+ "Inspector"
+ ]
+ ) {
+ const button =
+ input.page.getByRole(
+ "button",
+ { name: pane }
+ );
+
+ if (
+ await button.isVisible().catch(() => false) &&
+ await button.isEnabled()
+ ) {
+ await button.click();
+
+ await saveCapture({
+ ...input,
+ state:
+ `media-mobile-pane-${pane.toLowerCase()}`,
+ fullPage: false
+ });
+ }
+ }
+ }
+}
+
+async function captureVisualizerStates(input: {
+ page: Page;
+ auth: AuthState;
+ route: string;
+ theme: ThemeMode;
+ profile: ViewportProfile;
+ status: number | null;
+}) {
+ const preview = input.page.getByRole("region", { name: "Interactive conceptual furniture preview" });
+ if (!await preview.isVisible().catch(() => false)) return;
+
+ for (const name of ["front", "side", "top", "Orthographic", "Rotate preview left", "Zoom preview in", "Zoom preview out", "Reset view"]) {
+ const button = input.page.getByRole("button", { name, exact: true });
+ if (!await button.isVisible().catch(() => false)) continue;
+ await button.click();
+ await input.page.waitForTimeout(120);
+ await saveCapture({ ...input, state: `visualizer-${safeName(name)}`, locator: preview });
+ }
+
+ const pieceType = input.page.getByLabel("Piece type");
+ if (await pieceType.isVisible().catch(() => false)) {
+ const options = await pieceType.locator("option").evaluateAll((nodes) => nodes.map((node) => (node as HTMLOptionElement).value));
+ for (const value of options) {
+ await pieceType.selectOption(value);
+ await input.page.waitForTimeout(100);
+ await saveCapture({ ...input, state: `visualizer-template-${safeName(value)}`, locator: preview });
+ }
+ }
+
+ const dimensionFields = [
+ { label: "Width (in)", min: "4", max: "240" },
+ { label: "Depth (in)", min: "2", max: "120" },
+ { label: "Height (in)", min: "2", max: "144" }
+ ];
+
+ for (const boundary of ["min", "max"] as const) {
+ for (const field of dimensionFields) {
+ const inputField = input.page.getByLabel(field.label);
+ if (await inputField.isVisible().catch(() => false)) await inputField.fill(field[boundary]);
+ }
+ await input.page.waitForTimeout(120);
+ await saveCapture({ ...input, state: `visualizer-dimensions-${boundary}`, locator: preview });
+ }
+}
+
+async function captureElementAtlas(input: {
+ page: Page;
+ auth: AuthState;
+ route: string;
+ theme: ThemeMode;
+ profile: ViewportProfile;
+ status: number | null;
+}) {
+ const elements =
+ input.page.locator([
+ "a",
+ "button",
+ "input",
+ "textarea",
+ "select",
+ "summary",
+ '[role="button"]',
+ '[role="tab"]',
+ '[aria-pressed]'
+ ].join(","));
+
+ const count = deepCount(await elements.count(), 48);
+
+ for (let index = 0; index < count; index += 1) {
+ const element =
+ elements.nth(index);
+
+ if (!await element.isVisible().catch(() => false)) {
+ continue;
+ }
+
+ const box =
+ await element.boundingBox();
+
+ if (
+ !box ||
+ box.width < 2 ||
+ box.height < 2
+ ) {
+ continue;
+ }
+
+ const prefix =
+ `element-${String(index + 1)
+ .padStart(5, "0")}`;
+
+ await saveCapture({
+ ...input,
+ state: `${prefix}-normal`,
+ locator: element
+ });
+
+ await element.hover({
+ force: true
+ }).catch(() => undefined);
+
+ await saveCapture({
+ ...input,
+ state: `${prefix}-hover`,
+ locator: element
+ });
+
+ await element.focus()
+ .catch(() => undefined);
+
+ await saveCapture({
+ ...input,
+ state: `${prefix}-focus`,
+ locator: element
+ });
+ }
+}
+
+async function captureRoute(input: {
+ context: BrowserContext;
+ auth: AuthState;
+ route: string;
+ theme: ThemeMode;
+ profile: ViewportProfile;
+ deep: boolean;
+}) {
+ const page =
+ await input.context.newPage();
+
+ attachDiagnostics(page, input.route);
+
+ try {
+ const response = await page.goto(
+ input.route,
+ {
+ waitUntil: "domcontentloaded",
+ timeout: 60_000
+ }
+ );
+
+ const redirectChain: string[] = [];
+ let redirected =
+ response?.request().redirectedFrom();
+
+ while (redirected) {
+ redirectChain.unshift(
+ redirected.url()
+ );
+
+ redirected =
+ redirected.redirectedFrom();
+ }
+
+ const routeResult: RouteResult = {
+ route: input.route,
+ auth: input.auth,
+ theme: input.theme,
+ viewport: input.profile.name,
+ deep: input.deep,
+ finalUrl: page.url(),
+ status:
+ response?.status() ?? null,
+ redirectChain,
+ expected: true
+ };
+
+ manifest.routes.push(routeResult);
+
+ await waitForVisualReady(page);
+ const evidence = await collectPageEvidence(page, input.route);
+ routeResult.discoveredLinks = evidence.links;
+ routeResult.surfaces = evidence.surfaces;
+
+ const base = {
+ page,
+ auth: input.auth,
+ route: input.route,
+ theme: input.theme,
+ profile: input.profile,
+ status: response?.status() ?? null
+ };
+
+ await saveCapture({
+ ...base,
+ state: "viewport-top",
+ fullPage: false
+ });
+
+ await saveCapture({
+ ...base,
+ state: "full-page-default",
+ fullPage: true
+ });
+
+ try {
+ await captureHeaderStates(base);
+ } catch (error) {
+ manifest.diagnostics.push({
+ timestamp: now(),
+ type: "pageerror",
+ route: input.route,
+ message: `Header-state capture failed: ${error instanceof Error ? error.message : String(error)}`
+ });
+ }
+
+ if (input.deep) {
+ const steps = [
+ ["details", captureDetailsStates],
+ ["lightboxes", captureLightboxes],
+ ["media-pickers", captureMediaPickers],
+ ["inline-editing", captureInlineEditing],
+ ["studio-cards", captureStudioCards],
+ ["media-inspectors", captureMediaPageItems],
+ ["form-validation", captureFormValidationStates],
+ ["commission-visualizer", captureVisualizerStates],
+ ["element-atlas", captureElementAtlas]
+ ] as const;
+
+ for (const [label, step] of steps) {
+ try {
+ await step(base);
+ } catch (error) {
+ manifest.diagnostics.push({
+ timestamp: now(),
+ type: "pageerror",
+ route: input.route,
+ message: `Deep capture step ${label} failed: ${error instanceof Error ? error.message : String(error)}`
+ });
+ }
+ }
+ }
+
+ await persistManifest();
+ } catch (error) {
+ manifest.diagnostics.push({
+ timestamp: now(),
+ type: "pageerror",
+ route: input.route,
+ message:
+ error instanceof Error
+ ? error.stack || error.message
+ : String(error)
+ });
+
+ await persistManifest();
+ } finally {
+ await page.close();
+ }
+}
+
+async function captureMediaPickers(input: {
+ page: Page;
+ auth: AuthState;
+ route: string;
+ theme: ThemeMode;
+ profile: ViewportProfile;
+ status: number | null;
+}) {
+ const openers =
+ input.page.getByRole(
+ "button",
+ { name: "Browse library" }
+ );
+
+ const count = deepCount(await openers.count(), 3);
+
+ for (
+ let index = 0;
+ index < count;
+ index += 1
+ ) {
+ const opener = openers.nth(index);
+
+ if (
+ !await opener
+ .isVisible()
+ .catch(() => false)
+ ) {
+ continue;
+ }
+
+ await opener.click();
+
+ const dialog =
+ input.page.locator(
+ '.media-picker-dialog[role="dialog"]'
+ );
+
+ await dialog.waitFor({
+ state: "visible",
+ timeout: 10_000
+ });
+
+ await saveCapture({
+ ...input,
+ state:
+ `media-picker-${String(index + 1)
+ .padStart(3, "0")}-default`,
+ fullPage: false
+ });
+
+ const filter = dialog.getByLabel("Search");
+
+ if (
+ await filter
+ .isVisible()
+ .catch(() => false)
+ ) {
+ await filter.fill(
+ "__visual_audit_no_results__"
+ );
+ await dialog.getByRole("button", { name: "Search" }).click();
+ await dialog.locator('[aria-busy="false"]').waitFor({ state: "attached", timeout: 10_000 }).catch(() => input.page.waitForTimeout(500));
+
+ await saveCapture({
+ ...input,
+ state:
+ `media-picker-${String(index + 1)
+ .padStart(3, "0")}-empty-filter`,
+ fullPage: false
+ });
+
+ await filter.fill("");
+ }
+
+ await dialog
+ .getByRole(
+ "button",
+ { name: "Close media browser" }
+ )
+ .click();
+
+ await dialog.waitFor({
+ state: "hidden",
+ timeout: 10_000
+ });
+ }
+}
+
+function structuralMatrix() {
+ if (config.scope === "smoke") {
+ return [
+ {
+ profile:
+ viewports.find(
+ viewport =>
+ viewport.name ===
+ "desktop-1440"
+ )!,
+ theme: "dark" as const
+ }
+ ];
+ }
+
+ return viewports.flatMap(profile =>
+ (["dark", "light"] as const).map(
+ theme => ({
+ profile,
+ theme
+ })
+ )
+ );
+}
+
+async function runRoutes(
+ browser: Browser,
+ auth: AuthState,
+ routes: string[]
+) {
+ const matrix = structuralMatrix();
+
+ for (const route of routes) {
+ for (const entry of matrix) {
+ const deep =
+ entry.profile.name ===
+ "desktop-archival" &&
+ entry.theme === "dark";
+
+ const context =
+ await createCaptureContext(
+ browser,
+ auth,
+ entry.profile,
+ entry.theme
+ );
+
+ try {
+ await captureRoute({
+ context,
+ auth,
+ route,
+ theme: entry.theme,
+ profile: entry.profile,
+ deep
+ });
+ } finally {
+ await context.close();
+ }
+ }
+ }
+}
+
+function captureableDiscoveredRoute(route: string, auth: AuthState) {
+ let url: URL;
+ try {
+ url = new URL(route, config.baseUrl);
+ } catch {
+ return false;
+ }
+ if (url.origin !== new URL(config.baseUrl).origin) return false;
+ if (url.pathname.startsWith("/api/") || url.pathname.startsWith("/media/") || url.pathname.startsWith("/_next/")) return false;
+ if (auth === "anonymous" && (url.pathname.startsWith("/studio") || url.pathname.startsWith("/requests/") || ["/account/profile", "/account/projects"].includes(url.pathname))) return false;
+ return true;
+}
+
+async function runDiscoveredRoutes(browser: Browser, auth: AuthState, seededRoutes: string[]) {
+ const captured = new Set([
+ ...seededRoutes,
+ ...manifest.routes.filter((route) => route.auth === auth).map((route) => route.route)
+ ]);
+
+ for (let pass = 0; pass < 3; pass += 1) {
+ const pending = manifest.discoveredLinks
+ .filter((route) => !captured.has(route) && captureableDiscoveredRoute(route, auth))
+ .sort();
+ if (pending.length === 0) return;
+ pending.forEach((route) => captured.add(route));
+ await runRoutes(browser, auth, pending);
+ }
+}
+
+async function runMediaPagination(
+ browser: Browser,
+ inventory: Inventory
+) {
+ const profile =
+ viewports.find(
+ viewport =>
+ viewport.name === (config.scope === "smoke" ? "desktop-1440" : "desktop-archival")
+ )!;
+
+ const theme = "dark" as const;
+ const totalPages = Math.max(
+ 1,
+ Math.ceil(inventory.counts.media / 48)
+ );
+
+ const filterRoutes = [
+ "/studio?panel=media&mediaAssignment=unassigned",
+ "/studio?panel=media&mediaAssignment=assigned",
+ "/studio?panel=media&mediaAssignment=review",
+ "/studio?panel=media&mediaKind=image",
+ "/studio?panel=media&mediaKind=video",
+ "/studio?panel=media&mediaAi=high",
+ "/studio?panel=media&mediaAi=ambiguous",
+ "/studio?panel=media&mediaAi=details",
+ "/studio?panel=media&mediaAi=unanalyzed",
+ "/studio?panel=media&mediaAi=missing-alt",
+ "/studio?panel=media&mediaAi=representatives"
+ ];
+
+ const pageRoutes = Array.from(
+ { length: config.scope === "smoke" ? 1 : totalPages },
+ (_, index) =>
+ `/studio?panel=media&mediaPage=${index + 1}`
+ );
+
+ for (
+ const route of unique([
+ ...pageRoutes,
+ ...(config.scope === "smoke" ? [] : filterRoutes)
+ ])
+ ) {
+ const context =
+ await createCaptureContext(
+ browser,
+ "admin",
+ profile,
+ theme
+ );
+
+ try {
+ await captureRoute({
+ context,
+ auth: "admin",
+ route,
+ theme,
+ profile,
+ deep: true
+ });
+ } finally {
+ await context.close();
+ }
+ }
+}
+
+async function runVisualizerFallbackStates(browser: Browser) {
+ if (config.scope === "smoke") return;
+ const profile = viewports.find((viewport) => viewport.name === "desktop-archival")!;
+ const variants = [
+ { route: "/commissions?auditState=reduced-motion", options: { reducedMotion: "reduce" as const } },
+ { route: "/commissions?auditState=webgl-unavailable", options: { disableWebGl: true } }
+ ];
+
+ for (const variant of variants) {
+ const context = await createCaptureContext(browser, "anonymous", profile, "dark", variant.options);
+ try {
+ await captureRoute({ context, auth: "anonymous", route: variant.route, theme: "dark", profile, deep: false });
+ } finally {
+ await context.close();
+ }
+ }
+}
+
+function routesForCurrentScope(routes: ReturnType, inventory: Inventory) {
+ if (config.scope === "full") return routes;
+
+ const firstPiece = inventory.pieces.find((piece) => piece.publicationStatus === "published");
+ const publicCandidates = [
+ "/",
+ "/portfolio",
+ ...(firstPiece ? [`/portfolio/${encodeURIComponent(firstPiece.slug)}`] : []),
+ "/shop",
+ "/commissions",
+ "/contact",
+ "/search?q=__visual_audit_no_results__",
+ "/account/login",
+ "/studio/login",
+ "/__visual-audit-route-not-found__"
+ ];
+ const publicRoutes = publicCandidates.filter((route) => routes.publicRoutes.includes(route));
+ const panelPrefixes = ["overview", "pages", "pieces", "media", "projects", "notifications"]
+ .map((panel) => `/studio?panel=${panel}`);
+ const adminRoutes = unique([
+ ...publicRoutes,
+ "/commissions",
+ ...routes.adminRoutes.filter((route) => panelPrefixes.some((prefix) => route.startsWith(prefix)))
+ ]);
+
+ return { ...routes, publicRoutes, adminRoutes };
+}
+
+async function main() {
+ await ensureDirectory(config.outputRoot);
+ await ensureDirectory(config.runRoot);
+ await ensureDirectory(captureRoot);
+
+ const browser =
+ await chromium.launch({
+ headless: true,
+ chromiumSandbox: false,
+ ...(config.browserChannel ? { channel: config.browserChannel } : {}),
+ args: [
+ "--disable-dev-shm-usage=false",
+ "--hide-scrollbars=false"
+ ]
+ });
+
+ try {
+ await authenticateAdmin(browser);
+
+ const inventory =
+ await fetchInventory(
+ browser,
+ config.authStatePath
+ );
+
+ const targetHost = new URL(config.baseUrl).hostname;
+ const localTarget = ["127.0.0.1", "localhost", "::1"].includes(targetHost);
+ if (inventory.buildSha === "unknown" && !localTarget) {
+ throw new Error(
+ "The target image is missing WOODSMITH_BUILD_SHA; rebuild it with the audited commit before capture."
+ );
+ }
+ if (inventory.buildSha !== "unknown" && inventory.buildSha !== config.expectedCommit) {
+ throw new Error(
+ `Deployment mismatch: expected ${config.expectedCommit}, ` +
+ `but production reported ${inventory.buildSha}.`
+ );
+ }
+
+ const source =
+ await discoverSourceRoutes();
+
+ const completeRoutes = buildRoutes(inventory, source);
+ const routes = routesForCurrentScope(completeRoutes, inventory);
+
+ manifest =
+ await loadOrCreateManifest(
+ browser.version(),
+ inventory
+ );
+
+ manifest.diagnostics.push(...preAuthenticationDiagnostics);
+ manifest.security.sameOriginUnsafeRequestsBlocked += preAuthenticationUnsafeBlocks;
+
+ await writeJsonAtomic(
+ path.join(
+ config.runRoot,
+ "coverage-plan.json"
+ ),
+ {
+ generatedAt: now(),
+ runId: config.runId,
+ mode: config.targetMode,
+ scope: config.scope,
+ source,
+ inventoryCounts:
+ inventory.counts,
+ routes,
+ completeInventoryRoutes: completeRoutes,
+ exclusions: coverageExclusions,
+ requiredStates: [
+ "route-default", "header-scroll", "theme-light", "theme-dark", "desktop", "tablet", "mobile", "archival-dpr",
+ "disclosures", "dialogs", "lightbox-zoom-boundaries", "inline-editing", "media-picker", "media-inspector",
+ "nested-scroll-surfaces", "empty-states", "error-states", "snapshot-lab-validation"
+ ],
+ safety: {
+ liveReadonly: "Unsafe same-origin and cross-origin requests are blocked client-side; same-origin requests also carry the server read-only header.",
+ snapshotLab: "Mutation-dependent states are permitted only against the separately mounted SQLite and media clones."
+ },
+ structuralMatrix:
+ structuralMatrix()
+ }
+ );
+
+ await runRoutes(
+ browser,
+ "anonymous",
+ routes.publicRoutes
+ );
+
+ if (config.scope === "full") await runDiscoveredRoutes(browser, "anonymous", routes.publicRoutes);
+
+ await runRoutes(
+ browser,
+ "admin",
+ routes.adminRoutes
+ );
+
+ if (config.scope === "full") await runDiscoveredRoutes(browser, "admin", routes.adminRoutes);
+
+ await runMediaPagination(
+ browser,
+ inventory
+ );
+
+ await runVisualizerFallbackStates(browser);
+
+ if (config.targetMode === "live-readonly" && manifest.security.successfulUnsafeRequests > 0) {
+ throw new Error(`Live read-only audit observed ${manifest.security.successfulUnsafeRequests} successful unsafe request(s).`);
+ }
+
+ manifest.completedAt = now();
+ await persistManifest();
+ } finally {
+ await browser.close();
+
+ await fs.rm(
+ config.tmpRoot,
+ {
+ recursive: true,
+ force: true
+ }
+ );
+ }
+}
+
+await main();
diff --git a/visual-audit/src/tiling.test.ts b/visual-audit/src/tiling.test.ts
new file mode 100644
index 0000000..a6244d4
--- /dev/null
+++ b/visual-audit/src/tiling.test.ts
@@ -0,0 +1,26 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { overlappingPositions, positionsIntersectingRange } from "./tiling.js";
+
+test("overlapping tile positions cover the full surface with a deterministic tail", () => {
+ const positions = overlappingPositions(3_200, 1_000);
+
+ assert.equal(positions[0], 0);
+ assert.equal(positions.at(-1), 2_200);
+
+ for (let index = 1; index < positions.length; index += 1) {
+ const previous = positions[index - 1]!;
+ const current = positions[index]!;
+ assert.ok(current > previous, "positions remain strictly increasing");
+ assert.ok(current < previous + 1_000, "adjacent tiles overlap");
+ }
+});
+
+test("range selection retains every tile needed across a segment boundary", () => {
+ const positions = overlappingPositions(5_000, 1_000);
+ const segment = positionsIntersectingRange(positions, 1_000, 2_000, 3_000);
+
+ assert.ok(segment.some((position) => position < 2_000));
+ assert.ok(segment.some((position) => position + 1_000 >= 3_000));
+});
diff --git a/visual-audit/src/tiling.ts b/visual-audit/src/tiling.ts
new file mode 100644
index 0000000..40082bc
--- /dev/null
+++ b/visual-audit/src/tiling.ts
@@ -0,0 +1,34 @@
+export const TILE_OVERLAP_RATIO = 0.12;
+
+export function overlappingPositions(
+ totalSize: number,
+ viewportSize: number,
+ overlapRatio = TILE_OVERLAP_RATIO
+) {
+ const total = Math.max(1, Math.ceil(totalSize));
+ const viewport = Math.max(1, Math.ceil(viewportSize));
+
+ if (total <= viewport) return [0];
+
+ const overlap = Math.max(1, Math.min(viewport - 1, Math.round(viewport * overlapRatio)));
+ const step = Math.max(1, viewport - overlap);
+ const maximum = total - viewport;
+ const positions = new Set([0, maximum]);
+
+ for (let position = 0; position < maximum; position += step) {
+ positions.add(Math.min(position, maximum));
+ }
+
+ return [...positions].sort((left, right) => left - right);
+}
+
+export function positionsIntersectingRange(
+ positions: number[],
+ viewportSize: number,
+ rangeStart: number,
+ rangeEnd: number
+) {
+ return positions.filter((position) => (
+ position < rangeEnd && position + viewportSize > rangeStart
+ ));
+}
diff --git a/visual-audit/src/types.ts b/visual-audit/src/types.ts
new file mode 100644
index 0000000..b314d75
--- /dev/null
+++ b/visual-audit/src/types.ts
@@ -0,0 +1,172 @@
+export type TargetMode = "live-readonly" | "snapshot-lab";
+export type AuditScope = "smoke" | "full";
+export type AuthState = "anonymous" | "admin";
+export type ThemeMode = "dark" | "light";
+
+export type ViewportProfile = {
+ name: string;
+ width: number;
+ height: number;
+ deviceScaleFactor: number;
+ isMobile: boolean;
+ archival: boolean;
+};
+
+export type Inventory = {
+ schemaVersion: number;
+ generatedAt: string;
+ buildSha: string;
+ staticRoutes: string[];
+ legacyRoutes: string[];
+ studioPanels: string[];
+ dynamicPatterns: string[];
+ pages: Array<{ slug: string; title: string; status: string }>;
+ pieces: Array<{ slug: string; title: string; publicationStatus: string; status: string }>;
+ posts: Array<{ slug: string; title: string; publicationStatus: string }>;
+ projects: Array<{ reference: string; status: string; stage: string }>;
+ orders: Array<{ orderNumber: string; status: string; paymentStatus: string }>;
+ reviews: Array<{ id: string; pieceSlug: string; status: string }>;
+ notifications: Array<{ id: string; status: string }>;
+ counts: {
+ pages: number;
+ pieces: number;
+ posts: number;
+ projects: number;
+ orders: number;
+ reviews: number;
+ notifications: number;
+ users: number;
+ media: number;
+ };
+ limits: {
+ recordsPerCollection: number;
+ truncatedCollections: string[];
+ };
+};
+
+export type DiagnosticType =
+ | "console"
+ | "pageerror"
+ | "requestfailed"
+ | "http-error"
+ | "mutation-blocked"
+ | "broken-media"
+ | "horizontal-overflow"
+ | "coverage"
+ | "security";
+
+export type DiagnosticRecord = {
+ timestamp: string;
+ type: DiagnosticType;
+ route: string;
+ message: string;
+ expected?: boolean;
+};
+
+export type RouteResult = {
+ route: string;
+ auth: AuthState;
+ theme: ThemeMode;
+ viewport: string;
+ deep: boolean;
+ finalUrl: string;
+ status: number | null;
+ redirectChain: string[];
+ expected: boolean;
+ discoveredLinks?: string[];
+ surfaces?: SurfaceInventory;
+};
+
+export type SurfaceInventory = {
+ details: number;
+ lightboxOpeners: number;
+ mediaPickerOpeners: number;
+ inlineEditLinks: number;
+ studioCards: number;
+ mediaCards: number;
+ validationForms: number;
+ interactiveElements: number;
+ scrollContainers: number;
+ visualizer: boolean;
+};
+
+export type CaptureRecord = {
+ key: string;
+ createdAt: string;
+ auth: AuthState;
+ route: string;
+ finalUrl: string;
+ theme: ThemeMode;
+ viewport: string;
+ state: string;
+ status: number | null;
+ files: string[];
+ width: number;
+ height: number;
+ deviceScaleFactor: number;
+ sensitive: boolean;
+};
+
+export type SecuritySummary = {
+ sameOriginUnsafeRequestsBlocked: number;
+ successfulUnsafeRequests: number;
+ tokenEligibleRequests: number;
+ crossOriginRequests: number;
+};
+
+export type CoverageExclusion = {
+ surface: string;
+ reason: string;
+};
+
+export type RunManifest = {
+ schemaVersion: number;
+ runId: string;
+ startedAt: string;
+ completedAt: string | null;
+ mode: TargetMode;
+ scope: AuditScope;
+ baseUrl: string;
+ expectedCommit: string;
+ deployedCommit: string;
+ browserVersion: string;
+ inventory: Inventory;
+ captures: CaptureRecord[];
+ routes: RouteResult[];
+ diagnostics: DiagnosticRecord[];
+ completedKeys: string[];
+ discoveredLinks: string[];
+ exclusions: CoverageExclusion[];
+ security: SecuritySummary;
+};
+
+export type RouteCollection = {
+ publicRoutes: string[];
+ adminRoutes: string[];
+ unresolvedPatterns: string[];
+};
+
+export type TileRecord = {
+ file: string;
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+};
+
+export type SegmentRecord = {
+ file: string;
+ startY: number;
+ width: number;
+ height: number;
+ tiles: TileRecord[];
+};
+
+export type TileManifest = {
+ kind: "page" | "scroll-container";
+ createdAt: string;
+ sourceWidth: number;
+ sourceHeight: number;
+ deviceScaleFactor: number;
+ segments: SegmentRecord[];
+};
diff --git a/visual-audit/src/util.ts b/visual-audit/src/util.ts
new file mode 100644
index 0000000..9df6a26
--- /dev/null
+++ b/visual-audit/src/util.ts
@@ -0,0 +1,75 @@
+import { createHash } from "node:crypto";
+import fs from "node:fs/promises";
+import path from "node:path";
+
+export async function ensureDirectory(directory: string, mode = 0o700) {
+ await fs.mkdir(directory, { recursive: true, mode });
+ await fs.chmod(directory, mode).catch(() => undefined);
+}
+
+export async function exists(file: string) {
+ return fs.access(file).then(() => true).catch(() => false);
+}
+
+export function unique(values: T[]) {
+ return [...new Set(values)];
+}
+
+export function safeName(value: string) {
+ return value
+ .normalize("NFKD")
+ .replace(/[^a-zA-Z0-9._-]+/g, "-")
+ .replace(/^-+|-+$/g, "")
+ .slice(0, 180) || "surface";
+}
+
+export function relativeTo(root: string, file: string) {
+ const relative = path.relative(root, file);
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
+ throw new Error(`Refusing to reference a file outside the run root: ${file}`);
+ }
+ return relative.split(path.sep).join("/");
+}
+
+export async function writeJsonAtomic(file: string, value: unknown) {
+ await ensureDirectory(path.dirname(file));
+ const temporary = `${file}.tmp-${process.pid}-${Date.now()}`;
+ await fs.writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
+ for (let attempt = 0; ; attempt += 1) {
+ try {
+ await fs.rename(temporary, file);
+ break;
+ } catch (error) {
+ const code = error instanceof Error && "code" in error ? String(error.code) : "";
+ if (!["EACCES", "EBUSY", "EPERM"].includes(code) || attempt >= 7) throw error;
+ await new Promise((resolve) => setTimeout(resolve, 25 * (attempt + 1)));
+ }
+ }
+ await fs.chmod(file, 0o600).catch(() => undefined);
+}
+
+export async function sha256File(file: string) {
+ const hash = createHash("sha256");
+ hash.update(await fs.readFile(file));
+ return hash.digest("hex");
+}
+
+export function escapeHtml(value: unknown) {
+ return String(value)
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """)
+ .replaceAll("'", "'");
+}
+
+export async function listFiles(directory: string): Promise {
+ const entries = await fs.readdir(directory, { withFileTypes: true });
+ const files: string[] = [];
+ for (const entry of entries) {
+ const absolute = path.join(directory, entry.name);
+ if (entry.isDirectory()) files.push(...await listFiles(absolute));
+ else if (entry.isFile()) files.push(absolute);
+ }
+ return files;
+}
diff --git a/visual-audit/src/validate.ts b/visual-audit/src/validate.ts
new file mode 100644
index 0000000..48bd110
--- /dev/null
+++ b/visual-audit/src/validate.ts
@@ -0,0 +1,330 @@
+import fs from "node:fs/promises";
+import path from "node:path";
+
+import sharp from "sharp";
+
+import { config, viewports } from "./config.js";
+import type { RunManifest, TileManifest } from "./types.js";
+import { exists, listFiles, relativeTo, sha256File, writeJsonAtomic } from "./util.js";
+
+function expectedDiagnostic(message: string, type: string, route: string) {
+ if (type === "mutation-blocked") return true;
+ if (message.includes("/api/visits") && type === "requestfailed") return true;
+ if (route.includes("__visual-audit-route-not-found__") && type === "http-error" && message.startsWith("404 ")) return true;
+ if (/THREE\.THREE\.Clock: This module has been deprecated/.test(message)) return true;
+ return false;
+}
+
+async function validatePng(file: string, label: string, failures: string[]) {
+ const image = sharp(file);
+ const metadata = await image.metadata();
+ if (!metadata.width || !metadata.height) {
+ failures.push(`PNG has invalid dimensions: ${label}`);
+ return { width: metadata.width, height: metadata.height };
+ }
+ if (metadata.format !== "png") failures.push(`Capture is not PNG: ${label}`);
+ const stats = await image.stats();
+ if (stats.channels.length > 0 && stats.channels.every((channel) => channel.stdev < 0.15)) {
+ failures.push(`Capture appears blank or single-color: ${label}`);
+ }
+ return { width: metadata.width, height: metadata.height };
+}
+
+async function overlapDifference(input: {
+ previousFile: string;
+ currentFile: string;
+ axis: "horizontal" | "vertical";
+ overlap: number;
+ width: number;
+ height: number;
+}) {
+ const sampleWidth = input.axis === "vertical" ? input.width : input.overlap;
+ const sampleHeight = input.axis === "vertical" ? input.overlap : input.height;
+ const previousMetadata = await sharp(input.previousFile).metadata();
+ const currentMetadata = await sharp(input.currentFile).metadata();
+ const width = Math.max(1, Math.min(sampleWidth, previousMetadata.width ?? sampleWidth, currentMetadata.width ?? sampleWidth));
+ const height = Math.max(1, Math.min(sampleHeight, previousMetadata.height ?? sampleHeight, currentMetadata.height ?? sampleHeight));
+ const previousLeft = input.axis === "horizontal" ? Math.max(0, (previousMetadata.width ?? width) - width) : 0;
+ const previousTop = input.axis === "vertical" ? Math.max(0, (previousMetadata.height ?? height) - height) : 0;
+
+ const targetWidth = Math.min(512, width);
+ const targetHeight = Math.min(192, height);
+ const previous = await sharp(input.previousFile)
+ .extract({ left: previousLeft, top: previousTop, width, height })
+ .resize(targetWidth, targetHeight, { fit: "fill" })
+ .removeAlpha()
+ .raw()
+ .toBuffer();
+ const current = await sharp(input.currentFile)
+ .extract({ left: 0, top: 0, width, height })
+ .resize(targetWidth, targetHeight, { fit: "fill" })
+ .removeAlpha()
+ .raw()
+ .toBuffer();
+
+ let total = 0;
+ for (let index = 0; index < Math.min(previous.length, current.length); index += 1) {
+ total += Math.abs((previous[index] ?? 0) - (current[index] ?? 0));
+ }
+ return total / Math.max(1, Math.min(previous.length, current.length)) / 255;
+}
+
+async function validateTileAxis(
+ tiles: TileManifest["segments"][number]["tiles"],
+ axis: "horizontal" | "vertical",
+ segmentFile: string,
+ failures: string[]
+) {
+ const primary = axis === "vertical" ? "y" : "x";
+ const secondary = axis === "vertical" ? "x" : "y";
+ const extent = axis === "vertical" ? "height" : "width";
+ const crossExtent = axis === "vertical" ? "width" : "height";
+ const groups = new Map();
+
+ for (const tile of tiles) {
+ const key = tile[secondary];
+ groups.set(key, [...(groups.get(key) ?? []), tile]);
+ }
+
+ for (const group of groups.values()) {
+ const ordered = [...group].sort((left, right) => left[primary] - right[primary]);
+ for (let index = 1; index < ordered.length; index += 1) {
+ const previous = ordered[index - 1]!;
+ const current = ordered[index]!;
+ const overlap = previous[primary] + previous[extent] - current[primary];
+ if (overlap <= 0) {
+ failures.push(`Tile seam has a ${axis} coverage gap in ${segmentFile}.`);
+ continue;
+ }
+
+ const difference = await overlapDifference({
+ previousFile: path.join(config.runRoot, previous.file),
+ currentFile: path.join(config.runRoot, current.file),
+ axis,
+ overlap,
+ width: Math.min(previous[crossExtent], current[crossExtent]),
+ height: Math.min(previous[crossExtent], current[crossExtent])
+ });
+ if (difference > 0.12) {
+ failures.push(`Tile seam correlation failed in ${segmentFile} (${axis}, normalized difference ${difference.toFixed(4)}).`);
+ }
+ }
+ }
+}
+
+async function validateTiles(files: string[], failures: string[]) {
+ for (const file of files.filter((item) => item.endsWith("__tiles.json"))) {
+ const tileManifest = JSON.parse(await fs.readFile(file, "utf8")) as TileManifest;
+ if (tileManifest.segments.length === 0) failures.push(`Tile manifest has no segments: ${relativeTo(config.runRoot, file)}`);
+ let coveredHeight = 0;
+ for (const segment of tileManifest.segments) {
+ if (segment.tiles.length === 0) failures.push(`Stitched segment has no raw tiles: ${segment.file}`);
+ const output = path.join(config.runRoot, segment.file);
+ if (!await exists(output)) {
+ failures.push(`Stitched segment is missing: ${segment.file}`);
+ } else {
+ const metadata = await sharp(output).metadata();
+ if (metadata.width !== segment.width || metadata.height !== segment.height) {
+ failures.push(`Stitched segment dimensions do not match its tile manifest: ${segment.file}.`);
+ }
+ }
+
+ for (const tile of segment.tiles) {
+ if (!await exists(path.join(config.runRoot, tile.file))) failures.push(`Raw tile is missing: ${tile.file}`);
+ }
+
+ await validateTileAxis(segment.tiles, "vertical", segment.file, failures);
+ await validateTileAxis(segment.tiles, "horizontal", segment.file, failures);
+ coveredHeight = Math.max(coveredHeight, segment.startY + segment.height);
+ }
+ if (coveredHeight + 2 < tileManifest.sourceHeight) {
+ failures.push(`Stitched output does not cover source height for ${relativeTo(config.runRoot, file)}.`);
+ }
+ }
+}
+
+async function scanForSecretLeaks(files: string[], failures: string[]) {
+ const secrets = [config.adminPassword, config.auditToken].filter(Boolean).map((value) => Buffer.from(value));
+ for (const file of files) {
+ const data = await fs.readFile(file);
+ if (secrets.some((secret) => secret.length > 0 && data.indexOf(secret) >= 0)) {
+ failures.push(`A secret value was detected in an output artifact: ${relativeTo(config.runRoot, file)}`);
+ }
+ }
+}
+
+async function validateHtml(file: string, failures: string[]) {
+ if (!await exists(file)) {
+ failures.push(`HTML report is missing: ${relativeTo(config.runRoot, file)}`);
+ return;
+ }
+ const html = await fs.readFile(file, "utf8");
+ const ids = new Set([...html.matchAll(/\sid="([^"]+)"/g)].map((match) => match[1]!));
+ const targets = [...html.matchAll(/href="#([^"]+)"/g)].map((match) => match[1]!);
+ if (targets.length === 0) failures.push(`HTML report has no linked table of contents: ${relativeTo(config.runRoot, file)}`);
+ for (const target of targets) {
+ if (!ids.has(target)) failures.push(`HTML report contains an unresolved contents link #${target}: ${relativeTo(config.runRoot, file)}`);
+ }
+}
+
+async function validatePdf(file: string, failures: string[]) {
+ if (!await exists(file)) {
+ failures.push(`Compiled PDF is missing: ${relativeTo(config.runRoot, file)}`);
+ return 0;
+ }
+ const data = await fs.readFile(file);
+ if (!data.subarray(0, 5).equals(Buffer.from("%PDF-"))) failures.push(`Invalid PDF header: ${relativeTo(config.runRoot, file)}`);
+ const text = data.toString("latin1");
+ const pageCount = (text.match(/\/Type\s*\/Page\b/g) ?? []).length;
+ if (pageCount < 1) failures.push(`PDF contains no detectable pages: ${relativeTo(config.runRoot, file)}`);
+ if (!/\/Outlines\b/.test(text)) failures.push(`PDF does not contain an outline/bookmark tree: ${relativeTo(config.runRoot, file)}`);
+ return pageCount;
+}
+
+async function main() {
+ const manifestFile = path.join(config.runRoot, "manifest.json");
+ const manifest = JSON.parse(await fs.readFile(manifestFile, "utf8")) as RunManifest;
+ const failures: string[] = [];
+
+ if (!manifest.completedAt) failures.push("Manifest does not contain completedAt.");
+ if (manifest.runId !== config.runId) failures.push("Manifest run ID does not match AUDIT_RUN_ID.");
+ if (manifest.mode !== config.targetMode) failures.push("Manifest mode does not match TARGET_MODE.");
+ if (manifest.deployedCommit !== "unknown" && manifest.deployedCommit !== manifest.expectedCommit) failures.push(`Commit mismatch: expected ${manifest.expectedCommit}, deployed ${manifest.deployedCommit}.`);
+ if (manifest.inventory.limits.truncatedCollections.length > 0) failures.push(`Inventory collections were truncated: ${manifest.inventory.limits.truncatedCollections.join(", ")}.`);
+ if (manifest.captures.length === 0) failures.push("Manifest contains no captures.");
+
+ const captureKeys = new Set();
+ for (const capture of manifest.captures) {
+ if (captureKeys.has(capture.key)) failures.push(`Duplicate capture key: ${capture.key}`);
+ captureKeys.add(capture.key);
+ if (capture.files.length === 0) failures.push(`Capture has no files: ${capture.key}`);
+ for (const relativeFile of capture.files) {
+ const absolute = path.join(config.runRoot, relativeFile);
+ if (!await exists(absolute)) {
+ failures.push(`Missing capture file: ${relativeFile}`);
+ continue;
+ }
+ await validatePng(absolute, relativeFile, failures);
+ }
+ }
+
+ for (const route of manifest.routes) {
+ const missingRoute = route.route.includes("__visual-audit-route-not-found__");
+ if (route.status == null) failures.push(`Route did not return a response: ${route.auth} ${route.route}`);
+ else if (route.status >= 400 && !(missingRoute && route.status === 404)) failures.push(`Unexpected HTTP ${route.status}: ${route.auth} ${route.route}`);
+ if (!manifest.captures.some((capture) => capture.auth === route.auth && capture.route === route.route)) failures.push(`Route has no successful capture: ${route.auth} ${route.route}`);
+ }
+
+ const matrixProfiles = config.scope === "smoke" ? ["desktop-1440"] : viewports.map((viewport) => viewport.name);
+ const matrixThemes = config.scope === "smoke" ? ["dark"] : ["dark", "light"];
+ const matrixRoutes = new Map();
+ for (const route of manifest.routes.filter((item) => !item.route.includes("auditState="))) {
+ const key = `${route.auth}::${route.route}`;
+ matrixRoutes.set(key, [...(matrixRoutes.get(key) ?? []), route]);
+ }
+
+ for (const [key, results] of matrixRoutes) {
+ for (const profile of matrixProfiles) {
+ for (const theme of matrixThemes) {
+ const routeResult = results.find((item) => item.viewport === profile && item.theme === theme);
+ if (!routeResult) failures.push(`Coverage matrix is missing ${profile}/${theme} for ${key}.`);
+ else if (!manifest.captures.some((capture) => capture.auth === routeResult.auth && capture.route === routeResult.route && capture.viewport === profile && capture.theme === theme)) {
+ failures.push(`Coverage matrix has no successful capture for ${profile}/${theme} ${key}.`);
+ }
+ }
+ }
+ }
+
+ const capturedRoutes = new Set(manifest.routes.map((route) => route.route));
+ if (config.scope === "full") {
+ for (const discovered of manifest.discoveredLinks) {
+ if (!capturedRoutes.has(discovered)) failures.push(`Rendered same-origin link was discovered but not captured: ${discovered}`);
+ }
+ }
+
+ for (const route of manifest.routes.filter((item) => item.deep && item.surfaces)) {
+ const captures = manifest.captures.filter((capture) => (
+ capture.auth === route.auth && capture.route === route.route && capture.theme === route.theme && capture.viewport === route.viewport
+ ));
+ const hasState = (prefix: string) => captures.some((capture) => capture.state.startsWith(prefix));
+ if (route.surfaces!.details > 0 && !hasState("all-details-open")) failures.push(`Deep coverage missed disclosures for ${route.auth} ${route.route}.`);
+ if (route.surfaces!.lightboxOpeners > 0 && !hasState("lightbox-")) failures.push(`Deep coverage missed lightboxes for ${route.auth} ${route.route}.`);
+ if (route.surfaces!.mediaPickerOpeners > 0 && !hasState("media-picker-")) failures.push(`Deep coverage missed media pickers for ${route.auth} ${route.route}.`);
+ if (route.auth === "admin" && route.surfaces!.inlineEditLinks > 0 && !hasState("inline-section-")) failures.push(`Deep coverage missed inline-edit states for ${route.route}.`);
+ if (route.auth === "admin" && route.surfaces!.studioCards > 0 && !hasState("studio-editor-")) failures.push(`Deep coverage missed Studio record editors for ${route.route}.`);
+ if (config.targetMode === "snapshot-lab" && route.surfaces!.validationForms > 0 && !hasState("form-")) failures.push(`Snapshot-lab deep coverage missed form validation for ${route.route}.`);
+ if (route.surfaces!.visualizer && !hasState("visualizer-")) failures.push(`Deep coverage missed commission visualizer states for ${route.route}.`);
+ if (route.surfaces!.interactiveElements > 0 && !hasState("element-")) failures.push(`Deep coverage missed the element atlas for ${route.auth} ${route.route}.`);
+ if (route.surfaces!.scrollContainers > 0 && !captures.some((capture) => capture.files.some((file) => file.includes("__scroll-")))) {
+ failures.push(`Deep coverage missed nested scroll surfaces for ${route.auth} ${route.route}.`);
+ }
+ }
+
+ const expectedProfiles = config.scope === "smoke" ? ["desktop-1440"] : viewports.map((viewport) => viewport.name);
+ for (const profile of expectedProfiles) {
+ if (!manifest.captures.some((capture) => capture.viewport === profile)) failures.push(`Required viewport has no captures: ${profile}`);
+ }
+
+ const unexpectedDiagnostics = manifest.diagnostics.filter((record) => !record.expected && !expectedDiagnostic(record.message, record.type, record.route));
+ if (config.strictDiagnostics && unexpectedDiagnostics.length > 0) {
+ failures.push(`${unexpectedDiagnostics.length} unexpected browser/network diagnostic(s) were recorded.`);
+ }
+ if (config.targetMode === "live-readonly" && manifest.security.successfulUnsafeRequests > 0) failures.push("Live read-only capture recorded a successful unsafe request.");
+ if (config.targetMode === "live-readonly" && manifest.diagnostics.some((record) => record.type === "security" && !record.expected)) failures.push("Live read-only capture recorded an unsafe security diagnostic.");
+
+ const filesBeforeValidation = await listFiles(config.runRoot);
+ await validateTiles(filesBeforeValidation, failures);
+ const reportIndexFile = path.join(config.runRoot, "report", "report-index.json");
+ if (!await exists(reportIndexFile)) failures.push("Report index is missing.");
+ const reportIndex = await exists(reportIndexFile)
+ ? JSON.parse(await fs.readFile(reportIndexFile, "utf8")) as { restrictedPrintPages?: number; shareablePrintPages?: number }
+ : {};
+ await Promise.all([
+ validateHtml(path.join(config.runRoot, "report", "index.html"), failures),
+ validateHtml(path.join(config.runRoot, "report", "print.html"), failures),
+ validateHtml(path.join(config.runRoot, "shareable", "index.html"), failures),
+ validateHtml(path.join(config.runRoot, "shareable", "print.html"), failures)
+ ]);
+ const restrictedPdfPages = await validatePdf(path.join(config.runRoot, "woodmat-visual-atlas.pdf"), failures);
+ const shareablePdfPages = await validatePdf(path.join(config.runRoot, "shareable", "woodmat-visual-atlas-redacted.pdf"), failures);
+ if (restrictedPdfPages < (reportIndex.restrictedPrintPages ?? 0)) failures.push("Restricted PDF page count is lower than its print-slice count.");
+ if (shareablePdfPages < (reportIndex.shareablePrintPages ?? 0)) failures.push("Shareable PDF page count is lower than its print-slice count.");
+ await scanForSecretLeaks(filesBeforeValidation, failures);
+
+ if (process.platform !== "win32") {
+ const mode = (await fs.stat(config.runRoot)).mode & 0o777;
+ if ((mode & 0o077) !== 0) failures.push(`Run directory permissions are ${mode.toString(8)}; expected no group/other access.`);
+ }
+
+ const validationFile = path.join(config.runRoot, "validation.json");
+ const artifactCount = filesBeforeValidation.filter((file) => !/[\\/]checksums(?:\.json|\.sha256)$/.test(file)).length + (filesBeforeValidation.includes(validationFile) ? 0 : 1);
+ await writeJsonAtomic(validationFile, {
+ validatedAt: new Date().toISOString(),
+ passed: failures.length === 0,
+ failures,
+ diagnostics: unexpectedDiagnostics,
+ captureCount: manifest.captures.length,
+ routeCount: manifest.routes.length,
+ checksumCount: artifactCount,
+ security: manifest.security
+ });
+
+ const checksumCandidates = (await listFiles(config.runRoot)).filter((file) => !/[\\/]checksums(?:\.json|\.sha256)$/.test(file));
+ const checksums = [] as Array<{ file: string; sha256: string; width?: number; height?: number }>;
+ for (const file of checksumCandidates) {
+ const relative = relativeTo(config.runRoot, file);
+ if (file.endsWith(".png")) {
+ const metadata = await sharp(file).metadata();
+ checksums.push({ file: relative, sha256: await sha256File(file), width: metadata.width, height: metadata.height });
+ } else {
+ checksums.push({ file: relative, sha256: await sha256File(file) });
+ }
+ }
+ checksums.sort((left, right) => left.file.localeCompare(right.file));
+ await writeJsonAtomic(path.join(config.runRoot, "checksums.json"), checksums);
+ await fs.writeFile(path.join(config.runRoot, "checksums.sha256"), `${checksums.map((item) => `${item.sha256} ${item.file}`).join("\n")}\n`, { encoding: "utf8", mode: 0o600 });
+
+ if (failures.length > 0) throw new Error(`Visual audit failed validation with ${failures.length} failure(s). Review validation.json.`);
+}
+
+await main();
diff --git a/visual-audit/tsconfig.json b/visual-audit/tsconfig.json
new file mode 100644
index 0000000..97a135f
--- /dev/null
+++ b/visual-audit/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "strict": true,
+ "noUncheckedIndexedAccess": true,
+ "exactOptionalPropertyTypes": true,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "skipLibCheck": true,
+ "outDir": "dist",
+ "rootDir": "src",
+ "declaration": false,
+ "sourceMap": false,
+ "types": ["node"]
+ },
+ "include": ["src/**/*.ts"]
+}
diff --git a/woodsmith_DeepWiki_Merged_03222026.md b/woodsmith_DeepWiki_Merged_03222026.md
index 2142e0f..8b97904 100644
--- a/woodsmith_DeepWiki_Merged_03222026.md
+++ b/woodsmith_DeepWiki_Merged_03222026.md
@@ -16,7 +16,7 @@ Beaman Woodworks is a self-hosted Next.js 16 application with a SQLite-backed co
- Nodemailer for SMTP delivery
- Stripe API for hosted checkout and invoice generation
- EasyPost API for shipment creation
-- Credential-free procedural 3D preview UI for custom work, with Three.js and React Three Fiber dependencies retained for future rendering work
+- Dynamically isolated React Three Fiber/Three.js conceptual proportional preview with deterministic SVG and textual fallbacks
- Optional OpenAI Image API and Embeddings API integration, disabled unless `OPENAI_API_KEY` and feature flags are configured server-side
## Core application areas
@@ -80,6 +80,10 @@ The SQLite schema includes these primary tables:
- `orders`
- `reviews`
- `notifications`
+- `schema_migrations`
+- `piece_media_links`
+- `admin_edit_audit`
+- `media_rename_history`
Seeds from `site/lib/seed.ts` initialize site settings, profile records, pages, pieces, custom work types, and process notes. Existing databases are upgraded through seed v5 without deleting runtime orders, projects, users, media metadata, dashboard edits, or deletion tombstones. Seed v3 and later migrations are non-destructive for existing Studio-edited content; they normalize legacy developer-email references, replace only exact stale seed wording, and remove the obsolete public Process navigation entry.
@@ -114,8 +118,14 @@ The public custom work route is contact-first. It captures buyer contact details
Custom work type records still store default dimensions, material options, labor hours, and markup settings so the woodshop can maintain estimator context and future richer intake flows.
-`site/components/visualizer.tsx` now provides a live 3D CSS/procedural scale preview and still stores an SVG snapshot with submitted project data when the buyer opts in. When `OPENAI_API_KEY` and `ENABLE_PUBLIC_AI_RENDERING=true` are configured, `/api/render-preview` can generate a photorealistic image preview, persist it under `/app/pics`, and attach it to the project only when the buyer includes the preview.
+`site/components/visualizer.tsx` dynamically loads `site/components/commission-scene.tsx` only on the custom-work route. The React Three Fiber scene supports category-specific and generic templates, exact submitted dimensions, material cues, perspective/orthographic cameras, front/side/top/isometric presets, orbit/zoom/reset controls, and demand rendering. A deterministic SVG drawing and textual dimensions remain available for printing, submission, reduced motion, and WebGL failure. When `OPENAI_API_KEY` and `ENABLE_PUBLIC_AI_RENDERING=true` are configured, `/api/render-preview` can generate a photorealistic conceptual image, persist it under `/app/pics`, and attach it only when the buyer includes the preview.
+## Visual archive and rendered QA
+
+`visual-audit/` is an independent TypeScript package pinned to Playwright 1.61.0 and Sharp 0.35.3. It reconciles source routes, a token-and-admin-protected bounded database inventory, and rendered same-origin links. `live-readonly` blocks unsafe browser requests and adds a server read-only header; `snapshot-lab` uses a verified SQLite/media clone on an internal Docker network with external providers disabled.
+
+The runner captures the required desktop/tablet/mobile/theme matrix plus a 5120 x 2880 archival viewport, deep dialog/disclosure/lightbox/Studio/media/inline-edit/visualizer states, overlapping raw tiles and stitched long surfaces, and an element atlas. It writes restricted and redacted searchable HTML/PDF editions, SHA-256 manifests, route/network/render diagnostics, tile-seam validation, and baseline comparisons. See `docs/visual-archive.md` for the exact safety and operating contract.
+
## Search
`searchSite()` searches across pieces, process notes, pages, media, and projects. Admin users receive private results including unpublished content, media paths, tags, cluster keys, and project records. Public users see public content only.
@@ -145,7 +155,7 @@ The active design language is based on the Beaman Woodworks 2.0 prototypes but u
## Known caveats
- SQLite support still relies on Node's experimental `node:sqlite` API.
-- The visualizer is a procedural 3D scale preview unless optional OpenAI rendering is configured. Generated images are previews, not fabrication drawings.
+- The visualizer is a conceptual proportional R3F preview, not fabrication-ready CAD. Optional generated images are also conceptual and provider-dependent.
- Scientist Desk media is intentionally withheld until the correct images are verified.
- Local pixel embeddings require the optional sidecar model dependencies and a sidecar URL reachable from the web container. The manual workflow remains available when it is offline.
- OpenAI-backed rendering and cleaned image copies remain separate explicit feature flags. ChatGPT Plus is not an API credential.
@@ -174,6 +184,9 @@ The active design language is based on the Beaman Woodworks 2.0 prototypes but u
- `site/components/studio-media-workspace.tsx`: compact media-management workspace for `/studio?panel=media`
- `site/components/visitor-tracker.tsx` + `site/components/visitor-insights.tsx`: client visit logging and dashboard visitor map/list
- `site/components/visualizer.tsx`: procedural custom-work visualizer, optional AI preview trigger, legacy to-scale SVG snapshot, and estimator fields
+- `site/components/commission-scene.tsx`: route-local React Three Fiber templates, cameras, lighting, dimensions, and fallback-safe scene controls
+- `visual-audit/`: deterministic two-mode visual archive, reports, validation, comparison, and NAS scripts
+- `docs/visual-archive.md`: visual-archive security and operations manual
- `site/app/icon.tsx`: generated favicon
- `site/app/studio/page.tsx`: private Woodshop dashboard
- `site/app/media/[...slug]/route.ts`: file-backed media serving route
From 9067229731e10a50be8b4de5a92cd391fb745b2f Mon Sep 17 00:00:00 2001
From: Cooper Beaman <55124795+lowestprime@users.noreply.github.com>
Date: Sun, 12 Jul 2026 15:56:35 -0700
Subject: [PATCH 08/43] feat(studio): make inline edits atomic and reversible
---
PLANS.md | 5 +-
README.md | 2 +-
admin.md | 2 +-
docs/sitewide-ux-overhaul-audit-20260711.md | 2 +
site/app/api/studio/inline-edit/route.ts | 610 ++++++++++++++++----
site/components/inline-edit-assistant.tsx | 114 +++-
site/components/inline-editable.tsx | 3 +-
site/lib/db.ts | 39 +-
site/lib/inline-edit-registry.test.mts | 123 ++++
site/lib/inline-edit-registry.ts | 369 ++++++++++++
site/lib/request-security.ts | 48 ++
site/package.json | 2 +-
woodsmith_DeepWiki_Merged_03222026.md | 2 +-
13 files changed, 1150 insertions(+), 171 deletions(-)
create mode 100644 site/lib/inline-edit-registry.test.mts
create mode 100644 site/lib/inline-edit-registry.ts
create mode 100644 site/lib/request-security.ts
diff --git a/PLANS.md b/PLANS.md
index e5157ca..4880f0e 100644
--- a/PLANS.md
+++ b/PLANS.md
@@ -12,7 +12,8 @@
| Structured Studio and public content | DONE / COMMITTED | `8b2a39b` adds structured footer/home services, typed public pricing/inquiry/review behavior, visual media selection, compact Page/Piece/Process editing, media roles, and public build records. |
| Conceptual proportional 3D preview | DONE / COMMITTED | `9e143da` adds route-local R3F templates, view/camera controls, exact dimensions, deterministic SVG/text fallbacks, demand rendering, and estimator tests. |
| Deterministic visual archive | IMPLEMENTED / LOCALLY VALIDATED; NAS RUNTIME VALIDATION PENDING | The QA slice adds protected bounded inventory, strict live-readonly and isolated snapshot-lab modes, pinned Playwright Docker, route/link reconciliation, required high-resolution profiles, overlapping page/nested-scroll tiles with seam checks, deep UI state capture, restricted/redacted HTML/PDF, checksums, diffs, locks, and retention. Local tests, typecheck, lint, build, Compose validation, and a disposable-data smoke archive pass. The local Docker daemon is unavailable, so candidate-image and NAS smoke/full evidence remain required before deployment. |
-| Remaining product work | PENDING | Typed atomic inline editing, secure resumable multi-step commissions, transactional batch media operations/rollback, derivative-only cleanup, accessibility/performance completion, final docs, archive evidence, release, rollback, and deployment gates remain active. |
+| Typed atomic inline editing | DONE | Added a typed field registry spanning scalar, list, link, relation, media, status, and structural settings fields; trusted-origin enforcement; complete-batch validation; one SQLite transaction; optimistic expected-value conflicts; admin audit records; reset, rollback, keyboard, focus, and one-step Undo behavior. Thirty-two application tests, typecheck, lint, production build, and a disposable authenticated Chromium save/undo/reset/conflict/origin smoke pass. Final-candidate archive evidence remains a release gate. |
+| Remaining product work | PENDING | Secure resumable multi-step commissions, transactional batch media operations/rollback, derivative-only cleanup, accessibility/performance completion, final docs, archive evidence, release, rollback, and deployment gates remain active. |
## 2026-07-05 Local-First Media AI, Header, and Persistence Pass
@@ -90,7 +91,7 @@ Verification for this pass: `npm run typecheck`, `npm run lint`, and `npm run bu
|----|--------|------------------|
| 1. Public navigation and Process/Shop separation | DONE | Removed Process from seeded and rendered primary navigation, removed the Process/behind-the-scenes block from Shop, preserved the dedicated `/process` archive and legacy Journal redirects, and added seed v5 exact-string cleanup for persisted legacy copy. |
| 2. Buyer email verification | DONE / CONFIGURATION REQUIRED | New accounts receive expiring verification tokens, unverified customer login is rejected, resend accepts an email without an authenticated session, SMTP acceptance is checked for the primary recipient, and UI errors distinguish configuration/authentication/sender/connection failures. Live dispatch still requires valid `SMTP_*` credentials and sender values. |
-| 3. Direct visual editing | DONE | Admin pencil controls intercept in capture phase and edit mapped public text/URLs in place without navigation or full-page refresh. Route changes clear stale editor state; structural edits use the explicit full-editor handoff. No raw JSON control is exposed. |
+| 3. Direct visual editing | DONE | Admin pencil controls intercept in capture phase and edit mapped public fields in place without navigation or full-page refresh. The typed registry drives the server allowlist; saves are atomic, audited, conflict-aware, origin-protected, and reversible. Route changes clear stale editor state; structural edits use the explicit visual full-editor handoff. No raw JSON control is exposed. |
| 4. Compact media assignment desk | DONE | Replaced the long media editor stack with a bounded three-pane workspace: utility tools, paged responsive thumbnail browser, and one active inspector. Added whole-library search, assignment/type filters, collapsed upload/automation/crop sections, and in-place action state. |
| 5. Media assignment integrity | DONE | Reviewed assignments now remove stale old-piece gallery membership and add new-piece membership. Unreviewed assignments remain private. Refresh/cluster tools run locally without AI credentials; the AI route requires admin authentication. |
| 6. Keyboard media workflow | DONE | J/K navigate visible media, F focuses whole-library search, P focuses piece assignment, U clears it, R toggles review state, S saves, Shift+S saves and advances, A approves and advances, and roving tab stops keep one active thumbnail keyboard-focusable. |
diff --git a/README.md b/README.md
index f0fe4cb..a8a8098 100644
--- a/README.md
+++ b/README.md
@@ -20,7 +20,7 @@ Woodsmith is a self-hosted Next.js application for the Beaman Woodworks company
- Optional server-side OpenAI image-model previews for custom work when `OPENAI_API_KEY` and `ENABLE_PUBLIC_AI_RENDERING=true` are configured; generated previews are stored back into `/app/pics`
- Buyer account pages for signup, login, password reset, profile editing, profile images, and account-linked projects
- Private Woodshop dashboard with focused workspace tabs for editing settings, pages, pieces, custom work types, users, media, process notes, projects, orders, reviews, and notifications through structured browser forms
-- Admin-only pencil controls that edit mapped public text and links in place, with an explicit full-editor link for structural work
+- Admin-only pencil controls backed by a typed field registry, atomic audited saves, conflict detection, URL/origin validation, reset/undo controls, and an explicit visual full-editor link for structural work
- A compact browser media desk with whole-library and AI-state filters, in-place paging and edits, explicit candidate review, guided local training actions, training-weighted visual ranking, upload/rename/delete safety, persistent assignment, crop/focal controls, and source credit against the writable NAS photo library
- Email notification queueing, Stripe invoice creation, and EasyPost shipping-label requests when the related environment variables are configured
- Full-size image lightbox support with zoom, pan, arrow navigation, plus `Esc` and close-button exit behavior
diff --git a/admin.md b/admin.md
index e7ed7f2..f35c56c 100644
--- a/admin.md
+++ b/admin.md
@@ -11,7 +11,7 @@ This guide covers the private Woodshop dashboard at `/studio`.
## Dashboard areas
-The dashboard opens on an overview workspace and lets you move between focused panels instead of loading every editor at once. Public pages also show admin-only pencil controls while you are signed in. Mapped text and links edit in place; **Full editor** opens the matching dashboard workspace for structural changes. The site header is intentionally compact and hides while scrolling down; scroll up, focus a header control, or move the pointer over the header area to reveal it again.
+The dashboard opens on an overview workspace and lets you move between focused panels instead of loading every editor at once. Public pages also show admin-only pencil controls while you are signed in. Mapped text and links edit in place; use `Ctrl+S` to save, `Esc` to exit, **Reset unsaved** to restore the value shown when editing opened, or **Undo last save** to reverse the most recent inline batch. **Full editor** opens the matching visual dashboard workspace for structural changes. Inline batches are validated from a typed field registry, saved in one SQLite transaction, checked for concurrent changes, and recorded in the admin edit audit. The site header is intentionally compact and hides while scrolling down; scroll up, focus a header control, or move the pointer over the header area to reveal it again.
### Settings
diff --git a/docs/sitewide-ux-overhaul-audit-20260711.md b/docs/sitewide-ux-overhaul-audit-20260711.md
index c9378b1..32581db 100644
--- a/docs/sitewide-ux-overhaul-audit-20260711.md
+++ b/docs/sitewide-ux-overhaul-audit-20260711.md
@@ -160,6 +160,8 @@ Production-like disposable migration result: 26 pieces retained, 193 normalized
7. Extend Media Desk batch operations, rename previews, roles/stages, and provider-safe derivatives.
8. Replace pseudo-3D with dynamically loaded R3F generators and retain an honest scale-drawing fallback.
9. Replace inline-edit conditionals with a typed registry and atomic audited patch application.
+
+Implemented after the baseline audit: the public editor now derives its allowlist from `site/lib/inline-edit-registry.ts`, validates complete batches before an outer SQLite transaction, records admin audit entries, rejects stale expected values and untrusted origins, and returns reversible patches for one-step Undo. The full visual editor remains the explicit path for structural changes.
10. Run static, disposable-database, browser, accessibility, performance, Docker, backup, candidate, rollback, and live deployment gates.
## Validation Matrix
diff --git a/site/app/api/studio/inline-edit/route.ts b/site/app/api/studio/inline-edit/route.ts
index 1d04e86..fb223d1 100644
--- a/site/app/api/studio/inline-edit/route.ts
+++ b/site/app/api/studio/inline-edit/route.ts
@@ -1,147 +1,501 @@
-import { NextResponse } from "next/server";
+import { randomUUID } from "node:crypto";
import { revalidatePath } from "next/cache";
-import { requireAdmin } from "@/lib/auth";
-import { getPage, getPiece, getPost, getSiteSettings, getUserByEmail, savePage, savePiece, savePost, saveSiteSettings, saveUserProfile } from "@/lib/db";
-
-type InlineMode = "update" | "add" | "cut";
-type InlinePatch = { resource?: string; id?: string; field?: string; index?: number | string | null; value?: string; mode?: InlineMode };
-type NavLink = { label: string; href: string };
-type SocialLink = { label: string; url: string };
-
-const settingsText = new Set(["brandName", "brandTagline", "siteAnnouncement", "builderName", "builderHeadline", "builderEmail", "developerName", "developerHeadline", "developerEmail", "supportEmail", "notificationForwardEmail", "repoLabel", "repoUrl"]);
-const homeText = new Set(["eyebrow", "title", "copy", "primaryCta.label", "primaryCta.href", "secondaryCta.label", "secondaryCta.href"]);
-const pageText = new Set(["title", "navLabel", "intro", "body"]);
-const pieceText = new Set(["title", "subtitle", "summary", "story", "availabilityLabel"]);
-const pieceLists = new Set(["details", "materials", "tags"]);
-const postText = new Set(["title", "excerpt", "body", "sourceLabel", "sourceUrl"]);
-const postLists = new Set(["tags"]);
-const userText = new Set(["displayName", "headline", "bio"]);
-
-const clean = (value: unknown) => String(value ?? "").replace(/\u00a0/g, " ").replace(/[ \t]+\n/g, "\n").trim();
-const cleanIndex = (value: unknown) => { const n = Number(value); return Number.isInteger(n) && n >= 0 ? n : null; };
-const responseError = (message: string, status = 400, details?: unknown) => NextResponse.json({ ok: false, message, details }, { status });
-
-function safeUrl(value: string) {
- const trimmed = value.trim();
- if (trimmed.startsWith("/") && !trimmed.startsWith("//")) return trimmed;
- if (trimmed.startsWith("mailto:")) {
- const address = trimmed.slice(7).split("?")[0] ?? "";
- if (/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(address)) return trimmed;
- throw new Error("mailto: links must contain a valid email address.");
+import { NextResponse } from "next/server";
+
+import { getCurrentUser } from "@/lib/auth";
+import { sanitizeCategoryIconSvg } from "@/lib/category-icons";
+import { normalizePieceCategories } from "@/lib/categories";
+import {
+ getCommissionType,
+ getMedia,
+ getPage,
+ getPiece,
+ getPost,
+ getProject,
+ getSiteSettings,
+ getUserByEmail,
+ recordAdminEditAudit,
+ saveCommissionType,
+ savePage,
+ savePiece,
+ savePost,
+ saveSiteSettings,
+ saveUserProfile,
+ updateProject,
+ withDatabaseTransaction,
+ type SiteSettings
+} from "@/lib/db";
+import {
+ editInlineList,
+ validateInlineEditPatch,
+ type InlineEditPatchInput,
+ type ValidatedInlineEditPatch
+} from "@/lib/inline-edit-registry";
+import { assertTrustedMutationOrigin, UntrustedMutationOriginError } from "@/lib/request-security";
+import { normalizeFooterConfiguration, normalizeHomeServices } from "@/lib/site-structure";
+
+type RevertPatch = {
+ resource: string;
+ id?: string;
+ field: string;
+ index?: number;
+ toIndex?: number;
+ value?: unknown;
+ expectedValue?: string;
+ mode?: "update" | "add" | "cut" | "move";
+};
+
+type DeepMutable = T extends (...args: never[]) => unknown
+ ? T
+ : T extends readonly (infer Item)[]
+ ? DeepMutable- []
+ : T extends object
+ ? { -readonly [Key in keyof T]: DeepMutable
}
+ : T;
+type MutableSiteSettings = DeepMutable;
+
+type AppliedPatch = {
+ resource: string;
+ id: string;
+ field: string;
+ index: number | null;
+ mode: string;
+ auditId: string;
+ revertPatches: RevertPatch[];
+};
+
+class InlineEditError extends Error {
+ constructor(message: string, readonly status: number, readonly details?: unknown) {
+ super(message);
}
- const parsed = new URL(trimmed);
- if (!["https:", "http:"].includes(parsed.protocol)) throw new Error("Inline URL must be https, http, mailto, or a root-relative /path.");
- return parsed.toString();
-}
-function normalize(field: string, value: string) { return field === "repoUrl" || field === "sourceUrl" || field.endsWith(".href") || field.endsWith(".url") ? safeUrl(value) : value; }
-function splitLines(value: string) { return value.split(/\r?\n|,/g).map((item) => item.trim()).filter(Boolean); }
-function editList(current: string[], mode: InlineMode, index: number | null, value: string) {
- const next = [...current];
- if (mode === "add") return [...next, ...splitLines(value)];
- if (mode === "cut") { if (index == null || index >= next.length) throw new Error("A valid item index is required for this list edit."); next.splice(index, 1); return next; }
- if (index == null) return splitLines(value);
- if (index >= next.length) throw new Error("Array index is outside the current editable range.");
- next[index] = value;
- return next.map((item) => item.trim()).filter(Boolean);
-}
-function editLabel(current: T[], mode: InlineMode, index: number | null, value: string) {
- const next = [...current];
- if (mode === "cut") { if (index == null || index >= next.length) throw new Error("A valid link index is required for this link edit."); next.splice(index, 1); return next; }
- if (mode === "add") return next;
- if (index == null || index >= next.length) throw new Error("A valid link index is required for this link edit.");
- next[index] = { ...next[index], label: value };
- return next;
-}
-function editNavUrl(current: NavLink[], index: number | null, value: string) {
- if (index == null || index >= current.length) throw new Error("A valid navigation index is required for this URL edit.");
- return current.map((item, i) => i === index ? { ...item, href: safeUrl(value) } : item);
-}
-function editSocialUrl(current: SocialLink[], index: number | null, value: string) {
- if (index == null || index >= current.length) throw new Error("A valid social-link index is required for this URL edit.");
- return current.map((item, i) => i === index ? { ...item, url: safeUrl(value) } : item);
-}
-function refresh(resource: string, id?: string) {
- revalidatePath("/", "layout");
- for (const route of ["/", "/portfolio", "/shop", "/process", "/about", "/contact", "/studio"]) revalidatePath(route);
- if (resource === "page" && id) revalidatePath(id === "home" ? "/" : `/${id}`);
- if (resource === "piece" && id) revalidatePath(`/portfolio/${id}`);
- if (resource === "post" && id) revalidatePath(`/process/${id}`);
}
-function updateHomeSection(section: Record, field: string, value: string) {
- if (field.startsWith("primaryCta.") || field.startsWith("secondaryCta.")) {
- const key = field.startsWith("primary") ? "primaryCta" : "secondaryCta";
- const prop = field.endsWith(".href") ? "href" : "label";
- const current = typeof section[key] === "object" && section[key] ? section[key] as Record : {};
- return { ...section, [key]: { ...current, [prop]: normalize(field, value) } };
+
+function responseError(message: string, status = 400, details?: unknown) {
+ return NextResponse.json({ ok: false, message, details }, { status });
+}
+
+function comparable(value: unknown) {
+ if (value == null) return "";
+ if (typeof value === "string") return value.replace(/\u00a0/g, " ").trim();
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
+ return JSON.stringify(value);
+}
+
+function assertExpected(patch: ValidatedInlineEditPatch, current: unknown) {
+ if (patch.expectedValue === undefined) return;
+ if (comparable(current) !== comparable(patch.expectedValue)) {
+ throw new InlineEditError(
+ `${patch.definition.label} changed after edit mode opened. Refresh and review the newer value before saving.`,
+ 409,
+ { resource: patch.resource, id: patch.id, field: patch.field, currentValue: current }
+ );
+ }
+}
+
+function patchIdentity(patch: ValidatedInlineEditPatch) {
+ return `${patch.resource}:${patch.id}:${patch.field}:${patch.index ?? "all"}`;
+}
+
+function directInverse(patch: ValidatedInlineEditPatch, before: unknown, after: unknown): RevertPatch[] {
+ return [{
+ resource: patch.resource,
+ ...(patch.id ? { id: patch.id } : {}),
+ field: patch.field,
+ ...(patch.index != null ? { index: patch.index } : {}),
+ value: before,
+ expectedValue: comparable(after),
+ mode: "update"
+ }];
+}
+
+function listValues(patch: ValidatedInlineEditPatch) {
+ return Array.isArray(patch.value) ? patch.value : [patch.value];
+}
+
+function applyList(current: readonly T[], patch: ValidatedInlineEditPatch, additions: readonly T[]) {
+ const selectedBefore = patch.index == null ? current : current[patch.index];
+ if (patch.mode !== "add") assertExpected(patch, selectedBefore);
+ const insertionIndex = patch.toIndex == null ? current.length : Math.min(patch.toIndex, current.length);
+ const next = editInlineList(current, patch, additions);
+ let revertPatches: RevertPatch[];
+ if (patch.mode === "add") {
+ revertPatches = additions.map(() => ({ resource: patch.resource, ...(patch.id ? { id: patch.id } : {}), field: patch.field, index: insertionIndex, mode: "cut" }));
+ } else if (patch.mode === "cut") {
+ revertPatches = [{ resource: patch.resource, ...(patch.id ? { id: patch.id } : {}), field: patch.field, value: selectedBefore, toIndex: patch.index ?? 0, mode: "add" }];
+ } else if (patch.mode === "move") {
+ revertPatches = [{ resource: patch.resource, ...(patch.id ? { id: patch.id } : {}), field: patch.field, index: patch.toIndex ?? 0, toIndex: patch.index ?? 0, mode: "move" }];
+ } else {
+ const selectedAfter = patch.index == null ? next : next[patch.index];
+ revertPatches = directInverse(patch, selectedBefore, selectedAfter);
}
- return { ...section, [field]: value };
-}
-
-function applyPatch(patch: InlinePatch) {
- const resource = clean(patch.resource), field = clean(patch.field), id = clean(patch.id), rawValue = clean(patch.value);
- const index = cleanIndex(patch.index), mode: InlineMode = patch.mode === "add" || patch.mode === "cut" ? patch.mode : "update";
- if (!resource || !field) throw new Error("Inline edit target is missing resource or field metadata.");
- if (!rawValue && mode !== "cut") throw new Error("Inline edit value cannot be empty.");
- const value = normalize(field, rawValue);
-
- if (resource === "settings") {
- const settings = getSiteSettings();
- if (field === "navigation") saveSiteSettings({ ...settings, navigation: editLabel([...settings.navigation] as unknown as NavLink[], mode, index, value) as unknown as typeof settings.navigation });
- else if (field === "navigation.href") { if (mode !== "update") throw new Error("Navigation URLs can only be updated inline."); saveSiteSettings({ ...settings, navigation: editNavUrl([...settings.navigation] as unknown as NavLink[], index, value) as unknown as typeof settings.navigation }); }
- else if (field === "socialLinks") saveSiteSettings({ ...settings, socialLinks: editLabel([...settings.socialLinks] as unknown as SocialLink[], mode, index, value) as unknown as typeof settings.socialLinks });
- else if (field === "socialLinks.url") { if (mode !== "update") throw new Error("Social link URLs can only be updated inline."); saveSiteSettings({ ...settings, socialLinks: editSocialUrl([...settings.socialLinks] as unknown as SocialLink[], index, value) as unknown as typeof settings.socialLinks }); }
- else { if (!settingsText.has(field) || mode !== "update") throw new Error(`Settings field '${field}' is not inline editable for '${mode}'.`); saveSiteSettings({ ...settings, [field]: value }); }
- refresh(resource);
- return { resource, field, index, mode };
+ return { next, revertPatches };
+}
+
+function audit(input: {
+ actorEmail: string;
+ requestId: string;
+ patch: ValidatedInlineEditPatch;
+ entityType: string;
+ entityKey: string;
+ before: unknown;
+ after: unknown;
+ revertPatches: RevertPatch[];
+}): AppliedPatch {
+ const auditId = recordAdminEditAudit({
+ actorEmail: input.actorEmail,
+ entityType: input.entityType,
+ entityKey: input.entityKey,
+ operation: `inline-${input.patch.mode}:${input.patch.field}`,
+ before: input.before,
+ after: input.after,
+ requestId: input.requestId
+ });
+ return {
+ resource: input.patch.resource,
+ id: input.patch.id,
+ field: input.patch.field,
+ index: input.patch.index,
+ mode: input.patch.mode,
+ auditId,
+ revertPatches: input.revertPatches
+ };
+}
+
+function applySettingsPatch(patch: ValidatedInlineEditPatch, actorEmail: string, requestId: string) {
+ const before = getSiteSettings();
+ const next = structuredClone(before) as MutableSiteSettings;
+ let currentValue: unknown;
+ let afterValue: unknown;
+ let revertPatches: RevertPatch[];
+
+ if (patch.field === "navigation" || patch.field === "socialLinks") {
+ const links = patch.field === "navigation"
+ ? next.navigation.map((item) => ({ label: item.label, url: item.href }))
+ : next.socialLinks.map((item) => ({ label: item.label, url: item.url }));
+ const additions = patch.mode === "update" && patch.index == null
+ ? (patch.value as Array<{ label: string; url: string }>)
+ : listValues(patch).map((value) => {
+ if (value && typeof value === "object") return value as { label: string; url: string };
+ const existing = patch.mode === "update" && patch.index != null ? links[patch.index] : null;
+ return { label: String(value), url: existing?.url ?? (patch.field === "navigation" ? "/" : "") };
+ });
+ if (patch.index != null && patch.mode !== "add") assertExpected(patch, links[patch.index]?.label);
+ const result = applyList(links, { ...patch, expectedValue: undefined }, additions);
+ currentValue = patch.index == null ? links : links[patch.index]?.label;
+ afterValue = patch.index == null ? result.next : result.next[patch.index]?.label;
+ revertPatches = patch.mode === "update" && patch.index != null
+ ? directInverse(patch, links[patch.index]?.label ?? "", result.next[patch.index]?.label ?? "")
+ : result.revertPatches;
+ if (patch.field === "navigation") next.navigation = result.next.map((item) => ({ label: item.label, href: item.url }));
+ else next.socialLinks = result.next;
+ } else if (patch.field === "navigation.href" || patch.field === "socialLinks.url") {
+ const links = patch.field === "navigation.href" ? next.navigation : next.socialLinks;
+ if (patch.index == null || patch.index >= links.length) throw new Error("The selected link is no longer available.");
+ const property = patch.field === "navigation.href" ? "href" : "url";
+ currentValue = String((links[patch.index] as unknown as Record)[property] ?? "");
+ assertExpected(patch, currentValue);
+ (links[patch.index] as unknown as Record)[property] = String(patch.value ?? "");
+ afterValue = patch.value;
+ revertPatches = directInverse(patch, currentValue, afterValue);
+ } else if (patch.field === "footer.introHeading" || patch.field === "footer.introBody") {
+ const property = patch.field === "footer.introHeading" ? "introHeading" : "introBody";
+ currentValue = next.footer[property];
+ assertExpected(patch, currentValue);
+ next.footer[property] = String(patch.value ?? "");
+ afterValue = next.footer[property];
+ revertPatches = directInverse(patch, currentValue, afterValue);
+ } else if (patch.field.startsWith("footer.group.")) {
+ const group = next.footer.groups.find((entry) => entry.id === patch.id);
+ if (!group) throw new Error("The selected footer group is no longer available.");
+ const property = patch.field.slice("footer.group.".length) as "heading" | "visible" | "order";
+ currentValue = group[property];
+ assertExpected(patch, currentValue);
+ (group as unknown as Record)[property] = patch.value;
+ afterValue = group[property];
+ revertPatches = directInverse(patch, currentValue, afterValue);
+ } else if (patch.field.startsWith("footer.item.")) {
+ const [groupId, itemId] = patch.id.split("/");
+ const item = next.footer.groups.find((entry) => entry.id === groupId)?.items.find((entry) => entry.id === itemId);
+ if (!item) throw new Error("The selected footer item is no longer available.");
+ const property = patch.field.slice("footer.item.".length) as "label" | "value" | "url" | "type" | "visible" | "newTab" | "order";
+ currentValue = item[property];
+ assertExpected(patch, currentValue);
+ (item as unknown as Record)[property] = patch.value;
+ afterValue = item[property];
+ revertPatches = directInverse(patch, currentValue, afterValue);
+ } else {
+ currentValue = (next as unknown as Record)[patch.field];
+ assertExpected(patch, currentValue);
+ (next as unknown as Record)[patch.field] = patch.value;
+ afterValue = patch.value;
+ revertPatches = directInverse(patch, currentValue, afterValue);
}
- if (resource === "homeSection") {
- if (!id) throw new Error("Home-section inline edit is missing a section key.");
- if (!homeText.has(field) || mode !== "update") throw new Error(`Home section field '${field}' is not inline editable for '${mode}'.`);
- const settings = getSiteSettings();
- const homeSections = settings.homeSections.map((section) => section.key === id ? updateHomeSection(section, field, value) : section) as unknown as typeof settings.homeSections;
- saveSiteSettings({ ...settings, homeSections });
- refresh(resource, id);
- return { resource, field, id, mode };
+ next.footer = normalizeFooterConfiguration(next.footer);
+ saveSiteSettings(next as SiteSettings);
+ return audit({ actorEmail, requestId, patch, entityType: "settings", entityKey: "site", before, after: next, revertPatches });
+}
+
+function applyHomeSectionPatch(patch: ValidatedInlineEditPatch, actorEmail: string, requestId: string) {
+ const settings = getSiteSettings();
+ const next = structuredClone(settings) as MutableSiteSettings;
+ const section = next.homeSections.find((entry) => entry.key === patch.id) as Record | undefined;
+ if (!section) throw new Error("The selected home section is no longer available.");
+ const [root, child] = patch.field.split(".");
+ const currentValue = child ? (section[root] as Record | undefined)?.[child] : section[root];
+ assertExpected(patch, currentValue);
+ if (child) section[root] = { ...(section[root] as Record ?? {}), [child]: patch.value };
+ else section[root] = patch.value;
+ const afterValue = child ? (section[root] as Record)[child] : section[root];
+ const revertPatches = directInverse(patch, currentValue, afterValue);
+ saveSiteSettings(next as SiteSettings);
+ return audit({ actorEmail, requestId, patch, entityType: "home-section", entityKey: patch.id, before: settings, after: next, revertPatches });
+}
+
+function applyHomeServicePatch(patch: ValidatedInlineEditPatch, actorEmail: string, requestId: string) {
+ const settings = getSiteSettings();
+ const next = structuredClone(settings) as MutableSiteSettings;
+ const service = next.homeServices.find((entry) => entry.id === patch.id);
+ if (!service) throw new Error("The selected home service is no longer available.");
+ const currentValue = (service as unknown as Record)[patch.field];
+ assertExpected(patch, currentValue);
+ (service as unknown as Record)[patch.field] = patch.value;
+ next.homeServices = normalizeHomeServices(next.homeServices);
+ const afterValue = (next.homeServices.find((entry) => entry.id === patch.id) as unknown as Record)[patch.field];
+ const revertPatches = directInverse(patch, currentValue, afterValue);
+ saveSiteSettings(next as SiteSettings);
+ return audit({ actorEmail, requestId, patch, entityType: "home-service", entityKey: patch.id, before: settings, after: next, revertPatches });
+}
+
+function applyPagePatch(patch: ValidatedInlineEditPatch, actorEmail: string, requestId: string) {
+ const page = getPage(patch.id);
+ if (!page) throw new Error(`Page '${patch.id}' was not found.`);
+ if (patch.field === "heroMediaPath" && patch.value && !getMedia(String(patch.value))) throw new Error("Select media that exists in the mounted library.");
+ const currentValue = (page as unknown as Record)[patch.field];
+ assertExpected(patch, currentValue);
+ const nextValue = patch.value === "" && patch.definition.nullable ? null : patch.value;
+ const next = { ...page, [patch.field]: nextValue };
+ savePage(next);
+ const revertPatches = directInverse(patch, currentValue, nextValue);
+ return audit({ actorEmail, requestId, patch, entityType: "page", entityKey: patch.id, before: page, after: next, revertPatches });
+}
+
+function applyPiecePatch(patch: ValidatedInlineEditPatch, actorEmail: string, requestId: string) {
+ const piece = getPiece(patch.id);
+ if (!piece) throw new Error(`Piece '${patch.id}' was not found.`);
+ const next = { ...piece };
+ let currentValue: unknown;
+ let afterValue: unknown;
+ let revertPatches: RevertPatch[];
+ if (["details", "materials", "tags", "mediaPaths"].includes(patch.field)) {
+ const current = [...((piece as unknown as Record)[patch.field] as string[])];
+ const additions = listValues(patch).map(String);
+ if (patch.field === "mediaPaths") for (const mediaPath of additions) if (mediaPath && !getMedia(mediaPath)) throw new Error(`Media '${mediaPath}' is not in the mounted library.`);
+ const result = applyList(current, patch, additions);
+ (next as unknown as Record)[patch.field] = result.next;
+ currentValue = patch.index == null ? current : current[patch.index];
+ afterValue = patch.index == null ? result.next : result.next[patch.index];
+ revertPatches = result.revertPatches;
+ } else {
+ currentValue = (piece as unknown as Record)[patch.field];
+ assertExpected(patch, currentValue);
+ if (patch.field === "category" && !getSiteSettings().pieceCategories.some((entry) => entry.key === patch.value)) throw new Error("Select an existing portfolio category.");
+ if (patch.field === "commissionTypeSlug" && patch.value && !getCommissionType(String(patch.value))) throw new Error("Select an existing commission type.");
+ (next as unknown as Record)[patch.field] = patch.value === "" && patch.definition.nullable ? null : patch.value;
+ afterValue = (next as unknown as Record)[patch.field];
+ revertPatches = directInverse(patch, currentValue, afterValue);
}
- if (resource === "page") {
- if (!id) throw new Error("Page inline edit is missing a slug.");
- if (!pageText.has(field) || mode !== "update") throw new Error(`Page field '${field}' is not inline editable for '${mode}'.`);
- const page = getPage(id); if (!page) throw new Error(`Page '${id}' was not found.`);
- savePage({ ...page, [field]: value }); refresh(resource, id); return { resource, field, id, mode };
+ savePiece(next);
+ return audit({ actorEmail, requestId, patch, entityType: "piece", entityKey: patch.id, before: piece, after: next, revertPatches });
+}
+
+function applyPostPatch(patch: ValidatedInlineEditPatch, actorEmail: string, requestId: string) {
+ const post = getPost(patch.id);
+ if (!post) throw new Error(`Process note '${patch.id}' was not found.`);
+ const next = { ...post };
+ let currentValue: unknown;
+ let afterValue: unknown;
+ let revertPatches: RevertPatch[];
+ if (patch.field === "tags") {
+ const result = applyList(post.tags, patch, listValues(patch).map(String));
+ next.tags = result.next;
+ currentValue = patch.index == null ? post.tags : post.tags[patch.index];
+ afterValue = patch.index == null ? next.tags : next.tags[patch.index];
+ revertPatches = result.revertPatches;
+ } else {
+ currentValue = (post as unknown as Record)[patch.field];
+ assertExpected(patch, currentValue);
+ if (patch.field === "coverMediaPath" && patch.value && !getMedia(String(patch.value))) throw new Error("Select media that exists in the mounted library.");
+ (next as unknown as Record)[patch.field] = patch.value === "" && patch.definition.nullable ? null : patch.value;
+ afterValue = (next as unknown as Record)[patch.field];
+ revertPatches = directInverse(patch, currentValue, afterValue);
}
- if (resource === "piece") {
- if (!id) throw new Error("Piece inline edit is missing a slug.");
- const piece = getPiece(id); if (!piece) throw new Error(`Piece '${id}' was not found.`);
- if (pieceLists.has(field)) { const current = field === "details" ? piece.details : field === "materials" ? piece.materials : piece.tags; savePiece({ ...piece, [field]: editList(current, mode, index, value) }); }
- else { if (!pieceText.has(field) || mode !== "update") throw new Error(`Piece field '${field}' is not inline editable for '${mode}'.`); savePiece({ ...piece, [field]: value }); }
- refresh(resource, id); return { resource, field, id, index, mode };
+ savePost(next);
+ return audit({ actorEmail, requestId, patch, entityType: "post", entityKey: patch.id, before: post, after: next, revertPatches });
+}
+
+function applyUserPatch(patch: ValidatedInlineEditPatch, actorEmail: string, requestId: string) {
+ const user = getUserByEmail(patch.id);
+ if (!user) throw new Error(`Profile '${patch.id}' was not found.`);
+ const next = { ...user };
+ let currentValue: unknown;
+ let afterValue: unknown;
+ let revertPatches: RevertPatch[];
+ if (patch.field === "links") {
+ const links = user.links.map((entry) => ({ label: entry.label, url: entry.url }));
+ const additions = patch.mode === "update" && patch.index == null
+ ? patch.value as Array<{ label: string; url: string }>
+ : listValues(patch).map((value) => {
+ if (value && typeof value === "object") return value as { label: string; url: string };
+ const existing = patch.mode === "update" && patch.index != null ? links[patch.index] : null;
+ return { label: String(value), url: existing?.url ?? "" };
+ });
+ if (patch.index != null && patch.mode !== "add") assertExpected(patch, links[patch.index]?.label);
+ const result = applyList(links, { ...patch, expectedValue: undefined }, additions);
+ next.links = result.next;
+ currentValue = patch.index == null ? links : links[patch.index]?.label;
+ afterValue = patch.index == null ? next.links : next.links[patch.index]?.label;
+ revertPatches = patch.mode === "update" && patch.index != null
+ ? directInverse(patch, links[patch.index]?.label ?? "", result.next[patch.index]?.label ?? "")
+ : result.revertPatches;
+ } else {
+ currentValue = (user as unknown as Record)[patch.field];
+ assertExpected(patch, currentValue);
+ if (patch.field === "avatarPath" && patch.value && !getMedia(String(patch.value))) throw new Error("Select media that exists in the mounted library.");
+ (next as unknown as Record)[patch.field] = patch.value === "" && patch.definition.nullable ? null : patch.value;
+ afterValue = (next as unknown as Record)[patch.field];
+ revertPatches = directInverse(patch, currentValue, afterValue);
}
- if (resource === "post") {
- if (!id) throw new Error("Post inline edit is missing a slug.");
- const post = getPost(id); if (!post) throw new Error(`Process note '${id}' was not found.`);
- if (postLists.has(field)) savePost({ ...post, tags: editList(post.tags, mode, index, value) });
- else { if (!postText.has(field) || mode !== "update") throw new Error(`Post field '${field}' is not inline editable for '${mode}'.`); savePost({ ...post, [field]: value }); }
- refresh(resource, id); return { resource, field, id, index, mode };
+ saveUserProfile(next);
+ return audit({ actorEmail, requestId, patch, entityType: "user", entityKey: patch.id, before: user, after: next, revertPatches });
+}
+
+function applyCategoryPatch(patch: ValidatedInlineEditPatch, actorEmail: string, requestId: string) {
+ const settings = getSiteSettings();
+ const next = structuredClone(settings) as MutableSiteSettings;
+ const category = next.pieceCategories.find((entry) => entry.key === patch.id);
+ if (!category) throw new Error(`Category '${patch.id}' was not found.`);
+ let currentValue: unknown;
+ let afterValue: unknown;
+ let revertPatches: RevertPatch[];
+ if (patch.field === "aliases") {
+ const result = applyList(category.aliases, patch, listValues(patch).map(String));
+ currentValue = patch.index == null ? category.aliases : category.aliases[patch.index];
+ category.aliases = result.next;
+ afterValue = patch.index == null ? category.aliases : category.aliases[patch.index];
+ revertPatches = result.revertPatches;
+ } else {
+ currentValue = (category as unknown as Record)[patch.field];
+ assertExpected(patch, currentValue);
+ (category as unknown as Record)[patch.field] = patch.field === "customIconSvg" ? sanitizeCategoryIconSvg(String(patch.value ?? "")) : patch.value;
+ afterValue = (category as unknown as Record)[patch.field];
+ revertPatches = directInverse(patch, currentValue, afterValue);
}
- if (resource === "user") {
- if (!id) throw new Error("Profile inline edit is missing an email.");
- if (!userText.has(field) || mode !== "update") throw new Error(`Profile field '${field}' is not inline editable for '${mode}'.`);
- const user = getUserByEmail(id); if (!user) throw new Error(`Profile '${id}' was not found.`);
- saveUserProfile({ ...user, [field]: value }); refresh(resource, id); return { resource, field, id, mode };
+ next.pieceCategories = normalizePieceCategories(next.pieceCategories);
+ saveSiteSettings(next as SiteSettings);
+ return audit({ actorEmail, requestId, patch, entityType: "category", entityKey: patch.id, before: settings, after: next, revertPatches });
+}
+
+function applyCommissionTypePatch(patch: ValidatedInlineEditPatch, actorEmail: string, requestId: string) {
+ const commissionType = getCommissionType(patch.id);
+ if (!commissionType) throw new Error(`Commission type '${patch.id}' was not found.`);
+ const next = { ...commissionType };
+ let currentValue: unknown;
+ let afterValue: unknown;
+ let revertPatches: RevertPatch[];
+ if (patch.field === "materialOptions") {
+ const result = applyList(commissionType.materialOptions, patch, listValues(patch).map(String));
+ next.materialOptions = result.next;
+ currentValue = patch.index == null ? commissionType.materialOptions : commissionType.materialOptions[patch.index];
+ afterValue = patch.index == null ? next.materialOptions : next.materialOptions[patch.index];
+ revertPatches = result.revertPatches;
+ } else {
+ currentValue = (commissionType as unknown as Record)[patch.field];
+ assertExpected(patch, currentValue);
+ (next as unknown as Record)[patch.field] = patch.value;
+ afterValue = patch.value;
+ revertPatches = directInverse(patch, currentValue, afterValue);
}
- throw new Error(`Resource '${resource}' is not inline editable.`);
+ saveCommissionType(next);
+ return audit({ actorEmail, requestId, patch, entityType: "commission-type", entityKey: patch.id, before: commissionType, after: next, revertPatches });
+}
+
+function applyProjectPatch(patch: ValidatedInlineEditPatch, actorEmail: string, requestId: string) {
+ const project = getProject(patch.id);
+ if (!project) throw new Error(`Project '${patch.id}' was not found.`);
+ const currentValue = (project as unknown as Record)[patch.field];
+ assertExpected(patch, currentValue);
+ const nextValue = patch.value === "" && patch.definition.nullable ? null : patch.value;
+ updateProject(patch.id, { [patch.field]: nextValue });
+ const after = getProject(patch.id);
+ const revertPatches = directInverse(patch, currentValue, nextValue);
+ return audit({ actorEmail, requestId, patch, entityType: "project", entityKey: patch.id, before: project, after, revertPatches });
+}
+
+function applyPatch(patch: ValidatedInlineEditPatch, actorEmail: string, requestId: string) {
+ if (patch.resource === "settings") return applySettingsPatch(patch, actorEmail, requestId);
+ if (patch.resource === "homeSection") return applyHomeSectionPatch(patch, actorEmail, requestId);
+ if (patch.resource === "homeService") return applyHomeServicePatch(patch, actorEmail, requestId);
+ if (patch.resource === "page") return applyPagePatch(patch, actorEmail, requestId);
+ if (patch.resource === "piece") return applyPiecePatch(patch, actorEmail, requestId);
+ if (patch.resource === "post") return applyPostPatch(patch, actorEmail, requestId);
+ if (patch.resource === "user") return applyUserPatch(patch, actorEmail, requestId);
+ if (patch.resource === "category") return applyCategoryPatch(patch, actorEmail, requestId);
+ if (patch.resource === "commissionType") return applyCommissionTypePatch(patch, actorEmail, requestId);
+ return applyProjectPatch(patch, actorEmail, requestId);
+}
+
+function refresh(resource: string, id: string) {
+ revalidatePath("/", "layout");
+ for (const route of ["/", "/portfolio", "/shop", "/process", "/about", "/contact", "/commissions", "/studio"]) revalidatePath(route);
+ if (resource === "page" && id) revalidatePath(id === "home" ? "/" : `/${id}`);
+ if (resource === "piece" && id) revalidatePath(`/portfolio/${id}`);
+ if (resource === "post" && id) revalidatePath(`/process/${id}`);
+ if (resource === "project" && id) revalidatePath(`/requests/${id}`);
}
export async function POST(request: Request) {
try {
- await requireAdmin();
- const body = await request.json().catch(() => null) as { patches?: InlinePatch[] } | InlinePatch | null;
- const patches = Array.isArray((body as { patches?: InlinePatch[] } | null)?.patches) ? (body as { patches: InlinePatch[] }).patches : body ? [body as InlinePatch] : [];
- if (patches.length === 0) return responseError("No inline edit patches were provided.");
- if (patches.length > 80) return responseError("Too many inline edit patches in one request.");
- return NextResponse.json({ ok: true, applied: patches.map(applyPatch) });
+ assertTrustedMutationOrigin(request);
+ const admin = await getCurrentUser();
+ if (!admin || admin.role !== "admin") return responseError("Admin authentication is required.", 401);
+ const body = await request.json().catch(() => null) as { patches?: InlineEditPatchInput[] } | InlineEditPatchInput | null;
+ const inputs = Array.isArray((body as { patches?: InlineEditPatchInput[] } | null)?.patches)
+ ? (body as { patches: InlineEditPatchInput[] }).patches
+ : body ? [body as InlineEditPatchInput] : [];
+ if (inputs.length === 0) return responseError("No inline edit patches were provided.");
+ if (inputs.length > 80) return responseError("Too many inline edit patches in one request.");
+
+ const errors: Array<{ index: number; resource: string; id: string; field: string; message: string }> = [];
+ const patches = inputs.flatMap((input, index) => {
+ try { return [validateInlineEditPatch(input)]; }
+ catch (error) {
+ errors.push({ index, resource: String(input.resource ?? ""), id: String(input.id ?? ""), field: String(input.field ?? ""), message: error instanceof Error ? error.message : "Patch validation failed." });
+ return [];
+ }
+ });
+ if (errors.length) return responseError("Inline edit validation failed.", 400, errors);
+ const identities = patches.map(patchIdentity);
+ if (new Set(identities).size !== identities.length) return responseError("A field may be patched only once per atomic request.");
+
+ const requestId = randomUUID();
+ const applied = withDatabaseTransaction(() => patches.map((patch) => {
+ try {
+ return applyPatch(patch, admin.email, requestId);
+ } catch (error) {
+ if (error instanceof InlineEditError) throw error;
+ throw new InlineEditError(
+ error instanceof Error ? error.message : "Inline edit field could not be applied.",
+ 400,
+ { resource: patch.resource, id: patch.id, field: patch.field }
+ );
+ }
+ }));
+ for (const result of applied) refresh(result.resource, result.id);
+ return NextResponse.json({ ok: true, requestId, applied, revertPatches: applied.flatMap((result) => result.revertPatches).reverse() });
} catch (error) {
+ if (error instanceof UntrustedMutationOriginError) return responseError(error.message, error.status);
+ if (error instanceof InlineEditError) return responseError(error.message, error.status, error.details);
return responseError(error instanceof Error ? error.message : "Inline edit save failed.", 500);
}
}
diff --git a/site/components/inline-edit-assistant.tsx b/site/components/inline-edit-assistant.tsx
index e193cfa..6e1d50d 100644
--- a/site/components/inline-edit-assistant.tsx
+++ b/site/components/inline-edit-assistant.tsx
@@ -1,10 +1,10 @@
"use client";
-import { useEffect, useMemo, useState } from "react";
+import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
import { usePathname } from "next/navigation";
-type InlineMode = "update" | "add" | "cut";
-type EditablePatch = { resource: string; id?: string; field: string; index?: number; value: string; mode?: InlineMode };
+type InlineMode = "update" | "add" | "cut" | "move";
+type EditablePatch = { resource: string; id?: string; field: string; index?: number; toIndex?: number; value?: unknown; expectedValue?: string; mode?: InlineMode };
type EditableSnapshot = EditablePatch & { text: string };
type UrlDraft = { resource: string; id?: string; field: string; index?: number; value: string };
@@ -28,13 +28,21 @@ function patchFromElement(element: HTMLElement, mode: InlineMode = "update"): Ed
const value = element.textContent?.replace(/\u00a0/g, " ").trim() ?? "";
if (!resource || !field || (!value && mode !== "cut")) return null;
const index = getInlineIndex(element);
- return { resource, field, ...(element.dataset.inlineEditId ? { id: element.dataset.inlineEditId } : {}), ...(index !== undefined ? { index } : {}), value, ...(mode !== "update" ? { mode } : {}) };
+ return {
+ resource,
+ field,
+ ...(element.dataset.inlineEditId ? { id: element.dataset.inlineEditId } : {}),
+ ...(index !== undefined ? { index } : {}),
+ value,
+ ...(mode !== "add" ? { expectedValue: element.dataset.inlineEditOriginal ?? value } : {}),
+ ...(mode !== "update" ? { mode } : {})
+ };
}
function collectEditableText(root: ParentNode): EditableSnapshot[] {
return editableElements(root).flatMap((element) => {
const patch = patchFromElement(element);
- return patch ? [{ ...patch, text: patch.value }] : [];
+ return patch ? [{ ...patch, text: String(patch.value ?? "") }] : [];
});
}
@@ -60,7 +68,8 @@ function setEditableState(root: ParentNode, enabled: boolean) {
element.spellcheck = enabled;
element.classList.toggle("inline-editable-active", enabled);
element.classList.remove("inline-editable-active-selected");
- element.dataset.inlineEditOriginal = enabled ? element.textContent?.trim() ?? "" : element.dataset.inlineEditOriginal ?? "";
+ if (enabled) element.dataset.inlineEditOriginal = element.textContent?.trim() ?? "";
+ else delete element.dataset.inlineEditOriginal;
if (element instanceof HTMLAnchorElement) {
if (enabled) element.addEventListener("click", preventAnchorNavigation, true);
else element.removeEventListener("click", preventAnchorNavigation, true);
@@ -93,7 +102,9 @@ export function InlineEditAssistant() {
const [addValue, setAddValue] = useState("");
const [urlDraft, setUrlDraft] = useState(null);
const [urlError, setUrlError] = useState(null);
- const help = useMemo(() => "Select highlighted text or a mapped link, then save text edits or use Edit URL for mapped destinations. Add/remove is limited to mapped arrays.", []);
+ const [lastRevertPatches, setLastRevertPatches] = useState([]);
+ const focusReturnRef = useRef(null);
+ const help = useMemo(() => "Select highlighted text or a mapped link. Use Ctrl+S to save and Esc to exit. Structural fields open in the visual full editor.", []);
useEffect(() => {
document.querySelectorAll("section[data-inline-editing='true']").forEach((section) => {
@@ -124,6 +135,7 @@ export function InlineEditAssistant() {
setEditableCount(count);
setSelectedElement(null);
setMessage(`Inline edit mode enabled for ${count} mapped field${count === 1 ? "" : "s"}. Select highlighted text or a mapped link before URL edits.`);
+ focusReturnRef.current = editLink;
section.dataset.inlineEditing = "true";
setEditableState(section, true);
section.scrollIntoView({ block: "start", behavior: "smooth" });
@@ -143,27 +155,68 @@ export function InlineEditAssistant() {
return () => { document.removeEventListener("click", handleClick, true); document.removeEventListener("click", handleSelect); };
}, [editingSection]);
+ const handleKeyboard = useEffectEvent((event: globalThis.KeyboardEvent) => {
+ if (!active) return;
+ if (event.key === "Escape") {
+ event.preventDefault();
+ cancelInlineEditing();
+ } else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") {
+ event.preventDefault();
+ void saveInlineEdits();
+ }
+ });
+
+ useEffect(() => {
+ if (!active) return;
+ document.addEventListener("keydown", handleKeyboard);
+ return () => document.removeEventListener("keydown", handleKeyboard);
+ }, [active]);
+
if (!active) return null;
- async function sendPatches(patches: EditablePatch[], successMessage: string, reload = false) {
+ function restoreOriginalText(close = false) {
+ const root = editingSection ?? document.querySelector("section[data-inline-editing='true']");
+ root?.querySelectorAll(".inline-editable-active").forEach((element) => {
+ if (element.dataset.inlineEditOriginal != null) element.textContent = element.dataset.inlineEditOriginal;
+ if (close) {
+ element.contentEditable = "false";
+ element.classList.remove("inline-editable-active", "inline-editable-active-selected");
+ delete element.dataset.inlineEditOriginal;
+ element.removeEventListener("click", preventAnchorNavigation, true);
+ }
+ });
+ return root;
+ }
+
+ async function sendPatches(patches: EditablePatch[], successMessage: string, options: { reload?: boolean; rollbackOnFailure?: boolean } = {}) {
if (patches.length === 0) { setMessage("No changes to save."); return false; }
setSaving(true);
setMessage("Saving mapped inline edits...");
try {
const response = await fetch("/api/studio/inline-edit", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ patches }) });
- const payload = await response.json().catch(() => null) as { ok?: boolean; message?: string } | null;
- if (!response.ok || !payload?.ok) { setMessage(payload?.message || `Inline save failed with HTTP ${response.status}.`); return false; }
+ const payload = await response.json().catch(() => null) as { ok?: boolean; message?: string; details?: Array<{ message?: string }>; revertPatches?: EditablePatch[] } | null;
+ if (!response.ok || !payload?.ok) {
+ if (options.rollbackOnFailure) restoreOriginalText();
+ const detail = Array.isArray(payload?.details) ? payload.details.map((entry) => entry.message).filter(Boolean).join(" ") : "";
+ setMessage(`${payload?.message || `Inline save failed with HTTP ${response.status}.`}${detail ? ` ${detail}` : ""}${options.rollbackOnFailure ? " Unsaved text was restored." : ""}`);
+ return false;
+ }
setMessage(successMessage);
+ setLastRevertPatches(payload.revertPatches ?? []);
const root = editingSection ?? document.querySelector("section[data-inline-editing='true']");
if (root) { setEditableState(root, false); delete root.dataset.inlineEditing; }
- if (reload) window.setTimeout(() => window.location.reload(), 350);
+ if (options.reload) window.setTimeout(() => window.location.reload(), 350);
else {
- setActive(false);
- setEditingSection(null);
+ if (root) {
+ root.dataset.inlineEditing = "true";
+ setEditableState(root, true);
+ }
setSelectedElement(null);
+ window.setTimeout(() => root?.querySelector(".inline-editable-active")?.focus(), 0);
}
return true;
} catch (error) {
+ if (options.rollbackOnFailure) restoreOriginalText();
setMessage(error instanceof Error ? error.message : "Inline save failed.");
return false;
} finally { setSaving(false); }
@@ -173,20 +226,20 @@ export function InlineEditAssistant() {
const root = editingSection ?? document.querySelector("section[data-inline-editing='true']");
if (!root) { setMessage("No active inline-edit section was found."); return; }
const patches = collectChangedText(root);
- await sendPatches(patches, `Saved ${patches.length} inline edit${patches.length === 1 ? "" : "s"}.`);
+ await sendPatches(patches, `Saved ${patches.length} inline edit${patches.length === 1 ? "" : "s"}.`, { rollbackOnFailure: true });
}
async function addInlineItem() {
const patch = selectedElement ? patchFromElement(selectedElement, "add") : null;
if (!patch) { setMessage("Select a mapped list item first."); return; }
if (!addValue.trim()) { setMessage("Type the new item before adding it."); return; }
- await sendPatches([{ ...patch, value: addValue.trim(), mode: "add" }], "Added mapped inline item. Refreshing current view...", true);
+ await sendPatches([{ ...patch, value: addValue.trim(), mode: "add" }], "Added mapped inline item. Refreshing current view...", { reload: true });
}
async function removeSelectedItem() {
const patch = selectedElement ? patchFromElement(selectedElement, "cut") : null;
if (!patch || patch.index == null) { setMessage("Select a mapped list item with an index before removing it."); return; }
- await sendPatches([{ ...patch, value: "", mode: "cut" }], "Removed mapped inline item. Refreshing current view...", true);
+ await sendPatches([{ ...patch, value: "", mode: "cut" }], "Removed mapped inline item. Refreshing current view...", { reload: true });
}
function openUrlEditor() {
@@ -206,33 +259,40 @@ export function InlineEditAssistant() {
const validation = validateUrl(urlDraft.value);
if (!validation.ok) { setUrlError(validation.message); return; }
const element = selectedElement;
- const saved = await sendPatches([{ resource: urlDraft.resource, field: urlDraft.field, ...(urlDraft.id ? { id: urlDraft.id } : {}), ...(urlDraft.index != null ? { index: urlDraft.index } : {}), value: validation.value }], "Saved mapped URL.");
+ const saved = await sendPatches([{ resource: urlDraft.resource, field: urlDraft.field, ...(urlDraft.id ? { id: urlDraft.id } : {}), ...(urlDraft.index != null ? { index: urlDraft.index } : {}), value: validation.value, expectedValue: element instanceof HTMLAnchorElement ? element.getAttribute("href") ?? "" : "" }], "Saved mapped URL.");
if (saved && element instanceof HTMLAnchorElement) element.href = validation.value;
}
+ function resetInlineChanges() {
+ restoreOriginalText();
+ setMessage("Unsaved text was reset to the value shown when edit mode opened.");
+ }
+
+ async function undoLastSave() {
+ if (lastRevertPatches.length === 0) { setMessage("There is no inline save to undo in this session."); return; }
+ const reverted = await sendPatches(lastRevertPatches, "Reverted the last inline save. Refreshing current view...", { reload: true });
+ if (reverted) setLastRevertPatches([]);
+ }
+
function cancelInlineEditing() {
- const root = editingSection ?? document.querySelector("section[data-inline-editing='true']");
- root?.querySelectorAll(".inline-editable-active").forEach((element) => {
- if (element.dataset.inlineEditOriginal != null) element.textContent = element.dataset.inlineEditOriginal;
- element.contentEditable = "false";
- element.classList.remove("inline-editable-active", "inline-editable-active-selected");
- delete element.dataset.inlineEditOriginal;
- element.removeEventListener("click", preventAnchorNavigation, true);
- });
+ restoreOriginalText(true);
document.querySelectorAll("section[data-inline-editing='true']").forEach((section) => delete section.dataset.inlineEditing);
setActive(false); setEditingSection(null); setSelectedElement(null); setUrlDraft(null);
+ window.setTimeout(() => focusReturnRef.current?.focus(), 0);
}
return (
<>
-
-
Inline editing {message}
{help}
+
+
Inline editing {message}
{help}
Add item setAddValue(event.target.value)} placeholder="New detail, tag, material, or divider" />
{saving ? "Saving..." : "Save inline edits"}
Add item
Remove selected
Edit URL
+
Reset unsaved
+
Undo last save
{advancedHref ?
Full editor : null}
Cancel
diff --git a/site/components/inline-editable.tsx b/site/components/inline-editable.tsx
index 458b53c..f5f00c5 100644
--- a/site/components/inline-editable.tsx
+++ b/site/components/inline-editable.tsx
@@ -1,6 +1,5 @@
import { createElement, type ElementType, type HTMLAttributes, type ReactNode } from "react";
-
-export type InlineEditResource = "settings" | "homeSection" | "page" | "piece" | "post" | "user";
+import type { InlineEditResource } from "@/lib/inline-edit-registry";
export type InlineEditTarget = {
resource: InlineEditResource;
diff --git a/site/lib/db.ts b/site/lib/db.ts
index 7db7113..c047dfd 100644
--- a/site/lib/db.ts
+++ b/site/lib/db.ts
@@ -8,10 +8,12 @@ import {
seedPieces,
seedPosts,
seedProfiles,
- siteSettingsSeed
+ siteSettingsSeed,
+ type FooterConfiguration,
+ type HomeServiceDefinition
} from "./seed.ts";
import { scanMediaLibrary } from "./media.ts";
-import { normalizePieceCategories } from "./categories.ts";
+import { normalizePieceCategories, type PieceCategoryDefinition } from "./categories.ts";
import { safeFooterConfiguration, safeHomeServices } from "./site-structure.ts";
import { applySchemaMigrations } from "./database-migrations.ts";
import {
@@ -32,7 +34,28 @@ export type PieceStatus = "inventory" | "commission" | "archive";
export type ProjectKind = "commission" | "purchase";
export type ProjectVisibility = "public" | "private";
-export type SiteSettings = typeof siteSettingsSeed;
+type PersistedSettingValue
= T extends string
+ ? string
+ : T extends number
+ ? number
+ : T extends boolean
+ ? boolean
+ : T extends readonly (infer Item)[]
+ ? PersistedSettingValue- []
+ : T extends object
+ ? { -readonly [Key in keyof T]: PersistedSettingValue
}
+ : T;
+
+type WidenedSiteSettings = PersistedSettingValue>;
+export type SiteSettings = WidenedSiteSettings & {
+ footer: FooterConfiguration;
+ homeServices: HomeServiceDefinition[];
+ pieceCategories: PieceCategoryDefinition[];
+};
+
+function seededSiteSettings() {
+ return structuredClone(siteSettingsSeed) as unknown as SiteSettings;
+}
export type UserRecord = {
id: string;
@@ -1083,7 +1106,7 @@ function seedDefaultContent(db: DatabaseSync) {
// Older releases rewrote seeded records during this upgrade, which could
// erase Studio edits on a rebuilt container. Keep the version marker and
// backfill only missing settings arrays without replacing live records.
- const currentSite = getSetting("site", siteSettingsSeed);
+ const currentSite = getSetting("site", seededSiteSettings());
upsertSetting(db, "site", {
...siteSettingsSeed,
...currentSite,
@@ -1096,7 +1119,7 @@ function seedDefaultContent(db: DatabaseSync) {
}
if (seededVersion > 0 && seededVersion < 4) {
- const currentSite = getSetting("site", siteSettingsSeed);
+ const currentSite = getSetting("site", seededSiteSettings());
const nextSite: SiteSettings = {
...currentSite,
developerName: siteSettingsSeed.developerName,
@@ -1190,7 +1213,7 @@ function seedDefaultContent(db: DatabaseSync) {
.run(replacement.to, timestamp, replacement.slug, replacement.from);
}
- const currentSite = getSetting("site", siteSettingsSeed);
+ const currentSite = getSetting("site", seededSiteSettings());
const legacyNavigationHrefs = new Set(["/process", "/shop#process"]);
const navigation = currentSite.navigation.filter((item) => !legacyNavigationHrefs.has(String(item.href))) as unknown as SiteSettings["navigation"];
if (navigation.length !== currentSite.navigation.length) {
@@ -1564,8 +1587,8 @@ function mapNotification(row: Record): NotificationRecord {
sentAt: row.sentAt ? String(row.sentAt) : null
};
}
-export function getSiteSettings() {
- const fallback = { ...siteSettingsSeed };
+export function getSiteSettings(): SiteSettings {
+ const fallback = seededSiteSettings();
const stored = getSetting("site", fallback);
const activeSettings = { ...(stored as SiteSettings & { pieceDividerNames?: unknown }) };
delete activeSettings.pieceDividerNames;
diff --git a/site/lib/inline-edit-registry.test.mts b/site/lib/inline-edit-registry.test.mts
new file mode 100644
index 0000000..23b1b53
--- /dev/null
+++ b/site/lib/inline-edit-registry.test.mts
@@ -0,0 +1,123 @@
+import assert from "node:assert/strict";
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import test from "node:test";
+
+import {
+ INLINE_EDIT_DEFINITIONS,
+ editInlineList,
+ getInlineEditDefinition,
+ normalizeInlineEditUrl,
+ validateInlineEditPatch
+} from "./inline-edit-registry.ts";
+import { mutationOriginAllowed } from "./request-security.ts";
+
+test("typed inline registry allows declared fields and denies arbitrary paths", () => {
+ assert.equal(getInlineEditDefinition("piece", "title")?.kind, "text");
+ assert.equal(getInlineEditDefinition("piece", "constructor.prototype") ?? null, null);
+ assert.throws(
+ () => validateInlineEditPatch({ resource: "piece", id: "pastry-table", field: "metadata.admin", value: "unsafe" }),
+ /not inline editable/
+ );
+ const kinds = new Set(INLINE_EDIT_DEFINITIONS.map((definition) => definition.kind));
+ for (const kind of ["text", "multiline", "rich-text", "url", "email", "number", "currency", "boolean", "date", "enum", "list", "link-list", "media-relation", "relation"]) {
+ assert.equal(kinds.has(kind as never), true, `${kind} definition is missing`);
+ }
+});
+
+test("URL normalization accepts safe destinations and rejects executable schemes", () => {
+ assert.equal(normalizeInlineEditUrl("/portfolio"), "/portfolio");
+ assert.equal(normalizeInlineEditUrl("mailto:woodsmithbb@proton.me"), "mailto:woodsmithbb@proton.me");
+ assert.equal(normalizeInlineEditUrl("https://woodmat.ch/about"), "https://woodmat.ch/about");
+ assert.throws(() => normalizeInlineEditUrl("javascript:alert(1)"), /scheme/);
+ assert.throws(() => normalizeInlineEditUrl("//malicious.example"), /root-relative/);
+});
+
+test("optional values clear while required fields and traversal media fail", () => {
+ const cleared = validateInlineEditPatch({ resource: "piece", id: "pastry-table", field: "subtitle", value: "" });
+ assert.equal(cleared.value, "");
+ assert.throws(() => validateInlineEditPatch({ resource: "piece", id: "pastry-table", field: "title", value: "" }), /cannot be empty/);
+ assert.throws(() => validateInlineEditPatch({ resource: "piece", id: "pastry-table", field: "mediaPaths", value: "../secret.jpg", mode: "add" }), /library-relative/);
+});
+
+test("list add, remove, replace, and reorder operations are deterministic", () => {
+ const add = validateInlineEditPatch({ resource: "piece", id: "pastry-table", field: "details", value: "Third", mode: "add", toIndex: 1 });
+ assert.deepEqual(editInlineList(["First", "Second"], add, add.value as string[]), ["First", "Third", "Second"]);
+ const cut = validateInlineEditPatch({ resource: "piece", id: "pastry-table", field: "details", value: "", mode: "cut", index: 1 });
+ assert.deepEqual(editInlineList(["First", "Third", "Second"], cut, []), ["First", "Second"]);
+ const move = validateInlineEditPatch({ resource: "piece", id: "pastry-table", field: "details", value: "", mode: "move", index: 2, toIndex: 0 });
+ assert.deepEqual(editInlineList(["First", "Second", "Third"], move, []), ["Third", "First", "Second"]);
+});
+
+test("mutation origin policy accepts only same-site or explicitly configured origins", () => {
+ assert.equal(mutationOriginAllowed({ requestUrl: "http://127.0.0.1:3002/api/studio/inline-edit", origin: "http://127.0.0.1:3002" }), true);
+ assert.equal(mutationOriginAllowed({ requestUrl: "http://woodsmith:3002/api/studio/inline-edit", origin: "https://woodmat.ch", forwardedHost: "woodmat.ch", forwardedProto: "https" }), true);
+ assert.equal(mutationOriginAllowed({ requestUrl: "http://woodsmith:3002/api/studio/inline-edit", origin: "https://evil.example", configuredOrigins: ["https://woodmat.ch"] }), false);
+ assert.equal(mutationOriginAllowed({ requestUrl: "http://127.0.0.1:3002/api/studio/inline-edit", origin: null }), false);
+});
+
+test("SQLite rollback, media synchronization, and footer URL persistence remain atomic", async () => {
+ const root = mkdtempSync(path.join(tmpdir(), "woodsmith-inline-edit-"));
+ const dataRoot = path.join(root, "data");
+ const mediaRoot = path.join(root, "media");
+ const relativePath = "Furniture/inline-fixture/hero.jpg";
+ mkdirSync(path.join(mediaRoot, "Furniture", "inline-fixture"), { recursive: true });
+ writeFileSync(path.join(mediaRoot, ...relativePath.split("/")), Buffer.from("fixture"));
+ process.env.NODE_ENV = "test";
+ process.env.DATA_ROOT = dataRoot;
+ process.env.MEDIA_ROOT = mediaRoot;
+ const db = await import("./db.ts");
+ try {
+ db.getRuntimePersistenceStatus();
+ db.refreshMediaLibrary();
+ const original = db.getPage("about");
+ assert.ok(original);
+ assert.throws(() => db.withDatabaseTransaction(() => {
+ db.savePage({ ...original, title: "Must roll back" });
+ throw new Error("later patch failed");
+ }), /later patch failed/);
+ assert.equal(db.getPage("about")?.title, original.title);
+
+ db.savePiece({
+ slug: "inline-fixture",
+ title: "Inline fixture",
+ subtitle: "",
+ category: "objects",
+ status: "archive",
+ publicationStatus: "draft",
+ availabilityLabel: "",
+ summary: "",
+ story: "",
+ details: [],
+ tags: [],
+ materials: [],
+ dimensions: null,
+ priceCents: null,
+ priceMode: "not-listed",
+ inquiryMode: "disabled",
+ reviewsMode: "hidden",
+ inventoryCount: 0,
+ leadTimeDays: 0,
+ mediaPaths: [],
+ featuredRank: 999,
+ ownerEmail: null,
+ metadata: { verifiedMedia: true }
+ });
+ const mediaPatch = validateInlineEditPatch({ resource: "piece", id: "inline-fixture", field: "mediaPaths", value: [relativePath] });
+ db.savePiece({ ...db.getPiece("inline-fixture")!, mediaPaths: mediaPatch.value as string[] });
+ assert.deepEqual(db.getPiece("inline-fixture")?.mediaPaths, [relativePath]);
+ assert.deepEqual(db.listPieceMediaLinks("inline-fixture").map((link) => link.relativePath), [relativePath]);
+
+ const footerPatch = validateInlineEditPatch({ resource: "settings", id: "links/repository", field: "footer.item.url", value: "https://woodmat.ch/source" });
+ const settings = db.getSiteSettings();
+ const footerItem = settings.footer.groups.find((group) => group.id === "links")?.items.find((item) => item.id === "repository");
+ assert.ok(footerItem);
+ footerItem.url = String(footerPatch.value);
+ db.saveSiteSettings(settings);
+ assert.equal(db.getSiteSettings().footer.groups.find((group) => group.id === "links")?.items.find((item) => item.id === "repository")?.url, "https://woodmat.ch/source");
+ } finally {
+ db.closeDatabaseForTests();
+ rmSync(root, { recursive: true, force: true });
+ }
+});
diff --git a/site/lib/inline-edit-registry.ts b/site/lib/inline-edit-registry.ts
new file mode 100644
index 0000000..91c5a8e
--- /dev/null
+++ b/site/lib/inline-edit-registry.ts
@@ -0,0 +1,369 @@
+export type InlineEditResource =
+ | "settings"
+ | "homeSection"
+ | "homeService"
+ | "page"
+ | "piece"
+ | "post"
+ | "user"
+ | "category"
+ | "commissionType"
+ | "project";
+
+export type InlineEditMode = "update" | "add" | "cut" | "move";
+export type InlineEditKind =
+ | "text"
+ | "multiline"
+ | "rich-text"
+ | "url"
+ | "email"
+ | "number"
+ | "currency"
+ | "boolean"
+ | "date"
+ | "enum"
+ | "list"
+ | "link-list"
+ | "media-relation"
+ | "relation";
+
+export type InlineEditPatchInput = {
+ resource?: unknown;
+ id?: unknown;
+ field?: unknown;
+ index?: unknown;
+ toIndex?: unknown;
+ value?: unknown;
+ expectedValue?: unknown;
+ mode?: unknown;
+};
+
+export type InlineEditDefinition = {
+ resource: InlineEditResource;
+ field: string;
+ kind: InlineEditKind;
+ label: string;
+ required?: boolean;
+ nullable?: boolean;
+ maxLength?: number;
+ minimum?: number;
+ maximum?: number;
+ values?: readonly string[];
+ modes?: readonly InlineEditMode[];
+};
+
+export type ValidatedInlineEditPatch = {
+ resource: InlineEditResource;
+ id: string;
+ field: string;
+ index: number | null;
+ toIndex: number | null;
+ value: string | number | boolean | null | string[] | { label: string; url: string } | Array<{ label: string; url: string }>;
+ expectedValue?: string;
+ mode: InlineEditMode;
+ definition: InlineEditDefinition;
+};
+
+const allModes = ["update", "add", "cut", "move"] as const;
+const updateOnly = ["update"] as const;
+const listModes = allModes;
+
+function definitions(resource: InlineEditResource, entries: Array>) {
+ return entries.map((entry) => ({ resource, ...entry }));
+}
+
+export const INLINE_EDIT_DEFINITIONS: readonly InlineEditDefinition[] = [
+ ...definitions("settings", [
+ { field: "brandName", kind: "text", label: "Brand name", required: true, maxLength: 100, modes: updateOnly },
+ { field: "brandTagline", kind: "multiline", label: "Brand description", required: true, maxLength: 300, modes: updateOnly },
+ { field: "siteAnnouncement", kind: "multiline", label: "Site announcement", nullable: true, maxLength: 500, modes: updateOnly },
+ { field: "builderName", kind: "text", label: "Builder name", required: true, maxLength: 100, modes: updateOnly },
+ { field: "builderHeadline", kind: "text", label: "Builder title", nullable: true, maxLength: 120, modes: updateOnly },
+ { field: "builderEmail", kind: "email", label: "Builder email", required: true, modes: updateOnly },
+ { field: "developerName", kind: "text", label: "Developer name", required: true, maxLength: 100, modes: updateOnly },
+ { field: "developerHeadline", kind: "text", label: "Developer title", nullable: true, maxLength: 120, modes: updateOnly },
+ { field: "developerEmail", kind: "email", label: "Developer email", required: true, modes: updateOnly },
+ { field: "supportEmail", kind: "email", label: "Support email", required: true, modes: updateOnly },
+ { field: "notificationForwardEmail", kind: "email", label: "Notification forwarding email", nullable: true, modes: updateOnly },
+ { field: "repoLabel", kind: "text", label: "Repository label", nullable: true, maxLength: 100, modes: updateOnly },
+ { field: "repoUrl", kind: "url", label: "Repository URL", nullable: true, modes: updateOnly },
+ { field: "navigation", kind: "link-list", label: "Navigation item", required: true, maxLength: 80, modes: listModes },
+ { field: "navigation.href", kind: "url", label: "Navigation destination", required: true, modes: updateOnly },
+ { field: "socialLinks", kind: "link-list", label: "Social link", maxLength: 80, modes: listModes },
+ { field: "socialLinks.url", kind: "url", label: "Social-link destination", nullable: true, modes: updateOnly },
+ { field: "footer.introHeading", kind: "text", label: "Footer heading", required: true, maxLength: 100, modes: updateOnly },
+ { field: "footer.introBody", kind: "multiline", label: "Footer introduction", required: true, maxLength: 500, modes: updateOnly },
+ { field: "footer.group.heading", kind: "text", label: "Footer group heading", required: true, maxLength: 80, modes: updateOnly },
+ { field: "footer.group.visible", kind: "boolean", label: "Footer group visibility", modes: updateOnly },
+ { field: "footer.group.order", kind: "number", label: "Footer group order", minimum: 0, maximum: 999, modes: updateOnly },
+ { field: "footer.item.label", kind: "text", label: "Footer item label", required: true, maxLength: 80, modes: updateOnly },
+ { field: "footer.item.value", kind: "text", label: "Footer item value", nullable: true, maxLength: 240, modes: updateOnly },
+ { field: "footer.item.url", kind: "url", label: "Footer item destination", nullable: true, modes: updateOnly },
+ { field: "footer.item.type", kind: "enum", label: "Footer item type", values: ["text", "internal-link", "external-link", "email"], modes: updateOnly },
+ { field: "footer.item.visible", kind: "boolean", label: "Footer item visibility", modes: updateOnly },
+ { field: "footer.item.newTab", kind: "boolean", label: "Open footer item in a new tab", modes: updateOnly },
+ { field: "footer.item.order", kind: "number", label: "Footer item order", minimum: 0, maximum: 999, modes: updateOnly }
+ ]),
+ ...definitions("homeSection", [
+ { field: "eyebrow", kind: "text", label: "Section eyebrow", nullable: true, maxLength: 120, modes: updateOnly },
+ { field: "title", kind: "text", label: "Section title", required: true, maxLength: 220, modes: updateOnly },
+ { field: "copy", kind: "multiline", label: "Section copy", nullable: true, maxLength: 1000, modes: updateOnly },
+ { field: "primaryCta.label", kind: "text", label: "Primary action label", required: true, maxLength: 80, modes: updateOnly },
+ { field: "primaryCta.href", kind: "url", label: "Primary action destination", required: true, modes: updateOnly },
+ { field: "secondaryCta.label", kind: "text", label: "Secondary action label", required: true, maxLength: 80, modes: updateOnly },
+ { field: "secondaryCta.href", kind: "url", label: "Secondary action destination", required: true, modes: updateOnly }
+ ]),
+ ...definitions("homeService", [
+ { field: "title", kind: "text", label: "Service title", required: true, maxLength: 100, modes: updateOnly },
+ { field: "body", kind: "multiline", label: "Service description", required: true, maxLength: 500, modes: updateOnly },
+ { field: "href", kind: "url", label: "Service destination", required: true, modes: updateOnly },
+ { field: "linkLabel", kind: "text", label: "Service action label", required: true, maxLength: 80, modes: updateOnly },
+ { field: "visible", kind: "boolean", label: "Service visibility", modes: updateOnly },
+ { field: "order", kind: "number", label: "Service order", minimum: 0, maximum: 999, modes: updateOnly }
+ ]),
+ ...definitions("page", [
+ { field: "title", kind: "text", label: "Page title", required: true, maxLength: 200, modes: updateOnly },
+ { field: "navLabel", kind: "text", label: "Navigation label", required: true, maxLength: 80, modes: updateOnly },
+ { field: "intro", kind: "multiline", label: "Page introduction", nullable: true, maxLength: 2000, modes: updateOnly },
+ { field: "body", kind: "rich-text", label: "Page body", nullable: true, maxLength: 50000, modes: updateOnly },
+ { field: "layout", kind: "enum", label: "Page layout", values: ["document", "redirect"], modes: updateOnly },
+ { field: "heroMediaPath", kind: "media-relation", label: "Hero media", nullable: true, modes: updateOnly },
+ { field: "status", kind: "enum", label: "Publication state", values: ["published", "draft", "archived"], modes: updateOnly }
+ ]),
+ ...definitions("piece", [
+ { field: "title", kind: "text", label: "Piece title", required: true, maxLength: 200, modes: updateOnly },
+ { field: "subtitle", kind: "text", label: "Piece subtitle", nullable: true, maxLength: 240, modes: updateOnly },
+ { field: "category", kind: "relation", label: "Piece category", required: true, modes: updateOnly },
+ { field: "status", kind: "enum", label: "Piece status", values: ["inventory", "commission", "archive"], modes: updateOnly },
+ { field: "publicationStatus", kind: "enum", label: "Publication state", values: ["published", "draft", "archived"], modes: updateOnly },
+ { field: "availabilityLabel", kind: "text", label: "Availability", nullable: true, maxLength: 160, modes: updateOnly },
+ { field: "summary", kind: "multiline", label: "Piece summary", nullable: true, maxLength: 2000, modes: updateOnly },
+ { field: "story", kind: "rich-text", label: "Piece story", nullable: true, maxLength: 50000, modes: updateOnly },
+ { field: "details", kind: "list", label: "Piece detail", maxLength: 500, modes: listModes },
+ { field: "materials", kind: "list", label: "Material", maxLength: 120, modes: listModes },
+ { field: "tags", kind: "list", label: "Tag", maxLength: 80, modes: listModes },
+ { field: "mediaPaths", kind: "media-relation", label: "Piece media", nullable: true, modes: listModes },
+ { field: "priceCents", kind: "currency", label: "Asking price", nullable: true, minimum: 0, maximum: 100000000, modes: updateOnly },
+ { field: "priceMode", kind: "enum", label: "Price mode", values: ["fixed", "contact-for-price", "determined-after-approval", "not-listed"], modes: updateOnly },
+ { field: "publicPriceLabel", kind: "text", label: "Public price label", nullable: true, maxLength: 120, modes: updateOnly },
+ { field: "internalEstimateCents", kind: "currency", label: "Internal estimate", nullable: true, minimum: 0, maximum: 100000000, modes: updateOnly },
+ { field: "inquiryMode", kind: "enum", label: "Inquiry mode", values: ["disabled", "exact-piece", "custom-pattern", "related-commission"], modes: updateOnly },
+ { field: "reviewsMode", kind: "enum", label: "Review mode", values: ["hidden", "display-only", "display-and-accept"], modes: updateOnly },
+ { field: "inventoryCount", kind: "number", label: "Inventory count", minimum: 0, maximum: 10000, modes: updateOnly },
+ { field: "leadTimeDays", kind: "number", label: "Lead time", minimum: 0, maximum: 3650, modes: updateOnly },
+ { field: "featuredRank", kind: "number", label: "Featured order", minimum: 0, maximum: 9999, modes: updateOnly },
+ { field: "commissionTypeSlug", kind: "relation", label: "Commission type", nullable: true, modes: updateOnly },
+ { field: "visualizerTemplate", kind: "relation", label: "Visualizer template", nullable: true, modes: updateOnly },
+ { field: "processSectionTitle", kind: "text", label: "Process section title", nullable: true, maxLength: 160, modes: updateOnly },
+ { field: "processSectionIntro", kind: "multiline", label: "Process section introduction", nullable: true, maxLength: 1000, modes: updateOnly }
+ ]),
+ ...definitions("post", [
+ { field: "title", kind: "text", label: "Process title", required: true, maxLength: 200, modes: updateOnly },
+ { field: "excerpt", kind: "multiline", label: "Process excerpt", nullable: true, maxLength: 2000, modes: updateOnly },
+ { field: "body", kind: "rich-text", label: "Process body", nullable: true, maxLength: 50000, modes: updateOnly },
+ { field: "sourceLabel", kind: "text", label: "Source label", nullable: true, maxLength: 160, modes: updateOnly },
+ { field: "sourceUrl", kind: "url", label: "Source URL", nullable: true, modes: updateOnly },
+ { field: "coverMediaPath", kind: "media-relation", label: "Cover media", nullable: true, modes: updateOnly },
+ { field: "tags", kind: "list", label: "Process tag", maxLength: 80, modes: listModes },
+ { field: "publicationStatus", kind: "enum", label: "Publication state", values: ["published", "draft", "archived"], modes: updateOnly },
+ { field: "publishedAt", kind: "date", label: "Published date", nullable: true, modes: updateOnly }
+ ]),
+ ...definitions("user", [
+ { field: "displayName", kind: "text", label: "Profile name", required: true, maxLength: 120, modes: updateOnly },
+ { field: "headline", kind: "text", label: "Profile title", nullable: true, maxLength: 160, modes: updateOnly },
+ { field: "bio", kind: "rich-text", label: "Profile biography", nullable: true, maxLength: 10000, modes: updateOnly },
+ { field: "publicProfile", kind: "boolean", label: "Public profile", modes: updateOnly },
+ { field: "avatarPath", kind: "media-relation", label: "Profile image", nullable: true, modes: updateOnly },
+ { field: "links", kind: "link-list", label: "Profile link", maxLength: 120, modes: listModes }
+ ]),
+ ...definitions("category", [
+ { field: "label", kind: "text", label: "Category label", required: true, maxLength: 80, modes: updateOnly },
+ { field: "aliases", kind: "list", label: "Category alias", maxLength: 80, modes: listModes },
+ { field: "icon", kind: "relation", label: "Category icon", required: true, modes: updateOnly },
+ { field: "customIconSvg", kind: "multiline", label: "Custom category icon", nullable: true, maxLength: 8000, modes: updateOnly },
+ { field: "sortOrder", kind: "number", label: "Category order", minimum: 0, maximum: 999, modes: updateOnly },
+ { field: "visible", kind: "boolean", label: "Category visibility", modes: updateOnly }
+ ]),
+ ...definitions("commissionType", [
+ { field: "label", kind: "text", label: "Commission type label", required: true, maxLength: 120, modes: updateOnly },
+ { field: "description", kind: "multiline", label: "Commission type description", nullable: true, maxLength: 2000, modes: updateOnly },
+ { field: "baseLaborHours", kind: "number", label: "Base labor hours", minimum: 0, maximum: 10000, modes: updateOnly },
+ { field: "baseMarkupPercent", kind: "number", label: "Base markup", minimum: 0, maximum: 500, modes: updateOnly },
+ { field: "materialOptions", kind: "list", label: "Allowed material", maxLength: 120, modes: listModes },
+ { field: "active", kind: "boolean", label: "Commission type enabled", modes: updateOnly }
+ ]),
+ ...definitions("project", [
+ { field: "status", kind: "text", label: "Project status", required: true, maxLength: 120, modes: updateOnly },
+ { field: "stage", kind: "text", label: "Project stage", required: true, maxLength: 120, modes: updateOnly },
+ { field: "publicNotes", kind: "multiline", label: "Buyer-visible project notes", nullable: true, maxLength: 5000, modes: updateOnly },
+ { field: "leadTimeDays", kind: "number", label: "Project lead time", nullable: true, minimum: 0, maximum: 3650, modes: updateOnly }
+ ])
+] as const;
+
+const registry = new Map(INLINE_EDIT_DEFINITIONS.map((definition) => [`${definition.resource}:${definition.field}`, definition]));
+
+export function getInlineEditDefinition(resource: string, field: string) {
+ return registry.get(`${resource}:${field}`) ?? null;
+}
+
+export function normalizeInlineEditUrl(value: unknown, nullable = false) {
+ const trimmed = String(value ?? "").trim();
+ if (!trimmed && nullable) return "";
+ if (trimmed.startsWith("/") && !trimmed.startsWith("//")) return trimmed;
+ if (trimmed.startsWith("mailto:")) {
+ const address = trimmed.slice(7).split("?")[0] ?? "";
+ if (/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(address)) return trimmed;
+ throw new Error("mailto: links must contain a valid email address.");
+ }
+ let parsed: URL;
+ try {
+ parsed = new URL(trimmed);
+ } catch {
+ throw new Error("Enter an http, https, mailto, or root-relative /path URL.");
+ }
+ if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error("URL scheme is not allowed.");
+ return parsed.toString();
+}
+
+function integer(value: unknown, label: string) {
+ const parsed = typeof value === "number" ? value : Number(String(value ?? "").trim());
+ if (!Number.isInteger(parsed) || parsed < 0) throw new Error(`${label} must be a non-negative integer.`);
+ return parsed;
+}
+
+function cleanString(value: unknown) {
+ return String(value ?? "").replace(/\u00a0/g, " ").replace(/[ \t]+\n/g, "\n").trim();
+}
+
+function normalizeMediaPath(value: unknown, nullable: boolean) {
+ const path = cleanString(value).replace(/\\/g, "/");
+ if (!path && nullable) return "";
+ if (!path || path.startsWith("/") || path.split("/").some((segment) => !segment || segment === "." || segment === "..")) {
+ throw new Error("Media selections must use a safe library-relative path.");
+ }
+ return path;
+}
+
+function parseStringList(value: unknown, maxLength: number) {
+ const source = Array.isArray(value) ? value : cleanString(value).split(/\r?\n|,/g);
+ const values = source.map(cleanString).filter(Boolean);
+ if (values.some((entry) => entry.length > maxLength)) throw new Error(`List items may not exceed ${maxLength} characters.`);
+ return [...new Set(values)];
+}
+
+function parseLinks(value: unknown, maxLength: number) {
+ let source: unknown = value;
+ if (typeof value === "string" && value.trim().startsWith("[")) {
+ try { source = JSON.parse(value); } catch { throw new Error("Link-list JSON is invalid."); }
+ }
+ if (!Array.isArray(source)) throw new Error("A complete link-list update must be an array.");
+ return source.map((entry) => {
+ if (!entry || typeof entry !== "object") throw new Error("Each link must contain a label and URL.");
+ const record = entry as Record;
+ const label = cleanString(record.label);
+ if (!label || label.length > maxLength) throw new Error(`Link labels must be 1-${maxLength} characters.`);
+ return { label, url: normalizeInlineEditUrl(record.url ?? record.href ?? "", true) };
+ });
+}
+
+function normalizedValue(definition: InlineEditDefinition, patch: InlineEditPatchInput, mode: InlineEditMode) {
+ if (mode === "cut" || mode === "move") return "";
+ const raw = patch.value;
+ if (definition.kind === "boolean") {
+ if (typeof raw === "boolean") return raw;
+ const value = cleanString(raw).toLowerCase();
+ if (["1", "true", "yes", "on"].includes(value)) return true;
+ if (["0", "false", "no", "off"].includes(value)) return false;
+ throw new Error(`${definition.label} must be true or false.`);
+ }
+ if (definition.kind === "number" || definition.kind === "currency") {
+ if (cleanString(raw) === "" && definition.nullable) return null;
+ const number = Number(raw);
+ if (!Number.isFinite(number)) throw new Error(`${definition.label} must be a number.`);
+ const normalized = definition.kind === "currency" ? Math.round(number) : number;
+ if (definition.minimum != null && normalized < definition.minimum) throw new Error(`${definition.label} must be at least ${definition.minimum}.`);
+ if (definition.maximum != null && normalized > definition.maximum) throw new Error(`${definition.label} must not exceed ${definition.maximum}.`);
+ return normalized;
+ }
+ if (definition.kind === "list") return parseStringList(raw, definition.maxLength ?? 500);
+ if (definition.kind === "link-list") {
+ if (mode === "update" && patch.index == null) return parseLinks(raw, definition.maxLength ?? 120);
+ if (raw && typeof raw === "object") return parseLinks([raw], definition.maxLength ?? 120)[0];
+ }
+ if (definition.kind === "url") return normalizeInlineEditUrl(raw, Boolean(definition.nullable));
+ if (definition.kind === "email") {
+ const value = cleanString(raw).toLowerCase();
+ if (!value && definition.nullable) return "";
+ if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value)) throw new Error(`${definition.label} must be a valid email address.`);
+ return value;
+ }
+ if (definition.kind === "media-relation") {
+ if (mode === "update" && patch.index == null && Array.isArray(raw)) return raw.map((entry) => normalizeMediaPath(entry, false));
+ return normalizeMediaPath(raw, Boolean(definition.nullable));
+ }
+ if (definition.kind === "date") {
+ const value = cleanString(raw);
+ if (!value && definition.nullable) return null;
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) throw new Error(`${definition.label} must be a valid date.`);
+ return date.toISOString();
+ }
+ const value = cleanString(raw);
+ if (!value && definition.required) throw new Error(`${definition.label} cannot be empty.`);
+ if (!value && !definition.nullable && mode !== "add") throw new Error(`${definition.label} cannot be empty.`);
+ if (definition.maxLength != null && value.length > definition.maxLength) throw new Error(`${definition.label} may not exceed ${definition.maxLength} characters.`);
+ if (definition.kind === "enum" && !definition.values?.includes(value)) throw new Error(`${definition.label} has an unsupported value.`);
+ if (definition.kind === "relation" && value && !/^[a-zA-Z0-9][a-zA-Z0-9._@-]{0,199}$/.test(value)) throw new Error(`${definition.label} contains unsupported characters.`);
+ return value;
+}
+
+export function validateInlineEditPatch(input: InlineEditPatchInput): ValidatedInlineEditPatch {
+ const resource = cleanString(input.resource);
+ const field = cleanString(input.field);
+ const id = cleanString(input.id);
+ const definition = getInlineEditDefinition(resource, field);
+ if (!definition) throw new Error(`Field '${resource}.${field}' is not inline editable.`);
+ const mode = (["add", "cut", "move"].includes(cleanString(input.mode)) ? cleanString(input.mode) : "update") as InlineEditMode;
+ if (!(definition.modes ?? updateOnly).includes(mode)) throw new Error(`${definition.label} does not support '${mode}'.`);
+ const index = input.index == null || input.index === "" ? null : integer(input.index, "Item index");
+ const toIndex = input.toIndex == null || input.toIndex === "" ? null : integer(input.toIndex, "Destination index");
+ if ((mode === "cut" || mode === "move") && index == null) throw new Error(`${definition.label} requires an item index for '${mode}'.`);
+ if (mode === "move" && toIndex == null) throw new Error(`${definition.label} requires a destination index.`);
+ return {
+ resource: definition.resource,
+ field,
+ id,
+ index,
+ toIndex,
+ value: normalizedValue(definition, input, mode),
+ ...(input.expectedValue !== undefined ? { expectedValue: cleanString(input.expectedValue) } : {}),
+ mode,
+ definition
+ };
+}
+
+export function editInlineList(current: readonly T[], patch: Pick, added: readonly T[]) {
+ const next = [...current];
+ if (patch.mode === "update") {
+ if (patch.index == null) return [...added];
+ if (patch.index >= next.length || added.length !== 1) throw new Error("The selected list item is no longer available.");
+ next[patch.index] = added[0];
+ return next;
+ }
+ if (patch.mode === "add") {
+ const insertion = patch.toIndex == null ? next.length : Math.min(patch.toIndex, next.length);
+ next.splice(insertion, 0, ...added);
+ return next;
+ }
+ if (patch.mode === "cut") {
+ if (patch.index == null || patch.index >= next.length) throw new Error("The selected list item is no longer available.");
+ next.splice(patch.index, 1);
+ return next;
+ }
+ if (patch.index == null || patch.index >= next.length || patch.toIndex == null || patch.toIndex >= next.length) {
+ throw new Error("The list move is outside the available range.");
+ }
+ const [moved] = next.splice(patch.index, 1);
+ next.splice(patch.toIndex, 0, moved);
+ return next;
+}
diff --git a/site/lib/request-security.ts b/site/lib/request-security.ts
new file mode 100644
index 0000000..9fbca62
--- /dev/null
+++ b/site/lib/request-security.ts
@@ -0,0 +1,48 @@
+function normalizedOrigin(value: string | null | undefined) {
+ if (!value) return null;
+ try {
+ return new URL(value).origin.toLowerCase();
+ } catch {
+ return null;
+ }
+}
+
+export function mutationOriginAllowed(input: {
+ requestUrl: string;
+ origin?: string | null;
+ forwardedHost?: string | null;
+ forwardedProto?: string | null;
+ configuredOrigins?: Array;
+}) {
+ const origin = normalizedOrigin(input.origin);
+ if (!origin) return false;
+ const request = new URL(input.requestUrl);
+ const allowed = new Set();
+ allowed.add(request.origin.toLowerCase());
+ if (input.forwardedHost) {
+ const protocol = input.forwardedProto?.split(",")[0]?.trim() || request.protocol.replace(":", "");
+ const forwarded = normalizedOrigin(`${protocol}://${input.forwardedHost.split(",")[0]?.trim()}`);
+ if (forwarded) allowed.add(forwarded);
+ }
+ for (const value of input.configuredOrigins ?? []) {
+ const configured = normalizedOrigin(value);
+ if (configured) allowed.add(configured);
+ }
+ return allowed.has(origin);
+}
+
+export class UntrustedMutationOriginError extends Error {
+ readonly status = 403;
+}
+
+export function assertTrustedMutationOrigin(request: Request) {
+ if (!mutationOriginAllowed({
+ requestUrl: request.url,
+ origin: request.headers.get("origin"),
+ forwardedHost: request.headers.get("x-forwarded-host"),
+ forwardedProto: request.headers.get("x-forwarded-proto"),
+ configuredOrigins: [process.env.SITE_URL, process.env.NEXT_PUBLIC_SITE_URL]
+ })) {
+ throw new UntrustedMutationOriginError("The request origin is not allowed.");
+ }
+}
diff --git a/site/package.json b/site/package.json
index f0e1632..8794d38 100644
--- a/site/package.json
+++ b/site/package.json
@@ -8,7 +8,7 @@
"start": "node --experimental-sqlite .next/standalone/server.js",
"lint": "eslint app components lib",
"typecheck": "tsc --noEmit",
- "test": "node --experimental-strip-types --test lib/media-scoring.test.mts lib/piece-model.test.mts lib/database-migrations.test.mts lib/media-reference-transaction.test.mts lib/category-icons.test.mts lib/site-structure.test.mts lib/piece-media.test.mts lib/estimator.test.mts lib/visual-audit-security.test.mts"
+ "test": "node --experimental-strip-types --test lib/media-scoring.test.mts lib/piece-model.test.mts lib/database-migrations.test.mts lib/media-reference-transaction.test.mts lib/category-icons.test.mts lib/site-structure.test.mts lib/piece-media.test.mts lib/estimator.test.mts lib/visual-audit-security.test.mts lib/inline-edit-registry.test.mts"
},
"dependencies": {
"@react-three/drei": "^10.7.7",
diff --git a/woodsmith_DeepWiki_Merged_03222026.md b/woodsmith_DeepWiki_Merged_03222026.md
index 8b97904..5d3277f 100644
--- a/woodsmith_DeepWiki_Merged_03222026.md
+++ b/woodsmith_DeepWiki_Merged_03222026.md
@@ -60,7 +60,7 @@ Project trackers live at `/requests/[reference]`. Access is allowed only when th
- buyer email verification at `/account/verify`
- visitor-session logging endpoint at `/api/visits`
-Admins signed into the public site get pencil controls on supported sections. Mapped text and link destinations save through `/api/studio/inline-edit` without a route change or full-page reload. Structural changes use the explicit full-editor link; no raw JSON editor is exposed.
+Admins signed into the public site get pencil controls on supported sections. Mapped text and link destinations save through `/api/studio/inline-edit` without a route change or full-page reload. A typed registry is the single server allowlist for text, rich text, URL, email, number, currency, boolean, date, enum, list, link-list, relation, and media-relation fields. Requests require admin authentication and a trusted same-origin mutation request; each batch validates before one SQLite transaction, detects stale expected values, writes an admin audit record, and returns reversible patches for one-step Undo. Structural changes use the explicit visual full-editor link; no raw JSON editor is exposed.
## Database model
From 43bf43498d7edc9e4999d9a8507593a787fc8700 Mon Sep 17 00:00:00 2001
From: Cooper Beaman <55124795+lowestprime@users.noreply.github.com>
Date: Sun, 12 Jul 2026 17:21:40 -0700
Subject: [PATCH 09/43] feat(commissions): secure resumable request workflow
Add server-backed drafts, idempotent submissions, private attachment staging, capability access, and abuse controls. Prevent runtime databases from entering standalone or Docker build artifacts.
---
PLANS.md | 6 +-
README.md | 8 +-
admin.md | 8 +-
site/app/api/commissions/draft/route.ts | 58 +++
site/app/api/render-preview/route.ts | 74 ++--
site/app/commissions/page.tsx | 17 +-
site/app/commissions/status/page.tsx | 86 ++---
site/app/globals.css | 251 ++++++++++++-
site/app/layout.tsx | 2 +-
site/app/requests/[reference]/page.tsx | 36 +-
site/components/commission-draft-cleanup.tsx | 12 +
site/components/commission-workflow.tsx | 367 +++++++++++++++++++
site/components/forms.tsx | 49 +--
site/components/visualizer.tsx | 31 +-
site/lib/actions.ts | 363 +++++++++---------
site/lib/commission-security.ts | 43 +++
site/lib/commission-workflow.test.mts | 130 +++++++
site/lib/database-migrations.test.mts | 2 +-
site/lib/database-migrations.ts | 84 +++++
site/lib/db.ts | 360 ++++++++++++++++--
site/lib/media.ts | 46 ++-
site/lib/seed.ts | 6 +-
site/next.config.ts | 7 +-
site/package.json | 4 +-
site/scripts/safe-build-lib.mjs | 59 +++
site/scripts/safe-build.mjs | 9 +
site/scripts/safe-build.test.mjs | 78 ++++
synology-nas-deploy.md | 15 +-
woodsmith_DeepWiki_Merged_03222026.md | 18 +-
29 files changed, 1854 insertions(+), 375 deletions(-)
create mode 100644 site/app/api/commissions/draft/route.ts
create mode 100644 site/components/commission-draft-cleanup.tsx
create mode 100644 site/components/commission-workflow.tsx
create mode 100644 site/lib/commission-security.ts
create mode 100644 site/lib/commission-workflow.test.mts
create mode 100644 site/scripts/safe-build-lib.mjs
create mode 100644 site/scripts/safe-build.mjs
create mode 100644 site/scripts/safe-build.test.mjs
diff --git a/PLANS.md b/PLANS.md
index 4880f0e..e5dbae6 100644
--- a/PLANS.md
+++ b/PLANS.md
@@ -11,9 +11,11 @@
| Audit and category design | DONE / COMMITTED | `30c87f6` records the evidence audit; `959a2ca` adds managed visual category icons and tests. |
| Structured Studio and public content | DONE / COMMITTED | `8b2a39b` adds structured footer/home services, typed public pricing/inquiry/review behavior, visual media selection, compact Page/Piece/Process editing, media roles, and public build records. |
| Conceptual proportional 3D preview | DONE / COMMITTED | `9e143da` adds route-local R3F templates, view/camera controls, exact dimensions, deterministic SVG/text fallbacks, demand rendering, and estimator tests. |
-| Deterministic visual archive | IMPLEMENTED / LOCALLY VALIDATED; NAS RUNTIME VALIDATION PENDING | The QA slice adds protected bounded inventory, strict live-readonly and isolated snapshot-lab modes, pinned Playwright Docker, route/link reconciliation, required high-resolution profiles, overlapping page/nested-scroll tiles with seam checks, deep UI state capture, restricted/redacted HTML/PDF, checksums, diffs, locks, and retention. Local tests, typecheck, lint, build, Compose validation, and a disposable-data smoke archive pass. The local Docker daemon is unavailable, so candidate-image and NAS smoke/full evidence remain required before deployment. |
+| Deterministic visual archive | IMPLEMENTED / LOCAL DOCKER VALIDATED; NAS RUNTIME VALIDATION PENDING | The QA slice adds protected bounded inventory, strict live-readonly and isolated snapshot-lab modes, pinned Playwright Docker, route/link reconciliation, required high-resolution profiles, overlapping page/nested-scroll tiles with seam checks, deep UI state capture, restricted/redacted HTML/PDF, checksums, diffs, locks, and retention. Local tests, typecheck, lint, safe standalone build, all Compose validation, a disposable-data smoke archive, a `linux/amd64` image build, image-filesystem inspection, and loopback snapshot-lab application smoke pass. NAS backup/candidate/full archive/rollback evidence remains required before deployment. |
| Typed atomic inline editing | DONE | Added a typed field registry spanning scalar, list, link, relation, media, status, and structural settings fields; trusted-origin enforcement; complete-batch validation; one SQLite transaction; optimistic expected-value conflicts; admin audit records; reset, rollback, keyboard, focus, and one-step Undo behavior. Thirty-two application tests, typecheck, lint, production build, and a disposable authenticated Chromium save/undo/reset/conflict/origin smoke pass. Final-candidate archive evidence remains a release gate. |
-| Remaining product work | PENDING | Secure resumable multi-step commissions, transactional batch media operations/rollback, derivative-only cleanup, accessibility/performance completion, final docs, archive evidence, release, rollback, and deployment gates remain active. |
+| Secure resumable custom requests | DONE / LOCAL DOCKER VALIDATED | Added the ten-step autosaved workflow, verified-account server drafts, idempotent server-authoritative submission, hashed submission/render quotas, private staged attachments with rollback, owner-bound generated previews, opaque project-access cookies, POST lookup without URL email, and exact dimension/category continuity into R3F. Additive schema v5, seed v6, 36 application tests, production build, image safety inspection, and disposable browser/container submission evidence pass. |
+| Safe standalone and image build | DONE / LOCAL DOCKER VALIDATED | `safe-build` uses disposable build roots, excludes runtime data from Next tracing, rejects database/backup files, and proves cleanup for child-failure, forbidden-output, and success paths. Docker Desktop Engine 29.6.1 and BuildKit 0.31.1 built/loaded `woodsmith:local-gate`; the image contains no runtime DB, env/key, private evidence, audit output, or production media and starts with an empty data directory. |
+| Remaining product work | PENDING | Transactional batch media operations/rollback, derivative-only cleanup, accessibility/performance completion, final docs, full archive evidence, release, NAS backup/restore, rollback, and deployment gates remain active. |
## 2026-07-05 Local-First Media AI, Header, and Persistence Pass
diff --git a/README.md b/README.md
index a8a8098..efb0c4f 100644
--- a/README.md
+++ b/README.md
@@ -153,7 +153,7 @@ Legacy redirects:
- `/journal`
- `/journal/[slug]`
-Buyer account and request access:
+Buyer account and request access:
- `/account/signup`
- `/account/login`
@@ -161,7 +161,9 @@ Buyer account and request access:
- `/account/reset`
- `/account/profile`
- `/account/projects`
-- `/requests/[reference]`
+- `/requests/[reference]`
+
+Custom work uses a ten-step, locally autosaved request flow. Verified accounts also receive resumable server drafts. Submission is idempotent, rate-limited by a hashed owner key, recalculates the estimate and lead time on the server, stages allowlisted private image references safely, and redirects without putting buyer email in the URL. Existing projects are opened through the POST lookup at `/commissions/status`, which issues an expiring `HttpOnly` capability cookie.
Private Woodshop:
@@ -171,6 +173,8 @@ Private Woodshop:
## 🚀 Deployment
The supported deployment target is Synology NAS with Docker Compose and reverse proxy termination. The compose file mounts `/volume1/homes/Cooper/Photos/Dad_Woodworking_09262025` directly to `/app/pics:rw`; do not remount or bind the repo-local `pics/` folder under `docker_ssd`. `MEDIA_ROOT` must be an absolute container path.
+
+`npm run build` uses disposable build-time data and media roots and fails if Next standalone output contains SQLite, WAL/SHM, or backup files. Production state must enter the container only through the writable runtime mounts.
After deploying a build from this branch, the startup migration updates legacy `lowestprime@proton.me` developer references in persisted settings and seeded profile data to `cooperbeaman@proton.me`.
diff --git a/admin.md b/admin.md
index f35c56c..6599f37 100644
--- a/admin.md
+++ b/admin.md
@@ -120,7 +120,11 @@ Password resets, verification links, project updates, contact requests, and comm
### Custom work contact
-The public custom work page collects contact details, location, budget, requested piece type, preferred material, pickup/delivery/shipping preference, attachments, an optional conceptual proportional preview, and a written brief. The preview uses a dynamically loaded React Three Fiber scene with perspective/orthographic and front/side/top/isometric controls, while the deterministic SVG drawing remains available if WebGL or motion is unavailable. It creates a private project record and redirects the buyer to a reference page. If `OPENAI_API_KEY` and `ENABLE_PUBLIC_AI_RENDERING=true` are configured, the visualizer can also generate a photorealistic preview and attach it only when the buyer chooses to include the preview.
+The public custom work page is a ten-step guided request covering intent, category, room/use, exact working dimensions, materials, private reference uploads, conceptual preview, fulfillment, contact details, and final review. Browser autosave is always available; verified accounts also receive serialized server-side drafts that can resume on another browser. The server, not hidden browser fields, recalculates material, labor, overhead, markup, queue-aware lead time, and the planning total before creating the project.
+
+Reference uploads are limited to eight allowlisted image files, 20 MB each and 60 MB total. They are staged under the writable media mount, moved into the private project folder only after an idempotent project insert, and removed with the retry key if finalization fails. A honeypot and hashed per-owner submission window limit reduce automated abuse without storing raw IP addresses in quota tables.
+
+The preview uses a dynamically loaded React Three Fiber scene with perspective/orthographic and front/side/top/isometric controls, while the deterministic SVG drawing remains available if WebGL or motion is unavailable. If `OPENAI_API_KEY` and `ENABLE_PUBLIC_AI_RENDERING=true` are configured, generated previews have a separate owner-bound quota and can attach only once to the request that owns them.
## Private visual archive
@@ -134,7 +138,7 @@ The cart calculates subtotal, coupon discount, tax estimate, shipping estimate,
### Buyer project lookup
-Buyers can use `/commissions/status` or `/requests/[reference]?email=buyer@example.com`. Reference links should be shared only with the buyer and trusted collaborators.
+Buyers use `/commissions/status` to exchange the project reference and buyer email through a POST form for a 30-day, `HttpOnly`, same-site capability cookie. Email addresses are never placed in request URLs. A signed-in buyer whose account email matches the project can open it directly; administrators retain operational access.
## Environment-dependent services
diff --git a/site/app/api/commissions/draft/route.ts b/site/app/api/commissions/draft/route.ts
new file mode 100644
index 0000000..9c84893
--- /dev/null
+++ b/site/app/api/commissions/draft/route.ts
@@ -0,0 +1,58 @@
+import { NextResponse } from "next/server";
+
+import { getCurrentUser } from "@/lib/auth";
+import { deleteCommissionDraftForUser, getCommissionDraftForUser, listCommissionDraftsForUser, saveCommissionDraftForUser } from "@/lib/db";
+import { assertTrustedMutationOrigin, UntrustedMutationOriginError } from "@/lib/request-security";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+function errorResponse(message: string, status: number) {
+ return NextResponse.json({ ok: false, message }, { status, headers: { "cache-control": "no-store" } });
+}
+
+export async function GET(request: Request) {
+ const user = await getCurrentUser();
+ if (!user || user.role === "customer" && !user.emailVerified) return errorResponse("A verified account is required for server drafts.", 401);
+ const id = new URL(request.url).searchParams.get("id")?.trim();
+ const result = id ? getCommissionDraftForUser(id, user.email) : listCommissionDraftsForUser(user.email);
+ if (id && !result) return errorResponse("Draft not found.", 404);
+ return NextResponse.json({ ok: true, draft: id ? result : undefined, drafts: id ? undefined : result }, { headers: { "cache-control": "no-store" } });
+}
+
+export async function POST(request: Request) {
+ try {
+ assertTrustedMutationOrigin(request);
+ const user = await getCurrentUser();
+ if (!user || user.role === "customer" && !user.emailVerified) return errorResponse("A verified account is required for server drafts.", 401);
+ const body = await request.json().catch(() => null) as Record | null;
+ if (!body || !body.payload || typeof body.payload !== "object" || Array.isArray(body.payload)) return errorResponse("Draft payload is invalid.", 400);
+ const draft = saveCommissionDraftForUser({
+ id: typeof body.id === "string" ? body.id : null,
+ userEmail: user.email,
+ payload: body.payload as Record,
+ currentStep: Number(body.currentStep ?? 1),
+ idempotencyKey: String(body.idempotencyKey ?? ""),
+ expectedUpdatedAt: typeof body.expectedUpdatedAt === "string" ? body.expectedUpdatedAt : null
+ });
+ return NextResponse.json({ ok: true, draft }, { headers: { "cache-control": "no-store" } });
+ } catch (error) {
+ if (error instanceof UntrustedMutationOriginError) return errorResponse(error.message, error.status);
+ const message = error instanceof Error ? error.message : "Draft save failed.";
+ return errorResponse(message, /another session/.test(message) ? 409 : 400);
+ }
+}
+
+export async function DELETE(request: Request) {
+ try {
+ assertTrustedMutationOrigin(request);
+ const user = await getCurrentUser();
+ if (!user) return errorResponse("Authentication is required.", 401);
+ const id = new URL(request.url).searchParams.get("id")?.trim() ?? "";
+ if (!id || !deleteCommissionDraftForUser(id, user.email)) return errorResponse("Draft not found.", 404);
+ return NextResponse.json({ ok: true }, { headers: { "cache-control": "no-store" } });
+ } catch (error) {
+ if (error instanceof UntrustedMutationOriginError) return errorResponse(error.message, error.status);
+ return errorResponse(error instanceof Error ? error.message : "Draft deletion failed.", 400);
+ }
+}
diff --git a/site/app/api/render-preview/route.ts b/site/app/api/render-preview/route.ts
index 864888e..fc0f558 100644
--- a/site/app/api/render-preview/route.ts
+++ b/site/app/api/render-preview/route.ts
@@ -1,8 +1,12 @@
-import { NextResponse } from "next/server";
-import { createPhotorealisticPreview, getAiServiceStatus, type PhotorealisticRenderInput } from "@/lib/ai-services";
-import { persistGeneratedMedia } from "@/lib/media";
-import { refreshMediaLibrary, saveMediaMetadata } from "@/lib/db";
-import { toMediaUrl } from "@/lib/format";
+import { NextResponse } from "next/server";
+import { createPhotorealisticPreview, getAiServiceStatus, type PhotorealisticRenderInput } from "@/lib/ai-services";
+import { persistGeneratedMedia } from "@/lib/media";
+import { consumeCommissionRenderQuota, refreshMediaLibrary, registerCommissionRenderAsset, saveMediaMetadata } from "@/lib/db";
+import { toMediaUrl } from "@/lib/format";
+import { getCurrentUser } from "@/lib/auth";
+import { commissionOwnerKey } from "@/lib/commission-security";
+import { normalizeVisualizerState } from "@/lib/estimator";
+import { assertTrustedMutationOrigin, UntrustedMutationOriginError } from "@/lib/request-security";
export const runtime = "nodejs";
@@ -11,31 +15,59 @@ function numberField(value: unknown, fallback: number) {
return Number.isFinite(parsed) ? parsed : fallback;
}
-export async function POST(request: Request) {
- const status = getAiServiceStatus();
+export async function POST(request: Request) {
+ try {
+ assertTrustedMutationOrigin(request);
+ } catch (error) {
+ return NextResponse.json({ error: error instanceof UntrustedMutationOriginError ? error.message : "Preview request was rejected." }, { status: 403 });
+ }
+ const status = getAiServiceStatus();
if (!status.publicRendering) {
return NextResponse.json({
error: "AI rendering is not enabled. Set OPENAI_API_KEY and ENABLE_PUBLIC_AI_RENDERING=true to activate photorealistic previews."
}, { status: 503 });
}
- const body = await request.json().catch(() => ({})) as Partial;
- const input: PhotorealisticRenderInput = {
- pieceType: String(body.pieceType || "custom woodworking piece"),
- material: String(body.material || "White maple"),
- joinery: String(body.joinery || "Mortise and tenon"),
- width: numberField(body.width, 48),
- depth: numberField(body.depth, 24),
- height: numberField(body.height, 30),
- drawers: numberField(body.drawers, 0),
- shelves: numberField(body.shelves, 0),
- notes: String(body.notes || "")
+ const user = await getCurrentUser();
+ const ownerKey = await commissionOwnerKey(user?.email);
+ const quota = consumeCommissionRenderQuota(ownerKey, user ? 8 : 3);
+ if (!quota.allowed) {
+ return NextResponse.json({ error: "The preview limit for this 24-hour window has been reached.", retryAfterSeconds: quota.retryAfterSeconds }, {
+ status: 429,
+ headers: { "retry-after": String(quota.retryAfterSeconds) }
+ });
+ }
+
+ const body = await request.json().catch(() => ({})) as Partial;
+ const normalized = normalizeVisualizerState({
+ kind: String(body.pieceType || "other-custom-work").slice(0, 120),
+ material: String(body.material || "White maple").slice(0, 120),
+ joinery: String(body.joinery || "Mortise and tenon").slice(0, 120),
+ width: numberField(body.width, 48),
+ depth: numberField(body.depth, 24),
+ height: numberField(body.height, 30),
+ drawers: numberField(body.drawers, 0),
+ shelves: numberField(body.shelves, 0),
+ notes: String(body.notes || "").slice(0, 2000),
+ includeVisualization: true
+ });
+ const input: PhotorealisticRenderInput = {
+ pieceType: normalized.kind,
+ material: normalized.material,
+ joinery: normalized.joinery,
+ width: normalized.width,
+ depth: normalized.depth,
+ height: normalized.height,
+ drawers: normalized.drawers,
+ shelves: normalized.shelves,
+ notes: normalized.notes
};
try {
const generated = await createPhotorealisticPreview(input);
if (generated.b64Json) {
- const relativePath = persistGeneratedMedia(generated.b64Json, "ai-renderings", input.pieceType, ".png");
+ const relativePath = persistGeneratedMedia(generated.b64Json, "ai-renderings", input.pieceType, ".png");
+ registerCommissionRenderAsset(relativePath, ownerKey);
refreshMediaLibrary();
saveMediaMetadata({
relativePath,
@@ -54,10 +86,10 @@ export async function POST(request: Request) {
}
});
- return NextResponse.json({ relativePath, mediaUrl: toMediaUrl(relativePath), model: status.imageModel });
+ return NextResponse.json({ relativePath, mediaUrl: toMediaUrl(relativePath), model: status.imageModel, remaining: quota.remaining });
}
- return NextResponse.json({ imageUrl: generated.url, model: status.imageModel });
+ return NextResponse.json({ imageUrl: generated.url, model: status.imageModel, remaining: quota.remaining });
} catch (error) {
return NextResponse.json({ error: error instanceof Error ? error.message : "AI preview generation failed." }, { status: 502 });
}
diff --git a/site/app/commissions/page.tsx b/site/app/commissions/page.tsx
index 6227ec4..199f9ad 100644
--- a/site/app/commissions/page.tsx
+++ b/site/app/commissions/page.tsx
@@ -3,7 +3,8 @@ import Link from "next/link";
import { connection } from "next/server";
import { ContactRequestForm } from "@/components/forms";
import { PageIntro, PageSection, Shell } from "@/components/site-chrome";
-import { getBandwidthSnapshot, getPage, listCommissionTypes } from "@/lib/db";
+import { getBandwidthSnapshot, getPage, listCommissionTypes } from "@/lib/db";
+import { getCurrentUser } from "@/lib/auth";
export const metadata: Metadata = {
title: "Custom Work",
@@ -14,7 +15,8 @@ export const metadata: Metadata = {
export default async function CommissionsPage() {
await connection();
const page = getPage("commissions");
- const bandwidth = getBandwidthSnapshot();
+ const bandwidth = getBandwidthSnapshot();
+ const user = await getCurrentUser();
return (
@@ -23,10 +25,13 @@ export default async function CommissionsPage() {
{page?.body ? {page.body}
: null}
Already have a reference? Look up your project status here.
+ bandwidthLeadTimeDays={bandwidth.leadTimeDays}
+ commissionTypes={listCommissionTypes()}
+ defaultEmail={user?.email}
+ defaultName={user?.displayName}
+ queueCount={bandwidth.activeProjects}
+ signedIn={Boolean(user && (user.role !== "customer" || user.emailVerified))}
+ />
);
diff --git a/site/app/commissions/status/page.tsx b/site/app/commissions/status/page.tsx
index 9bf8a7e..38943ff 100644
--- a/site/app/commissions/status/page.tsx
+++ b/site/app/commissions/status/page.tsx
@@ -1,64 +1,22 @@
-import Link from "next/link";
-import { ProjectReplyForm } from "@/components/forms";
-import { PageIntro, PageSection, Shell } from "@/components/site-chrome";
-import { getProject, listProjectUpdates } from "@/lib/db";
-import { formatDateTime, formatLeadTime, toMediaUrl } from "@/lib/format";
-
-export default async function CommissionStatusPage({ searchParams }: { searchParams: Promise<{ reference?: string; email?: string }> }) {
- const { reference = "", email = "" } = await searchParams;
- const project = reference ? getProject(reference) : null;
- const matches = project && email && project.guestEmail.toLowerCase() === email.toLowerCase();
- const updates = matches && project ? listProjectUpdates(project.reference) : [];
- const aiPreviewPath = project && typeof project.options.aiPreviewPath === "string" && project.options.aiPreviewPath ? project.options.aiPreviewPath : null;
-
- return (
-
-
-
-
-
- Reference
- Email
-
- Open project
-
-
-
- {reference && !matches ? (
-
-
-
Project not found. The reference or email address did not match any project on file. Check for typos or try the email used during your original request.
-
-
- ) : null}
-
- {matches && project ? (
-
-
-
-
{project.reference}
-
{project.commissionTypeSlug || project.pieceSlug || "Custom project"}
-
Status: {project.status} · Stage: {project.stage} · Estimated lead time {formatLeadTime(project.leadTimeDays)}
-
-
-
-
-
Project brief
-
{project.brief}
- {project.includeVisualization && aiPreviewPath ?
: null}
- {project.publicNotes ?
{project.publicNotes}
: null}
-
-
-
Timeline
-
- {updates.map((update) => {update.authorRole} {formatDateTime(update.createdAt)} {update.body}
)}
-
-
-
-
- Open the dedicated request page.
-
- ) : null}
-
- );
-}
+import { lookupProjectStatusAction } from "@/lib/actions";
+import { PageIntro, PageSection, Shell } from "@/components/site-chrome";
+
+export default async function CommissionStatusPage({ searchParams }: { searchParams: Promise<{ reference?: string; error?: string }> }) {
+ const { reference = "", error = "" } = await searchParams;
+ return (
+
+
+
+ {error ? The reference and email did not match a project. Check both entries and try again.
: null}
+
+
+ Reference
+ Buyer email
+
+ Open project
+
+ Access expires after 30 days on this browser. Repeat this lookup whenever access needs to be renewed.
+
+
+ );
+}
diff --git a/site/app/globals.css b/site/app/globals.css
index dcaf5d3..aa80b8d 100644
--- a/site/app/globals.css
+++ b/site/app/globals.css
@@ -2587,6 +2587,251 @@ input[type="range"]::-moz-range-thumb {
scroll-margin-top: 6rem;
}
-.studio-loading-shell {
- min-height: 40vh;
-}
+.studio-loading-shell {
+ min-height: 40vh;
+}
+
+/* --- Resumable commission workflow --- */
+
+.commission-workflow {
+ gap: clamp(1rem, 2vw, 1.5rem);
+}
+
+.form-honeypot {
+ height: 1px !important;
+ inset-inline-start: -10000px !important;
+ overflow: hidden !important;
+ position: absolute !important;
+ width: 1px !important;
+}
+
+.form-honeypot[hidden] {
+ display: none !important;
+}
+
+.commission-workflow > section {
+ min-height: 22rem;
+ padding: clamp(1rem, 2.5vw, 1.75rem);
+ border: 1px solid var(--line);
+ border-radius: var(--radius-lg);
+ background: color-mix(in srgb, var(--panel) 92%, transparent);
+}
+
+.commission-workflow > section[hidden] {
+ display: none;
+}
+
+.commission-progress {
+ position: sticky;
+ top: calc(var(--header-height, 4.5rem) + 0.5rem);
+ z-index: 8;
+ display: grid;
+ gap: 0.65rem;
+ padding: 0.85rem;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-lg);
+ background: color-mix(in srgb, var(--bg) 94%, transparent);
+ backdrop-filter: blur(18px);
+}
+
+.commission-progress > div:nth-child(2) {
+ display: flex;
+ justify-content: space-between;
+ gap: 1rem;
+ color: var(--muted);
+ font-size: 0.82rem;
+}
+
+.commission-progress-meter {
+ height: 0.35rem;
+ overflow: hidden;
+ border-radius: var(--radius-pill);
+ background: var(--line);
+}
+
+.commission-progress-meter span {
+ display: block;
+ height: 100%;
+ border-radius: inherit;
+ background: linear-gradient(90deg, var(--accent), var(--button));
+ transition: width 180ms ease;
+}
+
+.commission-progress ol {
+ display: grid;
+ grid-template-columns: repeat(10, minmax(4.5rem, 1fr));
+ gap: 0.35rem;
+ min-width: 47rem;
+ margin: 0;
+ padding: 0 0 0.2rem;
+ overflow-x: auto;
+ list-style: none;
+ scrollbar-width: thin;
+}
+
+.commission-progress li button {
+ width: 100%;
+ min-height: 3.1rem;
+ padding: 0.35rem;
+ border: 1px solid transparent;
+ border-radius: var(--radius-md);
+ background: transparent;
+ color: var(--muted);
+ font: inherit;
+ font-size: 0.72rem;
+}
+
+.commission-progress li button span {
+ display: block;
+ font-family: var(--font-display);
+ font-size: 1rem;
+}
+
+.commission-progress li button[aria-current="step"] {
+ border-color: var(--accent);
+ background: var(--panel);
+ color: var(--ink);
+}
+
+.choice-card-grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 0.75rem;
+}
+
+.choice-card {
+ position: relative;
+ display: block;
+ min-height: 9rem;
+ padding: 1rem;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-lg);
+ cursor: pointer;
+}
+
+.choice-card input {
+ position: absolute;
+ inset: 0.8rem 0.8rem auto auto;
+ width: 1.15rem;
+ height: 1.15rem;
+}
+
+.choice-card span {
+ display: grid;
+ gap: 0.5rem;
+ padding-right: 1.5rem;
+}
+
+.choice-card small {
+ color: var(--muted);
+ line-height: 1.45;
+}
+
+.choice-card:has(input:checked) {
+ border-color: var(--accent);
+ background: color-mix(in srgb, var(--accent) 10%, var(--panel));
+}
+
+.commission-upload-previews {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr));
+ gap: 0.65rem;
+}
+
+.commission-upload-previews figure {
+ display: grid;
+ gap: 0.35rem;
+ margin: 0;
+ padding: 0.5rem;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-md);
+ background: var(--panel);
+}
+
+.commission-upload-previews img {
+ width: 100%;
+ aspect-ratio: 4 / 3;
+ border-radius: var(--radius-sm);
+ object-fit: cover;
+}
+
+.commission-upload-previews figcaption {
+ overflow: hidden;
+ font-size: 0.75rem;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.commission-upload-previews button {
+ min-height: 2rem;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-pill);
+ background: transparent;
+ color: var(--ink);
+}
+
+.commission-review-list {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
+ gap: 0.65rem;
+}
+
+.commission-review-list div {
+ padding: 0.75rem;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-md);
+}
+
+.commission-review-list dt {
+ color: var(--muted);
+ font-size: 0.72rem;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+}
+
+.commission-review-list dd {
+ margin: 0.2rem 0 0;
+}
+
+.commission-step-actions {
+ display: flex;
+ justify-content: space-between;
+ gap: 0.75rem;
+}
+
+@media (max-width: 720px) {
+ .commission-workflow > section {
+ min-height: 0;
+ }
+
+ .commission-progress {
+ position: relative;
+ top: auto;
+ margin-inline: -0.25rem;
+ }
+
+ .commission-progress > div:nth-child(2) {
+ align-items: flex-start;
+ flex-direction: column;
+ gap: 0.2rem;
+ }
+
+ .choice-card-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .choice-card {
+ min-height: 0;
+ }
+
+ .commission-step-actions {
+ position: sticky;
+ bottom: 0.5rem;
+ z-index: 7;
+ padding: 0.5rem;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-pill);
+ background: color-mix(in srgb, var(--bg) 94%, transparent);
+ backdrop-filter: blur(16px);
+ }
+}
diff --git a/site/app/layout.tsx b/site/app/layout.tsx
index 0264258..cf57d54 100644
--- a/site/app/layout.tsx
+++ b/site/app/layout.tsx
@@ -83,7 +83,7 @@ export default async function RootLayout({ children }: Readonly<{ children: Reac
const theme = cookieStore.get("beaman-theme")?.value === "light" ? "light" : "dark";
return (
-
+
diff --git a/site/app/requests/[reference]/page.tsx b/site/app/requests/[reference]/page.tsx
index dd90ea7..5e4419f 100644
--- a/site/app/requests/[reference]/page.tsx
+++ b/site/app/requests/[reference]/page.tsx
@@ -1,12 +1,15 @@
import Link from "next/link";
import { notFound } from "next/navigation";
-import { ProjectReplyForm } from "@/components/forms";
-import { getCurrentUser } from "@/lib/auth";
+import { ProjectReplyForm } from "@/components/forms";
+import { CommissionDraftCleanup } from "@/components/commission-draft-cleanup";
+import { getCurrentUser } from "@/lib/auth";
+import { lookupProjectStatusAction } from "@/lib/actions";
+import { userCanAccessProject } from "@/lib/commission-security";
import { PageSection, Shell } from "@/components/site-chrome";
import { getProject, listProjectUpdates } from "@/lib/db";
import { formatDateTime, formatLeadTime, formatMoney, sanitizeHtml, toMediaUrl } from "@/lib/format";
-export default async function RequestPage({ params, searchParams }: { params: Promise<{ reference: string }>; searchParams: Promise<{ created?: string; updated?: string; error?: string; email?: string }> }) {
+export default async function RequestPage({ params, searchParams }: { params: Promise<{ reference: string }>; searchParams: Promise<{ created?: string; updated?: string; error?: string }> }) {
const { reference } = await params;
const flags = await searchParams;
const project = getProject(reference);
@@ -15,13 +18,7 @@ export default async function RequestPage({ params, searchParams }: { params: Pr
}
const user = await getCurrentUser();
- const lookupEmail = (flags.email ?? "").trim().toLowerCase();
- const signedInEmail = user?.email?.toLowerCase() ?? "";
- const canView = Boolean(
- user?.role === "admin"
- || (signedInEmail && [project.userEmail, project.guestEmail].filter(Boolean).some((value) => String(value).toLowerCase() === signedInEmail))
- || (lookupEmail && lookupEmail === project.guestEmail.toLowerCase())
- );
+ const canView = await userCanAccessProject(project, user);
if (!canView) {
return (
@@ -34,16 +31,16 @@ export default async function RequestPage({ params, searchParams }: { params: Pr
Enter the same email used during the custom work request or checkout to open this project tracker.
- {flags.created ? The project was created successfully. Enter the buyer email used on the form to open the tracker.
: null}
- {flags.error ? The reference and email did not match a live project record.
: null}
-
-
- Email
-
+ {flags.error ? Private access is missing or expired. Confirm the buyer email to renew access on this browser.
: null}
+
+
+
+ Email
+
Open project
- Use the project status lookup page instead.
+ Use the project status lookup page instead.
);
@@ -52,8 +49,9 @@ export default async function RequestPage({ params, searchParams }: { params: Pr
const updates = listProjectUpdates(project.reference, user?.role === "admin");
const aiPreviewPath = typeof project.options.aiPreviewPath === "string" && project.options.aiPreviewPath ? project.options.aiPreviewPath : null;
- return (
-
+ return (
+
+ {flags.created ? : null}
diff --git a/site/components/commission-draft-cleanup.tsx b/site/components/commission-draft-cleanup.tsx
new file mode 100644
index 0000000..d93b1bc
--- /dev/null
+++ b/site/components/commission-draft-cleanup.tsx
@@ -0,0 +1,12 @@
+"use client";
+
+import { useEffect } from "react";
+
+const STORAGE_KEY = "beaman-commission-draft-v2";
+
+export function CommissionDraftCleanup() {
+ useEffect(() => {
+ window.localStorage.removeItem(STORAGE_KEY);
+ }, []);
+ return null;
+}
diff --git a/site/components/commission-workflow.tsx b/site/components/commission-workflow.tsx
new file mode 100644
index 0000000..6ff1a4c
--- /dev/null
+++ b/site/components/commission-workflow.tsx
@@ -0,0 +1,367 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import { useFormStatus } from "react-dom";
+
+import { submitContactRequestAction } from "@/lib/actions";
+import type { CommissionTypeRecord } from "@/lib/db";
+import { CustomWorkVisualizer3D } from "@/components/visualizer";
+
+const STORAGE_KEY = "beaman-commission-draft-v2";
+const STEPS = [
+ "Intent",
+ "Category",
+ "Room & use",
+ "Dimensions",
+ "Materials",
+ "References",
+ "Preview",
+ "Fulfillment",
+ "Contact",
+ "Review"
+] as const;
+
+type StoredDraft = {
+ values?: Record
;
+ currentStep?: number;
+ idempotencyKey?: string;
+ draftId?: string;
+ draftUpdatedAt?: string;
+};
+
+function serializeForm(form: HTMLFormElement) {
+ const values: Record = {};
+ for (const [name, value] of new FormData(form).entries()) {
+ if (value instanceof File || name === "visualizationSvg") continue;
+ values[name] = String(value);
+ }
+ return values;
+}
+
+function restoreForm(form: HTMLFormElement, values: Record) {
+ for (const [name, value] of Object.entries(values)) {
+ const fields = Array.from(form.elements).filter((field): field is HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement =>
+ (field instanceof HTMLInputElement || field instanceof HTMLSelectElement || field instanceof HTMLTextAreaElement) && field.name === name
+ );
+ for (const field of fields) {
+ if (field instanceof HTMLInputElement && (field.type === "radio" || field.type === "checkbox")) field.checked = field.value === value;
+ else if (!(field instanceof HTMLInputElement && field.type === "hidden")) field.value = value;
+ }
+ }
+}
+
+export function IdempotencyInput() {
+ const inputRef = useRef(null);
+ useEffect(() => {
+ if (inputRef.current && !inputRef.current.value) inputRef.current.value = crypto.randomUUID();
+ }, []);
+ return ;
+}
+
+function CommissionSubmitButton({ ready }: { ready: boolean }) {
+ const { pending } = useFormStatus();
+ return (
+
+ {pending ? "Uploading references and submitting..." : "Submit custom work request"}
+
+ );
+}
+
+export function CommissionWorkflow({
+ commissionTypes,
+ bandwidthLeadTimeDays,
+ queueCount,
+ defaultName = "",
+ defaultEmail = "",
+ signedIn = false
+}: {
+ commissionTypes: CommissionTypeRecord[];
+ bandwidthLeadTimeDays: number;
+ queueCount: number;
+ defaultName?: string;
+ defaultEmail?: string;
+ signedIn?: boolean;
+}) {
+ const formRef = useRef(null);
+ const fileInputRef = useRef(null);
+ const autosaveTimerRef = useRef | null>(null);
+ const draftSaveQueueRef = useRef>(Promise.resolve());
+ const idempotencyRef = useRef("");
+ const draftIdRef = useRef("");
+ const draftUpdatedAtRef = useRef("");
+ const [currentStep, setCurrentStep] = useState(1);
+ const [furthestStep, setFurthestStep] = useState(1);
+ const [idempotencyKey, setIdempotencyKey] = useState("");
+ const [draftId, setDraftId] = useState("");
+ const [planningCategory, setPlanningCategory] = useState(commissionTypes[0]?.slug ?? "other-custom-work");
+ const [requestedDimensions, setRequestedDimensions] = useState({ width: "", depth: "", height: "" });
+ const [saveStatus, setSaveStatus] = useState("Draft saves in this browser.");
+ const [filePreviews, setFilePreviews] = useState>([]);
+ const [revision, setRevision] = useState(0);
+
+ useEffect(() => {
+ const form = formRef.current;
+ if (!form) return;
+ let stored: StoredDraft = {};
+ try { stored = JSON.parse(window.localStorage.getItem(STORAGE_KEY) || "{}") as StoredDraft; } catch { stored = {}; }
+ const key = stored.idempotencyKey && /^[a-zA-Z0-9][a-zA-Z0-9._-]{15,127}$/.test(stored.idempotencyKey) ? stored.idempotencyKey : crypto.randomUUID();
+ idempotencyRef.current = key;
+ draftIdRef.current = stored.draftId ?? "";
+ draftUpdatedAtRef.current = stored.draftUpdatedAt ?? "";
+ setIdempotencyKey(key);
+ setDraftId(draftIdRef.current);
+ const restoredStep = Math.max(1, Math.min(10, Number(stored.currentStep ?? 1)));
+ setCurrentStep(restoredStep);
+ setFurthestStep(restoredStep);
+ if (stored.values) {
+ restoreForm(form, stored.values);
+ setPlanningCategory(stored.values.planningCategory || stored.values.commissionTypeSlug || commissionTypes[0]?.slug || "other-custom-work");
+ setRequestedDimensions({
+ width: stored.values.requestedWidth ?? "",
+ depth: stored.values.requestedDepth ?? "",
+ height: stored.values.requestedHeight ?? ""
+ });
+ }
+
+ if (signedIn) {
+ const draftUrl = draftIdRef.current ? `/api/commissions/draft?id=${encodeURIComponent(draftIdRef.current)}` : "/api/commissions/draft";
+ void fetch(draftUrl, { cache: "no-store" })
+ .then(async (response) => response.ok ? response.json() as Promise<{
+ draft?: { id?: string; payload?: Record; currentStep?: number; updatedAt?: string; status?: string };
+ drafts?: Array<{ id: string; payload?: Record; currentStep?: number; updatedAt?: string; status?: string }>;
+ }> : null)
+ .then((payload) => {
+ const accountDraft = payload?.draft ?? payload?.drafts?.find((candidate) => candidate.status === "draft");
+ if (!accountDraft) return;
+ const values = accountDraft.payload ?? {};
+ restoreForm(form, values);
+ const step = Math.max(1, Math.min(10, Number(accountDraft.currentStep ?? restoredStep)));
+ setCurrentStep(step);
+ setFurthestStep((current) => Math.max(current, step));
+ draftIdRef.current = accountDraft.id ?? draftIdRef.current;
+ draftUpdatedAtRef.current = accountDraft.updatedAt ?? "";
+ setDraftId(draftIdRef.current);
+ setPlanningCategory(values.planningCategory || values.commissionTypeSlug || commissionTypes[0]?.slug || "other-custom-work");
+ setRequestedDimensions({
+ width: values.requestedWidth ?? "",
+ depth: values.requestedDepth ?? "",
+ height: values.requestedHeight ?? ""
+ });
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify({
+ values,
+ currentStep: step,
+ idempotencyKey: idempotencyRef.current,
+ draftId: draftIdRef.current,
+ draftUpdatedAt: draftUpdatedAtRef.current
+ }));
+ setSaveStatus("Resumed the latest saved account draft.");
+ setRevision((current) => current + 1);
+ })
+ .catch(() => setSaveStatus("Browser draft restored. Account sync is temporarily unavailable."));
+ }
+ }, [commissionTypes, signedIn]);
+
+ useEffect(() => () => {
+ if (autosaveTimerRef.current) clearTimeout(autosaveTimerRef.current);
+ filePreviews.forEach((preview) => URL.revokeObjectURL(preview.url));
+ }, [filePreviews]);
+
+ function persistDraft(step = currentStep) {
+ const form = formRef.current;
+ if (!form || !idempotencyRef.current) return Promise.resolve();
+ const values = serializeForm(form);
+ const localDraft: StoredDraft = {
+ values,
+ currentStep: step,
+ idempotencyKey: idempotencyRef.current,
+ draftId: draftIdRef.current,
+ draftUpdatedAt: draftUpdatedAtRef.current
+ };
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(localDraft));
+ setSaveStatus(signedIn ? "Saving account draft..." : "Draft saved in this browser.");
+ if (!signedIn) return Promise.resolve();
+ const save = async () => {
+ try {
+ const response = await fetch("/api/commissions/draft", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ id: draftIdRef.current || null,
+ payload: values,
+ currentStep: step,
+ idempotencyKey: idempotencyRef.current,
+ expectedUpdatedAt: draftUpdatedAtRef.current || null
+ })
+ });
+ const result = await response.json() as { message?: string; draft?: { id: string; updatedAt: string } };
+ if (!response.ok || !result.draft) throw new Error(result.message || "Draft save failed.");
+ draftIdRef.current = result.draft.id;
+ draftUpdatedAtRef.current = result.draft.updatedAt;
+ setDraftId(result.draft.id);
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ ...localDraft, draftId: result.draft.id, draftUpdatedAt: result.draft.updatedAt }));
+ setSaveStatus("Account draft saved.");
+ } catch (error) {
+ setSaveStatus(error instanceof Error ? `${error.message} Browser copy retained.` : "Account sync failed. Browser copy retained.");
+ }
+ };
+ const queued = draftSaveQueueRef.current.then(save, save);
+ draftSaveQueueRef.current = queued;
+ return queued;
+ }
+
+ function scheduleAutosave() {
+ setRevision((current) => current + 1);
+ if (autosaveTimerRef.current) clearTimeout(autosaveTimerRef.current);
+ autosaveTimerRef.current = setTimeout(() => void persistDraft(), 700);
+ }
+
+ function validateStep(step: number) {
+ const section = formRef.current?.querySelector(`[data-commission-step="${step}"]`);
+ if (!section) return true;
+ const fields = Array.from(section.querySelectorAll("input, select, textarea"));
+ for (const field of fields) {
+ if (!field.checkValidity()) {
+ field.reportValidity();
+ field.focus();
+ return false;
+ }
+ }
+ return true;
+ }
+
+ function moveTo(step: number) {
+ const next = Math.max(1, Math.min(10, step));
+ if (next > currentStep && !validateStep(currentStep)) return;
+ setCurrentStep(next);
+ setFurthestStep((current) => Math.max(current, next));
+ void persistDraft(next);
+ formRef.current?.scrollIntoView({ block: "start", behavior: "smooth" });
+ }
+
+ function updateFiles() {
+ filePreviews.forEach((preview) => URL.revokeObjectURL(preview.url));
+ const files = Array.from(fileInputRef.current?.files ?? []);
+ setFilePreviews(files.map((file) => ({ name: file.name, url: URL.createObjectURL(file) })));
+ scheduleAutosave();
+ }
+
+ function removeFile(index: number) {
+ const input = fileInputRef.current;
+ if (!input?.files) return;
+ const transfer = new DataTransfer();
+ Array.from(input.files).forEach((file, fileIndex) => { if (fileIndex !== index) transfer.items.add(file); });
+ input.files = transfer.files;
+ updateFiles();
+ }
+
+ const review = formRef.current ? serializeForm(formRef.current) : {};
+ void revision;
+
+ return (
+
+
+
+
+
+ Company website
+
+
+
+
Step {currentStep} of {STEPS.length} {saveStatus}
+
+ {STEPS.map((label, index) => {
+ const step = index + 1;
+ return furthestStep} onClick={() => moveTo(step)} type="button">{step} {label} ;
+ })}
+
+
+
+
+ What kind of help do you need?
+
+ {[
+ ["new-build", "New commissioned build", "Plan an original piece around a room, use, and dimensions."],
+ ["variation", "Variation on existing work", "Use a portfolio piece as the starting point, then change proportion or material."],
+ ["repair", "Repair or restoration review", "Send condition photos and describe the repair before transport is discussed."]
+ ].map(([value, title, copy], index) => {title} {copy} )}
+
+
+
+
+
+
+
+
+
+
+
+
+ Add room photos, sketches, or measurements
+ Up to 8 images, 20 MB each, 60 MB total
+ {filePreviews.length ? {filePreviews.map((preview, index) =>
{preview.name} removeFile(index)} type="button">Remove )}
: No files selected. References are optional and remain private to this project.
}
+
+
+
+ Preview proportion and estimate
+
+
+
+
+
+
+
+
+ Review and submit
+
Intent {review.intent || "Not set"}
Category {review.planningCategory || review.commissionTypeSlug || "Not set"}
Location {review.cityRegion || review.roomLocation || "Not set"}
Fulfillment {review.deliveryMode || "Not set"}
Contact {review.customerName || defaultName || "Not set"}
+ The displayed estimate is planning guidance, not a quote. The server recalculates materials, labor, overhead, markup, queue load, and lead time from the submitted options before creating the private project.
+ I reviewed the contact details, dimensions, and project brief.
+
+ Files are uploaded only when you submit. Keep this page open until the private project page appears.
+
+
+
+ moveTo(currentStep - 1)} type="button">Back
+ {currentStep < 10 ? moveTo(currentStep + 1)} type="button">Save and continue : null}
+
+
+ );
+}
diff --git a/site/components/forms.tsx b/site/components/forms.tsx
index ebbfcf3..8833713 100644
--- a/site/components/forms.tsx
+++ b/site/components/forms.tsx
@@ -10,20 +10,30 @@ import {
updateProfileAction
} from "@/lib/actions";
import type { CommissionTypeRecord, PieceRecord, ProjectRecord, UserRecord } from "@/lib/db";
-import { CustomWorkVisualizer3D } from "@/components/visualizer";
+import { CommissionWorkflow, IdempotencyInput } from "@/components/commission-workflow";
import { ProfileAvatarFields } from "@/components/profile-avatar-fields";
-export function ContactRequestForm({ commissionTypes, bandwidthLeadTimeDays, queueCount, piece }: {
- commissionTypes: CommissionTypeRecord[];
- bandwidthLeadTimeDays: number;
- queueCount: number;
- piece?: PieceRecord | null;
-}) {
- return (
-
+export function ContactRequestForm({ commissionTypes, bandwidthLeadTimeDays, queueCount, piece, defaultName, defaultEmail, signedIn }: {
+ commissionTypes: CommissionTypeRecord[];
+ bandwidthLeadTimeDays: number;
+ queueCount: number;
+ piece?: PieceRecord | null;
+ defaultName?: string;
+ defaultEmail?: string;
+ signedIn?: boolean;
+}) {
+ if (!piece) {
+ return ;
+ }
+ const pieceType = commissionTypes.find((type) => type.slug === piece.commissionTypeSlug);
+ const materialOptions = pieceType?.materialOptions ?? commissionTypes.flatMap((type) => type.materialOptions).filter((option, index, all) => all.indexOf(option) === index);
+ return (
+
+
{piece ? : null}
-
+
+ Company website
Your name
@@ -48,14 +58,13 @@ export function ContactRequestForm({ commissionTypes, bandwidthLeadTimeDays, que
- {!piece ? : null}
{piece ? (
Material preference
Open to recommendation
- {commissionTypes.flatMap((type) => type.materialOptions).filter((option, index, all) => all.indexOf(option) === index).map((option) => (
+ {materialOptions.map((option) => (
{option}
))}
@@ -182,16 +191,12 @@ export function ProfileForm({ user }: { user: UserRecord }) {
);
}
-export function ProjectReplyForm({ project }: { project: ProjectRecord }) {
- return (
-
-
-
- Email
-
-
-
- Reply
+export function ProjectReplyForm({ project }: { project: ProjectRecord }) {
+ return (
+
+
+
+ Reply
Post update
diff --git a/site/components/visualizer.tsx b/site/components/visualizer.tsx
index f36ba8c..a484260 100644
--- a/site/components/visualizer.tsx
+++ b/site/components/visualizer.tsx
@@ -148,15 +148,26 @@ function downloadSvg(svg: string, kind: string) {
URL.revokeObjectURL(objectUrl);
}
-export function CustomWorkVisualizer3D({ commissionTypes, bandwidthLeadTimeDays, queueCount }: {
- commissionTypes: CommissionTypeOption[];
- bandwidthLeadTimeDays: number;
- queueCount: number;
-}) {
- const availableTypes = commissionTypes.length > 0 ? commissionTypes : [FALLBACK_COMMISSION_TYPE];
- const [selectedSlug, setSelectedSlug] = useState(availableTypes[0].slug);
+export function CustomWorkVisualizer3D({ commissionTypes, bandwidthLeadTimeDays, queueCount, initialTypeSlug, initialDimensions, lockType = false }: {
+ commissionTypes: CommissionTypeOption[];
+ bandwidthLeadTimeDays: number;
+ queueCount: number;
+ initialTypeSlug?: string;
+ initialDimensions?: { width: number; depth: number; height: number };
+ lockType?: boolean;
+}) {
+ const availableTypes = commissionTypes.length > 0
+ ? commissionTypes.some((type) => type.slug === FALLBACK_COMMISSION_TYPE.slug) ? commissionTypes : [...commissionTypes, FALLBACK_COMMISSION_TYPE]
+ : [FALLBACK_COMMISSION_TYPE];
+ const initialType = availableTypes.find((type) => type.slug === initialTypeSlug) ?? availableTypes[0];
+ const [selectedSlug, setSelectedSlug] = useState(initialType.slug);
const selectedType = availableTypes.find((type) => type.slug === selectedSlug) ?? availableTypes[0];
- const [state, setState] = useState(defaultVisualizerState(availableTypes[0].slug));
+ const [state, setState] = useState(() => normalizeVisualizerState({
+ ...defaultVisualizerState(initialType.slug),
+ width: Number.isFinite(initialDimensions?.width) && Number(initialDimensions?.width) > 0 ? Number(initialDimensions?.width) : initialType.defaultDimensions.width,
+ depth: Number.isFinite(initialDimensions?.depth) && Number(initialDimensions?.depth) > 0 ? Number(initialDimensions?.depth) : initialType.defaultDimensions.depth,
+ height: Number.isFinite(initialDimensions?.height) && Number(initialDimensions?.height) > 0 ? Number(initialDimensions?.height) : initialType.defaultDimensions.height
+ }));
const [isGenerating, startGeneration] = useTransition();
const [renderedPreview, setRenderedPreview] = useState<{ url: string; relativePath?: string; message: string } | null>(null);
@@ -238,7 +249,7 @@ export function CustomWorkVisualizer3D({ commissionTypes, bandwidthLeadTimeDays,
-
+ {lockType ? Piece type {selectedType.label} :
Piece type
{availableTypes.map((type) => {type.label} )}
-
+ }
{selectedType.description}
Primary material
diff --git a/site/lib/actions.ts b/site/lib/actions.ts
index 9da5f4b..ce8d035 100644
--- a/site/lib/actions.ts
+++ b/site/lib/actions.ts
@@ -7,9 +7,13 @@ import {
appendProjectUpdate,
countMedia,
countUsersByRole,
+ consumeCommissionRenderAsset,
+ consumeCommissionSubmissionQuota,
createDraftOrder,
createProject,
+ createProjectIdempotent,
deleteCommissionType,
+ deleteMediaRecordAndReferences,
deletePage,
deletePiece,
deletePost,
@@ -17,6 +21,8 @@ import {
deleteUserProfile,
getOrder,
getMedia,
+ getBandwidthSnapshot,
+ getCommissionType,
getPage,
getPiece,
getPost,
@@ -29,6 +35,8 @@ import {
listPieceMediaLinks,
listPieces,
markEmailVerified,
+ markCommissionDraftSubmitted,
+ rollbackCommissionSubmission,
patchMediaMetadata,
refreshMediaLibrary,
removeCartItem,
@@ -46,7 +54,6 @@ import {
setPasswordResetToken,
updateProject,
withDatabaseTransaction,
- deleteMediaRecordAndReferences,
finishMediaRenameHistory,
recordAdminEditAudit,
renameMediaRecordAndReferences,
@@ -67,6 +74,7 @@ import {
import { clearSession, createPasswordHash, createSession, getCurrentUser, requireAdmin, requireUser, verifyLogin } from "@/lib/auth";
import {
finalizeStagedMediaDeletion,
+ deleteMediaAsset,
moveMediaAsset,
persistGeneratedMedia,
persistUploadedMedia,
@@ -84,6 +92,8 @@ import { normalizeBuiltinCategoryIcon, sanitizeCategoryIconSvg } from "@/lib/cat
import { normalizeFooterConfiguration, normalizeHomeServices } from "@/lib/site-structure";
import { normalizePieceMediaLinks } from "@/lib/piece-media";
import { getPieceInquiryMode, getPiecePriceMode, getPieceReviewsMode, pieceAcceptsReviews, pieceAllowsInquiry, pieceCanEnterCart } from "@/lib/piece-model";
+import { calculateEstimate, normalizeVisualizerState } from "@/lib/estimator";
+import { commissionOwnerKey, grantProjectBrowserAccess, userCanAccessProject } from "@/lib/commission-security";
function revalidatePagePaths(slug: string) {
revalidatePath("/", "layout");
revalidatePath("/");
@@ -551,232 +561,237 @@ export async function startCheckoutAction(formData: FormData) {
redirect(`/shop/cart?checkout=configuration-needed&order=${encodeURIComponent(orderNumber)}`);
}
+function commissionAttachments(formData: FormData) {
+ const files = formData.getAll("attachments").filter((entry): entry is File => entry instanceof File && entry.size > 0);
+ if (files.length > 8) throw new Error("Attach no more than eight reference images.");
+ const totalBytes = files.reduce((sum, file) => sum + file.size, 0);
+ if (totalBytes > 60 * 1024 * 1024) throw new Error("Reference uploads may not exceed 60 MB in total.");
+ for (const file of files) {
+ if (file.size > 20 * 1024 * 1024 || !file.type.toLowerCase().startsWith("image/")) {
+ throw new Error("Each reference must be an image smaller than 20 MB.");
+ }
+ }
+ return files;
+}
+
+function serverCommissionEstimate(formData: FormData, requestType: string, dimensions: { width: number; depth: number; height: number; unit: string } | null, options: Record) {
+ const commissionType = getCommissionType(requestType);
+ if (requestType && (!commissionType || !commissionType.active)) throw new Error("Select an active commission type.");
+ const defaults = commissionType?.defaultDimensions ?? { width: 48, depth: 24, height: 30, unit: "in" as const };
+ const materialPreference = optionalField(formData.get("materialPreference")) || commissionType?.materialOptions[0] || "White Oak";
+ if (commissionType && !commissionType.materialOptions.includes(materialPreference)) throw new Error("Select a material allowed for this commission type.");
+ const state = normalizeVisualizerState({
+ kind: requestType || "other-custom-work",
+ material: materialPreference,
+ joinery: optionalField(formData.get("joineryPreference")) || String(options.joinery ?? "Mortise and tenon"),
+ width: Number(dimensions?.width ?? defaults.width),
+ depth: Number(dimensions?.depth ?? defaults.depth),
+ height: Number(dimensions?.height ?? defaults.height),
+ drawers: Number(options.drawers ?? formData.get("drawers") ?? 0),
+ shelves: Number(options.shelves ?? formData.get("shelves") ?? 0),
+ notes: optionalField(formData.get("visualizerNotes")),
+ includeVisualization: optionalField(formData.get("includeVisualization")) === "1"
+ });
+ const bandwidth = getBandwidthSnapshot();
+ return { state, estimate: calculateEstimate(state, bandwidth.activeProjects, bandwidth.leadTimeDays) };
+}
+
export async function submitContactRequestAction(formData: FormData) {
const user = await getCurrentUser();
- const guestName = requiredField(formData.get("customerName"), "Your name");
+ const guestName = requiredField(formData.get("customerName"), "Your name").slice(0, 120);
const guestEmail = requiredField(formData.get("email"), "Email").toLowerCase();
- const message = requiredField(formData.get("message"), "Project details");
- const materialPreference = optionalField(formData.get("materialPreference"));
- const materials = parseJsonField(formData.get("materials"), materialPreference ? [materialPreference] : []);
- const dimensions = parseJsonField<{ width: number; depth: number; height: number; unit: string } | null>(formData.get("dimensionsJson"), null);
+ if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(guestEmail)) throw new Error("Enter a valid email address.");
+ const message = requiredField(formData.get("message") || formData.get("brief"), "Project details").slice(0, 20_000);
+ const files = commissionAttachments(formData);
+ const requestedPieceSlug = optionalField(formData.get("pieceSlug")) || null;
+ let requestedPiece: ReturnType = null;
+ if (requestedPieceSlug) {
+ requestedPiece = getPiece(requestedPieceSlug);
+ if (!requestedPiece || !pieceAllowsInquiry(requestedPiece)) redirect(`/contact?error=${encodeURIComponent("This piece is not currently accepting inquiries.")}`);
+ }
+
+ const requestType = optionalField(formData.get("commissionTypeSlug")) || requestedPiece?.commissionTypeSlug || "other-custom-work";
+ const visualizerDimensions = parseJsonField<{ width: number; depth: number; height: number; unit: string } | null>(formData.get("dimensionsJson"), null);
+ const requestedWidth = Number(formData.get("requestedWidth"));
+ const requestedDepth = Number(formData.get("requestedDepth"));
+ const requestedHeight = Number(formData.get("requestedHeight"));
+ const dimensions = [requestedWidth, requestedDepth, requestedHeight].every((value) => Number.isFinite(value) && value > 0)
+ ? { width: requestedWidth, depth: requestedDepth, height: requestedHeight, unit: "in" }
+ : visualizerDimensions;
const visualizerOptions = parseJsonField>(formData.get("visualizerOptions"), {});
- const estimatedTotalCents = parseOptionalInteger(formData.get("estimatedTotalCents"));
+ const { state, estimate } = serverCommissionEstimate(formData, requestType, dimensions, visualizerOptions);
+ const materialPreference = state.material;
+ const materials = [...new Set([materialPreference, state.joinery].filter(Boolean))];
const includeVisualization = optionalField(formData.get("includeVisualization")) === "1";
+ const cityRegion = optionalField(formData.get("cityRegion")).slice(0, 200);
const deliveryMode = optionalField(formData.get("deliveryMode"));
- const requestType = optionalField(formData.get("commissionTypeSlug"));
- const cityRegion = optionalField(formData.get("cityRegion"));
- const leadTimeDays = parseInteger(formData.get("leadTimeDays"), 0);
+ if (deliveryMode && !["pickup", "local-delivery", "shipment"].includes(deliveryMode)) throw new Error("Delivery preference is invalid.");
+ const idempotencyKey = requiredField(formData.get("idempotencyKey"), "Submission key");
+ if (optionalField(formData.get("companyWebsite"))) throw new Error("The request could not be submitted.");
+ const requestSource = optionalField(formData.get("requestSource")) || "commissions-workflow";
+ if (requestSource === "commissions-workflow" && optionalField(formData.get("accuracyConfirmation")) !== "1") throw new Error("Confirm the request details before submitting.");
+ const ownerKey = await commissionOwnerKey(user?.email);
+ const submissionQuota = consumeCommissionSubmissionQuota(ownerKey, user ? 12 : 5);
+ if (!submissionQuota.allowed) throw new Error(`Too many requests were submitted from this browser. Try again in about ${Math.ceil(submissionQuota.retryAfterSeconds / 60)} minutes.`);
const aiPreviewPath = optionalField(formData.get("aiPreviewPath"));
-
- const requestedPieceSlug = optionalField(formData.get("pieceSlug")) || null;
- if (requestedPieceSlug) {
- const requestedPiece = getPiece(requestedPieceSlug);
- if (!requestedPiece || !pieceAllowsInquiry(requestedPiece)) {
- redirect(`/contact?error=${encodeURIComponent("This piece is not currently accepting inquiries.")}`);
+ const stagedUploads: string[] = [];
+ try {
+ for (const file of files) {
+ stagedUploads.push(await persistUploadedMedia(file, `commission-staging/${idempotencyKey.slice(0, 24)}`, {
+ maxBytes: 20 * 1024 * 1024,
+ allowedMimePrefixes: ["image/"],
+ allowedExtensions: [".jpg", ".jpeg", ".png", ".webp", ".gif", ".heic", ".heif", ".avif"]
+ }));
}
+ } catch (error) {
+ stagedUploads.forEach((relativePath) => deleteMediaAsset(relativePath));
+ throw error;
}
- const reference = createProject({
+ let result: ReturnType;
+ try {
+ result = createProjectIdempotent({
userEmail: user?.email ?? null,
guestName,
guestEmail,
pieceSlug: requestedPieceSlug,
- commissionTypeSlug: requestType || null,
+ commissionTypeSlug: requestType,
kind: "commission",
status: "Request received",
stage: "Contact review",
budgetCents: (parseInteger(formData.get("budgetDollars"), 0) || parseInteger(formData.get("budgetCents"), 0)) * (formData.get("budgetDollars") ? 100 : 1) || null,
- estimatedTotalCents,
- estimator: visualizerOptions,
+ estimatedTotalCents: estimate.totalCents,
+ estimator: { ...estimate, schemaVersion: 2, calculatedBy: "server" },
brief: message,
materials,
- dimensions,
+ dimensions: { width: state.width, depth: state.depth, height: state.height, unit: "in" },
options: {
+ intent: optionalField(formData.get("intent")),
+ referencePieceSlug: optionalField(formData.get("referencePieceSlug")),
+ roomUse: optionalField(formData.get("roomUse")),
+ roomLocation: optionalField(formData.get("roomLocation")),
+ functionalLoad: optionalField(formData.get("functionalLoad")),
+ fitConstraints: optionalField(formData.get("fitConstraints")),
+ finishPreference: optionalField(formData.get("finishPreference")),
+ hardwarePreference: optionalField(formData.get("hardwarePreference")),
+ timingPreference: optionalField(formData.get("timingPreference")),
phone: optionalField(formData.get("phone")),
cityRegion,
deliveryMode,
- requestSource: optionalField(formData.get("requestSource")) || "contact-form",
+ requestSource,
materialPreference,
- visualizerOptions,
- aiPreviewPath
+ visualizerOptions: { ...visualizerOptions, serverState: state },
+ aiPreviewPath: ""
},
visualizationSvg: includeVisualization ? optionalField(formData.get("visualizationSvg")) || null : null,
includeVisualization,
- leadTimeDays,
+ leadTimeDays: estimate.leadTimeDays,
shippingAddress: cityRegion ? { cityRegion } : {},
billingAddress: { email: guestEmail }
- });
-
- if (aiPreviewPath) {
- const existingPreview = getMedia(aiPreviewPath);
- if (existingPreview) {
- saveMediaMetadata({
- relativePath: aiPreviewPath,
- altText: existingPreview.altText || `${reference} AI preview`,
- pieceSlug: existingPreview.pieceSlug,
- postSlug: existingPreview.postSlug,
- pageSlug: existingPreview.pageSlug,
- projectReference: reference,
- userEmail: guestEmail,
- focalX: existingPreview.focalX,
- focalY: existingPreview.focalY,
- zoom: existingPreview.zoom,
- reviewed: false,
- tags: [...new Set([...existingPreview.tags, "project", reference, "ai-preview"])],
- metadata: { ...existingPreview.metadata, projectReference: reference, attachedToRequestAt: new Date().toISOString() }
- });
- }
- }
-
- const files = formData.getAll("attachments").filter((entry): entry is File => entry instanceof File && entry.size > 0);
- for (const file of files) {
- const relativePath = await persistUploadedMedia(file, `projects/${reference}`);
- saveMediaMetadata({
- relativePath,
- altText: `${reference} reference image`,
- projectReference: reference,
- userEmail: guestEmail,
- focalX: 50,
- focalY: 50,
- zoom: 1,
- reviewed: true,
- tags: ["project", reference, "reference"]
- });
+ }, idempotencyKey);
+ } catch (error) {
+ stagedUploads.forEach((relativePath) => deleteMediaAsset(relativePath));
+ throw error;
}
+ const reference = result.reference;
- appendProjectUpdate({
- projectReference: reference,
- authorEmail: guestEmail,
- authorRole: user ? "buyer-account" : "buyer",
- visibility: "public",
- body: message
- });
-
- const statusUrl = `${resolveBaseUrl()}/commissions/status?reference=${encodeURIComponent(reference)}&email=${encodeURIComponent(guestEmail)}`;
- await sendNotificationEmail({
- category: "contact_request",
- to: [guestEmail, getSiteSettings().builderEmail],
- subject: `New Beaman Woodworks request: ${reference}`,
- text: `Your Beaman Woodworks request reference is ${reference}. Review status at ${statusUrl}.`,
- html: `Your Beaman Woodworks request reference is ${reference} .
Review status at ${statusUrl} .
`
- });
+ if (!result.created) stagedUploads.forEach((relativePath) => deleteMediaAsset(relativePath));
- revalidatePath("/about");
- revalidatePath("/shop");
- revalidatePath("/portfolio");
- revalidatePath("/studio");
- redirect(`/requests/${reference}?created=1&email=${encodeURIComponent(guestEmail)}`);
-}
-export async function submitCommissionAction(formData: FormData) {
- const user = await getCurrentUser();
- const guestName = requiredField(formData.get("customerName"), "Your name");
- const guestEmail = requiredField(formData.get("email"), "Email").toLowerCase();
- const materials = parseJsonField(formData.get("materials"), []);
- const dimensions = parseJsonField<{ width: number; depth: number; height: number; unit: string } | null>(formData.get("dimensionsJson"), null);
- const options = parseJsonField>(formData.get("visualizerOptions"), {});
- const estimatedTotalCents = parseInteger(formData.get("estimatedTotalCents"), 0);
- const leadTimeDays = parseInteger(formData.get("leadTimeDays"), 0);
- const aiPreviewPath = optionalField(formData.get("aiPreviewPath"));
- const requestedPieceSlug = optionalField(formData.get("pieceSlug")) || null;
- if (requestedPieceSlug) {
- const requestedPiece = getPiece(requestedPieceSlug);
- if (!requestedPiece || !pieceAllowsInquiry(requestedPiece)) {
- redirect(`/commissions?error=${encodeURIComponent("This piece is not currently accepting custom requests.")}`);
+ if (result.created) {
+ const movedUploads: string[] = [];
+ try {
+ for (const stagedPath of stagedUploads) {
+ const fileName = stagedPath.split("/").at(-1) ?? `reference-${crypto.randomUUID()}.jpg`;
+ movedUploads.push(moveMediaAsset(stagedPath, `projects/${reference}/references/${fileName}`));
+ }
+ for (const relativePath of movedUploads) {
+ saveMediaMetadata({
+ relativePath,
+ altText: `${reference} buyer reference image`,
+ projectReference: reference,
+ userEmail: guestEmail,
+ focalX: 50,
+ focalY: 50,
+ zoom: 1,
+ reviewed: false,
+ tags: ["project", reference, "buyer-reference"],
+ metadata: { privateProjectMedia: true, uploadedAt: new Date().toISOString() }
+ });
+ }
+ } catch (error) {
+ for (const relativePath of [...stagedUploads, ...movedUploads]) {
+ if (movedUploads.includes(relativePath)) {
+ try { deleteMediaRecordAndReferences(relativePath); } catch { /* Continue removing staged files and the retry key. */ }
+ }
+ try { deleteMediaAsset(relativePath); } catch { /* Best-effort cleanup; rollback still removes the project key. */ }
+ }
+ rollbackCommissionSubmission(reference, idempotencyKey);
+ throw error;
}
- }
- const reference = createProject({
- userEmail: user?.email ?? null,
- guestName,
- guestEmail,
- pieceSlug: requestedPieceSlug,
- commissionTypeSlug: optionalField(formData.get("commissionTypeSlug")) || null,
- kind: "commission",
- status: "Brief received",
- stage: "Review",
- budgetCents: (parseInteger(formData.get("budgetDollars"), 0) || parseInteger(formData.get("budgetCents"), 0)) * (formData.get("budgetDollars") ? 100 : 1) || null,
- estimatedTotalCents,
- estimator: { laborHours: options.drawers ? 4 + Number(options.drawers) : undefined },
- brief: requiredField(formData.get("brief"), "Project brief"),
- materials,
- dimensions,
- options: { ...options, aiPreviewPath },
- visualizationSvg: optionalField(formData.get("visualizationSvg")) || null,
- includeVisualization: optionalField(formData.get("includeVisualization")) === "1",
- leadTimeDays,
- shippingAddress: {},
- billingAddress: { email: guestEmail }
- });
- if (aiPreviewPath) {
- const existingPreview = getMedia(aiPreviewPath);
- if (existingPreview) {
+ let ownedPreviewPath = "";
+ if (includeVisualization && aiPreviewPath && getMedia(aiPreviewPath) && consumeCommissionRenderAsset(aiPreviewPath, ownerKey, reference)) {
+ ownedPreviewPath = aiPreviewPath;
+ const preview = getMedia(aiPreviewPath)!;
saveMediaMetadata({
relativePath: aiPreviewPath,
- altText: existingPreview.altText || `${reference} AI preview`,
- pieceSlug: existingPreview.pieceSlug,
- postSlug: existingPreview.postSlug,
- pageSlug: existingPreview.pageSlug,
+ altText: preview.altText || `${reference} conceptual AI preview`,
+ pieceSlug: preview.pieceSlug,
+ postSlug: preview.postSlug,
+ pageSlug: preview.pageSlug,
projectReference: reference,
userEmail: guestEmail,
- focalX: existingPreview.focalX,
- focalY: existingPreview.focalY,
- zoom: existingPreview.zoom,
+ focalX: preview.focalX,
+ focalY: preview.focalY,
+ zoom: preview.zoom,
reviewed: false,
- tags: [...new Set([...existingPreview.tags, "project", reference, "ai-preview"])],
- metadata: { ...existingPreview.metadata, projectReference: reference, attachedToRequestAt: new Date().toISOString() }
+ tags: [...new Set([...preview.tags, "project", reference, "ai-preview"])],
+ metadata: { ...preview.metadata, projectReference: reference, attachedToRequestAt: new Date().toISOString() }
});
+ const project = getProject(reference)!;
+ updateProject(reference, { options: { ...project.options, aiPreviewPath: ownedPreviewPath } });
}
- }
-
- const files = formData.getAll("attachments").filter((entry): entry is File => entry instanceof File && entry.size > 0);
- for (const file of files) {
- const relativePath = await persistUploadedMedia(file, `projects/${reference}`);
- saveMediaMetadata({
- relativePath,
- altText: `${reference} reference image`,
- projectReference: reference,
- userEmail: guestEmail,
- focalX: 50,
- focalY: 50,
- zoom: 1,
- reviewed: true,
- tags: ["project", reference]
+ appendProjectUpdate({ projectReference: reference, authorEmail: guestEmail, authorRole: user ? "buyer-account" : "buyer", visibility: "public", body: message });
+ const statusUrl = `${resolveBaseUrl()}/commissions/status`;
+ await sendNotificationEmail({
+ category: "commission_submitted",
+ to: [guestEmail, getSiteSettings().builderEmail],
+ subject: `Custom work request received: ${reference}`,
+ text: `Your Beaman Woodworks project reference is ${reference}. Open ${statusUrl} and enter the reference with your email to view updates.`,
+ html: `Your Beaman Woodworks project reference is ${reference} .
Open ${statusUrl} and enter the reference with your email to view updates.
`
});
+ const draftId = optionalField(formData.get("draftId"));
+ if (draftId && user) markCommissionDraftSubmitted(draftId, user.email, reference);
}
- appendProjectUpdate({
- projectReference: reference,
- authorEmail: guestEmail,
- authorRole: user ? "buyer-account" : "buyer",
- visibility: "public",
- body: requiredField(formData.get("brief"), "Project brief")
- });
- const statusUrl = `${resolveBaseUrl()}/commissions/status?reference=${encodeURIComponent(reference)}&email=${encodeURIComponent(guestEmail)}`;
- await sendNotificationEmail({
- category: "commission_submitted",
- to: [guestEmail, getSiteSettings().builderEmail],
- subject: `Commission received: ${reference}`,
- text: `Your commission reference is ${reference}. Review status at ${statusUrl}.`,
- html: `Your commission reference is ${reference} .
Review status at ${statusUrl} .
`
- });
-
+ await grantProjectBrowserAccess(reference);
revalidatePath("/commissions");
revalidatePath("/studio");
- redirect(`/requests/${reference}?created=1&email=${encodeURIComponent(guestEmail)}`);
+ redirect(`/requests/${reference}?created=1`);
}
-export async function submitProjectReplyAction(formData: FormData) {
- const reference = requiredField(formData.get("reference"), "Reference");
+export async function submitCommissionAction(formData: FormData) {
+ return submitContactRequestAction(formData);
+}
+
+export async function lookupProjectStatusAction(formData: FormData) {
+ const reference = requiredField(formData.get("reference"), "Reference").toUpperCase();
const email = requiredField(formData.get("email"), "Email").toLowerCase();
- const body = requiredField(formData.get("body"), "Message");
const project = getProject(reference);
- if (!project || project.guestEmail.toLowerCase() !== email) {
- redirect(`/requests/${reference}?error=lookup`);
- }
+ if (!project || project.guestEmail.toLowerCase() !== email) redirect(`/commissions/status?error=not-found&reference=${encodeURIComponent(reference)}`);
+ await grantProjectBrowserAccess(reference);
+ redirect(`/requests/${reference}`);
+}
- appendProjectUpdate({ projectReference: reference, authorEmail: email, authorRole: "buyer", visibility: "public", body });
+export async function submitProjectReplyAction(formData: FormData) {
+ const reference = requiredField(formData.get("reference"), "Reference");
+ const body = requiredField(formData.get("body"), "Message").slice(0, 10_000);
+ const project = getProject(reference);
+ const user = await getCurrentUser();
+ if (!project || !await userCanAccessProject(project, user)) redirect(`/requests/${reference}?error=access`);
+ appendProjectUpdate({ projectReference: reference, authorEmail: user?.email ?? project.guestEmail, authorRole: user ? "buyer-account" : "buyer", visibility: "public", body });
revalidatePath(`/requests/${reference}`);
- redirect(`/requests/${reference}?updated=1&email=${encodeURIComponent(email)}`);
+ redirect(`/requests/${reference}?updated=1`);
}
export async function submitReviewAction(formData: FormData) {
diff --git a/site/lib/commission-security.ts b/site/lib/commission-security.ts
new file mode 100644
index 0000000..7686067
--- /dev/null
+++ b/site/lib/commission-security.ts
@@ -0,0 +1,43 @@
+import { createHash } from "node:crypto";
+import { cookies, headers } from "next/headers";
+
+import { createProjectAccessGrant, projectAccessGrantValid, type ProjectRecord, type UserRecord } from "@/lib/db";
+
+function projectCookieName(reference: string) {
+ return `bw_project_${createHash("sha256").update(reference).digest("hex").slice(0, 16)}`;
+}
+
+export async function commissionOwnerKey(userEmail?: string | null) {
+ if (userEmail) return `user:${userEmail.trim().toLowerCase()}`;
+ const requestHeaders = await headers();
+ const forwardedFor = requestHeaders.get("cf-connecting-ip")
+ || requestHeaders.get("x-forwarded-for")?.split(",")[0]?.trim()
+ || "unknown-address";
+ const userAgent = requestHeaders.get("user-agent")?.slice(0, 300) || "unknown-agent";
+ return `guest:${forwardedFor}:${userAgent}`;
+}
+
+export async function grantProjectBrowserAccess(reference: string) {
+ const token = createProjectAccessGrant(reference);
+ const cookieStore = await cookies();
+ cookieStore.set(projectCookieName(reference), token, {
+ httpOnly: true,
+ sameSite: "lax",
+ secure: process.env.NODE_ENV === "production",
+ path: "/",
+ maxAge: 60 * 60 * 24 * 30
+ });
+}
+
+export async function projectBrowserAccessValid(reference: string) {
+ const cookieStore = await cookies();
+ const token = cookieStore.get(projectCookieName(reference))?.value ?? "";
+ return Boolean(token && projectAccessGrantValid(reference, token));
+}
+
+export async function userCanAccessProject(project: ProjectRecord, user: UserRecord | null) {
+ if (user?.role === "admin") return true;
+ const signedInEmail = user?.email.toLowerCase() ?? "";
+ if (signedInEmail && [project.userEmail, project.guestEmail].filter(Boolean).some((value) => String(value).toLowerCase() === signedInEmail)) return true;
+ return projectBrowserAccessValid(project.reference);
+}
diff --git a/site/lib/commission-workflow.test.mts b/site/lib/commission-workflow.test.mts
new file mode 100644
index 0000000..d5cc167
--- /dev/null
+++ b/site/lib/commission-workflow.test.mts
@@ -0,0 +1,130 @@
+import assert from "node:assert/strict";
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import test from "node:test";
+import { DatabaseSync } from "node:sqlite";
+
+import type { ProjectInput } from "./db.ts";
+
+test("commission drafts, idempotency, capabilities, and render ownership persist safely", async () => {
+ const root = mkdtempSync(path.join(tmpdir(), "woodsmith-commission-"));
+ process.env.NODE_ENV = "test";
+ process.env.DATA_ROOT = path.join(root, "data");
+ process.env.MEDIA_ROOT = path.join(root, "media");
+ const db = await import("./db.ts");
+ const media = await import("./media.ts");
+ try {
+ db.getRuntimePersistenceStatus();
+ const draft = db.saveCommissionDraftForUser({
+ userEmail: "buyer@example.com",
+ payload: { intent: "new-build" },
+ currentStep: 2,
+ idempotencyKey: "draft-key-1234567890"
+ });
+ assert.equal(draft.currentStep, 2);
+ const updated = db.saveCommissionDraftForUser({
+ id: draft.id,
+ userEmail: "buyer@example.com",
+ payload: { intent: "new-build", room: "Dining room" },
+ currentStep: 3,
+ idempotencyKey: "draft-key-1234567890",
+ expectedUpdatedAt: draft.updatedAt
+ });
+ assert.equal(updated.currentStep, 3);
+ assert.throws(() => db.saveCommissionDraftForUser({
+ id: draft.id,
+ userEmail: "buyer@example.com",
+ payload: {},
+ currentStep: 4,
+ idempotencyKey: "draft-key-1234567890",
+ expectedUpdatedAt: draft.updatedAt
+ }), /another session/);
+
+ const projectInput: ProjectInput = {
+ userEmail: null,
+ guestName: "Buyer",
+ guestEmail: "buyer@example.com",
+ commissionTypeSlug: "hallway-bench",
+ kind: "commission",
+ status: "Request received",
+ stage: "Contact review",
+ estimatedTotalCents: 100000,
+ estimator: { calculatedBy: "server" },
+ brief: "A bench for a narrow entry.",
+ materials: ["White Oak"],
+ dimensions: { width: 60, depth: 15, height: 18, unit: "in" }
+ };
+ const first = db.createProjectIdempotent(projectInput, "submission-key-1234567890");
+ const second = db.createProjectIdempotent(projectInput, "submission-key-1234567890");
+ assert.equal(first.created, true);
+ assert.equal(second.created, false);
+ assert.equal(second.reference, first.reference);
+
+ const retryable = db.createProjectIdempotent(projectInput, "retryable-key-1234567890");
+ assert.equal(db.rollbackCommissionSubmission(retryable.reference, "wrong-key-1234567890"), false);
+ assert.equal(db.rollbackCommissionSubmission(retryable.reference, "retryable-key-1234567890"), true);
+ assert.equal(db.getProject(retryable.reference), null);
+ assert.equal(db.createProjectIdempotent(projectInput, "retryable-key-1234567890").created, true);
+
+ await assert.rejects(
+ media.persistUploadedMedia(new File([" "], "reference.svg", { type: "image/svg+xml" }), "commission-staging/test"),
+ /file type is not allowed/
+ );
+ const nestedUpload = await media.persistUploadedMedia(
+ new File([new Uint8Array([0xff, 0xd8, 0xff, 0xd9])], "Reference Photo.JPG", { type: "image/jpeg" }),
+ "commission-staging/submission-test",
+ { maxBytes: 1024, allowedMimePrefixes: ["image/"], allowedExtensions: [".jpg"] }
+ );
+ assert.match(nestedUpload, /^commission-staging\/submission-test\/reference-photo-[a-f0-9]{8}\.jpg$/);
+ db.saveMediaMetadata({
+ relativePath: nestedUpload,
+ altText: "Buyer reference",
+ projectReference: first.reference,
+ userEmail: "buyer@example.com",
+ focalX: 50,
+ focalY: 50,
+ zoom: 1,
+ reviewed: false,
+ tags: ["buyer-reference"]
+ });
+ assert.equal(db.getMedia(nestedUpload)?.projectReference, first.reference);
+
+ const accessToken = db.createProjectAccessGrant(first.reference, 1);
+ assert.equal(db.projectAccessGrantValid(first.reference, accessToken), true);
+ assert.equal(db.projectAccessGrantValid(first.reference, "wrong-token"), false);
+
+ assert.equal(db.consumeCommissionRenderQuota("guest:test", 2, 60_000).allowed, true);
+ assert.equal(db.consumeCommissionRenderQuota("guest:test", 2, 60_000).allowed, true);
+ assert.equal(db.consumeCommissionRenderQuota("guest:test", 2, 60_000).allowed, false);
+ assert.equal(db.consumeCommissionSubmissionQuota("guest:submission", 2, 60_000).allowed, true);
+ assert.equal(db.consumeCommissionSubmissionQuota("guest:submission", 2, 60_000).allowed, true);
+ assert.equal(db.consumeCommissionSubmissionQuota("guest:submission", 2, 60_000).allowed, false);
+ db.registerCommissionRenderAsset("ai-renderings/test.png", "guest:test");
+ assert.equal(db.consumeCommissionRenderAsset("ai-renderings/test.png", "guest:other", first.reference), false);
+ assert.equal(db.consumeCommissionRenderAsset("ai-renderings/test.png", "guest:test", first.reference), true);
+ assert.equal(db.consumeCommissionRenderAsset("ai-renderings/test.png", "guest:test", first.reference), false);
+
+ db.markCommissionDraftSubmitted(draft.id, "buyer@example.com", first.reference);
+ assert.equal(db.getCommissionDraftForUser(draft.id, "buyer@example.com")?.status, "submitted");
+ assert.equal(db.getRuntimePersistenceStatus().quickCheck, "ok");
+
+ db.closeDatabaseForTests();
+ const raw = new DatabaseSync(path.join(root, "data", "woodsmith.sqlite"));
+ raw.prepare("UPDATE settings SET value = ? WHERE key = 'seededVersion'").run(JSON.stringify({ version: 5 }));
+ raw.prepare("UPDATE pages SET title = ?, intro = ?, body = ? WHERE slug = 'commissions'").run(
+ "Custom Work Contact",
+ "Owner-written introduction",
+ "The private workflow still supports estimates, build notes, lead-time tracking, and visualization, but the public entry point is a simpler contact-first intake."
+ );
+ raw.close();
+ const upgradedPage = db.getPage("commissions");
+ assert.equal(upgradedPage?.title, "Request Custom Work");
+ assert.equal(upgradedPage?.intro, "Owner-written introduction");
+ assert.match(upgradedPage?.body ?? "", /form saves progress/i);
+ assert.equal(db.getRuntimePersistenceStatus().seededVersion, 6);
+ } finally {
+ db.closeDatabaseForTests();
+ rmSync(root, { recursive: true, force: true });
+ }
+});
diff --git a/site/lib/database-migrations.test.mts b/site/lib/database-migrations.test.mts
index 0e9413f..2bfe2c2 100644
--- a/site/lib/database-migrations.test.mts
+++ b/site/lib/database-migrations.test.mts
@@ -43,7 +43,7 @@ test("schema migrations are additive, idempotent, and preserve legacy truth", ()
const second = applySchemaMigrations(db);
assert.equal(first.quickCheckBefore, "ok");
assert.equal(first.quickCheckAfter, "ok");
- assert.deepEqual(first.applied.map((entry) => entry.version), [1, 2, 3]);
+ assert.deepEqual(first.applied.map((entry) => entry.version), [1, 2, 3, 4, 5]);
assert.equal(second.applied.length, 0);
const policies = (db.prepare(`SELECT slug, price_mode AS priceMode, price_cents AS priceCents, inquiry_mode AS inquiryMode, reviews_mode AS reviewsMode FROM pieces ORDER BY slug`).all() as Array>).map((row) => ({ ...row }));
diff --git a/site/lib/database-migrations.ts b/site/lib/database-migrations.ts
index 5e97432..5ea8739 100644
--- a/site/lib/database-migrations.ts
+++ b/site/lib/database-migrations.ts
@@ -230,6 +230,90 @@ const migrations: Migration[] = [
return { inserted, missing, missingCount: missing.length };
}
+ },
+ {
+ version: 4,
+ name: "resumable-commission-access-and-idempotency",
+ checksum: "2026-07-commission-draft-access-v1",
+ apply(db) {
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS commission_drafts (
+ id TEXT PRIMARY KEY,
+ user_email TEXT NOT NULL,
+ payload_json TEXT NOT NULL DEFAULT '{}',
+ current_step INTEGER NOT NULL DEFAULT 1 CHECK (current_step BETWEEN 1 AND 10),
+ status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'submitted', 'expired')),
+ idempotency_hash TEXT NOT NULL UNIQUE,
+ project_reference TEXT,
+ expires_at TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+ ) STRICT;
+ CREATE INDEX IF NOT EXISTS idx_commission_drafts_owner
+ ON commission_drafts(user_email, status, updated_at DESC);
+ CREATE INDEX IF NOT EXISTS idx_commission_drafts_expiry
+ ON commission_drafts(status, expires_at);
+
+ CREATE TABLE IF NOT EXISTS commission_submissions (
+ idempotency_hash TEXT PRIMARY KEY,
+ project_reference TEXT NOT NULL UNIQUE,
+ created_at TEXT NOT NULL
+ ) STRICT;
+
+ CREATE TABLE IF NOT EXISTS project_access_grants (
+ token_hash TEXT PRIMARY KEY,
+ project_reference TEXT NOT NULL,
+ expires_at TEXT NOT NULL,
+ last_used_at TEXT,
+ revoked_at TEXT,
+ created_at TEXT NOT NULL
+ ) STRICT;
+ CREATE INDEX IF NOT EXISTS idx_project_access_reference
+ ON project_access_grants(project_reference, expires_at DESC);
+
+ CREATE TABLE IF NOT EXISTS commission_render_usage (
+ owner_key_hash TEXT PRIMARY KEY,
+ window_started_at TEXT NOT NULL,
+ request_count INTEGER NOT NULL DEFAULT 0 CHECK (request_count >= 0),
+ updated_at TEXT NOT NULL
+ ) STRICT;
+
+ CREATE TABLE IF NOT EXISTS commission_render_assets (
+ relative_path TEXT PRIMARY KEY,
+ owner_key_hash TEXT NOT NULL,
+ consumed_project_reference TEXT,
+ created_at TEXT NOT NULL,
+ consumed_at TEXT
+ ) STRICT;
+ CREATE INDEX IF NOT EXISTS idx_commission_render_owner
+ ON commission_render_assets(owner_key_hash, created_at DESC);
+ `);
+ return {
+ tables: [
+ "commission_drafts",
+ "commission_submissions",
+ "project_access_grants",
+ "commission_render_usage",
+ "commission_render_assets"
+ ]
+ };
+ }
+ },
+ {
+ version: 5,
+ name: "commission-submission-rate-limits",
+ checksum: "2026-07-commission-submission-rate-limit-v1",
+ apply(db) {
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS commission_submission_usage (
+ owner_key_hash TEXT PRIMARY KEY,
+ window_started_at TEXT NOT NULL,
+ request_count INTEGER NOT NULL DEFAULT 0 CHECK (request_count >= 0),
+ updated_at TEXT NOT NULL
+ ) STRICT;
+ `);
+ return { tables: ["commission_submission_usage"] };
+ }
}
];
diff --git a/site/lib/db.ts b/site/lib/db.ts
index c047dfd..3b84f37 100644
--- a/site/lib/db.ts
+++ b/site/lib/db.ts
@@ -1,6 +1,6 @@
import { accessSync, constants as fsConstants, existsSync, mkdirSync } from "node:fs";
import path from "node:path";
-import { randomUUID } from "node:crypto";
+import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
import { DatabaseSync } from "node:sqlite";
import {
seedCommissionTypes,
@@ -12,7 +12,7 @@ import {
type FooterConfiguration,
type HomeServiceDefinition
} from "./seed.ts";
-import { scanMediaLibrary } from "./media.ts";
+import { scanMediaAsset, scanMediaLibrary } from "./media.ts";
import { normalizePieceCategories, type PieceCategoryDefinition } from "./categories.ts";
import { safeFooterConfiguration, safeHomeServices } from "./site-structure.ts";
import { applySchemaMigrations } from "./database-migrations.ts";
@@ -251,6 +251,41 @@ export type ProjectRecord = {
updatedAt: string;
};
+export type CommissionDraftRecord = {
+ id: string;
+ userEmail: string;
+ payload: Record;
+ currentStep: number;
+ status: "draft" | "submitted" | "expired";
+ projectReference: string | null;
+ expiresAt: string;
+ createdAt: string;
+ updatedAt: string;
+};
+
+export type ProjectInput = {
+ userEmail?: string | null;
+ guestName: string;
+ guestEmail: string;
+ pieceSlug?: string | null;
+ commissionTypeSlug?: string | null;
+ kind: ProjectKind;
+ status: string;
+ stage: string;
+ budgetCents?: number | null;
+ estimatedTotalCents?: number | null;
+ estimator?: Record;
+ brief: string;
+ materials: string[];
+ dimensions: { width: number; depth: number; height: number; unit: string } | null;
+ options?: Record;
+ visualizationSvg?: string | null;
+ includeVisualization?: boolean;
+ leadTimeDays?: number | null;
+ shippingAddress?: Record;
+ billingAddress?: Record;
+};
+
export type ProjectUpdateRecord = {
id: string;
projectReference: string;
@@ -838,7 +873,7 @@ function seedDefaultContent(db: DatabaseSync) {
const seededVersion = getSeededVersion(db);
if (seededVersion === 0) {
upsertSetting(db, "site", siteSettingsSeed);
- upsertSetting(db, "seededVersion", { version: 5, updatedAt: nowIso() });
+ upsertSetting(db, "seededVersion", { version: 6, updatedAt: nowIso() });
}
for (const profile of seedProfiles) {
@@ -1222,6 +1257,25 @@ function seedDefaultContent(db: DatabaseSync) {
upsertSetting(db, "seededVersion", { version: 5, updatedAt: timestamp });
}
+
+ if (seededVersion > 0 && seededVersion < 6) {
+ const timestamp = nowIso();
+ db.prepare(`UPDATE pages SET title = ?, updated_at = ? WHERE slug = 'commissions' AND title = ?`)
+ .run("Request Custom Work", timestamp, "Custom Work Contact");
+ db.prepare(`UPDATE pages SET intro = ?, updated_at = ? WHERE slug = 'commissions' AND intro = ?`)
+ .run(
+ "Describe the piece, room, dimensions, materials, timing, and fulfillment needs in one guided request.",
+ timestamp,
+ "Custom work now starts with a direct contact request instead of a fixed public template."
+ );
+ db.prepare(`UPDATE pages SET body = ?, updated_at = ? WHERE slug = 'commissions' AND body = ?`)
+ .run(
+ "The form saves progress in this browser, shows a proportional planning preview, and creates a private project page for follow-up after submission.",
+ timestamp,
+ "The private workflow still supports estimates, build notes, lead-time tracking, and visualization, but the public entry point is a simpler contact-first intake."
+ );
+ upsertSetting(db, "seededVersion", { version: 6, updatedAt: timestamp });
+ }
}
function syncMediaLibraryIntoDatabase(db: DatabaseSync, options: { applySeedAssignments?: boolean } = {}) {
@@ -2497,6 +2551,27 @@ export function saveMediaMetadata(input: {
metadata?: Record;
}) {
const db = getDatabase();
+ if (!getMedia(input.relativePath)) {
+ const media = scanMediaAsset(input.relativePath);
+ if (!media) throw new Error(`Media file '${input.relativePath}' was not found in the configured library.`);
+ db.prepare(`
+ INSERT INTO media_items (
+ relative_path, folder, file_name, kind, size_bytes, cluster_key, alt_text,
+ piece_slug, post_slug, page_slug, project_reference, user_email,
+ focal_x, focal_y, zoom, reviewed, tags_json, metadata_json, created_at, updated_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, NULL, NULL, 50, 50, 1, 0, '[]', '{}', ?, ?)
+ `).run(
+ media.relativePath,
+ media.folder,
+ media.fileName,
+ media.kind,
+ media.sizeBytes,
+ media.clusterKey,
+ media.guessedAlt,
+ media.createdAt,
+ media.updatedAt
+ );
+ }
db.prepare(`
UPDATE media_items
SET alt_text = :altText,
@@ -2875,6 +2950,228 @@ export function mergeMediaTags(relativePath: string, newTags: string[]) {
db.prepare(`UPDATE media_items SET tags_json = ?, updated_at = ? WHERE relative_path = ?`)
.run(writeJson(merged), nowIso(), relativePath);
}
+
+function hashOpaqueValue(value: string) {
+ return createHash("sha256").update(value).digest("hex");
+}
+
+function safeHashEqual(left: string, right: string) {
+ const leftBuffer = Buffer.from(left, "hex");
+ const rightBuffer = Buffer.from(right, "hex");
+ return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
+}
+
+function mapCommissionDraft(row: Record): CommissionDraftRecord {
+ return {
+ id: String(row.id),
+ userEmail: String(row.userEmail),
+ payload: readJson(row.payloadJson, {}),
+ currentStep: Number(row.currentStep),
+ status: String(row.status) as CommissionDraftRecord["status"],
+ projectReference: row.projectReference ? String(row.projectReference) : null,
+ expiresAt: String(row.expiresAt),
+ createdAt: String(row.createdAt),
+ updatedAt: String(row.updatedAt)
+ };
+}
+
+export function getCommissionDraftForUser(id: string, userEmail: string) {
+ const db = getDatabase();
+ const row = db.prepare(`
+ SELECT id, user_email AS userEmail, payload_json AS payloadJson, current_step AS currentStep,
+ status, project_reference AS projectReference, expires_at AS expiresAt,
+ created_at AS createdAt, updated_at AS updatedAt
+ FROM commission_drafts
+ WHERE id = ? AND user_email = ?
+ LIMIT 1
+ `).get(id, userEmail.toLowerCase()) as Record | undefined;
+ if (!row) return null;
+ const draft = mapCommissionDraft(row);
+ if (draft.status === "draft" && new Date(draft.expiresAt).getTime() <= Date.now()) {
+ db.prepare("UPDATE commission_drafts SET status = 'expired', updated_at = ? WHERE id = ?").run(nowIso(), id);
+ return { ...draft, status: "expired" as const };
+ }
+ return draft;
+}
+
+export function listCommissionDraftsForUser(userEmail: string, limit = 10) {
+ const db = getDatabase();
+ db.prepare(`
+ UPDATE commission_drafts
+ SET status = 'expired', updated_at = ?
+ WHERE user_email = ? AND status = 'draft' AND datetime(expires_at) <= datetime(?)
+ `).run(nowIso(), userEmail.toLowerCase(), nowIso());
+ const rows = db.prepare(`
+ SELECT id, user_email AS userEmail, payload_json AS payloadJson, current_step AS currentStep,
+ status, project_reference AS projectReference, expires_at AS expiresAt,
+ created_at AS createdAt, updated_at AS updatedAt
+ FROM commission_drafts
+ WHERE user_email = ?
+ ORDER BY datetime(updated_at) DESC
+ LIMIT ?
+ `).all(userEmail.toLowerCase(), Math.max(1, Math.min(50, Math.round(limit)))) as Record[];
+ return rows.map(mapCommissionDraft);
+}
+
+export function saveCommissionDraftForUser(input: {
+ id?: string | null;
+ userEmail: string;
+ payload: Record;
+ currentStep: number;
+ idempotencyKey: string;
+ expectedUpdatedAt?: string | null;
+}) {
+ const email = input.userEmail.trim().toLowerCase();
+ if (!email) throw new Error("A signed-in user is required for server draft storage.");
+ const idempotencyKey = input.idempotencyKey.trim();
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{15,127}$/.test(idempotencyKey)) throw new Error("Draft idempotency key is invalid.");
+ const currentStep = Math.max(1, Math.min(10, Math.round(input.currentStep)));
+ const payload = JSON.stringify(input.payload);
+ if (Buffer.byteLength(payload, "utf8") > 256_000) throw new Error("Commission draft data exceeds the 256 KB limit.");
+
+ return withDatabaseTransaction((db) => {
+ const timestamp = nowIso();
+ const expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30).toISOString();
+ const id = input.id?.trim() || randomUUID();
+ const existing = getCommissionDraftForUser(id, email);
+ if (existing) {
+ if (existing.status !== "draft") throw new Error("Only active drafts can be updated.");
+ if (input.expectedUpdatedAt && existing.updatedAt !== input.expectedUpdatedAt) throw new Error("Commission draft changed in another session.");
+ db.prepare(`
+ UPDATE commission_drafts
+ SET payload_json = ?, current_step = ?, idempotency_hash = ?, expires_at = ?, updated_at = ?
+ WHERE id = ? AND user_email = ?
+ `).run(payload, currentStep, hashOpaqueValue(idempotencyKey), expiresAt, timestamp, id, email);
+ } else {
+ db.prepare(`
+ INSERT INTO commission_drafts (
+ id, user_email, payload_json, current_step, status, idempotency_hash,
+ project_reference, expires_at, created_at, updated_at
+ ) VALUES (?, ?, ?, ?, 'draft', ?, NULL, ?, ?, ?)
+ `).run(id, email, payload, currentStep, hashOpaqueValue(idempotencyKey), expiresAt, timestamp, timestamp);
+ }
+ return getCommissionDraftForUser(id, email)!;
+ });
+}
+
+export function markCommissionDraftSubmitted(id: string, userEmail: string, projectReference: string) {
+ const db = getDatabase();
+ db.prepare(`
+ UPDATE commission_drafts
+ SET status = 'submitted', project_reference = ?, updated_at = ?
+ WHERE id = ? AND user_email = ? AND status = 'draft'
+ `).run(projectReference, nowIso(), id, userEmail.toLowerCase());
+}
+
+export function deleteCommissionDraftForUser(id: string, userEmail: string) {
+ const db = getDatabase();
+ const result = db.prepare("DELETE FROM commission_drafts WHERE id = ? AND user_email = ? AND status = 'draft'")
+ .run(id, userEmail.toLowerCase());
+ return Number(result.changes ?? 0) === 1;
+}
+
+export function createProjectAccessGrant(projectReference: string, days = 30) {
+ const db = getDatabase();
+ if (!getProject(projectReference)) throw new Error("Project not found.");
+ const token = randomBytes(32).toString("base64url");
+ const timestamp = nowIso();
+ db.prepare(`
+ INSERT INTO project_access_grants (
+ token_hash, project_reference, expires_at, last_used_at, revoked_at, created_at
+ ) VALUES (?, ?, ?, NULL, NULL, ?)
+ `).run(hashOpaqueValue(token), projectReference, new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString(), timestamp);
+ return token;
+}
+
+export function projectAccessGrantValid(projectReference: string, token: string) {
+ const db = getDatabase();
+ const candidateHash = hashOpaqueValue(token.trim());
+ const row = db.prepare(`
+ SELECT token_hash AS tokenHash, expires_at AS expiresAt, revoked_at AS revokedAt
+ FROM project_access_grants
+ WHERE project_reference = ? AND token_hash = ?
+ LIMIT 1
+ `).get(projectReference, candidateHash) as Record | undefined;
+ if (!row || row.revokedAt || new Date(String(row.expiresAt)).getTime() <= Date.now()) return false;
+ if (!safeHashEqual(String(row.tokenHash), candidateHash)) return false;
+ db.prepare("UPDATE project_access_grants SET last_used_at = ? WHERE token_hash = ?").run(nowIso(), candidateHash);
+ return true;
+}
+
+export function consumeCommissionRenderQuota(ownerKey: string, limit = 3, windowMs = 24 * 60 * 60 * 1000) {
+ return withDatabaseTransaction((db) => {
+ const ownerKeyHash = hashOpaqueValue(ownerKey);
+ const row = db.prepare(`SELECT window_started_at AS windowStartedAt, request_count AS requestCount FROM commission_render_usage WHERE owner_key_hash = ?`).get(ownerKeyHash) as Record | undefined;
+ const now = Date.now();
+ const currentWindow = row ? new Date(String(row.windowStartedAt)).getTime() : 0;
+ const reset = !row || !Number.isFinite(currentWindow) || now - currentWindow >= windowMs;
+ const count = reset ? 0 : Number(row.requestCount);
+ if (count >= limit) return { allowed: false as const, remaining: 0, retryAfterSeconds: Math.max(1, Math.ceil((currentWindow + windowMs - now) / 1000)), ownerKeyHash };
+ const timestamp = nowIso();
+ db.prepare(`
+ INSERT INTO commission_render_usage (owner_key_hash, window_started_at, request_count, updated_at)
+ VALUES (?, ?, 1, ?)
+ ON CONFLICT(owner_key_hash) DO UPDATE SET
+ window_started_at = excluded.window_started_at,
+ request_count = CASE WHEN ? THEN 1 ELSE commission_render_usage.request_count + 1 END,
+ updated_at = excluded.updated_at
+ `).run(ownerKeyHash, reset ? timestamp : new Date(currentWindow).toISOString(), timestamp, reset ? 1 : 0);
+ return { allowed: true as const, remaining: Math.max(0, limit - count - 1), retryAfterSeconds: 0, ownerKeyHash };
+ });
+}
+
+export function consumeCommissionSubmissionQuota(ownerKey: string, limit = 5, windowMs = 60 * 60 * 1000) {
+ return withDatabaseTransaction((db) => {
+ const ownerKeyHash = hashOpaqueValue(ownerKey);
+ const row = db.prepare(`
+ SELECT window_started_at AS windowStartedAt, request_count AS requestCount
+ FROM commission_submission_usage
+ WHERE owner_key_hash = ?
+ `).get(ownerKeyHash) as Record | undefined;
+ const now = Date.now();
+ const currentWindow = row ? new Date(String(row.windowStartedAt)).getTime() : 0;
+ const reset = !row || !Number.isFinite(currentWindow) || now - currentWindow >= windowMs;
+ const count = reset ? 0 : Number(row.requestCount);
+ if (count >= limit) {
+ return {
+ allowed: false as const,
+ remaining: 0,
+ retryAfterSeconds: Math.max(1, Math.ceil((currentWindow + windowMs - now) / 1000))
+ };
+ }
+ const timestamp = nowIso();
+ db.prepare(`
+ INSERT INTO commission_submission_usage (owner_key_hash, window_started_at, request_count, updated_at)
+ VALUES (?, ?, 1, ?)
+ ON CONFLICT(owner_key_hash) DO UPDATE SET
+ window_started_at = excluded.window_started_at,
+ request_count = CASE WHEN ? THEN 1 ELSE commission_submission_usage.request_count + 1 END,
+ updated_at = excluded.updated_at
+ `).run(ownerKeyHash, reset ? timestamp : new Date(currentWindow).toISOString(), timestamp, reset ? 1 : 0);
+ return { allowed: true as const, remaining: Math.max(0, limit - count - 1), retryAfterSeconds: 0 };
+ });
+}
+
+export function registerCommissionRenderAsset(relativePath: string, ownerKey: string) {
+ const db = getDatabase();
+ db.prepare(`
+ INSERT INTO commission_render_assets (relative_path, owner_key_hash, consumed_project_reference, created_at, consumed_at)
+ VALUES (?, ?, NULL, ?, NULL)
+ ON CONFLICT(relative_path) DO UPDATE SET owner_key_hash = excluded.owner_key_hash
+ `).run(relativePath, hashOpaqueValue(ownerKey), nowIso());
+}
+
+export function consumeCommissionRenderAsset(relativePath: string, ownerKey: string, projectReference: string) {
+ const db = getDatabase();
+ const ownerKeyHash = hashOpaqueValue(ownerKey);
+ const result = db.prepare(`
+ UPDATE commission_render_assets
+ SET consumed_project_reference = ?, consumed_at = ?
+ WHERE relative_path = ? AND owner_key_hash = ? AND consumed_project_reference IS NULL
+ `).run(projectReference, nowIso(), relativePath, ownerKeyHash);
+ return Number(result.changes ?? 0) === 1;
+}
+
function createReference(kind: ProjectKind) {
const prefix = kind === "commission" ? "CM" : "SH";
const stamp = new Intl.DateTimeFormat("en-CA", { year: "2-digit", month: "2-digit", day: "2-digit" }).format(new Date()).replace(/-/g, "").slice(2);
@@ -2923,28 +3220,7 @@ export function getProject(reference: string) {
return row ? mapProject(row) : null;
}
-export function createProject(input: {
- userEmail?: string | null;
- guestName: string;
- guestEmail: string;
- pieceSlug?: string | null;
- commissionTypeSlug?: string | null;
- kind: ProjectKind;
- status: string;
- stage: string;
- budgetCents?: number | null;
- estimatedTotalCents?: number | null;
- estimator?: Record;
- brief: string;
- materials: string[];
- dimensions: { width: number; depth: number; height: number; unit: string } | null;
- options?: Record;
- visualizationSvg?: string | null;
- includeVisualization?: boolean;
- leadTimeDays?: number | null;
- shippingAddress?: Record;
- billingAddress?: Record;
-}) {
+export function createProject(input: ProjectInput) {
const db = getDatabase();
const reference = createReference(input.kind);
const timestamp = nowIso();
@@ -2988,6 +3264,40 @@ export function createProject(input: {
return reference;
}
+export function createProjectIdempotent(input: ProjectInput, idempotencyKey: string) {
+ const cleanKey = idempotencyKey.trim();
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{15,127}$/.test(cleanKey)) throw new Error("Submission idempotency key is invalid.");
+ const idempotencyHash = hashOpaqueValue(cleanKey);
+ return withDatabaseTransaction((db) => {
+ const existing = db.prepare(`SELECT project_reference AS projectReference FROM commission_submissions WHERE idempotency_hash = ? LIMIT 1`).get(idempotencyHash) as { projectReference?: unknown } | undefined;
+ if (existing?.projectReference) return { reference: String(existing.projectReference), created: false as const };
+ const reference = createProject(input);
+ db.prepare(`INSERT INTO commission_submissions (idempotency_hash, project_reference, created_at) VALUES (?, ?, ?)`)
+ .run(idempotencyHash, reference, nowIso());
+ return { reference, created: true as const };
+ });
+}
+
+export function rollbackCommissionSubmission(reference: string, idempotencyKey: string) {
+ const cleanKey = idempotencyKey.trim();
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{15,127}$/.test(cleanKey)) return false;
+ const idempotencyHash = hashOpaqueValue(cleanKey);
+ return withDatabaseTransaction((db) => {
+ const submission = db.prepare(`
+ SELECT project_reference AS projectReference
+ FROM commission_submissions
+ WHERE idempotency_hash = ?
+ LIMIT 1
+ `).get(idempotencyHash) as { projectReference?: unknown } | undefined;
+ if (String(submission?.projectReference ?? "") !== reference) return false;
+ db.prepare("DELETE FROM project_updates WHERE project_reference = ?").run(reference);
+ db.prepare("DELETE FROM project_access_grants WHERE project_reference = ?").run(reference);
+ db.prepare("DELETE FROM commission_submissions WHERE idempotency_hash = ?").run(idempotencyHash);
+ db.prepare("DELETE FROM projects WHERE reference = ?").run(reference);
+ return true;
+ });
+}
+
export function updateProject(reference: string, input: Partial>) {
const project = getProject(reference);
if (!project) {
diff --git a/site/lib/media.ts b/site/lib/media.ts
index dda4168..f43883b 100644
--- a/site/lib/media.ts
+++ b/site/lib/media.ts
@@ -154,7 +154,7 @@ function walkMedia(directory: string, root: string, output: MediaScanRecord[]) {
}
}
-export function scanMediaLibrary() {
+export function scanMediaLibrary() {
const root = getMediaRoot();
if (!existsSync(root)) {
return [];
@@ -178,11 +178,24 @@ export function resolveMediaPath(relativePath: string) {
return absolutePath;
}
-export async function persistUploadedMedia(file: File, folder = "Uploads") {
- const safeFolder = slugify(folder) || "uploads";
- const originalName = file.name || "upload";
- const extension = path.extname(originalName) || ".bin";
- const stem = slugify(path.basename(originalName, extension)) || `upload-${randomUUID().slice(0, 8)}`;
+export type MediaUploadPolicy = {
+ maxBytes?: number;
+ allowedMimePrefixes?: string[];
+ allowedExtensions?: string[];
+};
+
+export async function persistUploadedMedia(file: File, folder = "Uploads", policy: MediaUploadPolicy = {}) {
+ const safeFolder = folder.split(/[\\/]+/g).map(slugify).filter(Boolean).join("/") || "uploads";
+ const originalName = file.name || "upload";
+ const originalExtension = path.extname(originalName) || "";
+ const extension = originalExtension.toLowerCase();
+ const maxBytes = Math.max(1, policy.maxBytes ?? 250 * 1024 * 1024);
+ const allowedMimePrefixes = policy.allowedMimePrefixes ?? ["image/", "video/"];
+ const allowedExtensions = new Set((policy.allowedExtensions ?? [".jpg", ".jpeg", ".png", ".webp", ".gif", ".heic", ".heif", ".avif", ".mp4", ".mov", ".m4v", ".webm"]).map((value) => value.toLowerCase()));
+ if (file.size <= 0 || file.size > maxBytes) throw new Error(`Upload must be smaller than ${Math.round(maxBytes / 1024 / 1024)} MB.`);
+ if (!extension || !allowedExtensions.has(extension)) throw new Error("This upload file type is not allowed.");
+ if (!allowedMimePrefixes.some((prefix) => file.type.toLowerCase().startsWith(prefix))) throw new Error("The upload content type is not allowed.");
+ const stem = slugify(path.basename(originalName, originalExtension)) || `upload-${randomUUID().slice(0, 8)}`;
const finalName = `${stem}-${randomUUID().slice(0, 8)}${extension.toLowerCase()}`;
const relativePath = `${safeFolder}/${finalName}`;
const absolutePath = resolveMediaPath(relativePath);
@@ -208,6 +221,27 @@ export function renameMediaAsset(relativePath: string, nextBaseName: string) {
return moveMediaAsset(relativePath, nextPath);
}
+export function scanMediaAsset(relativePath: string): MediaScanRecord | null {
+ const normalized = normalizeRelativePath(relativePath);
+ if (normalized.split("/").some(shouldIgnoreMediaEntry)) return null;
+ const absolutePath = resolveMediaPath(normalized);
+ if (!existsSync(absolutePath)) return null;
+ const stats = statSync(absolutePath);
+ if (!stats.isFile()) return null;
+ const fileName = path.posix.basename(normalized);
+ return {
+ relativePath: normalized,
+ folder: path.posix.dirname(normalized) === "." ? "" : path.posix.dirname(normalized),
+ fileName,
+ kind: detectMediaKind(fileName),
+ sizeBytes: stats.size,
+ createdAt: stats.birthtime.toISOString(),
+ updatedAt: stats.mtime.toISOString(),
+ clusterKey: deriveClusterKey(normalized),
+ guessedAlt: guessAltFromPath(normalized)
+ };
+}
+
export function previewMediaRenamePath(relativePath: string, nextBaseName: string) {
const currentAbsolutePath = resolveMediaPath(relativePath);
const parsed = path.parse(currentAbsolutePath);
diff --git a/site/lib/seed.ts b/site/lib/seed.ts
index 85e95f2..5bc36bb 100644
--- a/site/lib/seed.ts
+++ b/site/lib/seed.ts
@@ -653,11 +653,11 @@ export const seedPages: SeedPage[] = [
},
{
slug: "commissions",
- title: "Custom Work Contact",
+ title: "Request Custom Work",
navLabel: "Custom Work",
status: "published",
- intro: "Custom work now starts with a direct contact request instead of a fixed public template.",
- body: "The private workflow still supports estimates, build notes, lead-time tracking, and visualization, but the public entry point is a simpler contact-first intake.",
+ intro: "Describe the piece, room, dimensions, materials, timing, and fulfillment needs in one guided request.",
+ body: "The form saves progress in this browser, shows a proportional planning preview, and creates a private project page for follow-up after submission.",
layout: "contact",
sections: []
},
diff --git a/site/next.config.ts b/site/next.config.ts
index 4bb8244..cfea160 100644
--- a/site/next.config.ts
+++ b/site/next.config.ts
@@ -1,7 +1,10 @@
import type { NextConfig } from "next";
-const nextConfig: NextConfig = {
- output: "standalone",
+const nextConfig: NextConfig = {
+ output: "standalone",
+ outputFileTracingExcludes: {
+ "/*": ["./data/**/*"]
+ },
async headers() {
return [
{
diff --git a/site/package.json b/site/package.json
index 8794d38..4dfac2c 100644
--- a/site/package.json
+++ b/site/package.json
@@ -4,11 +4,11 @@
"private": true,
"scripts": {
"dev": "node --experimental-sqlite ./node_modules/next/dist/bin/next dev",
- "build": "node --experimental-sqlite ./node_modules/next/dist/bin/next build",
+ "build": "node ./scripts/safe-build.mjs",
"start": "node --experimental-sqlite .next/standalone/server.js",
"lint": "eslint app components lib",
"typecheck": "tsc --noEmit",
- "test": "node --experimental-strip-types --test lib/media-scoring.test.mts lib/piece-model.test.mts lib/database-migrations.test.mts lib/media-reference-transaction.test.mts lib/category-icons.test.mts lib/site-structure.test.mts lib/piece-media.test.mts lib/estimator.test.mts lib/visual-audit-security.test.mts lib/inline-edit-registry.test.mts"
+ "test": "node --experimental-strip-types --test lib/media-scoring.test.mts lib/piece-model.test.mts lib/database-migrations.test.mts lib/media-reference-transaction.test.mts lib/category-icons.test.mts lib/site-structure.test.mts lib/piece-media.test.mts lib/estimator.test.mts lib/visual-audit-security.test.mts lib/inline-edit-registry.test.mts lib/commission-workflow.test.mts scripts/safe-build.test.mjs"
},
"dependencies": {
"@react-three/drei": "^10.7.7",
diff --git a/site/scripts/safe-build-lib.mjs b/site/scripts/safe-build-lib.mjs
new file mode 100644
index 0000000..d70dbc0
--- /dev/null
+++ b/site/scripts/safe-build-lib.mjs
@@ -0,0 +1,59 @@
+import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { spawnSync } from "node:child_process";
+
+export class SafeBuildError extends Error {
+ constructor(message, exitCode = 1) {
+ super(message);
+ this.name = "SafeBuildError";
+ this.exitCode = exitCode;
+ }
+}
+
+export function collectForbiddenRuntimeFiles(projectRoot, directory, output = []) {
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
+ const absolutePath = path.join(directory, entry.name);
+ if (entry.isDirectory()) {
+ collectForbiddenRuntimeFiles(projectRoot, absolutePath, output);
+ continue;
+ }
+ if (/(?:\.sqlite|\.db)(?:-(?:wal|shm))?$|\.(?:sqlite|db)-(?:wal|shm)$|\.(?:bak|backup)$/i.test(entry.name)) {
+ output.push(path.relative(projectRoot, absolutePath));
+ }
+ }
+ return output;
+}
+
+function spawnNextBuild({ projectRoot, temporaryRoot }) {
+ const nextCli = path.join(projectRoot, "node_modules", "next", "dist", "bin", "next");
+ return spawnSync(process.execPath, ["--experimental-sqlite", nextCli, "build"], {
+ cwd: projectRoot,
+ env: {
+ ...process.env,
+ DATA_ROOT: path.join(temporaryRoot, "data"),
+ MEDIA_ROOT: path.join(temporaryRoot, "media")
+ },
+ stdio: "inherit"
+ });
+}
+
+export function runSafeBuild({ projectRoot = process.cwd(), temporaryParent = tmpdir(), spawnBuild = spawnNextBuild } = {}) {
+ const temporaryRoot = mkdtempSync(path.join(temporaryParent, "woodsmith-build-"));
+ const standaloneRoot = path.join(projectRoot, ".next", "standalone");
+ try {
+ rmSync(standaloneRoot, { recursive: true, force: true });
+ const result = spawnBuild({ projectRoot, temporaryRoot });
+ if (result.error) throw result.error;
+ if (result.status !== 0) throw new SafeBuildError(`Next build exited with status ${result.status ?? "unknown"}.`, result.status ?? 1);
+ if (!existsSync(standaloneRoot)) throw new SafeBuildError("Next build did not produce standalone output.");
+
+ const forbidden = collectForbiddenRuntimeFiles(projectRoot, standaloneRoot);
+ if (forbidden.length > 0) {
+ throw new SafeBuildError(`Standalone output contains runtime database or backup files:\n${forbidden.map((value) => `- ${value}`).join("\n")}`);
+ }
+ return { standaloneRoot };
+ } finally {
+ rmSync(temporaryRoot, { recursive: true, force: true });
+ }
+}
diff --git a/site/scripts/safe-build.mjs b/site/scripts/safe-build.mjs
new file mode 100644
index 0000000..d9a5dd5
--- /dev/null
+++ b/site/scripts/safe-build.mjs
@@ -0,0 +1,9 @@
+import { runSafeBuild, SafeBuildError } from "./safe-build-lib.mjs";
+
+try {
+ runSafeBuild();
+ console.log("Standalone runtime-data gate passed.");
+} catch (error) {
+ console.error(`Build rejected: ${error instanceof Error ? error.message : String(error)}`);
+ process.exitCode = error instanceof SafeBuildError ? error.exitCode : 1;
+}
diff --git a/site/scripts/safe-build.test.mjs b/site/scripts/safe-build.test.mjs
new file mode 100644
index 0000000..60d3731
--- /dev/null
+++ b/site/scripts/safe-build.test.mjs
@@ -0,0 +1,78 @@
+import assert from "node:assert/strict";
+import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import test from "node:test";
+
+import { runSafeBuild, SafeBuildError } from "./safe-build-lib.mjs";
+
+function fixture() {
+ const root = mkdtempSync(path.join(tmpdir(), "woodsmith-safe-build-test-"));
+ const projectRoot = path.join(root, "project");
+ const temporaryParent = path.join(root, "temporary");
+ mkdirSync(projectRoot, { recursive: true });
+ mkdirSync(temporaryParent, { recursive: true });
+ return { root, projectRoot, temporaryParent };
+}
+
+function assertNoBuildRoots(temporaryParent) {
+ assert.deepEqual(readdirSync(temporaryParent).filter((name) => name.startsWith("woodsmith-build-")), []);
+}
+
+test("safe build removes its temporary root after a child failure", () => {
+ const input = fixture();
+ try {
+ assert.throws(() => runSafeBuild({
+ projectRoot: input.projectRoot,
+ temporaryParent: input.temporaryParent,
+ spawnBuild({ temporaryRoot }) {
+ mkdirSync(path.join(temporaryRoot, "data"), { recursive: true });
+ writeFileSync(path.join(temporaryRoot, "data", "fixture.sqlite"), "temporary");
+ return { status: 23 };
+ }
+ }), (error) => error instanceof SafeBuildError && error.exitCode === 23);
+ assertNoBuildRoots(input.temporaryParent);
+ } finally {
+ rmSync(input.root, { recursive: true, force: true });
+ }
+});
+
+test("safe build rejects bundled runtime state and still removes its temporary root", () => {
+ const input = fixture();
+ try {
+ assert.throws(() => runSafeBuild({
+ projectRoot: input.projectRoot,
+ temporaryParent: input.temporaryParent,
+ spawnBuild({ temporaryRoot }) {
+ mkdirSync(path.join(temporaryRoot, "media"), { recursive: true });
+ writeFileSync(path.join(temporaryRoot, "media", "marker"), "temporary");
+ const outputData = path.join(input.projectRoot, ".next", "standalone", "data");
+ mkdirSync(outputData, { recursive: true });
+ writeFileSync(path.join(outputData, "leak.sqlite"), "forbidden");
+ return { status: 0 };
+ }
+ }), /runtime database or backup files/);
+ assertNoBuildRoots(input.temporaryParent);
+ } finally {
+ rmSync(input.root, { recursive: true, force: true });
+ }
+});
+
+test("safe build accepts clean standalone output and removes its temporary root", () => {
+ const input = fixture();
+ try {
+ const result = runSafeBuild({
+ projectRoot: input.projectRoot,
+ temporaryParent: input.temporaryParent,
+ spawnBuild({ temporaryRoot }) {
+ assert.equal(existsSync(temporaryRoot), true);
+ mkdirSync(path.join(input.projectRoot, ".next", "standalone"), { recursive: true });
+ return { status: 0 };
+ }
+ });
+ assert.equal(result.standaloneRoot, path.join(input.projectRoot, ".next", "standalone"));
+ assertNoBuildRoots(input.temporaryParent);
+ } finally {
+ rmSync(input.root, { recursive: true, force: true });
+ }
+});
diff --git a/synology-nas-deploy.md b/synology-nas-deploy.md
index 676efc4..cf54f4b 100644
--- a/synology-nas-deploy.md
+++ b/synology-nas-deploy.md
@@ -186,7 +186,9 @@ cd /volume2/docker_ssd/woodsmith
docker compose -f docker-compose.synology.yml up -d
```
-The startup path includes seed migration v5. It preserves dashboard edits and deletion tombstones, normalizes legacy developer-contact data, removes the obsolete Process navigation entry, and replaces only exact legacy Shop/Process seed wording.
+The startup path includes seed migration v6. It preserves dashboard edits and deletion tombstones, normalizes legacy developer-contact data, removes the obsolete Process navigation entry, and replaces only exact legacy Shop/Process/custom-work seed wording.
+
+The independent SQLite schema ledger currently applies through version 5. Its additive commission tables persist account drafts, idempotency keys, expiring project-access grants, render ownership/quotas, and submission quotas. Never replace the mounted `/app/site/data` directory during an image rebuild; back it up and run `PRAGMA quick_check` before and after deployment.
## Reverse proxy
@@ -251,9 +253,12 @@ curl -I http://127.0.0.1:3002/
curl -I http://127.0.0.1:3002/portfolio
curl -I http://127.0.0.1:3002/shop
curl -I http://127.0.0.1:3002/process
-curl -I http://127.0.0.1:3002/commissions
-curl -I http://127.0.0.1:3002/studio/login
-```
+curl -I http://127.0.0.1:3002/commissions
+curl -I http://127.0.0.1:3002/commissions/status
+curl -I http://127.0.0.1:3002/studio/login
+```
+
+Use a disposable buyer request during candidate validation and confirm that the resulting `/requests/BW-CM-*` URL contains no email query parameter. Project lookup must POST the reference and buyer email at `/commissions/status`, set an `HttpOnly` access cookie, and keep access after a reload. Also verify that `/app/pics/commission-staging` contains no abandoned files after successful or rejected submissions.
`/journal` and `/journal/[slug]` should redirect to the Process routes.
@@ -294,7 +299,7 @@ Because the dashboard can mutate the shared media library, back up these paths t
A SQLite backup without the matching media tree is no longer sufficient for full recovery.
-The Docker build excludes SQLite databases, WAL/SHM files, backups, and media-AI caches. Runtime state is never copied into an image layer; the image creates an empty `/app/site/data` directory that is populated only by the writable production bind mount. Seed upgrades are non-destructive for existing Studio-edited records, so rebuilds should preserve page/settings edits when the same mounted database is active.
+The Docker context excludes SQLite databases, WAL/SHM files, backups, and media-AI caches. In addition, `site/scripts/safe-build.mjs` forces every Next build to use disposable temporary data/media roots and rejects standalone output containing a database, WAL/SHM, or backup file. Runtime state is never copied into an image layer; the image creates an empty `/app/site/data` directory that is populated only by the writable production bind mount. Seed upgrades are non-destructive for existing Studio-edited records, so rebuilds should preserve page/settings edits when the same mounted database is active.
## Current deployment caveats
diff --git a/woodsmith_DeepWiki_Merged_03222026.md b/woodsmith_DeepWiki_Merged_03222026.md
index 5d3277f..1d353b9 100644
--- a/woodsmith_DeepWiki_Merged_03222026.md
+++ b/woodsmith_DeepWiki_Merged_03222026.md
@@ -39,7 +39,7 @@ Routes under `site/app/` provide:
The account system supports signup, login, password reset, profile updates, profile image upload, and account-linked project listing.
-Project trackers live at `/requests/[reference]`. Access is allowed only when the viewer is an admin, the viewer is signed in with the linked account email, or the buyer email supplied in the request URL matches the project record.
+Project trackers live at `/requests/[reference]`. Access is allowed only when the viewer is an administrator, is signed in with the linked account email, or holds an unexpired per-project capability cookie. The `/commissions/status` POST form exchanges a matching reference and buyer email for that `HttpOnly`, same-site cookie; buyer email is never accepted in a project URL.
### Private Woodshop dashboard
@@ -84,8 +84,14 @@ The SQLite schema includes these primary tables:
- `piece_media_links`
- `admin_edit_audit`
- `media_rename_history`
-
-Seeds from `site/lib/seed.ts` initialize site settings, profile records, pages, pieces, custom work types, and process notes. Existing databases are upgraded through seed v5 without deleting runtime orders, projects, users, media metadata, dashboard edits, or deletion tombstones. Seed v3 and later migrations are non-destructive for existing Studio-edited content; they normalize legacy developer-email references, replace only exact stale seed wording, and remove the obsolete public Process navigation entry.
+- `commission_drafts`
+- `commission_submissions`
+- `project_access_grants`
+- `commission_render_usage`
+- `commission_render_assets`
+- `commission_submission_usage`
+
+Seeds from `site/lib/seed.ts` initialize site settings, profile records, pages, pieces, custom work types, and process notes. Existing databases are upgraded through seed v6 without deleting runtime orders, projects, users, media metadata, dashboard edits, or deletion tombstones. Seed v3 and later migrations are non-destructive for existing Studio-edited content; they normalize legacy developer-email references, replace only exact stale seed wording, and remove the obsolete public Process navigation entry.
User records keep buyer email-verification state in dedicated `email_verified`, `verification_token`, and `verification_expires_at` columns. Visitor-session telemetry is persisted in the `visitor_sessions` table so the dashboard can render a world map and recent-session list without any third-party analytics dependency.
@@ -114,11 +120,13 @@ Payment capture, invoice delivery, shipping labels, and outbound email degrade s
## Custom work workflow
-The public custom work route is contact-first. It captures buyer contact details, location, budget, project type, material preference, fulfillment preference, attachments, optional procedural 3D scale preview data, and a written brief. Submission creates a project record, queues notifications, and redirects the buyer to a reference page.
+The public custom work route is a ten-step guided request covering intent, category, room/use, dimensions, materials, private reference files, conceptual preview, fulfillment, contact identity, and final review. Every browser gets local autosave. Verified accounts additionally get optimistic, serialized server drafts with 30-day expiry and cross-browser recovery. The server treats browser totals and lead times as advisory, recalculates the estimator from normalized dimensions/options plus the live queue, and stores the exact submitted options separately.
+
+Submission uses a client-generated idempotency key backed by `commission_submissions`, a honeypot, and hashed owner-window quotas. Allowlisted images are staged before the database insert, moved into `projects//references/` after creation, and rolled back with the project/idempotency row if finalization fails. Project status access uses expiring opaque capability cookies rather than email query parameters.
Custom work type records still store default dimensions, material options, labor hours, and markup settings so the woodshop can maintain estimator context and future richer intake flows.
-`site/components/visualizer.tsx` dynamically loads `site/components/commission-scene.tsx` only on the custom-work route. The React Three Fiber scene supports category-specific and generic templates, exact submitted dimensions, material cues, perspective/orthographic cameras, front/side/top/isometric presets, orbit/zoom/reset controls, and demand rendering. A deterministic SVG drawing and textual dimensions remain available for printing, submission, reduced motion, and WebGL failure. When `OPENAI_API_KEY` and `ENABLE_PUBLIC_AI_RENDERING=true` are configured, `/api/render-preview` can generate a photorealistic conceptual image, persist it under `/app/pics`, and attach it only when the buyer includes the preview.
+`site/components/visualizer.tsx` dynamically loads `site/components/commission-scene.tsx` only on the custom-work route. The React Three Fiber scene supports category-specific and generic templates, exact submitted dimensions, material cues, perspective/orthographic cameras, front/side/top/isometric presets, orbit/zoom/reset controls, and demand rendering. A deterministic SVG drawing and textual dimensions remain available for printing, submission, reduced motion, and WebGL failure. When `OPENAI_API_KEY` and `ENABLE_PUBLIC_AI_RENDERING=true` are configured, `/api/render-preview` can generate a photorealistic conceptual image, persist it under `/app/pics`, and attach it only once when the submitting owner explicitly includes it. Render and submission quotas store only hashed owner keys.
## Visual archive and rendered QA
From 2c92209695d922c5e486919b385b9bc5b482eaf8 Mon Sep 17 00:00:00 2001
From: Cooper Beaman <55124795+lowestprime@users.noreply.github.com>
Date: Sun, 12 Jul 2026 18:01:56 -0700
Subject: [PATCH 10/43] feat(media): add transactional batch organization
---
PLANS.md | 3 +-
README.md | 2 +-
admin.md | 6 +-
site/app/studio/page.tsx | 12 +-
site/app/ui-repair.css | 57 ++++
site/components/studio-media-workspace.tsx | 101 ++++++-
site/lib/actions.ts | 192 ++++++++++++-
site/lib/database-migrations.test.mts | 2 +-
site/lib/database-migrations.ts | 46 +++
site/lib/db.ts | 310 +++++++++++++++++++++
site/lib/media-batch-transaction.test.mts | 249 +++++++++++++++++
site/lib/media-operations.ts | 208 ++++++++++++++
site/lib/media.ts | 33 ++-
site/package.json | 2 +-
synology-nas-deploy.md | 6 +-
woodsmith_DeepWiki_Merged_03222026.md | 7 +-
16 files changed, 1211 insertions(+), 25 deletions(-)
create mode 100644 site/lib/media-batch-transaction.test.mts
create mode 100644 site/lib/media-operations.ts
diff --git a/PLANS.md b/PLANS.md
index e5dbae6..8c0328d 100644
--- a/PLANS.md
+++ b/PLANS.md
@@ -15,7 +15,8 @@
| Typed atomic inline editing | DONE | Added a typed field registry spanning scalar, list, link, relation, media, status, and structural settings fields; trusted-origin enforcement; complete-batch validation; one SQLite transaction; optimistic expected-value conflicts; admin audit records; reset, rollback, keyboard, focus, and one-step Undo behavior. Thirty-two application tests, typecheck, lint, production build, and a disposable authenticated Chromium save/undo/reset/conflict/origin smoke pass. Final-candidate archive evidence remains a release gate. |
| Secure resumable custom requests | DONE / LOCAL DOCKER VALIDATED | Added the ten-step autosaved workflow, verified-account server drafts, idempotent server-authoritative submission, hashed submission/render quotas, private staged attachments with rollback, owner-bound generated previews, opaque project-access cookies, POST lookup without URL email, and exact dimension/category continuity into R3F. Additive schema v5, seed v6, 36 application tests, production build, image safety inspection, and disposable browser/container submission evidence pass. |
| Safe standalone and image build | DONE / LOCAL DOCKER VALIDATED | `safe-build` uses disposable build roots, excludes runtime data from Next tracing, rejects database/backup files, and proves cleanup for child-failure, forbidden-output, and success paths. Docker Desktop Engine 29.6.1 and BuildKit 0.31.1 built/loaded `woodsmith:local-gate`; the image contains no runtime DB, env/key, private evidence, audit output, or production media and starts with an empty data directory. |
-| Remaining product work | PENDING | Transactional batch media operations/rollback, derivative-only cleanup, accessibility/performance completion, final docs, full archive evidence, release, NAS backup/restore, rollback, and deployment gates remain active. |
+| Transactional media organization | DONE / LOCAL BROWSER VALIDATED | Additive schema v6 records batch/item snapshots; selected media can be moved, deterministically renamed, tagged, rated, assigned, and given normalized role/stage/visibility metadata in one compensated operation. Optimistic rollback refuses later edits, filesystem failures reverse earlier moves, legacy and normalized references stay synchronized, and optional cleanup writes unpublished source-linked derivatives only. Forty application tests plus disposable desktop/mobile browser apply/rollback and SQLite `quick_check` evidence pass. |
+| Remaining product work | PENDING | Accessibility/performance completion, final documentation review, full archive evidence, release, NAS backup/restore, rollback, and deployment gates remain active. |
## 2026-07-05 Local-First Media AI, Header, and Persistence Pass
diff --git a/README.md b/README.md
index efb0c4f..7b49257 100644
--- a/README.md
+++ b/README.md
@@ -21,7 +21,7 @@ Woodsmith is a self-hosted Next.js application for the Beaman Woodworks company
- Buyer account pages for signup, login, password reset, profile editing, profile images, and account-linked projects
- Private Woodshop dashboard with focused workspace tabs for editing settings, pages, pieces, custom work types, users, media, process notes, projects, orders, reviews, and notifications through structured browser forms
- Admin-only pencil controls backed by a typed field registry, atomic audited saves, conflict detection, URL/origin validation, reset/undo controls, and an explicit visual full-editor link for structural work
-- A compact browser media desk with whole-library and AI-state filters, in-place paging and edits, explicit candidate review, guided local training actions, training-weighted visual ranking, upload/rename/delete safety, persistent assignment, crop/focal controls, and source credit against the writable NAS photo library
+- A compact browser media desk with whole-library and AI-state filters, in-place paging and edits, explicit candidate review, guided local training actions, transactional selected-item folder/name/assignment/role/stage changes with rollback, upload/rename/delete safety, crop/focal controls, and source credit against the writable NAS photo library
- Email notification queueing, Stripe invoice creation, and EasyPost shipping-label requests when the related environment variables are configured
- Full-size image lightbox support with zoom, pan, arrow navigation, plus `Esc` and close-button exit behavior
- Keyword/metadata search plus visual search across public content and, for admins, private media, visual labels, clusters, unpublished content, and project records. The optional local sidecar supplies shared image/text CLIP vectors; Gemini or OpenAI embeddings remain opt-in alternatives.
diff --git a/admin.md b/admin.md
index 6599f37..c16bea8 100644
--- a/admin.md
+++ b/admin.md
@@ -61,10 +61,12 @@ The media section operates against the NAS photo library mounted directly to `/a
- browse a compact thumbnail workspace instead of a long full-page stack of editors
- assign media to a piece, process note, page, or project
- rename files in place
+- select up to 96 cards and apply one folder, deterministic name pattern, piece assignment, normalized role/stage/visibility, review state, quality rating, and tag change as a compensated batch
+- roll back a recent completed batch when none of its media/link snapshots have been changed afterward
- edit alt text, tags, focal X/Y, zoom, and reviewed status
- use the visual crop editor to set focal point, zoom, crop frame, and crop notes through sliders and form controls
- set cleanup mode, photo quality, source credit, visual search labels, and display order; verified-piece metadata is derived from the reviewed piece assignment
-- generate a cleaned copy of an image when `OPENAI_API_KEY` and `ENABLE_AI_BACKGROUND_CLEANUP=true` are configured
+- generate an unpublished cleaned derivative under `derivatives/background-cleanup/` when `OPENAI_API_KEY` and `ENABLE_AI_BACKGROUND_CLEANUP=true` are configured; the source file is never overwritten, derivatives cannot be chained, and manual review remains required
- delete files
- refresh the indexed library
- select one or more cards and run **Train selected**, **Improve page**, or **Continue library** without choosing individual scan/analyze/embed/cluster steps
@@ -74,6 +76,8 @@ The media section operates against the NAS photo library mounted directly to `/a
The desk keeps one active inspector beside the thumbnail browser on desktop; phones use a fixed-height Tools / Library / Inspector switcher to avoid stacking three long panes. Routine saves, assignments, renames, uploads, and deletes update in place without reloading the Studio route. `J`/`K` move between visible records, `F` focuses whole-library search, `P` focuses piece assignment, `U` clears the assignment, `R` toggles review state, `I` analyzes, `E` embeds, `C` inspects the current cluster, `S` saves, `Shift+S` saves and advances, and `A` approves and advances. Assignment changes update both media metadata and the affected piece galleries; unreviewed media stays private until approved. Reviewed assignments, reviewer rejections, verified cluster neighbors, and same-folder review history are saved as training evidence and weighted into later candidate rankings.
+The **Organize selected** panel uses `{name}`, `{index}`, and `{folder}` rename tokens. Every batch is preflighted for collisions, limited to 96 records, recorded in `media_operation_batches` / `media_operation_items`, and applied with one SQLite reference transaction after the filesystem moves succeed. If a move or database update fails, completed moves are reversed. Rollback performs the same checks in reverse and stops rather than overwriting a media record or normalized link changed after the original batch. Back up `site/data/` and the mounted photo tree together before large production reorganizations.
+
Synology sidecar files such as `SYNOINDEX_MEDIA_INFO`, `.DS_Store`, `Thumbs.db`, AppleDouble `._*`, `@eaDir`, and `SYNOFILE_THUMB*` files are filtered during indexing and querying. Manual media assignments take priority over heuristic clustering. The verification queue offers at most one sufficiently separated best-piece proposal per unassigned image; ambiguous matches remain in the library for manual review. Inspecting a candidate never assigns it.
### Media automation providers
diff --git a/site/app/studio/page.tsx b/site/app/studio/page.tsx
index 9355a74..a3053ad 100644
--- a/site/app/studio/page.tsx
+++ b/site/app/studio/page.tsx
@@ -14,8 +14,10 @@ import {
loadMediaPageAction,
loadMediaVerificationQueueAction,
markMediaAiSuggestionWrongAction,
- refreshMediaLibraryAction,
- renameMediaAction,
+ organizeMediaBatchAction,
+ refreshMediaLibraryAction,
+ renameMediaAction,
+ rollbackMediaBatchAction,
saveCommissionTypeAction,
savePieceCategoryAction,
saveMediaMetadataAction,
@@ -39,7 +41,8 @@ import {
getStudioDashboardSummary,
getRuntimePersistenceStatus,
listCommissionTypes,
- listMedia,
+ listMedia,
+ listMediaOperationBatches,
listMediaForProjectReferences,
listNotifications,
listOrders,
@@ -567,6 +570,7 @@ export default async function StudioPage({
cleanupAction={cleanupMediaBackgroundAction}
deleteAction={deleteMediaAction}
initialItems={media}
+ initialOperations={listMediaOperationBatches(12)}
initialAssignment={mediaAssignment}
initialKind={mediaKind}
initialAiFilter={mediaAi}
@@ -580,8 +584,10 @@ export default async function StudioPage({
pages={pages.map((page) => ({ slug: page.slug, title: page.title }))}
pieces={pieces.map((piece) => ({ slug: piece.slug, title: piece.title }))}
posts={posts.map((post) => ({ slug: post.slug, title: post.title }))}
+ organizeBatchAction={organizeMediaBatchAction}
refreshAction={refreshMediaLibraryAction}
renameAction={renameMediaAction}
+ rollbackBatchAction={rollbackMediaBatchAction}
saveAction={saveMediaMetadataAction}
uploadAction={uploadMediaAction}
verificationQueue={verificationQueue.map((entry) => ({ pieceSlug: entry.piece.slug, pieceTitle: entry.piece.title, assignedCount: entry.assigned.length, needsReview: entry.needsReview, suggestions: entry.suggestions }))}
diff --git a/site/app/ui-repair.css b/site/app/ui-repair.css
index 0e244f0..5e59798 100644
--- a/site/app/ui-repair.css
+++ b/site/app/ui-repair.css
@@ -650,6 +650,63 @@ body:has(.inline-edit-hint) main {
letter-spacing: 0.03em;
}
+.media-batch-panel > summary {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+}
+
+.media-batch-panel > summary span {
+ min-width: 1.6rem;
+ padding: 0.12rem 0.4rem;
+ border-radius: var(--radius-pill);
+ background: color-mix(in srgb, var(--accent) 16%, transparent);
+ text-align: center;
+ font-size: 0.72rem;
+}
+
+.media-batch-form {
+ margin-top: 0.55rem;
+ gap: 0.48rem;
+}
+
+.media-batch-panel .field-grid {
+ grid-template-columns: minmax(0, 1fr);
+}
+
+.media-batch-panel code {
+ color: var(--accent);
+ font-size: 0.72rem;
+}
+
+.media-batch-history {
+ display: grid;
+ gap: 0.38rem;
+ margin-top: 0.65rem;
+ padding-top: 0.55rem;
+ border-top: 1px solid var(--line);
+}
+
+.media-batch-history article {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.45rem;
+ padding: 0.35rem 0;
+}
+
+.media-batch-history article span,
+.media-batch-history article small {
+ display: block;
+}
+
+.media-batch-history article small {
+ margin-top: 0.08rem;
+ color: var(--muted);
+ font-size: 0.64rem;
+}
+
.studio-verification-list {
display: grid;
gap: 0.55rem;
diff --git a/site/components/studio-media-workspace.tsx b/site/components/studio-media-workspace.tsx
index f63e107..e039a18 100644
--- a/site/components/studio-media-workspace.tsx
+++ b/site/components/studio-media-workspace.tsx
@@ -7,7 +7,7 @@ import { MediaLightbox } from "@/components/lightbox";
import { MediaCropEditor } from "@/components/media-crop-editor";
import { toMediaUrl } from "@/lib/format";
import type { MediaActionResult, MediaPageRequest, MediaPageResult } from "@/lib/actions";
-import type { MediaAiFilter, MediaAssignmentFilter, MediaKindFilter, MediaRecord } from "@/lib/db";
+import type { MediaAiFilter, MediaAssignmentFilter, MediaKindFilter, MediaOperationBatchRecord, MediaRecord } from "@/lib/db";
import type { MediaMatchCandidate } from "@/lib/media-audit";
type MediaAction = (state: MediaActionResult | null, formData: FormData) => Promise;
@@ -62,6 +62,7 @@ type StudioMediaWorkspaceProps = {
initialAssignment: MediaAssignmentFilter;
initialKind: MediaKindFilter;
initialAiFilter: MediaAiFilter;
+ initialOperations: MediaOperationBatchRecord[];
pieces: StudioOption[];
posts: StudioOption[];
pages: StudioOption[];
@@ -71,6 +72,8 @@ type StudioMediaWorkspaceProps = {
deleteAction: MediaAction;
saveAction: MediaAction;
cleanupAction: MediaAction;
+ organizeBatchAction: MediaAction;
+ rollbackBatchAction: MediaAction;
assignAction: MediaAction;
rejectSuggestionAction: MediaAction;
refreshAction: () => Promise;
@@ -78,6 +81,8 @@ type StudioMediaWorkspaceProps = {
loadVerificationQueueAction: () => Promise;
};
+const MEDIA_ROLES = ["hero", "gallery", "detail", "context", "process", "drawing", "plan", "installation", "source", "private-project"] as const;
+
function confidenceForScore(score: number) {
if (score >= 82) return { label: "High confidence", className: "is-strong" };
if (score >= 58) return { label: "Needs review", className: "is-moderate" };
@@ -236,6 +241,77 @@ function UploadMediaPanel({
);
}
+function MediaBatchPanel({
+ selectedPaths,
+ pieces,
+ operations,
+ organizeAction,
+ rollbackAction,
+ onCompleted
+}: {
+ selectedPaths: Set;
+ pieces: StudioOption[];
+ operations: MediaOperationBatchRecord[];
+ organizeAction: MediaAction;
+ rollbackAction: MediaAction;
+ onCompleted: (result: Extract) => void;
+}) {
+ const completedBatches = operations.filter((operation) => operation.operation === "organize").slice(0, 6);
+ return (
+
+ Organize selected {selectedPaths.size}
+ One operation updates files, references, assignments, and normalized roles together. A failed step restores prior file paths.
+ {
+ if (result.kind === "batch") onCompleted(result);
+ }}>
+
+
+ Move to folder
+ Rename pattern
+
+
+ Piece assignment
+
+ Keep each current assignment
+ Clear piece assignments
+ {pieces.map((piece) => {piece.title} )}
+
+
+
+ Role Keep current role {MEDIA_ROLES.map((role) => {role.replaceAll("-", " ")} )}
+ Build stage Keep current stage Clear stage Use stage below
+
+ Stage name
+
+ Review Keep review state Needs review Mark reviewed
+ Piece visibility Keep visibility Private link Public link + reviewed
+ Photo quality Keep rating Unrated Shop ready Portfolio ready Background distracting Needs reshoot
+
+
+ Add tags
+ Remove tags
+
+ Rename tokens: {"{name}"}, {"{index}"}, {"{folder}"}. Public links always require reviewed media.
+ Apply to {selectedPaths.size || "selected"}
+
+ {completedBatches.length > 0 ?
+
Recent operations
+ {completedBatches.map((operation) => (
+
+ {operation.itemCount} item{operation.itemCount === 1 ? "" : "s"} {operation.status} · {aiTimestamp(operation.createdAt)}
+ {operation.status === "completed" ? {
+ if (result.kind === "rollback") onCompleted(result);
+ }}>
+
+ Roll back
+ : null}
+
+ ))}
+
: null}
+
+ );
+}
+
function MediaInspector({
item,
pieces,
@@ -500,6 +576,7 @@ export function StudioMediaWorkspace({
initialAssignment,
initialKind,
initialAiFilter,
+ initialOperations,
pieces,
posts,
pages,
@@ -509,6 +586,8 @@ export function StudioMediaWorkspace({
deleteAction,
saveAction,
cleanupAction,
+ organizeBatchAction,
+ rollbackBatchAction,
assignAction,
rejectSuggestionAction,
refreshAction,
@@ -528,6 +607,7 @@ export function StudioMediaWorkspace({
const [queue, setQueue] = useState(verificationQueue);
const [candidateAssignments, setCandidateAssignments] = useState>({});
const [selectedPaths, setSelectedPaths] = useState>(() => new Set());
+ const [operations, setOperations] = useState(initialOperations);
const [automationResult, setAutomationResult] = useState(null);
const [providerStatus, setProviderStatus] = useState(undefined);
const [cacheStatus, setCacheStatus] = useState(undefined);
@@ -555,8 +635,9 @@ export function StudioMediaWorkspace({
setKindFilter(initialKind);
setAiFilter(initialAiFilter);
setQueue(verificationQueue);
+ setOperations(initialOperations);
setSelectedPath((current) => initialItems.some((item) => item.relativePath === current) ? current : initialItems[0]?.relativePath ?? "");
- }, [initialAiFilter, initialAssignment, initialItems, initialKind, initialPage, initialPageSize, initialQuery, initialTotal, verificationQueue]);
+ }, [initialAiFilter, initialAssignment, initialItems, initialKind, initialOperations, initialPage, initialPageSize, initialQuery, initialTotal, verificationQueue]);
useEffect(() => {
let active = true;
@@ -768,6 +849,20 @@ export function StudioMediaWorkspace({
uploadAction={uploadAction}
/>
+ {
+ setOperations(result.operations);
+ setSelectedPaths(new Set());
+ setPageMessage(result.message);
+ void refreshWorkspaceData(false);
+ }}
+ operations={operations}
+ organizeAction={organizeBatchAction}
+ pieces={pieces}
+ rollbackAction={rollbackBatchAction}
+ selectedPaths={selectedPaths}
+ />
+
Guided media trainer
One guided run updates review evidence only. Every assignment, rejection, and public approval remains manual and persistent.
@@ -929,7 +1024,7 @@ export function StudioMediaWorkspace({
{item.reviewed ? "Reviewed" : "Needs review"}{clusterId ? ` · ${clusterId.slice(-6)}` : ""}
- setSelectedPaths((current) => { const next = new Set(current); if (next.has(item.relativePath)) next.delete(item.relativePath); else next.add(item.relativePath); return next; })} title="Select for batch automation" type="button">{selectedForAutomation ? "Selected" : "Select"}
+ setSelectedPaths((current) => { const next = new Set(current); if (next.has(item.relativePath)) next.delete(item.relativePath); else next.add(item.relativePath); return next; })} title="Select for organize or training actions" type="button">{selectedForAutomation ? "Selected" : "Select"}
;
})}
{items.length === 0 ?
No media found Clear a filter, rescan the mounted library, or upload a file.
: null}
diff --git a/site/lib/actions.ts b/site/lib/actions.ts
index ce8d035..17d0422 100644
--- a/site/lib/actions.ts
+++ b/site/lib/actions.ts
@@ -4,12 +4,16 @@ import { cookies } from "next/headers";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import {
+ PIECE_MEDIA_ROLES,
+ applyMediaOperationSnapshots,
appendProjectUpdate,
+ captureMediaOperationSnapshot,
countMedia,
countUsersByRole,
consumeCommissionRenderAsset,
consumeCommissionSubmissionQuota,
createDraftOrder,
+ createMediaOperationBatch,
createProject,
createProjectIdempotent,
deleteCommissionType,
@@ -55,6 +59,9 @@ import {
updateProject,
withDatabaseTransaction,
finishMediaRenameHistory,
+ failMediaOperationBatch,
+ getMediaOperationBatch,
+ listMediaOperationBatches,
recordAdminEditAudit,
renameMediaRecordAndReferences,
replacePieceMediaLinks,
@@ -63,6 +70,7 @@ import {
type MediaAssignmentFilter,
type MediaAiFilter,
type MediaKindFilter,
+ type MediaOperationBatchRecord,
type MediaRecord,
type OrderRecord,
type PageRecord,
@@ -83,6 +91,15 @@ import {
restoreStagedMediaAsset,
stageMediaAssetDeletion
} from "@/lib/media";
+import {
+ buildMediaOperationPlan,
+ invertMediaOperationPlan,
+ moveMediaOperationFiles,
+ restoreMediaOperationFiles,
+ type MediaBatchOptions,
+ type MediaOperationMutation,
+ type MovedMediaAsset
+} from "@/lib/media-operations";
import { calculateCheckoutTotals, createEasyPostShippingLabel, createStripeCheckoutSession, createStripeInvoice, stripeIsConfigured } from "@/lib/payments";
import { sendNotificationEmail, summarizeEmailFailure } from "@/lib/notifications";
import { createCleanedBackgroundVariant, getAiServiceStatus } from "@/lib/ai-services";
@@ -1367,6 +1384,7 @@ export type MediaActionResult =
| { ok: true; kind: "assign"; relativePath: string; pieceSlug: string }
| { ok: true; kind: "cleanup"; relativePath: string }
| { ok: true; kind: "save"; relativePath: string }
+ | { ok: true; kind: "batch" | "rollback"; batchId: string; message: string; paths: Array<{ previousPath: string; relativePath: string }>; operations: MediaOperationBatchRecord[] }
| { ok: true; kind: "refresh" }
| { ok: false; kind: "error"; message: string };
@@ -1545,6 +1563,152 @@ export async function renameMediaAction(_: unknown, formData: FormData): Promise
}
}
+async function executeMediaOperationPlan(input: {
+ actorEmail: string;
+ operation: MediaOperationBatchRecord["operation"];
+ request: Record
;
+ mutations: MediaOperationMutation[];
+ rollbackOf?: string | null;
+}) {
+ const batch = createMediaOperationBatch({
+ operation: input.operation,
+ actorEmail: input.actorEmail,
+ request: input.request,
+ rollbackOf: input.rollbackOf,
+ mutations: input.mutations
+ });
+ let moved: MovedMediaAsset[] = [];
+ try {
+ moved = moveMediaOperationFiles(input.mutations);
+ const result = applyMediaOperationSnapshots({
+ mutations: input.mutations,
+ actorEmail: input.actorEmail,
+ requestId: batch.id,
+ batchId: batch.id,
+ markRolledBackBatchId: input.rollbackOf ?? null
+ });
+ revalidateMediaSurfaces(result.affected);
+ return { batch: getMediaOperationBatch(batch.id)!, operations: listMediaOperationBatches(12) };
+ } catch (error) {
+ let failure = error instanceof Error ? error : new Error(String(error));
+ if (moved.length > 0) {
+ try {
+ restoreMediaOperationFiles(moved);
+ } catch (rollbackError) {
+ failure = new Error(`${failure.message} Filesystem rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, { cause: failure });
+ }
+ }
+ failMediaOperationBatch(batch.id, failure.message);
+ throw failure;
+ }
+}
+
+export async function organizeMediaBatchAction(_: unknown, formData: FormData): Promise {
+ try {
+ const admin = await requireAdmin();
+ const selectedPaths = [...new Set(parseJsonField(formData.get("selectedPathsJson"), []).map((value) => String(value).trim()).filter(Boolean))];
+ if (selectedPaths.length === 0) return { ok: false, kind: "error", message: "Select at least one media item." };
+ if (selectedPaths.length > 96) return { ok: false, kind: "error", message: "Select no more than 96 media items per batch." };
+
+ const pieceSelection = optionalField(formData.get("pieceAssignment")) || "__keep__";
+ const pieceAssignment: MediaBatchOptions["pieceAssignment"] = pieceSelection === "__clear__" ? "clear" : pieceSelection === "__keep__" ? "keep" : "set";
+ const pieceSlug = pieceAssignment === "set" ? pieceSelection : undefined;
+ if (pieceSlug && !getPiece(pieceSlug)) return { ok: false, kind: "error", message: "The selected piece no longer exists." };
+
+ const requestedRole = optionalField(formData.get("role")) || "keep";
+ const role: MediaBatchOptions["role"] = requestedRole === "keep"
+ ? "keep"
+ : PIECE_MEDIA_ROLES.includes(requestedRole as (typeof PIECE_MEDIA_ROLES)[number])
+ ? requestedRole as (typeof PIECE_MEDIA_ROLES)[number]
+ : "gallery";
+ const stageModeValue = optionalField(formData.get("stageMode"));
+ const stageMode: MediaBatchOptions["stageMode"] = stageModeValue === "clear" || stageModeValue === "set" ? stageModeValue : "keep";
+ const visibilityValue = optionalField(formData.get("visibility"));
+ const visibility: MediaBatchOptions["visibility"] = visibilityValue === "private" || visibilityValue === "public" ? visibilityValue : "keep";
+ const reviewValue = optionalField(formData.get("review"));
+ const review: MediaBatchOptions["review"] = reviewValue === "unreviewed" || reviewValue === "reviewed" ? reviewValue : "keep";
+ const photoQualityValue = optionalField(formData.get("photoQuality"));
+ const photoQuality: MediaBatchOptions["photoQuality"] = ["unrated", "shop-ready", "portfolio-ready", "background-distracting", "needs-reshoot"].includes(photoQualityValue)
+ ? photoQualityValue as Exclude
+ : "keep";
+ const options: MediaBatchOptions = {
+ folder: optionalField(formData.get("folder")),
+ renamePattern: optionalField(formData.get("renamePattern")) || "{name}",
+ pieceAssignment,
+ pieceSlug,
+ role,
+ stageMode,
+ stage: optionalField(formData.get("stage")),
+ visibility,
+ review,
+ addTags: parseListField(formData.get("addTags")),
+ removeTags: parseListField(formData.get("removeTags")),
+ photoQuality,
+ actorEmail: admin.email
+ };
+ const snapshots = selectedPaths.map(captureMediaOperationSnapshot);
+ const mutations = buildMediaOperationPlan(snapshots, options);
+ const result = await executeMediaOperationPlan({
+ actorEmail: admin.email,
+ operation: "organize",
+ request: {
+ count: selectedPaths.length,
+ folder: options.folder || null,
+ renamePattern: options.renamePattern,
+ pieceAssignment,
+ pieceSlug: pieceSlug ?? null,
+ role,
+ stageMode,
+ stage: options.stage || null,
+ visibility,
+ review,
+ addTags: options.addTags,
+ removeTags: options.removeTags,
+ photoQuality
+ },
+ mutations
+ });
+ return {
+ ok: true,
+ kind: "batch",
+ batchId: result.batch.id,
+ message: `Updated ${result.batch.itemCount} media item${result.batch.itemCount === 1 ? "" : "s"}.`,
+ paths: result.batch.items.map((item) => ({ previousPath: item.previousPath, relativePath: item.nextPath })),
+ operations: result.operations
+ };
+ } catch (error) {
+ return mediaActionFailure(error, "Media batch update failed.");
+ }
+}
+
+export async function rollbackMediaBatchAction(_: unknown, formData: FormData): Promise {
+ try {
+ const admin = await requireAdmin();
+ const batchId = requiredField(formData.get("batchId"), "Media batch");
+ const original = getMediaOperationBatch(batchId);
+ if (!original || original.operation !== "organize") return { ok: false, kind: "error", message: "The selected media batch was not found." };
+ if (original.status !== "completed") return { ok: false, kind: "error", message: "Only a completed media batch can be rolled back." };
+ const mutations = invertMediaOperationPlan(original.items);
+ const result = await executeMediaOperationPlan({
+ actorEmail: admin.email,
+ operation: "rollback",
+ request: { count: mutations.length, rollbackOf: original.id },
+ rollbackOf: original.id,
+ mutations
+ });
+ return {
+ ok: true,
+ kind: "rollback",
+ batchId: result.batch.id,
+ message: `Restored ${result.batch.itemCount} media item${result.batch.itemCount === 1 ? "" : "s"}.`,
+ paths: result.batch.items.map((item) => ({ previousPath: item.previousPath, relativePath: item.nextPath })),
+ operations: result.operations
+ };
+ } catch (error) {
+ return mediaActionFailure(error, "Media batch rollback failed.");
+ }
+}
+
export async function deleteMediaAction(_: unknown, formData: FormData): Promise {
try {
const admin = await requireAdmin();
@@ -1631,6 +1795,9 @@ export async function cleanupMediaBackgroundAction(_: unknown, formData: FormDat
if (!media) {
return { ok: false, kind: "error", message: "Media not found for cleanup." };
}
+ if (media.metadata.cleanupGeneratedFrom || media.metadata.derivativeKind === "background-cleanup") {
+ return { ok: false, kind: "error", message: "Create cleanup derivatives from an original image, not from another generated copy." };
+ }
if (!getAiServiceStatus().backgroundCleanup) {
return { ok: false, kind: "error", message: "AI background cleanup is not configured on this deployment." };
@@ -1650,8 +1817,9 @@ export async function cleanupMediaBackgroundAction(_: unknown, formData: FormDat
}
const stem = relativePath.replace(/\.[^.]+$/, "").split("/").pop() || "cleaned-media";
- const nextPath = persistGeneratedMedia(b64Json, "cleaned-media", stem, ".png");
+ const nextPath = persistGeneratedMedia(b64Json, "Derivatives/background-cleanup", stem, ".png");
refreshMediaLibrary();
+ const generatedAt = new Date().toISOString();
saveMediaMetadata({
relativePath: nextPath,
altText: `${media.altText || media.fileName} cleaned background`,
@@ -1667,12 +1835,30 @@ export async function cleanupMediaBackgroundAction(_: unknown, formData: FormDat
tags: [...new Set([...media.tags, "cleaned-background", mode])],
metadata: {
...media.metadata,
+ verifiedPieceSlug: "",
+ verifiedAt: "",
+ verifiedBy: "",
+ aiTrainingLabel: "",
+ aiTrainingPieceSlug: "",
cleanupMode: mode,
cleanupGeneratedFrom: relativePath,
- cleanupGeneratedAt: new Date().toISOString(),
- cleanupProvider: getAiServiceStatus().imageModel
+ cleanupGeneratedAt: generatedAt,
+ cleanupProvider: getAiServiceStatus().imageModel,
+ derivativeKind: "background-cleanup",
+ derivativeSourcePath: relativePath,
+ derivativeSourceUpdatedAt: media.updatedAt,
+ derivativeSourceSizeBytes: media.sizeBytes,
+ derivativePublicationGate: "manual-review-required",
+ manualApprovalRequired: true
}
});
+ const existingDerivatives = Array.isArray(media.metadata.cleanupDerivativePaths)
+ ? media.metadata.cleanupDerivativePaths.map(String)
+ : [];
+ patchMediaMetadata(relativePath, {
+ cleanupDerivativePaths: [...new Set([...existingDerivatives, nextPath])],
+ cleanupLastGeneratedAt: generatedAt
+ });
revalidateMediaSurfaces();
return { ok: true, kind: "cleanup", relativePath: nextPath };
diff --git a/site/lib/database-migrations.test.mts b/site/lib/database-migrations.test.mts
index 2bfe2c2..f60d871 100644
--- a/site/lib/database-migrations.test.mts
+++ b/site/lib/database-migrations.test.mts
@@ -43,7 +43,7 @@ test("schema migrations are additive, idempotent, and preserve legacy truth", ()
const second = applySchemaMigrations(db);
assert.equal(first.quickCheckBefore, "ok");
assert.equal(first.quickCheckAfter, "ok");
- assert.deepEqual(first.applied.map((entry) => entry.version), [1, 2, 3, 4, 5]);
+ assert.deepEqual(first.applied.map((entry) => entry.version), [1, 2, 3, 4, 5, 6]);
assert.equal(second.applied.length, 0);
const policies = (db.prepare(`SELECT slug, price_mode AS priceMode, price_cents AS priceCents, inquiry_mode AS inquiryMode, reviews_mode AS reviewsMode FROM pieces ORDER BY slug`).all() as Array>).map((row) => ({ ...row }));
diff --git a/site/lib/database-migrations.ts b/site/lib/database-migrations.ts
index 5ea8739..5a58e54 100644
--- a/site/lib/database-migrations.ts
+++ b/site/lib/database-migrations.ts
@@ -314,6 +314,52 @@ const migrations: Migration[] = [
`);
return { tables: ["commission_submission_usage"] };
}
+ },
+ {
+ version: 6,
+ name: "transactional-media-operation-ledger",
+ checksum: "2026-07-media-operation-ledger-v1",
+ apply(db) {
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS media_operation_batches (
+ id TEXT PRIMARY KEY,
+ operation TEXT NOT NULL CHECK (operation IN ('organize', 'rollback')),
+ status TEXT NOT NULL CHECK (status IN ('planned', 'completed', 'rolled-back', 'failed')),
+ actor_email TEXT,
+ request_json TEXT NOT NULL DEFAULT '{}',
+ error TEXT,
+ rollback_of TEXT,
+ created_at TEXT NOT NULL,
+ completed_at TEXT,
+ FOREIGN KEY (rollback_of) REFERENCES media_operation_batches(id)
+ ) STRICT;
+ CREATE INDEX IF NOT EXISTS idx_media_operation_batches_created
+ ON media_operation_batches(created_at DESC);
+ CREATE INDEX IF NOT EXISTS idx_media_operation_batches_rollback
+ ON media_operation_batches(rollback_of);
+
+ CREATE TABLE IF NOT EXISTS media_operation_items (
+ id TEXT PRIMARY KEY,
+ batch_id TEXT NOT NULL,
+ ordinal INTEGER NOT NULL,
+ previous_path TEXT NOT NULL,
+ next_path TEXT NOT NULL,
+ before_json TEXT NOT NULL,
+ after_json TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ FOREIGN KEY (batch_id) REFERENCES media_operation_batches(id) ON DELETE CASCADE,
+ UNIQUE (batch_id, ordinal),
+ UNIQUE (batch_id, previous_path)
+ ) STRICT;
+ CREATE INDEX IF NOT EXISTS idx_media_operation_items_batch
+ ON media_operation_items(batch_id, ordinal);
+ CREATE INDEX IF NOT EXISTS idx_media_operation_items_previous
+ ON media_operation_items(previous_path);
+ CREATE INDEX IF NOT EXISTS idx_media_operation_items_next
+ ON media_operation_items(next_path);
+ `);
+ return { tables: ["media_operation_batches", "media_operation_items"] };
+ }
}
];
diff --git a/site/lib/db.ts b/site/lib/db.ts
index 3b84f37..7b2f14d 100644
--- a/site/lib/db.ts
+++ b/site/lib/db.ts
@@ -171,6 +171,36 @@ export type MediaRenameHistoryRecord = {
completedAt: string | null;
};
+export type MediaOperationSnapshot = {
+ media: MediaRecord;
+ links: PieceMediaLinkRecord[];
+};
+
+export type MediaOperationItemRecord = {
+ id: string;
+ batchId: string;
+ ordinal: number;
+ previousPath: string;
+ nextPath: string;
+ before: MediaOperationSnapshot;
+ after: MediaOperationSnapshot;
+ createdAt: string;
+};
+
+export type MediaOperationBatchRecord = {
+ id: string;
+ operation: "organize" | "rollback";
+ status: "planned" | "completed" | "rolled-back" | "failed";
+ actorEmail: string | null;
+ request: Record;
+ error: string | null;
+ rollbackOf: string | null;
+ createdAt: string;
+ completedAt: string | null;
+ itemCount: number;
+ items: MediaOperationItemRecord[];
+};
+
export type PostRecord = {
slug: string;
title: string;
@@ -2165,6 +2195,20 @@ export function listPieceMediaLinks(pieceSlug: string, options: { publicOnly?: b
return rows.map(mapPieceMediaLink);
}
+export function listPieceMediaLinksForPath(relativePath: string) {
+ const db = getDatabase();
+ const rows = db.prepare(`
+ SELECT id, piece_slug AS pieceSlug, relative_path AS relativePath, role, stage,
+ occurred_at AS occurredAt, title, caption, technical_note AS technicalNote,
+ alt_override AS altOverride, display_order AS displayOrder, is_public AS public,
+ legacy_synced AS legacySynced, created_at AS createdAt, updated_at AS updatedAt
+ FROM piece_media_links
+ WHERE relative_path = ?
+ ORDER BY piece_slug ASC, display_order ASC, created_at ASC
+ `).all(relativePath) as Record[];
+ return rows.map(mapPieceMediaLink);
+}
+
export type PieceMediaLinkInput = Omit & {
id?: string;
};
@@ -2750,6 +2794,272 @@ export function listMediaRenameHistory(limit = 100) {
}));
}
+function mapMediaOperationItem(row: Record): MediaOperationItemRecord {
+ return {
+ id: String(row.id),
+ batchId: String(row.batchId),
+ ordinal: Number(row.ordinal),
+ previousPath: String(row.previousPath),
+ nextPath: String(row.nextPath),
+ before: readJson(row.beforeJson, {} as MediaOperationSnapshot),
+ after: readJson(row.afterJson, {} as MediaOperationSnapshot),
+ createdAt: String(row.createdAt)
+ };
+}
+
+function mediaOperationItems(batchId: string) {
+ const db = getDatabase();
+ const rows = db.prepare(`
+ SELECT id, batch_id AS batchId, ordinal, previous_path AS previousPath, next_path AS nextPath,
+ before_json AS beforeJson, after_json AS afterJson, created_at AS createdAt
+ FROM media_operation_items
+ WHERE batch_id = ?
+ ORDER BY ordinal ASC
+ `).all(batchId) as Record[];
+ return rows.map(mapMediaOperationItem);
+}
+
+function mapMediaOperationBatch(row: Record, includeItems = true): MediaOperationBatchRecord {
+ const id = String(row.id);
+ const items = includeItems ? mediaOperationItems(id) : [];
+ return {
+ id,
+ operation: row.operation as MediaOperationBatchRecord["operation"],
+ status: row.status as MediaOperationBatchRecord["status"],
+ actorEmail: row.actorEmail ? String(row.actorEmail) : null,
+ request: readJson>(row.requestJson, {}),
+ error: row.error ? String(row.error) : null,
+ rollbackOf: row.rollbackOf ? String(row.rollbackOf) : null,
+ createdAt: String(row.createdAt),
+ completedAt: row.completedAt ? String(row.completedAt) : null,
+ itemCount: Number(row.itemCount ?? items.length),
+ items
+ };
+}
+
+export function captureMediaOperationSnapshot(relativePath: string): MediaOperationSnapshot {
+ const media = getMedia(relativePath);
+ if (!media) throw new Error(`Media '${relativePath}' is not indexed.`);
+ return { media, links: listPieceMediaLinksForPath(relativePath) };
+}
+
+export function getMediaOperationBatch(id: string) {
+ const db = getDatabase();
+ const row = db.prepare(`
+ SELECT b.id, b.operation, b.status, b.actor_email AS actorEmail, b.request_json AS requestJson,
+ b.error, b.rollback_of AS rollbackOf, b.created_at AS createdAt, b.completed_at AS completedAt,
+ COUNT(i.id) AS itemCount
+ FROM media_operation_batches b
+ LEFT JOIN media_operation_items i ON i.batch_id = b.id
+ WHERE b.id = ?
+ GROUP BY b.id
+ LIMIT 1
+ `).get(id) as Record | undefined;
+ return row ? mapMediaOperationBatch(row) : null;
+}
+
+export function listMediaOperationBatches(limit = 12) {
+ const db = getDatabase();
+ const rows = db.prepare(`
+ SELECT b.id, b.operation, b.status, b.actor_email AS actorEmail, b.request_json AS requestJson,
+ b.error, b.rollback_of AS rollbackOf, b.created_at AS createdAt, b.completed_at AS completedAt,
+ COUNT(i.id) AS itemCount
+ FROM media_operation_batches b
+ LEFT JOIN media_operation_items i ON i.batch_id = b.id
+ GROUP BY b.id
+ ORDER BY b.created_at DESC
+ LIMIT ?
+ `).all(Math.max(1, Math.min(50, Math.round(limit)))) as Record[];
+ return rows.map((row) => mapMediaOperationBatch(row, false));
+}
+
+export function createMediaOperationBatch(input: {
+ operation: MediaOperationBatchRecord["operation"];
+ actorEmail?: string | null;
+ request?: Record;
+ rollbackOf?: string | null;
+ mutations: Array<{ before: MediaOperationSnapshot; after: MediaOperationSnapshot }>;
+}) {
+ if (input.mutations.length === 0) throw new Error("A media operation requires at least one item.");
+ return withDatabaseTransaction((db) => {
+ const id = randomUUID();
+ const timestamp = nowIso();
+ db.prepare(`
+ INSERT INTO media_operation_batches (
+ id, operation, status, actor_email, request_json, error, rollback_of, created_at, completed_at
+ ) VALUES (?, ?, 'planned', ?, ?, NULL, ?, ?, NULL)
+ `).run(id, input.operation, input.actorEmail?.toLowerCase() ?? null, writeJson(input.request ?? {}), input.rollbackOf ?? null, timestamp);
+ const insertItem = db.prepare(`
+ INSERT INTO media_operation_items (
+ id, batch_id, ordinal, previous_path, next_path, before_json, after_json, created_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ `);
+ input.mutations.forEach((mutation, ordinal) => {
+ insertItem.run(
+ randomUUID(),
+ id,
+ ordinal,
+ mutation.before.media.relativePath,
+ mutation.after.media.relativePath,
+ writeJson(mutation.before),
+ writeJson(mutation.after),
+ timestamp
+ );
+ });
+ return getMediaOperationBatch(id)!;
+ });
+}
+
+export function completeMediaOperationBatch(id: string, snapshots: MediaOperationSnapshot[]) {
+ return withDatabaseTransaction((db) => {
+ const batch = getMediaOperationBatch(id);
+ if (!batch || batch.status !== "planned") throw new Error("The media operation is no longer pending.");
+ if (batch.items.length !== snapshots.length) throw new Error("The media operation result count does not match its plan.");
+ const updateItem = db.prepare("UPDATE media_operation_items SET after_json = ?, next_path = ? WHERE batch_id = ? AND ordinal = ?");
+ snapshots.forEach((snapshot, ordinal) => updateItem.run(writeJson(snapshot), snapshot.media.relativePath, id, ordinal));
+ db.prepare("UPDATE media_operation_batches SET status = 'completed', error = NULL, completed_at = ? WHERE id = ?")
+ .run(nowIso(), id);
+ return getMediaOperationBatch(id)!;
+ });
+}
+
+export function failMediaOperationBatch(id: string, error: string) {
+ const db = getDatabase();
+ db.prepare("UPDATE media_operation_batches SET status = 'failed', error = ?, completed_at = ? WHERE id = ? AND status = 'planned'")
+ .run(error, nowIso(), id);
+}
+
+export function markMediaOperationRolledBack(id: string) {
+ const db = getDatabase();
+ db.prepare("UPDATE media_operation_batches SET status = 'rolled-back', completed_at = ? WHERE id = ? AND status = 'completed'")
+ .run(nowIso(), id);
+}
+
+function snapshotsMatch(current: MediaOperationSnapshot, expected: MediaOperationSnapshot) {
+ return current.media.updatedAt === expected.media.updatedAt
+ && writeJson(current.links) === writeJson(expected.links);
+}
+
+function synchronizePieceLegacyPathsFromLinks(db: DatabaseSync, pieceSlug: string) {
+ const row = db.prepare("SELECT metadata_json AS metadataJson FROM pieces WHERE slug = ? LIMIT 1").get(pieceSlug) as Record | undefined;
+ if (!row) return;
+ const links = db.prepare(`
+ SELECT relative_path AS relativePath, role, display_order AS displayOrder
+ FROM piece_media_links
+ WHERE piece_slug = ? AND is_public = 1 AND role IN ('hero', 'gallery', 'detail', 'context')
+ ORDER BY CASE role WHEN 'hero' THEN 0 WHEN 'gallery' THEN 1 WHEN 'detail' THEN 2 ELSE 3 END,
+ display_order ASC, created_at ASC
+ `).all(pieceSlug) as Array>;
+ const paths = [...new Set(links.map((link) => String(link.relativePath)))];
+ const metadata = readJson>(row.metadataJson, {});
+ db.prepare("UPDATE pieces SET media_paths_json = ?, metadata_json = ?, updated_at = ? WHERE slug = ?")
+ .run(writeJson(paths), writeJson({ ...metadata, verifiedMedia: paths.length > 0, mediaReviewRequired: paths.length === 0 }), nowIso(), pieceSlug);
+}
+
+function replaceMediaLinksForSnapshot(db: DatabaseSync, snapshot: MediaOperationSnapshot) {
+ const relativePath = snapshot.media.relativePath;
+ db.prepare("DELETE FROM piece_media_links WHERE relative_path = ?").run(relativePath);
+ const insert = db.prepare(`
+ INSERT INTO piece_media_links (
+ id, piece_slug, relative_path, role, stage, occurred_at, title, caption,
+ technical_note, alt_override, display_order, is_public, legacy_synced, created_at, updated_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `);
+ const timestamp = nowIso();
+ snapshot.links.forEach((link) => {
+ if (!PIECE_MEDIA_ROLES.includes(link.role)) throw new Error(`Unsupported piece media role '${link.role}'.`);
+ if (link.relativePath !== relativePath) throw new Error("A batch snapshot contains a mismatched media-link path.");
+ insert.run(
+ link.id || randomUUID(), link.pieceSlug, relativePath, link.role, link.stage, link.occurredAt,
+ link.title, link.caption, link.technicalNote, link.altOverride, Math.round(link.displayOrder),
+ link.public ? 1 : 0, link.legacySynced ? 1 : 0, link.createdAt || timestamp, timestamp
+ );
+ });
+}
+
+export function applyMediaOperationSnapshots(input: {
+ mutations: Array<{ before: MediaOperationSnapshot; after: MediaOperationSnapshot }>;
+ actorEmail?: string | null;
+ requestId?: string | null;
+ batchId?: string | null;
+ markRolledBackBatchId?: string | null;
+}) {
+ return withDatabaseTransaction((db) => {
+ const affectedPieceSlugs = new Set();
+ const affectedPostSlugs = new Set();
+ const affectedPageSlugs = new Set();
+
+ for (const mutation of input.mutations) {
+ const previousPath = mutation.before.media.relativePath;
+ const nextPath = mutation.after.media.relativePath;
+ const current = captureMediaOperationSnapshot(previousPath);
+ if (!snapshotsMatch(current, mutation.before)) {
+ throw new Error(`Media '${previousPath}' changed after this operation was prepared. Refresh and try again.`);
+ }
+ mutation.before.links.forEach((link) => affectedPieceSlugs.add(link.pieceSlug));
+ mutation.after.links.forEach((link) => affectedPieceSlugs.add(link.pieceSlug));
+ [mutation.before.media.pieceSlug, mutation.after.media.pieceSlug].filter(Boolean).forEach((slug) => affectedPieceSlugs.add(String(slug)));
+ [mutation.before.media.postSlug, mutation.after.media.postSlug].filter(Boolean).forEach((slug) => affectedPostSlugs.add(String(slug)));
+ [mutation.before.media.pageSlug, mutation.after.media.pageSlug].filter(Boolean).forEach((slug) => affectedPageSlugs.add(String(slug)));
+
+ if (previousPath !== nextPath) {
+ if (getMedia(nextPath)) throw new Error(`Media '${nextPath}' is already indexed.`);
+ const scanned = scanMediaAsset(nextPath);
+ if (!scanned) throw new Error(`Moved media '${nextPath}' was not found during reference synchronization.`);
+ saveMediaMetadata({
+ ...mutation.before.media,
+ relativePath: nextPath
+ });
+ const affected = rewriteMediaReferences(db, previousPath, nextPath);
+ affected.pieceSlugs.forEach((slug) => affectedPieceSlugs.add(slug));
+ affected.postSlugs.forEach((slug) => affectedPostSlugs.add(slug));
+ affected.pageSlugs.forEach((slug) => affectedPageSlugs.add(slug));
+ db.prepare("DELETE FROM media_items WHERE relative_path = ?").run(previousPath);
+ }
+
+ saveMediaMetadata({
+ relativePath: nextPath,
+ altText: mutation.after.media.altText,
+ pieceSlug: mutation.after.media.pieceSlug,
+ postSlug: mutation.after.media.postSlug,
+ pageSlug: mutation.after.media.pageSlug,
+ projectReference: mutation.after.media.projectReference,
+ userEmail: mutation.after.media.userEmail,
+ focalX: mutation.after.media.focalX,
+ focalY: mutation.after.media.focalY,
+ zoom: mutation.after.media.zoom,
+ reviewed: mutation.after.media.reviewed,
+ tags: mutation.after.media.tags,
+ metadata: mutation.after.media.metadata
+ });
+ replaceMediaLinksForSnapshot(db, mutation.after);
+ }
+
+ affectedPieceSlugs.forEach((slug) => synchronizePieceLegacyPathsFromLinks(db, slug));
+ recordAdminEditAudit({
+ actorEmail: input.actorEmail,
+ entityType: "media-batch",
+ entityKey: input.requestId ?? randomUUID(),
+ operation: "apply",
+ before: input.mutations.map((mutation) => mutation.before),
+ after: input.mutations.map((mutation) => mutation.after),
+ requestId: input.requestId
+ });
+
+ const snapshots = input.mutations.map((mutation) => captureMediaOperationSnapshot(mutation.after.media.relativePath));
+ if (input.batchId) completeMediaOperationBatch(input.batchId, snapshots);
+ if (input.markRolledBackBatchId) markMediaOperationRolledBack(input.markRolledBackBatchId);
+ return {
+ snapshots,
+ affected: {
+ pieceSlugs: [...affectedPieceSlugs],
+ postSlugs: [...affectedPostSlugs],
+ pageSlugs: [...affectedPageSlugs]
+ }
+ };
+ });
+}
+
export function renameMediaRecordAndReferences(previousPath: string, nextPath: string, options: { actorEmail?: string | null; historyId?: string | null } = {}) {
if (previousPath === nextPath) {
return { pieceSlugs: [], postSlugs: [], pageSlugs: [] };
diff --git a/site/lib/media-batch-transaction.test.mts b/site/lib/media-batch-transaction.test.mts
new file mode 100644
index 0000000..1c814cf
--- /dev/null
+++ b/site/lib/media-batch-transaction.test.mts
@@ -0,0 +1,249 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import path from "node:path";
+
+const originalPathA = "Furniture/batch-fixture/one.jpg";
+const originalPathB = "Furniture/batch-fixture/two.jpg";
+
+function writeFixture(mediaRoot: string, relativePath: string, contents = "fixture") {
+ const absolutePath = path.join(mediaRoot, ...relativePath.split("/"));
+ mkdirSync(path.dirname(absolutePath), { recursive: true });
+ writeFileSync(absolutePath, Buffer.from(contents));
+}
+
+function fixturePiece(mediaPaths: string[]) {
+ return {
+ slug: "batch-piece",
+ title: "Batch Piece",
+ subtitle: "",
+ category: "Objects",
+ status: "archive" as const,
+ publicationStatus: "published" as const,
+ availabilityLabel: "Unavailable",
+ summary: "",
+ story: "",
+ details: [],
+ tags: [],
+ materials: [],
+ dimensions: null,
+ priceCents: null,
+ priceMode: "not-listed" as const,
+ inquiryMode: "disabled" as const,
+ reviewsMode: "hidden" as const,
+ inventoryCount: 0,
+ leadTimeDays: 0,
+ mediaPaths,
+ featuredRank: 999,
+ ownerEmail: null,
+ metadata: { verifiedMedia: true }
+ };
+}
+
+function mediaSnapshot(relativePath: string, sizeBytes = 1) {
+ const now = new Date(0).toISOString();
+ return {
+ media: {
+ relativePath,
+ folder: path.posix.dirname(relativePath),
+ fileName: path.posix.basename(relativePath),
+ kind: "image" as const,
+ sizeBytes,
+ clusterKey: "fixture",
+ altText: "Fixture",
+ pieceSlug: null,
+ postSlug: null,
+ pageSlug: null,
+ projectReference: null,
+ userEmail: null,
+ focalX: 50,
+ focalY: 50,
+ zoom: 1,
+ reviewed: false,
+ tags: [],
+ metadata: {},
+ createdAt: now,
+ updatedAt: now
+ },
+ links: []
+ };
+}
+
+test("media batches update and roll back files, metadata, and all reference models", async () => {
+ const root = mkdtempSync(path.join(tmpdir(), "woodsmith-media-batch-"));
+ const dataRoot = path.join(root, "data");
+ const mediaRoot = path.join(root, "media");
+ writeFixture(mediaRoot, originalPathA, "one");
+ writeFixture(mediaRoot, originalPathB, "two");
+ process.env.NODE_ENV = "test";
+ process.env.DATA_ROOT = dataRoot;
+ process.env.MEDIA_ROOT = mediaRoot;
+
+ const db = await import("./db.ts");
+ const operations = await import("./media-operations.ts");
+ try {
+ db.getRuntimePersistenceStatus();
+ db.savePiece(fixturePiece([originalPathA]));
+ db.savePage({
+ slug: "batch-page",
+ title: "Batch page",
+ navLabel: "Batch page",
+ status: "draft",
+ intro: "",
+ body: "",
+ layout: "document",
+ sections: [],
+ heroMediaPath: originalPathA
+ });
+
+ const plan = operations.buildMediaOperationPlan([
+ db.captureMediaOperationSnapshot(originalPathA),
+ db.captureMediaOperationSnapshot(originalPathB)
+ ], {
+ folder: "Furniture/organized",
+ renamePattern: "batch-{index}-{name}",
+ pieceAssignment: "set",
+ pieceSlug: "batch-piece",
+ role: "detail",
+ stageMode: "set",
+ stage: "finish",
+ visibility: "public",
+ review: "reviewed",
+ addTags: ["organized"],
+ removeTags: [],
+ photoQuality: "portfolio-ready",
+ actorEmail: "admin@example.com"
+ });
+ const batch = db.createMediaOperationBatch({ operation: "organize", actorEmail: "admin@example.com", request: { fixture: true }, mutations: plan });
+ const moved = operations.moveMediaOperationFiles(plan);
+ assert.equal(moved.length, 2);
+ const applied = db.applyMediaOperationSnapshots({ mutations: plan, actorEmail: "admin@example.com", requestId: batch.id, batchId: batch.id });
+ const nextPaths = applied.snapshots.map((snapshot) => snapshot.media.relativePath);
+
+ assert.deepEqual(nextPaths, ["furniture/organized/batch-001-one.jpg", "furniture/organized/batch-002-two.jpg"]);
+ assert.equal(existsSync(path.join(mediaRoot, ...originalPathA.split("/"))), false);
+ assert.equal(existsSync(path.join(mediaRoot, ...nextPaths[0].split("/"))), true);
+ assert.equal(db.getPage("batch-page")?.heroMediaPath, nextPaths[0]);
+ assert.deepEqual(db.getPiece("batch-piece")?.mediaPaths, nextPaths);
+ assert.deepEqual(db.listPieceMediaLinks("batch-piece").map((link) => ({ path: link.relativePath, role: link.role, stage: link.stage, public: link.public })), [
+ { path: nextPaths[0], role: "detail", stage: "finish", public: true },
+ { path: nextPaths[1], role: "detail", stage: "finish", public: true }
+ ]);
+ assert.equal(db.getMedia(nextPaths[0])?.metadata.photoQuality, "portfolio-ready");
+ assert.equal(db.getMedia(nextPaths[0])?.tags.includes("organized"), true);
+ assert.equal(db.getMediaOperationBatch(batch.id)?.status, "completed");
+
+ const completed = db.getMediaOperationBatch(batch.id)!;
+ const rollbackPlan = operations.invertMediaOperationPlan(completed.items);
+ const rollbackBatch = db.createMediaOperationBatch({ operation: "rollback", actorEmail: "admin@example.com", rollbackOf: batch.id, request: { fixture: true }, mutations: rollbackPlan });
+ operations.moveMediaOperationFiles(rollbackPlan);
+ db.applyMediaOperationSnapshots({
+ mutations: rollbackPlan,
+ actorEmail: "admin@example.com",
+ requestId: rollbackBatch.id,
+ batchId: rollbackBatch.id,
+ markRolledBackBatchId: batch.id
+ });
+
+ assert.equal(existsSync(path.join(mediaRoot, ...originalPathA.split("/"))), true);
+ assert.equal(existsSync(path.join(mediaRoot, ...nextPaths[0].split("/"))), false);
+ assert.equal(db.getPage("batch-page")?.heroMediaPath, originalPathA);
+ assert.deepEqual(db.getPiece("batch-piece")?.mediaPaths, [originalPathA]);
+ assert.deepEqual(db.listPieceMediaLinks("batch-piece").map((link) => ({ path: link.relativePath, role: link.role, public: link.public })), [
+ { path: originalPathA, role: "hero", public: true }
+ ]);
+ assert.equal(db.getMediaOperationBatch(batch.id)?.status, "rolled-back");
+ assert.equal(db.getMediaOperationBatch(rollbackBatch.id)?.status, "completed");
+ assert.equal(db.getRuntimePersistenceStatus().schemaVersion, 6);
+ assert.equal(db.getRuntimePersistenceStatus().quickCheck, "ok");
+ } finally {
+ db.closeDatabaseForTests();
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("stale media batch snapshots refuse overwrite and filesystem moves compensate", async () => {
+ const root = mkdtempSync(path.join(tmpdir(), "woodsmith-media-batch-stale-"));
+ const dataRoot = path.join(root, "data");
+ const mediaRoot = path.join(root, "media");
+ writeFixture(mediaRoot, originalPathA, "one");
+ process.env.NODE_ENV = "test";
+ process.env.DATA_ROOT = dataRoot;
+ process.env.MEDIA_ROOT = mediaRoot;
+
+ const db = await import("./db.ts");
+ const operations = await import("./media-operations.ts");
+ try {
+ db.getRuntimePersistenceStatus();
+ const before = db.captureMediaOperationSnapshot(originalPathA);
+ const plan = operations.buildMediaOperationPlan([before], {
+ folder: "Furniture/stale-target",
+ renamePattern: "{name}",
+ pieceAssignment: "keep",
+ role: "keep",
+ stageMode: "keep",
+ visibility: "keep",
+ review: "keep",
+ addTags: ["planned"],
+ removeTags: [],
+ photoQuality: "keep",
+ actorEmail: "admin@example.com"
+ });
+ db.saveMediaMetadata({ ...before.media, tags: ["edited-after-plan"] });
+ const moved = operations.moveMediaOperationFiles(plan);
+ assert.throws(() => db.applyMediaOperationSnapshots({ mutations: plan, actorEmail: "admin@example.com" }), /changed after this operation was prepared/);
+ operations.restoreMediaOperationFiles(moved);
+
+ assert.equal(existsSync(path.join(mediaRoot, ...originalPathA.split("/"))), true);
+ assert.equal(existsSync(path.join(mediaRoot, "Furniture", "stale-target", "one.jpg")), false);
+ assert.deepEqual(db.getMedia(originalPathA)?.tags, ["edited-after-plan"]);
+ assert.equal(db.getRuntimePersistenceStatus().quickCheck, "ok");
+ } finally {
+ db.closeDatabaseForTests();
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("a later filesystem failure restores every earlier move in reverse order", async () => {
+ const root = mkdtempSync(path.join(tmpdir(), "woodsmith-media-move-rollback-"));
+ const mediaRoot = path.join(root, "media");
+ const first = "source/first.jpg";
+ const second = "source/second.jpg";
+ const firstTarget = "target/first.jpg";
+ const secondTarget = "target/occupied.jpg";
+ writeFixture(mediaRoot, first, "first");
+ writeFixture(mediaRoot, second, "second");
+ writeFixture(mediaRoot, secondTarget, "occupied");
+ process.env.MEDIA_ROOT = mediaRoot;
+ const operations = await import("./media-operations.ts");
+ try {
+ assert.throws(() => operations.moveMediaOperationFiles([
+ { before: mediaSnapshot(first, 5), after: mediaSnapshot(firstTarget, 5) },
+ { before: mediaSnapshot(second, 6), after: mediaSnapshot(secondTarget, 6) }
+ ]), /already exists/);
+ assert.equal(existsSync(path.join(mediaRoot, ...first.split("/"))), true);
+ assert.equal(existsSync(path.join(mediaRoot, ...second.split("/"))), true);
+ assert.equal(existsSync(path.join(mediaRoot, ...firstTarget.split("/"))), false);
+ assert.equal(existsSync(path.join(mediaRoot, ...secondTarget.split("/"))), true);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("generated cleanup media is written to a dedicated derivative tree without touching its source", async () => {
+ const root = mkdtempSync(path.join(tmpdir(), "woodsmith-media-derivative-"));
+ const mediaRoot = path.join(root, "media");
+ const sourcePath = "Furniture/source/original.jpg";
+ writeFixture(mediaRoot, sourcePath, "original-bytes");
+ process.env.MEDIA_ROOT = mediaRoot;
+ const media = await import("./media.ts");
+ try {
+ const derivativePath = media.persistGeneratedMedia(Buffer.from("derived-bytes").toString("base64"), "Derivatives/background-cleanup", "original", ".png");
+ assert.match(derivativePath, /^derivatives\/background-cleanup\/original-[a-f0-9]{8}\.png$/);
+ assert.equal(existsSync(path.join(mediaRoot, ...sourcePath.split("/"))), true);
+ assert.equal(existsSync(path.join(mediaRoot, ...derivativePath.split("/"))), true);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
diff --git a/site/lib/media-operations.ts b/site/lib/media-operations.ts
new file mode 100644
index 0000000..8923f17
--- /dev/null
+++ b/site/lib/media-operations.ts
@@ -0,0 +1,208 @@
+import path from "node:path";
+import type { MediaOperationSnapshot, PieceMediaLinkRecord, PieceMediaRole } from "./db.ts";
+import { moveMediaAsset, previewMediaOrganizePath, scanMediaAsset } from "./media.ts";
+
+export type MediaBatchOptions = {
+ folder?: string;
+ renamePattern?: string;
+ pieceAssignment: "keep" | "clear" | "set";
+ pieceSlug?: string;
+ role: "keep" | PieceMediaRole;
+ stageMode: "keep" | "clear" | "set";
+ stage?: string;
+ visibility: "keep" | "private" | "public";
+ review: "keep" | "unreviewed" | "reviewed";
+ addTags: string[];
+ removeTags: string[];
+ photoQuality?: "keep" | "unrated" | "shop-ready" | "portfolio-ready" | "background-distracting" | "needs-reshoot";
+ actorEmail?: string | null;
+};
+
+export type MediaOperationMutation = {
+ before: MediaOperationSnapshot;
+ after: MediaOperationSnapshot;
+};
+
+export type MovedMediaAsset = {
+ previousPath: string;
+ nextPath: string;
+};
+
+function applyRenamePattern(pattern: string, relativePath: string, index: number) {
+ const parsed = path.posix.parse(relativePath);
+ const folderName = parsed.dir.split("/").filter(Boolean).at(-1) ?? "media";
+ return pattern
+ .replaceAll("{name}", parsed.name)
+ .replaceAll("{index}", String(index + 1).padStart(3, "0"))
+ .replaceAll("{folder}", folderName);
+}
+
+function remapLinkPath(link: PieceMediaLinkRecord, relativePath: string): PieceMediaLinkRecord {
+ return { ...link, relativePath };
+}
+
+function nextLinks(
+ before: MediaOperationSnapshot,
+ relativePath: string,
+ options: MediaBatchOptions,
+ timestamp: string
+) {
+ let links = before.links.map((link) => remapLinkPath(link, relativePath));
+ if (options.pieceAssignment === "clear") {
+ links = [];
+ } else if (options.pieceAssignment === "set") {
+ if (!options.pieceSlug) throw new Error("Choose a piece before assigning selected media.");
+ const previous = links.find((link) => link.pieceSlug === options.pieceSlug) ?? links[0];
+ links = [{
+ id: previous?.id ?? "",
+ pieceSlug: options.pieceSlug,
+ relativePath,
+ role: options.role === "keep" ? previous?.role ?? "gallery" : options.role,
+ stage: options.stageMode === "keep" ? previous?.stage ?? null : options.stageMode === "clear" ? null : options.stage?.trim() || null,
+ occurredAt: previous?.occurredAt ?? null,
+ title: previous?.title ?? "",
+ caption: previous?.caption ?? "",
+ technicalNote: previous?.technicalNote ?? "",
+ altOverride: previous?.altOverride ?? null,
+ displayOrder: previous?.displayOrder ?? 0,
+ public: options.visibility === "public" ? true : options.visibility === "private" ? false : previous?.public ?? false,
+ legacySynced: false,
+ createdAt: previous?.createdAt ?? timestamp,
+ updatedAt: timestamp
+ }];
+ } else {
+ links = links.map((link) => ({
+ ...link,
+ role: options.role === "keep" ? link.role : options.role,
+ stage: options.stageMode === "keep" ? link.stage : options.stageMode === "clear" ? null : options.stage?.trim() || null,
+ public: options.visibility === "public" ? true : options.visibility === "private" ? false : link.public,
+ updatedAt: timestamp
+ }));
+ }
+ const identities = new Set();
+ return links.filter((link) => {
+ const identity = `${link.pieceSlug}\u0000${link.role}\u0000${link.stage ?? ""}`;
+ if (identities.has(identity)) return false;
+ identities.add(identity);
+ return true;
+ });
+}
+
+function snapshotsDiffer(left: MediaOperationSnapshot, right: MediaOperationSnapshot) {
+ return JSON.stringify(left) !== JSON.stringify(right);
+}
+
+export function buildMediaOperationPlan(snapshots: MediaOperationSnapshot[], options: MediaBatchOptions): MediaOperationMutation[] {
+ if (snapshots.length === 0) throw new Error("Select at least one media item.");
+ if (snapshots.length > 96) throw new Error("A media batch can contain at most 96 items.");
+ if (options.stageMode === "set" && !options.stage?.trim()) throw new Error("Enter a build stage or choose Keep/Clear stage.");
+ const hasRequestedChange = Boolean(options.folder?.trim())
+ || Boolean(options.renamePattern?.trim() && options.renamePattern.trim() !== "{name}")
+ || options.pieceAssignment !== "keep"
+ || options.role !== "keep"
+ || options.stageMode !== "keep"
+ || options.visibility !== "keep"
+ || options.review !== "keep"
+ || options.addTags.length > 0
+ || options.removeTags.length > 0
+ || Boolean(options.photoQuality && options.photoQuality !== "keep");
+ if (!hasRequestedChange) throw new Error("Choose at least one folder, name, assignment, role, stage, review, tag, or quality change.");
+ const timestamp = new Date().toISOString();
+ const pattern = options.renamePattern?.trim() || "{name}";
+ const removeTags = new Set(options.removeTags.map((tag) => tag.toLowerCase()));
+ const targets = new Set();
+
+ const mutations = snapshots.map((before, index): MediaOperationMutation => {
+ const nextBaseName = applyRenamePattern(pattern, before.media.relativePath, index);
+ const relativePath = previewMediaOrganizePath(before.media.relativePath, {
+ baseName: nextBaseName,
+ folder: options.folder
+ });
+ if (targets.has(relativePath)) throw new Error(`Two selected files would be moved to '${relativePath}'. Change the rename pattern.`);
+ targets.add(relativePath);
+
+ const links = nextLinks(before, relativePath, options, timestamp);
+ const assignedPiece = options.pieceAssignment === "clear"
+ ? null
+ : options.pieceAssignment === "set"
+ ? options.pieceSlug ?? null
+ : before.media.pieceSlug;
+ const reviewed = options.visibility === "public" || options.review === "reviewed"
+ ? true
+ : options.review === "unreviewed"
+ ? false
+ : before.media.reviewed;
+ const publicLinks = reviewed ? links : links.map((link) => ({ ...link, public: false }));
+ const tags = [...new Set([
+ ...before.media.tags.filter((tag) => !removeTags.has(tag.toLowerCase())),
+ ...options.addTags
+ ].filter(Boolean))];
+ const metadata = {
+ ...before.media.metadata,
+ ...(options.photoQuality && options.photoQuality !== "keep" ? { photoQuality: options.photoQuality } : {}),
+ batchOrganizedAt: timestamp,
+ batchOrganizedBy: options.actorEmail ?? null
+ };
+ const after: MediaOperationSnapshot = {
+ media: {
+ ...before.media,
+ relativePath,
+ folder: path.posix.dirname(relativePath) === "." ? "" : path.posix.dirname(relativePath),
+ fileName: path.posix.basename(relativePath),
+ pieceSlug: assignedPiece,
+ reviewed,
+ tags,
+ metadata
+ },
+ links: publicLinks
+ };
+ return { before, after };
+ });
+
+ if (!mutations.some((mutation) => snapshotsDiffer(mutation.before, mutation.after))) {
+ throw new Error("The selected batch does not change any media records.");
+ }
+ return mutations;
+}
+
+export function moveMediaOperationFiles(mutations: MediaOperationMutation[]) {
+ const moved: MovedMediaAsset[] = [];
+ try {
+ for (const mutation of mutations) {
+ const previousPath = mutation.before.media.relativePath;
+ const nextPath = mutation.after.media.relativePath;
+ if (previousPath === nextPath) continue;
+ const current = scanMediaAsset(previousPath);
+ if (!current) throw new Error(`Media '${previousPath}' is no longer present in the mounted library.`);
+ if (mutation.before.media.sizeBytes > 0 && current.sizeBytes !== mutation.before.media.sizeBytes) {
+ throw new Error(`Media '${previousPath}' changed on disk after this operation was prepared.`);
+ }
+ moveMediaAsset(previousPath, nextPath);
+ moved.push({ previousPath, nextPath });
+ }
+ return moved;
+ } catch (error) {
+ try {
+ restoreMediaOperationFiles(moved);
+ } catch (rollbackError) {
+ throw new Error(`Media move failed and filesystem rollback also failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, { cause: error });
+ }
+ throw error;
+ }
+}
+
+export function restoreMediaOperationFiles(moved: MovedMediaAsset[]) {
+ const failures: string[] = [];
+ for (const item of [...moved].reverse()) {
+ try {
+ moveMediaAsset(item.nextPath, item.previousPath);
+ } catch (error) {
+ failures.push(`${item.nextPath}: ${error instanceof Error ? error.message : String(error)}`);
+ }
+ }
+ if (failures.length > 0) throw new Error(`Could not restore ${failures.length} media file(s): ${failures.join("; ")}`);
+}
+
+export function invertMediaOperationPlan(items: Array<{ before: MediaOperationSnapshot; after: MediaOperationSnapshot }>) {
+ return items.map((item) => ({ before: item.after, after: item.before }));
+}
diff --git a/site/lib/media.ts b/site/lib/media.ts
index f43883b..ba4d690 100644
--- a/site/lib/media.ts
+++ b/site/lib/media.ts
@@ -36,13 +36,21 @@ export function getMediaUrl(relativePath: string) {
.join("/")}`;
}
-export function slugify(value: string) {
+export function slugify(value: string) {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
- .slice(0, 80);
-}
+ .slice(0, 80);
+}
+
+export function sanitizeMediaFolder(value: string) {
+ return value
+ .split(/[\\/]+/g)
+ .map(slugify)
+ .filter(Boolean)
+ .join("/");
+}
function normalizeRelativePath(value: string) {
return value.replace(/\\/g, "/").replace(/^\/+/, "");
@@ -185,7 +193,7 @@ export type MediaUploadPolicy = {
};
export async function persistUploadedMedia(file: File, folder = "Uploads", policy: MediaUploadPolicy = {}) {
- const safeFolder = folder.split(/[\\/]+/g).map(slugify).filter(Boolean).join("/") || "uploads";
+ const safeFolder = sanitizeMediaFolder(folder) || "uploads";
const originalName = file.name || "upload";
const originalExtension = path.extname(originalName) || "";
const extension = originalExtension.toLowerCase();
@@ -205,8 +213,8 @@ export async function persistUploadedMedia(file: File, folder = "Uploads", polic
return relativePath;
}
-export function persistGeneratedMedia(base64Image: string, folder = "generated", baseName = "preview", extension = ".png") {
- const safeFolder = slugify(folder) || "generated";
+export function persistGeneratedMedia(base64Image: string, folder = "generated", baseName = "preview", extension = ".png") {
+ const safeFolder = sanitizeMediaFolder(folder) || "generated";
const safeBase = slugify(baseName) || `generated-${randomUUID().slice(0, 8)}`;
const cleanExtension = extension.startsWith(".") ? extension.toLowerCase() : `.${extension.toLowerCase()}`;
const relativePath = `${safeFolder}/${safeBase}-${randomUUID().slice(0, 8)}${cleanExtension}`;
@@ -243,11 +251,20 @@ export function scanMediaAsset(relativePath: string): MediaScanRecord | null {
}
export function previewMediaRenamePath(relativePath: string, nextBaseName: string) {
+ return previewMediaOrganizePath(relativePath, { baseName: nextBaseName });
+}
+
+export function previewMediaOrganizePath(relativePath: string, options: { baseName?: string; folder?: string }) {
const currentAbsolutePath = resolveMediaPath(relativePath);
const parsed = path.parse(currentAbsolutePath);
- const baseName = slugify(nextBaseName) || `media-${randomUUID().slice(0, 8)}`;
- const targetAbsolutePath = path.join(parsed.dir, `${baseName}${parsed.ext.toLowerCase()}`);
+ const baseName = slugify(options.baseName ?? parsed.name) || `media-${randomUUID().slice(0, 8)}`;
const mediaRoot = getMediaRoot();
+ const currentFolder = normalizeRelativePath(path.relative(mediaRoot, parsed.dir));
+ const requestedFolder = options.folder == null || options.folder.trim() === ""
+ ? currentFolder
+ : sanitizeMediaFolder(options.folder);
+ const targetDirectory = requestedFolder ? resolveMediaPath(requestedFolder) : mediaRoot;
+ const targetAbsolutePath = path.join(targetDirectory, `${baseName}${parsed.ext.toLowerCase()}`);
if (path.resolve(targetAbsolutePath) === path.resolve(currentAbsolutePath)) {
return normalizeRelativePath(path.relative(mediaRoot, currentAbsolutePath));
}
diff --git a/site/package.json b/site/package.json
index 4dfac2c..3d6961f 100644
--- a/site/package.json
+++ b/site/package.json
@@ -8,7 +8,7 @@
"start": "node --experimental-sqlite .next/standalone/server.js",
"lint": "eslint app components lib",
"typecheck": "tsc --noEmit",
- "test": "node --experimental-strip-types --test lib/media-scoring.test.mts lib/piece-model.test.mts lib/database-migrations.test.mts lib/media-reference-transaction.test.mts lib/category-icons.test.mts lib/site-structure.test.mts lib/piece-media.test.mts lib/estimator.test.mts lib/visual-audit-security.test.mts lib/inline-edit-registry.test.mts lib/commission-workflow.test.mts scripts/safe-build.test.mjs"
+ "test": "node --experimental-strip-types --test lib/media-scoring.test.mts lib/piece-model.test.mts lib/database-migrations.test.mts lib/media-reference-transaction.test.mts lib/media-batch-transaction.test.mts lib/category-icons.test.mts lib/site-structure.test.mts lib/piece-media.test.mts lib/estimator.test.mts lib/visual-audit-security.test.mts lib/inline-edit-registry.test.mts lib/commission-workflow.test.mts scripts/safe-build.test.mjs"
},
"dependencies": {
"@react-three/drei": "^10.7.7",
diff --git a/synology-nas-deploy.md b/synology-nas-deploy.md
index cf54f4b..cbe858c 100644
--- a/synology-nas-deploy.md
+++ b/synology-nas-deploy.md
@@ -188,7 +188,7 @@ docker compose -f docker-compose.synology.yml up -d
The startup path includes seed migration v6. It preserves dashboard edits and deletion tombstones, normalizes legacy developer-contact data, removes the obsolete Process navigation entry, and replaces only exact legacy Shop/Process/custom-work seed wording.
-The independent SQLite schema ledger currently applies through version 5. Its additive commission tables persist account drafts, idempotency keys, expiring project-access grants, render ownership/quotas, and submission quotas. Never replace the mounted `/app/site/data` directory during an image rebuild; back it up and run `PRAGMA quick_check` before and after deployment.
+The independent SQLite schema ledger currently applies through version 6. Its additive tables persist account drafts, idempotency keys, expiring project-access grants, render ownership/quotas, submission quotas, and media-operation before/after snapshots used by guarded batch rollback. Never replace the mounted `/app/site/data` directory during an image rebuild; back it up and run `PRAGMA quick_check` before and after deployment.
## Reverse proxy
@@ -241,6 +241,8 @@ Missing or removed files must return **404** (not a broken stream). Stale `media
- Media automation now centers on **Train selected**, **Improve page**, and **Continue library**. These guided actions run the bounded scan/analyze/embed/cluster/rank sequence for selected, current-page, or next-library-batch scopes while preserving persistent model/hash/cluster metadata and explicit evidence. Suggested matches cannot publish or assign without a reviewed human action.
- The compact trainer status card should show the active local provider, cache totals, indexed media, accepted/rejected training labels, analyzed files, vectors, and clusters. Raw provider cards and individual scan/analyze/embed/cluster actions are intentionally tucked under Advanced actions for diagnostics.
- Routine media metadata saves, assignments, uploads, renames, and deletes do not refresh `/studio`. **Refresh library** is the explicit filesystem rescan and requires the `/app/pics:rw` mount to be present.
+- **Organize selected** applies at most 96 collision-checked folder/name/tag/quality/assignment/role/stage/visibility changes with filesystem compensation and one SQLite reference transaction. Recent completed batches can be rolled back only while their current snapshots still match; later edits are never overwritten.
+- Optional background cleanup writes an unreviewed derivative under `/app/pics/derivatives/background-cleanup/`, records its source path/size/time/provider and manual-publication gate, and never modifies the original file.
- The verification queue proposes only one sufficiently separated best-piece match per unassigned image. It never assigns on preview; use the explicit **Assign** control after visual verification.
- Confirm `/studio?panel=categories` can add, rename, reassign, and delete portfolio categories, and `/studio?panel=media` shows one active inspector rather than a long editor stack.
- After upgrading the app image, use **Refresh library** once so the scanner skips Synology **`@eaDir`** folders and **`SYNOFILE_THUMB*`** files; those paths are also excluded from SQL media lists.
@@ -299,6 +301,8 @@ Because the dashboard can mutate the shared media library, back up these paths t
A SQLite backup without the matching media tree is no longer sufficient for full recovery.
+Create the paired backup before a large batch reorganization. The database operation ledger can reverse application-managed changes, but it is not a replacement for a filesystem snapshot when files are changed outside the application or storage fails mid-operation.
+
The Docker context excludes SQLite databases, WAL/SHM files, backups, and media-AI caches. In addition, `site/scripts/safe-build.mjs` forces every Next build to use disposable temporary data/media roots and rejects standalone output containing a database, WAL/SHM, or backup file. Runtime state is never copied into an image layer; the image creates an empty `/app/site/data` directory that is populated only by the writable production bind mount. Seed upgrades are non-destructive for existing Studio-edited records, so rebuilds should preserve page/settings edits when the same mounted database is active.
## Current deployment caveats
diff --git a/woodsmith_DeepWiki_Merged_03222026.md b/woodsmith_DeepWiki_Merged_03222026.md
index 1d353b9..db06e79 100644
--- a/woodsmith_DeepWiki_Merged_03222026.md
+++ b/woodsmith_DeepWiki_Merged_03222026.md
@@ -84,6 +84,8 @@ The SQLite schema includes these primary tables:
- `piece_media_links`
- `admin_edit_audit`
- `media_rename_history`
+- `media_operation_batches`
+- `media_operation_items`
- `commission_drafts`
- `commission_submissions`
- `project_access_grants`
@@ -105,10 +107,11 @@ The master media library lives outside the app bundle and outside `docker_ssd`.
- filters Synology and OS sidecar files such as `SYNOINDEX_MEDIA_INFO`, `.DS_Store`, `Thumbs.db`, and `._*`
- stores alt text, clustering, associations, focal data, zoom, cleanup mode, visual labels, source credit, display order, review state, and tags in SQLite
- can upload, rename, delete, and assign files in the mounted media root
+- can apply collision-checked selected-item folder/name/tag/quality/assignment/role/stage/visibility changes with SQLite snapshots, reverse-order filesystem compensation, normalized/legacy reference synchronization, and guarded rollback
- synchronizes reviewed piece assignments with public piece galleries while keeping unreviewed assignments private
-- can create non-destructive cleaned copies under the same mounted media root when the optional OpenAI cleanup feature is configured
+- can create unpublished source-linked cleanup derivatives under `derivatives/background-cleanup/` when the optional OpenAI cleanup feature is configured; originals are never overwritten and derivative chaining is rejected
-Media automation is provider-agnostic and local-first. `tools/media-ai-sidecar/` scans the configured library, excludes Synology/hidden sidecars, stores SHA-256 and perceptual hashes plus generated 768px review thumbnails outside the source tree, computes true image-pixel and text embeddings in a shared SentenceTransformers CLIP space, applies deterministic visual clustering, and can use Ollama or Gemini only for ambiguity arbitration. Bounded guided trainer runs resume changed or uncached files, heavy work is serialized, and partial cluster updates preserve unrelated cluster state. The compact media desk exposes Train selected, Improve page, and Continue library as the primary workflow, with raw scan/analyze/embed/cluster/rank/dry-run controls kept under Advanced actions. The status card shows provider/cache/training totals; AI-state filters, evidence and margin breakdowns, rejection memory, and J/K/F/P/U/R/I/E/C/S/Shift+S/A keyboard controls remain available.
+Media automation is provider-agnostic and local-first. `tools/media-ai-sidecar/` scans the configured library, excludes Synology/hidden sidecars, stores SHA-256 and perceptual hashes plus generated 768px review thumbnails outside the source tree, computes true image-pixel and text embeddings in a shared SentenceTransformers CLIP space, applies deterministic visual clustering, and can use Ollama or Gemini only for ambiguity arbitration. Bounded guided trainer runs resume changed or uncached files, heavy work is serialized, and partial cluster updates preserve unrelated cluster state. The compact media desk exposes Organize selected, Train selected, Improve page, and Continue library as the primary workflows, with raw scan/analyze/embed/cluster/rank/dry-run controls kept under Advanced actions. The status card shows provider/cache/training totals; AI-state filters, evidence and margin breakdowns, rejection memory, and J/K/F/P/U/R/I/E/C/S/Shift+S/A keyboard controls remain available.
SQLite media metadata stores analysis schema/provider/model/time, object/class/context/stage, tags and alt draft, candidate confidence/evidence, uncertainty, unsafe reason, embedding provider/model/version/hash/time, cluster ID/representative/score/label, human-review reason, accepted training labels, and rejected training labels. The ranker combines visual similarity, VLM candidate confidence, lexical overlap, verified cluster propagation, folder context, and manual priors, then subtracts negative reviewer signals. It requires a configurable minimum score and runner-up margin. Context/detail/ambiguous or reviewer-rejected matches are not proposed. Manual reviewed assignment plus accurate alt text remains the only public publishing gate.
From 0758c9a8d23e2dcf8ae20d6443cc1b474e60fc09 Mon Sep 17 00:00:00 2001
From: Cooper Beaman <55124795+lowestprime@users.noreply.github.com>
Date: Sun, 12 Jul 2026 18:58:24 -0700
Subject: [PATCH 11/43] feat(ui): harden accessibility and media delivery
---
PLANS.md | 3 +-
README.md | 6 +-
admin.md | 4 +-
site/app/layout.tsx | 3 +-
site/app/media/[...slug]/route.ts | 49 +--
site/app/portfolio/[slug]/page.tsx | 2 +-
site/app/shop/cart/page.tsx | 3 +-
site/app/shop/page.tsx | 3 +-
site/app/ui-repair.css | 226 ++++++++++++
site/components/header-shell.tsx | 16 +-
site/components/inline-edit-assistant.tsx | 48 ++-
site/components/lightbox.tsx | 408 ++++++++++++++++------
site/components/site-chrome.tsx | 14 +-
site/components/site-nav-link.tsx | 18 +
site/components/theme-toggle.tsx | 28 +-
site/lib/media-http.test.mts | 15 +
site/lib/media-http.ts | 22 ++
site/lib/ui-behavior.test.mts | 22 ++
site/lib/ui-behavior.ts | 26 ++
site/next.config.ts | 3 +
site/package.json | 2 +-
synology-nas-deploy.md | 14 +-
woodsmith_DeepWiki_Merged_03222026.md | 6 +-
23 files changed, 763 insertions(+), 178 deletions(-)
create mode 100644 site/components/site-nav-link.tsx
create mode 100644 site/lib/media-http.test.mts
create mode 100644 site/lib/media-http.ts
create mode 100644 site/lib/ui-behavior.test.mts
create mode 100644 site/lib/ui-behavior.ts
diff --git a/PLANS.md b/PLANS.md
index 8c0328d..0ebe20e 100644
--- a/PLANS.md
+++ b/PLANS.md
@@ -16,7 +16,8 @@
| Secure resumable custom requests | DONE / LOCAL DOCKER VALIDATED | Added the ten-step autosaved workflow, verified-account server drafts, idempotent server-authoritative submission, hashed submission/render quotas, private staged attachments with rollback, owner-bound generated previews, opaque project-access cookies, POST lookup without URL email, and exact dimension/category continuity into R3F. Additive schema v5, seed v6, 36 application tests, production build, image safety inspection, and disposable browser/container submission evidence pass. |
| Safe standalone and image build | DONE / LOCAL DOCKER VALIDATED | `safe-build` uses disposable build roots, excludes runtime data from Next tracing, rejects database/backup files, and proves cleanup for child-failure, forbidden-output, and success paths. Docker Desktop Engine 29.6.1 and BuildKit 0.31.1 built/loaded `woodsmith:local-gate`; the image contains no runtime DB, env/key, private evidence, audit output, or production media and starts with an empty data directory. |
| Transactional media organization | DONE / LOCAL BROWSER VALIDATED | Additive schema v6 records batch/item snapshots; selected media can be moved, deterministically renamed, tagged, rated, assigned, and given normalized role/stage/visibility metadata in one compensated operation. Optimistic rollback refuses later edits, filesystem failures reverse earlier moves, legacy and normalized references stay synchronized, and optional cleanup writes unpublished source-linked derivatives only. Forty application tests plus disposable desktop/mobile browser apply/rollback and SQLite `quick_check` evidence pass. |
-| Remaining product work | PENDING | Accessibility/performance completion, final documentation review, full archive evidence, release, NAS backup/restore, rollback, and deployment gates remain active. |
+| Accessibility, responsive UI, and public media performance | DONE / LOCAL BROWSER VALIDATED | Added skip navigation, route-aware current navigation, high-contrast focus treatment, focus-safe auto-hide header behavior, modal focus containment/restoration, keyboard/touch bounded lightbox pan and zoom, announced carousel position, 24px target protection, and hydration-safe persistent themes. Public portfolio/shop/carousel thumbnails now use responsive Next image requests while the full-screen viewer retains the source file; raw media supports ETag/Last-Modified revalidation. Disposable browser QA passed at 1440, 390, and 320px in both themes with zero horizontal overflow, zero unnamed/unlabelled controls, no duplicate IDs/heading skips/missing alt text, and tested base/muted contrast above AA thresholds. |
+| Remaining product work | PENDING | Final documentation review, full visual-archive evidence, release, NAS backup/restore, rollback, candidate deployment, and production verification remain active. |
## 2026-07-05 Local-First Media AI, Header, and Persistence Pass
diff --git a/README.md b/README.md
index 7b49257..c5a03fe 100644
--- a/README.md
+++ b/README.md
@@ -23,9 +23,11 @@ Woodsmith is a self-hosted Next.js application for the Beaman Woodworks company
- Admin-only pencil controls backed by a typed field registry, atomic audited saves, conflict detection, URL/origin validation, reset/undo controls, and an explicit visual full-editor link for structural work
- A compact browser media desk with whole-library and AI-state filters, in-place paging and edits, explicit candidate review, guided local training actions, transactional selected-item folder/name/assignment/role/stage changes with rollback, upload/rename/delete safety, crop/focal controls, and source credit against the writable NAS photo library
- Email notification queueing, Stripe invoice creation, and EasyPost shipping-label requests when the related environment variables are configured
-- Full-size image lightbox support with zoom, pan, arrow navigation, plus `Esc` and close-button exit behavior
+- Responsive piece carousels with announced position and full-size lightboxes that trap focus, restore the opener, support bounded keyboard/touch pan and zoom, and close through `Esc`, backdrop click, or the visible X control
- Keyword/metadata search plus visual search across public content and, for admins, private media, visual labels, clusters, unpublished content, and project records. The optional local sidecar supplies shared image/text CLIP vectors; Gemini or OpenAI embeddings remain opt-in alternatives.
-- Persistent light/day and black OLED night themes using the local ITC New Rennie Mackintosh font assets
+- Persistent light/day and black OLED night themes using the local ITC New Rennie Mackintosh font assets; the cookie-backed initial theme and client store are synchronized without hydration overwriting the saved choice
+- WCAG-oriented skip navigation, current-page navigation state, visible high-contrast focus, reduced-motion handling, compact 24px-or-larger targets, and focus clearance for the auto-hiding header
+- Responsive optimized thumbnail requests on portfolio, shop, cart, and carousel surfaces; the raw source remains available in the full-screen viewer and direct media responses use ETag/Last-Modified revalidation
- Programmatic Beaman Woodworks favicon and brand mark
- Safe profile administration for renaming accounts, replacing legacy developer emails, and deleting non-current users from the dashboard
- Compact auto-hide navigation that keeps the public and dashboard workspaces usable on narrow and desktop viewports
diff --git a/admin.md b/admin.md
index c16bea8..67e2239 100644
--- a/admin.md
+++ b/admin.md
@@ -11,7 +11,7 @@ This guide covers the private Woodshop dashboard at `/studio`.
## Dashboard areas
-The dashboard opens on an overview workspace and lets you move between focused panels instead of loading every editor at once. Public pages also show admin-only pencil controls while you are signed in. Mapped text and links edit in place; use `Ctrl+S` to save, `Esc` to exit, **Reset unsaved** to restore the value shown when editing opened, or **Undo last save** to reverse the most recent inline batch. **Full editor** opens the matching visual dashboard workspace for structural changes. Inline batches are validated from a typed field registry, saved in one SQLite transaction, checked for concurrent changes, and recorded in the admin edit audit. The site header is intentionally compact and hides while scrolling down; scroll up, focus a header control, or move the pointer over the header area to reveal it again.
+The dashboard opens on an overview workspace and lets you move between focused panels instead of loading every editor at once. Public pages also show admin-only pencil controls while you are signed in. Mapped text and links edit in place; use `Ctrl+S` to save, `Esc` to exit, **Reset unsaved** to restore the value shown when editing opened, or **Undo last save** to reverse the most recent inline batch. **Full editor** opens the matching visual dashboard workspace for structural changes. Inline batches are validated from a typed field registry, saved in one SQLite transaction, checked for concurrent changes, and recorded in the admin edit audit. The site header is intentionally compact and hides while scrolling down; scroll up, focus a header control, or move the pointer over the header area to reveal it again. When focus returns to page content, the header reveals without covering the focused control. The inline URL editor and media browser keep keyboard focus inside their modal surfaces and restore it when closed.
### Settings
@@ -95,6 +95,8 @@ Every cache record includes provider, model, version, source hash, and timestamp
The same visual picker is now used in Pages, Pieces, and Process editors, so cover images and piece galleries can be selected directly from the mounted library without typing raw paths.
+Public piece and shop cards request responsive optimized thumbnails rather than each raw original. Opening a piece image still loads the source-resolution media in the full-screen viewer. Carousel arrows update an announced position; the viewer supports plus/minus/reset controls, bounded drag or arrow-key panning while zoomed, `Esc`, backdrop click, and the fixed X close control.
+
### Visitor map
The overview workspace now shows recent visitor sessions on a world map and in a recent-session list. The map is sourced from session records stored in SQLite.
diff --git a/site/app/layout.tsx b/site/app/layout.tsx
index cf57d54..da6dec8 100644
--- a/site/app/layout.tsx
+++ b/site/app/layout.tsx
@@ -86,11 +86,12 @@ export default async function RootLayout({ children }: Readonly<{ children: Reac
+ Skip to main content
- {children}
+ {children}
diff --git a/site/app/media/[...slug]/route.ts b/site/app/media/[...slug]/route.ts
index 4e240ed..f686085 100644
--- a/site/app/media/[...slug]/route.ts
+++ b/site/app/media/[...slug]/route.ts
@@ -1,6 +1,8 @@
-import { createReadStream, existsSync, statSync } from "node:fs";
-import { NextResponse } from "next/server";
-import { detectMediaKind, resolveMediaPath } from "@/lib/media";
+import { createReadStream, existsSync, statSync } from "node:fs";
+import { Readable } from "node:stream";
+import { NextResponse } from "next/server";
+import { detectMediaKind, resolveMediaPath } from "@/lib/media";
+import { mediaEntityTag, mediaLastModified, mediaRequestIsFresh } from "@/lib/media-http";
const MIME_TYPES: Record = {
".jpg": "image/jpeg",
@@ -19,7 +21,7 @@ function notFound() {
return new NextResponse("Not found", { status: 404, headers: { "Cache-Control": "no-store" } });
}
-export async function GET(_: Request, { params }: { params: Promise<{ slug: string[] }> }) {
+export async function GET(request: Request, { params }: { params: Promise<{ slug: string[] }> }) {
const { slug } = await params;
const relativePath = slug.join("/");
if (!relativePath || relativePath.includes("..")) {
@@ -48,21 +50,24 @@ export async function GET(_: Request, { params }: { params: Promise<{ slug: stri
return notFound();
}
- const extension = absolutePath.slice(absolutePath.lastIndexOf(".")).toLowerCase();
- const kind = detectMediaKind(relativePath);
-
- const stream = createReadStream(absolutePath);
- stream.on("error", () => {
- stream.destroy();
- });
-
- return new NextResponse(stream as never, {
- headers: {
- "Content-Type":
- MIME_TYPES[extension] ||
- (kind === "video" ? "application/octet-stream" : "image/jpeg"),
- "Cache-Control": "no-store",
- "CDN-Cache-Control": "no-store"
- }
- });
-}
+ const extension = absolutePath.slice(absolutePath.lastIndexOf(".")).toLowerCase();
+ const kind = detectMediaKind(relativePath);
+ const responseHeaders = {
+ "Content-Type": MIME_TYPES[extension] || (kind === "video" ? "application/octet-stream" : "image/jpeg"),
+ "Content-Length": String(stat.size),
+ "Cache-Control": "public, max-age=0, must-revalidate",
+ "CDN-Cache-Control": "public, max-age=0, must-revalidate",
+ ETag: mediaEntityTag(stat),
+ "Last-Modified": mediaLastModified(stat),
+ "X-Content-Type-Options": "nosniff"
+ };
+
+ if (mediaRequestIsFresh(request.headers, stat)) {
+ return new NextResponse(null, { status: 304, headers: responseHeaders });
+ }
+
+ const stream = Readable.toWeb(createReadStream(absolutePath)) as ReadableStream;
+ return new NextResponse(stream, {
+ headers: responseHeaders
+ });
+}
diff --git a/site/app/portfolio/[slug]/page.tsx b/site/app/portfolio/[slug]/page.tsx
index 9f82477..61b64c8 100644
--- a/site/app/portfolio/[slug]/page.tsx
+++ b/site/app/portfolio/[slug]/page.tsx
@@ -93,7 +93,7 @@ export default async function PiecePage({ params }: { params: Promise<{ slug: st
{mediaItems.length > 0 ? (
-
+
) : (
Archival media is being verified for this piece before additional images are shown publicly.
)}
diff --git a/site/app/shop/cart/page.tsx b/site/app/shop/cart/page.tsx
index 63085e9..d4a077b 100644
--- a/site/app/shop/cart/page.tsx
+++ b/site/app/shop/cart/page.tsx
@@ -1,4 +1,5 @@
import { cookies } from "next/headers";
+import Image from "next/image";
import { removeCartItemAction } from "@/lib/actions";
import { getCurrentUser } from "@/lib/auth";
import { getDisplayMediaPaths, getFulfillmentSummary } from "@/lib/catalog";
@@ -38,7 +39,7 @@ export default async function CartPage({ searchParams }: { searchParams: Promise
{lines.length > 0 ? lines.map(({ item, piece }) => (
- {getDisplayMediaPaths(piece)[0] ? : No image
}
+ {getDisplayMediaPaths(piece)[0] ? : No image
}
{piece.title}
{piece.subtitle}
diff --git a/site/app/shop/page.tsx b/site/app/shop/page.tsx
index 013950b..96ab1f5 100644
--- a/site/app/shop/page.tsx
+++ b/site/app/shop/page.tsx
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
+import Image from "next/image";
import Link from "next/link";
import { connection } from "next/server";
import { addToCartAction } from "@/lib/actions";
@@ -44,7 +45,7 @@ export default async function ShopPage() {
return (
- {firstImage ? : Media under review
}
+ {firstImage ? : Media under review
}
{piece.category} {piece.inventoryCount} available
{piece.title}
diff --git a/site/app/ui-repair.css b/site/app/ui-repair.css
index 5e59798..2a54d65 100644
--- a/site/app/ui-repair.css
+++ b/site/app/ui-repair.css
@@ -2404,3 +2404,229 @@ textarea {
min-width: 7.6rem !important;
}
}
+
+/* WCAG 2.2 closure: visible focus, modal containment, and compact carousel controls. */
+:root {
+ --focus-ring: #6a3900;
+}
+
+html[data-theme="dark"] {
+ --focus-ring: #ffdca3;
+}
+
+html {
+ scroll-padding-top: calc(var(--site-header-open) + 0.75rem);
+}
+
+:where(section, article, [id]):target,
+:where(a[href], button, input, textarea, select, summary, [tabindex]):focus-visible {
+ scroll-margin-top: calc(var(--site-header-open) + 0.75rem);
+}
+
+:where(a[href], button, input, textarea, select, summary, [tabindex]):focus-visible {
+ outline: 3px solid var(--focus-ring) !important;
+ outline-offset: 3px !important;
+ box-shadow: 0 0 0 2px var(--bg) !important;
+}
+
+main:focus {
+ outline: none;
+}
+
+.skip-link {
+ position: fixed;
+ z-index: 12000;
+ top: 0.45rem;
+ left: 0.45rem;
+ translate: 0 calc(-100% - 1rem);
+ min-height: 2.75rem;
+ display: inline-flex;
+ align-items: center;
+ padding: 0.6rem 0.9rem;
+ border: 2px solid var(--focus-ring);
+ border-radius: var(--radius-pill);
+ background: var(--bg-elevated);
+ color: var(--text);
+ box-shadow: var(--shadow);
+ transition: translate 140ms ease;
+}
+
+.skip-link:focus-visible {
+ translate: 0 0;
+}
+
+.site-nav .nav-link-pill[aria-current="page"] {
+ border-color: color-mix(in srgb, var(--accent) 56%, var(--line));
+ background: color-mix(in srgb, var(--accent) 17%, var(--bg-elevated));
+ color: var(--accent-strong);
+}
+
+.media-gallery-shell {
+ min-width: 0;
+ display: grid;
+ gap: 0.42rem;
+}
+
+.media-gallery-controls {
+ min-height: 2rem;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.55rem;
+ color: var(--muted);
+ font-size: 0.78rem;
+}
+
+.media-gallery-controls > div {
+ display: inline-flex;
+ gap: 0.3rem;
+}
+
+.carousel-nav-button {
+ width: 2rem;
+ height: 2rem;
+ display: inline-grid;
+ place-items: center;
+ border: 1px solid var(--line);
+ border-radius: 50%;
+ background: var(--bg-elevated);
+}
+
+.share-links a {
+ min-width: 2rem;
+ min-height: 2rem;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding-inline: 0.28rem;
+ border-radius: var(--radius-pill);
+}
+
+.carousel-nav-button:disabled,
+.lightbox-toolbar button:disabled {
+ cursor: not-allowed;
+ opacity: 0.42;
+}
+
+.piece-media-carousel > .media-card,
+.piece-process-carousel > .media-card {
+ min-height: 0;
+ aspect-ratio: 4 / 3;
+}
+
+.lightbox-toolbar {
+ min-width: 0;
+ flex-wrap: wrap;
+ padding-inline: clamp(3rem, 8vw, 6rem);
+}
+
+.lightbox-toolbar > span {
+ min-width: min(17rem, 54vw);
+ text-align: center;
+}
+
+.lightbox-stage {
+ min-width: 0;
+ min-height: 0;
+ border-radius: var(--radius-sm);
+}
+
+.lightbox-stage[data-zoomed="true"] {
+ cursor: grab;
+ touch-action: none;
+}
+
+.lightbox-stage[data-zoomed="true"]:active {
+ cursor: grabbing;
+}
+
+.lightbox-stage[data-zoomed="false"] .lightbox-media {
+ cursor: default;
+}
+
+.inline-url-dialog-shell {
+ position: fixed;
+ z-index: 10020;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ padding: var(--site-gutter);
+}
+
+.inline-url-dialog-backdrop {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ border-radius: 0;
+ background: rgba(0, 0, 0, 0.72);
+}
+
+.inline-url-dialog {
+ position: relative;
+ right: auto;
+ bottom: auto;
+ z-index: 1;
+ width: min(31rem, 100%);
+ display: grid;
+ gap: 0.58rem;
+ max-height: min(86dvh, 32rem);
+ overflow: auto;
+ border: 1px solid var(--line);
+ background: var(--bg-elevated);
+ box-shadow: var(--shadow);
+}
+
+.inline-url-dialog label {
+ display: grid;
+ gap: 0.3rem;
+}
+
+@media (max-width: 520px) {
+ .media-gallery-controls {
+ padding-inline: 0.2rem;
+ }
+
+ .lightbox-shell {
+ padding: max(0.35rem, env(safe-area-inset-top)) 0.35rem max(0.35rem, env(safe-area-inset-bottom));
+ }
+
+ .lightbox-toolbar {
+ gap: 0.35rem;
+ padding-inline: 2.8rem;
+ }
+
+ .lightbox-toolbar button {
+ min-width: 2.5rem;
+ min-height: 2.5rem;
+ padding: 0.42rem 0.55rem;
+ }
+
+ .lightbox-toolbar > span {
+ order: -1;
+ width: 100%;
+ min-width: 0;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .skip-link {
+ transition: none;
+ }
+
+ .piece-media-carousel,
+ .piece-process-carousel {
+ scroll-behavior: auto;
+ }
+}
+
+@media (forced-colors: active) {
+ :where(a[href], button, input, textarea, select, summary, [tabindex]):focus-visible {
+ outline-color: Highlight !important;
+ box-shadow: none !important;
+ }
+
+ .site-nav .nav-link-pill[aria-current="page"] {
+ border-width: 2px;
+ }
+}
diff --git a/site/components/header-shell.tsx b/site/components/header-shell.tsx
index db0db11..b0d68e3 100644
--- a/site/components/header-shell.tsx
+++ b/site/components/header-shell.tsx
@@ -20,6 +20,18 @@ export function HeaderShell({ children }: { children: ReactNode }) {
current.dataset.headerState = "revealed";
}
+ function onFocusIn(event: FocusEvent) {
+ reveal();
+ const current = ref.current;
+ const target = event.target instanceof HTMLElement ? event.target : null;
+ if (!current || !target || current.contains(target)) return;
+ window.requestAnimationFrame(() => {
+ const targetTop = target.getBoundingClientRect().top;
+ const clearance = current.offsetHeight + 8;
+ if (targetTop < clearance) window.scrollBy({ top: targetTop - clearance, behavior: "auto" });
+ });
+ }
+
function update() {
const el = ref.current;
if (!el) return;
@@ -71,13 +83,13 @@ export function HeaderShell({ children }: { children: ReactNode }) {
}
window.addEventListener("scroll", onScroll, { passive: true });
- window.addEventListener("focusin", reveal);
+ window.addEventListener("focusin", onFocusIn);
window.addEventListener("pageshow", reveal);
el.addEventListener("pointerenter", reveal);
update();
return () => {
window.removeEventListener("scroll", onScroll);
- window.removeEventListener("focusin", reveal);
+ window.removeEventListener("focusin", onFocusIn);
window.removeEventListener("pageshow", reveal);
el.removeEventListener("pointerenter", reveal);
};
diff --git a/site/components/inline-edit-assistant.tsx b/site/components/inline-edit-assistant.tsx
index 6e1d50d..ae0ae23 100644
--- a/site/components/inline-edit-assistant.tsx
+++ b/site/components/inline-edit-assistant.tsx
@@ -104,6 +104,10 @@ export function InlineEditAssistant() {
const [urlError, setUrlError] = useState
(null);
const [lastRevertPatches, setLastRevertPatches] = useState([]);
const focusReturnRef = useRef(null);
+ const urlDialogRef = useRef(null);
+ const urlInputRef = useRef(null);
+ const urlReturnFocusRef = useRef(null);
+ const urlDialogOpen = Boolean(urlDraft);
const help = useMemo(() => "Select highlighted text or a mapped link. Use Ctrl+S to save and Esc to exit. Structural fields open in the visual full editor.", []);
useEffect(() => {
@@ -156,7 +160,7 @@ export function InlineEditAssistant() {
}, [editingSection]);
const handleKeyboard = useEffectEvent((event: globalThis.KeyboardEvent) => {
- if (!active) return;
+ if (!active || urlDraft) return;
if (event.key === "Escape") {
event.preventDefault();
cancelInlineEditing();
@@ -172,6 +176,40 @@ export function InlineEditAssistant() {
return () => document.removeEventListener("keydown", handleKeyboard);
}, [active]);
+ useEffect(() => {
+ if (!urlDialogOpen) return;
+ const previousOverflow = document.body.style.overflow;
+ document.body.style.overflow = "hidden";
+ window.requestAnimationFrame(() => urlInputRef.current?.focus());
+ function onKeyDown(event: globalThis.KeyboardEvent) {
+ if (event.key === "Escape") {
+ event.preventDefault();
+ setUrlDraft(null);
+ return;
+ }
+ if (event.key !== "Tab" || !urlDialogRef.current) return;
+ const focusable = [...urlDialogRef.current.querySelectorAll('button:not([disabled]), input:not([disabled]), [href], [tabindex]:not([tabindex="-1"])')];
+ const first = focusable[0];
+ const last = focusable.at(-1);
+ if (event.shiftKey && document.activeElement === first) {
+ event.preventDefault();
+ last?.focus();
+ } else if (!event.shiftKey && document.activeElement === last) {
+ event.preventDefault();
+ first?.focus();
+ }
+ }
+ window.addEventListener("keydown", onKeyDown);
+ return () => {
+ document.body.style.overflow = previousOverflow;
+ window.removeEventListener("keydown", onKeyDown);
+ const target = urlReturnFocusRef.current;
+ window.requestAnimationFrame(() => {
+ if (target?.isConnected) target.focus();
+ });
+ };
+ }, [urlDialogOpen]);
+
if (!active) return null;
function restoreOriginalText(close = false) {
@@ -250,6 +288,7 @@ export function InlineEditAssistant() {
const resource = element.dataset.inlineEditResource;
if (!resource) { setMessage("Selected anchor is missing inline resource metadata."); return; }
const index = getInlineIndex(element);
+ urlReturnFocusRef.current = document.activeElement as HTMLElement | null;
setUrlDraft({ resource, field, ...(element.dataset.inlineEditId ? { id: element.dataset.inlineEditId } : {}), ...(index !== undefined ? { index } : {}), value: element.getAttribute("href") ?? "" });
setUrlError(null);
}
@@ -260,7 +299,10 @@ export function InlineEditAssistant() {
if (!validation.ok) { setUrlError(validation.message); return; }
const element = selectedElement;
const saved = await sendPatches([{ resource: urlDraft.resource, field: urlDraft.field, ...(urlDraft.id ? { id: urlDraft.id } : {}), ...(urlDraft.index != null ? { index: urlDraft.index } : {}), value: validation.value, expectedValue: element instanceof HTMLAnchorElement ? element.getAttribute("href") ?? "" : "" }], "Saved mapped URL.");
- if (saved && element instanceof HTMLAnchorElement) element.href = validation.value;
+ if (saved) {
+ if (element instanceof HTMLAnchorElement) element.href = validation.value;
+ setUrlDraft(null);
+ }
}
function resetInlineChanges() {
@@ -297,7 +339,7 @@ export function InlineEditAssistant() {
Cancel
- {urlDraft ? Edit URL Allowed: http, https, mailto, or root-relative /paths.
{ setUrlDraft({ ...urlDraft, value: event.target.value }); setUrlError(null); }} />{urlError ?
{urlError}
: null}
Save URL setUrlDraft(null)}>Cancel
: null}
+ {urlDraft ? setUrlDraft(null)} type="button" />Edit URL Allowed: http, https, mailto, or root-relative /paths.
Destination { setUrlDraft({ ...urlDraft, value: event.target.value }); setUrlError(null); }} />{urlError ?
{urlError}
: null}
Save URL setUrlDraft(null)}>Cancel
: null}
>
);
}
diff --git a/site/components/lightbox.tsx b/site/components/lightbox.tsx
index bed1a0d..e9d17f2 100644
--- a/site/components/lightbox.tsx
+++ b/site/components/lightbox.tsx
@@ -1,119 +1,295 @@
-"use client";
-
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
-import { createPortal } from "react-dom";
-
-type LightboxItem = {
- src: string;
- alt: string;
- kind?: "image" | "video";
- focalX?: number;
- focalY?: number;
- zoom?: number;
- cleanupMode?: string;
-};
-
-export function MediaLightbox({ items, title, className = "gallery-grid" }: { items: LightboxItem[]; title: string; className?: string }) {
- const [activeIndex, setActiveIndex] = useState(null);
- const [zoom, setZoom] = useState(1);
- const [offset, setOffset] = useState({ x: 0, y: 0 });
+"use client";
+
+import Image from "next/image";
+import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
+import { createPortal } from "react-dom";
+import { clampLightboxZoom, clampPanOffset, type PanOffset } from "@/lib/ui-behavior";
+
+type LightboxItem = {
+ src: string;
+ alt: string;
+ kind?: "image" | "video";
+ focalX?: number;
+ focalY?: number;
+ zoom?: number;
+ cleanupMode?: string;
+};
+
+type ViewState = { zoom: number; offset: PanOffset };
+
+const INITIAL_VIEW: ViewState = { zoom: 1, offset: { x: 0, y: 0 } };
+const FOCUSABLE_SELECTOR = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), video[controls], [tabindex]:not([tabindex="-1"])';
+
+export function MediaLightbox({ items, title, className = "gallery-grid", preloadFirst = false }: { items: LightboxItem[]; title: string; className?: string; preloadFirst?: boolean }) {
+ const [activeIndex, setActiveIndex] = useState(null);
+ const [cursor, setCursor] = useState(0);
+ const [view, setView] = useState(INITIAL_VIEW);
const closeRef = useRef(null);
+ const dialogRef = useRef(null);
+ const stageRef = useRef(null);
+ const trackRef = useRef(null);
const returnFocusRef = useRef(null);
-
- const activeItem = useMemo(() => (activeIndex == null ? null : items[activeIndex] ?? null), [activeIndex, items]);
-
- const close = useCallback(() => {
- setActiveIndex(null);
- setZoom(1);
- setOffset({ x: 0, y: 0 });
- }, []);
-
- const navigate = useCallback((direction: 1 | -1) => {
- if (items.length < 2) return;
- setActiveIndex((current) => ((current ?? 0) + direction + items.length) % items.length);
- setZoom(1);
- setOffset({ x: 0, y: 0 });
- }, [items.length]);
-
+ const dragRef = useRef<{ pointerId: number; x: number; y: number } | null>(null);
+ const scrollFrameRef = useRef(null);
+ const statusId = useId();
+ const instructionsId = useId();
+
+ const activeItem = useMemo(() => (activeIndex == null ? null : items[activeIndex] ?? null), [activeIndex, items]);
+ const isOpen = activeItem !== null;
+
+ const resetView = useCallback(() => setView(INITIAL_VIEW), []);
+
+ const close = useCallback(() => {
+ setActiveIndex(null);
+ resetView();
+ }, [resetView]);
+
+ const navigate = useCallback((direction: 1 | -1) => {
+ if (items.length < 2) return;
+ setActiveIndex((current) => ((current ?? 0) + direction + items.length) % items.length);
+ resetView();
+ }, [items.length, resetView]);
+
+ const updateZoom = useCallback((delta: number) => {
+ setView((current) => {
+ const zoom = clampLightboxZoom(current.zoom + delta);
+ const bounds = stageRef.current?.getBoundingClientRect();
+ return {
+ zoom,
+ offset: clampPanOffset(current.offset, zoom, { width: bounds?.width ?? 0, height: bounds?.height ?? 0 })
+ };
+ });
+ }, []);
+
+ const panBy = useCallback((x: number, y: number) => {
+ setView((current) => {
+ const bounds = stageRef.current?.getBoundingClientRect();
+ return {
+ ...current,
+ offset: clampPanOffset(
+ { x: current.offset.x + x, y: current.offset.y + y },
+ current.zoom,
+ { width: bounds?.width ?? 0, height: bounds?.height ?? 0 }
+ )
+ };
+ });
+ }, []);
+
+ function openAt(index: number, opener: HTMLElement) {
+ returnFocusRef.current = opener;
+ setActiveIndex(index);
+ setCursor(index);
+ resetView();
+ }
+
+ function scrollToIndex(index: number) {
+ const track = trackRef.current;
+ const target = track?.querySelector(`[data-carousel-index="${index}"]`);
+ if (!track || !target) return;
+ const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+ track.scrollTo({ left: target.offsetLeft - track.offsetLeft, behavior: reduceMotion ? "auto" : "smooth" });
+ setCursor(index);
+ }
+
+ function syncCursorFromScroll() {
+ if (scrollFrameRef.current != null) return;
+ scrollFrameRef.current = window.requestAnimationFrame(() => {
+ scrollFrameRef.current = null;
+ const track = trackRef.current;
+ if (!track) return;
+ const children = [...track.querySelectorAll("[data-carousel-index]")];
+ let closest = cursor;
+ let distance = Number.POSITIVE_INFINITY;
+ children.forEach((child, index) => {
+ const nextDistance = Math.abs((child.offsetLeft - track.offsetLeft) - track.scrollLeft);
+ if (nextDistance < distance) {
+ closest = index;
+ distance = nextDistance;
+ }
+ });
+ if (closest !== cursor) setCursor(closest);
+ });
+ }
+
+ useEffect(() => () => {
+ if (scrollFrameRef.current != null) window.cancelAnimationFrame(scrollFrameRef.current);
+ }, []);
+
useEffect(() => {
- if (activeIndex == null) return;
- returnFocusRef.current = document.activeElement as HTMLElement | null;
- closeRef.current?.focus();
-
- return () => { returnFocusRef.current?.focus(); };
- }, [activeIndex]);
-
- useEffect(() => {
- if (activeIndex == null) return;
-
- function onKeyDown(event: KeyboardEvent) {
- if (event.key === "Escape") { close(); return; }
- if (event.key === "ArrowRight") { navigate(1); return; }
- if (event.key === "ArrowLeft") { navigate(-1); }
- }
-
- window.addEventListener("keydown", onKeyDown);
- return () => window.removeEventListener("keydown", onKeyDown);
- }, [activeIndex, close, navigate]);
-
- useEffect(() => {
- if (activeIndex == null) return;
- document.body.style.overflow = "hidden";
- return () => { document.body.style.overflow = ""; };
- }, [activeIndex]);
-
- return (
- <>
-
- {items.map((item, index) => (
-
{ setActiveIndex(index); setZoom(1); setOffset({ x: 0, y: 0 }); }} type="button">
- {item.kind === "video" ? (
-
- ) : (
-
- )}
-
- ))}
-
-
- {activeItem && typeof document !== "undefined"
- ? createPortal(
-
-
✕
-
event.stopPropagation()}>
- {items.length > 1 ? { event.stopPropagation(); navigate(-1); }} type="button">← : null}
- { event.stopPropagation(); setZoom((value) => Math.max(1, value - 0.25)); }} type="button">−
- {zoom > 1 ? `${Math.round(zoom * 100)}%` : ""} {title}{items.length > 1 ? ` (${(activeIndex ?? 0) + 1}/${items.length})` : ""}
- { event.stopPropagation(); setZoom((value) => Math.min(4, value + 0.25)); }} type="button">+
- {items.length > 1 ? { event.stopPropagation(); navigate(1); }} type="button">→ : null}
-
-
event.stopPropagation()}
- onPointerMove={(event) => {
- if (zoom <= 1 || event.buttons !== 1) return;
- setOffset((current) => ({ x: current.x + event.movementX, y: current.y + event.movementY }));
- }}
- role="presentation"
- >
- {activeItem.kind === "video" ? (
-
- ) : (
-
- )}
-
-
,
- document.body
- )
- : null}
- >
- );
-}
+ if (!isOpen) return;
+ const previousOverflow = document.body.style.overflow;
+ document.body.style.overflow = "hidden";
+ window.requestAnimationFrame(() => closeRef.current?.focus());
+ return () => {
+ document.body.style.overflow = previousOverflow;
+ const returnTarget = returnFocusRef.current;
+ window.requestAnimationFrame(() => {
+ if (returnTarget?.isConnected) returnTarget.focus();
+ });
+ };
+ }, [isOpen]);
+
+ useEffect(() => {
+ if (!isOpen) return;
+
+ function onKeyDown(event: KeyboardEvent) {
+ if (event.key === "Escape") {
+ event.preventDefault();
+ close();
+ return;
+ }
+
+ if (event.key === "Tab" && dialogRef.current) {
+ const focusable = [...dialogRef.current.querySelectorAll(FOCUSABLE_SELECTOR)].filter((element) => element.getClientRects().length > 0 && window.getComputedStyle(element).visibility !== "hidden");
+ const first = focusable[0];
+ const last = focusable.at(-1);
+ if (event.shiftKey && document.activeElement === first) {
+ event.preventDefault();
+ last?.focus();
+ } else if (!event.shiftKey && document.activeElement === last) {
+ event.preventDefault();
+ first?.focus();
+ }
+ return;
+ }
+
+ if (document.activeElement?.tagName === "VIDEO") return;
+ if (event.key === "+" || event.key === "=") {
+ event.preventDefault();
+ updateZoom(0.25);
+ } else if (event.key === "-") {
+ event.preventDefault();
+ updateZoom(-0.25);
+ } else if (event.key === "0") {
+ event.preventDefault();
+ resetView();
+ } else if (view.zoom > 1 && ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) {
+ event.preventDefault();
+ const step = event.shiftKey ? 72 : 32;
+ if (event.key === "ArrowLeft") panBy(-step, 0);
+ if (event.key === "ArrowRight") panBy(step, 0);
+ if (event.key === "ArrowUp") panBy(0, -step);
+ if (event.key === "ArrowDown") panBy(0, step);
+ } else if (event.key === "ArrowRight") {
+ event.preventDefault();
+ navigate(1);
+ } else if (event.key === "ArrowLeft") {
+ event.preventDefault();
+ navigate(-1);
+ }
+ }
+
+ window.addEventListener("keydown", onKeyDown);
+ return () => window.removeEventListener("keydown", onKeyDown);
+ }, [close, isOpen, navigate, panBy, resetView, updateZoom, view.zoom]);
+
+ if (items.length === 0) return null;
+
+ return (
+ <>
+
+
+
Image {cursor + 1} of {items.length}
+
+ scrollToIndex(Math.max(0, cursor - 1))} type="button">←
+ scrollToIndex(Math.min(items.length - 1, cursor + 1))} type="button">→
+
+
+
+ {items.map((item, index) => (
+ openAt(index, event.currentTarget)}
+ type="button"
+ >
+ {item.kind === "video" ? (
+
+ ) : (
+
+ )}
+
+ ))}
+
+
+
+ {activeItem && typeof document !== "undefined"
+ ? createPortal(
+
+
Use plus and minus to zoom, zero to reset, arrow keys to pan while zoomed, and Escape to close.
+
✕
+
event.stopPropagation()}>
+ {items.length > 1 ? { event.stopPropagation(); navigate(-1); }} type="button">← : null}
+ { event.stopPropagation(); updateZoom(-0.25); }} type="button">−
+ {Math.round(view.zoom * 100)}% · {title}{items.length > 1 ? ` · ${(activeIndex ?? 0) + 1} of ${items.length}` : ""}
+ { event.stopPropagation(); resetView(); }} type="button">Reset
+ = 4} onClick={(event) => { event.stopPropagation(); updateZoom(0.25); }} type="button">+
+ {items.length > 1 ? { event.stopPropagation(); navigate(1); }} type="button">→ : null}
+
+
1 ? "Zoomed image canvas. Drag or use arrow keys to pan." : "Image canvas"}
+ className="lightbox-stage"
+ data-zoomed={view.zoom > 1 ? "true" : "false"}
+ onClick={(event) => event.stopPropagation()}
+ onPointerDown={(event) => {
+ if (view.zoom <= 1) return;
+ event.currentTarget.setPointerCapture(event.pointerId);
+ dragRef.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY };
+ }}
+ onPointerMove={(event) => {
+ const drag = dragRef.current;
+ if (!drag || drag.pointerId !== event.pointerId) return;
+ panBy(event.clientX - drag.x, event.clientY - drag.y);
+ dragRef.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY };
+ }}
+ onPointerUp={(event) => {
+ if (dragRef.current?.pointerId !== event.pointerId) return;
+ dragRef.current = null;
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
+ }}
+ ref={stageRef}
+ role="group"
+ tabIndex={0}
+ >
+ {activeItem.kind === "video" ? (
+
+ ) : (
+
+ )}
+
+
,
+ document.body
+ )
+ : null}
+ >
+ );
+}
diff --git a/site/components/site-chrome.tsx b/site/components/site-chrome.tsx
index d962d01..aa22b55 100644
--- a/site/components/site-chrome.tsx
+++ b/site/components/site-chrome.tsx
@@ -1,9 +1,11 @@
import { cache, type ReactNode } from "react";
+import Image from "next/image";
import Link from "next/link";
import { cookies } from "next/headers";
import { ThemeToggle } from "@/components/theme-toggle";
import { HeaderSearch } from "@/components/header-search";
import { HeaderShell } from "@/components/header-shell";
+import { SiteNavLink } from "@/components/site-nav-link";
import { CategoryIcon as SharedCategoryIcon } from "@/components/category-icon";
import { EditableText, inlineEditAttrs, type InlineEditTarget } from "@/components/inline-editable";
import { avatarGradientStyle } from "@/lib/avatar";
@@ -48,10 +50,10 @@ const RESERVED_NAV_SLUGS = new Set(["home", "portfolio", "shop", "journal", "pro
export async function SiteHeader() {
const site = getSiteSettings();
- const user = await getViewer();
+ const [user, cookieStore] = await Promise.all([getViewer(), cookies()]);
const seedHrefs = new Set(site.navigation.map((entry) => String(entry.href)));
const dynamicPages = listPages(false).filter((page) => !RESERVED_NAV_SLUGS.has(page.slug) && !seedHrefs.has(`/${page.slug}`)).map((page) => ({ href: `/${page.slug}` as const, label: page.navLabel || page.title, slug: page.slug }));
- const cookieStore = await cookies();
+ const initialTheme = cookieStore.get("beaman-theme")?.value === "light" ? "light" : "dark";
const cartToken = cookieStore.get("beaman-cart")?.value;
const cartCount = cartToken ? listCartItems(cartToken, user?.email ?? null).reduce((sum, item) => sum + item.quantity, 0) : 0;
const accountHref = user ? (user.role === "admin" ? "/studio" : "/account/profile") : "/account/login";
@@ -68,15 +70,15 @@ export async function SiteHeader() {
const href = String(item.href).toLowerCase();
const label = String(item.label).trim().toLowerCase();
return href !== "/search" && href !== "/process" && href !== "/shop#process" && label !== "process";
- }).map(({ item, index }) => {item.label})}
- {dynamicPages.map((item) => {item.label})}
+ }).map(({ item, index }) => {item.label} )}
+ {dynamicPages.map((item) => {item.label} )}
0 ? `, ${cartCount} items` : ""}`} className="nav-link-pill cart-link" href="/shop/cart">
Cart {cartCount}
{user ?
: null}
-
+
@@ -120,7 +122,7 @@ export function PieceCard({ piece, categories }: { piece: PieceRecord; categorie
const verified = hasVerifiedMedia(piece);
const media = firstImage ? getMedia(firstImage) : null;
const category = findPieceCategory(piece.category, categories);
- return {firstImage ? : Media under review
} {category?.label ?? piece.category}{verified ? "Verified photography" : "Photography in progress"}
{piece.title} {piece.summary}
{piece.availabilityLabel} Updated {formatDate(piece.updatedAt)}
;
+ return {firstImage ? : Media under review
} {category?.label ?? piece.category}{verified ? "Verified photography" : "Photography in progress"}
{piece.title} {piece.summary}
{piece.availabilityLabel} Updated {formatDate(piece.updatedAt)}
;
}
export function PostCard({ post }: { post: PostRecord }) {
return {post.publishedAt ? formatDate(post.publishedAt) : "Draft"} {post.sourceUrl ? "Reference" : "Behind the scenes"}
{post.title}{post.excerpt}
;
diff --git a/site/components/site-nav-link.tsx b/site/components/site-nav-link.tsx
new file mode 100644
index 0000000..8b6a552
--- /dev/null
+++ b/site/components/site-nav-link.tsx
@@ -0,0 +1,18 @@
+"use client";
+
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import type { ComponentProps } from "react";
+import { isNavigationCurrent } from "@/lib/ui-behavior";
+
+type SiteNavLinkProps = Omit, "href"> & { href: string };
+
+export function SiteNavLink({ href, children, ...props }: SiteNavLinkProps) {
+ const pathname = usePathname();
+ const current = isNavigationCurrent(pathname, href);
+ return (
+
+ {children}
+
+ );
+}
diff --git a/site/components/theme-toggle.tsx b/site/components/theme-toggle.tsx
index 7ca23e1..f888a26 100644
--- a/site/components/theme-toggle.tsx
+++ b/site/components/theme-toggle.tsx
@@ -11,13 +11,15 @@ function applyTheme(nextTheme: ThemeMode) {
window.dispatchEvent(new Event("beaman-theme-change"));
}
-function getThemeSnapshot(): ThemeMode {
- if (typeof window === "undefined") {
- return "dark";
- }
-
- return window.localStorage.getItem("beaman-theme") === "light" ? "light" : "dark";
-}
+function getThemeSnapshot(): ThemeMode {
+ if (typeof window === "undefined") {
+ return "dark";
+ }
+
+ const stored = window.localStorage.getItem("beaman-theme");
+ if (stored === "light" || stored === "dark") return stored;
+ return document.documentElement.dataset.theme === "light" ? "light" : "dark";
+}
function subscribeTheme(listener: () => void) {
window.addEventListener("storage", listener);
@@ -28,12 +30,12 @@ function subscribeTheme(listener: () => void) {
};
}
-export function ThemeToggle() {
- const theme = useSyncExternalStore(subscribeTheme, getThemeSnapshot, () => "dark");
-
- useEffect(() => {
- applyTheme(theme);
- }, [theme]);
+export function ThemeToggle({ initialTheme = "dark" }: { initialTheme?: ThemeMode }) {
+ const theme = useSyncExternalStore(subscribeTheme, getThemeSnapshot, () => initialTheme);
+
+ useEffect(() => {
+ document.documentElement.dataset.theme = theme;
+ }, [theme]);
const isDark = theme === "dark";
diff --git a/site/lib/media-http.test.mts b/site/lib/media-http.test.mts
new file mode 100644
index 0000000..6fe5b96
--- /dev/null
+++ b/site/lib/media-http.test.mts
@@ -0,0 +1,15 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { mediaEntityTag, mediaLastModified, mediaRequestIsFresh } from "./media-http.ts";
+
+const file = { size: 4096, mtimeMs: Date.UTC(2026, 6, 12, 18, 30, 45, 700) };
+
+test("media validators are deterministic and honor conditional requests", () => {
+ assert.equal(mediaEntityTag(file), '"1000-19f579896c4"');
+ assert.equal(mediaLastModified(file), "Sun, 12 Jul 2026 18:30:45 GMT");
+ assert.equal(mediaRequestIsFresh(new Headers({ "if-none-match": mediaEntityTag(file) }), file), true);
+ assert.equal(mediaRequestIsFresh(new Headers({ "if-none-match": `W/${mediaEntityTag(file)}` }), file), true);
+ assert.equal(mediaRequestIsFresh(new Headers({ "if-none-match": '"different"' }), file), false);
+ assert.equal(mediaRequestIsFresh(new Headers({ "if-modified-since": mediaLastModified(file) }), file), true);
+ assert.equal(mediaRequestIsFresh(new Headers({ "if-modified-since": "Sun, 12 Jul 2026 18:30:44 GMT" }), file), false);
+});
diff --git a/site/lib/media-http.ts b/site/lib/media-http.ts
new file mode 100644
index 0000000..ac6e02d
--- /dev/null
+++ b/site/lib/media-http.ts
@@ -0,0 +1,22 @@
+export type MediaFileVersion = { size: number; mtimeMs: number };
+
+export function mediaEntityTag(file: MediaFileVersion) {
+ return `"${Math.max(0, file.size).toString(16)}-${Math.max(0, Math.trunc(file.mtimeMs)).toString(16)}"`;
+}
+
+export function mediaLastModified(file: MediaFileVersion) {
+ return new Date(file.mtimeMs).toUTCString();
+}
+
+export function mediaRequestIsFresh(headers: Headers, file: MediaFileVersion) {
+ const etag = mediaEntityTag(file);
+ const match = headers.get("if-none-match");
+ if (match) {
+ return match.split(",").map((entry) => entry.trim()).some((entry) => entry === "*" || entry === etag || entry === `W/${etag}`);
+ }
+ const modifiedSince = headers.get("if-modified-since");
+ if (!modifiedSince) return false;
+ const parsed = Date.parse(modifiedSince);
+ if (!Number.isFinite(parsed)) return false;
+ return Math.floor(file.mtimeMs / 1000) * 1000 <= parsed;
+}
diff --git a/site/lib/ui-behavior.test.mts b/site/lib/ui-behavior.test.mts
new file mode 100644
index 0000000..e020573
--- /dev/null
+++ b/site/lib/ui-behavior.test.mts
@@ -0,0 +1,22 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { clampLightboxZoom, clampPanOffset, isNavigationCurrent } from "./ui-behavior.ts";
+
+test("navigation current state matches exact roots and nested routes", () => {
+ assert.equal(isNavigationCurrent("/", "/"), true);
+ assert.equal(isNavigationCurrent("/portfolio", "/"), false);
+ assert.equal(isNavigationCurrent("/portfolio", "/portfolio"), true);
+ assert.equal(isNavigationCurrent("/portfolio/pastry-table", "/portfolio?category=tables"), true);
+ assert.equal(isNavigationCurrent("/shop", "/portfolio"), false);
+ assert.equal(isNavigationCurrent("/about", "https://example.com"), false);
+ assert.equal(isNavigationCurrent("/about", "//example.com"), false);
+});
+
+test("lightbox zoom and pan stay within visible bounds", () => {
+ assert.equal(clampLightboxZoom(0.4), 1);
+ assert.equal(clampLightboxZoom(2.12), 2);
+ assert.equal(clampLightboxZoom(8), 4);
+ assert.deepEqual(clampPanOffset({ x: 80, y: -60 }, 1, { width: 400, height: 300 }), { x: 0, y: 0 });
+ assert.deepEqual(clampPanOffset({ x: 999, y: -999 }, 2, { width: 400, height: 300 }), { x: 200, y: -150 });
+ assert.deepEqual(clampPanOffset({ x: Number.NaN, y: Number.POSITIVE_INFINITY }, 2, { width: 400, height: 300 }), { x: 0, y: 0 });
+});
diff --git a/site/lib/ui-behavior.ts b/site/lib/ui-behavior.ts
new file mode 100644
index 0000000..a7cf88e
--- /dev/null
+++ b/site/lib/ui-behavior.ts
@@ -0,0 +1,26 @@
+export type PanOffset = { x: number; y: number };
+export type PanViewport = { width: number; height: number };
+
+export function isNavigationCurrent(pathname: string, href: string) {
+ const target = href.split(/[?#]/u, 1)[0]?.replace(/\/+$/u, "") || "/";
+ if (!target.startsWith("/") || target.startsWith("//")) return false;
+ const current = (pathname || "/").replace(/\/+$/u, "") || "/";
+ return target === "/" ? current === "/" : current === target || current.startsWith(`${target}/`);
+}
+
+export function clampPanOffset(offset: PanOffset, zoom: number, viewport: PanViewport): PanOffset {
+ if (!Number.isFinite(zoom) || zoom <= 1) return { x: 0, y: 0 };
+ const width = Math.max(0, Number.isFinite(viewport.width) ? viewport.width : 0);
+ const height = Math.max(0, Number.isFinite(viewport.height) ? viewport.height : 0);
+ const maxX = width * (zoom - 1) / 2;
+ const maxY = height * (zoom - 1) / 2;
+ return {
+ x: Math.max(-maxX, Math.min(maxX, Number.isFinite(offset.x) ? offset.x : 0)),
+ y: Math.max(-maxY, Math.min(maxY, Number.isFinite(offset.y) ? offset.y : 0))
+ };
+}
+
+export function clampLightboxZoom(value: number) {
+ if (!Number.isFinite(value)) return 1;
+ return Math.max(1, Math.min(4, Math.round(value * 4) / 4));
+}
diff --git a/site/next.config.ts b/site/next.config.ts
index cfea160..371ffeb 100644
--- a/site/next.config.ts
+++ b/site/next.config.ts
@@ -2,6 +2,9 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
+ images: {
+ qualities: [75, 86, 88]
+ },
outputFileTracingExcludes: {
"/*": ["./data/**/*"]
},
diff --git a/site/package.json b/site/package.json
index 3d6961f..ee14a61 100644
--- a/site/package.json
+++ b/site/package.json
@@ -8,7 +8,7 @@
"start": "node --experimental-sqlite .next/standalone/server.js",
"lint": "eslint app components lib",
"typecheck": "tsc --noEmit",
- "test": "node --experimental-strip-types --test lib/media-scoring.test.mts lib/piece-model.test.mts lib/database-migrations.test.mts lib/media-reference-transaction.test.mts lib/media-batch-transaction.test.mts lib/category-icons.test.mts lib/site-structure.test.mts lib/piece-media.test.mts lib/estimator.test.mts lib/visual-audit-security.test.mts lib/inline-edit-registry.test.mts lib/commission-workflow.test.mts scripts/safe-build.test.mjs"
+ "test": "node --experimental-strip-types --test lib/media-scoring.test.mts lib/piece-model.test.mts lib/database-migrations.test.mts lib/media-reference-transaction.test.mts lib/media-batch-transaction.test.mts lib/category-icons.test.mts lib/site-structure.test.mts lib/piece-media.test.mts lib/estimator.test.mts lib/visual-audit-security.test.mts lib/inline-edit-registry.test.mts lib/commission-workflow.test.mts lib/ui-behavior.test.mts lib/media-http.test.mts scripts/safe-build.test.mjs"
},
"dependencies": {
"@react-three/drei": "^10.7.7",
diff --git a/synology-nas-deploy.md b/synology-nas-deploy.md
index cbe858c..752227a 100644
--- a/synology-nas-deploy.md
+++ b/synology-nas-deploy.md
@@ -229,11 +229,15 @@ docker compose -f docker-compose.synology.yml exec woodsmith sh -lc 'test -w /ap
### Media route
-```bash
-curl -I http://127.0.0.1:3002/media/Furniture/DSC_0051.JPG
-```
-
-Missing or removed files must return **404** (not a broken stream). Stale `media_items` rows pointing at deleted paths used to trigger `failed to pipe response` in logs when the dashboard rendered hundreds of thumbnails at once.
+```bash
+curl -I http://127.0.0.1:3002/media/Furniture/DSC_0051.JPG
+etag=$(curl -sS -D - -o /dev/null http://127.0.0.1:3002/media/Furniture/DSC_0051.JPG | awk 'BEGIN{IGNORECASE=1} /^etag:/{sub(/\r$/, "", $2); print $2; exit}')
+curl -sS -o /dev/null -w '%{http_code}\n' -H "If-None-Match: $etag" http://127.0.0.1:3002/media/Furniture/DSC_0051.JPG
+```
+
+An unchanged conditional request must return **304**. Media responses include ETag and Last-Modified validators with immediate revalidation, so browser and Next image-cache entries can avoid retransferring unchanged originals without hiding same-path updates. Missing or removed files must return **404** (not a broken stream). Stale `media_items` rows pointing at deleted paths used to trigger `failed to pipe response` in logs when the dashboard rendered hundreds of thumbnails at once.
+
+Portfolio, shop, cart, and carousel thumbnails use the mounted writable Next image cache at `/app/site/.next/cache`; the full-screen lightbox still requests the original `/media/...` source. Keep that cache mount writable and retain the configured Next image qualities during upgrades.
### Woodshop dashboard (`/studio`) and large libraries
diff --git a/woodsmith_DeepWiki_Merged_03222026.md b/woodsmith_DeepWiki_Merged_03222026.md
index db06e79..fd1ec5f 100644
--- a/woodsmith_DeepWiki_Merged_03222026.md
+++ b/woodsmith_DeepWiki_Merged_03222026.md
@@ -150,7 +150,7 @@ The `site/lib/search.ts` wrapper preserves the SQLite keyword/metadata search pa
The active design language is based on the Beaman Woodworks 2.0 prototypes but updated for the 3.0 client feedback:
- birds-eye maple, ebony, and white-maple palette
-- persistent light/day and black OLED night theme toggle
+- persistent light/day and black OLED night theme toggle whose server cookie and client store hydrate without overwriting the saved choice
- compact header shell that condenses on scroll and hides while scrolling down
- repaired toggle track/thumb alignment and admin-aware account/profile badge resolution
- local Mackintosh typography throughout the site
@@ -159,7 +159,9 @@ The active design language is based on the Beaman Woodworks 2.0 prototypes but u
- categorized portfolio tabs with icon-like labels
- dedicated Process archive replacing Journal without duplicating Process inside Shop
- account button as a rounded profile badge
-- full-size lightbox overlays with zoom, pan, arrows, close button, and `Esc` support
+- route-aware current navigation, a skip-to-main link, high-contrast focus rings, reduced-motion behavior, and header focus clearance
+- responsive carousels with announced position and optimized thumbnails; full-size lightboxes retain raw source quality, trap/restore focus, and support bounded keyboard/touch pan, zoom, arrows, reset, close button, backdrop click, and `Esc`
+- ETag and Last-Modified revalidation on direct media responses so unchanged originals are not retransferred while same-path updates remain visible after revalidation
- private dashboard media preview cards with crop/focal controls, cleanup modes, project media strips, and verification candidates
- programmatic Beaman Woodworks favicon and header mark
From d54e38f56f9cd62c6b8a86812a83cad8ef8d27a9 Mon Sep 17 00:00:00 2001
From: Cooper Beaman <55124795+lowestprime@users.noreply.github.com>
Date: Mon, 13 Jul 2026 23:57:56 -0700
Subject: [PATCH 12/43] fix(security): protect private media and mutation
origins
---
site/app/media/[...slug]/route.ts | 69 +++++++++---
site/components/media-picker.tsx | 5 +-
site/components/piece-media-editor.tsx | 2 +-
site/components/studio-media-workspace.tsx | 9 +-
site/lib/commission-workflow.test.mts | 16 +++
site/lib/db.ts | 46 ++++++++
site/lib/inline-edit-registry.test.mts | 8 --
site/lib/media-access.test.mts | 93 ++++++++++++++++
site/lib/media-access.ts | 124 +++++++++++++++++++++
site/lib/request-security.test.mts | 42 +++++++
site/lib/request-security.ts | 14 +--
site/package.json | 2 +-
12 files changed, 387 insertions(+), 43 deletions(-)
create mode 100644 site/lib/media-access.test.mts
create mode 100644 site/lib/media-access.ts
create mode 100644 site/lib/request-security.test.mts
diff --git a/site/app/media/[...slug]/route.ts b/site/app/media/[...slug]/route.ts
index f686085..1a2a39f 100644
--- a/site/app/media/[...slug]/route.ts
+++ b/site/app/media/[...slug]/route.ts
@@ -1,7 +1,11 @@
import { createReadStream, existsSync, statSync } from "node:fs";
import { Readable } from "node:stream";
import { NextResponse } from "next/server";
+import { getCurrentUser } from "@/lib/auth";
+import { commissionOwnerKey, userCanAccessProject } from "@/lib/commission-security";
+import { commissionRenderAssetOwnedBy, getMediaAccessAssociations, getProject } from "@/lib/db";
import { detectMediaKind, resolveMediaPath } from "@/lib/media";
+import { classifyMediaAccess, mediaAccessAllowed, mediaCacheHeaders, normalizeMediaRequestPath } from "@/lib/media-access";
import { mediaEntityTag, mediaLastModified, mediaRequestIsFresh } from "@/lib/media-http";
const MIME_TYPES: Record = {
@@ -17,18 +21,49 @@ const MIME_TYPES: Record = {
".webm": "video/webm"
};
-function notFound() {
- return new NextResponse("Not found", { status: 404, headers: { "Cache-Control": "no-store" } });
-}
-
+function notFound() {
+ return new NextResponse("Not found", {
+ status: 404,
+ headers: {
+ ...mediaCacheHeaders("denied"),
+ "Content-Type": "text/plain; charset=utf-8",
+ "X-Content-Type-Options": "nosniff"
+ }
+ });
+}
+
export async function GET(request: Request, { params }: { params: Promise<{ slug: string[] }> }) {
- const { slug } = await params;
- const relativePath = slug.join("/");
- if (!relativePath || relativePath.includes("..")) {
- return notFound();
- }
-
- let absolutePath: string;
+ const { slug } = await params;
+ const relativePath = normalizeMediaRequestPath(slug.join("/"));
+ if (!relativePath) return notFound();
+
+ const access = classifyMediaAccess(
+ relativePath,
+ getMediaAccessAssociations(relativePath)
+ );
+
+ if (access.kind !== "public-library") {
+ if (access.kind === "invalid" || access.kind === "transient") return notFound();
+
+ const user = await getCurrentUser();
+ const admin = user?.role === "admin";
+ let projectAuthorized = false;
+ let previewOwner = false;
+
+ if (access.kind === "private-project") {
+ const project = getProject(access.projectReference);
+ projectAuthorized = Boolean(project && await userCanAccessProject(project, user));
+ } else if (access.kind === "private-preview" && !admin) {
+ const ownerKey = await commissionOwnerKey(user?.email);
+ previewOwner = commissionRenderAssetOwnedBy(relativePath, ownerKey);
+ }
+
+ if (!mediaAccessAllowed(access, { admin, projectAuthorized, previewOwner })) {
+ return notFound();
+ }
+ }
+
+ let absolutePath: string;
try {
absolutePath = resolveMediaPath(relativePath);
} catch {
@@ -52,17 +87,19 @@ export async function GET(request: Request, { params }: { params: Promise<{ slug
const extension = absolutePath.slice(absolutePath.lastIndexOf(".")).toLowerCase();
const kind = detectMediaKind(relativePath);
+ const publicMedia = access.kind === "public-library";
const responseHeaders = {
"Content-Type": MIME_TYPES[extension] || (kind === "video" ? "application/octet-stream" : "image/jpeg"),
"Content-Length": String(stat.size),
- "Cache-Control": "public, max-age=0, must-revalidate",
- "CDN-Cache-Control": "public, max-age=0, must-revalidate",
- ETag: mediaEntityTag(stat),
- "Last-Modified": mediaLastModified(stat),
+ ...mediaCacheHeaders(publicMedia ? "public" : "private"),
+ ...(publicMedia ? {
+ ETag: mediaEntityTag(stat),
+ "Last-Modified": mediaLastModified(stat)
+ } : {}),
"X-Content-Type-Options": "nosniff"
};
- if (mediaRequestIsFresh(request.headers, stat)) {
+ if (publicMedia && mediaRequestIsFresh(request.headers, stat)) {
return new NextResponse(null, { status: 304, headers: responseHeaders });
}
diff --git a/site/components/media-picker.tsx b/site/components/media-picker.tsx
index 47c9df2..07ee6df 100644
--- a/site/components/media-picker.tsx
+++ b/site/components/media-picker.tsx
@@ -14,6 +14,7 @@ export type MediaPickerItem = {
pieceSlug: string | null;
postSlug: string | null;
pageSlug: string | null;
+ projectReference: string | null;
reviewed: boolean;
tags: string[];
metadata: Record;
@@ -197,7 +198,7 @@ export function MediaPicker({
const item = itemByPath.get(relativePath);
return (
- {item?.kind === "image" ? : {item?.kind?.toUpperCase() || "MEDIA"} }
+ {item?.kind === "image" ? : {item?.kind?.toUpperCase() || "MEDIA"} }
{item?.fileName || relativePath.split("/").pop()} {item?.folder || relativePath}
{multiple ? moveSelected(index, -1)} type="button">↑ moveSelected(index, 1)} type="button">↓ : null}
toggleItem(relativePath)} type="button">×
@@ -220,7 +221,7 @@ export function MediaPicker({
{visibleItems.map((item) => {
const selected = selectedPaths.includes(item.relativePath);
- return
toggleItem(item.relativePath)} type="button">{item.kind === "image" ? : {item.kind.toUpperCase()} }
{item.fileName} {item.folder}
{item.pieceSlug || item.pageSlug || item.postSlug || "Unassigned"}{item.reviewed ? " · reviewed" : " · review needed"} ;
+ return
toggleItem(item.relativePath)} type="button">{item.kind === "image" ? : {item.kind.toUpperCase()} }
{item.fileName} {item.folder}
{item.pieceSlug || item.pageSlug || item.postSlug || item.projectReference || "Unassigned"}{item.reviewed ? " · reviewed" : " · review needed"} ;
})}
{!loading && visibleItems.length === 0 ?
No media matches this page and folder filter.
: null}
diff --git a/site/components/piece-media-editor.tsx b/site/components/piece-media-editor.tsx
index 18cdaf0..e81faa0 100644
--- a/site/components/piece-media-editor.tsx
+++ b/site/components/piece-media-editor.tsx
@@ -77,7 +77,7 @@ export function PieceMediaEditor({
const item = itemMap.get(link.relativePath);
const buildRole = BUILD_ROLES.includes(link.role);
return
- {item?.kind === "image" ? : {item?.kind || "media"} }
+ {item?.kind === "image" ? : {item?.kind || "media"} }
{item?.fileName || link.relativePath.split("/").pop()}
diff --git a/site/components/studio-media-workspace.tsx b/site/components/studio-media-workspace.tsx
index e039a18..5bb970c 100644
--- a/site/components/studio-media-workspace.tsx
+++ b/site/components/studio-media-workspace.tsx
@@ -9,6 +9,7 @@ import { toMediaUrl } from "@/lib/format";
import type { MediaActionResult, MediaPageRequest, MediaPageResult } from "@/lib/actions";
import type { MediaAiFilter, MediaAssignmentFilter, MediaKindFilter, MediaOperationBatchRecord, MediaRecord } from "@/lib/db";
import type { MediaMatchCandidate } from "@/lib/media-audit";
+import { mediaRequiresDirectBrowserRequest } from "@/lib/media-access";
type MediaAction = (state: MediaActionResult | null, formData: FormData) => Promise
;
@@ -155,8 +156,8 @@ function providerCopy(provider?: ProviderState) {
return `${provider.provider.replace("-", " ")} ${availability}${provider.model ? ` · ${provider.model}` : ""}`;
}
-function imageNeedsUnoptimized(relativePath: string) {
- return /\.(gif|svg)$/i.test(relativePath);
+function imageNeedsUnoptimized(relativePath: string, projectReference?: string | null) {
+ return mediaRequiresDirectBrowserRequest(relativePath, { projectReference }) || /\.(gif|svg)$/i.test(relativePath);
}
function parseList(value: FormDataEntryValue | null) {
@@ -939,7 +940,7 @@ export function StudioMediaWorkspace({
return (
inspectCandidate(item)} title={`Inspect candidate scored ${score}`} type="button">
-
+
{confidenceForScore(score).label}
Why {score}% Visual {compactMetric(evidence.visualSimilarity)} VLM {compactMetric(evidence.vlmConfidence)} Text {compactMetric(evidence.lexicalScore)} Cluster training {compactMetric(evidence.clusterPropagation)} Folder training {compactMetric(evidence.folderDateContext)} Manual label {compactMetric(evidence.manualPrior)} Rejected signal {compactMetric(evidence.negativeReviewSignal)} Margin {compactMetric(margin)} {reasonCodes.join(" · ")}
@@ -1012,7 +1013,7 @@ export function StudioMediaWorkspace({
>
{item.kind === "image"
- ?
+ ?
: item.kind === "video"
?
:
{item.kind.toUpperCase()} }
diff --git a/site/lib/commission-workflow.test.mts b/site/lib/commission-workflow.test.mts
index d5cc167..ead3a84 100644
--- a/site/lib/commission-workflow.test.mts
+++ b/site/lib/commission-workflow.test.mts
@@ -89,6 +89,12 @@ test("commission drafts, idempotency, capabilities, and render ownership persist
tags: ["buyer-reference"]
});
assert.equal(db.getMedia(nestedUpload)?.projectReference, first.reference);
+ assert.deepEqual(db.getMediaAccessAssociations(nestedUpload), {
+ projectReference: first.reference,
+ privateAssociation: false,
+ renderAsset: false,
+ renderProjectReference: null
+ });
const accessToken = db.createProjectAccessGrant(first.reference, 1);
assert.equal(db.projectAccessGrantValid(first.reference, accessToken), true);
@@ -101,8 +107,18 @@ test("commission drafts, idempotency, capabilities, and render ownership persist
assert.equal(db.consumeCommissionSubmissionQuota("guest:submission", 2, 60_000).allowed, true);
assert.equal(db.consumeCommissionSubmissionQuota("guest:submission", 2, 60_000).allowed, false);
db.registerCommissionRenderAsset("ai-renderings/test.png", "guest:test");
+ assert.equal(db.commissionRenderAssetOwnedBy("ai-renderings/test.png", "guest:test"), true);
+ assert.equal(db.commissionRenderAssetOwnedBy("ai-renderings/test.png", "guest:other"), false);
+ assert.deepEqual(db.getMediaAccessAssociations("ai-renderings/test.png"), {
+ projectReference: null,
+ privateAssociation: false,
+ renderAsset: true,
+ renderProjectReference: null
+ });
assert.equal(db.consumeCommissionRenderAsset("ai-renderings/test.png", "guest:other", first.reference), false);
assert.equal(db.consumeCommissionRenderAsset("ai-renderings/test.png", "guest:test", first.reference), true);
+ assert.equal(db.commissionRenderAssetOwnedBy("ai-renderings/test.png", "guest:test"), false);
+ assert.equal(db.getMediaAccessAssociations("ai-renderings/test.png").renderProjectReference, first.reference);
assert.equal(db.consumeCommissionRenderAsset("ai-renderings/test.png", "guest:test", first.reference), false);
db.markCommissionDraftSubmitted(draft.id, "buyer@example.com", first.reference);
diff --git a/site/lib/db.ts b/site/lib/db.ts
index 7b2f14d..49e1f17 100644
--- a/site/lib/db.ts
+++ b/site/lib/db.ts
@@ -253,6 +253,13 @@ export type MediaRecord = {
updatedAt: string;
};
+export type MediaAccessAssociationsRecord = {
+ projectReference: string | null;
+ privateAssociation: boolean;
+ renderAsset: boolean;
+ renderProjectReference: string | null;
+};
+
export type ProjectRecord = {
reference: string;
userEmail: string | null;
@@ -2579,6 +2586,32 @@ export function getMedia(relativePath: string) {
return row ? mapMedia(row) : null;
}
+export function getMediaAccessAssociations(relativePath: string): MediaAccessAssociationsRecord {
+ const db = getDatabase();
+ const row = db.prepare(`
+ SELECT
+ (SELECT project_reference FROM media_items WHERE relative_path = ? LIMIT 1) AS projectReference,
+ EXISTS(
+ SELECT 1 FROM piece_media_links
+ WHERE relative_path = ? AND (role = 'private-project' OR is_public = 0)
+ ) AS privateAssociation,
+ EXISTS(
+ SELECT 1 FROM commission_render_assets WHERE relative_path = ?
+ ) AS renderAsset,
+ (
+ SELECT consumed_project_reference FROM commission_render_assets
+ WHERE relative_path = ? LIMIT 1
+ ) AS renderProjectReference
+ `).get(relativePath, relativePath, relativePath, relativePath) as Record
;
+
+ return {
+ projectReference: row.projectReference ? String(row.projectReference) : null,
+ privateAssociation: Number(row.privateAssociation) === 1,
+ renderAsset: Number(row.renderAsset) === 1,
+ renderProjectReference: row.renderProjectReference ? String(row.renderProjectReference) : null
+ };
+}
+
export function saveMediaMetadata(input: {
relativePath: string;
altText: string;
@@ -3471,6 +3504,19 @@ export function registerCommissionRenderAsset(relativePath: string, ownerKey: st
`).run(relativePath, hashOpaqueValue(ownerKey), nowIso());
}
+export function commissionRenderAssetOwnedBy(relativePath: string, ownerKey: string) {
+ if (!ownerKey) return false;
+ const db = getDatabase();
+ const candidateHash = hashOpaqueValue(ownerKey);
+ const row = db.prepare(`
+ SELECT owner_key_hash AS ownerKeyHash
+ FROM commission_render_assets
+ WHERE relative_path = ? AND consumed_project_reference IS NULL
+ LIMIT 1
+ `).get(relativePath) as { ownerKeyHash?: unknown } | undefined;
+ return Boolean(row?.ownerKeyHash && safeHashEqual(String(row.ownerKeyHash), candidateHash));
+}
+
export function consumeCommissionRenderAsset(relativePath: string, ownerKey: string, projectReference: string) {
const db = getDatabase();
const ownerKeyHash = hashOpaqueValue(ownerKey);
diff --git a/site/lib/inline-edit-registry.test.mts b/site/lib/inline-edit-registry.test.mts
index 23b1b53..684ae67 100644
--- a/site/lib/inline-edit-registry.test.mts
+++ b/site/lib/inline-edit-registry.test.mts
@@ -11,7 +11,6 @@ import {
normalizeInlineEditUrl,
validateInlineEditPatch
} from "./inline-edit-registry.ts";
-import { mutationOriginAllowed } from "./request-security.ts";
test("typed inline registry allows declared fields and denies arbitrary paths", () => {
assert.equal(getInlineEditDefinition("piece", "title")?.kind, "text");
@@ -50,13 +49,6 @@ test("list add, remove, replace, and reorder operations are deterministic", () =
assert.deepEqual(editInlineList(["First", "Second", "Third"], move, []), ["Third", "First", "Second"]);
});
-test("mutation origin policy accepts only same-site or explicitly configured origins", () => {
- assert.equal(mutationOriginAllowed({ requestUrl: "http://127.0.0.1:3002/api/studio/inline-edit", origin: "http://127.0.0.1:3002" }), true);
- assert.equal(mutationOriginAllowed({ requestUrl: "http://woodsmith:3002/api/studio/inline-edit", origin: "https://woodmat.ch", forwardedHost: "woodmat.ch", forwardedProto: "https" }), true);
- assert.equal(mutationOriginAllowed({ requestUrl: "http://woodsmith:3002/api/studio/inline-edit", origin: "https://evil.example", configuredOrigins: ["https://woodmat.ch"] }), false);
- assert.equal(mutationOriginAllowed({ requestUrl: "http://127.0.0.1:3002/api/studio/inline-edit", origin: null }), false);
-});
-
test("SQLite rollback, media synchronization, and footer URL persistence remain atomic", async () => {
const root = mkdtempSync(path.join(tmpdir(), "woodsmith-inline-edit-"));
const dataRoot = path.join(root, "data");
diff --git a/site/lib/media-access.test.mts b/site/lib/media-access.test.mts
new file mode 100644
index 0000000..1be2b6f
--- /dev/null
+++ b/site/lib/media-access.test.mts
@@ -0,0 +1,93 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ classifyMediaAccess,
+ mediaAccessAllowed,
+ mediaCacheHeaders,
+ mediaRequiresDirectBrowserRequest,
+ normalizeMediaRequestPath
+} from "./media-access.ts";
+
+test("public library media remains public and revalidatable", () => {
+ const access = classifyMediaAccess("Furniture/DSC_0051.JPG");
+ assert.deepEqual(access, {
+ kind: "public-library",
+ relativePath: "Furniture/DSC_0051.JPG"
+ });
+ assert.equal(mediaAccessAllowed(access), true);
+ assert.deepEqual(mediaCacheHeaders("public"), {
+ "Cache-Control": "public, max-age=0, must-revalidate",
+ "CDN-Cache-Control": "public, max-age=0, must-revalidate"
+ });
+});
+
+test("staging and deletion-transient media is never retrievable", () => {
+ for (const relativePath of [
+ "commission-staging/submission/reference.jpg",
+ ".woodsmith-trash/deleted-reference.jpg"
+ ]) {
+ const access = classifyMediaAccess(relativePath);
+ assert.equal(access.kind, "transient");
+ assert.equal(mediaAccessAllowed(access, { admin: true }), false);
+ }
+ assert.equal(mediaCacheHeaders("denied")["Cache-Control"], "no-store");
+ assert.equal(mediaCacheHeaders("denied")["CDN-Cache-Control"], "no-store");
+});
+
+test("project media requires admin, owner, or project capability authorization", () => {
+ const fromPath = classifyMediaAccess("projects/BW-CM-260713-ABCD/references/room.jpg");
+ assert.equal(fromPath.kind, "private-project");
+ assert.equal(mediaAccessAllowed(fromPath), false);
+ assert.equal(mediaAccessAllowed(fromPath, { projectAuthorized: true }), true);
+ assert.equal(mediaAccessAllowed(fromPath, { admin: true }), true);
+
+ const fromMetadata = classifyMediaAccess("Uploads/renamed-reference.jpg", {
+ projectReference: "BW-CM-260713-EFGH"
+ });
+ assert.equal(fromMetadata.kind, "private-project");
+ assert.equal(mediaAccessAllowed(fromMetadata), false);
+ assert.equal(mediaRequiresDirectBrowserRequest(fromPath.relativePath), true);
+ assert.equal(mediaRequiresDirectBrowserRequest(fromMetadata.relativePath, { projectReference: fromMetadata.projectReference }), true);
+ assert.equal(mediaRequiresDirectBrowserRequest("Furniture/public-chair.jpg"), false);
+});
+
+test("customer previews require the generating owner until attached to a project", () => {
+ const pending = classifyMediaAccess("ai-renderings/bench-preview.png", { renderAsset: true });
+ assert.equal(pending.kind, "private-preview");
+ assert.equal(mediaAccessAllowed(pending), false);
+ assert.equal(mediaAccessAllowed(pending, { previewOwner: true }), true);
+
+ const consumed = classifyMediaAccess("ai-renderings/bench-preview.png", {
+ renderAsset: true,
+ renderProjectReference: "BW-CM-260713-IJKL"
+ });
+ assert.equal(consumed.kind, "private-project");
+ assert.equal(mediaAccessAllowed(consumed, { projectAuthorized: true }), true);
+});
+
+test("nonpublic normalized links are admin-only and never publicly cached", () => {
+ const access = classifyMediaAccess("Furniture/private-plan.jpg", { privateAssociation: true });
+ assert.equal(access.kind, "private-admin");
+ assert.equal(mediaAccessAllowed(access), false);
+ assert.equal(mediaAccessAllowed(access, { admin: true }), true);
+ const headers = mediaCacheHeaders("private");
+ assert.equal(headers["Cache-Control"], "private, no-store, max-age=0");
+ assert.equal(headers["CDN-Cache-Control"], "no-store");
+ assert.equal(headers.Vary, "Cookie");
+});
+
+test("malformed and traversal media paths are denied", () => {
+ for (const relativePath of [
+ "",
+ "../secret.jpg",
+ "Furniture/../secret.jpg",
+ "Furniture\\secret.jpg",
+ "/absolute.jpg",
+ "Furniture//double.jpg",
+ "Furniture/control\0.jpg"
+ ]) {
+ assert.equal(normalizeMediaRequestPath(relativePath), null);
+ assert.equal(classifyMediaAccess(relativePath).kind, "invalid");
+ }
+});
diff --git a/site/lib/media-access.ts b/site/lib/media-access.ts
new file mode 100644
index 0000000..a254caa
--- /dev/null
+++ b/site/lib/media-access.ts
@@ -0,0 +1,124 @@
+export type MediaAccessAssociations = {
+ projectReference?: string | null;
+ privateAssociation?: boolean;
+ renderAsset?: boolean;
+ renderProjectReference?: string | null;
+};
+
+export type MediaAccessClassification =
+ | { kind: "invalid" }
+ | { kind: "public-library"; relativePath: string }
+ | { kind: "transient"; relativePath: string }
+ | { kind: "private-project"; relativePath: string; projectReference: string }
+ | { kind: "private-preview"; relativePath: string }
+ | { kind: "private-admin"; relativePath: string };
+
+export function normalizeMediaRequestPath(value: string) {
+ if (!value || value.length > 2_048 || value.startsWith("/") || /[\\\0-\x1f\x7f]/.test(value)) {
+ return null;
+ }
+
+ const segments = value.split("/");
+ if (segments.some((segment) => !segment || segment === "." || segment === "..")) {
+ return null;
+ }
+
+ return segments.join("/");
+}
+
+function pathUnder(relativePath: string, root: string) {
+ return relativePath === root || relativePath.startsWith(`${root}/`);
+}
+
+function projectReferenceFromPath(relativePath: string) {
+ if (!relativePath.startsWith("projects/")) return null;
+ return relativePath.split("/")[1] || null;
+}
+
+export function classifyMediaAccess(
+ requestedPath: string,
+ associations: MediaAccessAssociations = {}
+): MediaAccessClassification {
+ const relativePath = normalizeMediaRequestPath(requestedPath);
+ if (!relativePath) return { kind: "invalid" };
+
+ if (
+ pathUnder(relativePath, "commission-staging") ||
+ pathUnder(relativePath, ".woodsmith-trash")
+ ) {
+ return { kind: "transient", relativePath };
+ }
+
+ const projectReference =
+ associations.renderProjectReference?.trim() ||
+ associations.projectReference?.trim() ||
+ projectReferenceFromPath(relativePath);
+ if (projectReference) {
+ return {
+ kind: "private-project",
+ relativePath,
+ projectReference
+ };
+ }
+
+ if (
+ associations.renderAsset ||
+ pathUnder(relativePath, "ai-renderings")
+ ) {
+ return { kind: "private-preview", relativePath };
+ }
+
+ if (associations.privateAssociation) {
+ return { kind: "private-admin", relativePath };
+ }
+
+ return { kind: "public-library", relativePath };
+}
+
+export function mediaAccessAllowed(
+ classification: MediaAccessClassification,
+ viewer: {
+ admin?: boolean;
+ projectAuthorized?: boolean;
+ previewOwner?: boolean;
+ } = {}
+) {
+ if (classification.kind === "public-library") return true;
+ if (classification.kind === "private-project") {
+ return Boolean(viewer.admin || viewer.projectAuthorized);
+ }
+ if (classification.kind === "private-preview") {
+ return Boolean(viewer.admin || viewer.previewOwner);
+ }
+ if (classification.kind === "private-admin") return Boolean(viewer.admin);
+ return false;
+}
+
+export function mediaRequiresDirectBrowserRequest(
+ requestedPath: string,
+ associations: MediaAccessAssociations = {}
+) {
+ return classifyMediaAccess(requestedPath, associations).kind !== "public-library";
+}
+
+export function mediaCacheHeaders(access: "public" | "private" | "denied"): Record {
+ if (access === "public") {
+ return {
+ "Cache-Control": "public, max-age=0, must-revalidate",
+ "CDN-Cache-Control": "public, max-age=0, must-revalidate"
+ };
+ }
+
+ if (access === "private") {
+ return {
+ "Cache-Control": "private, no-store, max-age=0",
+ "CDN-Cache-Control": "no-store",
+ Vary: "Cookie"
+ };
+ }
+
+ return {
+ "Cache-Control": "no-store",
+ "CDN-Cache-Control": "no-store"
+ };
+}
diff --git a/site/lib/request-security.test.mts b/site/lib/request-security.test.mts
new file mode 100644
index 0000000..8b4fd1e
--- /dev/null
+++ b/site/lib/request-security.test.mts
@@ -0,0 +1,42 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { mutationOriginAllowed } from "./request-security.ts";
+
+test("mutation origin accepts the request URL origin", () => {
+ assert.equal(mutationOriginAllowed({
+ requestUrl: "http://127.0.0.1:3002/api/studio/inline-edit",
+ origin: "http://127.0.0.1:3002"
+ }), true);
+});
+
+test("mutation origin accepts an explicitly configured public origin", () => {
+ assert.equal(mutationOriginAllowed({
+ requestUrl: "http://woodsmith:3002/api/studio/inline-edit",
+ origin: "https://woodmat.ch",
+ configuredOrigins: ["https://woodmat.ch", "https://www.woodmat.ch"]
+ }), true);
+});
+
+test("forwarded headers cannot expand the mutation origin allowlist", () => {
+ const spoofedForwarding = {
+ requestUrl: "http://woodsmith:3002/api/studio/inline-edit",
+ origin: "https://evil.example",
+ forwardedHost: "evil.example",
+ forwardedProto: "https",
+ configuredOrigins: ["https://woodmat.ch"]
+ };
+
+ assert.equal(mutationOriginAllowed(spoofedForwarding), false);
+});
+
+test("mutation origin rejects missing, malformed, and unrelated origins", () => {
+ const base = {
+ requestUrl: "http://woodsmith:3002/api/studio/inline-edit",
+ configuredOrigins: ["https://woodmat.ch"]
+ };
+ assert.equal(mutationOriginAllowed({ ...base, origin: null }), false);
+ assert.equal(mutationOriginAllowed({ ...base, origin: "not a URL" }), false);
+ assert.equal(mutationOriginAllowed({ ...base, origin: "https://unrelated.example" }), false);
+ assert.equal(mutationOriginAllowed({ ...base, requestUrl: "not a URL", origin: "https://woodmat.ch" }), false);
+});
diff --git a/site/lib/request-security.ts b/site/lib/request-security.ts
index 9fbca62..79cc70d 100644
--- a/site/lib/request-security.ts
+++ b/site/lib/request-security.ts
@@ -10,20 +10,14 @@ function normalizedOrigin(value: string | null | undefined) {
export function mutationOriginAllowed(input: {
requestUrl: string;
origin?: string | null;
- forwardedHost?: string | null;
- forwardedProto?: string | null;
configuredOrigins?: Array;
}) {
const origin = normalizedOrigin(input.origin);
if (!origin) return false;
- const request = new URL(input.requestUrl);
+ const requestOrigin = normalizedOrigin(input.requestUrl);
+ if (!requestOrigin) return false;
const allowed = new Set();
- allowed.add(request.origin.toLowerCase());
- if (input.forwardedHost) {
- const protocol = input.forwardedProto?.split(",")[0]?.trim() || request.protocol.replace(":", "");
- const forwarded = normalizedOrigin(`${protocol}://${input.forwardedHost.split(",")[0]?.trim()}`);
- if (forwarded) allowed.add(forwarded);
- }
+ allowed.add(requestOrigin);
for (const value of input.configuredOrigins ?? []) {
const configured = normalizedOrigin(value);
if (configured) allowed.add(configured);
@@ -39,8 +33,6 @@ export function assertTrustedMutationOrigin(request: Request) {
if (!mutationOriginAllowed({
requestUrl: request.url,
origin: request.headers.get("origin"),
- forwardedHost: request.headers.get("x-forwarded-host"),
- forwardedProto: request.headers.get("x-forwarded-proto"),
configuredOrigins: [process.env.SITE_URL, process.env.NEXT_PUBLIC_SITE_URL]
})) {
throw new UntrustedMutationOriginError("The request origin is not allowed.");
diff --git a/site/package.json b/site/package.json
index ee14a61..705c42d 100644
--- a/site/package.json
+++ b/site/package.json
@@ -8,7 +8,7 @@
"start": "node --experimental-sqlite .next/standalone/server.js",
"lint": "eslint app components lib",
"typecheck": "tsc --noEmit",
- "test": "node --experimental-strip-types --test lib/media-scoring.test.mts lib/piece-model.test.mts lib/database-migrations.test.mts lib/media-reference-transaction.test.mts lib/media-batch-transaction.test.mts lib/category-icons.test.mts lib/site-structure.test.mts lib/piece-media.test.mts lib/estimator.test.mts lib/visual-audit-security.test.mts lib/inline-edit-registry.test.mts lib/commission-workflow.test.mts lib/ui-behavior.test.mts lib/media-http.test.mts scripts/safe-build.test.mjs"
+ "test": "node --experimental-strip-types --test lib/media-scoring.test.mts lib/piece-model.test.mts lib/database-migrations.test.mts lib/media-reference-transaction.test.mts lib/media-batch-transaction.test.mts lib/category-icons.test.mts lib/site-structure.test.mts lib/piece-media.test.mts lib/estimator.test.mts lib/visual-audit-security.test.mts lib/inline-edit-registry.test.mts lib/commission-workflow.test.mts lib/ui-behavior.test.mts lib/media-http.test.mts lib/media-access.test.mts lib/request-security.test.mts scripts/safe-build.test.mjs"
},
"dependencies": {
"@react-three/drei": "^10.7.7",
From fff7f07adfa329caa3e52a77429af9a29dff1f76 Mon Sep 17 00:00:00 2001
From: Cooper Beaman <55124795+lowestprime@users.noreply.github.com>
Date: Tue, 14 Jul 2026 00:22:31 -0700
Subject: [PATCH 13/43] fix(ui): correct price, icon, and theme behavior
---
site/app/shop/cart/page.tsx | 6 +--
site/app/shop/page.tsx | 13 ++++--
site/components/category-icon.tsx | 7 +--
site/components/theme-toggle.tsx | 78 ++++++++++++++++++++++---------
site/lib/catalog.ts | 2 +
site/lib/category-icons.test.mts | 7 ++-
site/lib/category-icons.ts | 7 +++
site/lib/piece-model.test.mts | 5 ++
site/lib/piece-model.ts | 25 +++++++---
site/lib/theme-store.test.mts | 48 +++++++++++++++++++
site/lib/theme-store.ts | 45 ++++++++++++++++++
site/package.json | 2 +-
12 files changed, 205 insertions(+), 40 deletions(-)
create mode 100644 site/lib/theme-store.test.mts
create mode 100644 site/lib/theme-store.ts
diff --git a/site/app/shop/cart/page.tsx b/site/app/shop/cart/page.tsx
index d4a077b..ffc7e15 100644
--- a/site/app/shop/cart/page.tsx
+++ b/site/app/shop/cart/page.tsx
@@ -18,7 +18,7 @@ export default async function CartPage({ searchParams }: { searchParams: Promise
const lines = cartItems.flatMap((item) => {
const piece = getPiece(item.pieceSlug);
if (!piece || piece.priceCents == null) return [];
- return [{ item, piece }];
+ return [{ item, piece, firstImage: getDisplayMediaPaths(piece)[0] ?? null }];
});
const totals = calculateCheckoutTotals({
lines: lines.map(({ item, piece }) => ({ slug: piece.slug, title: piece.title, quantity: item.quantity, unitAmountCents: piece.priceCents!, description: piece.subtitle })),
@@ -37,9 +37,9 @@ export default async function CartPage({ searchParams }: { searchParams: Promise
{checkout === "local-review" && order ? Local pickup/drop-off review was created for order {order} .
{summary ?
{summary}
: null}
: null}
- {lines.length > 0 ? lines.map(({ item, piece }) => (
+ {lines.length > 0 ? lines.map(({ item, piece, firstImage }) => (
- {getDisplayMediaPaths(piece)[0] ? : No image
}
+ {firstImage ? : No image
}
{piece.title}
{piece.subtitle}
diff --git a/site/app/shop/page.tsx b/site/app/shop/page.tsx
index 96ab1f5..3883be7 100644
--- a/site/app/shop/page.tsx
+++ b/site/app/shop/page.tsx
@@ -3,11 +3,11 @@ import Image from "next/image";
import Link from "next/link";
import { connection } from "next/server";
import { addToCartAction } from "@/lib/actions";
-import { getDisplayMediaPaths, getFulfillmentOptions, getFulfillmentSummary, getPiecePublicPriceLabel, pieceAllowsInquiry, pieceCanEnterCart, pieceShippingEnabled } from "@/lib/catalog";
+import { getDisplayMediaPaths, getFulfillmentOptions, getFulfillmentSummary, getPiecePublicPriceDisplay, pieceAllowsInquiry, pieceCanEnterCart, pieceShippingEnabled } from "@/lib/catalog";
import { PageIntro, PageSection, Shell } from "@/components/site-chrome";
import { inlineEditAttrs } from "@/components/inline-editable";
import { getPage, listPieces } from "@/lib/db";
-import { formatLeadTime, toMediaUrl } from "@/lib/format";
+import { formatLeadTime, formatMoney, toMediaUrl } from "@/lib/format";
export const metadata: Metadata = {
title: "Shop",
@@ -40,7 +40,12 @@ export default async function ShopPage() {
const fulfillment = getFulfillmentOptions(piece);
const shippingEnabled = pieceShippingEnabled(piece);
const canAddToCart = pieceCanEnterCart(piece);
- const priceLabel = getPiecePublicPriceLabel(piece);
+ const priceDisplay = getPiecePublicPriceDisplay(piece);
+ const priceLabel = priceDisplay.kind === "fixed"
+ ? formatMoney(priceDisplay.cents)
+ : priceDisplay.kind === "label"
+ ? priceDisplay.label
+ : "Not publicly listed";
const canAsk = pieceAllowsInquiry(piece);
return (
@@ -51,7 +56,7 @@ export default async function ShopPage() {
{piece.title}
{piece.summary}
-
Asking price {priceLabel ?? "Not publicly listed"}
+
Asking price {priceLabel}
Lead time {formatLeadTime(piece.leadTimeDays)}
Fulfillment {fulfillment.join(" / ")}
Shipping {shippingEnabled ? "Enabled for this piece" : "Disabled by default"}
diff --git a/site/components/category-icon.tsx b/site/components/category-icon.tsx
index 4ebb6c7..8a97236 100644
--- a/site/components/category-icon.tsx
+++ b/site/components/category-icon.tsx
@@ -1,5 +1,5 @@
import type { SVGProps } from "react";
-import { normalizeBuiltinCategoryIcon, type BuiltinCategoryIconName } from "@/lib/category-icons";
+import { categoryIconAccessibility, normalizeBuiltinCategoryIcon, type BuiltinCategoryIconName } from "@/lib/category-icons";
import type { PieceCategoryDefinition } from "@/lib/categories";
type IconProps = SVGProps & {
@@ -28,12 +28,13 @@ function BuiltinGeometry({ name }: { name: BuiltinCategoryIconName }) {
}
export function CategoryIcon({ name, category, label, className = "", ...props }: IconProps) {
+ const accessibility = categoryIconAccessibility(label);
if (category?.iconType === "custom" && category.customIconSvg) {
- return ;
+ return ;
}
const iconName = normalizeBuiltinCategoryIcon(category?.iconName ?? name);
return (
-
+
);
diff --git a/site/components/theme-toggle.tsx b/site/components/theme-toggle.tsx
index f888a26..d4ea822 100644
--- a/site/components/theme-toggle.tsx
+++ b/site/components/theme-toggle.tsx
@@ -1,40 +1,74 @@
"use client";
-import { useEffect, useSyncExternalStore } from "react";
-
-type ThemeMode = "light" | "dark";
-
-function applyTheme(nextTheme: ThemeMode) {
- document.documentElement.dataset.theme = nextTheme;
- document.cookie = `beaman-theme=${nextTheme}; path=/; max-age=31536000; samesite=lax`;
- window.localStorage.setItem("beaman-theme", nextTheme);
- window.dispatchEvent(new Event("beaman-theme-change"));
-}
+import { useEffect, useSyncExternalStore } from "react";
+import {
+ applyExternalThemeSnapshot,
+ normalizeThemeMode,
+ publishTheme,
+ syncThemePresentation,
+ THEME_CHANGE_EVENT,
+ THEME_STORAGE_KEY,
+ type ThemeMode,
+ type ThemePresentationTarget
+} from "@/lib/theme-store";
+
+function presentationTarget(): ThemePresentationTarget {
+ return {
+ setDocumentTheme: (theme) => {
+ document.documentElement.dataset.theme = theme;
+ },
+ setCookie: (value) => {
+ document.cookie = value;
+ }
+ };
+}
+
+function applyTheme(nextTheme: ThemeMode) {
+ publishTheme({
+ ...presentationTarget(),
+ setStoredTheme: (key, theme) => {
+ try {
+ window.localStorage.setItem(key, theme);
+ } catch {
+ // The cookie remains the durable fallback when browser storage is unavailable.
+ }
+ },
+ notify: (eventName) => window.dispatchEvent(new Event(eventName))
+ }, nextTheme);
+}
function getThemeSnapshot(): ThemeMode {
if (typeof window === "undefined") {
return "dark";
}
- const stored = window.localStorage.getItem("beaman-theme");
- if (stored === "light" || stored === "dark") return stored;
+ let stored: ThemeMode | null = null;
+ try {
+ stored = normalizeThemeMode(window.localStorage.getItem(THEME_STORAGE_KEY));
+ } catch {
+ // Fall back to the server-selected document theme.
+ }
+ if (stored) return stored;
return document.documentElement.dataset.theme === "light" ? "light" : "dark";
}
-
-function subscribeTheme(listener: () => void) {
- window.addEventListener("storage", listener);
- window.addEventListener("beaman-theme-change", listener);
- return () => {
- window.removeEventListener("storage", listener);
- window.removeEventListener("beaman-theme-change", listener);
- };
-}
+
+function subscribeTheme(listener: () => void) {
+ const storageListener = (event: StorageEvent) => {
+ if (applyExternalThemeSnapshot(presentationTarget(), event.key, event.newValue)) listener();
+ };
+ window.addEventListener("storage", storageListener);
+ window.addEventListener(THEME_CHANGE_EVENT, listener);
+ return () => {
+ window.removeEventListener("storage", storageListener);
+ window.removeEventListener(THEME_CHANGE_EVENT, listener);
+ };
+}
export function ThemeToggle({ initialTheme = "dark" }: { initialTheme?: ThemeMode }) {
const theme = useSyncExternalStore(subscribeTheme, getThemeSnapshot, () => initialTheme);
useEffect(() => {
- document.documentElement.dataset.theme = theme;
+ syncThemePresentation(presentationTarget(), theme);
}, [theme]);
const isDark = theme === "dark";
diff --git a/site/lib/catalog.ts b/site/lib/catalog.ts
index 122929f..955bd78 100644
--- a/site/lib/catalog.ts
+++ b/site/lib/catalog.ts
@@ -2,6 +2,7 @@ import { listPieceMediaLinks, type PieceRecord } from "@/lib/db";
import {
getPieceInquiryMode,
getPiecePriceMode,
+ getPiecePublicPriceDisplay,
getPiecePublicPriceLabel,
getPieceReviewsMode,
pieceAcceptsReviews,
@@ -42,6 +43,7 @@ export function hasVerifiedMedia(piece: Pick {
@@ -10,6 +10,11 @@ test("legacy and expanded built-in category icon names normalize safely", () =>
assert.equal(normalizeBuiltinCategoryIcon("not-an-icon"), "object");
});
+test("category icons are decorative unless an accessible label is supplied", () => {
+ assert.deepEqual(categoryIconAccessibility(), { "aria-hidden": true });
+ assert.deepEqual(categoryIconAccessibility(" Tables "), { "aria-label": "Tables", role: "img" });
+});
+
test("safe custom category SVG is reduced to the supported shape vocabulary", () => {
const output = sanitizeCategoryIconSvg(`
diff --git a/site/lib/category-icons.ts b/site/lib/category-icons.ts
index 48e64bb..18fe116 100644
--- a/site/lib/category-icons.ts
+++ b/site/lib/category-icons.ts
@@ -25,6 +25,13 @@ export type BuiltinCategoryIconDefinition = {
keywords: string[];
};
+export function categoryIconAccessibility(label?: string) {
+ const accessibleLabel = label?.trim();
+ return accessibleLabel
+ ? { "aria-label": accessibleLabel, role: "img" as const }
+ : { "aria-hidden": true as const };
+}
+
export const BUILTIN_CATEGORY_ICONS: BuiltinCategoryIconDefinition[] = [
{ name: "all", label: "All pieces", keywords: ["all", "collection", "grid"] },
{ name: "table", label: "Table", keywords: ["table", "dining", "pastry"] },
diff --git a/site/lib/piece-model.test.mts b/site/lib/piece-model.test.mts
index 0a980d5..746eb23 100644
--- a/site/lib/piece-model.test.mts
+++ b/site/lib/piece-model.test.mts
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
import {
getPieceInquiryMode,
getPiecePriceMode,
+ getPiecePublicPriceDisplay,
getPiecePublicPriceLabel,
getPieceReviewsMode,
pieceAcceptsReviews,
@@ -46,6 +47,10 @@ test("all pricing modes resolve without interpreting sentinels as money", () =>
assert.equal(getPiecePriceMode(piece({ status: "inventory", priceCents: -1, priceMode: null })), "contact-for-price");
assert.equal(getPiecePublicPriceLabel(piece({ priceMode: "contact-for-price" })), "Contact for price");
assert.equal(getPiecePublicPriceLabel(piece({ priceMode: "not-listed" })), null);
+ assert.deepEqual(getPiecePublicPriceDisplay(piece({ priceMode: "fixed", inventoryCount: 0 })), { kind: "fixed", cents: 125_000 });
+ assert.deepEqual(getPiecePublicPriceDisplay(piece({ priceMode: "fixed", priceCents: 0 })), { kind: "unlisted" });
+ assert.deepEqual(getPiecePublicPriceDisplay(piece({ priceMode: "determined-after-approval" })), { kind: "label", label: "Pricing follows design approval" });
+ assert.deepEqual(getPiecePublicPriceDisplay(piece({ priceMode: "fixed", publicPriceLabel: "Price on written quote" })), { kind: "label", label: "Price on written quote" });
});
test("inquiry and review modes are independent from legacy status", () => {
diff --git a/site/lib/piece-model.ts b/site/lib/piece-model.ts
index 19bc79a..07bc5f9 100644
--- a/site/lib/piece-model.ts
+++ b/site/lib/piece-model.ts
@@ -38,6 +38,11 @@ export type PiecePolicySource = {
metadata?: Record;
};
+export type PiecePublicPriceDisplay =
+ | { kind: "fixed"; cents: number }
+ | { kind: "label"; label: string }
+ | { kind: "unlisted" };
+
function oneOf(value: unknown, values: T): value is T[number] {
return typeof value === "string" && (values as readonly string[]).includes(value);
}
@@ -92,15 +97,23 @@ export function getPieceReviewsMode(piece: PiecePolicySource): ReviewsMode {
return normalizeReviewsMode(explicit, inferLegacyReviewsMode(piece));
}
-export function getPiecePublicPriceLabel(piece: PiecePolicySource): string | null {
+export function getPiecePublicPriceDisplay(piece: PiecePolicySource): PiecePublicPriceDisplay {
const override = String(piece.publicPriceLabel ?? piece.metadata?.publicPriceLabel ?? "").trim();
- if (override) return override;
+ if (override) return { kind: "label", label: override };
const mode = getPiecePriceMode(piece);
- if (mode === "contact-for-price") return "Contact for price";
- if (mode === "determined-after-approval") return "Pricing follows design approval";
- if (mode === "determined-at-order-completion") return "Final price determined at completion";
- return null;
+ if (mode === "fixed" && typeof piece.priceCents === "number" && piece.priceCents > 0) {
+ return { kind: "fixed", cents: piece.priceCents };
+ }
+ if (mode === "contact-for-price") return { kind: "label", label: "Contact for price" };
+ if (mode === "determined-after-approval") return { kind: "label", label: "Pricing follows design approval" };
+ if (mode === "determined-at-order-completion") return { kind: "label", label: "Final price determined at completion" };
+ return { kind: "unlisted" };
+}
+
+export function getPiecePublicPriceLabel(piece: PiecePolicySource): string | null {
+ const display = getPiecePublicPriceDisplay(piece);
+ return display.kind === "label" ? display.label : null;
}
export function pieceCanEnterCart(piece: PiecePolicySource): boolean {
diff --git a/site/lib/theme-store.test.mts b/site/lib/theme-store.test.mts
new file mode 100644
index 0000000..ad94262
--- /dev/null
+++ b/site/lib/theme-store.test.mts
@@ -0,0 +1,48 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ applyExternalThemeSnapshot,
+ publishTheme,
+ THEME_CHANGE_EVENT,
+ THEME_STORAGE_KEY,
+ type ThemeMode
+} from "./theme-store.ts";
+
+function target() {
+ const calls = {
+ documentThemes: [] as ThemeMode[],
+ cookies: [] as string[],
+ storage: [] as Array<[string, ThemeMode]>,
+ notifications: [] as string[]
+ };
+ return {
+ calls,
+ value: {
+ setDocumentTheme: (theme: ThemeMode) => calls.documentThemes.push(theme),
+ setCookie: (cookie: string) => calls.cookies.push(cookie),
+ setStoredTheme: (key: string, theme: ThemeMode) => calls.storage.push([key, theme]),
+ notify: (eventName: string) => calls.notifications.push(eventName)
+ }
+ };
+}
+
+test("publishing a theme synchronizes presentation, storage, and same-tab listeners", () => {
+ const runtime = target();
+ publishTheme(runtime.value, "light");
+ assert.deepEqual(runtime.calls.documentThemes, ["light"]);
+ assert.match(runtime.calls.cookies[0], /^beaman-theme=light;/);
+ assert.deepEqual(runtime.calls.storage, [[THEME_STORAGE_KEY, "light"]]);
+ assert.deepEqual(runtime.calls.notifications, [THEME_CHANGE_EVENT]);
+});
+
+test("external storage snapshots update presentation without notification loops", () => {
+ const runtime = target();
+ assert.equal(applyExternalThemeSnapshot(runtime.value, THEME_STORAGE_KEY, "dark"), true);
+ assert.deepEqual(runtime.calls.documentThemes, ["dark"]);
+ assert.match(runtime.calls.cookies[0], /^beaman-theme=dark;/);
+ assert.deepEqual(runtime.calls.storage, []);
+ assert.deepEqual(runtime.calls.notifications, []);
+ assert.equal(applyExternalThemeSnapshot(runtime.value, "unrelated", "light"), false);
+ assert.equal(applyExternalThemeSnapshot(runtime.value, THEME_STORAGE_KEY, "invalid"), false);
+});
diff --git a/site/lib/theme-store.ts b/site/lib/theme-store.ts
new file mode 100644
index 0000000..4ff36b6
--- /dev/null
+++ b/site/lib/theme-store.ts
@@ -0,0 +1,45 @@
+export const THEME_STORAGE_KEY = "beaman-theme";
+export const THEME_CHANGE_EVENT = "beaman-theme-change";
+
+export type ThemeMode = "light" | "dark";
+
+export type ThemePresentationTarget = {
+ setDocumentTheme: (theme: ThemeMode) => void;
+ setCookie: (value: string) => void;
+};
+
+export type ThemePublishTarget = ThemePresentationTarget & {
+ setStoredTheme: (key: string, theme: ThemeMode) => void;
+ notify: (eventName: string) => void;
+};
+
+export function normalizeThemeMode(value: unknown): ThemeMode | null {
+ return value === "light" || value === "dark" ? value : null;
+}
+
+export function themeCookie(theme: ThemeMode) {
+ return `beaman-theme=${theme}; path=/; max-age=31536000; samesite=lax`;
+}
+
+export function syncThemePresentation(target: ThemePresentationTarget, theme: ThemeMode) {
+ target.setDocumentTheme(theme);
+ target.setCookie(themeCookie(theme));
+}
+
+export function publishTheme(target: ThemePublishTarget, theme: ThemeMode) {
+ syncThemePresentation(target, theme);
+ target.setStoredTheme(THEME_STORAGE_KEY, theme);
+ target.notify(THEME_CHANGE_EVENT);
+}
+
+export function applyExternalThemeSnapshot(
+ target: ThemePresentationTarget,
+ key: string | null,
+ value: unknown
+) {
+ if (key !== THEME_STORAGE_KEY) return false;
+ const theme = normalizeThemeMode(value);
+ if (!theme) return false;
+ syncThemePresentation(target, theme);
+ return true;
+}
diff --git a/site/package.json b/site/package.json
index 705c42d..323ad60 100644
--- a/site/package.json
+++ b/site/package.json
@@ -8,7 +8,7 @@
"start": "node --experimental-sqlite .next/standalone/server.js",
"lint": "eslint app components lib",
"typecheck": "tsc --noEmit",
- "test": "node --experimental-strip-types --test lib/media-scoring.test.mts lib/piece-model.test.mts lib/database-migrations.test.mts lib/media-reference-transaction.test.mts lib/media-batch-transaction.test.mts lib/category-icons.test.mts lib/site-structure.test.mts lib/piece-media.test.mts lib/estimator.test.mts lib/visual-audit-security.test.mts lib/inline-edit-registry.test.mts lib/commission-workflow.test.mts lib/ui-behavior.test.mts lib/media-http.test.mts lib/media-access.test.mts lib/request-security.test.mts scripts/safe-build.test.mjs"
+ "test": "node --experimental-strip-types --test lib/media-scoring.test.mts lib/piece-model.test.mts lib/database-migrations.test.mts lib/media-reference-transaction.test.mts lib/media-batch-transaction.test.mts lib/category-icons.test.mts lib/site-structure.test.mts lib/piece-media.test.mts lib/estimator.test.mts lib/visual-audit-security.test.mts lib/inline-edit-registry.test.mts lib/commission-workflow.test.mts lib/ui-behavior.test.mts lib/media-http.test.mts lib/media-access.test.mts lib/request-security.test.mts lib/theme-store.test.mts scripts/safe-build.test.mjs"
},
"dependencies": {
"@react-three/drei": "^10.7.7",
From 6a1004cf36e886be6b987e5667b2d8aab001b8d7 Mon Sep 17 00:00:00 2001
From: Cooper Beaman <55124795+lowestprime@users.noreply.github.com>
Date: Tue, 14 Jul 2026 02:00:38 -0700
Subject: [PATCH 14/43] fix(app): harden drafts and standalone output
---
site/lib/commission-workflow.test.mts | 1 +
site/lib/db.ts | 8 +++++++-
site/next.config.ts | 8 +++++++-
site/scripts/safe-build-lib.mjs | 7 +++++--
site/scripts/safe-build.test.mjs | 21 ++++++++++++++++++++-
5 files changed, 40 insertions(+), 5 deletions(-)
diff --git a/site/lib/commission-workflow.test.mts b/site/lib/commission-workflow.test.mts
index ead3a84..7d44528 100644
--- a/site/lib/commission-workflow.test.mts
+++ b/site/lib/commission-workflow.test.mts
@@ -32,6 +32,7 @@ test("commission drafts, idempotency, capabilities, and render ownership persist
expectedUpdatedAt: draft.updatedAt
});
assert.equal(updated.currentStep, 3);
+ assert.ok(Date.parse(updated.updatedAt) > Date.parse(draft.updatedAt));
assert.throws(() => db.saveCommissionDraftForUser({
id: draft.id,
userEmail: "buyer@example.com",
diff --git a/site/lib/db.ts b/site/lib/db.ts
index 49e1f17..d2bde47 100644
--- a/site/lib/db.ts
+++ b/site/lib/db.ts
@@ -435,6 +435,11 @@ function nowIso() {
return new Date().toISOString();
}
+function isoAfter(previous: string) {
+ const previousTime = Date.parse(previous);
+ return new Date(Math.max(Date.now(), Number.isFinite(previousTime) ? previousTime + 1 : 0)).toISOString();
+}
+
export function withDatabaseTransaction(work: (db: DatabaseSync) => T): T {
const db = getDatabase();
const depth = transactionDepth;
@@ -3373,19 +3378,20 @@ export function saveCommissionDraftForUser(input: {
if (Buffer.byteLength(payload, "utf8") > 256_000) throw new Error("Commission draft data exceeds the 256 KB limit.");
return withDatabaseTransaction((db) => {
- const timestamp = nowIso();
const expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30).toISOString();
const id = input.id?.trim() || randomUUID();
const existing = getCommissionDraftForUser(id, email);
if (existing) {
if (existing.status !== "draft") throw new Error("Only active drafts can be updated.");
if (input.expectedUpdatedAt && existing.updatedAt !== input.expectedUpdatedAt) throw new Error("Commission draft changed in another session.");
+ const timestamp = isoAfter(existing.updatedAt);
db.prepare(`
UPDATE commission_drafts
SET payload_json = ?, current_step = ?, idempotency_hash = ?, expires_at = ?, updated_at = ?
WHERE id = ? AND user_email = ?
`).run(payload, currentStep, hashOpaqueValue(idempotencyKey), expiresAt, timestamp, id, email);
} else {
+ const timestamp = nowIso();
db.prepare(`
INSERT INTO commission_drafts (
id, user_email, payload_json, current_step, status, idempotency_hash,
diff --git a/site/next.config.ts b/site/next.config.ts
index 371ffeb..12b73a8 100644
--- a/site/next.config.ts
+++ b/site/next.config.ts
@@ -6,7 +6,13 @@ const nextConfig: NextConfig = {
qualities: [75, 86, 88]
},
outputFileTracingExcludes: {
- "/*": ["./data/**/*"]
+ "/*": [
+ "./data/**/*",
+ "./lib/**/*.test.mts",
+ "./lib/**/*.test.ts",
+ "./lib/**/*.test.tsx",
+ "./scripts/**/*.test.mjs"
+ ]
},
async headers() {
return [
diff --git a/site/scripts/safe-build-lib.mjs b/site/scripts/safe-build-lib.mjs
index d70dbc0..31e4415 100644
--- a/site/scripts/safe-build-lib.mjs
+++ b/site/scripts/safe-build-lib.mjs
@@ -18,7 +18,10 @@ export function collectForbiddenRuntimeFiles(projectRoot, directory, output = []
collectForbiddenRuntimeFiles(projectRoot, absolutePath, output);
continue;
}
- if (/(?:\.sqlite|\.db)(?:-(?:wal|shm))?$|\.(?:sqlite|db)-(?:wal|shm)$|\.(?:bak|backup)$/i.test(entry.name)) {
+ if (
+ /(?:\.sqlite|\.db)(?:-(?:wal|shm))?$|\.(?:sqlite|db)-(?:wal|shm)$|\.(?:bak|backup)$/i.test(entry.name) ||
+ /\.(?:test|spec)\.(?:[cm]?[jt]sx?)$/i.test(entry.name)
+ ) {
output.push(path.relative(projectRoot, absolutePath));
}
}
@@ -50,7 +53,7 @@ export function runSafeBuild({ projectRoot = process.cwd(), temporaryParent = tm
const forbidden = collectForbiddenRuntimeFiles(projectRoot, standaloneRoot);
if (forbidden.length > 0) {
- throw new SafeBuildError(`Standalone output contains runtime database or backup files:\n${forbidden.map((value) => `- ${value}`).join("\n")}`);
+ throw new SafeBuildError(`Standalone output contains runtime state or build-only test files:\n${forbidden.map((value) => `- ${value}`).join("\n")}`);
}
return { standaloneRoot };
} finally {
diff --git a/site/scripts/safe-build.test.mjs b/site/scripts/safe-build.test.mjs
index 60d3731..62231c8 100644
--- a/site/scripts/safe-build.test.mjs
+++ b/site/scripts/safe-build.test.mjs
@@ -51,7 +51,26 @@ test("safe build rejects bundled runtime state and still removes its temporary r
writeFileSync(path.join(outputData, "leak.sqlite"), "forbidden");
return { status: 0 };
}
- }), /runtime database or backup files/);
+ }), /runtime state or build-only test files/);
+ assertNoBuildRoots(input.temporaryParent);
+ } finally {
+ rmSync(input.root, { recursive: true, force: true });
+ }
+});
+
+test("safe build rejects test sources from standalone output and cleans up", () => {
+ const input = fixture();
+ try {
+ assert.throws(() => runSafeBuild({
+ projectRoot: input.projectRoot,
+ temporaryParent: input.temporaryParent,
+ spawnBuild() {
+ const outputLib = path.join(input.projectRoot, ".next", "standalone", "lib");
+ mkdirSync(outputLib, { recursive: true });
+ writeFileSync(path.join(outputLib, "runtime.test.mts"), "forbidden");
+ return { status: 0 };
+ }
+ }), /runtime state or build-only test files/);
assertNoBuildRoots(input.temporaryParent);
} finally {
rmSync(input.root, { recursive: true, force: true });
From 3f61d4ad349f0273e80104f399964858fa8c8a1a Mon Sep 17 00:00:00 2001
From: Cooper Beaman <55124795+lowestprime@users.noreply.github.com>
Date: Tue, 14 Jul 2026 02:06:53 -0700
Subject: [PATCH 15/43] fix(qa): harden visual archive capture and reporting
---
PLANS.md | 4 +-
README.md | 2 +-
admin.md | 2 +-
docs/visual-archive.md | 14 +-
synology-nas-deploy.md | 4 +-
visual-audit/Dockerfile | 8 +-
visual-audit/package-lock.json | 215 ++++++++++++++++++++++++-
visual-audit/package.json | 2 +
visual-audit/src/diagnostics.test.ts | 84 ++++++++++
visual-audit/src/diagnostics.ts | 78 +++++++++
visual-audit/src/inventory.ts | 57 +++++--
visual-audit/src/pdf-atlas.test.ts | 107 +++++++++++++
visual-audit/src/pdf-atlas.ts | 219 ++++++++++++++++++++++++++
visual-audit/src/policy.test.ts | 12 +-
visual-audit/src/policy.ts | 9 ++
visual-audit/src/report.ts | 103 +++++++-----
visual-audit/src/run.ts | 145 ++++++++++++++---
visual-audit/src/skip-link.test.ts | 25 +++
visual-audit/src/skip-link.ts | 19 +++
visual-audit/src/util.test.ts | 32 ++++
visual-audit/src/util.ts | 23 +++
visual-audit/src/validate.ts | 27 ++--
woodsmith_DeepWiki_Merged_03222026.md | 4 +-
23 files changed, 1097 insertions(+), 98 deletions(-)
create mode 100644 visual-audit/src/diagnostics.test.ts
create mode 100644 visual-audit/src/diagnostics.ts
create mode 100644 visual-audit/src/pdf-atlas.test.ts
create mode 100644 visual-audit/src/pdf-atlas.ts
create mode 100644 visual-audit/src/skip-link.test.ts
create mode 100644 visual-audit/src/skip-link.ts
create mode 100644 visual-audit/src/util.test.ts
diff --git a/PLANS.md b/PLANS.md
index 0ebe20e..80cc69a 100644
--- a/PLANS.md
+++ b/PLANS.md
@@ -11,10 +11,10 @@
| Audit and category design | DONE / COMMITTED | `30c87f6` records the evidence audit; `959a2ca` adds managed visual category icons and tests. |
| Structured Studio and public content | DONE / COMMITTED | `8b2a39b` adds structured footer/home services, typed public pricing/inquiry/review behavior, visual media selection, compact Page/Piece/Process editing, media roles, and public build records. |
| Conceptual proportional 3D preview | DONE / COMMITTED | `9e143da` adds route-local R3F templates, view/camera controls, exact dimensions, deterministic SVG/text fallbacks, demand rendering, and estimator tests. |
-| Deterministic visual archive | IMPLEMENTED / LOCAL DOCKER VALIDATED; NAS RUNTIME VALIDATION PENDING | The QA slice adds protected bounded inventory, strict live-readonly and isolated snapshot-lab modes, pinned Playwright Docker, route/link reconciliation, required high-resolution profiles, overlapping page/nested-scroll tiles with seam checks, deep UI state capture, restricted/redacted HTML/PDF, checksums, diffs, locks, and retention. Local tests, typecheck, lint, safe standalone build, all Compose validation, a disposable-data smoke archive, a `linux/amd64` image build, image-filesystem inspection, and loopback snapshot-lab application smoke pass. NAS backup/candidate/full archive/rollback evidence remains required before deployment. |
+| Deterministic visual archive | IMPLEMENTED / LOCAL DOCKER VALIDATED; NAS RUNTIME VALIDATION PENDING | The QA slice adds protected bounded inventory, exact same-origin token attachment, evidence-based aborted/blocked-request classification, keyboard skip-link focus/activation, strict live-readonly and isolated snapshot-lab modes, pinned Playwright Docker, route/link reconciliation, high-resolution overlapping tiles with seam checks, deep UI state capture, searchable HTML, bounded streamed PDFKit atlases, checksums, diffs, locks, and retention. The corrected disposable live-readonly smoke validated 310 captures across 27 routes, 54 skip-link states, 842 checksummed artifacts, 29 blocked and zero successful unsafe requests, zero unexpected diagnostics, zero cross-origin traffic, and opaque names for all 61 shareable image assets. NAS backup/candidate/full archive/rollback evidence remains required before deployment. |
| Typed atomic inline editing | DONE | Added a typed field registry spanning scalar, list, link, relation, media, status, and structural settings fields; trusted-origin enforcement; complete-batch validation; one SQLite transaction; optimistic expected-value conflicts; admin audit records; reset, rollback, keyboard, focus, and one-step Undo behavior. Thirty-two application tests, typecheck, lint, production build, and a disposable authenticated Chromium save/undo/reset/conflict/origin smoke pass. Final-candidate archive evidence remains a release gate. |
| Secure resumable custom requests | DONE / LOCAL DOCKER VALIDATED | Added the ten-step autosaved workflow, verified-account server drafts, idempotent server-authoritative submission, hashed submission/render quotas, private staged attachments with rollback, owner-bound generated previews, opaque project-access cookies, POST lookup without URL email, and exact dimension/category continuity into R3F. Additive schema v5, seed v6, 36 application tests, production build, image safety inspection, and disposable browser/container submission evidence pass. |
-| Safe standalone and image build | DONE / LOCAL DOCKER VALIDATED | `safe-build` uses disposable build roots, excludes runtime data from Next tracing, rejects database/backup files, and proves cleanup for child-failure, forbidden-output, and success paths. Docker Desktop Engine 29.6.1 and BuildKit 0.31.1 built/loaded `woodsmith:local-gate`; the image contains no runtime DB, env/key, private evidence, audit output, or production media and starts with an empty data directory. |
+| Safe standalone and image build | DONE / LOCAL DOCKER VALIDATED | `safe-build` uses disposable build roots, excludes runtime data and test sources from Next tracing, rejects databases/backups/test files, and proves cleanup for child-failure, forbidden-output, and success paths. Docker Desktop Engine 29.6.1 and BuildKit 0.31.1 built/loaded exact candidate `woodsmith:candidate-6a1004c`; the image contains no runtime DB, env/key, test source, private evidence, audit output, or production media and starts with an empty data directory. |
| Transactional media organization | DONE / LOCAL BROWSER VALIDATED | Additive schema v6 records batch/item snapshots; selected media can be moved, deterministically renamed, tagged, rated, assigned, and given normalized role/stage/visibility metadata in one compensated operation. Optimistic rollback refuses later edits, filesystem failures reverse earlier moves, legacy and normalized references stay synchronized, and optional cleanup writes unpublished source-linked derivatives only. Forty application tests plus disposable desktop/mobile browser apply/rollback and SQLite `quick_check` evidence pass. |
| Accessibility, responsive UI, and public media performance | DONE / LOCAL BROWSER VALIDATED | Added skip navigation, route-aware current navigation, high-contrast focus treatment, focus-safe auto-hide header behavior, modal focus containment/restoration, keyboard/touch bounded lightbox pan and zoom, announced carousel position, 24px target protection, and hydration-safe persistent themes. Public portfolio/shop/carousel thumbnails now use responsive Next image requests while the full-screen viewer retains the source file; raw media supports ETag/Last-Modified revalidation. Disposable browser QA passed at 1440, 390, and 320px in both themes with zero horizontal overflow, zero unnamed/unlabelled controls, no duplicate IDs/heading skips/missing alt text, and tested base/muted contrast above AA thresholds. |
| Remaining product work | PENDING | Final documentation review, full visual-archive evidence, release, NAS backup/restore, rollback, candidate deployment, and production verification remain active. |
diff --git a/README.md b/README.md
index c5a03fe..fd38c97 100644
--- a/README.md
+++ b/README.md
@@ -31,7 +31,7 @@ Woodsmith is a self-hosted Next.js application for the Beaman Woodworks company
- Programmatic Beaman Woodworks favicon and brand mark
- Safe profile administration for renaming accounts, replacing legacy developer emails, and deleting non-current users from the dashboard
- Compact auto-hide navigation that keeps the public and dashboard workspaces usable on narrow and desktop viewports
-- A pinned two-mode Playwright visual archive with protected source/database/link inventory, read-only production capture, isolated snapshot-lab states, overlapping high-resolution tiles, HTML/PDF reports, checksums, and baseline comparisons
+- A pinned two-mode Playwright visual archive with protected source/database/link inventory, evidence-based network diagnostics, keyboard skip-link states, read-only production capture, isolated snapshot-lab states, overlapping high-resolution tiles, searchable HTML, streamed bookmarked PDF atlases, checksums, and baseline comparisons
## 📃 Production Notes
diff --git a/admin.md b/admin.md
index 67e2239..a09e769 100644
--- a/admin.md
+++ b/admin.md
@@ -134,7 +134,7 @@ The preview uses a dynamically loaded React Three Fiber scene with perspective/o
## Private visual archive
-The visual archive is an operational QA tool, not a Studio content panel. It inventories public and authenticated routes, captures the final rendered interfaces, and produces restricted PNG/HTML/PDF evidence. Production capture is read-only at both browser and server layers. Save, upload, rename, delete, invoice, shipping, email, and model actions are captured only against an isolated SQLite/media clone.
+The visual archive is an operational QA tool, not a Studio content panel. It inventories public and authenticated routes, captures the final rendered interfaces, verifies keyboard skip navigation, and produces restricted PNG/HTML/PDF evidence. Production capture is read-only at both browser and server layers. Save, upload, rename, delete, invoice, shipping, email, and model actions are captured only against an isolated SQLite/media clone. Read-only mutation failures are accepted only when they match an exact client policy record; aborted resources are accepted only when they carry same-origin Next.js RSC/prefetch evidence.
Use [`docs/visual-archive.md`](docs/visual-archive.md) for secret preparation, smoke/full runs, snapshot-lab setup, validation, retention, and post-deployment gates. Never upload the restricted archive or its authentication state to public CI or Git.
diff --git a/docs/visual-archive.md b/docs/visual-archive.md
index 84d95ed..a3bd8d7 100644
--- a/docs/visual-archive.md
+++ b/docs/visual-archive.md
@@ -1,6 +1,6 @@
# Deterministic Visual Archive
-The visual archive is a private, repository-integrated Playwright system for rendered-browser QA and long-term evidence. It uses pinned Playwright 1.61.0 and Sharp 0.35.3, records the deployed commit, and reconciles routes from source files, the protected SQLite inventory, and rendered same-origin links.
+The visual archive is a private, repository-integrated Playwright system for rendered-browser QA and long-term evidence. It uses pinned Playwright 1.61.0, Sharp 0.35.3, and PDFKit 0.19.1, records the deployed commit, and reconciles routes from source files, the protected SQLite inventory, and rendered same-origin links.
Generated archives are runtime artifacts. They must remain outside Git and private unless the redacted edition has been reviewed for sharing.
@@ -11,7 +11,9 @@ The two modes are intentionally separate:
- `live-readonly` authenticates to the deployed site, then blocks every unsafe same-origin and cross-origin request in Playwright. Same-origin requests also carry `x-woodsmith-audit-readonly: 1`, which makes `site/proxy.ts` reject unsafe methods with HTTP 409. The one Studio login POST is the explicit authentication exception.
- `snapshot-lab` runs against a verified SQLite `VACUUM INTO` clone and a reflink or full-copy media tree. Its Docker network is internal, provider keys are empty, paid/local model providers are disabled, and the production data/media paths are rejected.
-The inventory endpoint requires both normal admin authentication and a separate audit token. It returns bounded route-driving metadata and counts, not users, customer contact details, notification bodies, payment data, reset tokens, or session state. The runner adds the audit token only to the same-origin inventory endpoint and authenticated `audit=all` Studio pages.
+The inventory endpoint requires both normal admin authentication and a separate audit token. It returns bounded route-driving metadata and counts, not users, customer contact details, notification bodies, payment data, reset tokens, or session state. Inventory acquisition permits only the exact same-origin GET endpoint. The ordinary capture context adds the audit token only to that endpoint and authenticated `audit=all` Studio pages.
+
+Network diagnostics are evidence-based. A blocked mutation is expected only when its unsafe method and exact URL match a route-guard policy record. An aborted request is expected only when it is a same-origin fetch/XHR with Next.js RSC or prefetch evidence. Aborted documents, scripts, stylesheets, images, media, API calls, inventory calls, and unrelated resources remain validation failures.
Secrets, storage state, SQLite files, media, raw tiles, PNGs, HTML, PDFs, traces, and reports are ignored by Git. Authentication state is held under the runner tmpfs and removed in `finally`.
@@ -109,6 +111,7 @@ The smoke is accepted only when:
- the inventory endpoint is hidden without the token and denies a token-only unauthenticated request;
- a read-only unsafe request returns HTTP 409;
- the manifest reports zero successful unsafe requests;
+- every rendered route has focused and activated skip-link evidence, and activation transfers focus to `#main-content`;
- capture, comparison, report, and validation use the same run ID;
- `validation.json` reports `passed: true`.
@@ -134,7 +137,7 @@ Full mode covers dark and light themes at:
- 430 x 932, 390 x 844, 375 x 812, and 320 x 720 mobile profiles at DPR 3;
- 2560 x 1440 archival desktop at DPR 2.
-Deep archival-dark capture opens disclosures, lightboxes, media pickers, inline editing, Studio editors, media inspectors, validation states, visualizer boundaries, and the element atlas. Long pages and independently scrollable surfaces use 12 percent overlapping raw tiles, stitched PNGs, tile manifests, and image-correlation seam checks.
+Every route receives a deterministic keyboard skip-link focus and activation check. Deep archival-dark capture opens disclosures, lightboxes, media pickers, inline editing, Studio editors, media inspectors, validation states, visualizer boundaries, and the element atlas. Long pages and independently scrollable surfaces use 12 percent overlapping raw tiles, stitched PNGs, tile manifests, and image-correlation seam checks.
## Snapshot Lab
@@ -174,9 +177,9 @@ visual-audits//
shareable/woodmat-visual-atlas-redacted.pdf
```
-Raw tiles and tile manifests live beside their stitched capture. The HTML report is searchable and includes linked contents. The PDF uses selectable text, Chromium outlines/bookmarks, and bounded image slices so tall pages remain readable. The PNG and raw-tile archive remains the highest-resolution source of truth.
+Raw tiles and tile manifests live beside their stitched capture. The HTML report is searchable and includes linked contents. PDFKit streams one bounded image slice per A3 page, with selectable route/state metadata and native capture bookmarks, so report generation does not decode the entire atlas in one browser page. Report reruns clear only their generated report/shareable directories and atomically replace both PDFs. The PNG and raw-tile archive remains the highest-resolution source of truth.
-The shareable edition includes anonymous, nonsensitive captures only. Review it before moving it outside the restricted NAS archive.
+The shareable edition includes anonymous, nonsensitive captures only. Its copied image assets use opaque sequence names and preserve only a safe image extension; captions and PDF labels do not reuse source filenames. Review it before moving it outside the restricted NAS archive.
## Validation Contract
@@ -184,6 +187,7 @@ The shareable edition includes anonymous, nonsensitive captures only. Review it
- incomplete or truncated inventory;
- missing route/theme/viewport combinations;
+- missing focused or activated skip-link evidence for any rendered route;
- discovered same-origin links not captured;
- missing deep states that were present in the rendered surface inventory;
- unexpected status codes, redirects, console/page errors, request failures, broken media, or horizontal overflow;
diff --git a/synology-nas-deploy.md b/synology-nas-deploy.md
index 752227a..5a0f404 100644
--- a/synology-nas-deploy.md
+++ b/synology-nas-deploy.md
@@ -289,7 +289,7 @@ export AUDIT_SCOPE=smoke
visual-audit/scripts/run-live-audit.sh
```
-The same run ID is used by capture, baseline comparison, report generation, and validation. Do not use `docker compose config --environment`; the Synology Compose build does not support it. The secret preparation script is noninteractive and zsh-safe, reads the active Studio password without displaying it, and writes ignored mode-600 files.
+The same run ID is used by capture, baseline comparison, report generation, and validation. Report generation writes searchable HTML and streams bounded A3 PDF pages with native bookmarks instead of loading the entire image atlas into Chromium. Do not use `docker compose config --environment`; the Synology Compose build does not support it. The secret preparation script is noninteractive and zsh-safe, reads the active Studio password without displaying it, and writes ignored mode-600 files.
Mutation-dependent success/error states require `visual-audit/scripts/prepare-snapshot-lab.sh` followed by `visual-audit/scripts/run-snapshot-lab.sh`. The lab uses a `VACUUM INTO` database clone, reflink/full-copy media, verified run markers, an internal Docker network, and disabled external integrations. It never mounts production data or media read-write.
@@ -307,7 +307,7 @@ A SQLite backup without the matching media tree is no longer sufficient for full
Create the paired backup before a large batch reorganization. The database operation ledger can reverse application-managed changes, but it is not a replacement for a filesystem snapshot when files are changed outside the application or storage fails mid-operation.
-The Docker context excludes SQLite databases, WAL/SHM files, backups, and media-AI caches. In addition, `site/scripts/safe-build.mjs` forces every Next build to use disposable temporary data/media roots and rejects standalone output containing a database, WAL/SHM, or backup file. Runtime state is never copied into an image layer; the image creates an empty `/app/site/data` directory that is populated only by the writable production bind mount. Seed upgrades are non-destructive for existing Studio-edited records, so rebuilds should preserve page/settings edits when the same mounted database is active.
+The Docker context excludes SQLite databases, WAL/SHM files, backups, and media-AI caches. In addition, `site/scripts/safe-build.mjs` forces every Next build to use disposable temporary data/media roots and rejects standalone output containing a database, WAL/SHM, backup, or test/spec source file. Runtime state and build-only tests are never copied into an image layer; the image creates an empty `/app/site/data` directory that is populated only by the writable production bind mount. Seed upgrades are non-destructive for existing Studio-edited records, so rebuilds should preserve page/settings edits when the same mounted database is active.
## Current deployment caveats
diff --git a/visual-audit/Dockerfile b/visual-audit/Dockerfile
index b52be8e..cbcd1e5 100644
--- a/visual-audit/Dockerfile
+++ b/visual-audit/Dockerfile
@@ -8,9 +8,13 @@ ENV HOME=/tmp
COPY visual-audit/package.json ./package.json
COPY visual-audit/package-lock.json ./package-lock.json
-RUN npm ci
+RUN npm ci --include=dev
COPY visual-audit/ ./
-RUN npm run build
+RUN npm run build \
+ && npm prune --omit=dev
+
+# Keep source-route inventory available when Docker Desktop cannot bind an SMB checkout.
+COPY site/app /workspace/site/app
ENTRYPOINT ["node", "dist/run.js"]
diff --git a/visual-audit/package-lock.json b/visual-audit/package-lock.json
index 161a50f..0b178d7 100644
--- a/visual-audit/package-lock.json
+++ b/visual-audit/package-lock.json
@@ -9,11 +9,13 @@
"version": "1.0.0",
"license": "ISC",
"dependencies": {
+ "pdfkit": "0.19.1",
"playwright": "1.61.0",
"sharp": "0.35.3"
},
"devDependencies": {
"@types/node": "22.15.0",
+ "@types/pdfkit": "0.17.6",
"typescript": "5.8.3"
}
},
@@ -527,6 +529,39 @@
"url": "https://opencollective.com/libvips"
}
},
+ "node_modules/@noble/ciphers": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
+ "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
+ "license": "MIT",
+ "engines": {
+ "node": "^14.21.3 || >=16"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@noble/hashes": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
+ "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
+ "license": "MIT",
+ "engines": {
+ "node": "^14.21.3 || >=16"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@swc/helpers": {
+ "version": "0.5.23",
+ "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
+ "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.8.0"
+ }
+ },
"node_modules/@types/node": {
"version": "22.15.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.0.tgz",
@@ -537,6 +572,63 @@
"undici-types": "~6.21.0"
}
},
+ "node_modules/@types/pdfkit": {
+ "version": "0.17.6",
+ "resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.6.tgz",
+ "integrity": "sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/brotli": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
+ "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.1.2"
+ }
+ },
+ "node_modules/browserify-zlib": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz",
+ "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==",
+ "license": "MIT",
+ "dependencies": {
+ "pako": "~1.0.5"
+ }
+ },
+ "node_modules/clone": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
+ "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -546,6 +638,35 @@
"node": ">=8"
}
},
+ "node_modules/dfa": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
+ "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
+ "license": "MIT"
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "license": "MIT"
+ },
+ "node_modules/fontkit": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
+ "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
+ "license": "MIT",
+ "dependencies": {
+ "@swc/helpers": "^0.5.12",
+ "brotli": "^1.3.2",
+ "clone": "^2.1.2",
+ "dfa": "^1.2.0",
+ "fast-deep-equal": "^3.1.3",
+ "restructure": "^3.0.0",
+ "tiny-inflate": "^1.0.3",
+ "unicode-properties": "^1.4.0",
+ "unicode-trie": "^2.0.0"
+ }
+ },
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
@@ -560,6 +681,51 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
+ "node_modules/js-md5": {
+ "version": "0.8.3",
+ "resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz",
+ "integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==",
+ "license": "MIT"
+ },
+ "node_modules/linebreak": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
+ "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "0.0.8",
+ "unicode-trie": "^2.0.0"
+ }
+ },
+ "node_modules/linebreak/node_modules/base64-js": {
+ "version": "0.0.8",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
+ "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/pako": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
+ "license": "(MIT AND Zlib)"
+ },
+ "node_modules/pdfkit": {
+ "version": "0.19.1",
+ "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.19.1.tgz",
+ "integrity": "sha512-6Gzk+wDwTs4VSxsR5rCMTnIl5nlmkye1oWB0l2hDB1EX6ZNSIBroKQEv+2+fPPn+stVjyqzmsqRJVDfB9fo5DA==",
+ "license": "MIT",
+ "dependencies": {
+ "@noble/ciphers": "^1.0.0",
+ "@noble/hashes": "^1.6.0",
+ "fontkit": "^2.0.4",
+ "js-md5": "^0.8.3",
+ "linebreak": "^1.1.0",
+ "png-js": "^1.1.0"
+ }
+ },
"node_modules/playwright": {
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz",
@@ -590,6 +756,20 @@
"node": ">=18"
}
},
+ "node_modules/png-js": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz",
+ "integrity": "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==",
+ "dependencies": {
+ "browserify-zlib": "^0.2.0"
+ }
+ },
+ "node_modules/restructure": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
+ "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
+ "license": "MIT"
+ },
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
@@ -651,12 +831,17 @@
}
}
},
+ "node_modules/tiny-inflate": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
+ "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
+ "license": "MIT"
+ },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
- "license": "0BSD",
- "optional": true
+ "license": "0BSD"
},
"node_modules/typescript": {
"version": "5.8.3",
@@ -678,6 +863,32 @@
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
+ },
+ "node_modules/unicode-properties": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
+ "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.3.0",
+ "unicode-trie": "^2.0.0"
+ }
+ },
+ "node_modules/unicode-trie": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
+ "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "pako": "^0.2.5",
+ "tiny-inflate": "^1.0.0"
+ }
+ },
+ "node_modules/unicode-trie/node_modules/pako": {
+ "version": "0.2.9",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
+ "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
+ "license": "MIT"
}
}
}
diff --git a/visual-audit/package.json b/visual-audit/package.json
index 61a9ff6..cd7cc6c 100644
--- a/visual-audit/package.json
+++ b/visual-audit/package.json
@@ -17,11 +17,13 @@
"type": "module",
"private": true,
"dependencies": {
+ "pdfkit": "0.19.1",
"playwright": "1.61.0",
"sharp": "0.35.3"
},
"devDependencies": {
"@types/node": "22.15.0",
+ "@types/pdfkit": "0.17.6",
"typescript": "5.8.3"
}
}
diff --git a/visual-audit/src/diagnostics.test.ts b/visual-audit/src/diagnostics.test.ts
new file mode 100644
index 0000000..8e7786c
--- /dev/null
+++ b/visual-audit/src/diagnostics.test.ts
@@ -0,0 +1,84 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ isExpectedNextPrefetchAbort,
+ isExpectedReadonlyBlockedConsole,
+ isExpectedReadonlyMutationBlock,
+ isKnownExpectedDiagnostic,
+ requestBlockKey
+} from "./diagnostics.js";
+
+const baseFailure = {
+ method: "GET",
+ url: "https://woodmat.ch/portfolio?_rsc=abc123",
+ failure: "net::ERR_ABORTED",
+ resourceType: "fetch",
+ headers: {},
+ baseUrl: "https://woodmat.ch"
+};
+
+test("only evidenced same-origin Next RSC or prefetch cancellations are expected", () => {
+ assert.equal(isExpectedNextPrefetchAbort(baseFailure), true);
+ assert.equal(isExpectedNextPrefetchAbort({ ...baseFailure, url: "https://woodmat.ch/shop", headers: { "next-router-prefetch": "1" } }), true);
+ assert.equal(isExpectedNextPrefetchAbort({ ...baseFailure, url: "https://woodmat.ch/shop", headers: { purpose: "prefetch" } }), true);
+ assert.equal(isExpectedNextPrefetchAbort({ ...baseFailure, url: "https://other.example/shop?_rsc=abc123" }), false);
+ assert.equal(isExpectedNextPrefetchAbort({ ...baseFailure, resourceType: "document" }), false);
+ assert.equal(isExpectedNextPrefetchAbort({ ...baseFailure, resourceType: "script" }), false);
+ assert.equal(isExpectedNextPrefetchAbort({ ...baseFailure, resourceType: "image" }), false);
+ assert.equal(isExpectedNextPrefetchAbort({ ...baseFailure, url: "https://woodmat.ch/api/visual-audit/inventory?_rsc=abc123" }), false);
+ assert.equal(isExpectedNextPrefetchAbort({ ...baseFailure, url: "https://woodmat.ch/shop", headers: {} }), false);
+});
+
+test("read-only blocked failures require an unsafe request and an exact policy record", () => {
+ const url = "https://woodmat.ch/api/contact";
+ const blockedRequests = new Set([requestBlockKey("POST", url)]);
+ const input = {
+ targetMode: "live-readonly",
+ method: "POST",
+ url,
+ failure: "net::ERR_BLOCKED_BY_CLIENT",
+ blockedRequests
+ };
+
+ assert.equal(isExpectedReadonlyMutationBlock(input), true);
+ assert.equal(isExpectedReadonlyMutationBlock({ ...input, method: "GET" }), false);
+ assert.equal(isExpectedReadonlyMutationBlock({ ...input, url: "https://woodmat.ch/media/required.jpg" }), false);
+ assert.equal(isExpectedReadonlyMutationBlock({ ...input, targetMode: "snapshot-lab" }), false);
+ assert.equal(isExpectedReadonlyMutationBlock({ ...input, blockedRequests: new Set() }), false);
+});
+
+test("blocked console noise is expected only after a route-local read-only policy decision", () => {
+ const input = {
+ targetMode: "live-readonly",
+ text: "Failed to load resource: net::ERR_BLOCKED_BY_CLIENT",
+ blockedRequestCount: 1
+ };
+ assert.equal(isExpectedReadonlyBlockedConsole(input), true);
+ assert.equal(isExpectedReadonlyBlockedConsole({ ...input, blockedRequestCount: 0 }), false);
+ assert.equal(isExpectedReadonlyBlockedConsole({ ...input, targetMode: "snapshot-lab" }), false);
+ assert.equal(isExpectedReadonlyBlockedConsole({ ...input, text: "Failed to load required image" }), false);
+});
+
+test("validator exceptions remain narrow and never hide arbitrary API failures", () => {
+ assert.equal(isKnownExpectedDiagnostic({
+ message: "GET https://woodmat.ch/api/visits - net::ERR_FAILED",
+ type: "requestfailed",
+ route: "/"
+ }), false);
+ assert.equal(isKnownExpectedDiagnostic({
+ message: "404 GET https://woodmat.ch/__visual-audit-route-not-found__",
+ type: "http-error",
+ route: "/__visual-audit-route-not-found__"
+ }), true);
+ assert.equal(isKnownExpectedDiagnostic({
+ message: "THREE.THREE.Clock: This module has been deprecated. Please use THREE.Timer instead.",
+ type: "console",
+ route: "/commissions"
+ }), true);
+ assert.equal(isKnownExpectedDiagnostic({
+ message: "Failed to load required image",
+ type: "console",
+ route: "/portfolio/pastry-table"
+ }), false);
+});
diff --git a/visual-audit/src/diagnostics.ts b/visual-audit/src/diagnostics.ts
new file mode 100644
index 0000000..139e3da
--- /dev/null
+++ b/visual-audit/src/diagnostics.ts
@@ -0,0 +1,78 @@
+import { isSameOrigin, isUnsafeMethod } from "./policy.js";
+
+export type RequestFailureEvidence = {
+ method: string;
+ url: string;
+ failure: string;
+ resourceType: string;
+ headers: Record;
+ baseUrl: string;
+};
+
+export function requestBlockKey(method: string, url: string) {
+ return `${method.toUpperCase()} ${url}`;
+}
+
+export function isExpectedNextPrefetchAbort(evidence: RequestFailureEvidence) {
+ const method = evidence.method.toUpperCase();
+ if (!evidence.failure.includes("ERR_ABORTED") || !["GET", "HEAD"].includes(method)) return false;
+ if (!["fetch", "xhr"].includes(evidence.resourceType)) return false;
+ if (!isSameOrigin(evidence.url, evidence.baseUrl)) return false;
+
+ const requestUrl = new URL(evidence.url);
+ if (
+ requestUrl.pathname.startsWith("/api/") ||
+ requestUrl.pathname.startsWith("/media/") ||
+ requestUrl.pathname.startsWith("/_next/static/")
+ ) {
+ return false;
+ }
+
+ const headers = Object.fromEntries(
+ Object.entries(evidence.headers).map(([name, value]) => [name.toLowerCase(), value.toLowerCase()])
+ );
+ return requestUrl.searchParams.has("_rsc") ||
+ headers.rsc === "1" ||
+ headers["next-router-prefetch"] === "1" ||
+ headers.purpose?.includes("prefetch") === true ||
+ headers["sec-purpose"]?.includes("prefetch") === true;
+}
+
+export function isExpectedReadonlyMutationBlock(input: {
+ targetMode: string;
+ method: string;
+ url: string;
+ failure: string;
+ blockedRequests: ReadonlySet;
+}) {
+ return input.targetMode === "live-readonly" &&
+ input.failure.includes("ERR_BLOCKED_BY_CLIENT") &&
+ isUnsafeMethod(input.method) &&
+ input.blockedRequests.has(requestBlockKey(input.method, input.url));
+}
+
+export function isExpectedReadonlyBlockedConsole(input: {
+ targetMode: string;
+ text: string;
+ blockedRequestCount: number;
+}) {
+ return input.targetMode === "live-readonly" &&
+ input.text.includes("ERR_BLOCKED_BY_CLIENT") &&
+ input.blockedRequestCount > 0;
+}
+
+export function isKnownExpectedDiagnostic(input: {
+ message: string;
+ type: string;
+ route: string;
+}) {
+ if (
+ input.route.includes("__visual-audit-route-not-found__") &&
+ input.type === "http-error" &&
+ input.message.startsWith("404 ")
+ ) {
+ return true;
+ }
+
+ return /THREE\.THREE\.Clock: This module has been deprecated/.test(input.message);
+}
diff --git a/visual-audit/src/inventory.ts b/visual-audit/src/inventory.ts
index 32bf32d..a944117 100644
--- a/visual-audit/src/inventory.ts
+++ b/visual-audit/src/inventory.ts
@@ -2,11 +2,11 @@ import fs from "node:fs/promises";
import path from "node:path";
import {
- request,
type Browser
} from "playwright";
import { config } from "./config.js";
+import { inventoryRequestEligible } from "./policy.js";
import type { Inventory } from "./types.js";
import { unique } from "./util.js";
@@ -98,31 +98,62 @@ export async function fetchInventory(
browser: Browser,
storageStatePath: string
) {
- void browser;
-
- const context = await request.newContext({
+ const endpoint = new URL(
+ "/api/visual-audit/inventory",
+ config.baseUrl
+ );
+ const context = await browser.newContext({
baseURL: config.baseUrl,
storageState: storageStatePath,
- extraHTTPHeaders: {
- "x-woodsmith-audit-token":
- config.auditToken
- }
+ serviceWorkers: "block"
});
try {
- const response = await context.get(
- "/api/visual-audit/inventory"
+ // A browser navigation preserves the production Secure session cookie on
+ // trusted HTTP loopback. Every request except this exact endpoint is denied.
+ await context.route("**/*", async route => {
+ const request = route.request();
+ const requestUrl = new URL(request.url());
+
+ if (inventoryRequestEligible(request.method(), requestUrl, config.baseUrl)) {
+ await route.continue({
+ headers: {
+ ...request.headers(),
+ "x-woodsmith-audit-token":
+ config.auditToken
+ }
+ });
+ return;
+ }
+
+ await route.abort("blockedbyclient");
+ });
+
+ const page = await context.newPage();
+ const response = await page.goto(
+ endpoint.toString(),
+ {
+ waitUntil: "domcontentloaded",
+ timeout: 45_000
+ }
);
+ const body = await page.locator("body").innerText();
+
+ if (!response) {
+ throw new Error(
+ "Inventory endpoint did not return a response."
+ );
+ }
if (!response.ok()) {
throw new Error(
- `Inventory endpoint returned HTTP ${response.status()}: ${await response.text()}`
+ `Inventory endpoint returned HTTP ${response.status()}: ${body}`
);
}
- return await response.json() as Inventory;
+ return JSON.parse(body) as Inventory;
} finally {
- await context.dispose();
+ await context.close();
}
}
diff --git a/visual-audit/src/pdf-atlas.test.ts b/visual-audit/src/pdf-atlas.test.ts
new file mode 100644
index 0000000..7cee1fa
--- /dev/null
+++ b/visual-audit/src/pdf-atlas.test.ts
@@ -0,0 +1,107 @@
+import assert from "node:assert/strict";
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import test from "node:test";
+
+import sharp from "sharp";
+
+import { createPdfAtlas, type PdfAtlasPage } from "./pdf-atlas.js";
+
+test("PDF atlas streams one bookmarked selectable page per image slice", async () => {
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "woodsmith-pdf-atlas-"));
+ const outputFile = path.join(root, "atlas.pdf");
+ const pages: PdfAtlasPage[] = [];
+
+ try {
+ for (let index = 0; index < 3; index += 1) {
+ const imageFile = path.join(root, `slice-${index + 1}.jpg`);
+ await sharp({
+ create: {
+ width: 320,
+ height: 180,
+ channels: 3,
+ background: index === 0 ? "#111111" : index === 1 ? "#d8c6a0" : "#f4efe4"
+ }
+ }).jpeg().toFile(imageFile);
+ pages.push({
+ imageFile,
+ captureKey: index < 2 ? "capture-a" : "capture-b",
+ route: index < 2 ? "/portfolio/test-piece" : "/shop",
+ state: index < 2 ? "canonical" : "cart-empty",
+ auth: "anonymous",
+ theme: "dark",
+ viewport: "desktop-1440",
+ status: "200",
+ assetLabel: `Capture image ${index + 1}`,
+ sliceIndex: index < 2 ? index + 1 : 1,
+ sliceCount: index < 2 ? 2 : 1
+ });
+ }
+
+ await createPdfAtlas({
+ outputFile,
+ title: "Woodmat Visual Atlas",
+ edition: "Shareable redacted edition",
+ runId: "test-run",
+ mode: "live-readonly",
+ commit: "0123456789abcdef",
+ createdAt: "2026-07-13T12:00:00.000Z",
+ captureCount: 2,
+ routeCount: 2,
+ unexpectedDiagnostics: 0,
+ redacted: true,
+ pages
+ });
+
+ const data = await fs.readFile(outputFile);
+ const pdf = data.toString("latin1");
+ assert.equal(data.subarray(0, 5).toString("ascii"), "%PDF-");
+ assert.equal((pdf.match(/\/Type\s*\/Page\b/g) ?? []).length, 4);
+ assert.match(pdf, /\/Outlines\b/);
+ assert.match(pdf, /BT\b/);
+ assert.match(pdf, /Woodmat Visual Atlas/);
+ assert.doesNotMatch(pdf, /customer-secret@example\.com/);
+ assert.equal((await fs.readdir(root)).some((file) => file.includes(".tmp-")), false);
+ } finally {
+ await fs.rm(root, { recursive: true, force: true });
+ }
+});
+
+test("PDF atlas removes a partial temporary file when image assembly fails", async () => {
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "woodsmith-pdf-atlas-failure-"));
+ const outputFile = path.join(root, "atlas.pdf");
+
+ try {
+ await assert.rejects(() => createPdfAtlas({
+ outputFile,
+ title: "Woodmat Visual Atlas",
+ edition: "Restricted edition",
+ runId: "test-failure",
+ mode: "snapshot-lab",
+ commit: "unknown",
+ createdAt: "invalid-date",
+ captureCount: 1,
+ routeCount: 1,
+ unexpectedDiagnostics: 0,
+ redacted: false,
+ pages: [{
+ imageFile: path.join(root, "missing.jpg"),
+ captureKey: "missing",
+ route: "/missing",
+ state: "canonical",
+ auth: "admin",
+ theme: "light",
+ viewport: "desktop-1440",
+ status: "404",
+ assetLabel: "missing.jpg",
+ sliceIndex: 1,
+ sliceCount: 1
+ }]
+ }));
+
+ assert.deepEqual(await fs.readdir(root), []);
+ } finally {
+ await fs.rm(root, { recursive: true, force: true });
+ }
+});
diff --git a/visual-audit/src/pdf-atlas.ts b/visual-audit/src/pdf-atlas.ts
new file mode 100644
index 0000000..59c0040
--- /dev/null
+++ b/visual-audit/src/pdf-atlas.ts
@@ -0,0 +1,219 @@
+import { randomUUID } from "node:crypto";
+import { createWriteStream } from "node:fs";
+import fs from "node:fs/promises";
+import path from "node:path";
+import { pipeline } from "node:stream/promises";
+
+import PDFDocument from "pdfkit";
+
+import { ensureDirectory } from "./util.js";
+
+export type PdfAtlasPage = {
+ imageFile: string;
+ captureKey: string;
+ route: string;
+ state: string;
+ auth: string;
+ theme: string;
+ viewport: string;
+ status: string;
+ assetLabel: string;
+ sliceIndex: number;
+ sliceCount: number;
+};
+
+export type PdfAtlasInput = {
+ outputFile: string;
+ title: string;
+ edition: string;
+ runId: string;
+ mode: string;
+ commit: string;
+ createdAt: string;
+ captureCount: number;
+ routeCount: number;
+ unexpectedDiagnostics: number;
+ redacted: boolean;
+ pages: PdfAtlasPage[];
+};
+
+const PAGE_MARGIN = 32;
+const HEADER_HEIGHT = 76;
+const FOOTER_HEIGHT = 22;
+
+function boundedText(value: unknown, maxLength = 280) {
+ return String(value)
+ .replace(/[\u0000-\u001f\u007f]/g, " ")
+ .replace(/\s+/g, " ")
+ .trim()
+ .slice(0, maxLength);
+}
+
+function documentDate(value: string) {
+ const parsed = new Date(value);
+ return Number.isFinite(parsed.getTime()) ? parsed : new Date(0);
+}
+
+function addCover(document: PDFKit.PDFDocument, input: PdfAtlasInput) {
+ document.addPage();
+ document.outline.addItem("Visual atlas summary", { expanded: true });
+ const width = document.page.width;
+ const height = document.page.height;
+
+ document.rect(0, 0, width, height).fill("#f5f0e6");
+ document
+ .fillColor("#17140f")
+ .font("Helvetica-Bold")
+ .fontSize(34)
+ .text(boundedText(input.title, 160), PAGE_MARGIN, 78, { width: width - PAGE_MARGIN * 2 });
+ document
+ .fillColor("#675d4d")
+ .font("Helvetica")
+ .fontSize(13)
+ .text(boundedText(input.edition, 160), PAGE_MARGIN, 126, { width: width - PAGE_MARGIN * 2 });
+
+ const summary = [
+ ["Run", input.runId],
+ ["Mode", input.mode],
+ ["Commit", input.commit],
+ ["Captures", input.captureCount],
+ ["Routes", input.routeCount],
+ ["Image pages", input.pages.length],
+ ["Unexpected diagnostics", input.unexpectedDiagnostics]
+ ];
+ let y = 190;
+ for (const [label, value] of summary) {
+ document
+ .fillColor("#675d4d")
+ .font("Helvetica-Bold")
+ .fontSize(10)
+ .text(boundedText(label, 60).toUpperCase(), PAGE_MARGIN, y, { width: 180, lineBreak: false });
+ document
+ .fillColor("#17140f")
+ .font("Helvetica")
+ .fontSize(11)
+ .text(boundedText(value, 220), PAGE_MARGIN + 190, y, { width: width - PAGE_MARGIN * 2 - 190 });
+ y += 34;
+ }
+
+ if (input.redacted) {
+ document
+ .roundedRect(PAGE_MARGIN, y + 20, width - PAGE_MARGIN * 2, 66, 8)
+ .fill("#fff8ed");
+ document
+ .fillColor("#694325")
+ .font("Helvetica")
+ .fontSize(11)
+ .text(
+ "Shareable redacted edition. Private routes, authenticated captures, account details, source filenames, and customer references are excluded.",
+ PAGE_MARGIN + 18,
+ y + 42,
+ { width: width - PAGE_MARGIN * 2 - 36 }
+ );
+ }
+}
+
+async function addCapturePages(document: PDFKit.PDFDocument, input: PdfAtlasInput) {
+ const capturesOutline = document.outline.addItem("Captures", { expanded: false });
+ let previousCaptureKey = "";
+ const totalPages = input.pages.length + 1;
+
+ for (const [index, page] of input.pages.entries()) {
+ document.addPage();
+ if (page.captureKey !== previousCaptureKey) {
+ capturesOutline.addItem(boundedText(`${page.route} - ${page.state}`, 180));
+ previousCaptureKey = page.captureKey;
+ }
+
+ const width = document.page.width;
+ const height = document.page.height;
+ document.rect(0, 0, width, height).fill("#ffffff");
+ document
+ .fillColor("#17140f")
+ .font("Helvetica-Bold")
+ .fontSize(14)
+ .text(boundedText(page.route, 220), PAGE_MARGIN, 22, { width: width - PAGE_MARGIN * 2, lineBreak: false });
+ document
+ .fillColor("#675d4d")
+ .font("Helvetica")
+ .fontSize(9)
+ .text(
+ boundedText(`${page.state} | ${page.auth} | ${page.theme} | ${page.viewport} | HTTP ${page.status}`, 260),
+ PAGE_MARGIN,
+ 44,
+ { width: width - PAGE_MARGIN * 2, lineBreak: false }
+ );
+ document
+ .fillColor("#675d4d")
+ .fontSize(8)
+ .text(
+ boundedText(`${page.assetLabel} | slice ${page.sliceIndex} of ${page.sliceCount}`, 260),
+ PAGE_MARGIN,
+ 59,
+ { width: width - PAGE_MARGIN * 2, lineBreak: false }
+ );
+
+ const imageY = HEADER_HEIGHT;
+ const imageHeight = height - imageY - FOOTER_HEIGHT - PAGE_MARGIN;
+ const imageWidth = width - PAGE_MARGIN * 2;
+ const image = await fs.readFile(page.imageFile);
+ document.image(image, PAGE_MARGIN, imageY, {
+ fit: [imageWidth, imageHeight],
+ align: "center",
+ valign: "center"
+ });
+ document
+ .lineWidth(0.5)
+ .strokeColor("#c8bda9")
+ .rect(PAGE_MARGIN, imageY, imageWidth, imageHeight)
+ .stroke();
+ document
+ .fillColor("#675d4d")
+ .font("Helvetica")
+ .fontSize(8)
+ .text(`Page ${index + 2} of ${totalPages}`, PAGE_MARGIN, height - 20, {
+ width: imageWidth,
+ align: "right",
+ lineBreak: false
+ });
+ }
+}
+
+export async function createPdfAtlas(input: PdfAtlasInput) {
+ await ensureDirectory(path.dirname(input.outputFile));
+ const temporaryFile = `${input.outputFile}.tmp-${process.pid}-${randomUUID()}`;
+ const createdAt = documentDate(input.createdAt);
+ const document = new PDFDocument({
+ autoFirstPage: false,
+ bufferPages: false,
+ compress: false,
+ size: "A3",
+ layout: "landscape",
+ margin: 0,
+ info: {
+ Title: boundedText(input.title, 160),
+ Author: "Beaman Woodworks",
+ Subject: boundedText(input.edition, 160),
+ Keywords: "Beaman Woodworks, visual QA, browser archive",
+ CreationDate: createdAt,
+ ModDate: createdAt
+ }
+ });
+ const output = createWriteStream(temporaryFile, { flags: "wx", mode: 0o600 });
+ const writing = pipeline(document, output);
+
+ try {
+ addCover(document, input);
+ await addCapturePages(document, input);
+ document.end();
+ await writing;
+ await fs.rename(temporaryFile, input.outputFile);
+ await fs.chmod(input.outputFile, 0o600).catch(() => undefined);
+ } catch (error) {
+ document.destroy(error instanceof Error ? error : new Error(String(error)));
+ output.destroy();
+ await writing.catch(() => undefined);
+ await fs.rm(temporaryFile, { force: true });
+ throw error;
+ }
+}
diff --git a/visual-audit/src/policy.test.ts b/visual-audit/src/policy.test.ts
index 9120855..c997155 100644
--- a/visual-audit/src/policy.test.ts
+++ b/visual-audit/src/policy.test.ts
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
-import { auditTokenEligible, isSameOrigin, isUnsafeMethod } from "./policy.js";
+import { auditTokenEligible, inventoryRequestEligible, isSameOrigin, isUnsafeMethod } from "./policy.js";
test("audit token eligibility is limited to protected same-origin inventory surfaces", () => {
const base = "https://woodmat.ch";
@@ -12,6 +12,16 @@ test("audit token eligibility is limited to protected same-origin inventory surf
assert.equal(auditTokenEligible("https://woodmat.ch/", base), false);
});
+test("inventory browser traffic permits only the exact same-origin GET endpoint", () => {
+ const base = "https://woodmat.ch";
+ assert.equal(inventoryRequestEligible("GET", "https://woodmat.ch/api/visual-audit/inventory", base), true);
+ assert.equal(inventoryRequestEligible("POST", "https://woodmat.ch/api/visual-audit/inventory", base), false);
+ assert.equal(inventoryRequestEligible("GET", "https://woodmat.ch/api/visual-audit/inventory?extra=1", base), false);
+ assert.equal(inventoryRequestEligible("GET", "https://woodmat.ch/api/visual-audit/inventory/extra", base), false);
+ assert.equal(inventoryRequestEligible("GET", "https://woodmat.ch/studio?audit=all", base), false);
+ assert.equal(inventoryRequestEligible("GET", "https://other.example/api/visual-audit/inventory", base), false);
+});
+
test("read-only policy distinguishes safe methods and origins", () => {
assert.equal(isUnsafeMethod("GET"), false);
assert.equal(isUnsafeMethod("options"), false);
diff --git a/visual-audit/src/policy.ts b/visual-audit/src/policy.ts
index 8772cc3..d96e934 100644
--- a/visual-audit/src/policy.ts
+++ b/visual-audit/src/policy.ts
@@ -14,3 +14,12 @@ export function auditTokenEligible(requestUrl: string | URL, baseUrl: string | U
return request.pathname === "/api/visual-audit/inventory" ||
(request.pathname === "/studio" && request.searchParams.get("audit") === "all");
}
+
+export function inventoryRequestEligible(method: string, requestUrl: string | URL, baseUrl: string | URL) {
+ const request = new URL(requestUrl, baseUrl);
+ const endpoint = new URL("/api/visual-audit/inventory", baseUrl);
+ return method.toUpperCase() === "GET" &&
+ request.origin === endpoint.origin &&
+ request.pathname === endpoint.pathname &&
+ request.search === "";
+}
diff --git a/visual-audit/src/report.ts b/visual-audit/src/report.ts
index 6039d02..5da3398 100644
--- a/visual-audit/src/report.ts
+++ b/visual-audit/src/report.ts
@@ -1,14 +1,13 @@
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
-import { pathToFileURL } from "node:url";
-import { chromium } from "playwright";
import sharp from "sharp";
import { config } from "./config.js";
+import { createPdfAtlas, type PdfAtlasPage } from "./pdf-atlas.js";
import type { CaptureRecord, RunManifest } from "./types.js";
-import { ensureDirectory, escapeHtml, exists, safeName, writeJsonAtomic } from "./util.js";
+import { clearDirectoryContents, ensureDirectory, escapeHtml, exists, redactedAssetName, writeJsonAtomic } from "./util.js";
const manifestFile = path.join(config.runRoot, "manifest.json");
@@ -30,7 +29,7 @@ function captureCard(capture: CaptureRecord, imageSources: string[], redacted: b
const fileFigures = imageSources.map((source, index) => `
- ${escapeHtml(capture.files[Math.min(index, capture.files.length - 1)] ?? source)}
+ ${escapeHtml(redacted ? `Capture image ${index + 1}` : capture.files[Math.min(index, capture.files.length - 1)] ?? source)}
`).join("");
return `
${escapeHtml(route)}
@@ -169,36 +168,6 @@ async function createPrintSlices(source: string, outputRoot: string, sequence: n
return outputs;
}
-async function createPdf(htmlFile: string, outputFile: string) {
- const browser = await chromium.launch({
- headless: true,
- chromiumSandbox: false,
- ...(config.browserChannel ? { channel: config.browserChannel } : {})
- });
- try {
- const page = await browser.newPage({ viewport: { width: 1800, height: 1200 } });
- await page.goto(pathToFileURL(htmlFile).href, { waitUntil: "load" });
- await page.emulateMedia({ media: "screen" });
- await page.evaluate(async () => {
- await document.fonts.ready;
- await Promise.all(Array.from(document.images).map((image) => image.decode().catch(() => undefined)));
- });
- await page.pdf({
- path: outputFile,
- format: "A3",
- landscape: true,
- printBackground: true,
- tagged: true,
- outline: true,
- preferCSSPageSize: true,
- margin: { top: "8mm", right: "8mm", bottom: "8mm", left: "8mm" }
- });
- await fs.chmod(outputFile, 0o600).catch(() => undefined);
- } finally {
- await browser.close();
- }
-}
-
async function main() {
const manifest = JSON.parse(await fs.readFile(manifestFile, "utf8")) as RunManifest;
const reportRoot = path.join(config.runRoot, "report");
@@ -206,7 +175,9 @@ async function main() {
const shareableRoot = path.join(config.runRoot, "shareable");
const shareableAssets = path.join(shareableRoot, "assets");
const shareablePrintAssets = path.join(shareableRoot, "print-assets");
- await Promise.all([reportRoot, restrictedPrintAssets, shareableRoot, shareableAssets, shareablePrintAssets].map((directory) => ensureDirectory(directory)));
+ await Promise.all([reportRoot, shareableRoot].map((directory) => ensureDirectory(directory)));
+ await Promise.all([reportRoot, shareableRoot].map((directory) => clearDirectoryContents(directory)));
+ await Promise.all([restrictedPrintAssets, shareableAssets, shareablePrintAssets].map((directory) => ensureDirectory(directory)));
const comparisonFile = path.join(config.runRoot, "comparison.json");
const comparison = await exists(comparisonFile) ? JSON.parse(await fs.readFile(comparisonFile, "utf8")) as unknown : { status: "No approved baseline configured." };
@@ -222,7 +193,7 @@ async function main() {
for (const relativeFile of capture.files) {
const source = path.join(config.runRoot, relativeFile);
shareableAssetIndex += 1;
- const destination = path.join(shareableAssets, `${String(shareableAssetIndex).padStart(7, "0")}-${path.extname(relativeFile) ? path.basename(relativeFile) : `${safeName(capture.state)}.png`}`);
+ const destination = path.join(shareableAssets, redactedAssetName(shareableAssetIndex, relativeFile));
await fs.copyFile(source, destination);
await fs.chmod(destination, 0o600).catch(() => undefined);
sources.push(`assets/${path.basename(destination)}`);
@@ -248,6 +219,8 @@ async function main() {
const restrictedPrintCards: string[] = [];
const shareablePrintCards: string[] = [];
+ const restrictedPdfPages: PdfAtlasPage[] = [];
+ const shareablePdfPages: PdfAtlasPage[] = [];
let restrictedPrintPages = 0;
let shareablePrintPages = 0;
let sequence = 0;
@@ -257,6 +230,19 @@ async function main() {
sequence += 1;
const slices = await createPrintSlices(path.join(config.runRoot, relativeFile), restrictedPrintAssets, sequence);
sources.push(...slices.map((file) => `print-assets/${path.basename(file)}`));
+ restrictedPdfPages.push(...slices.map((imageFile, index) => ({
+ imageFile,
+ captureKey: capture.key,
+ route: capture.route,
+ state: capture.state,
+ auth: capture.auth,
+ theme: capture.theme,
+ viewport: capture.viewport,
+ status: capture.status == null ? "unknown" : String(capture.status),
+ assetLabel: relativeFile,
+ sliceIndex: index + 1,
+ sliceCount: slices.length
+ })));
restrictedPrintPages += slices.length;
}
restrictedPrintCards.push(captureCard(capture, sources, false, false));
@@ -269,6 +255,19 @@ async function main() {
sequence += 1;
const slices = await createPrintSlices(path.join(config.runRoot, relativeFile), shareablePrintAssets, sequence);
sources.push(...slices.map((file) => `print-assets/${path.basename(file)}`));
+ shareablePdfPages.push(...slices.map((imageFile, index) => ({
+ imageFile,
+ captureKey: capture.key,
+ route: redact(capture.route),
+ state: capture.state,
+ auth: capture.auth,
+ theme: capture.theme,
+ viewport: capture.viewport,
+ status: capture.status == null ? "unknown" : String(capture.status),
+ assetLabel: `Capture image ${sequence}`,
+ sliceIndex: index + 1,
+ sliceCount: slices.length
+ })));
shareablePrintPages += slices.length;
}
shareablePrintCards.push(captureCard(capture, sources, true, false));
@@ -279,8 +278,36 @@ async function main() {
await fs.writeFile(restrictedPrintHtml, htmlDocument({ manifest, captures: manifest.captures, cards: restrictedPrintCards.join("\n"), redacted: false, comparison, print: true }), { encoding: "utf8", mode: 0o600 });
await fs.writeFile(shareablePrintHtml, htmlDocument({ manifest, captures: shareableCaptures, cards: shareablePrintCards.join("\n"), redacted: true, comparison: { status: "Comparison details are restricted." }, print: true }), { encoding: "utf8", mode: 0o600 });
- await createPdf(restrictedPrintHtml, path.join(config.runRoot, "woodmat-visual-atlas.pdf"));
- await createPdf(shareablePrintHtml, path.join(shareableRoot, "woodmat-visual-atlas-redacted.pdf"));
+ const unexpectedDiagnostics = manifest.diagnostics.filter((item) => !item.expected).length;
+ const routeCount = new Set(manifest.captures.map((capture) => `${capture.auth}:${capture.route}`)).size;
+ await createPdfAtlas({
+ outputFile: path.join(config.runRoot, "woodmat-visual-atlas.pdf"),
+ title: "Woodmat Visual Atlas",
+ edition: "Beaman Woodworks QA archive - restricted edition",
+ runId: manifest.runId,
+ mode: manifest.mode,
+ commit: manifest.deployedCommit,
+ createdAt: manifest.startedAt,
+ captureCount: manifest.captures.length,
+ routeCount,
+ unexpectedDiagnostics,
+ redacted: false,
+ pages: restrictedPdfPages
+ });
+ await createPdfAtlas({
+ outputFile: path.join(shareableRoot, "woodmat-visual-atlas-redacted.pdf"),
+ title: "Woodmat Visual Atlas",
+ edition: "Beaman Woodworks QA archive - shareable redacted edition",
+ runId: manifest.runId,
+ mode: manifest.mode,
+ commit: manifest.deployedCommit,
+ createdAt: manifest.startedAt,
+ captureCount: shareableCaptures.length,
+ routeCount: new Set(shareableCaptures.map((capture) => capture.route)).size,
+ unexpectedDiagnostics,
+ redacted: true,
+ pages: shareablePdfPages
+ });
await writeJsonAtomic(path.join(reportRoot, "report-index.json"), {
runId: manifest.runId,
restrictedHtml: "report/index.html",
diff --git a/visual-audit/src/run.ts b/visual-audit/src/run.ts
index a437f24..1f35fec 100644
--- a/visual-audit/src/run.ts
+++ b/visual-audit/src/run.ts
@@ -18,6 +18,12 @@ import {
config,
viewports
} from "./config.js";
+import {
+ isExpectedNextPrefetchAbort,
+ isExpectedReadonlyBlockedConsole,
+ isExpectedReadonlyMutationBlock,
+ requestBlockKey
+} from "./diagnostics.js";
import {
buildRoutes,
discoverSourceRoutes,
@@ -25,6 +31,7 @@ import {
} from "./inventory.js";
import { waitForVisualReady } from "./readiness.js";
import { auditTokenEligible, isUnsafeMethod } from "./policy.js";
+import { assertFocusedSkipLink, assertMainFocusTransferred } from "./skip-link.js";
import type {
AuthState,
CaptureRecord,
@@ -36,6 +43,7 @@ import type {
ViewportProfile
} from "./types.js";
import {
+ clearDirectoryContents,
ensureDirectory,
exists,
relativeTo,
@@ -57,6 +65,7 @@ const captureRoot = path.join(
let manifest: RunManifest;
const preAuthenticationDiagnostics: DiagnosticRecord[] = [];
let preAuthenticationUnsafeBlocks = 0;
+const intentionalMutationBlocks = new WeakMap>();
const coverageExclusions = [
{ surface: "Third-party origins", reason: "Recorded as network diagnostics only; the archive never sends credentials or audit tokens cross-origin." },
@@ -174,7 +183,8 @@ async function loadOrCreateManifest(
function attachDiagnostics(
page: Page,
- route: string
+ route: string,
+ blockedRequests: ReadonlySet
) {
page.on("console", message => {
if (
@@ -188,7 +198,13 @@ function attachDiagnostics(
type: "console",
route,
message: text,
- expected: /THREE\.THREE\.Clock: This module has been deprecated/.test(text)
+ expected:
+ /THREE\.THREE\.Clock: This module has been deprecated/.test(text) ||
+ isExpectedReadonlyBlockedConsole({
+ targetMode: config.targetMode,
+ text,
+ blockedRequestCount: blockedRequests.size
+ })
});
}
});
@@ -210,15 +226,19 @@ function attachDiagnostics(
const method =
request.method().toUpperCase();
- if (
- failure.includes("ERR_BLOCKED_BY_CLIENT") &&
- !["GET", "HEAD", "OPTIONS"].includes(method)
- ) {
+ if (isExpectedReadonlyMutationBlock({
+ targetMode: config.targetMode,
+ method,
+ url: request.url(),
+ failure,
+ blockedRequests
+ })) {
manifest.diagnostics.push({
timestamp: now(),
type: "mutation-blocked",
route,
- message: `${method} ${request.url()}`
+ message: `${method} ${request.url()}`,
+ expected: true
});
return;
@@ -228,7 +248,15 @@ function attachDiagnostics(
timestamp: now(),
type: "requestfailed",
route,
- message: `${method} ${request.url()} — ${failure}`
+ message: `${method} ${request.url()} — ${failure}`,
+ expected: isExpectedNextPrefetchAbort({
+ method,
+ url: request.url(),
+ failure,
+ resourceType: request.resourceType(),
+ headers: request.headers(),
+ baseUrl: config.baseUrl
+ })
});
});
@@ -262,7 +290,10 @@ function attachDiagnostics(
route,
message:
`${response.status()} ` +
- `${request.method()} ${response.url()}`
+ `${request.method()} ${response.url()}`,
+ expected:
+ response.headers()["x-woodsmith-audit-blocked"] === "1" &&
+ config.targetMode === "live-readonly"
});
}
});
@@ -452,6 +483,8 @@ async function createCaptureContext(
const targetOrigin =
new URL(config.baseUrl).origin;
+ const blockedRequests = new Set();
+ intentionalMutationBlocks.set(context, blockedRequests);
await context.route(
"**/*",
@@ -476,6 +509,7 @@ async function createCaptureContext(
config.targetMode === "live-readonly" &&
isUnsafeMethod(method)
) {
+ blockedRequests.add(requestBlockKey(method, request.url()));
manifest.diagnostics.push({
timestamp: now(),
type: "mutation-blocked",
@@ -513,6 +547,7 @@ async function createCaptureContext(
if (
isUnsafeMethod(method)
) {
+ blockedRequests.add(requestBlockKey(method, request.url()));
manifest.diagnostics.push({
timestamp: now(),
type: "mutation-blocked",
@@ -822,6 +857,69 @@ async function saveCapture(input: {
await persistManifest();
}
+async function captureSkipLinkStates(input: {
+ page: Page;
+ auth: AuthState;
+ route: string;
+ theme: ThemeMode;
+ profile: ViewportProfile;
+ status: number | null;
+}) {
+ const skipLink = input.page.locator('a.skip-link[href="#main-content"]').first();
+ const mainContent = input.page.locator("#main-content").first();
+ if (await skipLink.count() !== 1 || await mainContent.count() !== 1) {
+ throw new Error("The route is missing the skip link or #main-content target.");
+ }
+
+ await input.page.evaluate(() => {
+ window.scrollTo(0, 0);
+ if (document.activeElement instanceof HTMLElement) document.activeElement.blur();
+ });
+ await input.page.keyboard.press("Tab");
+
+ const focusEvidence = await skipLink.evaluate((element) => {
+ const rect = element.getBoundingClientRect();
+ const style = window.getComputedStyle(element);
+ return {
+ focused: document.activeElement === element,
+ visible:
+ style.display !== "none" &&
+ style.visibility !== "hidden" &&
+ Number.parseFloat(style.opacity || "1") > 0 &&
+ rect.width > 0 &&
+ rect.height > 0,
+ intersectsViewport:
+ rect.right > 0 &&
+ rect.bottom > 0 &&
+ rect.left < window.innerWidth &&
+ rect.top < window.innerHeight,
+ target: element.getAttribute("href") ?? ""
+ };
+ });
+ assertFocusedSkipLink(focusEvidence);
+
+ await saveCapture({
+ ...input,
+ state: "skip-link-focused",
+ locator: skipLink
+ });
+
+ await input.page.keyboard.press("Enter");
+ await input.page.waitForFunction(
+ () => document.activeElement?.id === "main-content",
+ undefined,
+ { timeout: 5_000 }
+ );
+ const activeElementId = await input.page.evaluate(() => document.activeElement?.id ?? null);
+ assertMainFocusTransferred(activeElementId);
+
+ await saveCapture({
+ ...input,
+ state: "skip-link-activated-main-focus",
+ fullPage: false
+ });
+}
+
async function captureHeaderStates(input: {
page: Page;
auth: AuthState;
@@ -1386,7 +1484,7 @@ async function captureElementAtlas(input: {
}) {
const elements =
input.page.locator([
- "a",
+ "a:not(.skip-link)",
"button",
"input",
"textarea",
@@ -1460,7 +1558,11 @@ async function captureRoute(input: {
const page =
await input.context.newPage();
- attachDiagnostics(page, input.route);
+ attachDiagnostics(
+ page,
+ input.route,
+ intentionalMutationBlocks.get(input.context) ?? new Set()
+ );
try {
const response = await page.goto(
@@ -1525,6 +1627,17 @@ async function captureRoute(input: {
fullPage: true
});
+ try {
+ await captureSkipLinkStates(base);
+ } catch (error) {
+ manifest.diagnostics.push({
+ timestamp: now(),
+ type: "coverage",
+ route: input.route,
+ message: `Skip-link capture failed: ${error instanceof Error ? error.message : String(error)}`
+ });
+ }
+
try {
await captureHeaderStates(base);
} catch (error) {
@@ -1945,7 +2058,7 @@ async function main() {
exclusions: coverageExclusions,
requiredStates: [
"route-default", "header-scroll", "theme-light", "theme-dark", "desktop", "tablet", "mobile", "archival-dpr",
- "disclosures", "dialogs", "lightbox-zoom-boundaries", "inline-editing", "media-picker", "media-inspector",
+ "skip-link-focus-and-activation", "disclosures", "dialogs", "lightbox-zoom-boundaries", "inline-editing", "media-picker", "media-inspector",
"nested-scroll-surfaces", "empty-states", "error-states", "snapshot-lab-validation"
],
safety: {
@@ -1989,13 +2102,7 @@ async function main() {
} finally {
await browser.close();
- await fs.rm(
- config.tmpRoot,
- {
- recursive: true,
- force: true
- }
- );
+ await clearDirectoryContents(config.tmpRoot);
}
}
diff --git a/visual-audit/src/skip-link.test.ts b/visual-audit/src/skip-link.test.ts
new file mode 100644
index 0000000..4313c65
--- /dev/null
+++ b/visual-audit/src/skip-link.test.ts
@@ -0,0 +1,25 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { assertFocusedSkipLink, assertMainFocusTransferred } from "./skip-link.js";
+
+const validEvidence = {
+ focused: true,
+ visible: true,
+ intersectsViewport: true,
+ target: "#main-content"
+};
+
+test("skip-link evidence requires keyboard focus, visibility, viewport intersection, and the main target", () => {
+ assert.doesNotThrow(() => assertFocusedSkipLink(validEvidence));
+ assert.throws(() => assertFocusedSkipLink({ ...validEvidence, focused: false }), /did not focus/);
+ assert.throws(() => assertFocusedSkipLink({ ...validEvidence, visible: false }), /not visually rendered/);
+ assert.throws(() => assertFocusedSkipLink({ ...validEvidence, intersectsViewport: false }), /does not intersect/);
+ assert.throws(() => assertFocusedSkipLink({ ...validEvidence, target: "#footer" }), /does not target/);
+});
+
+test("skip-link activation must transfer focus to the main content target", () => {
+ assert.doesNotThrow(() => assertMainFocusTransferred("main-content"));
+ assert.throws(() => assertMainFocusTransferred(null), /did not transfer focus/);
+ assert.throws(() => assertMainFocusTransferred("site-header"), /did not transfer focus/);
+});
diff --git a/visual-audit/src/skip-link.ts b/visual-audit/src/skip-link.ts
new file mode 100644
index 0000000..a7e2e9e
--- /dev/null
+++ b/visual-audit/src/skip-link.ts
@@ -0,0 +1,19 @@
+export type SkipLinkFocusEvidence = {
+ focused: boolean;
+ visible: boolean;
+ intersectsViewport: boolean;
+ target: string;
+};
+
+export function assertFocusedSkipLink(evidence: SkipLinkFocusEvidence) {
+ if (!evidence.focused) throw new Error("Tab did not focus the skip link.");
+ if (!evidence.visible) throw new Error("The focused skip link is not visually rendered.");
+ if (!evidence.intersectsViewport) throw new Error("The focused skip link does not intersect the viewport.");
+ if (evidence.target !== "#main-content") throw new Error("The skip link does not target #main-content.");
+}
+
+export function assertMainFocusTransferred(activeElementId: string | null) {
+ if (activeElementId !== "main-content") {
+ throw new Error("Activating the skip link did not transfer focus to #main-content.");
+ }
+}
diff --git a/visual-audit/src/util.test.ts b/visual-audit/src/util.test.ts
new file mode 100644
index 0000000..e91b25d
--- /dev/null
+++ b/visual-audit/src/util.test.ts
@@ -0,0 +1,32 @@
+import assert from "node:assert/strict";
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import test from "node:test";
+
+import { clearDirectoryContents, exists, redactedAssetName } from "./util.js";
+
+test("redacted asset names preserve only a safe image extension", () => {
+ assert.equal(redactedAssetName(42, "png/private-customer-project-reference.png"), "0000042.png");
+ assert.equal(redactedAssetName(7, "private-name.JPEG"), "0000007.jpeg");
+ assert.equal(redactedAssetName(3, "private-name.svg"), "0000003.png");
+ assert.doesNotMatch(redactedAssetName(42, "png/private-customer-project-reference.png"), /customer|project|reference/);
+});
+
+test("temporary cleanup removes contents without deleting the mounted root", async () => {
+ const parent = await fs.mkdtemp(path.join(os.tmpdir(), "woodsmith-audit-util-"));
+ const root = path.join(parent, "audit-tmp");
+
+ try {
+ await fs.mkdir(path.join(root, "auth"), { recursive: true });
+ await fs.writeFile(path.join(root, "auth", "state.json"), "temporary");
+ await fs.writeFile(path.join(root, "scratch.txt"), "temporary");
+
+ await clearDirectoryContents(root);
+
+ assert.equal(await exists(root), true);
+ assert.deepEqual(await fs.readdir(root), []);
+ } finally {
+ await fs.rm(parent, { recursive: true, force: true });
+ }
+});
diff --git a/visual-audit/src/util.ts b/visual-audit/src/util.ts
index 9df6a26..7a786f3 100644
--- a/visual-audit/src/util.ts
+++ b/visual-audit/src/util.ts
@@ -7,6 +7,20 @@ export async function ensureDirectory(directory: string, mode = 0o700) {
await fs.chmod(directory, mode).catch(() => undefined);
}
+export async function clearDirectoryContents(directory: string) {
+ let entries;
+ try {
+ entries = await fs.readdir(directory, { withFileTypes: true });
+ } catch (error) {
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") return;
+ throw error;
+ }
+
+ await Promise.all(entries.map(entry =>
+ fs.rm(path.join(directory, entry.name), { recursive: true, force: true })
+ ));
+}
+
export async function exists(file: string) {
return fs.access(file).then(() => true).catch(() => false);
}
@@ -23,6 +37,15 @@ export function safeName(value: string) {
.slice(0, 180) || "surface";
}
+export function redactedAssetName(index: number, source: string) {
+ const extension = path.extname(source).toLowerCase();
+ const safeExtension = new Set([".avif", ".gif", ".jpeg", ".jpg", ".png", ".webp"])
+ .has(extension)
+ ? extension
+ : ".png";
+ return `${String(index).padStart(7, "0")}${safeExtension}`;
+}
+
export function relativeTo(root: string, file: string) {
const relative = path.relative(root, file);
if (relative.startsWith("..") || path.isAbsolute(relative)) {
diff --git a/visual-audit/src/validate.ts b/visual-audit/src/validate.ts
index 48bd110..5cab454 100644
--- a/visual-audit/src/validate.ts
+++ b/visual-audit/src/validate.ts
@@ -4,17 +4,10 @@ import path from "node:path";
import sharp from "sharp";
import { config, viewports } from "./config.js";
+import { isKnownExpectedDiagnostic } from "./diagnostics.js";
import type { RunManifest, TileManifest } from "./types.js";
import { exists, listFiles, relativeTo, sha256File, writeJsonAtomic } from "./util.js";
-function expectedDiagnostic(message: string, type: string, route: string) {
- if (type === "mutation-blocked") return true;
- if (message.includes("/api/visits") && type === "requestfailed") return true;
- if (route.includes("__visual-audit-route-not-found__") && type === "http-error" && message.startsWith("404 ")) return true;
- if (/THREE\.THREE\.Clock: This module has been deprecated/.test(message)) return true;
- return false;
-}
-
async function validatePng(file: string, label: string, failures: string[]) {
const image = sharp(file);
const metadata = await image.metadata();
@@ -212,7 +205,18 @@ async function main() {
const missingRoute = route.route.includes("__visual-audit-route-not-found__");
if (route.status == null) failures.push(`Route did not return a response: ${route.auth} ${route.route}`);
else if (route.status >= 400 && !(missingRoute && route.status === 404)) failures.push(`Unexpected HTTP ${route.status}: ${route.auth} ${route.route}`);
- if (!manifest.captures.some((capture) => capture.auth === route.auth && capture.route === route.route)) failures.push(`Route has no successful capture: ${route.auth} ${route.route}`);
+ const routeCaptures = manifest.captures.filter((capture) =>
+ capture.auth === route.auth &&
+ capture.route === route.route &&
+ capture.theme === route.theme &&
+ capture.viewport === route.viewport
+ );
+ if (routeCaptures.length === 0) failures.push(`Route has no successful capture: ${route.auth} ${route.route}`);
+ for (const state of ["skip-link-focused", "skip-link-activated-main-focus"]) {
+ if (!routeCaptures.some((capture) => capture.state === state)) {
+ failures.push(`Route is missing ${state} accessibility evidence: ${route.auth} ${route.route} ${route.theme}/${route.viewport}`);
+ }
+ }
}
const matrixProfiles = config.scope === "smoke" ? ["desktop-1440"] : viewports.map((viewport) => viewport.name);
@@ -265,7 +269,10 @@ async function main() {
if (!manifest.captures.some((capture) => capture.viewport === profile)) failures.push(`Required viewport has no captures: ${profile}`);
}
- const unexpectedDiagnostics = manifest.diagnostics.filter((record) => !record.expected && !expectedDiagnostic(record.message, record.type, record.route));
+ const unexpectedDiagnostics = manifest.diagnostics.filter((record) =>
+ !record.expected &&
+ !isKnownExpectedDiagnostic(record)
+ );
if (config.strictDiagnostics && unexpectedDiagnostics.length > 0) {
failures.push(`${unexpectedDiagnostics.length} unexpected browser/network diagnostic(s) were recorded.`);
}
diff --git a/woodsmith_DeepWiki_Merged_03222026.md b/woodsmith_DeepWiki_Merged_03222026.md
index fd1ec5f..8858fd7 100644
--- a/woodsmith_DeepWiki_Merged_03222026.md
+++ b/woodsmith_DeepWiki_Merged_03222026.md
@@ -133,9 +133,9 @@ Custom work type records still store default dimensions, material options, labor
## Visual archive and rendered QA
-`visual-audit/` is an independent TypeScript package pinned to Playwright 1.61.0 and Sharp 0.35.3. It reconciles source routes, a token-and-admin-protected bounded database inventory, and rendered same-origin links. `live-readonly` blocks unsafe browser requests and adds a server read-only header; `snapshot-lab` uses a verified SQLite/media clone on an internal Docker network with external providers disabled.
+`visual-audit/` is an independent TypeScript package pinned to Playwright 1.61.0, Sharp 0.35.3, and PDFKit 0.19.1. It reconciles source routes, a token-and-admin-protected bounded database inventory, and rendered same-origin links. `live-readonly` blocks unsafe browser requests and adds a server read-only header; `snapshot-lab` uses a verified SQLite/media clone on an internal Docker network with external providers disabled. Diagnostic exceptions require exact mutation-policy or same-origin RSC/prefetch evidence rather than matching generic browser error text.
-The runner captures the required desktop/tablet/mobile/theme matrix plus a 5120 x 2880 archival viewport, deep dialog/disclosure/lightbox/Studio/media/inline-edit/visualizer states, overlapping raw tiles and stitched long surfaces, and an element atlas. It writes restricted and redacted searchable HTML/PDF editions, SHA-256 manifests, route/network/render diagnostics, tile-seam validation, and baseline comparisons. See `docs/visual-archive.md` for the exact safety and operating contract.
+The runner captures the required desktop/tablet/mobile/theme matrix plus a 5120 x 2880 archival viewport, keyboard skip-link focus/activation, deep dialog/disclosure/lightbox/Studio/media/inline-edit/visualizer states, overlapping raw tiles and stitched long surfaces, and an element atlas. It writes restricted and redacted searchable HTML plus streamed bookmarked PDF editions, SHA-256 manifests, route/network/render diagnostics, tile-seam validation, and baseline comparisons. See `docs/visual-archive.md` for the exact safety and operating contract.
## Search
From b95a3478dc35ab76deb829a797ea052ae90f9a1b Mon Sep 17 00:00:00 2001
From: Cooper Beaman <55124795+lowestprime@users.noreply.github.com>
Date: Tue, 14 Jul 2026 07:10:53 -0700
Subject: [PATCH 16/43] fix(qa): prove isolated visual archive modes
---
PLANS.md | 2 +-
README.md | 3 +-
docker-compose.visual-audit-lab.yml | 4 +-
docker-compose.visual-audit-live.yml | 2 +-
docs/visual-archive.md | 15 +
synology-nas-deploy.md | 4 +-
.../scripts/run-local-disposable-smoke.ps1 | 392 ++++++++++++++++++
visual-audit/src/capture.ts | 13 +-
visual-audit/src/diagnostics.test.ts | 31 +-
visual-audit/src/diagnostics.ts | 24 +-
visual-audit/src/policy.test.ts | 11 +-
visual-audit/src/policy.ts | 8 +
visual-audit/src/run.ts | 208 +++++++++-
.../src/snapshot-lab-evidence.test.ts | 49 +++
visual-audit/src/snapshot-lab-evidence.ts | 29 ++
visual-audit/src/tiling.test.ts | 14 +-
visual-audit/src/tiling.ts | 12 +
visual-audit/src/validate.ts | 6 +
18 files changed, 781 insertions(+), 46 deletions(-)
create mode 100644 visual-audit/scripts/run-local-disposable-smoke.ps1
create mode 100644 visual-audit/src/snapshot-lab-evidence.test.ts
create mode 100644 visual-audit/src/snapshot-lab-evidence.ts
diff --git a/PLANS.md b/PLANS.md
index 80cc69a..43b6f72 100644
--- a/PLANS.md
+++ b/PLANS.md
@@ -11,7 +11,7 @@
| Audit and category design | DONE / COMMITTED | `30c87f6` records the evidence audit; `959a2ca` adds managed visual category icons and tests. |
| Structured Studio and public content | DONE / COMMITTED | `8b2a39b` adds structured footer/home services, typed public pricing/inquiry/review behavior, visual media selection, compact Page/Piece/Process editing, media roles, and public build records. |
| Conceptual proportional 3D preview | DONE / COMMITTED | `9e143da` adds route-local R3F templates, view/camera controls, exact dimensions, deterministic SVG/text fallbacks, demand rendering, and estimator tests. |
-| Deterministic visual archive | IMPLEMENTED / LOCAL DOCKER VALIDATED; NAS RUNTIME VALIDATION PENDING | The QA slice adds protected bounded inventory, exact same-origin token attachment, evidence-based aborted/blocked-request classification, keyboard skip-link focus/activation, strict live-readonly and isolated snapshot-lab modes, pinned Playwright Docker, route/link reconciliation, high-resolution overlapping tiles with seam checks, deep UI state capture, searchable HTML, bounded streamed PDFKit atlases, checksums, diffs, locks, and retention. The corrected disposable live-readonly smoke validated 310 captures across 27 routes, 54 skip-link states, 842 checksummed artifacts, 29 blocked and zero successful unsafe requests, zero unexpected diagnostics, zero cross-origin traffic, and opaque names for all 61 shareable image assets. NAS backup/candidate/full archive/rollback evidence remains required before deployment. |
+| Deterministic visual archive | IMPLEMENTED / LOCAL DOCKER VALIDATED; NAS RUNTIME VALIDATION PENDING | The QA slice adds protected bounded inventory, exact same-origin token attachment, evidence-based aborted/blocked-request classification, keyboard skip-link focus/activation, strict live-readonly and isolated snapshot-lab modes, pinned Playwright Docker, route/link reconciliation, high-resolution overlapping tiles with seam checks, deep UI state capture, searchable HTML, bounded streamed PDFKit atlases, checksums, diffs, locks, and retention. The latest disposable live-readonly smoke validated 310 captures across 27 routes, 54 skip-link states, 842 checksummed artifacts, 29 blocked and zero successful unsafe requests, zero unexpected diagnostics, zero cross-origin traffic, and 126 opaque shareable image assets. The clone-only snapshot lab validated 384 captures across 39 routes, exactly one commission-draft save plus cleanup delete, zero residual drafts, SQLite `quick_check`, unchanged source data/media hashes, zero unexpected diagnostics, and full cleanup. NAS backup/candidate/full archive/rollback evidence remains required before deployment. |
| Typed atomic inline editing | DONE | Added a typed field registry spanning scalar, list, link, relation, media, status, and structural settings fields; trusted-origin enforcement; complete-batch validation; one SQLite transaction; optimistic expected-value conflicts; admin audit records; reset, rollback, keyboard, focus, and one-step Undo behavior. Thirty-two application tests, typecheck, lint, production build, and a disposable authenticated Chromium save/undo/reset/conflict/origin smoke pass. Final-candidate archive evidence remains a release gate. |
| Secure resumable custom requests | DONE / LOCAL DOCKER VALIDATED | Added the ten-step autosaved workflow, verified-account server drafts, idempotent server-authoritative submission, hashed submission/render quotas, private staged attachments with rollback, owner-bound generated previews, opaque project-access cookies, POST lookup without URL email, and exact dimension/category continuity into R3F. Additive schema v5, seed v6, 36 application tests, production build, image safety inspection, and disposable browser/container submission evidence pass. |
| Safe standalone and image build | DONE / LOCAL DOCKER VALIDATED | `safe-build` uses disposable build roots, excludes runtime data and test sources from Next tracing, rejects databases/backups/test files, and proves cleanup for child-failure, forbidden-output, and success paths. Docker Desktop Engine 29.6.1 and BuildKit 0.31.1 built/loaded exact candidate `woodsmith:candidate-6a1004c`; the image contains no runtime DB, env/key, test source, private evidence, audit output, or production media and starts with an empty data directory. |
diff --git a/README.md b/README.md
index fd38c97..aebb470 100644
--- a/README.md
+++ b/README.md
@@ -31,7 +31,7 @@ Woodsmith is a self-hosted Next.js application for the Beaman Woodworks company
- Programmatic Beaman Woodworks favicon and brand mark
- Safe profile administration for renaming accounts, replacing legacy developer emails, and deleting non-current users from the dashboard
- Compact auto-hide navigation that keeps the public and dashboard workspaces usable on narrow and desktop viewports
-- A pinned two-mode Playwright visual archive with protected source/database/link inventory, evidence-based network diagnostics, keyboard skip-link states, read-only production capture, isolated snapshot-lab states, overlapping high-resolution tiles, searchable HTML, streamed bookmarked PDF atlases, checksums, and baseline comparisons
+- A pinned two-mode Playwright visual archive with protected source/database/link inventory, evidence-based network diagnostics, keyboard skip-link states, read-only production capture, clone-only bounded mutation states, viewport-correct high-resolution tiles, searchable HTML, streamed bookmarked PDF atlases, checksums, and baseline comparisons
## 📃 Production Notes
@@ -54,6 +54,7 @@ Woodsmith is a self-hosted Next.js application for the Beaman Woodworks company
- `ITC_New_Rennie_Mackintosh_Complete_Family_Pack/`: source font assets for the site typography
- `docker-compose.synology.yml`: Synology runtime model
- `visual-audit/`: pinned Playwright capture, report, comparison, validation, and NAS automation package
+- `visual-audit/scripts/run-local-disposable-smoke.ps1`: exact-image Windows smoke using fake credentials, synthetic media, non-root containers, and automatically removed volumes
- `docs/visual-archive.md`: private live-readonly and snapshot-lab operating guide
- `synology-nas-deploy.md`: deployment and NAS operations guide
- `admin.md`: private Woodshop dashboard manual
diff --git a/docker-compose.visual-audit-lab.yml b/docker-compose.visual-audit-lab.yml
index 95762e4..b640d5a 100644
--- a/docker-compose.visual-audit-lab.yml
+++ b/docker-compose.visual-audit-lab.yml
@@ -45,7 +45,7 @@ services:
- "${AUDIT_LAB_MEDIA_DIR:?AUDIT_LAB_MEDIA_DIR must be set}:/app/pics:rw"
tmpfs:
- /tmp:rw,noexec,nosuid,nodev,size=128m,mode=1777
- - /app/site/.next/cache:rw,noexec,nosuid,nodev,size=128m,mode=700
+ - /app/site/.next/cache:rw,noexec,nosuid,nodev,size=128m,mode=700,uid=${PUID},gid=${PGID}
secrets:
- audit_token
security_opt:
@@ -93,7 +93,7 @@ services:
- ./:/workspace:ro
- /volume2/docker_ssd/woodsmith/visual-audits:/output:rw
tmpfs:
- - /audit-tmp:rw,noexec,nosuid,nodev,size=64m,mode=700
+ - /audit-tmp:rw,noexec,nosuid,nodev,size=64m,mode=700,uid=${PUID},gid=${PGID}
- /tmp:rw,noexec,nosuid,nodev,size=256m,mode=1777
secrets:
- admin_password
diff --git a/docker-compose.visual-audit-live.yml b/docker-compose.visual-audit-live.yml
index 6ddbfe2..0285ae4 100644
--- a/docker-compose.visual-audit-live.yml
+++ b/docker-compose.visual-audit-live.yml
@@ -29,7 +29,7 @@ services:
- ./:/workspace:ro
- /volume2/docker_ssd/woodsmith/visual-audits:/output:rw
tmpfs:
- - /audit-tmp:rw,noexec,nosuid,nodev,size=64m,mode=700
+ - /audit-tmp:rw,noexec,nosuid,nodev,size=64m,mode=700,uid=${PUID},gid=${PGID}
- /tmp:rw,noexec,nosuid,nodev,size=256m,mode=1777
secrets:
- admin_password
diff --git a/docs/visual-archive.md b/docs/visual-archive.md
index a3bd8d7..17959fc 100644
--- a/docs/visual-archive.md
+++ b/docs/visual-archive.md
@@ -117,6 +117,17 @@ The smoke is accepted only when:
Resume an interrupted run by preserving the same `AUDIT_RUN_ID`, `TARGET_COMMIT_SHA`, scope, and output directory.
+On a Windows release workstation, validate exact local app and runner images without mounting repository data or media:
+
+```powershell
+visual-audit/scripts/run-local-disposable-smoke.ps1 `
+ -AppImage woodsmith:candidate- `
+ -AuditImage woodsmith-visual-audit: `
+ -CommitSha
+```
+
+The script uses fake credentials, synthetic media, a network-isolated app, non-root containers, disabled external providers, and uniquely named disposable volumes. It validates the archive, rejects image-cache and unhandled-rejection errors, and removes every container and volume in `finally`.
+
## Full Live Archive
```bash
@@ -153,6 +164,10 @@ Preparation performs an online SQLite backup with `VACUUM INTO`, verifies `PRAGM
Both lab mounts contain a matching run marker. The runner rejects missing markers, production paths, mismatched run IDs, and unavailable clones. The lab container health check verifies the cloned database before capture begins. The internal Docker network blocks outbound provider access.
+The Compose files assign the private audit and image-cache tmpfs mounts to the configured `PUID:PGID`. Keep those values aligned with the container user; a root-owned mode-700 tmpfs prevents the non-root app or runner from writing its disposable cache.
+
+The lab archive performs one bounded commission-draft save, reads the saved record back, captures the visible saved state, and deletes the draft before continuing. Validation requires exactly those two successful unsafe responses and the saved-state capture. Form error-state capture uses browser-native constraint validation without submitting, and synthetic `/api/visits` telemetry is blocked so the archive does not create visitor sessions. These mutations run only in `snapshot-lab`; `live-readonly` continues to reject every capture-time unsafe request.
+
Do not run `docker compose down -v`; the scripts use ordinary `down` and preserve bind-mounted evidence for review.
## Artifacts
diff --git a/synology-nas-deploy.md b/synology-nas-deploy.md
index 5a0f404..dcd6e7e 100644
--- a/synology-nas-deploy.md
+++ b/synology-nas-deploy.md
@@ -158,7 +158,9 @@ docker buildx build \
--load .
```
-Optional local container smoke test:
+Optional local container smoke test:
+
+For the exact release-candidate app and visual-audit images on Windows, use `visual-audit/scripts/run-local-disposable-smoke.ps1`. It runs without production mounts or credentials and removes its temporary data, media, output, and secret volumes after validation.
```bash
docker run --rm -p 3002:3002 \
diff --git a/visual-audit/scripts/run-local-disposable-smoke.ps1 b/visual-audit/scripts/run-local-disposable-smoke.ps1
new file mode 100644
index 0000000..b3fd586
--- /dev/null
+++ b/visual-audit/scripts/run-local-disposable-smoke.ps1
@@ -0,0 +1,392 @@
+[CmdletBinding()]
+param(
+ [string]$AppImage = "woodsmith:local-gate",
+ [string]$AuditImage = "woodsmith-visual-audit:local-gate",
+ [string]$CommitSha = "",
+ [ValidateSet("smoke", "full")]
+ [string]$Scope = "smoke"
+)
+
+$ErrorActionPreference = "Stop"
+Set-StrictMode -Version Latest
+
+function Invoke-Docker {
+ param(
+ [Parameter(ValueFromRemainingArguments = $true)]
+ [string[]]$Arguments
+ )
+
+ & docker @Arguments
+ if ($LASTEXITCODE -ne 0) {
+ throw "Docker command failed with exit code $LASTEXITCODE."
+ }
+}
+
+if ([string]::IsNullOrWhiteSpace($CommitSha)) {
+ $CommitSha = (& git rev-parse HEAD).Trim()
+ if ($LASTEXITCODE -ne 0) {
+ throw "Unable to resolve the current commit."
+ }
+}
+
+if ($CommitSha -notmatch "^[0-9a-f]{40}$") {
+ throw "CommitSha must be a full 40-character lowercase Git SHA."
+}
+
+$appMetadata = (Invoke-Docker image inspect $AppImage) | ConvertFrom-Json
+$auditMetadata = (Invoke-Docker image inspect $AuditImage) | ConvertFrom-Json
+$appBuildSha = $appMetadata[0].Config.Env |
+ Where-Object { $_ -like "WOODSMITH_BUILD_SHA=*" } |
+ Select-Object -First 1
+
+if ($appMetadata[0].Os -ne "linux" -or $appMetadata[0].Architecture -ne "amd64") {
+ throw "The app image must be linux/amd64."
+}
+if ($auditMetadata[0].Os -ne "linux" -or $auditMetadata[0].Architecture -ne "amd64") {
+ throw "The visual-audit image must be linux/amd64."
+}
+if ($appBuildSha -ne ("WOODSMITH_BUILD_SHA=" + $CommitSha)) {
+ throw "The app image build identity does not match CommitSha."
+}
+
+$shortSha = $CommitSha.Substring(0, 8)
+$stamp = [DateTime]::UtcNow.ToString("yyyyMMddTHHmmssZ")
+$suffix = ([Guid]::NewGuid().ToString("N")).Substring(0, 10)
+$runId = "local-$Scope-$stamp-$shortSha-$suffix"
+$appContainer = "woodsmith-local-audit-app-$suffix"
+$dataVolume = "woodsmith-local-audit-data-$suffix"
+$mediaVolume = "woodsmith-local-audit-media-$suffix"
+$outputVolume = "woodsmith-local-audit-output-$suffix"
+$secretVolume = "woodsmith-local-audit-secrets-$suffix"
+$password = [Convert]::ToHexString(
+ [Security.Cryptography.RandomNumberGenerator]::GetBytes(36)
+).ToLowerInvariant()
+$sessionSecret = [Convert]::ToHexString(
+ [Security.Cryptography.RandomNumberGenerator]::GetBytes(48)
+).ToLowerInvariant()
+$auditToken = [Convert]::ToHexString(
+ [Security.Cryptography.RandomNumberGenerator]::GetBytes(32)
+).ToLowerInvariant()
+$failed = $true
+
+try {
+ Write-Output "AUDIT_RUN_ID=$runId"
+
+ foreach ($volume in @(
+ $dataVolume,
+ $mediaVolume,
+ $outputVolume,
+ $secretVolume
+ )) {
+ Invoke-Docker volume create $volume | Out-Null
+ }
+
+ $initArguments = @(
+ "run", "--rm", "--network", "none", "--user", "0",
+ "-v", ($dataVolume + ":/data"),
+ "-v", ($mediaVolume + ":/media"),
+ "-v", ($outputVolume + ":/output"),
+ "--entrypoint", "/bin/sh", $AppImage, "-c",
+ "chown 1001:1001 /data /media /output && chmod 700 /data /output && chmod 755 /media"
+ )
+ Invoke-Docker @initArguments
+
+ $secretArguments = @(
+ "run", "--rm", "--network", "none", "--user", "0",
+ "-e", ("AUDIT_PASSWORD=" + $password),
+ "-e", ("AUDIT_TOKEN=" + $auditToken),
+ "-v", ($secretVolume + ":/run/secrets"),
+ "--entrypoint", "/bin/sh", $AuditImage, "-c",
+ 'umask 077; printf %s "$AUDIT_PASSWORD" > /run/secrets/admin_password; printf %s "$AUDIT_TOKEN" > /run/secrets/audit_token; chmod 444 /run/secrets/admin_password /run/secrets/audit_token'
+ )
+ Invoke-Docker @secretArguments
+
+ $mediaScript = @'
+const fs = require("node:fs");
+const path = require("node:path");
+const sharp = require("sharp");
+const files = [
+ "Cabinets/PXL_20240624_020957542.jpg",
+ "Cabinets/PXL_20240624_021020003.jpg",
+ "Cabinets/PXL_20240624_021024485.jpg",
+ "Cabinets/PXL_20240624_021031088.jpg",
+ "Furniture/DSC_0051.JPG",
+ "Furniture/DSC_0052.JPG",
+ "Furniture/DSC_0053.JPG",
+ "Furniture/IMG_20200621_172630.jpg",
+ "Furniture/IMG_20200628_153747.jpg",
+ "Furniture/IMG_20200628_153839.jpg",
+ "Furniture/IMG_20210420_175427.jpg",
+ "Furniture/IMG_20210420_175450.jpg",
+ "Furniture/IMG_20210420_175507.jpg",
+ "Furniture/PXL_20250222_201547090.jpg",
+ "Furniture/PXL_20250302_223145008.jpg",
+ "Furniture/PXL_20250302_223155446.jpg",
+ "Furniture/PXL_20260319_000709864.jpg",
+ "Furniture/PXL_20260319_000724223.jpg",
+ "Furniture/PXL_20260321_195141872.jpg"
+];
+
+(async () => {
+ for (let index = 0; index < files.length; index += 1) {
+ const target = path.join("/output", files[index]);
+ fs.mkdirSync(path.dirname(target), { recursive: true });
+ const base = index % 2 === 0 ? "#d7c6a3" : "#5b3d29";
+ const accent = index % 3 === 0 ? "#17130f" : "#f2ead9";
+ const svg =
+ '' +
+ ' ' +
+ ' ' +
+ ' ' +
+ 'Disposable visual-audit fixture ' +
+ (index + 1) + " ";
+
+ await sharp(Buffer.from(svg))
+ .jpeg({ quality: 88 })
+ .toFile(target);
+ }
+ console.log("SYNTHETIC_MEDIA_COUNT=" + files.length);
+})().catch((error) => {
+ console.error(error);
+ process.exit(1);
+});
+'@
+
+ $mediaArguments = @(
+ "run", "--rm", "--network", "none", "--user", "0",
+ "-v", ($mediaVolume + ":/output"),
+ "--entrypoint", "node", $AuditImage, "-e", $mediaScript
+ )
+ Invoke-Docker @mediaArguments
+
+ $appArguments = @(
+ "run", "-d", "--name", $appContainer,
+ "--network", "none",
+ "--read-only",
+ "--cap-drop", "ALL",
+ "--security-opt", "no-new-privileges:true",
+ "--tmpfs", "/tmp:rw,noexec,nosuid,nodev,size=128m,mode=1777",
+ "--tmpfs", "/app/site/.next/cache:rw,noexec,nosuid,nodev,size=128m,mode=700,uid=1001,gid=1001",
+ "-v", ($dataVolume + ":/app/site/data:rw"),
+ "-v", ($mediaVolume + ":/app/pics:ro"),
+ "-v", ($secretVolume + ":/run/secrets:ro"),
+ "-e", "NODE_ENV=production",
+ "-e", "SELF_HOSTED=true",
+ "-e", "SITE_URL=http://127.0.0.1:3002",
+ "-e", "NEXT_PUBLIC_SITE_URL=http://127.0.0.1:3002",
+ "-e", "MEDIA_ROOT=/app/pics",
+ "-e", "DATA_ROOT=/app/site/data",
+ "-e", ("STUDIO_PASSWORD=" + $password),
+ "-e", ("SESSION_SECRET=" + $sessionSecret),
+ "-e", "VISUAL_AUDIT_TOKEN_FILE=/run/secrets/audit_token",
+ "-e", "VISUAL_AUDIT_MAX_RECORDS=5000",
+ "-e", "STRIPE_SECRET_KEY=",
+ "-e", "STRIPE_PUBLISHABLE_KEY=",
+ "-e", "EASYPOST_API_KEY=",
+ "-e", "SMTP_HOST=",
+ "-e", "SMTP_USER=",
+ "-e", "SMTP_PASSWORD=",
+ "-e", "OPENAI_API_KEY=",
+ "-e", "ENABLE_PUBLIC_AI_RENDERING=false",
+ "-e", "ENABLE_AI_BACKGROUND_CLEANUP=false",
+ "-e", "ENABLE_AI_MEDIA_ANALYSIS=false",
+ "-e", "ENABLE_EMBEDDING_SEARCH=false",
+ "-e", "ENABLE_LOCAL_IMAGE_EMBEDDINGS=false",
+ "-e", "ENABLE_GEMINI_FALLBACK=false",
+ "-e", "AI_PROVIDER=disabled",
+ "-e", "AI_ANALYSIS_PROVIDER=disabled",
+ "-e", "AI_EMBEDDING_PROVIDER=disabled",
+ "-e", "AI_FALLBACK_PROVIDER=disabled",
+ "-e", "LOCAL_AI_SIDECAR_URL=http://127.0.0.1:9",
+ "-e", "OLLAMA_BASE_URL=http://127.0.0.1:9",
+ $AppImage
+ )
+ Invoke-Docker @appArguments | Out-Null
+
+ $ready = $false
+ for ($attempt = 1; $attempt -le 60; $attempt += 1) {
+ & docker exec $appContainer node -e "fetch('http://127.0.0.1:3002/studio/login').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
+ if ($LASTEXITCODE -eq 0) {
+ $ready = $true
+ Write-Output "APP_READY_ATTEMPT=$attempt"
+ break
+ }
+ Start-Sleep -Seconds 2
+ }
+ if (-not $ready) {
+ throw "The disposable application did not become ready."
+ }
+
+ $runnerArguments = @(
+ "--rm",
+ "--network", ("container:" + $appContainer),
+ "--user", "1001:1001",
+ "--read-only",
+ "--cap-drop", "ALL",
+ "--security-opt", "no-new-privileges:true",
+ "--ipc", "host",
+ "--tmpfs", "/audit-tmp:rw,nosuid,nodev,size=128m,mode=700,uid=1001,gid=1001",
+ "--tmpfs", "/tmp:rw,nosuid,nodev,size=256m,mode=1777",
+ "-v", ($outputVolume + ":/output:rw"),
+ "-v", ($secretVolume + ":/run/secrets:ro"),
+ "-e", "TARGET_MODE=live-readonly",
+ "-e", "BASE_URL=http://127.0.0.1:3002",
+ "-e", ("TARGET_COMMIT_SHA=" + $CommitSha),
+ "-e", ("AUDIT_RUN_ID=" + $runId),
+ "-e", ("AUDIT_SCOPE=" + $Scope),
+ "-e", "AUDIT_RESUME=true",
+ "-e", "RUN_OUTPUT_ROOT=/output",
+ "-e", "REPO_ROOT=/workspace",
+ "-e", "AUDIT_TMP_ROOT=/audit-tmp",
+ "-e", "WOODSMITH_ADMIN_EMAIL=woodsmithbb@proton.me",
+ "-e", "ADMIN_PASSWORD_FILE=/run/secrets/admin_password",
+ "-e", "AUDIT_TOKEN_FILE=/run/secrets/audit_token",
+ "-e", "AUDIT_STRICT_DIAGNOSTICS=true"
+ )
+
+ Write-Output "CAPTURE_START"
+ Invoke-Docker run @runnerArguments $AuditImage
+
+ foreach ($step in @(
+ @{ Name = "COMPARE"; Entry = "dist/diff.js" },
+ @{ Name = "REPORT"; Entry = "dist/report.js" },
+ @{ Name = "VALIDATE"; Entry = "dist/validate.js" }
+ )) {
+ Write-Output ($step.Name + "_START")
+ $stepArguments = @("run") + $runnerArguments + @(
+ "--entrypoint", "node", $AuditImage, $step.Entry
+ )
+ Invoke-Docker @stepArguments
+ }
+
+ $summaryScript = @'
+const fs = require("node:fs");
+const path = require("node:path");
+const root = path.join("/output", process.env.AUDIT_RUN_ID);
+const manifest = JSON.parse(
+ fs.readFileSync(path.join(root, "manifest.json"), "utf8")
+);
+const validation = JSON.parse(
+ fs.readFileSync(path.join(root, "validation.json"), "utf8")
+);
+const checksums = JSON.parse(
+ fs.readFileSync(path.join(root, "checksums.json"), "utf8")
+);
+const walk = (directory) =>
+ fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
+ const item = path.join(directory, entry.name);
+ return entry.isDirectory() ? walk(item) : [item];
+ });
+const files = walk(root);
+const temporaryFiles = files.filter(
+ (file) => /\.(tmp|part)$/i.test(file) || file.includes(".tmp-")
+);
+const shareableRoot = path.join(root, "shareable");
+const shareableFiles = fs.existsSync(shareableRoot)
+ ? walk(shareableRoot)
+ : [];
+const shareableImages = shareableFiles.filter(
+ (file) => /\.(png|jpe?g|webp)$/i.test(file)
+);
+const skipFocused = manifest.captures.filter(
+ (item) => item.state === "skip-link-focused"
+).length;
+const skipActivated = manifest.captures.filter(
+ (item) => item.state === "skip-link-activated-main-focus"
+).length;
+const routeKeys = new Set(
+ manifest.routes.map((item) => item.auth + ":" + item.route)
+);
+const result = {
+ runId: process.env.AUDIT_RUN_ID,
+ passed: validation.passed,
+ failures: validation.failures.length,
+ unexpectedDiagnostics: validation.diagnostics.length,
+ captures: manifest.captures.length,
+ routes: routeKeys.size,
+ skipFocused,
+ skipActivated,
+ unsafeSuccessful: manifest.security.successfulUnsafeRequests,
+ unsafeBlocked: manifest.security.sameOriginUnsafeRequestsBlocked,
+ tokenEligible: manifest.security.tokenEligibleRequests,
+ crossOrigin: manifest.security.crossOriginRequests,
+ checksums: checksums.length,
+ totalFiles: files.length,
+ temporaryFiles: temporaryFiles.length,
+ shareableImages: shareableImages.length
+};
+console.log(JSON.stringify(result, null, 2));
+
+if (
+ !result.passed ||
+ result.failures !== 0 ||
+ result.unexpectedDiagnostics !== 0 ||
+ result.unsafeSuccessful !== 0 ||
+ result.unsafeBlocked < 1 ||
+ result.tokenEligible < 1 ||
+ result.crossOrigin !== 0 ||
+ result.temporaryFiles !== 0 ||
+ result.skipFocused < 1 ||
+ result.skipFocused !== result.skipActivated
+) {
+ process.exit(9);
+}
+'@
+
+ $summaryArguments = @(
+ "run", "--rm", "--network", "none", "--user", "1001:1001",
+ "-v", ($outputVolume + ":/output:ro"),
+ "-e", ("AUDIT_RUN_ID=" + $runId),
+ "--entrypoint", "node", $AuditImage, "-e", $summaryScript
+ )
+ Invoke-Docker @summaryArguments
+
+ $applicationLogs = @(& docker logs $appContainer 2>&1)
+ $logFailures = @(
+ $applicationLogs |
+ Select-String -Pattern "EACCES|unhandledRejection|Failed to write image to cache"
+ )
+ Write-Output ("SERVER_LOG_FAILURES=" + $logFailures.Count)
+ if ($logFailures.Count -gt 0) {
+ $logFailures | ForEach-Object { Write-Error $_.Line }
+ throw "The disposable application emitted cache or unhandled-rejection failures."
+ }
+
+ $failed = $false
+ Write-Output "LOCAL_DISPOSABLE_AUDIT_OK=1"
+}
+finally {
+ $existingApp = @(
+ & docker ps -a --format "{{.Names}}" |
+ Where-Object { $_ -eq $appContainer }
+ )
+ if ($failed -and $existingApp.Count -gt 0) {
+ Write-Output "APP_LOG_TAIL_BEGIN"
+ & docker logs --tail 200 $appContainer
+ Write-Output "APP_LOG_TAIL_END"
+ }
+
+ & docker rm -f $appContainer 2>$null | Out-Null
+ foreach ($volume in @(
+ $dataVolume,
+ $mediaVolume,
+ $outputVolume,
+ $secretVolume
+ )) {
+ & docker volume rm -f $volume 2>$null | Out-Null
+ }
+
+ $remainingContainers = @(
+ & docker ps -a --format "{{.Names}}" |
+ Where-Object { $_ -like ("*" + $suffix + "*") }
+ )
+ $remainingVolumes = @(
+ & docker volume ls --format "{{.Name}}" |
+ Where-Object { $_ -like ("*" + $suffix + "*") }
+ )
+ Write-Output ("CLEANUP_CONTAINERS=" + $remainingContainers.Count)
+ Write-Output ("CLEANUP_VOLUMES=" + $remainingVolumes.Count)
+}
diff --git a/visual-audit/src/capture.ts b/visual-audit/src/capture.ts
index 099bac0..37a72a1 100644
--- a/visual-audit/src/capture.ts
+++ b/visual-audit/src/capture.ts
@@ -5,7 +5,7 @@ import sharp from "sharp";
import type { Locator, Page } from "playwright";
import { config } from "./config.js";
-import { overlappingPositions, positionsIntersectingRange } from "./tiling.js";
+import { overlappingPositions, positionsIntersectingRange, viewportClipOrigin } from "./tiling.js";
import type { SegmentRecord, TileManifest, TileRecord } from "./types.js";
import { ensureDirectory, relativeTo, safeName, writeJsonAtomic } from "./util.js";
@@ -225,12 +225,17 @@ async function captureScrollableContainers(page: Page, outputDirectory: string,
scrollHeight: element.scrollHeight,
scrollLeft: element.scrollLeft,
scrollTop: element.scrollTop,
- clipX: rect.left + window.scrollX + element.clientLeft,
- clipY: rect.top + window.scrollY + element.clientTop
+ rectLeft: rect.left,
+ rectTop: rect.top,
+ clientLeft: element.clientLeft,
+ clientTop: element.clientTop
};
}, index).catch(() => null);
if (!info) continue;
+ // Playwright screenshot clips use viewport CSS coordinates from getBoundingClientRect().
+ const clipOrigin = viewportClipOrigin(info);
+
const rawRoot = path.join(outputDirectory, "raw", `${safeName(baseName)}-scroll-${String(captured + 1).padStart(3, "0")}`);
await ensureDirectory(rawRoot);
const xPositions = overlappingPositions(info.scrollWidth, info.clientWidth);
@@ -260,7 +265,7 @@ async function captureScrollableContainers(page: Page, outputDirectory: string,
scale: "device",
animations: "disabled",
caret: "hide",
- clip: { x: info.clipX, y: info.clipY, width: info.clientWidth, height: info.clientHeight }
+ clip: { x: clipOrigin.x, y: clipOrigin.y, width: info.clientWidth, height: info.clientHeight }
});
const metadata = await sharp(buffer).metadata();
const rawFile = path.join(rawRoot, `segment-${String(segmentIndex + 1).padStart(3, "0")}-tile-${String(tileRecords.length + 1).padStart(4, "0")}.png`);
diff --git a/visual-audit/src/diagnostics.test.ts b/visual-audit/src/diagnostics.test.ts
index 8e7786c..165a1b3 100644
--- a/visual-audit/src/diagnostics.test.ts
+++ b/visual-audit/src/diagnostics.test.ts
@@ -3,8 +3,8 @@ import test from "node:test";
import {
isExpectedNextPrefetchAbort,
- isExpectedReadonlyBlockedConsole,
- isExpectedReadonlyMutationBlock,
+ isExpectedAuditBlockedConsole,
+ isExpectedAuditMutationBlock,
isKnownExpectedDiagnostic,
requestBlockKey
} from "./diagnostics.js";
@@ -37,15 +37,24 @@ test("read-only blocked failures require an unsafe request and an exact policy r
targetMode: "live-readonly",
method: "POST",
url,
+ baseUrl: "https://woodmat.ch",
failure: "net::ERR_BLOCKED_BY_CLIENT",
blockedRequests
};
- assert.equal(isExpectedReadonlyMutationBlock(input), true);
- assert.equal(isExpectedReadonlyMutationBlock({ ...input, method: "GET" }), false);
- assert.equal(isExpectedReadonlyMutationBlock({ ...input, url: "https://woodmat.ch/media/required.jpg" }), false);
- assert.equal(isExpectedReadonlyMutationBlock({ ...input, targetMode: "snapshot-lab" }), false);
- assert.equal(isExpectedReadonlyMutationBlock({ ...input, blockedRequests: new Set() }), false);
+ assert.equal(isExpectedAuditMutationBlock(input), true);
+ assert.equal(isExpectedAuditMutationBlock({ ...input, method: "GET" }), false);
+ assert.equal(isExpectedAuditMutationBlock({ ...input, url: "https://woodmat.ch/media/required.jpg" }), false);
+ assert.equal(isExpectedAuditMutationBlock({ ...input, targetMode: "snapshot-lab" }), false);
+ assert.equal(isExpectedAuditMutationBlock({ ...input, blockedRequests: new Set() }), false);
+
+ const visitUrl = "https://woodmat.ch/api/visits";
+ assert.equal(isExpectedAuditMutationBlock({
+ ...input,
+ targetMode: "snapshot-lab",
+ url: visitUrl,
+ blockedRequests: new Set([requestBlockKey("POST", visitUrl)])
+ }), true);
});
test("blocked console noise is expected only after a route-local read-only policy decision", () => {
@@ -54,10 +63,10 @@ test("blocked console noise is expected only after a route-local read-only polic
text: "Failed to load resource: net::ERR_BLOCKED_BY_CLIENT",
blockedRequestCount: 1
};
- assert.equal(isExpectedReadonlyBlockedConsole(input), true);
- assert.equal(isExpectedReadonlyBlockedConsole({ ...input, blockedRequestCount: 0 }), false);
- assert.equal(isExpectedReadonlyBlockedConsole({ ...input, targetMode: "snapshot-lab" }), false);
- assert.equal(isExpectedReadonlyBlockedConsole({ ...input, text: "Failed to load required image" }), false);
+ assert.equal(isExpectedAuditBlockedConsole(input), true);
+ assert.equal(isExpectedAuditBlockedConsole({ ...input, blockedRequestCount: 0 }), false);
+ assert.equal(isExpectedAuditBlockedConsole({ ...input, targetMode: "snapshot-lab" }), true);
+ assert.equal(isExpectedAuditBlockedConsole({ ...input, text: "Failed to load required image" }), false);
});
test("validator exceptions remain narrow and never hide arbitrary API failures", () => {
diff --git a/visual-audit/src/diagnostics.ts b/visual-audit/src/diagnostics.ts
index 139e3da..b830289 100644
--- a/visual-audit/src/diagnostics.ts
+++ b/visual-audit/src/diagnostics.ts
@@ -1,4 +1,4 @@
-import { isSameOrigin, isUnsafeMethod } from "./policy.js";
+import { isSameOrigin, isSyntheticVisitTelemetry, isUnsafeMethod } from "./policy.js";
export type RequestFailureEvidence = {
method: string;
@@ -38,25 +38,33 @@ export function isExpectedNextPrefetchAbort(evidence: RequestFailureEvidence) {
headers["sec-purpose"]?.includes("prefetch") === true;
}
-export function isExpectedReadonlyMutationBlock(input: {
+export function isExpectedAuditMutationBlock(input: {
targetMode: string;
method: string;
url: string;
+ baseUrl: string;
failure: string;
blockedRequests: ReadonlySet;
}) {
- return input.targetMode === "live-readonly" &&
- input.failure.includes("ERR_BLOCKED_BY_CLIENT") &&
- isUnsafeMethod(input.method) &&
- input.blockedRequests.has(requestBlockKey(input.method, input.url));
+ if (
+ !input.failure.includes("ERR_BLOCKED_BY_CLIENT") ||
+ !isUnsafeMethod(input.method) ||
+ !input.blockedRequests.has(requestBlockKey(input.method, input.url))
+ ) {
+ return false;
+ }
+
+ return input.targetMode === "live-readonly" ||
+ input.targetMode === "snapshot-lab" &&
+ isSyntheticVisitTelemetry(input.method, input.url, input.baseUrl);
}
-export function isExpectedReadonlyBlockedConsole(input: {
+export function isExpectedAuditBlockedConsole(input: {
targetMode: string;
text: string;
blockedRequestCount: number;
}) {
- return input.targetMode === "live-readonly" &&
+ return ["live-readonly", "snapshot-lab"].includes(input.targetMode) &&
input.text.includes("ERR_BLOCKED_BY_CLIENT") &&
input.blockedRequestCount > 0;
}
diff --git a/visual-audit/src/policy.test.ts b/visual-audit/src/policy.test.ts
index c997155..6b0d9c0 100644
--- a/visual-audit/src/policy.test.ts
+++ b/visual-audit/src/policy.test.ts
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
-import { auditTokenEligible, inventoryRequestEligible, isSameOrigin, isUnsafeMethod } from "./policy.js";
+import { auditTokenEligible, inventoryRequestEligible, isSameOrigin, isSyntheticVisitTelemetry, isUnsafeMethod } from "./policy.js";
test("audit token eligibility is limited to protected same-origin inventory surfaces", () => {
const base = "https://woodmat.ch";
@@ -30,3 +30,12 @@ test("read-only policy distinguishes safe methods and origins", () => {
assert.equal(isSameOrigin("https://woodmat.ch/shop", "https://woodmat.ch"), true);
assert.equal(isSameOrigin("https://cdn.example.com/image.jpg", "https://woodmat.ch"), false);
});
+
+test("synthetic visit telemetry matches only the exact same-origin POST", () => {
+ const base = "https://woodmat.ch";
+ assert.equal(isSyntheticVisitTelemetry("POST", "https://woodmat.ch/api/visits", base), true);
+ assert.equal(isSyntheticVisitTelemetry("GET", "https://woodmat.ch/api/visits", base), false);
+ assert.equal(isSyntheticVisitTelemetry("POST", "https://woodmat.ch/api/visits?extra=1", base), false);
+ assert.equal(isSyntheticVisitTelemetry("POST", "https://other.example/api/visits", base), false);
+ assert.equal(isSyntheticVisitTelemetry("POST", "https://woodmat.ch/api/contact", base), false);
+});
diff --git a/visual-audit/src/policy.ts b/visual-audit/src/policy.ts
index d96e934..26acd47 100644
--- a/visual-audit/src/policy.ts
+++ b/visual-audit/src/policy.ts
@@ -23,3 +23,11 @@ export function inventoryRequestEligible(method: string, requestUrl: string | UR
request.pathname === endpoint.pathname &&
request.search === "";
}
+
+export function isSyntheticVisitTelemetry(method: string, requestUrl: string | URL, baseUrl: string | URL) {
+ const request = new URL(requestUrl, baseUrl);
+ return method.toUpperCase() === "POST" &&
+ request.origin === new URL(baseUrl).origin &&
+ request.pathname === "/api/visits" &&
+ request.search === "";
+}
diff --git a/visual-audit/src/run.ts b/visual-audit/src/run.ts
index 1f35fec..a8823bb 100644
--- a/visual-audit/src/run.ts
+++ b/visual-audit/src/run.ts
@@ -20,8 +20,8 @@ import {
} from "./config.js";
import {
isExpectedNextPrefetchAbort,
- isExpectedReadonlyBlockedConsole,
- isExpectedReadonlyMutationBlock,
+ isExpectedAuditBlockedConsole,
+ isExpectedAuditMutationBlock,
requestBlockKey
} from "./diagnostics.js";
import {
@@ -30,8 +30,9 @@ import {
fetchInventory
} from "./inventory.js";
import { waitForVisualReady } from "./readiness.js";
-import { auditTokenEligible, isUnsafeMethod } from "./policy.js";
+import { auditTokenEligible, isSyntheticVisitTelemetry, isUnsafeMethod } from "./policy.js";
import { assertFocusedSkipLink, assertMainFocusTransferred } from "./skip-link.js";
+import { SNAPSHOT_LAB_COMMISSION_DRAFT_STATE } from "./snapshot-lab-evidence.js";
import type {
AuthState,
CaptureRecord,
@@ -200,7 +201,7 @@ function attachDiagnostics(
message: text,
expected:
/THREE\.THREE\.Clock: This module has been deprecated/.test(text) ||
- isExpectedReadonlyBlockedConsole({
+ isExpectedAuditBlockedConsole({
targetMode: config.targetMode,
text,
blockedRequestCount: blockedRequests.size
@@ -226,10 +227,11 @@ function attachDiagnostics(
const method =
request.method().toUpperCase();
- if (isExpectedReadonlyMutationBlock({
+ if (isExpectedAuditMutationBlock({
targetMode: config.targetMode,
method,
url: request.url(),
+ baseUrl: config.baseUrl,
failure,
blockedRequests
})) {
@@ -536,6 +538,25 @@ async function createCaptureContext(
manifest.security.tokenEligibleRequests += 1;
}
+ if (
+ config.targetMode === "snapshot-lab" &&
+ isSyntheticVisitTelemetry(method, requestUrl, config.baseUrl)
+ ) {
+ blockedRequests.add(requestBlockKey(method, request.url()));
+ manifest.diagnostics.push({
+ timestamp: now(),
+ type: "mutation-blocked",
+ route: request.url(),
+ message:
+ "Snapshot-lab route guard blocked synthetic visitor telemetry " +
+ method + " " + request.url(),
+ expected: true
+ });
+ manifest.security.sameOriginUnsafeRequestsBlocked += 1;
+ await route.abort("blockedbyclient");
+ return;
+ }
+
if (
config.targetMode ===
"live-readonly"
@@ -620,16 +641,8 @@ async function captureFormValidationStates(input: {
].join(",")
).first();
- const submit =
- form.locator(
- 'button[type="submit"],input[type="submit"]'
- ).first();
-
if (
!await requiredField
- .isVisible()
- .catch(() => false) ||
- !await submit
.isVisible()
.catch(() => false)
) {
@@ -646,7 +659,17 @@ async function captureFormValidationStates(input: {
}
);
- await submit.click();
+ await requiredField.evaluate(
+ element => {
+ const field =
+ element as HTMLInputElement;
+ if (field.form) {
+ field.form.reportValidity();
+ } else {
+ field.reportValidity();
+ }
+ }
+ );
await saveCapture({
...input,
@@ -666,6 +689,147 @@ async function captureFormValidationStates(input: {
}
}
+async function captureSnapshotLabMutationState(input: {
+ page: Page;
+ auth: AuthState;
+ route: string;
+ theme: ThemeMode;
+ profile: ViewportProfile;
+ status: number | null;
+}) {
+ const route = new URL(input.route, config.baseUrl);
+ const targetProfile =
+ config.scope === "smoke"
+ ? "desktop-1440"
+ : "desktop-archival";
+
+ if (
+ config.targetMode !== "snapshot-lab" ||
+ input.auth !== "admin" ||
+ route.pathname !== "/commissions" ||
+ input.theme !== "dark" ||
+ input.profile.name !== targetProfile
+ ) {
+ return;
+ }
+
+ const key = captureKey({
+ auth: input.auth,
+ route: input.route,
+ theme: input.theme,
+ viewport: input.profile.name,
+ state: SNAPSHOT_LAB_COMMISSION_DRAFT_STATE
+ });
+ if (
+ config.resume &&
+ manifest.completedKeys.includes(key)
+ ) {
+ return;
+ }
+
+ const form =
+ input.page.locator("form.commission-workflow");
+ await form.waitFor({
+ state: "visible",
+ timeout: 10_000
+ });
+
+ const saveAndContinue =
+ form.getByRole("button", {
+ name: "Save and continue",
+ exact: true
+ });
+ await saveAndContinue.click();
+
+ await input.page.getByText(
+ "Account draft saved.",
+ { exact: true }
+ ).waitFor({
+ state: "visible",
+ timeout: 15_000
+ });
+
+ const draftId =
+ await form.locator(
+ 'input[name="draftId"]'
+ ).inputValue();
+ if (!draftId) {
+ throw new Error(
+ "Snapshot-lab commission draft did not return an ID."
+ );
+ }
+
+ try {
+ const verified =
+ await input.page.evaluate(
+ async (id) => {
+ const response = await fetch(
+ "/api/commissions/draft?id=" +
+ encodeURIComponent(id),
+ {
+ cache: "no-store"
+ }
+ );
+ const payload =
+ await response.json()
+ .catch(() => null) as {
+ ok?: boolean;
+ draft?: {
+ id?: string;
+ };
+ } | null;
+
+ return (
+ response.ok &&
+ payload?.ok === true &&
+ payload.draft?.id === id
+ );
+ },
+ draftId
+ );
+ if (!verified) {
+ throw new Error(
+ "Snapshot-lab commission draft could not be read back."
+ );
+ }
+
+ await saveCapture({
+ ...input,
+ state: SNAPSHOT_LAB_COMMISSION_DRAFT_STATE,
+ locator: form
+ });
+ } finally {
+ const deleted =
+ await input.page.evaluate(
+ async (id) => {
+ const response = await fetch(
+ "/api/commissions/draft?id=" +
+ encodeURIComponent(id),
+ {
+ method: "DELETE"
+ }
+ );
+ const payload =
+ await response.json()
+ .catch(() => null) as {
+ ok?: boolean;
+ } | null;
+
+ return (
+ response.ok &&
+ payload?.ok === true
+ );
+ },
+ draftId
+ );
+ if (!deleted) {
+ throw new Error(
+ "Snapshot-lab commission draft cleanup failed."
+ );
+ }
+ }
+}
+
function routeLabel(route: string) {
const url = new URL(
route,
@@ -1649,6 +1813,19 @@ async function captureRoute(input: {
});
}
+ try {
+ await captureSnapshotLabMutationState(base);
+ } catch (error) {
+ manifest.diagnostics.push({
+ timestamp: now(),
+ type: "pageerror",
+ route: input.route,
+ message:
+ "Snapshot-lab mutation capture failed: " +
+ (error instanceof Error ? error.message : String(error))
+ });
+ }
+
if (input.deep) {
const steps = [
["details", captureDetailsStates],
@@ -2059,7 +2236,8 @@ async function main() {
requiredStates: [
"route-default", "header-scroll", "theme-light", "theme-dark", "desktop", "tablet", "mobile", "archival-dpr",
"skip-link-focus-and-activation", "disclosures", "dialogs", "lightbox-zoom-boundaries", "inline-editing", "media-picker", "media-inspector",
- "nested-scroll-surfaces", "empty-states", "error-states", "snapshot-lab-validation"
+ "nested-scroll-surfaces", "empty-states", "error-states", "snapshot-lab-validation",
+ ...(config.targetMode === "snapshot-lab" ? ["snapshot-lab-successful-mutation"] : [])
],
safety: {
liveReadonly: "Unsafe same-origin and cross-origin requests are blocked client-side; same-origin requests also carry the server read-only header.",
diff --git a/visual-audit/src/snapshot-lab-evidence.test.ts b/visual-audit/src/snapshot-lab-evidence.test.ts
new file mode 100644
index 0000000..840985e
--- /dev/null
+++ b/visual-audit/src/snapshot-lab-evidence.test.ts
@@ -0,0 +1,49 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ SNAPSHOT_LAB_COMMISSION_DRAFT_STATE,
+ snapshotLabEvidenceFailures
+} from "./snapshot-lab-evidence.js";
+
+test("live-readonly archives do not require mutation evidence", () => {
+ assert.deepEqual(
+ snapshotLabEvidenceFailures({
+ targetMode: "live-readonly",
+ captureStates: [],
+ successfulUnsafeRequests: 0
+ }),
+ []
+ );
+});
+
+test("snapshot-lab archives require a saved state and cleanup mutation", () => {
+ const failures = snapshotLabEvidenceFailures({
+ targetMode: "snapshot-lab",
+ captureStates: [],
+ successfulUnsafeRequests: 1
+ });
+
+ assert.equal(failures.length, 2);
+});
+
+test("snapshot-lab save and cleanup evidence satisfies the gate", () => {
+ assert.deepEqual(
+ snapshotLabEvidenceFailures({
+ targetMode: "snapshot-lab",
+ captureStates: [SNAPSHOT_LAB_COMMISSION_DRAFT_STATE],
+ successfulUnsafeRequests: 2
+ }),
+ []
+ );
+});
+
+test("snapshot-lab archives reject unrelated successful mutations", () => {
+ const failures = snapshotLabEvidenceFailures({
+ targetMode: "snapshot-lab",
+ captureStates: [SNAPSHOT_LAB_COMMISSION_DRAFT_STATE],
+ successfulUnsafeRequests: 3
+ });
+
+ assert.equal(failures.length, 1);
+});
diff --git a/visual-audit/src/snapshot-lab-evidence.ts b/visual-audit/src/snapshot-lab-evidence.ts
new file mode 100644
index 0000000..8a9ebc5
--- /dev/null
+++ b/visual-audit/src/snapshot-lab-evidence.ts
@@ -0,0 +1,29 @@
+import type { TargetMode } from "./types.js";
+
+export const SNAPSHOT_LAB_COMMISSION_DRAFT_STATE =
+ "snapshot-lab-commission-draft-saved";
+
+export function snapshotLabEvidenceFailures(input: {
+ targetMode: TargetMode;
+ captureStates: readonly string[];
+ successfulUnsafeRequests: number;
+}) {
+ if (input.targetMode !== "snapshot-lab") return [];
+
+ const failures: string[] = [];
+ if (
+ !input.captureStates.includes(
+ SNAPSHOT_LAB_COMMISSION_DRAFT_STATE
+ )
+ ) {
+ failures.push(
+ "Snapshot-lab archive is missing the successful commission-draft capture."
+ );
+ }
+ if (input.successfulUnsafeRequests !== 2) {
+ failures.push(
+ "Snapshot-lab archive must contain exactly one clone-only save and one cleanup mutation."
+ );
+ }
+ return failures;
+}
diff --git a/visual-audit/src/tiling.test.ts b/visual-audit/src/tiling.test.ts
index a6244d4..d94f7e8 100644
--- a/visual-audit/src/tiling.test.ts
+++ b/visual-audit/src/tiling.test.ts
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
-import { overlappingPositions, positionsIntersectingRange } from "./tiling.js";
+import { overlappingPositions, positionsIntersectingRange, viewportClipOrigin } from "./tiling.js";
test("overlapping tile positions cover the full surface with a deterministic tail", () => {
const positions = overlappingPositions(3_200, 1_000);
@@ -24,3 +24,15 @@ test("range selection retains every tile needed across a segment boundary", () =
assert.ok(segment.some((position) => position < 2_000));
assert.ok(segment.some((position) => position + 1_000 >= 3_000));
});
+
+test("scroll-container screenshot clips remain viewport relative", () => {
+ assert.deepEqual(
+ viewportClipOrigin({
+ rectLeft: 24,
+ rectTop: 118,
+ clientLeft: 1,
+ clientTop: 1
+ }),
+ { x: 25, y: 119 }
+ );
+});
diff --git a/visual-audit/src/tiling.ts b/visual-audit/src/tiling.ts
index 40082bc..81e93ed 100644
--- a/visual-audit/src/tiling.ts
+++ b/visual-audit/src/tiling.ts
@@ -32,3 +32,15 @@ export function positionsIntersectingRange(
position < rangeEnd && position + viewportSize > rangeStart
));
}
+
+export function viewportClipOrigin(input: {
+ rectLeft: number;
+ rectTop: number;
+ clientLeft: number;
+ clientTop: number;
+}) {
+ return {
+ x: input.rectLeft + input.clientLeft,
+ y: input.rectTop + input.clientTop
+ };
+}
diff --git a/visual-audit/src/validate.ts b/visual-audit/src/validate.ts
index 5cab454..4d85e3f 100644
--- a/visual-audit/src/validate.ts
+++ b/visual-audit/src/validate.ts
@@ -5,6 +5,7 @@ import sharp from "sharp";
import { config, viewports } from "./config.js";
import { isKnownExpectedDiagnostic } from "./diagnostics.js";
+import { snapshotLabEvidenceFailures } from "./snapshot-lab-evidence.js";
import type { RunManifest, TileManifest } from "./types.js";
import { exists, listFiles, relativeTo, sha256File, writeJsonAtomic } from "./util.js";
@@ -185,6 +186,11 @@ async function main() {
if (manifest.deployedCommit !== "unknown" && manifest.deployedCommit !== manifest.expectedCommit) failures.push(`Commit mismatch: expected ${manifest.expectedCommit}, deployed ${manifest.deployedCommit}.`);
if (manifest.inventory.limits.truncatedCollections.length > 0) failures.push(`Inventory collections were truncated: ${manifest.inventory.limits.truncatedCollections.join(", ")}.`);
if (manifest.captures.length === 0) failures.push("Manifest contains no captures.");
+ failures.push(...snapshotLabEvidenceFailures({
+ targetMode: config.targetMode,
+ captureStates: manifest.captures.map((capture) => capture.state),
+ successfulUnsafeRequests: manifest.security.successfulUnsafeRequests
+ }));
const captureKeys = new Set();
for (const capture of manifest.captures) {
From c30a9096ad1d4e3652041d9ca34d455c4b09bdc9 Mon Sep 17 00:00:00 2001
From: Cooper Beaman <55124795+lowestprime@users.noreply.github.com>
Date: Tue, 14 Jul 2026 08:39:37 -0700
Subject: [PATCH 17/43] feat(ops): add verified paired recovery
---
.dockerignore | 6 +-
.gitignore | 11 +-
Dockerfile | 2 +
PLANS.md | 3 +-
README.md | 4 +
admin.md | 4 +-
site/package.json | 2 +-
site/scripts/runtime-state-lib.mjs | 606 ++++++++++++++++++++++++++
site/scripts/runtime-state.mjs | 97 +++++
site/scripts/runtime-state.test.mjs | 199 +++++++++
synology-nas-deploy.md | 141 +++++-
woodsmith_DeepWiki_Merged_03222026.md | 2 +
12 files changed, 1056 insertions(+), 21 deletions(-)
create mode 100644 site/scripts/runtime-state-lib.mjs
create mode 100644 site/scripts/runtime-state.mjs
create mode 100644 site/scripts/runtime-state.test.mjs
diff --git a/.dockerignore b/.dockerignore
index e5f2c2d..aab128b 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -25,8 +25,10 @@ site/data/media-ai-cache/
**/@eaDir
# Legacy local media placeholders, image cache, and releases
pics/
-cache/
-releases/
+cache/
+releases/
+backups/
+restores/
archive/
design/
output/
diff --git a/.gitignore b/.gitignore
index 5acc94b..43d01a2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -38,9 +38,14 @@ site/data/*.sqlite-shm
site/data/*.sqlite-wal
# Legacy local media placeholders, image cache, and releases
-pics/
-cache/
-releases/
+pics/
+cache/
+releases/
+backups/
+restores/
+site/data/backups/
+site/data.restore-*/
+site/data.rollback-*/
# Secrets
.env
diff --git a/Dockerfile b/Dockerfile
index 2c6b1ca..188d082 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -35,6 +35,8 @@ RUN useradd --system --uid 1001 --gid 1001 nextjs
COPY --from=builder --chown=nextjs:nextjs /app/site/.next/standalone ./
COPY --from=builder --chown=nextjs:nextjs /app/site/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nextjs /app/site/public ./public
+COPY --from=builder --chown=nextjs:nextjs /app/site/scripts/runtime-state.mjs ./ops/runtime-state.mjs
+COPY --from=builder --chown=nextjs:nextjs /app/site/scripts/runtime-state-lib.mjs ./ops/runtime-state-lib.mjs
RUN mkdir -p /app/site/data \
&& chown nextjs:nextjs /app/site/data \
diff --git a/PLANS.md b/PLANS.md
index 43b6f72..fbdc0d1 100644
--- a/PLANS.md
+++ b/PLANS.md
@@ -15,9 +15,10 @@
| Typed atomic inline editing | DONE | Added a typed field registry spanning scalar, list, link, relation, media, status, and structural settings fields; trusted-origin enforcement; complete-batch validation; one SQLite transaction; optimistic expected-value conflicts; admin audit records; reset, rollback, keyboard, focus, and one-step Undo behavior. Thirty-two application tests, typecheck, lint, production build, and a disposable authenticated Chromium save/undo/reset/conflict/origin smoke pass. Final-candidate archive evidence remains a release gate. |
| Secure resumable custom requests | DONE / LOCAL DOCKER VALIDATED | Added the ten-step autosaved workflow, verified-account server drafts, idempotent server-authoritative submission, hashed submission/render quotas, private staged attachments with rollback, owner-bound generated previews, opaque project-access cookies, POST lookup without URL email, and exact dimension/category continuity into R3F. Additive schema v5, seed v6, 36 application tests, production build, image safety inspection, and disposable browser/container submission evidence pass. |
| Safe standalone and image build | DONE / LOCAL DOCKER VALIDATED | `safe-build` uses disposable build roots, excludes runtime data and test sources from Next tracing, rejects databases/backups/test files, and proves cleanup for child-failure, forbidden-output, and success paths. Docker Desktop Engine 29.6.1 and BuildKit 0.31.1 built/loaded exact candidate `woodsmith:candidate-6a1004c`; the image contains no runtime DB, env/key, test source, private evidence, audit output, or production media and starts with an empty data directory. |
+| Paired runtime recovery | IMPLEMENTED / LOCAL DOCKER VALIDATED; NAS BACKUP PENDING | The production image now includes a fail-closed backup/verify/staging-restore CLI. It uses SQLite `VACUUM INTO`, copies database/media/environment state into a protected partial directory, records exact SHA-256/file-count evidence, rejects changing sources, symlinks, traversal, tampering, extra files, and overwrites, verifies `quick_check`, and compensates failed promotions. Five disposable tests pass. A Node 22 Docker proof backed up state, mutated all three sources, restored the pre-mutation database/media/environment, started the rollback-tagged app from restored mounts, served Workshop and media, rechecked SQLite, and removed every container, volume, and temporary tag. A paired restricted NAS backup and staged restore remain a pre-deployment gate. |
| Transactional media organization | DONE / LOCAL BROWSER VALIDATED | Additive schema v6 records batch/item snapshots; selected media can be moved, deterministically renamed, tagged, rated, assigned, and given normalized role/stage/visibility metadata in one compensated operation. Optimistic rollback refuses later edits, filesystem failures reverse earlier moves, legacy and normalized references stay synchronized, and optional cleanup writes unpublished source-linked derivatives only. Forty application tests plus disposable desktop/mobile browser apply/rollback and SQLite `quick_check` evidence pass. |
| Accessibility, responsive UI, and public media performance | DONE / LOCAL BROWSER VALIDATED | Added skip navigation, route-aware current navigation, high-contrast focus treatment, focus-safe auto-hide header behavior, modal focus containment/restoration, keyboard/touch bounded lightbox pan and zoom, announced carousel position, 24px target protection, and hydration-safe persistent themes. Public portfolio/shop/carousel thumbnails now use responsive Next image requests while the full-screen viewer retains the source file; raw media supports ETag/Last-Modified revalidation. Disposable browser QA passed at 1440, 390, and 320px in both themes with zero horizontal overflow, zero unnamed/unlabelled controls, no duplicate IDs/heading skips/missing alt text, and tested base/muted contrast above AA thresholds. |
-| Remaining product work | PENDING | Final documentation review, full visual-archive evidence, release, NAS backup/restore, rollback, candidate deployment, and production verification remain active. |
+| Remaining product work | PENDING | Final documentation review, full visual-archive evidence, exact release artifact, restricted NAS backup and staged-restore proof, candidate deployment, production rollback/persistence checks, and post-deployment verification remain active. |
## 2026-07-05 Local-First Media AI, Header, and Persistence Pass
diff --git a/README.md b/README.md
index aebb470..585637f 100644
--- a/README.md
+++ b/README.md
@@ -32,6 +32,7 @@ Woodsmith is a self-hosted Next.js application for the Beaman Woodworks company
- Safe profile administration for renaming accounts, replacing legacy developer emails, and deleting non-current users from the dashboard
- Compact auto-hide navigation that keeps the public and dashboard workspaces usable on narrow and desktop viewports
- A pinned two-mode Playwright visual archive with protected source/database/link inventory, evidence-based network diagnostics, keyboard skip-link states, read-only production capture, clone-only bounded mutation states, viewport-correct high-resolution tiles, searchable HTML, streamed bookmarked PDF atlases, checksums, and baseline comparisons
+- A fail-closed paired recovery tool that creates and verifies an online-consistent SQLite backup, a hashed copy of the mounted media tree, and an optional protected environment-file copy, then restores only to new staging destinations
## 📃 Production Notes
@@ -55,6 +56,7 @@ Woodsmith is a self-hosted Next.js application for the Beaman Woodworks company
- `docker-compose.synology.yml`: Synology runtime model
- `visual-audit/`: pinned Playwright capture, report, comparison, validation, and NAS automation package
- `visual-audit/scripts/run-local-disposable-smoke.ps1`: exact-image Windows smoke using fake credentials, synthetic media, non-root containers, and automatically removed volumes
+- `site/scripts/runtime-state.mjs`: backup, verify, and staging-only restore CLI included in the production image at `/app/site/ops/runtime-state.mjs`
- `docs/visual-archive.md`: private live-readonly and snapshot-lab operating guide
- `synology-nas-deploy.md`: deployment and NAS operations guide
- `admin.md`: private Woodshop dashboard manual
@@ -178,6 +180,8 @@ Private Woodshop:
The supported deployment target is Synology NAS with Docker Compose and reverse proxy termination. The compose file mounts `/volume1/homes/Cooper/Photos/Dad_Woodworking_09262025` directly to `/app/pics:rw`; do not remount or bind the repo-local `pics/` folder under `docker_ssd`. `MEDIA_ROOT` must be an absolute container path.
`npm run build` uses disposable build-time data and media roots and fails if Next standalone output contains SQLite, WAL/SHM, or backup files. Production state must enter the container only through the writable runtime mounts.
+
+Before deployment or a large media operation, use the paired runtime-state procedure in `synology-nas-deploy.md`. A completed backup is accepted only after its manifest hashes, exact file inventory, and SQLite `quick_check` pass; restore never overwrites the live data, media, or environment paths.
After deploying a build from this branch, the startup migration updates legacy `lowestprime@proton.me` developer references in persisted settings and seeded profile data to `cooperbeaman@proton.me`.
diff --git a/admin.md b/admin.md
index a09e769..74decd1 100644
--- a/admin.md
+++ b/admin.md
@@ -197,4 +197,6 @@ Open Media → Guided media trainer and click **Refresh status**. For the defaul
### Page edits do not survive rebuilds
-Open Studio overview and check the Persistence card first. It should show a configured, writable `/app/site/data`, `quick_check=ok`, WAL journal mode, and the expected seed version. If it shows a different path or a warning, verify `DATA_ROOT=/app/site/data`, the Compose mount `/volume2/docker_ssd/woodsmith/site/data:/app/site/data`, and write access for the configured `PUID:PGID`. The application rejects a relative `DATA_ROOT` so a changed working directory cannot silently create a second SQLite database, and seed upgrades no longer overwrite browser-edited pages or settings. Back up `woodsmith.sqlite`, `woodsmith.sqlite-wal`, and `woodsmith.sqlite-shm` together or use SQLite's online backup command.
+Open Studio overview and check the Persistence card first. It should show a configured, writable `/app/site/data`, `quick_check=ok`, WAL journal mode, and the expected seed version. If it shows a different path or a warning, verify `DATA_ROOT=/app/site/data`, the Compose mount `/volume2/docker_ssd/woodsmith/site/data:/app/site/data`, and write access for the configured `PUID:PGID`. The application rejects a relative `DATA_ROOT` so a changed working directory cannot silently create a second SQLite database, and seed upgrades no longer overwrite browser-edited pages or settings.
+
+Do not copy a live WAL database file by itself. Follow the paired runtime-state procedure in `synology-nas-deploy.md`; it uses SQLite `VACUUM INTO`, copies the matching media tree and protected environment file, records SHA-256 evidence, verifies `quick_check`, and restores only into new staging paths. Keep the pre-deploy data, media, environment, and rollback image until the replacement survives recreation and public validation.
diff --git a/site/package.json b/site/package.json
index 323ad60..3234ad2 100644
--- a/site/package.json
+++ b/site/package.json
@@ -8,7 +8,7 @@
"start": "node --experimental-sqlite .next/standalone/server.js",
"lint": "eslint app components lib",
"typecheck": "tsc --noEmit",
- "test": "node --experimental-strip-types --test lib/media-scoring.test.mts lib/piece-model.test.mts lib/database-migrations.test.mts lib/media-reference-transaction.test.mts lib/media-batch-transaction.test.mts lib/category-icons.test.mts lib/site-structure.test.mts lib/piece-media.test.mts lib/estimator.test.mts lib/visual-audit-security.test.mts lib/inline-edit-registry.test.mts lib/commission-workflow.test.mts lib/ui-behavior.test.mts lib/media-http.test.mts lib/media-access.test.mts lib/request-security.test.mts lib/theme-store.test.mts scripts/safe-build.test.mjs"
+ "test": "node --experimental-strip-types --test lib/media-scoring.test.mts lib/piece-model.test.mts lib/database-migrations.test.mts lib/media-reference-transaction.test.mts lib/media-batch-transaction.test.mts lib/category-icons.test.mts lib/site-structure.test.mts lib/piece-media.test.mts lib/estimator.test.mts lib/visual-audit-security.test.mts lib/inline-edit-registry.test.mts lib/commission-workflow.test.mts lib/ui-behavior.test.mts lib/media-http.test.mts lib/media-access.test.mts lib/request-security.test.mts lib/theme-store.test.mts scripts/safe-build.test.mjs scripts/runtime-state.test.mjs"
},
"dependencies": {
"@react-three/drei": "^10.7.7",
diff --git a/site/scripts/runtime-state-lib.mjs b/site/scripts/runtime-state-lib.mjs
new file mode 100644
index 0000000..a1cadad
--- /dev/null
+++ b/site/scripts/runtime-state-lib.mjs
@@ -0,0 +1,606 @@
+import { createHash, randomBytes } from "node:crypto";
+import { createReadStream, createWriteStream } from "node:fs";
+import {
+ chmod,
+ lstat,
+ mkdir,
+ readFile,
+ readdir,
+ rename,
+ rm,
+ stat,
+ utimes,
+ writeFile
+} from "node:fs/promises";
+import path from "node:path";
+import { Transform } from "node:stream";
+import { pipeline } from "node:stream/promises";
+import { DatabaseSync } from "node:sqlite";
+
+const BACKUP_KIND = "woodsmith-runtime-backup";
+const BACKUP_SCHEMA_VERSION = 1;
+const DATABASE_RELATIVE_PATH = "data/woodsmith.sqlite";
+const MANIFEST_FILE = "manifest.json";
+const ENVIRONMENT_RELATIVE_PATH = "config/runtime.env";
+
+function normalizeAbsolute(value, label) {
+ if (!value || !path.isAbsolute(value)) {
+ throw new Error(`${label} must be an absolute path.`);
+ }
+ return path.resolve(value);
+}
+
+function pathContains(parent, child) {
+ const relative = path.relative(parent, child);
+ return relative === "" || (
+ relative !== ".." &&
+ !relative.startsWith(`..${path.sep}`) &&
+ !path.isAbsolute(relative)
+ );
+}
+
+function assertDisjoint(labelA, pathA, labelB, pathB) {
+ if (pathContains(pathA, pathB) || pathContains(pathB, pathA)) {
+ throw new Error(`${labelA} and ${labelB} must not overlap.`);
+ }
+}
+
+async function pathExists(target) {
+ try {
+ await lstat(target);
+ return true;
+ } catch (error) {
+ if (error?.code === "ENOENT") return false;
+ throw error;
+ }
+}
+
+async function requireDirectory(target, label) {
+ const details = await lstat(target).catch((error) => {
+ if (error?.code === "ENOENT") throw new Error(`${label} does not exist.`);
+ throw error;
+ });
+ if (!details.isDirectory() || details.isSymbolicLink()) {
+ throw new Error(`${label} must be a real directory, not a symlink.`);
+ }
+}
+
+async function requireRegularFile(target, label) {
+ const details = await lstat(target).catch((error) => {
+ if (error?.code === "ENOENT") throw new Error(`${label} does not exist.`);
+ throw error;
+ });
+ if (!details.isFile() || details.isSymbolicLink()) {
+ throw new Error(`${label} must be a regular file, not a symlink.`);
+ }
+ return details;
+}
+
+function safeRunId(runId) {
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,80}$/.test(runId ?? "")) {
+ throw new Error("runId must contain only letters, numbers, dots, underscores, or hyphens.");
+ }
+ return runId;
+}
+
+function toManifestPath(segments) {
+ return segments.join("/");
+}
+
+function safeManifestSegments(relativePath) {
+ if (
+ !relativePath ||
+ relativePath.includes("\\") ||
+ path.posix.isAbsolute(relativePath)
+ ) {
+ throw new Error(`Unsafe backup path: ${relativePath}`);
+ }
+ const segments = relativePath.split("/");
+ if (segments.some((segment) => !segment || segment === "." || segment === "..")) {
+ throw new Error(`Unsafe backup path: ${relativePath}`);
+ }
+ return segments;
+}
+
+function resolveContained(root, relativePath) {
+ const resolved = path.resolve(root, ...safeManifestSegments(relativePath));
+ if (!pathContains(root, resolved) || resolved === root) {
+ throw new Error(`Backup path escapes its root: ${relativePath}`);
+ }
+ return resolved;
+}
+
+async function hashFile(file) {
+ await requireRegularFile(file, `File ${file}`);
+ const hash = createHash("sha256");
+ let size = 0;
+ for await (const chunk of createReadStream(file)) {
+ hash.update(chunk);
+ size += chunk.length;
+ }
+ return { sha256: hash.digest("hex"), size };
+}
+
+async function copyFileWithHash(source, destination, options = {}) {
+ const sourceDetails = await requireRegularFile(source, `Source file ${source}`);
+ const sourceSnapshot = await stat(source, { bigint: true });
+ await mkdir(path.dirname(destination), { recursive: true, mode: 0o700 });
+
+ const hash = createHash("sha256");
+ let size = 0;
+ const meter = new Transform({
+ transform(chunk, encoding, callback) {
+ hash.update(chunk);
+ size += chunk.length;
+ callback(null, chunk);
+ }
+ });
+
+ await pipeline(
+ createReadStream(source),
+ meter,
+ createWriteStream(destination, {
+ flags: "wx",
+ mode: options.mode ?? 0o600
+ })
+ );
+ await chmod(destination, options.mode ?? 0o600);
+ if (options.preserveTimestamp !== false) {
+ await utimes(destination, sourceDetails.atime, sourceDetails.mtime);
+ }
+
+ const after = await stat(source, { bigint: true });
+ if (
+ after.size !== sourceSnapshot.size ||
+ after.mtimeNs !== sourceSnapshot.mtimeNs ||
+ Number(after.size) !== size
+ ) {
+ throw new Error(`Source file changed while it was copied: ${source}`);
+ }
+
+ return {
+ sha256: hash.digest("hex"),
+ size,
+ mode: Number(sourceDetails.mode & 0o777),
+ mtimeMs: sourceDetails.mtimeMs
+ };
+}
+
+async function enumerateRegularFiles(root) {
+ await requireDirectory(root, `Directory ${root}`);
+ const files = [];
+
+ async function visit(directory, segments) {
+ const entries = await readdir(directory, { withFileTypes: true });
+ entries.sort((a, b) => a.name.localeCompare(b.name, "en"));
+ for (const entry of entries) {
+ const nextSegments = [...segments, entry.name];
+ const absolute = path.join(directory, entry.name);
+ const details = await lstat(absolute);
+ if (details.isSymbolicLink()) {
+ throw new Error(`Symlinks are not permitted in runtime-state backups: ${absolute}`);
+ }
+ if (details.isDirectory()) {
+ await visit(absolute, nextSegments);
+ continue;
+ }
+ if (!details.isFile()) {
+ throw new Error(`Special files are not permitted in runtime-state backups: ${absolute}`);
+ }
+ const bigintDetails = await stat(absolute, { bigint: true });
+ files.push({
+ absolute,
+ path: toManifestPath(nextSegments),
+ size: Number(bigintDetails.size),
+ mtimeNs: bigintDetails.mtimeNs.toString(),
+ mode: Number(details.mode & 0o777),
+ mtimeMs: details.mtimeMs
+ });
+ }
+ }
+
+ await visit(root, []);
+ return files;
+}
+
+function sameFileSnapshot(before, after) {
+ if (before.length !== after.length) return false;
+ return before.every((item, index) => {
+ const next = after[index];
+ return item.path === next.path && item.size === next.size && item.mtimeNs === next.mtimeNs;
+ });
+}
+
+function quickCheck(databasePath) {
+ const database = new DatabaseSync(databasePath, { readOnly: true });
+ try {
+ const rows = database.prepare("PRAGMA quick_check").all();
+ if (!rows.some((row) => row.quick_check === "ok")) {
+ throw new Error(`SQLite quick_check failed for ${databasePath}.`);
+ }
+ } finally {
+ database.close();
+ }
+}
+
+function vacuumInto(source, destination) {
+ quickCheck(source);
+ const database = new DatabaseSync(source, { readOnly: true });
+ try {
+ database.exec(`VACUUM INTO '${destination.replaceAll("'", "''")}'`);
+ } finally {
+ database.close();
+ }
+ quickCheck(destination);
+}
+
+async function writeJson(file, value) {
+ await writeFile(file, `${JSON.stringify(value, null, 2)}\n`, {
+ encoding: "utf8",
+ flag: "wx",
+ mode: 0o600
+ });
+}
+
+function temporarySibling(finalPath, label) {
+ return path.join(
+ path.dirname(finalPath),
+ `.${path.basename(finalPath)}.${label}-${process.pid}-${randomBytes(6).toString("hex")}`
+ );
+}
+
+async function assertDestinationMissing(target, label) {
+ if (await pathExists(target)) {
+ throw new Error(`${label} already exists; refusing to overwrite it.`);
+ }
+}
+
+function validateManifest(manifest) {
+ if (
+ !manifest ||
+ manifest.kind !== BACKUP_KIND ||
+ manifest.schemaVersion !== BACKUP_SCHEMA_VERSION ||
+ !manifest.database ||
+ manifest.database.file !== DATABASE_RELATIVE_PATH ||
+ manifest.database.quickCheck !== "ok" ||
+ !manifest.media ||
+ manifest.media.root !== "media" ||
+ !Array.isArray(manifest.media.files)
+ ) {
+ throw new Error("The runtime-state backup manifest is invalid or unsupported.");
+ }
+ safeRunId(manifest.runId);
+ safeManifestSegments(manifest.database.file);
+ if (
+ !Number.isSafeInteger(manifest.database.size) ||
+ manifest.database.size < 1 ||
+ !/^[a-f0-9]{64}$/.test(manifest.database.sha256)
+ ) {
+ throw new Error("The backup database evidence is invalid.");
+ }
+ const seen = new Set();
+ for (const file of manifest.media.files) {
+ safeManifestSegments(file.path);
+ if (seen.has(file.path)) throw new Error(`Duplicate media path in manifest: ${file.path}`);
+ seen.add(file.path);
+ if (
+ !Number.isSafeInteger(file.size) ||
+ file.size < 0 ||
+ !/^[a-f0-9]{64}$/.test(file.sha256) ||
+ !Number.isInteger(file.mode) ||
+ file.mode < 0 ||
+ file.mode > 0o777 ||
+ !Number.isFinite(file.mtimeMs)
+ ) {
+ throw new Error(`Invalid media evidence for ${file.path}.`);
+ }
+ }
+ if (manifest.media.count !== manifest.media.files.length) {
+ throw new Error("Media count does not match the manifest file list.");
+ }
+ if (manifest.environment) {
+ safeManifestSegments(manifest.environment.file);
+ if (
+ manifest.environment.file !== ENVIRONMENT_RELATIVE_PATH ||
+ !Number.isSafeInteger(manifest.environment.size) ||
+ manifest.environment.size < 0 ||
+ !/^[a-f0-9]{64}$/.test(manifest.environment.sha256)
+ ) {
+ throw new Error("The backup environment-file evidence is invalid.");
+ }
+ }
+ return manifest;
+}
+
+export async function createRuntimeBackup(input) {
+ const dataRoot = normalizeAbsolute(input.dataRoot, "dataRoot");
+ const mediaRoot = normalizeAbsolute(input.mediaRoot, "mediaRoot");
+ const backupRoot = normalizeAbsolute(input.backupRoot, "backupRoot");
+ const environmentFile = input.environmentFile
+ ? normalizeAbsolute(input.environmentFile, "environmentFile")
+ : null;
+ const runId = safeRunId(input.runId);
+
+ assertDisjoint("dataRoot", dataRoot, "mediaRoot", mediaRoot);
+ assertDisjoint("dataRoot", dataRoot, "backupRoot", backupRoot);
+ assertDisjoint("mediaRoot", mediaRoot, "backupRoot", backupRoot);
+ await requireDirectory(dataRoot, "dataRoot");
+ await requireDirectory(mediaRoot, "mediaRoot");
+ const databaseSource = path.join(dataRoot, "woodsmith.sqlite");
+ await requireRegularFile(databaseSource, "Woodsmith SQLite database");
+ if (environmentFile) await requireRegularFile(environmentFile, "environmentFile");
+
+ await mkdir(backupRoot, { recursive: true, mode: 0o700 });
+ await requireDirectory(backupRoot, "backupRoot");
+ await chmod(backupRoot, 0o700);
+ const finalRoot = path.join(backupRoot, `woodsmith-runtime-${runId}`);
+ const temporaryRoot = temporarySibling(finalRoot, "partial");
+ await assertDestinationMissing(finalRoot, "Backup destination");
+ await assertDestinationMissing(temporaryRoot, "Temporary backup destination");
+
+ try {
+ await mkdir(temporaryRoot, { recursive: false, mode: 0o700 });
+ const databaseDestination = resolveContained(temporaryRoot, DATABASE_RELATIVE_PATH);
+ await mkdir(path.dirname(databaseDestination), { recursive: true, mode: 0o700 });
+ vacuumInto(databaseSource, databaseDestination);
+ await chmod(databaseDestination, 0o600);
+ const databaseEvidence = await hashFile(databaseDestination);
+
+ const sourceBefore = await enumerateRegularFiles(mediaRoot);
+ if (sourceBefore.length === 0) throw new Error("mediaRoot contains no regular files.");
+ const mediaEvidence = [];
+ for (const source of sourceBefore) {
+ const destination = resolveContained(temporaryRoot, `media/${source.path}`);
+ const copied = await copyFileWithHash(source.absolute, destination, { mode: 0o600 });
+ mediaEvidence.push({
+ path: source.path,
+ size: copied.size,
+ sha256: copied.sha256,
+ mode: source.mode,
+ mtimeMs: source.mtimeMs
+ });
+ }
+ const sourceAfter = await enumerateRegularFiles(mediaRoot);
+ if (!sameFileSnapshot(sourceBefore, sourceAfter)) {
+ throw new Error("mediaRoot changed while the paired backup was being created.");
+ }
+
+ let environment = null;
+ if (environmentFile) {
+ const copied = await copyFileWithHash(
+ environmentFile,
+ resolveContained(temporaryRoot, ENVIRONMENT_RELATIVE_PATH),
+ { mode: 0o600 }
+ );
+ environment = {
+ file: ENVIRONMENT_RELATIVE_PATH,
+ size: copied.size,
+ sha256: copied.sha256
+ };
+ }
+
+ const manifest = {
+ schemaVersion: BACKUP_SCHEMA_VERSION,
+ kind: BACKUP_KIND,
+ runId,
+ createdAt: new Date().toISOString(),
+ database: {
+ file: DATABASE_RELATIVE_PATH,
+ size: databaseEvidence.size,
+ sha256: databaseEvidence.sha256,
+ quickCheck: "ok"
+ },
+ media: {
+ root: "media",
+ count: mediaEvidence.length,
+ totalBytes: mediaEvidence.reduce((sum, file) => sum + file.size, 0),
+ files: mediaEvidence
+ },
+ environment
+ };
+ await writeJson(path.join(temporaryRoot, MANIFEST_FILE), manifest);
+ await verifyRuntimeBackup({ backup: temporaryRoot });
+ await rename(temporaryRoot, finalRoot);
+ return {
+ backup: finalRoot,
+ manifest,
+ manifestSha256: (await hashFile(path.join(finalRoot, MANIFEST_FILE))).sha256
+ };
+ } catch (error) {
+ await rm(temporaryRoot, { recursive: true, force: true });
+ throw error;
+ }
+}
+
+export async function verifyRuntimeBackup(input) {
+ const backup = normalizeAbsolute(input.backup, "backup");
+ await requireDirectory(backup, "backup");
+ const manifestPath = path.join(backup, MANIFEST_FILE);
+ await requireRegularFile(manifestPath, "Backup manifest");
+ const manifest = validateManifest(JSON.parse(await readFile(manifestPath, "utf8")));
+
+ const expectedFiles = new Set([MANIFEST_FILE, manifest.database.file]);
+ const database = resolveContained(backup, manifest.database.file);
+ const databaseEvidence = await hashFile(database);
+ if (
+ databaseEvidence.size !== manifest.database.size ||
+ databaseEvidence.sha256 !== manifest.database.sha256
+ ) {
+ throw new Error("Backup database hash or size does not match the manifest.");
+ }
+ quickCheck(database);
+
+ let mediaBytes = 0;
+ for (const file of manifest.media.files) {
+ const relative = `media/${file.path}`;
+ expectedFiles.add(relative);
+ const evidence = await hashFile(resolveContained(backup, relative));
+ if (evidence.size !== file.size || evidence.sha256 !== file.sha256) {
+ throw new Error(`Backup media hash or size mismatch: ${file.path}`);
+ }
+ mediaBytes += evidence.size;
+ }
+ if (mediaBytes !== manifest.media.totalBytes) {
+ throw new Error("Backup media byte count does not match the manifest.");
+ }
+
+ if (manifest.environment) {
+ expectedFiles.add(manifest.environment.file);
+ const evidence = await hashFile(resolveContained(backup, manifest.environment.file));
+ if (
+ evidence.size !== manifest.environment.size ||
+ evidence.sha256 !== manifest.environment.sha256
+ ) {
+ throw new Error("Backup environment-file hash or size does not match the manifest.");
+ }
+ }
+
+ const actualFiles = (await enumerateRegularFiles(backup)).map((file) => file.path);
+ const unexpected = actualFiles.filter((file) => !expectedFiles.has(file));
+ const missing = [...expectedFiles].filter((file) => !actualFiles.includes(file));
+ if (unexpected.length || missing.length) {
+ throw new Error(`Backup file inventory mismatch (unexpected=${unexpected.length}, missing=${missing.length}).`);
+ }
+
+ return {
+ backup,
+ manifest,
+ manifestSha256: (await hashFile(manifestPath)).sha256,
+ quickCheck: "ok"
+ };
+}
+
+async function copyBackupFile(backup, relative, destination, mode, mtimeMs) {
+ const source = resolveContained(backup, relative);
+ const copied = await copyFileWithHash(source, destination, {
+ mode,
+ preserveTimestamp: false
+ });
+ if (Number.isFinite(mtimeMs)) {
+ const timestamp = new Date(mtimeMs);
+ await utimes(destination, timestamp, timestamp);
+ }
+ return copied;
+}
+
+export async function restoreRuntimeBackup(input) {
+ const verification = await verifyRuntimeBackup({ backup: input.backup });
+ const backup = verification.backup;
+ const dataDestination = normalizeAbsolute(input.dataDestination, "dataDestination");
+ const mediaDestination = normalizeAbsolute(input.mediaDestination, "mediaDestination");
+ const environmentDestination = input.environmentDestination
+ ? normalizeAbsolute(input.environmentDestination, "environmentDestination")
+ : null;
+
+ assertDisjoint("backup", backup, "dataDestination", dataDestination);
+ assertDisjoint("backup", backup, "mediaDestination", mediaDestination);
+ assertDisjoint("dataDestination", dataDestination, "mediaDestination", mediaDestination);
+ if (environmentDestination) {
+ if (!verification.manifest.environment) {
+ throw new Error("The backup does not contain an environment file.");
+ }
+ assertDisjoint("backup", backup, "environmentDestination", environmentDestination);
+ assertDisjoint("dataDestination", dataDestination, "environmentDestination", environmentDestination);
+ assertDisjoint("mediaDestination", mediaDestination, "environmentDestination", environmentDestination);
+ } else if (verification.manifest.environment && !input.skipEnvironment) {
+ throw new Error("environmentDestination is required unless skipEnvironment is explicit.");
+ }
+
+ await assertDestinationMissing(dataDestination, "dataDestination");
+ await assertDestinationMissing(mediaDestination, "mediaDestination");
+ if (environmentDestination) {
+ await assertDestinationMissing(environmentDestination, "environmentDestination");
+ }
+ await mkdir(path.dirname(dataDestination), { recursive: true, mode: 0o700 });
+ await mkdir(path.dirname(mediaDestination), { recursive: true, mode: 0o700 });
+ await requireDirectory(path.dirname(dataDestination), "dataDestination parent");
+ await requireDirectory(path.dirname(mediaDestination), "mediaDestination parent");
+ if (environmentDestination) {
+ await mkdir(path.dirname(environmentDestination), { recursive: true, mode: 0o700 });
+ await requireDirectory(path.dirname(environmentDestination), "environmentDestination parent");
+ }
+
+ const temporaryData = temporarySibling(dataDestination, "restore");
+ const temporaryMedia = temporarySibling(mediaDestination, "restore");
+ const temporaryEnvironment = environmentDestination
+ ? temporarySibling(environmentDestination, "restore")
+ : null;
+ const promoted = [];
+
+ try {
+ await mkdir(temporaryData, { recursive: false, mode: 0o700 });
+ await mkdir(temporaryMedia, { recursive: false, mode: 0o700 });
+ const restoredDatabase = path.join(temporaryData, "woodsmith.sqlite");
+ await copyBackupFile(
+ backup,
+ verification.manifest.database.file,
+ restoredDatabase,
+ 0o600
+ );
+ const restoredDatabaseEvidence = await hashFile(restoredDatabase);
+ if (restoredDatabaseEvidence.sha256 !== verification.manifest.database.sha256) {
+ throw new Error("Restored database does not match its backup hash.");
+ }
+ quickCheck(restoredDatabase);
+
+ for (const file of verification.manifest.media.files) {
+ const destination = path.resolve(temporaryMedia, ...safeManifestSegments(file.path));
+ const copied = await copyBackupFile(
+ backup,
+ `media/${file.path}`,
+ destination,
+ file.mode ?? 0o640,
+ file.mtimeMs
+ );
+ if (copied.sha256 !== file.sha256 || copied.size !== file.size) {
+ throw new Error(`Restored media does not match its backup hash: ${file.path}`);
+ }
+ }
+
+ if (temporaryEnvironment) {
+ const copied = await copyBackupFile(
+ backup,
+ verification.manifest.environment.file,
+ temporaryEnvironment,
+ 0o600
+ );
+ if (copied.sha256 !== verification.manifest.environment.sha256) {
+ throw new Error("Restored environment file does not match its backup hash.");
+ }
+ }
+
+ await rename(temporaryData, dataDestination);
+ promoted.push(dataDestination);
+ await rename(temporaryMedia, mediaDestination);
+ promoted.push(mediaDestination);
+ if (temporaryEnvironment) {
+ await rename(temporaryEnvironment, environmentDestination);
+ promoted.push(environmentDestination);
+ }
+
+ return {
+ backup,
+ dataDestination,
+ mediaDestination,
+ environmentDestination,
+ manifestSha256: verification.manifestSha256,
+ quickCheck: "ok",
+ mediaCount: verification.manifest.media.count
+ };
+ } catch (error) {
+ for (const target of promoted.reverse()) {
+ await rm(target, { recursive: true, force: true });
+ }
+ throw error;
+ } finally {
+ await rm(temporaryData, { recursive: true, force: true });
+ await rm(temporaryMedia, { recursive: true, force: true });
+ if (temporaryEnvironment) await rm(temporaryEnvironment, { force: true });
+ }
+}
+
+export const runtimeStateConstants = {
+ backupKind: BACKUP_KIND,
+ schemaVersion: BACKUP_SCHEMA_VERSION,
+ manifestFile: MANIFEST_FILE
+};
diff --git a/site/scripts/runtime-state.mjs b/site/scripts/runtime-state.mjs
new file mode 100644
index 0000000..6dc58f7
--- /dev/null
+++ b/site/scripts/runtime-state.mjs
@@ -0,0 +1,97 @@
+#!/usr/bin/env node
+
+import {
+ createRuntimeBackup,
+ restoreRuntimeBackup,
+ verifyRuntimeBackup
+} from "./runtime-state-lib.mjs";
+
+function parseArguments(values) {
+ const options = {};
+ for (let index = 0; index < values.length; index += 1) {
+ const token = values[index];
+ if (!token.startsWith("--")) throw new Error(`Unexpected argument: ${token}`);
+ const key = token.slice(2);
+ if (key === "skip-environment") {
+ options.skipEnvironment = true;
+ continue;
+ }
+ const value = values[index + 1];
+ if (!value || value.startsWith("--")) throw new Error(`Missing value for --${key}.`);
+ options[key] = value;
+ index += 1;
+ }
+ return options;
+}
+
+function required(options, key) {
+ if (!options[key]) throw new Error(`--${key} is required.`);
+ return options[key];
+}
+
+async function main() {
+ const [command, ...rest] = process.argv.slice(2);
+ const options = parseArguments(rest);
+ let result;
+
+ if (command === "backup") {
+ result = await createRuntimeBackup({
+ dataRoot: required(options, "data-root"),
+ mediaRoot: required(options, "media-root"),
+ backupRoot: required(options, "backup-root"),
+ runId: required(options, "run-id"),
+ environmentFile: options["environment-file"]
+ });
+ } else if (command === "verify") {
+ result = await verifyRuntimeBackup({
+ backup: required(options, "backup")
+ });
+ } else if (command === "restore") {
+ result = await restoreRuntimeBackup({
+ backup: required(options, "backup"),
+ dataDestination: required(options, "data-destination"),
+ mediaDestination: required(options, "media-destination"),
+ environmentDestination: options["environment-destination"],
+ skipEnvironment: options.skipEnvironment === true
+ });
+ } else {
+ throw new Error("Command must be backup, verify, or restore.");
+ }
+
+ const summary = command === "backup"
+ ? {
+ command,
+ backup: result.backup,
+ manifestSha256: result.manifestSha256,
+ quickCheck: result.manifest.database.quickCheck,
+ mediaCount: result.manifest.media.count,
+ mediaBytes: result.manifest.media.totalBytes,
+ environmentIncluded: Boolean(result.manifest.environment)
+ }
+ : command === "verify"
+ ? {
+ command,
+ backup: result.backup,
+ manifestSha256: result.manifestSha256,
+ quickCheck: result.quickCheck,
+ mediaCount: result.manifest.media.count,
+ mediaBytes: result.manifest.media.totalBytes,
+ environmentIncluded: Boolean(result.manifest.environment)
+ }
+ : {
+ command,
+ dataDestination: result.dataDestination,
+ mediaDestination: result.mediaDestination,
+ environmentDestination: result.environmentDestination,
+ manifestSha256: result.manifestSha256,
+ quickCheck: result.quickCheck,
+ mediaCount: result.mediaCount
+ };
+
+ process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
+}
+
+main().catch((error) => {
+ process.stderr.write(`Runtime-state operation failed: ${error instanceof Error ? error.message : String(error)}\n`);
+ process.exitCode = 1;
+});
diff --git a/site/scripts/runtime-state.test.mjs b/site/scripts/runtime-state.test.mjs
new file mode 100644
index 0000000..451ec3b
--- /dev/null
+++ b/site/scripts/runtime-state.test.mjs
@@ -0,0 +1,199 @@
+import assert from "node:assert/strict";
+import { mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import test from "node:test";
+import { DatabaseSync } from "node:sqlite";
+
+import {
+ createRuntimeBackup,
+ restoreRuntimeBackup,
+ verifyRuntimeBackup
+} from "./runtime-state-lib.mjs";
+
+function fixture() {
+ const root = mkdtempSync(path.join(tmpdir(), "woodsmith-runtime-state-test-"));
+ const dataRoot = path.join(root, "source-data");
+ const mediaRoot = path.join(root, "source-media");
+ const backupRoot = path.join(root, "backups");
+ const environmentFile = path.join(root, "runtime.env");
+ mkdirSync(dataRoot, { recursive: true });
+ mkdirSync(path.join(mediaRoot, "Furniture", "Tables"), { recursive: true });
+ mkdirSync(path.join(mediaRoot, "Cabinets"), { recursive: true });
+
+ const databasePath = path.join(dataRoot, "woodsmith.sqlite");
+ const database = new DatabaseSync(databasePath);
+ database.exec("CREATE TABLE evidence (id INTEGER PRIMARY KEY, value TEXT NOT NULL)");
+ database.prepare("INSERT INTO evidence (value) VALUES (?)").run("before-backup");
+ database.close();
+
+ writeFileSync(path.join(mediaRoot, "Furniture", "Tables", "pastry-table.jpg"), "pastry-table-original");
+ writeFileSync(path.join(mediaRoot, "Cabinets", "pantry.jpg"), "pantry-original");
+ writeFileSync(environmentFile, "TEST_ONLY=fake-value\n", { mode: 0o600 });
+ return { root, dataRoot, mediaRoot, backupRoot, environmentFile, databasePath };
+}
+
+function readEvidence(databasePath) {
+ const database = new DatabaseSync(databasePath, { readOnly: true });
+ try {
+ return database.prepare("SELECT value FROM evidence ORDER BY id").all().map((row) => row.value);
+ } finally {
+ database.close();
+ }
+}
+
+test("paired backup verifies and restores the pre-mutation database, media, and environment", async () => {
+ const input = fixture();
+ try {
+ const created = await createRuntimeBackup({
+ dataRoot: input.dataRoot,
+ mediaRoot: input.mediaRoot,
+ backupRoot: input.backupRoot,
+ environmentFile: input.environmentFile,
+ runId: "fixture-001"
+ });
+ const verified = await verifyRuntimeBackup({ backup: created.backup });
+ assert.equal(verified.quickCheck, "ok");
+ assert.equal(verified.manifest.media.count, 2);
+
+ const sourceDatabase = new DatabaseSync(input.databasePath);
+ sourceDatabase.prepare("UPDATE evidence SET value = ?").run("after-backup");
+ sourceDatabase.close();
+ writeFileSync(path.join(input.mediaRoot, "Furniture", "Tables", "pastry-table.jpg"), "mutated-source");
+ writeFileSync(input.environmentFile, "TEST_ONLY=mutated\n");
+
+ const restoredData = path.join(input.root, "restored-data");
+ const restoredMedia = path.join(input.root, "restored-media");
+ const restoredEnvironment = path.join(input.root, "restored.env");
+ const restored = await restoreRuntimeBackup({
+ backup: created.backup,
+ dataDestination: restoredData,
+ mediaDestination: restoredMedia,
+ environmentDestination: restoredEnvironment
+ });
+
+ assert.equal(restored.quickCheck, "ok");
+ assert.deepEqual(readEvidence(path.join(restoredData, "woodsmith.sqlite")), ["before-backup"]);
+ assert.equal(
+ readFileSync(path.join(restoredMedia, "Furniture", "Tables", "pastry-table.jpg"), "utf8"),
+ "pastry-table-original"
+ );
+ assert.equal(readFileSync(restoredEnvironment, "utf8"), "TEST_ONLY=fake-value\n");
+ } finally {
+ rmSync(input.root, { recursive: true, force: true });
+ }
+});
+
+test("verification rejects a modified backup and restore creates no destination", async () => {
+ const input = fixture();
+ try {
+ const created = await createRuntimeBackup({
+ dataRoot: input.dataRoot,
+ mediaRoot: input.mediaRoot,
+ backupRoot: input.backupRoot,
+ runId: "fixture-002"
+ });
+ writeFileSync(path.join(created.backup, "media", "Cabinets", "pantry.jpg"), "tampered");
+ await assert.rejects(
+ verifyRuntimeBackup({ backup: created.backup }),
+ /hash or size mismatch/
+ );
+
+ const restoredData = path.join(input.root, "rejected-data");
+ const restoredMedia = path.join(input.root, "rejected-media");
+ await assert.rejects(
+ restoreRuntimeBackup({
+ backup: created.backup,
+ dataDestination: restoredData,
+ mediaDestination: restoredMedia,
+ skipEnvironment: true
+ }),
+ /hash or size mismatch/
+ );
+ assert.equal(readFileSync(input.databasePath).length > 0, true);
+ assert.throws(() => readFileSync(path.join(restoredData, "woodsmith.sqlite")));
+ assert.throws(() => readFileSync(path.join(restoredMedia, "Cabinets", "pantry.jpg")));
+ } finally {
+ rmSync(input.root, { recursive: true, force: true });
+ }
+});
+
+test("backup and restore refuse to overwrite existing destinations", async () => {
+ const input = fixture();
+ try {
+ const created = await createRuntimeBackup({
+ dataRoot: input.dataRoot,
+ mediaRoot: input.mediaRoot,
+ backupRoot: input.backupRoot,
+ runId: "fixture-003"
+ });
+ await assert.rejects(
+ createRuntimeBackup({
+ dataRoot: input.dataRoot,
+ mediaRoot: input.mediaRoot,
+ backupRoot: input.backupRoot,
+ runId: "fixture-003"
+ }),
+ /refusing to overwrite/
+ );
+
+ const existingData = path.join(input.root, "existing-data");
+ mkdirSync(existingData);
+ await assert.rejects(
+ restoreRuntimeBackup({
+ backup: created.backup,
+ dataDestination: existingData,
+ mediaDestination: path.join(input.root, "new-media"),
+ skipEnvironment: true
+ }),
+ /refusing to overwrite/
+ );
+ } finally {
+ rmSync(input.root, { recursive: true, force: true });
+ }
+});
+
+test("manifest traversal is rejected before restored files are written", async () => {
+ const input = fixture();
+ try {
+ const created = await createRuntimeBackup({
+ dataRoot: input.dataRoot,
+ mediaRoot: input.mediaRoot,
+ backupRoot: input.backupRoot,
+ runId: "fixture-004"
+ });
+ const manifestPath = path.join(created.backup, "manifest.json");
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
+ manifest.media.files[0].path = "../escape.jpg";
+ writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
+ await assert.rejects(
+ verifyRuntimeBackup({ backup: created.backup }),
+ /Unsafe backup path/
+ );
+ } finally {
+ rmSync(input.root, { recursive: true, force: true });
+ }
+});
+
+test("failed backup removes its partial directory", async () => {
+ const input = fixture();
+ try {
+ rmSync(input.mediaRoot, { recursive: true, force: true });
+ mkdirSync(input.mediaRoot);
+ await assert.rejects(
+ createRuntimeBackup({
+ dataRoot: input.dataRoot,
+ mediaRoot: input.mediaRoot,
+ backupRoot: input.backupRoot,
+ runId: "fixture-005"
+ }),
+ /contains no regular files/
+ );
+ assert.deepEqual(
+ readdirSync(input.backupRoot).filter((name) => name.includes("partial")),
+ []
+ );
+ } finally {
+ rmSync(input.root, { recursive: true, force: true });
+ }
+});
diff --git a/synology-nas-deploy.md b/synology-nas-deploy.md
index dcd6e7e..4245fa5 100644
--- a/synology-nas-deploy.md
+++ b/synology-nas-deploy.md
@@ -31,14 +31,14 @@ Deploy Beaman Woodworks from `/volume2/docker_ssd/woodsmith/` so that:
Create these once on the NAS:
```bash
-mkdir -p /volume2/docker_ssd/woodsmith/{site/data,cache/next-image,releases,backups}
+mkdir -p /volume2/docker_ssd/woodsmith/{site/data,cache/next-image,releases,backups/runtime,restores}
```
Then ensure the container user can write to `site/data`, `cache`, and the real NAS photo library:
```bash
-chown -R 1026:100 /volume2/docker_ssd/woodsmith/site/data /volume2/docker_ssd/woodsmith/cache /volume1/homes/Cooper/Photos/Dad_Woodworking_09262025
-chmod -R u+rwX,g+rwX /volume2/docker_ssd/woodsmith/site/data /volume2/docker_ssd/woodsmith/cache /volume1/homes/Cooper/Photos/Dad_Woodworking_09262025
+chown -R 1026:100 /volume2/docker_ssd/woodsmith/site/data /volume2/docker_ssd/woodsmith/cache /volume2/docker_ssd/woodsmith/backups /volume2/docker_ssd/woodsmith/restores /volume1/homes/Cooper/Photos/Dad_Woodworking_09262025
+chmod -R u+rwX,g+rwX /volume2/docker_ssd/woodsmith/site/data /volume2/docker_ssd/woodsmith/cache /volume2/docker_ssd/woodsmith/backups /volume2/docker_ssd/woodsmith/restores /volume1/homes/Cooper/Photos/Dad_Woodworking_09262025
```
## `.env`
@@ -297,17 +297,132 @@ Mutation-dependent success/error states require `visual-audit/scripts/prepare-sn
Full commands, artifacts, permissions, retention, and acceptance criteria are in [`docs/visual-archive.md`](docs/visual-archive.md).
-## Backup guidance
-
-Because the dashboard can mutate the shared media library, back up these paths together:
-
-- `site/data/`
-- `/volume1/homes/Cooper/Photos/Dad_Woodworking_09262025`
-- `.env`
-
-A SQLite backup without the matching media tree is no longer sufficient for full recovery.
+## Paired backup and recovery
+
+Because Studio can update both SQLite references and the shared media library, recoverable state consists of all three of these surfaces:
+
+- `site/data/woodsmith.sqlite`, captured online with SQLite `VACUUM INTO`
+- `/volume1/homes/Cooper/Photos/Dad_Woodworking_09262025`, copied byte-for-byte with per-file SHA-256 evidence
+- `.env`, copied into the restricted backup without printing its contents
+
+The candidate image includes `/app/site/ops/runtime-state.mjs`. The tool refuses overlapping source/output paths, symlinks, special files, changing source media, existing destinations, path traversal, missing or extra backup files, failed hashes, and failed SQLite `quick_check`. It writes through a hidden partial directory and promotes the backup only after verification.
+
+### Create and verify a paired backup
+
+Build the exact candidate first; building does not touch mounted production state. Then set only nonsecret shell values and run the tool with networking disabled. Keep the data mount writable so SQLite can coordinate safely with the live WAL; the tool itself opens the source database read-only and writes only to the backup mount.
+
+```bash
+cd /volume2/docker_ssd/woodsmith
+
+export PUID=1026
+export PGID=100
+export RUN_ID="$(date -u '+%Y%m%dT%H%M%SZ')"
+export CANDIDATE_IMAGE="woodsmith:candidate-$(git rev-parse --short=8 HEAD)"
+
+mkdir -p backups/runtime restores
+chmod 700 backups backups/runtime restores
+chown -R "${PUID}:${PGID}" backups restores
+
+docker run --rm \
+ --network none \
+ --read-only \
+ --user "${PUID}:${PGID}" \
+ --cap-drop ALL \
+ --security-opt no-new-privileges:true \
+ --tmpfs /tmp:rw,noexec,nosuid,nodev,size=64m,mode=1777 \
+ -v /volume2/docker_ssd/woodsmith/site/data:/state/data:rw \
+ -v /volume1/homes/Cooper/Photos/Dad_Woodworking_09262025:/state/media:ro \
+ -v /volume2/docker_ssd/woodsmith/backups/runtime:/state/backups:rw \
+ -v /volume2/docker_ssd/woodsmith/.env:/state/config/runtime.env:ro \
+ --entrypoint node \
+ "$CANDIDATE_IMAGE" \
+ --experimental-sqlite \
+ /app/site/ops/runtime-state.mjs backup \
+ --data-root /state/data \
+ --media-root /state/media \
+ --backup-root /state/backups \
+ --environment-file /state/config/runtime.env \
+ --run-id "$RUN_ID"
+
+docker run --rm \
+ --network none \
+ --read-only \
+ --user "${PUID}:${PGID}" \
+ --cap-drop ALL \
+ --security-opt no-new-privileges:true \
+ -v /volume2/docker_ssd/woodsmith/backups/runtime:/state/backups:ro \
+ --entrypoint node \
+ "$CANDIDATE_IMAGE" \
+ --experimental-sqlite \
+ /app/site/ops/runtime-state.mjs verify \
+ --backup "/state/backups/woodsmith-runtime-${RUN_ID}"
+```
+
+Record the emitted manifest SHA-256, media count, byte count, and `quickCheck=ok`. The backup directory and environment copy are mode-restricted and must never be added to Git, a public archive, or CI artifacts.
+
+### Prove a staging restore
+
+Before promotion, restore the backup into new paths on the same volumes as the eventual live paths. This validates recovery without overwriting or mounting production state:
+
+```bash
+export DATA_STAGE="/volume2/docker_ssd/woodsmith/restores/${RUN_ID}-data"
+export MEDIA_STAGE="/volume1/homes/Cooper/Photos/.woodsmith-restore-${RUN_ID}"
+export ENV_STAGE="/volume2/docker_ssd/woodsmith/restores/${RUN_ID}.env"
+
+test ! -e "$DATA_STAGE"
+test ! -e "$MEDIA_STAGE"
+test ! -e "$ENV_STAGE"
+
+docker run --rm \
+ --network none \
+ --read-only \
+ --user "${PUID}:${PGID}" \
+ --cap-drop ALL \
+ --security-opt no-new-privileges:true \
+ --tmpfs /tmp:rw,noexec,nosuid,nodev,size=64m,mode=1777 \
+ -v /volume2/docker_ssd/woodsmith/backups/runtime:/state/backups:ro \
+ -v /volume2/docker_ssd/woodsmith/restores:/state/restores:rw \
+ -v /volume1/homes/Cooper/Photos:/state/photos:rw \
+ --entrypoint node \
+ "$CANDIDATE_IMAGE" \
+ --experimental-sqlite \
+ /app/site/ops/runtime-state.mjs restore \
+ --backup "/state/backups/woodsmith-runtime-${RUN_ID}" \
+ --data-destination "/state/restores/${RUN_ID}-data" \
+ --media-destination "/state/photos/.woodsmith-restore-${RUN_ID}" \
+ --environment-destination "/state/restores/${RUN_ID}.env"
+```
+
+The restore command rechecks the complete backup before writing, validates the restored database and every copied file, and compensates its own newly created outputs if promotion fails. Do not swap these paths during an ordinary pre-deployment proof; keep them as verified rollback inputs until the candidate passes.
+
+### Stopped-service recovery or rollback
+
+Only when recovery is actually required, stop the app and move the verified staging paths into place. Keep the previous state under unique hold names on the same filesystems; never delete it during the release window.
+
+```bash
+export DATA_HOLD="/volume2/docker_ssd/woodsmith/restores/${RUN_ID}-previous-data"
+export MEDIA_HOLD="/volume1/homes/Cooper/Photos/.woodsmith-previous-${RUN_ID}"
+export ENV_HOLD="/volume2/docker_ssd/woodsmith/restores/${RUN_ID}-previous.env"
+
+test ! -e "$DATA_HOLD"
+test ! -e "$MEDIA_HOLD"
+test ! -e "$ENV_HOLD"
+
+docker compose --env-file .env -f docker-compose.synology.yml stop woodsmith
+
+mv site/data "$DATA_HOLD"
+mv "$DATA_STAGE" site/data
+mv /volume1/homes/Cooper/Photos/Dad_Woodworking_09262025 "$MEDIA_HOLD"
+mv "$MEDIA_STAGE" /volume1/homes/Cooper/Photos/Dad_Woodworking_09262025
+mv .env "$ENV_HOLD"
+mv "$ENV_STAGE" .env
+
+docker compose --env-file .env -f docker-compose.synology.yml up -d --force-recreate woodsmith
+```
+
+If candidate validation fails, retag the recorded rollback image, stop the service, move the failed candidate state aside, move the three hold paths back, and recreate the service. Run SQLite `quick_check`, mounted-media checks, internal/public routes, logs, and persistence/recreation checks again before declaring rollback complete.
-Create the paired backup before a large batch reorganization. The database operation ledger can reverse application-managed changes, but it is not a replacement for a filesystem snapshot when files are changed outside the application or storage fails mid-operation.
+Create the paired backup before deployment and before a large media reorganization. The database operation ledger can reverse application-managed changes, but it is not a substitute for a verified filesystem copy when storage fails or files change outside the application.
The Docker context excludes SQLite databases, WAL/SHM files, backups, and media-AI caches. In addition, `site/scripts/safe-build.mjs` forces every Next build to use disposable temporary data/media roots and rejects standalone output containing a database, WAL/SHM, backup, or test/spec source file. Runtime state and build-only tests are never copied into an image layer; the image creates an empty `/app/site/data` directory that is populated only by the writable production bind mount. Seed upgrades are non-destructive for existing Studio-edited records, so rebuilds should preserve page/settings edits when the same mounted database is active.
diff --git a/woodsmith_DeepWiki_Merged_03222026.md b/woodsmith_DeepWiki_Merged_03222026.md
index 8858fd7..e6bdc9d 100644
--- a/woodsmith_DeepWiki_Merged_03222026.md
+++ b/woodsmith_DeepWiki_Merged_03222026.md
@@ -113,6 +113,8 @@ The master media library lives outside the app bundle and outside `docker_ssd`.
Media automation is provider-agnostic and local-first. `tools/media-ai-sidecar/` scans the configured library, excludes Synology/hidden sidecars, stores SHA-256 and perceptual hashes plus generated 768px review thumbnails outside the source tree, computes true image-pixel and text embeddings in a shared SentenceTransformers CLIP space, applies deterministic visual clustering, and can use Ollama or Gemini only for ambiguity arbitration. Bounded guided trainer runs resume changed or uncached files, heavy work is serialized, and partial cluster updates preserve unrelated cluster state. The compact media desk exposes Organize selected, Train selected, Improve page, and Continue library as the primary workflows, with raw scan/analyze/embed/cluster/rank/dry-run controls kept under Advanced actions. The status card shows provider/cache/training totals; AI-state filters, evidence and margin breakdowns, rejection memory, and J/K/F/P/U/R/I/E/C/S/Shift+S/A keyboard controls remain available.
+Runtime recovery is paired rather than database-only. The production image includes `/app/site/ops/runtime-state.mjs`, which creates an online-consistent SQLite snapshot, copies and hashes the matching media tree, optionally protects a copy of `.env`, rejects symlinks and changing sources, and writes an exact manifest. Verification checks every hash, rejects missing or extra files, and runs SQLite `quick_check`; restore refuses existing targets and writes only to new staging paths before an explicit stopped-service swap.
+
SQLite media metadata stores analysis schema/provider/model/time, object/class/context/stage, tags and alt draft, candidate confidence/evidence, uncertainty, unsafe reason, embedding provider/model/version/hash/time, cluster ID/representative/score/label, human-review reason, accepted training labels, and rejected training labels. The ranker combines visual similarity, VLM candidate confidence, lexical overlap, verified cluster propagation, folder context, and manual priors, then subtracts negative reviewer signals. It requires a configurable minimum score and runner-up margin. Context/detail/ambiguous or reviewer-rejected matches are not proposed. Manual reviewed assignment plus accurate alt text remains the only public publishing gate.
## Commerce and operations
From 132c1d75ed4c7a46b1c63a24c71bdd6cbcdbe2b3 Mon Sep 17 00:00:00 2001
From: Cooper Beaman <55124795+lowestprime@users.noreply.github.com>
Date: Tue, 14 Jul 2026 19:28:38 -0700
Subject: [PATCH 18/43] fix(qa): bound and isolate visual archives
---
PLANS.md | 2 +-
README.md | 4 +-
admin.md | 2 +-
docker-compose.visual-audit-lab.yml | 2 +-
docker-compose.visual-audit-live.yml | 2 +-
docs/visual-archive.md | 16 +-
synology-nas-deploy.md | 4 +-
.../scripts/run-local-disposable-smoke.ps1 | 346 ++++++++++++++----
visual-audit/src/browser-launch.test.ts | 19 +
visual-audit/src/browser-launch.ts | 16 +
visual-audit/src/coverage-matrix.test.ts | 55 +++
visual-audit/src/coverage-matrix.ts | 69 ++++
visual-audit/src/report-selection.test.ts | 77 ++++
visual-audit/src/report-selection.ts | 97 +++++
visual-audit/src/report.ts | 37 +-
visual-audit/src/run.ts | 94 +++--
visual-audit/src/types.ts | 2 +
visual-audit/src/validate.ts | 64 +++-
woodsmith_DeepWiki_Merged_03222026.md | 2 +-
19 files changed, 755 insertions(+), 155 deletions(-)
create mode 100644 visual-audit/src/browser-launch.test.ts
create mode 100644 visual-audit/src/browser-launch.ts
create mode 100644 visual-audit/src/coverage-matrix.test.ts
create mode 100644 visual-audit/src/coverage-matrix.ts
create mode 100644 visual-audit/src/report-selection.test.ts
create mode 100644 visual-audit/src/report-selection.ts
diff --git a/PLANS.md b/PLANS.md
index fbdc0d1..0e7f7b5 100644
--- a/PLANS.md
+++ b/PLANS.md
@@ -11,7 +11,7 @@
| Audit and category design | DONE / COMMITTED | `30c87f6` records the evidence audit; `959a2ca` adds managed visual category icons and tests. |
| Structured Studio and public content | DONE / COMMITTED | `8b2a39b` adds structured footer/home services, typed public pricing/inquiry/review behavior, visual media selection, compact Page/Piece/Process editing, media roles, and public build records. |
| Conceptual proportional 3D preview | DONE / COMMITTED | `9e143da` adds route-local R3F templates, view/camera controls, exact dimensions, deterministic SVG/text fallbacks, demand rendering, and estimator tests. |
-| Deterministic visual archive | IMPLEMENTED / LOCAL DOCKER VALIDATED; NAS RUNTIME VALIDATION PENDING | The QA slice adds protected bounded inventory, exact same-origin token attachment, evidence-based aborted/blocked-request classification, keyboard skip-link focus/activation, strict live-readonly and isolated snapshot-lab modes, pinned Playwright Docker, route/link reconciliation, high-resolution overlapping tiles with seam checks, deep UI state capture, searchable HTML, bounded streamed PDFKit atlases, checksums, diffs, locks, and retention. The latest disposable live-readonly smoke validated 310 captures across 27 routes, 54 skip-link states, 842 checksummed artifacts, 29 blocked and zero successful unsafe requests, zero unexpected diagnostics, zero cross-origin traffic, and 126 opaque shareable image assets. The clone-only snapshot lab validated 384 captures across 39 routes, exactly one commission-draft save plus cleanup delete, zero residual drafts, SQLite `quick_check`, unchanged source data/media hashes, zero unexpected diagnostics, and full cleanup. NAS backup/candidate/full archive/rollback evidence remains required before deployment. |
+| Deterministic visual archive | IMPLEMENTED / LOCAL DOCKER VALIDATED; FULL CURRENT-IMAGE AND NAS VALIDATION PENDING | The QA slice adds protected bounded inventory, exact same-origin token attachment, evidence-based aborted/blocked-request classification, keyboard skip-link focus/activation, strict live-readonly and isolated snapshot-lab modes, pinned Playwright Docker, route/link reconciliation, high-resolution overlapping tiles with seam checks, deep canonical UI state capture, complete restricted searchable HTML, deterministic representative PDF/shareable atlases, checksums, diffs, locks, and retention. Chromium now uses the explicit shared-memory mount and a 512 MiB browser scratch ceiling. The latest disposable live-readonly smoke validated 310 captures across 27 routes, 54 skip-link states, 602 checksummed artifacts, 29 blocked and zero successful unsafe requests, zero unexpected diagnostics, zero cross-origin traffic, 115 restricted report representatives, and 40 shareable representatives. The repeatable clone-only snapshot lab validated 384 captures across 39 routes, exactly one commission-draft save plus cleanup delete, zero residual drafts, SQLite `quick_check`, unchanged source data/media hashes, an unchanged cloned media tree, zero unexpected diagnostics, 866 checksummed artifacts, and full cleanup. A stronger full run exposed and led to removal of an unbounded discovered-route/PDF cross-product; a fresh optimized full current-image archive remains required. NAS backup/candidate/full archive/rollback evidence remains required before deployment. |
| Typed atomic inline editing | DONE | Added a typed field registry spanning scalar, list, link, relation, media, status, and structural settings fields; trusted-origin enforcement; complete-batch validation; one SQLite transaction; optimistic expected-value conflicts; admin audit records; reset, rollback, keyboard, focus, and one-step Undo behavior. Thirty-two application tests, typecheck, lint, production build, and a disposable authenticated Chromium save/undo/reset/conflict/origin smoke pass. Final-candidate archive evidence remains a release gate. |
| Secure resumable custom requests | DONE / LOCAL DOCKER VALIDATED | Added the ten-step autosaved workflow, verified-account server drafts, idempotent server-authoritative submission, hashed submission/render quotas, private staged attachments with rollback, owner-bound generated previews, opaque project-access cookies, POST lookup without URL email, and exact dimension/category continuity into R3F. Additive schema v5, seed v6, 36 application tests, production build, image safety inspection, and disposable browser/container submission evidence pass. |
| Safe standalone and image build | DONE / LOCAL DOCKER VALIDATED | `safe-build` uses disposable build roots, excludes runtime data and test sources from Next tracing, rejects databases/backups/test files, and proves cleanup for child-failure, forbidden-output, and success paths. Docker Desktop Engine 29.6.1 and BuildKit 0.31.1 built/loaded exact candidate `woodsmith:candidate-6a1004c`; the image contains no runtime DB, env/key, test source, private evidence, audit output, or production media and starts with an empty data directory. |
diff --git a/README.md b/README.md
index 585637f..e4e8c35 100644
--- a/README.md
+++ b/README.md
@@ -31,7 +31,7 @@ Woodsmith is a self-hosted Next.js application for the Beaman Woodworks company
- Programmatic Beaman Woodworks favicon and brand mark
- Safe profile administration for renaming accounts, replacing legacy developer emails, and deleting non-current users from the dashboard
- Compact auto-hide navigation that keeps the public and dashboard workspaces usable on narrow and desktop viewports
-- A pinned two-mode Playwright visual archive with protected source/database/link inventory, evidence-based network diagnostics, keyboard skip-link states, read-only production capture, clone-only bounded mutation states, viewport-correct high-resolution tiles, searchable HTML, streamed bookmarked PDF atlases, checksums, and baseline comparisons
+- A pinned two-mode Playwright visual archive with protected source/database/link inventory, evidence-based network diagnostics, keyboard skip-link states, read-only production capture, clone-only bounded mutation states, viewport-correct high-resolution tiles, complete restricted searchable HTML, deterministic representative bookmarked PDF/shareable atlases, checksums, and baseline comparisons
- A fail-closed paired recovery tool that creates and verifies an online-consistent SQLite backup, a hashed copy of the mounted media tree, and an optional protected environment-file copy, then restores only to new staging destinations
## 📃 Production Notes
@@ -55,7 +55,7 @@ Woodsmith is a self-hosted Next.js application for the Beaman Woodworks company
- `ITC_New_Rennie_Mackintosh_Complete_Family_Pack/`: source font assets for the site typography
- `docker-compose.synology.yml`: Synology runtime model
- `visual-audit/`: pinned Playwright capture, report, comparison, validation, and NAS automation package
-- `visual-audit/scripts/run-local-disposable-smoke.ps1`: exact-image Windows smoke using fake credentials, synthetic media, non-root containers, and automatically removed volumes
+- `visual-audit/scripts/run-local-disposable-smoke.ps1`: exact-image Windows live-readonly or clone-only snapshot-lab smoke using fake credentials, synthetic media, non-root containers, and automatically removed volumes
- `site/scripts/runtime-state.mjs`: backup, verify, and staging-only restore CLI included in the production image at `/app/site/ops/runtime-state.mjs`
- `docs/visual-archive.md`: private live-readonly and snapshot-lab operating guide
- `synology-nas-deploy.md`: deployment and NAS operations guide
diff --git a/admin.md b/admin.md
index 74decd1..bb228da 100644
--- a/admin.md
+++ b/admin.md
@@ -134,7 +134,7 @@ The preview uses a dynamically loaded React Three Fiber scene with perspective/o
## Private visual archive
-The visual archive is an operational QA tool, not a Studio content panel. It inventories public and authenticated routes, captures the final rendered interfaces, verifies keyboard skip navigation, and produces restricted PNG/HTML/PDF evidence. Production capture is read-only at both browser and server layers. Save, upload, rename, delete, invoice, shipping, email, and model actions are captured only against an isolated SQLite/media clone. Read-only mutation failures are accepted only when they match an exact client policy record; aborted resources are accepted only when they carry same-origin Next.js RSC/prefetch evidence.
+The visual archive is an operational QA tool, not a Studio content panel. It inventories public and authenticated routes, captures the final rendered interfaces, verifies keyboard skip navigation, and produces restricted PNG/HTML/PDF evidence. Canonical routes retain the complete responsive/theme matrix and deep states; rendered link variants use desktop/tablet/mobile theme representatives plus archival desktop. Every raw restricted capture remains available in the searchable HTML, while the PDF and redacted edition use a recorded per-route representative selection so derived reports remain reviewable. Production capture is read-only at both browser and server layers. Save, upload, rename, delete, invoice, shipping, email, and model actions are captured only against an isolated SQLite/media clone. Read-only mutation failures are accepted only when they match an exact client policy record; aborted resources are accepted only when they carry same-origin Next.js RSC/prefetch evidence.
Use [`docs/visual-archive.md`](docs/visual-archive.md) for secret preparation, smoke/full runs, snapshot-lab setup, validation, retention, and post-deployment gates. Never upload the restricted archive or its authentication state to public CI or Git.
diff --git a/docker-compose.visual-audit-lab.yml b/docker-compose.visual-audit-lab.yml
index b640d5a..d6ebab6 100644
--- a/docker-compose.visual-audit-lab.yml
+++ b/docker-compose.visual-audit-lab.yml
@@ -94,7 +94,7 @@ services:
- /volume2/docker_ssd/woodsmith/visual-audits:/output:rw
tmpfs:
- /audit-tmp:rw,noexec,nosuid,nodev,size=64m,mode=700,uid=${PUID},gid=${PGID}
- - /tmp:rw,noexec,nosuid,nodev,size=256m,mode=1777
+ - /tmp:rw,noexec,nosuid,nodev,size=512m,mode=1777
secrets:
- admin_password
- audit_token
diff --git a/docker-compose.visual-audit-live.yml b/docker-compose.visual-audit-live.yml
index 0285ae4..3f97d87 100644
--- a/docker-compose.visual-audit-live.yml
+++ b/docker-compose.visual-audit-live.yml
@@ -30,7 +30,7 @@ services:
- /volume2/docker_ssd/woodsmith/visual-audits:/output:rw
tmpfs:
- /audit-tmp:rw,noexec,nosuid,nodev,size=64m,mode=700,uid=${PUID},gid=${PGID}
- - /tmp:rw,noexec,nosuid,nodev,size=256m,mode=1777
+ - /tmp:rw,noexec,nosuid,nodev,size=512m,mode=1777
secrets:
- admin_password
- audit_token
diff --git a/docs/visual-archive.md b/docs/visual-archive.md
index 17959fc..14ab26d 100644
--- a/docs/visual-archive.md
+++ b/docs/visual-archive.md
@@ -126,7 +126,9 @@ visual-audit/scripts/run-local-disposable-smoke.ps1 `
-CommitSha
```
-The script uses fake credentials, synthetic media, a network-isolated app, non-root containers, disabled external providers, and uniquely named disposable volumes. It validates the archive, rejects image-cache and unhandled-rejection errors, and removes every container and volume in `finally`.
+The default `live-readonly` mode uses fake credentials, synthetic media, a network-isolated app, non-root containers, disabled external providers, and uniquely named disposable volumes. It validates the archive, rejects image-cache and unhandled-rejection errors, and removes every container and volume in `finally`.
+
+Run the same current-image gate against an isolated local snapshot lab with `-TargetMode snapshot-lab`. The harness first initializes disposable source data/media, creates the lab database online with SQLite `VACUUM INTO`, copies media into distinct lab volumes, and then runs the app and audit only against those clones. Acceptance requires exactly one draft save and one cleanup delete, zero residual drafts, SQLite `quick_check`, unchanged source data/media fingerprints, an unchanged cloned media tree, and complete container/volume cleanup.
## Full Live Archive
@@ -148,7 +150,9 @@ Full mode covers dark and light themes at:
- 430 x 932, 390 x 844, 375 x 812, and 320 x 720 mobile profiles at DPR 3;
- 2560 x 1440 archival desktop at DPR 2.
-Every route receives a deterministic keyboard skip-link focus and activation check. Deep archival-dark capture opens disclosures, lightboxes, media pickers, inline editing, Studio editors, media inspectors, validation states, visualizer boundaries, and the element atlas. Long pages and independently scrollable surfaces use 12 percent overlapping raw tiles, stitched PNGs, tile manifests, and image-correlation seam checks.
+Source- and database-inventoried canonical routes use the complete matrix above. Rendered same-origin links discovered from those pages use desktop, tablet, and mobile representatives in both themes plus archival desktop dark. This accounts for every finite link target without repeating deep captures for query variants that render the same page template. `coverage-plan.json` records both matrices and the sampling rationale.
+
+Every captured route state receives a deterministic keyboard skip-link focus and activation check. Deep archival-dark capture on canonical routes opens disclosures, lightboxes, media pickers, inline editing, Studio editors, media inspectors, validation states, visualizer boundaries, and the element atlas. Long pages and independently scrollable surfaces use 12 percent overlapping raw tiles, stitched PNGs, tile manifests, and image-correlation seam checks. Chromium uses the container's explicit shared-memory mount rather than spilling into the bounded `/tmp` tmpfs; the audit runner has a 512 MiB scratch ceiling for browser profiles and temporary files.
## Snapshot Lab
@@ -185,6 +189,7 @@ visual-audits//
png/
report/index.html
report/print.html
+ report/selection.json
report/report-index.json
woodmat-visual-atlas.pdf
shareable/index.html
@@ -192,16 +197,16 @@ visual-audits//
shareable/woodmat-visual-atlas-redacted.pdf
```
-Raw tiles and tile manifests live beside their stitched capture. The HTML report is searchable and includes linked contents. PDFKit streams one bounded image slice per A3 page, with selectable route/state metadata and native capture bookmarks, so report generation does not decode the entire atlas in one browser page. Report reruns clear only their generated report/shareable directories and atomically replace both PDFs. The PNG and raw-tile archive remains the highest-resolution source of truth.
+Raw tiles and tile manifests live beside their stitched capture. The restricted HTML report remains complete and searchable across every capture. `report/selection.json` records a deterministic maximum of 16 print representatives per authenticated route, covering route, theme, viewport, accessibility, header, and available deep-state families. PDFKit streams only that reviewed selection as bounded A3 image slices with selectable route/state metadata and native bookmarks. This prevents query variants and element atlases from expanding derived PDFs without bound; no raw restricted PNG is removed. Report reruns clear only their generated report/shareable directories and atomically replace both PDFs. The PNG/raw-tile tree and complete restricted HTML remain the highest-resolution sources of truth.
-The shareable edition includes anonymous, nonsensitive captures only. Its copied image assets use opaque sequence names and preserve only a safe image extension; captions and PDF labels do not reuse source filenames. Review it before moving it outside the restricted NAS archive.
+The shareable edition includes the same deterministic representatives filtered to anonymous, nonsensitive captures only. Its copied image assets use opaque sequence names and preserve only a safe image extension; captions and PDF labels do not reuse source filenames. The redacted manifest records both source and selected counts. Review it before moving it outside the restricted NAS archive.
## Validation Contract
`dist/validate.js` fails the run for:
- incomplete or truncated inventory;
-- missing route/theme/viewport combinations;
+- missing canonical or discovered-link route/theme/viewport combinations;
- missing focused or activated skip-link evidence for any rendered route;
- discovered same-origin links not captured;
- missing deep states that were present in the rendered surface inventory;
@@ -209,6 +214,7 @@ The shareable edition includes anonymous, nonsensitive captures only. Its copied
- invalid, blank, missing, or duplicate PNG captures;
- raw-tile coverage gaps or seam-correlation failures;
- missing HTML contents targets, PDFs, PDF pages, or bookmark trees;
+- a report selection count mismatch, unknown capture key, or omitted route;
- commit/run/mode mismatches;
- successful unsafe live requests;
- exact secret values found in any output artifact;
diff --git a/synology-nas-deploy.md b/synology-nas-deploy.md
index 4245fa5..69f2550 100644
--- a/synology-nas-deploy.md
+++ b/synology-nas-deploy.md
@@ -160,7 +160,7 @@ docker buildx build \
Optional local container smoke test:
-For the exact release-candidate app and visual-audit images on Windows, use `visual-audit/scripts/run-local-disposable-smoke.ps1`. It runs without production mounts or credentials and removes its temporary data, media, output, and secret volumes after validation.
+For the exact release-candidate app and visual-audit images on Windows, use `visual-audit/scripts/run-local-disposable-smoke.ps1`. Its default live-readonly mode runs without production mounts or credentials. Add `-TargetMode snapshot-lab` to prove the bounded mutation flow against separate online-cloned SQLite and copied synthetic-media volumes. Both modes remove their temporary app, data, media, output, and secret resources after validation.
```bash
docker run --rm -p 3002:3002 \
@@ -291,7 +291,7 @@ export AUDIT_SCOPE=smoke
visual-audit/scripts/run-live-audit.sh
```
-The same run ID is used by capture, baseline comparison, report generation, and validation. Report generation writes searchable HTML and streams bounded A3 PDF pages with native bookmarks instead of loading the entire image atlas into Chromium. Do not use `docker compose config --environment`; the Synology Compose build does not support it. The secret preparation script is noninteractive and zsh-safe, reads the active Studio password without displaying it, and writes ignored mode-600 files.
+The same run ID is used by capture, baseline comparison, report generation, and validation. Canonical routes retain the full viewport/theme/deep matrix; discovered link variants use recorded desktop/tablet/mobile theme representatives plus archival desktop. The restricted PNG tree and searchable HTML remain complete. `report/selection.json` records the bounded per-route set used by the A3 bookmarked PDFs and redacted edition. Chromium uses the Compose `ipc: host` shared-memory path, and the audit runner receives a 512 MiB `/tmp` scratch ceiling. Do not use `docker compose config --environment`; the Synology Compose build does not support it. The secret preparation script is noninteractive and zsh-safe, reads the active Studio password without displaying it, and writes ignored mode-600 files.
Mutation-dependent success/error states require `visual-audit/scripts/prepare-snapshot-lab.sh` followed by `visual-audit/scripts/run-snapshot-lab.sh`. The lab uses a `VACUUM INTO` database clone, reflink/full-copy media, verified run markers, an internal Docker network, and disabled external integrations. It never mounts production data or media read-write.
diff --git a/visual-audit/scripts/run-local-disposable-smoke.ps1 b/visual-audit/scripts/run-local-disposable-smoke.ps1
index b3fd586..69f0fa7 100644
--- a/visual-audit/scripts/run-local-disposable-smoke.ps1
+++ b/visual-audit/scripts/run-local-disposable-smoke.ps1
@@ -3,6 +3,8 @@ param(
[string]$AppImage = "woodsmith:local-gate",
[string]$AuditImage = "woodsmith-visual-audit:local-gate",
[string]$CommitSha = "",
+ [ValidateSet("live-readonly", "snapshot-lab")]
+ [string]$TargetMode = "live-readonly",
[ValidateSet("smoke", "full")]
[string]$Scope = "smoke"
)
@@ -52,12 +54,24 @@ if ($appBuildSha -ne ("WOODSMITH_BUILD_SHA=" + $CommitSha)) {
$shortSha = $CommitSha.Substring(0, 8)
$stamp = [DateTime]::UtcNow.ToString("yyyyMMddTHHmmssZ")
$suffix = ([Guid]::NewGuid().ToString("N")).Substring(0, 10)
-$runId = "local-$Scope-$stamp-$shortSha-$suffix"
+$modeLabel = if ($TargetMode -eq "snapshot-lab") { "lab" } else { "readonly" }
+$runId = "local-$modeLabel-$Scope-$stamp-$shortSha-$suffix"
$appContainer = "woodsmith-local-audit-app-$suffix"
$dataVolume = "woodsmith-local-audit-data-$suffix"
$mediaVolume = "woodsmith-local-audit-media-$suffix"
+$labDataVolume = "woodsmith-local-audit-lab-data-$suffix"
+$labMediaVolume = "woodsmith-local-audit-lab-media-$suffix"
$outputVolume = "woodsmith-local-audit-output-$suffix"
$secretVolume = "woodsmith-local-audit-secrets-$suffix"
+$managedVolumes = @(
+ $dataVolume,
+ $mediaVolume,
+ $outputVolume,
+ $secretVolume
+)
+if ($TargetMode -eq "snapshot-lab") {
+ $managedVolumes += @($labDataVolume, $labMediaVolume)
+}
$password = [Convert]::ToHexString(
[Security.Cryptography.RandomNumberGenerator]::GetBytes(36)
).ToLowerInvariant()
@@ -72,22 +86,34 @@ $failed = $true
try {
Write-Output "AUDIT_RUN_ID=$runId"
- foreach ($volume in @(
- $dataVolume,
- $mediaVolume,
- $outputVolume,
- $secretVolume
- )) {
+ foreach ($volume in $managedVolumes) {
Invoke-Docker volume create $volume | Out-Null
}
- $initArguments = @(
- "run", "--rm", "--network", "none", "--user", "0",
+ $initMounts = @(
"-v", ($dataVolume + ":/data"),
"-v", ($mediaVolume + ":/media"),
- "-v", ($outputVolume + ":/output"),
+ "-v", ($outputVolume + ":/output")
+ )
+ $initTargets = "/data /media /output"
+ if ($TargetMode -eq "snapshot-lab") {
+ $initMounts += @(
+ "-v", ($labDataVolume + ":/lab-data"),
+ "-v", ($labMediaVolume + ":/lab-media")
+ )
+ $initTargets += " /lab-data /lab-media"
+ }
+ $initArguments = @(
+ "run", "--rm", "--network", "none", "--user", "0"
+ )
+ $initArguments += $initMounts
+ $initArguments += @(
"--entrypoint", "/bin/sh", $AppImage, "-c",
- "chown 1001:1001 /data /media /output && chmod 700 /data /output && chmod 755 /media"
+ ("chown 1001:1001 " + $initTargets +
+ " && chmod 700 /data /output" +
+ $(if ($TargetMode -eq "snapshot-lab") { " /lab-data" } else { "" }) +
+ " && chmod 755 /media" +
+ $(if ($TargetMode -eq "snapshot-lab") { " /lab-media" } else { "" }))
)
Invoke-Docker @initArguments
@@ -162,64 +188,174 @@ const files = [
)
Invoke-Docker @mediaArguments
- $appArguments = @(
- "run", "-d", "--name", $appContainer,
- "--network", "none",
- "--read-only",
- "--cap-drop", "ALL",
- "--security-opt", "no-new-privileges:true",
- "--tmpfs", "/tmp:rw,noexec,nosuid,nodev,size=128m,mode=1777",
- "--tmpfs", "/app/site/.next/cache:rw,noexec,nosuid,nodev,size=128m,mode=700,uid=1001,gid=1001",
- "-v", ($dataVolume + ":/app/site/data:rw"),
- "-v", ($mediaVolume + ":/app/pics:ro"),
- "-v", ($secretVolume + ":/run/secrets:ro"),
- "-e", "NODE_ENV=production",
- "-e", "SELF_HOSTED=true",
- "-e", "SITE_URL=http://127.0.0.1:3002",
- "-e", "NEXT_PUBLIC_SITE_URL=http://127.0.0.1:3002",
- "-e", "MEDIA_ROOT=/app/pics",
- "-e", "DATA_ROOT=/app/site/data",
- "-e", ("STUDIO_PASSWORD=" + $password),
- "-e", ("SESSION_SECRET=" + $sessionSecret),
- "-e", "VISUAL_AUDIT_TOKEN_FILE=/run/secrets/audit_token",
- "-e", "VISUAL_AUDIT_MAX_RECORDS=5000",
- "-e", "STRIPE_SECRET_KEY=",
- "-e", "STRIPE_PUBLISHABLE_KEY=",
- "-e", "EASYPOST_API_KEY=",
- "-e", "SMTP_HOST=",
- "-e", "SMTP_USER=",
- "-e", "SMTP_PASSWORD=",
- "-e", "OPENAI_API_KEY=",
- "-e", "ENABLE_PUBLIC_AI_RENDERING=false",
- "-e", "ENABLE_AI_BACKGROUND_CLEANUP=false",
- "-e", "ENABLE_AI_MEDIA_ANALYSIS=false",
- "-e", "ENABLE_EMBEDDING_SEARCH=false",
- "-e", "ENABLE_LOCAL_IMAGE_EMBEDDINGS=false",
- "-e", "ENABLE_GEMINI_FALLBACK=false",
- "-e", "AI_PROVIDER=disabled",
- "-e", "AI_ANALYSIS_PROVIDER=disabled",
- "-e", "AI_EMBEDDING_PROVIDER=disabled",
- "-e", "AI_FALLBACK_PROVIDER=disabled",
- "-e", "LOCAL_AI_SIDECAR_URL=http://127.0.0.1:9",
- "-e", "OLLAMA_BASE_URL=http://127.0.0.1:9",
- $AppImage
- )
- Invoke-Docker @appArguments | Out-Null
+ function New-AppArguments {
+ param(
+ [string]$DataVolume,
+ [string]$MediaVolume,
+ [ValidateSet("ro", "rw")]
+ [string]$MediaAccess,
+ [string]$CloneDataVolume = ""
+ )
- $ready = $false
- for ($attempt = 1; $attempt -le 60; $attempt += 1) {
- & docker exec $appContainer node -e "fetch('http://127.0.0.1:3002/studio/login').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
- if ($LASTEXITCODE -eq 0) {
- $ready = $true
- Write-Output "APP_READY_ATTEMPT=$attempt"
- break
+ $arguments = @(
+ "run", "-d", "--name", $appContainer,
+ "--network", "none",
+ "--read-only",
+ "--cap-drop", "ALL",
+ "--security-opt", "no-new-privileges:true",
+ "--tmpfs", "/tmp:rw,noexec,nosuid,nodev,size=128m,mode=1777",
+ "--tmpfs", "/app/site/.next/cache:rw,noexec,nosuid,nodev,size=128m,mode=700,uid=1001,gid=1001",
+ "-v", ($DataVolume + ":/app/site/data:rw"),
+ "-v", ($MediaVolume + ":/app/pics:" + $MediaAccess),
+ "-v", ($secretVolume + ":/run/secrets:ro")
+ )
+ if (-not [string]::IsNullOrWhiteSpace($CloneDataVolume)) {
+ $arguments += @("-v", ($CloneDataVolume + ":/clone:rw"))
}
- Start-Sleep -Seconds 2
+ $arguments += @(
+ "-e", "NODE_ENV=production",
+ "-e", "SELF_HOSTED=true",
+ "-e", "SITE_URL=http://127.0.0.1:3002",
+ "-e", "NEXT_PUBLIC_SITE_URL=http://127.0.0.1:3002",
+ "-e", "MEDIA_ROOT=/app/pics",
+ "-e", "DATA_ROOT=/app/site/data",
+ "-e", ("STUDIO_PASSWORD=" + $password),
+ "-e", ("SESSION_SECRET=" + $sessionSecret),
+ "-e", "VISUAL_AUDIT_TOKEN_FILE=/run/secrets/audit_token",
+ "-e", "VISUAL_AUDIT_MAX_RECORDS=5000",
+ "-e", "STRIPE_SECRET_KEY=",
+ "-e", "STRIPE_PUBLISHABLE_KEY=",
+ "-e", "EASYPOST_API_KEY=",
+ "-e", "SMTP_HOST=",
+ "-e", "SMTP_USER=",
+ "-e", "SMTP_PASSWORD=",
+ "-e", "OPENAI_API_KEY=",
+ "-e", "ENABLE_PUBLIC_AI_RENDERING=false",
+ "-e", "ENABLE_AI_BACKGROUND_CLEANUP=false",
+ "-e", "ENABLE_AI_MEDIA_ANALYSIS=false",
+ "-e", "ENABLE_EMBEDDING_SEARCH=false",
+ "-e", "ENABLE_LOCAL_IMAGE_EMBEDDINGS=false",
+ "-e", "ENABLE_GEMINI_FALLBACK=false",
+ "-e", "AI_PROVIDER=disabled",
+ "-e", "AI_ANALYSIS_PROVIDER=disabled",
+ "-e", "AI_EMBEDDING_PROVIDER=disabled",
+ "-e", "AI_FALLBACK_PROVIDER=disabled",
+ "-e", "LOCAL_AI_SIDECAR_URL=http://127.0.0.1:9",
+ "-e", "OLLAMA_BASE_URL=http://127.0.0.1:9",
+ $AppImage
+ )
+ return $arguments
}
- if (-not $ready) {
+
+ function Wait-DisposableApp {
+ param([string]$Label)
+
+ for ($attempt = 1; $attempt -le 60; $attempt += 1) {
+ & docker exec $appContainer node -e "fetch('http://127.0.0.1:3002/studio/login').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
+ if ($LASTEXITCODE -eq 0) {
+ Write-Output ("APP_READY_" + $Label + "_ATTEMPT=" + $attempt)
+ return
+ }
+ Start-Sleep -Seconds 2
+ }
throw "The disposable application did not become ready."
}
+ $fingerprintScript = @'
+const crypto = require("node:crypto");
+const fs = require("node:fs");
+const path = require("node:path");
+const root = "/input";
+const files = [];
+const walk = (directory) => {
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
+ const absolute = path.join(directory, entry.name);
+ if (entry.isDirectory()) walk(absolute);
+ else if (entry.isFile()) files.push(absolute);
+ else throw new Error("Fingerprint input contains a non-file entry.");
+ }
+};
+walk(root);
+files.sort();
+const hash = crypto.createHash("sha256");
+for (const file of files) {
+ hash.update(path.relative(root, file));
+ hash.update("\0");
+ hash.update(fs.readFileSync(file));
+ hash.update("\0");
+}
+console.log(hash.digest("hex"));
+'@
+
+ function Get-VolumeFingerprint {
+ param([string]$Volume)
+
+ $result = @(
+ & docker run --rm --network none --read-only --user 1001:1001 `
+ --cap-drop ALL --security-opt no-new-privileges:true `
+ -v ($Volume + ":/input:ro") --entrypoint node $AuditImage `
+ -e $fingerprintScript
+ )
+ if ($LASTEXITCODE -ne 0 -or $result.Count -eq 0) {
+ throw "Unable to fingerprint disposable volume $Volume."
+ }
+ return $result[-1].Trim()
+ }
+
+ $activeDataVolume = $dataVolume
+ $activeMediaVolume = $mediaVolume
+ $activeMediaAccess = "ro"
+ $sourceDataBefore = ""
+ $sourceMediaBefore = ""
+
+ if ($TargetMode -eq "snapshot-lab") {
+ $sourceArguments = @(
+ New-AppArguments -DataVolume $dataVolume -MediaVolume $mediaVolume `
+ -MediaAccess "ro" -CloneDataVolume $labDataVolume
+ )
+ Invoke-Docker @sourceArguments | Out-Null
+ Wait-DisposableApp -Label "SOURCE"
+
+ $cloneDatabaseScript = @'
+const { DatabaseSync } = require("node:sqlite");
+const source = new DatabaseSync("/app/site/data/woodsmith.sqlite");
+source.exec("VACUUM INTO '/clone/woodsmith.sqlite'");
+source.close();
+const clone = new DatabaseSync("/clone/woodsmith.sqlite", { readOnly: true });
+const quickCheck = clone.prepare("PRAGMA quick_check").all();
+clone.close();
+if (!quickCheck.some((row) => row.quick_check === "ok")) process.exit(1);
+console.log("SNAPSHOT_CLONE_QUICK_CHECK=ok");
+'@
+ $cloneDatabaseArguments = @(
+ "exec", $appContainer, "node", "--experimental-sqlite", "-e", $cloneDatabaseScript
+ )
+ Invoke-Docker @cloneDatabaseArguments
+
+ $copyMediaArguments = @(
+ "run", "--rm", "--network", "none", "--user", "0",
+ "-v", ($mediaVolume + ":/source:ro"),
+ "-v", ($labMediaVolume + ":/target:rw"),
+ "--entrypoint", "/bin/sh", $AppImage, "-c",
+ "cp -a /source/. /target/ && chown -R 1001:1001 /target"
+ )
+ Invoke-Docker @copyMediaArguments
+ Invoke-Docker rm -f $appContainer | Out-Null
+
+ $sourceDataBefore = Get-VolumeFingerprint -Volume $dataVolume
+ $sourceMediaBefore = Get-VolumeFingerprint -Volume $mediaVolume
+ $activeDataVolume = $labDataVolume
+ $activeMediaVolume = $labMediaVolume
+ $activeMediaAccess = "rw"
+ }
+
+ $appArguments = @(
+ New-AppArguments -DataVolume $activeDataVolume -MediaVolume $activeMediaVolume `
+ -MediaAccess $activeMediaAccess
+ )
+ Invoke-Docker @appArguments | Out-Null
+ Wait-DisposableApp -Label $(if ($TargetMode -eq "snapshot-lab") { "LAB" } else { "READONLY" })
+
$runnerArguments = @(
"--rm",
"--network", ("container:" + $appContainer),
@@ -229,10 +365,10 @@ const files = [
"--security-opt", "no-new-privileges:true",
"--ipc", "host",
"--tmpfs", "/audit-tmp:rw,nosuid,nodev,size=128m,mode=700,uid=1001,gid=1001",
- "--tmpfs", "/tmp:rw,nosuid,nodev,size=256m,mode=1777",
+ "--tmpfs", "/tmp:rw,nosuid,nodev,size=512m,mode=1777",
"-v", ($outputVolume + ":/output:rw"),
"-v", ($secretVolume + ":/run/secrets:ro"),
- "-e", "TARGET_MODE=live-readonly",
+ "-e", ("TARGET_MODE=" + $TargetMode),
"-e", "BASE_URL=http://127.0.0.1:3002",
"-e", ("TARGET_COMMIT_SHA=" + $CommitSha),
"-e", ("AUDIT_RUN_ID=" + $runId),
@@ -275,6 +411,9 @@ const validation = JSON.parse(
const checksums = JSON.parse(
fs.readFileSync(path.join(root, "checksums.json"), "utf8")
);
+const reportIndex = JSON.parse(
+ fs.readFileSync(path.join(root, "report", "report-index.json"), "utf8")
+);
const walk = (directory) =>
fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const item = path.join(directory, entry.name);
@@ -302,6 +441,7 @@ const routeKeys = new Set(
);
const result = {
runId: process.env.AUDIT_RUN_ID,
+ targetMode: process.env.TARGET_MODE,
passed: validation.passed,
failures: validation.failures.length,
unexpectedDiagnostics: validation.diagnostics.length,
@@ -309,25 +449,46 @@ const result = {
routes: routeKeys.size,
skipFocused,
skipActivated,
+ snapshotLabSaved: manifest.captures.filter(
+ (item) => item.state === "snapshot-lab-commission-draft-saved"
+ ).length,
unsafeSuccessful: manifest.security.successfulUnsafeRequests,
unsafeBlocked: manifest.security.sameOriginUnsafeRequestsBlocked,
tokenEligible: manifest.security.tokenEligibleRequests,
crossOrigin: manifest.security.crossOriginRequests,
checksums: checksums.length,
+ reportSourceCaptures: reportIndex.sourceCaptureCount,
+ reportSelectedCaptures: reportIndex.captureCount,
+ shareableSourceCaptures: reportIndex.shareableSourceCaptureCount,
+ shareableSelectedCaptures: reportIndex.shareableCaptureCount,
+ restrictedPrintPages: reportIndex.restrictedPrintPages,
+ shareablePrintPages: reportIndex.shareablePrintPages,
totalFiles: files.length,
temporaryFiles: temporaryFiles.length,
shareableImages: shareableImages.length
};
console.log(JSON.stringify(result, null, 2));
+const expectedUnsafeSuccessful = Number(
+ process.env.EXPECTED_UNSAFE_SUCCESSFUL
+);
+
if (
!result.passed ||
result.failures !== 0 ||
result.unexpectedDiagnostics !== 0 ||
- result.unsafeSuccessful !== 0 ||
- result.unsafeBlocked < 1 ||
+ result.unsafeSuccessful !== expectedUnsafeSuccessful ||
+ (result.targetMode === "live-readonly" && result.unsafeBlocked < 1) ||
+ (result.targetMode === "snapshot-lab" && result.snapshotLabSaved !== 1) ||
result.tokenEligible < 1 ||
result.crossOrigin !== 0 ||
+ result.reportSourceCaptures !== result.captures ||
+ result.reportSelectedCaptures < 1 ||
+ result.reportSelectedCaptures > result.reportSourceCaptures ||
+ result.shareableSelectedCaptures < 1 ||
+ result.shareableSelectedCaptures > result.shareableSourceCaptures ||
+ result.restrictedPrintPages < result.reportSelectedCaptures ||
+ result.shareablePrintPages < result.shareableSelectedCaptures ||
result.temporaryFiles !== 0 ||
result.skipFocused < 1 ||
result.skipFocused !== result.skipActivated
@@ -340,10 +501,54 @@ if (
"run", "--rm", "--network", "none", "--user", "1001:1001",
"-v", ($outputVolume + ":/output:ro"),
"-e", ("AUDIT_RUN_ID=" + $runId),
+ "-e", ("TARGET_MODE=" + $TargetMode),
+ "-e", ("EXPECTED_UNSAFE_SUCCESSFUL=" + $(if ($TargetMode -eq "snapshot-lab") { "2" } else { "0" })),
"--entrypoint", "node", $AuditImage, "-e", $summaryScript
)
Invoke-Docker @summaryArguments
+ if ($TargetMode -eq "snapshot-lab") {
+ $labStateScript = @'
+const { DatabaseSync } = require("node:sqlite");
+const database = new DatabaseSync("/data/woodsmith.sqlite", { readOnly: true });
+const quickCheck = database.prepare("PRAGMA quick_check").all();
+const draftCount = database.prepare("SELECT COUNT(*) AS count FROM commission_drafts").get().count;
+database.close();
+console.log(JSON.stringify({
+ quickCheck: quickCheck.some((row) => row.quick_check === "ok"),
+ draftCount
+}));
+'@
+ $labStateOutput = @(
+ & docker run --rm --network none --read-only --user 1001:1001 `
+ --cap-drop ALL --security-opt no-new-privileges:true `
+ -v ($labDataVolume + ":/data:ro") --entrypoint node $AppImage `
+ --experimental-sqlite -e $labStateScript
+ )
+ if ($LASTEXITCODE -ne 0 -or $labStateOutput.Count -eq 0) {
+ throw "Unable to verify the disposable snapshot-lab database."
+ }
+ $labState = $labStateOutput[-1] | ConvertFrom-Json
+ if (-not $labState.quickCheck -or $labState.draftCount -ne 0) {
+ throw "The disposable snapshot lab failed quick_check or retained commission drafts."
+ }
+
+ $sourceDataAfter = Get-VolumeFingerprint -Volume $dataVolume
+ $sourceMediaAfter = Get-VolumeFingerprint -Volume $mediaVolume
+ $labMediaAfter = Get-VolumeFingerprint -Volume $labMediaVolume
+ $sourceDataUnchanged = $sourceDataBefore -eq $sourceDataAfter
+ $sourceMediaUnchanged = $sourceMediaBefore -eq $sourceMediaAfter
+ $labMediaMatchesSource = $sourceMediaBefore -eq $labMediaAfter
+ Write-Output ("SNAPSHOT_LAB_QUICK_CHECK=" + $labState.quickCheck)
+ Write-Output ("SNAPSHOT_LAB_RESIDUAL_DRAFTS=" + $labState.draftCount)
+ Write-Output ("SNAPSHOT_SOURCE_DATA_UNCHANGED=" + $sourceDataUnchanged)
+ Write-Output ("SNAPSHOT_SOURCE_MEDIA_UNCHANGED=" + $sourceMediaUnchanged)
+ Write-Output ("SNAPSHOT_LAB_MEDIA_MATCHES_SOURCE=" + $labMediaMatchesSource)
+ if (-not $sourceDataUnchanged -or -not $sourceMediaUnchanged -or -not $labMediaMatchesSource) {
+ throw "Snapshot-lab isolation or media-clone verification failed."
+ }
+ }
+
$applicationLogs = @(& docker logs $appContainer 2>&1)
$logFailures = @(
$applicationLogs |
@@ -370,12 +575,7 @@ finally {
}
& docker rm -f $appContainer 2>$null | Out-Null
- foreach ($volume in @(
- $dataVolume,
- $mediaVolume,
- $outputVolume,
- $secretVolume
- )) {
+ foreach ($volume in $managedVolumes) {
& docker volume rm -f $volume 2>$null | Out-Null
}
diff --git a/visual-audit/src/browser-launch.test.ts b/visual-audit/src/browser-launch.test.ts
new file mode 100644
index 0000000..5840deb
--- /dev/null
+++ b/visual-audit/src/browser-launch.test.ts
@@ -0,0 +1,19 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { chromiumLaunchOptions } from "./browser-launch.js";
+
+test("Chromium uses container shared memory for long archive runs", () => {
+ const options = chromiumLaunchOptions();
+
+ assert.deepEqual(options.ignoreDefaultArgs, ["--disable-dev-shm-usage"]);
+ assert.equal(
+ options.args?.some((argument) => argument.startsWith("--disable-dev-shm-usage")),
+ false
+ );
+});
+
+test("an explicitly selected browser channel is preserved", () => {
+ assert.equal(chromiumLaunchOptions("chrome").channel, "chrome");
+ assert.equal(chromiumLaunchOptions("msedge").channel, "msedge");
+});
diff --git a/visual-audit/src/browser-launch.ts b/visual-audit/src/browser-launch.ts
new file mode 100644
index 0000000..85f1397
--- /dev/null
+++ b/visual-audit/src/browser-launch.ts
@@ -0,0 +1,16 @@
+import { chromium } from "playwright";
+
+export function chromiumLaunchOptions(
+ channel?: "chrome" | "msedge"
+) {
+ return {
+ headless: true,
+ chromiumSandbox: false,
+ ...(channel ? { channel } : {}),
+ // Playwright enables this by default. The audit containers provide shared
+ // memory explicitly, so spilling Chromium into the bounded /tmp tmpfs can
+ // terminate long, high-resolution archive runs.
+ ignoreDefaultArgs: ["--disable-dev-shm-usage"],
+ args: ["--hide-scrollbars=false"]
+ } satisfies Parameters[0];
+}
diff --git a/visual-audit/src/coverage-matrix.test.ts b/visual-audit/src/coverage-matrix.test.ts
new file mode 100644
index 0000000..4e9817c
--- /dev/null
+++ b/visual-audit/src/coverage-matrix.test.ts
@@ -0,0 +1,55 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ canonicalCoverageMatrix,
+ discoveredCoverageMatrix
+} from "./coverage-matrix.js";
+import type { ViewportProfile } from "./types.js";
+
+const viewports: ViewportProfile[] = [
+ { name: "desktop-1440", width: 1440, height: 900, deviceScaleFactor: 1, isMobile: false, archival: false },
+ { name: "desktop-1280", width: 1280, height: 800, deviceScaleFactor: 1, isMobile: false, archival: false },
+ { name: "desktop-1024", width: 1024, height: 768, deviceScaleFactor: 1, isMobile: false, archival: false },
+ { name: "tablet-portrait", width: 1024, height: 1366, deviceScaleFactor: 2, isMobile: false, archival: false },
+ { name: "mobile-430", width: 430, height: 932, deviceScaleFactor: 3, isMobile: true, archival: false },
+ { name: "mobile-390", width: 390, height: 844, deviceScaleFactor: 3, isMobile: true, archival: false },
+ { name: "mobile-375", width: 375, height: 812, deviceScaleFactor: 3, isMobile: true, archival: false },
+ { name: "mobile-320", width: 320, height: 720, deviceScaleFactor: 3, isMobile: true, archival: false },
+ { name: "desktop-archival", width: 2560, height: 1440, deviceScaleFactor: 2, isMobile: false, archival: true }
+];
+
+test("canonical full coverage retains every configured viewport and theme", () => {
+ const matrix = canonicalCoverageMatrix("full", viewports);
+
+ assert.equal(matrix.length, viewports.length * 2);
+ assert.deepEqual(
+ matrix.filter((entry) => entry.deep).map((entry) => `${entry.profile.name}:${entry.theme}`),
+ ["desktop-archival:dark"]
+ );
+});
+
+test("discovered links use representative structural states without deep duplication", () => {
+ const matrix = discoveredCoverageMatrix("full", viewports);
+
+ assert.deepEqual(
+ matrix.map((entry) => `${entry.profile.name}:${entry.theme}`),
+ [
+ "desktop-1440:dark",
+ "desktop-1440:light",
+ "tablet-portrait:dark",
+ "tablet-portrait:light",
+ "mobile-390:dark",
+ "mobile-390:light",
+ "desktop-archival:dark"
+ ]
+ );
+ assert.equal(matrix.some((entry) => entry.deep), false);
+});
+
+test("smoke coverage remains one deterministic desktop dark state", () => {
+ assert.deepEqual(
+ discoveredCoverageMatrix("smoke", viewports),
+ canonicalCoverageMatrix("smoke", viewports)
+ );
+});
diff --git a/visual-audit/src/coverage-matrix.ts b/visual-audit/src/coverage-matrix.ts
new file mode 100644
index 0000000..03e5021
--- /dev/null
+++ b/visual-audit/src/coverage-matrix.ts
@@ -0,0 +1,69 @@
+import type {
+ AuditScope,
+ ThemeMode,
+ ViewportProfile
+} from "./types.js";
+
+export type CoverageMatrixEntry = {
+ profile: ViewportProfile;
+ theme: ThemeMode;
+ deep: boolean;
+};
+
+function profileByName(
+ viewports: ViewportProfile[],
+ name: string
+) {
+ const profile = viewports.find((candidate) => candidate.name === name);
+ if (!profile) throw new Error(`Missing visual-audit viewport profile: ${name}`);
+ return profile;
+}
+
+export function canonicalCoverageMatrix(
+ scope: AuditScope,
+ viewports: ViewportProfile[]
+): CoverageMatrixEntry[] {
+ if (scope === "smoke") {
+ return [{
+ profile: profileByName(viewports, "desktop-1440"),
+ theme: "dark",
+ deep: false
+ }];
+ }
+
+ return viewports.flatMap((profile) =>
+ (["dark", "light"] as const).map((theme) => ({
+ profile,
+ theme,
+ deep: profile.name === "desktop-archival" && theme === "dark"
+ }))
+ );
+}
+
+export function discoveredCoverageMatrix(
+ scope: AuditScope,
+ viewports: ViewportProfile[]
+): CoverageMatrixEntry[] {
+ if (scope === "smoke") return canonicalCoverageMatrix(scope, viewports);
+
+ const structuralProfiles = [
+ "desktop-1440",
+ "tablet-portrait",
+ "mobile-390"
+ ].map((name) => profileByName(viewports, name));
+
+ return [
+ ...structuralProfiles.flatMap((profile) =>
+ (["dark", "light"] as const).map((theme) => ({
+ profile,
+ theme,
+ deep: false
+ }))
+ ),
+ {
+ profile: profileByName(viewports, "desktop-archival"),
+ theme: "dark" as const,
+ deep: false
+ }
+ ];
+}
diff --git a/visual-audit/src/report-selection.test.ts b/visual-audit/src/report-selection.test.ts
new file mode 100644
index 0000000..42757d6
--- /dev/null
+++ b/visual-audit/src/report-selection.test.ts
@@ -0,0 +1,77 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ missingSelectedRoutes,
+ REPORT_SELECTION_POLICY,
+ selectReportCaptures
+} from "./report-selection.js";
+import type { CaptureRecord } from "./types.js";
+
+function capture(input: Partial & Pick): CaptureRecord {
+ return {
+ key: input.key,
+ createdAt: "2026-07-14T00:00:00.000Z",
+ auth: input.auth ?? "anonymous",
+ route: input.route,
+ finalUrl: `http://127.0.0.1${input.route}`,
+ theme: input.theme ?? "dark",
+ viewport: input.viewport ?? "desktop-archival",
+ state: input.state,
+ status: 200,
+ files: [`png/${input.key}.png`],
+ width: 1440,
+ height: 900,
+ deviceScaleFactor: 1,
+ sensitive: input.sensitive ?? false
+ };
+}
+
+test("report selection bounds each route while preserving representative states", () => {
+ const source: CaptureRecord[] = [];
+ for (const matrixKey of REPORT_SELECTION_POLICY.viewportStates) {
+ const [viewport, theme] = matrixKey.split(":") as [string, "dark" | "light"];
+ source.push(capture({ key: `top-${matrixKey}`, route: "/portfolio", state: "viewport-top", viewport, theme: theme as "dark" | "light" }));
+ }
+ source.push(capture({ key: "full", route: "/portfolio", state: "full-page-default" }));
+ source.push(capture({ key: "skip", route: "/portfolio", state: "skip-link-activated-main-focus" }));
+ source.push(capture({ key: "header", route: "/portfolio", state: "header-after-scroll-down" }));
+ for (const state of [
+ "lightbox-0001-100-percent",
+ "lightbox-previous-boundary",
+ "lightbox-next-boundary",
+ "lightbox-0001-200-percent",
+ "lightbox-0001-400-percent",
+ "lightbox-0001-pan-boundary"
+ ]) {
+ source.push(capture({ key: state, route: "/portfolio", state }));
+ }
+ for (let index = 0; index < 60; index += 1) {
+ source.push(capture({ key: `element-${index}`, route: "/portfolio", state: `element-${index}-focus` }));
+ }
+
+ const selected = selectReportCaptures(source);
+
+ assert.ok(selected.length <= REPORT_SELECTION_POLICY.maxCapturesPerRoute);
+ assert.ok(selected.some((item) => item.key === "full"));
+ assert.ok(selected.some((item) => item.key === "skip"));
+ assert.ok(selected.some((item) => item.state === "lightbox-0001-400-percent"));
+ assert.ok(selected.some((item) => item.state === "lightbox-0001-pan-boundary"));
+ assert.ok(selected.some((item) => item.state.startsWith("element-")));
+ for (const matrixKey of REPORT_SELECTION_POLICY.viewportStates) {
+ assert.ok(selected.some((item) => `${item.viewport}:${item.theme}` === matrixKey && item.state === "viewport-top"));
+ }
+});
+
+test("every source route receives at least one report representative", () => {
+ const source = [
+ capture({ key: "home", route: "/", state: "viewport-top" }),
+ capture({ key: "studio", route: "/studio", state: "studio-editor-open", auth: "admin", sensitive: true }),
+ capture({ key: "fallback", route: "/commissions?auditState=webgl-unavailable", state: "visualizer-fallback" })
+ ];
+ const selected = selectReportCaptures(source, 1);
+
+ assert.equal(selected.length, 3);
+ assert.deepEqual(missingSelectedRoutes(source, selected), []);
+ assert.deepEqual(selectReportCaptures(source, 1).map((item) => item.key), selected.map((item) => item.key));
+});
diff --git a/visual-audit/src/report-selection.ts b/visual-audit/src/report-selection.ts
new file mode 100644
index 0000000..9dff8e9
--- /dev/null
+++ b/visual-audit/src/report-selection.ts
@@ -0,0 +1,97 @@
+import type { CaptureRecord } from "./types.js";
+
+export const REPORT_SELECTION_POLICY = {
+ version: 1,
+ maxCapturesPerRoute: 16,
+ description: "The restricted raw PNG archive and searchable HTML retain every capture. Print/PDF and shareable editions select deterministic route, theme, viewport, accessibility, and deep-state representatives.",
+ viewportStates: [
+ "desktop-1440:dark",
+ "desktop-1440:light",
+ "tablet-portrait:dark",
+ "mobile-390:light",
+ "desktop-archival:dark"
+ ]
+} as const;
+
+const stateFamilies = [
+ { prefix: "lightbox-", limit: 6 },
+ { prefix: "inline-section-", limit: 1 },
+ { prefix: "media-picker-", limit: 1 },
+ { prefix: "media-inspector-", limit: 1 },
+ { prefix: "media-mobile-pane-", limit: 1 },
+ { prefix: "visualizer-", limit: 3 },
+ { prefix: "form-", limit: 2 },
+ { prefix: "snapshot-lab-", limit: 2 },
+ { prefix: "all-details-", limit: 1 },
+ { prefix: "studio-editor-", limit: 1 },
+ { prefix: "element-", limit: 1 }
+] as const;
+
+function preferred(captures: CaptureRecord[]) {
+ return [...captures].sort((left, right) => {
+ const rank = (capture: CaptureRecord) => {
+ if (capture.viewport === "desktop-archival" && capture.theme === "dark") return 0;
+ if (capture.viewport === "desktop-1440" && capture.theme === "dark") return 1;
+ if (capture.viewport === "mobile-390") return 2;
+ return 3;
+ };
+ return rank(left) - rank(right);
+ });
+}
+
+export function selectReportCaptures(
+ captures: CaptureRecord[],
+ maxPerRoute: number = REPORT_SELECTION_POLICY.maxCapturesPerRoute
+) {
+ if (!Number.isInteger(maxPerRoute) || maxPerRoute < 1) {
+ throw new Error("maxPerRoute must be a positive integer.");
+ }
+
+ const groups = new Map();
+ for (const capture of captures) {
+ const key = `${capture.auth}::${capture.route}`;
+ groups.set(key, [...(groups.get(key) ?? []), capture]);
+ }
+
+ const selected = new Set();
+ for (const routeCaptures of groups.values()) {
+ const add = (capture: CaptureRecord | undefined) => {
+ if (capture) selected.add(capture.key);
+ };
+ const routeSelected = () => routeCaptures.filter((capture) => selected.has(capture.key)).length;
+ const addWithinLimit = (capture: CaptureRecord | undefined) => {
+ if (routeSelected() < maxPerRoute) add(capture);
+ };
+
+ for (const matrixKey of REPORT_SELECTION_POLICY.viewportStates) {
+ const [viewport, theme] = matrixKey.split(":");
+ addWithinLimit(routeCaptures.find((capture) =>
+ capture.state === "viewport-top" &&
+ capture.viewport === viewport &&
+ capture.theme === theme
+ ));
+ }
+
+ addWithinLimit(preferred(routeCaptures.filter((capture) => capture.state === "full-page-default"))[0]);
+ addWithinLimit(preferred(routeCaptures.filter((capture) => capture.state === "skip-link-activated-main-focus"))[0]);
+ addWithinLimit(preferred(routeCaptures.filter((capture) => capture.state === "header-after-scroll-down"))[0]);
+
+ for (const family of stateFamilies) {
+ const candidates = preferred(routeCaptures.filter((capture) => capture.state.startsWith(family.prefix)));
+ for (const capture of candidates.slice(0, family.limit)) addWithinLimit(capture);
+ }
+
+ if (routeSelected() === 0) add(routeCaptures[0]);
+ }
+
+ return captures.filter((capture) => selected.has(capture.key));
+}
+
+export function missingSelectedRoutes(
+ source: CaptureRecord[],
+ selected: CaptureRecord[]
+) {
+ const selectedRoutes = new Set(selected.map((capture) => `${capture.auth}::${capture.route}`));
+ return [...new Set(source.map((capture) => `${capture.auth}::${capture.route}`))]
+ .filter((route) => !selectedRoutes.has(route));
+}
diff --git a/visual-audit/src/report.ts b/visual-audit/src/report.ts
index 5da3398..191f518 100644
--- a/visual-audit/src/report.ts
+++ b/visual-audit/src/report.ts
@@ -6,6 +6,11 @@ import sharp from "sharp";
import { config } from "./config.js";
import { createPdfAtlas, type PdfAtlasPage } from "./pdf-atlas.js";
+import {
+ missingSelectedRoutes,
+ REPORT_SELECTION_POLICY,
+ selectReportCaptures
+} from "./report-selection.js";
import type { CaptureRecord, RunManifest } from "./types.js";
import { clearDirectoryContents, ensureDirectory, escapeHtml, exists, redactedAssetName, writeJsonAtomic } from "./util.js";
@@ -125,7 +130,8 @@ function htmlDocument(input: {
Routes ${new Set(input.captures.map((capture) => `${capture.auth}:${capture.route}`)).size}
Unexpected diagnostics ${diagnostics.length}
- ${input.redacted ? 'Private routes, authenticated captures, account details, and customer references are excluded from this edition.
' : ""}
+ ${input.redacted ? 'Private routes, authenticated captures, account details, and customer references are excluded from this edition. This edition uses the deterministic representative selection recorded in its manifest; the restricted raw archive remains complete.
' : ""}
+ ${input.print && !input.redacted ? 'This printable atlas contains deterministic route, viewport, theme, accessibility, and deep-state representatives. The restricted searchable HTML and PNG tree retain every capture.
' : ""}
${input.print ? "" : 'Search captures '}
@@ -185,7 +191,14 @@ async function main() {
const restrictedHtml = path.join(reportRoot, "index.html");
await fs.writeFile(restrictedHtml, htmlDocument({ manifest, captures: manifest.captures, cards: restrictedCards, redacted: false, comparison, print: false }), { encoding: "utf8", mode: 0o600 });
- const shareableCaptures = manifest.captures.filter((capture) => !capture.sensitive && capture.auth === "anonymous");
+ const printCaptures = selectReportCaptures(manifest.captures);
+ const shareableSourceCaptures = manifest.captures.filter((capture) => !capture.sensitive && capture.auth === "anonymous");
+ const shareableCaptures = selectReportCaptures(shareableSourceCaptures);
+ const missingRestrictedRoutes = missingSelectedRoutes(manifest.captures, printCaptures);
+ const missingShareableRoutes = missingSelectedRoutes(shareableSourceCaptures, shareableCaptures);
+ if (missingRestrictedRoutes.length > 0 || missingShareableRoutes.length > 0) {
+ throw new Error("Report representative selection omitted one or more source routes.");
+ }
const shareableCards: string[] = [];
let shareableAssetIndex = 0;
for (const capture of shareableCaptures) {
@@ -209,7 +222,9 @@ async function main() {
mode: manifest.mode,
scope: manifest.scope,
deployedCommit: manifest.deployedCommit,
+ sourceCaptureCount: shareableSourceCaptures.length,
captureCount: shareableCaptures.length,
+ selectionPolicy: REPORT_SELECTION_POLICY,
routes: [...new Set(shareableCaptures.map((capture) => redact(capture.route)))].sort(),
exclusions: manifest.exclusions
};
@@ -224,7 +239,7 @@ async function main() {
let restrictedPrintPages = 0;
let shareablePrintPages = 0;
let sequence = 0;
- for (const capture of manifest.captures) {
+ for (const capture of printCaptures) {
const sources: string[] = [];
for (const relativeFile of capture.files) {
sequence += 1;
@@ -275,7 +290,7 @@ async function main() {
const restrictedPrintHtml = path.join(reportRoot, "print.html");
const shareablePrintHtml = path.join(shareableRoot, "print.html");
- await fs.writeFile(restrictedPrintHtml, htmlDocument({ manifest, captures: manifest.captures, cards: restrictedPrintCards.join("\n"), redacted: false, comparison, print: true }), { encoding: "utf8", mode: 0o600 });
+ await fs.writeFile(restrictedPrintHtml, htmlDocument({ manifest, captures: printCaptures, cards: restrictedPrintCards.join("\n"), redacted: false, comparison, print: true }), { encoding: "utf8", mode: 0o600 });
await fs.writeFile(shareablePrintHtml, htmlDocument({ manifest, captures: shareableCaptures, cards: shareablePrintCards.join("\n"), redacted: true, comparison: { status: "Comparison details are restricted." }, print: true }), { encoding: "utf8", mode: 0o600 });
const unexpectedDiagnostics = manifest.diagnostics.filter((item) => !item.expected).length;
@@ -288,7 +303,7 @@ async function main() {
mode: manifest.mode,
commit: manifest.deployedCommit,
createdAt: manifest.startedAt,
- captureCount: manifest.captures.length,
+ captureCount: printCaptures.length,
routeCount,
unexpectedDiagnostics,
redacted: false,
@@ -316,11 +331,21 @@ async function main() {
shareableHtml: "shareable/index.html",
shareablePrintHtml: "shareable/print.html",
shareablePdf: "shareable/woodmat-visual-atlas-redacted.pdf",
- captureCount: manifest.captures.length,
+ sourceCaptureCount: manifest.captures.length,
+ captureCount: printCaptures.length,
shareableCaptureCount: shareableCaptures.length,
+ shareableSourceCaptureCount: shareableSourceCaptures.length,
+ selectionPolicy: REPORT_SELECTION_POLICY,
restrictedPrintPages,
shareablePrintPages
});
+ await writeJsonAtomic(path.join(reportRoot, "selection.json"), {
+ selectionPolicy: REPORT_SELECTION_POLICY,
+ sourceCaptureCount: manifest.captures.length,
+ selectedCaptureCount: printCaptures.length,
+ selectedKeys: printCaptures.map((capture) => capture.key),
+ missingRoutes: missingRestrictedRoutes
+ });
}
await main();
diff --git a/visual-audit/src/run.ts b/visual-audit/src/run.ts
index a8823bb..954d456 100644
--- a/visual-audit/src/run.ts
+++ b/visual-audit/src/run.ts
@@ -10,6 +10,13 @@ import {
type Page
} from "playwright";
+import { chromiumLaunchOptions } from "./browser-launch.js";
+import {
+ canonicalCoverageMatrix,
+ discoveredCoverageMatrix,
+ type CoverageMatrixEntry
+} from "./coverage-matrix.js";
+
import {
captureElement,
capturePageSurface
@@ -35,6 +42,7 @@ import { assertFocusedSkipLink, assertMainFocusTransferred } from "./skip-link.j
import { SNAPSHOT_LAB_COMMISSION_DRAFT_STATE } from "./snapshot-lab-evidence.js";
import type {
AuthState,
+ CoverageTier,
CaptureRecord,
DiagnosticRecord,
Inventory,
@@ -73,7 +81,8 @@ const coverageExclusions = [
{ surface: "Admin authentication POST", reason: "The single Studio login submission is the only live unsafe request allowed before the read-only capture context exists." },
{ surface: "Successful production mutations", reason: "Forbidden in live-readonly mode and captured only against the isolated snapshot lab." },
{ surface: "Fabrication-ready 3D output", reason: "The public renderer is explicitly a conceptual proportional planning preview." },
- { surface: "Unconfigured provider success states", reason: "Payment, shipping, email, and model-provider success states require provider fixtures and remain disabled in snapshot-lab mode." }
+ { surface: "Unconfigured provider success states", reason: "Payment, shipping, email, and model-provider success states require provider fixtures and remain disabled in snapshot-lab mode." },
+ { surface: "Redundant deep capture on discovered query variants", reason: "Every rendered same-origin link is captured across desktop, tablet, and mobile in both themes plus archival desktop dark; deep element and dialog states remain on canonical source/database routes to avoid duplicating the same template cross-product." }
] as const;
function now() {
@@ -135,9 +144,10 @@ async function loadOrCreateManifest(
existing.runId !== config.runId ||
existing.mode !== config.targetMode ||
existing.baseUrl !== config.baseUrl ||
- existing.expectedCommit !== config.expectedCommit
+ existing.expectedCommit !== config.expectedCommit ||
+ existing.schemaVersion !== 3
) {
- throw new Error("AUDIT_RESUME refused to combine output from a different run, mode, origin, or commit.");
+ throw new Error("AUDIT_RESUME refused to combine output from a different schema, run, mode, origin, or commit.");
}
return {
@@ -156,7 +166,7 @@ async function loadOrCreateManifest(
}
return {
- schemaVersion: 2,
+ schemaVersion: 3,
runId: config.runId,
startedAt: now(),
completedAt: null,
@@ -1718,6 +1728,7 @@ async function captureRoute(input: {
theme: ThemeMode;
profile: ViewportProfile;
deep: boolean;
+ coverageTier: CoverageTier;
}) {
const page =
await input.context.newPage();
@@ -1756,6 +1767,7 @@ async function captureRoute(input: {
theme: input.theme,
viewport: input.profile.name,
deep: input.deep,
+ coverageTier: input.coverageTier,
finalUrl: page.url(),
status:
response?.status() ?? null,
@@ -1960,45 +1972,20 @@ async function captureMediaPickers(input: {
}
}
-function structuralMatrix() {
- if (config.scope === "smoke") {
- return [
- {
- profile:
- viewports.find(
- viewport =>
- viewport.name ===
- "desktop-1440"
- )!,
- theme: "dark" as const
- }
- ];
- }
-
- return viewports.flatMap(profile =>
- (["dark", "light"] as const).map(
- theme => ({
- profile,
- theme
- })
- )
- );
-}
-
async function runRoutes(
browser: Browser,
auth: AuthState,
- routes: string[]
+ routes: string[],
+ options: {
+ matrix?: CoverageMatrixEntry[];
+ coverageTier?: CoverageTier;
+ } = {}
) {
- const matrix = structuralMatrix();
+ const matrix = options.matrix ?? canonicalCoverageMatrix(config.scope, viewports);
+ const coverageTier = options.coverageTier ?? "canonical";
for (const route of routes) {
for (const entry of matrix) {
- const deep =
- entry.profile.name ===
- "desktop-archival" &&
- entry.theme === "dark";
-
const context =
await createCaptureContext(
browser,
@@ -2014,7 +2001,8 @@ async function runRoutes(
route,
theme: entry.theme,
profile: entry.profile,
- deep
+ deep: entry.deep,
+ coverageTier
});
} finally {
await context.close();
@@ -2048,7 +2036,10 @@ async function runDiscoveredRoutes(browser: Browser, auth: AuthState, seededRout
.sort();
if (pending.length === 0) return;
pending.forEach((route) => captured.add(route));
- await runRoutes(browser, auth, pending);
+ await runRoutes(browser, auth, pending, {
+ matrix: discoveredCoverageMatrix(config.scope, viewports),
+ coverageTier: "discovered"
+ });
}
}
@@ -2109,7 +2100,8 @@ async function runMediaPagination(
route,
theme,
profile,
- deep: true
+ deep: true,
+ coverageTier: "special"
});
} finally {
await context.close();
@@ -2128,7 +2120,7 @@ async function runVisualizerFallbackStates(browser: Browser) {
for (const variant of variants) {
const context = await createCaptureContext(browser, "anonymous", profile, "dark", variant.options);
try {
- await captureRoute({ context, auth: "anonymous", route: variant.route, theme: "dark", profile, deep: false });
+ await captureRoute({ context, auth: "anonymous", route: variant.route, theme: "dark", profile, deep: false, coverageTier: "special" });
} finally {
await context.close();
}
@@ -2168,16 +2160,9 @@ async function main() {
await ensureDirectory(config.runRoot);
await ensureDirectory(captureRoot);
- const browser =
- await chromium.launch({
- headless: true,
- chromiumSandbox: false,
- ...(config.browserChannel ? { channel: config.browserChannel } : {}),
- args: [
- "--disable-dev-shm-usage=false",
- "--hide-scrollbars=false"
- ]
- });
+ const browser = await chromium.launch(
+ chromiumLaunchOptions(config.browserChannel)
+ );
try {
await authenticateAdmin(browser);
@@ -2243,8 +2228,13 @@ async function main() {
liveReadonly: "Unsafe same-origin and cross-origin requests are blocked client-side; same-origin requests also carry the server read-only header.",
snapshotLab: "Mutation-dependent states are permitted only against the separately mounted SQLite and media clones."
},
- structuralMatrix:
- structuralMatrix()
+ structuralMatrix: canonicalCoverageMatrix(config.scope, viewports),
+ discoveredLinkMatrix: discoveredCoverageMatrix(config.scope, viewports),
+ matrixPolicy: {
+ canonical: "Every source/database route uses every configured viewport in dark and light; desktop-archival dark also expands deep states.",
+ discovered: "Every rendered same-origin link uses standard desktop, tablet, and mobile dark/light states plus archival desktop dark, without duplicate deep template expansion.",
+ continuousControls: "Intermediate continuous dimensions use finite boundary and pairwise representatives; raw values and selected state are retained in the manifest."
+ }
}
);
diff --git a/visual-audit/src/types.ts b/visual-audit/src/types.ts
index b314d75..981fea0 100644
--- a/visual-audit/src/types.ts
+++ b/visual-audit/src/types.ts
@@ -2,6 +2,7 @@ export type TargetMode = "live-readonly" | "snapshot-lab";
export type AuditScope = "smoke" | "full";
export type AuthState = "anonymous" | "admin";
export type ThemeMode = "dark" | "light";
+export type CoverageTier = "canonical" | "discovered" | "special";
export type ViewportProfile = {
name: string;
@@ -69,6 +70,7 @@ export type RouteResult = {
theme: ThemeMode;
viewport: string;
deep: boolean;
+ coverageTier: CoverageTier;
finalUrl: string;
status: number | null;
redirectChain: string[];
diff --git a/visual-audit/src/validate.ts b/visual-audit/src/validate.ts
index 4d85e3f..390f2ec 100644
--- a/visual-audit/src/validate.ts
+++ b/visual-audit/src/validate.ts
@@ -4,6 +4,10 @@ import path from "node:path";
import sharp from "sharp";
import { config, viewports } from "./config.js";
+import {
+ canonicalCoverageMatrix,
+ discoveredCoverageMatrix
+} from "./coverage-matrix.js";
import { isKnownExpectedDiagnostic } from "./diagnostics.js";
import { snapshotLabEvidenceFailures } from "./snapshot-lab-evidence.js";
import type { RunManifest, TileManifest } from "./types.js";
@@ -225,8 +229,6 @@ async function main() {
}
}
- const matrixProfiles = config.scope === "smoke" ? ["desktop-1440"] : viewports.map((viewport) => viewport.name);
- const matrixThemes = config.scope === "smoke" ? ["dark"] : ["dark", "light"];
const matrixRoutes = new Map();
for (const route of manifest.routes.filter((item) => !item.route.includes("auditState="))) {
const key = `${route.auth}::${route.route}`;
@@ -234,13 +236,23 @@ async function main() {
}
for (const [key, results] of matrixRoutes) {
- for (const profile of matrixProfiles) {
- for (const theme of matrixThemes) {
- const routeResult = results.find((item) => item.viewport === profile && item.theme === theme);
- if (!routeResult) failures.push(`Coverage matrix is missing ${profile}/${theme} for ${key}.`);
- else if (!manifest.captures.some((capture) => capture.auth === routeResult.auth && capture.route === routeResult.route && capture.viewport === profile && capture.theme === theme)) {
- failures.push(`Coverage matrix has no successful capture for ${profile}/${theme} ${key}.`);
- }
+ const tier = results.some((item) => item.coverageTier === "canonical")
+ ? "canonical"
+ : results.some((item) => item.coverageTier === "discovered")
+ ? "discovered"
+ : "special";
+ if (tier === "special") continue;
+ const expectedMatrix = tier === "canonical"
+ ? canonicalCoverageMatrix(config.scope, viewports)
+ : discoveredCoverageMatrix(config.scope, viewports);
+
+ for (const entry of expectedMatrix) {
+ const profile = entry.profile.name;
+ const theme = entry.theme;
+ const routeResult = results.find((item) => item.viewport === profile && item.theme === theme);
+ if (!routeResult) failures.push(`${tier} coverage matrix is missing ${profile}/${theme} for ${key}.`);
+ else if (!manifest.captures.some((capture) => capture.auth === routeResult.auth && capture.route === routeResult.route && capture.viewport === profile && capture.theme === theme)) {
+ failures.push(`${tier} coverage matrix has no successful capture for ${profile}/${theme} ${key}.`);
}
}
}
@@ -290,8 +302,40 @@ async function main() {
const reportIndexFile = path.join(config.runRoot, "report", "report-index.json");
if (!await exists(reportIndexFile)) failures.push("Report index is missing.");
const reportIndex = await exists(reportIndexFile)
- ? JSON.parse(await fs.readFile(reportIndexFile, "utf8")) as { restrictedPrintPages?: number; shareablePrintPages?: number }
+ ? JSON.parse(await fs.readFile(reportIndexFile, "utf8")) as {
+ sourceCaptureCount?: number;
+ captureCount?: number;
+ shareableSourceCaptureCount?: number;
+ shareableCaptureCount?: number;
+ restrictedPrintPages?: number;
+ shareablePrintPages?: number;
+ }
: {};
+ const selectionFile = path.join(config.runRoot, "report", "selection.json");
+ if (!await exists(selectionFile)) failures.push("Report representative selection manifest is missing.");
+ if (await exists(selectionFile)) {
+ const selection = JSON.parse(await fs.readFile(selectionFile, "utf8")) as {
+ sourceCaptureCount?: number;
+ selectedCaptureCount?: number;
+ selectedKeys?: string[];
+ missingRoutes?: string[];
+ };
+ const selectedKeys = new Set(selection.selectedKeys ?? []);
+ const manifestKeys = new Set(manifest.captures.map((capture) => capture.key));
+ const selectedRoutes = new Set(manifest.captures
+ .filter((capture) => selectedKeys.has(capture.key))
+ .map((capture) => `${capture.auth}::${capture.route}`));
+ const sourceRoutes = new Set(manifest.captures.map((capture) => `${capture.auth}::${capture.route}`));
+
+ if (selection.sourceCaptureCount !== manifest.captures.length) failures.push("Report selection source count does not match the manifest.");
+ if (selection.selectedCaptureCount !== selectedKeys.size || selectedKeys.size === 0) failures.push("Report selection count is empty or inconsistent.");
+ if ([...selectedKeys].some((key) => !manifestKeys.has(key))) failures.push("Report selection references an unknown capture key.");
+ if ([...sourceRoutes].some((route) => !selectedRoutes.has(route))) failures.push("Report selection omitted one or more source routes.");
+ if ((selection.missingRoutes ?? []).length > 0) failures.push("Report selection recorded missing routes.");
+ if (reportIndex.sourceCaptureCount !== manifest.captures.length || reportIndex.captureCount !== selectedKeys.size) {
+ failures.push("Report index capture counts do not match the representative selection.");
+ }
+ }
await Promise.all([
validateHtml(path.join(config.runRoot, "report", "index.html"), failures),
validateHtml(path.join(config.runRoot, "report", "print.html"), failures),
diff --git a/woodsmith_DeepWiki_Merged_03222026.md b/woodsmith_DeepWiki_Merged_03222026.md
index e6bdc9d..4b99639 100644
--- a/woodsmith_DeepWiki_Merged_03222026.md
+++ b/woodsmith_DeepWiki_Merged_03222026.md
@@ -137,7 +137,7 @@ Custom work type records still store default dimensions, material options, labor
`visual-audit/` is an independent TypeScript package pinned to Playwright 1.61.0, Sharp 0.35.3, and PDFKit 0.19.1. It reconciles source routes, a token-and-admin-protected bounded database inventory, and rendered same-origin links. `live-readonly` blocks unsafe browser requests and adds a server read-only header; `snapshot-lab` uses a verified SQLite/media clone on an internal Docker network with external providers disabled. Diagnostic exceptions require exact mutation-policy or same-origin RSC/prefetch evidence rather than matching generic browser error text.
-The runner captures the required desktop/tablet/mobile/theme matrix plus a 5120 x 2880 archival viewport, keyboard skip-link focus/activation, deep dialog/disclosure/lightbox/Studio/media/inline-edit/visualizer states, overlapping raw tiles and stitched long surfaces, and an element atlas. It writes restricted and redacted searchable HTML plus streamed bookmarked PDF editions, SHA-256 manifests, route/network/render diagnostics, tile-seam validation, and baseline comparisons. See `docs/visual-archive.md` for the exact safety and operating contract.
+The runner captures the complete desktop/tablet/mobile/theme matrix plus a 5120 x 2880 archival viewport on canonical source/database routes, and uses recorded desktop/tablet/mobile theme representatives plus archival desktop for rendered link variants. Canonical archival-dark states cover keyboard skip-link focus/activation, dialogs, disclosures, lightboxes, Studio/media/inline editing, visualizer boundaries, overlapping raw tiles and stitched long surfaces, and the element atlas. The restricted PNG tree and searchable HTML retain every capture. A deterministic per-route selection manifest bounds the streamed bookmarked PDF and redacted shareable editions without deleting raw evidence. The Windows disposable harness validates either strict live-readonly behavior or the bounded snapshot-lab mutation flow against an online SQLite clone and separately copied synthetic media, then proves source immutability and full Docker cleanup. SHA-256 manifests, route/network/render diagnostics, tile-seam validation, and baseline comparisons remain release gates. See `docs/visual-archive.md` for the exact safety and operating contract.
## Search
From 2bde9732bbfbdf768b1ececbb7fde218dbcf1f04 Mon Sep 17 00:00:00 2001
From: Cooper Beaman <55124795+lowestprime@users.noreply.github.com>
Date: Tue, 14 Jul 2026 19:50:10 -0700
Subject: [PATCH 19/43] fix(qa): drain media before deep capture teardown
---
visual-audit/src/readiness.ts | 33 +++++++++++++++++++++++----------
visual-audit/src/run.ts | 4 +++-
2 files changed, 26 insertions(+), 11 deletions(-)
diff --git a/visual-audit/src/readiness.ts b/visual-audit/src/readiness.ts
index 2d4328f..0ccae5c 100644
--- a/visual-audit/src/readiness.ts
+++ b/visual-audit/src/readiness.ts
@@ -108,15 +108,7 @@ async function waitForStableLayout(page: Page) {
throw new Error("Visual readiness did not reach three consecutive stable layout samples.");
}
-export async function waitForVisualReady(page: Page) {
- await page.locator("body").waitFor({ state: "visible", timeout: 30_000 });
- await page.locator("main").first().waitFor({ state: "attached", timeout: 30_000 }).catch(() => undefined);
- await page.addStyleTag({ content: READINESS_CSS });
-
- await triggerLazyContent(page);
- await settleMedia(page);
- await waitForStableLayout(page);
-
+async function waitForBusySurfaces(page: Page) {
await page.waitForFunction(() => {
const visibleBusy = Array.from(document.querySelectorAll('[aria-busy="true"], .loading-pulse')).some((element) => {
const style = getComputedStyle(element);
@@ -125,6 +117,24 @@ export async function waitForVisualReady(page: Page) {
});
return !visibleBusy;
}, { timeout: 15_000 });
+}
+
+export async function waitForVisualIdle(page: Page) {
+ await page.waitForTimeout(100);
+ await settleMedia(page);
+ await waitForStableLayout(page);
+ await waitForBusySurfaces(page);
+}
+
+export async function waitForVisualReady(page: Page) {
+ await page.locator("body").waitFor({ state: "visible", timeout: 30_000 });
+ await page.locator("main").first().waitFor({ state: "attached", timeout: 30_000 }).catch(() => undefined);
+ await page.addStyleTag({ content: READINESS_CSS });
+
+ await triggerLazyContent(page);
+ await settleMedia(page);
+ await waitForStableLayout(page);
+ await waitForBusySurfaces(page);
await page.evaluate(() => {
const candidates = Array.from(document.querySelectorAll([
@@ -139,5 +149,8 @@ export async function waitForVisualReady(page: Page) {
window.scrollTo(0, 0);
});
- await page.waitForTimeout(100);
+ // Restoring the canonical scroll position can select new responsive image
+ // candidates. Settle those requests before screenshots begin so context
+ // teardown never manufactures client-aborted image diagnostics.
+ await waitForVisualIdle(page);
}
diff --git a/visual-audit/src/run.ts b/visual-audit/src/run.ts
index 954d456..4e663ec 100644
--- a/visual-audit/src/run.ts
+++ b/visual-audit/src/run.ts
@@ -36,7 +36,7 @@ import {
discoverSourceRoutes,
fetchInventory
} from "./inventory.js";
-import { waitForVisualReady } from "./readiness.js";
+import { waitForVisualIdle, waitForVisualReady } from "./readiness.js";
import { auditTokenEligible, isSyntheticVisitTelemetry, isUnsafeMethod } from "./policy.js";
import { assertFocusedSkipLink, assertMainFocusTransferred } from "./skip-link.js";
import { SNAPSHOT_LAB_COMMISSION_DRAFT_STATE } from "./snapshot-lab-evidence.js";
@@ -1863,6 +1863,8 @@ async function captureRoute(input: {
});
}
}
+
+ await waitForVisualIdle(page);
}
await persistManifest();
From 3fbb36592139115d823491aa380ed948c586e370 Mon Sep 17 00:00:00 2001
From: Cooper Beaman <55124795+lowestprime@users.noreply.github.com>
Date: Tue, 14 Jul 2026 20:01:37 -0700
Subject: [PATCH 20/43] fix(qa): drain media after every archive capture
---
visual-audit/src/run.ts | 11 +++++++++--
1 file changed, 9 insertions(+), 2 deletions(-)
diff --git a/visual-audit/src/run.ts b/visual-audit/src/run.ts
index 4e663ec..38e90d4 100644
--- a/visual-audit/src/run.ts
+++ b/visual-audit/src/run.ts
@@ -988,6 +988,11 @@ async function saveCapture(input: {
files = input.locator
? await captureElement(input.locator, outputDirectory, baseName)
: await capturePageSurface(input.page, outputDirectory, baseName, input.fullPage ?? true);
+
+ // A screenshot can expose a new lazy or responsive image candidate. Drain
+ // that work before the next capture changes scroll/viewport state so the
+ // browser does not manufacture ERR_ABORTED diagnostics between states.
+ await waitForVisualIdle(input.page);
} catch (error) {
manifest.diagnostics.push({
timestamp: now(),
@@ -1863,10 +1868,12 @@ async function captureRoute(input: {
});
}
}
-
- await waitForVisualIdle(page);
}
+ // Deep and canonical-only routes can both leave responsive media work
+ // behind after their final interaction. Settle it before closing the page.
+ await waitForVisualIdle(page);
+
await persistManifest();
} catch (error) {
manifest.diagnostics.push({
From 7e18b9d039b654da2b8e629cf45b8508d947e80f Mon Sep 17 00:00:00 2001
From: Cooper Beaman <55124795+lowestprime@users.noreply.github.com>
Date: Tue, 14 Jul 2026 20:12:58 -0700
Subject: [PATCH 21/43] perf(qa): wait only for active visual requests
---
visual-audit/src/request-drain.test.ts | 36 +++++++++++++++++++++
visual-audit/src/request-drain.ts | 30 ++++++++++++++++++
visual-audit/src/run.ts | 44 +++++++++++++++++++++++---
3 files changed, 106 insertions(+), 4 deletions(-)
create mode 100644 visual-audit/src/request-drain.test.ts
create mode 100644 visual-audit/src/request-drain.ts
diff --git a/visual-audit/src/request-drain.test.ts b/visual-audit/src/request-drain.test.ts
new file mode 100644
index 0000000..65928fb
--- /dev/null
+++ b/visual-audit/src/request-drain.test.ts
@@ -0,0 +1,36 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { waitForRequestDrain } from "./request-drain.js";
+
+test("request drain requires consecutive quiet samples", async () => {
+ const samples = [0, 1, 0, 0, 0];
+ let index = -1;
+ let sleeps = 0;
+
+ await waitForRequestDrain({
+ intervalMs: 1,
+ quietSamples: 3,
+ timeoutMs: 10,
+ pendingCount: () => samples[Math.min(index, samples.length - 1)] ?? 0,
+ sleep: async () => {
+ index += 1;
+ sleeps += 1;
+ }
+ });
+
+ assert.equal(sleeps, samples.length);
+});
+
+test("request drain fails closed when visual requests never finish", async () => {
+ await assert.rejects(
+ waitForRequestDrain({
+ intervalMs: 1,
+ quietSamples: 2,
+ timeoutMs: 3,
+ pendingCount: () => 1,
+ sleep: async () => undefined
+ }),
+ /did not drain within 3ms \(1 still pending\)/
+ );
+});
diff --git a/visual-audit/src/request-drain.ts b/visual-audit/src/request-drain.ts
new file mode 100644
index 0000000..21d0c93
--- /dev/null
+++ b/visual-audit/src/request-drain.ts
@@ -0,0 +1,30 @@
+export async function waitForRequestDrain(input: {
+ pendingCount: () => number;
+ sleep: (milliseconds: number) => Promise;
+ intervalMs?: number;
+ quietSamples?: number;
+ timeoutMs?: number;
+}) {
+ const intervalMs = input.intervalMs ?? 50;
+ const quietSamples = input.quietSamples ?? 3;
+ const timeoutMs = input.timeoutMs ?? 15_000;
+ const attempts = Math.max(quietSamples, Math.ceil(timeoutMs / intervalMs));
+ let consecutiveQuietSamples = 0;
+
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
+ await input.sleep(intervalMs);
+ const pending = input.pendingCount();
+ if (!Number.isInteger(pending) || pending < 0) {
+ throw new Error("Visual request tracking returned an invalid pending count.");
+ }
+
+ if (pending === 0) consecutiveQuietSamples += 1;
+ else consecutiveQuietSamples = 0;
+
+ if (consecutiveQuietSamples >= quietSamples) return;
+ }
+
+ throw new Error(
+ `Visual requests did not drain within ${timeoutMs}ms (${input.pendingCount()} still pending).`
+ );
+}
diff --git a/visual-audit/src/run.ts b/visual-audit/src/run.ts
index 38e90d4..4c2d9cb 100644
--- a/visual-audit/src/run.ts
+++ b/visual-audit/src/run.ts
@@ -7,7 +7,8 @@ import {
type Browser,
type BrowserContext,
type Locator,
- type Page
+ type Page,
+ type Request
} from "playwright";
import { chromiumLaunchOptions } from "./browser-launch.js";
@@ -37,6 +38,7 @@ import {
fetchInventory
} from "./inventory.js";
import { waitForVisualIdle, waitForVisualReady } from "./readiness.js";
+import { waitForRequestDrain } from "./request-drain.js";
import { auditTokenEligible, isSyntheticVisitTelemetry, isUnsafeMethod } from "./policy.js";
import { assertFocusedSkipLink, assertMainFocusTransferred } from "./skip-link.js";
import { SNAPSHOT_LAB_COMMISSION_DRAFT_STATE } from "./snapshot-lab-evidence.js";
@@ -75,6 +77,7 @@ let manifest: RunManifest;
const preAuthenticationDiagnostics: DiagnosticRecord[] = [];
let preAuthenticationUnsafeBlocks = 0;
const intentionalMutationBlocks = new WeakMap>();
+const pendingVisualRequests = new WeakMap>();
const coverageExclusions = [
{ surface: "Third-party origins", reason: "Recorded as network diagnostics only; the archive never sends credentials or audit tokens cross-origin." },
@@ -197,6 +200,19 @@ function attachDiagnostics(
route: string,
blockedRequests: ReadonlySet
) {
+ const pendingRequests = new Set();
+ pendingVisualRequests.set(page, pendingRequests);
+
+ page.on("request", request => {
+ if (["font", "image", "media"].includes(request.resourceType())) {
+ pendingRequests.add(request);
+ }
+ });
+
+ page.on("requestfinished", request => {
+ pendingRequests.delete(request);
+ });
+
page.on("console", message => {
if (
["error", "warning"].includes(
@@ -230,6 +246,7 @@ function attachDiagnostics(
});
page.on("requestfailed", request => {
+ pendingRequests.delete(request);
const failure =
request.failure()?.errorText ||
"unknown request failure";
@@ -311,6 +328,26 @@ function attachDiagnostics(
});
}
+async function waitForCaptureRequestDrain(page: Page) {
+ const pendingRequests = pendingVisualRequests.get(page);
+ if (!pendingRequests) {
+ throw new Error("Visual request tracking was not attached to the capture page.");
+ }
+
+ await waitForRequestDrain({
+ pendingCount: () => pendingRequests.size,
+ sleep: milliseconds => page.waitForTimeout(milliseconds)
+ });
+
+ await page.evaluate(async () => {
+ await Promise.all(Array.from(document.images).map(image =>
+ image.complete && image.naturalWidth > 0
+ ? image.decode().catch(() => undefined)
+ : Promise.resolve()
+ ));
+ });
+}
+
async function authenticateAdmin(
browser: Browser
) {
@@ -990,9 +1027,8 @@ async function saveCapture(input: {
: await capturePageSurface(input.page, outputDirectory, baseName, input.fullPage ?? true);
// A screenshot can expose a new lazy or responsive image candidate. Drain
- // that work before the next capture changes scroll/viewport state so the
- // browser does not manufacture ERR_ABORTED diagnostics between states.
- await waitForVisualIdle(input.page);
+ // only tracked visual requests before the next capture changes state.
+ await waitForCaptureRequestDrain(input.page);
} catch (error) {
manifest.diagnostics.push({
timestamp: now(),
From 88d9cece79949bffa1a5d9b0ad19a7b4f73933ab Mon Sep 17 00:00:00 2001
From: Cooper Beaman <55124795+lowestprime@users.noreply.github.com>
Date: Tue, 14 Jul 2026 20:32:59 -0700
Subject: [PATCH 22/43] fix(qa): settle protected route redirects
---
visual-audit/src/navigation-settle.test.ts | 58 ++++++++++++++++++++++
visual-audit/src/navigation-settle.ts | 56 +++++++++++++++++++++
visual-audit/src/run.ts | 35 ++++++++++++-
3 files changed, 147 insertions(+), 2 deletions(-)
create mode 100644 visual-audit/src/navigation-settle.test.ts
create mode 100644 visual-audit/src/navigation-settle.ts
diff --git a/visual-audit/src/navigation-settle.test.ts b/visual-audit/src/navigation-settle.test.ts
new file mode 100644
index 0000000..32bee0e
--- /dev/null
+++ b/visual-audit/src/navigation-settle.test.ts
@@ -0,0 +1,58 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ isNavigationInterruption,
+ waitForNavigationSettle
+} from "./navigation-settle.js";
+
+test("navigation settling follows a client redirect to a stable document", async () => {
+ const samples: Array