@certrev/cert-block 0.2.0 → 0.5.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 (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 -5
  19. package/dist/components/render-cert-block.d.ts.map +1 -1
  20. package/dist/components/render-cert-block.js +341 -93
  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 +10 -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 +404 -6
  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 +427 -121
  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
@@ -19,10 +19,23 @@
19
19
  * hooks on the ROOT element so the block is self-contained on a storefront that does
20
20
  * NOT define the guide's `:root` — the accent (`--ba`), its auto-contrast ink
21
21
  * (`--ba-fg`), the corner radius (`--br`), the body font (`--bf`) and surface
22
- * (`--surface`) come from the resolved theme; the navy trust tints + the fixed
23
- * label/serif/wordmark fonts are LOCKED literals (guide "the lock is legal, not
24
- * aesthetic"). A brand themes accent / font / corners / surface; the navy chrome ink
25
- * stays fixed.
22
+ * (`--surface`) come from the resolved theme; the fixed mono label font (`--font-mono`)
23
+ * is a LOCKED literal (guide "the lock is legal, not aesthetic"). Names, the memo quote,
24
+ * disclosures, links, and the "CertREV" wordmark ride the body font; the serif/grotesk
25
+ * slots remain reachable only as a brand's themeable body-font choice.
26
+ *
27
+ * V2 BRAND-INK (Phase 1, parity with the portal Liquid/CSS engines): a brand may lift TWO
28
+ * more chrome surfaces off their hardcoded navy literals — the header/seal bar (`barInk`,
29
+ * with its auto-contrast `barInkFg`) and the FREE-content trust ink (`inkColor`: reviewer/
30
+ * author names, title, credential, bio, initials + the derived muted scale). These reach
31
+ * the root as the exact `--certrev-bar-bg` / `--certrev-bar-fg` / `--certrev-ink` wire vars
32
+ * the Shopify/WordPress engines emit — but ONLY when the def sets them, so a no-def face is
33
+ * byte-identical. The bar consumes `var(--certrev-bar-bg, #0a1b3f)` / `var(--certrev-bar-fg,
34
+ * #fff)`; the free content consumes the `--cr-ink` family (`--cr-ink` = `var(--certrev-ink,
35
+ * #0a1b3f)`, with `-sub`/`-soft` muted derivations). The LOCKED trust text — the compensation
36
+ * cue, the FTC scope line, "Credential verified by CertREV", the WRITTEN/REVIEWED BY labels,
37
+ * and the certified date — NEVER follows `inkColor`; it stays on the navy `--navy*` scale so a
38
+ * brand ink can never drag legally load-bearing text below its floor.
26
39
  *
27
40
  * COMPLIANCE (LOCKED, non-negotiable): the three material disclosures always render
28
41
  * on a CLAIM face (banner + sidebar) — the compensation cue, the byte-exact FTC
@@ -35,6 +48,10 @@
35
48
  * interpolated value is escaped — `escapeHtml` for content, `escapeAttribute` for
36
49
  * attribute values, `safeHttpUrl` for hrefs. Theme tokens are re-validated
37
50
  * (`resolveBlockTheme`) and only ever emitted as strict hex / length / enum-stack.
51
+ *
52
+ * PLACED-FIELDS STRICTNESS (WS-B): Visibility is decided by the engine's
53
+ * `placedFields` alone; `rung` is a stamp, never a branch; an absent face
54
+ * grandfathers the 0.3.0 full-facts render byte-for-byte.
38
55
  */
39
56
  import { escapeAttribute, escapeHtml, safeHttpUrl } from './escape.js';
40
57
  import { formatDate } from './format.js';
@@ -50,15 +67,13 @@ const LOGO_PATHS = [
50
67
  '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',
51
68
  ];
52
69
  /**
53
- * The 4-chevron CertREV mark at `size`px, filled `fill` (a controlled literal). The
54
- * navy/pill HEADERS pass `currentColor` so the mark tracks the header text's accent
55
- * foreground ink (`--ba-fg`) — white on the navy default, and the dark auto-contrast
56
- * ink on a light themed accent (so it never goes white-on-light). The memo credit
57
- * passes the fixed navy `#0a1b3f` (it sits on the white surface).
70
+ * The 4-chevron CertREV mark at `size`px, always filled `currentColor` so it tracks
71
+ * its container's ink — white on the hardcoded-navy header, and the muted navy-55 of
72
+ * the memo footer's "Verified by CertREV" lockup (it sits on the white surface).
58
73
  */
59
- function logo(size, fill) {
74
+ function logo(size) {
60
75
  return (`<svg viewBox="0 0 375 375" width="${size}" height="${size}" style="flex-shrink:0" aria-label="CertREV">` +
61
- LOGO_PATHS.map((d) => `<path fill="${fill}" d="${d}"></path>`).join('') +
76
+ LOGO_PATHS.map((d) => `<path fill="currentColor" d="${d}"></path>`).join('') +
62
77
  `</svg>`);
63
78
  }
64
79
  /** The bio-accordion caret (progressive-enhancement affordance). */
@@ -67,19 +82,30 @@ const CARET = `<span class="certrev-cert__caret" style="display:inline-flex;vert
67
82
  // ─────────────────────────────────────────────────────────────────────────────
68
83
  // Small pure helpers
69
84
  // ─────────────────────────────────────────────────────────────────────────────
70
- /** "Jane Doe" → "JD"; "The Editorial Team" → "TE" (first letter of the first two words). */
85
+ /**
86
+ * Strip a leading honorific ("Dr. Jane Smith" → "Jane Smith") so the initials + first-name token don't
87
+ * capture it — matches the Liquid twin, which removes a matched honorific so "Dr. Marcus Ellison" yields
88
+ * the "ME" avatar + "Marcus's profile", never "DM" / "Dr.'s profile".
89
+ */
90
+ function stripHonorific(name) {
91
+ return (name
92
+ .trim()
93
+ .replace(/^(dr|prof|professor|mr|mrs|ms|mx|rev|sir|dame)\.?\s+/i, '')
94
+ .trim() || name.trim());
95
+ }
96
+ /** "Jane Doe" → "JD"; "Dr. Marcus Ellison" → "ME" (first letter of the first two words, honorific stripped). */
71
97
  function initials(name) {
72
- const parts = name.trim().split(/\s+/).filter(Boolean);
98
+ const parts = stripHonorific(name).split(/\s+/).filter(Boolean);
73
99
  const chars = parts
74
100
  .slice(0, 2)
75
101
  .map((p) => p[0] ?? '')
76
102
  .join('');
77
103
  return (chars || '?').toUpperCase();
78
104
  }
79
- /** The first name token (for "View Jane's profile"). */
105
+ /** The first name token (for "View Jane's profile") — honorific stripped. */
80
106
  function firstNameOf(name) {
81
- const first = name.trim().split(/\s+/)[0];
82
- return first || name.trim();
107
+ const clean = stripHonorific(name);
108
+ return clean.split(/\s+/)[0] || clean;
83
109
  }
84
110
  /**
85
111
  * A LOCKED display string: use the caller's value ONLY when it is a non-blank
@@ -90,9 +116,24 @@ function firstNameOf(name) {
90
116
  function locked(supplied, constant) {
91
117
  return supplied?.trim() ? supplied : constant;
92
118
  }
119
+ /**
120
+ * Absent face ⇒ constant-true (the grandfather path: every conditional collapses to
121
+ * today's markup, guaranteeing byte-identity with 0.3.0). Present ⇒ Set membership.
122
+ */
123
+ function placedTester(face) {
124
+ if (!face)
125
+ return () => true;
126
+ const set = new Set(face.placedFields);
127
+ return (id) => set.has(id);
128
+ }
129
+ /**
130
+ * The memo/custom action-link stamp style — the JetBrains-mono uppercase lockup that
131
+ * matches the badge footer (Owen 2026-07-17); navy so it still reads as an action.
132
+ */
133
+ const STAMP_LINK_STYLE = '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;';
93
134
  /** A round avatar tile with the given initials. */
94
135
  function avatar(text, size, fontSize) {
95
- return (`<div style="width:${size}px;height:${size}px;border-radius:50%;background:var(--stone);color:var(--navy-55);` +
136
+ return (`<div style="width:${size}px;height:${size}px;border-radius:50%;background:var(--stone);color:var(--cr-ink-sub);` +
96
137
  `display:flex;align-items:center;justify-content:center;font-family:var(--font-mono);font-weight:700;font-size:${fontSize}px;flex-shrink:0;">` +
97
138
  `${escapeHtml(text)}</div>`);
98
139
  }
@@ -116,19 +157,33 @@ function certificateLink(url, inner, style) {
116
157
  * name, then the attribution line, then "on <date>" — one block, order preserved.
117
158
  * `withAccordion` adds the bio caret + hidden bio (banner) as progressive enhancement.
118
159
  */
119
- function verificationBlock(facts, withAccordion) {
160
+ function verificationBlock(facts, withAccordion, placed) {
161
+ const showCredentialed = placed('bylineCredentialed');
162
+ const showName = placed('bylinePlain') || showCredentialed;
163
+ // Nothing placed ⇒ empty (the caller drops the wrapping toggle div).
164
+ if (!showName)
165
+ return '';
120
166
  const credentialLine = locked(facts.credentialLine, CREDENTIAL_VERIFIED_ATTRIBUTION);
121
167
  const verified = formatDate(facts.credentialVerifiedAt) ?? escapeHtml(facts.credentialVerifiedAt);
122
- const caret = withAccordion && facts.bio ? CARET : '';
168
+ const showBio = withAccordion && placed('bio') && !!facts.bio;
169
+ const caret = showBio ? CARET : '';
123
170
  const nameSize = withAccordion ? '15px' : '16px';
124
171
  const muteSize = withAccordion ? '13px' : '12.5px';
125
- const bio = withAccordion && facts.bio
126
- ? `<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>`
172
+ const bio = showBio
173
+ ? `<div class="certrev-cert__bio" hidden style="display:none;margin-top:8px;font-size:12.5px;line-height:1.5;color:var(--cr-ink-soft);">${escapeHtml(facts.bio ?? '')}</div>`
174
+ : '';
175
+ // The credential suffix + the GROUPED attribution/date lines are all-or-nothing on
176
+ // `bylineCredentialed`; a plain byline shows the name only.
177
+ const nameInner = showCredentialed
178
+ ? nameWithCredential(facts.reviewerName, facts.credential)
179
+ : escapeHtml(facts.reviewerName);
180
+ const attribution = showCredentialed
181
+ ? `<div style="font-size:${muteSize};color:var(--navy-55);line-height:1.45;margin-top:3px;">${escapeHtml(credentialLine)}</div>` +
182
+ `<div style="font-size:${muteSize};color:var(--navy-55);line-height:1.35;">on ${verified}</div>`
127
183
  : '';
128
184
  return (`<div>` +
129
- `<div style="font-size:${nameSize};font-weight:700;">${nameWithCredential(facts.reviewerName, facts.credential)}${caret}</div>` +
130
- `<div style="font-size:${muteSize};color:var(--navy-55);line-height:1.45;margin-top:3px;">${escapeHtml(credentialLine)}</div>` +
131
- `<div style="font-size:${muteSize};color:var(--navy-55);line-height:1.35;">on ${verified}</div>` +
185
+ `<div style="font-size:${nameSize};font-weight:700;color:var(--cr-ink);">${nameInner}${caret}</div>` +
186
+ attribution +
132
187
  bio +
133
188
  `</div>`);
134
189
  }
@@ -139,12 +194,24 @@ function rootStyle(theme) {
139
194
  // Data-bearing theme tokens are already strict-validated (hex / length / literal
140
195
  // ink); the font is a controlled enum → stack literal. The navy tints + fixed
141
196
  // label/serif/wordmark fonts are LOCKED literals.
197
+ const ink = theme.inkColor;
142
198
  return [
143
199
  `--ba:${theme.accentColor}`,
144
200
  `--ba-fg:${theme.accentFg}`,
145
201
  `--br:${theme.cornerRadius}`,
146
- `--bf:${RENDER_BLOCK_FONT_STACKS[theme.fontSlot]}`,
202
+ // The body font is a host-overridable HOOK (POR-10742): `--cr-bf` resolves a host-page
203
+ // `--bf` (set on any ancestor) with the fontSlot stack as the FALLBACK — so a storefront
204
+ // can theme the card font via plain CSS, no `!important` (the old inline `--bf:<stack>`
205
+ // declaration shadowed a host `--bf`). Unset ⇒ resolves to the stack ⇒ byte-identical render.
206
+ `--cr-bf:var(--bf,${RENDER_BLOCK_FONT_STACKS[theme.fontSlot]})`,
147
207
  `--surface:${theme.surface}`,
208
+ // v2 chrome-ink (Phase 1): the header/seal bar FILL + its auto-contrast FOREGROUND — the exact
209
+ // `--certrev-bar-*` wire vars the Shopify/WP engines emit, present ONLY on a themed bar. ABSENT ⇒
210
+ // the consumers' `var(--certrev-bar-bg, #0a1b3f)` / `var(--certrev-bar-fg, #fff)` navy/white
211
+ // fallbacks resolve — byte-identical to the un-themed bar.
212
+ ...(theme.barInk && theme.barInkFg
213
+ ? [`--certrev-bar-bg:${theme.barInk}`, `--certrev-bar-fg:${theme.barInkFg}`]
214
+ : []),
148
215
  `--navy:#0a1b3f`,
149
216
  `--navy-06:rgba(10,27,63,.06)`,
150
217
  `--navy-10:rgba(10,27,63,.10)`,
@@ -152,111 +219,274 @@ function rootStyle(theme) {
152
219
  `--navy-55:rgba(10,27,63,.55)`,
153
220
  `--navy-75:rgba(10,27,63,.75)`,
154
221
  `--stone:#ece9e1`,
222
+ // v2 chrome-ink (Phase 1): the FREE-content trust-ink family (names/title/credential/bio/
223
+ // initials). `--certrev-ink` is the wire var, emitted ONLY when the def sets `inkColor`; `--cr-ink`
224
+ // consumes it with a navy fallback, so an un-inkColor'd face resolves to navy. The `-sub`/`-soft`
225
+ // muted scale is the EXACT navy literal when unthemed (byte-identical, zero color-mix dependency)
226
+ // and a color-mix derivation off the brand ink when themed. The LOCKED trust text keeps `--navy*`
227
+ // and never this family, so a brand ink can never touch a legal disclosure.
228
+ ...(ink ? [`--certrev-ink:${ink}`] : []),
229
+ `--cr-ink:var(--certrev-ink,#0a1b3f)`,
230
+ `--cr-ink-sub:${ink ? 'color-mix(in srgb,var(--cr-ink) 55%,transparent)' : 'rgba(10,27,63,.55)'}`,
231
+ `--cr-ink-soft:${ink ? 'color-mix(in srgb,var(--cr-ink) 75%,transparent)' : 'rgba(10,27,63,.75)'}`,
155
232
  `--font-mono:${RENDER_BLOCK_FONT_STACKS.mono}`,
156
- `--font-serif:${RENDER_BLOCK_FONT_STACKS.serif}`,
157
- `--font-wordmark:${RENDER_BLOCK_FONT_STACKS.grotesk}`,
158
- `font-family:var(--bf)`,
233
+ `font-family:var(--cr-bf)`,
159
234
  `color:var(--navy)`,
160
235
  ].join(';');
161
236
  }
162
- function root(mode, theme, inner) {
237
+ function root(mode, theme, inner, face) {
238
+ // The rung is an OBSERVABILITY stamp for the crawl monitor — only emitted when a
239
+ // face is present, so the grandfathered (face-absent) output stays byte-identical.
240
+ const rungAttr = face ? ` data-certrev-rung="${escapeAttribute(face.rung)}"` : '';
163
241
  return (`<div class="certrev-cert certrev-cert--${mode}" data-certrev-mode="${mode}" ` +
164
- `data-certrev-strings-version="${CERT_STRINGS_VERSION}" style="${rootStyle(theme)}">${inner}</div>`);
242
+ `data-certrev-strings-version="${CERT_STRINGS_VERSION}"${rungAttr} style="${rootStyle(theme)}">${inner}</div>`);
165
243
  }
166
244
  // ─────────────────────────────────────────────────────────────────────────────
167
245
  // Mode 1 — BANNER: a certification header card + a separate expert-memo card
168
246
  // ─────────────────────────────────────────────────────────────────────────────
169
- function bannerHeaderCard(facts) {
247
+ function bannerHeaderCard(facts, placed) {
170
248
  const compensationCue = locked(facts.compensationCue, COMPENSATED_EXPERT_CUE);
171
249
  const certified = formatDate(facts.certifiedAt) ?? escapeHtml(facts.certifiedAt);
172
- const subtitle = facts.authorTitle
173
- ? `<div style="font-size:13px;color:var(--navy-55);">${escapeHtml(facts.authorTitle)}</div>`
250
+ const subtitle = placed('authorTitle') && facts.authorTitle
251
+ ? `<div style="font-size:13px;color:var(--cr-ink-sub);">${escapeHtml(facts.authorTitle)}</div>`
174
252
  : '';
175
- return (`<div style="border:1px solid var(--navy-10);border-radius:calc(var(--br,14px) * 0.7);overflow:hidden;font-family:var(--bf);">` +
176
- // navy header
177
- `<div style="display:flex;align-items:center;justify-content:space-between;gap:16px;padding:15px 22px;background:var(--ba,#0a1b3f);color:var(--ba-fg,#fff);">` +
178
- `<span style="display:inline-flex;align-items:center;gap:11px;font-family:var(--font-mono);font-size:12px;font-weight:700;letter-spacing:.14em;text-transform:uppercase;">${logo(20, 'currentColor')} Expert reviewed</span>` +
179
- `</div>` +
253
+ // A subtle right-aligned arrow opens the certificate: the whole navy bar is the mouse
254
+ // target (data-certrev-modal-open); this focusable <a> is the keyboard / SR / no-JS control.
255
+ const certHref = safeHttpUrl(facts.certificateUrl) ?? '#';
256
+ const headerArrow = `<a href="${escapeAttribute(certHref)}" data-certrev-modal-open rel="noopener" aria-label="View certificate" ` +
257
+ `style="display:inline-flex;align-items:center;flex-shrink:0;color:inherit;opacity:.72;text-decoration:none;">` +
258
+ `<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>`;
259
+ // navy header — the trust bar stays fixed navy (accent theming is Phase B); mark + eyebrow + cert arrow
260
+ const header = placed('label')
261
+ ? `<div data-certrev-modal-open style="display:flex;align-items:center;justify-content:space-between;gap:16px;padding:15px 22px;background:var(--certrev-bar-bg,#0a1b3f);color:var(--certrev-bar-fg,#fff);cursor:pointer;">` +
262
+ `<span style="display:inline-flex;align-items:center;gap:11px;font-family:var(--font-mono);font-size:12px;font-weight:700;letter-spacing:.14em;text-transform:uppercase;">${logo(20)} Expert reviewed</span>` +
263
+ headerArrow +
264
+ `</div>`
265
+ : '';
266
+ // written by — muted label (navy-35); the whole author column is gated on `authorName`
267
+ const showAuthor = placed('authorName');
268
+ const authorColumn = showAuthor
269
+ ? `<div>` +
270
+ `<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>` +
271
+ `<div style="display:flex;align-items:center;gap:12px;">${avatar(initials(facts.authorName), 42, 12)}` +
272
+ `<div><div style="font-size:15px;font-weight:700;color:var(--cr-ink);">${escapeHtml(facts.authorName)}</div>${subtitle}</div></div>` +
273
+ `</div>`
274
+ : '';
275
+ // grid collapses to a single reviewer column when the author is absent
276
+ const gridCols = showAuthor ? '1fr 1fr' : '1fr';
277
+ // reviewed by — emphasized label (navy, 700): the certification's authority voice
278
+ const reviewerAvatar = placed('reviewerPhoto') ? avatar(initials(facts.reviewerName), 42, 12) : '';
279
+ const reviewerBlock = verificationBlock(facts, true, placed);
280
+ const revToggle = reviewerBlock
281
+ ? `<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;">` +
282
+ `${reviewerAvatar}${reviewerBlock}` +
283
+ `</div>`
284
+ : '';
285
+ // "Certified <date>" is card chrome (renders with the card); the cue is engine-placed
286
+ const cueSpan = placed('compensationCue') ? `<span>${escapeHtml(compensationCue)}</span>` : '';
287
+ return (`<div style="border:1px solid var(--navy-10);border-radius:calc(var(--br,14px) * 0.7);overflow:hidden;font-family:var(--cr-bf);">` +
288
+ header +
180
289
  `<div style="padding:22px 24px 18px;">` +
181
- `<div style="display:grid;grid-template-columns:1fr 1fr;gap:22px;">` +
182
- // written by
183
- `<div>` +
184
- `<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>` +
185
- `<div style="display:flex;align-items:center;gap:12px;">${avatar(initials(facts.authorName), 42, 12)}` +
186
- `<div><div style="font-size:15px;font-weight:700;">${escapeHtml(facts.authorName)}</div>${subtitle}</div></div>` +
187
- `</div>` +
188
- // reviewed by
290
+ `<div style="display:grid;grid-template-columns:${gridCols};gap:22px;">` +
291
+ authorColumn +
189
292
  `<div>` +
190
- `<div style="font-family:var(--font-mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--navy-35);margin-bottom:11px;">Reviewed by</div>` +
191
- `<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;">` +
192
- `${avatar(initials(facts.reviewerName), 42, 12)}${verificationBlock(facts, true)}` +
293
+ `<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>` +
294
+ revToggle +
193
295
  `</div>` +
194
296
  `</div>` +
195
- `</div>` +
196
- // hairline + mono footer
297
+ // hairline + mono footer — "Certified <date>" + "Compensated expert", UNIFORM navy-55
197
298
  `<div style="height:1px;background:var(--navy-10);margin:16px 0 14px;"></div>` +
198
- `<div style="display:flex;align-items:center;justify-content:space-between;gap:16px;font-family:var(--font-mono);font-size:10.5px;letter-spacing:.13em;text-transform:uppercase;color:var(--navy-55);">` +
199
- `<span>Certified <span style="color:var(--navy);margin-left:6px;">${certified}</span></span>` +
200
- `<span>${escapeHtml(compensationCue)}</span>` +
299
+ `<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);">` +
300
+ `<span>Certified <span style="margin-left:8px;">${certified}</span></span>` +
301
+ cueSpan +
201
302
  `</div>` +
202
303
  `</div>` +
203
304
  `</div>`);
204
305
  }
205
- function bannerMemoCard(facts) {
306
+ function bannerMemoCard(facts, placed) {
206
307
  const scopeLine = locked(facts.scopeLine, CERT_SCOPE_LINE);
207
- const credTail = facts.credential
208
- ? `<span style="color:var(--navy-55);">, ${escapeHtml(facts.credential)}</span>`
308
+ const credTail = placed('bylineCredentialed') && facts.credential
309
+ ? `<span style="color:var(--cr-ink-sub);">, ${escapeHtml(facts.credential)}</span>`
209
310
  : '';
210
311
  const first = firstNameOf(facts.reviewerName);
211
- return (`<div style="border:1px solid var(--navy-10);border-radius:calc(var(--br,14px) * 0.7);padding:24px;font-family:var(--bf);">` +
212
- `<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>` +
213
- `<div style="display:flex;gap:15px;">${avatar(initials(facts.reviewerName), 46, 13)}` +
214
- `<div style="flex:1;min-width:0;">` +
215
- `<div style="font-size:16px;margin-bottom:10px;"><span style="font-weight:700;">${escapeHtml(facts.reviewerName)}</span>${credTail}</div>` +
216
- `<blockquote style="margin:0;padding-left:18px;border-left:3px solid var(--ba,#0a1b3f);font-family:var(--font-serif);font-style:italic;font-size:16px;line-height:1.55;color:var(--navy-75);">${escapeHtml(facts.memo)}</blockquote>` +
217
- `<div style="display:flex;gap:22px;margin-top:16px;">` +
218
- profileLink(facts.profileUrl, `View ${escapeHtml(first)}'s profile`, 'font-size:14px;font-weight:700;color:var(--navy);text-decoration:none;cursor:pointer;') +
219
- certificateLink(facts.certificateUrl, 'View certificate', 'font-size:14px;font-weight:700;color:var(--navy);text-decoration:none;cursor:pointer;') +
220
- `</div>` +
312
+ // The eyebrow + reviewer name row + memo quote are grouped under `memo`; the avatar
313
+ // inside it is gated on `reviewerPhoto`.
314
+ const memoAvatar = placed('reviewerPhoto') ? avatar(initials(facts.reviewerName), 46, 13) : '';
315
+ const memoSection = placed('memo')
316
+ ? `<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>` +
317
+ `<div style="display:flex;gap:15px;">${memoAvatar}` +
318
+ `<div style="flex:1;min-width:0;">` +
319
+ `<div style="font-size:16px;line-height:1.2;color:var(--cr-ink);"><span style="font-weight:700;">${escapeHtml(facts.reviewerName)}</span>${credTail}</div>` +
320
+ // Readable, UPRIGHT DM Sans quote (Owen 2026-07-17: the display-serif italic hurt legibility);
321
+ // pre-line preserves the reviewer's paragraph breaks. No left accent bar (matches the guide).
322
+ `<p style="margin:11px 0 14px;font-size:15.5px;line-height:1.6;color:var(--cr-ink-soft);white-space:pre-line;">${escapeHtml(facts.memo)}</p>` +
323
+ `</div>` +
324
+ `</div>`
325
+ : '';
326
+ // The memo footer's profile/certificate actions read as the JetBrains-mono uppercase stamp
327
+ // that matches the badge footer lockup (Owen 2026-07-17) — navy, so they still read as actions.
328
+ const profile = placed('profileLink')
329
+ ? profileLink(facts.profileUrl, `${escapeHtml(first)}'s Profile`, STAMP_LINK_STYLE)
330
+ : '';
331
+ const certificate = placed('certificateLink')
332
+ ? certificateLink(facts.certificateUrl, 'Certificate', STAMP_LINK_STYLE)
333
+ : '';
334
+ const scope = placed('scopeLine')
335
+ ? `<div style="font-size:13px;color:var(--navy-55);margin-top:8px;line-height:1.5;">${escapeHtml(scopeLine)}</div>`
336
+ : '';
337
+ return (`<div style="border:1px solid var(--navy-10);border-radius:calc(var(--br,14px) * 0.7);padding:24px;font-family:var(--cr-bf);">` +
338
+ memoSection +
339
+ // footer hairline + one trust row: "Verified by CertREV" left, the two actions right
340
+ `<div style="height:1px;background:var(--navy-10);margin:18px 0 14px;"></div>` +
341
+ `<div style="display:flex;align-items:center;justify-content:space-between;gap:16px 22px;flex-wrap:wrap;">` +
342
+ `<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>` +
343
+ `<div style="display:flex;flex-wrap:wrap;align-items:center;gap:10px;">` +
344
+ profile +
345
+ (profile && certificate
346
+ ? '<span aria-hidden="true" style="font-family:var(--font-mono);font-size:10.5px;color:var(--navy-35);">|</span>'
347
+ : '') +
348
+ certificate +
221
349
  `</div>` +
222
350
  `</div>` +
223
- `<div style="height:1px;background:var(--navy-10);margin:18px 0 14px;"></div>` +
224
- `<div style="display:inline-flex;align-items:center;gap:9px;font-size:13px;color:var(--navy-75);">${logo(18, '#0a1b3f')} Verified by <span style="font-family:var(--font-wordmark);font-weight:800;">CertREV</span></div>` +
225
- `<div style="font-size:13px;color:var(--navy-55);margin-top:8px;line-height:1.5;">${escapeHtml(scopeLine)}</div>` +
351
+ scope +
226
352
  `</div>`);
227
353
  }
228
354
  // ─────────────────────────────────────────────────────────────────────────────
229
355
  // Mode 2 — SIDEBAR: a reviewer card pinned beside the article body
230
356
  // ─────────────────────────────────────────────────────────────────────────────
231
- function sidebarCard(facts) {
357
+ function sidebarCard(facts, placed) {
232
358
  const compensationCue = locked(facts.compensationCue, COMPENSATED_EXPERT_CUE);
233
359
  const scopeLine = locked(facts.scopeLine, CERT_SCOPE_LINE);
234
- const bioPara = facts.bio
235
- ? `<p style="font-size:14px;line-height:1.55;color:var(--navy-75);margin:16px 0 0;">${escapeHtml(facts.bio)}</p>`
360
+ const header = placed('label')
361
+ ? `<div style="display:flex;align-items:center;justify-content:center;gap:10px;padding:12px;background:var(--certrev-bar-bg,#0a1b3f);color:var(--certrev-bar-fg,#fff);font-family:var(--font-mono);font-size:12px;font-weight:700;letter-spacing:.16em;text-transform:uppercase;">${logo(18)} Expert reviewed</div>`
362
+ : '';
363
+ const sidebarAvatar = placed('reviewerPhoto') ? avatar(initials(facts.reviewerName), 46, 13) : '';
364
+ const bioPara = placed('bio') && facts.bio
365
+ ? `<p style="font-size:14px;line-height:1.55;color:var(--cr-ink-soft);margin:16px 0 0;">${escapeHtml(facts.bio)}</p>`
236
366
  : '';
237
- return (`<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);">` +
238
- `<div style="display:flex;align-items:center;justify-content:center;gap:10px;padding:12px;background:var(--ba,#0a1b3f);color:var(--ba-fg,#fff);font-family:var(--font-mono);font-size:12px;font-weight:700;letter-spacing:.16em;text-transform:uppercase;">${logo(18, 'currentColor')} Expert reviewed</div>` +
367
+ const blockquote = placed('memo')
368
+ ? `<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(--cr-ink-soft);white-space:pre-line;">${escapeHtml(facts.memo)}</blockquote>`
369
+ : '';
370
+ // Disclosure row: cue + ` · ` + scope; the separator only when BOTH land, the row only when either does.
371
+ const cueEl = placed('compensationCue')
372
+ ? `<span style="font-family:var(--font-mono);font-size:10px;letter-spacing:.1em;text-transform:uppercase;">${escapeHtml(compensationCue)}</span>`
373
+ : '';
374
+ const scopeEl = placed('scopeLine') ? escapeHtml(scopeLine) : '';
375
+ const disclosure = cueEl || scopeEl
376
+ ? `<div style="font-size:12.5px;line-height:1.5;color:var(--navy-55);margin-top:16px;">${cueEl}${cueEl && scopeEl ? ' · ' : ''}${scopeEl}</div>`
377
+ : '';
378
+ // Footer grid: each cell gated on its link; `1fr` (and no border-right on a lone profile) when only one.
379
+ const hasProfile = placed('profileLink');
380
+ const hasCert = placed('certificateLink');
381
+ const profileBorder = hasProfile && hasCert ? 'border-right:1px solid var(--navy-10);' : '';
382
+ const profileCell = hasProfile
383
+ ? profileLink(facts.profileUrl, 'View Profile', `text-align:center;padding:14px;font-size:14px;font-weight:700;color:var(--navy);text-decoration:none;${profileBorder}cursor:pointer;`)
384
+ : '';
385
+ const certCell = hasCert
386
+ ? certificateLink(facts.certificateUrl, 'View Certificate', 'text-align:center;padding:14px;font-size:14px;font-weight:700;color:var(--navy);text-decoration:none;cursor:pointer;')
387
+ : '';
388
+ const footer = hasProfile || hasCert
389
+ ? `<div style="display:grid;grid-template-columns:${hasProfile && hasCert ? '1fr 1fr' : '1fr'};border-top:1px solid var(--navy-10);">` +
390
+ profileCell +
391
+ certCell +
392
+ `</div>`
393
+ : '';
394
+ return (`<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(--cr-bf);">` +
395
+ header +
239
396
  `<div style="padding:20px;">` +
240
- `<div style="display:flex;align-items:flex-start;gap:13px;">${avatar(initials(facts.reviewerName), 46, 13)}${verificationBlock(facts, false)}</div>` +
397
+ `<div style="display:flex;align-items:flex-start;gap:13px;">${sidebarAvatar}${verificationBlock(facts, false, placed)}</div>` +
241
398
  bioPara +
242
- `<blockquote style="margin:16px 0 0;padding-left:16px;border-left:3px solid var(--ba,#0a1b3f);font-family:var(--font-serif);font-style:italic;font-size:15px;line-height:1.5;color:var(--navy-75);">${escapeHtml(facts.memo)}</blockquote>` +
243
- `<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>` +
244
- `</div>` +
245
- `<div style="display:grid;grid-template-columns:1fr 1fr;border-top:1px solid var(--navy-10);">` +
246
- profileLink(facts.profileUrl, 'View Profile', '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;') +
247
- certificateLink(facts.certificateUrl, 'View Certificate', 'text-align:center;padding:14px;font-size:14px;font-weight:700;color:var(--navy);text-decoration:none;cursor:pointer;') +
399
+ blockquote +
400
+ disclosure +
248
401
  `</div>` +
402
+ footer +
249
403
  `</div>`);
250
404
  }
251
405
  // ─────────────────────────────────────────────────────────────────────────────
252
406
  // Mode 3 — FLOATING: a compact pinned pill (a modal pointer, NOT a claim face)
253
407
  // ─────────────────────────────────────────────────────────────────────────────
254
- function floatingPill(facts) {
255
- return (`<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);">` +
256
- `<span style="font-family:var(--font-mono);font-size:11px;font-weight:700;letter-spacing:.14em;text-transform:uppercase;opacity:.75;">Expert reviewed:</span>` +
257
- profileLink(facts.profileUrl, 'Profile', 'font-size:14px;font-weight:700;text-decoration:underline;text-underline-offset:3px;color:inherit;cursor:pointer;') +
258
- `<span style="opacity:.5;">·</span>` +
259
- certificateLink(facts.certificateUrl, 'Certificate', 'font-size:14px;font-weight:700;text-decoration:underline;text-underline-offset:3px;color:inherit;cursor:pointer;') +
408
+ function floatingPill(facts, placed) {
409
+ const label = placed('label')
410
+ ? `<span style="font-family:var(--font-mono);font-size:11px;font-weight:700;letter-spacing:.14em;text-transform:uppercase;opacity:.75;">Expert reviewed:</span>`
411
+ : '';
412
+ const profile = placed('profileLink')
413
+ ? profileLink(facts.profileUrl, 'Profile', 'font-size:14px;font-weight:700;text-decoration:underline;text-underline-offset:3px;color:inherit;cursor:pointer;')
414
+ : '';
415
+ const cert = placed('certificateLink')
416
+ ? certificateLink(facts.certificateUrl, 'Certificate', 'font-size:14px;font-weight:700;text-decoration:underline;text-underline-offset:3px;color:inherit;cursor:pointer;')
417
+ : '';
418
+ // The `·` separator only when BOTH links land.
419
+ const sep = placed('profileLink') && placed('certificateLink') ? `<span style="opacity:.5;">·</span>` : '';
420
+ return (
421
+ // v2 chrome-ink (Phase 1): the minimal-tier pill is the SAME trust surface as the header bar — it
422
+ // follows barInk/barInkFg so a themed brand's compact format matches its card (portal parity; the
423
+ // portal `.certrev-pill` reads the same `--certrev-bar-*` vars). Navy/white fallbacks ⇒ byte-identical
424
+ // when unthemed. NOTE: this moves the pill OFF the accent (`--ba`) it used pre-v2 — see the port report.
425
+ `<div class="certrev-cert__pill" style="display:inline-flex;align-items:center;gap:14px;padding:12px 22px;border-radius:999px;background:var(--certrev-bar-bg,#0a1b3f);color:var(--certrev-bar-fg,#fff);box-shadow:0 10px 26px var(--navy-10);">` +
426
+ label +
427
+ profile +
428
+ sep +
429
+ cert +
430
+ `</div>`);
431
+ }
432
+ // ─────────────────────────────────────────────────────────────────────────────
433
+ // Mode 4 — CUSTOM: the engine's free-composition face — ONE vertical stack of
434
+ // EXACTLY the placed fields, in canonical order, from the SAME escaped / locked() /
435
+ // data-certrev-* building blocks the locked layouts use (so the ftc-guard + crawl
436
+ // monitor see identical markers). Never consults `rung`.
437
+ // ─────────────────────────────────────────────────────────────────────────────
438
+ function renderCustomFace(facts, placed) {
439
+ const parts = [];
440
+ // 1. label — the navy "Expert reviewed" header bar (sidebar's header markup)
441
+ if (placed('label')) {
442
+ parts.push(`<div style="display:flex;align-items:center;justify-content:center;gap:10px;padding:12px;background:var(--certrev-bar-bg,#0a1b3f);color:var(--certrev-bar-fg,#fff);font-family:var(--font-mono);font-size:12px;font-weight:700;letter-spacing:.16em;text-transform:uppercase;">${logo(18)} Expert reviewed</div>`);
443
+ }
444
+ // 2. authorName — the "Written by" row (subtitle nested iff authorTitle placed + present)
445
+ if (placed('authorName')) {
446
+ const subtitle = placed('authorTitle') && facts.authorTitle
447
+ ? `<div style="font-size:13px;color:var(--cr-ink-sub);">${escapeHtml(facts.authorTitle)}</div>`
448
+ : '';
449
+ parts.push(`<div>` +
450
+ `<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>` +
451
+ `<div style="display:flex;align-items:center;gap:12px;">${avatar(initials(facts.authorName), 42, 12)}` +
452
+ `<div><div style="font-size:15px;font-weight:700;color:var(--cr-ink);">${escapeHtml(facts.authorName)}</div>${subtitle}</div></div>` +
453
+ `</div>`);
454
+ }
455
+ // 3. reviewer row — optional photo + the self-gating verification block
456
+ const reviewerAvatar = placed('reviewerPhoto') ? avatar(initials(facts.reviewerName), 46, 13) : '';
457
+ const reviewerBlock = verificationBlock(facts, false, placed);
458
+ if (reviewerAvatar || reviewerBlock) {
459
+ parts.push(`<div style="display:flex;align-items:flex-start;gap:13px;">${reviewerAvatar}${reviewerBlock}</div>`);
460
+ }
461
+ // 4. bio (sidebar's bio paragraph)
462
+ if (placed('bio') && facts.bio) {
463
+ parts.push(`<p style="font-size:14px;line-height:1.55;color:var(--cr-ink-soft);margin:16px 0 0;">${escapeHtml(facts.bio)}</p>`);
464
+ }
465
+ // 5. memo (sidebar's blockquote)
466
+ if (placed('memo')) {
467
+ parts.push(`<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(--cr-ink-soft);white-space:pre-line;">${escapeHtml(facts.memo)}</blockquote>`);
468
+ }
469
+ // 6. disclosures (sidebar's cue · scope row; separator only when BOTH land)
470
+ const cueEl = placed('compensationCue')
471
+ ? `<span style="font-family:var(--font-mono);font-size:10px;letter-spacing:.1em;text-transform:uppercase;">${escapeHtml(locked(facts.compensationCue, COMPENSATED_EXPERT_CUE))}</span>`
472
+ : '';
473
+ const scopeEl = placed('scopeLine') ? escapeHtml(locked(facts.scopeLine, CERT_SCOPE_LINE)) : '';
474
+ if (cueEl || scopeEl) {
475
+ parts.push(`<div style="font-size:12.5px;line-height:1.5;color:var(--navy-55);margin-top:16px;">${cueEl}${cueEl && scopeEl ? ' · ' : ''}${scopeEl}</div>`);
476
+ }
477
+ // 7. action row — the memo-card stamp links (same data-certrev-modal-open hooks)
478
+ const first = firstNameOf(facts.reviewerName);
479
+ const profile = placed('profileLink')
480
+ ? profileLink(facts.profileUrl, `${escapeHtml(first)}'s Profile`, STAMP_LINK_STYLE)
481
+ : '';
482
+ const certificate = placed('certificateLink')
483
+ ? certificateLink(facts.certificateUrl, 'Certificate', STAMP_LINK_STYLE)
484
+ : '';
485
+ if (profile || certificate) {
486
+ parts.push(`<div style="display:flex;flex-wrap:wrap;gap:22px;">${profile}${certificate}</div>`);
487
+ }
488
+ return (`<div style="border:1px solid var(--navy-10);border-radius:var(--br,14px);background:var(--surface);padding:20px;font-family:var(--cr-bf);display:flex;flex-direction:column;gap:14px;">` +
489
+ parts.join('') +
260
490
  `</div>`);
261
491
  }
262
492
  // ─────────────────────────────────────────────────────────────────────────────
@@ -269,14 +499,32 @@ function floatingPill(facts) {
269
499
  */
270
500
  export function renderCertBlock(input) {
271
501
  const theme = resolveBlockTheme(input.theme);
272
- const { facts, mode } = input;
502
+ const { facts, mode, face } = input;
503
+ const placed = placedTester(face);
273
504
  switch (mode) {
274
- case 'banner':
275
- return root(mode, theme, bannerHeaderCard(facts) + bannerMemoCard(facts));
505
+ case 'banner': {
506
+ // Suppress the memo card entirely when the engine placed none of its fields
507
+ // (avoids an empty bordered shell). Grandfather: all placed ⇒ card renders.
508
+ const memoCard = placed('memo') || placed('profileLink') || placed('certificateLink') || placed('scopeLine')
509
+ ? bannerMemoCard(facts, placed)
510
+ : '';
511
+ // First-class memo split (POR-10741): default 'full' is byte-identical to the prior
512
+ // header+memo render; 'header'/'memo' emit only that card so the exporter can place
513
+ // the memo as its own block (retires the CertReviewCard string-split shim).
514
+ const part = input.part ?? 'full';
515
+ const inner = part === 'header'
516
+ ? bannerHeaderCard(facts, placed)
517
+ : part === 'memo'
518
+ ? memoCard
519
+ : bannerHeaderCard(facts, placed) + memoCard;
520
+ return root(mode, theme, inner, face);
521
+ }
276
522
  case 'sidebar':
277
- return root(mode, theme, sidebarCard(facts));
523
+ return root(mode, theme, sidebarCard(facts, placed), face);
278
524
  case 'floating':
279
- return root(mode, theme, floatingPill(facts));
525
+ return root(mode, theme, floatingPill(facts, placed), face);
526
+ case 'custom':
527
+ return root(mode, theme, renderCustomFace(facts, placed), face);
280
528
  }
281
529
  }
282
530
  //# sourceMappingURL=render-cert-block.js.map