@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
|
@@ -16,7 +16,9 @@
|
|
|
16
16
|
import { describe, expect, it } from 'vitest'
|
|
17
17
|
import {
|
|
18
18
|
CERT_SCOPE_LINE,
|
|
19
|
+
type CertBlockFace,
|
|
19
20
|
type CertBlockFacts,
|
|
21
|
+
type CertBlockLayout,
|
|
20
22
|
type CertBlockMode,
|
|
21
23
|
COMPENSATED_EXPERT_CUE,
|
|
22
24
|
CREDENTIAL_VERIFIED_ATTRIBUTION,
|
|
@@ -48,6 +50,17 @@ const THEMED: ResolvedBlockTheme = {
|
|
|
48
50
|
accentFg: '#141414',
|
|
49
51
|
}
|
|
50
52
|
|
|
53
|
+
/**
|
|
54
|
+
* A v2 brand-ink themed brand (e.l.f.-shaped): a black seal bar + near-black free-content ink on
|
|
55
|
+
* top of the accent/surface/corner/font theming. `barInkFg` is DERIVED at the edge from `barInk`
|
|
56
|
+
* (accentFg('#000000') = '#ffffff'), so it need not be supplied here.
|
|
57
|
+
*/
|
|
58
|
+
const THEMED_INK: ResolvedBlockTheme = {
|
|
59
|
+
...THEMED,
|
|
60
|
+
barInk: '#000000',
|
|
61
|
+
inkColor: '#1a1a1a',
|
|
62
|
+
}
|
|
63
|
+
|
|
51
64
|
const MODES: CertBlockMode[] = ['banner', 'sidebar', 'floating']
|
|
52
65
|
const CLAIM_FACES: CertBlockMode[] = ['banner', 'sidebar']
|
|
53
66
|
|
|
@@ -90,6 +103,53 @@ describe('renderCertBlock — locked-design snapshots', () => {
|
|
|
90
103
|
expect(renderCertBlock({ mode, theme: THEMED, facts: FACTS })).toMatchSnapshot()
|
|
91
104
|
})
|
|
92
105
|
}
|
|
106
|
+
for (const mode of CLAIM_FACES) {
|
|
107
|
+
it(`${mode} (v2 brand-ink: barInk + inkColor) matches the locked snapshot`, () => {
|
|
108
|
+
expect(renderCertBlock({ mode, theme: THEMED_INK, facts: FACTS })).toMatchSnapshot()
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
114
|
+
// First-class banner memo split (POR-10741) — retires the CertReviewCard string-hack
|
|
115
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
describe('renderCertBlock — banner memo split (part)', () => {
|
|
118
|
+
it("default (no part) is byte-identical to part:'full'", () => {
|
|
119
|
+
expect(renderCertBlock({ mode: 'banner', facts: FACTS })).toBe(
|
|
120
|
+
renderCertBlock({ mode: 'banner', part: 'full', facts: FACTS }),
|
|
121
|
+
)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it("part:'full' === header + memo (the two split parts recompose the whole card set)", () => {
|
|
125
|
+
const header = renderCertBlock({ mode: 'banner', part: 'header', facts: FACTS })
|
|
126
|
+
const memo = renderCertBlock({ mode: 'banner', part: 'memo', facts: FACTS })
|
|
127
|
+
// Each part is a self-contained themed root; concatenating the header's inner + the memo's
|
|
128
|
+
// inner reproduces the full render's inner (same root shell, split content).
|
|
129
|
+
const inner = (html: string) => html.slice(html.indexOf('>') + 1, html.lastIndexOf('</div>'))
|
|
130
|
+
const full = renderCertBlock({ mode: 'banner', part: 'full', facts: FACTS })
|
|
131
|
+
expect(inner(header) + inner(memo)).toBe(inner(full))
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it("part:'header' renders the header card WITHOUT the memo quote", () => {
|
|
135
|
+
const header = renderCertBlock({ mode: 'banner', part: 'header', facts: FACTS })
|
|
136
|
+
expect(header.startsWith('<div class="certrev-cert certrev-cert--banner')).toBe(true)
|
|
137
|
+
expect(header).toContain('Expert reviewed')
|
|
138
|
+
expect(header).not.toContain(FACTS.memo)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it("part:'memo' renders the memo card WITHOUT the header eyebrow", () => {
|
|
142
|
+
const memo = renderCertBlock({ mode: 'banner', part: 'memo', facts: FACTS })
|
|
143
|
+
expect(memo.startsWith('<div class="certrev-cert certrev-cert--banner')).toBe(true)
|
|
144
|
+
expect(memo).toContain(FACTS.memo)
|
|
145
|
+
expect(memo).not.toContain('Expert reviewed')
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('part is ignored outside banner mode (sidebar renders identically with or without it)', () => {
|
|
149
|
+
expect(renderCertBlock({ mode: 'sidebar', part: 'header', facts: FACTS })).toBe(
|
|
150
|
+
renderCertBlock({ mode: 'sidebar', facts: FACTS }),
|
|
151
|
+
)
|
|
152
|
+
})
|
|
93
153
|
})
|
|
94
154
|
|
|
95
155
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -166,7 +226,11 @@ describe('theming', () => {
|
|
|
166
226
|
expect(html).toContain('--ba:#0a1b3f')
|
|
167
227
|
expect(html).toContain('--ba-fg:#ffffff')
|
|
168
228
|
expect(html).toContain('--br:14px')
|
|
169
|
-
|
|
229
|
+
// The body font is a host-overridable HOOK (POR-10742): the resolved stack is the FALLBACK
|
|
230
|
+
// inside `--cr-bf:var(--bf,<stack>)`, and the root must NOT hard-declare a bare `--bf:` (which
|
|
231
|
+
// would shadow a host page's `--bf` and force `!important`).
|
|
232
|
+
expect(html).toContain(`--cr-bf:var(--bf,${RENDER_BLOCK_FONT_STACKS.sans})`)
|
|
233
|
+
expect(html).not.toContain(';--bf:')
|
|
170
234
|
expect(html).toContain('--surface:#ffffff')
|
|
171
235
|
expect(html).toContain('data-certrev-strings-version="2026-07"')
|
|
172
236
|
})
|
|
@@ -176,7 +240,7 @@ describe('theming', () => {
|
|
|
176
240
|
expect(html).toContain('--ba:#E8603C')
|
|
177
241
|
expect(html).toContain('--ba-fg:#141414')
|
|
178
242
|
expect(html).toContain('--br:22px')
|
|
179
|
-
expect(html).toContain(`--bf
|
|
243
|
+
expect(html).toContain(`--cr-bf:var(--bf,${RENDER_BLOCK_FONT_STACKS.grotesk})`)
|
|
180
244
|
expect(html).toContain('--surface:#faf8f4')
|
|
181
245
|
})
|
|
182
246
|
|
|
@@ -201,22 +265,112 @@ describe('theming', () => {
|
|
|
201
265
|
expect(html).toContain('--ba:#0a1b3f')
|
|
202
266
|
expect(html).toContain('--ba-fg:#ffffff')
|
|
203
267
|
expect(html).toContain('--br:14px')
|
|
204
|
-
expect(html).toContain(`--bf
|
|
268
|
+
expect(html).toContain(`--cr-bf:var(--bf,${RENDER_BLOCK_FONT_STACKS.sans})`)
|
|
205
269
|
})
|
|
206
270
|
|
|
207
271
|
it('the fixed CertREV logo chrome renders on the banner + sidebar headers and the memo credit', () => {
|
|
208
272
|
const banner = renderCertBlock({ mode: 'banner', facts: FACTS })
|
|
209
273
|
const sidebar = renderCertBlock({ mode: 'sidebar', facts: FACTS })
|
|
210
|
-
//
|
|
274
|
+
// every mark tracks its container ink via currentColor (navy header + memo verified lockup)
|
|
211
275
|
expect(banner).toContain('aria-label="CertREV"')
|
|
212
276
|
expect(banner).toContain('<path fill="currentColor"')
|
|
213
|
-
|
|
214
|
-
|
|
277
|
+
// the memo footer carries the "Verified by CertREV" trust mark; the wordmark rides the body
|
|
278
|
+
// font now (the grotesk --font-wordmark slot is gone from the locked chrome)
|
|
279
|
+
expect(banner).toContain('Verified by')
|
|
280
|
+
expect(banner).not.toContain('font-family:var(--font-wordmark)')
|
|
215
281
|
expect(sidebar).toContain('aria-label="CertREV"')
|
|
216
282
|
expect(sidebar).toContain('<path fill="currentColor"')
|
|
217
283
|
})
|
|
218
284
|
})
|
|
219
285
|
|
|
286
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
287
|
+
// V2 brand-ink theming (barInk + inkColor) — parity with the portal Liquid/CSS
|
|
288
|
+
// engines. The bar/ink wire vars are emitted ONLY when the def sets the token;
|
|
289
|
+
// unset ⇒ neither (byte-identical navy default). The FREE-content ink follows the
|
|
290
|
+
// brand ink; the LOCKED legal disclosures stay on the navy trust ink.
|
|
291
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
describe('v2 brand-ink theming — barInk + inkColor (portal parity)', () => {
|
|
294
|
+
it('unset ⇒ NEITHER the bar wire vars NOR --certrev-ink is emitted (byte-clean default)', () => {
|
|
295
|
+
const html = renderCertBlock({ mode: 'banner', facts: FACTS })
|
|
296
|
+
expect(html).not.toContain('--certrev-bar-bg:#')
|
|
297
|
+
expect(html).not.toContain('--certrev-bar-fg:#')
|
|
298
|
+
expect(html).not.toContain('--certrev-ink:#')
|
|
299
|
+
// the --cr-ink family is ALWAYS defined; unthemed it is the EXACT navy literals (no color-mix)
|
|
300
|
+
expect(html).toContain('--cr-ink:var(--certrev-ink,#0a1b3f)')
|
|
301
|
+
expect(html).toContain('--cr-ink-sub:rgba(10,27,63,.55)') // === --navy-55
|
|
302
|
+
expect(html).toContain('--cr-ink-soft:rgba(10,27,63,.75)') // === --navy-75
|
|
303
|
+
// the header bar consumes the navy/white fallbacks (byte-identical)
|
|
304
|
+
expect(html).toContain('background:var(--certrev-bar-bg,#0a1b3f);color:var(--certrev-bar-fg,#fff)')
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
it('barInk set ⇒ emits --certrev-bar-bg + the DERIVED --certrev-bar-fg (accentFg)', () => {
|
|
308
|
+
const html = renderCertBlock({ mode: 'banner', theme: { ...THEMED, barInk: '#000000' }, facts: FACTS })
|
|
309
|
+
expect(html).toContain('--certrev-bar-bg:#000000')
|
|
310
|
+
// accentFg('#000000') = '#ffffff' (a dark bar takes a white foreground)
|
|
311
|
+
expect(html).toContain('--certrev-bar-fg:#ffffff')
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
it('barInkFg is DERIVED at the edge — a LIGHT bar → #141414, and a bogus input barInkFg is ignored', () => {
|
|
315
|
+
const html = renderCertBlock({
|
|
316
|
+
mode: 'floating',
|
|
317
|
+
// pass a WRONG barInkFg — resolveBlockTheme recomputes it from barInk via accentFg
|
|
318
|
+
theme: { ...THEMED, barInk: '#f5f5f5', barInkFg: '#ffffff' },
|
|
319
|
+
facts: FACTS,
|
|
320
|
+
})
|
|
321
|
+
expect(html).toContain('--certrev-bar-bg:#f5f5f5')
|
|
322
|
+
expect(html).toContain('--certrev-bar-fg:#141414') // light bar → dark ink, recomputed
|
|
323
|
+
expect(html).not.toContain('--certrev-bar-fg:#ffffff')
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
it('inkColor set ⇒ emits --certrev-ink + derives the muted scale off --cr-ink via color-mix', () => {
|
|
327
|
+
const html = renderCertBlock({ mode: 'banner', theme: { ...THEMED, inkColor: '#1a1a1a' }, facts: FACTS })
|
|
328
|
+
expect(html).toContain('--certrev-ink:#1a1a1a')
|
|
329
|
+
expect(html).toContain('--cr-ink:var(--certrev-ink,#0a1b3f)')
|
|
330
|
+
expect(html).toContain('--cr-ink-sub:color-mix(in srgb,var(--cr-ink) 55%,transparent)')
|
|
331
|
+
expect(html).toContain('--cr-ink-soft:color-mix(in srgb,var(--cr-ink) 75%,transparent)')
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
it('FREE content follows the brand ink; LOCKED legal disclosures stay on the navy trust ink', () => {
|
|
335
|
+
const html = renderCertBlock({ mode: 'banner', theme: THEMED_INK, facts: FACTS })
|
|
336
|
+
// FREE — names ride --cr-ink; the memo/bio ride --cr-ink-soft; title/credential/initials --cr-ink-sub
|
|
337
|
+
expect(html).toContain('font-weight:700;color:var(--cr-ink);">Jane Doe, MD') // reviewer name
|
|
338
|
+
expect(html).toContain('color:var(--cr-ink);">The Editorial Team') // author name
|
|
339
|
+
expect(html).toContain('color:var(--cr-ink-soft);white-space:pre-line;">Patients ask me') // memo body
|
|
340
|
+
// LOCKED — the legal/trust text is NEVER repointed to --cr-ink (proves the free/locked boundary)
|
|
341
|
+
expect(html).toContain('color:var(--navy-55);line-height:1.45;margin-top:3px;">Credential verified by CertREV')
|
|
342
|
+
expect(html).toContain(`color:var(--navy-55);margin-top:8px;line-height:1.5;">${CERT_SCOPE_LINE}</div>`) // FTC scope
|
|
343
|
+
expect(html).toContain('color:var(--navy);margin-bottom:11px;">Reviewed by') // section label
|
|
344
|
+
expect(html).toContain('text-transform:uppercase;color:var(--navy-55);"><span>Certified') // certified date + cue row
|
|
345
|
+
// and the legal text never carries a --cr-ink token
|
|
346
|
+
expect(html).not.toMatch(/--cr-ink[^;]*;[^"]*">Credential verified by CertREV/)
|
|
347
|
+
expect(html).not.toMatch(/--cr-ink[^;]*;[^"]*">Independent editorial review/)
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
it('barInk themes the pill (floating) — portal parity: the pill follows the BAR, not the accent', () => {
|
|
351
|
+
const html = renderCertBlock({ mode: 'floating', theme: THEMED_INK, facts: FACTS })
|
|
352
|
+
expect(html).toContain('--certrev-bar-bg:#000000')
|
|
353
|
+
expect(html).toContain('background:var(--certrev-bar-bg,#0a1b3f);color:var(--certrev-bar-fg,#fff)')
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
it('an invalid barInk / inkColor fails SAFE — dropped, never emitted (bar + ink stay navy)', () => {
|
|
357
|
+
const html = renderCertBlock({
|
|
358
|
+
mode: 'banner',
|
|
359
|
+
theme: {
|
|
360
|
+
...THEMED,
|
|
361
|
+
barInk: 'black;}</style>' as ResolvedBlockTheme['barInk'],
|
|
362
|
+
inkColor: '#12' as ResolvedBlockTheme['inkColor'],
|
|
363
|
+
},
|
|
364
|
+
facts: FACTS,
|
|
365
|
+
})
|
|
366
|
+
expect(html).not.toContain('--certrev-bar-bg:#')
|
|
367
|
+
expect(html).not.toContain('--certrev-ink:#')
|
|
368
|
+
expect(html).not.toContain('black;}')
|
|
369
|
+
// the free-content ink falls back to the navy literals
|
|
370
|
+
expect(html).toContain('--cr-ink-sub:rgba(10,27,63,.55)')
|
|
371
|
+
})
|
|
372
|
+
})
|
|
373
|
+
|
|
220
374
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
221
375
|
// Escaping — storefront HTML built by hand
|
|
222
376
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -257,3 +411,247 @@ describe('escaping (this is storefront HTML)', () => {
|
|
|
257
411
|
})
|
|
258
412
|
}
|
|
259
413
|
})
|
|
414
|
+
|
|
415
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
416
|
+
// Reviewer name — honorific stripping (parity with the Liquid twin)
|
|
417
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
418
|
+
|
|
419
|
+
describe('reviewer name — honorific stripping (Liquid parity)', () => {
|
|
420
|
+
it('strips a leading honorific for the avatar initials + the first-name link', () => {
|
|
421
|
+
const html = renderCertBlock({ mode: 'banner', facts: { ...FACTS, reviewerName: 'Dr. Marcus Ellison' } })
|
|
422
|
+
// "Dr. Marcus Ellison" → "ME" avatar, never "DM"
|
|
423
|
+
expect(html).toContain('flex-shrink:0;">ME<')
|
|
424
|
+
expect(html).not.toContain('flex-shrink:0;">DM<')
|
|
425
|
+
// "Marcus's Profile", never "Dr.'s Profile" (apostrophe-agnostic)
|
|
426
|
+
expect(html).toMatch(/Marcus.s Profile/)
|
|
427
|
+
expect(html).not.toMatch(/View Dr\..s profile/)
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
it('leaves an un-prefixed name untouched', () => {
|
|
431
|
+
const html = renderCertBlock({ mode: 'banner', facts: { ...FACTS, reviewerName: 'Jane Doe' } })
|
|
432
|
+
expect(html).toContain('flex-shrink:0;">JD<')
|
|
433
|
+
})
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
437
|
+
// WS-B — placedFields-strict rendering: visibility is the engine's face alone,
|
|
438
|
+
// `rung` is a stamp (never a branch), and an ABSENT face grandfathers 0.3.0.
|
|
439
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
440
|
+
|
|
441
|
+
/** Every engine field id renderCertBlock knows how to paint. */
|
|
442
|
+
const ALL_FIELDS = [
|
|
443
|
+
'label',
|
|
444
|
+
'authorName',
|
|
445
|
+
'authorTitle',
|
|
446
|
+
'bylinePlain',
|
|
447
|
+
'bylineCredentialed',
|
|
448
|
+
'reviewerPhoto',
|
|
449
|
+
'bio',
|
|
450
|
+
'memo',
|
|
451
|
+
'compensationCue',
|
|
452
|
+
'scopeLine',
|
|
453
|
+
'profileLink',
|
|
454
|
+
'certificateLink',
|
|
455
|
+
] as const
|
|
456
|
+
|
|
457
|
+
/** A full claim face (everything placed) — the strict analogue of the grandfathered full-facts render. */
|
|
458
|
+
const FULL_FACE: CertBlockFace = { rung: 'credentialed', placedFields: [...ALL_FIELDS] }
|
|
459
|
+
|
|
460
|
+
/** Drop the observability-only rung attribute so a strict render can be compared to its grandfathered twin. */
|
|
461
|
+
function stripRung(html: string): string {
|
|
462
|
+
return html.replace(/ data-certrev-rung="[^"]*"/g, '')
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** A face placing everything EXCEPT the given ids. */
|
|
466
|
+
function faceExcept(...omit: string[]): CertBlockFace {
|
|
467
|
+
return { rung: 'credentialed', placedFields: ALL_FIELDS.filter((f) => !omit.includes(f)) }
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
describe('WS-B: face-absent grandfathers the 0.3.0 full-facts render byte-for-byte', () => {
|
|
471
|
+
for (const mode of MODES) {
|
|
472
|
+
it(`${mode}: a full-face strict render === the grandfathered render (modulo the rung stamp)`, () => {
|
|
473
|
+
const grandfathered = renderCertBlock({ mode, facts: FACTS })
|
|
474
|
+
const strictFull = renderCertBlock({ mode, facts: FACTS, face: FULL_FACE })
|
|
475
|
+
// the ONLY difference a full face introduces is the observability stamp
|
|
476
|
+
expect(strictFull).toContain('data-certrev-rung="credentialed"')
|
|
477
|
+
expect(stripRung(strictFull)).toBe(grandfathered)
|
|
478
|
+
})
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
it('an absent face emits NO rung attribute (grandfather stays byte-clean)', () => {
|
|
482
|
+
expect(renderCertBlock({ mode: 'banner', facts: FACTS })).not.toContain('data-certrev-rung')
|
|
483
|
+
})
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
describe('WS-B: strict placedFields — a minimal face renders only what it placed', () => {
|
|
487
|
+
const MINIMAL: CertBlockFace = { rung: 'minimal', placedFields: ['label', 'profileLink', 'certificateLink'] }
|
|
488
|
+
|
|
489
|
+
it('custom: [label, profileLink, certificateLink] paints exactly those, nothing else', () => {
|
|
490
|
+
const html = renderCertBlock({ mode: 'custom', facts: FACTS, face: MINIMAL })
|
|
491
|
+
// the placed fields
|
|
492
|
+
expect(html).toContain('Expert reviewed')
|
|
493
|
+
expect(html).toMatch(/Jane's Profile/)
|
|
494
|
+
expect(html).toContain('>Certificate</a>')
|
|
495
|
+
// everything NOT placed is absent
|
|
496
|
+
expect(html).not.toContain('Written by')
|
|
497
|
+
expect(html).not.toContain('Jane Doe') // no byline / reviewer name
|
|
498
|
+
expect(html).not.toContain('Expert memo')
|
|
499
|
+
expect(html).not.toContain(FACTS.memo)
|
|
500
|
+
expect(html).not.toContain(FACTS.bio)
|
|
501
|
+
expect(html).not.toContain(CERT_SCOPE_LINE)
|
|
502
|
+
expect(html).not.toContain(CREDENTIAL_VERIFIED_ATTRIBUTION)
|
|
503
|
+
expect(visibleText(html)).not.toContain(COMPENSATED_EXPERT_CUE)
|
|
504
|
+
// the custom face still carries the strict + observability markers
|
|
505
|
+
expect(html).toContain('data-certrev-strings-version="2026-07"')
|
|
506
|
+
expect(html).toContain('data-certrev-rung="minimal"')
|
|
507
|
+
expect(html).toContain('data-certrev-mode="custom"')
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
it('banner: a minimal face drops the author + reviewer body + compensation cue', () => {
|
|
511
|
+
const html = renderCertBlock({ mode: 'banner', facts: FACTS, face: MINIMAL })
|
|
512
|
+
expect(html).toContain('Expert reviewed') // label header
|
|
513
|
+
expect(html).not.toContain('Written by')
|
|
514
|
+
expect(html).not.toContain('Jane Doe') // no byline placed → no reviewer name anywhere
|
|
515
|
+
expect(html).not.toContain(CREDENTIAL_VERIFIED_ATTRIBUTION)
|
|
516
|
+
expect(visibleText(html)).not.toContain(COMPENSATED_EXPERT_CUE)
|
|
517
|
+
// the memo card still renders because profile/certificate are placed
|
|
518
|
+
expect(html).toMatch(/Jane's Profile/)
|
|
519
|
+
expect(html).toContain('>Certificate</a>')
|
|
520
|
+
})
|
|
521
|
+
})
|
|
522
|
+
|
|
523
|
+
describe('WS-B: a claim face paints all disclosures byte-verbatim', () => {
|
|
524
|
+
const CLAIM: CertBlockFace = {
|
|
525
|
+
rung: 'memo',
|
|
526
|
+
placedFields: [
|
|
527
|
+
'label',
|
|
528
|
+
'bylineCredentialed',
|
|
529
|
+
'reviewerPhoto',
|
|
530
|
+
'memo',
|
|
531
|
+
'compensationCue',
|
|
532
|
+
'scopeLine',
|
|
533
|
+
'profileLink',
|
|
534
|
+
'certificateLink',
|
|
535
|
+
],
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
for (const mode of ['banner', 'sidebar', 'custom'] as CertBlockLayout[]) {
|
|
539
|
+
it(`${mode}: the compensation cue + FTC scope line + dated credential verification all render`, () => {
|
|
540
|
+
const html = renderCertBlock({ mode, facts: FACTS, face: CLAIM })
|
|
541
|
+
expect(visibleText(html)).toContain(COMPENSATED_EXPERT_CUE)
|
|
542
|
+
expect(html).toContain(CERT_SCOPE_LINE) // byte-exact incl. the U+2019 apostrophe
|
|
543
|
+
expect(html).toContain(CREDENTIAL_VERIFIED_ATTRIBUTION)
|
|
544
|
+
expect(html).toContain('on Apr 14, 2026')
|
|
545
|
+
// the credentialed byline + its dated verification stay GROUPED, in order
|
|
546
|
+
const iName = html.indexOf('Jane Doe, MD')
|
|
547
|
+
const iAttr = html.indexOf(CREDENTIAL_VERIFIED_ATTRIBUTION)
|
|
548
|
+
const iDate = html.indexOf('on Apr 14, 2026')
|
|
549
|
+
expect(iName).toBeGreaterThanOrEqual(0)
|
|
550
|
+
expect(iAttr).toBeGreaterThan(iName)
|
|
551
|
+
expect(iDate).toBeGreaterThan(iAttr)
|
|
552
|
+
})
|
|
553
|
+
}
|
|
554
|
+
})
|
|
555
|
+
|
|
556
|
+
describe('WS-B: the credentialed byline group is all-or-nothing on `bylineCredentialed`', () => {
|
|
557
|
+
it('sidebar: a PLAIN byline shows the name only — no credential suffix, no attribution/date', () => {
|
|
558
|
+
const face: CertBlockFace = { rung: 'named', placedFields: ['label', 'bylinePlain', 'reviewerPhoto'] }
|
|
559
|
+
const html = renderCertBlock({ mode: 'sidebar', facts: FACTS, face })
|
|
560
|
+
expect(html).toContain('Jane Doe')
|
|
561
|
+
expect(html).not.toContain('Jane Doe, MD')
|
|
562
|
+
expect(html).not.toContain(CREDENTIAL_VERIFIED_ATTRIBUTION)
|
|
563
|
+
expect(html).not.toContain('on Apr 14, 2026')
|
|
564
|
+
})
|
|
565
|
+
|
|
566
|
+
it('sidebar: a CREDENTIALED byline fuses the credential + the grouped attribution/date', () => {
|
|
567
|
+
const face: CertBlockFace = { rung: 'credentialed', placedFields: ['label', 'bylineCredentialed'] }
|
|
568
|
+
const html = renderCertBlock({ mode: 'sidebar', facts: FACTS, face })
|
|
569
|
+
expect(html).toContain('Jane Doe, MD')
|
|
570
|
+
expect(html).toContain(CREDENTIAL_VERIFIED_ATTRIBUTION)
|
|
571
|
+
expect(html).toContain('on Apr 14, 2026')
|
|
572
|
+
})
|
|
573
|
+
})
|
|
574
|
+
|
|
575
|
+
describe('WS-B: per-field subtraction drops exactly that markup', () => {
|
|
576
|
+
it('banner: omitting reviewerPhoto removes the reviewer avatar', () => {
|
|
577
|
+
expect(renderCertBlock({ mode: 'banner', facts: FACTS, face: FULL_FACE })).toContain('flex-shrink:0;">JD<')
|
|
578
|
+
expect(renderCertBlock({ mode: 'banner', facts: FACTS, face: faceExcept('reviewerPhoto') })).not.toContain(
|
|
579
|
+
'flex-shrink:0;">JD<',
|
|
580
|
+
)
|
|
581
|
+
})
|
|
582
|
+
|
|
583
|
+
it('banner: omitting authorName removes the "Written by" column (and collapses the grid to 1fr)', () => {
|
|
584
|
+
const html = renderCertBlock({ mode: 'banner', facts: FACTS, face: faceExcept('authorName') })
|
|
585
|
+
expect(html).not.toContain('Written by')
|
|
586
|
+
expect(html).toContain('grid-template-columns:1fr;')
|
|
587
|
+
})
|
|
588
|
+
|
|
589
|
+
it('banner: omitting memo removes the memo body', () => {
|
|
590
|
+
const html = renderCertBlock({ mode: 'banner', facts: FACTS, face: faceExcept('memo') })
|
|
591
|
+
expect(html).not.toContain('Expert memo')
|
|
592
|
+
expect(html).not.toContain(FACTS.memo)
|
|
593
|
+
// but the trust footer + links survive (profile/certificate still placed)
|
|
594
|
+
expect(html).toContain('Verified by')
|
|
595
|
+
})
|
|
596
|
+
|
|
597
|
+
it('banner: omitting bio removes the hidden bio accordion', () => {
|
|
598
|
+
const html = renderCertBlock({ mode: 'banner', facts: FACTS, face: faceExcept('bio') })
|
|
599
|
+
expect(html).not.toContain(FACTS.bio)
|
|
600
|
+
expect(html).not.toContain('certrev-cert__bio')
|
|
601
|
+
})
|
|
602
|
+
|
|
603
|
+
it('sidebar: omitting BOTH links removes the entire footer grid', () => {
|
|
604
|
+
const html = renderCertBlock({
|
|
605
|
+
mode: 'sidebar',
|
|
606
|
+
facts: FACTS,
|
|
607
|
+
face: faceExcept('profileLink', 'certificateLink'),
|
|
608
|
+
})
|
|
609
|
+
expect(html).not.toContain('View Profile')
|
|
610
|
+
expect(html).not.toContain('>Certificate</a>')
|
|
611
|
+
expect(html).not.toContain('border-top:1px solid var(--navy-10);')
|
|
612
|
+
})
|
|
613
|
+
|
|
614
|
+
it('sidebar: a lone profile link drops its border-right and collapses the grid to 1fr', () => {
|
|
615
|
+
const html = renderCertBlock({ mode: 'sidebar', facts: FACTS, face: faceExcept('certificateLink') })
|
|
616
|
+
expect(html).toContain('View Profile')
|
|
617
|
+
expect(html).not.toContain('>Certificate</a>')
|
|
618
|
+
expect(html).toContain('grid-template-columns:1fr;border-top')
|
|
619
|
+
expect(html).not.toContain('border-right:1px solid var(--navy-10);')
|
|
620
|
+
})
|
|
621
|
+
})
|
|
622
|
+
|
|
623
|
+
describe('WS-B: custom face — a compliant vertical stack in canonical order', () => {
|
|
624
|
+
it('renders placed fields top-to-bottom: label → reviewer → memo → disclosures → actions', () => {
|
|
625
|
+
const html = renderCertBlock({ mode: 'custom', facts: FACTS, face: FULL_FACE })
|
|
626
|
+
const order = [
|
|
627
|
+
html.indexOf('Expert reviewed'), // 1. label
|
|
628
|
+
html.indexOf('Written by'), // 2. author
|
|
629
|
+
html.indexOf('Jane Doe, MD'), // 3. reviewer byline
|
|
630
|
+
html.indexOf(FACTS.bio), // 4. bio
|
|
631
|
+
html.indexOf(FACTS.memo), // 5. memo
|
|
632
|
+
html.indexOf(CERT_SCOPE_LINE), // 6. disclosures
|
|
633
|
+
html.indexOf("Jane's Profile"), // 7. actions
|
|
634
|
+
]
|
|
635
|
+
for (const i of order) expect(i).toBeGreaterThanOrEqual(0)
|
|
636
|
+
const sorted = [...order].sort((a, b) => a - b)
|
|
637
|
+
expect(order).toEqual(sorted)
|
|
638
|
+
// the modal + strings + rung markers ride the same building blocks as the locked layouts
|
|
639
|
+
expect(html).toContain('data-certrev-modal-open')
|
|
640
|
+
expect(html).toContain('data-certrev-strings-version="2026-07"')
|
|
641
|
+
expect(html).toContain('data-certrev-rung="credentialed"')
|
|
642
|
+
// it is a single flex-column stack
|
|
643
|
+
expect(html).toContain('flex-direction:column;gap:14px;')
|
|
644
|
+
})
|
|
645
|
+
})
|
|
646
|
+
|
|
647
|
+
describe('WS-B: `rung` is a stamp, never a branch — it never changes visibility', () => {
|
|
648
|
+
for (const mode of ['banner', 'sidebar', 'floating', 'custom'] as CertBlockLayout[]) {
|
|
649
|
+
it(`${mode}: two faces with the same placedFields but different rung render identical HTML (modulo the rung attr)`, () => {
|
|
650
|
+
const placedFields = [...ALL_FIELDS]
|
|
651
|
+
const a = renderCertBlock({ mode, facts: FACTS, face: { rung: 'minimal', placedFields } })
|
|
652
|
+
const b = renderCertBlock({ mode, facts: FACTS, face: { rung: 'credentialed', placedFields } })
|
|
653
|
+
expect(a).not.toBe(b) // the rung attr differs
|
|
654
|
+
expect(stripRung(a)).toBe(stripRung(b)) // …but nothing else does
|
|
655
|
+
})
|
|
656
|
+
}
|
|
657
|
+
})
|
|
@@ -66,3 +66,74 @@ describe('Builder adapter — CertRevAnchor render', () => {
|
|
|
66
66
|
expect(html).not.toContain('data-certrev-editor-placeholder')
|
|
67
67
|
})
|
|
68
68
|
})
|
|
69
|
+
|
|
70
|
+
// ── POR-10721 W1/W3: the PUSH kit (CertReviewCard) + contract lock ──
|
|
71
|
+
import {
|
|
72
|
+
BUILDER_CERT_CHROME_KEYS,
|
|
73
|
+
BUILDER_REGISTRATION,
|
|
74
|
+
type BuilderCertChromeData,
|
|
75
|
+
CERT_COMPONENT_NAME,
|
|
76
|
+
CertReviewCard,
|
|
77
|
+
WIRE_INPUTS,
|
|
78
|
+
} from '../index.js'
|
|
79
|
+
|
|
80
|
+
describe('POR-10721 W3 — Builder kit contract lock (inputs ⇄ wire-type parity)', () => {
|
|
81
|
+
it('WIRE_INPUTS names are EXACTLY the wire-type keys, in order (no silent drift)', () => {
|
|
82
|
+
expect(WIRE_INPUTS.map((i) => i.name)).toEqual([...BUILDER_CERT_CHROME_KEYS])
|
|
83
|
+
})
|
|
84
|
+
it('BUILDER_REGISTRATION carries every wire input + the placement inputs, named canonically', () => {
|
|
85
|
+
expect(BUILDER_REGISTRATION.name).toBe(CERT_COMPONENT_NAME)
|
|
86
|
+
const inputNames = new Set(BUILDER_REGISTRATION.inputs.map((i) => i.name))
|
|
87
|
+
for (const k of BUILDER_CERT_CHROME_KEYS) expect(inputNames.has(k)).toBe(true)
|
|
88
|
+
expect(inputNames.has('mode')).toBe(true) // a placement input, NOT a wire-type key
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
describe('POR-10721 W1 — CertReviewCard render + the fused-credential R1 gate', () => {
|
|
93
|
+
const base: Partial<BuilderCertChromeData> = {
|
|
94
|
+
reviewerName: 'Dr. Jane Doe',
|
|
95
|
+
verifyUrl: 'https://certrev.com/verify/abc',
|
|
96
|
+
bylinePlain: 'Reviewed on 2026-03-08 by Dr. Jane Doe.',
|
|
97
|
+
certifiedAt: '2026-03-08',
|
|
98
|
+
scopeLine: 'Independent editorial review.',
|
|
99
|
+
display: { showExpertPhoto: true, showCredentials: true },
|
|
100
|
+
renderDef: null,
|
|
101
|
+
stringsVersion: '2026-07',
|
|
102
|
+
}
|
|
103
|
+
it('renders a certified face (the reviewer name)', () => {
|
|
104
|
+
expect(renderToStaticMarkup(<CertReviewCard {...base} />)).toContain('Dr. Jane Doe')
|
|
105
|
+
})
|
|
106
|
+
it('name-only when credentialVerification is null — a credential never leaks without its date', () => {
|
|
107
|
+
expect(renderToStaticMarkup(<CertReviewCard {...base} credentialVerification={null} />)).not.toContain(
|
|
108
|
+
'FAAAAI',
|
|
109
|
+
)
|
|
110
|
+
})
|
|
111
|
+
it('renders the credential ONLY as the fused (credential + verifiedAt) pair', () => {
|
|
112
|
+
const html = renderToStaticMarkup(
|
|
113
|
+
<CertReviewCard {...base} credentialVerification={{ credential: 'FAAAAI', verifiedAt: '2026-03-01' }} />,
|
|
114
|
+
)
|
|
115
|
+
expect(html).toContain('FAAAAI')
|
|
116
|
+
})
|
|
117
|
+
it('showCredentials:false collapses to name-only even with the pair present', () => {
|
|
118
|
+
const html = renderToStaticMarkup(
|
|
119
|
+
<CertReviewCard
|
|
120
|
+
{...base}
|
|
121
|
+
display={{ showExpertPhoto: true, showCredentials: false }}
|
|
122
|
+
credentialVerification={{ credential: 'FAAAAI', verifiedAt: '2026-03-01' }}
|
|
123
|
+
/>,
|
|
124
|
+
)
|
|
125
|
+
expect(html).not.toContain('FAAAAI')
|
|
126
|
+
})
|
|
127
|
+
it('returns nothing pre-cert (no reviewerName / verifyUrl)', () => {
|
|
128
|
+
expect(renderToStaticMarkup(<CertReviewCard />)).toBe('')
|
|
129
|
+
})
|
|
130
|
+
it('passes `part` through to renderCertBlock (POR-10741 — header excludes the memo, memo excludes the header)', () => {
|
|
131
|
+
const withMemo = { ...base, memo: 'Checked the claims against current guidance; accurate.' }
|
|
132
|
+
const header = renderToStaticMarkup(<CertReviewCard {...withMemo} part="header" />)
|
|
133
|
+
const memo = renderToStaticMarkup(<CertReviewCard {...withMemo} part="memo" />)
|
|
134
|
+
expect(header).toContain('Expert reviewed')
|
|
135
|
+
expect(header).not.toContain('Checked the claims against current guidance; accurate.')
|
|
136
|
+
expect(memo).toContain('Checked the claims against current guidance; accurate.')
|
|
137
|
+
expect(memo).not.toContain('Expert reviewed')
|
|
138
|
+
})
|
|
139
|
+
})
|