diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
index 628c476..34a8f76 100644
--- a/.gitea/workflows/deploy.yml
+++ b/.gitea/workflows/deploy.yml
@@ -213,15 +213,26 @@ jobs:
# We also need to (1) ship the standalone loader script
# `scripts/start-standalone.cjs`, (2) copy `.next/static/` into
# `.next/standalone/.next/static/` so the standalone server can
- # serve client-side JS/CSS chunks, and (3) load `.env` via a
- # Node loader — bash's `set -a; . ./.env` truncates DATABASE_URL
- # at the `&` in `&channel_binding=require`.
+ # serve client-side JS/CSS chunks, (3) copy `public/` into
+ # `.next/standalone/public/` (REQUIRED for Next.js standalone —
+ # without it, `/_next/image?url=/brand-logos/...` returns 400
+ # because the optimizer can't resolve local relative paths),
+ # and (4) load `.env` via a Node loader — bash's
+ # `set -a; . ./.env` truncates DATABASE_URL at the `&` in
+ # `&channel_binding=require`.
scp -o ConnectTimeout=15 -o StrictHostKeyChecking=no scripts/start-standalone.cjs tyler@route.crispygoat.com:$APP_DIR/scripts/start-standalone.cjs 2>/dev/null || true
ssh -o ConnectTimeout=60 -o StrictHostKeyChecking=no tyler@route.crispygoat.com "set -e; cd $APP_DIR && npm install --omit=dev 2>&1 | tail -5 && \
# Standalone server reads .next/static/ relative to itself.
mkdir -p .next/standalone/.next && \
rm -rf .next/standalone/.next/static && \
cp -r .next/static .next/standalone/.next/static && \
+ # Next.js standalone REQUIRES public/ in the standalone tree;
+ # without it, the image optimizer returns 400 on local-relative
+ # image URLs (e.g. /brand-logos/.../logo.png) and storefront
+ # logos / favicons go blank. Mirror public/ → .next/standalone/public/.
+ rm -rf .next/standalone/public && \
+ mkdir -p .next/standalone/public && \
+ cp -r public/. .next/standalone/public/ && \
# Start with the wrapper that loads .env and exec's the server.
# `pm2 start` is idempotent via `pm2 restart` after the first run.
pm2 delete route-commerce 2>/dev/null || true; \
diff --git a/scripts/regression-brand-logos.mjs b/scripts/regression-brand-logos.mjs
new file mode 100644
index 0000000..d034cbd
--- /dev/null
+++ b/scripts/regression-brand-logos.mjs
@@ -0,0 +1,82 @@
+// Regression test: brand logo (and all /brand-logos/... images) must load
+// through Next.js Image optimizer on the tuxedo storefront.
+//
+// Pre-fix state:
+// GET https://water.tuxedocorn.com/_next/image?url=%2Fbrand-logos%2F64294306-...%2Flogo.png
+// → HTTP 400, body: "url parameter is not allowed"
+//
+// Post-fix expectation:
+// → HTTP 200, content-type: image/* and non-empty body
+//
+// Root cause identified: Next.js standalone build is missing the
+// `.next/standalone/public/` folder. The deploy workflow copies
+// `.next/static` but not `public/`. Without `public/` in standalone, the
+// image optimizer can't resolve local-relative URLs.
+
+import { setTimeout as sleep } from 'node:timers/promises';
+
+const URL = 'https://water.tuxedocorn.com/tuxedo?_=' + Date.now();
+const LOGO_PROXY =
+ 'https://water.tuxedocorn.com/_next/image?url=%2Fbrand-logos%2F64294306-5f42-463d-a5e8-2ad6c81a96de%2Flogo.png&w=256&q=75';
+
+const failures = [];
+
+// 1. Hit the image proxy directly
+const r = await fetch(LOGO_PROXY, {
+ headers: { 'User-Agent': 'regression-brand-logos/1.0' },
+});
+if (r.status !== 200) {
+ failures.push(`Logo proxy returned HTTP ${r.status} (expected 200)`);
+} else {
+ const ct = r.headers.get('content-type') ?? '';
+ if (!ct.startsWith('image/')) failures.push(`Logo proxy content-type was ${ct} (expected image/*)`);
+ const buf = new Uint8Array(await r.arrayBuffer());
+ if (buf.length < 1000) failures.push(`Logo proxy body too small: ${buf.length} bytes`);
+ console.log(` Logo proxy: HTTP ${r.status} ${ct} ${buf.length}B`);
+}
+
+// 2. Verify via headless browser — visit page, check that the logo
actually rendered.
+const { chromium } = await import('playwright');
+const browser = await chromium.launch();
+const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
+const page = await ctx.newPage();
+const failed = [];
+page.on('response', (resp) => {
+ const u = resp.url();
+ if (u.includes('brand-logos') && resp.status() >= 400) failed.push(`HTTP ${resp.status()} ${u.slice(0, 120)}`);
+});
+await page.goto(URL, { waitUntil: 'networkidle', timeout: 30000 });
+// Scroll to bottom so footer logo loads.
+await page.evaluate(() => window.scrollTo({ top: document.body.scrollHeight, behavior: 'instant' }));
+await sleep(4000);
+await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' }));
+await sleep(2000);
+
+const logoInfo = await page.evaluate(() => {
+ const imgs = Array.from(document.querySelectorAll('img'));
+ const logos = imgs.filter((i) => (i.currentSrc || i.src || '').includes('brand-logos'));
+ return logos.map((i) => ({
+ alt: i.alt.slice(0, 40),
+ currentSrc: (i.currentSrc || i.src || '').slice(0, 130),
+ naturalWidth: i.naturalWidth,
+ naturalHeight: i.naturalHeight,
+ complete: i.complete,
+ visible: i.getBoundingClientRect().height > 0,
+ }));
+});
+console.log('\n LOGO IMAGES ON PAGE:');
+for (const l of logoInfo) console.log(' ', JSON.stringify(l));
+
+const anyLogoBlank = logoInfo.some((l) => l.naturalWidth === 0 || !l.complete);
+if (anyLogoBlank) failures.push(`Logo image(s) failed to render: ${JSON.stringify(logoInfo)}`);
+if (failed.length > 0) failures.push(`Network failed for brand-logos URLs: ${failed.join(' | ')}`);
+
+await browser.close();
+
+if (failures.length > 0) {
+ console.error('\n❌ REGRESSION FAILED:');
+ for (const f of failures) console.error(' -', f);
+ process.exit(1);
+} else {
+ console.log('\n✅ Brand logos serve correctly through Next.js Image optimizer.');
+}
diff --git a/src/actions/water-log/auth.ts b/src/actions/water-log/auth.ts
index 3bf0555..07169a2 100644
--- a/src/actions/water-log/auth.ts
+++ b/src/actions/water-log/auth.ts
@@ -40,11 +40,16 @@ import {
// ── Result types ────────────────────────────────────────────────────────
-// `FieldSession` is now defined in `@/lib/water-log/session.ts` so the
-// `FieldSession | { ok: false, error }` shape is shared with
+// `FieldSession` is now defined in `@/lib/water-log/session-server.ts`
+// so the `FieldSession | { ok: false, error }` shape is shared with
// `lib/water-log/pin-login.ts` (Phase 2) and the rest of the module
-// surface. Re-export for back-compat with existing call sites.
-export type { FieldSession };
+// surface. Consumers should import as:
+// `import type { FieldSession } from "@/lib/water-log/session-server";`
+// The previous `export type { FieldSession }` re-export here caused
+// Next.js 16.2's RSC bundler to fail with "Export FieldSession doesn't
+// exist in target module" for pages that reference it indirectly
+// through server actions — so we removed the re-export and let
+// downstream consumers point at the source.
export type SiteAdminPermission =
| { ok: true; adminUser: NonNullable>> }
diff --git a/src/actions/water-log/field.ts b/src/actions/water-log/field.ts
index b8c6c6b..a00e019 100644
--- a/src/actions/water-log/field.ts
+++ b/src/actions/water-log/field.ts
@@ -32,10 +32,10 @@ import { verifyPin, validatePin } from "@/lib/water-log-pin";
import { logAlert } from "@/lib/water-log-audit";
import {
requireFieldSession,
- type FieldSession,
} from "@/actions/water-log/auth";
import {
WL_SESSION_COOKIE,
+ type FieldSession,
clearSessionCookie,
readSessionCookie,
setSessionCookie,
@@ -45,7 +45,11 @@ import { triggerSyncForEntry } from "@/actions/water-log/smartsheet";
// Re-export so existing call sites that import from
// `@/actions/water-log/field` keep working.
-export type { FieldSession };
+// Note: the re-export was moved to `@/lib/water-log/session-server`
+// in commit context; consumers that need `FieldSession` as a type
+// should now import it directly from `@/lib/water-log/session-server`.
+// This avoids Next.js 16.2's RSC bundler bug that treats type-only
+// re-exports through server-action wrappers as runtime exports.
// Field sessions last 8h — a working day in the field. Long enough
// that a single sign-in covers a morning shift + afternoon shift.
diff --git a/src/app/tuxedo/page.tsx b/src/app/tuxedo/page.tsx
index 6ca182e..5c64277 100644
--- a/src/app/tuxedo/page.tsx
+++ b/src/app/tuxedo/page.tsx
@@ -202,6 +202,7 @@ function PullQuoteBand() {
fill
sizes="100vw"
quality={88}
+ priority
className="object-cover -z-10"
/>
{/* Layered tints: top darkens the sky, bottom grounds the type. */}
@@ -343,6 +344,7 @@ function FounderStrip() {
fill
sizes="(max-width: 1024px) 100vw, 42vw"
quality={88}
+ priority
className="object-cover"
/>
{/* Soft amber wash on the bottom — matches The Corn plate. */}