Issues
Issues & decisions
Per store, split into defects to fix and decisions to make, ordered by severity. Shared-package issues are authored once and surface in every store they affect.
Issues
defects to fix64Mock checkout — no real commerce
/cart is built and the checkout handoff to the BFF works: an e2e intercepts the POST and asserts priceCents, the packing block and storeCode CPF. Two things remain before a REAL order can be placed: NEXT_PUBLIC_SHOPIFY_FRAME_VARIANT_ID must be set in the Vercel prod env (see variant-id-env), and a live test order has to land in Shopify Admin. Until the variant id is set, checkout falls back to a mock GID and Shopify rejects it, with no symptom in local dev.
Personalization consent gate not built
Pre-launch legal requirement on any permanent-text flow (nameplate/engraving/mat lettering). Mechanism unbuilt (FL-F05). Pairs with decision D5 (counsel wording).
Flagship points at an API host that does not exist
`apps/flagship/.env.local.example` (and the default in `src/lib/env.ts`) set `NEXT_PUBLIC_API_URL=https://dev-api.custompictureframes.com`. **That host does not resolve.** Neither does `api.custompictureframes.com`. Both fail DNS outright (curl: could not resolve host, 2026-07-13). So flagship's cart would not merely be blocked by CORS — it would never reach the BFF at all. The request dies on DNS. There is ONE BFF (`api.customframesizes.com`) serving all three brands: they share a single Shopify store (`cpf-headless`), and the per-brand routing rides on the `_fc_store_code` cart attribute, not on separate API hosts. Verified live: `api.customframesizes.com/api/health` -> 200, `/api/health/db` -> `{"ok":true}`. DECIDED (Ben, 2026-07-13): **one API, many storefronts.** framecraft-api at `api.customframesizes.com` is THE api service; the monorepo apps are storefronts that call it. No per-brand API host — the brand rides on `_fc_store_code`. FIXED in custom-frame-sizes#412. All three storefronts now default to `api.customframesizes.com`, and it was worse than filed on two counts: - store-b was equally broken: `api.shadowboxframes.com` and `dev-api.shadowboxframes.com` both return **525** (broken TLS). Only the customframesizes.com API hosts have ever been real. - The default never reached the browser anyway. `env.apiUrl` is dead config (nothing reads it); the cart pages read `process.env.NEXT_PUBLIC_API_URL || ""`, and `""` means SAME-ORIGIN — so with the var unset a storefront POSTed `/api/cart/fresh-checkout` to itself and 404'd. They now default to a named `FRAMECRAFT_API_ORIGIN`, verified baked into the client bundle. A unit test had been pinning the fictional `dev-api.custompictureframes.com` and passing the whole time. Replaced with a repo-wide guard across all three storefronts. TO CLOSE: set `NEXT_PUBLIC_API_URL=https://api.customframesizes.com` in each storefront's Vercel env (the code default now covers it, but make it explicit), and confirm a real checkout reaches the BFF.
CPF assets: the buckets are 86% populated — the CDN env vars are commented out
**CORRECTED 2026-07-13 — this issue was mostly wrong, twice.** Earlier versions said "2,416 files to upload", then "2,874". Both only ever looked at `apps/flagship/public/`. Nobody probed the actual bucket. When probed: | | count | status | |---|---|---| | `frames/` | 2,393 | **already on `assets-flagship.customframesizes.com`** ✓ | | `mats/` | 92 | **already on `shared-assets.customframesizes.com`** ✓ | | other, present | 39 | ✓ | | **other, MISSING** | **414** | images/ 161 · assets/ 110 · lifestyle/ 110 · comic/ 13 · sonogram/ 7 · specialty-hero/ 6 · glazing/ 2 · components/ 2 · magazine, puzzle, military 1 each | **2,524 of 2,938 (86%) are already uploaded.** They 404 today for ONE reason: `NEXT_PUBLIC_CDN_FLAGSHIP_URL` and `NEXT_PUBLIC_CDN_SHARED_URL` are **commented out** in `apps/flagship/.env.local.example`, so `getStoreCdnUrl()` returns null for flagship and every asset helper falls through to an empty local `/public`. So the single highest-value action on the CPF board is **uncommenting two env vars and setting them in Vercel** — that fixes 86% of the broken images with no upload at all. Then upload the remaining **414**. Bucket family settled empirically (D1 in the migration plan): use the custom domains `assets-flagship.customframesizes.com` + `shared-assets.customframesizes.com`. The `pub-*.r2.dev` origins are the same buckets by raw name; the two ids in `asset-urls.ts`'s comments return **401** (not public) and should be deleted. Of the 414: most exist in `original-cpf`; 87 need a RENAME (flagship invented tidy paths for files the original keeps flat in `attached_assets/`); and **6 exist nowhere and must be created** — `/assets/reviews/reviews-hero.png`, `/assets/specialty/photo-restoration-hero.png`, `/assets/visit/jersey_framing_central_nj_hero.png`, `/components/foam-board/{standard-white,standard-black,self-adhesive-white}.png`.
/api/proxy-image is an unauthenticated SSRF
The route validates only that the string parses as a `new URL()` — **no protocol check, no host allowlist** — then does a server-side `fetch(url)` and returns the full response body base64-encoded to the caller. That reaches cloud metadata (`http://169.254.169.254/...`) and any internal service, and hands the contents back to the attacker. No size limit either: `arrayBuffer()` on an arbitrary URL is a memory DoS. Unauthenticated, like all 9 store-a API routes. This is provably an oversight rather than a risk decision: the sibling route `objects/upload-from-url/route.ts` already implements `isAllowedSourceHostname` (replicate.delivery only) and enforces `https:`. The pattern was known and not applied here. MUST be fixed BEFORE this route is copied to store-b (see the migration plan, Phase 1.1).
/wedding-invitation-frames could not be configured or bought on mobile
Found and fixed 2026-07-14. **Live on production.** On mobile the page rendered a frame preview, "Finished size: 16.0" × 12.3"", and then nothing — no price, no pickers, no Add to Cart. Customers could not configure or buy at all. The designer splits its mobile layout on `mobileView` from `useMobileViewToggle`: the controls half — every picker, the `PriceBox`, and Add to Cart — hides when the value is `"preview"`. That state initialises to `"preview"` and **the hook never changes it internally**; the only mutation path is the `setMobileView` it returns. The component destructured neither `setMobileView` nor `showMobileBar`, and rendered neither the toggle FAB nor the sticky bar. So `mobileView` was pinned for the life of the page. It was the only one of the 28 registered designers missing both affordances. **Nothing else caught this**: the route returns 200, the H1 prerenders fine (so SEO was unaffected), and it typechecks clean. It is only visible by actually driving the page at a phone viewport. FIX (PR #423): destructure the setter, render the sticky bar (which is what restores the purchase path — the page mounts the designer `embedded`, and the FAB is deliberately suppressed when embedded, matching every sibling) plus the FAB for non-embedded mounts. Verified in a real 390×844 Chromium viewport against both `next dev` and a production build: 0 Add-to-Cart affordances before, working sticky bar at $37.23 after. Guarded by `e2e/specialty-mobile-purchase-path.spec.ts`, proven non-vacuous (reverting the fix turns it red). ⚠️ Note for anyone re-running it: **HMR does not reliably propagate `packages/ui` edits into a running store-a dev server** — it will serve the old component and give a false pass. Restart the dev server after editing `packages/ui` before trusting any result.
Media/upload API routes missing -> upload/export/upscale/AI 404
Shared core/ui fetch same-origin /api/objects/upload(+from-url), /api/frame-images, /api/proxy-image, /api/upscale, /api/design-recommendations; store-b served none. Broke print-and-frame, nameplate upload, canvas CORS export, upscaling, AI recs. **FIXED 2026-07-14 (#415).** The 6 designer-critical handlers moved into a new shared `@framecraft/core/api-handlers` subpath; every app's route.ts is now a one-line re-export, so store-a, store-b and (later) flagship share ONE copy rather than three that drift — the same lesson the proxy-image SSRF (#413) taught, applied preventively. store-b now serves all 6; verified both apps build clean with the routes compiling as dynamic routes. Authored store-b's first `.env.local.example` (it had none) so a deployer knows to set the R2/Replicate vars; each unset var degrades to a clear 503, never a crash. The 7th route (/api/asset) is deferred on purpose — see SBF-3.
No dedicated Shopify provisioning for store-b
store-b's .env reuses CPF's live Shopify store (cpf-headless, real token + variant), so checkout works but routes through CPF's store — there is NO dedicated store-b Market / token / variant. Needed before store-b takes its own orders.
Mat Designer v2 not migrated — /custom-mats ships a reduced v1
CPF's real mat designer is the **v2** (`original-cpf/client/src/features/mat-v2/`, 3,777-line MatConfigurator + a 1,721-line Zustand store, 25 files): collage layouts, mat pack tiers with bulk pricing, frame upsell, combo presets, 9 mat shapes with collision detection, and text-as-design on the top mat. The monorepo ships a reduced 1,128-line MatConfigurator derived from the v1 (the source v1 is itself 2,051 lines). /custom-mats renders and adds to cart, but it is NOT the CPF experience. Note the v2 renders in SVG with the frame overlay as HTML divs layered OVER it — a documented workaround for Chromium bugs with foreignObject / clip-path; do not "fix" that on port.
NEXT_PUBLIC_SHOPIFY_FRAME_VARIANT_ID unset in prod
/cart reads this literal to attach every headless line to the shared placeholder Shopify variant (the real price rides on `_price_cents` and the Cart Transform overrides it). Unset, it falls back to `gid://shopify/ProductVariant/mock` and Shopify rejects the checkout — a misconfiguration with NO local symptom, because mock mode is the normal state in dev. It must stay a literal `process.env.NEXT_PUBLIC_*` read: src/lib/env.ts uses computed keys, which Next cannot inline into the browser bundle.
84 frame product pages missing — /frames/[frameSlug] route does not exist
Found by the first real link crawl (`npm run link-crawl`): **71 broken internal links** on flagship, and ~60 of them are one missing route. `apps/flagship/src/app/frames/` has `colors/`, `sizes/` and `styles/` but **no `[frameSlug]/`**, so none of the 84 individual frame product pages exist. store-a has the route. The two biggest category hubs funnel customers straight into 404s: **/picture-frames links 27 of them, /shadow-box-frames links 26.** These are the pages the 301 map's Rule 4 (~2,279 moulding URLs) is supposed to land on, so this is a rank-retention problem as well as a UX one. The remaining 11: /samples, /achievements, /graded-card-frames, /hockey-puck-frame-designer, /frames-by-style, /framed-bulletin-boards, /frames/styles/traditional, two /frames/colors children, two /components children (/security-hardware-kit, /acrylic-cleaner). Re-run the crawl after any content wave: `npm run build -w @framecraft/flagship && npm run start -w @framecraft/flagship`, then `npm run link-crawl -- http://localhost:3000`. It exits non-zero on any 404, so it can gate a deploy.
Two pages mount the wrong designer; specialtyConfig was dropped from the port
`/diamond-painting-frames` and `/paint-by-number-frames` mount `FrameDesigner` (a plain picture-frame builder). **The original mounts `NeedleworkFrameDesigner` with a `specialtyConfig`** (`original-cpf/client/src/pages/specialty/DiamondPaintingFrames.tsx:197`, `PaintByNumberFrames.tsx:207`). Introduced by us: these two were guessed during the Wave 11 batch rather than read off the original. They lose stretch-to-fill, size presets, measurement guidance and stock-art pickers. The root cause is deeper than the mapping: **`packages/ui/.../NeedleworkFrameDesigner.tsx` has no `specialtyConfig` prop at all.** The whole `SpecialtyDesignerConfig` mechanism (~15 fields — artworkLabel, sizePresets, presetsSubLabel, sizeAccordionLabel, sizeDescription, uploadLabel, matBorderHint, getStockImage, defaultWidth/Height …) did not survive the port. And `DesignerIsland` structurally cannot pass it — its registry only ever emits `{embedded}` / `{syncUrl}` / `{}`. So all FOUR needlework-family pages are wrong: two on the wrong component entirely, and `/latch-hook-frames` + `/needlework-frames` on the right component with none of their presets or copy (DEGRADED, not broken). FIX ORDER: restore `specialtyConfig` + a way for DesignerIsland to pass it, THEN re-point the two pages.
~20 pages mount no designer where the original mounted one
FIXED 2026-07-14 — **PR #422, awaiting review.** All 20 pages now mount their designer. Along the way: 13 designers existed in `packages/ui/src/components/specialty/` but were **absent from the DesignerIsland registry**, so nothing could mount them (registry 15 → 28). The island also gained a `config` prop, which is what unblocks `cpf-needlework-config`. `next build` passes at 138 routes with the 20 pages prerendering their designer. Two findings worth keeping. (1) These pages were **not prose** — each carried a dashed `designer-embed-slot` placeholder, and each page's hero CTA anchors to that slot's `id`, which is frequently NOT literally `designer` (e.g. `design-tool`, `movie-poster-designer`). The placeholder had to be converted **in place** to keep the CTA resolving. (2) `/photo-collage-frames` was correctly skipped — no route exists, only a redirect. Original scope, for history: `/certificate-frames`, `/collage-frames`, `/comic-book-frames`, `/movie-poster-frames`, `/newspaper-frames`, `/playbill-frames`, `/signature-frames`, `/sonogram-frames`, `/ticket-frames`, `/diploma-certificate-frames`, `/wedding-frames`, `/puzzle-frames`, `/vinyl-record-frames`, `/shadow-box-frames`, `/sports-memorabilia-framing`, `/floater-frames-for-canvas`, `/poster-frames`, `/gallery-wall-frames`, `/acrylic-frames`, `/print-and-frame`. Two need the un-ported components: `/heart-frames` and `/canvas-frames` (3-mode). Also: **`/mat-designer` does not exist in flagship at all**, and `/photo-mats` documents its own funnel to it — a funnel to a 404. `/military-frames` has no route either, though `MilitaryFrameDesigner` is ported. This is the largest CPF gap by page count, and the cheapest by effort.
The shared designer CHROME LAYER was never ported — this is why all 21 designers are behind
Found 2026-07-14 by a **feature-level** audit (comparing code, not filenames) of 21 designers against `original-cpf`. **Every one is materially behind — and all for the same single reason**, which is the useful part: it makes this one shared port instead of 21 rewrites. `packages/ui` descends from the **store-a/store-b** migration, which predates CPF's designer-first refactor. The configurator bodies (accordions, mat, glazing, nameplate, hardware, print-and-frame) ported faithfully. The entire wrapper around them is absent **monorepo-wide** — not as per-designer omissions: `DesignerStickyBar` · `useDesignerStickyBarState` · `useScrollCoupledPreview` · `ProofreadBarGate` · `OwnershipBarGate` · `ReviewsWidgetSlot` · `GoogleStarsBadge` · `EditPhotoModal` · `designerUrlState` · `useConfiguratorUrlSync` · `SizePresetPills` · `SampleCtaInline` · `LocalPickupCallout` · `PrintCropDialog` · `usePreviewSnapshotAR` · `brass-nameplate-proofread` The tell that this is a *lineage* difference and not a lossy copy: `useMobileViewToggle` appears in 27 ported files and **zero** originals. It's the older generation's pattern. **`ProofreadBarGate` is not cosmetic** — it makes the customer affirm they proofread engraved text that "cannot be remade for free". A refund-liability control with no monorepo equivalent. **PR #425 ports 14 of the 16 modules.** Wiring them into the 21 designers is the remaining work, and ~5 designers additionally need real content re-ports (Newspaper −56%, Signature −52%, TicketStub −42%, Certificate −30% by bytes, Needlework). Deliberately deferred: `SizePresetPills` (Ben chose to keep the port's size accordion) and `usePreviewSnapshotAR` (needs a new npm dep, `html-to-image`, and serves only the 10 composite-preview designers).
301 long-tail hard-404s (Rule 4/5/6 + CMS remaps + .html-strip)
Sharpens plan feature "301 redirect map · FL-H05" with the audit's exact residual. The **priority tier is DONE** (18,100-SV `/shadow-boxes.html` + the 13 top URLs verified) and Rules 1-2 (size patterns + ~55 category 301s) are live. What still hard-404s and loses rank equity: **Rule 6 catch-all** (unmatched legacy URL → nearest category; today every unmatched legacy path returns a hard 404, including ~2,185 zero-traffic mouldings), **Rule 4** (~60+ traffic-bearing moulding/product `.html` 1:1s), **Rule 5** (7 chain-flatten pairs = 14 rows), **~11 category `.html` children + ~17 CMS remap keys**, and there is **no global `.html`-strip / case-lowering** so mixed-case inbound URLs miss the config redirects. FIX (all owner=code, destinations all specified in 301-redirect-map.md): add the Rule 4/5 + category/CMS rows to redirects.js, add a case-normalization + catch-all classifier to middleware.ts, and spam → 410. Also build the handful of remaining high-SV sizes still hitting the designer fallback, and wire the shadowbox unbuilt fallback to carry w/h (currently drops to the bare hub).
Contact form silently discards messages
handleSubmit shows "Message Sent!" but never POSTs — customer messages are lost.
Business quote API is a stub
Returns {ok:true} with a TODO to send email / save to CRM; B2B leads are not delivered.
The safety nets fail open — lint rule, CI, and Sentry all silently do nothing
The CFS code is careful (Uppy focus outlines for WCAG 2.4.7, honest force-dynamic comments, next/image discipline, a real allowlist in upload-from-url). What is broken is the machinery that is supposed to catch mistakes — and every piece of it fails OPEN, so it reads as done: 1. `@next/next/no-html-link-for-pages` is configured `"error"` with pages dir `"apps/store-a/src/app"`. `next lint` runs with cwd = `apps/store-a`, so it resolves to `apps/store-a/apps/store-a/src/app` — **a path that does not exist**. The rule then checks NOTHING. Verified. This is why 4 raw `<a href="/...">` internal links accumulated on the homepage. One-line fix. 2. `.github/workflows/accessibility.yml` uses `if: always()` and then unconditionally echoes `"✅ WCAG 2.1 Level AA checks passed"` — **it prints success even when lint failed**. It also claims to check "color contrast", which eslint-plugin-jsx-a11y cannot do. 3. **There is no CI for build, type-check, unit tests, or the 40 Playwright specs.** `accessibility.yml` is the only workflow. 4. Sentry is fully implemented (`packages/core/src/monitoring/sentry.ts`, real SDK deps, DSN env wiring, bespoke webpack config to keep @sentry/node out of the client bundle) and **nothing ever calls it**. Zero importers in store-a. `sentryInitialized` is permanently false, so every capture silently falls through to console. Combined with having no error boundary, a production render crash is invisible to us and unbranded to the customer.
No app-level tests
0 unit/component/E2E tests in store-b (no test script, no vitest/playwright config); violates the test-every-issue policy. Compounds the shared-package risk: any packages/** change hits store-b with no store-b test to catch it, so scan store-b for parallel defects when fixing store-a.
No middleware — every SBF preview is indexable
store-a and flagship both ship the `X-Robots-Tag: noindex` staging gate. **store-b has neither the middleware nor the host-gate lib.** Every Vercel preview and dev host of SBF is fully indexable, free to compete with production in search and to leak unfinished pages into the index. Same defect as CFS #7; store-b never got the port. Fix: port store-a's `src/middleware.ts` + `lib/host-gate.ts` (~40 lines + a test). **FIXED 2026-07-14 (feat/sbf-launch-readiness).** Ported store-a's gate: added `apps/store-b/src/lib/host-gate.ts` (canonical hosts = www + apex shadowboxframes.com) and `apps/store-b/src/middleware.ts` (X-Robots-Tag: noindex,nofollow on any non-production host). At src/middleware.ts — the only path Next honours when src/ exists. Verified: Middleware compiles in the store-b build.
Non-glare glazing upcharge rounds to $0
CD/Record apply a flat $8 non-glare upcharge, but other engine-path specialty frames (Magazine, etc.) route glazing through the area-based pricing delta, which rounds to $0 at small sizes -> under-charge. Same failure mode as the old CD bug. UPDATE (#407): does NOT reproduce at the ENGINE level. Non-glare is genuinely dearer and scales with area ($76.99 -> $79.99 at 16x20; $189.99 -> $197.99 at 32x40) and matches the live CPF engine exactly across the parity harness. Pinned by a test. If the defect is real it lives ABOVE the engine, in the specialty layout tables / designer glazing path — re-scope it there before spending time in pricing-engine.ts.
Frame picker renders in raw JSON order — head-term frames are buried
Found 2026-07-14. The port dropped frame prioritisation: ``` original FrameDesigner.tsx:77 const frameStyles = prioritizeFrames(getFramesByCategory('picture')); ported FrameDesigner.tsx:100 const frameStyles = getFramesByCategory("picture"); ``` `getFramesByCategory` (`packages/core/src/services/products.ts:174`) only **filters** — it never sorts. So the picker renders raw `frames.json` order, which is grouped by colour. The first three picture frames are `wide-black` (searchVolume **40**), `modern-black` (**170**), then `black-wood` (**1900**) — the "black frame" head term, the single most-wanted frame, sitting third behind two low-demand profiles. The highest-converting slots hold the lowest-demand SKUs. This is exactly what the original's `lib/frame-priority.ts` exists to prevent ("buries the head-term winners behind less-searched profiles"). Compounding it: **flagship already has a fix that is never called.** `apps/flagship/src/data/picture-frame-display-order.ts` curates all 40 picture frames by buyer intent (`comparePictureFrames`), covers them with zero orphans, and already places `black-wood` first — but it is referenced **only by its own test file**, and its docstring falsely claims "Used by `getFramesByCategory('picture')`". Another safety-net-that-fails-open, alongside the eslint rule aimed at a nonexistent path and the unconditionally-green CI job. DECIDED (Ben, 2026-07-14): **wire flagship's curated order** — move it into `packages/core` and call it, fixing all three stores. Do NOT port the original's `prioritizeFrames`; it would override flagship's deliberate buyer-intent bucketing. Note `searchVolume` had also been dropped from `frames.json` entirely (0 of 84); PR #425 restores 66/84 keyed by SKU. That restore is **not** a prerequisite here — the curated list covers all 40 picture frames, so the searchVolume tiebreaker never fires. It is data parity plus future-proofing for uncurated frames.
Share links silently lose the design
Found 2026-07-14. A customer who shares their design sends a link that does not reproduce it. ``` original handleShare = () => { const url = getShareUrl(); ... } // encodes the design config ported handleShare = () => { navigator.clipboard.writeText(window.location.href) } ``` (e.g. `packages/ui/.../CertificateFrameDesigner.tsx:535`.) The ported "Copy link" copies the bare URL with no design state. `designerUrlState`, `setShareParams`/`getShareUrl` and `useConfiguratorUrlSync` were all absent monorepo-wide — the port re-implemented `handleShare` locally on the designers that kept a share dialog, and stubbed it dead on others (`PuzzleFrameDesigner.tsx:307`: `const [_showShareDialog, _setShowShareDialog] = useState(false)`). Silent data loss, not a cosmetic gap: nothing errors, the link just doesn't work. PR #425 lands the missing mechanism (`designer-url-state` + `useConfiguratorUrlSync`). Re-pointing each designer's `handleShare` at it is the follow-up, and is part of wiring the chrome layer (cpf-designer-chrome-missing).
SSR crash: @sentry/node OTel graph "Class extends value undefined"
Found 2026-07-17 while chasing a store-a `/stamp-frames` report ("designer never mounts"). Every store-a AND store-b SSR page logs `⨯ Class extends value undefined is not a constructor or null` 120-150× per render on the server. Pages still return 200 (a `try/catch` in sentry.ts swallows it), so it hid from status-code checks — but it's server-side instability and a launch-blocker. Chain: the `@framecraft/ui` barrel → `card-production-codes.ts` → `@framecraft/core` barrel → `services/index.ts` → `services/logging.ts` (module-scope import) → `monitoring/sentry.ts` → `require("@sentry/node")`. @sentry/node v8 pulls the `@opentelemetry/instrumentation-*` graph, one module of which is `class … extends <undefined>` under webpack's RSC server layer. store-a/store-b enable `experimental.instrumentationHook` + ship an `instrumentation.ts` that imports `@framecraft/core`, which drags that graph into the RSC compile. **flagship (CPF) has no instrumentationHook, so it is exempt** — this is NOT the flagship hydration errors. Diagnosed, not fixed. In dev (all caches cleared) a lazy `getSentryNode()` require dropped it ~147→121 but not to 0; `serverComponentsExternalPackages: ['@sentry/node']` alone did nothing. The unverified attempt was reverted. FIX (dedicated pass): reproduce with a real `next build && next start` (dev's on-demand compile + caching make verification unreliable), then likely BOTH a lazy require AND `serverComponentsExternalPackages`, or stop `services/logging.ts` importing sentry at module scope. Note store-a/b's `initializeProductCatalog` (the instrumentation's payload) is now a no-op in core, so the hook may not even be needed.
GTM (GTM-WNC3937K) ships on zero flagship pages
Surfaced by the 2026-07-24 handoff audit. Anthony's spec: the GoogleTagManager container `GTM-WNC3937K` must sit in `<head>` AND immediately after `<body>` on every page of every store. Reality: **flagship (CPF), the priority storefront, ships zero analytics** — no `<Script>` injection mechanism at all in its root layout. store-b (SBF) only fires GTM if a deploy sets `NEXT_PUBLIC_GTM_ID`, which is unset in code. Only CFS (store-a) has GTM genuinely wired. Without this, launch-week measurement (conversion, funnel, ad attribution) is blind on the flagship. FIX: add the head `<Script>` + `<noscript>` iframe GTM block to `apps/flagship/src/app/layout.tsx` (mirror store-a), and supply the per-store `NEXT_PUBLIC_GTM_ID` env. Microsoft Clarity (post-launch) can then ride the same container as a GTM tag with no further code. **Fixed (uncommitted 2026-07-24):** GTM head `<Script>` + `<noscript>` iframe added to flagship's root layout (mirrors store-a); store-b's `GTM_CONTAINER_ID` given the `?? "GTM-WNC3937K"` fallback so it fires by default. Set `brandConfig.metadata.gtmId` per brand if separate containers are wanted. Typecheck 3/3 green.
Personalization confirmation is not bound to the order record
Sharpens the CPF `personalization-gate` issue with what the audit found. The shared proofread gate (`ProofreadBarGate` / brass-nameplate proofread) exists and blocks add-to-cart — but **the single most important architecture detail from Anthony's spec is unmet**: the customer's confirmation + a timestamp + the exact confirmed text are NOT written to the cart/line attributes, so nothing is tied to the order record (the spec: capture it WITH the order, not as a generic site-wide agreement — that is what makes it defensible in a dispute). Two more gaps: (1) the gate is keyed to the brass-nameplate config, so Sonogram's pen-tool caption (permanent printed text) BYPASSES it; generalize the predicate to "any customer-supplied permanent text". (2) The echo-back proofing panel ("Your nameplate will read: ___") the doc calls the gold standard is not surfaced as a distinct affirmation. FIX: mirror the existing "Copyright Agreed" cart attribute — on add-to-cart serialize `Personalization Confirmed: Yes` + ISO timestamp + the confirmed text as line attributes; route the sonogram path through the same gate. Pairs with `personalization-tos` (counsel) below. **Core landed (uncommitted 2026-07-24, unit-tested 27/27):** added `proofreadConfirmedAt` to `BrassNameplateConfig`; `createCartItemFromFrameConfig` (the shared add-to-cart chokepoint for all designers) stamps the ISO timestamp when a confirmed, non-empty nameplate has no stamp yet; `serializeFrameConfiguration` now emits `Personalization Confirmed` + `Personalization Confirmed At` alongside the already-captured `Nameplate Line N` text. Additive (no existing key touched). Also added the ToS **8.1 Personalized and Custom-Text Items** clause to the shared `TermsOfServiceModal` (the surface the gate links to; covers all 3 stores) — interim wording pending counsel (D5). REMAINING: (1) generalize the gate to Sonogram's pen-tool caption (it does not use `brassNameplateConfig`); (2) add the clause to the per-store `/terms` PAGE content (guide markdown), not just the modal; (3) browser e2e — add-to-cart a confirmed nameplate and assert the two attributes on the cart line (needs a prod build).
welcome.custompictureframes.com preservation is documented nowhere
A launch-day DNS trap with NO in-repo reminder. `welcome.custompictureframes.com` points to a DIFFERENT host (in-store POS / QR flow) and must NOT be dropped or repointed during the DNS cutover to Vercel/Shopify. It is referenced in no code, config, or even JOHN-HANDOFF.md — so the deploy team has zero safeguard. Related rule: do NOT use a wildcard `*.custompictureframes.com` at Vercel/Shopify; if unavoidable, add an explicit `welcome` CNAME that takes precedence. ACTION: add both rules to the deployment/DNS runbook (captured in `migration-playbook/06-personalization-locations-dns.md`) and to the launch checklist. Owner: deployment (Ben).
Confirm NEXT_PUBLIC_SITE_ORIGIN in Vercel prod
Confirm NEXT_PUBLIC_SITE_ORIGIN is set in the Vercel prod env. Low risk: brand.config already hard-defaults to the prod host when unset, and the staging noindex host-gate prevents preview indexing. A config confirmation, not a canonical leak.
Prod rate-limiter may 429 crawlers / bursts
The dev limiter caused repeated 429 cooldowns. Tune for prod load + AIO crawlers so it never blocks GPTBot/asset bursts.
Mobile visual pass outstanding
Responsive infra is present but only spot-verified; a full mobile visual pass is outstanding (FL-I03).
CWV unmeasured on prod hosting
Dev numbers are inflated; re-measure LCP/CLS on the Vercel CDN (FL-I04).
HeartFrameDesigner not migrated
FIXED 2026-07-14 — **PR #427.** `HeartFrameDesigner` ported (2,020 → 2,214 lines, faithful) and mounted on `/heart-frames`; the dashed placeholder is gone and the hero CTA's `#design-tool` anchor is preserved. Browser-verified against a production build: the designer hydrates, prices live (Add to Cart · $97.48), throws zero client errors, and the picker orders `black-wood` first. /heart-frames was one of the last SEO builds on the source (13,000 SV target), so this is a real revenue page. Heart was the **only** genuinely new CPF designer — every other specialty designer already existed in `packages/ui` in some form. It is also the **first real consumer of the chrome layer** (#425), so it doubles as end-to-end proof that layer works: its sticky bar renders the total and `ReviewsWidgetSlot` resolves to "4.8 · Read 4,280+ reviews" from brandConfig. Findings worth keeping: - **`prioritizeFrames` → `packages/core`.** Heart needed frame ordering, but flagship's curated `picture-frame-display-order.ts` lived in `apps/flagship/src/data` and a package cannot import from an app. Moved to `@framecraft/core`. **Additive — no store's picker changes**, because nothing called it in production (its only reference in the whole repo was its own test). Heart is its first real caller. Its docstring falsely claimed `getFramesByCategory` used it; corrected. The remaining wiring is still tracked as `frame-picker-unordered` in platform.md. - **Two props dropped as lying APIs**, both verified dead *in the original*: `hideLifestyleCarousel` (documented no-op) and `embedded` (declared + destructured, never read — the `embedded={true}` at original :1304 is a prop passed TO BrassNameplateSection). Heart writes nothing to the URL, so it is registered `urlProp: "none"`. - **One testid deliberately changed.** original-cpf puts `data-testid="signature-frame-designer"` on Heart's root div (`:693`) — a copy-paste artifact from SignatureFrameDesigner, which Heart was derived from. Ported verbatim, two designers in packages/ui would claim the same identity. Renamed to `heart-frame-designer`; nothing keyed off it. Every other testid is verbatim. - ⚠️ **Do NOT use `waitForLoadState("networkidle")` in flagship e2e.** Frame/mat imagery resolves to CDN buckets that 404 in a local build, and the retries keep the network busy past any sane timeout (hung 60s). Wait on the designer instead. Those broken thumbnails are `cpf-assets-not-uploaded`, not a port defect.
Canvas designer suite not migrated (traditional + unframed)
CPF splits canvas into `CanvasTraditionalFrameDesigner` (1,331 lines) and `CanvasUnframedDesigner` (1,132 lines); neither is in packages/ui. The three canvas landing pages currently embed the generic `CanvasFrameDesigner`, so the traditional-frame and unframed/stretch-only paths are missing.
dev-api database is down
`https://dev-api.customframesizes.com/api/health/db` returns `{"ok":false,"error":{"code":"DB_UNAVAILABLE","message":"Database health check failed"}}` while production is `{"ok":true}` (2026-07-13). So the DEV BFF cannot reach its database: accounts, saved designs, the photo library, sample credits and loyalty are all broken on dev/staging even though they work in production. Anything QA'd against dev will look broken for the wrong reason — worth knowing before someone re-opens the accounts issues on the strength of a dev test.
/samples does not exist in flagship — SampleCtaInline points at a 404
FIXED 2026-07-14 — `/samples` ported (982-line faithful port of `pages/SamplesPage.tsx`), so the CTA resolves and **HeartFrameDesigner is unblocked**. Prerenders static (11.1 kB); the `?frame=` deep-link preselect is guarded by `e2e/samples-deeplink.spec.ts`, proven non-vacuous. Two findings worth keeping: **store-b's `/samples` is NOT this page.** It has the same name and is a real 811-line page, but it came from **original-sbf** (a different business) and has **no searchParams handling at all** — so no `?frame=` preselect. Used only as a reference for monorepo idioms. Another instance of same-name-different-thing. **Image resolution cannot port verbatim, and porting it literally is a real bug.** original-cpf serves `public/{frames,mats/swatches,attached_assets}` locally and returns paths like `/frames/<id>/corner.jpg`. flagship ships none of those and resolves imagery from the CDN buckets keyed by **SKU, not id**. Left verbatim, all 84 frame cards render broken-image icons (they have no `onError`) and 46 mat swatches silently degrade to flat hex. `getMatSwatchPath` in `@framecraft/config` is a trap here — it returns the local path. Rewired to `getFrameImageUrl` / `getCanvasImageUrl` / `getStoreBaseAssetUrl` / `getSharedAssetUrl`, mirroring store-b. **Any future CPF page port that renders frame or mat imagery hits this.** Still open: two backing cards deep-link to `/bulletin-board-designer`, which flagship does not have.
Sample-SKU 1:1 coverage unverified (Shopify catalog)
Anthony's spec: every product/component (frame moldings, mat colors, acrylic, backing, bulletin-board components) needs a corresponding **sample SKU**, sold as a normal cart product, with the $25 credit as the one-time account reward. Whether every option shown on `/samples` (and every active molding/mat color) has a real 1:1 sample SKU is a **Shopify catalog fact not verifiable from this repo**. ACTION (Ben): audit the Shopify sample-SKU catalog against the `/samples` inventory + active moldings/mat colors to confirm 1:1 coverage before enabling the sample flow.
Newsletter signup has no backend
Footer newsletter signups aren't captured anywhere (shared component).
No measured Lighthouse / CWV baseline
Target >=90 is unverified; original numbers are estimates. Measure on /, /designer, a PDP, a category page.
No measured a11y audit
AA compliance is claimed-but-unverified. Run axe / WAVE / screen reader before launch.
10 inheritance-dup blog posts not deleted/redirected
Anthony's SBF handoff flags that the SBF fork carries 21 blog posts, **10 of which are inheritance duplicates from CPF/CFS** that must be deleted with 301s to the canonical owners, plus an /about meta differentiation — to avoid duplicate-content dilution with the network. The 8 net-new handoff articles are live and schema-complete (verified 2026-07-24), but this dedup work is **entirely absent**: no dup posts deleted, no 301s emitted. Canonical-owner assignment per dup needs `research/duplicate-content-audit/JOHN_BRIEF_SBF.md` (not in this repo). ACTION: obtain JOHN_BRIEF_SBF.md → delete the 10 dup `.md` files + add `/blog/<slug>` 301s to canonical CPF/CFS owners in redirects.js → differentiate the /about meta.
Confirm CDN env on Vercel + populate store-b bucket
CDN URLs are set in local .env (shared-assets.customframesizes.com + assets.shadowboxframes.com). Confirm they're set on Vercel and the store-b bucket is populated, or images 404 in prod. (Local part verified; the Vercel side is a deploy check.)
Default OG/Twitter image 404
brand.config points ogImage/twitterImage at /assets/og-image.jpg, which doesn't exist -> a broken/duplicate og:image site-wide. **FIXED 2026-07-14.** Root cause: layout.tsx set openGraph.images + twitter.images to brandConfig.seo.ogImage, which is `${origin}/assets/og-image.jpg` — a file that does not exist — so every page emitted a BROKEN og:image, AND a DUPLICATE, since app/opengraph-image.tsx already generates a valid card Next auto-wires site-wide. Removed the explicit `images` from both; the file-based opengraph-image.tsx / twitter-image.tsx routes are now the single source. Product pages still override per-segment.
No error boundary
Only not-found.tsx; no error.tsx / global-error.tsx, so unhandled client errors hit the unbranded Next page. **FIXED 2026-07-14.** Added `app/error.tsx` (route-segment boundary, brand-styled, retry + home) and `app/global-error.tsx` (root-layout failures; self-contained html/body). store-b has no Sentry wired, so they log to console — the honest state; capture-to-Sentry is a one-line add when it lands. store-a and flagship still lack these (platform follow-up).
Mock Gallery / Achievements content
NOT A MIGRATION REGRESSION — corrected 2026-07-13. The ORIGINAL is mock too, with the same TODO: `original-sbf/client/src/pages/Gallery.tsx:9` and `Achievements.tsx:8` both carry `// todo: remove mock functionality`. store-b faithfully carried the debt forward and even annotates it (`gallery-page-content.tsx:24`). So this is pre-existing PRODUCT debt, not something the migration broke. What makes it worse than in the original: both routes are in `app/sitemap.ts`, so they are actively submitted to Google. /gallery ships six fake designs ("Classic Oak Frame", $125.99, `createdAt: "2 days ago"`) all sharing ONE stock photo; /achievements ships hardcoded gamification shown identically to every anonymous visitor. **DECIDED 2026-07-13 (D4): keep the pages, drop them from the sitemap.** Keeping them is what "faithful to the original" means — the original is mock too. But the original was an SPA that never advertised them to Google, and store-b's sitemap.ts does: it actively submits six fake designs (all sharing one stock photo, "$125.99", "2 days ago") and fake gamification. Shipping an unfinished page and feeding invented content to a search engine are different acts. Minimal change: remove the two sitemap entries. Do not build the features (out of scope — product debt the original carries too); do not delete the routes (that would diverge from the original). **FIXED 2026-07-14 (D4 applied).** Removed /gallery and /achievements from app/sitemap.ts (the routes stay — the original is mock too, so keeping them is faithful; but we no longer feed the invented content to Google). Verified both still build; neither is in the sitemap.
Contact form has no backend
contact-page-content has no submit handler/endpoint (business quote works via api/business-quote).
Raw <img> throughout -> no image optimization
no-img-element disabled in 28 files; heavy specialty routes hit ~455 kB First Load. Convert to next/image + code-split.
Pages in original-sbf with no store-b route and no redirect
Present in original-sbf, absent from store-b, with no redirect — so the old URLs hard-404: | original route | what it was | LOC | |---|---|---| | `/print-and-frame` | upload → print → frame (embeds FrameDesigner) | 127 | | `/specialty-framing` | specialty hub | 465 | | `/order-dashboard` | brass nameplate order files + LightBurn download | 196 | | `/order-fulfillment` | order lookup by Shopify id | 234 | | `/ai-recommendation-preview` | renamed to `/recommendation-preview` with NO redirect | — | **DECIDED 2026-07-13 (D5): do not port `/order-dashboard` or `/order-fulfillment`.** framecraft-order supersedes both — verified live at `order.customframesizes.com` (307 → /login behind its auth gate), it is "a Next.js admin dashboard for retrieving custom frame orders directly from Shopify", and it already serves `/order/[orderId]` with order search and signed R2 print-file downloads, which is exactly what `/order-fulfillment` did. One capability may not be covered — `/order-dashboard`'s brass-nameplate LightBurn (.lbrn) download. That belongs in framecraft-order next to the other print-file downloads, NOT re-ported into a customer storefront; filed there. `/print-and-frame` and `/specialty-framing` are customer-facing and remain genuine gaps. `/ai-recommendation-preview` just needs a redirect. Also missing redirects (targets exist, just unlinked): `/specialty/jersey`, `/specialty/canvas`.
"No Acrylic" discount glazings never applied
backing-only / none glazing discounts never subtract: `pricing.ts` collapses FOUR glazing options into a binary (`isNonGlare ? NON_GLARE : STANDARD`), so a customer who picks "No Acrylic" is charged the full standard-acrylic price for acrylic they do not receive. INVESTIGATED — this is now a DECISION for the client, not a code task. Two findings: 1. It is IDENTICAL in the original (original-cpf does the same binary), so the migration is faithful and the bug is inherited. Fixing it therefore CHANGES PRICES vs the live site, and would deliberately break the FL-F03 parity harness. 2. The discount MAGNITUDE is unspecified. glass.json carries `pricePerSqFt: -1` (backing-only) and `-2` (none), but that field is VESTIGIAL — it drives nothing. Proof: non-glare's `pricePerSqFt: 3.5` would imply a +$7.78 upcharge at 16x20, and the real upcharge is +$3.00 (which comes from COMPONENT_PRICING.costPerSqIn instead). So the -1/-2 values are not a trustworthy spec. Also, the engine cannot even distinguish "backing-only" from "no backing": there is no backing cost anywhere in COMPONENT_PRICING, so both would price identically. THE CLIENT NEEDS TO SAY: what should "No Acrylic, with Backing" and "No Acrylic, No Backing" actually cost? The only defensible engineering answer with no invented numbers is to stop charging for acrylic MATERIAL (acrylic costs $0.0032/sq-in, ~$1.04 of material at 16x20, which the multiplier and marketing load amplify to roughly $6 at retail) — but that still cannot separate the two backing options.
orders / sample_credits tables absent
PARTLY STALE. sample_credits and loyalty_ledger now exist (migrations 0003/0004), so the schema is customers / saved_designs / uploaded_images / sample_credits / loyalty_ledger. Only the webhook-persisted orders table is genuinely absent, and that is intentional: order history runs off the live Customer Account API. Narrow this to 'decide whether we want an orders table at all', or close it.
ImageEditor can return crops that stretch at print
`ImageEditor` takes only `{ imageUrl, onComplete, onCancel }` — it has **no way to learn which mat opening the customer is buying**, so its `<ReactCrop>` is free-form. Its `aspectMatches()` helper is *advisory only*: it tells the customer "this crop matches an 11×14" within a 3% tolerance while they drag, but nothing constrains the result. A crop that doesn't match the opening gets physically stretched or re-cropped at production. The original's replacement, `EditPhotoModal`, takes `aspectW`/`aspectH` and locks the crop to the real opening — in its own words, "so we never receive a crop that would be physically stretched into the opening at print". It also consolidates the legacy `ImageEditor` (Enhance/Rotate, no lock) and `PrintCropDialog` (lock, no Enhance/Rotate) into one flow. **PR #425 ports `EditPhotoModal` but does NOT swap any caller.** Collage and Diploma still use the old editor; migrating them is per-designer work. Print-correctness, not a UI preference.
store-b omits Organization + WebSite JSON-LD site-wide
Flagship injects Organization + WebSite JSON-LD site-wide from its root layout; store-a does too (homepage). **store-b's root layout renders no `ld+json` Organization/WebSite block** — it has Product/AggregateOffer + FAQPage on product/content pages but is missing 2 of the 5 required org-level schema types in server HTML. The generators already exist in `packages/seo`; this is a thin site-wide `<JsonLd organization+website>` add to the store-b root layout. **Fixed (uncommitted 2026-07-24):** `<JsonLd schemas={[seo.organizationSchema(), seo.websiteSchema()]} />` added to store-b's root layout (the `seo` facade already existed via `createSeo`). store-b now emits all 5 org/product schema types.
Frame-PDP meta-descriptions have no 120-char floor on flagship + store-b
The CFS punch-list "4-word tagline as meta description" bug was fixed with `buildFrameProductMetaDescription` (pads to 120 / trims to 155) — but it is wired on **store-a ONLY**. `rg buildFrameProductMetaDescription` returns zero callers in flagship and store-b. Flagship happens to pass on sampled routes because its ~45 PDPs carry hand-authored descriptions (no enforced floor). **store-b reproduces the exact bug**: its `metaDescription()` falls back to `shortDescription` (median 25 chars, tagline-like) for the 18 shared frames lacking a `featuredDescription`, and truncates at 300 (wrong ceiling). FIX: route flagship AND store-b frame/shadowbox PDPs through `buildFrameProductMetaDescription` (or add a shared min-120/max-155 clamp); drop store-b's 300-char cap. **Fixed (uncommitted 2026-07-24) — store-b:** its `/frames/[slug]` `metaDescription()` now routes through `buildFrameProductMetaDescription` (pads to 120 / trims to 155), replacing the shortDescription-tagline + 300-cap. **flagship left as-is:** its ~45 PDPs use compliant hand-authored copy; routing them through the builder is optional hardening, not a fix.
Voice/brand lint fails on the stores (banned words, em-dashes, exclamations, emoji)
Anthony's voice rules (JOHN-HANDOFF §6 + SBF network rules) are ~90% honored on flagship but violated on the two stores: ~30 (store-a) and ~40 (store-b) banned-word instances (transform, journey, elevate, seamless, unlock, discover…), em-dashes in visible copy across all three, `Yes!` exclamations in store-a/store-b FAQ answers, and a pictographic emoji on a shipped store-b route. flagship is clean apart from 2 blog "Transforms". This is a content lint, mostly find-and-replace. NOTE two items need a Ben decision first (see cpf.md `voice-decisions`): the exact glazing descriptor ("frame shop grade acrylic" vs the house "framer's grade acrylic") and whether "picture framing over custom framing" as lead descriptor applies network-wide.
axe-core has never been run against the current monorepo build
Both accessibility audits on file (CFS axe 4.8.4 = 4 violations; CPF axe 4.10 = 0) were run against the OLD Replit prototypes, not the current Next.js apps. Spot-checks confirm the current code resolved the old CFS violations (skip link, icon-button names, mat slider, contrast fix above), but no axe-core scan has been run against a production build of any current storefront. LAUNCH GATE: run axe-core + a manual keyboard/screen-reader pass against `next build && next start` for each store (homepage, a designer, a PDP, a content page, /cart, /contact) before go-live. Owner: code (cheap; most remediation already done).
Dark-mode contrast unverified (theme is placeholder)
Dark mode ships and is toggleable (ThemeToggle mounted on all three), but the brand theme colors are explicit placeholders pending FL-A03 calibration and only the primary button + primary-text pairs are documented AA-checked. A full pairwise contrast pass over the `.dark` tokens (muted-foreground on card/background, accent, destructive) has NOT been done. Either complete FL-A03 calibration + run the pairwise pass before claiming AA in dark mode, or gate the dark-mode toggle until it passes. Owner: ben (theme identity decision) + code.
Flagship homepage Lighthouse a11y fixes (axe-verified 0 violations)
Ben ran Lighthouse on the live flagship homepage (staging) 2026-07-24: a11y **82** with 5 red items. axe-core against a local build pinned the exact nodes and all are now fixed (re-axe = **0 violations**, uncommitted): - **Slider (aria-allowed-attr + aria-input-field-name):** the shared `packages/ui/src/components/ui/slider.tsx` spread `aria-label` + `aria-value*` onto the role-less Radix Root instead of the `role="slider"` Thumb. Fixed the component to forward `aria-label`/`aria-labelledby`/`aria-valuetext` to the Thumb and drop the redundant manual `aria-value*` (Radix computes them). **One fix, all 13 slider designers.** - **button-name:** the brass-nameplate `<Switch>` (both instances in `BrassNameplateSection.tsx`) had no name → added `aria-label="Add personalized engraving"`. - **color-contrast:** homepage `.cpf-stage-number` watermark numerals were `foreground @ opacity 0.35` = 2.18:1 (fail large-text 3:1) → raised to 0.7. - **target-size:** the `#puzzle-glue` checkbox (FrameDesigner) was 16px → bumped to 24px (`h-6 w-6`). FOLLOW-UP (not blocking): the SEO score in that run (69) is the **staging noindex guard working** ("Page is blocked from indexing") — it clears on the real production host, not a defect. Other 16px checkboxes on other pages (bottomWeighted, copyright gates) may trip target-size elsewhere — a shared-Checkbox 24px-target decision + the full multi-page/prod-build axe sweep remain under `a11y-axe-not-run`.
/military-frames imports the @framecraft/ui barrel — crashes under next dev
`page.tsx:3` does `import { Badge, Card, MilitaryLifestyleCarousel } from "@framecraft/ui"` — the **barrel**, which `RENDER_MATRIX.md` locks as forbidden (deep-path only). It drags `@sentry/react`'s class-component ErrorBoundary into an RSC, so `next dev` throws `Class extends value undefined is not a constructor or null` and the error overlay covers the whole page. **A production build renders it fine** — verified both ways, so this is DX/rule debt, not a prod incident. Filed at low severity for exactly that reason. Fix = deep-path imports. It is excluded-with-a-comment from `e2e/specialty-mobile-purchase-path.spec.ts` until fixed.
/samples OG image is 1.69MB
The CFS punch-list "OG image > 300KB" class, one live instance: flagship `/samples` ships a 1.69MB OG image. A `og-samples.webp` variant already exists in `public/assets/`. FIX: compress `og-samples.jpg` under 300KB or point `samples/page.tsx` at a resized asset. Other pages comply.
Hero cross-browser rendering not verifiably closed
No dedicated fix commit found for the original Chrome gray-gradient bug; re-test on the current hero. PARTIALLY VERIFIED. Rendered the store-a homepage in Chromium, Firefox AND WebKit: all three are byte-for-byte the same, the H1 paints, and there is NO cross-browser difference — so the "Chrome-specific" framing is unsupported. The hero DOES render as a flat gray gradient locally, but that is a local-env artifact, not the bug: the hero is a VIDEO (`/assets/hero/collage-wall-1.mp4`) served from the CDN, and with `NEXT_PUBLIC_CDN_*` unset it 404s and falls back to the gray poster (the mat swatches 404 for the same reason). TO CLOSE: re-run the same check against a CDN-configured environment (staging or prod), where the hero video actually loads. Cannot be closed from a local build.
api/asset route absent but rewrites point to it
/mats/*, /assets/*... rewrites target a non-existent /api/asset/* handler -> 404 in local dev; prod masks it only if CDN env is set. **Downgraded blocker→low 2026-07-14.** Confirmed this is a LOCAL-DEV-ONLY gap, not a launch blocker. asset-urls.ts emits absolute CDN URLs whenever NEXT_PUBLIC_CDN_STORE_URL is set (store-b on assets.shadowboxframes.com in prod), so the /api/asset rewrites are never hit in production and no designer fetches that path (verified: the only reference in shared code is a comment). It was deliberately left out of the #415 shared-route migration because it is a local file server for the assets_to_use dir AND genuinely per-store (its dir routing is store-a specific). If we want store-b `npm run dev` to serve local assets without a CDN env, it needs a store-parameterised port of store-a's route; otherwise it can be closed.
Stale/contradictory docs
README / MIGRATION_GUIDE describe an early 6-page PoC with the wrong theme/fonts vs the actual brand.config (olive/sage, Cormorant/Outfit) and ~100 routes.
Stamp emits non-canonical STAMP-MAT-### code
Code prefix isn't in the canonical DIP/COM/CARD list — confirm the warehouse recognizes it.
checkoutBrandingUpsert base script missing
The Branding-API script to hide the default Shopify logo / set a neutral base isn't in the repo, so the extension renders below the built-in header logo.
No route-level skeleton (loading.tsx) on store-a or store-b
Flagship added `loading.tsx` (root `PageSkeleton` + `gallery/GallerySkeleton`). **store-a and store-b have NO `loading.tsx` at all** — zero route-level skeleton coverage — even though the shared `PageSkeleton`/`GallerySkeleton` (`packages/ui` skeleton-loader) are available to them. FIX: add a one-line `apps/store-a/src/app/loading.tsx` and `apps/store-b/src/app/loading.tsx` (each returning `<PageSkeleton />`), then shape-matched overrides for gallery/account/designer segments. **Fixed (uncommitted 2026-07-24):** added `apps/store-a/src/app/loading.tsx` + `apps/store-b/src/app/loading.tsx` (each `<PageSkeleton />`). Shape-matched overrides (gallery/account/designer) are optional follow-ups.
Decisions
calls someone must make17D5 — personalization consent wording (counsel)
IdeaLegal / E. Milesnick must sign off the final ToS/consent wording before the gate can ship. Legal/liability blocker.
Two-source review conflation risk
Seller (global) and product (per-page) reviews must stay 2 slots / 2 schema scopes. Never pipe reviewStats into the product slot. Design guard for FL-G02/G03. (R3)
Metal frames — do we sell them?
The page (ported verbatim) says "we do not carry metal frames in standard inventory" and routes to a special-order form, contradicting the homepage's "wood and metal profiles." Needs a call + a one-line copy fix.
Launch-day hours change sequencing
Update the 41 /visit pages first, wait 24-48h for crawl, THEN edit GBP/Yelp/FB/BBB so Google consensus doesn't auto-revert the 9-5 -> 9-4 change (FL-I07).
D1 — reviews.io plan tier timing
Flagship must be on the Plus tier to be the syndication source; currently a lower tier. Decide when to flip (FL-G04).
D2 — rb2b destination + privacy disclosure
Sign off the Slack/CRM destination and confirm the pixel is disclosed in /privacy-policy (FL-G05).
D6 — designer APIs: flagship app vs shared service
Decide which designer-support APIs (styles, uploads, nameplate .lbrn, recommendations) live in the flagship app vs framecraft-api / framecraft-order (FL-F06).
D7 — voice normalization decisions (glazing descriptor + lead term)
Two voice calls block the store copy cleanup (`voice-lint-stores` in platform.md): (1) The mandated standard-glazing descriptor is "frame shop grade acrylic"; flagship uses the house wording "framer's grade acrylic". Decide whether the house wording is acceptable or must be normalized to the exact spec term across all stores. (2) Anthony prefers "picture framing" over "custom framing" as the lead descriptor — decide whether that preference applies network-wide (would rebalance store-a/store-b hero/H1/lead copy). Both are load-bearing-intent-satisfied on flagship (no UV claims, no museum glazing); this is about exact wording, so it needs Ben, not code.
Sentry not enabled / no alerting
Optional DSN; monitoring/alerting for cart/pricing/Shopify failures isn't configured.
CardFrameDesigner is fully ported but store-b mounts no route for it
`CardFrameDesigner` (2,151 lines) is fully migrated into `packages/ui`, and store-a and flagship both mount it. **store-b mounts nothing** — its only mention is a comment. `/card-frames` and `/cards` (both real routes in original-sbf, `App.tsx:2140,2145`) are 301'd to `/sports-memorabilia-frames`, which mounts only the generic `ShadowboxDesigner`. The supporting `CardLayoutGallery`, `CardPreviewCanvas` and `GradedCardLifestyleCarousel` are ported and idle too. This looks DELIBERATE rather than dropped — `redirects.js:33-37` says "graded cards fit under sports memorabilia". So it is a product decision, not a defect. **DECIDED 2026-07-13 (D3): RESTORE IT.** The rule is "rely on the original; the feature should be exactly the same", and original-sbf mounted it on BOTH `/card-frames` and `/cards`. The redirect comment reads like a shortcut taken during the port, not a product decision recorded anywhere. Cost: one page — the designer and its three supporting components are already ported and idle, and store-a + flagship both mount it. Reversible: if the client confirms SBF genuinely does not sell graded cards, restore the redirect. **FIXED 2026-07-14 (D3 applied).** Restored the dedicated CardFrameDesigner: new /card-frames and /cards pages mount it (it is self-contained — renders its own hero/layouts/FAQ non-embedded, matching the original bare mount). Removed the two redirects that 301'd both to /sports-memorabilia-frames. /cards canonicalises to /card-frames to avoid duplicate content. Original meta restored from the source Helmet. /card-frames added to the sitemap. Both build clean.
Comic/Puck price basis + Sonogram/Puck preview orientation
Slips show the PDF value, but the layout tables driving price + preview still differ from the PDF. Re-basing deviates from "match original" and changes live prices + preview geometry.
Consolidate framecraft-api into monorepo apps/api
Scaffolding copied but keep-vs-revert unconfirmed. Alt: keep separate repo + publish @framecraft/db to a registry. Recommended: consolidate.
D3 — Magento SKU export (optional)
Not required, but if provided it upgrades chain/long-tail moulding redirects to 1:1 /frames/<id> (FL-H04/H05).
D4 — Glass cut-to-size configurator
Flagged-not-built; net-new, out of migration scope unless prioritized.
biz@ never changed to b2b@ (and live routing uses hello@)
Anthony asked to change `biz@custompictureframes.com` → `b2b@custompictureframes.com` everywhere. Reality: the stray `biz@` at `business-page-content.tsx:308` was never changed, `b2b@` exists nowhere, and the live mailto/quote routing actually uses `hello@custompictureframes.com`. DECISION for Ben: confirm the canonical B2B address (`b2b@` per the spec vs the currently-live `hello@`), then update `business-page-content.tsx:308` (and the B2B quote routing if `b2b@` is canonical).
Home H1 lacks the primary keyword
Home H1 "Display Your Story" (brand voice) vs a keyword-rich "Custom Shadow Box Frames." Decide brand voice vs SEO.
Loyalty vendor: Smile.io vs Rivo
Pick a provider behind the LoyaltyService abstraction.