FFrameCraft Ops

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.

Severity

Issues

defects to fix64
mock-checkout

Mock checkout — no real commerce

Verify pendingBlocker

/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.

commerce· manual
personalization-gate

Personalization consent gate not built

OpenBlocker

Pre-launch legal requirement on any permanent-text flow (nameplate/engraving/mat lettering). Mechanism unbuilt (FL-F05). Pairs with decision D5 (counsel wording).

designer / legal· manual
cpf-api-host-missing

Flagship points at an API host that does not exist

Verify pendingBlocker

`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.

apps/flagship env · NEXT_PUBLIC_API_URL· manual
cpf-assets-not-uploaded

CPF assets: the buckets are 86% populated — the CDN env vars are commented out

OpenBlocker

**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`.

apps/flagship · Cloudflare R2 (assets-flagship.customframesizes.com)· manual
cfs-proxy-image-ssrf

/api/proxy-image is an unauthenticated SSRF

OpenBlocker

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).

apps/store-a/src/app/api/proxy-image/route.ts· manual
wedding-invitation-mobile-dead-end

/wedding-invitation-frames could not be configured or bought on mobile

Verify pendingBlocker

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.

packages/ui/src/components/specialty/WeddingInvitationFrameDesigner.tsx· frame-auditPR #423
SBF-1-media-routes

Media/upload API routes missing -> upload/export/upscale/AI 404

OpenBlocker

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.

apps/store-b (missing api routes)· manual
SBF-2-shopify

No dedicated Shopify provisioning for store-b

OpenBlocker

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.

commerce· manual
mat-v2-missing

Mat Designer v2 not migrated — /custom-mats ships a reduced v1

OpenHigh

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.

/custom-mats · packages/ui MatConfigurator· manual
variant-id-env

NEXT_PUBLIC_SHOPIFY_FRAME_VARIANT_ID unset in prod

OpenHigh

/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.

deploy / Vercel env· manual
frame-pdp-missing

84 frame product pages missing — /frames/[frameSlug] route does not exist

OpenHigh

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.

apps/flagship/src/app/frames· manual
cpf-needlework-config

Two pages mount the wrong designer; specialtyConfig was dropped from the port

OpenHigh

