# The Layout Mapping Engine

## Scope

Not the final storefront — a reusable engine every future storefront template (Nike, Apple, Premium, Retail, Marketplace) builds on. This phase ships exactly one template, "Nike Layout," modeled on a Nike homepage reference used purely for *layout* (grid, spacing, proportions, section order) — no real images, no real products, no real promotional copy. Every visual content area is a uniquely identifiable **region** (`HERO-001`, `FEATURE-001`–`004`, `PROMO-001`, `CATEGORY-001`, `PRODUCT-CAROUSEL-001`), rendered as a wireframe placeholder box (`#F3F4F6` background, thin gray border, centered bold region ID, nothing else) until an image is uploaded for it. Footer nav columns are static site chrome, not a mappable region.

Content is deliberately narrow: a region has an uploaded image and, optionally, overlay copy (heading/subheading) plus a single CTA button (label + link) — nothing richer. There's no product/collection/category/brand/promotion/video/dynamic-module content-source concept; that richer model was explored in an earlier pass and dropped in favor of this simpler, provable contract. Once an image is uploaded for a region, it fully replaces the gray placeholder on the storefront; nothing else (real product rendering, dynamic modules, a content-source picker) is in scope here.

## Relationship to the (now-retired) homepage module system

CommerceDex used to have a `HomepageModuleConfig` system (`storefront/src/homepage/`) — a hardcoded array, 6 module types, a `ContentSource` union, an `EditableRegion` "unconfigured" placeholder. The Layout Mapping Engine's Hero/Feature/Promo/Category-banner regions replaced that system's `hero-banner`/`dark-feature`/`collection-grid` module types; the `category-rail` and `catalog-grid` module types were later removed outright (not replaced — category browsing lives in the header nav and `/category/:id` pages, catalog browsing via "Shop all"), leaving only `product-rail` ("New arrivals"). With just one live section left, the whole module-system abstraction was retired too — `Home.tsx` now fetches Nike Layout's regions and "New arrivals" directly, with no generic config/registry/resolver layer in between. See `docs/design/08-homepage-module-system.md` for what the homepage looks like today.

`PRODUCT-CAROUSEL-001` is deliberately **not** used on the real homepage — it has no live-data backing in this engine, and "New arrivals" already fills that visual role with real data. It stays available for other future templates.

On the real homepage, an unconfigured region (`HomeLayoutBanner.tsx`) renders nothing at all — no gray wireframe box, no region-key label. That debug-looking placeholder is a `RegionPlaceholder`/`LayoutPreviewPage`-only affordance for the admin mapping tool; showing it to real shoppers would look broken.

## Backend (`laravel/Modules/StorefrontLayouts`)

Two tables: `storefront_layouts_layout` (id, name, slug) and `storefront_layouts_region` (id, layout_id, region_key, region_type, position, content_source_kind, content_source_ref, heading, subheading, cta_label, cta_url). `content_source_kind` is always exactly `"media"` or `null` — set automatically whenever `mediaAssetId` is part of an update, never caller-specified — and `content_source_ref` holds the uploaded asset's ID. `heading`/`subheading`/`cta_label`/`cta_url` are independent, nullable columns — a region can have an image with no text, text with no image (renders nothing on the real homepage until an image exists too), a CTA button with neither, or any combination. The button only renders when both `cta_label` and `cta_url` are set. No foreign key from `content_source_ref` into Media — matching the existing cross-module "plain string, no FK" convention. `cta_url` isn't validated as an absolute URL — it may be an internal path (`/products/some-slug`) or an external URL.

`StorefrontLayoutsService::RECOMMENDED_SIZE_BY_TYPE` maps each `region_type` to a recommended width/height (hero 1920×860, feature 960×768, promo 1920×800, category 1920×480, product-carousel 1920×480) — purely a display hint for the admin editor's "Recommended Size" field, chosen to match the aspect ratio each region actually renders at on the storefront preview so an upload isn't a surprise crop. The presented region shape is `{id, regionKey, regionType, position, mediaAssetId, mediaUrl, heading, subheading, ctaLabel, ctaUrl, recommendedWidth, recommendedHeight}`.

`StorefrontLayoutsService::updateRegion()` is a **partial update**: only keys actually present in the request are touched (`array_key_exists`, not `??`), because an image upload and a text save are two independent actions from the same admin card and must never clobber each other. The controller's validation uses `sometimes` on every field for exactly this reason — `Validator::validated()` only includes keys the caller actually sent.

Two Nike Layout regions were renamed after the initial pass to fit the engine's settled naming scheme: the single full-width banner is `PROMO-001` (`region_type: promo`), not a one-off "footer banner"; the "Choose Your Team" grid is `CATEGORY-001` (`region_type: category`), a grid of category-like entry points rather than a one-off "team" type.

