@certrev/cert-block 0.3.0 → 0.5.1

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 (129) hide show
  1. package/README.md +48 -1
  2. package/dist/builder/cert-chrome-data.d.ts +129 -0
  3. package/dist/builder/cert-chrome-data.d.ts.map +1 -0
  4. package/dist/builder/cert-chrome-data.js +59 -0
  5. package/dist/builder/cert-chrome-data.js.map +1 -0
  6. package/dist/builder/cert-review-card.d.ts +68 -0
  7. package/dist/builder/cert-review-card.d.ts.map +1 -0
  8. package/dist/builder/cert-review-card.js +129 -0
  9. package/dist/builder/cert-review-card.js.map +1 -0
  10. package/dist/builder/index.d.ts +2 -0
  11. package/dist/builder/index.d.ts.map +1 -1
  12. package/dist/builder/index.js +3 -0
  13. package/dist/builder/index.js.map +1 -1
  14. package/dist/components/preview-block.d.ts +43 -0
  15. package/dist/components/preview-block.d.ts.map +1 -0
  16. package/dist/components/preview-block.js +53 -0
  17. package/dist/components/preview-block.js.map +1 -0
  18. package/dist/components/render-cert-block.d.ts +50 -7
  19. package/dist/components/render-cert-block.d.ts.map +1 -1
  20. package/dist/components/render-cert-block.js +304 -80
  21. package/dist/components/render-cert-block.js.map +1 -1
  22. package/dist/components/render-def.d.ts +27 -0
  23. package/dist/components/render-def.d.ts.map +1 -1
  24. package/dist/components/render-def.js +38 -0
  25. package/dist/components/render-def.js.map +1 -1
  26. package/dist/index.d.ts +3 -2
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +3 -1
  29. package/dist/index.js.map +1 -1
  30. package/dist/modal/avatar-resize.d.ts +60 -0
  31. package/dist/modal/avatar-resize.d.ts.map +1 -0
  32. package/dist/modal/avatar-resize.js +91 -0
  33. package/dist/modal/avatar-resize.js.map +1 -0
  34. package/dist/modal/cert-modal-view.d.ts +74 -0
  35. package/dist/modal/cert-modal-view.d.ts.map +1 -0
  36. package/dist/modal/cert-modal-view.js +160 -0
  37. package/dist/modal/cert-modal-view.js.map +1 -0
  38. package/dist/modal/certrev-cert-modal.d.ts +72 -0
  39. package/dist/modal/certrev-cert-modal.d.ts.map +1 -0
  40. package/dist/modal/certrev-cert-modal.js +270 -0
  41. package/dist/modal/certrev-cert-modal.js.map +1 -0
  42. package/dist/modal/certrev-cert-modal.styles.d.ts +35 -0
  43. package/dist/modal/certrev-cert-modal.styles.d.ts.map +1 -0
  44. package/dist/modal/certrev-cert-modal.styles.js +488 -0
  45. package/dist/modal/certrev-cert-modal.styles.js.map +1 -0
  46. package/dist/modal/certrev-cert.entry.d.ts +19 -0
  47. package/dist/modal/certrev-cert.entry.d.ts.map +1 -0
  48. package/dist/modal/certrev-cert.entry.js +39 -0
  49. package/dist/modal/certrev-cert.entry.js.map +1 -0
  50. package/dist/modal/certrev-cert.js +557 -0
  51. package/dist/modal/display-strings.d.ts +22 -0
  52. package/dist/modal/display-strings.d.ts.map +1 -0
  53. package/dist/modal/display-strings.js +35 -0
  54. package/dist/modal/display-strings.js.map +1 -0
  55. package/dist/modal/escape.d.ts +13 -0
  56. package/dist/modal/escape.d.ts.map +1 -0
  57. package/dist/modal/escape.js +20 -0
  58. package/dist/modal/escape.js.map +1 -0
  59. package/dist/modal/font-face.d.ts +4 -0
  60. package/dist/modal/font-face.d.ts.map +1 -0
  61. package/dist/modal/font-face.js +137 -0
  62. package/dist/modal/font-face.js.map +1 -0
  63. package/dist/modal/ftc-disclosure.d.ts +12 -0
  64. package/dist/modal/ftc-disclosure.d.ts.map +1 -0
  65. package/dist/modal/ftc-disclosure.js +12 -0
  66. package/dist/modal/ftc-disclosure.js.map +1 -0
  67. package/dist/modal/ftc-guard.d.ts +50 -0
  68. package/dist/modal/ftc-guard.d.ts.map +1 -0
  69. package/dist/modal/ftc-guard.js +117 -0
  70. package/dist/modal/ftc-guard.js.map +1 -0
  71. package/dist/modal/index.d.ts +20 -0
  72. package/dist/modal/index.d.ts.map +1 -0
  73. package/dist/modal/index.js +25 -0
  74. package/dist/modal/index.js.map +1 -0
  75. package/dist/modal/interactions.d.ts +53 -0
  76. package/dist/modal/interactions.d.ts.map +1 -0
  77. package/dist/modal/interactions.js +147 -0
  78. package/dist/modal/interactions.js.map +1 -0
  79. package/dist/modal/logo-paths.d.ts +15 -0
  80. package/dist/modal/logo-paths.d.ts.map +1 -0
  81. package/dist/modal/logo-paths.js +24 -0
  82. package/dist/modal/logo-paths.js.map +1 -0
  83. package/dist/modal/placement.d.ts +95 -0
  84. package/dist/modal/placement.d.ts.map +1 -0
  85. package/dist/modal/placement.js +302 -0
  86. package/dist/modal/placement.js.map +1 -0
  87. package/dist/modal/revalidate.d.ts +48 -0
  88. package/dist/modal/revalidate.d.ts.map +1 -0
  89. package/dist/modal/revalidate.js +65 -0
  90. package/dist/modal/revalidate.js.map +1 -0
  91. package/dist/modal/verify-client.d.ts +24 -0
  92. package/dist/modal/verify-client.d.ts.map +1 -0
  93. package/dist/modal/verify-client.js +38 -0
  94. package/dist/modal/verify-client.js.map +1 -0
  95. package/package.json +14 -4
  96. package/src/__tests__/__snapshots__/render-cert-block.test.ts.snap +10 -6
  97. package/src/__tests__/preview-block.test.ts +84 -0
  98. package/src/__tests__/render-cert-block.test.ts +380 -5
  99. package/src/builder/__tests__/builder.test.tsx +71 -0
  100. package/src/builder/cert-chrome-data.ts +159 -0
  101. package/src/builder/cert-review-card.tsx +177 -0
  102. package/src/builder/index.tsx +18 -0
  103. package/src/components/preview-block.ts +74 -0
  104. package/src/components/render-cert-block.ts +387 -102
  105. package/src/components/render-def.ts +55 -0
  106. package/src/index.ts +9 -0
  107. package/src/modal/avatar-resize.ts +99 -0
  108. package/src/modal/cert-modal-view.test.ts +175 -0
  109. package/src/modal/cert-modal-view.ts +210 -0
  110. package/src/modal/certrev-cert-modal.styles.ts +494 -0
  111. package/src/modal/certrev-cert-modal.test.ts +125 -0
  112. package/src/modal/certrev-cert-modal.ts +273 -0
  113. package/src/modal/certrev-cert.entry.ts +44 -0
  114. package/src/modal/display-strings.ts +38 -0
  115. package/src/modal/escape.ts +20 -0
  116. package/src/modal/font-face.ts +138 -0
  117. package/src/modal/ftc-disclosure.ts +13 -0
  118. package/src/modal/ftc-guard.test.ts +153 -0
  119. package/src/modal/ftc-guard.ts +119 -0
  120. package/src/modal/index.ts +34 -0
  121. package/src/modal/interactions.test.ts +250 -0
  122. package/src/modal/interactions.ts +166 -0
  123. package/src/modal/logo-paths.ts +23 -0
  124. package/src/modal/placement.test.ts +398 -0
  125. package/src/modal/placement.ts +331 -0
  126. package/src/modal/revalidate.test.ts +111 -0
  127. package/src/modal/revalidate.ts +81 -0
  128. package/src/modal/verify-client.test.ts +164 -0
  129. package/src/modal/verify-client.ts +42 -0
