@mohasinac/appkit 4.52.0 → 4.52.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/.build-stamp.json +3 -3
  2. package/dist/_internal/client/features/digital-code/CodeRevealPanel.js +4 -4
  3. package/dist/_internal/server/features/products/list-public.js +37 -1
  4. package/dist/features/about/components/PolicyPageView.js +1 -1
  5. package/dist/features/auctions/components/MarketplaceAuctionCard.js +2 -2
  6. package/dist/features/auth/components/SocialAuthButtons.js +1 -1
  7. package/dist/features/categories/components/CategoryBundlesListing.js +1 -1
  8. package/dist/features/categories/components/CategoryDetailPageView.js +8 -7
  9. package/dist/features/categories/constants/category-tabs.d.ts +9 -1
  10. package/dist/features/categories/constants/category-tabs.js +11 -1
  11. package/dist/features/checkout/components/CheckoutStepper.js +1 -1
  12. package/dist/features/faq/components/FAQAccordion.js +1 -1
  13. package/dist/features/filters/FilterFacetSection.js +1 -1
  14. package/dist/features/filters/RangeFilter.js +1 -1
  15. package/dist/features/filters/SwitchFilter.js +1 -1
  16. package/dist/features/homepage/components/BeforeAfterCard.js +2 -2
  17. package/dist/features/homepage/components/CharacterHotspot.js +1 -1
  18. package/dist/features/homepage/components/FAQSection.js +1 -1
  19. package/dist/features/homepage/components/HeroBanner.js +1 -1
  20. package/dist/features/homepage/components/HeroCarousel.js +4 -4
  21. package/dist/features/homepage/components/HowItWorksSection.js +1 -1
  22. package/dist/features/homepage/components/SectionCarousel.js +1 -1
  23. package/dist/features/homepage/components/SecurityHighlightsSection.js +1 -1
  24. package/dist/features/homepage/components/TrustBadges.js +1 -1
  25. package/dist/features/homepage/components/WelcomeSection.js +1 -1
  26. package/dist/features/layout/BottomActions.js +1 -1
  27. package/dist/features/live/components/LiveItemDetailPageView.js +1 -1
  28. package/dist/features/products/hooks/useProducts.js +18 -1
  29. package/dist/features/reviews/components/ReviewDetailShell.js +2 -1
  30. package/dist/features/reviews/components/ReviewSummary.js +1 -1
  31. package/dist/features/tester/seed-data/_happy-path.js +1 -1
  32. package/dist/features/tester/seed-data/authored/admin__catalog-listings.js +1 -1
  33. package/dist/features/tester/seed-data/authored/content-discovery__category-brand-relations.js +3 -3
  34. package/dist/features/tester/seed-data/authored/content-discovery__category-tabs-franchise-bundles.d.ts +2 -0
  35. package/dist/features/tester/seed-data/authored/content-discovery__category-tabs-franchise-bundles.js +152 -0
  36. package/dist/features/tester/seed-data/authored/design-ux__rich-text-readability.d.ts +2 -0
  37. package/dist/features/tester/seed-data/authored/design-ux__rich-text-readability.js +84 -0
  38. package/dist/features/tester/seed-data/authored/index.js +4 -0
  39. package/dist/features/tester/seed-data/authored/public-pages__core-listing-pages.js +2 -2
  40. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +77 -3
  41. package/dist/styles.css +73 -0
  42. package/dist/ui/rich-text/RichText.style.css +73 -0
  43. package/package.json +1 -1
@@ -277,7 +277,7 @@ export default function BottomActions() {
277
277
  isVisible ? "" : "pointer-events-none",
278
278
  ]
279
279
  .filter(Boolean)
