@certrev/cert-block 0.1.3 → 0.3.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 +91 -0
  14. package/dist/components/render-cert-block.d.ts.map +1 -0
  15. package/dist/components/render-cert-block.js +306 -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 +282 -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 +424 -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,424 @@
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 mono
23
+ * label font (`--font-mono`) are LOCKED literals (guide "the lock is legal, not
24
+ * aesthetic"). Names, the memo quote, disclosures, links, and the "CertREV" wordmark
25
+ * ride the body font; the serif/grotesk slots remain reachable only as a brand's
26
+ * themeable body-font choice. A brand themes accent / font / corners / surface, but
27
+ * the navy header bar + trust ink stay fixed (accent theming of the bar is Phase B).
28
+ *
29
+ * COMPLIANCE (LOCKED, non-negotiable): the three material disclosures always render
30
+ * on a CLAIM face (banner + sidebar) — the compensation cue, the byte-exact FTC
31
+ * scope line, and the dated credential verification, which stays GROUPED with the
32
+ * credential (`<name>, <credential>` → "Credential verified by CertREV" → "on
33
+ * <date>"). The `floating` pill makes no claim (it only labels + links to the
34
+ * modal), so it carries no disclosures — matching the locked reference.
35
+ *
36
+ * SECURITY: this is storefront HTML built by hand (no JSX auto-escaping), so EVERY
37
+ * interpolated value is escaped — `escapeHtml` for content, `escapeAttribute` for
38
+ * attribute values, `safeHttpUrl` for hrefs. Theme tokens are re-validated
39
+ * (`resolveBlockTheme`) and only ever emitted as strict hex / length / enum-stack.
40
+ */
41
+
42
+ import { escapeAttribute, escapeHtml, safeHttpUrl } from './escape.js'
43
+ import { formatDate } from './format.js'
44
+ import {
45
+ CERT_SCOPE_LINE,
46
+ CERT_STRINGS_VERSION,
47
+ COMPENSATED_EXPERT_CUE,
48
+ CREDENTIAL_VERIFIED_ATTRIBUTION,
49
+ RENDER_BLOCK_FONT_STACKS,
50
+ type ResolvedBlockTheme,
51
+ resolveBlockTheme,
52
+ } from './render-def.js'
53
+
54
+ /** The three LOCKED placement faces. */
55
+ export type CertBlockMode = 'banner' | 'sidebar' | 'floating'
56
+
57
+ /**
58
+ * The cert facts + LOCKED display strings a face renders. Facts are escaped; the
59
+ * three locked strings (`compensationCue`, `scopeLine`, `credentialLine`) are
60
+ * byte-verbatim per the CertREV Display Guide and default to the mirrored constants
61
+ * when a caller omits them — so the compliance baseline is exact even if unsupplied.
62
+ */
63
+ export interface CertBlockFacts {
64
+ /** The content author (the "Written by" column). */
65
+ readonly authorName: string
66
+ /** The author's subtitle (e.g. "Editorial"). Omitted when absent. */
67
+ readonly authorTitle?: string
68
+ /** The reviewing expert's display name, WITHOUT the credential (e.g. "Jane Doe"). */
69
+ readonly reviewerName: string
70
+ /** The reviewer's credential abbreviation (e.g. "MD"). Empty → name-only. */
71
+ readonly credential: string
72
+ /** ISO instant CertREV verified the CREDENTIAL (the dated verification date). */
73
+ readonly credentialVerifiedAt: string
74
+ /** ISO instant the article was certified (the "Certified <date>" footer). */
75
+ readonly certifiedAt: string
76
+ /** The expert's memo (rendered as the serif blockquote). */
77
+ readonly memo: string
78
+ /** The expert's bio (banner accordion / sidebar paragraph). Omitted when absent. */
79
+ readonly bio?: string
80
+ /** The expert profile URL (the "Profile" link + modal `expert` hook). */
81
+ readonly profileUrl: string
82
+ /** The certificate URL (the "Certificate" link + modal hook). */
83
+ readonly certificateUrl: string
84
+ /** LOCKED — the compensation cue. Default: `COMPENSATED_EXPERT_CUE`. */
85
+ readonly compensationCue?: string
86
+ /** LOCKED — the byte-exact FTC scope line. Default: `CERT_SCOPE_LINE`. */
87
+ readonly scopeLine?: string
88
+ /** LOCKED — the verification attribution. Default: `CREDENTIAL_VERIFIED_ATTRIBUTION`. */
89
+ readonly credentialLine?: string
90
+ }
91
+
92
+ /** The full input to `renderCertBlock`. */
93
+ export interface RenderCertBlockInput {
94
+ readonly mode: CertBlockMode
95
+ /** RESOLVED theme tokens (the portal bakes these). Absent/invalid tokens fall back to navy. */
96
+ readonly theme?: Partial<ResolvedBlockTheme>
97
+ readonly facts: CertBlockFacts
98
+ }
99
+
100
+ // ─────────────────────────────────────────────────────────────────────────────
101
+ // The CertREV logo — the fixed 4-chevron mark (owner decision 2026-07-17: the logo
102
+ // IS permitted on the article face). Path data is byte-exact to the locked reference.
103
+ // ─────────────────────────────────────────────────────────────────────────────
104
+
105
+ const LOGO_PATHS = [
106
+ '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',
107
+ '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',
108
+ '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',
109
+ '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',
110
+ ]
111
+
112
+ /**
113
+ * The 4-chevron CertREV mark at `size`px, always filled `currentColor` so it tracks
114
+ * its container's ink — white on the hardcoded-navy header, and the muted navy-55 of
115
+ * the memo footer's "Verified by CertREV" lockup (it sits on the white surface).
116
+ */
117
+ function logo(size: number): 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="currentColor" 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
+ /**
135
+ * Strip a leading honorific ("Dr. Jane Smith" → "Jane Smith") so the initials + first-name token don't
136
+ * capture it — matches the Liquid twin, which removes a matched honorific so "Dr. Marcus Ellison" yields
137
+ * the "ME" avatar + "Marcus's profile", never "DM" / "Dr.'s profile".
138
+ */
139
+ function stripHonorific(name: string): string {
140
+ return (
141
+ name
142
+ .trim()
143
+ .replace(/^(dr|prof|professor|mr|mrs|ms|mx|rev|sir|dame)\.?\s+/i, '')
144
+ .trim() || name.trim()
145
+ )
146
+ }
147
+
148
+ /** "Jane Doe" → "JD"; "Dr. Marcus Ellison" → "ME" (first letter of the first two words, honorific stripped). */
149
+ function initials(name: string): string {
150
+ const parts = stripHonorific(name).split(/\s+/).filter(Boolean)
151
+ const chars = parts
152
+ .slice(0, 2)
153
+ .map((p) => p[0] ?? '')
154
+ .join('')
155
+ return (chars || '?').toUpperCase()
156
+ }
157
+
158
+ /** The first name token (for "View Jane's profile") — honorific stripped. */
159
+ function firstNameOf(name: string): string {
160
+ const clean = stripHonorific(name)
161
+ return clean.split(/\s+/)[0] || clean
162
+ }
163
+
164
+ /**
165
+ * A LOCKED display string: use the caller's value ONLY when it is a non-blank
166
+ * string, else the byte-verbatim constant. The three compliance strings
167
+ * (compensation cue, scope line, credential attribution) are undroppable — an
168
+ * omitted OR empty/whitespace override must never blank the disclosure.
169
+ */
170
+ function locked(supplied: string | undefined, constant: string): string {
171
+ return supplied?.trim() ? supplied : constant
172
+ }
173
+
174
+ /** A round avatar tile with the given initials. */
175
+ function avatar(text: string, size: number, fontSize: number): string {
176
+ return (
177
+ `<div style="width:${size}px;height:${size}px;border-radius:50%;background:var(--stone);color:var(--navy-55);` +
178
+ `display:flex;align-items:center;justify-content:center;font-family:var(--font-mono);font-weight:700;font-size:${fontSize}px;flex-shrink:0;">` +
179
+ `${escapeHtml(text)}</div>`
180
+ )
181
+ }
182
+
183
+ /** "Jane Doe, MD" (or just the name when there is no credential) — escaped. */
184
+ function nameWithCredential(name: string, credential: string): string {
185
+ const n = escapeHtml(name)
186
+ return credential ? `${n}, ${escapeHtml(credential)}` : n
187
+ }
188
+
189
+ /** The reviewer profile link (carries the modal `expert` hook + a real href fallback). */
190
+ function profileLink(url: string, inner: string, style: string): string {
191
+ const href = safeHttpUrl(url) ?? '#'
192
+ return `<a href="${escapeAttribute(href)}" data-certrev-modal-open="expert" rel="noopener" style="${style}">${inner}</a>`
193
+ }
194
+
195
+ /** The certificate link (carries the modal hook + a real href fallback). */
196
+ function certificateLink(url: string, inner: string, style: string): string {
197
+ const href = safeHttpUrl(url) ?? '#'
198
+ return `<a href="${escapeAttribute(href)}" data-certrev-modal-open rel="noopener" style="${style}">${inner}</a>`
199
+ }
200
+
201
+ /**
202
+ * The GROUPED dated credential verification (LOCKED requirement): the credentialed
203
+ * name, then the attribution line, then "on <date>" — one block, order preserved.
204
+ * `withAccordion` adds the bio caret + hidden bio (banner) as progressive enhancement.
205
+ */
206
+ function verificationBlock(facts: CertBlockFacts, withAccordion: boolean): string {
207
+ const credentialLine = locked(facts.credentialLine, CREDENTIAL_VERIFIED_ATTRIBUTION)
208
+ const verified = formatDate(facts.credentialVerifiedAt) ?? escapeHtml(facts.credentialVerifiedAt)
209
+ const caret = withAccordion && facts.bio ? CARET : ''
210
+ const nameSize = withAccordion ? '15px' : '16px'
211
+ const muteSize = withAccordion ? '13px' : '12.5px'
212
+ const bio =
213
+ withAccordion && facts.bio
214
+ ? `<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>`
215
+ : ''
216
+ return (
217
+ `<div>` +
218
+ `<div style="font-size:${nameSize};font-weight:700;">${nameWithCredential(facts.reviewerName, facts.credential)}${caret}</div>` +
219
+ `<div style="font-size:${muteSize};color:var(--navy-55);line-height:1.45;margin-top:3px;">${escapeHtml(credentialLine)}</div>` +
220
+ `<div style="font-size:${muteSize};color:var(--navy-55);line-height:1.35;">on ${verified}</div>` +
221
+ bio +
222
+ `</div>`
223
+ )
224
+ }
225
+
226
+ // ─────────────────────────────────────────────────────────────────────────────
227
+ // The root wrapper — sets the guide's CSS vars so the block is self-contained
228
+ // ─────────────────────────────────────────────────────────────────────────────
229
+
230
+ function rootStyle(theme: ResolvedBlockTheme): string {
231
+ // Data-bearing theme tokens are already strict-validated (hex / length / literal
232
+ // ink); the font is a controlled enum → stack literal. The navy tints + fixed
233
+ // label/serif/wordmark fonts are LOCKED literals.
234
+ return [
235
+ `--ba:${theme.accentColor}`,
236
+ `--ba-fg:${theme.accentFg}`,
237
+ `--br:${theme.cornerRadius}`,
238
+ `--bf:${RENDER_BLOCK_FONT_STACKS[theme.fontSlot]}`,
239
+ `--surface:${theme.surface}`,
240
+ `--navy:#0a1b3f`,
241
+ `--navy-06:rgba(10,27,63,.06)`,
242
+ `--navy-10:rgba(10,27,63,.10)`,
243
+ `--navy-35:rgba(10,27,63,.35)`,
244
+ `--navy-55:rgba(10,27,63,.55)`,
245
+ `--navy-75:rgba(10,27,63,.75)`,
246
+ `--stone:#ece9e1`,
247
+ `--font-mono:${RENDER_BLOCK_FONT_STACKS.mono}`,
248
+ `font-family:var(--bf)`,
249
+ `color:var(--navy)`,
250
+ ].join(';')
251
+ }
252
+
253
+ function root(mode: CertBlockMode, theme: ResolvedBlockTheme, inner: string): string {
254
+ return (
255
+ `<div class="certrev-cert certrev-cert--${mode}" data-certrev-mode="${mode}" ` +
256
+ `data-certrev-strings-version="${CERT_STRINGS_VERSION}" style="${rootStyle(theme)}">${inner}</div>`
257
+ )
258
+ }
259
+
260
+ // ─────────────────────────────────────────────────────────────────────────────
261
+ // Mode 1 — BANNER: a certification header card + a separate expert-memo card
262
+ // ─────────────────────────────────────────────────────────────────────────────
263
+
264
+ function bannerHeaderCard(facts: CertBlockFacts): string {
265
+ const compensationCue = locked(facts.compensationCue, COMPENSATED_EXPERT_CUE)
266
+ const certified = formatDate(facts.certifiedAt) ?? escapeHtml(facts.certifiedAt)
267
+ const subtitle = facts.authorTitle
268
+ ? `<div style="font-size:13px;color:var(--navy-55);">${escapeHtml(facts.authorTitle)}</div>`
269
+ : ''
270
+ // A subtle right-aligned arrow opens the certificate: the whole navy bar is the mouse
271
+ // target (data-certrev-modal-open); this focusable <a> is the keyboard / SR / no-JS control.
272
+ const certHref = safeHttpUrl(facts.certificateUrl) ?? '#'
273
+ const headerArrow =
274
+ `<a href="${escapeAttribute(certHref)}" data-certrev-modal-open rel="noopener" aria-label="View certificate" ` +
275
+ `style="display:inline-flex;align-items:center;flex-shrink:0;color:inherit;opacity:.72;text-decoration:none;">` +
276
+ `<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14"></path><path d="m12 5 7 7-7 7"></path></svg></a>`
277
+ return (
278
+ `<div style="border:1px solid var(--navy-10);border-radius:calc(var(--br,14px) * 0.7);overflow:hidden;font-family:var(--bf);">` +
279
+ // navy header — the trust bar stays fixed navy (accent theming is Phase B); mark + eyebrow + cert arrow
280
+ `<div data-certrev-modal-open style="display:flex;align-items:center;justify-content:space-between;gap:16px;padding:15px 22px;background:#0a1b3f;color:#fff;cursor:pointer;">` +
281
+ `<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)} Expert reviewed</span>` +
282
+ headerArrow +
283
+ `</div>` +
284
+ `<div style="padding:22px 24px 18px;">` +
285
+ `<div style="display:grid;grid-template-columns:1fr 1fr;gap:22px;">` +
286
+ // written by — muted label (navy-35)
287
+ `<div>` +
288
+ `<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>` +
289
+ `<div style="display:flex;align-items:center;gap:12px;">${avatar(initials(facts.authorName), 42, 12)}` +
290
+ `<div><div style="font-size:15px;font-weight:700;">${escapeHtml(facts.authorName)}</div>${subtitle}</div></div>` +
291
+ `</div>` +
292
+ // reviewed by — emphasized label (navy, 700): the certification's authority voice
293
+ `<div>` +
294
+ `<div style="font-family:var(--font-mono);font-size:10px;font-weight:700;letter-spacing:.14em;text-transform:uppercase;color:var(--navy);margin-bottom:11px;">Reviewed by</div>` +
295
+ `<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;">` +
296
+ `${avatar(initials(facts.reviewerName), 42, 12)}${verificationBlock(facts, true)}` +
297
+ `</div>` +
298
+ `</div>` +
299
+ `</div>` +
300
+ // hairline + mono footer — "Certified <date>" + "Compensated expert", UNIFORM navy-55
301
+ `<div style="height:1px;background:var(--navy-10);margin:16px 0 14px;"></div>` +
302
+ `<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;font-family:var(--font-mono);font-size:10.5px;font-weight:500;letter-spacing:.14em;text-transform:uppercase;color:var(--navy-55);">` +
303
+ `<span>Certified <span style="margin-left:8px;">${certified}</span></span>` +
304
+ `<span>${escapeHtml(compensationCue)}</span>` +
305
+ `</div>` +
306
+ `</div>` +
307
+ `</div>`
308
+ )
309
+ }
310
+
311
+ function bannerMemoCard(facts: CertBlockFacts): string {
312
+ const scopeLine = locked(facts.scopeLine, CERT_SCOPE_LINE)
313
+ const credTail = facts.credential
314
+ ? `<span style="color:var(--navy-55);">, ${escapeHtml(facts.credential)}</span>`
315
+ : ''
316
+ const first = firstNameOf(facts.reviewerName)
317
+ // The memo footer's profile/certificate actions read as the JetBrains-mono uppercase stamp
318
+ // that matches the badge footer lockup (Owen 2026-07-17) — navy, so they still read as actions.
319
+ const stampLink =
320
+ 'font-family:var(--font-mono);font-size:10.5px;font-weight:500;letter-spacing:.14em;text-transform:uppercase;color:var(--navy);text-decoration:none;cursor:pointer;'
321
+ return (
322
+ `<div style="border:1px solid var(--navy-10);border-radius:calc(var(--br,14px) * 0.7);padding:24px;font-family:var(--bf);">` +
323
+ `<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>` +
324
+ `<div style="display:flex;gap:15px;">${avatar(initials(facts.reviewerName), 46, 13)}` +
325
+ `<div style="flex:1;min-width:0;">` +
326
+ `<div style="font-size:16px;line-height:1.2;"><span style="font-weight:700;">${escapeHtml(facts.reviewerName)}</span>${credTail}</div>` +
327
+ // Readable, UPRIGHT DM Sans quote (Owen 2026-07-17: the display-serif italic hurt legibility);
328
+ // pre-line preserves the reviewer's paragraph breaks. No left accent bar (matches the guide).
329
+ `<p style="margin:11px 0 14px;font-size:15.5px;line-height:1.6;color:var(--navy-75);white-space:pre-line;">${escapeHtml(facts.memo)}</p>` +
330
+ `</div>` +
331
+ `</div>` +
332
+ // footer hairline + one trust row: "Verified by CertREV" left, the two actions right
333
+ `<div style="height:1px;background:var(--navy-10);margin:18px 0 14px;"></div>` +
334
+ `<div style="display:flex;align-items:center;justify-content:space-between;gap:16px 22px;flex-wrap:wrap;">` +
335
+ `<div style="display:inline-flex;align-items:center;gap:9px;font-family:var(--font-mono);font-size:10.5px;font-weight:500;letter-spacing:.14em;text-transform:uppercase;color:var(--navy-55);">${logo(15)}<span>Verified by <span style="font-weight:700;color:var(--navy);">CertREV</span></span></div>` +
336
+ `<div style="display:flex;flex-wrap:wrap;gap:22px;">` +
337
+ profileLink(facts.profileUrl, `View ${escapeHtml(first)}'s profile`, stampLink) +
338
+ certificateLink(facts.certificateUrl, 'View certificate', stampLink) +
339
+ `</div>` +
340
+ `</div>` +
341
+ `<div style="font-size:13px;color:var(--navy-55);margin-top:8px;line-height:1.5;">${escapeHtml(scopeLine)}</div>` +
342
+ `</div>`
343
+ )
344
+ }
345
+
346
+ // ─────────────────────────────────────────────────────────────────────────────
347
+ // Mode 2 — SIDEBAR: a reviewer card pinned beside the article body
348
+ // ─────────────────────────────────────────────────────────────────────────────
349
+
350
+ function sidebarCard(facts: CertBlockFacts): string {
351
+ const compensationCue = locked(facts.compensationCue, COMPENSATED_EXPERT_CUE)
352
+ const scopeLine = locked(facts.scopeLine, CERT_SCOPE_LINE)
353
+ const bioPara = facts.bio
354
+ ? `<p style="font-size:14px;line-height:1.55;color:var(--navy-75);margin:16px 0 0;">${escapeHtml(facts.bio)}</p>`
355
+ : ''
356
+ return (
357
+ `<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);">` +
358
+ `<div style="display:flex;align-items:center;justify-content:center;gap:10px;padding:12px;background:#0a1b3f;color:#fff;font-family:var(--font-mono);font-size:12px;font-weight:700;letter-spacing:.16em;text-transform:uppercase;">${logo(18)} Expert reviewed</div>` +
359
+ `<div style="padding:20px;">` +
360
+ `<div style="display:flex;align-items:flex-start;gap:13px;">${avatar(initials(facts.reviewerName), 46, 13)}${verificationBlock(facts, false)}</div>` +
361
+ bioPara +
362
+ `<blockquote style="margin:16px 0 0;padding-left:16px;border-left:3px solid var(--ba,#0a1b3f);font-size:15px;line-height:1.5;color:var(--navy-75);white-space:pre-line;">${escapeHtml(facts.memo)}</blockquote>` +
363
+ `<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>` +
364
+ `</div>` +
365
+ `<div style="display:grid;grid-template-columns:1fr 1fr;border-top:1px solid var(--navy-10);">` +
366
+ profileLink(
367
+ facts.profileUrl,
368
+ 'View Profile',
369
+ '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;',
370
+ ) +
371
+ certificateLink(
372
+ facts.certificateUrl,
373
+ 'View Certificate',
374
+ 'text-align:center;padding:14px;font-size:14px;font-weight:700;color:var(--navy);text-decoration:none;cursor:pointer;',
375
+ ) +
376
+ `</div>` +
377
+ `</div>`
378
+ )
379
+ }
380
+
381
+ // ─────────────────────────────────────────────────────────────────────────────
382
+ // Mode 3 — FLOATING: a compact pinned pill (a modal pointer, NOT a claim face)
383
+ // ─────────────────────────────────────────────────────────────────────────────
384
+
385
+ function floatingPill(facts: CertBlockFacts): string {
386
+ return (
387
+ `<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);">` +
388
+ `<span style="font-family:var(--font-mono);font-size:11px;font-weight:700;letter-spacing:.14em;text-transform:uppercase;opacity:.75;">Expert reviewed:</span>` +
389
+ profileLink(
390
+ facts.profileUrl,
391
+ 'Profile',
392
+ 'font-size:14px;font-weight:700;text-decoration:underline;text-underline-offset:3px;color:inherit;cursor:pointer;',
393
+ ) +
394
+ `<span style="opacity:.5;">·</span>` +
395
+ certificateLink(
396
+ facts.certificateUrl,
397
+ 'Certificate',
398
+ 'font-size:14px;font-weight:700;text-decoration:underline;text-underline-offset:3px;color:inherit;cursor:pointer;',
399
+ ) +
400
+ `</div>`
401
+ )
402
+ }
403
+
404
+ // ─────────────────────────────────────────────────────────────────────────────
405
+ // Entry point
406
+ // ─────────────────────────────────────────────────────────────────────────────
407
+
408
+ /**
409
+ * Render the LOCKED CertREV cert design as an SSR-safe HTML string for one of the
410
+ * three placement modes. Returns a self-contained `<div class="certrev-cert …">`
411
+ * that needs no JS to render (the modal + bio accordion progressively enhance).
412
+ */
413
+ export function renderCertBlock(input: RenderCertBlockInput): string {
414
+ const theme = resolveBlockTheme(input.theme)
415
+ const { facts, mode } = input
416
+ switch (mode) {
417
+ case 'banner':
418
+ return root(mode, theme, bannerHeaderCard(facts) + bannerMemoCard(facts))
419
+ case 'sidebar':
420
+ return root(mode, theme, sidebarCard(facts))
421
+ case 'floating':
422
+ return root(mode, theme, floatingPill(facts))
423
+ }
424
+ }