@stapel/listings-react 0.27.0 → 0.28.1

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 (56) hide show
  1. package/CHANGELOG.md +123 -0
  2. package/README.md +56 -7
  3. package/dist/default/ListingActions.d.ts +8 -1
  4. package/dist/default/ListingActions.d.ts.map +1 -1
  5. package/dist/default/ListingActions.js +1 -1
  6. package/dist/default/ListingActions.js.map +1 -1
  7. package/dist/default/ListingDetailPane.d.ts +52 -1
  8. package/dist/default/ListingDetailPane.d.ts.map +1 -1
  9. package/dist/default/ListingDetailPane.js +4 -2
  10. package/dist/default/ListingDetailPane.js.map +1 -1
  11. package/dist/default/ShareAction.d.ts +16 -1
  12. package/dist/default/ShareAction.d.ts.map +1 -1
  13. package/dist/default/ShareAction.js +16 -9
  14. package/dist/default/ShareAction.js.map +1 -1
  15. package/dist/default/actionRow.d.ts +24 -0
  16. package/dist/default/actionRow.d.ts.map +1 -1
  17. package/dist/default/actionRow.js +50 -7
  18. package/dist/default/actionRow.js.map +1 -1
  19. package/dist/default/index.d.ts +1 -1
  20. package/dist/default/index.d.ts.map +1 -1
  21. package/dist/default/index.js +1 -1
  22. package/dist/default/index.js.map +1 -1
  23. package/dist/default/notice.d.ts.map +1 -1
  24. package/dist/default/notice.js +46 -4
  25. package/dist/default/notice.js.map +1 -1
  26. package/dist/headless/Share.d.ts +60 -2
  27. package/dist/headless/Share.d.ts.map +1 -1
  28. package/dist/headless/Share.js +55 -3
  29. package/dist/headless/Share.js.map +1 -1
  30. package/dist/index.d.ts +4 -4
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +2 -2
  33. package/dist/index.js.map +1 -1
  34. package/dist/model/cardBadges.d.ts +25 -0
  35. package/dist/model/cardBadges.d.ts.map +1 -1
  36. package/dist/model/cardBadges.js +41 -5
  37. package/dist/model/cardBadges.js.map +1 -1
  38. package/dist/model/draft.d.ts +42 -1
  39. package/dist/model/draft.d.ts.map +1 -1
  40. package/dist/model/draft.js +40 -3
  41. package/dist/model/draft.js.map +1 -1
  42. package/llms.txt +1 -1
  43. package/manifest.json +8 -1
  44. package/nav-manifest.json +1 -1
  45. package/package.json +3 -3
  46. package/src/analytics/generated/events.json +1 -1
  47. package/src/default/ListingActions.tsx +9 -1
  48. package/src/default/ListingDetailPane.tsx +57 -3
  49. package/src/default/ShareAction.tsx +36 -10
  50. package/src/default/actionRow.ts +55 -7
  51. package/src/default/index.ts +1 -0
  52. package/src/default/notice.ts +56 -4
  53. package/src/headless/Share.tsx +98 -5
  54. package/src/index.ts +7 -0
  55. package/src/model/cardBadges.ts +42 -5
  56. package/src/model/draft.ts +61 -3
@@ -86,13 +86,62 @@ export const LISTING_ACTION_HIT: number = controls["height-phone"];
86
86
  */
87
87
  export const LISTING_CARD_ACTION_HIT = 36;
88
88
 
