@certrev/cert-block 0.5.4 → 1.0.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/CHANGELOG.md +195 -12
- package/LICENSE +21 -0
- package/README.md +422 -50
- package/dist/builder/cert-chrome-data.d.ts +26 -8
- package/dist/builder/cert-chrome-data.d.ts.map +1 -1
- package/dist/builder/cert-chrome-data.js +5 -5
- package/dist/builder/cert-chrome-data.js.map +1 -1
- package/dist/builder/cert-review-card.d.ts +35 -5
- package/dist/builder/cert-review-card.d.ts.map +1 -1
- package/dist/builder/cert-review-card.js +115 -34
- package/dist/builder/cert-review-card.js.map +1 -1
- package/dist/builder/index.d.ts +33 -16
- package/dist/builder/index.d.ts.map +1 -1
- package/dist/builder/index.js +33 -16
- package/dist/builder/index.js.map +1 -1
- package/dist/components/CertBadge.d.ts +2 -2
- package/dist/components/CertBadge.d.ts.map +1 -1
- package/dist/components/CertBadge.js +11 -5
- package/dist/components/CertBadge.js.map +1 -1
- package/dist/components/CertJsonLd.d.ts.map +1 -1
- package/dist/components/CertJsonLd.js.map +1 -1
- package/dist/components/CertRevBacklink.d.ts.map +1 -1
- package/dist/components/CertRevBacklink.js +3 -2
- package/dist/components/CertRevBacklink.js.map +1 -1
- package/dist/components/CertReview.d.ts +1 -1
- package/dist/components/ExpertBio.d.ts.map +1 -1
- package/dist/components/ExpertBio.js +12 -4
- package/dist/components/ExpertBio.js.map +1 -1
- package/dist/components/escape.d.ts +28 -1
- package/dist/components/escape.d.ts.map +1 -1
- package/dist/components/escape.js +37 -8
- package/dist/components/escape.js.map +1 -1
- package/dist/components/format.d.ts +51 -2
- package/dist/components/format.d.ts.map +1 -1
- package/dist/components/format.js +103 -6
- package/dist/components/format.js.map +1 -1
- package/dist/components/preview-block.d.ts +1 -1
- package/dist/components/preview-block.js +3 -3
- package/dist/components/preview-block.js.map +1 -1
- package/dist/components/rel.d.ts +24 -0
- package/dist/components/rel.d.ts.map +1 -0
- package/dist/components/rel.js +24 -0
- package/dist/components/rel.js.map +1 -0
- package/dist/components/render-cert-block.d.ts +50 -22
- package/dist/components/render-cert-block.d.ts.map +1 -1
- package/dist/components/render-cert-block.js +146 -80
- package/dist/components/render-cert-block.js.map +1 -1
- package/dist/components/render-def.d.ts +13 -14
- package/dist/components/render-def.d.ts.map +1 -1
- package/dist/components/render-def.js +14 -15
- package/dist/components/render-def.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/jsonld/project.d.ts +46 -13
- package/dist/jsonld/project.d.ts.map +1 -1
- package/dist/jsonld/project.js +139 -56
- package/dist/jsonld/project.js.map +1 -1
- package/dist/modal/avatar-resize.d.ts +10 -10
- package/dist/modal/avatar-resize.js +14 -14
- package/dist/modal/avatar-resize.js.map +1 -1
- package/dist/modal/cert-modal-view.d.ts +27 -7
- package/dist/modal/cert-modal-view.d.ts.map +1 -1
- package/dist/modal/cert-modal-view.js +49 -25
- package/dist/modal/cert-modal-view.js.map +1 -1
- package/dist/modal/certrev-cert-modal.d.ts +5 -5
- package/dist/modal/certrev-cert-modal.js +9 -9
- package/dist/modal/certrev-cert-modal.js.map +1 -1
- package/dist/modal/certrev-cert-modal.styles.d.ts +6 -6
- package/dist/modal/certrev-cert-modal.styles.d.ts.map +1 -1
- package/dist/modal/certrev-cert-modal.styles.js +8 -8
- package/dist/modal/certrev-cert-modal.styles.js.map +1 -1
- package/dist/modal/certrev-cert.entry.d.ts +8 -6
- package/dist/modal/certrev-cert.entry.d.ts.map +1 -1
- package/dist/modal/certrev-cert.entry.js +9 -7
- package/dist/modal/certrev-cert.entry.js.map +1 -1
- package/dist/modal/certrev-cert.js +6 -6
- package/dist/modal/display-strings.d.ts +25 -5
- package/dist/modal/display-strings.d.ts.map +1 -1
- package/dist/modal/display-strings.js +27 -5
- package/dist/modal/display-strings.js.map +1 -1
- package/dist/modal/escape.d.ts +26 -3
- package/dist/modal/escape.d.ts.map +1 -1
- package/dist/modal/escape.js +32 -3
- package/dist/modal/escape.js.map +1 -1
- package/dist/modal/font-face.d.ts.map +1 -1
- package/dist/modal/font-face.js +8 -11
- package/dist/modal/font-face.js.map +1 -1
- package/dist/modal/ftc-disclosure.d.ts +2 -2
- package/dist/modal/ftc-disclosure.js +2 -2
- package/dist/modal/ftc-guard.d.ts +44 -9
- package/dist/modal/ftc-guard.d.ts.map +1 -1
- package/dist/modal/ftc-guard.js +50 -17
- package/dist/modal/ftc-guard.js.map +1 -1
- package/dist/modal/index.d.ts +1 -1
- package/dist/modal/index.js +1 -1
- package/dist/modal/interactions.d.ts +2 -2
- package/dist/modal/interactions.js +2 -2
- package/dist/modal/logo-paths.js +2 -2
- package/dist/modal/logo-paths.js.map +1 -1
- package/dist/modal/placement.d.ts +4 -3
- package/dist/modal/placement.d.ts.map +1 -1
- package/dist/modal/placement.js +7 -6
- package/dist/modal/placement.js.map +1 -1
- package/dist/modal/revalidate.d.ts +2 -3
- package/dist/modal/revalidate.d.ts.map +1 -1
- package/dist/modal/revalidate.js +2 -3
- package/dist/modal/revalidate.js.map +1 -1
- package/dist/modal/verify-client.d.ts +2 -2
- package/dist/modal/verify-client.js +2 -2
- package/dist/verify/cache.d.ts +21 -6
- package/dist/verify/cache.d.ts.map +1 -1
- package/dist/verify/cache.js +29 -8
- package/dist/verify/cache.js.map +1 -1
- package/dist/verify/fetch-with-deadline.d.ts +35 -0
- package/dist/verify/fetch-with-deadline.d.ts.map +1 -0
- package/dist/verify/fetch-with-deadline.js +55 -0
- package/dist/verify/fetch-with-deadline.js.map +1 -0
- package/dist/verify/get-verified-envelope.d.ts +23 -9
- package/dist/verify/get-verified-envelope.d.ts.map +1 -1
- package/dist/verify/get-verified-envelope.js +89 -40
- package/dist/verify/get-verified-envelope.js.map +1 -1
- package/dist/verify/resolve-kid.d.ts +3 -0
- package/dist/verify/resolve-kid.d.ts.map +1 -1
- package/dist/verify/resolve-kid.js +17 -8
- package/dist/verify/resolve-kid.js.map +1 -1
- package/dist/webcomponent/certrev-badge.d.ts.map +1 -1
- package/dist/webcomponent/certrev-badge.js +24 -2
- package/dist/webcomponent/certrev-badge.js.map +1 -1
- package/dist/webcomponent/render-badge-html.d.ts +1 -1
- package/dist/webcomponent/render-badge-html.d.ts.map +1 -1
- package/dist/webcomponent/render-badge-html.js +36 -17
- package/dist/webcomponent/render-badge-html.js.map +1 -1
- package/package.json +14 -2
- package/src/builder/cert-chrome-data.ts +26 -8
- package/src/builder/cert-review-card.tsx +147 -38
- package/src/builder/index.tsx +34 -17
- package/src/components/CertBadge.tsx +14 -9
- package/src/components/CertJsonLd.tsx +1 -1
- package/src/components/CertRevBacklink.tsx +10 -3
- package/src/components/CertReview.tsx +1 -1
- package/src/components/ExpertBio.tsx +12 -7
- package/src/components/escape.ts +37 -7
- package/src/components/format.ts +102 -6
- package/src/components/preview-block.ts +3 -3
- package/src/components/rel.ts +23 -0
- package/src/components/render-cert-block.ts +192 -109
- package/src/components/render-def.ts +14 -15
- package/src/index.ts +4 -2
- package/src/jsonld/project.ts +155 -59
- package/src/modal/avatar-resize.ts +14 -14
- package/src/modal/cert-modal-view.ts +52 -27
- package/src/modal/certrev-cert-modal.styles.ts +8 -8
- package/src/modal/certrev-cert-modal.ts +9 -9
- package/src/modal/certrev-cert.entry.ts +9 -7
- package/src/modal/display-strings.ts +26 -5
- package/src/modal/escape.ts +32 -4
- package/src/modal/font-face.ts +8 -11
- package/src/modal/ftc-disclosure.ts +2 -2
- package/src/modal/ftc-guard.ts +73 -18
- package/src/modal/index.ts +1 -1
- package/src/modal/interactions.ts +2 -2
- package/src/modal/logo-paths.ts +2 -2
- package/src/modal/placement.ts +7 -6
- package/src/modal/revalidate.ts +2 -3
- package/src/modal/verify-client.ts +2 -2
- package/src/verify/cache.ts +34 -8
- package/src/verify/fetch-with-deadline.ts +60 -0
- package/src/verify/get-verified-envelope.ts +131 -48
- package/src/verify/resolve-kid.ts +22 -6
- package/src/webcomponent/certrev-badge.ts +24 -2
- package/src/webcomponent/render-badge-html.ts +66 -19
- package/src/__tests__/__snapshots__/render-cert-block.test.ts.snap +0 -17
- package/src/__tests__/components.test.tsx +0 -206
- package/src/__tests__/preview-block.test.ts +0 -84
- package/src/__tests__/project.test.ts +0 -128
- package/src/__tests__/render-cert-block.test.ts +0 -657
- package/src/__tests__/render-def.test.tsx +0 -295
- package/src/__tests__/verify.test.ts +0 -239
- package/src/__tests__/webcomponent.test.tsx +0 -116
- package/src/builder/__tests__/builder.test.tsx +0 -158
- package/src/modal/cert-modal-view.test.ts +0 -175
- package/src/modal/certrev-cert-modal.test.ts +0 -125
- package/src/modal/ftc-guard.test.ts +0 -153
- package/src/modal/interactions.test.ts +0 -250
- package/src/modal/placement.test.ts +0 -398
- package/src/modal/revalidate.test.ts +0 -111
- package/src/modal/verify-client.test.ts +0 -164
|
@@ -1,657 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* renderCertBlock — the LOCKED 3-mode CertREV cert design (banner / sidebar /
|
|
3
|
-
* floating) as an SSR-safe HTML string.
|
|
4
|
-
*
|
|
5
|
-
* Coverage:
|
|
6
|
-
* 1. SNAPSHOTS — each mode for a representative envelope + a themed (non-navy)
|
|
7
|
-
* variant, so any drift from the owner-approved locked design is caught.
|
|
8
|
-
* 2. LOCKED disclosures — the compensation cue, the byte-exact FTC scope line,
|
|
9
|
-
* and the GROUPED dated credential verification appear on every CLAIM face
|
|
10
|
-
* (banner + sidebar). Floating is a modal pointer, not a claim face.
|
|
11
|
-
* 3. THEMING — the resolved tokens reach the root as the guide's CSS vars; an
|
|
12
|
-
* invalid token fails safe to navy; the fixed logo chrome is present.
|
|
13
|
-
* 4. ESCAPING — a value carrying `< & "` is escaped (this is storefront HTML).
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { describe, expect, it } from 'vitest'
|
|
17
|
-
import {
|
|
18
|
-
CERT_SCOPE_LINE,
|
|
19
|
-
type CertBlockFace,
|
|
20
|
-
type CertBlockFacts,
|
|
21
|
-
type CertBlockLayout,
|
|
22
|
-
type CertBlockMode,
|
|
23
|
-
COMPENSATED_EXPERT_CUE,
|
|
24
|
-
CREDENTIAL_VERIFIED_ATTRIBUTION,
|
|
25
|
-
RENDER_BLOCK_FONT_STACKS,
|
|
26
|
-
type ResolvedBlockTheme,
|
|
27
|
-
renderCertBlock,
|
|
28
|
-
} from '../index.js'
|
|
29
|
-
|
|
30
|
-
/** A representative, well-formed cert (mirrors the locked-reference sample facts). */
|
|
31
|
-
const FACTS: CertBlockFacts = {
|
|
32
|
-
authorName: 'The Editorial Team',
|
|
33
|
-
authorTitle: 'Editorial',
|
|
34
|
-
reviewerName: 'Jane Doe',
|
|
35
|
-
credential: 'MD',
|
|
36
|
-
credentialVerifiedAt: '2026-04-14T00:00:00.000Z',
|
|
37
|
-
certifiedAt: '2026-04-14T00:00:00.000Z',
|
|
38
|
-
memo: 'Patients ask me almost every week whether the SPF in their foundation is enough on its own. Treat makeup with SPF as a helpful last layer, not the base of your routine.',
|
|
39
|
-
bio: 'Board-certified dermatologist in San Francisco specializing in photoprotection, skin-cancer prevention, and evidence-based skincare, with fifteen years of clinical practice.',
|
|
40
|
-
profileUrl: 'https://certrev.com/expert/sample',
|
|
41
|
-
certificateUrl: 'https://certrev.com/verify/sample',
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** A themed brand (non-navy accent + dark ink + custom corners/surface/font). */
|
|
45
|
-
const THEMED: ResolvedBlockTheme = {
|
|
46
|
-
accentColor: '#E8603C',
|
|
47
|
-
surface: '#faf8f4',
|
|
48
|
-
cornerRadius: '22px',
|
|
49
|
-
fontSlot: 'grotesk',
|
|
50
|
-
accentFg: '#141414',
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* A v2 brand-ink themed brand: 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
|
-
|
|
64
|
-
const MODES: CertBlockMode[] = ['banner', 'sidebar', 'floating']
|
|
65
|
-
const CLAIM_FACES: CertBlockMode[] = ['banner', 'sidebar']
|
|
66
|
-
|
|
67
|
-
/** Tag-strip a fragment of HTML to its visible text (collapse whitespace). */
|
|
68
|
-
function visibleText(html: string): string {
|
|
69
|
-
return html
|
|
70
|
-
.replace(/<[^>]+>/g, ' ')
|
|
71
|
-
.replace(/\s+/g, ' ')
|
|
72
|
-
.trim()
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
76
|
-
// LOCKED disclosures are undroppable — omitted OR empty/whitespace defaults
|
|
77
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
78
|
-
|
|
79
|
-
describe('renderCertBlock — LOCKED disclosure strings are undroppable', () => {
|
|
80
|
-
for (const mode of CLAIM_FACES) {
|
|
81
|
-
it(`${mode}: an empty/whitespace override of the locked strings falls back to the byte-exact constants`, () => {
|
|
82
|
-
const html = renderCertBlock({
|
|
83
|
-
mode,
|
|
84
|
-
facts: { ...FACTS, compensationCue: '', scopeLine: '', credentialLine: ' ' },
|
|
85
|
-
})
|
|
86
|
-
expect(html).toContain(COMPENSATED_EXPERT_CUE)
|
|
87
|
-
expect(html).toContain(CERT_SCOPE_LINE)
|
|
88
|
-
expect(html).toContain(CREDENTIAL_VERIFIED_ATTRIBUTION)
|
|
89
|
-
})
|
|
90
|
-
}
|
|
91
|
-
})
|
|
92
|
-
|
|
93
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
94
|
-
// Snapshots — the locked-design byte target
|
|
95
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
96
|
-
|
|
97
|
-
describe('renderCertBlock — locked-design snapshots', () => {
|
|
98
|
-
for (const mode of MODES) {
|
|
99
|
-
it(`${mode} (navy default) matches the locked snapshot`, () => {
|
|
100
|
-
expect(renderCertBlock({ mode, facts: FACTS })).toMatchSnapshot()
|
|
101
|
-
})
|
|
102
|
-
it(`${mode} (themed, non-navy accent) matches the locked snapshot`, () => {
|
|
103
|
-
expect(renderCertBlock({ mode, theme: THEMED, facts: FACTS })).toMatchSnapshot()
|
|
104
|
-
})
|
|
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
|
-
})
|
|
153
|
-
})
|
|
154
|
-
|
|
155
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
156
|
-
// LOCKED disclosures — on every claim face
|
|
157
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
158
|
-
|
|
159
|
-
describe('the three LOCKED disclosures render on every CLAIM face', () => {
|
|
160
|
-
for (const mode of CLAIM_FACES) {
|
|
161
|
-
it(`${mode}: compensation cue + byte-exact FTC scope line + dated credential verification`, () => {
|
|
162
|
-
const html = renderCertBlock({ mode, facts: FACTS })
|
|
163
|
-
// (1) compensation cue
|
|
164
|
-
expect(visibleText(html)).toContain(COMPENSATED_EXPERT_CUE)
|
|
165
|
-
// (2) FTC scope line — byte-exact, including the load-bearing curly apostrophe U+2019
|
|
166
|
-
expect(html).toContain(CERT_SCOPE_LINE)
|
|
167
|
-
expect(CERT_SCOPE_LINE).toContain('’')
|
|
168
|
-
// (3) dated credential verification
|
|
169
|
-
expect(html).toContain(CREDENTIAL_VERIFIED_ATTRIBUTION)
|
|
170
|
-
expect(html).toContain('on Apr 14, 2026')
|
|
171
|
-
})
|
|
172
|
-
|
|
173
|
-
it(`${mode}: the credential + its dated verification stay GROUPED, in order`, () => {
|
|
174
|
-
const html = renderCertBlock({ mode, facts: FACTS })
|
|
175
|
-
const iName = html.indexOf('Jane Doe, MD')
|
|
176
|
-
const iAttribution = html.indexOf(CREDENTIAL_VERIFIED_ATTRIBUTION)
|
|
177
|
-
const iDate = html.indexOf('on Apr 14, 2026')
|
|
178
|
-
expect(iName).toBeGreaterThanOrEqual(0)
|
|
179
|
-
expect(iAttribution).toBeGreaterThan(iName)
|
|
180
|
-
expect(iDate).toBeGreaterThan(iAttribution)
|
|
181
|
-
})
|
|
182
|
-
|
|
183
|
-
it(`${mode}: the disclosures survive even when the caller omits the locked strings`, () => {
|
|
184
|
-
// Omit compensationCue / scopeLine / credentialLine — they default to the
|
|
185
|
-
// byte-verbatim mirrored constants (the compliance baseline is never dropped).
|
|
186
|
-
const html = renderCertBlock({ mode, facts: FACTS })
|
|
187
|
-
expect(html).toContain(CERT_SCOPE_LINE)
|
|
188
|
-
expect(visibleText(html)).toContain(COMPENSATED_EXPERT_CUE)
|
|
189
|
-
expect(html).toContain(CREDENTIAL_VERIFIED_ATTRIBUTION)
|
|
190
|
-
})
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
it('the caller-supplied locked strings are rendered verbatim when provided', () => {
|
|
194
|
-
const html = renderCertBlock({
|
|
195
|
-
mode: 'banner',
|
|
196
|
-
facts: {
|
|
197
|
-
...FACTS,
|
|
198
|
-
compensationCue: COMPENSATED_EXPERT_CUE,
|
|
199
|
-
scopeLine: CERT_SCOPE_LINE,
|
|
200
|
-
credentialLine: CREDENTIAL_VERIFIED_ATTRIBUTION,
|
|
201
|
-
},
|
|
202
|
-
})
|
|
203
|
-
expect(html).toContain(CERT_SCOPE_LINE)
|
|
204
|
-
expect(html).toContain(CREDENTIAL_VERIFIED_ATTRIBUTION)
|
|
205
|
-
})
|
|
206
|
-
|
|
207
|
-
it('the floating pill is a modal pointer — it makes no claim (no disclosures)', () => {
|
|
208
|
-
const html = renderCertBlock({ mode: 'floating', facts: FACTS })
|
|
209
|
-
expect(html).toContain('Expert reviewed:')
|
|
210
|
-
expect(html).not.toContain(CERT_SCOPE_LINE)
|
|
211
|
-
expect(html).not.toContain(CREDENTIAL_VERIFIED_ATTRIBUTION)
|
|
212
|
-
expect(visibleText(html)).not.toContain(COMPENSATED_EXPERT_CUE)
|
|
213
|
-
// but it still links to the two modal surfaces
|
|
214
|
-
expect(html).toContain('data-certrev-modal-open="expert"')
|
|
215
|
-
expect(html).toContain('data-certrev-modal-open ')
|
|
216
|
-
})
|
|
217
|
-
})
|
|
218
|
-
|
|
219
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
220
|
-
// Theming — CSS vars, fail-safe, fixed chrome
|
|
221
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
222
|
-
|
|
223
|
-
describe('theming', () => {
|
|
224
|
-
it('un-themed: the root carries the navy default vars + the strings-version stamp', () => {
|
|
225
|
-
const html = renderCertBlock({ mode: 'banner', facts: FACTS })
|
|
226
|
-
expect(html).toContain('--ba:#0a1b3f')
|
|
227
|
-
expect(html).toContain('--ba-fg:#ffffff')
|
|
228
|
-
expect(html).toContain('--br:14px')
|
|
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:')
|
|
234
|
-
expect(html).toContain('--surface:#ffffff')
|
|
235
|
-
expect(html).toContain('data-certrev-strings-version="2026-07"')
|
|
236
|
-
})
|
|
237
|
-
|
|
238
|
-
it('themed: the resolved tokens reach the root as CSS vars', () => {
|
|
239
|
-
const html = renderCertBlock({ mode: 'sidebar', theme: THEMED, facts: FACTS })
|
|
240
|
-
expect(html).toContain('--ba:#E8603C')
|
|
241
|
-
expect(html).toContain('--ba-fg:#141414')
|
|
242
|
-
expect(html).toContain('--br:22px')
|
|
243
|
-
expect(html).toContain(`--cr-bf:var(--bf,${RENDER_BLOCK_FONT_STACKS.grotesk})`)
|
|
244
|
-
expect(html).toContain('--surface:#faf8f4')
|
|
245
|
-
})
|
|
246
|
-
|
|
247
|
-
it('an invalid token fails SAFE to the navy default (never emitted into CSS)', () => {
|
|
248
|
-
const html = renderCertBlock({
|
|
249
|
-
mode: 'banner',
|
|
250
|
-
// injection attempts + malformed values across every token
|
|
251
|
-
theme: {
|
|
252
|
-
accentColor: 'red;}</style>',
|
|
253
|
-
surface: '#ffffffaa',
|
|
254
|
-
cornerRadius: 'calc(1px)',
|
|
255
|
-
fontSlot: 'comic-sans' as ResolvedBlockTheme['fontSlot'],
|
|
256
|
-
accentFg: 'blue' as ResolvedBlockTheme['accentFg'],
|
|
257
|
-
},
|
|
258
|
-
facts: FACTS,
|
|
259
|
-
})
|
|
260
|
-
expect(html).not.toContain('red;}')
|
|
261
|
-
expect(html).not.toContain('#ffffffaa')
|
|
262
|
-
// the injected radius is dropped (the card's own `calc(var(--br,14px) * 0.7)` is a fixed literal)
|
|
263
|
-
expect(html).not.toContain('calc(1px)')
|
|
264
|
-
expect(html).not.toContain('comic-sans')
|
|
265
|
-
expect(html).toContain('--ba:#0a1b3f')
|
|
266
|
-
expect(html).toContain('--ba-fg:#ffffff')
|
|
267
|
-
expect(html).toContain('--br:14px')
|
|
268
|
-
expect(html).toContain(`--cr-bf:var(--bf,${RENDER_BLOCK_FONT_STACKS.sans})`)
|
|
269
|
-
})
|
|
270
|
-
|
|
271
|
-
it('the fixed CertREV logo chrome renders on the banner + sidebar headers and the memo credit', () => {
|
|
272
|
-
const banner = renderCertBlock({ mode: 'banner', facts: FACTS })
|
|
273
|
-
const sidebar = renderCertBlock({ mode: 'sidebar', facts: FACTS })
|
|
274
|
-
// every mark tracks its container ink via currentColor (navy header + memo verified lockup)
|
|
275
|
-
expect(banner).toContain('aria-label="CertREV"')
|
|
276
|
-
expect(banner).toContain('<path fill="currentColor"')
|
|
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)')
|
|
281
|
-
expect(sidebar).toContain('aria-label="CertREV"')
|
|
282
|
-
expect(sidebar).toContain('<path fill="currentColor"')
|
|
283
|
-
})
|
|
284
|
-
})
|
|
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
|
-
|
|
374
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
375
|
-
// Escaping — storefront HTML built by hand
|
|
376
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
377
|
-
|
|
378
|
-
describe('escaping (this is storefront HTML)', () => {
|
|
379
|
-
const HOSTILE = 'Ev<script>il & "quoted" \'x\''
|
|
380
|
-
const hostileFacts: CertBlockFacts = {
|
|
381
|
-
...FACTS,
|
|
382
|
-
authorName: HOSTILE,
|
|
383
|
-
authorTitle: HOSTILE,
|
|
384
|
-
reviewerName: HOSTILE,
|
|
385
|
-
credential: HOSTILE,
|
|
386
|
-
memo: HOSTILE,
|
|
387
|
-
bio: HOSTILE,
|
|
388
|
-
profileUrl: 'javascript:alert(1)',
|
|
389
|
-
certificateUrl: 'javascript:alert(2)',
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
for (const mode of CLAIM_FACES) {
|
|
393
|
-
it(`${mode}: every interpolated fact value is escaped; no raw injection survives`, () => {
|
|
394
|
-
const html = renderCertBlock({ mode, facts: hostileFacts })
|
|
395
|
-
// the injected markup never lands as live HTML
|
|
396
|
-
expect(html).not.toContain('<script>il')
|
|
397
|
-
expect(html).toContain('<script>')
|
|
398
|
-
expect(html).toContain('&')
|
|
399
|
-
expect(html).toContain('"')
|
|
400
|
-
expect(html).toContain(''')
|
|
401
|
-
})
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
for (const mode of MODES) {
|
|
405
|
-
it(`${mode}: an unsafe URL scheme is dropped from every href`, () => {
|
|
406
|
-
const html = renderCertBlock({ mode, facts: hostileFacts })
|
|
407
|
-
expect(html).not.toContain('javascript:')
|
|
408
|
-
// the modal hook + a safe fallback href remain
|
|
409
|
-
expect(html).toContain('data-certrev-modal-open')
|
|
410
|
-
expect(html).toContain('href="#"')
|
|
411
|
-
})
|
|
412
|
-
}
|
|
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
|
-
})
|