@@ -221,6 +221,34 @@ export function isRenderBlockFontSlot(value: string): value is RenderBlockFontSl
221
221
  return (RENDER_BLOCK_FONT_SLOTS as readonly string[]).includes(value)
222
222
  }
223
223
 
224
+ /**
225
+ * Parse a 3- or 6-digit opaque hex into 8-bit RGB channels (null on a malformed value). The
226
+ * 3-digit form expands each nibble (`#abc` → `#aabbcc`), matching CSS.
227
+ */
228
+ function parseHexColor(hex: string): { r: number; g: number; b: number } | null {
229
+ const m = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.exec(hex)
230
+ if (!m) return null
231
+ let h = m[1]
232
+ if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2]
233
+ const n = Number.parseInt(h, 16)
234
+ return { r: (n >> 16) & 0xff, g: (n >> 8) & 0xff, b: n & 0xff }
235
+ }
236
+
237
+ /**
238
+ * AUTO-CONTRAST PICKER — the ink painted over an opaque fill. BYTE-FAITHFUL to the portal's
239
+ * `accentFg` (src/lib/design-guide/tokens.ts): Rec.601 perceptual luma with a strict `> 0.62`
240
+ * cutoff, returning EXACTLY `#141414` (near-black) or `#ffffff`; an unparseable value falls back
241
+ * to the dark ink (safe default). Used at the render edge to DERIVE `barInkFg` from a themed
242
+ * `barInk` (v2 Phase 1), so the bar foreground always clears its own fill with no separate stored
243
+ * token — the exact model the portal uses for the accent fill.
244
+ */
245
+ export function accentFg(hex: string): '#141414' | '#ffffff' {
246
+ const rgb = parseHexColor(hex)
247
+ if (!rgb) return '#141414'
248
+ const luma = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255
249
+ return luma > 0.62 ? '#141414' : '#ffffff'
250
+ }
251
+
224
252
  /**
225
253
  * The resolved themeable-token set `renderCertBlock` paints — the render-edge mirror
226
254
  * of the portal `ResolvedRenderDef` theme half (`tokens` ⊕ the precomputed
@@ -234,6 +262,24 @@ export interface ResolvedBlockTheme {
234
262
  readonly cornerRadius: string
235
263
  readonly fontSlot: RenderBlockFontSlot
236
264
  readonly accentFg: '#141414' | '#ffffff'
265
+ /**
266
+ * V2 chrome-ink (Phase 1, e.l.f.) — the header/seal bar (and floating-pill) FILL. OPTIONAL:
267
+ * present ONLY when the def themes it, so an un-barInk'd face emits nothing extra and the bar
268
+ * keeps its locked navy/white base (byte-identical). Mirrors the portal's `ThemeTokens.barInk`.
269
+ */
270
+ readonly barInk?: string
271
+ /**
272
+ * V2 chrome-ink — the bar's auto-contrast FOREGROUND, DERIVED at the edge via `accentFg(barInk)`
273
+ * (the bar-background twin of the accent's `accentFg`). A SIBLING of the tokens, present ONLY when
274
+ * `barInk` is (absent ⇒ the locked white bar fg). Mirrors the portal's `ResolvedRenderDef.barInkFg`.
275
+ */
276
+ readonly barInkFg?: '#141414' | '#ffffff'
277
+ /**
278
+ * V2 chrome-ink — the primary FREE-content trust ink (reviewer/author names, title, credential,
279
+ * bio, initials + the derived muted scale). OPTIONAL: absent ⇒ the ink stays navy (byte-identical).
280
+ * The LOCKED legal disclosures NEVER follow it. Mirrors the portal's `ThemeTokens.inkColor`.
281
+ */
282
+ readonly inkColor?: string
237
283
  }
