@certrev/cert-block 0.3.0 → 0.5.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 (129) hide show
  1. package/README.md +48 -1
  2. package/dist/builder/cert-chrome-data.d.ts +129 -0
  3. package/dist/builder/cert-chrome-data.d.ts.map +1 -0
  4. package/dist/builder/cert-chrome-data.js +59 -0
  5. package/dist/builder/cert-chrome-data.js.map +1 -0
  6. package/dist/builder/cert-review-card.d.ts +68 -0
  7. package/dist/builder/cert-review-card.d.ts.map +1 -0
  8. package/dist/builder/cert-review-card.js +129 -0
  9. package/dist/builder/cert-review-card.js.map +1 -0
  10. package/dist/builder/index.d.ts +2 -0
  11. package/dist/builder/index.d.ts.map +1 -1
  12. package/dist/builder/index.js +3 -0
  13. package/dist/builder/index.js.map +1 -1
  14. package/dist/components/preview-block.d.ts +43 -0
  15. package/dist/components/preview-block.d.ts.map +1 -0
  16. package/dist/components/preview-block.js +53 -0
  17. package/dist/components/preview-block.js.map +1 -0
  18. package/dist/components/render-cert-block.d.ts +50 -7
  19. package/dist/components/render-cert-block.d.ts.map +1 -1
  20. package/dist/components/render-cert-block.js +304 -80
  21. package/dist/components/render-cert-block.js.map +1 -1
  22. package/dist/components/render-def.d.ts +27 -0
  23. package/dist/components/render-def.d.ts.map +1 -1
  24. package/dist/components/render-def.js +38 -0
  25. package/dist/components/render-def.js.map +1 -1
  26. package/dist/index.d.ts +3 -2
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +3 -1
  29. package/dist/index.js.map +1 -1
  30. package/dist/modal/avatar-resize.d.ts +60 -0
  31. package/dist/modal/avatar-resize.d.ts.map +1 -0
  32. package/dist/modal/avatar-resize.js +91 -0
  33. package/dist/modal/avatar-resize.js.map +1 -0
  34. package/dist/modal/cert-modal-view.d.ts +74 -0
  35. package/dist/modal/cert-modal-view.d.ts.map +1 -0
  36. package/dist/modal/cert-modal-view.js +160 -0
  37. package/dist/modal/cert-modal-view.js.map +1 -0
  38. package/dist/modal/certrev-cert-modal.d.ts +72 -0
  39. package/dist/modal/certrev-cert-modal.d.ts.map +1 -0
  40. package/dist/modal/certrev-cert-modal.js +270 -0
  41. package/dist/modal/certrev-cert-modal.js.map +1 -0
  42. package/dist/modal/certrev-cert-modal.styles.d.ts +35 -0
  43. package/dist/modal/certrev-cert-modal.styles.d.ts.map +1 -0
  44. package/dist/modal/certrev-cert-modal.styles.js +488 -0
  45. package/dist/modal/certrev-cert-modal.styles.js.map +1 -0
  46. package/dist/modal/certrev-cert.entry.d.ts +19 -0
  47. package/dist/modal/certrev-cert.entry.d.ts.map +1 -0
  48. package/dist/modal/certrev-cert.entry.js +39 -0
  49. package/dist/modal/certrev-cert.entry.js.map +1 -0
  50. package/dist/modal/certrev-cert.js +557 -0
  51. package/dist/modal/display-strings.d.ts +22 -0
  52. package/dist/modal/display-strings.d.ts.map +1 -0
  53. package/dist/modal/display-strings.js +35 -0
  54. package/dist/modal/display-strings.js.map +1 -0
  55. package/dist/modal/escape.d.ts +13 -0
  56. package/dist/modal/escape.d.ts.map +1 -0
  57. package/dist/modal/escape.js +20 -0
  58. package/dist/modal/escape.js.map +1 -0
  59. package/dist/modal/font-face.d.ts +4 -0
  60. package/dist/modal/font-face.d.ts.map +1 -0
  61. package/dist/modal/font-face.js +137 -0
  62. package/dist/modal/font-face.js.map +1 -0
  63. package/dist/modal/ftc-disclosure.d.ts +12 -0
  64. package/dist/modal/ftc-disclosure.d.ts.map +1 -0
  65. package/dist/modal/ftc-disclosure.js +12 -0
  66. package/dist/modal/ftc-disclosure.js.map +1 -0
  67. package/dist/modal/ftc-guard.d.ts +50 -0
  68. package/dist/modal/ftc-guard.d.ts.map +1 -0
  69. package/dist/modal/ftc-guard.js +117 -0
  70. package/dist/modal/ftc-guard.js.map +1 -0
  71. package/dist/modal/index.d.ts +20 -0
  72. package/dist/modal/index.d.ts.map +1 -0
  73. package/dist/modal/index.js +25 -0
  74. package/dist/modal/index.js.map +1 -0
  75. package/dist/modal/interactions.d.ts +53 -0
  76. package/dist/modal/interactions.d.ts.map +1 -0
  77. package/dist/modal/interactions.js +147 -0
  78. package/dist/modal/interactions.js.map +1 -0
  79. package/dist/modal/logo-paths.d.ts +15 -0
  80. package/dist/modal/logo-paths.d.ts.map +1 -0
  81. package/dist/modal/logo-paths.js +24 -0
  82. package/dist/modal/logo-paths.js.map +1 -0
  83. package/dist/modal/placement.d.ts +95 -0
  84. package/dist/modal/placement.d.ts.map +1 -0
  85. package/dist/modal/placement.js +302 -0
  86. package/dist/modal/placement.js.map +1 -0
  87. package/dist/modal/revalidate.d.ts +48 -0
  88. package/dist/modal/revalidate.d.ts.map +1 -0
  89. package/dist/modal/revalidate.js +65 -0
  90. package/dist/modal/revalidate.js.map +1 -0
  91. package/dist/modal/verify-client.d.ts +24 -0
  92. package/dist/modal/verify-client.d.ts.map +1 -0
  93. package/dist/modal/verify-client.js +38 -0
  94. package/dist/modal/verify-client.js.map +1 -0
  95. package/package.json +10 -4
  96. package/src/__tests__/__snapshots__/render-cert-block.test.ts.snap +10 -6
  97. package/src/__tests__/preview-block.test.ts +84 -0
  98. package/src/__tests__/render-cert-block.test.ts +380 -5
  99. package/src/builder/__tests__/builder.test.tsx +71 -0
  100. package/src/builder/cert-chrome-data.ts +159 -0
  101. package/src/builder/cert-review-card.tsx +177 -0
  102. package/src/builder/index.tsx +18 -0
  103. package/src/components/preview-block.ts +74 -0
  104. package/src/components/render-cert-block.ts +387 -102
  105. package/src/components/render-def.ts +55 -0
  106. package/src/index.ts +9 -0
  107. package/src/modal/avatar-resize.ts +99 -0
  108. package/src/modal/cert-modal-view.test.ts +175 -0
  109. package/src/modal/cert-modal-view.ts +210 -0
  110. package/src/modal/certrev-cert-modal.styles.ts +494 -0
  111. package/src/modal/certrev-cert-modal.test.ts +125 -0
  112. package/src/modal/certrev-cert-modal.ts +273 -0
  113. package/src/modal/certrev-cert.entry.ts +44 -0
  114. package/src/modal/display-strings.ts +38 -0
  115. package/src/modal/escape.ts +20 -0
  116. package/src/modal/font-face.ts +138 -0
  117. package/src/modal/ftc-disclosure.ts +13 -0
  118. package/src/modal/ftc-guard.test.ts +153 -0
  119. package/src/modal/ftc-guard.ts +119 -0
  120. package/src/modal/index.ts +34 -0
  121. package/src/modal/interactions.test.ts +250 -0
  122. package/src/modal/interactions.ts +166 -0
  123. package/src/modal/logo-paths.ts +23 -0
  124. package/src/modal/placement.test.ts +398 -0
  125. package/src/modal/placement.ts +331 -0
  126. package/src/modal/revalidate.test.ts +111 -0
  127. package/src/modal/revalidate.ts +81 -0
  128. package/src/modal/verify-client.test.ts +164 -0
  129. package/src/modal/verify-client.ts +42 -0
