@certrev/cert-block 0.2.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 -5
  19. package/dist/components/render-cert-block.d.ts.map +1 -1
  20. package/dist/components/render-cert-block.js +341 -93
  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 +404 -6
  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 +427 -121
  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,331 @@
1
+ /**
2
+ * Cert-card PLACEMENT for the single `target: body` CertREV app embed.
3
+ *
4
+ * The embed renders globally just before `</body>` — the only theme-app-extension primitive that
5
+ * reliably renders on modern themes (Horizon and others forbid app blocks inside the blog-post
6
+ * content section, so an in-content app block isn't available). On DOM-ready this module, ONCE:
7
+ *
8
+ * (a) Relocates the @graph `<script data-certrev-graph>` into `<head>`. JSON-LD is inert, so the
9
+ * move is invisible + reliable: JS-executing crawlers (Google + most AI fetchers) read it
10
+ * head-side, while non-JS fetchers still read the server-rendered copy that shipped in
11
+ * `<body>`. The AEO payload is never gambled on body-only.
12
+ * (b) Repositions the TWO cert wrappers to mirror the legacy iframe layout (iframe→native parity),
13
+ * BOTH anchored off the html-generator's `#references` list so they land in the SAME article:
14
+ * • `.certrev-card-wrap` (`data-certrev-anchor="top"`) → the TOP of the article content
15
+ * that OWNS `#references` (the contributors banner sat at the top of the legacy `body_html`).
16
+ * • `.certrev-memo-wrap` (`data-certrev-anchor="bottom"`) → the BOTTOM, just before the
17
+ * references list if present (where the legacy inline memo sat), else after the article.
18
+ * When no references exist, each falls back to a chrome-aware semantic-landmark / theme-class
19
+ * scan, with a STYLED end-of-article fallback when nothing matches.
20
+ * (c) Records the matched tier on each wrap as `data-certrev-placement` — the observability the
21
+ * pre-launch theme-matrix sweep + the POR-9578 liveness check read, so a silent misplacement
22
+ * becomes an inspectable signal rather than a surprise on a real brand's store.
23
+ *
24
+ * It runs BEFORE the badge re-verify pass and never moves a wrap twice, so the `<certrev-badge>`
25
+ * element is never detached/re-attached mid-verify (no duplicate Delivery-API calls).
26
+ */
27
+
28
+ /** Anchor tiers for the BOTTOM (memo) wrap, best→worst — landmarks first (stable across themes),
29
+ * theme classes last, above the styled body-bottom fallback. The memo is appended AFTER the matched
30
+ * anchor (or just before the references list). */
31
+ export const PLACEMENT_ANCHORS: ReadonlyArray<{ readonly selector: string; readonly tier: string }> = [
32
+ { selector: 'main article', tier: 'article' },
33
+ { selector: 'article', tier: 'article' },
34
+ { selector: 'main', tier: 'main' },
35
+ { selector: '.article-template__content', tier: 'theme-class' },
36
+ { selector: '[id^="ArticleContent"]', tier: 'theme-class' },
37
+ { selector: '.rte', tier: 'theme-class' },
38
+ ]
39
+
40
+ /** Anchor tiers for the TOP (contributors card) wrap. Body-CONTENT containers come FIRST so the
41
+ * `afterbegin` insert lands above the article text but below the title/hero — matching the legacy
42
+ * contributors banner's first-in-`body_html` position. The post-level landmarks (`article`/`main`)
43
+ * are the fallback (an `afterbegin` there may land above an in-element title; acceptable + flagged
44
+ * for per-theme tuning, and observable via the recorded tier). */
45
+ export const TOP_ANCHORS: ReadonlyArray<{ readonly selector: string; readonly tier: string }> = [
46
+ { selector: '.article-template__content', tier: 'theme-class' },
47
+ { selector: '[id^="ArticleContent"]', tier: 'theme-class' },
48
+ { selector: '.rte', tier: 'theme-class' },
49
+ { selector: 'main article', tier: 'article' },
50
+ { selector: 'article', tier: 'article' },
51
+ { selector: 'main', tier: 'main' },
52
+ ]
53
+
54
+ /** The union of TOP_ANCHORS selectors — the shapes that count as an "article content container". The
55
+ * card resolves its anchor by walking UP from `#references` (`closest`) to the nearest of these, so
56
+ * it lands in the exact article body that owns the references — never a same-classed element that
57
+ * merely precedes the article in document order (see placeWrap + SITE_CHROME_SELECTOR). */
58
+ export const TOP_CONTENT_SELECTOR = TOP_ANCHORS.map((a) => a.selector).join(', ')
59
+
60
+ /** Site-chrome containers a cert wrap must NEVER anchor into. The anchor selectors (`.rte` / `article`
61
+ * / `main`) are NOT article-exclusive on real merchant themes: a VISIBLE announcement bar, header,
62
+ * footer, nav, or cart drawer often carries its own `.rte` that precedes the article in document
63
+ * order — IntelliPure's announcement bar is literally `<div class="announcement__content … rte">`.
64
+ * `isRenderable` can't reject those (they're visible), so `querySelector('.rte')` returned the
65
+ * announcement bar and the contributors card was injected there instead of the article (POR-9809 —
66
+ * "contributors not showing"). Placement scopes anchors out of chrome STRUCTURALLY via this set. */
67
+ export const SITE_CHROME_SELECTOR =
68
+ 'header, [role="banner"], [class*="announcement"], footer, [role="contentinfo"], nav, [role="navigation"], cart-drawer, .cart-drawer, .drawer, .mini-cart, .modal, [role="dialog"]'
69
+
70
+ /** Achieved-tier attribute the JS stamps on each wrap (CSS body-fallback styling + observability). */
71
+ export const PLACEMENT_ATTR = 'data-certrev-placement'
72
+ /** Desired-position marker the Liquid sets on each wrap: `top` (card) | `bottom` (memo). */
73
+ export const ANCHOR_TARGET_ATTR = 'data-certrev-anchor'
74
+ export const GRAPH_SELECTOR = 'script[type="application/ld+json"][data-certrev-graph]'
75
+ export const CARD_WRAP_SELECTOR = '.certrev-card-wrap'
76
+ export const MEMO_WRAP_SELECTOR = '.certrev-memo-wrap'
77
+ /** The floating-pill wrap (layout 'floating'). CSS owns its fixed placement; the JS only stamps it. */
78
+ export const PILL_WRAP_SELECTOR = '.certrev-pill-wrap'
79
+ /** Min free right gutter (px) beside the article column for the fixed rail. 320 rail + margins. */
80
+ export const RAIL_MIN_GUTTER = 384
81
+ /** Gap (px) between the article column's right edge and the fixed rail. */
82
+ export const RAIL_GAP = 24
83
+ /** The article's references list (emitted by html-generator into the clean body_html). The memo
84
+ * renders just before it, matching the legacy inline-memo position (memo → references). */
85
+ export const REFERENCES_SELECTOR = '#references'
86
+
87
+ export interface PlaceResult {
88
+ /** Whether the wrapper was moved to an article anchor (false on the body-bottom fallback). */
89
+ readonly anchored: boolean
90
+ /** The matched anchor tier (`references` | `article` | `main` | `theme-class` | `body-fallback`). */
91
+ readonly tier: string
92
+ }
93
+
94
+ export interface PlaceCertResult {
95
+ /** Whether the @graph script was relocated into <head> this pass. */
96
+ readonly graph: boolean
97
+ /** Placement of the contributors card wrap (null when absent). */
98
+ readonly card: PlaceResult | null
99
+ /** Placement of the memo wrap (null when absent). */
100
+ readonly memo: PlaceResult | null
101
+ }
102
+
103
+ /**
104
+ * Whether `el` is RENDERABLE — not inside a `display:none` / `visibility:hidden` / `[hidden]` /
105
+ * `aria-hidden` subtree. The guard exists because anchor selectors like `.rte` / `main` / `article`
106
+ * are NOT article-exclusive on real merchant themes: a CLOSED cart-drawer / nav / search popup often
107
+ * carries its own `.rte` (e.g. an empty-cart message) that sits BEFORE the article in document order,
108
+ * so `querySelector('.rte')` returns the DRAWER's copy and the cert card gets relocated into a hidden
109
+ * panel — invisible to the reader (observed live on IntelliPure's theme: the card landed in
110
+ * `<cart-drawer hidden>`). Skipping hidden matches makes placement fall through to the visible article
111
+ * content for BOTH the top card and the bottom memo.
112
+ *
113
+ * Walks ancestors via `getComputedStyle` + the hidden/aria-hidden attributes (NOT `offsetParent`,
114
+ * which jsdom always reports null since it does no layout — that would false-reject everything in the
115
+ * unit suite). Normal elements compute `display:block` / `visibility:visible` and pass.
116
+ */
117
+ function isRenderable(el: Element): boolean {
118
+ for (let node: Element | null = el; node; node = node.parentElement) {
119
+ if (node.hasAttribute('hidden') || node.getAttribute('aria-hidden') === 'true') return false
120
+ const style = getComputedStyle(node as HTMLElement)
121
+ if (style.display === 'none' || style.visibility === 'hidden') return false
122
+ }
123
+ return true
124
+ }
125
+
126
+ /**
127
+ * Whether `el` sits inside SITE CHROME (announcement bar / header / footer / nav / cart drawer /
128
+ * modal). Complements `isRenderable`: chrome anchors are usually VISIBLE, so the visibility guard
129
+ * can't reject them — but a cert wrap must never anchor there. This is the guard that keeps the
130
+ * contributors card out of IntelliPure's `.rte`-classed announcement bar (POR-9809).
131
+ */
132
+ function isInSiteChrome(el: Element): boolean {
133
+ return el.closest(SITE_CHROME_SELECTOR) !== null
134
+ }
135
+
136
+ /** Observability tier for an already-resolved container: the first anchor selector it matches
137
+ * (`theme-class` | `article` | `main`), defaulting to `article`. Used to record the tier of the
138
+ * `#references`-resolved card container so the placement attribute stays in the known vocabulary. */
139
+ function anchorTier(el: Element, anchors: ReadonlyArray<{ readonly selector: string; readonly tier: string }>): string {
140
+ for (const { selector, tier } of anchors) if (el.matches(selector)) return tier
141
+ return 'article'
142
+ }
143
+
144
+ /**
145
+ * First anchor that exists, is RENDERABLE (not in a hidden drawer/popup), is NOT site chrome (a
146
+ * visible announcement bar / header / nav — POR-9809), and is safely positionable relative to
147
+ * `wrap`. Iterates ALL matches per selector (not just the first in document order) so an unusable
148
+ * early match (a closed drawer's `.rte`, or the announcement bar's) is skipped for the article copy.
149
+ */
150
+ function firstUsableAnchor(
151
+ doc: Document,
152
+ wrap: HTMLElement,
153
+ anchors: ReadonlyArray<{ readonly selector: string; readonly tier: string }>,
154
+ ): { readonly el: Element; readonly tier: string } | null {
155
+ for (const { selector, tier } of anchors) {
156
+ for (const el of Array.from(doc.querySelectorAll(selector))) {
157
+ // Never anchor to the wrap itself or to an element that contains / is contained by it (that
158
+ // would detach the wrap into its own subtree or no-op the move), never to a HIDDEN match, and
159
+ // never into site chrome (a visible announcement bar / header / nav carrying its own `.rte`).
160
+ if (el !== wrap && !el.contains(wrap) && !wrap.contains(el) && isRenderable(el) && !isInSiteChrome(el))
161
+ return { el, tier }
162
+ }
163
+ }
164
+ return null
165
+ }
166
+
167
+ /**
168
+ * Reposition ONE wrap to its `data-certrev-anchor` target. Idempotent: a wrap already carrying
169
+ * `data-certrev-placement` is left untouched (records the achieved tier on first pass).
170
+ */
171
+ export function placeWrap(doc: Document, wrap: HTMLElement): PlaceResult {
172
+ const existing = wrap.getAttribute(PLACEMENT_ATTR)
173
+ if (existing) return { anchored: existing !== 'body-fallback', tier: existing }
174
+
175
+ const anchorAttr = wrap.getAttribute(ANCHOR_TARGET_ATTR)
176
+
177
+ // RAIL (layout 'sidebar'): pin the whole rail beside the article column when there's room, else
178
+ // gracefully fall back to the banner-style in-flow TOP placement (a full-width card). The owner is
179
+ // resolved exactly like the top card (`#references` → closest content container, chrome-aware).
180
+ if (anchorAttr === 'rail') {
181
+ const refs = doc.querySelector(REFERENCES_SELECTOR)
182
+ const owner = refs?.closest(TOP_CONTENT_SELECTOR) ?? firstUsableAnchor(doc, wrap, TOP_ANCHORS)?.el ?? null
183
+ const win = doc.defaultView
184
+ if (owner && win && isRenderable(owner) && !isInSiteChrome(owner)) {
185
+ const rect = owner.getBoundingClientRect()
186
+ const gutter = win.innerWidth - rect.right
187
+ if (gutter >= RAIL_MIN_GUTTER) {
188
+ // Fixed gutter rail — the working "sticky" for an injected widget (a CSS-sticky float has no
189
+ // stick room without theme layout control). The wrap stays at the embed point.
190
+ wrap.style.setProperty('--certrev-rail-left', `${Math.round(rect.right + RAIL_GAP)}px`)
191
+ wrap.setAttribute(PLACEMENT_ATTR, 'rail')
192
+ return { anchored: true, tier: 'rail' }
193
+ }
194
+ // GRACEFUL FALLBACK: no gutter → banner-style in-flow top placement (full-width card).
195
+ owner.insertAdjacentElement('afterbegin', wrap)
196
+ const fallbackTier = anchorTier(owner, TOP_ANCHORS)
197
+ wrap.setAttribute(PLACEMENT_ATTR, fallbackTier)
198
+ return { anchored: true, tier: fallbackTier }
199
+ }
200
+ wrap.setAttribute(PLACEMENT_ATTR, 'body-fallback')
201
+ return { anchored: false, tier: 'body-fallback' }
202
+ }
203
+
204
+ const target = anchorAttr === 'bottom' ? 'bottom' : 'top'
205
+ let tier = 'body-fallback'
206
+
207
+ if (target === 'top') {
208
+ // Anchor the card to the TOP of the article CONTENT that OWNS `#references` — the same reliable
209
+ // marker the memo uses at the bottom. `#references` (emitted by the html-generator, always present
210
+ // on a certified article) lives INSIDE the body-content container, so `closest(TOP_CONTENT_SELECTOR)`
211
+ // resolves the exact article `.rte` / `article` / `main` — never a same-classed site-chrome element
212
+ // that merely precedes the article in document order (IntelliPure's `.rte` announcement bar, which
213
+ // is visible so isRenderable can't reject it — POR-9809 "contributors not showing"). This also ties
214
+ // the card + memo to the SAME article region by construction.
215
+ const refs = doc.querySelector(REFERENCES_SELECTOR)
216
+ const owner = refs?.closest(TOP_CONTENT_SELECTOR) ?? null
217
+ if (
218
+ owner &&
219
+ owner !== wrap &&
220
+ !owner.contains(wrap) &&
221
+ !wrap.contains(owner) &&
222
+ isRenderable(owner) &&
223
+ !isInSiteChrome(owner)
224
+ ) {
225
+ // First child of the content container → above the article text, below the title/hero.
226
+ owner.insertAdjacentElement('afterbegin', wrap)
227
+ tier = anchorTier(owner, TOP_ANCHORS)
228
+ } else {
229
+ // No references (rare) → fall back to the chrome-aware anchor scan.
230
+ const found = firstUsableAnchor(doc, wrap, TOP_ANCHORS)
231
+ if (found) {
232
+ found.el.insertAdjacentElement('afterbegin', wrap)
233
+ tier = found.tier
234
+ }
235
+ }
236
+ } else {
237
+ // Memo → just before the references list when one exists. The references are the reliable
238
+ // bottom-of-content marker INDEPENDENT of which landmark matched: a theme can have a small hero
239
+ // <article> BEFORE the content <article> (IntelliPure: `article banner` then `article
240
+ // page-width`), and `firstUsableAnchor` matches the hero first — so `afterend(hero)` would
241
+ // strand the memo at the TOP, above the card. Anchoring off `#references` (the html-generator's
242
+ // references list, always present on a certified article) avoids that. The old code required the
243
+ // refs to live INSIDE the matched landmark (`found.el.contains(refs)`), which failed exactly when
244
+ // the matched landmark was the hero and the refs were in the separate content article.
245
+ const refs = doc.querySelector(REFERENCES_SELECTOR)
246
+ if (refs && refs !== wrap && !wrap.contains(refs) && !refs.contains(wrap) && isRenderable(refs)) {
247
+ refs.insertAdjacentElement('beforebegin', wrap)
248
+ tier = 'references'
249
+ } else {
250
+ const found = firstUsableAnchor(doc, wrap, PLACEMENT_ANCHORS)
251
+ if (found) {
252
+ found.el.insertAdjacentElement('afterend', wrap)
253
+ tier = found.tier
254
+ }
255
+ }
256
+ }
257
+
258
+ wrap.setAttribute(PLACEMENT_ATTR, tier)
259
+ return { anchored: tier !== 'body-fallback', tier }
260
+ }
261
+
262
+ /** Relocate the @graph into `<head>`, then reposition the card (top) + memo (bottom) wraps — ONCE. */
263
+ export function placeCert(doc: Document): PlaceCertResult {
264
+ // (a) @graph → <head> (idempotent: skip if already there or absent).
265
+ const graph = doc.querySelector(GRAPH_SELECTOR)
266
+ let graphMoved = false
267
+ if (graph && graph.parentNode !== doc.head) {
268
+ doc.head.appendChild(graph)
269
+ graphMoved = true
270
+ }
271
+
272
+ // (b)+(c) reposition each wrap to its target, once.
273
+ const cardEl = doc.querySelector<HTMLElement>(CARD_WRAP_SELECTOR)
274
+ const memoEl = doc.querySelector<HTMLElement>(MEMO_WRAP_SELECTOR)
275
+ // The floating pill (layout 'floating') is CSS-fixed — no repositioning; stamp it for observability
276
+ // only, so a live-liveness check still sees a placement attribute on the pill wrap.
277
+ doc.querySelector<HTMLElement>(PILL_WRAP_SELECTOR)?.setAttribute(PLACEMENT_ATTR, 'pill-fixed')
278
+ return {
279
+ graph: graphMoved,
280
+ card: cardEl ? placeWrap(doc, cardEl) : null,
281
+ memo: memoEl ? placeWrap(doc, memoEl) : null,
282
+ }
283
+ }
284
+
285
+ /**
286
+ * A fixed sidebar rail only fits while the article column leaves ≥`RAIL_MIN_GUTTER` of right gutter;
287
+ * a viewport shrink (resize / rotate) can erase it. Bind a debounced resize listener that re-measures
288
+ * and, on gutter loss, DEMOTES the rail to the in-flow banner-style top placement — ONE-WAY (never
289
+ * bounces back on scrollbar jitter): once demoted the listener detaches. No-op unless a fixed rail
290
+ * exists. Idempotent per pass (a rail already demoted carries no `rail` placement so it's skipped).
291
+ */
292
+ function bindRailResize(doc: Document): void {
293
+ const win = doc.defaultView
294
+ if (!win) return
295
+ const wrap = doc.querySelector<HTMLElement>(`${MEMO_WRAP_SELECTOR}[${ANCHOR_TARGET_ATTR}="rail"]`)
296
+ if (!wrap || wrap.getAttribute(PLACEMENT_ATTR) !== 'rail') return
297
+
298
+ let timer: ReturnType<typeof setTimeout> | undefined
299
+ const onResize = (): void => {
300
+ if (timer) clearTimeout(timer)
301
+ timer = setTimeout(() => {
302
+ // Only act while still a fixed rail; once demoted we're done (one-way — no bounce-back).
303
+ if (wrap.getAttribute(PLACEMENT_ATTR) !== 'rail') {
304
+ win.removeEventListener('resize', onResize)
305
+ return
306
+ }
307
+ const refs = doc.querySelector(REFERENCES_SELECTOR)
308
+ const owner = refs?.closest(TOP_CONTENT_SELECTOR) ?? firstUsableAnchor(doc, wrap, TOP_ANCHORS)?.el ?? null
309
+ const gutter = owner ? win.innerWidth - owner.getBoundingClientRect().right : 0
310
+ if (gutter < RAIL_MIN_GUTTER) {
311
+ // Demote to in-flow — clear the fixed var + attr, then re-run placement once (the rail
312
+ // branch now measures a lost gutter → the banner-style top card fallback).
313
+ wrap.style.removeProperty('--certrev-rail-left')
314
+ wrap.removeAttribute(PLACEMENT_ATTR)
315
+ placeWrap(doc, wrap)
316
+ win.removeEventListener('resize', onResize)
317
+ }
318
+ }, 150)
319
+ }
320
+ win.addEventListener('resize', onResize)
321
+ }
322
+
323
+ /** Run placement once the DOM is parsed (mirrors `initBadgeRevalidation`'s readiness handling). */
324
+ export function initCertPlacement(doc: Document): void {
325
+ const run = (): void => {
326
+ placeCert(doc)
327
+ bindRailResize(doc)
328
+ }
329
+ if (doc.readyState === 'loading') doc.addEventListener('DOMContentLoaded', run, { once: true })
330
+ else run()
331
+ }
@@ -0,0 +1,111 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * Unit tests for the Shopify cert-badge Web-Component revalidation DOM layer — the fail-closed
4
+ * (suppress → hide) / fail-open (404|offline → keep SSR) behavior, driven with a mocked verdict
5
+ * (no network), plus the key-sync cross-check guarding the browser-bundled public key against
6
+ * signer.ts drift.
7
+ */
8
+
9
+ import type { CertVerdict } from "@certrev/cert-contract";
10
+ import { beforeEach, describe, expect, it, vi } from "vitest";
11
+ import { type RevalidateDeps, revalidateBadge } from "./revalidate.js";
12
+
13
+ // NOTE (POR-10721 W2): the "published key sync" cross-check that lived here — asserting the
14
+ // browser-bundled `CERT_ISSUER_*` (certrev-cert.entry.ts) equals portal's signer
15
+ // `CERT_ENVELOPE_*` — is a CROSS-REPO guard and cannot run in this package (there is no portal
16
+ // signer to compare against). It stays a PORTAL-side test: once portal re-points at the published
17
+ // package, portal asserts `@certrev/cert-block/modal`'s exported CERT_ISSUER_* == its own
18
+ // signer's CERT_ENVELOPE_*. The package exports CERT_ISSUER_* from the modal barrel for exactly
19
+ // that consumer-side assertion.
20
+
21
+ /** Build a `<certrev-badge revalidate>` host wrapping an SSR `.certrev-badge`. */
22
+ function mountHost(attrs: Record<string, string> = {}): Element {
23
+ const host = document.createElement("certrev-badge");
24
+ host.setAttribute("revalidate", "");
25
+ host.setAttribute("data-delivery-api", "https://portal.certrev.com");
26
+ host.setAttribute("data-external-id", "gid://shopify/Article/12345");
27
+ host.setAttribute("data-platform", "shopify");
28
+ for (const [k, v] of Object.entries(attrs)) {
29
+ if (v === "") host.removeAttribute(k);
30
+ else host.setAttribute(k, v);
31
+ }
32
+ host.innerHTML =
33
+ '<section class="certrev-badge">Reviewed by Dr. Jane Doe</section>';
34
+ document.body.appendChild(host);
35
+ return host;
36
+ }
37
+
38
+ function depsReturning(verdict: CertVerdict): {
39
+ deps: RevalidateDeps;
40
+ spy: ReturnType<typeof vi.fn>;
41
+ } {
42
+ const spy = vi.fn(async () => verdict);
43
+ return { deps: { verify: spy as RevalidateDeps["verify"] }, spy };
44
+ }
45
+
46
+ beforeEach(() => {
47
+ document.body.innerHTML = "";
48
+ });
49
+
50
+ describe("revalidateBadge", () => {
51
+ it("leaves the SSR badge visible on a `render` verdict", async () => {
52
+ const host = mountHost();
53
+ const { deps } = depsReturning({
54
+ decision: "render",
55
+ payload: {},
56
+ } as unknown as CertVerdict);
57
+ await revalidateBadge(host, deps);
58
+ expect(host.querySelector(".certrev-badge")!.hasAttribute("hidden")).toBe(
59
+ false,
60
+ );
61
+ });
62
+
63
+ it("HIDES the SSR badge on a `suppress` verdict (fail-closed)", async () => {
64
+ const host = mountHost();
65
+ const { deps } = depsReturning({ decision: "suppress", reason: "revoked" });
66
+ await revalidateBadge(host, deps);
67
+ const badge = host.querySelector(".certrev-badge")!;
68
+ expect(badge.hasAttribute("hidden")).toBe(true);
69
+ expect(badge.getAttribute("data-certrev-suppressed")).toBe("revoked");
70
+ });
71
+
72
+ it("leaves the SSR badge when verify throws (404 / offline → never a new failure mode)", async () => {
73
+ const host = mountHost();
74
+ const deps: RevalidateDeps = {
75
+ verify: vi.fn(async () => {
76
+ throw new Error("cert delivery 404");
77
+ }) as RevalidateDeps["verify"],
78
+ };
79
+ await revalidateBadge(host, deps);
80
+ expect(host.querySelector(".certrev-badge")!.hasAttribute("hidden")).toBe(
81
+ false,
82
+ );
83
+ });
84
+
85
+ it("does not call verify when routing attributes are missing", async () => {
86
+ const host = mountHost({ "data-external-id": "" });
87
+ const { deps, spy } = depsReturning({
88
+ decision: "suppress",
89
+ reason: "revoked",
90
+ });
91
+ await revalidateBadge(host, deps);
92
+ expect(spy).not.toHaveBeenCalled();
93
+ expect(host.querySelector(".certrev-badge")!.hasAttribute("hidden")).toBe(
94
+ false,
95
+ );
96
+ });
97
+
98
+ it("passes the host routing attributes through to verify", async () => {
99
+ const host = mountHost();
100
+ const { deps, spy } = depsReturning({
101
+ decision: "suppress",
102
+ reason: "expired",
103
+ });
104
+ await revalidateBadge(host, deps);
105
+ expect(spy).toHaveBeenCalledWith({
106
+ baseUrl: "https://portal.certrev.com",
107
+ platform: "shopify",
108
+ externalId: "gid://shopify/Article/12345",
109
+ });
110
+ });
111
+ });
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Shopify cert-badge Web-Component revalidation — the `crypto_verify` progressive-enhancement
3
+ * layer over the SSR Liquid badge.
4
+ *
5
+ * The Liquid edge renders the badge server-side (crawlable) and gates lifecycle in Liquid, but
6
+ * it CANNOT run Ed25519 (`platform_ownership`). This module upgrades the HUMAN-visible badge to
7
+ * a real crypto_verify check: on page load it re-fetches the Delivery artifact from the public
8
+ * Delivery API and runs CertREV's CANONICAL verdict kernel (`verifyArtifact` from
9
+ * `@certrev/cert-contract` — NOT a re-implementation), then:
10
+ *
11
+ * • verdict `render` → leave the SSR badge (verified live);
12
+ * • verdict `suppress` (invalid-sig / subject-mismatch / revoked / expired) → HIDE it
13
+ * (fail-closed), stamping `data-certrev-suppressed` with the verdict's reason. A revoked
14
+ * placement now serves the SLIM `CertTombstone` (POR-10481), which `verifyArtifact`
15
+ * resolves to `suppress:'revoked'` — so the badge hides AND the suppressed-reason telemetry
16
+ * is right (`verifyEnvelope` used to mis-report it as `unsupported_contract_version`;
17
+ * POR-10684);
18
+ * • the Delivery API 404s (no servable artifact) or the network errors → LEAVE the SSR badge.
19
+ * The SSR copy already passed the Liquid lifecycle gate against the app-owned metafield
20
+ * (the authoritative revoke/expire surface — the binding retracts it on revoke), so the WC
21
+ * is enhancement, never a NEW failure mode or a false-hide on an untracked placement.
22
+ *
23
+ * Crawlers + AI extractors read the SSR copy regardless (this never blocks first paint). The
24
+ * verdict source is injected so the unit test drives it without a network; the production entry
25
+ * (`certrev-cert.entry.ts`) wires the real fetch-+-`verifyArtifact` verifier + published key.
26
+ */
27
+
28
+ import type { CertVerdict } from '@certrev/cert-contract'
29
+
30
+ /** Where + what to verify, derived from the `<certrev-badge>` host's routing attributes. */
31
+ export interface BadgeSource {
32
+ readonly baseUrl: string
33
+ readonly platform: string
34
+ readonly externalId: string
35
+ }
36
+
37
+ /** Fetch-the-Delivery-API-+-verify, injected so tests drive it without a network. THROWS on a
38
+ * 404 / network error (→ keep SSR); returns a verdict on a served envelope. */
39
+ export type VerifyBadgeFn = (source: BadgeSource) => Promise<CertVerdict>
40
+
41
+ export interface RevalidateDeps {
42
+ readonly verify: VerifyBadgeFn
43
+ }
44
+
45
+ /**
46
+ * Re-verify one `<certrev-badge revalidate>` host and hide its inner `.certrev-badge` on a
47
+ * definitive `suppress` verdict. No-op (keep SSR) when the host lacks routing attributes or the
48
+ * verify call throws (404 / offline).
49
+ */
50
+ export async function revalidateBadge(host: Element, deps: RevalidateDeps): Promise<void> {
51
+ const badge = host.querySelector('.certrev-badge')
52
+ if (!badge) return
53
+
54
+ const baseUrl = host.getAttribute('data-delivery-api')
55
+ const externalId = host.getAttribute('data-external-id')
56
+ const platform = host.getAttribute('data-platform') ?? 'shopify'
57
+ if (!baseUrl || !externalId) return // no source → keep the lifecycle-gated SSR copy
58
+
59
+ let verdict: CertVerdict
60
+ try {
61
+ verdict = await deps.verify({ baseUrl, platform, externalId })
62
+ } catch {
63
+ return // 404 / offline → keep SSR (never a new failure mode or a false-hide)
64
+ }
65
+
66
+ if (verdict.decision !== 'render') {
67
+ badge.setAttribute('hidden', '')
68
+ badge.setAttribute('data-certrev-suppressed', verdict.reason)
69
+ }
70
+ }
71
+
72
+ /** Revalidate every `<certrev-badge revalidate>` once the DOM is ready. */
73
+ export function initBadgeRevalidation(doc: Document, deps: RevalidateDeps): void {
74
+ const run = (): void => {
75
+ for (const host of Array.from(doc.querySelectorAll('certrev-badge[revalidate]'))) {
76
+ void revalidateBadge(host, deps)
77
+ }
78
+ }
79
+ if (doc.readyState === 'loading') doc.addEventListener('DOMContentLoaded', run, { once: true })
80
+ else run()
81
+ }