238
284
 
239
285
  /** The CertREV navy default theme (the un-themed face — matches the locked reference `:root`). */
@@ -253,12 +299,21 @@ export const DEFAULT_BLOCK_THEME: ResolvedBlockTheme = {
253
299
  */
254
300
  export function resolveBlockTheme(theme?: Partial<ResolvedBlockTheme>): ResolvedBlockTheme {
255
301
  const t = theme ?? {}
302
+ // v2 chrome-ink (Phase 1): re-validate the two brand-ink tokens at the edge (defense in depth over
303
+ // the portal's fail-closed bake). Each is DROPPED unless it is an opaque hex; `barInkFg` is always
304
+ // DERIVED here via `accentFg(barInk)` — never trusted from the input — so the bar foreground can
305
+ // never diverge from the fill it sits on. ABSENT ⇒ omitted, so the renderer emits nothing extra and
306
+ // the face is byte-identical to the un-themed default.
307
+ const barInk = t.barInk && isOpaqueHexColor(t.barInk) ? t.barInk : undefined
308
+ const inkColor = t.inkColor && isOpaqueHexColor(t.inkColor) ? t.inkColor : undefined
256
309
  return {
257
310
  accentColor: t.accentColor && isOpaqueHexColor(t.accentColor) ? t.accentColor : DEFAULT_BLOCK_THEME.accentColor,
258
311
  surface: t.surface && isOpaqueHexColor(t.surface) ? t.surface : DEFAULT_BLOCK_THEME.surface,
259
312
  cornerRadius: t.cornerRadius && isCornerRadius(t.cornerRadius) ? t.cornerRadius : DEFAULT_BLOCK_THEME.cornerRadius,
260
313
  fontSlot: t.fontSlot && isRenderBlockFontSlot(t.fontSlot) ? t.fontSlot : DEFAULT_BLOCK_THEME.fontSlot,
261
314
  accentFg: t.accentFg === '#141414' || t.accentFg === '#ffffff' ? t.accentFg : DEFAULT_BLOCK_THEME.accentFg,
315
+ ...(barInk ? { barInk, barInkFg: accentFg(barInk) } : {}),
316
+ ...(inkColor ? { inkColor } : {}),
262
317
  }
263
318
  }
264
319
 
package/src/index.ts CHANGED
@@ -36,15 +36,24 @@ export {
36
36
  type ResolvedDisplay,
37
37
  resolveDisplay,
38
38
  } from './components/format.js'
39
+ // ── Pre-sales PREVIEW block — static, non-delivery, hard-bounded (POR-10743) ──
40
+ export {
41
+ type PreviewBlockOptions,
42
+ previewExpiry,
43
+ renderPreviewBlock,
44
+ } from './components/preview-block.js'
39
45
  // ── Cert-render BLOCK — the LOCKED 3-mode cert design as an SSR-safe HTML string ──
40
46
  export {
47
+ type CertBlockFace,
41
48
  type CertBlockFacts,
49
+ type CertBlockLayout,
42
50
  type CertBlockMode,
43
51
  type RenderCertBlockInput,
44
52
  renderCertBlock,
45
53
  } from './components/render-cert-block.js'
46
54
  // ── Design-guide render-def support (WS6: themeable tokens + subtractive visibility) ──
47
55
  export {
56
+ accentFg,
48
57
  type BadgeVisibilityFieldId,
49
58
  CERT_SCOPE_LINE,
50
59
  CERT_STRINGS_VERSION,
@@ -0,0 +1,99 @@
1
+ // ⚠ VENDORED, BYTE-FAITHFUL relocation of portal `src/lib/cert-delivery/avatar-resize.ts`
2
+ // (POR-10721 W2). The body is byte-identical to the source the deployed `certrev-cert.js`
3
+ // was bundled from — keep it that way. Configurable asset origins for other headless
4
+ // brands are a deliberate post-relocation follow-up (byte-parity takes precedence now).
5
+ /**
6
+ * Avatar right-sizing (POR-10470) — the SINGLE source of truth for how a cert contributor/expert
7
+ * headshot URL is rewritten to a display-sized derivative. Referenced by the cert MODAL (TS,
8
+ * cert-modal-view.ts), mirrored by the Liquid snippet (certrev-avatar-url.liquid), and by the
9
+ * Cloudflare size-allowlist WAF rule (infra/terraform/cloudflare). Keep the three in lockstep — the
10
+ * `avatar-resize.test.ts` `SIZES`/param assertions are the tripwire.
11
+ *
12
+ * Architecture (Fable-reviewed): the signed cert envelope carries ONE canonical full-res photoUrl
13
+ * (also the JSON-LD Person.image — never shrunk). Display sizing is a PRESENTATION concern, applied
14
+ * here via Cloudflare Image Resizing on the same zone as assets.certrev.com:
15
+ * https://assets.certrev.com/cdn-cgi/image/width=W,height=W,fit=cover,format=auto/<path>
16
+ * Same-zone (assets.certrev.com is a custom domain on certrev.com), so no cross-origin origin
17
+ * allowlist is needed; format=auto serves webp/avif; the transform is cached + billed per-unique.
18
+ *
19
+ * ENABLEMENT GATE (`AVATAR_RESIZE_ENABLED`): OFF until Cloudflare Image Resizing is enabled on the
20
+ * certrev.com zone AND a real photo is verified to resize (POR-10470 gate). While OFF, avatars use
21
+ * the raw canonical URL — byte-identical to today's behavior — so the extension can deploy for the
22
+ * font de-block with ZERO avatar regression (a cdn-cgi/image URL 404s → initials fallback if the
23
+ * feature isn't live). Going live is a one-line flip here + in certrev-avatar-url.liquid, then the
24
+ * raw branch is removed (flag-debt: tracked as the POR-10470 avatar go-live follow-up).
25
+ */
26
+
27
+ /**
28
+ * Enablement gate — LIVE as of 2026-07-20 (POR-10656). Cloudflare Image Resizing (Transformations)
29
+ * is enabled on the certrev.com zone (`image_resizing = on`), and the size-allowlist WAF rule
30
+ * (ruleset 7c9df62e88cb4713b18f12853651af08) is verified enforcing on `/cdn-cgi/image/` paths — a real
31
+ * photo resizes (`width=84` → 200 image/jpeg) while a disallowed size (`width=999`) is 403-blocked.
32
+ * Kept as a one-line kill-switch: flip back to `false` + redeploy to instantly revert to raw URLs if
33
+ * resizing ever misbehaves (removing the raw branch entirely is the flag-debt follow-up, POR-10680).
34
+ */
35
+ export const AVATAR_RESIZE_ENABLED = true;
36
+
37
+ /**
38
+ * The closed size menu (display px @2x for retina). Must equal the widths the Cloudflare WAF rule
39
+ * allowlists — an open resizer lets anyone mint unbounded transforms (per-unique billing = a cost
40
+ * DoS). 42→84 (card), 56→112 (memo + modal cert), 64→128 (modal reviewer), 144 = headroom.
41
+ */
42
+ export const AVATAR_SIZES = [84, 112, 128, 144] as const;
43
+ export type AvatarSize = (typeof AVATAR_SIZES)[number];
44
+
45
+ /** The R2 asset hosts we own + resize. Anything else is returned untouched (host-guard). */
46
+ export const AVATAR_RESIZE_HOSTS = [
47
+ "assets.certrev.com",
48
+ "staging-assets.certrev.com",
49
+ ] as const;
50
+
51
+ /**
52
+ * The canonical transform options (order is load-bearing — the WAF allowlist matches this EXACT
53
+ * string). `onerror=redirect` (same-zone) degrades a fatal transform to the original image rather
54
+ * than a broken avatar. If this string changes, the Cloudflare allowlist + certrev-avatar-url.liquid
55
+ * + the tripwire test must change in lockstep.
56
+ */
57
+ export function avatarTransformOptions(size: AvatarSize): string {
58
+ return `width=${size},height=${size},fit=cover,format=auto,onerror=redirect`;
59
+ }
60
+
61
+ /**
62
+ * Pure wrap (ignores the enablement gate) — rewrite a full-res avatar URL to a display-sized
63
+ * cdn-cgi/image derivative when the host is one we own. Everything else (foreign host, non-https,
64
+ * malformed, already-a-cdn-cgi URL) returns the input unchanged, so this can never break a URL or
65
+ * double-wrap. Kept separate from the gate so the wrapping logic is unit-testable before go-live.
66
+ */
67
+ export function buildResizedAvatarUrl(
68
+ url: string | null | undefined,
69
+ size: AvatarSize,
70
+ ): string {
71
+ const raw = (url ?? "").trim();
72
+ if (!/^https:\/\//i.test(raw)) return raw;
73
+ let parsed: URL;
74
+ try {
75
+ parsed = new URL(raw);
76
+ } catch {
77
+ return raw;
78
+ }
79
+ if (!(AVATAR_RESIZE_HOSTS as readonly string[]).includes(parsed.hostname))
80
+ return raw;
81
+ if (parsed.pathname.startsWith("/cdn-cgi/image/")) return raw; // never double-wrap
82
+ // Drop any query string: the transform request must carry an EMPTY query so the WAF allowlist's
83
+ // `query eq ""` pin holds (a photoUrl query would otherwise both bust the cache and trip the rule).
84
+ const path = parsed.pathname.replace(/^\//, "");
85
+ return `${parsed.origin}/cdn-cgi/image/${avatarTransformOptions(size)}/${path}`;
86
+ }
87
+
88
+ /**
89
+ * The enablement-gated entry point used by the render surfaces. Until Cloudflare Image Resizing is
90
+ * verified live (`AVATAR_RESIZE_ENABLED`), returns the raw canonical URL — so nothing 404s on a host
91
+ * whose resizing isn't enabled. After go-live it delegates to the (already-tested) pure builder.
92
+ */
93
+ export function resizeAvatarUrl(
94
+ url: string | null | undefined,
95
+ size: AvatarSize,
96
+ ): string {
97
+ if (!AVATAR_RESIZE_ENABLED) return (url ?? "").trim();
98
+ return buildResizedAvatarUrl(url, size);
99
+ }
@@ -0,0 +1,175 @@
1
+ /** Unit tests for the pure cert-modal view (POR-10102): envelope → dialog HTML. */
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import {
5
+ type CertModalContent,
6
+ deriveInitials,
7
+ renderCertDialogInner,
8
+ renderExpertDialogInner,
9
+ safeHref,
10
+ signatureName,
11
+ stripProtocol,
12
+ } from "./cert-modal-view.js";
13
+ import { CERT_SCOPE_LINE, COMPENSATED_EXPERT_CUE } from "./display-strings.js";
14
+
15
+ const FULL: CertModalContent = {
16
+ expert: {
17
+ displayName: "Dr. Erik Schraga, MD",
18
+ credentials: [
19
+ { cardLabel: "Board-Certified" },
20
+ { cardLabel: "Emergency Medicine" },
21
+ ],
22
+ profileUrl: "https://certrev.com/expert/abc",
23
+ photoUrl: null,
24
+ bio: "Short bio.",
25
+ background: "Reviews health content for accuracy and responsible framing.",
26
+ },
27
+ certifiedAt: "2026-04-14T00:00:00.000Z",
28
+ verifyUrl: "https://certrev.com/verify/abc123",
29
+ articleTitle: "Spring Cold or Spring Allergies?",
30
+ displayCertId: "CR-2026-0512",
31
+ display: { showExpertPhoto: false, showBio: true },
32
+ };
33
+
34
+ describe("name helpers", () => {
35
+ it("deriveInitials strips honorific AND post-nominal (ES, not EM)", () => {
36
+ expect(deriveInitials("Dr. Erik Schraga, MD")).toBe("ES");
37
+ expect(deriveInitials("Jane Doe")).toBe("JD");
38
+ expect(deriveInitials("Prof. Ada Lovelace, PhD")).toBe("AL");
39
+ expect(deriveInitials("Madonna")).toBe("MA");
40
+ expect(deriveInitials("")).toBe("");
41
+ });
42
+ it("signatureName is the bare given+family name", () => {
43
+ expect(signatureName("Dr. Erik Schraga, MD")).toBe("Erik Schraga");
44
+ expect(signatureName("Jane Doe")).toBe("Jane Doe");
45
+ });
46
+ it("stripProtocol / safeHref", () => {
47
+ expect(stripProtocol("https://certrev.com/verify/x/")).toBe(
48
+ "certrev.com/verify/x",
49
+ );
50
+ expect(safeHref("https://certrev.com/x")).toBe("https://certrev.com/x");
51
+ expect(safeHref("javascript:alert(1)")).toBe("#");
52
+ expect(safeHref("/relative")).toBe("#");
53
+ expect(safeHref(null)).toBe("#");
54
+ });
55
+ });
56
+
57
+ describe("renderCertDialogInner (3a)", () => {
58
+ it("renders the header, name, creds, article title, cert id, signature, stamp, CTA", () => {
59
+ const html = renderCertDialogInner(FULL) ?? "";
60
+ expect(html).toContain("Certificate of Expert Review");
61
+ expect(html).toContain("Dr. Erik Schraga, MD");
62
+ expect(html).toContain("Board-Certified · Emergency Medicine");
63
+ expect(html).toContain("Spring Cold or Spring Allergies?");
64
+ expect(html).toContain("CR-2026-0512");
65
+ expect(html).toContain(">Erik Schraga<"); // Allura signature = core name
66
+ expect(html).toContain("certrev.com/verify/abc123"); // footer url, protocol stripped
67
+ expect(html).toContain("CERTREV VERIFIED · CERTIFIED"); // the stamp textPath
68
+ expect(html).toContain("View full certificate");
69
+ expect(html).toContain('href="https://certrev.com/verify/abc123"');
70
+ });
71
+ it("fails closed with no reviewer name", () => {
72
+ expect(
73
+ renderCertDialogInner({
74
+ ...FULL,
75
+ expert: { ...FULL.expert, displayName: "" },
76
+ }),
77
+ ).toBeNull();
78
+ expect(renderCertDialogInner({ ...FULL, expert: null })).toBeNull();
79
+ });
80
+ it('drops the "Certifies the article" block when no title', () => {
81
+ const html = renderCertDialogInner({ ...FULL, articleTitle: null }) ?? "";
82
+ expect(html).not.toContain("Certifies the article");
83
+ });
84
+ it("drops the Certificate ID column when no display id", () => {
85
+ const html = renderCertDialogInner({ ...FULL, displayCertId: null }) ?? "";
86
+ expect(html).not.toContain("Certificate ID");
87
+ expect(html).toContain("Issued"); // other meta cols still present
88
+ });
89
+ it("escapes a hostile reviewer name", () => {
90
+ const html =
91
+ renderCertDialogInner({
92
+ ...FULL,
93
+ expert: { ...FULL.expert, displayName: "<script>x</script>" },
94
+ }) ?? "";
95
+ expect(html).not.toContain("<script>x</script>");
96
+ expect(html).toContain("&lt;script&gt;");
97
+ });
98
+ });
99
+
100
+ describe("renderExpertDialogInner (4b)", () => {
101
+ it("renders the header, name, verified credential chips, bio, trust, CTA", () => {
102
+ const html = renderExpertDialogInner(FULL) ?? "";
103
+ expect(html).toContain("Verified reviewer");
104
+ expect(html).toContain("Dr. Erik Schraga, MD");
105
+ expect(html).toContain(">Board-Certified<");
106
+ expect(html).toContain(">Emergency Medicine<");
107
+ expect(html).toContain("Reviews health content for accuracy"); // verified background as bio
108
+ expect(html).toContain("Independently reviewed · Certified Apr 14, 2026");
109
+ expect(html).toContain("View full profile on CertREV");
110
+ expect(html).toContain('href="https://certrev.com/expert/abc"');
111
+ });
112
+ it("NEVER renders an affiliation subtitle (omitted — no data source)", () => {
113
+ const html = renderExpertDialogInner(FULL) ?? "";
114
+ expect(html).not.toContain("crm-id__subtitle");
115
+ });
116
+ it("drops the bio when showBio is false", () => {
117
+ const html =
118
+ renderExpertDialogInner({ ...FULL, display: { showBio: false } }) ?? "";
119
+ expect(html).not.toContain("crm-bio");
120
+ });
121
+ it("renders no chips when there are no credentials", () => {
122
+ const html =
123
+ renderExpertDialogInner({
124
+ ...FULL,
125
+ expert: { ...FULL.expert, credentials: [] },
126
+ }) ?? "";
127
+ expect(html).not.toContain("crm-chips");
128
+ });
129
+ it("drops a javascript: profile url to #", () => {
130
+ const html =
131
+ renderExpertDialogInner({
132
+ ...FULL,
133
+ expert: { ...FULL.expert, profileUrl: "javascript:alert(1)" },
134
+ }) ?? "";
135
+ expect(html).toContain('href="#"');
136
+ expect(html).not.toContain("javascript:");
137
+ });
138
+ it("fails closed with no reviewer name", () => {
139
+ expect(
140
+ renderExpertDialogInner({
141
+ ...FULL,
142
+ expert: { ...FULL.expert, displayName: null },
143
+ }),
144
+ ).toBeNull();
145
+ });
146
+ });
147
+
148
+ describe("FTC disclosure footer (POR-10496)", () => {
149
+ const COMPENSATED: CertModalContent = {
150
+ ...FULL,
151
+ expert: { ...FULL.expert, compensationCue: COMPENSATED_EXPERT_CUE },
152
+ };
153
+ // A pro-bono reviewer carries no cue (FULL omits `compensationCue`).
154
+ const PRO_BONO = FULL;
155
+
156
+ for (const [label, render] of [
157
+ ["reviewer (4b)", renderExpertDialogInner],
158
+ ["certificate (3a)", renderCertDialogInner],
159
+ ] as const) {
160
+ it(`${label}: compensated → the cue label + the scope line`, () => {
161
+ const html = render(COMPENSATED) ?? "";
162
+ expect(html).toContain("crm-disclosure");
163
+ expect(html).toContain("crm-disclosure__cue");
164
+ expect(html).toContain(COMPENSATED_EXPERT_CUE);
165
+ expect(html).toContain(CERT_SCOPE_LINE);
166
+ });
167
+ it(`${label}: pro-bono → the scope line ONLY (no cue label)`, () => {
168
+ const html = render(PRO_BONO) ?? "";
169
+ expect(html).toContain("crm-disclosure"); // footer + scope line always render
170
+ expect(html).toContain(CERT_SCOPE_LINE);
171
+ expect(html).not.toContain("crm-disclosure__cue"); // the "Compensated expert" label is dropped
172
+ expect(html).not.toContain(COMPENSATED_EXPERT_CUE);
173
+ });
174
+ }
175
+ });
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Pure envelope → modal-HTML mapping for `<certrev-cert-modal>` (POR-10102).
3
+ *
4
+ * DOM-free and side-effect-free so it unit-tests without a browser: given the
5
+ * signed envelope's `payload.content`, it returns the inner HTML of the certificate
6
+ * (3a) and reviewer (4b) `<dialog>`s. The element (`certrev-cert-modal.ts`) owns the
7
+ * shadow root, the `<dialog>` wrappers, and the open/close wiring; this module owns
8
+ * only the content.
9
+ *
10
+ * Invariants:
11
+ * - Every dynamic value is `escapeHtml`'d (the envelope is signed, but a reviewer
12
+ * name / memo is still attacker-adjacent content).
13
+ * - Only `http(s)` hrefs render (mirrors the Liquid `certrev-safe-url` guard).
14
+ * - Verified-only fields (POR-10091): the bio binds the envelope's verified
15
+ * `background`/`bio`; this module never reaches for a raw column.
16
+ * - Graceful degradation: a missing article title drops the "Certifies the article"
17
+ * block; a missing display cert id drops that meta column; no credentials → no
18
+ * chips / no creds line; no bio → no bio paragraph. Nothing renders an empty gap.
19
+ * - WS4a (display eviction) accept-on-read: an ABSENT `display` block resolves to the
20
+ * preset default (show-all) — every read below is `display?.x !== false` / `=== false`,
21
+ * so `undefined` never suppresses a field. The modal is the LOCKED navy trust chrome and
22
+ * is deliberately NOT themeable by a brand render-def; it reads visibility off the signed
23
+ * envelope only, so no render-def wiring belongs here.
24
+ */
25
+
26
+ import { escapeHtml } from './escape.js'
27
+ import { resizeAvatarUrl } from './avatar-resize.js'
28
+ import { CERT_SCOPE_LINE, formatCertDisplayDate } from './display-strings.js'
29
+ import { checkIcon, chevronMark, verifiedStamp } from './certrev-cert-modal.styles.js'
30
+
31
+ export interface CertModalCredential {
32
+ abbreviation?: string | null
33
+ fullName?: string | null
34
+ cardLabel?: string | null
35
+ }
36
+
37
+ export interface CertModalExpert {
38
+ displayName?: string | null
39
+ credentials?: readonly CertModalCredential[] | null
40
+ profileUrl?: string | null
41
+ photoUrl?: string | null
42
+ bio?: string | null
43
+ background?: string | null
44
+ /** POR-10496: the pre-composed material-connection label ('Compensated expert') or null/absent for
45
+ * a pro-bono reviewer. Present → the disclosure footer renders the label; null/absent → label omitted. */
46
+ compensationCue?: string | null
47
+ }
48
+
49
+ /** The subset of the signed `payload.content` the modals read (structural). */
50
+ export interface CertModalContent {
51
+ expert?: CertModalExpert | null
52
+ certifiedAt?: string | null
53
+ verifyUrl?: string | null
54
+ articleTitle?: string | null
55
+ displayCertId?: string | null
56
+ display?: { showExpertPhoto?: boolean | null; showBio?: boolean | null } | null
57
+ }
58
+
59
+ const HONORIFIC_RE = /^(dr|prof|professor|mr|mrs|ms|mx|rev|sir|dame)\.?\s+/i
60
+
61
+ /** The bare given+family name: strip the leading honorific AND the trailing post-nominal
62
+ * (`, MD`), so "Dr. Erik Schraga, MD" → "Erik Schraga". */
63
+ function coreName(displayName: string): string {
64
+ const withoutHonorific = displayName.replace(HONORIFIC_RE, '').trim()
65
+ const comma = withoutHonorific.indexOf(',')
66
+ return (comma === -1 ? withoutHonorific : withoutHonorific.slice(0, comma)).trim()
67
+ }
68
+
69
+ /** Two-letter initials — first + last of the core name (honorific + post-nominal stripped),
70
+ * so "Dr. Erik Schraga, MD" → "ES" (matches the Liquid derivation), not "EM". */
71
+ export function deriveInitials(name: string): string {
72
+ const words = coreName(name).split(/\s+/).filter(Boolean)
73
+ if (words.length === 0) return ''
74
+ if (words.length === 1) return words[0].slice(0, 2).toUpperCase()
75
+ return (words[0][0] + words[words.length - 1][0]).toUpperCase()
76
+ }
77
+
78
+ /** The handwritten-signature form (the core name). */
79
+ export function signatureName(displayName: string): string {
80
+ return coreName(displayName)
81
+ }
82
+
83
+ /** `https://certrev.com/verify/x` → `certrev.com/verify/x` (footer URL line). */
84
+ export function stripProtocol(url: string): string {
85
+ return url.replace(/^https?:\/\//i, '').replace(/\/$/, '')
86
+ }
87
+
88
+ /** Return the url only if it is an absolute http(s) URL, else `#` (safe-url guard). */
89
+ export function safeHref(url: string | null | undefined): string {
90
+ if (!url) return '#'
91
+ return /^https?:\/\//i.test(url.trim()) ? url.trim() : '#'
92
+ }
93
+
94
+ /** A single credential's display label (cardLabel ?? fullName ?? abbreviation). */
95
+ function credLabel(c: CertModalCredential): string {
96
+ return (c.cardLabel || c.fullName || c.abbreviation || '').trim()
97
+ }
98
+
99
+ /** The verified credential labels, in order, de-duped + non-empty. */
100
+ function credLabels(expert: CertModalExpert | null | undefined): string[] {
101
+ const out: string[] = []
102
+ for (const c of expert?.credentials ?? []) {
103
+ const label = credLabel(c)
104
+ if (label && !out.includes(label)) out.push(label)
105
+ }
106
+ return out
107
+ }
108
+
109
+ /** The reviewer bio — verified background preferred, then verified bio. */
110
+ function bioText(expert: CertModalExpert | null | undefined, display: CertModalContent['display']): string {
111
+ if (display?.showBio === false) return ''
112
+ return (expert?.background || expert?.bio || '').trim()
113
+ }
114
+
115
+ /** An avatar cell — the cert-frozen photo (when shown) or the initials chip. */
116
+ function avatarHtml(
117
+ expert: CertModalExpert | null | undefined,
118
+ name: string,
119
+ sizeClass: string,
120
+ showPhoto: boolean,
121
+ ): string {
122
+ const photo = (expert?.photoUrl || '').trim()
123
+ if (showPhoto && /^https?:\/\//i.test(photo)) {
124
+ // Display px from the size class; request the @2x derivative for retina crispness (POR-10470).
125
+ // resizeAvatarUrl is host-guarded + enablement-gated: until Cloudflare Image Resizing is live it
126
+ // returns the raw canonical URL, so the modal never 404s an unresized host.
127
+ const displayPx = sizeClass.includes('--64') ? 64 : 56
128
+ const src = resizeAvatarUrl(photo, displayPx === 64 ? 128 : 112)
129
+ return `<img class="crm-avatar ${sizeClass}" src="${escapeHtml(src)}" alt="" width="${displayPx}" height="${displayPx}" decoding="async">`
130
+ }
131
+ return `<span class="crm-avatar ${sizeClass}">${escapeHtml(deriveInitials(name))}</span>`
132
+ }
133
+
134
+ const CLOSE_BUTTON = `<button type="button" class="crm-close" data-certrev-modal-close aria-label="Close">&times;</button>`
135
+
136
+ function headHtml(eyebrow: string): string {
137
+ return `<div class="crm-head">${chevronMark(16, '#ffffff')}<span class="crm-head__eyebrow">${escapeHtml(eyebrow)}</span><span class="crm-head__wordmark">CertREV</span></div>`
138
+ }
139
+
140
+ function certifiedChip(cls: string): string {
141
+ return `<span class="${cls}">${checkIcon(8)}Certified</span>`
142
+ }
143
+
144
+ /**
145
+ * The FTC disclosure footer (POR-10496), rendered on both dialogs. The material-connection cue
146
+ * (`expert.compensationCue`, a FINISHED string off the signed envelope) leads ONLY when the reviewer
147
+ * was compensated; a pro-bono reviewer (null/absent cue) drops the label but keeps the scope line —
148
+ * which makes no compensation claim, so it is true for volunteers too and renders unconditionally.
149
+ * The scope line is the verbatim FTC copy (`CERT_SCOPE_LINE`), never re-assembled here.
150
+ */
151
+ function disclosureFooter(expert: CertModalExpert | null | undefined): string {
152
+ const cue = (expert?.compensationCue || '').trim()
153
+ const cueHtml = cue ? `<span class="crm-disclosure__cue">${escapeHtml(cue)}</span> ` : ''
154
+ return `<div class="crm-disclosure">${cueHtml}${escapeHtml(CERT_SCOPE_LINE)}</div>`
155
+ }
156
+
157
+ /**
158
+ * The certificate modal (3a) inner HTML. Returns `null` when there is no reviewer
159
+ * name to display — a certificate card must never render anonymous (fail-closed,
160
+ * mirrors the Liquid `ename != ''` gate).
161
+ */
162
+ export function renderCertDialogInner(content: CertModalContent): string | null {
163
+ const expert = content.expert ?? null
164
+ const name = (expert?.displayName || '').trim()
165
+ if (!name) return null
166
+
167
+ const showPhoto = content.display?.showExpertPhoto !== false
168
+ const creds = credLabels(expert).join(' · ')
169
+ const issued = formatCertDisplayDate(content.certifiedAt) ?? ''
170
+ const certId = (content.displayCertId || '').trim()
171
+ const title = (content.articleTitle || '').trim()
172
+ const verifyUrl = (content.verifyUrl || '').trim()
173
+ const sig = signatureName(name)
174
+
175
+ const certifiesBlock = title
176
+ ? `<div class="crm-certifies"><div class="crm-certifies__eyebrow">Certifies the article</div><div class="crm-certifies__title">${escapeHtml(title)}</div></div>`
177
+ : ''
178
+
179
+ const certIdCol = certId
180
+ ? `<div class="crm-meta__col"><div class="crm-meta__label">Certificate ID</div><div class="crm-meta__value crm-meta__value--mono">${escapeHtml(certId)}</div></div>`
181
+ : ''
182
+
183
+ const urlLine = verifyUrl ? `<div class="crm-sig__url">${escapeHtml(stripProtocol(verifyUrl))}</div>` : ''
184
+
185
+ return `${CLOSE_BUTTON}${headHtml('Certificate of Expert Review')}<div class="crm-body--cert"><div class="crm-expert-row">${avatarHtml(expert, name, 'crm-avatar--56', showPhoto)}<div class="crm-expert-row__info"><div class="crm-expert-row__eyebrow">Reviewing Expert</div><div class="crm-expert-row__name">${escapeHtml(name)}</div>${creds ? `<div class="crm-expert-row__creds">${escapeHtml(creds)}</div>` : ''}</div></div>${certifiesBlock}<div class="crm-meta"><div class="crm-meta__col"><div class="crm-meta__label">Issued</div><div class="crm-meta__value">${escapeHtml(issued)}</div></div>${certIdCol}<div class="crm-meta__col"><div class="crm-meta__label">Status</div><div class="crm-meta__status">${certifiedChip('crm-chip')}</div></div></div><div class="crm-sig"><div><div class="crm-sig__name">${escapeHtml(sig)}</div><div class="crm-sig__rule"></div>${urlLine}</div>${verifiedStamp('cert')}</div>${disclosureFooter(expert)}<div class="crm-actions"><a class="crm-cta" href="${escapeHtml(safeHref(verifyUrl))}" target="_blank" rel="noopener noreferrer">View full certificate</a><button type="button" class="crm-ghost" data-certrev-modal-close>Close</button></div></div>`
186
+ }
187
+
188
+ /**
189
+ * The reviewer/profile modal (4b) inner HTML. Returns `null` when there is no
190
+ * reviewer name (fail-closed). The affiliation subtitle is intentionally omitted
191
+ * (no verified data source — POR-10102 decision).
192
+ */
193
+ export function renderExpertDialogInner(content: CertModalContent): string | null {
194
+ const expert = content.expert ?? null
195
+ const name = (expert?.displayName || '').trim()
196
+ if (!name) return null
197
+
198
+ const showPhoto = content.display?.showExpertPhoto !== false
199
+ const chips = credLabels(expert)
200
+ const bio = bioText(expert, content.display)
201
+ const date = formatCertDisplayDate(content.certifiedAt)
202
+
203
+ const chipsHtml = chips.length
204
+ ? `<div class="crm-chips">${chips.map((c) => `<span class="crm-chips__chip">${escapeHtml(c)}</span>`).join('')}</div>`
205
+ : ''
206
+ const bioHtml = bio ? `<p class="crm-bio">${escapeHtml(bio)}</p>` : ''
207
+ const trustText = date ? `Independently reviewed · Certified ${escapeHtml(date)}` : 'Independently reviewed'
208
+
209
+ return `${CLOSE_BUTTON}${headHtml('Verified reviewer')}<div class="crm-body--expert"><div class="crm-id">${avatarHtml(expert, name, 'crm-avatar--64', showPhoto)}<div class="crm-id__info"><div class="crm-id__name">${escapeHtml(name)}</div></div></div>${chipsHtml}${bioHtml}<div class="crm-trust">${certifiedChip('crm-trust__chip')}<span class="crm-trust__text">${trustText}</span></div>${disclosureFooter(expert)}<div class="crm-actions"><a class="crm-cta" href="${escapeHtml(safeHref(expert?.profileUrl))}" target="_blank" rel="noopener noreferrer">View full profile on CertREV</a><button type="button" class="crm-ghost" data-certrev-modal-close>Close</button></div></div>`
210
+ }