`/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.

packages/ui NeedleworkFrameDesigner · apps/flagship designer-island + 4 pages· manual
cpf-missing-designer-mounts

~20 pages mount no designer where the original mounted one

Verify pendingHigh

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.

apps/flagship/src/app· manualPR #422
cpf-designer-chrome-missing

The shared designer CHROME LAYER was never ported — this is why all 21 designers are behind

Verify pendingHigh

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).

packages/ui · packages/core · packages/config· frame-auditPR #425 · feature-level audit 2026-07-14
redirect-longtail-404

301 long-tail hard-404s (Rule 4/5/6 + CMS remaps + .html-strip)

OpenHigh

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).

apps/flagship/src/config/redirects.js · middleware.ts· manualLAUNCH_READINESS_AUDIT_2026-07-24.md · redirects · 301-redirect-map.md
cfs-contact-form

Contact form silently discards messages

On holdHigh

handleSubmit shows "Message Sent!" but never POSTs — customer messages are lost.

(company)/contact/ContactForm.tsx· manual
cfs-business-quote

Business quote API is a stub

On holdHigh

Returns {ok:true} with a TODO to send email / save to CRM; B2B leads are not delivered.

api/business-quote/route.ts· manual
cfs-safety-nets

The safety nets fail open — lint rule, CI, and Sentry all silently do nothing

OpenHigh

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.

.eslintrc.json · .github/workflows/accessibility.yml · instrumentation.ts· manual
SBF-5-no-tests

No app-level tests

OpenHigh

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.

QA infra· manual
SBF-16-noindex-gate

No middleware — every SBF preview is indexable

OpenHigh

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.

apps/store-b (no src/middleware.ts, no lib/host-gate.ts)· manual
T7a-glazing

Non-glare glazing upcharge rounds to $0

Verify pendingHigh

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.

shared → CFS, SBF, CPFcore/utils/specialty/{cd,record-album}-layouts.ts (flat $8) + services/pricing.ts area-delta path· frame-audit
frame-picker-unordered

Frame picker renders in raw JSON order — head-term frames are buried

OpenHigh

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.

shared → CFS, SBF, CPFpackages/ui FrameDesigner · packages/core/services/products.ts· frame-audit
share-link-loses-design

Share links silently lose the design

OpenHigh

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).

shared → CFS, SBF, CPFpackages/ui specialty designers· frame-audit
sentry-otel-rsc-crash

SSR crash: @sentry/node OTel graph "Class extends value undefined"

OpenHigh

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.

shared → CFS, SBFpackages/core/src/monitoring/sentry.ts + apps/{store-a,store-b}/next.config.js (instrumentationHook)· manualframecraft-storea-ssr-sentry-crash (session memory)
gtm-analytics-missing

GTM (GTM-WNC3937K) ships on zero flagship pages

UncommittedHigh

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.

shared → CPF, SBFapps/*/src/app/layout.tsx (analytics injection)· manualLAUNCH_READINESS_AUDIT_2026-07-24.md · seo-core/gtm
personalization-order-capture

Personalization confirmation is not bound to the order record

UncommittedHigh

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).

shared → CFS, SBF, CPFpackages/ui specialty designers (nameplate gate) · cart attribute builder· manualLAUNCH_READINESS_AUDIT_2026-07-24.md · personalization · personalization-acknowledgment.md §4
dns-welcome-subdomain

welcome.custompictureframes.com preservation is documented nowhere

OpenHigh

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).

shared → CPFdeployment / DNS runbook (not in repo)· manualLAUNCH_READINESS_AUDIT_2026-07-24.md · dns-infra · category 8
canonical-host

Confirm NEXT_PUBLIC_SITE_ORIGIN in Vercel prod

OpenMedium

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.

SEO / deploy· manual
rate-limiter

Prod rate-limiter may 429 crawlers / bursts

OpenMedium

The dev limiter caused repeated 429 cooldowns. Tune for prod load + AIO crawlers so it never blocks GPTBot/asset bursts.

infra· manual
mobile-visual

Mobile visual pass outstanding

OpenMedium

Responsive infra is present but only spot-verified; a full mobile visual pass is outstanding (FL-I03).

QA· manual
cwv-unmeasured

CWV unmeasured on prod hosting

OpenMedium

Dev numbers are inflated; re-measure LCP/CLS on the Vercel CDN (FL-I04).

perf· manual
heart-designer-missing

HeartFrameDesigner not migrated

Verify pendingMedium

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.

/heart-frames · packages/ui· manualPR #427
canvas-designers-missing

Canvas designer suite not migrated (traditional + unframed)

OpenMedium

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.

/canvas-prints · /canvas-stretching · /floater-frame-for-panel-painting· manual
dev-api-db-down

dev-api database is down

OpenMedium

`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.

framecraft-api (dev deploy) · Supabase· manual
cpf-samples-route-missing

/samples does not exist in flagship — SampleCtaInline points at a 404

Verify pendingMedium

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.

apps/flagship/src/app· frame-auditPR #425
sample-sku-coverage

Sample-SKU 1:1 coverage unverified (Shopify catalog)

OpenMedium

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.

Shopify catalog vs /samples page· manualLAUNCH_READINESS_AUDIT_2026-07-24.md · accounts/accounts-sample-skus
cfs-newsletter

Newsletter signup has no backend

On holdMedium

Footer newsletter signups aren't captured anywhere (shared component).

shared → CFS, SBF, CPFshared Footer· manual
cfs-cwv-unmeasured

No measured Lighthouse / CWV baseline

OpenMedium

Target >=90 is unverified; original numbers are estimates. Measure on /, /designer, a PDP, a category page.

whole app· manual
cfs-a11y-unmeasured

No measured a11y audit

OpenMedium

AA compliance is claimed-but-unverified. Run axe / WAVE / screen reader before launch.

whole app· manual
sbf-dedup

10 inheritance-dup blog posts not deleted/redirected

OpenMedium

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.

apps/store-b/content/blog · redirects.js· manualLAUNCH_READINESS_AUDIT_2026-07-24.md · sbf-blog/sbf-dedup · sbf-john-handoff/_README.md
SBF-6-cdn-env

Confirm CDN env on Vercel + populate store-b bucket

OpenMedium

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.)

