@certrev/cert-block 0.3.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +14 -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,494 @@
1
+ /**
2
+ * Scoped styles + inline SVGs for the `<certrev-cert-modal>` Shadow-DOM element
3
+ * (POR-10102). The single source of truth for the certificate (3a) + reviewer (4b)
4
+ * modal look, ported byte-faithfully from the Claude Design reference
5
+ * (`docs/claude/design/certificate-modals/`, footer variant 3a + reviewer 4b).
6
+ *
7
+ * Because the element renders inside a shadow root, this CSS is fully scoped: the
8
+ * brand storefront theme cannot bleed in, and these rules never leak out — so the
9
+ * old `certrev-cert.css` "theme-bleed armor" and the literal-hex-only constraint
10
+ * (which the `body_html` WordPress modal still needs) do NOT apply here. Tokens are
11
+ * plain custom properties on `:host`; fonts resolve from document-level `@font-face`
12
+ * that the element injects on first construct (font faces do not scope into a shadow
13
+ * root, so they must live at document level — see `certrev-cert-modal.ts`).
14
+ */
15
+
16
+ import { LOGO_PATHS } from './logo-paths.js'
17
+
18
+ /** The four-chevron CertREV mark (viewBox 0 0 375 375), tinted via the passed fill. Path data is
19
+ * single-sourced from src/lib/design-guide/logo.ts (the SOT the codegen mirrors into every twin). */
20
+ export function chevronMark(size: number, fill: string): string {
21
+ const paths = LOGO_PATHS.map((d) => `<path fill="${fill}" d="${d}"/>`).join('')
22
+ return `<svg width="${size}" height="${size}" viewBox="0 0 375 375" aria-hidden="true" style="display:block;flex-shrink:0">${paths}</svg>`
23
+ }
24
+
25
+ /** The 8px lime-chip check glyph (viewBox 0 0 24 24). */
26
+ export function checkIcon(size = 8): string {
27
+ return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg>`
28
+ }
29
+
30
+ /**
31
+ * The rotated 74×74 verified stamp: two concentric navy rings + a circular
32
+ * `textPath` reading "CERTREV VERIFIED · CERTIFIED ·" + the chevron mark centered.
33
+ * `idSuffix` uniquifies the internal `<path>` id so multiple stamps on one page
34
+ * (or a story grid) don't collide on the shared `href`.
35
+ */
36
+ export function verifiedStamp(idSuffix = 'crm'): string {
37
+ const arcId = `crm-stamp-arc-${idSuffix}`
38
+ const mark = LOGO_PATHS.map((d) => `<path fill="#0a1b3f" d="${d}"/>`).join('')
39
+ return `<span class="crm-stamp"><svg viewBox="0 0 128 128" width="74" height="74" role="img" aria-label="CertREV verified stamp" style="display:block"><defs><path id="${arcId}" d="M64 18 A46 46 0 1 1 64 110 A46 46 0 1 1 64 18"/></defs><circle cx="64" cy="64" r="58" fill="none" stroke="#0a1b3f" stroke-width="1.3"/><circle cx="64" cy="64" r="51.5" fill="none" stroke="#0a1b3f" stroke-width="0.7" stroke-opacity="0.4"/><text font-family="'JetBrains Mono',ui-monospace,monospace" font-size="7.8" font-weight="600" letter-spacing="1.3" fill="#0a1b3f"><textPath href="#${arcId}" startOffset="1%">CERTREV VERIFIED · CERTIFIED · </textPath></text><svg x="49" y="50" width="30" height="28" viewBox="0 0 375 375" preserveAspectRatio="xMidYMid meet">${mark}</svg></svg></span>`
40
+ }
41
+
42
+ /**
43
+ * The five families the modal uses, self-hosted-or-Google (the element injects
44
+ * ONE document-level `<link>`/`@font-face` for these; font-display:swap). Kept as
45
+ * a constant so the loader + any story reference the identical set.
46
+ */
47
+ export const CERT_MODAL_FONT_FAMILIES = [
48
+ 'DM Sans',
49
+ 'Playfair Display',
50
+ 'JetBrains Mono',
51
+ 'Plus Jakarta Sans',
52
+ 'Allura',
53
+ ] as const
54
+
55
+ // The modal's fonts are self-hosted (POR-10657): the standalone loader (certrev-cert-modal.ts)
56
+ // injects the shared CERT_FONT_FACE_CSS @font-face block (R2 URLs) instead of a render-blocking
57
+ // Google Fonts stylesheet. In the theme-app-extension the host page already declares these via
58
+ // certrev-cert.css, so `data-fonts="host"` suppresses the injection entirely.
59
+
60
+ /** The full scoped stylesheet, adopted via a constructable `CSSStyleSheet`. */
61
+ export const CERT_MODAL_CSS = `
62
+ :host {
63
+ --cr-navy: #0a1b3f;
64
+ --cr-lime: #d4e157;
65
+ --cr-white: #ffffff;
66
+ --cr-navy-06: rgba(10, 27, 63, 0.06);
67
+ --cr-navy-10: rgba(10, 27, 63, 0.1);
68
+ --cr-navy-25: rgba(10, 27, 63, 0.25);
69
+ --cr-navy-40: rgba(10, 27, 63, 0.4);
70
+ --cr-navy-45: rgba(10, 27, 63, 0.45);
71
+ --cr-navy-50: rgba(10, 27, 63, 0.5);
72
+ --cr-navy-55: rgba(10, 27, 63, 0.55);
73
+ --cr-navy-60: rgba(10, 27, 63, 0.6);
74
+ --cr-navy-85: rgba(10, 27, 63, 0.85);
75
+ --cr-white-60: rgba(255, 255, 255, 0.6);
76
+ --cr-white-85: rgba(255, 255, 255, 0.85);
77
+ --cr-cta-hover: #0c2149;
78
+ --cr-ghost-hover: rgba(10, 27, 63, 0.12);
79
+ --cr-shadow: 0 30px 60px -18px rgba(10, 27, 63, 0.5);
80
+ --cr-backdrop: rgba(10, 27, 63, 0.45);
81
+ --cr-sans: 'DM Sans', system-ui, -apple-system, sans-serif;
82
+ --cr-serif: 'Playfair Display', Georgia, serif;
83
+ --cr-mono: 'JetBrains Mono', ui-monospace, Menlo, monospace;
84
+ --cr-wordmark: 'Plus Jakarta Sans', system-ui, sans-serif;
85
+ --cr-script: 'Allura', cursive;
86
+ }
87
+
88
+ .crm-dialog {
89
+ border: none;
90
+ border-radius: 14px;
91
+ padding: 0;
92
+ width: 92%;
93
+ max-height: 92vh;
94
+ overflow: auto;
95
+ box-shadow: var(--cr-shadow);
96
+ background: var(--cr-white);
97
+ color: var(--cr-navy);
98
+ font-family: var(--cr-sans);
99
+ /* fixed, NOT relative (POR-10473): a :modal dialog (showModal) renders in the TOP LAYER; a
100
+ * relative dialog anchors to its scrolled document position, so once the page is scrolled the
101
+ * panel renders off-screen while the ::backdrop still covers the viewport (measured top -1972 at
102
+ * scrollY 2537). fixed is viewport-anchored (the UA inset:0 + margin:auto centers it) and still
103
+ * provides the containing block for the absolutely-positioned .crm-close (x stays in-corner). */
104
+ position: fixed;
105
+ /* defensive resets against a hostile brand theme reaching the top-layer dialog */
106
+ box-sizing: border-box;
107
+ text-align: left;
108
+ line-height: 1.4;
109
+ }
110
+ .crm-dialog *,
111
+ .crm-dialog *::before,
112
+ .crm-dialog *::after {
113
+ box-sizing: border-box;
114
+ }
115
+ .crm-dialog::backdrop {
116
+ background: var(--cr-backdrop);
117
+ }
118
+ .crm-dialog--cert {
119
+ max-width: 484px;
120
+ }
121
+ .crm-dialog--expert {
122
+ max-width: 436px;
123
+ }
124
+
125
+ /* Close (×) */
126
+ .crm-close {
127
+ position: absolute;
128
+ top: 12px;
129
+ right: 15px;
130
+ z-index: 2;
131
+ background: none;
132
+ border: none;
133
+ cursor: pointer;
134
+ padding: 2px;
135
+ color: var(--cr-white-60);
136
+ font-size: 1.35rem;
137
+ line-height: 1;
138
+ font-family: var(--cr-sans);
139
+ transition: color 0.15s;
140
+ }
141
+ .crm-close:hover {
142
+ color: var(--cr-white);
143
+ }
144
+
145
+ /* Header bar */
146
+ .crm-head {
147
+ display: flex;
148
+ align-items: center;
149
+ gap: 9px;
150
+ padding: 13px 22px;
151
+ background: var(--cr-navy);
152
+ color: var(--cr-white);
153
+ }
154
+ .crm-head__eyebrow {
155
+ font-family: var(--cr-mono);
156
+ font-size: 8.5px;
157
+ font-weight: 600;
158
+ letter-spacing: 0.16em;
159
+ text-transform: uppercase;
160
+ color: var(--cr-white-85);
161
+ }
162
+ .crm-head__wordmark {
163
+ margin-left: auto;
164
+ margin-right: 22px;
165
+ font-family: var(--cr-wordmark);
166
+ font-weight: 800;
167
+ font-size: 13px;
168
+ letter-spacing: 0.03em;
169
+ color: var(--cr-white);
170
+ }
171
+
172
+ /* Body */
173
+ .crm-body--cert {
174
+ padding: 24px 28px 26px;
175
+ }
176
+ .crm-body--expert {
177
+ padding: 22px 26px 24px;
178
+ }
179
+
180
+ /* Avatar (shared, lime ring) */
181
+ .crm-avatar {
182
+ position: relative;
183
+ flex-shrink: 0;
184
+ display: flex;
185
+ align-items: center;
186
+ justify-content: center;
187
+ border-radius: 50%;
188
+ background: var(--cr-navy);
189
+ color: var(--cr-white);
190
+ font-weight: 700;
191
+ object-fit: cover;
192
+ box-shadow: 0 0 0 2px var(--cr-white), 0 0 0 3.5px var(--cr-lime);
193
+ }
194
+ .crm-avatar--56 {
195
+ width: 56px;
196
+ height: 56px;
197
+ font-size: 17px;
198
+ }
199
+ .crm-avatar--64 {
200
+ width: 64px;
201
+ height: 64px;
202
+ font-size: 1.35rem;
203
+ letter-spacing: 0.02em;
204
+ }
205
+
206
+ /* Certificate — reviewing-expert row */
207
+ .crm-expert-row {
208
+ display: flex;
209
+ align-items: center;
210
+ gap: 14px;
211
+ }
212
+ .crm-expert-row__info {
213
+ flex: 1;
214
+ min-width: 0;
215
+ }
216
+ .crm-expert-row__eyebrow {
217
+ font-family: var(--cr-mono);
218
+ font-size: 8.5px;
219
+ font-weight: 600;
220
+ letter-spacing: 0.14em;
221
+ text-transform: uppercase;
222
+ color: var(--cr-navy-45);
223
+ }
224
+ .crm-expert-row__name {
225
+ font-size: 15px;
226
+ font-weight: 700;
227
+ color: var(--cr-navy);
228
+ margin-top: 2px;
229
+ }
230
+ .crm-expert-row__creds {
231
+ font-size: 12px;
232
+ color: var(--cr-navy-60);
233
+ margin-top: 1px;
234
+ }
235
+
236
+ /* Certificate — "Certifies the article" */
237
+ .crm-certifies {
238
+ margin: 18px 0 0;
239
+ padding-top: 16px;
240
+ border-top: 1px solid var(--cr-navy-10);
241
+ }
242
+ .crm-certifies__eyebrow {
243
+ font-family: var(--cr-mono);
244
+ font-size: 9px;
245
+ font-weight: 600;
246
+ letter-spacing: 0.16em;
247
+ text-transform: uppercase;
248
+ color: var(--cr-navy-40);
249
+ }
250
+ .crm-certifies__title {
251
+ font-family: var(--cr-serif);
252
+ font-weight: 700;
253
+ font-size: 18px;
254
+ line-height: 1.25;
255
+ color: var(--cr-navy);
256
+ margin-top: 5px;
257
+ text-wrap: balance;
258
+ }
259
+
260
+ /* Certificate — meta row */
261
+ .crm-meta {
262
+ display: flex;
263
+ gap: 10px;
264
+ margin-top: 16px;
265
+ }
266
+ .crm-meta__col {
267
+ flex: 1;
268
+ }
269
+ .crm-meta__label {
270
+ font-family: var(--cr-mono);
271
+ font-size: 8px;
272
+ letter-spacing: 0.12em;
273
+ text-transform: uppercase;
274
+ color: var(--cr-navy-40);
275
+ }
276
+ .crm-meta__value {
277
+ font-size: 12.5px;
278
+ font-weight: 600;
279
+ color: var(--cr-navy);
280
+ margin-top: 3px;
281
+ }
282
+ .crm-meta__value--mono {
283
+ font-family: var(--cr-mono);
284
+ font-size: 12px;
285
+ }
286
+ .crm-meta__status {
287
+ margin-top: 3px;
288
+ }
289
+
290
+ /* Certified chip (meta status) */
291
+ .crm-chip {
292
+ display: inline-flex;
293
+ align-items: center;
294
+ gap: 3px;
295
+ padding: 2px 7px;
296
+ border-radius: 4px;
297
+ background: var(--cr-lime);
298
+ color: var(--cr-navy);
299
+ font-family: var(--cr-mono);
300
+ font-size: 8.5px;
301
+ font-weight: 700;
302
+ letter-spacing: 0.06em;
303
+ text-transform: uppercase;
304
+ }
305
+
306
+ /* Certificate — signature + stamp footer */
307
+ .crm-sig {
308
+ display: flex;
309
+ align-items: flex-end;
310
+ justify-content: space-between;
311
+ margin-top: 20px;
312
+ padding-top: 16px;
313
+ border-top: 1px solid var(--cr-navy-10);
314
+ gap: 12px;
315
+ }
316
+ .crm-sig__name {
317
+ font-family: var(--cr-script);
318
+ font-size: 30px;
319
+ line-height: 0.9;
320
+ color: var(--cr-navy);
321
+ }
322
+ .crm-sig__rule {
323
+ width: 146px;
324
+ max-width: 100%;
325
+ height: 1px;
326
+ background: var(--cr-navy-25);
327
+ margin: 4px 0 6px;
328
+ }
329
+ .crm-sig__url {
330
+ font-family: var(--cr-mono);
331
+ font-size: 8px;
332
+ font-weight: 500;
333
+ letter-spacing: 0.12em;
334
+ text-transform: uppercase;
335
+ color: var(--cr-navy-50);
336
+ }
337
+ .crm-stamp {
338
+ transform: rotate(-8deg);
339
+ flex-shrink: 0;
340
+ }
341
+ .crm-stamp svg {
342
+ display: block;
343
+ }
344
+
345
+ /* Actions (shared) */
346
+ .crm-actions {
347
+ display: flex;
348
+ gap: 10px;
349
+ margin-top: 20px;
350
+ }
351
+ .crm-cta {
352
+ flex: 1;
353
+ display: inline-block;
354
+ text-align: center;
355
+ padding: 11px 16px;
356
+ background: var(--cr-navy);
357
+ color: var(--cr-white);
358
+ text-decoration: none;
359
+ border-radius: 8px;
360
+ font-size: 13px;
361
+ font-weight: 600;
362
+ font-family: var(--cr-sans);
363
+ border: none;
364
+ cursor: pointer;
365
+ transition: background 0.15s;
366
+ }
367
+ .crm-cta:hover {
368
+ background: var(--cr-cta-hover);
369
+ }
370
+ .crm-ghost {
371
+ padding: 11px 18px;
372
+ background: var(--cr-navy-06);
373
+ color: var(--cr-navy);
374
+ border: none;
375
+ border-radius: 8px;
376
+ font-size: 13px;
377
+ font-weight: 600;
378
+ font-family: var(--cr-sans);
379
+ cursor: pointer;
380
+ transition: background 0.15s;
381
+ }
382
+ .crm-ghost:hover {
383
+ background: var(--cr-ghost-hover);
384
+ }
385
+
386
+ /* Reviewer — identity row */
387
+ .crm-id {
388
+ display: flex;
389
+ align-items: center;
390
+ gap: 14px;
391
+ }
392
+ .crm-id__info {
393
+ min-width: 0;
394
+ }
395
+ .crm-id__name {
396
+ font-size: 16px;
397
+ font-weight: 700;
398
+ line-height: 1.25;
399
+ color: var(--cr-navy);
400
+ }
401
+ .crm-id__subtitle {
402
+ font-size: 12.5px;
403
+ line-height: 1.4;
404
+ color: var(--cr-navy-60);
405
+ margin-top: 2px;
406
+ }
407
+
408
+ /* Reviewer — credential chips */
409
+ .crm-chips {
410
+ display: flex;
411
+ flex-wrap: wrap;
412
+ gap: 7px;
413
+ margin-top: 16px;
414
+ }
415
+ .crm-chips__chip {
416
+ display: inline-flex;
417
+ align-items: center;
418
+ padding: 4px 10px;
419
+ border-radius: 999px;
420
+ background: var(--cr-navy-06);
421
+ color: var(--cr-navy);
422
+ font-size: 11px;
423
+ font-weight: 600;
424
+ }
425
+
426
+ /* Reviewer — bio */
427
+ .crm-bio {
428
+ margin: 16px 0 0;
429
+ font-size: 0.9rem;
430
+ line-height: 1.55;
431
+ color: var(--cr-navy-85);
432
+ }
433
+
434
+ /* Reviewer — trust line (the disclosure footer below now owns the spacing to the actions) */
435
+ .crm-trust {
436
+ display: flex;
437
+ align-items: center;
438
+ gap: 9px;
439
+ flex-wrap: wrap;
440
+ margin: 16px 0 0;
441
+ padding-top: 16px;
442
+ border-top: 1px solid var(--cr-navy-10);
443
+ }
444
+ .crm-trust__chip {
445
+ display: inline-flex;
446
+ align-items: center;
447
+ gap: 4px;
448
+ padding: 3px 8px;
449
+ border-radius: 4px;
450
+ background: var(--cr-lime);
451
+ color: var(--cr-navy);
452
+ font-family: var(--cr-mono);
453
+ font-size: 8.5px;
454
+ font-weight: 700;
455
+ letter-spacing: 0.08em;
456
+ text-transform: uppercase;
457
+ }
458
+ .crm-trust__text {
459
+ font-family: var(--cr-mono);
460
+ font-size: 9px;
461
+ letter-spacing: 0.05em;
462
+ color: var(--cr-navy-50);
463
+ }
464
+
465
+ /* FTC disclosure footer (POR-10496) — material-connection cue (compensated only) + scope line
466
+ * (always). Rendered on both the certificate + reviewer dialogs, immediately above the actions. */
467
+ .crm-disclosure {
468
+ margin-top: 14px;
469
+ padding-top: 14px;
470
+ border-top: 1px solid var(--cr-navy-10);
471
+ font-family: var(--cr-sans);
472
+ font-size: 11.5px;
473
+ line-height: 1.5;
474
+ color: var(--cr-navy-50);
475
+ }
476
+ .crm-disclosure__cue {
477
+ font-family: var(--cr-mono);
478
+ font-size: 10px;
479
+ font-weight: 700;
480
+ letter-spacing: 0.06em;
481
+ text-transform: uppercase;
482
+ color: var(--cr-navy);
483
+ }
484
+
485
+ /* Narrow storefronts / phones */
486
+ @media (max-width: 440px) {
487
+ .crm-body--cert {
488
+ padding: 20px 18px 22px;
489
+ }
490
+ .crm-body--expert {
491
+ padding: 18px 18px 20px;
492
+ }
493
+ }
494
+ `
@@ -0,0 +1,125 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * Unit tests — the `<certrev-cert-modal>` kill-switch decision (POR-10101 / POR-10684).
4
+ *
5
+ * `isArtifactSuppressed` is the modal-side lifecycle gate: a fetched Delivery-API artifact that is
6
+ * REVOKED (the slim `CertTombstone` OR a legacy envelope with `revokedAt` set) or EXPIRED must NOT
7
+ * render "verified" content. The route-integration + control-plane tests prove the tombstone is
8
+ * SERVED and cryptographically verifies to `suppress:'revoked'`; this pins the modal's consumption
9
+ * of it — including the SLIM tombstone (no payload), which a `payload.lifecycle` read alone would
10
+ * miss so `markSuppressed()` would never fire (POR-10684).
11
+ */
12
+
13
+ import type {
14
+ CertDeliveryArtifact,
15
+ CertDeliveryEnvelope,
16
+ CertTombstone,
17
+ } from "@certrev/cert-contract";
18
+ import { afterEach, describe, expect, it, vi } from "vitest";
19
+ import { CERT_MODAL_TAG, isArtifactSuppressed } from "./certrev-cert-modal.js";
20
+
21
+ function envelope(
22
+ lifecycle: Partial<CertDeliveryEnvelope["payload"]["lifecycle"]>,
23
+ ): CertDeliveryEnvelope {
24
+ // Only `lifecycle` matters to the gate; the rest is filler cast through `unknown`.
25
+ return {
26
+ payload: {
27
+ lifecycle: {
28
+ issuedAt: "2026-01-01T00:00:00.000Z",
29
+ expiresAt: "2099-01-01T00:00:00.000Z",
30
+ revokedAt: null,
31
+ revision: 1,
32
+ ...lifecycle,
33
+ },
34
+ },
35
+ signature: {},
36
+ } as unknown as CertDeliveryEnvelope;
37
+ }
38
+
39
+ /** A SLIM revocation tombstone (POR-10481): `kind: 'tombstone'`, subject + revocation facts, NO payload. */
40
+ function tombstone(): CertTombstone {
41
+ return {
42
+ kind: "tombstone",
43
+ contractVersion: 1,
44
+ subject: { platform: "shopify", externalId: "gid://shopify/Article/12345" },
45
+ revokedAt: "2026-06-20T00:00:00.000Z",
46
+ revocationReason: "cert_revoked",
47
+ signature: { alg: "ed25519", kid: "cert-issuer-1", value: "x".repeat(86) },
48
+ } as unknown as CertTombstone;
49
+ }
50
+
51
+ describe("isArtifactSuppressed (modal kill-switch)", () => {
52
+ it("a live envelope (not revoked, not expired) → NOT suppressed", () => {
53
+ expect(isArtifactSuppressed(envelope({}))).toBe(false);
54
+ });
55
+
56
+ it('a SLIM tombstone (kind: "tombstone", no payload) → suppressed (POR-10684)', () => {
57
+ // The whole point of POR-10684: a slim tombstone has NO `payload.lifecycle`, so the old
58
+ // payload-only read returned false and markSuppressed() never fired. `isTombstone` catches it.
59
+ expect(isArtifactSuppressed(tombstone())).toBe(true);
60
+ });
61
+
62
+ it("a REVOKED envelope (legacy: revokedAt set) → suppressed", () => {
63
+ expect(
64
+ isArtifactSuppressed(envelope({ revokedAt: "2026-06-20T00:00:00.000Z" })),
65
+ ).toBe(true);
66
+ });
67
+
68
+ it("an EXPIRED envelope (expiresAt in the past) → suppressed", () => {
69
+ expect(
70
+ isArtifactSuppressed(envelope({ expiresAt: "2020-01-01T00:00:00.000Z" })),
71
+ ).toBe(true);
72
+ });
73
+
74
+ it("a missing/malformed artifact → NOT suppressed (fail-open; the 404 path handles absence)", () => {
75
+ expect(isArtifactSuppressed(null)).toBe(false);
76
+ expect(isArtifactSuppressed(undefined)).toBe(false);
77
+ expect(
78
+ isArtifactSuppressed({ payload: {} } as unknown as CertDeliveryArtifact),
79
+ ).toBe(false);
80
+ });
81
+ });
82
+
83
+ describe("<certrev-cert-modal> markSuppressed on a fetched slim tombstone (POR-10684)", () => {
84
+ afterEach(() => {
85
+ vi.unstubAllGlobals();
86
+ document.body.innerHTML = "";
87
+ });
88
+
89
+ it("a fetched slim tombstone → marks the host suppressed AND hides the light-DOM triggers", async () => {
90
+ // The Delivery API serves a slim tombstone (200, kind: 'tombstone', no payload). Before
91
+ // POR-10684 the modal read `payload.lifecycle`, missed it, and never called markSuppressed().
92
+ vi.stubGlobal(
93
+ "fetch",
94
+ vi.fn(
95
+ async () =>
96
+ ({ ok: true, json: async () => tombstone() }) as unknown as Response,
97
+ ),
98
+ );
99
+
100
+ const el = document.createElement(CERT_MODAL_TAG) as HTMLElement & {
101
+ open(kind: "cert" | "expert"): boolean;
102
+ };
103
+ el.setAttribute("data-delivery-api", "https://portal.certrev.com");
104
+ el.setAttribute("data-platform", "shopify");
105
+ el.setAttribute("data-external-id", "gid://shopify/Article/12345");
106
+ el.setAttribute("data-fonts", "host"); // skip document-level font injection in the test
107
+ document.body.appendChild(el);
108
+
109
+ const trigger = document.createElement("a");
110
+ trigger.setAttribute("data-certrev-modal-open", "cert");
111
+ document.body.appendChild(trigger);
112
+
113
+ const handled = el.open("cert");
114
+ expect(handled).toBe(true); // it has a fetch source, so it takes over the open
115
+ // The open() kicks off an un-awaited fetch; await the private in-flight promise it stored.
116
+ await (el as unknown as { fetchInFlight: Promise<void> | null })
117
+ .fetchInFlight;
118
+
119
+ expect(el.hasAttribute("data-certrev-suppressed")).toBe(true);
120
+ expect(trigger.hasAttribute("hidden")).toBe(true);
121
+ expect(trigger.getAttribute("aria-hidden")).toBe("true");
122
+ // A revoked cert never renders a dialog.
123
+ expect(el.shadowRoot?.querySelector("dialog")).toBeNull();
124
+ });
125
+ });