@stapel/listings-react 0.26.0 → 0.28.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 (80) hide show
  1. package/CHANGELOG.md +129 -0
  2. package/MODULE.md +3 -1
  3. package/README.md +102 -8
  4. package/dist/api/generated/schema.d.ts +20 -10
  5. package/dist/api/generated/schema.d.ts.map +1 -1
  6. package/dist/default/ListingActions.d.ts +8 -1
  7. package/dist/default/ListingActions.d.ts.map +1 -1
  8. package/dist/default/ListingActions.js +1 -1
  9. package/dist/default/ListingActions.js.map +1 -1
  10. package/dist/default/ListingDetailPane.d.ts +148 -9
  11. package/dist/default/ListingDetailPane.d.ts.map +1 -1
  12. package/dist/default/ListingDetailPane.js +131 -19
  13. package/dist/default/ListingDetailPane.js.map +1 -1
  14. package/dist/default/MyListingsPane.d.ts.map +1 -1
  15. package/dist/default/MyListingsPane.js +10 -6
  16. package/dist/default/MyListingsPane.js.map +1 -1
  17. package/dist/default/ShareAction.d.ts +16 -1
  18. package/dist/default/ShareAction.d.ts.map +1 -1
  19. package/dist/default/ShareAction.js +16 -9
  20. package/dist/default/ShareAction.js.map +1 -1
  21. package/dist/default/actionRow.d.ts +24 -0
  22. package/dist/default/actionRow.d.ts.map +1 -1
  23. package/dist/default/actionRow.js +50 -7
  24. package/dist/default/actionRow.js.map +1 -1
  25. package/dist/default/detailGallery.d.ts +77 -0
  26. package/dist/default/detailGallery.d.ts.map +1 -0
  27. package/dist/default/detailGallery.js +91 -0
  28. package/dist/default/detailGallery.js.map +1 -0
  29. package/dist/default/index.d.ts +4 -2
  30. package/dist/default/index.d.ts.map +1 -1
  31. package/dist/default/index.js +5 -1
  32. package/dist/default/index.js.map +1 -1
  33. package/dist/default/movableCluster.d.ts +18 -0
  34. package/dist/default/movableCluster.d.ts.map +1 -0
  35. package/dist/default/movableCluster.js +110 -0
  36. package/dist/default/movableCluster.js.map +1 -0
  37. package/dist/headless/MyListings.d.ts +31 -10
  38. package/dist/headless/MyListings.d.ts.map +1 -1
  39. package/dist/headless/MyListings.js +19 -2
  40. package/dist/headless/MyListings.js.map +1 -1
  41. package/dist/headless/Share.d.ts +60 -2
  42. package/dist/headless/Share.d.ts.map +1 -1
  43. package/dist/headless/Share.js +55 -3
  44. package/dist/headless/Share.js.map +1 -1
  45. package/dist/index.d.ts +4 -4
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +2 -2
  48. package/dist/index.js.map +1 -1
  49. package/dist/model/cardBadges.d.ts +25 -0
  50. package/dist/model/cardBadges.d.ts.map +1 -1
  51. package/dist/model/cardBadges.js +41 -5
  52. package/dist/model/cardBadges.js.map +1 -1
  53. package/dist/model/draft.d.ts +42 -1
  54. package/dist/model/draft.d.ts.map +1 -1
  55. package/dist/model/draft.js +40 -3
  56. package/dist/model/draft.js.map +1 -1
  57. package/dist/model/status.d.ts +31 -16
  58. package/dist/model/status.d.ts.map +1 -1
  59. package/dist/model/status.js +30 -15
  60. package/dist/model/status.js.map +1 -1
  61. package/llms.txt +1 -1
  62. package/manifest.json +8 -1
  63. package/nav-manifest.json +1 -1
  64. package/package.json +3 -3
  65. package/src/analytics/generated/events.json +1 -1
  66. package/src/api/generated/schema.ts +20 -10
  67. package/src/default/ListingActions.tsx +9 -1
  68. package/src/default/ListingDetailPane.tsx +316 -18
  69. package/src/default/MyListingsPane.tsx +10 -6
  70. package/src/default/ShareAction.tsx +36 -10
  71. package/src/default/actionRow.ts +55 -7
  72. package/src/default/detailGallery.ts +97 -0
  73. package/src/default/index.ts +15 -1
  74. package/src/default/movableCluster.tsx +137 -0
  75. package/src/headless/MyListings.tsx +52 -12
  76. package/src/headless/Share.tsx +98 -5
  77. package/src/index.ts +7 -0
  78. package/src/model/cardBadges.ts +42 -5
  79. package/src/model/draft.ts +61 -3
  80. package/src/model/status.ts +31 -16
