@mohasinac/appkit 4.47.0 → 4.48.0

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 (173) hide show
  1. package/dist/.build-stamp.json +4 -4
  2. package/dist/_internal/server/features/checkout/expand-order-items.js +3 -0
  3. package/dist/_internal/server/features/messages/actions.d.ts +68 -0
  4. package/dist/_internal/server/features/messages/actions.js +249 -0
  5. package/dist/_internal/server/features/parts/buildable.d.ts +47 -0
  6. package/dist/_internal/server/features/parts/buildable.js +159 -0
  7. package/dist/_internal/server/features/products/list-public.d.ts +17 -0
  8. package/dist/_internal/server/features/products/list-public.js +92 -5
  9. package/dist/_internal/server/features/products/og.d.ts +7 -0
  10. package/dist/_internal/server/features/products/og.js +38 -0
  11. package/dist/_internal/server/features/site-settings/adapters.d.ts +1 -1
  12. package/dist/_internal/server/features/site-settings/adapters.js +6 -0
  13. package/dist/_internal/server/jobs/core/cleanupRtdbEvents.js +28 -0
  14. package/dist/_internal/server/jobs/core/notificationPrune.js +87 -3
  15. package/dist/_internal/server/jobs/core/priceIndexRollup.js +36 -1
  16. package/dist/_internal/shared/actions/action-registry.js +8 -0
  17. package/dist/_internal/shared/features/notifications/action-url.js +8 -0
  18. package/dist/_internal/shared/features/notifications/email-eligibility.js +3 -0
  19. package/dist/_internal/shared/features/site-settings/types.d.ts +4 -0
  20. package/dist/client.d.ts +10 -0
  21. package/dist/client.js +8 -0
  22. package/dist/constants/api-endpoints.d.ts +48 -0
  23. package/dist/constants/api-endpoints.js +30 -0
  24. package/dist/constants/notification-types.d.ts +1 -1
  25. package/dist/features/about/components/GstExplainerView.d.ts +28 -0
  26. package/dist/features/about/components/GstExplainerView.js +59 -0
  27. package/dist/features/about/components/GuidesHubView.d.ts +28 -0
  28. package/dist/features/about/components/GuidesHubView.js +58 -0
  29. package/dist/features/about/components/PublicProfileView.js +5 -0
  30. package/dist/features/about/components/SpotAFakeView.d.ts +29 -0
  31. package/dist/features/about/components/SpotAFakeView.js +49 -0
  32. package/dist/features/admin/actions/notification-actions.js +4 -0
  33. package/dist/features/admin/schemas/firestore.d.ts +13 -3
  34. package/dist/features/admin/schemas/firestore.js +8 -0
  35. package/dist/features/blog/repository/blog.repository.js +21 -7
  36. package/dist/features/categories/api/route.js +41 -1
  37. package/dist/features/categories/components/BreadcrumbTrail.d.ts +39 -17
  38. package/dist/features/categories/components/BreadcrumbTrail.js +46 -50
  39. package/dist/features/categories/components/CategoryDetailPageView.js +92 -38
  40. package/dist/features/categories/components/ContextualTrail.d.ts +62 -0
  41. package/dist/features/categories/components/ContextualTrail.js +81 -0
  42. package/dist/features/categories/components/LastCategoryBeacon.d.ts +18 -0
  43. package/dist/features/categories/components/LastCategoryBeacon.js +33 -0
  44. package/dist/features/categories/repository/categories.repository.d.ts +21 -0
  45. package/dist/features/categories/repository/categories.repository.js +36 -3
  46. package/dist/features/categories/repository/price-index.repository.d.ts +25 -0
  47. package/dist/features/email/notification-templates.js +4 -0
  48. package/dist/features/events/repository/events.repository.js +10 -3
  49. package/dist/features/faq/constants/read-caps.d.ts +29 -0
  50. package/dist/features/faq/constants/read-caps.js +29 -0
  51. package/dist/features/faq/repository/faqs.repository.d.ts +0 -2
  52. package/dist/features/faq/repository/faqs.repository.js +19 -16
  53. package/dist/features/homepage/components/AdSlot.js +10 -4
  54. package/dist/features/homepage/hooks/useActiveAd.d.ts +18 -1
  55. package/dist/features/homepage/hooks/useActiveAd.js +42 -2
  56. package/dist/features/homepage/lib/section-renderer.js +12 -2
  57. package/dist/features/layout/AppLayoutShell.d.ts +4 -1
  58. package/dist/features/layout/AppLayoutShell.js +2 -2
  59. package/dist/features/layout/TitleBarLayout.d.ts +4 -1
  60. package/dist/features/layout/TitleBarLayout.js +8 -4
  61. package/dist/features/media/AvatarUpload.js +8 -1
  62. package/dist/features/media/MediaAvatar.js +1 -1
  63. package/dist/features/media/MediaImage.js +3 -1
  64. package/dist/features/media/MediaLightbox.js +4 -1
  65. package/dist/features/media/MediaPickerModal.js +6 -1
  66. package/dist/features/media/MediaVideo.js +3 -1
  67. package/dist/features/messages/components/ConversationThreadView.d.ts +3 -0
  68. package/dist/features/messages/components/ConversationThreadView.js +75 -0
  69. package/dist/features/messages/components/ConversationsInboxView.d.ts +1 -0
  70. package/dist/features/messages/components/ConversationsInboxView.js +39 -0
  71. package/dist/features/messages/components/MessageSellerButton.d.ts +17 -0
  72. package/dist/features/messages/components/MessageSellerButton.js +33 -0
  73. package/dist/features/messages/hooks/useConversationChannel.d.ts +24 -0
  74. package/dist/features/messages/hooks/useConversationChannel.js +89 -0
  75. package/dist/features/messages/hooks/useMessages.d.ts +347 -0
  76. package/dist/features/messages/hooks/useMessages.js +113 -0
  77. package/dist/features/messages/repository/conversations.repository.d.ts +44 -0
  78. package/dist/features/messages/repository/conversations.repository.js +173 -0
  79. package/dist/features/messages/repository/messages.repository.d.ts +42 -0
  80. package/dist/features/messages/repository/messages.repository.js +142 -0
  81. package/dist/features/messages/schemas/firestore.d.ts +99 -0
  82. package/dist/features/messages/schemas/firestore.js +46 -0
  83. package/dist/features/orders/repository/orders.repository.js +21 -7
  84. package/dist/features/orders/schemas/firestore.d.ts +11 -0
  85. package/dist/features/products/components/CityCollectiblesView.d.ts +31 -0
  86. package/dist/features/products/components/CityCollectiblesView.js +82 -0
  87. package/dist/features/products/components/MarketPriceAnchor.d.ts +45 -0
  88. package/dist/features/products/components/MarketPriceAnchor.js +62 -0
  89. package/dist/features/products/components/ProductDetailPageView.js +71 -5
  90. package/dist/features/products/constants/meetup-cities.d.ts +47 -0
  91. package/dist/features/products/constants/meetup-cities.js +49 -0
  92. package/dist/features/products/repository/category-assignment.d.ts +82 -11
  93. package/dist/features/products/repository/category-assignment.js +93 -12
  94. package/dist/features/products/repository/products.repository.d.ts +12 -1
  95. package/dist/features/products/repository/products.repository.js +66 -34
  96. package/dist/features/products/schemas/firestore.d.ts +24 -2
  97. package/dist/features/products/schemas/firestore.js +13 -1
  98. package/dist/features/products/schemas/index.d.ts +2 -2
  99. package/dist/features/promotions/repository/coupons.repository.js +11 -4
  100. package/dist/features/reviews/repository/reviews.repository.js +11 -4
  101. package/dist/features/scams/repository/scammer.repository.js +21 -7
  102. package/dist/features/seller/components/SellerProductShell.d.ts +1 -1
  103. package/dist/features/seller/components/SellerProductShell.js +2 -2
  104. package/dist/features/seller/repository/offer.repository.js +21 -6
  105. package/dist/features/seller/schemas/product-form.d.ts +3 -3
  106. package/dist/features/seller/schemas/product-form.js +1 -1
  107. package/dist/features/server-errors/repository/server-errors.repository.js +11 -0
  108. package/dist/features/server-errors/schemas/firestore.d.ts +17 -0
  109. package/dist/features/server-errors/schemas/firestore.js +2 -0
  110. package/dist/features/store-extensions/repository/store-extensions.repositories.d.ts +17 -1
  111. package/dist/features/store-extensions/repository/store-extensions.repositories.js +31 -0
  112. package/dist/features/store-extensions/schemas/firestore.d.ts +1 -1
  113. package/dist/features/store-extensions/schemas/firestore.js +4 -0
  114. package/dist/features/stores/repository/store.repository.js +11 -3
  115. package/dist/features/tester/seed-data/authored/community-support__messages.d.ts +2 -0
  116. package/dist/features/tester/seed-data/authored/community-support__messages.js +200 -0
  117. package/dist/features/tester/seed-data/authored/index.js +2 -0
  118. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +64 -1
  119. package/dist/index.d.ts +8 -2
  120. package/dist/index.js +1 -1
  121. package/dist/next/routing/route-map.d.ts +14 -0
  122. package/dist/next/routing/route-map.js +26 -0
  123. package/dist/providers/db-firebase/rtdb-paths.d.ts +8 -0
  124. package/dist/providers/db-firebase/rtdb-paths.js +8 -0
  125. package/dist/repositories/index.d.ts +1 -0
  126. package/dist/repositories/index.js +4 -0
  127. package/dist/schemas/registry.d.ts +3 -3
  128. package/dist/security/index.d.ts +1 -1
  129. package/dist/security/index.js +1 -1
  130. package/dist/security/pii-schemas.d.ts +17 -0
  131. package/dist/security/pii-schemas.js +15 -7
  132. package/dist/seed/_helpers/category-content-generated.d.ts +3 -3
  133. package/dist/seed/_helpers/category-content-generated.js +23 -7
  134. package/dist/seed/_helpers/category-models.d.ts +1 -1
  135. package/dist/seed/_helpers/category-models.js +25 -1
  136. package/dist/seed/_helpers/category-names.d.ts +54 -0
  137. package/dist/seed/_helpers/category-names.js +118 -0
  138. package/dist/seed/_helpers/character-features.d.ts +1 -1
  139. package/dist/seed/_helpers/character-features.js +5 -4
  140. package/dist/seed/_helpers/final-sale-fixtures.js +1 -1
  141. package/dist/seed/categories-seed-data.js +55 -1
  142. package/dist/seed/grouped-listings-seed-data.js +2 -2
  143. package/dist/seed/products-art-seed-data.js +2 -1
  144. package/dist/seed/products-auctions-seed-data.js +42 -22
  145. package/dist/seed/products-classifieds-seed-data.js +21 -3
  146. package/dist/seed/products-digital-codes-seed-data.js +2 -1
  147. package/dist/seed/products-live-items-seed-data.js +2 -1
  148. package/dist/seed/products-preorders-seed-data.js +26 -11
  149. package/dist/seed/products-prize-draws-seed-data.js +8 -1
  150. package/dist/seed/products-standard-seed-data.js +26 -25
  151. package/dist/seed/products-stickers-seed-data.js +12 -11
  152. package/dist/server.d.ts +8 -0
  153. package/dist/server.js +15 -0
  154. package/dist/styles.css +113 -1
  155. package/dist/tailwind-utilities.css +1 -1
  156. package/dist/tokens/themes/sunset.js +17 -0
  157. package/dist/tokens/tokens.css +5 -0
  158. package/dist/ui/components/Breadcrumb.d.ts +8 -1
  159. package/dist/ui/components/Breadcrumb.js +2 -2
  160. package/dist/ui/components/Breadcrumb.style.css +37 -0
  161. package/dist/ui/components/Details.d.ts +11 -1
  162. package/dist/ui/components/Details.js +2 -2
  163. package/dist/ui/components/ImageMosaic.d.ts +43 -0
  164. package/dist/ui/components/ImageMosaic.js +46 -0
  165. package/dist/ui/components/ImageMosaic.style.css +69 -0
  166. package/dist/ui/components/SplitTile.d.ts +34 -0
  167. package/dist/ui/components/SplitTile.js +28 -0
  168. package/dist/ui/components/index.style.css +1 -0
  169. package/dist/ui/index.d.ts +4 -0
  170. package/dist/ui/index.js +4 -0
  171. package/dist/utils/search-txt-query.d.ts +77 -0
  172. package/dist/utils/search-txt-query.js +50 -28
  173. package/package.json +1 -1
