@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.
- package/README.md +48 -1
- package/dist/builder/cert-chrome-data.d.ts +129 -0
- package/dist/builder/cert-chrome-data.d.ts.map +1 -0
- package/dist/builder/cert-chrome-data.js +59 -0
- package/dist/builder/cert-chrome-data.js.map +1 -0
- package/dist/builder/cert-review-card.d.ts +68 -0
- package/dist/builder/cert-review-card.d.ts.map +1 -0
- package/dist/builder/cert-review-card.js +129 -0
- package/dist/builder/cert-review-card.js.map +1 -0
- package/dist/builder/index.d.ts +2 -0
- package/dist/builder/index.d.ts.map +1 -1
- package/dist/builder/index.js +3 -0
- package/dist/builder/index.js.map +1 -1
- package/dist/components/preview-block.d.ts +43 -0
- package/dist/components/preview-block.d.ts.map +1 -0
- package/dist/components/preview-block.js +53 -0
- package/dist/components/preview-block.js.map +1 -0
- package/dist/components/render-cert-block.d.ts +50 -5
- package/dist/components/render-cert-block.d.ts.map +1 -1
- package/dist/components/render-cert-block.js +341 -93
- package/dist/components/render-cert-block.js.map +1 -1
- package/dist/components/render-def.d.ts +27 -0
- package/dist/components/render-def.d.ts.map +1 -1
- package/dist/components/render-def.js +38 -0
- package/dist/components/render-def.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/modal/avatar-resize.d.ts +60 -0
- package/dist/modal/avatar-resize.d.ts.map +1 -0
- package/dist/modal/avatar-resize.js +91 -0
- package/dist/modal/avatar-resize.js.map +1 -0
- package/dist/modal/cert-modal-view.d.ts +74 -0
- package/dist/modal/cert-modal-view.d.ts.map +1 -0
- package/dist/modal/cert-modal-view.js +160 -0
- package/dist/modal/cert-modal-view.js.map +1 -0
- package/dist/modal/certrev-cert-modal.d.ts +72 -0
- package/dist/modal/certrev-cert-modal.d.ts.map +1 -0
- package/dist/modal/certrev-cert-modal.js +270 -0
- package/dist/modal/certrev-cert-modal.js.map +1 -0
- package/dist/modal/certrev-cert-modal.styles.d.ts +35 -0
- package/dist/modal/certrev-cert-modal.styles.d.ts.map +1 -0
- package/dist/modal/certrev-cert-modal.styles.js +488 -0
- package/dist/modal/certrev-cert-modal.styles.js.map +1 -0
- package/dist/modal/certrev-cert.entry.d.ts +19 -0
- package/dist/modal/certrev-cert.entry.d.ts.map +1 -0
- package/dist/modal/certrev-cert.entry.js +39 -0
- package/dist/modal/certrev-cert.entry.js.map +1 -0
- package/dist/modal/certrev-cert.js +557 -0
- package/dist/modal/display-strings.d.ts +22 -0
- package/dist/modal/display-strings.d.ts.map +1 -0
- package/dist/modal/display-strings.js +35 -0
- package/dist/modal/display-strings.js.map +1 -0
- package/dist/modal/escape.d.ts +13 -0
- package/dist/modal/escape.d.ts.map +1 -0
- package/dist/modal/escape.js +20 -0
- package/dist/modal/escape.js.map +1 -0
- package/dist/modal/font-face.d.ts +4 -0
- package/dist/modal/font-face.d.ts.map +1 -0
- package/dist/modal/font-face.js +137 -0
- package/dist/modal/font-face.js.map +1 -0
- package/dist/modal/ftc-disclosure.d.ts +12 -0
- package/dist/modal/ftc-disclosure.d.ts.map +1 -0
- package/dist/modal/ftc-disclosure.js +12 -0
- package/dist/modal/ftc-disclosure.js.map +1 -0
- package/dist/modal/ftc-guard.d.ts +50 -0
- package/dist/modal/ftc-guard.d.ts.map +1 -0
- package/dist/modal/ftc-guard.js +117 -0
- package/dist/modal/ftc-guard.js.map +1 -0
- package/dist/modal/index.d.ts +20 -0
- package/dist/modal/index.d.ts.map +1 -0
- package/dist/modal/index.js +25 -0
- package/dist/modal/index.js.map +1 -0
- package/dist/modal/interactions.d.ts +53 -0
- package/dist/modal/interactions.d.ts.map +1 -0
- package/dist/modal/interactions.js +147 -0
- package/dist/modal/interactions.js.map +1 -0
- package/dist/modal/logo-paths.d.ts +15 -0
- package/dist/modal/logo-paths.d.ts.map +1 -0
- package/dist/modal/logo-paths.js +24 -0
- package/dist/modal/logo-paths.js.map +1 -0
- package/dist/modal/placement.d.ts +95 -0
- package/dist/modal/placement.d.ts.map +1 -0
- package/dist/modal/placement.js +302 -0
- package/dist/modal/placement.js.map +1 -0
- package/dist/modal/revalidate.d.ts +48 -0
- package/dist/modal/revalidate.d.ts.map +1 -0
- package/dist/modal/revalidate.js +65 -0
- package/dist/modal/revalidate.js.map +1 -0
- package/dist/modal/verify-client.d.ts +24 -0
- package/dist/modal/verify-client.d.ts.map +1 -0
- package/dist/modal/verify-client.js +38 -0
- package/dist/modal/verify-client.js.map +1 -0
- package/package.json +10 -4
- package/src/__tests__/__snapshots__/render-cert-block.test.ts.snap +10 -6
- package/src/__tests__/preview-block.test.ts +84 -0
- package/src/__tests__/render-cert-block.test.ts +404 -6
- package/src/builder/__tests__/builder.test.tsx +71 -0
- package/src/builder/cert-chrome-data.ts +159 -0
- package/src/builder/cert-review-card.tsx +177 -0
- package/src/builder/index.tsx +18 -0
- package/src/components/preview-block.ts +74 -0
- package/src/components/render-cert-block.ts +427 -121
- package/src/components/render-def.ts +55 -0
- package/src/index.ts +9 -0
- package/src/modal/avatar-resize.ts +99 -0
- package/src/modal/cert-modal-view.test.ts +175 -0
- package/src/modal/cert-modal-view.ts +210 -0
- package/src/modal/certrev-cert-modal.styles.ts +494 -0
- package/src/modal/certrev-cert-modal.test.ts +125 -0
- package/src/modal/certrev-cert-modal.ts +273 -0
- package/src/modal/certrev-cert.entry.ts +44 -0
- package/src/modal/display-strings.ts +38 -0
- package/src/modal/escape.ts +20 -0
- package/src/modal/font-face.ts +138 -0
- package/src/modal/ftc-disclosure.ts +13 -0
- package/src/modal/ftc-guard.test.ts +153 -0
- package/src/modal/ftc-guard.ts +119 -0
- package/src/modal/index.ts +34 -0
- package/src/modal/interactions.test.ts +250 -0
- package/src/modal/interactions.ts +166 -0
- package/src/modal/logo-paths.ts +23 -0
- package/src/modal/placement.test.ts +398 -0
- package/src/modal/placement.ts +331 -0
- package/src/modal/revalidate.test.ts +111 -0
- package/src/modal/revalidate.ts +81 -0
- package/src/modal/verify-client.test.ts +164 -0
- 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
|
+
});
|