280
- .join(" "), children: [isBulkMode && (_jsx(Div, { className: "h-[3px] w-full bg-[image:var(--appkit-gradient-brand-tri)]" })), secondaryLabel && !isBulkMode && (_jsx(Div, { className: "pt-[var(--appkit-space-2)] pb-[var(--appkit-space-0)] border-b border-[var(--appkit-color-border-subtle)]", padding: "x-md", children: _jsx(Text, { className: "leading-5 truncate", color: "muted", size: "xs", weight: "semibold", children: secondaryLabel }) })), infoLabel && !isBulkMode && !hasInfoPanel && (_jsx(Div, { className: "pt-[var(--appkit-space-2)] pb-[var(--appkit-space-0)] border-b border-[var(--appkit-color-border-subtle)]", padding: "x-md", children: _jsx(Text, { className: "leading-5 truncate", color: "muted", size: "xs", weight: "semibold", children: infoLabel }) })), infoLabel && !isBulkMode && hasInfoPanel && (_jsxs(Button, { type: "button", variant: "ghost", onClick: () => setInfoOpen((o) => !o), "aria-expanded": infoOpen, "aria-controls": infoPanelId, className: "w-full flex items-center justify-between gap-[var(--appkit-space-2)] rounded-none border-b border-[var(--appkit-color-border)]/80 px-[var(--appkit-space-4)] pt-[var(--appkit-space-2)] pb-[var(--appkit-space-1)] min-h-0", children: [_jsx(Text, { className: "leading-5 truncate", color: "muted", size: "xs", weight: "semibold", children: infoLabel }), _jsxs(Span, { className: "flex-shrink-0 flex items-center gap-[var(--appkit-space-1)]", children: [_jsx(Text, { as: "span", size: "xs", color: "muted", children: infoOpen ? "Hide" : "Details" }), infoOpen ? (_jsx(ChevronDown, { className: "w-4 h-4 text-zinc-400", "aria-hidden": "true" })) : (_jsx(ChevronUp, { className: "w-4 h-4 text-zinc-400", "aria-hidden": "true" }))] })] })), _jsx(Row, { className: `min-${BOTTOM_NAV_HEIGHT} py-[var(--appkit-space-2)]`, gap: "sm", padding: "x-sm", children: isBulkMode && bulk ? (_jsxs(_Fragment, { children: [_jsxs(Button, { rounded: "full", gap: "xs", type: "button", variant: "ghost", onClick: dispatchBulkClear, className: "inline- flex-shrink-0 bg-primary-50 hover:bg-primary-surface active:bg-primary-200 dark:bg-primary-950/30 text-primary-700 dark:text-primary-300 pl-2 pr-3 h-8 border border-primary-200/70 dark:border-primary-800/50 transition-colors min-h-0", "aria-label": "Clear selection", children: [_jsx(X, { className: "w-3.5 h-3.5 flex-shrink-0", "aria-hidden": "true" }), _jsx(Span, { size: "xs", weight: "semibold", className: "tabular-nums whitespace-nowrap leading-none", children: bulk.noun
280
+ .join(" "), children: [isBulkMode && (_jsx(Div, { className: "h-[3px] w-full bg-[image:var(--appkit-gradient-brand-tri)]" })), secondaryLabel && !isBulkMode && (_jsx(Div, { className: "pt-[var(--appkit-space-2)] pb-[var(--appkit-space-0)] border-b border-[var(--appkit-color-border-subtle)]", padding: "x-md", children: _jsx(Text, { className: "leading-5 truncate", color: "muted", size: "xs", weight: "semibold", children: secondaryLabel }) })), infoLabel && !isBulkMode && !hasInfoPanel && (_jsx(Div, { className: "pt-[var(--appkit-space-2)] pb-[var(--appkit-space-0)] border-b border-[var(--appkit-color-border-subtle)]", padding: "x-md", children: _jsx(Text, { className: "leading-5 truncate", color: "muted", size: "xs", weight: "semibold", children: infoLabel }) })), infoLabel && !isBulkMode && hasInfoPanel && (_jsxs(Button, { type: "button", variant: "ghost", onClick: () => setInfoOpen((o) => !o), "aria-expanded": infoOpen, "aria-controls": infoPanelId, className: "w-full flex items-center justify-between gap-[var(--appkit-space-2)] rounded-none border-b border-[var(--appkit-color-border)]/80 px-[var(--appkit-space-4)] pt-[var(--appkit-space-2)] pb-[var(--appkit-space-1)] min-h-0", children: [_jsx(Text, { className: "leading-5 truncate", color: "muted", size: "xs", weight: "semibold", children: infoLabel }), _jsxs(Span, { className: "flex-shrink-0 flex items-center gap-[var(--appkit-space-1)]", children: [_jsx(Text, { as: "span", size: "xs", color: "muted", children: infoOpen ? "Hide" : "Details" }), infoOpen ? (_jsx(ChevronDown, { className: "w-4 h-4 text-zinc-400", "aria-hidden": "true" })) : (_jsx(ChevronUp, { className: "w-4 h-4 text-zinc-400", "aria-hidden": "true" }))] })] })), _jsx(Row, { className: `min-${BOTTOM_NAV_HEIGHT} py-[var(--appkit-space-2)]`, gap: "sm", padding: "x-sm", children: isBulkMode && bulk ? (_jsxs(_Fragment, { children: [_jsxs(Button, { rounded: "full", gap: "xs", type: "button", variant: "ghost", onClick: dispatchBulkClear, className: "flex-shrink-0 bg-primary-50 hover:bg-primary-surface active:bg-primary-200 dark:bg-primary-950/30 text-primary-700 dark:text-primary-300 pl-2 pr-3 h-8 border border-primary-200/70 dark:border-primary-800/50 transition-colors min-h-0", "aria-label": "Clear selection", children: [_jsx(X, { className: "w-3.5 h-3.5 flex-shrink-0", "aria-hidden": "true" }), _jsx(Span, { size: "xs", weight: "semibold", className: "tabular-nums whitespace-nowrap leading-none", children: bulk.noun
281
281
  ? `${bulk.selectedCount} ${bulk.noun}`
282
282
  : `${bulk.selectedCount} selected` })] }), bulkActions.length > 0 && (_jsxs(Button, { type: "button", variant: "ghost", onClick: () => setPickerOpen((o) => !o), "aria-haspopup": "listbox", "aria-expanded": pickerOpen, className: [
283
283
  "flex-1 min-w-0 h-10 flex items-center gap-[var(--appkit-space-2)] px-[var(--appkit-space-3)] rounded-lg border text-[length:var(--appkit-text-sm)] font-medium transition-colors",
@@ -70,7 +70,7 @@ export async function LiveItemDetailPageView({ slug, initialProduct, renderActio
70
70
  price: price ?? undefined,
71
71
  storeId: typeof p.storeId === "string" ? p.storeId : undefined,
72
72
  storeName: storeName ?? undefined,
73
- } }), _jsxs(Container, { size: "xl", padding: "y-lg", children: [_jsxs(Row, { className: "mb-4", align: "center", justify: "between", gap: "sm", wrap: true, children: [_jsxs(Nav, { "aria-label": "Breadcrumb", layout: "flex-wrap", gap: "2xs", textSize: "xs", color: "muted", children: [_jsx(Link, { href: String(ROUTES.HOME), className: CLS_BREADCRUMB_LINK, children: "Home" }), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.LIVE), className: CLS_BREADCRUMB_LINK, children: "Live" }), category && (_jsxs(_Fragment, { children: [_jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.CATEGORY_DETAIL(category)), className: CLS_BREADCRUMB_LINK, children: categoryName || category })] })), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Span, { className: "truncate max-w-[200px]", color: "muted", children: title })] }), _jsx(ShareButton, { title: title })] }), _jsx(PreOrderDetailView, { renderGallery: () => _jsx(ProductGalleryClient, { images: images, video: productVideo, productName: title }), renderInfo: () => (_jsxs(Stack, { gap: "md", children: [_jsxs(Div, { children: [_jsxs(Row, { gap: "xs", wrap: true, className: "mb-2", children: [_jsx(Span, { size: "xs", weight: "semibold", className: CLS_LIVE_ITEM_BADGE, children: "Live Item" }), meta?.vendorVerified && (_jsx(Span, { color: "success", surface: "success-surface", size: "xs", weight: "medium", className: "inline-block", padding: "pill-sm", rounded: "full", children: "Verified Seller" })), meta?.cites && (_jsxs(Span, { color: "warning", surface: "warning-surface", size: "xs", weight: "medium", className: "inline-block", padding: "pill-sm", rounded: "full", children: ["CITES: ", meta.cites] }))] }), _jsx(Heading, { level: 1, className: "leading-snug", smSize: "2xl", color: "primary", size: "xl", weight: "bold", children: title }), meta?.species && (_jsxs(Text, { className: "italic mt-1", color: "muted", size: "sm", children: [meta.species, meta.sex && meta.sex !== "n/a" && ` · ${meta.sex}`, meta.ageMonths !== undefined && ` · ${meta.ageMonths}mo`] }))] }), price !== null && (_jsx(Text, { size: "2xl", weight: "bold", color: "primary", children: _jsx(GatedPrice, { children: formatCurrency(price, currency) }) })), jurisdictions.length > 0 && (_jsxs(Div, { className: CLS_WARN_BOX, children: [_jsx(Text, { className: CLS_WARN_TITLE, children: "Delivery restrictions" }), _jsxs(Text, { className: CLS_WARN_BODY, children: ["This item can only be shipped to: ", jurisdictions.join(", ")] })] })), transport && (_jsxs(Div, { textSize: "sm", border: "subtle", surface: "muted", padding: "md", rounded: "lg", children: [_jsx(Text, { weight: "medium", children: "Transport" }), _jsxs(Text, { className: "text-muted-foreground", children: ["Method: ", transport.method, transport.handlingFee !== undefined && (_jsx(PricesOnly, { children: ` · Handling: ${formatCurrency(transport.handlingFee, "INR")}` })), transport.insuranceIncluded && " · Insurance included"] })] })), meta?.careInfo && (_jsxs(Div, { textSize: "sm", border: "subtle", surface: "muted", padding: "md", rounded: "lg", children: [_jsx(Text, { weight: "medium", children: "Care information" }), _jsx(Text, { className: "mt-1 text-muted-foreground", children: meta.careInfo })] })), descriptionHtml && (_jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm max-w-none dark:prose-invert prose-p:my-0", className: "text-[length:var(--appkit-text-sm)] leading-relaxed text-[var(--appkit-color-text-muted)] line-clamp-4" })), safeSeller && (_jsx(Div, { border: "subtle", rounded: "xl", surface: "muted", padding: "md", children: _jsxs(Row, { justify: "between", align: "center", children: [_jsxs(Div, { children: [_jsx(Text, { className: "text-[10px] tracking-wide mb-0.5", color: "faint", transform: "uppercase", children: "Sold by" }), _jsx(Text, { size: "sm", weight: "semibold", color: "primary", children: safeSeller })] }), storeHref && (_jsx(Link, { href: storeHref, className: "shrink-0 rounded-lg bg-primary/10 dark:bg-primary/20 px-[var(--appkit-space-3)] py-[var(--appkit-space-1-5)] text-[length:var(--appkit-text-xs)] font-semibold text-primary-700 dark:text-primary-300 hover:bg-primary/20 dark:hover:bg-primary/30 transition-colors", children: "Visit Store \u2192" }))] }) }))] })), renderTabs: () => (_jsx(ProductTabsShell, { descriptionContent: descriptionHtml ? (_jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm sm:prose max-w-none dark:prose-invert", className: "text-[var(--appkit-color-text-muted)]" })) : undefined, specsContent: specs.length > 0 ? (_jsx(Dl, { divide: "subtle", rounded: "xl", border: "subtle", className: "overflow-hidden", children: specs.map((s, i) => (_jsxs(Row, { gap: "md", oddEven: "zebra", surface: "default", padding: "inline", children: [_jsx(Dt, { className: "w-36 flex-shrink-0", color: "primary", weight: "medium", children: s.name }), _jsxs(Dd, { className: "flex-1", color: "muted", children: [s.value, s.unit ? ` ${s.unit}` : ""] })] }, i))) })) : undefined, reviewsContent: _jsx(ReviewsListingPanel, { source: { kind: "product", productId: product.id }, stateMode: "local", context: "listing", initialData: initialReviews, emptyLabel: "No reviews yet \u2014 be the first to review this product." }), customTabs: customSections.map((s) => ({
73
+ } }), _jsxs(Container, { size: "xl", padding: "y-lg", children: [_jsxs(Row, { className: "mb-4", align: "center", justify: "between", gap: "sm", wrap: true, children: [_jsxs(Nav, { "aria-label": "Breadcrumb", layout: "flex-wrap", gap: "2xs", textSize: "xs", color: "muted", children: [_jsx(Link, { href: String(ROUTES.HOME), className: CLS_BREADCRUMB_LINK, children: "Home" }), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.LIVE), className: CLS_BREADCRUMB_LINK, children: "Live" }), category && (_jsxs(_Fragment, { children: [_jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.CATEGORY_DETAIL(category)), className: CLS_BREADCRUMB_LINK, children: categoryName || category })] })), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Span, { className: "truncate max-w-[200px]", color: "muted", children: title })] }), _jsx(ShareButton, { title: title })] }), _jsx(PreOrderDetailView, { renderGallery: () => _jsx(ProductGalleryClient, { images: images, video: productVideo, productName: title }), renderInfo: () => (_jsxs(Stack, { gap: "md", children: [_jsxs(Div, { children: [_jsxs(Row, { gap: "xs", wrap: true, className: "mb-2", children: [_jsx(Span, { size: "xs", weight: "semibold", className: CLS_LIVE_ITEM_BADGE, children: "Live Item" }), meta?.vendorVerified && (_jsx(Span, { color: "success", surface: "success-surface", size: "xs", weight: "medium", className: "inline-block", padding: "pill-sm", rounded: "full", children: "Verified Seller" })), meta?.cites && (_jsxs(Span, { color: "warning", surface: "warning-surface", size: "xs", weight: "medium", className: "inline-block", padding: "pill-sm", rounded: "full", children: ["CITES: ", meta.cites] }))] }), _jsx(Heading, { level: 1, className: "leading-snug", smSize: "2xl", color: "primary", size: "xl", weight: "bold", children: title }), meta?.species && (_jsxs(Text, { className: "italic mt-1", color: "muted", size: "sm", children: [meta.species, meta.sex && meta.sex !== "n/a" && ` · ${meta.sex}`, meta.ageMonths !== undefined && ` · ${meta.ageMonths}mo`] }))] }), price !== null && (_jsx(Text, { size: "2xl", weight: "bold", color: "primary", children: _jsx(GatedPrice, { children: formatCurrency(price, currency) }) })), jurisdictions.length > 0 && (_jsxs(Div, { className: CLS_WARN_BOX, children: [_jsx(Text, { className: CLS_WARN_TITLE, children: "Delivery restrictions" }), _jsxs(Text, { className: CLS_WARN_BODY, children: ["This item can only be shipped to: ", jurisdictions.join(", ")] })] })), transport && (_jsxs(Div, { textSize: "sm", border: "subtle", surface: "muted", padding: "md", rounded: "lg", children: [_jsx(Text, { weight: "medium", children: "Transport" }), _jsxs(Text, { className: "text-[var(--appkit-color-text-muted)]", children: ["Method: ", transport.method, transport.handlingFee !== undefined && (_jsx(PricesOnly, { children: ` · Handling: ${formatCurrency(transport.handlingFee, "INR")}` })), transport.insuranceIncluded && " · Insurance included"] })] })), meta?.careInfo && (_jsxs(Div, { textSize: "sm", border: "subtle", surface: "muted", padding: "md", rounded: "lg", children: [_jsx(Text, { weight: "medium", children: "Care information" }), _jsx(Text, { className: "mt-1 text-[var(--appkit-color-text-muted)]", children: meta.careInfo })] })), descriptionHtml && (_jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm max-w-none dark:prose-invert prose-p:my-0", className: "text-[length:var(--appkit-text-sm)] leading-relaxed text-[var(--appkit-color-text-muted)] line-clamp-4" })), safeSeller && (_jsx(Div, { border: "subtle", rounded: "xl", surface: "muted", padding: "md", children: _jsxs(Row, { justify: "between", align: "center", children: [_jsxs(Div, { children: [_jsx(Text, { className: "text-[10px] tracking-wide mb-0.5", color: "faint", transform: "uppercase", children: "Sold by" }), _jsx(Text, { size: "sm", weight: "semibold", color: "primary", children: safeSeller })] }), storeHref && (_jsx(Link, { href: storeHref, className: "shrink-0 rounded-lg bg-primary/10 dark:bg-primary/20 px-[var(--appkit-space-3)] py-[var(--appkit-space-1-5)] text-[length:var(--appkit-text-xs)] font-semibold text-primary-700 dark:text-primary-300 hover:bg-primary/20 dark:hover:bg-primary/30 transition-colors", children: "Visit Store \u2192" }))] }) }))] })), renderTabs: () => (_jsx(ProductTabsShell, { descriptionContent: descriptionHtml ? (_jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm sm:prose max-w-none dark:prose-invert", className: "text-[var(--appkit-color-text-muted)]" })) : undefined, specsContent: specs.length > 0 ? (_jsx(Dl, { divide: "subtle", rounded: "xl", border: "subtle", className: "overflow-hidden", children: specs.map((s, i) => (_jsxs(Row, { gap: "md", oddEven: "zebra", surface: "default", padding: "inline", children: [_jsx(Dt, { className: "w-36 flex-shrink-0", color: "primary", weight: "medium", children: s.name }), _jsxs(Dd, { className: "flex-1", color: "muted", children: [s.value, s.unit ? ` ${s.unit}` : ""] })] }, i))) })) : undefined, reviewsContent: _jsx(ReviewsListingPanel, { source: { kind: "product", productId: product.id }, stateMode: "local", context: "listing", initialData: initialReviews, emptyLabel: "No reviews yet \u2014 be the first to review this product." }), customTabs: customSections.map((s) => ({
74
74
  id: s.id,
75
75
  label: s.title,
76
76
  content: _jsx(CustomSectionTabContent, { section: s }),
@@ -6,6 +6,7 @@ import { useToast } from "../../../ui/index.js";
6
6
  import { useOptionalSession } from "../../../react/contexts/SessionContext.js";
7
7
  import { isAdminUser, isTesterUser } from "../../auth/role-predicates.js";
8
8
  import { PRODUCT_ENDPOINTS } from "../../../constants/api-endpoints.js";
9
+ import { LISTING_PARAM_NAMES } from "../../../utils/listing-params.js";
9
10
  export function useProducts(params = {}, opts) {
10
11
  const { showToast } = useToast();
11
12
  const sp = new URLSearchParams();
@@ -36,8 +37,24 @@ export function useProducts(params = {}, opts) {
36
37
  sp.set("listingType", params.listingType);
37
38
  if (params.storeId)
38
39
  sp.set("storeId", params.storeId);
40
+ /*
41
+ * 🛑 THE SHARED CONSTANT, not a hand-written string. This read
42
+ * `sp.set("sorts", …)` while `parsePublicProductParams` read
43
+ * `TABLE_KEYS.SORT` (= "sort") — two spellings of one concept, neither
44
+ * derived from the other, so they could not be kept in step by anything.
45
+ * Every client-side fetch therefore arrived with a sort the server could not
46
+ * see and fell back to `-createdAt`, while the dropdown displayed the sort
47
+ * the user had picked. Measured: `?sorts=auctionEndDate` returned byte-identical
48
+ * row order to sending no sort at all.
49
+ *
50
+ * "We have shared constants and it still broke" is exactly this: the
51
+ * constants existed, and this call site did not use them. The server now
52
+ * also accepts all three spellings (`s` / `sorts` / `sort`) the way every
53
+ * sibling listing route already did, so the two halves are belt-and-braces —
54
+ * but the literal is what made the gap possible and it is gone.
55
+ */
39
56
  if (params.sort)
40
- sp.set("sorts", params.sort);
57
+ sp.set(LISTING_PARAM_NAMES.sorts.long, params.sort);
41
58
  if (params.page)
42
59
  sp.set("page", String(params.page));
43
60
  if (params.perPage)
@@ -20,6 +20,7 @@ const CLS_ICON_PURPLE_BARE = "flex h-10 w-10 flex-shrink-0 items-center justify-
20
20
  import Link from "next/link.js";
21
21
  import { Button, Div, Grid, Heading, IconButton, RichText, Row, Section, Span, StarRating, Stack, Text } from "../../../ui/index.js";
22
22
  import { MediaImage } from "../../media/MediaImage.js";
23
+ import { MediaVideo } from "../../media/MediaVideo.js";
23
24
  import { maskName } from "../../../security/index.js";
24
25
  import { getDefaultLocale } from "../../../core/baseline-resolver.js";
25
26
  import { normalizeRichTextHtml } from "../../../utils/string.formatter.js";
@@ -101,7 +102,7 @@ export function ReviewDetailShell({ review, storeHref }) {
101
102
  ? String(ROUTES.PUBLIC.PROFILE(reviewerProfileId))
102
103
  : null;
103
104
  const currentImage = lightboxIdx !== null ? images[lightboxIdx] : null;
104
- return (_jsxs(_Fragment, { children: [_jsx(Div, { surface: "default", className: "border-b border-neutral-200 pt-[2.5rem]", padding: "b-xl", children: _jsxs(Div, { className: "mx-auto max-w-3xl", padding: "x-md", children: [_jsxs(Row, { gap: "sm", className: "mb-4", children: [_jsx(StarRating, { value: review.rating, size: "lg", readOnly: true }), _jsxs(Span, { color: "inverse", weight: "bold", className: "text-[var(--appkit-color-text)] dark:", size: "2xl", children: [review.rating, ".0"] }), review.verified && (_jsx(Span, { layout: "inline-flex", gap: "xs", color: "success", surface: "success-surface", size: "xs", weight: "semibold", rounded: "full", padding: "pill-md", children: "\u2713 Verified Purchase" })), review.featured && (_jsx(Span, { size: "xs", weight: "semibold", className: CLS_RATING_PILL, children: "\u2605 Featured" }))] }), review.title && (_jsx(Heading, { color: "inverse", level: 1, className: "text-[var(--appkit-color-text)] dark: mb-4 leading-snug", size: "2xl", weight: "bold", children: review.title })), _jsxs(Row, { gap: "sm", children: [review.userAvatar ? (_jsx(Div, { className: "relative h-11 w-11 flex-shrink-0 ring-2 ring-white ring-[var(--appkit-color-border-subtle)]", rounded: "full", overflow: "hidden", children: _jsx(MediaImage, { src: review.userAvatar, alt: displayName, size: "avatar" }) })) : (_jsx(Row, { textWeight: "bold", textSize: "base", centered: true, className: "h-11 w-11 flex-shrink-0 bg-primary/10 text-primary ring-2 ring-white ring-[var(--appkit-color-border-subtle)]", rounded: "full", children: initials })), _jsxs(Div, { className: "min-w-0", children: [reviewerHref ? (_jsx(Link, { href: reviewerHref, className: "text-[length:var(--appkit-text-sm)] font-semibold text-[var(--appkit-color-text)] dark:text-white hover:text-primary transition-colors", children: displayName })) : (_jsx(Span, { color: "inverse", size: "sm", weight: "semibold", className: "text-[var(--appkit-color-text)] dark:", children: review.isAnonymous ? "Anonymous" : displayName })), date && (_jsx(Text, { size: "xs", color: "muted", className: "mt-0.5", children: date }))] })] })] }) }), _jsxs(Stack, { gap: "xl", className: "mx-auto max-w-3xl", paddingY: "y-xl", paddingX: "x-md", children: [review.comment && (_jsx(Section, { children: _jsx(RichText, { html: normalizeRichTextHtml(review.comment), proseClass: "prose prose-neutral dark:prose-invert max-w-none prose-p:leading-relaxed prose-headings:font-semibold prose-img:rounded-lg prose-a:text-primary", className: "text-[var(--appkit-color-text-muted)]" }) })), images.length > 0 && (_jsxs(Section, { children: [_jsxs(Heading, { level: 2, className: "tracking-wide mb-3", color: "faint", size: "sm", weight: "semibold", transform: "uppercase", children: ["Photos (", images.length, ")"] }), _jsx(Grid, { gap: "xs", className: "grid-cols-3 sm:grid-cols-4", children: images.map((img, i) => (_jsxs(Button, { variant: "ghost", type: "button", onClick: () => setLightboxIdx(i), "aria-label": `View photo ${i + 1}`, rounded: "xl", paddingX: "none", paddingY: "none", className: "group relative aspect-square overflow-hidden border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface-elevated)] focus-visible:ring-2 focus-visible:ring-primary", children: [_jsx(MediaImage, { src: img, alt: `Review image ${i + 1}`, size: "card", className: "transition-transform duration-300 group-hover:scale-105" }), _jsx(Row, { centered: true, className: "absolute inset-0 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity bg-[rgba(0,0,0,0.3)]", children: _jsx(Span, { color: "inverse", size: "xl", children: "\uD83D\uDD0D" }) })] }, i))) })] })), review.video && (_jsxs(Section, { children: [_jsx(Heading, { level: 2, className: "tracking-wide mb-3", color: "faint", size: "sm", weight: "semibold", transform: "uppercase", children: "Video" }), _jsx(Div, { className: `${__O.hidden} border border-[var(--appkit-color-border)] bg-[rgb(0,0,0)] aspect-video`, rounded: "xl", children: _jsx("video", { src: review.video.url, poster: review.video.thumbnailUrl, controls: true, className: "h-full w-full", preload: "metadata" }) })] })), _jsxs(Section, { className: "flex items-[center] gap-[1rem] border-t border-neutral-100", padding: "y-md", children: [_jsx(Div, { textSize: "sm", className: "text-[var(--appkit-color-text-muted)]", children: helpfulCount > 0 && (_jsxs(Span, { children: [_jsx(Span, { color: "inverse", weight: "bold", className: "text-[var(--appkit-color-text)] dark:", children: helpfulCount }), " ", helpfulCount === 1 ? "person" : "people", " found this helpful"] })) }), _jsxs(Button, { variant: "outline", type: "button", onClick: handleVote, disabled: voted || voting, gap: "sm", rounded: "lg", paddingX: "md", paddingY: "sm", textSize: "sm", weight: "medium", className: `ml-auto transition-colors ${voted
105
+ return (_jsxs(_Fragment, { children: [_jsx(Div, { surface: "default", className: "border-b border-neutral-200 pt-[2.5rem]", padding: "b-xl", children: _jsxs(Div, { className: "mx-auto max-w-3xl", padding: "x-md", children: [_jsxs(Row, { gap: "sm", className: "mb-4", children: [_jsx(StarRating, { value: review.rating, size: "lg", readOnly: true }), _jsxs(Span, { color: "inverse", weight: "bold", className: "text-[var(--appkit-color-text)] dark:", size: "2xl", children: [review.rating, ".0"] }), review.verified && (_jsx(Span, { layout: "inline-flex", gap: "xs", color: "success", surface: "success-surface", size: "xs", weight: "semibold", rounded: "full", padding: "pill-md", children: "\u2713 Verified Purchase" })), review.featured && (_jsx(Span, { size: "xs", weight: "semibold", className: CLS_RATING_PILL, children: "\u2605 Featured" }))] }), review.title && (_jsx(Heading, { color: "inverse", level: 1, className: "text-[var(--appkit-color-text)] dark: mb-4 leading-snug", size: "2xl", weight: "bold", children: review.title })), _jsxs(Row, { gap: "sm", children: [review.userAvatar ? (_jsx(Div, { className: "relative h-11 w-11 flex-shrink-0 ring-2 ring-white ring-[var(--appkit-color-border-subtle)]", rounded: "full", overflow: "hidden", children: _jsx(MediaImage, { src: review.userAvatar, alt: displayName, size: "avatar" }) })) : (_jsx(Row, { textWeight: "bold", textSize: "base", centered: true, className: "h-11 w-11 flex-shrink-0 bg-primary/10 text-primary ring-2 ring-white ring-[var(--appkit-color-border-subtle)]", rounded: "full", children: initials })), _jsxs(Div, { className: "min-w-0", children: [reviewerHref ? (_jsx(Link, { href: reviewerHref, className: "text-[length:var(--appkit-text-sm)] font-semibold text-[var(--appkit-color-text)] dark:text-white hover:text-primary transition-colors", children: displayName })) : (_jsx(Span, { color: "inverse", size: "sm", weight: "semibold", className: "text-[var(--appkit-color-text)] dark:", children: review.isAnonymous ? "Anonymous" : displayName })), date && (_jsx(Text, { size: "xs", color: "muted", className: "mt-0.5", children: date }))] })] })] }) }), _jsxs(Stack, { gap: "xl", className: "mx-auto max-w-3xl", paddingY: "y-xl", paddingX: "x-md", children: [review.comment && (_jsx(Section, { children: _jsx(RichText, { html: normalizeRichTextHtml(review.comment), proseClass: "prose prose-neutral dark:prose-invert max-w-none prose-p:leading-relaxed prose-headings:font-semibold prose-img:rounded-lg prose-a:text-primary", className: "text-[var(--appkit-color-text-muted)]" }) })), images.length > 0 && (_jsxs(Section, { children: [_jsxs(Heading, { level: 2, className: "tracking-wide mb-3", color: "faint", size: "sm", weight: "semibold", transform: "uppercase", children: ["Photos (", images.length, ")"] }), _jsx(Grid, { gap: "xs", className: "grid-cols-3 sm:grid-cols-4", children: images.map((img, i) => (_jsxs(Button, { variant: "ghost", type: "button", onClick: () => setLightboxIdx(i), "aria-label": `View photo ${i + 1}`, rounded: "xl", paddingX: "none", paddingY: "none", className: "group relative aspect-square overflow-hidden border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface-elevated)] focus-visible:ring-2 focus-visible:ring-primary", children: [_jsx(MediaImage, { src: img, alt: `Review image ${i + 1}`, size: "card", className: "transition-transform duration-300 group-hover:scale-105" }), _jsx(Row, { centered: true, className: "absolute inset-0 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity bg-[rgba(0,0,0,0.3)]", children: _jsx(Span, { color: "inverse", size: "xl", children: "\uD83D\uDD0D" }) })] }, i))) })] })), review.video && (_jsxs(Section, { children: [_jsx(Heading, { level: 2, className: "tracking-wide mb-3", color: "faint", size: "sm", weight: "semibold", transform: "uppercase", children: "Video" }), _jsx(Div, { className: `${__O.hidden} border border-[var(--appkit-color-border)] bg-[rgb(0,0,0)] aspect-video relative`, rounded: "xl", children: _jsx(MediaVideo, { src: review.video.url, thumbnailUrl: review.video.thumbnailUrl, alt: "Review video", objectFit: "contain" }) })] })), _jsxs(Section, { className: "flex items-center gap-[1rem] border-t border-neutral-100", padding: "y-md", children: [_jsx(Div, { textSize: "sm", className: "text-[var(--appkit-color-text-muted)]", children: helpfulCount > 0 && (_jsxs(Span, { children: [_jsx(Span, { color: "inverse", weight: "bold", className: "text-[var(--appkit-color-text)] dark:", children: helpfulCount }), " ", helpfulCount === 1 ? "person" : "people", " found this helpful"] })) }), _jsxs(Button, { variant: "outline", type: "button", onClick: handleVote, disabled: voted || voting, gap: "sm", rounded: "lg", paddingX: "md", paddingY: "sm", textSize: "sm", weight: "medium", className: `ml-auto transition-colors ${voted
105
106
  ? CLS_HELPFUL_ACTIVE
106
107
  : "border-[var(--appkit-color-border)] text-[var(--appkit-color-text-muted)] hover:border-primary hover:text-primary dark:hover:border-primary dark:hover:text-primary disabled:opacity-50"}`, children: [_jsx(Span, { "aria-hidden": "true", children: voted ? "✓" : "👍" }), voted ? "Marked helpful" : voting ? "Saving…" : "Helpful?"] })] }), _jsxs(Section, { className: "grid gap-[0.75rem] sm:grid-cols-3", children: [productHref && (_jsxs(Link, { href: productHref, className: CLS_RELATED_LINK, children: [_jsx(Span, { className: CLS_ICON_ORANGE, children: "\uD83D\uDCE6" }), _jsxs(Div, { className: "min-w-0", children: [_jsx(Text, { className: CLS_RELATED_LABEL, children: "Product" }), _jsx(Text, { className: CLS_RELATED_TITLE, children: review.productTitle ?? "View Product" })] })] })), sellerHref && (_jsxs(Link, { href: sellerHref, className: CLS_RELATED_LINK, children: [_jsx(Span, { className: CLS_ICON_BLUE, children: "\uD83C\uDFEA" }), _jsxs(Div, { className: "min-w-0", children: [_jsx(Text, { className: CLS_RELATED_LABEL, children: "Seller" }), _jsx(Text, { className: CLS_RELATED_TITLE, children: "View Seller" })] })] })), reviewerHref ? (_jsxs(Link, { href: reviewerHref, className: CLS_RELATED_LINK, children: [_jsx(Span, { className: CLS_ICON_PURPLE, children: "\uD83D\uDC64" }), _jsxs(Div, { className: "min-w-0", children: [_jsx(Text, { className: CLS_RELATED_LABEL, children: "Reviewer" }), _jsx(Text, { className: CLS_RELATED_TITLE, children: displayName })] })] })) : (_jsxs(Row, { surface: "default", gap: "sm", className: `border border-neutral-200 dark:border-[var(--appkit-color-border)] ${__P.p4}`, rounded: "xl", children: [_jsx(Span, { size: "xl", className: CLS_ICON_PURPLE_BARE, children: "\uD83D\uDC64" }), _jsxs(Div, { className: "min-w-0", children: [_jsx(Span, { size: "xs", className: "block mb-0.5", color: "faint", children: "Reviewer" }), _jsx(Span, { color: "inverse", size: "sm", weight: "medium", className: "block text-[var(--appkit-color-text)] dark: truncate", children: "Anonymous" })] })] }))] })] }), lightboxIdx !== null && currentImage && (_jsxs(Row, { centered: true, className: "fixed inset-0 z-50 bg-[rgba(0,0,0,0.95)]", onClick: closeLightbox, role: "dialog", "aria-modal": "true", "aria-label": "Image lightbox", children: [_jsx(IconButton, { type: "button", onClick: closeLightbox, "aria-label": "Close lightbox", variant: "scrim", rounded: "full", className: "absolute top-4 right-4 z-10 h-10 w-10", children: _jsx(Span, { size: "xl", children: "\u00D7" }) }), _jsxs(Div, { textSize: "sm", className: "absolute top-4 left-1/2 -translate-x-1/2 text-white/70", children: [lightboxIdx + 1, " / ", images.length] }), images.length > 1 && (_jsx(IconButton, { type: "button", onClick: (e) => { e.stopPropagation(); prevImage(); }, "aria-label": "Previous image", variant: "scrim", rounded: "full", className: "absolute left-4 top-1/2 -translate-y-1/2 z-10 h-12 w-12", children: _jsx(Span, { size: "2xl", children: "\u2039" }) })), _jsx(Row, { centered: true, className: "relative h-[85vh] w-[85vw]", onClick: (e) => e.stopPropagation(), children: _jsx(MediaImage, { src: currentImage, alt: `Review photo ${lightboxIdx + 1}`, size: "hero", objectFit: "contain", rounded: "lg", shadow: "2xl" }) }), images.length > 1 && (_jsx(IconButton, { type: "button", onClick: (e) => { e.stopPropagation(); nextImage(); }, "aria-label": "Next image", variant: "scrim", rounded: "full", className: "absolute right-4 top-1/2 -translate-y-1/2 z-10 h-12 w-12", children: _jsx(Span, { size: "2xl", children: "\u203A" }) })), images.length > 1 && (_jsx(Row, { justify: "center", gap: "xs", className: "absolute bottom-4 left-0 right-0", padding: "x-md", children: images.map((img, i) => (_jsx(Button, { variant: "ghost", type: "button", onClick: (e) => { e.stopPropagation(); setLightboxIdx(i); }, "aria-label": `Go to image ${i + 1}`, rounded: "md", paddingX: "none", paddingY: "none", className: `h-12 w-12 flex-shrink-0 overflow-hidden border-2 transition-all ${i === lightboxIdx
107
108
  ? "border-white scale-110"
@@ -14,7 +14,7 @@ const CLS_BAR = "h-full rounded-full bg-warning-surface transition-all duration-
14
14
  */
15
15
  export function ReviewSummary({ averageRating, totalReviews, distribution, className = "", }) {
16
16
  const stars = [5, 4, 3, 2, 1];
17
- return (_jsxs(Stack, { align: "center", className: `sm:[flex-direction:row] sm: sm:gap-[2rem] ${className}`, gap: "md", children: [_jsxs(Stack, { className: "sm:items-[flex-start]", align: "center", gap: "xs", children: [_jsx(Span, { color: "inverse", weight: "bold", className: "leading-none text-[var(--appkit-color-text)] dark:", size: "5xl", children: averageRating.toFixed(1) }), _jsx(StarRating, { value: averageRating, size: "md", readOnly: true }), _jsxs(Text, { size: "sm", className: "text-[var(--appkit-color-text-muted)]", children: [totalReviews.toLocaleString(), " review", totalReviews !== 1 ? "s" : ""] })] }), _jsx(Stack, { className: "flex-1", gap: "sm", children: stars.map((star) => {
17
+ return (_jsxs(Stack, { align: "center", className: `sm:[flex-direction:row] sm: sm:gap-[2rem] ${className}`, gap: "md", children: [_jsxs(Stack, { className: /* audit-variant-ok: RESPONSIVE alignment override; `align` is base-breakpoint only */ "sm:items-start", align: "center", gap: "xs", children: [_jsx(Span, { color: "inverse", weight: "bold", className: "leading-none text-[var(--appkit-color-text)] dark:", size: "5xl", children: averageRating.toFixed(1) }), _jsx(StarRating, { value: averageRating, size: "md", readOnly: true }), _jsxs(Text, { size: "sm", className: "text-[var(--appkit-color-text-muted)]", children: [totalReviews.toLocaleString(), " review", totalReviews !== 1 ? "s" : ""] })] }), _jsx(Stack, { className: "flex-1", gap: "sm", children: stars.map((star) => {
18
18
  const count = distribution[star] ?? 0;
19
19
  const pct = totalReviews > 0 ? Math.round((count / totalReviews) * 100) : 0;
20
20
  return (_jsxs(Row, { align: "center", gap: "sm", children: [_jsx(Span, { size: "xs", weight: "medium", className: "w-4 text-[var(--appkit-color-text-muted)] tabular-nums", align: "end", children: star }), _jsx(Span, { size: "xs", className: CLS_STAR, children: "\u2605" }), _jsx(Div, { className: "flex-1 h-2 bg-[var(--appkit-color-surface)] overflow-hidden", rounded: "full", role: "progressbar", "aria-valuenow": pct, "aria-valuemin": 0, "aria-valuemax": 100, "aria-label": `${star} star: ${pct}%`, children: _jsx(ProgressBarFill, { pct: pct, className: CLS_BAR }) }), _jsxs(Span, { size: "xs", className: "w-8 tabular-nums", color: "faint", align: "end", children: [pct, "%"] })] }, star));
@@ -67,7 +67,7 @@ export const happyPathPages = [
67
67
  "Wait for the grid to render.",
68
68
  "Read every category name shown at the top level.",
69
69
  ],
70
- expectedBehaviour: "The index lists the tier-0 roots of the category forest, which are Spinning Tops and Living Collectibles.",
70
+ expectedBehaviour: "The index lists the tier-0 roots of the category forest — SIX of them: Spinning Tops, Trading Cards, Collectible Figures, Hot Wheels, Model Kits and Living Collectibles. It lists ONLY roots: the forest is 1,185 rows and the index used to render all of them flat.",
71
71
  expectedUiState: "Both 'Spinning Tops' and 'Living Collectibles' are visible as cards or links. The page is not empty and does not render only leaf categories.",
72
72
  expectedData: { rootCategoriesVisible: 2 },
73
73
  endResult: "Nothing is written; reloading shows the same roots.",
@@ -51,7 +51,7 @@ export const authored = {
51
51
  startPage: "/admin/categories",
52
52
  steps: [
53
53
  "Sign in as admin@letitrip.in / TempPass123!.",
54
- "Open /admin/categories and read the tree, noting the two roots and how depth is shown.",
54
+ "Open /admin/categories and read the tree, noting the six roots and how depth is shown.",
55
55
  "Create a child category named 'QA Category admin-crud' under category-beyblade-burst.",
56
56
  "Save, RELOAD, and read its parent, its tier and its position in the tree.",
57
57
  "Open /categories in a private window and find it beneath its parent.",
@@ -44,14 +44,14 @@ export const authored = {
44
44
  startPage: "/categories",
45
45
  steps: [
46
46
  "Open /categories and read every top-level category shown.",
47
- "Check both Spinning Tops and Living Collectibles are present.",
47
+ "Check all six roots are present: Spinning Tops, Trading Cards, Collectible Figures, Hot Wheels, Model Kits, Living Collectibles.",
48
48
  "Click Living Collectibles and read its children.",
49
49
  "Click down to a leaf under it.",
50
50
  "Check the leaf lists at least one live-item listing.",
51
51
  ],
52
52
  inputs: { rootA: "category-spinning-tops", rootB: "category-living-collectibles" },
53
- expectedBehaviour: "Both roots are on the index. The tree had one root and two tiers before it was rebuilt, so the live-item listings carried no category at all and were unreachable from every category and brand page — a whole listing type invisible to browsing while its detail pages worked fine.",
54
- expectedUiState: "Two roots on the index, and the Living Collectibles branch walks down to a leaf holding a live listing. One root, or a second root that leads nowhere, is the finding.",
53
+ expectedBehaviour: "All SIX roots are on the index. The tree had one root and two tiers before it was rebuilt, so the live-item listings carried no category at all and were unreachable from every category and brand page — a whole listing type invisible to browsing while its detail pages worked fine. Living Collectibles is the one to walk, because it is the root whose leaves hold the live-item listings.",
54
+ expectedUiState: "Six roots on the index, and the Living Collectibles branch walks down to a leaf holding a live listing. A missing root, or a root that leads nowhere, is the finding.",
55
55
  endResult: "Read-only.",
56
56
  },
57
57
  "checklist-content-discovery-category-brand-relations-root-page-shows-descendant-products": {
@@ -0,0 +1,2 @@
1
+ import type { AuthoredCase } from "./_types";
2
+ export declare const authored: Record<string, AuthoredCase>;
@@ -0,0 +1,152 @@
1
+ /*
2
+ * WHY: Authored six-part procedures for the categories-index tabs, the
3
+ * franchise unit and the bundle collage (2026-10-14).
4
+ * WHAT: 8 cases, keyed by full checklist id.
5
+ *
6
+ * 🛑 EVERY CASE IS BEFORE → AFTER, because every defect this work produced
7
+ * rendered a page that looked fine:
8
+ *
9
+ * 1. The index rendered all 1,185 rows flat — six roots buried under 752
10
+ * part leaves. A case reading "the categories page loads" passes against
11
+ * that.
12
+ * 2. The Parts tab first returned 772, catching the 20 classification NODES
13
+ * as well as the leaves, because their own `parentIds` also pass through
14
+ * a `*-parts` node. A list mixing "Attack Rings" with attack rings is not
15
+ * visibly wrong.
16
+ * 3. Seven franchises have no stock. Active, each is a card opening an empty
17
+ * shop; absent, seventeen `CharacterHotspot` links stay 404. Both look
18
+ * correct from any single page — which is why two cases here check the
19
+ * index and the detail page SEPARATELY.
20
+ * 4. Every bundle also carries a placeholder cover, so a collage tested
21
+ * after the cover never renders — and one grey tile is not "broken".
22
+ *
23
+ * So each case counts an exact number or compares two surfaces. Several carry
24
+ * a nonsense control, because a filter that returns everything looks identical
25
+ * to a filter that works.
26
+ *
27
+ * @tag domain:tester
28
+ * @tag layer:seed
29
+ * @tag pattern:none
30
+ * @tag access:server-only
31
+ * @tag consumers:seed-data/authored/index.ts
32
+ * @tag sideEffects:none
33
+ */
34
+ export const authored = {
35
+ "checklist-content-discovery-category-tabs-franchise-bundles-index-shows-roots-only": {
36
+ roles: ["guest"],
37
+ startPage: "/categories",
38
+ steps: [
39
+ "Open /categories.",
40
+ "Confirm the Categories tab is the one selected on arrival.",
41
+ "Count the cards. There must be exactly six.",
42
+ "Read the six names: Spinning Tops, Trading Cards, Collectible Figures, Hot Wheels, Model Kits, Living Collectibles.",
43
+ "Answer no if any deeper category appears here — 'Attack Rings', 'Beyblade Burst', '1B' or any single part name.",
44
+ ],
45
+ expectedBehaviour: "BEFORE: the index rendered the whole flat forest — 1,185 rows, of which 752 are part leaves and 220 are model leaves — so the six roots were buried and a visitor met a wall of names with no sense of where they were. AFTER: the Categories tab shows ONLY the tier-0 roots and you walk down from one.",
46
+ expectedUiState: "Exactly six cards, each a root. No tier-1-or-deeper row anywhere on this tab.",
47
+ expectedData: { rootCards: 6 },
48
+ endResult: "Nothing is changed; this case only reads.",
49
+ },
50
+ "checklist-content-discovery-category-tabs-franchise-bundles-five-tabs-no-all": {
51
+ roles: ["guest"],
52
+ startPage: "/categories",
53
+ steps: [
54
+ "Open /categories.",
55
+ "Read the tab bar.",
56
+ "Confirm there are exactly five tabs: Categories, Brands, Franchises, Parts, Bundles.",
57
+ "Answer no if a tab labelled 'All' is present.",
58
+ ],
59
+ expectedBehaviour: "BEFORE: the tabs were All / Categories / Brands, and 'All' was the DEFAULT — it is what rendered the 1,185 rows. AFTER: five tabs, no 'All'. Every tab answers a question ('which parts do you have?'); 'all' answered none of them, which is why it was removed rather than reordered.",
60
+ expectedUiState: "Five tabs, named exactly as above, with no 'All'.",
61
+ expectedData: { tabCount: 5, hasAllTab: false },
62
+ endResult: "Nothing is changed; this case only reads.",
63
+ },
64
+ "checklist-content-discovery-category-tabs-franchise-bundles-parts-tab-is-leaves-only": {
65
+ roles: ["guest"],
66
+ startPage: "/categories",
67
+ steps: [
68
+ "Open /categories and click the Parts tab.",
69
+ "Read the total the pager reports.",
70
+ "Confirm it reads 752 and not 772.",
71
+ "Scan the first page and confirm every card is an individual PART — '1B', 'Ten Heavy', 'Neo Right SG'.",
72
+ "Answer no if a classification GROUP appears as a card: 'Attack Rings', 'Weight Disks', 'Performance Tips', 'Bit Chips'.",
73
+ ],
74
+ expectedBehaviour: "BEFORE: the tab returned 772, because the predicate matched any row whose ancestry passes a `*-parts` node — which is true of the 20 classification NODES as well as the leaves beneath them. The list mixed groups with their own contents and was not visibly wrong. AFTER: leaves only, 752. A part is a leaf by itself; a classification is a branch you reach by drilling down.",
75
+ expectedUiState: "752 parts reported. Every card is a single part; no classification group appears.",
76
+ expectedData: { partsTotal: 752, classificationNodesShown: 0 },
77
+ endResult: "Nothing is changed; this case only reads.",
78
+ },
79
+ "checklist-content-discovery-category-tabs-franchise-bundles-tabs-do-not-overlap": {
80
+ roles: ["guest"],
81
+ startPage: "/categories",
82
+ steps: [
83
+ "Open /categories and note the total each of the five tabs reports: Categories, Brands, Franchises, Parts, Bundles.",
84
+ "Expect 6, 28, 8, 752, 5.",
85
+ "Pick one brand by name on the Brands tab — for example Takara-Tomy.",
86
+ "Open each of the other four tabs and search for that same name.",
87
+ "Answer no if it appears on any tab other than Brands.",
88
+ "Repeat once with a bundle name taken from the Bundles tab.",
89
+ ],
90
+ expectedBehaviour: "Brands, franchises and bundles are all tier-0 rows, so a predicate that tested the tier before the discriminator would list every one of them under Categories as well. The type check has to come first. A row on two tabs double-counts the catalogue and makes the totals unreadable.",
91
+ expectedUiState: "Tab totals read 6 / 28 / 8 / 752 / 5. The sampled brand and bundle each appear on exactly one tab.",
92
+ expectedData: { categories: 6, brands: 28, franchises: 8, parts: 752, bundles: 5 },
93
+ endResult: "Nothing is changed; this case only reads.",
94
+ },
95
+ "checklist-content-discovery-category-tabs-franchise-bundles-franchise-index-hides-unstocked": {
96
+ roles: ["guest"],
97
+ startPage: "/franchise",
98
+ steps: [
99
+ "Open /franchise.",
100
+ "Count the cards. Exactly one must be present: Beyblade.",
101
+ "Answer no if Marvel, DC Comics, Dragon Ball, Naruto, Bleach, One Piece or One Punch Man appears here.",
102
+ ],
103
+ expectedBehaviour: "Eight franchises are seeded; seven have no stock. A row with no product is a dead facet — a card that opens an empty shop — so the index lists ACTIVE franchises only. The seven still exist and their pages still resolve; they are simply not advertised as somewhere to shop until they have something to sell.",
104
+ expectedUiState: "One card, Beyblade. None of the seven unstocked IPs.",
105
+ expectedData: { franchiseCards: 1 },
106
+ endResult: "Nothing is changed; this case only reads.",
107
+ },
108
+ "checklist-content-discovery-category-tabs-franchise-bundles-unstocked-franchise-page-resolves": {
109
+ roles: ["guest"],
110
+ startPage: "/franchise/marvel",
111
+ steps: [
112
+ "Open /franchise/marvel directly.",
113
+ "Confirm a real page renders — a heading reading Marvel — and NOT a 404 or not-found view.",
114
+ "Confirm a message says plainly that this franchise is not stocked yet.",
115
+ "Answer no if the page 404s, or if it renders an empty product grid with no explanation.",
116
+ ],
117
+ expectedBehaviour: "BEFORE: `/franchise/*` was not a route at all, so seventeen links in CharacterHotspot 404ed. AFTER: the page resolves. An unstocked franchise is NOT a 404 — the row exists deliberately so those links land somewhere honest. An unexplained empty grid would be the other wrong answer: the visitor arrived from a real link and should be told it is coming, not left to assume something broke.",
118
+ expectedUiState: "The Marvel page renders with its name and an explicit 'not stocking this yet' message. No 404, no bare empty grid.",
119
+ expectedData: { httpOk: true, explanationShown: true },
120
+ endResult: "Nothing is changed; this case only reads.",
121
+ },
122
+ "checklist-content-discovery-category-tabs-franchise-bundles-franchise-detail-lists-its-products": {
123
+ roles: ["guest"],
124
+ startPage: "/franchise/franchise-beyblade",
125
+ steps: [
126
+ "Open the Beyblade franchise page from /franchise.",
127
+ "Confirm it reports a listing count in the thousands, not zero and not a handful.",
128
+ "Confirm product cards render.",
129
+ "CONTROL: now open /franchise/zzznope by typing it.",
130
+ "Confirm that page 404s rather than showing the whole catalogue.",
131
+ ],
132
+ expectedBehaviour: "The products filter matches on `franchiseSlug`, so a clause that was silently dropped would return the ENTIRE catalogue on every franchise page — which looks like a working page full of products. The nonsense control is the only step that tells a working filter from an absent one.",
133
+ expectedUiState: "Beyblade lists ~1,304 listings. An unknown franchise slug 404s and never renders a populated grid.",
134
+ expectedData: { beybladeListings: 1304, nonsenseSlug404s: true },
135
+ endResult: "Nothing is changed; this case only reads.",
136
+ },
137
+ "checklist-content-discovery-category-tabs-franchise-bundles-bundle-card-shows-a-collage": {
138
+ roles: ["guest"],
139
+ startPage: "/categories",
140
+ steps: [
141
+ "Open /categories and click the Bundles tab.",
142
+ "Confirm five bundle cards render.",
143
+ "Look at each card's image area.",
144
+ "Confirm it shows a COLLAGE of several product photos, not one single image.",
145
+ "Answer no if any card shows a single flat colour tile or one lone photo.",
146
+ ],
147
+ expectedBehaviour: "BEFORE: a bundle card showed one generic placeholder tile — the least informative thing a bundle card can show, since a bundle IS its contents. AFTER: each card shows a collage built from its members' own photos, resolved at seed time so the card costs no extra query. All five bundles carry 3 or 4 member images, which clears the 3-image minimum the collage needs.",
148
+ expectedUiState: "Five bundle cards, each showing a multi-image collage rather than a single tile.",
149
+ expectedData: { bundleCards: 5, cardsWithCollage: 5 },
150
+ endResult: "Nothing is changed; this case only reads.",
151
+ },
152
+ };
@@ -0,0 +1,2 @@
1
+ import type { AuthoredCase } from "./_types";
2
+ export declare const authored: Record<string, AuthoredCase>;
@@ -0,0 +1,84 @@
1
+ /*
2
+ * WHY: Authored six-part procedures for the design-ux/rich-text-readability page.
3
+ * WHAT: 5 case(s), keyed by full checklist id.
4
+ *
5
+ * @tag domain:tester
6
+ * @tag layer:seed
7
+ * @tag pattern:none
8
+ * @tag access:server-only
9
+ * @tag consumers:seed-data/authored/index.ts
10
+ * @tag sideEffects:none
11
+ */
12
+ export const authored = {
13
+ "checklist-design-ux-rich-text-readability-prose-body-readable-dark": {
14
+ roles: ["guest"],
15
+ startPage: "/categories/category-spinning-tops",
16
+ steps: [
17
+ "Set the site to DARK mode using the theme toggle in the header.",
18
+ "Open /categories/category-spinning-tops.",
19
+ "Scroll to the multi-paragraph write-up directly below the hero banner (it begins 'Four generations of Beyblade').",
20
+ "Read the full write-up at normal viewing distance without leaning in or changing zoom.",
21
+ ],
22
+ expectedBehaviour: "The rich-text block takes its body colour from the active theme's own text token rather than from the Tailwind typography plugin's light-theme default. No element in the block should be rendering a colour that was chosen for a white page.",
23
+ expectedUiState: "Every sentence of the write-up is comfortably readable against the dark page. The body copy is a light grey, clearly brighter than the page background. It is NOT the dim, low-contrast grey that appears to recede into the background — the specific prior symptom was body text at gray-700 on a dark surface.",
24
+ endResult: "Nothing is written and nothing persists. Reloading the page in dark mode shows the same readable text; this is a pure rendering check.",
25
+ },
26
+ "checklist-design-ux-rich-text-readability-prose-links-visible-dark": {
27
+ roles: ["guest"],
28
+ startPage: "/categories/category-spinning-tops",
29
+ steps: [
30
+ "Stay in DARK mode on /categories/category-spinning-tops.",
31
+ "Without moving the mouse over them, locate the five inline links in the write-up: 'Beyblade Original', 'Metal Fight', 'Burst', 'Beyblade X' and 'Accessories'.",
32
+ "Compare each link's text colour against the paragraph text immediately surrounding it.",
33
+ ],
34
+ expectedBehaviour: "Link colour resolves to the theme's secondary accent in dark mode, not to the typography plugin's default link colour. Identifying a link must not require hovering, because hover is unavailable on touch devices.",
35
+ expectedUiState: "All five links are a visibly different colour from the body copy around them and are obviously clickable at a glance. The prior symptom was links rendering at gray-900 — near-black on a dark page, effectively invisible, findable only by sweeping the cursor and watching for the pointer to change. An underline alone does NOT satisfy this case: the text colour itself must differ.",
36
+ endResult: "No state changes. Clicking any of the five navigates to that category, which also confirms they were real links and not styled text.",
37
+ },
38
+ "checklist-design-ux-rich-text-readability-prose-readable-light-regression": {
39
+ roles: ["guest"],
40
+ startPage: "/categories/category-spinning-tops",
41
+ steps: [
42
+ "On /categories/category-spinning-tops, switch the theme toggle to LIGHT mode.",
43
+ "Read the same write-up and locate the same five inline links.",
44
+ "Toggle between light and dark three times, pausing on each to re-read one paragraph and one link.",
45
+ ],
46
+ expectedBehaviour: "Both themes resolve their colours from the same rebound token set, so a regression in one is a plausible consequence of a change to the other. Headings, body copy and links must each stay distinct from one another in both themes.",
47
+ expectedUiState: "In LIGHT mode the body copy is dark on the white surface and the links are the primary brand colour. Headings remain visibly darker/stronger than body copy. Nothing is washed out, and no text becomes near-invisible at either end of the toggle.",
48
+ endResult: "The theme choice persists across a reload (it is stored client-side), and the write-up renders correctly in whichever mode was last selected.",
49
+ },
50
+ "checklist-design-ux-rich-text-readability-prose-readable-across-surfaces": {
51
+ roles: ["guest"],
52
+ startPage: "/blog",
53
+ steps: [
54
+ "Set the site to DARK mode.",
55
+ "Open any published post from /blog and read its body copy and any inline links.",
56
+ "Open /terms (or /privacy) and read the policy body copy.",
57
+ "Open /faqs and expand any answer.",
58
+ "Open any product from /products and read its Description tab.",
59
+ ],
60
+ expectedBehaviour: "All four surfaces render through the same shared rich-text renderer, so one shared fix must reach all of them. A surface that is still unreadable means the fix did not apply there and the renderer is being bypassed.",
61
+ expectedUiState: "On each of the four pages the body copy is readable light-grey-on-dark and any inline links are a visibly distinct colour. No page shows the dim gray-700 body or the near-black links described in the dark-mode cases above.",
62
+ endResult: "Nothing persists. If any ONE of the four fails while the others pass, record which one — that narrows the cause to that surface's own markup rather than the shared renderer.",
63
+ },
64
+ "checklist-design-ux-rich-text-readability-prose-follows-custom-theme": {
65
+ roles: ["admin"],
66
+ startPage: "/admin/site",
67
+ steps: [
68
+ "Sign in as admin@letitrip.in / TempPass123!.",
69
+ "Open /admin/site and go to the Themes tab.",
70
+ "Note which theme is currently the default, then set a visibly different one as the active default (for example Cobalt Night, or Sunset).",
71
+ "Open /categories/category-spinning-tops in a new tab and read the write-up's body copy and links.",
72
+ "Return to /admin/site → Themes and restore the original default theme.",
73
+ ],
74
+ inputs: {
75
+ "Themes tab — active default": "a theme other than the current default",
76
+ },
77
+ expectedBehaviour: "Rich-text colours are bound to `--appkit-color-*` theme tokens, so switching the active theme must restyle admin-authored prose along with the rest of the site. This is only possible since the 2026-10-15 fix: the typography plugin's palette is hardcoded Tailwind greys that no theme record could ever reach.",
78
+ expectedUiState: "After the theme change, the write-up's paragraph colour and link colour both shift to match the new theme — they do not stay the same grey the previous theme used. Body copy remains readable against whatever surface the new theme sets.",
79
+ expectedData: {
80
+ "siteSettings.theme.defaultDarkThemeId": "restored to its original value at the end",
81
+ },
82
+ endResult: "The original default theme is restored, so no other tester inherits a changed site theme. Reloading the category page shows prose in the original theme's colours again.",
83
+ },
84
+ };
@@ -78,6 +78,7 @@ import { authored as a_content_discovery__coupons } from "./content-discovery__c
78
78
  import { authored as a_content_discovery__events } from "./content-discovery__events.js";
79
79
  import { authored as a_content_discovery__faq_help } from "./content-discovery__faq-help.js";
80
80
  import { authored as a_content_discovery__parts_database } from "./content-discovery__parts-database.js";
81
+ import { authored as a_content_discovery__category_tabs_franchise_bundles } from "./content-discovery__category-tabs-franchise-bundles.js";
81
82
  import { authored as a_content_discovery__notifications } from "./content-discovery__notifications.js";
82
83
  import { authored as a_content_discovery__search } from "./content-discovery__search.js";
83
84
  import { authored as a_cta_layout__checkout_bottom_bar } from "./cta-layout__checkout-bottom-bar.js";
@@ -85,6 +86,7 @@ import { authored as a_cta_layout__dialog_footers } from "./cta-layout__dialog-f
85
86
  import { authored as a_cta_layout__editor_action_bar } from "./cta-layout__editor-action-bar.js";
86
87
  import { authored as a_cta_layout__product_bottom_bar } from "./cta-layout__product-bottom-bar.js";
87
88
  import { authored as a_design_ux__back_to_top_button } from "./design-ux__back-to-top-button.js";
89
+ import { authored as a_design_ux__rich_text_readability } from "./design-ux__rich-text-readability.js";
88
90
  import { authored as a_design_ux__dashboard_layout } from "./design-ux__dashboard-layout.js";
89
91
  import { authored as a_design_ux__footer_theme } from "./design-ux__footer-theme.js";
90
92
  import { authored as a_design_ux__form_validation_errors } from "./design-ux__form-validation-errors.js";
@@ -199,6 +201,7 @@ export const AUTHORED_CASES = {
199
201
  ...a_content_discovery__events,
200
202
  ...a_content_discovery__faq_help,
201
203
  ...a_content_discovery__parts_database,
204
+ ...a_content_discovery__category_tabs_franchise_bundles,
202
205
  ...a_content_discovery__notifications,
203
206
  ...a_content_discovery__search,
204
207
  ...a_cta_layout__checkout_bottom_bar,
@@ -206,6 +209,7 @@ export const AUTHORED_CASES = {
206
209
  ...a_cta_layout__editor_action_bar,
207
210
  ...a_cta_layout__product_bottom_bar,
208
211
  ...a_design_ux__back_to_top_button,
212
+ ...a_design_ux__rich_text_readability,
209
213
  ...a_design_ux__dashboard_layout,
210
214
  ...a_design_ux__footer_theme,
211
215
  ...a_design_ux__form_validation_errors,
@@ -180,8 +180,8 @@ export const authored = {
180
180
  "Click into one root category and read its page.",
181
181
  "Go back and click a tier-3 category and read its page.",
182
182
  ],
183
- expectedBehaviour: "The index renders the category forest — two roots across four tiers — and every card links to a page that resolves. Products carry their full ancestor chain, so a category page matches on its own id alone and needs no descendant expansion.",
184
- expectedUiState: "Both roots, Spinning Tops and Living Collectibles, are present. Every card shows a name and a count rather than a bare name. Both the root and the tier-3 page render with products rather than an empty grid.",
183
+ expectedBehaviour: "The index renders the SIX roots of the category forest — Spinning Tops, Trading Cards, Collectible Figures, Hot Wheels, Model Kits, Living Collectibles — and every card links to a page that resolves. It shows roots ONLY: the forest is 1,185 rows across five tiers (0-4) and the index used to render every one of them flat. Products carry their full ancestor chain, so a category page matches on its own id alone and needs no descendant expansion.",
184
+ expectedUiState: "All six roots are present, and NO tier-1-or-deeper category appears on the Categories tab. Every card shows a name and a count rather than a bare name. Both the root and the tier-3 page render with products rather than an empty grid.",
185
185
  expectedData: { rootCategoryCount: 2 },
186
186
  endResult: "Read-only; nothing persists. A root category page rendering blank with no error is the specific failure worth watching for — the query that produces it is wrapped in a catch that returns nothing.",
187
187
  },
@@ -3119,6 +3119,43 @@ const rawTesterChecklistItems = [
3119
3119
  { key: "seller-description-lists-parts", label: "A listing under a model leaf gets a Parts line in its filled description, and a non-Beyblade category gets no empty one" },
3120
3120
  ],
3121
3121
  },
3122
+ {
3123
+ /*
3124
+ * The categories index, the franchise unit and the bundle collage —
3125
+ * 2026-10-14.
3126
+ *
3127
+ * 🛑 Every case here compares two things or counts an exact number,
3128
+ * because every defect this work already produced rendered a perfectly
3129
+ * plausible page:
3130
+ *
3131
+ * 1. The index rendered all 1,185 rows flat. Nothing errored; it just
3132
+ * buried the six roots under 752 part leaves.
3133
+ * 2. The Parts tab first matched 772 rows — it also caught the 20
3134
+ * classification NODES, whose own parentIds pass through a
3135
+ * `*-parts` node. A tab mixing "Attack Rings" with the attack rings
3136
+ * inside it looks entirely reasonable.
3137
+ * 3. Seven franchises have NO stock. Seeded active they would each be
3138
+ * a card opening an empty shop; omitted entirely, seventeen
3139
+ * CharacterHotspot links stay 404. Both failure modes look fine
3140
+ * from one page.
3141
+ * 4. Every bundle carries a placeholder cover image, so a collage
3142
+ * checked AFTER the cover would never render — and a card showing
3143
+ * one grey tile is not visibly broken.
3144
+ */
3145
+ pageKey: "category-tabs-franchise-bundles",
3146
+ pageLabel: "Categories index tabs, the franchise unit and the bundle collage",
3147
+ href: "/categories",
3148
+ cases: [
3149
+ { key: "index-shows-roots-only", label: "The Categories tab lists the six roots and NO deeper category" },
3150
+ { key: "five-tabs-no-all", label: "There are exactly five tabs and none of them is 'All'" },
3151
+ { key: "parts-tab-is-leaves-only", label: "The Parts tab lists part leaves and NOT the 20 classification nodes" },
3152
+ { key: "tabs-do-not-overlap", label: "No single row appears on two tabs" },
3153
+ { key: "franchise-index-hides-unstocked", label: "The franchise index shows only stocked franchises, not all eight" },
3154
+ { key: "unstocked-franchise-page-resolves", label: "An unstocked franchise page loads and says so, rather than 404ing or showing an empty grid" },
3155
+ { key: "franchise-detail-lists-its-products", label: "The Beyblade franchise page lists its listings, with a nonsense slug as the control" },
3156
+ { key: "bundle-card-shows-a-collage", label: "A bundle card shows a collage of its members, not its placeholder cover" },
3157
+ ],
3158
+ },
3122
3159
  {
3123
3160
  /*
3124
3161
  * Store reviews. The endpoint answered 200 with zero reviews for EVERY
@@ -3450,13 +3487,13 @@ const rawTesterChecklistItems = [
3450
3487
  cases: [
3451
3488
  {
3452
3489
  key: "tier-depth-reachable",
3453
- label: "The category tree is four tiers deep and every tier is reachable by clicking down from a root",
3454
- description: "Two roots exist — Spinning Tops and Living Collectibles. Walk one of them all the way to a leaf without typing a URL.",
3490
+ label: "The category tree is five tiers deep and every tier is reachable by clicking down from a root",
3491
+ description: "Six roots exist — Spinning Tops, Trading Cards, Collectible Figures, Hot Wheels, Model Kits, Living Collectibles. Walk one of them all the way to a leaf without typing a URL.",
3455
3492
  href: "/categories",
3456
3493
  },
3457
3494
  {
3458
3495
  key: "second-root-reachable",
3459
- label: "Both category roots appear on the categories index, not just the Beyblade one",
3496
+ label: "Every category root appears on the categories index, not just the Beyblade one",
3460
3497
  description: "BEFORE: the tree had ONE root and TWO tiers, so the live-item listings carried no category at all and were unreachable from every category and brand page.",
3461
3498
  href: "/categories",
3462
3499
  },
@@ -3855,6 +3892,43 @@ const rawTesterChecklistItems = [
3855
3892
  },
3856
3893
  ],
3857
3894
  },
3895
+ {
3896
+ pageKey: "rich-text-readability",
3897
+ pageLabel: "Admin-Authored Rich Text — Readability in Both Themes",
3898
+ href: "/categories/category-spinning-tops",
3899
+ cases: [
3900
+ {
3901
+ key: "prose-body-readable-dark",
3902
+ label: "IN DARK MODE: the paragraphs of a category's own write-up are clearly readable — not a dim grey that disappears into the page background",
3903
+ description: "Fixed 2026-10-15. BEFORE: every rich-text block on the site rendered the LIGHT-theme prose palette in dark mode — body text at gray-700 — because `dark:prose-invert` has never compiled in this project (the consumer Tailwind build is told `@source not \"../../appkit\"`, and appkit's own build has no typography plugin). AFTER: switch to DARK mode, open /categories/category-spinning-tops and read the four-paragraph write-up under the hero. Every sentence must be comfortably readable at arm's length. A paragraph you have to lean in for is still a bug.",
3904
+ href: "/categories/category-spinning-tops",
3905
+ },
3906
+ {
3907
+ key: "prose-links-visible-dark",
3908
+ label: "IN DARK MODE: links inside that write-up (Beyblade Original, Metal Fight, Burst, Beyblade X, Accessories) are visibly a different colour from the surrounding text and are obviously clickable",
3909
+ description: "The worst half of the same bug, and the one a reader notices first. BEFORE: prose links resolved to gray-900 — near-black on a dark page — so they were effectively invisible; you could only find them by sweeping the mouse for the cursor change. AFTER: each link must read as a link WITHOUT hovering. Check all five in the body copy. Underline alone is not enough: the text colour must differ from the paragraph around it.",
3910
+ href: "/categories/category-spinning-tops",
3911
+ },
3912
+ {
3913
+ key: "prose-readable-light-regression",
3914
+ label: "IN LIGHT MODE: the same write-up and its links are still correct — fixing dark mode must not have broken light mode",
3915
+ description: "A real risk, not a formality: the fix rebinds the prose colour palette to theme tokens, so both themes now come from one place and a mistake affects both. Toggle light/dark a few times on this page. Headings must stay darker than body text, and links must stay distinguishable in BOTH.",
3916
+ href: "/categories/category-spinning-tops",
3917
+ },
3918
+ {
3919
+ key: "prose-readable-across-surfaces",
3920
+ label: "The same readability holds on every other admin-authored rich-text surface — a blog post, a policy page, an FAQ answer and a product description",
3921
+ description: "This was never a category-page bug; ~25 surfaces share one renderer. In DARK mode check: a post under /blog, /terms (or /privacy), an expanded answer on /faqs, and the Description tab of any product. Each must have readable body copy and visible links. One unreadable surface here means the shared fix did not reach it.",
3922
+ href: "/blog",
3923
+ },
3924
+ {
3925
+ key: "prose-follows-custom-theme",
3926
+ label: "Rich-text body copy follows an admin-authored CUSTOM theme, not a fixed grey",
3927
+ description: "Added with the 2026-10-15 fix, and only possible because of it: the old prose palette was hardcoded Tailwind greys that no theme could reach. As admin, open Site Settings → Themes, switch the active theme (e.g. to Cobalt Night or Sunset), then reload a category page with a write-up. The paragraph and link colours must shift with the theme rather than staying the same grey.",
3928
+ href: "/admin/site",
3929
+ },
3930
+ ],
3931
+ },
3858
3932
  {
3859
3933
  pageKey: "status-badge-legibility",
3860
3934
  pageLabel: "Listing Tags & Status Chips — Light vs Dark Mode",