@@ -24,6 +24,23 @@ export const SUNSET_THEME = {
24
24
  "appkit-color-primary": "#f59e0b",
25
25
  "appkit-color-primary-500": "#f59e0b",
26
26
  "appkit-color-primary-600": "#d97706",
27
+ /*
28
+ * 🛑 D7 — DARK ink on this primary, declared rather than inherited.
29
+ *
30
+ * The light base's `text-on-primary` is `#ffffff`, and white on amber
31
+ * `#f59e0b` measures **2.15:1** — under the 3:1 AA threshold for
32
+ * large/UI text, so every primary button, badge and pill in this theme was
33
+ * unreadable. It survived because the pair was INHERITED: `audit-a11y`
34
+ * treated a token this theme does not declare as "nothing to check", which
35
+ * is exactly the pairing a brand theme is most likely to break (a new
36
+ * brand colour under somebody else's ink).
37
+ *
38
+ * `#431407` is this theme's own `text` token, so the fix introduces no new
39
+ * colour: **7.29:1** on `primary` and **4.91:1** on `primary-600`, which is
40
+ * the darker shade a hover state uses — both clear AA for normal text, not
41
+ * just the 3:1 this pair is held to.
42
+ */
43
+ "appkit-color-text-on-primary": "#431407",
27
44
  "appkit-color-secondary": "#f43f5e",
28
45
  "appkit-color-secondary-500": "#f43f5e",
29
46
  "appkit-color-bg": "#fff7ed",
@@ -608,6 +608,11 @@
608
608
  --appkit-color-text: #431407; /* orange-950 */
609
609
  --appkit-color-text-muted: #9a3412; /* orange-800 */
610
610
  --appkit-color-text-faint: #c2410c; /* orange-700 */
611
+ /* D7: dark ink on the amber primary. Inherited white measured 2.15:1 and
612
+ failed AA for UI text; orange-950 is 7.29:1 on primary and 4.91:1 on
613
+ primary-600. Declared in BOTH places or the first paint disagrees with
614
+ the hydrated theme (audit-theme-drift). */
615
+ --appkit-color-text-on-primary: #431407;
611
616
 
612
617
  --appkit-color-success: #15803d;
613
618
  --appkit-color-success-surface: #f0fdf4;
@@ -8,8 +8,15 @@ export interface BreadcrumbItem {
8
8
  label: string;
9
9
  href?: string;
10
10
  }
11
+ export type BreadcrumbTone = "default" | "on-media";
11
12
  export interface BreadcrumbProps {
12
13
  items: BreadcrumbItem[];
13
14
  className?: string;
15
+ /**
16
+ * `"on-media"` for a trail drawn over a cover photo — fixed white ink on the
17
+ * scrim, in both themes. See the tone block in `Breadcrumb.style.css` for why
18
+ * it is not a theme token.
19
+ */
20
+ tone?: BreadcrumbTone;
14
21
  }
15
- export declare function Breadcrumb({ items, className }: BreadcrumbProps): import("react").JSX.Element;
22
+ export declare function Breadcrumb({ items, className, tone }: BreadcrumbProps): import("react").JSX.Element;
@@ -3,8 +3,8 @@ import { ChevronRight } from "lucide-react";
3
3
  import { Nav, Ol, Li } from "./Semantic.js";
4
4
  import { Span } from "./Typography.js";
5
5
  import { TextLink } from "./TextLink.js";
6
- export function Breadcrumb({ items, className = "" }) {
7
- return (_jsx(Nav, { "aria-label": "Breadcrumb", className: `appkit-breadcrumb ${className}`, children: _jsx(Ol, { className: "appkit-breadcrumb__list", children: items.map((item, i) => {
6
+ export function Breadcrumb({ items, className = "", tone = "default" }) {
7
+ return (_jsx(Nav, { "aria-label": "Breadcrumb", className: `appkit-breadcrumb ${tone === "on-media" ? "appkit-breadcrumb--on-media" : ""} ${className}`, children: _jsx(Ol, { className: "appkit-breadcrumb__list", children: items.map((item, i) => {
8
8
  const isLast = i === items.length - 1;
9
9
  return (_jsxs(Li, { className: "appkit-breadcrumb__item", children: [i > 0 && (_jsx(ChevronRight, { className: "appkit-breadcrumb__separator", "aria-hidden": "true" })), isLast || !item.href ? (_jsx(Span, { className: isLast
10
10
  ? "appkit-breadcrumb__current"
@@ -58,3 +58,40 @@
58
58
  .dark .appkit-breadcrumb__link:hover {
59
59
  color: var(--appkit-color-primary-400);
60
60
  }
61
+
62
+ /*
63
+ * ── tone="on-media" ─────────────────────────────────────────────────────────
64
+ *
65
+ * A trail rendered ON a cover photo. The category hero does exactly this, and
66
+ * before this tone existed that page hand-rolled its own `<Nav>` with
67
+ * `text-white/70` conditionals purely to stay readable — which is why the
68
+ * shared component had no caller there, and why its visible trail (three
69
+ * crumbs) disagreed with its own JSON-LD (eight).
70
+ *
71
+ * 🛑 Fixed white rather than a theme token, deliberately. The backdrop is a
72
+ * photograph with a dark scrim over it in BOTH themes, so a theme-inverting
73
+ * token would be invisible in one of them — the trap Root Cause #67 is about.
74
+ * An alpha on white is the spelling that rule prescribes for a control on a
75
+ * fixed dark scrim.
76
+ */
77
+ .appkit-breadcrumb--on-media .appkit-breadcrumb__list,
78
+ .appkit-breadcrumb--on-media .appkit-breadcrumb__text,
79
+ .dark .appkit-breadcrumb--on-media .appkit-breadcrumb__list,
80
+ .dark .appkit-breadcrumb--on-media .appkit-breadcrumb__text {
81
+ color: rgb(255 255 255 / 0.7);
82
+ }
83
+
84
+ .appkit-breadcrumb--on-media .appkit-breadcrumb__separator,
85
+ .dark .appkit-breadcrumb--on-media .appkit-breadcrumb__separator {
86
+ color: rgb(255 255 255 / 0.4);
87
+ }
88
+
89
+ .appkit-breadcrumb--on-media .appkit-breadcrumb__current,
90
+ .dark .appkit-breadcrumb--on-media .appkit-breadcrumb__current {
91
+ color: rgb(255 255 255);
92
+ }
93
+
94
+ .appkit-breadcrumb--on-media .appkit-breadcrumb__link:hover,
95
+ .dark .appkit-breadcrumb--on-media .appkit-breadcrumb__link:hover {
96
+ color: rgb(255 255 255);
97
+ }
@@ -54,7 +54,17 @@ export interface SummaryProps {
54
54
  align?: SummaryAlign;
55
55
  /** Main-axis distribution — takes effect when `layout` is set. */
56
56
  justify?: SummaryJustify;
57
+ /**
58
+ * Accessible name for the disclosure control.
59
+ *
60
+ * A summary whose visible label is a shorthand ("… 3 more") reads as nothing
61
+ * useful to a screen reader, and the alternative — padding the visible label
62
+ * — costs the horizontal space the collapse exists to save. A prop rather
63
+ * than a caller's raw attribute, because a raw `aria-label` on a primitive is
64
+ * the className escape hatch by another name (Rule #21).
65
+ */
66
+ ariaLabel?: string;
57
67
  }
58
68
  export declare function Details({ children, defaultOpen, open, onToggle, tone, padding, rounded, name, className, style, }: DetailsProps): import("react").JSX.Element;
59
- export declare function Summary({ children, className, size, weight, paddingX, paddingY, color, layout, align, justify }: SummaryProps): import("react").JSX.Element;
69
+ export declare function Summary({ children, className, size, weight, paddingX, paddingY, color, layout, align, justify, ariaLabel }: SummaryProps): import("react").JSX.Element;
60
70
  export {};
@@ -75,8 +75,8 @@ export function Details({ children, defaultOpen, open, onToggle, tone = "default
75
75
  className ?? "",
76
76
  ].filter(Boolean).join(" ") || undefined, style: style, children: children }));
77
77
  }
78
- export function Summary({ children, className, size, weight, paddingX, paddingY, color, layout, align, justify }) {
79
- return (_jsx("summary", { className: [
78
+ export function Summary({ children, className, size, weight, paddingX, paddingY, color, layout, align, justify, ariaLabel }) {
79
+ return (_jsx("summary", { "aria-label": ariaLabel, className: [
80
80
  "cursor-pointer select-none font-medium text-[var(--appkit-color-text)] list-none [&::-webkit-details-marker]:hidden focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--appkit-color-focus-ring)] rounded",
81
81
  size ? SUMMARY_SIZE_MAP[size] : "",
82
82
  weight ? SUMMARY_WEIGHT_MAP[weight] : "",
@@ -0,0 +1,43 @@
1
+ import React from "react";
2
+ /** Below this there is nothing to arrange, so the caller should render nothing. */
3
+ export declare const MOSAIC_MIN = 3;
4
+ export interface ImageMosaicProps {
5
+ /** Image URLs, most important first. Fewer than `MOSAIC_MIN` renders null. */
6
+ images: readonly string[];
7
+ /** Describes the SUBJECT, not the images — one alt for a decorative group. */
8
+ alt: string;
9
+ className?: string;
10
+ }
11
+ /**
12
+ * D3 — a mosaic of a collection's own images, for a hero that has no cover
13
+ * photo of its own.
14
+ *
15
+ * ## 🛑 Why this exists and what it replaces
16
+ *
17
+ * A category with no `display.coverImage` rendered a flat
18
+ * `bg-[var(--appkit-color-bg)]` band behind its title — the same empty strip on
19
+ * every one of the ~1,100 categories that has no photographer. The images were
20
+ * already on the page (the product grid below it), so the hero was blank beside
21
+ * a screen full of pictures.
22
+ *
23
+ * ## 🛑 It is DECORATIVE, and that decides three things
24
+ *
25
+ * 1. **One `alt` for the group, describing the subject** — "Beyblade Burst
26
+ * parts", not five filenames. Five alts for five thumbnails of the same
27
+ * subject is noise to a screen reader, and `alt=""` on each would leave the
28
+ * band unlabelled entirely.
29
+ * 2. **`aria-hidden` is NOT used**, because the group does carry meaning: it is
30
+ * what this category looks like. Hidden decoration plus a labelled group are
31
+ * different claims and the second is true here.
32
+ * 3. **No links.** Every one of these products is linked in the grid below; a
33
+ * second set of hit targets over a title is a tap hazard on a phone and
34
+ * gives a crawler duplicate links to the same URLs.
35
+ *
36
+ * ## 🛑 Deterministic emphasis, never random
37
+ *
38
+ * The first image takes the large cell and the rest fill the small ones in
39
+ * order. `Math.random()` here would make the hero change on every render, which
40
+ * breaks SSR/hydration agreement and makes the page feel unstable — and it is
41
+ * the shape Root Cause #25 is about on the seed side.
42
+ */
43
+ export declare function ImageMosaic({ images, alt, className }: ImageMosaicProps): React.JSX.Element | null;
@@ -0,0 +1,46 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { MediaImage } from "../../features/media/MediaImage.js";
3
+ import { Div } from "./Div.js";
4
+ /** How many tiles the mosaic draws. More than five reads as a grid, not a hero. */
5
+ const MOSAIC_MAX = 5;
6
+ /** Below this there is nothing to arrange, so the caller should render nothing. */
7
+ export const MOSAIC_MIN = 3;
8
+ /**
9
+ * D3 — a mosaic of a collection's own images, for a hero that has no cover
10
+ * photo of its own.
11
+ *
12
+ * ## 🛑 Why this exists and what it replaces
13
+ *
14
+ * A category with no `display.coverImage` rendered a flat
15
+ * `bg-[var(--appkit-color-bg)]` band behind its title — the same empty strip on
16
+ * every one of the ~1,100 categories that has no photographer. The images were
17
+ * already on the page (the product grid below it), so the hero was blank beside
18
+ * a screen full of pictures.
19
+ *
20
+ * ## 🛑 It is DECORATIVE, and that decides three things
21
+ *
22
+ * 1. **One `alt` for the group, describing the subject** — "Beyblade Burst
23
+ * parts", not five filenames. Five alts for five thumbnails of the same
24
+ * subject is noise to a screen reader, and `alt=""` on each would leave the
25
+ * band unlabelled entirely.
26
+ * 2. **`aria-hidden` is NOT used**, because the group does carry meaning: it is
27
+ * what this category looks like. Hidden decoration plus a labelled group are
28
+ * different claims and the second is true here.
29
+ * 3. **No links.** Every one of these products is linked in the grid below; a
30
+ * second set of hit targets over a title is a tap hazard on a phone and
31
+ * gives a crawler duplicate links to the same URLs.
32
+ *
33
+ * ## 🛑 Deterministic emphasis, never random
34
+ *
35
+ * The first image takes the large cell and the rest fill the small ones in
36
+ * order. `Math.random()` here would make the hero change on every render, which
37
+ * breaks SSR/hydration agreement and makes the page feel unstable — and it is
38
+ * the shape Root Cause #25 is about on the seed side.
39
+ */
40
+ export function ImageMosaic({ images, alt, className }) {
41
+ const tiles = images.filter(Boolean).slice(0, MOSAIC_MAX);
42
+ if (tiles.length < MOSAIC_MIN)
43
+ return null;
44
+ const [lead, ...rest] = tiles;
45
+ return (_jsxs(Div, { className: `appkit-image-mosaic ${className ?? ""}`, role: "img", "aria-label": alt, children: [_jsx(Div, { className: "appkit-image-mosaic__lead", children: _jsx(MediaImage, { src: lead, alt: "", size: "card" }) }), _jsx(Div, { className: "appkit-image-mosaic__rest", children: rest.map((src, i) => (_jsx(Div, { className: "appkit-image-mosaic__tile", children: _jsx(MediaImage, { src: src, alt: "", size: "thumbnail" }) }, `${src}-${i}`))) })] }));
46
+ }
@@ -0,0 +1,69 @@
1
+ /*
2
+ * D3 — the collection mosaic.
3
+ *
4
+ * 🛑 A fixed HEIGHT, not an aspect ratio. This band sits behind a hero title
5
+ * whose length varies with the category name, and an aspect-ratio box would
6
+ * change the title's vertical position per category — so the page would jump
7
+ * around as a visitor moves through the tree. A fixed height per breakpoint is
8
+ * the one shape that keeps the hero stable.
9
+ *
10
+ * 🛑 `overflow: hidden` on the grid and `object-fit: cover` on the images, so a
11
+ * portrait product photo cannot stretch the band. Every image here is a
12
+ * thumbnail of unknown aspect ratio; cropping is the only option that does not
13
+ * distort the subject.
14
+ */
15
+ .appkit-image-mosaic {
16
+ display: grid;
17
+ grid-template-columns: 2fr 1fr;
18
+ gap: var(--appkit-space-2);
19
+ height: 180px;
20
+ overflow: hidden;
21
+ border-radius: var(--appkit-radius-xl);
22
+ }
23
+
24
+ @media (min-width: 768px) {
25
+ .appkit-image-mosaic {
26
+ height: 260px;
27
+ }
28
+ }
29
+
30
+ .appkit-image-mosaic__lead,
31
+ .appkit-image-mosaic__tile {
32
+ position: relative;
33
+ overflow: hidden;
34
+ border-radius: var(--appkit-radius-lg);
35
+ background: var(--appkit-color-surface);
36
+ }
37
+
38
+ .appkit-image-mosaic__lead img,
39
+ .appkit-image-mosaic__tile img {
40
+ width: 100%;
41
+ height: 100%;
42
+ object-fit: cover;
43
+ }
44
+
45
+ .appkit-image-mosaic__rest {
46
+ display: grid;
47
+ grid-template-rows: repeat(2, 1fr);
48
+ gap: var(--appkit-space-2);
49
+ }
50
+
51
+ /*
52
+ * The small column holds at most two tiles on a phone — four 60px squares beside
53
+ * a title is visual noise rather than a preview. The remaining tiles are
54
+ * dropped by CSS rather than by a second render path, so SSR output is one
55
+ * shape at every width (no layout shift on hydration).
56
+ */
57
+ .appkit-image-mosaic__rest .appkit-image-mosaic__tile:nth-child(n + 3) {
58
+ display: none;
59
+ }
60
+
61
+ @media (min-width: 768px) {
62
+ .appkit-image-mosaic__rest {
63
+ grid-template-rows: repeat(2, 1fr);
64
+ grid-template-columns: repeat(2, 1fr);
65
+ }
66
+ .appkit-image-mosaic__rest .appkit-image-mosaic__tile:nth-child(n + 3) {
67
+ display: block;
68
+ }
69
+ }
@@ -0,0 +1,34 @@
1
+ export interface SplitTilePane {
2
+ href: string;
3
+ label: string;
4
+ /** A real photo for this pane. Absent panes are never padded with a placeholder. */
5
+ imageUrl: string;
6
+ /** A real, already-counted figure — "128 listings", never invented. */
7
+ countLabel?: string;
8
+ }
9
+ export interface SplitTileProps {
10
+ left: SplitTilePane;
11
+ right: SplitTilePane;
12
+ className?: string;
13
+ }
14
+ /**
15
+ * D3 — two real options, side by side, each a real photo and a real count.
16
+ *
17
+ * ## 🛑 Why exactly two, and why both need a photo
18
+ *
19
+ * This exists for the shape a root category with two genuinely distinct
20
+ * branches has — Beyblade vs Battle Gear under Spinning Tops, Companion
21
+ * Animals vs Live Plants under Living Collectibles — where "pick one" is a
22
+ * more honest hero than a uniform grid that treats six branches as
23
+ * interchangeable. It is NOT a general-purpose N-up component: three or more
24
+ * options is what the existing sub-category scroller already does, and
25
+ * `ImageMosaic` is the pattern for "we have no single cover photo but we have
26
+ * many product photos".
27
+ *
28
+ * 🛑 THE CALLER DECIDES WHETHER TO RENDER THIS AT ALL. `SplitTile` takes two
29
+ * required panes and renders both — it has no "only one pane" fallback and no
30
+ * placeholder image, because a tile with an invented or missing side is worse
31
+ * than not showing the pattern. A category with only one photographed branch
32
+ * falls through to the ordinary sub-category scroller instead.
33
+ */
34
+ export declare function SplitTile({ left, right, className }: SplitTileProps): import("react").JSX.Element;
@@ -0,0 +1,28 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Anchor } from "./Anchor.js";
3
+ import { Div } from "./Div.js";
4
+ import { Heading, Span } from "./Typography.js";
5
+ import { MediaImage } from "../../features/media/MediaImage.js";
6
+ /**
7
+ * D3 — two real options, side by side, each a real photo and a real count.
8
+ *
9
+ * ## 🛑 Why exactly two, and why both need a photo
10
+ *
11
+ * This exists for the shape a root category with two genuinely distinct
12
+ * branches has — Beyblade vs Battle Gear under Spinning Tops, Companion
13
+ * Animals vs Live Plants under Living Collectibles — where "pick one" is a
14
+ * more honest hero than a uniform grid that treats six branches as
15
+ * interchangeable. It is NOT a general-purpose N-up component: three or more
16
+ * options is what the existing sub-category scroller already does, and
17
+ * `ImageMosaic` is the pattern for "we have no single cover photo but we have
18
+ * many product photos".
19
+ *
20
+ * 🛑 THE CALLER DECIDES WHETHER TO RENDER THIS AT ALL. `SplitTile` takes two
21
+ * required panes and renders both — it has no "only one pane" fallback and no
22
+ * placeholder image, because a tile with an invented or missing side is worse
23
+ * than not showing the pattern. A category with only one photographed branch
24
+ * falls through to the ordinary sub-category scroller instead.
25
+ */
26
+ export function SplitTile({ left, right, className }) {
27
+ return (_jsx(Div, { className: `grid grid-cols-1 md:grid-cols-2 gap-[var(--appkit-space-3)] ${className ?? ""}`, children: [left, right].map((pane) => (_jsxs(Anchor, { href: pane.href, underline: "none", className: "group relative block overflow-hidden rounded-xl h-[160px] md:h-[220px]", children: [_jsx(MediaImage, { src: pane.imageUrl, alt: "", size: "banner", className: "absolute inset-0 h-full w-full object-cover transition-transform group-hover:scale-105" }), _jsx(Div, { surface: "overlay-md", className: "absolute inset-0" }), _jsxs(Div, { className: "absolute inset-0 flex flex-col justify-end p-[var(--appkit-space-4)]", children: [_jsx(Heading, { level: 3, color: "inverse", size: "lg", weight: "bold", children: pane.label }), pane.countLabel && (_jsx(Span, { color: "inverse", size: "xs", className: "mt-[var(--appkit-space-1)] opacity-80", children: pane.countLabel }))] })] }, pane.href))) }));
28
+ }
@@ -7,6 +7,7 @@
7
7
  @import "./ActionRow.style.css";
8
8
  @import "./Accordion.style.css";
9
9
  @import "./Breadcrumb.style.css";
10
+ @import "./ImageMosaic.style.css";
10
11
  @import "./Badge.style.css";
11
12
  @import "./Divider.style.css";
12
13
  @import "./TextLink.style.css";
@@ -103,6 +103,10 @@ export type { StarRatingProps } from "./components/StarRating";
103
103
  export { StarRating } from "./components/StarRating";
104
104
  export type { BreadcrumbProps, BreadcrumbItem } from "./components/Breadcrumb";
105
105
  export { Breadcrumb } from "./components/Breadcrumb";
106
+ export { ImageMosaic, MOSAIC_MIN } from "./components/ImageMosaic";
107
+ export type { ImageMosaicProps } from "./components/ImageMosaic";
108
+ export { SplitTile } from "./components/SplitTile";
109
+ export type { SplitTileProps, SplitTilePane } from "./components/SplitTile";
106
110
  export type { ImageLightboxProps, LightboxImage, } from "./components/ImageLightbox";
107
111
  export { ImageLightbox } from "./components/ImageLightbox";
108
112
  export type { TagInputProps } from "./components/TagInput";
package/dist/ui/index.js CHANGED
@@ -81,6 +81,10 @@ export { QuantityStepper } from "./components/QuantityStepper.js";
81
81
  export { ToastProvider, useToast } from "./components/Toast.js";
82
82
  export { StarRating } from "./components/StarRating.js";
83
83
  export { Breadcrumb } from "./components/Breadcrumb.js";
84
+ /* D3 — the collection mosaic, for a hero with no cover photo of its own. */
85
+ export { ImageMosaic, MOSAIC_MIN } from "./components/ImageMosaic.js";
86
+ /* D3 — two real branches, side by side with real photos and real counts. */
87
+ export { SplitTile } from "./components/SplitTile.js";
84
88
  export { ImageLightbox } from "./components/ImageLightbox.js";
85
89
  export { TagInput } from "./components/TagInput.js";
86
90
  export { StepperNav } from "./components/StepperNav.js";
@@ -67,6 +67,69 @@ export declare const SEARCH_REFINE_WINDOW = 250;
67
67
  * false last one. Reporting a saturated window as an exact total is the defect
68
68
  * above with a bigger number.
69
69
  */
70
+ /**
71
+ * The page fields `beginSearchWindow` overrides.
72
+ *
73
+ * Structural rather than importing `SieveModel`, so `utils/` keeps no
74
+ * dependency on `providers/db-firebase` (`audit-import-cycles`), and NOT
75
+ * `unknown` — the shapes are the same `number | string` Sieve itself accepts,
76
+ * and `unknown` here would be a type-level shrug that `audit-unknown-leakage`
77
+ * correctly refuses.
78
+ */
79
+ export interface SearchWindowModel {
80
+ page?: number | string;
81
+ pageSize?: number | string;
82
+ }
83
+ /**
84
+ * The three coupled decisions a refined search has to make, in ONE place.
85
+ *
86
+ * ## 🛑 Why this exists rather than 13 hand-written copies
87
+ *
88
+ * `refineSearchTxt` grew a real-pagination mode, and `products` was the only
89
+ * caller converted — deliberately, so the other eleven stayed byte-identical.
90
+ * The conversion is mechanical and has THREE parts that must all happen:
91
+ *
92
+ * 1. fetch a bounded WINDOW instead of the requested page;
93
+ * 2. raise `maxPageSize` to that window, or Sieve clamps the request back to
94
+ * its own limit and the window silently becomes a page again — the
95
+ * original bug with a larger constant;
96
+ * 3. pass `{page, pageSize, windowSaturated}` so the refine slices the
97
+ * requested page out and reports `truncated`.
98
+ *
99
+ * Doing 1 and 3 without 2 reads exactly like a working conversion and is the
100
+ * half that no-ops. So the three move together here, and a caller cannot have
101
+ * two of them.
102
+ *
103
+ * ```ts
104
+ * const win = beginSearchWindow(model, plan);
105
+ * const result = await this.sieveQuery(win.model, FIELDS, {
106
+ * baseQuery, defaultPageSize: 20, maxPageSize: win.maxPageSize(100),
107
+ * });
108
+ * return win.finish(result);
109
+ * ```
110
+ */
111
+ export declare function beginSearchWindow<M extends SearchWindowModel>(model: M, plan: SearchTxtPlan,
112
+ /**
113
+ * True when something OTHER than the extra search terms also has to be
114
+ * refined in memory — a filter Firestore could not express beside the
115
+ * search clause. Then the window is needed even for a single-term query.
116
+ */
117
+ opts?: {
118
+ alsoRefining?: boolean;
119
+ }): {
120
+ model: M;
121
+ maxPageSize(normal: number): number;
122
+ finish<T extends {
123
+ searchTxt?: string[];
124
+ }, R extends {
125
+ items: T[];
126
+ total: number;
127
+ totalPages: number;
128
+ hasMore: boolean;
129
+ }>(result: R, also?: (row: T) => boolean): R & {
130
+ truncated?: boolean;
131
+ };
132
+ };
70
133
  export declare function refineSearchTxt<T extends {
71
134
  searchTxt?: string[];
72
135
  }, R extends {
@@ -85,6 +148,20 @@ opts?: {
85
148
  pageSize: number;
86
149
  /** True when the fetch returned a full window, so more may exist. */
87
150
  windowSaturated: boolean;
151
+ /**
152
+ * One more AND-condition to apply over the same window, for a filter
153
+ * Firestore could not express alongside the search clause.
154
+ *
155
+ * 🛑 It exists so there is ONE refine-and-paginate implementation rather
156
+ * than a second copy per filter. Firestore permits a single array operator
157
+ * per query, so when a search and a category filter arrive together the
158
+ * search takes the clause — and the category then HAS to be refined here
159
+ * or it is simply not applied (which is exactly what used to happen:
160
+ * searching inside a category returned matches from the whole catalogue).
161
+ * The window bookkeeping, the `total`-is-a-floor rule and `truncated` are
162
+ * identical for both conditions, which is why they share this function.
163
+ */
164
+ also?: (row: T) => boolean;
88
165
  }): R & {
89
166
  truncated?: boolean;
90
167
  };
@@ -50,38 +50,58 @@ export function emptySearchResult() {
50
50
  */
51
51
  export const SEARCH_REFINE_WINDOW = 250;
52
52
  /**
53
- * AND-refine the remaining terms, then slice the requested page out.
53
+ * The three coupled decisions a refined search has to make, in ONE place.
54
54
  *
55
- * ## 🛑 The bug this closes: multi-word search lied about its own totals
55
+ * ## 🛑 Why this exists rather than 13 hand-written copies
56
56
  *
57
- * This used to filter the ALREADY-PAGINATED page and then set
58
- * `total = items.length`, `totalPages = 0|1`, `hasMore = false`. Two
59
- * consequences, both silent:
57
+ * `refineSearchTxt` grew a real-pagination mode, and `products` was the only
58
+ * caller converted — deliberately, so the other eleven stayed byte-identical.
59
+ * The conversion is mechanical and has THREE parts that must all happen:
60
60
  *
61
- * 1. A two-word query could only ever return matches that happened to fall
62
- * inside one 50-row window. Rows 51+ that matched both terms were
63
- * unreachable — not on page 2, not anywhere, because `hasMore` said there
64
- * was no page 2.
65
- * 2. The pager actively asserted completeness. A search returning 7 of 40
66
- * real matches reported "7 results" and disabled Next.
61
+ * 1. fetch a bounded WINDOW instead of the requested page;
62
+ * 2. raise `maxPageSize` to that window, or Sieve clamps the request back to
63
+ * its own limit and the window silently becomes a page again — the
64
+ * original bug with a larger constant;
65
+ * 3. pass `{page, pageSize, windowSaturated}` so the refine slices the
66
+ * requested page out and reports `truncated`.
67
67
  *
68
- * Both render as plausible results with a 200, which is why neither was
69
- * reported: a search that returns seven relevant rows looks like it worked.
68
+ * Doing 1 and 3 without 2 reads exactly like a working conversion and is the
69
+ * half that no-ops. So the three move together here, and a caller cannot have
70
+ * two of them.
70
71
  *
71
- * ## The fix, and its honest cost
72
- *
73
- * The caller over-fetches a bounded WINDOW (`SEARCH_REFINE_WINDOW`), this
74
- * function refines over it and then slices the requested page. So page 2 of a
75
- * two-word search exists, and `total` is the refined count over the window
76
- * rather than over one page.
77
- *
78
- * 🛑 `truncated` is NOT optional, and it is the part that keeps this honest.
79
- * When the window saturated, the refined `total` is a FLOOR, not a total — the
80
- * same situation the availability queries document — so the renderer must show
81
- * it as "50+" and `totalPages` must offer one more page rather than claiming a
82
- * false last one. Reporting a saturated window as an exact total is the defect
83
- * above with a bigger number.
72
+ * ```ts
73
+ * const win = beginSearchWindow(model, plan);
74
+ * const result = await this.sieveQuery(win.model, FIELDS, {
75
+ * baseQuery, defaultPageSize: 20, maxPageSize: win.maxPageSize(100),
76
+ * });
77
+ * return win.finish(result);
78
+ * ```
79
+ */
80
+ export function beginSearchWindow(model, plan,
81
+ /**
82
+ * True when something OTHER than the extra search terms also has to be
83
+ * refined in memory — a filter Firestore could not express beside the
84
+ * search clause. Then the window is needed even for a single-term query.
84
85
  */
86
+ opts) {
87
+ const refining = plan.rest.length > 0 || opts?.alsoRefining === true;
88
+ const page = Math.max(1, Number(model?.page ?? 1) || 1);
89
+ const pageSize = Math.max(1, Number(model?.pageSize ?? 20) || 20);
90
+ return {
91
+ model: refining ? { ...model, page: 1, pageSize: SEARCH_REFINE_WINDOW } : model,
92
+ maxPageSize: (normal) => (refining ? SEARCH_REFINE_WINDOW : normal),
93
+ finish: (result, also) => refineSearchTxt(result, plan.rest, refining
94
+ ? {
95
+ page,
96
+ pageSize,
97
+ // Saturated means more may exist beyond the window, so the
98
+ // refined `total` is a FLOOR and `truncated` says so.
99
+ windowSaturated: (result.items?.length ?? 0) >= SEARCH_REFINE_WINDOW,
100
+ ...(also ? { also } : {}),
101
+ }
102
+ : undefined),
103
+ };
104
+ }
85
105
  export function refineSearchTxt(result, rest,
86
106
  /**
87
107
  * Supply these to get real pagination. Omitted, the behaviour is the old
@@ -89,9 +109,11 @@ export function refineSearchTxt(result, rest,
89
109
  * until each is converted, rather than all of them changing at once.
90
110
  */
91
111
  opts) {
92
- if (rest.length === 0)
112
+ const also = opts?.also;
113
+ if (rest.length === 0 && !also)
93
114
  return result;
94
- const matched = result.items.filter((row) => matchesAllSearchTerms(row.searchTxt, rest));
115
+ const matched = result.items.filter((row) => (rest.length === 0 || matchesAllSearchTerms(row.searchTxt, rest)) &&
116
+ (!also || also(row)));
95
117
  if (!opts) {
96
118
  /*
97
119
  * The legacy shape. `total` is this page's count — a floor — and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mohasinac/appkit",
3
- "version": "4.47.0",
3
+ "version": "4.48.0",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public"