@@ -0,0 +1,97 @@
1
+ /**
2
+ * HOW THE LISTING PAGE LAYS OUT ITS PHOTOGRAPHS — and why the answer is a
3
+ * class and a prop rather than an inline `display`.
4
+ *
5
+ * ── The defect a container was carrying ───────────────────────────────────
6
+ *
7
+ * The gallery is an element-width grid (`repeat(auto-fit, minmax(14rem,
8
+ * 1fr))`), which on a 390px phone resolves to ONE column: a listing with three
9
+ * pictures pushes its own title and price nearly three screens down, and the
10
+ * first thing a person sees after tapping a search result is a photograph with
11
+ * nothing beside it. A phone lays photographs out as a snap-scrolling STRIP —
12
+ * one visible with the next peeking, title and price directly under it.
13
+ *
14
+ * That is layout, and layout is the container's to decide. But the pane wrote
15
+ * `display: grid` INLINE, and an inline declaration is beaten by nothing that
16
+ * is not `!important` — so a live storefront carried
17
+ * `[data-testid="listings-detail-gallery"] { display: flex !important }`
18
+ * against a pair's own geometry, named by a test id, to say a thing the pair
19
+ * offered no way to say (a client storefront's own stylesheet, §2).
20
+ *
21
+ * ── The seam ──────────────────────────────────────────────────────────────
22
+ *
23
+ * Two halves, and both matter:
24
+ *
25
+ * 1. `<ListingDetailPane galleryLayout>` — the pane ships BOTH layouts and
26
+ * the host names one, exactly as it already names `layout="split"` and
27
+ * `gutter`. The host is the side that knows the viewport it granted;
28
+ * a media query guessed in a leaf is the thing this package does not do.
29
+ * 2. `display` and the track are no longer inline. They live on this
30
+ * stylesheet, at one class plus one attribute, so a host that wants
31
+ * something neither arm offers can still write CSS for it at its own
32
+ * breakpoints — and needs no `!important` to be heard, only a selector of
33
+ * its own (`[data-testid="listings-detail-gallery"][data-gallery-layout]`
34
+ * ties; add any third condition and it wins).
35
+ *
36
+ * What stays inline is what nobody overrides and what a stylesheet would make
37
+ * worse: the `gap` (the page's own responsive gutter token, D418 — a var, so
38
+ * a resize reflows it) and `position: relative`, which is the containing block
39
+ * the `actionsPlacement="gallery"` overlay is pinned to.
40
+ */
41
+ /**
42
+ * The narrowest a gallery tile may get before the grid drops a column. A
43
+ * measure rather than a pixel: the tiles then fill whatever the ELEMENT is,
44
+ * which is §83's geometry rule — one photo per row on a phone, three on a
45
+ * desktop pane, and no `width: 320` that is near-full-bleed on one and a
46
+ * postage stamp on the other.
47
+ *
48
+ * Declared here rather than in `<ListingDetailPane>` because this is the file
49
+ * that writes the track it feeds; the pane re-exports it, so the public name
50
+ * is unchanged.
51
+ */
52
+ export const DETAIL_PHOTO_MIN = "14rem";
53
+
54
+ /** Which shape the photographs take. See the file header. */
55
+ export type ListingGalleryLayout = "grid" | "strip";
56
+
57
+ /** The class the gallery box carries. */
58
+ export const LISTINGS_GALLERY_CLASS = "stapel-listings-detail-gallery";
59
+
60
+ /** The `href` the hoisted gallery stylesheet is deduplicated by. */
61
+ export const LISTINGS_GALLERY_STYLE_HREF = "stapel-listings-detail-gallery";
62
+
63
+ /**
64
+ * How much of the strip's width ONE photograph takes.
65
+ *
66
+ * Not 100: the remaining sliver of the next picture is the only thing on a
67
+ * phone that says the strip scrolls at all. Exported so a host laying out
68
+ * beside it measures against the same number instead of guessing it back out
69
+ * of a screenshot.
70
+ */
71
+ export const LISTINGS_GALLERY_STRIP_BASIS = "86%";
72
+
73
+ /**
74
+ * The gallery's layout rules, for the hoisted `<style>`.
75
+ *
76
+ * The strip's child rule (`> *`) is the reason this is a stylesheet and not
77
+ * two more inline properties: an inline style cannot reach a child, and the
78
+ * flex basis is what makes the strip a strip rather than a row of squeezed
79
+ * photographs.
80
+ */
81
+ export function detailGalleryCss(): string {
82
+ return `
83
+ .${LISTINGS_GALLERY_CLASS}[data-gallery-layout="grid"] {
84
+ display: grid;
85
+ grid-template-columns: repeat(auto-fit, minmax(${DETAIL_PHOTO_MIN}, 1fr));
86
+ }
87
+ .${LISTINGS_GALLERY_CLASS}[data-gallery-layout="strip"] {
88
+ display: flex;
89
+ overflow-x: auto;
90
+ scroll-snap-type: x mandatory;
91
+ }
92
+ .${LISTINGS_GALLERY_CLASS}[data-gallery-layout="strip"] > * {
93
+ flex: 0 0 ${LISTINGS_GALLERY_STRIP_BASIS};
94
+ scroll-snap-align: start;
95
+ }
96
+ `.trim();
97
+ }
@@ -73,7 +73,20 @@ export {
73
73
  DETAIL_PHOTO_MIN,
74
74
  DETAIL_GALLERY_GUTTER,
75
75
  } from "./ListingDetailPane.js";
