@akinon/pz-theme 2.0.143-beta.0 → 2.0.143

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.
@@ -0,0 +1,331 @@
1
+ /**
2
+ * Product card list settings (ZERO-5173): the list of cards as a slider, how
3
+ * many cards it shows at once, and the card picture zooming in on hover.
4
+ *
5
+ * WHERE THE SETTINGS LIVE. On the same levels as the card's switches
6
+ * (property-switches.ts), nearest definer wins:
7
+ *
8
+ * - the SLIDER belongs to the list — the product grid, or the Product Cards
9
+ * section around its repeated cards — because only the list can lay its
10
+ * cards out in one row. The grid reads its own properties; a section's
11
+ * settings reach the generated repeat that holds its cards through the
12
+ * property-switch pass, which writes them as `cardsSlider`. Its title
13
+ * (CardsSliderTitle) travels with them.
14
+ * - the ZOOM belongs to the card's picture, the image bound to `item.image`:
15
+ * the list switches it for every card, a card may say otherwise. Every
16
+ * Product Card ever inserted carries that binding, so a published card
17
+ * gains the setting without being re-inserted.
18
+ *
19
+ * Nothing authored means nothing written onto any block, so an unconfigured
20
+ * list renders exactly what it rendered before.
21
+ *
22
+ * Pure: the pass and the renderers read from here, and none of them is
23
+ * imported back.
24
+ */
25
+
26
+ const toText = (value: unknown): string =>
27
+ typeof value === 'string' ? value.trim() : '';
28
+
29
+ /** Keyed `cards*` so a block's own settings up the chain are never mistaken for them. */
30
+ export const CARD_LIST_PROPERTIES = {
31
+ slider: 'cardsSlider',
32
+ // The slider's title, carried the way the GANT rail (the flash deals
33
+ // slider) carries its own: the text, on or off, and its type — see
34
+ // CardsSliderTitle.
35
+ sliderShowTitle: 'cardsSliderShowTitle',
36
+ sliderTitle: 'cardsSliderTitle',
37
+ sliderTitleColor: 'cardsSliderTitleColor',
38
+ sliderTitleSize: 'cardsSliderTitleSize',
39
+ sliderTitleWeight: 'cardsSliderTitleWeight',
40
+ sliderTitleFont: 'cardsSliderTitleFont',
41
+ sliderTitleTransform: 'cardsSliderTitleTransform',
42
+ perView: 'cardsPerView',
43
+ perViewMobile: 'cardsPerViewMobile',
44
+ sliderArrows: 'cardsSliderArrows',
45
+ // The listing's grid views — see CardView: the view the list is drawn in,
46
+ // whether the shopper gets the toolbar's toggles, and the toggles' look.
47
+ view: 'cardsView',
48
+ viewModes: 'cardsViewModes',
49
+ viewTogglesAlign: 'cardsViewTogglesAlign',
50
+ viewTogglesColor: 'cardsViewTogglesColor',
51
+ viewTogglesSize: 'cardsViewTogglesSize',
52
+ viewTogglesGap: 'cardsViewTogglesGap',
53
+ viewTogglesPadding: 'cardsViewTogglesPadding',
54
+ hoverZoom: 'cardImageHoverZoom'
55
+ } as const;
56
+
57
+ /**
58
+ * The GANT listing's grid views (gant.co.uk, `utils/plp/grid-mode.ts` there)
59
+ * for a card list that is not sliding: catalogue — three across, one below
60
+ * 1024px; classic — the list's own grid (four across, two below), the
61
+ * default; archive — eight across, four below, pictures only.
62
+ *
63
+ * The designer picks the view the list is drawn in (`cardsView`). With the
64
+ * toolbar's toggles on (`cardsViewModes`) the shopper switches it, and the
65
+ * switch lives in the URL hash (`#catalogue` / `#classic` / `#archive`) as
66
+ * the listing's does, so a link opens the view it was shared in.
67
+ */
68
+ export type CardView = 'catalogue' | 'classic' | 'archive';
69
+
70
+ export const CARD_VIEWS: CardView[] = ['catalogue', 'classic', 'archive'];
71
+
72
+ export const DEFAULT_CARD_VIEW: CardView = 'classic';
73
+
74
+ /** Columns per view from 1024px up and below; classic keeps the list's grid. */
75
+ export const CARD_VIEW_COLUMNS: Record<
76
+ Exclude<CardView, 'classic'>,
77
+ { desktop: number; mobile: number }
78
+ > = {
79
+ catalogue: { desktop: 3, mobile: 1 },
80
+ archive: { desktop: 8, mobile: 4 }
81
+ };
82
+
83
+ /**
84
+ * What a part of the card says about the views: the Product Card's caption is
85
+ * `hideInCardView: 'archive'`, so the archive shows pictures only. Written on
86
+ * the block's wrapper, where its box is, so a hidden caption leaves no padding.
87
+ */
88
+ export const HIDE_IN_CARD_VIEW_PROPERTY = 'hideInCardView';
89
+ export const HIDE_IN_CARD_VIEW_ATTRIBUTE = 'data-hide-in-card-view';
90
+
91
+ export const isCardView = (value: unknown): value is CardView =>
92
+ typeof value === 'string' && (CARD_VIEWS as string[]).includes(value);
93
+
94
+ /** The view a hash names, or null — the list's own view then. */
95
+ export const cardViewFromHash = (
96
+ hash: string | null | undefined
97
+ ): CardView | null => {
98
+ const value = (hash ?? '').replace(/^#/, '').toLowerCase();
99
+ return isCardView(value) ? value : null;
100
+ };
101
+
102
+ export type CardViewTogglesAlign = 'left' | 'center' | 'right';
103
+
104
+ /** The toggles' row, as the listing's toolbar draws it by default. */
105
+ export interface CardViewToggles {
106
+ align: CardViewTogglesAlign;
107
+ color: string;
108
+ /** The glyph, pixels; the button is 8px wider (44px squares below 1024px). */
109
+ size: number;
110
+ /** Between the buttons from 1024px up; flush below, as on the listing. */
111
+ gap: number;
112
+ /** The row's padding, a CSS shorthand of plain lengths. */
113
+ padding: string;
114
+ }
115
+
116
+ export const DEFAULT_CARD_VIEW_TOGGLES: CardViewToggles = {
117
+ align: 'right',
118
+ color: '#000000',
119
+ size: 24,
120
+ gap: 6,
121
+ padding: '6px 14px 6px 24px'
122
+ };
123
+
124
+ /** The list's views: the one it is drawn in, and the toggles when offered. */
125
+ export interface CardViewSettings {
126
+ view: CardView;
127
+ toggles?: CardViewToggles;
128
+ }
129
+
130
+ export const readCardView = (raw: unknown): CardView | undefined =>
131
+ isCardView(toText(raw)) ? (toText(raw) as CardView) : undefined;
132
+
133
+ export const readCardViewTogglesAlign = (
134
+ raw: unknown
135
+ ): CardViewTogglesAlign | undefined => {
136
+ const text = toText(raw);
137
+ return text === 'left' || text === 'center' || text === 'right'
138
+ ? text
139
+ : undefined;
140
+ };
141
+
142
+ const readPixels = (raw: unknown, min: number, max: number) => {
143
+ if (raw === undefined || raw === null || raw === '') return undefined;
144
+ const value = parseFloat(String(raw));
145
+ return Number.isFinite(value) && value >= min && value <= max
146
+ ? value
147
+ : undefined;
148
+ };
149
+
150
+ export const readCardViewTogglesSize = (raw: unknown): number | undefined =>
151
+ readPixels(raw, 12, 48);
152
+
153
+ export const readCardViewTogglesGap = (raw: unknown): number | undefined =>
154
+ readPixels(raw, 0, 48);
155
+
156
+ /** One to four plain lengths — it lands in a style declaration. */
157
+ export const readCardViewTogglesPadding = (
158
+ raw: unknown
159
+ ): string | undefined => {
160
+ const text = toText(raw);
161
+ return /^(\d+(\.\d+)?(px|rem|em|%)?)(\s+\d+(\.\d+)?(px|rem|em|%)?){0,3}$/.test(
162
+ text
163
+ )
164
+ ? text
165
+ .split(/\s+/)
166
+ .map((part) => (/^\d+(\.\d+)?$/.test(part) ? `${part}px` : part))
167
+ .join(' ')
168
+ : undefined;
169
+ };
170
+
171
+ export type CardsSliderTitleTransform = 'uppercase' | 'none' | 'capitalize';
172
+
173
+ /**
174
+ * The title the slider draws on the left of its arrows. Settings of the list,
175
+ * beside its other "Cards:" settings, like the rail's own title settings — so
176
+ * a list that slides is titled from the same panel that made it slide, and
177
+ * there is no block to insert, find or lose. The canvas selects the list
178
+ * when the title is clicked.
179
+ */
180
+ export interface CardsSliderTitle {
181
+ text: string;
182
+ color: string;
183
+ /** Pixels. */
184
+ size: number;
185
+ weight: string;
186
+ /** Empty = the theme's body font, the cards' own. */
187
+ font: string;
188
+ transform: CardsSliderTitleTransform;
189
+ }
190
+
191
+ /** The rail's title: regular 14px / 1.4, black, in capitals. */
192
+ export const DEFAULT_CARDS_SLIDER_TITLE: Omit<CardsSliderTitle, 'text'> = {
193
+ color: '#000000',
194
+ size: 14,
195
+ weight: '400',
196
+ font: '',
197
+ transform: 'uppercase'
198
+ };
199
+
200
+ const TITLE_TRANSFORMS: CardsSliderTitleTransform[] = [
201
+ 'uppercase',
202
+ 'none',
203
+ 'capitalize'
204
+ ];
205
+
206
+ /** One colour value and nothing else — it lands in a style declaration. */
207
+ const PLAIN_COLOR =
208
+ /^(#[0-9a-f]{3,8}|(rgb|rgba|hsl|hsla)\([\d\s.,%/]+\)|[a-z]+)$/i;
209
+
210
+ /** The authored text; a non-string (an old on/off value) is no text at all. */
211
+ export const readCardsSliderTitleText = (raw: unknown): string | undefined =>
212
+ typeof raw === 'string' ? raw : undefined;
213
+
214
+ export const readCardsSliderTitleColor = (raw: unknown): string | undefined => {
215
+ const text = toText(raw);
216
+ return PLAIN_COLOR.test(text) ? text : undefined;
217
+ };
218
+
219
+ export const readCardsSliderTitleSize = (raw: unknown): number | undefined => {
220
+ if (raw === undefined || raw === null || raw === '') return undefined;
221
+ const size = parseFloat(String(raw));
222
+ return Number.isFinite(size) && size >= 8 && size <= 96 ? size : undefined;
223
+ };
224
+
225
+ export const readCardsSliderTitleWeight = (
226
+ raw: unknown
227
+ ): string | undefined => {
228
+ const text = toText(raw);
229
+ return /^[1-9]00$/.test(text) ? text : undefined;
230
+ };
231
+
232
+ /** A font stack; anything that could end the declaration it lands in is refused. */
233
+ export const readCardsSliderTitleFont = (raw: unknown): string | undefined => {
234
+ const text = toText(raw);
235
+ return text && text.length <= 200 && !/[;{}<>]/.test(text) ? text : undefined;
236
+ };
237
+
238
+ export const readCardsSliderTitleTransform = (
239
+ raw: unknown
240
+ ): CardsSliderTitleTransform | undefined => {
241
+ const text = toText(raw);
242
+ return (TITLE_TRANSFORMS as string[]).includes(text)
243
+ ? (text as CardsSliderTitleTransform)
244
+ : undefined;
245
+ };
246
+
247
+ /**
248
+ * `top-right` is the GANT rail's placement — the two chevrons on the right of
249
+ * the row above the cards, where a title sits on the left — and the default;
250
+ * `sides` centres one on either edge of the cards; `none` leaves the row to
251
+ * the swipe.
252
+ */
253
+ export type CardsSliderArrows = 'top-right' | 'sides' | 'none';
254
+
255
+ export const CARDS_SLIDER_ARROW_POSITIONS: CardsSliderArrows[] = [
256
+ 'top-right',
257
+ 'sides',
258
+ 'none'
259
+ ];
260
+
261
+ /** Cards in view at once, desktop (1024px and up) and below it. */
262
+ export const DEFAULT_CARDS_PER_VIEW = 4;
263
+ export const DEFAULT_CARDS_PER_VIEW_MOBILE = 2;
264
+ export const MAX_CARDS_PER_VIEW = 6;
265
+ export const MAX_CARDS_PER_VIEW_MOBILE = 3;
266
+
267
+ export interface CardsSliderSettings {
268
+ perView: number;
269
+ perViewMobile: number;
270
+ arrows: CardsSliderArrows;
271
+ /** Present only while the list shows a title: switched on, with text. */
272
+ title?: CardsSliderTitle;
273
+ }
274
+
275
+ /** `{{item.image}}` alone means the bare path, as it does for the iterator. */
276
+ const normalizeBinding = (value: unknown): string => {
277
+ const text = toText(value);
278
+ const single = text.match(/^\{\{\s*([^{}]+?)\s*\}\}$/);
279
+ return single ? single[1].trim() : text;
280
+ };
281
+
282
+ /**
283
+ * The card's picture: the image block bound to the row's main image. Not a
284
+ * flag a template sets — the binding is what makes the block the product's
285
+ * picture, and it is what the cards inserted before this existed carry.
286
+ */
287
+ export const isCardPictureBlock = (block: {
288
+ type?: string;
289
+ properties?: Record<string, unknown> | null;
290
+ }): boolean =>
291
+ block?.type === 'image' &&
292
+ normalizeBinding(block.properties?.dataBinding) === 'item.image';
293
+
294
+ /** A count as authored, capped, or undefined when nothing usable was. */
295
+ export const readCardsPerView = (
296
+ raw: unknown,
297
+ max: number
298
+ ): number | undefined => {
299
+ if (raw === undefined || raw === null || raw === '') return undefined;
300
+
301
+ const numeric = Math.round(Number(raw));
302
+ if (!Number.isFinite(numeric) || numeric < 1) return undefined;
303
+
304
+ return Math.min(numeric, max);
305
+ };
306
+
307
+ export const readCardsSliderArrows = (
308
+ raw: unknown
309
+ ): CardsSliderArrows | undefined => {
310
+ const text = toText(raw);
311
+ return (CARDS_SLIDER_ARROW_POSITIONS as string[]).includes(text)
312
+ ? (text as CardsSliderArrows)
313
+ : undefined;
314
+ };
315
+
316
+ /**
317
+ * Hover rules for the zoom, injected once per document by the renderer. Only
318
+ * where a pointer can hover, so a tap on a phone never leaves a picture stuck
319
+ * enlarged.
320
+ */
321
+ export const CARD_ZOOM_STYLE_ID = 'pz-card-zoom';
322
+
323
+ export const CARD_ZOOM_CSS =
324
+ '@media (hover:hover){' +
325
+ '[data-card-zoom] img{transition:transform .5s ease}' +
326
+ '[data-card-zoom]:hover img{transform:scale(var(--card-zoom-scale,1.08))}' +
327
+ '}' +
328
+ '@media (prefers-reduced-motion:reduce){[data-card-zoom] img{transition:none}}';
329
+
330
+ /** The toggles' colour: one colour value, as for the slider title. */
331
+ export const readCardViewTogglesColor = readCardsSliderTitleColor;
@@ -1504,7 +1504,12 @@ export const readRowLookups = (
1504
1504
  export const normalizeCollectionRow = (
1505
1505
  item: Record<string, unknown>,
1506
1506
  index: number,
1507
- options: { lookups?: ListingValueLookups; format?: RowMoneyFormat } = {}
1507
+ options: {
1508
+ lookups?: ListingValueLookups;
1509
+ format?: RowMoneyFormat;
1510
+ /** The repeat's rows, for a brand's swatch colours (utils/swatch-colors). */
1511
+ rows?: unknown[];
1512
+ } = {}
1508
1513
  ): Record<string, unknown> => {
1509
1514
  if (!isRecord(item) || Object.keys(item).length === 0) return item;
1510
1515
 
@@ -1525,7 +1530,8 @@ export const normalizeCollectionRow = (
1525
1530
  currency: typeof currency === 'string' ? currency : undefined,
1526
1531
  locale: options.format?.locale,
1527
1532
  money: options.format?.money,
1528
- lookups: options.lookups
1533
+ lookups: options.lookups,
1534
+ rows: options.rows
1529
1535
  });
1530
1536
  if (!normalized) return flat;
1531
1537
 
@@ -1688,7 +1694,8 @@ export const buildIteratorBlock = ({
1688
1694
  const item = scopesProducts
1689
1695
  ? normalizeCollectionRow(rawItem as Record<string, unknown>, index, {
1690
1696
  lookups: rowLookups,
1691
- format: rowFormat
1697
+ format: rowFormat,
1698
+ rows: displayItems
1692
1699
  })
1693
1700
  : rawItem;
1694
1701
 
@@ -16,13 +16,19 @@ import { buildClientRequestUrl } from '@akinon/next/utils';
16
16
  * live rows at all: only while the editor reports the section's pick as
17
17
  * saved (`dataSourceSaved`). Until an unsaved pick is published, the section's
18
18
  * widget is still bound to the previous collection, so its live answer would
19
- * be the wrong rows — the picked snapshot is shown instead.
19
+ * be the wrong rows. An unsaved pick shows the rows of the collection's
20
+ * PREVIEW WIDGET instead, when the editor prepared one (see
21
+ * `collectionPreviewWidget`), and the picked snapshot otherwise.
20
22
  *
21
23
  * Client-safe: no server-only imports. The server loader imports
22
24
  * `carriesProducts` from here so both sides apply one acceptance rule.
23
25
  */
24
26
 
25
- /** Rows kept per `<sectionId>|<bindingKey>` for the designer canvas. */
27
+ /**
28
+ * Rows kept for the designer canvas: a section's own answer per
29
+ * `<sectionId>|<bindingKey>`, a preview widget's per its slug (which holds no
30
+ * `|`, so the two can never collide).
31
+ */
26
32
  export type LiveRowsStore = Map<string, unknown>;
27
33
 
28
34
  /**
@@ -71,6 +77,68 @@ export const collectionBindingKey = (dataSource: unknown): string | null => {
71
77
  export const liveRowsEntryKey = (sectionId: string, bindingKey: string) =>
72
78
  `${sectionId}|${bindingKey}`;
73
79
 
80
+ /**
81
+ * The hidden widget the theme editor keeps bound to one collection binding,
82
+ * so an UNSAVED pick can be shown with the rows the channel will give it once
83
+ * published (pz-dynamic-widget services/collection-preview-widget.ts builds
84
+ * the same slug — keep the two in step).
85
+ *
86
+ * WHY IT EXISTS. Before publish nothing else answers with the channel's rows
87
+ * for a collection: the section's widget is still bound to the previous one,
88
+ * the storefront lists a collection only through a widget, and the editor's
89
+ * snapshot comes from Omnitron's item list — the catalogue's rows, out-of-stock
90
+ * variants and old prices included, in its own order. So an unsaved pick
91
+ * showed different products from the ones the section showed once published.
92
+ * The preview widget is bound exactly as publishing binds the section
93
+ * (`product_collection` and `product_limit`) and sits in no placeholder, so no
94
+ * page ever renders it.
95
+ */
96
+ export const COLLECTION_PREVIEW_WIDGET_PREFIX = 'theme-editor-collection-';
97
+
98
+ const POSITIVE_INTEGER = /^[1-9]\d*$/;
99
+
100
+ const integerText = (value: unknown): string | null => {
101
+ const text =
102
+ typeof value === 'number' || typeof value === 'string' ? String(value) : '';
103
+ return POSITIVE_INTEGER.test(text) ? text : null;
104
+ };
105
+
106
+ /**
107
+ * `theme-editor-collection-<pk>-<productLimit>`: one widget per binding, never
108
+ * re-pointed, so its slug alone says what it answers for. `null` for anything
109
+ * that is not a positive integer pair.
110
+ */
111
+ export const collectionPreviewWidgetSlug = (
112
+ pk: unknown,
113
+ productLimit: unknown
114
+ ): string | null => {
115
+ const pkText = integerText(pk);
116
+ const limitText = integerText(productLimit);
117
+
118
+ return pkText && limitText
119
+ ? `${COLLECTION_PREVIEW_WIDGET_PREFIX}${pkText}-${limitText}`
120
+ : null;
121
+ };
122
+
123
+ /**
124
+ * The preview widget the canvas may ask for this source: the slug the editor
125
+ * stamped on it (`collection.previewWidget`) once that widget was in place,
126
+ * and only while it still names the source's current binding. A record
127
+ * re-pointed by anything that prepared no widget keeps the stamp of the
128
+ * binding it left, and that widget's rows are another collection's.
129
+ */
130
+ export const collectionPreviewWidget = (dataSource: unknown): string | null => {
131
+ const collection = readCollection(dataSource);
132
+ if (!collection) return null;
133
+
134
+ const slug = collectionPreviewWidgetSlug(
135
+ collection.pk,
136
+ collection.productLimit
137
+ );
138
+
139
+ return slug && collection.previewWidget === slug ? slug : null;
140
+ };
141
+
74
142
  export interface LiveRowsRequest {
75
143
  path: string;
76
144
  /**
@@ -114,42 +182,81 @@ export const liveRowsPaths = (
114
182
  return paths;
115
183
  };
116
184
 
185
+ /**
186
+ * One endpoint's answer, or `null` when it gives none that is usable — also
187
+ * after `timeoutMs`, when one is given. Cache-busted, so an answer asked for
188
+ * right after a write is not a copy from before it. Never rejects.
189
+ */
190
+ const askWidget = async (
191
+ { path, requireProducts }: LiveRowsRequest,
192
+ timeoutMs?: number
193
+ ): Promise<unknown | null> => {
194
+ let answer: unknown = null;
195
+ const controller =
196
+ timeoutMs && typeof AbortController !== 'undefined'
197
+ ? new AbortController()
198
+ : null;
199
+ const timer = controller
200
+ ? setTimeout(() => controller.abort(), timeoutMs)
201
+ : null;
202
+
203
+ try {
204
+ const response = await fetch(
205
+ buildClientRequestUrl(path, { cache: false }),
206
+ controller ? { signal: controller.signal } : undefined
207
+ );
208
+ if (response.ok) {
209
+ answer = await response.json();
210
+ }
211
+ } catch {
212
+ answer = null;
213
+ } finally {
214
+ if (timer) clearTimeout(timer);
215
+ }
216
+
217
+ if (!answer || typeof answer !== 'object') return null;
218
+ if (requireProducts && !carriesProducts(answer)) return null;
219
+
220
+ return answer;
221
+ };
222
+
117
223
  /**
118
224
  * Fetches a section's live rows from the browser, walking `liveRowsPaths` in
119
- * order. Cache-busted, so an answer asked for right after a publish is not a
120
- * copy from before it. Resolves `null` when no endpoint gives a usable answer;
121
- * never rejects.
225
+ * order. Resolves `null` when no endpoint gives a usable answer; never
226
+ * rejects.
122
227
  */
123
228
  export const fetchDesignerLiveRows = async (
124
229
  sectionId: string,
125
230
  collection: { slug?: unknown } | null | undefined
126
231
  ): Promise<unknown | null> => {
127
- for (const { path, requireProducts } of liveRowsPaths(
128
- sectionId,
129
- collection
130
- )) {
131
- let answer: unknown = null;
132
-
133
- try {
134
- const response = await fetch(
135
- buildClientRequestUrl(path, { cache: false })
136
- );
137
- if (response.ok) {
138
- answer = await response.json();
139
- }
140
- } catch {
141
- answer = null;
142
- }
143
-
144
- if (!answer || typeof answer !== 'object') continue;
145
- if (requireProducts && !carriesProducts(answer)) continue;
146
-
147
- return answer;
232
+ for (const request of liveRowsPaths(sectionId, collection)) {
233
+ const answer = await askWidget(request);
234
+ if (answer !== null) return answer;
148
235
  }
149
236
 
150
237
  return null;
151
238
  };
152
239
 
240
+ /**
241
+ * How long the canvas waits for a preview widget before it lets the snapshot
242
+ * through: the section holds its previous rows meanwhile (see
243
+ * `hangDesignerDataSource`), and must not hold them forever.
244
+ */
245
+ export const PREVIEW_ROWS_TIMEOUT_MS = 8000;
246
+
247
+ /**
248
+ * A preview widget's rows (`collectionPreviewWidget`), taken only when it
249
+ * carries products: a collection the channel serves nothing from leaves the
250
+ * canvas on the snapshot, which is also what the storefront falls back to.
251
+ */
252
+ export const fetchDesignerPreviewRows = (
253
+ previewWidget: string
254
+ ): Promise<unknown | null> =>
255
+ askWidget(
256
+ { path: `/widgets/${previewWidget}/`, requireProducts: true },
257
+ PREVIEW_ROWS_TIMEOUT_MS
258
+ );
259
+
153
260
  /**
154
261
  * The live rows the server already wrote into the document, keyed by the
155
262
  * binding the server resolved them for. Seeding from these is what lets a
@@ -177,11 +284,23 @@ export const seedLiveRows = (
177
284
  * The data source the designer canvas renders a section with.
178
285
  *
179
286
  * `raw` is the source record as the editor (or theme-config) holds it. Its
180
- * `details.collection.data` is dropped, and live rows are put back only when
181
- * the editor reports the section's pick as saved AND rows are held for the
182
- * binding the record names now. A late answer for a binding the section has
183
- * since left is stored under that binding's key, so it can never be attached
184
- * to the new one.
287
+ * `details.collection.data` is dropped, and live rows are put back only for
288
+ * the binding the record names now:
289
+ *
290
+ * - the section's own answer, when the editor reports the pick as saved;
291
+ * - otherwise the rows of the binding's preview widget, when the record names
292
+ * one and they are held. They are the section's rows-to-be before publish,
293
+ * and the same rows as its own answer after, until that answer is in.
294
+ *
295
+ * WHILE AN UNSAVED PICK'S PREVIEW ROWS ARE ON THEIR WAY, a caller that passes
296
+ * `hold` gets back `hold.previous` — what the section was drawn with until now
297
+ * (nothing, for a section that had no source) — so a pick changes the
298
+ * products once, straight to the right ones, instead of showing the snapshot
299
+ * first and swapping. A preview widget that gave no rows is held as `null`,
300
+ * which lets the snapshot through.
301
+ *
302
+ * A late answer for a binding the section has since left is stored under that
303
+ * binding's key, so it can never be attached to the new one.
185
304
  *
186
305
  * A record that carries no snapshot keeps the `data` it came with: there is
187
306
  * nothing to protect, and those rows are what the canvas showed before and
@@ -190,16 +309,32 @@ export const seedLiveRows = (
190
309
  export const hangDesignerDataSource = (
191
310
  section: { id?: string; dataSourceSaved?: unknown } | null | undefined,
192
311
  raw: any,
193
- liveRows: LiveRowsStore
312
+ liveRows: LiveRowsStore,
313
+ hold?: { previous: unknown }
194
314
  ): any => {
195
315
  const collection = readCollection(raw);
196
316
  if (!collection) return raw;
197
317
 
318
+ const saved = section?.dataSourceSaved === true;
198
319
  const bindingKey = collectionBindingKey(raw);
199
- const rows =
200
- section?.dataSourceSaved === true && section.id && bindingKey
320
+ const sectionRows =
321
+ saved && section?.id && bindingKey
201
322
  ? liveRows.get(liveRowsEntryKey(section.id, bindingKey))
202
323
  : undefined;
324
+ const previewWidget = collectionPreviewWidget(raw);
325
+ // `undefined` until the preview widget answered, `null` if it gave nothing.
326
+ const previewRows = previewWidget ? liveRows.get(previewWidget) : undefined;
327
+ const rows = sectionRows !== undefined ? sectionRows : previewRows ?? undefined;
328
+
329
+ if (
330
+ rows === undefined &&
331
+ hold &&
332
+ !saved &&
333
+ previewWidget &&
334
+ previewRows === undefined
335
+ ) {
336
+ return hold.previous;
337
+ }
203
338
 
204
339
  if (rows !== undefined) {
205
340
  return {