assets / CDN· manual
SBF-4-og-image

Default OG/Twitter image 404

OpenMedium

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.

SEO· manual
SBF-9-error-boundary

No error boundary

OpenMedium

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).

ops· manual
SBF-10-mock-content

Mock Gallery / Achievements content

OpenMedium

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.

content· manual
SBF-11-contact-form

Contact form has no backend

On holdMedium

contact-page-content has no submit handler/endpoint (business quote works via api/business-quote).

content / forms· manual
SBF-12-raw-img

Raw <img> throughout -> no image optimization

On holdMedium

no-img-element disabled in 28 files; heavy specialty routes hit ~455 kB First Load. Convert to next/image + code-split.

perf / SEO· manual
SBF-18-missing-pages

Pages in original-sbf with no store-b route and no redirect

OpenMedium

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`.

apps/store-b/src/app· manual
T7b-noacrylic

"No Acrylic" discount glazings never applied

On holdMedium

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.

shared → CFS, SBF, CPFpackages/core pricing SKU path (glassPrice hardcoded 0)· issue-tracker
P-4-missing-tables

orders / sample_credits tables absent

In progressMedium

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.

framecraft-api/db/schema.ts· manual
image-editor-no-aspect-lock

ImageEditor can return crops that stretch at print

Verify pendingMedium

`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.

shared → CFS, SBF, CPFpackages/ui/src/components/shared/ImageEditor.tsx· frame-auditPR #425
storeb-schema-parity

store-b omits Organization + WebSite JSON-LD site-wide

UncommittedMedium

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.

shared → SBFapps/store-b/src/app/layout.tsx· manualLAUNCH_READINESS_AUDIT_2026-07-24.md · seo-core/schema-types
meta-desc-floor

Frame-PDP meta-descriptions have no 120-char floor on flagship + store-b

UncommittedMedium

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.

shared → CPF, SBFapps/flagship + apps/store-b frame PDP metadata · packages/seo buildFrameProductMetaDescription· manualLAUNCH_READINESS_AUDIT_2026-07-24.md · seo-core/meta-descriptions
voice-lint-stores

Voice/brand lint fails on the stores (banned words, em-dashes, exclamations, emoji)

OpenMedium

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.

shared → CFS, SBFapps/store-a + apps/store-b copy · content/**· lintLAUNCH_READINESS_AUDIT_2026-07-24.md · voice-brand
a11y-axe-not-run

axe-core has never been run against the current monorepo build

OpenMedium

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).

shared → CFS, SBF, CPFapps/* production build (a11y verification)· manualCFS_ADA Compliance.pdf · migration-playbook/07-accessibility.md
a11y-dark-contrast

Dark-mode contrast unverified (theme is placeholder)

OpenMedium

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.

shared → CFS, SBF, CPFpackages/config theme tokens (.dark) · brand.config· frame-auditLAUNCH_READINESS_AUDIT_2026-07-24.md · a11y-mobile/a11y-dark-contrast · FL-A03
a11y-lighthouse-homepage

Flagship homepage Lighthouse a11y fixes (axe-verified 0 violations)

UncommittedMedium

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`.

shared → CFS, SBF, CPFpackages/ui (Slider, BrassNameplateSection, FrameDesigner) · apps/flagship home· manualLAUNCH_READINESS_AUDIT_2026-07-24.md · a11y-mobile · Ben's live Lighthouse (a11y 82)
cpf-military-barrel-import

/military-frames imports the @framecraft/ui barrel — crashes under next dev

OpenLow

`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.

apps/store-a/src/app/(specialty)/military-frames/page.tsx· frame-audit
og-samples-oversize

/samples OG image is 1.69MB

OpenLow

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.

apps/flagship/public/assets/og-samples.jpg · samples/page.tsx· manualLAUNCH_READINESS_AUDIT_2026-07-24.md · seo-core/og-image
cfs-hero-render

Hero cross-browser rendering not verifiably closed

OpenLow

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.

homepage Hero· manual
SBF-3-asset-route

api/asset route absent but rewrites point to it

OpenLow

/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.

assets / CDN· manual
SBF-8-stale-docs

Stale/contradictory docs

OpenLow

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.

docs· manual
T2-stamp

Stamp emits non-canonical STAMP-MAT-### code

On holdLow

Code prefix isn't in the canonical DIP/COM/CARD list — confirm the warehouse recognizes it.

shared → CFS, SBF, CPFStampFrameDesigner / cartAttributes· issue-tracker
P-9-branding-base

checkoutBrandingUpsert base script missing

OpenLow

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.

framecraft-pricing/checkout-branding· manual
skeleton-loading-stores

No route-level skeleton (loading.tsx) on store-a or store-b

UncommittedLow

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.

shared → CFS, SBFapps/store-a + apps/store-b src/app/**/loading.tsx· manualLAUNCH_READINESS_AUDIT_2026-07-24.md · cfs-punchlist/skeleton-loading

