@stapel/listings-react 0.30.1 → 0.30.3
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.
- package/CHANGELOG.md +55 -0
- package/dist/default/cardGallery.d.ts +67 -4
- package/dist/default/cardGallery.d.ts.map +1 -1
- package/dist/default/cardGallery.js +197 -18
- package/dist/default/cardGallery.js.map +1 -1
- package/dist/default/detailGallery.d.ts.map +1 -1
- package/dist/default/detailGallery.js +9 -0
- package/dist/default/detailGallery.js.map +1 -1
- package/dist/default/index.d.ts +1 -1
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +1 -1
- package/dist/default/index.js.map +1 -1
- package/dist/headless/ListingComposer.d.ts.map +1 -1
- package/dist/headless/ListingComposer.js +39 -4
- package/dist/headless/ListingComposer.js.map +1 -1
- package/dist/model/draft.d.ts +10 -7
- package/dist/model/draft.d.ts.map +1 -1
- package/dist/model/draft.js +84 -19
- package/dist/model/draft.js.map +1 -1
- package/llms.txt +1 -1
- package/manifest.json +1 -1
- package/nav-manifest.json +1 -1
- package/package.json +2 -2
- package/src/analytics/generated/events.json +1 -1
- package/src/default/cardGallery.ts +222 -18
- package/src/default/detailGallery.ts +9 -0
- package/src/default/index.ts +4 -0
- package/src/headless/ListingComposer.tsx +44 -4
- package/src/model/draft.ts +89 -18
|
@@ -34,6 +34,31 @@
|
|
|
34
34
|
* {@link SWIPE_AXIS_RATIO}. A diagonal thumb scrolling the feed changes no
|
|
35
35
|
* photograph.
|
|
36
36
|
*
|
|
37
|
+
* ── HOW FAR IS FAR ENOUGH, and how many photographs that buys ────────────
|
|
38
|
+
*
|
|
39
|
+
* Intent is not a commit. A drag that has declared itself horizontal still
|
|
40
|
+
* has to EARN the photograph, and the price is a fraction of the photograph
|
|
41
|
+
* itself — {@link SWIPE_COMMIT_FRACTION} of the SLIDE's own measured width,
|
|
42
|
+
* never a pixel constant. The constant was the defect, and measurably so: a
|
|
43
|
+
* one-column search card on a 390px phone is a ~351px slide (the well less
|
|
44
|
+
* the carousel's 8% peek), so a fixed 32px priced a photograph at 9% of
|
|
45
|
+
* itself; the same 32px on the two-column home feed is 19% of a ~170px slide.
|
|
46
|
+
* One number, two different gestures, and the cheap one is the one the owner
|
|
47
|
+
* met — "it feels like 10%", and the middle photograph flying past.
|
|
48
|
+
*
|
|
49
|
+
* Two further rules, and both are about a gesture being ONE decision:
|
|
50
|
+
*
|
|
51
|
+
* - A flick is a choice too. Under the distance, a drag thrown faster than
|
|
52
|
+
* {@link SWIPE_FLICK_VELOCITY} still advances — the speed says the intent
|
|
53
|
+
* the distance did not.
|
|
54
|
+
* - ONE photograph per gesture, and one only. The old handler moved its own
|
|
55
|
+
* origin on each commit, so a drag across three slide widths advanced
|
|
56
|
+
* three photographs (and a fling across the card advanced ten). A gesture
|
|
57
|
+
* now commits at most once, and a second photograph costs a second press.
|
|
58
|
+
*
|
|
59
|
+
* Below the threshold the strip goes back to the picture it was showing —
|
|
60
|
+
* a refused gesture must leave the strip where it found it, not halfway.
|
|
61
|
+
*
|
|
37
62
|
* ── What neither gesture touches ─────────────────────────────────────────
|
|
38
63
|
*
|
|
39
64
|
* The keyboard, and the card's single link target. The strip underneath is
|
|
@@ -59,14 +84,56 @@ export const CARD_GALLERY_STYLE_HREF = "stapel-listings-card-gallery";
|
|
|
59
84
|
export const SCRUB_MEDIA = "(hover: hover) and (pointer: fine)";
|
|
60
85
|
|
|
61
86
|
/**
|
|
62
|
-
* How far a finger travels before it
|
|
87
|
+
* How far a finger travels before it is a GESTURE at all, in CSS pixels.
|
|
63
88
|
*
|
|
64
89
|
* Under this, a drag is a tap that wobbled — and a tap on a card is a
|
|
65
90
|
* navigation, so a low threshold does not change a photograph, it changes one
|
|
66
91
|
* and then leaves the listing.
|
|
92
|
+
*
|
|
93
|
+
* This is the floor, not the price: what actually buys a photograph is
|
|
94
|
+
* {@link SWIPE_COMMIT_FRACTION} of the slide, or a flick above
|
|
95
|
+
* {@link SWIPE_FLICK_VELOCITY}. A pixel count cannot be the price, because
|
|
96
|
+
* the same 32px is a third of a card in a four-column desktop grid and a
|
|
97
|
+
* twelfth of one on a phone.
|
|
67
98
|
*/
|
|
68
99
|
export const SWIPE_MIN_PX = 32;
|
|
69
100
|
|
|
101
|
+
/**
|
|
102
|
+
* How much of ONE PHOTOGRAPH a slow drag must cover to turn it, as a fraction
|
|
103
|
+
* of that photograph's own measured width.
|
|
104
|
+
*
|
|
105
|
+
* The owner's ruling, third pass: "maybe not 75, maybe 30 — right now it
|
|
106
|
+
* feels like 10%". 10% is what the old fixed 32px came to on a phone slide of
|
|
107
|
+
* ~350px, and it is why a middle photograph flew past on a gesture aimed at
|
|
108
|
+
* the feed. 0.3 is far enough that the drag is unmistakably a drag and short
|
|
109
|
+
* enough that a thumb can make it without crossing the whole card.
|
|
110
|
+
*/
|
|
111
|
+
export const SWIPE_COMMIT_FRACTION = 0.3;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The speed, in CSS pixels per millisecond, at which a SHORT drag still turns
|
|
115
|
+
* the photograph.
|
|
116
|
+
*
|
|
117
|
+
* 0.5 px/ms — 500 px/s — is the owner's number and sits in the empty band
|
|
118
|
+
* between the two gestures it has to tell apart: a deliberate slow drag runs
|
|
119
|
+
* at 100–300 px/s (a thumb crossing a 350px card in one to three seconds),
|
|
120
|
+
* and a flick people expect to throw the strip runs at 800–2000 px/s. Nothing
|
|
121
|
+
* a person means as a careful drag arrives here, and nothing they mean as a
|
|
122
|
+
* flick falls under it.
|
|
123
|
+
*/
|
|
124
|
+
export const SWIPE_FLICK_VELOCITY = 0.5;
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The window the speed is measured over, in milliseconds.
|
|
128
|
+
*
|
|
129
|
+
* A flick is what the finger was doing AS IT LEFT, not the average of the
|
|
130
|
+
* whole gesture: a drag that crawls for a second and is then thrown would
|
|
131
|
+
* average out to a crawl, and the throw is the part the person meant. One
|
|
132
|
+
* moving window of roughly six frames is long enough to survive a single
|
|
133
|
+
* jittery sample and short enough to still be about the end of the gesture.
|
|
134
|
+
*/
|
|
135
|
+
export const SWIPE_VELOCITY_WINDOW_MS = 100;
|
|
136
|
+
|
|
70
137
|
/**
|
|
71
138
|
* How much more horizontal than vertical a drag must be to count.
|
|
72
139
|
*
|
|
@@ -90,18 +157,60 @@ export function segmentIndex(offsetX: number, width: number, count: number): num
|
|
|
90
157
|
|
|
91
158
|
/**
|
|
92
159
|
* A drag → the number of photographs it asks for: `1` forward, `-1` back, `0`
|
|
93
|
-
* for a drag that has not
|
|
160
|
+
* for a drag that has not earned one.
|
|
94
161
|
*
|
|
95
162
|
* Dragging LEFT advances, the direction the content moves under the finger —
|
|
96
|
-
* the same mapping the native scroller has.
|
|
163
|
+
* the same mapping the native scroller has. The answer is never outside
|
|
164
|
+
* `-1…1`: a gesture is worth one photograph however far it travelled, which
|
|
165
|
+
* is what stops a fling crossing the whole strip.
|
|
166
|
+
*
|
|
167
|
+
* @param dx Horizontal travel from the press, signed, in CSS pixels.
|
|
168
|
+
* @param dy Vertical travel from the press, signed.
|
|
169
|
+
* @param slideWidth The measured width of ONE slide. `0` means it could not
|
|
170
|
+
* be measured (an unlaid-out strip, a server render) — and an unknown width
|
|
171
|
+
* has no fraction, so the decision falls back to {@link SWIPE_MIN_PX}
|
|
172
|
+
* alone rather than to an invented number.
|
|
173
|
+
* @param velocity The finger's recent speed in CSS pixels per millisecond.
|
|
97
174
|
*/
|
|
98
|
-
export function swipeStep(
|
|
175
|
+
export function swipeStep(
|
|
176
|
+
dx: number,
|
|
177
|
+
dy: number,
|
|
178
|
+
slideWidth = 0,
|
|
179
|
+
velocity = 0
|
|
180
|
+
): -1 | 0 | 1 {
|
|
99
181
|
const across = Math.abs(dx);
|
|
182
|
+
// Is this a gesture at all — past the tap wobble, and across rather than
|
|
183
|
+
// down the page.
|
|
100
184
|
if (across < SWIPE_MIN_PX) return 0;
|
|
101
185
|
if (across <= Math.abs(dy) * SWIPE_AXIS_RATIO) return 0;
|
|
186
|
+
// Has it earned a photograph: the distance rule, measured against THIS
|
|
187
|
+
// strip's slide, or the speed rule when the distance is short.
|
|
188
|
+
const far = slideWidth > 0 ? across >= slideWidth * SWIPE_COMMIT_FRACTION : true;
|
|
189
|
+
if (!far && velocity < SWIPE_FLICK_VELOCITY) return 0;
|
|
102
190
|
return dx < 0 ? 1 : -1;
|
|
103
191
|
}
|
|
104
192
|
|
|
193
|
+
/**
|
|
194
|
+
* The width of ONE slide inside this gallery's strip, in CSS pixels.
|
|
195
|
+
*
|
|
196
|
+
* Read off the SLIDE, which is the house rule (§83: geometry from the
|
|
197
|
+
* element's width, never the viewport's) and here also the only correct
|
|
198
|
+
* answer: a slide is the well less the carousel's peek, so the well's width
|
|
199
|
+
* is 8% too generous and a viewport-derived number is not about this card at
|
|
200
|
+
* all — the same card is full-bleed on a phone and a quarter of a row on a
|
|
201
|
+
* desktop grid.
|
|
202
|
+
*
|
|
203
|
+
* `0` when nothing can be measured. That is a refusal to guess, not a
|
|
204
|
+
* measurement: {@link swipeStep} falls back to the pixel floor for it.
|
|
205
|
+
*/
|
|
206
|
+
export function measureSlideWidth(box: HTMLElement): number {
|
|
207
|
+
const strip = box.querySelector<HTMLElement>("[data-stapel-carousel-strip]");
|
|
208
|
+
if (strip === null) return 0;
|
|
209
|
+
const slide = strip.children.item(0);
|
|
210
|
+
const width = slide === null ? 0 : slide.getBoundingClientRect().width;
|
|
211
|
+
return width > 0 ? width : 0;
|
|
212
|
+
}
|
|
213
|
+
|
|
105
214
|
/** Does this environment have a real pointer? `false` where there is no
|
|
106
215
|
* `matchMedia` to ask (a server render, an old jsdom), which is the safe
|
|
107
216
|
* side: a scrub that does not happen costs a hover, a scrub on a phone is a
|
|
@@ -198,6 +307,63 @@ function showSlide(box: HTMLElement, index: number, instant: boolean): void {
|
|
|
198
307
|
}
|
|
199
308
|
}
|
|
200
309
|
|
|
310
|
+
/** One position of the finger, in time — the raw material of a flick. */
|
|
311
|
+
interface Sample {
|
|
312
|
+
readonly x: number;
|
|
313
|
+
readonly t: number;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** The drag in progress. Everything a release has to decide on. */
|
|
317
|
+
interface Drag {
|
|
318
|
+
/** Where the finger went down. */
|
|
319
|
+
readonly x: number;
|
|
320
|
+
readonly y: number;
|
|
321
|
+
/** One slide's width, measured ONCE at the press: a strip whose geometry
|
|
322
|
+
* changed mid-gesture would move the goalposts under the finger. */
|
|
323
|
+
readonly width: number;
|
|
324
|
+
/** The recent trail, newest last — see {@link SWIPE_VELOCITY_WINDOW_MS}. */
|
|
325
|
+
samples: Sample[];
|
|
326
|
+
/** Has this gesture already spent its one photograph. */
|
|
327
|
+
committed: boolean;
|
|
328
|
+
/** Has it declared horizontal intent — the only kind of refused gesture
|
|
329
|
+
* that is worth snapping back from. */
|
|
330
|
+
crossed: boolean;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** When this event happened, on whatever clock the environment has. A UA
|
|
334
|
+
* stamps every input event; the fallback is for a synthesised one. */
|
|
335
|
+
function stampOf(event: { timeStamp: number }): number {
|
|
336
|
+
const stamp = event.timeStamp;
|
|
337
|
+
if (Number.isFinite(stamp) && stamp > 0) return stamp;
|
|
338
|
+
return typeof performance === "object" && typeof performance.now === "function"
|
|
339
|
+
? performance.now()
|
|
340
|
+
: Date.now();
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Record a position, and forget the ones the speed window has passed. */
|
|
344
|
+
function sample(drag: Drag, x: number, t: number): void {
|
|
345
|
+
drag.samples.push({ x, t });
|
|
346
|
+
// Drop the oldest only while the NEXT one is still inside the window, so
|
|
347
|
+
// the trail always spans at least one interval to divide by.
|
|
348
|
+
while (drag.samples.length > 2) {
|
|
349
|
+
const second = drag.samples[1];
|
|
350
|
+
if (second === undefined || t - second.t <= SWIPE_VELOCITY_WINDOW_MS) break;
|
|
351
|
+
drag.samples.shift();
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** The finger's speed over the trail, in CSS pixels per millisecond. `0`
|
|
356
|
+
* where the environment gave no usable clock — an unknown speed is not a
|
|
357
|
+
* flick. */
|
|
358
|
+
function velocityOf(drag: Drag): number {
|
|
359
|
+
const first = drag.samples[0];
|
|
360
|
+
const last = drag.samples[drag.samples.length - 1];
|
|
361
|
+
if (first === undefined || last === undefined) return 0;
|
|
362
|
+
const elapsed = last.t - first.t;
|
|
363
|
+
if (elapsed <= 0) return 0;
|
|
364
|
+
return Math.abs(last.x - first.x) / elapsed;
|
|
365
|
+
}
|
|
366
|
+
|
|
201
367
|
/**
|
|
202
368
|
* The gallery gestures for a media well holding `count` photographs.
|
|
203
369
|
*
|
|
@@ -208,9 +374,9 @@ export function useCardGallery(count: number): CardGallery {
|
|
|
208
374
|
const ref = useRef<HTMLDivElement | null>(null);
|
|
209
375
|
const [active, setActive] = useState(0);
|
|
210
376
|
const [scrubbing, setScrubbing] = useState(false);
|
|
211
|
-
// The
|
|
212
|
-
//
|
|
213
|
-
const
|
|
377
|
+
// The drag in progress, or `null`. A ref rather than state: it changes on
|
|
378
|
+
// every move and no render depends on it.
|
|
379
|
+
const drag = useRef<Drag | null>(null);
|
|
214
380
|
/**
|
|
215
381
|
* THE INDEX THIS HOOK ASKED FOR, and the whole of the fix.
|
|
216
382
|
*
|
|
@@ -276,15 +442,23 @@ export function useCardGallery(count: number): CardGallery {
|
|
|
276
442
|
request(segmentIndex(event.clientX - rect.left, rect.width, count));
|
|
277
443
|
return;
|
|
278
444
|
}
|
|
279
|
-
const
|
|
280
|
-
if (
|
|
281
|
-
|
|
445
|
+
const current = drag.current;
|
|
446
|
+
if (current === null) return;
|
|
447
|
+
sample(current, event.clientX, stampOf(event));
|
|
448
|
+
// ONE PHOTOGRAPH PER GESTURE. The trail keeps being recorded — a
|
|
449
|
+
// release still wants to know how the finger was moving — but the
|
|
450
|
+
// decision below is made once and the rest of the drag is scenery.
|
|
451
|
+
if (current.committed) return;
|
|
452
|
+
const dx = event.clientX - current.x;
|
|
453
|
+
const dy = event.clientY - current.y;
|
|
454
|
+
if (Math.abs(dx) >= SWIPE_MIN_PX && Math.abs(dx) > Math.abs(dy) * SWIPE_AXIS_RATIO) {
|
|
455
|
+
current.crossed = true;
|
|
456
|
+
}
|
|
457
|
+
const step = swipeStep(dx, dy, current.width, velocityOf(current));
|
|
282
458
|
if (step === 0) return;
|
|
283
|
-
|
|
284
|
-
// photograph per threshold rather than one per gesture.
|
|
285
|
-
origin.current = { x: event.clientX, y: event.clientY };
|
|
459
|
+
current.committed = true;
|
|
286
460
|
setScrubbing(false);
|
|
287
|
-
request((
|
|
461
|
+
request((at) => Math.min(count - 1, Math.max(0, at + step)));
|
|
288
462
|
},
|
|
289
463
|
[count, fine, many, request]
|
|
290
464
|
);
|
|
@@ -292,18 +466,48 @@ export function useCardGallery(count: number): CardGallery {
|
|
|
292
466
|
const onPointerDown = useCallback(
|
|
293
467
|
(event: ReactPointerEvent<HTMLDivElement>): void => {
|
|
294
468
|
if (!many || event.pointerType === "mouse") return;
|
|
295
|
-
|
|
469
|
+
const box = ref.current;
|
|
470
|
+
drag.current = {
|
|
471
|
+
x: event.clientX,
|
|
472
|
+
y: event.clientY,
|
|
473
|
+
// Measured here, from the slide itself, and held for the gesture.
|
|
474
|
+
width: box === null ? 0 : measureSlideWidth(box),
|
|
475
|
+
samples: [{ x: event.clientX, t: stampOf(event) }],
|
|
476
|
+
committed: false,
|
|
477
|
+
crossed: false,
|
|
478
|
+
};
|
|
296
479
|
},
|
|
297
480
|
[many]
|
|
298
481
|
);
|
|
299
482
|
|
|
483
|
+
/**
|
|
484
|
+
* The finger leaves.
|
|
485
|
+
*
|
|
486
|
+
* A gesture that earned its photograph already took it, mid-drag, the
|
|
487
|
+
* moment it crossed the threshold. What is left here is the REFUSED one: a
|
|
488
|
+
* drag that went sideways far enough to be read as a swipe and then stopped
|
|
489
|
+
* short. The strip goes back to the photograph it was showing, because a
|
|
490
|
+
* refused gesture that leaves the strip halfway is the defect wearing the
|
|
491
|
+
* other face.
|
|
492
|
+
*
|
|
493
|
+
* A release with no drag behind it touches nothing at all — that is the
|
|
494
|
+
* guard probe p23 bought: `pointerup` also arrives after a NATIVE scroll
|
|
495
|
+
* this hook never started, and scrolling "back" there means undoing the
|
|
496
|
+
* person's own swipe.
|
|
497
|
+
*/
|
|
300
498
|
const endDrag = useCallback((): void => {
|
|
301
|
-
|
|
302
|
-
|
|
499
|
+
const current = drag.current;
|
|
500
|
+
drag.current = null;
|
|
501
|
+
if (current === null || !many) return;
|
|
502
|
+
if (current.committed || !current.crossed) return;
|
|
503
|
+
const box = ref.current;
|
|
504
|
+
if (box === null) return;
|
|
505
|
+
showSlide(box, active, false);
|
|
506
|
+
}, [active, many]);
|
|
303
507
|
|
|
304
508
|
const onPointerLeave = useCallback(
|
|
305
509
|
(event: ReactPointerEvent<HTMLDivElement>): void => {
|
|
306
|
-
|
|
510
|
+
drag.current = null;
|
|
307
511
|
if (!many) return;
|
|
308
512
|
// THE REWIND IS A HOVER RULE, AND ONLY A HOVER RULE.
|
|
309
513
|
//
|
|
@@ -215,6 +215,15 @@ export function detailGalleryCss(): string {
|
|
|
215
215
|
.${LISTINGS_GALLERY_CLASS}[data-gallery-layout="strip"] > * {
|
|
216
216
|
flex: 0 0 ${LISTINGS_GALLERY_STRIP_BASIS};
|
|
217
217
|
scroll-snap-align: start;
|
|
218
|
+
/* EVERY PHOTOGRAPH IS A DESTINATION, not a waypoint. A mandatory snap type
|
|
219
|
+
alone only promises the strip will COME TO REST on a snap point — a fling
|
|
220
|
+
with any momentum behind it still crosses two or three photographs on its
|
|
221
|
+
way there, which is the owner's "the middle photo flies past" in its
|
|
222
|
+
native form. "always" makes the scroller stop at the first snap point it
|
|
223
|
+
reaches, so one gesture is one photograph here for the same reason the
|
|
224
|
+
card's gesture layer clamps its own step to one. The same declaration
|
|
225
|
+
SkinCarousel already carries on its slides. */
|
|
226
|
+
scroll-snap-stop: always;
|
|
218
227
|
}
|
|
219
228
|
.${LISTINGS_GALLERY_FRAME_CLASS} {
|
|
220
229
|
position: relative;
|
package/src/default/index.ts
CHANGED
|
@@ -176,9 +176,13 @@ export type { CardBadgesProps } from "./CardBadges.js";
|
|
|
176
176
|
// ── the card gallery's two gestures ─────────────────────────────────────────
|
|
177
177
|
export {
|
|
178
178
|
SWIPE_AXIS_RATIO,
|
|
179
|
+
SWIPE_COMMIT_FRACTION,
|
|
180
|
+
SWIPE_FLICK_VELOCITY,
|
|
179
181
|
SWIPE_MIN_PX,
|
|
182
|
+
SWIPE_VELOCITY_WINDOW_MS,
|
|
180
183
|
cardGalleryCss,
|
|
181
184
|
hasFinePointer,
|
|
185
|
+
measureSlideWidth,
|
|
182
186
|
segmentIndex,
|
|
183
187
|
swipeStep,
|
|
184
188
|
useCardGallery,
|
|
@@ -39,7 +39,6 @@ import {
|
|
|
39
39
|
draftValuesFromWire,
|
|
40
40
|
droppedFeatureSlugs,
|
|
41
41
|
emptyDraftValues,
|
|
42
|
-
retainKnownFeatureValues,
|
|
43
42
|
} from "../model/draft.js";
|
|
44
43
|
import type { ListingDraftValues, ListingLocation } from "../model/draft.js";
|
|
45
44
|
import { asFeatureDaoList } from "../model/features.js";
|
|
@@ -383,12 +382,53 @@ export function useListingComposer(
|
|
|
383
382
|
const schemaSettled =
|
|
384
383
|
options.featuresLoading !== true && options.featuresError === undefined;
|
|
385
384
|
const { features } = options;
|
|
385
|
+
/**
|
|
386
|
+
* WHICH SCHEMA THIS IS, by its content and not by its identity (D455).
|
|
387
|
+
*
|
|
388
|
+
* Hosts hand `features` as a fresh array on most renders — the storefront's
|
|
389
|
+
* comes out of a query mapper — so array identity says "a render happened",
|
|
390
|
+
* never "the questions changed". The value judge below may only run on the
|
|
391
|
+
* second, and this is how the two are told apart.
|
|
392
|
+
*/
|
|
393
|
+
const schemaKey = useMemo(
|
|
394
|
+
() =>
|
|
395
|
+
features
|
|
396
|
+
.map((feature) => `${feature.slug}:${JSON.stringify(feature.config ?? null)}`)
|
|
397
|
+
.join("|"),
|
|
398
|
+
[features]
|
|
399
|
+
);
|
|
400
|
+
/** The schema the values were last judged against — `null` until the first
|
|
401
|
+
* one settles. */
|
|
402
|
+
const judgedSchema = useRef<string | null>(null);
|
|
386
403
|
useEffect(() => {
|
|
387
404
|
if (!schemaSettled || features.length === 0) return;
|
|
405
|
+
/* A DIFFERENT SCHEMA, not merely a different render.
|
|
406
|
+
*
|
|
407
|
+
* `false` on the very first settle, which is a REOPEN and not a switch:
|
|
408
|
+
* the answers came back from the server that stored them, and re-judging
|
|
409
|
+
* them against the catalogue as it stands today would silently delete a
|
|
410
|
+
* draft's contents on the way in. */
|
|
411
|
+
const switched = judgedSchema.current !== null && judgedSchema.current !== schemaKey;
|
|
412
|
+
judgedSchema.current = schemaKey;
|
|
388
413
|
setValues((current) => {
|
|
389
|
-
|
|
414
|
+
/* On a SWITCH, `gone` is "what this schema will not carry": since
|
|
415
|
+
0.30.2 that includes an answer whose slug survives but whose VALUE
|
|
416
|
+
this category refuses — two leaves both declaring `color` differ in
|
|
417
|
+
the options behind it (D455). Otherwise it stays the older, narrower
|
|
418
|
+
question — "which slugs is this schema not asking about?" — because
|
|
419
|
+
within ONE category a value the mirror refuses is a person typing,
|
|
420
|
+
and deleting that is the composer editing them mid-sentence. */
|
|
421
|
+
const gone = switched
|
|
422
|
+
? droppedFeatureSlugs(current.features, features)
|
|
423
|
+
: Object.keys(current.features)
|
|
424
|
+
.filter((slug) => !features.some((feature) => feature.slug === slug))
|
|
425
|
+
.sort();
|
|
390
426
|
const kept =
|
|
391
|
-
gone.length === 0
|
|
427
|
+
gone.length === 0
|
|
428
|
+
? current.features
|
|
429
|
+
: Object.fromEntries(
|
|
430
|
+
Object.entries(current.features).filter(([slug]) => !gone.includes(slug))
|
|
431
|
+
);
|
|
392
432
|
// `FeatureDef.default` (and the type's own default) is what the CATALOGUE
|
|
393
433
|
// says a blank form opens with — a `select` option flagged `default`, a
|
|
394
434
|
// preset date. It is applied ONLY where the draft has no answer: a
|
|
@@ -412,7 +452,7 @@ export function useListingComposer(
|
|
|
412
452
|
}
|
|
413
453
|
return { ...current, features: { ...kept, ...seeded } };
|
|
414
454
|
});
|
|
415
|
-
}, [schemaSettled, features]);
|
|
455
|
+
}, [schemaSettled, features, schemaKey]);
|
|
416
456
|
|
|
417
457
|
// The gallery is the upload bag's, whenever there is one: two sources of
|
|
418
458
|
// truth for the same list is how a publish sends photos the person removed.
|
package/src/model/draft.ts
CHANGED
|
@@ -13,7 +13,11 @@
|
|
|
13
13
|
* assert the BODY a save sends rather than the fact that it sent one.
|
|
14
14
|
*/
|
|
15
15
|
import type { FeatureDef, FeaturesDto } from "@stapel/attributes-react";
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
fromFeaturesDto,
|
|
18
|
+
mirrorValidate,
|
|
19
|
+
toFeaturesDto,
|
|
20
|
+
} from "@stapel/attributes-react";
|
|
17
21
|
import type {
|
|
18
22
|
ListingDetail,
|
|
19
23
|
ListingDraft,
|
|
@@ -350,8 +354,80 @@ export function createDraftBody(categoryId?: string): ListingDraftPatch {
|
|
|
350
354
|
}
|
|
351
355
|
|
|
352
356
|
/**
|
|
353
|
-
*
|
|
354
|
-
*
|
|
357
|
+
* A SHARED SLUG IS NOT A SHARED ANSWER (D455).
|
|
358
|
+
*
|
|
359
|
+
* Retention used to ask one question — "does the new schema declare this
|
|
360
|
+
* slug?" — and a slug is the cheapest half of the answer. Measured on the
|
|
361
|
+
* live stand, 2026-09-12: the analysis read a photo as the wristwatch leaf
|
|
362
|
+
* and answered the colour with `zolotoy`, which that leaf offers; the
|
|
363
|
+
* category was then corrected to the laptop leaf, which declares `color` too
|
|
364
|
+
* and therefore KEPT the value — but its sixteen options spell gold
|
|
365
|
+
* `zolotistyy`, not `zolotoy`. The control drew the raw code, the mirror
|
|
366
|
+
* refused it `not_in_options`, and the publish gate shut on the colour field
|
|
367
|
+
* over a row the catalogue marks OPTIONAL and the server publishes without
|
|
368
|
+
* (`validate-draft` -> `valid: true`). The seller cannot read the demand and
|
|
369
|
+
* the gate cannot be cleared by answering what it names.
|
|
370
|
+
*
|
|
371
|
+
* So the question is asked of the VALUE: a retained answer must be one the
|
|
372
|
+
* new schema would accept. The judge is `mirrorValidate` — the very function
|
|
373
|
+
* the publish gate reads — so a value kept here can never be one the gate
|
|
374
|
+
* refuses, and the two cannot drift. Same invariant `acceptableAiWrites`
|
|
375
|
+
* states for the analysis's writes, now held for the person's own.
|
|
376
|
+
*
|
|
377
|
+
* Only a value that is BOTH present and non-blank can be dropped this way.
|
|
378
|
+
* A blank one has nothing to lose, and its `mandatory_missing` row is a
|
|
379
|
+
* demand for an answer rather than a verdict on one — naming it as "dropped"
|
|
380
|
+
* would tell a person their answer did not apply when they never gave one.
|
|
381
|
+
* A rule set the mirror cannot parse fails the batch on `_root` and drops
|
|
382
|
+
* nothing: the schema is broken, not the draft.
|
|
383
|
+
*/
|
|
384
|
+
function judgeRetention(
|
|
385
|
+
values: Readonly<Record<string, unknown>>,
|
|
386
|
+
features: readonly FeatureDef[]
|
|
387
|
+
): { readonly kept: Record<string, unknown>; readonly dropped: string[] } {
|
|
388
|
+
const known = new Set(features.map((feature) => feature.slug));
|
|
389
|
+
const kept: Record<string, unknown> = {};
|
|
390
|
+
const dropped: string[] = [];
|
|
391
|
+
for (const [slug, value] of Object.entries(values)) {
|
|
392
|
+
if (known.has(slug)) kept[slug] = value;
|
|
393
|
+
else dropped.push(slug);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
let refused: ReadonlySet<string>;
|
|
397
|
+
try {
|
|
398
|
+
const batch = mirrorValidate(features, toFeaturesDto(features, kept));
|
|
399
|
+
refused = new Set(
|
|
400
|
+
batch.results
|
|
401
|
+
.filter((result) => result.status !== "ok")
|
|
402
|
+
.map((result) => result.slug)
|
|
403
|
+
);
|
|
404
|
+
} catch {
|
|
405
|
+
// A schema this build cannot judge is not a reason to delete answers.
|
|
406
|
+
return { kept, dropped: dropped.sort() };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const survived: Record<string, unknown> = {};
|
|
410
|
+
for (const [slug, value] of Object.entries(kept)) {
|
|
411
|
+
if (refused.has(slug) && answeredFeatureValue(value)) dropped.push(slug);
|
|
412
|
+
else survived[slug] = value;
|
|
413
|
+
}
|
|
414
|
+
return { kept: survived, dropped: dropped.sort() };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Does this value count as an answer? An empty list is what clearing the last
|
|
419
|
+
* chip leaves behind — an unanswered field, not an answer of `[]`.
|
|
420
|
+
*/
|
|
421
|
+
function answeredFeatureValue(value: unknown): boolean {
|
|
422
|
+
if (value === undefined || value === null) return false;
|
|
423
|
+
if (Array.isArray(value)) return value.length > 0;
|
|
424
|
+
if (typeof value === "string") return value.trim().length > 0;
|
|
425
|
+
return true;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Switching category: keep the answers the new schema also asks for AND would
|
|
430
|
+
* accept, drop the rest.
|
|
355
431
|
*
|
|
356
432
|
* Spec §4.1 asks for exactly this, and the reason is the same one the forms
|
|
357
433
|
* spec gives for `error.409.forms_version_superseded`: a person who picked
|
|
@@ -359,30 +435,25 @@ export function createDraftBody(categoryId?: string): ListingDraftPatch {
|
|
|
359
435
|
* phones" should not retype what both categories ask for. A value whose slug
|
|
360
436
|
* is gone IS dropped, because 0.6.0 rejects an unknown slug per feature —
|
|
361
437
|
* carrying it would turn a category change into a publish refusal about a
|
|
362
|
-
* field the composer no longer draws.
|
|
438
|
+
* field the composer no longer draws. Since 0.30.2 a value whose slug SURVIVES
|
|
439
|
+
* but which the new schema refuses is dropped for the same reason, which is
|
|
440
|
+
* the harm rather than the mechanism — see {@link judgeRetention}.
|
|
363
441
|
*/
|
|
364
442
|
export function retainKnownFeatureValues(
|
|
365
443
|
values: Readonly<Record<string, unknown>>,
|
|
366
444
|
features: readonly FeatureDef[]
|
|
367
445
|
): Readonly<Record<string, unknown>> {
|
|
368
|
-
|
|
369
|
-
const out: Record<string, unknown> = {};
|
|
370
|
-
for (const [slug, value] of Object.entries(values)) {
|
|
371
|
-
if (known.has(slug)) out[slug] = value;
|
|
372
|
-
}
|
|
373
|
-
return out;
|
|
446
|
+
return judgeRetention(values, features).kept;
|
|
374
447
|
}
|
|
375
448
|
|
|
376
|
-
/** Slugs that were answered and
|
|
377
|
-
* {@link retainKnownFeatureValues} just dropped
|
|
378
|
-
*
|
|
379
|
-
*
|
|
449
|
+
/** Slugs that were answered and the new schema will not carry — what
|
|
450
|
+
* {@link retainKnownFeatureValues} just dropped, whether because the slug is
|
|
451
|
+
* gone or because the answer is not one this category offers. A composer
|
|
452
|
+
* tells the person ("2 answers do not apply to this category") instead of
|
|
453
|
+
* losing them silently. */
|
|
380
454
|
export function droppedFeatureSlugs(
|
|
381
455
|
values: Readonly<Record<string, unknown>>,
|
|
382
456
|
features: readonly FeatureDef[]
|
|
383
457
|
): readonly string[] {
|
|
384
|
-
|
|
385
|
-
return Object.keys(values)
|
|
386
|
-
.filter((slug) => !known.has(slug))
|
|
387
|
-
.sort();
|
|
458
|
+
return judgeRetention(values, features).dropped;
|
|
388
459
|
}
|