@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.
- package/README.md +48 -1
- package/dist/builder/cert-chrome-data.d.ts +129 -0
- package/dist/builder/cert-chrome-data.d.ts.map +1 -0
- package/dist/builder/cert-chrome-data.js +59 -0
- package/dist/builder/cert-chrome-data.js.map +1 -0
- package/dist/builder/cert-review-card.d.ts +68 -0
- package/dist/builder/cert-review-card.d.ts.map +1 -0
- package/dist/builder/cert-review-card.js +129 -0
- package/dist/builder/cert-review-card.js.map +1 -0
- package/dist/builder/index.d.ts +2 -0
- package/dist/builder/index.d.ts.map +1 -1
- package/dist/builder/index.js +3 -0
- package/dist/builder/index.js.map +1 -1
- package/dist/components/preview-block.d.ts +43 -0
- package/dist/components/preview-block.d.ts.map +1 -0
- package/dist/components/preview-block.js +53 -0
- package/dist/components/preview-block.js.map +1 -0
- package/dist/components/render-cert-block.d.ts +50 -7
- package/dist/components/render-cert-block.d.ts.map +1 -1
- package/dist/components/render-cert-block.js +304 -80
- package/dist/components/render-cert-block.js.map +1 -1
- package/dist/components/render-def.d.ts +27 -0
- package/dist/components/render-def.d.ts.map +1 -1
- package/dist/components/render-def.js +38 -0
- package/dist/components/render-def.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/modal/avatar-resize.d.ts +60 -0
- package/dist/modal/avatar-resize.d.ts.map +1 -0
- package/dist/modal/avatar-resize.js +91 -0
- package/dist/modal/avatar-resize.js.map +1 -0
- package/dist/modal/cert-modal-view.d.ts +74 -0
- package/dist/modal/cert-modal-view.d.ts.map +1 -0
- package/dist/modal/cert-modal-view.js +160 -0
- package/dist/modal/cert-modal-view.js.map +1 -0
- package/dist/modal/certrev-cert-modal.d.ts +72 -0
- package/dist/modal/certrev-cert-modal.d.ts.map +1 -0
- package/dist/modal/certrev-cert-modal.js +270 -0
- package/dist/modal/certrev-cert-modal.js.map +1 -0
- package/dist/modal/certrev-cert-modal.styles.d.ts +35 -0
- package/dist/modal/certrev-cert-modal.styles.d.ts.map +1 -0
- package/dist/modal/certrev-cert-modal.styles.js +488 -0
- package/dist/modal/certrev-cert-modal.styles.js.map +1 -0
- package/dist/modal/certrev-cert.entry.d.ts +19 -0
- package/dist/modal/certrev-cert.entry.d.ts.map +1 -0
- package/dist/modal/certrev-cert.entry.js +39 -0
- package/dist/modal/certrev-cert.entry.js.map +1 -0
- package/dist/modal/certrev-cert.js +557 -0
- package/dist/modal/display-strings.d.ts +22 -0
- package/dist/modal/display-strings.d.ts.map +1 -0
- package/dist/modal/display-strings.js +35 -0
- package/dist/modal/display-strings.js.map +1 -0
- package/dist/modal/escape.d.ts +13 -0
- package/dist/modal/escape.d.ts.map +1 -0
- package/dist/modal/escape.js +20 -0
- package/dist/modal/escape.js.map +1 -0
- package/dist/modal/font-face.d.ts +4 -0
- package/dist/modal/font-face.d.ts.map +1 -0
- package/dist/modal/font-face.js +137 -0
- package/dist/modal/font-face.js.map +1 -0
- package/dist/modal/ftc-disclosure.d.ts +12 -0
- package/dist/modal/ftc-disclosure.d.ts.map +1 -0
- package/dist/modal/ftc-disclosure.js +12 -0
- package/dist/modal/ftc-disclosure.js.map +1 -0
- package/dist/modal/ftc-guard.d.ts +50 -0
- package/dist/modal/ftc-guard.d.ts.map +1 -0
- package/dist/modal/ftc-guard.js +117 -0
- package/dist/modal/ftc-guard.js.map +1 -0
- package/dist/modal/index.d.ts +20 -0
- package/dist/modal/index.d.ts.map +1 -0
- package/dist/modal/index.js +25 -0
- package/dist/modal/index.js.map +1 -0
- package/dist/modal/interactions.d.ts +53 -0
- package/dist/modal/interactions.d.ts.map +1 -0
- package/dist/modal/interactions.js +147 -0
- package/dist/modal/interactions.js.map +1 -0
- package/dist/modal/logo-paths.d.ts +15 -0
- package/dist/modal/logo-paths.d.ts.map +1 -0
- package/dist/modal/logo-paths.js +24 -0
- package/dist/modal/logo-paths.js.map +1 -0
- package/dist/modal/placement.d.ts +95 -0
- package/dist/modal/placement.d.ts.map +1 -0
- package/dist/modal/placement.js +302 -0
- package/dist/modal/placement.js.map +1 -0
- package/dist/modal/revalidate.d.ts +48 -0
- package/dist/modal/revalidate.d.ts.map +1 -0
- package/dist/modal/revalidate.js +65 -0
- package/dist/modal/revalidate.js.map +1 -0
- package/dist/modal/verify-client.d.ts +24 -0
- package/dist/modal/verify-client.d.ts.map +1 -0
- package/dist/modal/verify-client.js +38 -0
- package/dist/modal/verify-client.js.map +1 -0
- package/package.json +14 -4
- package/src/__tests__/__snapshots__/render-cert-block.test.ts.snap +10 -6
- package/src/__tests__/preview-block.test.ts +84 -0
- package/src/__tests__/render-cert-block.test.ts +380 -5
- package/src/builder/__tests__/builder.test.tsx +71 -0
- package/src/builder/cert-chrome-data.ts +159 -0
- package/src/builder/cert-review-card.tsx +177 -0
- package/src/builder/index.tsx +18 -0
- package/src/components/preview-block.ts +74 -0
- package/src/components/render-cert-block.ts +387 -102
- package/src/components/render-def.ts +55 -0
- package/src/index.ts +9 -0
- package/src/modal/avatar-resize.ts +99 -0
- package/src/modal/cert-modal-view.test.ts +175 -0
- package/src/modal/cert-modal-view.ts +210 -0
- package/src/modal/certrev-cert-modal.styles.ts +494 -0
- package/src/modal/certrev-cert-modal.test.ts +125 -0
- package/src/modal/certrev-cert-modal.ts +273 -0
- package/src/modal/certrev-cert.entry.ts +44 -0
- package/src/modal/display-strings.ts +38 -0
- package/src/modal/escape.ts +20 -0
- package/src/modal/font-face.ts +138 -0
- package/src/modal/ftc-disclosure.ts +13 -0
- package/src/modal/ftc-guard.test.ts +153 -0
- package/src/modal/ftc-guard.ts +119 -0
- package/src/modal/index.ts +34 -0
- package/src/modal/interactions.test.ts +250 -0
- package/src/modal/interactions.ts +166 -0
- package/src/modal/logo-paths.ts +23 -0
- package/src/modal/placement.test.ts +398 -0
- package/src/modal/placement.ts +331 -0
- package/src/modal/revalidate.test.ts +111 -0
- package/src/modal/revalidate.ts +81 -0
- package/src/modal/verify-client.test.ts +164 -0
- package/src/modal/verify-client.ts +42 -0
|
@@ -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("<script>");
|
|
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">×</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
|
+
}
|