89
+ /**
90
+ * HOW MANY TIMES THE HIT-TARGET CLASS IS REPEATED IN ITS OWN SELECTOR — and
91
+ * why a repeat rather than a number typed once (D450).
92
+ *
93
+ * Measured on the live listing page: the heart and the share glyph were
94
+ * **32 × 44**, not 44 × 44. The block axis survived and the inline one did
95
+ * not, because antd's circle shape ships
96
+ *
97
+ * `:where(…).ant-btn.ant-btn-circle.ant-btn{min-width:var(--ant-control-height)}`
98
+ *
99
+ * — three classes, specificity (0,3,0), against this sheet's single class
100
+ * (0,1,0). `:where()` adds nothing, and a media query adds nothing either, so
101
+ * the only thing that decides is the class count: antd's 32 won and there was
102
+ * no viewport at which it did not.
103
+ *
104
+ * A repeated class is the one way to outrank it without `!important`. Four
105
+ * repeats — (0,4,0) — clear antd's three with one to spare, and the selector
106
+ * still matches exactly the same element, so nothing about WHAT the rule
107
+ * applies to changes. `!important` was refused deliberately: a host that
108
+ * genuinely wants a different target must be able to say so with a selector,
109
+ * and this sheet's whole argument (see `movableCluster.tsx`) is that a pair's
110
+ * geometry should never force one on somebody else.
111
+ */
112
+ export const LISTING_ACTION_SPECIFICITY = 4;
113
+
114
+ /** A class name repeated {@link LISTING_ACTION_SPECIFICITY} times — the
115
+ * selector that beats antd's circle. */
116
+ function outranking(className: string): string {
117
+ return `.${className}`.repeat(LISTING_ACTION_SPECIFICITY);
118
+ }
119
+
120
+ /**
121
+ * The floor, in BOTH spellings of the same axis.
122
+ *
123
+ * A browser cascades `min-inline-size` and `min-width` together and keeps the
124
+ * winner, so in a browser the logical pair alone would be enough once the
125
+ * selector outranks antd's. The physical pair is written beside it because
126
+ * the declaration being beaten is spelled physically, and an engine that does
127
+ * NOT merge the two names (jsdom, where this rule is asserted) would leave
128
+ * antd's `min-width:32px` standing beside our `min-inline-size:44px` and call
129
+ * that a pass. Two spellings of one number, and the number has one source.
130
+ */
131
+ function floor(size: number): string {
132
+ const px = `${String(size)}px`;
133
+ return (
134
+ `min-inline-size:${px};min-block-size:${px};` +
135
+ `min-width:${px};min-height:${px}`
136
+ );
137
+ }
138
+
89
139
  export function actionRowCss(): string {
90
- const action = `.${LISTING_ACTION_CLASS}`;
91
- const cardAction = `.${LISTING_CARD_ACTION_CLASS}`;
140
+ const action = outranking(LISTING_ACTION_CLASS);
141
+ const cardAction = outranking(LISTING_CARD_ACTION_CLASS);
92
142
  const row = `.${LISTING_ACTIONS_CLASS}`;
93
143
  const over = `.${LISTING_ACTIONS_OVERLAY_CLASS}`;
94
144
  const label = `.${LISTING_ACTION_LABEL_CLASS}`;
95
- const hit = String(LISTING_ACTION_HIT);
96
145
  const phone = `(max-width:${String(breakpoints.tablet - 1)}px)`;
97
146
  return [
98
147
  // The floor. `min-*` rather than `width`/`height`: a share button with a
@@ -100,14 +149,13 @@ export function actionRowCss(): string {
100
149
  // `controlHeight` already reaches the height on a phone — this is the
101
150
  // guarantee for every OTHER viewport and for a host-registered button
102
151
  // that never read the antd token at all.
103
- `${action}{min-inline-size:${hit}px;min-block-size:${hit}px;` +
152
+ `${action}{${floor(LISTING_ACTION_HIT)};` +
104
153
  `display:inline-flex;align-items:center;justify-content:center}`,
105
154
  // A card's control: one tier smaller where there is a cursor, the same
106
155
  // 44px where there is a thumb. See LISTING_CARD_ACTION_HIT.
107
- `${cardAction}{min-inline-size:${String(LISTING_CARD_ACTION_HIT)}px;` +
108
- `min-block-size:${String(LISTING_CARD_ACTION_HIT)}px;` +
156
+ `${cardAction}{${floor(LISTING_CARD_ACTION_HIT)};` +
109
157
  `display:inline-flex;align-items:center;justify-content:center}`,
110
- `@media ${phone}{${cardAction}{min-inline-size:${hit}px;min-block-size:${hit}px}}`,
158
+ `@media ${phone}{${cardAction}{${floor(LISTING_ACTION_HIT)}}}`,
111
159
  // The cluster. `align-items:flex-end` so a blocked heart's reason — the
112
160
  // one thing here that can be two lines — stacks against the same edge
113
161
  // instead of pushing the controls inwards (the arrangement
@@ -155,6 +155,7 @@ export {
155
155
  LISTING_ACTION_CLASS,
156
156
  LISTING_ACTION_HIT,
157
157
  LISTING_ACTION_LABEL_CLASS,
158
+ LISTING_ACTION_SPECIFICITY,
158
159
  LISTING_CARD_ACTION_CLASS,
159
160
  LISTING_CARD_ACTION_HIT,
160
161
  actionRowCss,
@@ -30,13 +30,43 @@
30
30
  * the heart's state is on the heart. This is the AMPLIFIER, never the record.
31
31
  * A future `SkinNotice` in the substrate replaces the body of this function
32
32
  * and nothing else; the callers already speak in resolved sentences.
33
+ *
34
+ * ── An amplifier may not outlive what it amplifies ────────────────────────
35
+ *
36
+ * Both arms hand the notice to a holder MOUNTED OUTSIDE this component tree —
37
+ * antd's `<App>` holder in the first arm, and in the second a React root antd
38
+ * renders into the document the first time the static entry is called. Neither
39
+ * is unmounted by unmounting the surface that spoke, and a standing notice is
40
+ * not an idle DOM node: `@rc-component/notification` counts its two seconds
41
+ * down with a `requestAnimationFrame` LOOP (`useNoticeTimer`), which is live
42
+ * work driven from a root nothing in this package owns.
43
+ *
44
+ * So the notices raised here are RETIRED when the surface that raised them
45
+ * goes. Two things follow, and both are the intent:
46
+ *
47
+ * - a person who presses the heart and immediately navigates away does not
48
+ * get "Saved" floating over the next page, about a listing they left;
49
+ * - nothing this pair started keeps running after the tree it started in is
50
+ * gone. Measured as CI flake: the detail page's heart toast kept stepping
51
+ * its rAF loop past the end of the test file that raised it, and the frame
52
+ * that landed after the environment was torn down threw
53
+ * `ReferenceError: window is not defined` out of react-dom — from a suite
54
+ * in which every test had passed.
33
55
  */
34
- import { useCallback } from "react";
56
+ import { useCallback, useEffect, useRef } from "react";
35
57
  import { App, message as staticMessage } from "antd";
36
58
 
37
59
  /** Say one short sentence. Resolved copy — this is the skin, not a bag. */
38
60
  export type Notice = (text: string) => void;
39
61
 
62
+ /**
63
+ * What antd hands back for a raised notice: CALL it to close the notice early,
64
+ * `then` it to learn that it closed on its own. Typed structurally rather than
65
+ * imported (`antd/es/message/interface`) so this file keeps to antd's public
66
+ * entry — the shape is antd's documented `MessageType` either way.
67
+ */
68
+ type RaisedNotice = (() => void) & PromiseLike<unknown>;
69
+
40
70
  /**
41
71
  * How long a confirmation stands, in seconds.
42
72
  *
@@ -51,18 +81,40 @@ export const NOTICE_SECONDS = 2;
51
81
  export function useNotice(): Notice {
52
82
  const app = App.useApp();
53
83
  const contextual = app.message.success;
84
+ // The notices this surface has raised and that have not closed themselves
85
+ // yet — see the header. A notice retires itself the moment it closes, so
86
+ // this holds at most the handful still on screen.
87
+ const standing = useRef<Set<RaisedNotice>>(new Set());
88
+ useEffect(
89
+ () => () => {
90
+ const open = standing.current;
91
+ standing.current = new Set();
92
+ for (const close of open) close();
93
+ },
94
+ []
95
+ );
96
+ const hold = useCallback((raised: RaisedNotice): void => {
97
+ standing.current.add(raised);
98
+ const retire = (): void => {
99
+ standing.current.delete(raised);
100
+ };
101
+ // Closed by its own timer, by our unmount, or by a host calling
102
+ // `message.destroy()` — every ending resolves this, and a rejection is an
103
+ // ending too. Nothing is awaited: the notice is already on screen.
104
+ raised.then(retire, retire);
105
+ }, []);
54
106
  return useCallback(
55
107
  (text: string): void => {
56
108
  if (typeof contextual === "function") {
57
- contextual(text, NOTICE_SECONDS);
109
+ hold(contextual(text, NOTICE_SECONDS) as RaisedNotice);
58
110
  return;
59
111
  }
60
112
  // No `<App>` above us. antd's static entry renders its own holder into
61
113
  // the document, which is exactly right for a host that never opted in,
62
114
  // and is a no-op on a server where there is no document to render into.
63
115
  if (typeof document === "undefined") return;
64
- staticMessage.success(text, NOTICE_SECONDS);
116
+ hold(staticMessage.success(text, NOTICE_SECONDS) as RaisedNotice);
65
117
  },
66
- [contextual]
118
+ [contextual, hold]
67
119
  );
68
120
  }
@@ -17,6 +17,15 @@
17
17
  * menu with "copy the link" and the three networks a Russian-speaking
18
18
  * marketplace actually gets traffic from.
19
19
  *
20
+ * ── …AND "HAS A SHEET" IS NOT "SHOULD USE THE SHEET" (§25) ───────────────
21
+ *
22
+ * `navigator.share` is true on desktop Chrome on macOS. Measured on the stand:
23
+ * every share on the storefront opened the OS sheet and the copy-link menu —
24
+ * three networks and a clipboard row, built for exactly that platform — was
25
+ * unreachable there. So the decision takes a second reading, the primary
26
+ * POINTER, and {@link UseShareOptions.prefer} lets a surface state the answer
27
+ * outright. See {@link SharePreference}.
28
+ *
20
29
  * `native` is resolved in an EFFECT rather than during render, for the reason
21
30
  * `cardGallery.ts`'s `useFinePointer` gives at length: a server render has no
22
31
  * `navigator`, and a first client render that disagreed with it is a
@@ -134,6 +143,70 @@ export function hasNativeShare(): boolean {
134
143
  return typeof navigator.share === "function";
135
144
  }
136
145
 
146
+ /**
147
+ * The media query that asks "is this a thumb" — the second half of the arm
148
+ * decision (§25).
149
+ *
150
+ * `(pointer: coarse)` describes the PRIMARY input device, which is exactly the
151
+ * question: a phone and a tablet match, a mouse and a trackpad do not, and a
152
+ * touchscreen laptop being driven with its trackpad reports the trackpad.
153
+ */
154
+ export const SHARE_COARSE_MEDIA = "(pointer: coarse)";
155
+
156
+ /** Does the primary pointer look like a finger? `false` wherever the question
157
+ * cannot be asked — a server, an engine without `matchMedia` — because the
158
+ * fallback arm is the one that draws something. */
159
+ export function hasCoarsePointer(): boolean {
160
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
161
+ return false;
162
+ }
163
+ try {
164
+ return window.matchMedia(SHARE_COARSE_MEDIA).matches;
165
+ } catch {
166
+ return false;
167
+ }
168
+ }
169
+
170
+ /**
171
+ * WHICH ARM A SURFACE WANTS, and why `navigator.share` alone was the wrong
172
+ * question.
173
+ *
174
+ * The capability probe is true on desktop Chrome on macOS — measured on the
175
+ * stand (§25), where every share on the storefront opened the OS sheet and the
176
+ * copy-link menu was therefore unreachable on the platform it was BUILT for.
177
+ * "Has a share sheet" and "is a device whose share sheet is the better answer"
178
+ * turned out to be two questions, and the pair was only asking the first.
179
+ *
180
+ * `"auto"` (default) the platform sheet only where the primary pointer is
181
+ * COARSE and the API exists; a mouse gets the menu, with its
182
+ * copy-link row and its three networks.
183
+ * `"menu"` always this pair's menu, whatever the device offers. For a
184
+ * host whose desktop and mobile web are one build and which
185
+ * wants one answer.
186
+ * `"native"` the platform sheet wherever the API exists, pointer ignored —
187
+ * the behaviour every version before this one had, kept
188
+ * reachable by name rather than deleted.
189
+ *
190
+ * In all three, a missing `navigator.share` is the menu: an arm that cannot
191
+ * open is not an arm.
192
+ */
193
+ export type SharePreference = "auto" | "menu" | "native";
194
+
195
+ /**
196
+ * Resolve the arm from the preference and what the device actually answered.
197
+ *
198
+ * Pure, and separate from the hook, so the decision is a thing a test reads
199
+ * rather than a thing a rendered `data-share-mode` implies.
200
+ */
201
+ export function preferNativeShare(
202
+ prefer: SharePreference,
203
+ capability: { readonly native: boolean; readonly coarse: boolean }
204
+ ): boolean {
205
+ if (prefer === "menu") return false;
206
+ if (!capability.native) return false;
207
+ return prefer === "native" || capability.coarse;
208
+ }
209
+
137
210
  export interface UseShareOptions {
138
211
  /** The canonical address, absolute or a path. Absent: the address bar,
139
212
  * which is the honest answer only for a host with no route seam. */
@@ -146,13 +219,24 @@ export interface UseShareOptions {
146
219
  * through; `"native"` never says which app, because the sheet does not
147
220
  * tell the page. */
148
221
  readonly onShared?: ((channel: ShareChannel) => void) | undefined;
222
+ /**
223
+ * Which arm this surface wants — see {@link SharePreference}. Default
224
+ * `"auto"`: the platform sheet on a coarse pointer, this pair's menu on a
225
+ * mouse, the menu wherever `navigator.share` is missing.
226
+ */
227
+ readonly prefer?: SharePreference | undefined;
149
228
  }
150
229
 
151
230
  export interface ShareBag {
152
231
  /** The absolute address every arm shares. `undefined` only on a server. */
153
232
  readonly url: string | undefined;
154
- /** Does this device have the platform's own sheet? Settles in an effect —
155
- * see the file header. */
233
+ /**
234
+ * IS THE PLATFORM SHEET THE ARM ON SCREEN — the resolved answer, not the
235
+ * raw capability. `prefer` and the primary pointer are both in it (see
236
+ * {@link SharePreference}); `hasNativeShare()` is the capability alone, for
237
+ * a caller that wants to ask that question itself. Settles in an effect —
238
+ * see the file header.
239
+ */
156
240
  readonly native: boolean;
157
241
  /** The three networks' links, already encoded. */
158
242
  readonly links: readonly ShareLink[];
@@ -182,15 +266,24 @@ export const SHARE_COPIED_MS = 2400;
182
266
 
183
267
  export function useShare(options: UseShareOptions = {}): ShareBag {
184
268
  const { url: given, title, text, onShared } = options;
269
+ const prefer = options.prefer ?? "auto";
185
270
  const [native, setNative] = useState(false);
186
271
  const [copied, setCopied] = useState(false);
187
272
  const [copyFailed, setCopyFailed] = useState(false);
188
273
 
189
274
  // See the header: resolved in an effect so a server render and the
190
- // hydration pass that must agree with it draw the same arm.
275
+ // hydration pass that must agree with it draw the same arm. Both halves of
276
+ // the question are asked in the SAME effect — the capability and the
277
+ // pointer — so there is never a frame in which one has landed and the
278
+ // other has not and the button changes arm twice.
191
279
  useEffect(() => {
192
- setNative(hasNativeShare());
193
- }, []);
280
+ setNative(
281
+ preferNativeShare(prefer, {
282
+ native: hasNativeShare(),
283
+ coarse: hasCoarsePointer(),
284
+ })
285
+ );
286
+ }, [prefer]);
194
287
 
195
288
  const url = useMemo(() => resolveShareUrl(given), [given]);
196
289
  const links = useMemo(
package/src/index.ts CHANGED
@@ -163,7 +163,9 @@ export {
163
163
  retainKnownFeatureValues,
164
164
  } from "./model/draft.js";
165
165
  export type {
166
+ DraftPatchOptions,
166
167
  EmptyDraftOptions,
168
+ ListingDraftField,
167
169
  ListingDraftValues,
168
170
  ListingLocation,
169
171
  } from "./model/draft.js";
@@ -184,6 +186,7 @@ export { featureUnit, formatSpecValue } from "./model/featureText.js";
184
186
  export {
185
187
  badgePresentation,
186
188
  badgeValueText,
189
+ captionName,
187
190
  cardBadgeText,
188
191
  cardBadgeTexts,
189
192
  hasCardBadgeContract,
@@ -316,10 +319,13 @@ export type {
316
319
  UseFavoritesOptions,
317
320
  } from "./headless/Favorites.js";
318
321
  export {
322
+ SHARE_COARSE_MEDIA,
319
323
  SHARE_COPIED_MS,
320
324
  SHARE_NETWORKS,
321
325
  Share,
326
+ hasCoarsePointer,
322
327
  hasNativeShare,
328
+ preferNativeShare,
323
329
  resolveShareUrl,
324
330
  shareLinks,
325
331
  useShare,
@@ -329,6 +335,7 @@ export type {
329
335
  ShareChannel,
330
336
  ShareLink,
331
337
  ShareNetwork,
338
+ SharePreference,
332
339
  ShareTarget,
333
340
  UseShareOptions,
334
341
  } from "./headless/Share.js";
@@ -227,12 +227,41 @@ function isTrue(raw: unknown): boolean {
227
227
  */
228
228
  export type CardBadgeStyle = "badge" | "line";
229
229
 
230
+ /**
231
+ * A CAPTION AS THIS PAIR WILL PUNCTUATE IT — the catalogue's own trailing
232
+ * colon stripped (D455).
233
+ *
234
+ * Measured on a live feed, translated: one card in twenty-four read
235
+ * "HONOR · **Model:: 90** · 256 GB". The catalogue row for that leaf spells
236
+ * the feature's name "Model:" — with the colon IN the name — and presents it
237
+ * `name_value`, while the neighbouring listing's row for the same slug spells
238
+ * it "Model" and presents it `value`. So the content is inconsistent and only
239
+ * one half of that is ours; what is ours is that {@link caption} then adds a
240
+ * second colon to a name that already ended in one.
241
+ *
242
+ * Punctuation between a caption and its answer is the SURFACE's decision (see
243
+ * the module header) — which means it is not the catalogue's, and a name that
244
+ * arrives carrying its own is a name with a separator baked into it. It is
245
+ * taken off here, once, so both styles are unaffected by which of the two
246
+ * spellings a row happens to use: the chip draws "Model 90" and the line
247
+ * "Model: 90" either way.
248
+ *
249
+ * Only a TRAILING colon, and only the colon: a name is otherwise printed
250
+ * exactly as the catalogue wrote it. "Model: year:" is not a shape anybody
251
+ * sends, and a rule that chewed punctuation off the end of every caption
252
+ * would eventually eat a name that meant it.
253
+ */
254
+ export function captionName(name: string): string {
255
+ return name.replace(/\s*:+$/u, "");
256
+ }
257
+
230
258
  /** A caption and its answer, joined the way this surface separates them. */
231
259
  function caption(name: string, body: string, style: CardBadgeStyle): string {
232
260
  // A SPACE in a chip and a COLON in a line. "Floor 3" is a caption inside a
233
261
  // border; "Floor: 3" is what the same pair has to become when the border is
234
262
  // gone and the neighbours are a dot away.
235
- return style === "line" ? `${name}: ${body}` : `${name} ${body}`;
263
+ const head = captionName(name);
264
+ return style === "line" ? `${head}: ${body}` : `${head} ${body}`;
236
265
  }
237
266
 
238
267
  /**
@@ -250,11 +279,16 @@ export function cardBadgeText(
250
279
  style: CardBadgeStyle = "badge"
251
280
  ): string | undefined {
252
281
  const presentation = badgePresentation(row);
253
- const name = text(row.name);
282
+ // Normalised ONCE, here (D455): every arm below asks "is there a name to
283
+ // print", and the answer has to be about the name this pair will actually
284
+ // draw — a row whose whole name is ":" has none.
285
+ const name = captionName(text(row.name));
254
286
  const unit = text(row.unit);
255
287
 
256
288
  if (presentation === "name") {
257
- // The name IS the badge, and only while the answer is yes.
289
+ // The name IS the badge, and only while the answer is yes. Normalised by
290
+ // the same rule as a caption: a lone "Brick:" is a colon with nothing
291
+ // after it, which is the defect in its plainest form.
258
292
  return isTrue(row.value) && name.length > 0 ? name : undefined;
259
293
  }
260
294
 
@@ -288,7 +322,7 @@ export function cardBadgeText(
288
322
  function alreadyCaptioned(row: CardBadgeRow): boolean {
289
323
  const presentation = badgePresentation(row);
290
324
  if (presentation === "name") return true;
291
- return presentation === "name_value" && text(row.name).length > 0;
325
+ return presentation === "name_value" && captionName(text(row.name)).length > 0;
292
326
  }
293
327
 
294
328
  /**
@@ -331,7 +365,10 @@ export function cardBadgeTexts(
331
365
 
332
366
  for (const positions of groups.values()) {
333
367
  if (positions.length < 2) continue;
334
- const names = positions.map((at) => text(printedRows[at]?.name));
368
+ // The names as they will be DRAWN (D455) so a catalogue row spelling
369
+ // one axis "Floor:" and the other "Floor" is two spellings of one word
370
+ // here rather than two distinct captions that tell a reader nothing apart.
371
+ const names = positions.map((at) => captionName(text(printedRows[at]?.name)));
335
372
  // Nothing to caption with, or one word for both axes: leave the line as
336
373
  // the server wrote it rather than adding a caption that tells a reader
337
374
  // nothing they did not already have.
@@ -224,6 +224,28 @@ function toWireFeatures(
224
224
  return toFeaturesDto(features, values) as unknown as WireFeaturesDraft;
225
225
  }
226
226
 
227
+ /** One field of the `save-draft` body, by the name the wire uses. */
228
+ export type ListingDraftField = keyof ListingDraftPatch;
229
+
230
+ /** Options for {@link draftPatchFromValues}. */
231
+ export interface DraftPatchOptions {
232
+ /**
233
+ * NAME THE FIELDS THIS SAVE IS WRITING, and the body carries no others.
234
+ *
235
+ * `save-draft` REPLACES every field in the body — it does not merge — so a
236
+ * body is not "the values I have", it is "the fields I am claiming". A save
237
+ * fired by one control (a photo settling, a blurred title) previously
238
+ * claimed all fourteen, which is only harmless while every one of them is
239
+ * loaded and true.
240
+ *
241
+ * Given, only these keys are spelled; the omission rules below still apply
242
+ * inside the selection, so naming `features_draft` without a schema still
243
+ * writes nothing. Absent, the whole body is sent, which is right for the
244
+ * composer's own save — it holds every value on the form.
245
+ */
246
+ readonly fields?: readonly ListingDraftField[];
247
+ }
248
+
227
249
  /**
228
250
  * The composer's values → the `save-draft` body.
229
251
  *
@@ -234,12 +256,34 @@ function toWireFeatures(
234
256
  * (`error.400.listing_feature_not_allowed`), which is why
235
257
  * {@link retainKnownFeatureValues} prunes on the way in rather than letting
236
258
  * the server explain it.
259
+ *
260
+ * ── NO SCHEMA IS NOT AN EMPTY ANSWER SHEET ───────────────────────────────
261
+ *
262
+ * An absent or empty `features` used to produce `features_draft: {}`, and
263
+ * `save-draft` REPLACES that map rather than merging into it — so a save that
264
+ * left before the category's schema arrived DELETED every characteristic the
265
+ * row was holding, while the form on screen still showed them. Measured by a
266
+ * live container over two cold loads of one draft: a reopen's own
267
+ * settled-photo save fires in the first commit, before the row's category has
268
+ * even been adopted, and the row alternated between the draft's answers and
269
+ * none of them (a client storefront's README, "Named gaps").
270
+ *
271
+ * `{}` is a claim — "this listing has no characteristics" — and a caller with
272
+ * no schema is in no position to make it. So the key is OMITTED entirely
273
+ * whenever there is no schema to tag values with, and `save-draft` then
274
+ * leaves the stored map exactly as it was. A category that genuinely declares
275
+ * no features writes nothing either, which is the same answer arrived at
276
+ * honestly: there is nothing to say and the row already says it.
277
+ *
278
+ * {@link DraftPatchOptions.fields} is the general form of the same rule — a
279
+ * save that names what it is writing cannot erase what it is not.
237
280
  */
238
281
  export function draftPatchFromValues(
239
282
  values: ListingDraftValues,
240
- features: readonly FeatureDef[]
283
+ features: readonly FeatureDef[] | undefined,
284
+ options: DraftPatchOptions = {}
241
285
  ): ListingDraftPatch {
242
- return {
286
+ const full: ListingDraftPatch = {
243
287
  // Omitted while unchosen rather than sent as `""`: a draft is allowed to
244
288
  // have no category (0.21.4), and `""` is not "no category" on the wire —
245
289
  // it is an empty id the serializer refuses. The category is written by
@@ -258,7 +302,11 @@ export function draftPatchFromValues(
258
302
  // sent here is discarded. Sending one would be a claim the wire ignores.
259
303
  lat_draft: values.location.lat,
260
304
  lon_draft: values.location.lon,
261
- features_draft: toWireFeatures(features, values.features),
305
+ // Omitted, not emptied, when there is no schema to tag with — see the
306
+ // doc above. `{}` is a claim about the listing; silence is not.
307
+ ...(features !== undefined && features.length > 0
308
+ ? { features_draft: toWireFeatures(features, values.features) }
309
+ : {}),
262
310
  countable: values.countable,
263
311
  // The pair mirrors the model's cross-field rule rather than sending a
264
312
  // contradiction: a service carries no quantity, and `validate_countable
@@ -266,6 +314,16 @@ export function draftPatchFromValues(
266
314
  stock_quantity: values.countable ? values.stockQuantity : null,
267
315
  auto_republish: values.autoRepublish,
268
316
  };
317
+ const named = options.fields;
318
+ if (named === undefined) return full;
319
+ // A selection, not a second body: the field's VALUE is still whatever the
320
+ // rules above produced, so a named field the rules omit stays omitted.
321
+ const wanted = new Set<string>(named);
322
+ const out: Record<string, unknown> = {};
323
+ for (const [key, value] of Object.entries(full)) {
324
+ if (wanted.has(key)) out[key] = value;
325
+ }
326
+ return out as ListingDraftPatch;
269
327
  }
270
328
 
271
329
  /**