@certrev/cert-block 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/components/CertBadge.d.ts +18 -3
  2. package/dist/components/CertBadge.d.ts.map +1 -1
  3. package/dist/components/CertBadge.js +8 -2
  4. package/dist/components/CertBadge.js.map +1 -1
  5. package/dist/components/CertReview.d.ts +7 -0
  6. package/dist/components/CertReview.d.ts.map +1 -1
  7. package/dist/components/CertReview.js +1 -1
  8. package/dist/components/CertReview.js.map +1 -1
  9. package/dist/components/format.d.ts +14 -2
  10. package/dist/components/format.d.ts.map +1 -1
  11. package/dist/components/format.js +18 -5
  12. package/dist/components/format.js.map +1 -1
  13. package/dist/components/render-cert-block.d.ts +89 -0
  14. package/dist/components/render-cert-block.d.ts.map +1 -0
  15. package/dist/components/render-cert-block.js +282 -0
  16. package/dist/components/render-cert-block.js.map +1 -0
  17. package/dist/components/render-def.d.ts +178 -0
  18. package/dist/components/render-def.d.ts.map +1 -0
  19. package/dist/components/render-def.js +204 -0
  20. package/dist/components/render-def.js.map +1 -0
  21. package/dist/index.d.ts +2 -0
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +4 -0
  24. package/dist/index.js.map +1 -1
  25. package/dist/webcomponent/render-badge-html.d.ts +8 -0
  26. package/dist/webcomponent/render-badge-html.d.ts.map +1 -1
  27. package/dist/webcomponent/render-badge-html.js +31 -4
  28. package/dist/webcomponent/render-badge-html.js.map +1 -1
  29. package/package.json +77 -76
  30. package/src/__tests__/__snapshots__/render-cert-block.test.ts.snap +13 -0
  31. package/src/__tests__/components.test.tsx +16 -1
  32. package/src/__tests__/render-cert-block.test.ts +259 -0
  33. package/src/__tests__/render-def.test.tsx +286 -0
  34. package/src/__tests__/webcomponent.test.tsx +10 -0
  35. package/src/components/CertBadge.tsx +56 -4
  36. package/src/components/CertReview.tsx +8 -0
  37. package/src/components/format.ts +22 -5
  38. package/src/components/render-cert-block.ts +403 -0
  39. package/src/components/render-def.ts +286 -0
  40. package/src/index.ts +35 -0
  41. package/src/webcomponent/render-badge-html.ts +46 -4
