diff --git "a/web/src/customer-grid/CatalogView.tsx" "b/web/src/customer-grid/CatalogView.tsx"
--- "a/web/src/customer-grid/CatalogView.tsx"
+++ "b/web/src/customer-grid/CatalogView.tsx"
@@ -1,1244 +1,1728 @@
-// ---------------------------------------------------------------------------
-// customer-grid / CatalogView.tsx
-// Wave-18 owner item 4 (contract C6-CATALOG) — the CATALOG view.
-//
-// A view holds several catalogs; a catalog is an ordered list of pages; a
-// section page names product CODES and joins them to the live pool at paint
-// time. Three columns: the rail (catalogs, then that catalog's pages), the
-// stage (the pages themselves, at true paper size, scaled to fit), and the
-// inspector (everything about the selected page, plus the catalog's brand,
-// paper and column bindings).
-//
-// FIDELITY. The default templates reproduce `Royal Collection 2027.pdf`, which
-// was rendered and read before this file was written:
-// · section header — letterspaced caps, navy, light weight, top-left
-// · product renders on WHITE (the C2-ASSET web derivative is alpha-preserving
-// PNG precisely so this works)
-// · description in caps, centred, two lines
-// · a full-width navy PILL carrying the pack line
-// · SKU in terracotta bold on the left, colour name on the right
-// · a full-bleed lifestyle strip under the grid
-// · crown mark bottom-left, folio bottom-right
-// · gallery pages full-bleed with a letterspaced caps title
-//
-// TYPE. Catalog pages use a display SERIF for titles (`--cat-serif`, a system
-// Didone/old-style stack — Didot, Bodoni, Playfair, Cormorant, Georgia). That is
-// deliberate and it is the ONLY place it appears: app chrome stays Inter, per
-// the owner constant. Listing text is Inter with wide tracking, which is what
-// the reference's geometric sans reads as at listing size. NO EMOJIS anywhere —
-// every mark here is a drawn path.
-// ---------------------------------------------------------------------------
-
-import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
-import { createPortal } from "react-dom";
-import {
- CATALOG_COLS,
- CATALOG_NAME_MAX,
- CATALOG_ORIENTATIONS,
- CATALOG_PAGE_KINDS,
- CATALOG_PAPERS,
- MAX_CATALOGS,
- MAX_CATALOG_PAGES,
- MAX_CATALOG_CODES,
-} from "./types";
-import type {
- CatalogOrientation,
- CatalogPage,
- CatalogPageKind,
- CatalogPaper,
- CatalogQuality,
- CatalogSpec,
- Field,
- Row,
-} from "./types";
-import {
- CATALOG_CODE_FIELD,
- DEFAULT_BRAND,
- EDITORIAL_PREFIX,
- PAGE_KIND_LABELS,
- PAPER_LABELS,
- addCodes,
- assetUrl,
- autoBind,
- codesUsed,
- editorialSlug,
- fetchAssetCodes,
- fetchEditorialSlugs,
- indexByCode,
- newCatalog,
- newPage,
- pageBox,
- resolveItems,
- sectionLayout,
- slugFromFilename,
- uploadEditorial,
-} from "./catalogData";
-import type { CatalogItem } from "./catalogData";
-// Wave-19 R7 — the record-image namespace, so the gap report can say "I do not know" about an
-// upload rather than marking every one of them missing.
-import { RECORD_IMAGE_PREFIX } from "./types";
-import { applyPrintCss, awaitImages, printOnce } from "./catalogPrint";
-import { formatDisplay } from "./display";
-
-// ------------------------------------------------------------------- marks
-
-const STROKE = {
- stroke: "currentColor",
- strokeWidth: 1.5,
- strokeLinecap: "round" as const,
- strokeLinejoin: "round" as const,
- fill: "none",
-};
-
-function IconPlus() {
- return (
-
- );
-}
-function IconTrash() {
- return (
-
- );
-}
-function IconUp() {
- return (
-
- );
-}
-function IconDown() {
- return (
-
- );
-}
-function IconPrint() {
- return (
-
- );
-}
-
-/**
- * The Royal crown-diamond mark, drawn as paths.
- *
- * A row of three diamond outlines over a solid bar — the silhouette the 2027
- * catalogue carries on its cover, its footers and its back page. Drawn rather
- * than uploaded because a catalog has to print the mark at whatever size the
- * page gives it, and because an image dependency for the ONE element on every
- * page would make an unreachable asset route into a blank catalogue.
- */
-function CrownMark({ className }: { className?: string }) {
- return (
-
- );
-}
-
-// ------------------------------------------------------------ page templates
-
-/** The image slot every template shares: the asset, or a STATED gap.
- *
- * A missing master is drawn as a labelled frame naming the code, never as
- * silence — the user has to be able to see, in the designer, that this page
- * will print an empty box, and which SKU caused it. */
-function Shot({
- code,
- quality,
- missing,
- cover,
-}: {
- code?: string;
- quality: CatalogQuality;
- missing?: boolean;
- cover?: boolean;
-}) {
- if (!code)
- return (
-
- No image
-
- );
- if (missing)
- return (
-
-
- {editorialSlug(code) !== null
- ? `No editorial image named ${editorialSlug(code)}`
- : `No master for ${code}`}
-
-
- );
- return (
-
-
-
- );
-}
-
-function ProductCard({
- item,
- quality,
- layout,
- hasAsset,
-}: {
- item: CatalogItem;
- quality: CatalogQuality;
- layout: ReturnType;
- hasAsset: (code: string) => boolean | null;
-}) {
- // ⭐ Wave-19 R7 — the picture is the ITEM'S image reference, which equals the code on every
- // row nobody has overridden. Both the tile and the gap report read the SAME string, or the
- // designer could mark a product "no master" while printing a picture it does have.
- const known = hasAsset(item.image);
- return (
-
-
- {/* CAPS in CSS (`text-transform`), never by upper-casing the string: the
- value stays the value, so a copy of it is not shouting. */}
-
{item.name ?? (item.known ? "" : "Not in this table")}
- );
-}
-
-/** Crown mark left, folio right — exactly the reference's footer, which carries no
- * company line (the wordmark's place is the cover). `light` inverts it for the
- * pages whose field is dark.
- *
- * `mark={false}` drops the crown: the intro page's terracotta lattice bleeds through the
- * bottom-left corner, and a second terracotta mark sitting inside it reads as a smudge on the
- * lattice rather than as the brand. The reference's own letter spread carries no crown there
- * either — judged from the rendered page, not assumed. */
-function PageFooter({
- folio,
- light,
- mark = true,
-}: {
- folio: number;
- light?: boolean;
- mark?: boolean;
-}) {
- return (
-
- {mark ? : null}
- {folio}
-
- );
-}
-
-/** ONE page, at true paper size. The same component paints the stage and the
- * print tree — there is no second renderer that could drift from what the
- * screenshot showed. */
-const CatalogPageView = memo(function CatalogPageView({
- cat,
- page,
- index,
- items,
- quality,
- hasAsset,
-}: {
- cat: CatalogSpec;
- page: CatalogPage;
- index: number;
- items: CatalogItem[];
- quality: CatalogQuality;
- hasAsset: (code: string) => boolean | null;
-}) {
- const box = pageBox(cat.paper, cat.orientation);
- const px = box.unit === "in" ? 96 : 96 / 25.4;
- const brand = cat.brand ?? {};
- const primary = brand.primary ?? DEFAULT_BRAND.primary;
- const accent = brand.accent ?? DEFAULT_BRAND.accent;
- const company = brand.company ?? DEFAULT_BRAND.company;
- const style = {
- "--cat-w": `${box.w * px}px`,
- "--cat-h": `${box.h * px}px`,
- "--cat-primary": primary,
- "--cat-accent": accent,
- } as React.CSSProperties;
- const folio = index + 1;
- const logoKnown = brand.logo ? hasAsset(brand.logo) : null;
-
- const Wordmark = (
-
- {/* The terracotta lattice bleeding off the left edge — the reference's
- own device for the letter spread, drawn from the crown's diamond. */}
-
-
-
-
{page.title}
- {/* Paragraphs, not a wall: the reference's letter is set in short
- blocks and reads as one because of the spacing between them. */}
- {(page.body ?? "").split(/\n{2,}/).map((para, i) =>
- para.trim() ? (
-
- );
- })}
- >
- );
-}
-
-// ------------------------------------------------------------------- view
-
-export const CatalogView = memo(function CatalogView({
- catalogs,
- onCatalogs,
- fields,
- fieldByKey,
- poolRows,
- filteredCodes,
-}: {
- catalogs: CatalogSpec[];
- onCatalogs: (next: CatalogSpec[]) => void;
- fields: Field[];
- fieldByKey: Map;
- /** The WHOLE pool — a catalogue keeps painting what it names after the view
- * is filtered, so the join must not run over the filtered rows. */
- poolRows: Row[];
- /** The codes the CURRENT filter kept — the "add what the view shows" door. */
- filteredCodes: string[];
-}) {
- const [openId, setOpenId] = useState(catalogs[0]?.id ?? null);
- const [pageId, setPageId] = useState(null);
- const [search, setSearch] = useState("");
- const [picked, setPicked] = useState>(() => new Set());
- const [assetCodes, setAssetCodes] = useState | null>(null);
- const [editorialSlugs, setEditorialSlugs] = useState | null>(null);
- const edFileRef = useRef(null);
- const [printing, setPrinting] = useState<{ cat: CatalogSpec; quality: CatalogQuality } | null>(
- null
- );
- const [askQuality, setAskQuality] = useState(null);
- const stageRef = useRef(null);
- const printRef = useRef(null);
- const [stageW, setStageW] = useState(0);
-
- const cat = catalogs.find((c) => c.id === openId) ?? null;
- const page = cat?.pages.find((p) => p.id === pageId) ?? null;
-
- // The manifest, once. `null` means "we do not know" and the designer stays
- // quiet — a false gap report sends the user hunting for images that are there.
- useEffect(() => {
- let live = true;
- void fetchAssetCodes().then((codes) => {
- if (live) setAssetCodes(codes);
- });
- void fetchEditorialSlugs().then((slugs) => {
- if (live) setEditorialSlugs(slugs);
- });
- return () => {
- live = false;
- };
- }, []);
-
- const hasAsset = useCallback(
- (code: string): boolean | null => {
- // `ed:` codes resolve against the EDITORIAL library (DEBT-5); everything
- // else stays a product code. Unknown-library (null) keeps the designer
- // quiet, same as the product manifest's own rule.
- const slug = editorialSlug(code);
- if (slug !== null) return editorialSlugs ? editorialSlugs.has(slug) : null;
- // ⭐ Wave-19 R7 — a `rec:` upload has NO manifest to check against, and inventing one
- // would be a per-tenant listing endpoint built for a designer badge. `null` is the
- // vocabulary this function already has for "I do not know", and it is the truthful
- // answer: an uploaded picture was validated at upload, so the far likelier state is that
- // it is there. Marking it missing would send the user hunting for a file that exists.
- if (code.startsWith(RECORD_IMAGE_PREFIX)) return null;
- return assetCodes ? assetCodes.has(code) : null;
- },
- [assetCodes, editorialSlugs]
- );
-
- useLayoutEffect(() => {
- const el = stageRef.current;
- if (!el || typeof ResizeObserver === "undefined") return;
- const ro = new ResizeObserver(() => setStageW(el.clientWidth));
- ro.observe(el);
- setStageW(el.clientWidth);
- return () => ro.disconnect();
- }, []);
-
- const byCode = useMemo(() => indexByCode(poolRows), [poolRows]);
- const itemsFor = useCallback(
- (p: CatalogPage) =>
- resolveItems(p.products ?? [], byCode, cat?.fields, formatDisplay, fieldByKey),
- [byCode, cat?.fields, fieldByKey]
- );
-
- const scale = useMemo(() => {
- if (!cat || !stageW) return 0.5;
- const box = pageBox(cat.paper, cat.orientation);
- const px = box.unit === "in" ? 96 : 96 / 25.4;
- return Math.min(1, Math.max(0.2, (stageW - 56) / (box.w * px)));
- }, [cat, stageW]);
-
- // ------------------------------------------------------------- mutation
- const write = useCallback(
- (next: CatalogSpec[]) => {
- onCatalogs(next);
- },
- [onCatalogs]
- );
- const patchCat = useCallback(
- (id: string, patch: Partial) => {
- write(catalogs.map((c) => (c.id === id ? { ...c, ...patch } : c)));
- },
- [catalogs, write]
- );
- const patchPage = useCallback(
- (catId: string, pgId: string, patch: Partial) => {
- write(
- catalogs.map((c) =>
- c.id !== catId
- ? c
- : { ...c, pages: c.pages.map((p) => (p.id === pgId ? { ...p, ...patch } : p)) }
- )
- );
- },
- [catalogs, write]
- );
-
- const addCatalog = () => {
- if (catalogs.length >= MAX_CATALOGS) return;
- const made = newCatalog(`Catalog ${catalogs.length + 1}`, fields);
- write([...catalogs, made]);
- setOpenId(made.id);
- setPageId(made.pages[0]?.id ?? null);
- };
-
- const addPage = (kind: CatalogPageKind) => {
- if (!cat || cat.pages.length >= MAX_CATALOG_PAGES) return;
- const made = newPage(kind);
- patchCat(cat.id, { pages: [...cat.pages, made] });
- setPageId(made.id);
- };
-
- const movePage = (delta: number) => {
- if (!cat || !page) return;
- const i = cat.pages.findIndex((p) => p.id === page.id);
- const j = i + delta;
- if (i < 0 || j < 0 || j >= cat.pages.length) return;
- const pages = [...cat.pages];
- [pages[i], pages[j]] = [pages[j], pages[i]];
- patchCat(cat.id, { pages });
- };
-
- // ------------------------------------------------------------ the picker
- const matches = useMemo(() => {
- if (!cat) return [];
- const q = search.trim().toLowerCase();
- const nameKey = cat.fields?.name;
- const out: { code: string; label: string }[] = [];
- for (const r of poolRows) {
- const code = r[CATALOG_CODE_FIELD];
- if (typeof code !== "string" || !code) continue;
- const nameField = nameKey ? fieldByKey.get(nameKey) : undefined;
- const label = nameField ? formatDisplay(nameField, r[nameKey as string]) : "";
- if (q && !code.toLowerCase().includes(q) && !label.toLowerCase().includes(q)) continue;
- out.push({ code, label });
- if (out.length >= 200) break;
- }
- return out;
- }, [cat, poolRows, search, fieldByKey]);
-
- const commitCodes = (codes: string[]) => {
- if (!cat || !page) return;
- const res = addCodes(cat, page.id, codes);
- patchCat(cat.id, { pages: res.pages });
- setPicked(new Set());
- if (res.skippedCap || res.skippedDuplicate) {
- const parts: string[] = [];
- if (res.skippedDuplicate) parts.push(`${res.skippedDuplicate} already on the page`);
- if (res.skippedCap)
- parts.push(`${res.skippedCap} over the ${MAX_CATALOG_CODES}-product catalog limit`);
- setNote(`Added ${res.added}. Skipped ${parts.join("; ")}.`);
- } else {
- setNote(`Added ${res.added}.`);
- }
- };
- const [note, setNote] = useState("");
-
- // DEBT-5: one editorial master in, `ed:` bound to the open page. The server
- // owns validation (admin wall, charset, opens-as-an-image); this just reports honestly.
- const uploadEditorialFile = async (f: File) => {
- const slug = slugFromFilename(f.name);
- if (!slug) {
- setNote("That filename does not reduce to a usable name — rename the file first.");
- return;
- }
- setNote(`Uploading ${slug}…`);
- try {
- const stored = await uploadEditorial(slug, f);
- setEditorialSlugs(await fetchEditorialSlugs());
- if (cat && page) patchPage(cat.id, page.id, { imageCode: `${EDITORIAL_PREFIX}${stored}` });
- setNote(`Editorial image "${stored}" stored and bound to this page.`);
- } catch (e) {
- setNote(e instanceof Error ? e.message : "The upload failed.");
- }
- };
-
- // ------------------------------------------------------------- printing
- useEffect(() => {
- if (!printing) return;
- let cancelled = false;
- void (async () => {
- // One frame so the print tree is in the DOM before its images are awaited.
- await new Promise((res) => requestAnimationFrame(() => res()));
- await awaitImages(printRef.current);
- if (cancelled) return;
- const drop = applyPrintCss(printing.cat);
- await printOnce();
- drop();
- if (!cancelled) setPrinting(null);
- })();
- return () => {
- cancelled = true;
- };
- }, [printing]);
-
- // ----------------------------------------------------------------- paint
- if (!catalogs.length)
- return (
-
-
-
-
No catalogs yet
-
- A catalog is a printable document built from this table’s products — cover,
- introduction, product sections and full-page images. Print it to PDF from the browser
- when it is ready.
-
- );
-});
-
-export default CatalogView;
+// ---------------------------------------------------------------------------
+// customer-grid / CatalogView.tsx
+// Wave-18 owner item 4 (contract C6-CATALOG) — the CATALOG view.
+//
+// A view holds several catalogs; a catalog is an ordered list of pages; a
+// section page names product CODES and joins them to the live pool at paint
+// time. Three columns: the rail (catalogs, then that catalog's pages), the
+// stage (the pages themselves, at true paper size, scaled to fit), and the
+// inspector (everything about the selected page, plus the catalog's brand,
+// paper and column bindings).
+//
+// FIDELITY. The default templates reproduce `Royal Collection 2027.pdf`, which
+// was rendered and read before this file was written:
+// · section header — letterspaced caps, navy, light weight, top-left
+// · product renders on WHITE (the C2-ASSET web derivative is alpha-preserving
+// PNG precisely so this works)
+// · description in caps, centred, two lines
+// · a full-width navy PILL carrying the pack line
+// · SKU in terracotta bold on the left, colour name on the right
+// · a full-bleed lifestyle strip under the grid
+// · crown mark bottom-left, folio bottom-right
+// · gallery pages full-bleed with a letterspaced caps title
+//
+// TYPE. Catalog pages use a display SERIF for titles (`--cat-serif`, a system
+// Didone/old-style stack — Didot, Bodoni, Playfair, Cormorant, Georgia). That is
+// deliberate and it is the ONLY place it appears: app chrome stays Inter, per
+// the owner constant. Listing text is Inter with wide tracking, which is what
+// the reference's geometric sans reads as at listing size. NO EMOJIS anywhere —
+// every mark here is a drawn path.
+// ---------------------------------------------------------------------------
+
+import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
+import { createPortal } from "react-dom";
+import {
+ CATALOG_COLS,
+ CATALOG_NAME_MAX,
+ CATALOG_ORIENTATIONS,
+ CATALOG_PAGE_KINDS,
+ CATALOG_PAPERS,
+ MAX_CATALOGS,
+ MAX_CATALOG_PAGES,
+ MAX_CATALOG_CODES,
+} from "./types";
+import type {
+ CatalogOrientation,
+ CatalogPage,
+ CatalogPageKind,
+ CatalogPaper,
+ CatalogQuality,
+ CatalogSpec,
+ Field,
+ Row,
+} from "./types";
+import {
+ CATALOG_CODE_FIELD,
+ DEFAULT_BRAND,
+ EDITORIAL_PREFIX,
+ PAGE_KIND_LABELS,
+ PAPER_LABELS,
+ addCodes,
+ assetUrl,
+ autoBind,
+ codesUsed,
+ editorialSlug,
+ fetchAssetCodes,
+ fetchEditorialSlugs,
+ newCatalog,
+ newPage,
+ pageBox,
+ resolveItems,
+ rowImageRef,
+ sectionLayout,
+ slugFromFilename,
+ uploadEditorial,
+} from "./catalogData";
+import type { CatalogItem } from "./catalogData";
+// Wave-19 R7 — the record-image namespace, so the gap report can say "I do not know" about an
+// upload rather than marking every one of them missing.
+import { RECORD_IMAGE_PREFIX } from "./types";
+import { applyPrintCss, awaitImages, printOnce } from "./catalogPrint";
+import { formatDisplay } from "./display";
+import "./catalog.css";
+
+/**
+ * ⭐⭐ W37 CONTRACT C3 — THE PER-VIEW CATALOG SPEC, client side. Mirrors
+ * `aios_grid._clean_catalog_spec` key for key.
+ *
+ * ⛔⛔ AN ABSENT COORDINATE IS NOT ZERO, AND THAT ONE SENTENCE IS THE WHOLE MIGRATION.
+ * Every catalog item authored before this wave has no `x`/`y`. `0` is the top-left corner and is a
+ * real, storable position; ABSENT means "wherever the flow puts you", which is exactly where the
+ * item is today. Read the two as the same thing and every pre-existing item piles up at the origin
+ * the first time somebody opens an old catalog: a catalog that silently loses its layout, which R6
+ * names as a FAIL rather than a migration. So every coordinate is `number | undefined` and every
+ * read of one uses `??`, never `||` and never a truthiness test.
+ * ⚠ `w`/`h` carry the OPPOSITE asymmetry, matching the host: a zero WIDTH is an invisible item, so
+ * absent means "the renderer's default size" and zero is refused before it ever gets here.
+ *
+ * ⭐ THE UNIT IS PERCENT OF THE PAGE, 0..100. The host deliberately does not care ("D owns what the
+ * numbers MEAN"), and percent is the only choice that survives the designer's zoom-to-fit scale AND
+ * the print tree's true-paper-size rendering without being recomputed for either.
+ */
+export interface CatalogViewItem {
+ code: string;
+ x?: number;
+ y?: number;
+ w?: number;
+ h?: number;
+}
+export interface CatalogViewSection {
+ id: string;
+ items: CatalogViewItem[];
+}
+export interface CatalogViewPage {
+ id: string;
+ order?: number;
+ sections: CatalogViewSection[];
+}
+export interface CatalogViewSpec {
+ imageField?: string;
+ titleField?: string;
+ pages?: CatalogViewPage[];
+}
+
+/** The placed items of one page, by code. ⛔ Returns an EMPTY map for a page with no stored
+ * layout, which is every page of every catalog authored before this wave, and an empty map means
+ * "everything flows" rather than "everything at the origin". */
+export function placedItems(
+ spec: CatalogViewSpec | undefined,
+ pageId: string
+): Map {
+ const out = new Map();
+ const page = spec?.pages?.find((p) => p.id === pageId);
+ for (const sec of page?.sections ?? []) {
+ for (const it of sec.items ?? []) {
+ if (it && typeof it.code === "string" && it.code) out.set(it.code, it);
+ }
+ }
+ return out;
+}
+
+/** True only when BOTH coordinates are present. ⚠ `!= null` and not truthiness: `x: 0, y: 0` is the
+ * top-left corner, a position somebody deliberately chose, and a truthy test drops it back into
+ * the flow the moment they drag an item to the corner. */
+export function isPlaced(it: CatalogViewItem | undefined): boolean {
+ return !!it && it.x != null && it.y != null;
+}
+
+// ------------------------------------------------------------------- marks
+
+const STROKE = {
+ stroke: "currentColor",
+ strokeWidth: 1.5,
+ strokeLinecap: "round" as const,
+ strokeLinejoin: "round" as const,
+ fill: "none",
+};
+
+function IconPlus() {
+ return (
+
+ );
+}
+function IconTrash() {
+ return (
+
+ );
+}
+function IconUp() {
+ return (
+
+ );
+}
+function IconDown() {
+ return (
+
+ );
+}
+function IconPrint() {
+ return (
+
+ );
+}
+
+/**
+ * The Royal crown-diamond mark, drawn as paths.
+ *
+ * A row of three diamond outlines over a solid bar — the silhouette the 2027
+ * catalogue carries on its cover, its footers and its back page. Drawn rather
+ * than uploaded because a catalog has to print the mark at whatever size the
+ * page gives it, and because an image dependency for the ONE element on every
+ * page would make an unreachable asset route into a blank catalogue.
+ */
+function CrownMark({ className }: { className?: string }) {
+ return (
+
+ );
+}
+
+// ------------------------------------------------------------ page templates
+
+/** The image slot every template shares: the asset, or a STATED gap.
+ *
+ * A missing master is drawn as a labelled frame naming the code, never as
+ * silence — the user has to be able to see, in the designer, that this page
+ * will print an empty box, and which SKU caused it. */
+function Shot({
+ code,
+ quality,
+ missing,
+ cover,
+}: {
+ code?: string;
+ quality: CatalogQuality;
+ missing?: boolean;
+ cover?: boolean;
+}) {
+ if (!code)
+ return (
+
+ No image
+
+ );
+ if (missing)
+ return (
+
+
+ {editorialSlug(code) !== null
+ ? `No editorial image named ${editorialSlug(code)}`
+ : `No master for ${code}`}
+
+
+ );
+ return (
+
+
+
+ );
+}
+
+function ProductCard({
+ item,
+ quality,
+ layout,
+ hasAsset,
+}: {
+ item: CatalogItem;
+ quality: CatalogQuality;
+ layout: ReturnType;
+ hasAsset: (code: string) => boolean | null;
+}) {
+ // ⭐ Wave-19 R7 — the picture is the ITEM'S image reference, which equals the code on every
+ // row nobody has overridden. Both the tile and the gap report read the SAME string, or the
+ // designer could mark a product "no master" while printing a picture it does have.
+ const known = hasAsset(item.image);
+ return (
+
+
+ {/* CAPS in CSS (`text-transform`), never by upper-casing the string: the
+ value stays the value, so a copy of it is not shouting. */}
+
{item.name ?? (item.known ? "" : "Not in this table")}
+ );
+}
+
+/** Crown mark left, folio right — exactly the reference's footer, which carries no
+ * company line (the wordmark's place is the cover). `light` inverts it for the
+ * pages whose field is dark.
+ *
+ * `mark={false}` drops the crown: the intro page's terracotta lattice bleeds through the
+ * bottom-left corner, and a second terracotta mark sitting inside it reads as a smudge on the
+ * lattice rather than as the brand. The reference's own letter spread carries no crown there
+ * either — judged from the rendered page, not assumed. */
+function PageFooter({
+ folio,
+ light,
+ mark = true,
+}: {
+ folio: number;
+ light?: boolean;
+ mark?: boolean;
+}) {
+ return (
+
+ {mark ? : null}
+ {folio}
+
+ );
+}
+
+/** ONE page, at true paper size. The same component paints the stage and the
+ * print tree — there is no second renderer that could drift from what the
+ * screenshot showed. */
+const CatalogPageView = memo(function CatalogPageView({
+ cat,
+ page,
+ index,
+ items,
+ quality,
+ hasAsset,
+ placed,
+ onPlaceItem,
+ gridRef,
+}: {
+ cat: CatalogSpec;
+ page: CatalogPage;
+ index: number;
+ items: CatalogItem[];
+ quality: CatalogQuality;
+ hasAsset: (code: string) => boolean | null;
+ /** W37-T33 — this page's placed items, by code. ⛔ ABSENT or EMPTY means every item flows, which
+ * is every page of every catalog authored before this wave. */
+ placed?: Map;
+ /** Absent in the PRINT tree, deliberately: a printed page has nothing to drag. */
+ onPlaceItem?: (e: React.PointerEvent, code: string) => void;
+ gridRef?: (n: HTMLDivElement | null) => void;
+}) {
+ const box = pageBox(cat.paper, cat.orientation);
+ const px = box.unit === "in" ? 96 : 96 / 25.4;
+ const brand = cat.brand ?? {};
+ const primary = brand.primary ?? DEFAULT_BRAND.primary;
+ const accent = brand.accent ?? DEFAULT_BRAND.accent;
+ const company = brand.company ?? DEFAULT_BRAND.company;
+ const style = {
+ "--cat-w": `${box.w * px}px`,
+ "--cat-h": `${box.h * px}px`,
+ "--cat-primary": primary,
+ "--cat-accent": accent,
+ } as React.CSSProperties;
+ const folio = index + 1;
+ const logoKnown = brand.logo ? hasAsset(brand.logo) : null;
+
+ const Wordmark = (
+
+ {/* The terracotta lattice bleeding off the left edge — the reference's
+ own device for the letter spread, drawn from the crown's diamond. */}
+
+
+
+
{page.title}
+ {/* Paragraphs, not a wall: the reference's letter is set in short
+ blocks and reads as one because of the spacing between them. */}
+ {(page.body ?? "").split(/\n{2,}/).map((para, i) =>
+ para.trim() ? (
+
+ );
+ }
+
+ const layout = sectionLayout(page);
+ // How many of this page's items carry coordinates. Zero => the container needs no
+ // positioning context at all, so an untouched catalog's DOM is unchanged too.
+ const freeCount = items.reduce((n, it) => n + (isPlaced(placed?.get(it.code)) ? 1 : 0), 0);
+ const stripKnown = page.imageCode ? hasAsset(page.imageCode) : null;
+ return (
+
+
{page.title}
+ {/* ⭐⭐ W37-T33 / R6 — FREE PLACEMENT, AND THE MIGRATION IS THE HARD PART.
+ An item with BOTH coordinates is positioned absolutely; an item with neither stays in the
+ grid flow exactly where it has always been. That is not a compatibility shim bolted on
+ the side, it is the rule C3 states: "the RENDERER turns an absent coordinate into flow
+ position N". A catalog authored before this wave has no coordinates at all, so every one
+ of its items takes the second branch and the page prints identically.
+ ⛔ THE MIXED CASE IS DELIBERATE AND IS NOT A BUG: place two of six images and the other
+ four keep their grid cells. Snapping the whole section to absolute the moment one item
+ moves would relocate five images the person never touched. */}
+
+ );
+ })}
+ >
+ );
+}
+
+// ------------------------------------------------------------------- view
+
+export const CatalogView = memo(function CatalogView({
+ catalogs,
+ onCatalogs,
+ fields,
+ fieldByKey,
+ poolRows,
+ filteredCodes,
+ identityField,
+ catalogSpec,
+ onCatalogSpec,
+}: {
+ catalogs: CatalogSpec[];
+ onCatalogs: (next: CatalogSpec[]) => void;
+ fields: Field[];
+ fieldByKey: Map;
+ /** The WHOLE pool — a catalogue keeps painting what it names after the view
+ * is filtered, so the join must not run over the filtered rows. */
+ poolRows: Row[];
+ /** The codes the CURRENT filter kept — the "add what the view shows" door. */
+ filteredCodes: string[];
+ /**
+ * ⭐⭐ W37-T31 — THE COLUMN THAT IDENTIFIES A RECORD ON *THIS* DATABASE.
+ *
+ * The catalog was built for products and joins on the literal column `code`
+ * (`catalogData.CATALOG_CODE_FIELD`), which is both the SKU printed on the page and the key the
+ * image masters are named for. That coincidence is real and is why the join was hardcoded; it is
+ * also exactly why a catalog could not be opened on anything else, because no other table has a
+ * column called `code`. Naming the identity makes the same machinery work anywhere.
+ *
+ * ⚠ ABSENT falls back to `code`, so every product catalog authored before this wave joins
+ * byte-identically. This is a widening, not a migration.
+ */
+ identityField?: Field;
+ /**
+ * ⭐⭐ W37-T31 / R5 / C3 — THE PER-VIEW CATALOG SETTINGS, stored by the host at
+ * `views[].config.display.catalog`.
+ *
+ * ⛔ PER VIEW, WHICH IS THE WHOLE OF R5: two catalog views over one database may show different
+ * pictures and different titles, because a trade catalog and a lookbook are the same records
+ * presented differently. Putting these on the CATALOG instead would have made that impossible
+ * without duplicating the records.
+ */
+ catalogSpec?: CatalogViewSpec;
+ onCatalogSpec?: (next: CatalogViewSpec) => void;
+}) {
+ const [openId, setOpenId] = useState(catalogs[0]?.id ?? null);
+ const [pageId, setPageId] = useState(null);
+ const [search, setSearch] = useState("");
+ const [picked, setPicked] = useState>(() => new Set());
+ const [assetCodes, setAssetCodes] = useState | null>(null);
+ const [editorialSlugs, setEditorialSlugs] = useState | null>(null);
+ const edFileRef = useRef(null);
+ const [printing, setPrinting] = useState<{ cat: CatalogSpec; quality: CatalogQuality } | null>(
+ null
+ );
+ const [askQuality, setAskQuality] = useState(null);
+ const stageRef = useRef(null);
+ const printRef = useRef(null);
+ const [stageW, setStageW] = useState(0);
+
+ const cat = catalogs.find((c) => c.id === openId) ?? null;
+ const page = cat?.pages.find((p) => p.id === pageId) ?? null;
+
+ // The manifest, once. `null` means "we do not know" and the designer stays
+ // quiet — a false gap report sends the user hunting for images that are there.
+ useEffect(() => {
+ let live = true;
+ void fetchAssetCodes().then((codes) => {
+ if (live) setAssetCodes(codes);
+ });
+ void fetchEditorialSlugs().then((slugs) => {
+ if (live) setEditorialSlugs(slugs);
+ });
+ return () => {
+ live = false;
+ };
+ }, []);
+
+ const hasAsset = useCallback(
+ (code: string): boolean | null => {
+ // `ed:` codes resolve against the EDITORIAL library (DEBT-5); everything
+ // else stays a product code. Unknown-library (null) keeps the designer
+ // quiet, same as the product manifest's own rule.
+ const slug = editorialSlug(code);
+ if (slug !== null) return editorialSlugs ? editorialSlugs.has(slug) : null;
+ // ⭐ Wave-19 R7 — a `rec:` upload has NO manifest to check against, and inventing one
+ // would be a per-tenant listing endpoint built for a designer badge. `null` is the
+ // vocabulary this function already has for "I do not know", and it is the truthful
+ // answer: an uploaded picture was validated at upload, so the far likelier state is that
+ // it is there. Marking it missing would send the user hunting for a file that exists.
+ if (code.startsWith(RECORD_IMAGE_PREFIX)) return null;
+ return assetCodes ? assetCodes.has(code) : null;
+ },
+ [assetCodes, editorialSlugs]
+ );
+
+ useLayoutEffect(() => {
+ const el = stageRef.current;
+ if (!el || typeof ResizeObserver === "undefined") return;
+ const ro = new ResizeObserver(() => setStageW(el.clientWidth));
+ ro.observe(el);
+ setStageW(el.clientWidth);
+ return () => ro.disconnect();
+ }, []);
+
+ /**
+ * ⭐⭐ W37-T31 — THE JOIN, GENERALISED. `indexByCode` reads the literal column `code`; this reads
+ * whichever column identifies a record on the open database, defaulting to `code` so every
+ * product catalog resolves exactly as before.
+ *
+ * ⚠ TEMPORARILY LOCAL. `catalogData.ts` is where it belongs, beside the constant it generalises,
+ * and it is in no lane's fence (`ASK D-14`). Written here so the ticket is not blocked on the
+ * grant; the moment `catalogData.ts` is mine this becomes `indexByKey` there and this local goes.
+ * ⛔ `indexByCode` is deliberately NOT imported any more: two joins in one path is the divergence
+ * this generalisation exists to prevent, so there is exactly ONE live indexer for a catalog.
+ */
+ const identityKey = identityField?.key || CATALOG_CODE_FIELD;
+ const byCode = useMemo(() => {
+ const out = new Map();
+ for (const r of poolRows) {
+ const v = r[identityKey];
+ // ⚠ Numbers are stringified rather than skipped. `code` is text on the product table, but a
+ // generic identity column is often an int id, and refusing those would mean the catalog
+ // silently found nothing on exactly the databases this ticket is about.
+ const k = typeof v === "string" ? v : typeof v === "number" ? String(v) : "";
+ if (k && !out.has(k)) out.set(k, r);
+ }
+ return out;
+ }, [poolRows, identityKey]);
+ /**
+ * ⭐⭐ W37-T31 / R5 — THE VIEW'S OWN PICTURE AND TITLE, LAYERED OVER THE RESOLVED ITEM.
+ *
+ * `resolveItems` picks the picture with `imageFieldKey()`, which returns the FIRST `image`-typed
+ * field on the TABLE. That is a sensible default and it is not what R5 asks for: the choice
+ * belongs to the VIEW, so two catalog views over one database can print different pictures.
+ *
+ * ⛔ LAYERED RATHER THAN REPLACED, deliberately. Everything `resolveItems` decides stays
+ * decided, including the STATED GAP for a record that is not in the pool (`known: false`, which
+ * prints a labelled empty frame naming the reference). Re-implementing that here to thread two
+ * extra arguments would put a second resolver in the tree and would eventually disagree with the
+ * first about what a missing record looks like.
+ * ⚠ `rowImageRef` is REUSED for the override rather than reading the cell directly, so the
+ * per-view picture obeys the same "trimmed, non-empty, else fall back" rule as the default one.
+ */
+ const itemsFor = useCallback(
+ (p: CatalogPage) => {
+ const base = resolveItems(p.products ?? [], byCode, cat?.fields, formatDisplay, fieldByKey);
+ const imgKey = catalogSpec?.imageField;
+ const titleKey = catalogSpec?.titleField;
+ if (!imgKey && !titleKey) return base;
+ return base.map((it) => {
+ const row = byCode.get(it.code);
+ if (!row) return it;
+ const picture = imgKey ? rowImageRef(row, imgKey) : "";
+ const titleField = titleKey ? fieldByKey.get(titleKey) : undefined;
+ const title = titleField ? formatDisplay(titleField, row[titleKey!]) : "";
+ return {
+ ...it,
+ ...(picture ? { image: picture } : {}),
+ ...(title ? { name: title } : {}),
+ };
+ });
+ },
+ [byCode, cat?.fields, fieldByKey, catalogSpec?.imageField, catalogSpec?.titleField]
+ );
+
+ const scale = useMemo(() => {
+ if (!cat || !stageW) return 0.5;
+ const box = pageBox(cat.paper, cat.orientation);
+ const px = box.unit === "in" ? 96 : 96 / 25.4;
+ return Math.min(1, Math.max(0.2, (stageW - 56) / (box.w * px)));
+ }, [cat, stageW]);
+
+ // ------------------------------------------------------------- mutation
+ const write = useCallback(
+ (next: CatalogSpec[]) => {
+ onCatalogs(next);
+ },
+ [onCatalogs]
+ );
+ const patchCat = useCallback(
+ (id: string, patch: Partial) => {
+ write(catalogs.map((c) => (c.id === id ? { ...c, ...patch } : c)));
+ },
+ [catalogs, write]
+ );
+ const patchPage = useCallback(
+ (catId: string, pgId: string, patch: Partial) => {
+ write(
+ catalogs.map((c) =>
+ c.id !== catId
+ ? c
+ : { ...c, pages: c.pages.map((p) => (p.id === pgId ? { ...p, ...patch } : p)) }
+ )
+ );
+ },
+ [catalogs, write]
+ );
+
+ const addCatalog = () => {
+ if (catalogs.length >= MAX_CATALOGS) return;
+ const made = newCatalog(`Catalog ${catalogs.length + 1}`, fields);
+ write([...catalogs, made]);
+ setOpenId(made.id);
+ setPageId(made.pages[0]?.id ?? null);
+ };
+
+ const addPage = (kind: CatalogPageKind) => {
+ if (!cat || cat.pages.length >= MAX_CATALOG_PAGES) return;
+ const made = newPage(kind);
+ patchCat(cat.id, { pages: [...cat.pages, made] });
+ setPageId(made.id);
+ };
+
+ const movePage = (delta: number) => {
+ if (!cat || !page) return;
+ const i = cat.pages.findIndex((p) => p.id === page.id);
+ const j = i + delta;
+ if (i < 0 || j < 0 || j >= cat.pages.length) return;
+ const pages = [...cat.pages];
+ [pages[i], pages[j]] = [pages[j], pages[i]];
+ patchCat(cat.id, { pages });
+ };
+
+ /**
+ * ⭐⭐ W37-T32 / R6 — DRAG A PAGE TO REORDER IT.
+ *
+ * ⛔ PAGES REORDER, THEY DO NOT FREE-FLOAT, and that is R6's exact wording. A page has a POSITION
+ * IN A SEQUENCE because a printed document is a sequence; only the IMAGES inside a section carry
+ * free x/y (T33). Reading R6 as "everything drags anywhere" would have produced a page that can
+ * be dropped between two others and print in a third place.
+ *
+ * ⭐ IT MOVES, IT DOES NOT SWAP. `movePage`'s up/down buttons swap neighbours, which is right for
+ * a one-step nudge and wrong for a drag: dragging page 1 onto page 5 should leave 2, 3 and 4 in
+ * their order with 1 behind them, and a swap would put page 5 where page 1 was. Two gestures,
+ * two behaviours, both correct for what they are.
+ */
+ /**
+ * ⛔⛔ THE DRAGGED ID LIVES IN A REF, AND THE STATE BESIDE IT IS ONLY FOR THE LOOK.
+ *
+ * `dragstart` and `drop` are separate events, so the obvious shape is `useState`. It is racy:
+ * the drop handler closes over the value from ITS render, and if no render has happened between
+ * the two events the handler still sees `null` and the drop silently does nothing. A human drag
+ * takes hundreds of milliseconds and React always flushes in between, which is exactly why this
+ * class of bug ships: it is invisible to the hand and reproducible only when the two events
+ * arrive in one task. MEASURED here, on the render probe, by dispatching both synchronously.
+ * The ref is read at EVENT time, so the gesture cannot depend on a render having occurred.
+ */
+ const dragPageRef = useRef(null);
+ const [dragPageId, setDragPageId] = useState(null);
+ const [dropPageId, setDropPageId] = useState(null);
+
+ const dropPageOn = useCallback(
+ (targetId: string) => {
+ const held = dragPageRef.current;
+ if (!cat || !held || held === targetId) return;
+ const from = cat.pages.findIndex((p) => p.id === held);
+ const to = cat.pages.findIndex((p) => p.id === targetId);
+ if (from < 0 || to < 0) return;
+ const pages = [...cat.pages];
+ pages.splice(to, 0, pages.splice(from, 1)[0]);
+ patchCat(cat.id, { pages });
+ },
+ [cat, patchCat]
+ );
+
+ /**
+ * W37-T32 — CLICKING A PAGE SCROLLS THE CANVAS TO IT.
+ *
+ * ⚠ A MAP OF LIVE NODES, not a query at click time. The stage is a scroller of page slots and a
+ * `querySelector` would have to encode the slot's position in the DOM, which changes the moment
+ * pages reorder. Registering each slot under its page id means the lookup survives every reorder
+ * this ticket also introduces.
+ */
+ /**
+ * ⭐⭐ W37-T33 / R6 — DRAG AN IMAGE ANYWHERE INSIDE ITS SECTION.
+ *
+ * Pointer events rather than HTML5 drag-and-drop, and the two are not interchangeable here. A
+ * `dragstart` gives you a drag IMAGE and drop TARGETS, which is exactly right for reordering a
+ * list (T32) and exactly wrong for placing something at a coordinate: the browser paints a ghost,
+ * the position only arrives at the end, and there is no live preview. `setPointerCapture` gives
+ * continuous coordinates and keeps them coming when the cursor leaves the page box.
+ *
+ * ⛔ WRITTEN IN PERCENT OF THE PAGE, so the number means the same thing in the designer (scaled
+ * to fit the stage) and in the print tree (true paper size). Storing pixels would have made a
+ * catalog print at a different layout from the one it was designed at, which is the single most
+ * expensive kind of wrong for a print feature.
+ * ⚠ CLAMPED to 0..96 rather than 0..100: an item positioned exactly at 100% starts where the
+ * page ends and prints nothing at all.
+ */
+ const gridRefs = useRef(new Map());
+ const placeRef = useRef<{ code: string; pageId: string; el: HTMLElement } | null>(null);
+
+ const writeItem = useCallback(
+ (pageId: string, code: string, patch: Partial) => {
+ if (!onCatalogSpec) return;
+ const spec: CatalogViewSpec = { ...(catalogSpec || {}) };
+ const pages = [...(spec.pages || [])];
+ let pi = pages.findIndex((p) => p.id === pageId);
+ if (pi < 0) {
+ pages.push({ id: pageId, sections: [{ id: `${pageId}-s1`, items: [] }] });
+ pi = pages.length - 1;
+ }
+ const page = { ...pages[pi] };
+ const sections = [...(page.sections || [])];
+ if (!sections.length) sections.push({ id: `${pageId}-s1`, items: [] });
+ const sec = { ...sections[0], items: [...(sections[0].items || [])] };
+ const ii = sec.items.findIndex((it) => it.code === code);
+ if (ii < 0) sec.items.push({ code, ...patch });
+ else sec.items[ii] = { ...sec.items[ii], ...patch };
+ sections[0] = sec;
+ page.sections = sections;
+ pages[pi] = page;
+ onCatalogSpec({ ...spec, pages });
+ },
+ [catalogSpec, onCatalogSpec]
+ );
+
+ const onPlaceItem = useCallback(
+ (e: React.PointerEvent, code: string) => {
+ if (!onCatalogSpec || !page) return;
+ const el = e.currentTarget as HTMLElement;
+ // ⛔⛔ MEASURED AGAINST THE PAGE, NOT THE GRID, and the two are not interchangeable.
+ // A placed item is out of flow, so the grid SHRINKS as items are placed: measure against it
+ // and the coordinate space moves while you are using it, until placing the last item collapses
+ // the grid to zero height and drops everything at the top. `.cg-cat-page` is the sheet of
+ // paper -- `position: relative`, explicit `height: var(--cat-h)`, `overflow: hidden` -- so a
+ // percentage of it is stable, and identical in the designer and the print tree.
+ const grid = el.closest(".cg-cat-page") as HTMLElement | null;
+ if (!grid) return;
+ e.preventDefault();
+ // ⛔ CAPTURE IS AN ENHANCEMENT, NOT A PRECONDITION, and it used to be able to kill the whole
+ // gesture. `setPointerCapture` THROWS on a pointer id the browser does not consider active,
+ // and it sat above the listener registration: one throw and the drag was never wired up at
+ // all, silently. What capture buys is events continuing when the cursor leaves the page box;
+ // without it a drag still works, it just stops at the edge. Losing the edge case is a small
+ // cost. Losing the feature is not.
+ try {
+ el.setPointerCapture(e.pointerId);
+ } catch {
+ /* not capturable: the drag still tracks while the pointer is over the item */
+ }
+ placeRef.current = { code, pageId: page.id, el };
+ const box = grid.getBoundingClientRect();
+ // The grab OFFSET, so the image does not jump its own top-left corner under the cursor the
+ // instant you touch it. Without this every drag begins with a visible snap.
+ const item = el.getBoundingClientRect();
+ const dx = e.clientX - item.left;
+ const dy = e.clientY - item.top;
+
+ const move = (ev: PointerEvent) => {
+ const x = ((ev.clientX - dx - box.left) / box.width) * 100;
+ const y = ((ev.clientY - dy - box.top) / box.height) * 100;
+ const cx = Math.max(0, Math.min(96, Math.round(x * 100) / 100));
+ const cy = Math.max(0, Math.min(96, Math.round(y * 100) / 100));
+ // Painted straight onto the node during the drag. Writing through state on every
+ // pointermove would round-trip the whole catalog document per frame.
+ el.classList.add("cg-cat-item-free", "is-dragging");
+ el.style.left = `${cx}%`;
+ el.style.top = `${cy}%`;
+ grid.classList.add("cg-cat-slot-free");
+ };
+ const up = (ev: PointerEvent) => {
+ el.removeEventListener("pointermove", move);
+ el.removeEventListener("pointerup", up);
+ el.removeEventListener("pointercancel", up);
+ el.classList.remove("is-dragging");
+ const x = ((ev.clientX - dx - box.left) / box.width) * 100;
+ const y = ((ev.clientY - dy - box.top) / box.height) * 100;
+ placeRef.current = null;
+ writeItem(page.id, code, {
+ x: Math.max(0, Math.min(96, Math.round(x * 100) / 100)),
+ y: Math.max(0, Math.min(96, Math.round(y * 100) / 100)),
+ });
+ };
+ el.addEventListener("pointermove", move);
+ el.addEventListener("pointerup", up);
+ el.addEventListener("pointercancel", up);
+ },
+ [onCatalogSpec, page, writeItem]
+ );
+
+ const slotRefs = useRef(new Map());
+ const openPage = useCallback((id: string) => {
+ setPageId(id);
+ const node = slotRefs.current.get(id);
+ // `block: "start"` rather than "center": a page is taller than the viewport at most zooms, so
+ // centring it puts its header off the top, which is the half you are navigating BY.
+ if (node) node.scrollIntoView({ behavior: "smooth", block: "start" });
+ }, []);
+
+ // ------------------------------------------------------------ the picker
+ const matches = useMemo(() => {
+ if (!cat) return [];
+ const q = search.trim().toLowerCase();
+ const nameKey = cat.fields?.name;
+ const out: { code: string; label: string }[] = [];
+ for (const r of poolRows) {
+ const code = r[CATALOG_CODE_FIELD];
+ if (typeof code !== "string" || !code) continue;
+ const nameField = nameKey ? fieldByKey.get(nameKey) : undefined;
+ const label = nameField ? formatDisplay(nameField, r[nameKey as string]) : "";
+ if (q && !code.toLowerCase().includes(q) && !label.toLowerCase().includes(q)) continue;
+ out.push({ code, label });
+ if (out.length >= 200) break;
+ }
+ return out;
+ }, [cat, poolRows, search, fieldByKey]);
+
+ const commitCodes = (codes: string[]) => {
+ if (!cat || !page) return;
+ const res = addCodes(cat, page.id, codes);
+ patchCat(cat.id, { pages: res.pages });
+ setPicked(new Set());
+ if (res.skippedCap || res.skippedDuplicate) {
+ const parts: string[] = [];
+ if (res.skippedDuplicate) parts.push(`${res.skippedDuplicate} already on the page`);
+ if (res.skippedCap)
+ parts.push(`${res.skippedCap} over the ${MAX_CATALOG_CODES}-product catalog limit`);
+ setNote(`Added ${res.added}. Skipped ${parts.join("; ")}.`);
+ } else {
+ setNote(`Added ${res.added}.`);
+ }
+ };
+ const [note, setNote] = useState("");
+
+ // DEBT-5: one editorial master in, `ed:` bound to the open page. The server
+ // owns validation (admin wall, charset, opens-as-an-image); this just reports honestly.
+ const uploadEditorialFile = async (f: File) => {
+ const slug = slugFromFilename(f.name);
+ if (!slug) {
+ setNote("That filename does not reduce to a usable name. Rename the file first.");
+ return;
+ }
+ setNote(`Uploading ${slug}…`);
+ try {
+ const stored = await uploadEditorial(slug, f);
+ setEditorialSlugs(await fetchEditorialSlugs());
+ if (cat && page) patchPage(cat.id, page.id, { imageCode: `${EDITORIAL_PREFIX}${stored}` });
+ setNote(`Editorial image "${stored}" stored and bound to this page.`);
+ } catch (e) {
+ setNote(e instanceof Error ? e.message : "The upload failed.");
+ }
+ };
+
+ // ------------------------------------------------------------- printing
+ useEffect(() => {
+ if (!printing) return;
+ let cancelled = false;
+ void (async () => {
+ // One frame so the print tree is in the DOM before its images are awaited.
+ await new Promise((res) => requestAnimationFrame(() => res()));
+ await awaitImages(printRef.current);
+ if (cancelled) return;
+ const drop = applyPrintCss(printing.cat);
+ await printOnce();
+ drop();
+ if (!cancelled) setPrinting(null);
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [printing]);
+
+ // ----------------------------------------------------------------- paint
+ if (!catalogs.length)
+ return (
+
+
+
+
No catalogs yet
+
+ A catalog is a printable document built from this table’s records: cover,
+ introduction, product sections and full-page images. Print it to PDF from the browser
+ when it is ready.
+
+ placedItems(catalogSpec, id)}
+ onPlaceItem={onPlaceItem}
+ registerGrid={(id, node) => {
+ if (node) gridRefs.current.set(id, node);
+ else gridRefs.current.delete(id);
+ }}
+ registerSlot={(id, node) => {
+ // ⚠ DELETE on unmount rather than storing null. A map that accumulates dead ids
+ // would let a scroll target a node that is no longer in the document, and
+ // `scrollIntoView` on a detached element does nothing at all -- a click that
+ // silently goes nowhere, which is the hardest kind of dead control to notice.
+ if (node) slotRefs.current.set(id, node);
+ else slotRefs.current.delete(id);
+ }}
+ />
+