@@ -0,0 +1,398 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * Unit tests for the cert-card placement DOM layer (iframe→native parity): the @graph→<head>
4
+ * relocate, the TWO-wrap reposition — contributors card to the TOP of the article, expert memo to
5
+ * the BOTTOM (before the references list if present) — the tier observability attribute, and
6
+ * idempotency (never move twice / detach the badge).
7
+ */
8
+
9
+ import { beforeEach, describe, expect, it } from "vitest";
10
+ import {
11
+ CARD_WRAP_SELECTOR,
12
+ MEMO_WRAP_SELECTOR,
13
+ PLACEMENT_ATTR,
14
+ placeCert,
15
+ } from "./placement.js";
16
+
17
+ /** Stub an element's box so the rail's gutter math is deterministic in jsdom (which does no layout). */
18
+ function stubRight(el: Element, right: number): void {
19
+ el.getBoundingClientRect = () =>
20
+ ({
21
+ right,
22
+ left: 0,
23
+ top: 0,
24
+ bottom: 0,
25
+ width: right,
26
+ height: 0,
27
+ x: 0,
28
+ y: 0,
29
+ toJSON() {},
30
+ }) as DOMRect;
31
+ }
32
+ function setInnerWidth(px: number): void {
33
+ Object.defineProperty(window, "innerWidth", {
34
+ configurable: true,
35
+ value: px,
36
+ });
37
+ }
38
+
39
+ beforeEach(() => {
40
+ document.head.innerHTML = "";
41
+ document.body.innerHTML = "";
42
+ });
43
+
44
+ /** Append the @graph script + the two cert wraps at the END of <body> (where a body embed renders). */
45
+ function mountEmbed(opts: { graph?: boolean; memo?: boolean } = {}): {
46
+ card: HTMLElement;
47
+ memo: HTMLElement | null;
48
+ } {
49
+ if (opts.graph !== false) {
50
+ const s = document.createElement("script");
51
+ s.setAttribute("type", "application/ld+json");
52
+ s.setAttribute("data-certrev-graph", "");
53
+ s.textContent = '{"@context":"https://schema.org","@graph":[]}';
54
+ document.body.appendChild(s);
55
+ }
56
+ const card = document.createElement("div");
57
+ card.className = "certrev-card-wrap";
58
+ card.setAttribute("data-certrev-anchor", "top");
59
+ card.innerHTML =
60
+ '<certrev-badge revalidate><section class="certrev-badge">card</section></certrev-badge>';
61
+ document.body.appendChild(card);
62
+
63
+ let memo: HTMLElement | null = null;
64
+ if (opts.memo !== false) {
65
+ memo = document.createElement("div");
66
+ memo.className = "certrev-memo-wrap";
67
+ memo.setAttribute("data-certrev-anchor", "bottom");
68
+ memo.innerHTML = '<section class="certrev-memo">memo</section>';
69
+ document.body.appendChild(memo);
70
+ }
71
+ return { card, memo };
72
+ }
73
+
74
+ describe("placeCert — @graph relocation", () => {
75
+ it("moves the marked @graph script into <head>", () => {
76
+ mountEmbed();
77
+ expect(
78
+ document.head.querySelector("script[data-certrev-graph]"),
79
+ ).toBeNull();
80
+ const res = placeCert(document);
81
+ expect(res.graph).toBe(true);
82
+ const inHead = document.head.querySelector(
83
+ 'script[type="application/ld+json"][data-certrev-graph]',
84
+ );
85
+ expect(inHead).not.toBeNull();
86
+ expect(inHead!.textContent).toContain("@graph");
87
+ });
88
+
89
+ it("leaves an unmarked ld+json script alone (only ours moves)", () => {
90
+ const other = document.createElement("script");
91
+ other.setAttribute("type", "application/ld+json");
92
+ other.textContent = '{"@type":"WebSite"}';
93
+ document.body.appendChild(other);
94
+ mountEmbed({ graph: false });
95
+ placeCert(document);
96
+ expect(document.head.querySelector("script")).toBeNull();
97
+ expect(
98
+ document.body.querySelector('script[type="application/ld+json"]'),
99
+ ).toBe(other);
100
+ });
101
+ });
102
+
103
+ describe("placeCert — contributors card → TOP of article", () => {
104
+ it("inserts the card as the first child of a semantic <article> (afterbegin)", () => {
105
+ const article = document.createElement("article");
106
+ article.innerHTML = "<p>body</p>";
107
+ document.body.insertBefore(article, document.body.firstChild);
108
+ const { card } = mountEmbed();
109
+
110
+ const res = placeCert(document);
111
+ expect(res.card?.tier).toBe("article");
112
+ expect(res.card?.anchored).toBe(true);
113
+ expect(article.firstElementChild).toBe(card);
114
+ expect(card.getAttribute(PLACEMENT_ATTR)).toBe("article");
115
+ });
116
+
117
+ it("prefers the body-content container so the card lands above the article text (theme-class)", () => {
118
+ // Both a post-level <article> and an inner content container exist; the card should go into
119
+ // the CONTENT container (TOP_ANCHORS prefers it) so it sits above the body, not above the title.
120
+ const article = document.createElement("article");
121
+ const content = document.createElement("div");
122
+ content.className = "rte";
123
+ content.innerHTML = "<p>summary</p>";
124
+ article.appendChild(content);
125
+ document.body.insertBefore(article, document.body.firstChild);
126
+ const { card } = mountEmbed();
127
+
128
+ const res = placeCert(document);
129
+ expect(res.card?.tier).toBe("theme-class");
130
+ expect(content.firstElementChild).toBe(card);
131
+ });
132
+ });
133
+
134
+ describe("placeCert — skips hidden drawer/popup anchors (IntelliPure cart-drawer regression)", () => {
135
+ it("does NOT relocate the card into a closed drawer whose empty-cart message uses .rte", () => {
136
+ // A closed cart-drawer (hidden) sits BEFORE the article and carries its own `.rte` (empty-cart
137
+ // message) — exactly the shape that put IntelliPure's card into a `<cart-drawer hidden>` and made
138
+ // it invisible. The card must skip the hidden `.rte` and anchor to the visible article content.
139
+ const article = document.createElement("article");
140
+ const content = document.createElement("div");
141
+ content.className = "rte";
142
+ content.innerHTML = "<p>article body</p>";
143
+ article.appendChild(content);
144
+ document.body.appendChild(article);
145
+
146
+ const drawer = document.createElement("div");
147
+ drawer.setAttribute("hidden", ""); // closed drawer → display:none
148
+ drawer.innerHTML = '<div class="rte"><p>Your cart is empty</p></div>';
149
+ document.body.insertBefore(drawer, document.body.firstChild); // drawer is FIRST in document order
150
+
151
+ const { card, memo } = mountEmbed();
152
+ const res = placeCert(document);
153
+
154
+ expect(drawer.contains(card)).toBe(false); // never in the hidden drawer
155
+ expect(content.contains(card)).toBe(true); // anchored to the VISIBLE article .rte
156
+ expect(res.card?.tier).toBe("theme-class");
157
+ expect(drawer.contains(memo!)).toBe(false); // the shared guard protects the memo too
158
+ });
159
+
160
+ it("skips a visibility:hidden anchor in favour of the visible one (card + memo)", () => {
161
+ const hidden = document.createElement("article");
162
+ hidden.style.visibility = "hidden";
163
+ hidden.innerHTML = "<p>hidden</p>";
164
+ document.body.appendChild(hidden);
165
+ const visible = document.createElement("article");
166
+ visible.innerHTML = "<p>visible</p>";
167
+ document.body.appendChild(visible);
168
+ const { card, memo } = mountEmbed();
169
+
170
+ placeCert(document);
171
+ expect(hidden.contains(card)).toBe(false);
172
+ expect(visible.firstElementChild).toBe(card);
173
+ expect(hidden.contains(memo!)).toBe(false);
174
+ expect(visible.nextElementSibling).toBe(memo); // memo lands after the VISIBLE article
175
+ });
176
+
177
+ it("memo anchors before #references even when a small hero <article> precedes the content article", () => {
178
+ // IntelliPure's theme has TWO <article>s — a small hero banner BEFORE the content article — and
179
+ // #references lives in the CONTENT article. The memo must anchor off #references (bottom), NOT
180
+ // afterend of the first-matched hero (which stranded it at the TOP, above the card).
181
+ const hero = document.createElement("article");
182
+ hero.className = "article banner";
183
+ hero.innerHTML = "<h1>title</h1>";
184
+ document.body.appendChild(hero);
185
+ const content = document.createElement("article");
186
+ content.className = "article page-width";
187
+ // #references lives INSIDE the body .rte (as the html-generator emits it) — the card anchors to
188
+ // the .rte that OWNS #references, the memo to just before #references.
189
+ content.innerHTML =
190
+ '<div class="rte"><p>body</p><div id="references"><p>refs</p></div></div>';
191
+ document.body.appendChild(content);
192
+ const { card, memo } = mountEmbed();
193
+
194
+ const res = placeCert(document);
195
+ const refs = content.querySelector("#references")!;
196
+ expect(refs.previousElementSibling).toBe(memo); // memo just before references (bottom of content)
197
+ expect(hero.contains(memo!)).toBe(false); // NOT stranded at the hero (top)
198
+ expect(res.memo?.tier).toBe("references");
199
+ expect(content.querySelector(".rte")!.firstElementChild).toBe(card); // card lands in the .rte that owns #references
200
+ });
201
+ });
202
+
203
+ describe("placeCert — skips VISIBLE site-chrome anchors (IntelliPure announcement-bar regression, POR-9809)", () => {
204
+ it("does NOT land the card in a VISIBLE .rte announcement bar — anchors to the article that owns #references", () => {
205
+ // IntelliPure's announcement bar is `<div class="announcement__content … rte">` — VISIBLE (so the
206
+ // isRenderable guard can't reject it) and FIRST in document order. Before the fix the contributors
207
+ // card was injected there and "wasn't showing". It must anchor to the article `.rte` that owns
208
+ // #references instead.
209
+ const announcement = document.createElement("div");
210
+ announcement.className = "announcement__content flex items-center rte";
211
+ announcement.innerHTML = "<p>Free shipping over $50</p>";
212
+ document.body.appendChild(announcement); // FIRST in document order, visible
213
+
214
+ const article = document.createElement("article");
215
+ const content = document.createElement("div");
216
+ content.className = "rte";
217
+ content.innerHTML =
218
+ '<p>article body</p><div id="references"><p>refs</p></div>';
219
+ article.appendChild(content);
220
+ document.body.appendChild(article);
221
+
222
+ const { card } = mountEmbed();
223
+ const res = placeCert(document);
224
+
225
+ expect(announcement.contains(card)).toBe(false); // never in the announcement bar
226
+ expect(content.firstElementChild).toBe(card); // anchored to the article body that owns #references
227
+ expect(res.card?.tier).toBe("theme-class");
228
+ });
229
+
230
+ it("skips a VISIBLE chrome .rte in the fallback scan when no #references exists", () => {
231
+ // Same announcement-bar shape, but the article has NO references list → the card uses the fallback
232
+ // anchor scan. The chrome-exclusion must still skip the announcement `.rte` and land in the article.
233
+ const announcement = document.createElement("div");
234
+ announcement.className = "announcement-bar rte";
235
+ announcement.innerHTML = "<p>promo</p>";
236
+ document.body.appendChild(announcement);
237
+
238
+ const article = document.createElement("article");
239
+ const content = document.createElement("div");
240
+ content.className = "rte";
241
+ content.innerHTML = "<p>article body</p>";
242
+ article.appendChild(content);
243
+ document.body.appendChild(article);
244
+
245
+ const { card } = mountEmbed({ memo: false });
246
+ const res = placeCert(document);
247
+
248
+ expect(announcement.contains(card)).toBe(false);
249
+ expect(content.firstElementChild).toBe(card);
250
+ expect(res.card?.tier).toBe("theme-class");
251
+ });
252
+ });
253
+
254
+ describe("placeCert — expert memo → BOTTOM of article", () => {
255
+ it("inserts the memo just after a semantic <article> when no references list exists", () => {
256
+ const article = document.createElement("article");
257
+ article.innerHTML = "<p>body</p>";
258
+ document.body.insertBefore(article, document.body.firstChild);
259
+ const { memo } = mountEmbed();
260
+
261
+ const res = placeCert(document);
262
+ expect(res.memo?.tier).toBe("article");
263
+ expect(article.nextElementSibling).toBe(memo);
264
+ expect(memo!.getAttribute(PLACEMENT_ATTR)).toBe("article");
265
+ });
266
+
267
+ it("inserts the memo just BEFORE the references list (legacy memo → references order)", () => {
268
+ const article = document.createElement("article");
269
+ const refs = document.createElement("div");
270
+ refs.id = "references";
271
+ article.appendChild(document.createElement("p"));
272
+ article.appendChild(refs);
273
+ document.body.insertBefore(article, document.body.firstChild);
274
+ const { memo } = mountEmbed();
275
+
276
+ placeCert(document);
277
+ expect(refs.previousElementSibling).toBe(memo);
278
+ expect(memo!.parentElement).toBe(article);
279
+ });
280
+ });
281
+
282
+ describe("placeCert — fallbacks + idempotency", () => {
283
+ it("uses the styled body-bottom fallback for both wraps when nothing matches", () => {
284
+ const { card, memo } = mountEmbed();
285
+ const res = placeCert(document);
286
+ expect(res.card?.tier).toBe("body-fallback");
287
+ expect(res.card?.anchored).toBe(false);
288
+ expect(res.memo?.tier).toBe("body-fallback");
289
+ expect(card.getAttribute(PLACEMENT_ATTR)).toBe("body-fallback");
290
+ expect(memo!.getAttribute(PLACEMENT_ATTR)).toBe("body-fallback");
291
+ });
292
+
293
+ it("is idempotent — a second pass does not move either wrap again", () => {
294
+ const article = document.createElement("article");
295
+ document.body.insertBefore(article, document.body.firstChild);
296
+ const { card, memo } = mountEmbed();
297
+ placeCert(document);
298
+ const cardParent = card.parentElement;
299
+ const memoNext = memo!.previousElementSibling ?? memo!.nextElementSibling;
300
+
301
+ const res2 = placeCert(document);
302
+ expect(res2.card?.tier).toBe("article");
303
+ expect(card.parentElement).toBe(cardParent);
304
+ expect(memo!.previousElementSibling ?? memo!.nextElementSibling).toBe(
305
+ memoNext,
306
+ );
307
+ });
308
+
309
+ it("no-ops without throwing when the wraps are absent", () => {
310
+ expect(() => placeCert(document)).not.toThrow();
311
+ expect(document.querySelector(CARD_WRAP_SELECTOR)).toBeNull();
312
+ expect(document.querySelector(MEMO_WRAP_SELECTOR)).toBeNull();
313
+ });
314
+
315
+ it("places the card even when the memo wrap is absent (no memo on this article)", () => {
316
+ const article = document.createElement("article");
317
+ document.body.insertBefore(article, document.body.firstChild);
318
+ const { memo } = mountEmbed({ memo: false });
319
+ expect(memo).toBeNull();
320
+ const res = placeCert(document);
321
+ expect(res.card?.tier).toBe("article");
322
+ expect(res.memo).toBeNull();
323
+ });
324
+ });
325
+
326
+ describe('placeCert — sidebar rail (layout "sidebar")', () => {
327
+ /** Mount an article that owns #references + a lone rail wrap (the sidebar render's single wrap). */
328
+ function mountRail(): { wrap: HTMLElement; content: HTMLElement } {
329
+ const article = document.createElement("article");
330
+ const content = document.createElement("div");
331
+ content.className = "rte";
332
+ content.innerHTML = '<p>body</p><div id="references"><p>refs</p></div>';
333
+ article.appendChild(content);
334
+ document.body.appendChild(article);
335
+ const wrap = document.createElement("div");
336
+ wrap.className = "certrev-memo-wrap certrev-sidebar-wrap";
337
+ wrap.setAttribute("data-certrev-anchor", "rail");
338
+ wrap.innerHTML =
339
+ '<section class="certrev-badge certrev-memo certrev-sidebar">rail</section>';
340
+ document.body.appendChild(wrap);
341
+ return { wrap, content };
342
+ }
343
+
344
+ it("pins the rail (fixed) + sets --certrev-rail-left when the right gutter is wide enough", () => {
345
+ const { wrap, content } = mountRail();
346
+ stubRight(content, 600);
347
+ setInnerWidth(1200); // gutter = 600 ≥ 384
348
+
349
+ const res = placeCert(document);
350
+ expect(res.memo?.tier).toBe("rail");
351
+ expect(res.memo?.anchored).toBe(true);
352
+ expect(wrap.getAttribute(PLACEMENT_ATTR)).toBe("rail");
353
+ expect(wrap.style.getPropertyValue("--certrev-rail-left")).toBe("624px"); // rect.right + RAIL_GAP
354
+ // The rail is FIXED at the embed point — never moved into the article content.
355
+ expect(content.contains(wrap)).toBe(false);
356
+ });
357
+
358
+ it("falls back to the in-flow banner-style TOP placement when the gutter is too narrow", () => {
359
+ const { wrap, content } = mountRail();
360
+ stubRight(content, 1100);
361
+ setInnerWidth(1200); // gutter = 100 < 384
362
+
363
+ const res = placeCert(document);
364
+ expect(res.memo?.tier).toBe("theme-class"); // the .rte content container
365
+ expect(content.firstElementChild).toBe(wrap); // afterbegin, like the banner card
366
+ expect(wrap.style.getPropertyValue("--certrev-rail-left")).toBe(""); // never pinned
367
+ });
368
+
369
+ it("body-fallback when no article owner exists for the rail", () => {
370
+ const wrap = document.createElement("div");
371
+ wrap.className = "certrev-memo-wrap certrev-sidebar-wrap";
372
+ wrap.setAttribute("data-certrev-anchor", "rail");
373
+ wrap.innerHTML =
374
+ '<section class="certrev-badge certrev-memo certrev-sidebar">rail</section>';
375
+ document.body.appendChild(wrap);
376
+
377
+ const res = placeCert(document);
378
+ expect(res.memo?.tier).toBe("body-fallback");
379
+ expect(res.memo?.anchored).toBe(false);
380
+ });
381
+ });
382
+
383
+ describe('placeCert — floating pill (layout "floating")', () => {
384
+ it('stamps the pill wrap "pill-fixed" (CSS owns the placement) with no card/memo', () => {
385
+ const wrap = document.createElement("div");
386
+ wrap.className = "certrev-pill-wrap";
387
+ wrap.setAttribute("data-certrev-anchor", "pill");
388
+ wrap.innerHTML =
389
+ '<section class="certrev-badge certrev-pill">pill</section>';
390
+ document.body.appendChild(wrap);
391
+
392
+ const res = placeCert(document);
393
+ expect(wrap.getAttribute(PLACEMENT_ATTR)).toBe("pill-fixed");
394
+ // A floating render carries neither the card nor the memo wrap.
395
+ expect(res.card).toBeNull();
396
+ expect(res.memo).toBeNull();
397
+ });
398
+ });