@@ -0,0 +1,403 @@
1
+ /**
2
+ * ─────────────────────────────────────────────────────────────────────────────
3
+ * renderCertBlock — the LOCKED CertREV cert design, as an SSR-safe HTML string
4
+ * ─────────────────────────────────────────────────────────────────────────────
5
+ *
6
+ * Paints the owner-approved cert render (decision 2026-07-17) in one of three
7
+ * placement modes — `banner`, `sidebar`, `floating` — from RESOLVED theme tokens +
8
+ * the cert facts. It returns a self-contained HTML string that renders with ZERO
9
+ * JavaScript (the CertREV modal + the reviewer-bio accordion are progressive
10
+ * enhancement, wired by the shared embed script via the `data-certrev-*` hooks).
11
+ *
12
+ * WHY A STRING (not a React component): the block is emitted into storefront HTML
13
+ * across every render surface (Shopify Liquid, WordPress PHP, the JS engine, and
14
+ * this headless twin). A pure string renderer — like `renderBadgeHtml` — runs on
15
+ * every runtime the main entry targets (edge / Workers / server) with no React, no
16
+ * DOM, no Node builtins.
17
+ *
18
+ * THEMING: the reference is variable-driven. This emits the guide's CSS-var theming
19
+ * hooks on the ROOT element so the block is self-contained on a storefront that does
20
+ * NOT define the guide's `:root` — the accent (`--ba`), its auto-contrast ink
21
+ * (`--ba-fg`), the corner radius (`--br`), the body font (`--bf`) and surface
22
+ * (`--surface`) come from the resolved theme; the navy trust tints + the fixed
23
+ * label/serif/wordmark fonts are LOCKED literals (guide "the lock is legal, not
24
+ * aesthetic"). A brand themes accent / font / corners / surface; the navy chrome ink
25
+ * stays fixed.
26
+ *
27
+ * COMPLIANCE (LOCKED, non-negotiable): the three material disclosures always render
28
+ * on a CLAIM face (banner + sidebar) — the compensation cue, the byte-exact FTC
29
+ * scope line, and the dated credential verification, which stays GROUPED with the
30
+ * credential (`<name>, <credential>` → "Credential verified by CertREV" → "on
31
+ * <date>"). The `floating` pill makes no claim (it only labels + links to the
32
+ * modal), so it carries no disclosures — matching the locked reference.
33
+ *
34
+ * SECURITY: this is storefront HTML built by hand (no JSX auto-escaping), so EVERY
35
+ * interpolated value is escaped — `escapeHtml` for content, `escapeAttribute` for
36
+ * attribute values, `safeHttpUrl` for hrefs. Theme tokens are re-validated
37
+ * (`resolveBlockTheme`) and only ever emitted as strict hex / length / enum-stack.
38
+ */
39
+
40
+ import { escapeAttribute, escapeHtml, safeHttpUrl } from './escape.js'
41
+ import { formatDate } from './format.js'
42
+ import {
43
+ CERT_SCOPE_LINE,
44
+ CERT_STRINGS_VERSION,
45
+ COMPENSATED_EXPERT_CUE,
46
+ CREDENTIAL_VERIFIED_ATTRIBUTION,
47
+ RENDER_BLOCK_FONT_STACKS,
48
+ type ResolvedBlockTheme,
49
+ resolveBlockTheme,
50
+ } from './render-def.js'
51
+
52
+ /** The three LOCKED placement faces. */
53
+ export type CertBlockMode = 'banner' | 'sidebar' | 'floating'
54
+
55
+ /**
56
+ * The cert facts + LOCKED display strings a face renders. Facts are escaped; the
57
+ * three locked strings (`compensationCue`, `scopeLine`, `credentialLine`) are
58
+ * byte-verbatim per the CertREV Display Guide and default to the mirrored constants
59
+ * when a caller omits them — so the compliance baseline is exact even if unsupplied.
60
+ */
61
+ export interface CertBlockFacts {
62
+ /** The content author (the "Written by" column). */
63
+ readonly authorName: string
64
+ /** The author's subtitle (e.g. "Editorial"). Omitted when absent. */
65
+ readonly authorTitle?: string
66
+ /** The reviewing expert's display name, WITHOUT the credential (e.g. "Jane Doe"). */
67
+ readonly reviewerName: string
68
+ /** The reviewer's credential abbreviation (e.g. "MD"). Empty → name-only. */
69
+ readonly credential: string
70
+ /** ISO instant CertREV verified the CREDENTIAL (the dated verification date). */
71
+ readonly credentialVerifiedAt: string
72
+ /** ISO instant the article was certified (the "Certified <date>" footer). */
73
+ readonly certifiedAt: string
74
+ /** The expert's memo (rendered as the serif blockquote). */
75
+ readonly memo: string
76
+ /** The expert's bio (banner accordion / sidebar paragraph). Omitted when absent. */
77
+ readonly bio?: string
78
+ /** The expert profile URL (the "Profile" link + modal `expert` hook). */
79
+ readonly profileUrl: string
80
+ /** The certificate URL (the "Certificate" link + modal hook). */
81
+ readonly certificateUrl: string
82
+ /** LOCKED — the compensation cue. Default: `COMPENSATED_EXPERT_CUE`. */
83
+ readonly compensationCue?: string
84
+ /** LOCKED — the byte-exact FTC scope line. Default: `CERT_SCOPE_LINE`. */
85
+ readonly scopeLine?: string
86
+ /** LOCKED — the verification attribution. Default: `CREDENTIAL_VERIFIED_ATTRIBUTION`. */
87
+ readonly credentialLine?: string
88
+ }
89
+
90
+ /** The full input to `renderCertBlock`. */
91
+ export interface RenderCertBlockInput {
92
+ readonly mode: CertBlockMode
93
+ /** RESOLVED theme tokens (the portal bakes these). Absent/invalid tokens fall back to navy. */
94
+ readonly theme?: Partial<ResolvedBlockTheme>
95
+ readonly facts: CertBlockFacts
96
+ }
97
+
98
+ // ─────────────────────────────────────────────────────────────────────────────
99
+ // The CertREV logo — the fixed 4-chevron mark (owner decision 2026-07-17: the logo
100
+ // IS permitted on the article face). Path data is byte-exact to the locked reference.
101
+ // ─────────────────────────────────────────────────────────────────────────────
102
+
103
+ const LOGO_PATHS = [
104
+ 'M67.59 221.77L138.77 292.95C145.8 299.98 145.8 311.44 138.77 318.47L123.02 334.22C116 341.25 104.54 341.25 97.5 334.22L26.32 263.04C19.29 256.01 19.29 244.55 26.32 237.52L42.07 221.77C49.1 214.74 60.56 214.74 67.59 221.77Z',
105
+ 'M96.79 335.08L81.12 319.42C73.8 312.1 73.8 300.23 81.12 292.92L315.88 58.16C323.2 50.85 335.06 50.85 342.38 58.16L358.04 73.83C365.36 81.14 365.36 93.01 358.04 100.33L123.29 335.08C115.97 342.4 104.1 342.4 96.79 335.08Z',
106
+ 'M203.5 335.14L187.83 319.48C180.52 312.16 180.52 300.29 187.83 292.98L315.88 164.93C323.2 157.62 335.06 157.62 342.38 164.93L358.04 180.6C365.36 187.92 365.36 199.78 358.04 207.1L230 335.14C222.68 342.46 210.82 342.46 203.5 335.14Z',
107
+ 'M310.21 335.2L294.54 319.54C287.23 312.22 287.23 300.36 294.54 293.04L315.88 271.71C323.2 264.39 335.06 264.39 342.38 271.71L358.04 287.37C365.36 294.69 365.36 306.55 358.04 313.87L336.71 335.2C329.39 342.52 317.53 342.52 310.21 335.2Z',
108
+ ]
109
+
110
+ /**
111
+ * The 4-chevron CertREV mark at `size`px, filled `fill` (a controlled literal). The
112
+ * navy/pill HEADERS pass `currentColor` so the mark tracks the header text's accent
113
+ * foreground ink (`--ba-fg`) — white on the navy default, and the dark auto-contrast
114
+ * ink on a light themed accent (so it never goes white-on-light). The memo credit
115
+ * passes the fixed navy `#0a1b3f` (it sits on the white surface).
116
+ */
117
+ function logo(size: number, fill: 'currentColor' | '#0a1b3f'): string {
118
+ return (
119
+ `<svg viewBox="0 0 375 375" width="${size}" height="${size}" style="flex-shrink:0" aria-label="CertREV">` +
120
+ LOGO_PATHS.map((d) => `<path fill="${fill}" d="${d}"></path>`).join('') +
121
+ `</svg>`
122
+ )
123
+ }
124
+
125
+ /** The bio-accordion caret (progressive-enhancement affordance). */
126
+ const CARET =
127
+ `<span class="certrev-cert__caret" style="display:inline-flex;vertical-align:middle;margin-left:4px;color:var(--navy-35);transition:transform .18s ease-out;">` +
128
+ `<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"></path></svg></span>`
129
+
130
+ // ─────────────────────────────────────────────────────────────────────────────
131
+ // Small pure helpers
132
+ // ─────────────────────────────────────────────────────────────────────────────
133
+
134
+ /** "Jane Doe" → "JD"; "The Editorial Team" → "TE" (first letter of the first two words). */
135
+ function initials(name: string): string {
136
+ const parts = name.trim().split(/\s+/).filter(Boolean)
137
+ const chars = parts
138
+ .slice(0, 2)
139
+ .map((p) => p[0] ?? '')
140
+ .join('')
141
+ return (chars || '?').toUpperCase()
142
+ }
143
+
144
+ /** The first name token (for "View Jane's profile"). */
145
+ function firstNameOf(name: string): string {
146
+ const first = name.trim().split(/\s+/)[0]
147
+ return first || name.trim()
148
+ }
149
+
150
+ /**
151
+ * A LOCKED display string: use the caller's value ONLY when it is a non-blank
152
+ * string, else the byte-verbatim constant. The three compliance strings
153
+ * (compensation cue, scope line, credential attribution) are undroppable — an
154
+ * omitted OR empty/whitespace override must never blank the disclosure.
155
+ */
156
+ function locked(supplied: string | undefined, constant: string): string {
157
+ return supplied?.trim() ? supplied : constant
158
+ }
159
+
160
+ /** A round avatar tile with the given initials. */
161
+ function avatar(text: string, size: number, fontSize: number): string {
162
+ return (
163
+ `<div style="width:${size}px;height:${size}px;border-radius:50%;background:var(--stone);color:var(--navy-55);` +
164
+ `display:flex;align-items:center;justify-content:center;font-family:var(--font-mono);font-weight:700;font-size:${fontSize}px;flex-shrink:0;">` +
165
+ `${escapeHtml(text)}</div>`
166
+ )
167
+ }
168
+
169
+ /** "Jane Doe, MD" (or just the name when there is no credential) — escaped. */
170
+ function nameWithCredential(name: string, credential: string): string {
171
+ const n = escapeHtml(name)
172
+ return credential ? `${n}, ${escapeHtml(credential)}` : n
173
+ }
174
+
175
+ /** The reviewer profile link (carries the modal `expert` hook + a real href fallback). */
176
+ function profileLink(url: string, inner: string, style: string): string {
177
+ const href = safeHttpUrl(url) ?? '#'
178
+ return `<a href="${escapeAttribute(href)}" data-certrev-modal-open="expert" rel="noopener" style="${style}">${inner}</a>`
179
+ }
180
+
181
+ /** The certificate link (carries the modal hook + a real href fallback). */
182
+ function certificateLink(url: string, inner: string, style: string): string {
183
+ const href = safeHttpUrl(url) ?? '#'
184
+ return `<a href="${escapeAttribute(href)}" data-certrev-modal-open rel="noopener" style="${style}">${inner}</a>`
185
+ }
186
+
187
+ /**
188
+ * The GROUPED dated credential verification (LOCKED requirement): the credentialed
189
+ * name, then the attribution line, then "on <date>" — one block, order preserved.
190
+ * `withAccordion` adds the bio caret + hidden bio (banner) as progressive enhancement.
191
+ */
192
+ function verificationBlock(facts: CertBlockFacts, withAccordion: boolean): string {
193
+ const credentialLine = locked(facts.credentialLine, CREDENTIAL_VERIFIED_ATTRIBUTION)
194
+ const verified = formatDate(facts.credentialVerifiedAt) ?? escapeHtml(facts.credentialVerifiedAt)
195
+ const caret = withAccordion && facts.bio ? CARET : ''
196
+ const nameSize = withAccordion ? '15px' : '16px'
197
+ const muteSize = withAccordion ? '13px' : '12.5px'
198
+ const bio =
199
+ withAccordion && facts.bio
200
+ ? `<div class="certrev-cert__bio" hidden style="display:none;margin-top:8px;font-size:12.5px;line-height:1.5;color:var(--navy-75);">${escapeHtml(facts.bio)}</div>`
201
+ : ''
202
+ return (
203
+ `<div>` +
204
+ `<div style="font-size:${nameSize};font-weight:700;">${nameWithCredential(facts.reviewerName, facts.credential)}${caret}</div>` +
205
+ `<div style="font-size:${muteSize};color:var(--navy-55);line-height:1.45;margin-top:3px;">${escapeHtml(credentialLine)}</div>` +
206
+ `<div style="font-size:${muteSize};color:var(--navy-55);line-height:1.35;">on ${verified}</div>` +
207
+ bio +
208
+ `</div>`
209
+ )
210
+ }
211
+
212
+ // ─────────────────────────────────────────────────────────────────────────────
213
+ // The root wrapper — sets the guide's CSS vars so the block is self-contained
214
+ // ─────────────────────────────────────────────────────────────────────────────
215
+
216
+ function rootStyle(theme: ResolvedBlockTheme): string {
217
+ // Data-bearing theme tokens are already strict-validated (hex / length / literal
218
+ // ink); the font is a controlled enum → stack literal. The navy tints + fixed
219
+ // label/serif/wordmark fonts are LOCKED literals.
220
+ return [
221
+ `--ba:${theme.accentColor}`,
222
+ `--ba-fg:${theme.accentFg}`,
223
+ `--br:${theme.cornerRadius}`,
224
+ `--bf:${RENDER_BLOCK_FONT_STACKS[theme.fontSlot]}`,
225
+ `--surface:${theme.surface}`,
226
+ `--navy:#0a1b3f`,
227
+ `--navy-06:rgba(10,27,63,.06)`,
228
+ `--navy-10:rgba(10,27,63,.10)`,
229
+ `--navy-35:rgba(10,27,63,.35)`,
230
+ `--navy-55:rgba(10,27,63,.55)`,
231
+ `--navy-75:rgba(10,27,63,.75)`,
232
+ `--stone:#ece9e1`,
233
+ `--font-mono:${RENDER_BLOCK_FONT_STACKS.mono}`,
234
+ `--font-serif:${RENDER_BLOCK_FONT_STACKS.serif}`,
235
+ `--font-wordmark:${RENDER_BLOCK_FONT_STACKS.grotesk}`,
236
+ `font-family:var(--bf)`,
237
+ `color:var(--navy)`,
238
+ ].join(';')
239
+ }
240
+
241
+ function root(mode: CertBlockMode, theme: ResolvedBlockTheme, inner: string): string {
242
+ return (
243
+ `<div class="certrev-cert certrev-cert--${mode}" data-certrev-mode="${mode}" ` +
244
+ `data-certrev-strings-version="${CERT_STRINGS_VERSION}" style="${rootStyle(theme)}">${inner}</div>`
245
+ )
246
+ }
247
+
248
+ // ─────────────────────────────────────────────────────────────────────────────
249
+ // Mode 1 — BANNER: a certification header card + a separate expert-memo card
250
+ // ─────────────────────────────────────────────────────────────────────────────
251
+
252
+ function bannerHeaderCard(facts: CertBlockFacts): string {
253
+ const compensationCue = locked(facts.compensationCue, COMPENSATED_EXPERT_CUE)
254
+ const certified = formatDate(facts.certifiedAt) ?? escapeHtml(facts.certifiedAt)
255
+ const subtitle = facts.authorTitle
256
+ ? `<div style="font-size:13px;color:var(--navy-55);">${escapeHtml(facts.authorTitle)}</div>`
257
+ : ''
258
+ return (
259
+ `<div style="border:1px solid var(--navy-10);border-radius:calc(var(--br,14px) * 0.7);overflow:hidden;font-family:var(--bf);">` +
260
+ // navy header
261
+ `<div style="display:flex;align-items:center;justify-content:space-between;gap:16px;padding:15px 22px;background:var(--ba,#0a1b3f);color:var(--ba-fg,#fff);">` +
262
+ `<span style="display:inline-flex;align-items:center;gap:11px;font-family:var(--font-mono);font-size:12px;font-weight:700;letter-spacing:.14em;text-transform:uppercase;">${logo(20, 'currentColor')} Expert reviewed</span>` +
263
+ `</div>` +
264
+ `<div style="padding:22px 24px 18px;">` +
265
+ `<div style="display:grid;grid-template-columns:1fr 1fr;gap:22px;">` +
266
+ // written by
267
+ `<div>` +
268
+ `<div style="font-family:var(--font-mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--navy-35);margin-bottom:11px;">Written by</div>` +
269
+ `<div style="display:flex;align-items:center;gap:12px;">${avatar(initials(facts.authorName), 42, 12)}` +
270
+ `<div><div style="font-size:15px;font-weight:700;">${escapeHtml(facts.authorName)}</div>${subtitle}</div></div>` +
271
+ `</div>` +
272
+ // reviewed by
273
+ `<div>` +
274
+ `<div style="font-family:var(--font-mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--navy-35);margin-bottom:11px;">Reviewed by</div>` +
275
+ `<div class="certrev-cert__rev" role="button" tabindex="0" aria-expanded="false" data-certrev-bio-toggle aria-label="Read more about ${escapeAttribute(facts.reviewerName)}" style="display:flex;align-items:flex-start;gap:12px;cursor:pointer;border-radius:8px;transition:opacity .15s;">` +
276
+ `${avatar(initials(facts.reviewerName), 42, 12)}${verificationBlock(facts, true)}` +
277
+ `</div>` +
278
+ `</div>` +
279
+ `</div>` +
280
+ // hairline + mono footer
281
+ `<div style="height:1px;background:var(--navy-10);margin:16px 0 14px;"></div>` +
282
+ `<div style="display:flex;align-items:center;justify-content:space-between;gap:16px;font-family:var(--font-mono);font-size:10.5px;letter-spacing:.13em;text-transform:uppercase;color:var(--navy-55);">` +
283
+ `<span>Certified <span style="color:var(--navy);margin-left:6px;">${certified}</span></span>` +
284
+ `<span>${escapeHtml(compensationCue)}</span>` +
285
+ `</div>` +
286
+ `</div>` +
287
+ `</div>`
288
+ )
289
+ }
290
+
291
+ function bannerMemoCard(facts: CertBlockFacts): string {
292
+ const scopeLine = locked(facts.scopeLine, CERT_SCOPE_LINE)
293
+ const credTail = facts.credential
294
+ ? `<span style="color:var(--navy-55);">, ${escapeHtml(facts.credential)}</span>`
295
+ : ''
296
+ const first = firstNameOf(facts.reviewerName)
297
+ return (
298
+ `<div style="border:1px solid var(--navy-10);border-radius:calc(var(--br,14px) * 0.7);padding:24px;font-family:var(--bf);">` +
299
+ `<div style="font-family:var(--font-mono);font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:var(--navy-55);margin-bottom:14px;">Expert memo</div>` +
300
+ `<div style="display:flex;gap:15px;">${avatar(initials(facts.reviewerName), 46, 13)}` +
301
+ `<div style="flex:1;min-width:0;">` +
302
+ `<div style="font-size:16px;margin-bottom:10px;"><span style="font-weight:700;">${escapeHtml(facts.reviewerName)}</span>${credTail}</div>` +
303
+ `<blockquote style="margin:0;padding-left:18px;border-left:3px solid var(--ba,#0a1b3f);font-family:var(--font-serif);font-style:italic;font-size:16px;line-height:1.55;color:var(--navy-75);">${escapeHtml(facts.memo)}</blockquote>` +
304
+ `<div style="display:flex;gap:22px;margin-top:16px;">` +
305
+ profileLink(
306
+ facts.profileUrl,
307
+ `View ${escapeHtml(first)}'s profile`,
308
+ 'font-size:14px;font-weight:700;color:var(--navy);text-decoration:none;cursor:pointer;',
309
+ ) +
310
+ certificateLink(
311
+ facts.certificateUrl,
312
+ 'View certificate',
313
+ 'font-size:14px;font-weight:700;color:var(--navy);text-decoration:none;cursor:pointer;',
314
+ ) +
315
+ `</div>` +
316
+ `</div>` +
317
+ `</div>` +
318
+ `<div style="height:1px;background:var(--navy-10);margin:18px 0 14px;"></div>` +
319
+ `<div style="display:inline-flex;align-items:center;gap:9px;font-size:13px;color:var(--navy-75);">${logo(18, '#0a1b3f')} Verified by <span style="font-family:var(--font-wordmark);font-weight:800;">CertREV</span></div>` +
320
+ `<div style="font-size:13px;color:var(--navy-55);margin-top:8px;line-height:1.5;">${escapeHtml(scopeLine)}</div>` +
321
+ `</div>`
322
+ )
323
+ }
324
+
325
+ // ─────────────────────────────────────────────────────────────────────────────
326
+ // Mode 2 — SIDEBAR: a reviewer card pinned beside the article body
327
+ // ─────────────────────────────────────────────────────────────────────────────
328
+
329
+ function sidebarCard(facts: CertBlockFacts): string {
330
+ const compensationCue = locked(facts.compensationCue, COMPENSATED_EXPERT_CUE)
331
+ const scopeLine = locked(facts.scopeLine, CERT_SCOPE_LINE)
332
+ const bioPara = facts.bio
333
+ ? `<p style="font-size:14px;line-height:1.55;color:var(--navy-75);margin:16px 0 0;">${escapeHtml(facts.bio)}</p>`
334
+ : ''
335
+ return (
336
+ `<div style="background:var(--surface);border:1px solid var(--navy-10);border-radius:var(--br,14px);overflow:hidden;box-shadow:0 4px 18px var(--navy-06);font-family:var(--bf);">` +
337
+ `<div style="display:flex;align-items:center;justify-content:center;gap:10px;padding:12px;background:var(--ba,#0a1b3f);color:var(--ba-fg,#fff);font-family:var(--font-mono);font-size:12px;font-weight:700;letter-spacing:.16em;text-transform:uppercase;">${logo(18, 'currentColor')} Expert reviewed</div>` +
338
+ `<div style="padding:20px;">` +
339
+ `<div style="display:flex;align-items:flex-start;gap:13px;">${avatar(initials(facts.reviewerName), 46, 13)}${verificationBlock(facts, false)}</div>` +
340
+ bioPara +
341
+ `<blockquote style="margin:16px 0 0;padding-left:16px;border-left:3px solid var(--ba,#0a1b3f);font-family:var(--font-serif);font-style:italic;font-size:15px;line-height:1.5;color:var(--navy-75);">${escapeHtml(facts.memo)}</blockquote>` +
342
+ `<div style="font-size:12.5px;line-height:1.5;color:var(--navy-55);margin-top:16px;"><span style="font-family:var(--font-mono);font-size:10px;letter-spacing:.1em;text-transform:uppercase;">${escapeHtml(compensationCue)}</span> · ${escapeHtml(scopeLine)}</div>` +
343
+ `</div>` +
344
+ `<div style="display:grid;grid-template-columns:1fr 1fr;border-top:1px solid var(--navy-10);">` +
345
+ profileLink(
346
+ facts.profileUrl,
347
+ 'View Profile',
348
+ 'text-align:center;padding:14px;font-size:14px;font-weight:700;color:var(--navy);text-decoration:none;border-right:1px solid var(--navy-10);cursor:pointer;',
349
+ ) +
350
+ certificateLink(
351
+ facts.certificateUrl,
352
+ 'View Certificate',
353
+ 'text-align:center;padding:14px;font-size:14px;font-weight:700;color:var(--navy);text-decoration:none;cursor:pointer;',
354
+ ) +
355
+ `</div>` +
356
+ `</div>`
357
+ )
358
+ }
359
+
360
+ // ─────────────────────────────────────────────────────────────────────────────
361
+ // Mode 3 — FLOATING: a compact pinned pill (a modal pointer, NOT a claim face)
362
+ // ─────────────────────────────────────────────────────────────────────────────
363
+
364
+ function floatingPill(facts: CertBlockFacts): string {
365
+ return (
366
+ `<div class="certrev-cert__pill" style="display:inline-flex;align-items:center;gap:14px;padding:12px 22px;border-radius:999px;background:var(--ba,#0a1b3f);color:var(--ba-fg,#fff);box-shadow:0 10px 26px var(--navy-10);">` +
367
+ `<span style="font-family:var(--font-mono);font-size:11px;font-weight:700;letter-spacing:.14em;text-transform:uppercase;opacity:.75;">Expert reviewed:</span>` +
368
+ profileLink(
369
+ facts.profileUrl,
370
+ 'Profile',
371
+ 'font-size:14px;font-weight:700;text-decoration:underline;text-underline-offset:3px;color:inherit;cursor:pointer;',
372
+ ) +
373
+ `<span style="opacity:.5;">·</span>` +
374
+ certificateLink(
375
+ facts.certificateUrl,
376
+ 'Certificate',
377
+ 'font-size:14px;font-weight:700;text-decoration:underline;text-underline-offset:3px;color:inherit;cursor:pointer;',
378
+ ) +
379
+ `</div>`
380
+ )
381
+ }
382
+
383
+ // ─────────────────────────────────────────────────────────────────────────────
384
+ // Entry point
385
+ // ─────────────────────────────────────────────────────────────────────────────
386
+
387
+ /**
388
+ * Render the LOCKED CertREV cert design as an SSR-safe HTML string for one of the
389
+ * three placement modes. Returns a self-contained `<div class="certrev-cert …">`
390
+ * that needs no JS to render (the modal + bio accordion progressively enhance).
391
+ */
392
+ export function renderCertBlock(input: RenderCertBlockInput): string {
393
+ const theme = resolveBlockTheme(input.theme)
394
+ const { facts, mode } = input
395
+ switch (mode) {
396
+ case 'banner':
397
+ return root(mode, theme, bannerHeaderCard(facts) + bannerMemoCard(facts))
398
+ case 'sidebar':
399
+ return root(mode, theme, sidebarCard(facts))
400
+ case 'floating':
401
+ return root(mode, theme, floatingPill(facts))
402
+ }
403
+ }