Routes: a public `GET /storefront/layouts/{slug}` (no auth — the storefront preview loads this with no session at all, since it's a tool surface, not a customer-facing page) and staff routes (`GET /layouts`, `GET /layouts/{slug}`, `PATCH /layouts/{slug}/regions/{regionKey}` accepting `{mediaAssetId?, heading?, subheading?, ctaLabel?, ctaUrl?}`) gated by the existing `marketing.view`/`marketing.manage` permissions.

### Image quality: "large" variant, not "medium"

`MediaService` generates `thumbnail`/`medium`/`large` WebP variants per upload. Every full-bleed or otherwise large placement (this engine's banners, the product detail page's main gallery image) must request `getLargeVariantUrls()`, not `getMediumVariantUrls()` — "medium" is capped at 600px wide, correctly sized for small product-grid thumbnails but visibly blurry when a browser stretches it across a ~1920px-wide hero. "large" was bumped from 1200px to 1920px for this reason. This only affects newly uploaded assets — existing images keep whatever dimensions their variants were generated at, since variants aren't regenerated automatically when the target width changes.

## Storefront half (`storefront/src/layoutPreview/`, `storefront/src/homepage/`)

`LayoutPreviewPage` lives at `/layout-preview/:slug`, deliberately outside the real `<Layout>` shell — no header, footer, or cart, since a shopper never reaches this route. It fetches the layout's regions and renders each through `RegionPlaceholder`: for an unconfigured region, a `#F3F4F6` box with a thin gray border and a bold centered region key — no image icon, no upload icon, no instructions, no buttons, no helper text. Once a region has an uploaded image, `RegionPlaceholder` also renders the heading/subheading/CTA overlay when any is set, so this admin-facing preview is a close WYSIWYG match for what `HomeLayoutBanner` renders on the real homepage — same scrim/heading/subheading CSS, duplicated rather than shared since the two components' empty-states are otherwise unrelated (wireframe box vs. render-nothing). The one deliberate difference: the CTA renders as a styled `<span>`, not a real link — `RegionPlaceholder`'s root element is itself a `<button>` for click-to-map, and nesting interactive content inside a `<button>` is invalid HTML; clicking anywhere in the preview selects the region, it never navigates.

The scrim is a `linear-gradient(to bottom, black 65% opacity → transparent)`, text centered and top-anchored, only rendered when `heading || subheading || (ctaLabel && ctaUrl)` is truthy — a plain image gets no scrim at all. The hero gets the site's largest type scale (`--type-hero-size`); every other region type uses a smaller, single scale, since feature/promo/category boxes are too short/narrow for headline-scale text to read well.

On the real homepage, `HomeLayoutBanner`'s CTA button is a router `<Link>` for internal paths and a plain `<a>` only for URLs matching `^https?://` — the storefront is mounted under a `/commercedex` basename in production (`App.tsx`), so a plain `<a href="/shop">` would resolve against the domain root and 404 instead of navigating within the app. The button itself reuses `Button.module.css`'s own `primary`/`md` classes rather than duplicating that CSS, so it stays visually consistent with every other button on the site instead of copying the reference screenshot's colors verbatim.

## Admin half (`admin/src/pages/storefront-layouts/`)

"Storefront Layouts" in the main nav → a list page (currently one row: Nike Layout) → the editor page: a vertical list of per-region cards (Region ID, Recommended Size, Current Media, Upload/Replace Image, then Heading/Subheading/Button Label/Button Link text fields with a single "Save Text" button) beside an iframe loading the storefront's own `/layout-preview/nike-layout`. Uploading calls `MediaService` via `POST /media`, then `PATCH .../regions/{regionKey}` with `{mediaAssetId}` only; saving text sends `{heading, subheading, ctaLabel, ctaUrl}` together (one small form — clearing a field to empty submits `null`) — the backend's partial-update semantics mean neither action touches the other's data. Both mutations bump `previewReloadKey` to remount the iframe, so the change shows up immediately with no manual reload.

## Two-way click-to-map

`postMessage` in both directions, since the iframe is same-origin in production (admin at `/commercedex/portal`, storefront at `/commercedex`, one Laravel domain) but cross-origin in local dev (two separate Vite dev servers). Clicking a region card in the admin editor posts `{ type: "layout-mapping:highlight-region", regionKey }` into the iframe; clicking a placeholder in the preview posts `{ type: "layout-mapping:region-selected", regionKey }` to the parent window, which selects and scrolls to the matching card.

## Verified live

End-to-end Puppeteer passes against disposable staff accounts, cleaned up afterward each time: wireframe placeholder styling before any upload; the admin card format; an uploaded image fully replacing the storefront placeholder with zero manual reload; a freshly uploaded asset's "large" variant generating at 1920px (not the old 1200px); heading/subheading/CTA saved from the admin card rendering as a centered, top-anchored overlay on both the real homepage and the admin's live wireframe preview, with the image itself untouched by a text-only save. Caught and fixed one real bug in this process: the CTA button's link initially resolved against the domain root (`http://localhost/shop`) instead of the app's `/commercedex` basename — fixed by routing internal paths through `<Link>` instead of a plain `<a>`.