76
- export type { ListingDetailPaneProps } from "./ListingDetailPane.js";
76
+ export type {
77
+ ListingDetailPaneProps,
78
+ ListingActionsPlacement,
79
+ } from "./ListingDetailPane.js";
80
+ // The gallery's two shapes, its class and the stylesheet behind them — a host
81
+ // that wants a third writes CSS against these rather than `!important` over an
82
+ // inline `display`.
83
+ export {
84
+ LISTINGS_GALLERY_CLASS,
85
+ LISTINGS_GALLERY_STYLE_HREF,
86
+ LISTINGS_GALLERY_STRIP_BASIS,
87
+ detailGalleryCss,
88
+ } from "./detailGallery.js";
89
+ export type { ListingGalleryLayout } from "./detailGallery.js";
77
90
  export {
78
91
  ListingComposerPage,
79
92
  COMPOSER_DETAILS_PLACEMENT,
@@ -142,6 +155,7 @@ export {
142
155
  LISTING_ACTION_CLASS,
143
156
  LISTING_ACTION_HIT,
144
157
  LISTING_ACTION_LABEL_CLASS,
158
+ LISTING_ACTION_SPECIFICITY,
145
159
  LISTING_CARD_ACTION_CLASS,
146
160
  LISTING_CARD_ACTION_HIT,
147
161
  actionRowCss,
@@ -0,0 +1,137 @@
1
+ /**
2
+ * ONE CLUSTER, TWO PLACES ON THE PAGE — the same instance, moved.
3
+ *
4
+ * ── The ask this answers, and why the obvious shape does not ──────────────
5
+ *
6
+ * A listing page wants the reader's two verbs beside the title AND in the
7
+ * condensed bar that arrives when the title scrolls away (reference §24). A
8
+ * host cannot do that today: `<ListingDetailPane>` mounts its cluster at
9
+ * exactly one `actionsPlacement`, so a container that wants the second one
10
+ * mounts a `<ListingActions>` of its own — which is a SECOND
11
+ * `useFavoriteToggle` on one page, two hearts that agree only after a refetch,
12
+ * and two copies of a control whose geometry this pair exists to rule once.
13
+ *
14
+ * The shapes that do NOT close it, stated because each looks like it would:
15
+ *
16
+ * a render prop handed the cluster ELEMENT
17
+ * A React element is a description, not an instance. Rendered in two
18
+ * places it mounts twice — exactly the defect, now with the pair's name
19
+ * on it.
20
+ *
21
+ * the host moving one element between two parents
22
+ * React unmounts and remounts across a parent change: the hook state
23
+ * goes, the DOM node is a different node, and an optimistic favourite
24
+ * in flight is lost mid-write.
25
+ *
26
+ * two mounts kept in step by lifting the state
27
+ * Two controls, one state — and then the page has two `aria-pressed`
28
+ * hearts, two focus targets and two things for a probe to count.
29
+ *
30
+ * ── What this does ────────────────────────────────────────────────────────
31
+ *
32
+ * The cluster is rendered ONCE, through a portal, into a `<div>` this hook
33
+ * owns. The page draws SLOTS — empty `display: contents` divs — wherever the
34
+ * cluster may sit, and the container element is `appendChild`-ed into whichever
35
+ * slot is currently on screen with the highest priority. Moving a DOM node
36
+ * between parents is not a React tree change, so:
37
+ *
38
+ * - `<ListingActions>` (and everything under it) mounts exactly once;
39
+ * - the favourite button is literally the SAME `HTMLElement` in both
40
+ * placements — `test/detailActionsPlacements.test.tsx` holds the node
41
+ * across the move and asserts identity, which is the only assertion that
42
+ * can tell this apart from a well-behaved remount;
43
+ * - a slot that unmounts hands the cluster back to the next one down,
44
+ * rather than taking it off the page.
45
+ *
46
+ * `display: contents` on both the slot and the container is load-bearing: the
47
+ * cluster must remain a direct flex item of the heading row (it is laid out by
48
+ * `justify="space-between"`) and must stay absolutely positionable against the
49
+ * gallery's containing block in the overlay arm. A wrapper that generated a
50
+ * box would change both.
51
+ */
52
+ import { useCallback, useEffect, useRef } from "react";
53
+ import type { CSSProperties, ReactElement, ReactNode } from "react";
54
+ import { createPortal } from "react-dom";
55
+
56
+ /** Neither slot nor container may generate a box — see the file header. */
57
+ const CONTENTS: CSSProperties = { display: "contents" };
58
+
59
+ export interface MovableCluster {
60
+ /**
61
+ * True when there is a document to portal into. `false` under SSR, where
62
+ * the caller renders the cluster inline at its primary placement instead —
63
+ * a server render has no scrolling and therefore no second placement.
64
+ */
65
+ readonly portable: boolean;
66
+ /** Render the cluster ONCE, anywhere in the tree. Returns the portal. */
67
+ render(cluster: ReactNode): ReactNode;
68
+ /**
69
+ * A mount point. The highest `priority` currently on screen holds the
70
+ * cluster; when it goes, the next one down gets it back.
71
+ */
72
+ slot(priority: number, name: string): ReactElement;
73
+ }
74
+
75
+ export function useMovableCluster(enabled: boolean): MovableCluster {
76
+ const container = useRef<HTMLElement | null>(null);
77
+ if (enabled && container.current === null && typeof document !== "undefined") {
78
+ const host = document.createElement("div");
79
+ host.style.display = "contents";
80
+ host.setAttribute("data-listings-cluster-portal", "");
81
+ container.current = host;
82
+ }
83
+ const slots = useRef(new Map<number, HTMLElement>());
84
+ const refs = useRef(new Map<number, (node: HTMLElement | null) => void>());
85
+
86
+ /** Put the container in the highest-priority slot that is on the page. */
87
+ const settle = useCallback((): void => {
88
+ const host = container.current;
89
+ if (host === null) return;
90
+ let best: HTMLElement | undefined;
91
+ let bestPriority = -1;
92
+ for (const [priority, node] of slots.current) {
93
+ // `isConnected` matters during a commit that removes a slot: React may
94
+ // hand back a node it is about to drop, and appending into it would take
95
+ // the cluster off the page with it.
96
+ if (node.isConnected && priority > bestPriority) {
97
+ bestPriority = priority;
98
+ best = node;
99
+ }
100
+ }
101
+ if (best !== undefined && host.parentNode !== best) best.appendChild(host);
102
+ }, []);
103
+
104
+ // The belt. A slot's ref cleanup is what normally moves the cluster, but a
105
+ // subtree removed around it would leave the container detached with nothing
106
+ // to notice; this runs after every commit and repairs that in one comparison.
107
+ useEffect(settle);
108
+
109
+ const refFor = useCallback(
110
+ (priority: number): ((node: HTMLElement | null) => void) => {
111
+ let ref = refs.current.get(priority);
112
+ if (ref === undefined) {
113
+ ref = (node: HTMLElement | null): void => {
114
+ if (node === null) slots.current.delete(priority);
115
+ else slots.current.set(priority, node);
116
+ settle();
117
+ };
118
+ refs.current.set(priority, ref);
119
+ }
120
+ return ref;
121
+ },
122
+ [settle]
123
+ );
124
+
125
+ return {
126
+ portable: container.current !== null,
127
+ render: (cluster: ReactNode): ReactNode =>
128
+ container.current === null ? null : createPortal(cluster, container.current),
129
+ slot: (priority: number, name: string): ReactElement => (
130
+ <div
131
+ ref={refFor(priority)}
132
+ style={CONTENTS}
133
+ data-listings-cluster-slot={name}
134
+ />
135
+ ),
136
+ };
137
+ }
@@ -49,13 +49,23 @@ import { useMandateGate } from "./useMandateGate.js";
49
49
  * `blockedRows` is fetched beside them, off the same route, narrowed to
50
50
  * whatever `MY_LISTINGS_UNTABBED_STATUSES` derives.
51
51
  *
52
- * That second read is also the fourth tab's COUNT (D407). `my/counters` has
53
- * three integers and no fourth, so the removed tab is counted from the rows it
54
- * holds — which is honest exactly because that read is unpaged and runs
55
- * whichever tab is open. The one thing it cannot describe is a page cap: a
56
- * seller with more takedowns than one page of `my/listings` would see the page
57
- * count and not the total, and there is no counter on the wire to check it
58
- * against (see `MyListingsBag.tabCounts`).
52
+ * That second read used to be the fourth tab's COUNT as well (D407):
53
+ * `my/counters` had three integers and no fourth, so the removed tab was
54
+ * counted from the rows it holds — honest, because that read is unpaged and
55
+ * runs whichever tab is open, and blind to exactly one thing: a page cap. A
56
+ * seller with more takedowns than one page of `my/listings` saw the page count
57
+ * and not the total, and there was no counter on the wire to check it against.
58
+ *
59
+ * **stapel-listings 0.22.4 answers it**: `MyCountersResponse.blocked`, required
60
+ * beside the other three, over the same owner scope and the same grouping. So
61
+ * the tab's number is the SERVER's now and the unpaged read is what it always
62
+ * was underneath — the removed tab's ROWS. The two consequences are both
63
+ * visible: the tab appears from the counter (a beat earlier, and without the
64
+ * seller's whole takedown page having to land first), and a seller with more
65
+ * takedowns than fit on a page is told how many there are rather than how many
66
+ * arrived. A deployment on a server older than 0.22.4 sends no `blocked` at
67
+ * all, and then the rows are the count again — degraded to the previous
68
+ * answer, never to a `0` (see `MyListingsBag.tabCounts`).
59
69
  */
60
70
 
61
71
  export interface MyListingsBag {
@@ -66,10 +76,16 @@ export interface MyListingsBag {
66
76
  * The server's three always; the removed one only where there is something
67
77
  * in it or the person asked for it by address. An empty "Taken down" tab is
68
78
  * a scare, and the seller it would scare is the one it has nothing to tell.
79
+ *
80
+ * "Something in it" is `counters.blocked` (stapel-listings 0.22.4) and no
81
+ * longer the unpaged takedown page: the two reads settle independently, and
82
+ * the one that decides whether a tab strip has three tabs or four should be
83
+ * the one that answers in three integers rather than the one that answers in
84
+ * a page of listings.
69
85
  */
70
86
  readonly tabs: readonly MyListingsTab[];
71
87
  setTab(tab: MyListingsTab): void;
72
- /** The three real counts, as the server reports them. */
88
+ /** The four real counts, as the server reports them. */
73
89
  readonly counters: LoadState<MyCounters>;
74
90
  /**
75
91
  * The number to DRAW on each tab — the server's counter, raised to what is
@@ -89,8 +105,13 @@ export interface MyListingsBag {
89
105
  *
90
106
  * The second half: a takedown was in no tab and therefore in no number, so
91
107
  * a cabinet holding one read "Active 0 · Drafts 0 · Archived 0" over it.
92
- * `removed` is counted from `blockedRows` there is no server counter to
93
- * read, and the rows are the only evidence on the wire.
108
+ * `removed` was counted from `blockedRows`, because there was no server
109
+ * counter to read and the rows were the only evidence on the wire. There is
110
+ * one now — `MyCountersResponse.blocked`, stapel-listings 0.22.4 — and it is
111
+ * what the fourth tab draws, under the same floor as the other three: the
112
+ * open tab's number is never below the rows on screen. `blockedRows` stays
113
+ * the FALLBACK for a deployment whose server predates the field, which is a
114
+ * count that is right up to a page and never a `0` over a visible row.
94
115
  */
95
116
  readonly tabCounts: LoadState<Readonly<Record<MyListingsTab, number>>>;
96
117
  /** The rows for the current tab — {@link MyListingsBag.blockedRows} while
@@ -206,8 +227,17 @@ export function useMyListings(
206
227
  // The takedown count, when it is known. Not a `0` while the read is in
207
228
  // flight: the tab strip is drawn off this number and a tab that appeared a
208
229
  // beat after the page settled would move the three beside it.
209
- const blockedCount =
230
+ //
231
+ // The SERVER's integer first (stapel-listings 0.22.4), the unpaged page's
232
+ // length only where a deployment's backend predates it. `counters.data` is
233
+ // typed with `blocked` required, so the runtime check is the honest one and
234
+ // not a type-driven one: an older server answers 200 with three keys, and a
235
+ // `?? 0` there would print "Taken down 0" over a row the seller can see.
236
+ const rowsBlockedCount =
210
237
  blockedRows.status === "ready" ? blockedRows.data.length : undefined;
238
+ const serverBlockedCount =
239
+ typeof counters.data?.blocked === "number" ? counters.data.blocked : undefined;
240
+ const blockedCount = serverBlockedCount ?? rowsBlockedCount;
211
241
 
212
242
  // Paging belongs to the tab's own keyset read. The takedowns are fetched
213
243
  // unpaged on purpose, so the removed tab has nowhere to go and says so.
@@ -229,13 +259,23 @@ export function useMyListings(
229
259
  one === tab ? Math.max(server[one], visible) : server[one],
230
260
  ])
231
261
  ) as Readonly<Record<MyListingsCountedTab, number>>),
232
- [MY_LISTINGS_REMOVED_TAB]: blockedCount ?? 0,
262
+ // The fourth tab takes the same floor as the other three: the server's
263
+ // number, raised to what is on screen while this tab is the open one.
264
+ // The counter and the unpaged page are two reads of one set and either
265
+ // may be the staler — a badge under its own rows is not a count.
266
+ [MY_LISTINGS_REMOVED_TAB]:
267
+ tab === MY_LISTINGS_REMOVED_TAB
268
+ ? Math.max(blockedCount ?? 0, visible)
269
+ : (blockedCount ?? 0),
233
270
  });
234
271
  }, [counters.status, counters.error, counters.data, rows, tab, blockedCount]);
235
272
 
236
273
  // The removed tab is drawn where it has something to say — or where the
237
274
  // address named it, so `?tab=removed` opens a real (if empty) tab rather
238
275
  // than an activeKey pointing at nothing.
276
+ //
277
+ // "Something to say" is the COUNTER since 0.22.4, so the tab arrives with
278
+ // `my/counters` rather than waiting for a page of takedowns to come back.
239
279
  const tabs: readonly MyListingsTab[] =
240
280
  removed || (blockedCount ?? 0) > 0 ? MY_LISTINGS_TABS : MY_LISTINGS_COUNTED_TABS;
241
281
 
@@ -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.