Decisions

calls someone must make17
D5-consent-wording

D5 — personalization consent wording (counsel)

On holdHigh

IdeaLegal / E. Milesnick must sign off the final ToS/consent wording before the gate can ship. Legal/liability blocker.

legal· manual
reviews-conflation

Two-source review conflation risk

OpenHigh

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)

reviews· manual
metal-brand-truth

Metal frames — do we sell them?

On holdMedium

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.

/metal-picture-frames· manual
launch-hours

Launch-day hours change sequencing

OpenMedium

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).

launch / SEO· manual
D1-reviews-tier

D1 — reviews.io plan tier timing

On holdMedium

Flagship must be on the Plus tier to be the syndication source; currently a lower tier. Decide when to flip (FL-G04).

reviews· manual
D2-rb2b-privacy

D2 — rb2b destination + privacy disclosure

On holdMedium

Sign off the Slack/CRM destination and confirm the pixel is disclosed in /privacy-policy (FL-G05).

integrations· manual
D6-designer-api-placement

D6 — designer APIs: flagship app vs shared service

OpenMedium

Decide which designer-support APIs (styles, uploads, nameplate .lbrn, recommendations) live in the flagship app vs framecraft-api / framecraft-order (FL-F06).

architecture· manual
voice-decisions

D7 — voice normalization decisions (glazing descriptor + lead term)

OpenMedium

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.

voice / brand copy (network-wide)· manualLAUNCH_READINESS_AUDIT_2026-07-24.md · voice-brand · shared voice-lint-stores
cfs-sentry

Sentry not enabled / no alerting

OpenMedium

Optional DSN; monitoring/alerting for cart/pricing/Shopify failures isn't configured.

env.ts / instrumentation.ts· manual
SBF-17-card-designer

CardFrameDesigner is fully ported but store-b mounts no route for it

OpenMedium

`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.

apps/store-b · packages/ui/src/components/specialty/CardFrameDesigner.tsx· manual
comic-puck-basis

Comic/Puck price basis + Sonogram/Puck preview orientation

On holdMedium

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.

shared → CFS, SBF, CPFpackages/core/utils/specialty/{comic,puck,sonogram}-layouts.ts· frame-audit
P-5-consolidate-api

Consolidate framecraft-api into monorepo apps/api

OpenMedium

Scaffolding copied but keep-vs-revert unconfirmed. Alt: keep separate repo + publish @framecraft/db to a registry. Recommended: consolidate.

framecraft-api -> apps/api· manual
D3-magento-export

D3 — Magento SKU export (optional)

OpenLow

Not required, but if provided it upgrades chain/long-tail moulding redirects to 1:1 /frames/<id> (FL-H04/H05).

redirects· manual
D4-glass-configurator

D4 — Glass cut-to-size configurator

OpenLow

Flagged-not-built; net-new, out of migration scope unless prioritized.

designer· manual
b2b-email

biz@ never changed to b2b@ (and live routing uses hello@)

OpenLow

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).

apps/flagship/src/components/content/business-page-content.tsx· manualLAUNCH_READINESS_AUDIT_2026-07-24.md · bugfixes/bug-b2b-email
SBF-14-home-h1

Home H1 lacks the primary keyword

OpenLow

Home H1 "Display Your Story" (brand voice) vs a keyword-rich "Custom Shadow Box Frames." Decide brand voice vs SEO.

SEO· manual
P-11-loyalty-vendor

Loyalty vendor: Smile.io vs Rivo

OpenLow

Pick a provider behind the LoyaltyService abstraction.

accounts Phase 5· manual