@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
 
40
57
  import { escapeAttribute, escapeHtml, safeHttpUrl } from './escape.js'
@@ -52,6 +69,21 @@ import {
52
69
  /** The three LOCKED placement faces. */
53
70
  export type CertBlockMode = 'banner' | 'sidebar' | 'floating'
54
71
 
72
+ /** All layouts renderCertBlock can paint: the three locked modes + the engine's free-composition face. */
73
+ export type CertBlockLayout = CertBlockMode | 'custom'
74
+
75
+ /**
76
+ * The portal engine's resolved face (assertCompliantRender output). `rung` is
77
+ * INFORMATIONAL ONLY — stamped as data, NEVER branched on for visibility.
78
+ * `placedFields` is the single source of what renders. Plain strings (not a closed
79
+ * union) so a future engine field id degrades to "not painted here" instead of a
80
+ * type break.
81
+ */
82
+ export interface CertBlockFace {
83
+ readonly rung: string
84
+ readonly placedFields: readonly string[]
85
+ }
86
+
55
87
  /**
56
88
  * The cert facts + LOCKED display strings a face renders. Facts are escaped; the
57
89
  * three locked strings (`compensationCue`, `scopeLine`, `credentialLine`) are
@@ -89,10 +121,25 @@ export interface CertBlockFacts {
89
121
 
90
122
  /** The full input to `renderCertBlock`. */
91
123
  export interface RenderCertBlockInput {
92
- readonly mode: CertBlockMode
124
+ readonly mode: CertBlockLayout
93
125
  /** RESOLVED theme tokens (the portal bakes these). Absent/invalid tokens fall back to navy. */
94
126
  readonly theme?: Partial<ResolvedBlockTheme>
95
127
  readonly facts: CertBlockFacts
128
+ /**
129
+ * OPTIONAL engine face. ABSENT ⇒ grandfathered full-facts render, byte-identical
130
+ * to 0.3.0 (published callers unaffected). PRESENT ⇒ strict placedFields render.
131
+ */
132
+ readonly face?: CertBlockFace
133
+ /**
134
+ * BANNER memo placement (POR-10741 — first-class split, retires the CertReviewCard
135
+ * string-hack). Default `'full'` = header card + memo card in ONE root, BYTE-IDENTICAL
136
+ * to prior behavior. `'header'` / `'memo'` render ONLY that card in its own themed root,
137
+ * so a headless exporter can place the memo as its OWN block under the article body
138
+ * (the "banner → body → memo" layout) WITHOUT string-splitting the rendered HTML — and
139
+ * without duplicating the options payload (each block carries only what its card needs).
140
+ * Ignored outside banner mode.
141
+ */
142
+ readonly part?: 'full' | 'header' | 'memo'
96
143
  }
97
144
 
98
145
  // ─────────────────────────────────────────────────────────────────────────────
@@ -108,16 +155,14 @@ const LOGO_PATHS = [
108
155
  ]
109
156
 
110
157
  /**
111
- * The 4-chevron CertREV mark at `size`px, filled `fill` (a controlled literal). The
112
- * navy/pill HEADERS pass `currentColor` so the mark tracks the header text's accent
113
- * foreground ink (`--ba-fg`) — white on the navy default, and the dark auto-contrast
114
- * ink on a light themed accent (so it never goes white-on-light). The memo credit
115
- * passes the fixed navy `#0a1b3f` (it sits on the white surface).
158
+ * The 4-chevron CertREV mark at `size`px, always filled `currentColor` so it tracks
159
+ * its container's ink — white on the hardcoded-navy header, and the muted navy-55 of
160
+ * the memo footer's "Verified by CertREV" lockup (it sits on the white surface).
116
161
  */
117
- function logo(size: number, fill: 'currentColor' | '#0a1b3f'): string {
162
+ function logo(size: number): string {
118
163
  return (
119
164
  `<svg viewBox="0 0 375 375" width="${size}" height="${size}" style="flex-shrink:0" aria-label="CertREV">` +
120
- LOGO_PATHS.map((d) => `<path fill="${fill}" d="${d}"></path>`).join('') +
165
+ LOGO_PATHS.map((d) => `<path fill="currentColor" d="${d}"></path>`).join('') +
121
166
  `</svg>`
122
167
  )
123
168
  }
@@ -131,9 +176,23 @@ const CARET =
131
176
  // Small pure helpers
132
177
  // ─────────────────────────────────────────────────────────────────────────────
133
178
 
134
- /** "Jane Doe" → "JD"; "The Editorial Team" → "TE" (first letter of the first two words). */
179
+ /**
180
+ * Strip a leading honorific ("Dr. Jane Smith" → "Jane Smith") so the initials + first-name token don't
181
+ * capture it — matches the Liquid twin, which removes a matched honorific so "Dr. Marcus Ellison" yields
182
+ * the "ME" avatar + "Marcus's profile", never "DM" / "Dr.'s profile".
183
+ */
184
+ function stripHonorific(name: string): string {
185
+ return (
186
+ name
187
+ .trim()
188
+ .replace(/^(dr|prof|professor|mr|mrs|ms|mx|rev|sir|dame)\.?\s+/i, '')
189
+ .trim() || name.trim()
190
+ )
191
+ }
192
+
193
+ /** "Jane Doe" → "JD"; "Dr. Marcus Ellison" → "ME" (first letter of the first two words, honorific stripped). */
135
194
  function initials(name: string): string {
136
- const parts = name.trim().split(/\s+/).filter(Boolean)
195
+ const parts = stripHonorific(name).split(/\s+/).filter(Boolean)
137
196
  const chars = parts
138
197
  .slice(0, 2)
139
198
  .map((p) => p[0] ?? '')
@@ -141,10 +200,10 @@ function initials(name: string): string {
141
200
  return (chars || '?').toUpperCase()
142
201
  }
143
202
 
144
- /** The first name token (for "View Jane's profile"). */
203
+ /** The first name token (for "View Jane's profile") — honorific stripped. */
145
204
  function firstNameOf(name: string): string {
146
- const first = name.trim().split(/\s+/)[0]
147
- return first || name.trim()
205
+ const clean = stripHonorific(name)
206
+ return clean.split(/\s+/)[0] || clean
148
207
  }
149
208
 
150
209
  /**
@@ -157,10 +216,30 @@ function locked(supplied: string | undefined, constant: string): string {
157
216
  return supplied?.trim() ? supplied : constant
158
217
  }
159
218
 
219
+ /** Field-placement predicate every renderer consults. */
220
+ type PlacedTest = (fieldId: string) => boolean
221
+
222
+ /**
223
+ * Absent face ⇒ constant-true (the grandfather path: every conditional collapses to
224
+ * today's markup, guaranteeing byte-identity with 0.3.0). Present ⇒ Set membership.
225
+ */
226
+ function placedTester(face?: CertBlockFace): PlacedTest {
227
+ if (!face) return () => true
228
+ const set = new Set(face.placedFields)
229
+ return (id) => set.has(id)
230
+ }
231
+
232
+ /**
233
+ * The memo/custom action-link stamp style — the JetBrains-mono uppercase lockup that
234
+ * matches the badge footer (Owen 2026-07-17); navy so it still reads as an action.
235
+ */
236
+ const STAMP_LINK_STYLE =
237
+ '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;'
238
+
160
239
  /** A round avatar tile with the given initials. */
161
240
  function avatar(text: string, size: number, fontSize: number): string {
162
241
  return (
163
- `<div style="width:${size}px;height:${size}px;border-radius:50%;background:var(--stone);color:var(--navy-55);` +
242
+ `<div style="width:${size}px;height:${size}px;border-radius:50%;background:var(--stone);color:var(--cr-ink-sub);` +
164
243
  `display:flex;align-items:center;justify-content:center;font-family:var(--font-mono);font-weight:700;font-size:${fontSize}px;flex-shrink:0;">` +
165
244
  `${escapeHtml(text)}</div>`
166
245
  )
@@ -189,21 +268,33 @@ function certificateLink(url: string, inner: string, style: string): string {
189
268
  * name, then the attribution line, then "on <date>" — one block, order preserved.
190
269
  * `withAccordion` adds the bio caret + hidden bio (banner) as progressive enhancement.
191
270
  */
192
- function verificationBlock(facts: CertBlockFacts, withAccordion: boolean): string {
271
+ function verificationBlock(facts: CertBlockFacts, withAccordion: boolean, placed: PlacedTest): string {
272
+ const showCredentialed = placed('bylineCredentialed')
273
+ const showName = placed('bylinePlain') || showCredentialed
274
+ // Nothing placed ⇒ empty (the caller drops the wrapping toggle div).
275
+ if (!showName) return ''
193
276
  const credentialLine = locked(facts.credentialLine, CREDENTIAL_VERIFIED_ATTRIBUTION)
194
277
  const verified = formatDate(facts.credentialVerifiedAt) ?? escapeHtml(facts.credentialVerifiedAt)
195
- const caret = withAccordion && facts.bio ? CARET : ''
278
+ const showBio = withAccordion && placed('bio') && !!facts.bio
279
+ const caret = showBio ? CARET : ''
196
280
  const nameSize = withAccordion ? '15px' : '16px'
197
281
  const muteSize = withAccordion ? '13px' : '12.5px'
198
- const bio =
199
- withAccordion && facts.bio
200
- ? `<div class="certrev-cert__bio" hidden style="display:none;margin-top:8px;font-size:12.5px;line-height:1.5;color:var(--navy-75);">${escapeHtml(facts.bio)}</div>`
201
- : ''
282
+ const bio = showBio
283
+ ? `<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>`
284
+ : ''
285
+ // The credential suffix + the GROUPED attribution/date lines are all-or-nothing on
286
+ // `bylineCredentialed`; a plain byline shows the name only.
287
+ const nameInner = showCredentialed
288
+ ? nameWithCredential(facts.reviewerName, facts.credential)
289
+ : escapeHtml(facts.reviewerName)
290
+ const attribution = showCredentialed
291
+ ? `<div style="font-size:${muteSize};color:var(--navy-55);line-height:1.45;margin-top:3px;">${escapeHtml(credentialLine)}</div>` +
292
+ `<div style="font-size:${muteSize};color:var(--navy-55);line-height:1.35;">on ${verified}</div>`
293
+ : ''
202
294
  return (
203
295
  `<div>` +
204
- `<div style="font-size:${nameSize};font-weight:700;">${nameWithCredential(facts.reviewerName, facts.credential)}${caret}</div>` +
205
- `<div style="font-size:${muteSize};color:var(--navy-55);line-height:1.45;margin-top:3px;">${escapeHtml(credentialLine)}</div>` +
206
- `<div style="font-size:${muteSize};color:var(--navy-55);line-height:1.35;">on ${verified}</div>` +
296
+ `<div style="font-size:${nameSize};font-weight:700;color:var(--cr-ink);">${nameInner}${caret}</div>` +
297
+ attribution +
207
298
  bio +
208
299
  `</div>`
209
300
  )
@@ -217,12 +308,24 @@ function rootStyle(theme: ResolvedBlockTheme): string {
217
308
  // Data-bearing theme tokens are already strict-validated (hex / length / literal
218
309
  // ink); the font is a controlled enum → stack literal. The navy tints + fixed
219
310
  // label/serif/wordmark fonts are LOCKED literals.
311
+ const ink = theme.inkColor
220
312
  return [
221
313
  `--ba:${theme.accentColor}`,
222
314
  `--ba-fg:${theme.accentFg}`,
223
315
  `--br:${theme.cornerRadius}`,
224
- `--bf:${RENDER_BLOCK_FONT_STACKS[theme.fontSlot]}`,
316
+ // The body font is a host-overridable HOOK (POR-10742): `--cr-bf` resolves a host-page
317
+ // `--bf` (set on any ancestor) with the fontSlot stack as the FALLBACK — so a storefront
318
+ // can theme the card font via plain CSS, no `!important` (the old inline `--bf:<stack>`
319
+ // declaration shadowed a host `--bf`). Unset ⇒ resolves to the stack ⇒ byte-identical render.
320
+ `--cr-bf:var(--bf,${RENDER_BLOCK_FONT_STACKS[theme.fontSlot]})`,
225
321
  `--surface:${theme.surface}`,
322
+ // v2 chrome-ink (Phase 1): the header/seal bar FILL + its auto-contrast FOREGROUND — the exact
323
+ // `--certrev-bar-*` wire vars the Shopify/WP engines emit, present ONLY on a themed bar. ABSENT ⇒
324
+ // the consumers' `var(--certrev-bar-bg, #0a1b3f)` / `var(--certrev-bar-fg, #fff)` navy/white
325
+ // fallbacks resolve — byte-identical to the un-themed bar.
326
+ ...(theme.barInk && theme.barInkFg
327
+ ? [`--certrev-bar-bg:${theme.barInk}`, `--certrev-bar-fg:${theme.barInkFg}`]
328
+ : []),
226
329
  `--navy:#0a1b3f`,
227
330
  `--navy-06:rgba(10,27,63,.06)`,
228
331
  `--navy-10:rgba(10,27,63,.10)`,
@@ -230,18 +333,29 @@ function rootStyle(theme: ResolvedBlockTheme): string {
230
333
  `--navy-55:rgba(10,27,63,.55)`,
231
334
  `--navy-75:rgba(10,27,63,.75)`,
232
335
  `--stone:#ece9e1`,
336
+ // v2 chrome-ink (Phase 1): the FREE-content trust-ink family (names/title/credential/bio/
337
+ // initials). `--certrev-ink` is the wire var, emitted ONLY when the def sets `inkColor`; `--cr-ink`
338
+ // consumes it with a navy fallback, so an un-inkColor'd face resolves to navy. The `-sub`/`-soft`
339
+ // muted scale is the EXACT navy literal when unthemed (byte-identical, zero color-mix dependency)
340
+ // and a color-mix derivation off the brand ink when themed. The LOCKED trust text keeps `--navy*`
341
+ // and never this family, so a brand ink can never touch a legal disclosure.
342
+ ...(ink ? [`--certrev-ink:${ink}`] : []),
343
+ `--cr-ink:var(--certrev-ink,#0a1b3f)`,
344
+ `--cr-ink-sub:${ink ? 'color-mix(in srgb,var(--cr-ink) 55%,transparent)' : 'rgba(10,27,63,.55)'}`,
345
+ `--cr-ink-soft:${ink ? 'color-mix(in srgb,var(--cr-ink) 75%,transparent)' : 'rgba(10,27,63,.75)'}`,
233
346
  `--font-mono:${RENDER_BLOCK_FONT_STACKS.mono}`,
234
- `--font-serif:${RENDER_BLOCK_FONT_STACKS.serif}`,
235
- `--font-wordmark:${RENDER_BLOCK_FONT_STACKS.grotesk}`,
236
- `font-family:var(--bf)`,
347
+ `font-family:var(--cr-bf)`,
237
348
  `color:var(--navy)`,
238
349
  ].join(';')
239
350
  }
240
351
 
241
- function root(mode: CertBlockMode, theme: ResolvedBlockTheme, inner: string): string {
352
+ function root(mode: CertBlockLayout, theme: ResolvedBlockTheme, inner: string, face?: CertBlockFace): string {
353
+ // The rung is an OBSERVABILITY stamp for the crawl monitor — only emitted when a
354
+ // face is present, so the grandfathered (face-absent) output stays byte-identical.
355
+ const rungAttr = face ? ` data-certrev-rung="${escapeAttribute(face.rung)}"` : ''
242
356
  return (
243
357
  `<div class="certrev-cert certrev-cert--${mode}" data-certrev-mode="${mode}" ` +
244
- `data-certrev-strings-version="${CERT_STRINGS_VERSION}" style="${rootStyle(theme)}">${inner}</div>`
358
+ `data-certrev-strings-version="${CERT_STRINGS_VERSION}"${rungAttr} style="${rootStyle(theme)}">${inner}</div>`
245
359
  )
246
360
  }
247
361
 
@@ -249,75 +363,118 @@ function root(mode: CertBlockMode, theme: ResolvedBlockTheme, inner: string): st
249
363
  // Mode 1 — BANNER: a certification header card + a separate expert-memo card
250
364
  // ─────────────────────────────────────────────────────────────────────────────
251
365
 
252
- function bannerHeaderCard(facts: CertBlockFacts): string {
366
+ function bannerHeaderCard(facts: CertBlockFacts, placed: PlacedTest): string {
253
367
  const compensationCue = locked(facts.compensationCue, COMPENSATED_EXPERT_CUE)
254
368
  const certified = formatDate(facts.certifiedAt) ?? escapeHtml(facts.certifiedAt)
255
- const subtitle = facts.authorTitle
256
- ? `<div style="font-size:13px;color:var(--navy-55);">${escapeHtml(facts.authorTitle)}</div>`
369
+ const subtitle =
370
+ placed('authorTitle') && facts.authorTitle
371
+ ? `<div style="font-size:13px;color:var(--cr-ink-sub);">${escapeHtml(facts.authorTitle)}</div>`
372
+ : ''
373
+ // A subtle right-aligned arrow opens the certificate: the whole navy bar is the mouse
374
+ // target (data-certrev-modal-open); this focusable <a> is the keyboard / SR / no-JS control.
375
+ const certHref = safeHttpUrl(facts.certificateUrl) ?? '#'
376
+ const headerArrow =
377
+ `<a href="${escapeAttribute(certHref)}" data-certrev-modal-open rel="noopener" aria-label="View certificate" ` +
378
+ `style="display:inline-flex;align-items:center;flex-shrink:0;color:inherit;opacity:.72;text-decoration:none;">` +
379
+ `<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>`
380
+ // navy header — the trust bar stays fixed navy (accent theming is Phase B); mark + eyebrow + cert arrow
381
+ const header = placed('label')
382
+ ? `<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;">` +
383
+ `<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>` +
384
+ headerArrow +
385
+ `</div>`
257
386
  : ''
387
+ // written by — muted label (navy-35); the whole author column is gated on `authorName`
388
+ const showAuthor = placed('authorName')
389
+ const authorColumn = showAuthor
390
+ ? `<div>` +
391
+ `<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>` +
392
+ `<div style="display:flex;align-items:center;gap:12px;">${avatar(initials(facts.authorName), 42, 12)}` +
393
+ `<div><div style="font-size:15px;font-weight:700;color:var(--cr-ink);">${escapeHtml(facts.authorName)}</div>${subtitle}</div></div>` +
394
+ `</div>`
395
+ : ''
396
+ // grid collapses to a single reviewer column when the author is absent
397
+ const gridCols = showAuthor ? '1fr 1fr' : '1fr'
398
+ // reviewed by — emphasized label (navy, 700): the certification's authority voice
399
+ const reviewerAvatar = placed('reviewerPhoto') ? avatar(initials(facts.reviewerName), 42, 12) : ''
400
+ const reviewerBlock = verificationBlock(facts, true, placed)
401
+ const revToggle = reviewerBlock
402
+ ? `<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;">` +
403
+ `${reviewerAvatar}${reviewerBlock}` +
404
+ `</div>`
405
+ : ''
406
+ // "Certified <date>" is card chrome (renders with the card); the cue is engine-placed
407
+ const cueSpan = placed('compensationCue') ? `<span>${escapeHtml(compensationCue)}</span>` : ''
258
408
  return (
259
- `<div style="border:1px solid var(--navy-10);border-radius:calc(var(--br,14px) * 0.7);overflow:hidden;font-family:var(--bf);">` +
260
- // navy header
261
- `<div style="display:flex;align-items:center;justify-content:space-between;gap:16px;padding:15px 22px;background:var(--ba,#0a1b3f);color:var(--ba-fg,#fff);">` +
262
- `<span style="display:inline-flex;align-items:center;gap:11px;font-family:var(--font-mono);font-size:12px;font-weight:700;letter-spacing:.14em;text-transform:uppercase;">${logo(20, 'currentColor')} Expert reviewed</span>` +
263
- `</div>` +
409
+ `<div style="border:1px solid var(--navy-10);border-radius:calc(var(--br,14px) * 0.7);overflow:hidden;font-family:var(--cr-bf);">` +
410
+ header +
264
411
  `<div style="padding:22px 24px 18px;">` +
265
- `<div style="display:grid;grid-template-columns:1fr 1fr;gap:22px;">` +
266
- // written by
267
- `<div>` +
268
- `<div style="font-family:var(--font-mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--navy-35);margin-bottom:11px;">Written by</div>` +
269
- `<div style="display:flex;align-items:center;gap:12px;">${avatar(initials(facts.authorName), 42, 12)}` +
270
- `<div><div style="font-size:15px;font-weight:700;">${escapeHtml(facts.authorName)}</div>${subtitle}</div></div>` +
271
- `</div>` +
272
- // reviewed by
412
+ `<div style="display:grid;grid-template-columns:${gridCols};gap:22px;">` +
413
+ authorColumn +
273
414
  `<div>` +
274
- `<div style="font-family:var(--font-mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--navy-35);margin-bottom:11px;">Reviewed by</div>` +
275
- `<div class="certrev-cert__rev" role="button" tabindex="0" aria-expanded="false" data-certrev-bio-toggle aria-label="Read more about ${escapeAttribute(facts.reviewerName)}" style="display:flex;align-items:flex-start;gap:12px;cursor:pointer;border-radius:8px;transition:opacity .15s;">` +
276
- `${avatar(initials(facts.reviewerName), 42, 12)}${verificationBlock(facts, true)}` +
415
+ `<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>` +
416
+ revToggle +
277
417
  `</div>` +
278
418
  `</div>` +
279
- `</div>` +
280
- // hairline + mono footer
419
+ // hairline + mono footer — "Certified <date>" + "Compensated expert", UNIFORM navy-55
281
420
  `<div style="height:1px;background:var(--navy-10);margin:16px 0 14px;"></div>` +
282
- `<div style="display:flex;align-items:center;justify-content:space-between;gap:16px;font-family:var(--font-mono);font-size:10.5px;letter-spacing:.13em;text-transform:uppercase;color:var(--navy-55);">` +
283
- `<span>Certified <span style="color:var(--navy);margin-left:6px;">${certified}</span></span>` +
284
- `<span>${escapeHtml(compensationCue)}</span>` +
421
+ `<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);">` +
422
+ `<span>Certified <span style="margin-left:8px;">${certified}</span></span>` +
423
+ cueSpan +
285
424
  `</div>` +
286
425
  `</div>` +
287
426
  `</div>`
288
427
  )
289
428
  }
290
429
 
291
- function bannerMemoCard(facts: CertBlockFacts): string {
430
+ function bannerMemoCard(facts: CertBlockFacts, placed: PlacedTest): string {
292
431
  const scopeLine = locked(facts.scopeLine, CERT_SCOPE_LINE)
293
- const credTail = facts.credential
294
- ? `<span style="color:var(--navy-55);">, ${escapeHtml(facts.credential)}</span>`
295
- : ''
432
+ const credTail =
433
+ placed('bylineCredentialed') && facts.credential
434
+ ? `<span style="color:var(--cr-ink-sub);">, ${escapeHtml(facts.credential)}</span>`
435
+ : ''
296
436
  const first = firstNameOf(facts.reviewerName)
437
+ // The eyebrow + reviewer name row + memo quote are grouped under `memo`; the avatar
438
+ // inside it is gated on `reviewerPhoto`.
439
+ const memoAvatar = placed('reviewerPhoto') ? avatar(initials(facts.reviewerName), 46, 13) : ''
440
+ const memoSection = placed('memo')
441
+ ? `<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>` +
442
+ `<div style="display:flex;gap:15px;">${memoAvatar}` +
443
+ `<div style="flex:1;min-width:0;">` +
444
+ `<div style="font-size:16px;line-height:1.2;color:var(--cr-ink);"><span style="font-weight:700;">${escapeHtml(facts.reviewerName)}</span>${credTail}</div>` +
445
+ // Readable, UPRIGHT DM Sans quote (Owen 2026-07-17: the display-serif italic hurt legibility);
446
+ // pre-line preserves the reviewer's paragraph breaks. No left accent bar (matches the guide).
447
+ `<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>` +
448
+ `</div>` +
449
+ `</div>`
450
+ : ''
451
+ // The memo footer's profile/certificate actions read as the JetBrains-mono uppercase stamp
452
+ // that matches the badge footer lockup (Owen 2026-07-17) — navy, so they still read as actions.
453
+ const profile = placed('profileLink')
454
+ ? profileLink(facts.profileUrl, `${escapeHtml(first)}'s Profile`, STAMP_LINK_STYLE)
455
+ : ''
456
+ const certificate = placed('certificateLink')
457
+ ? certificateLink(facts.certificateUrl, 'Certificate', STAMP_LINK_STYLE)
458
+ : ''
459
+ const scope = placed('scopeLine')
460
+ ? `<div style="font-size:13px;color:var(--navy-55);margin-top:8px;line-height:1.5;">${escapeHtml(scopeLine)}</div>`
461
+ : ''
297
462
  return (
298
- `<div style="border:1px solid var(--navy-10);border-radius:calc(var(--br,14px) * 0.7);padding:24px;font-family:var(--bf);">` +
299
- `<div style="font-family:var(--font-mono);font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:var(--navy-55);margin-bottom:14px;">Expert memo</div>` +
300
- `<div style="display:flex;gap:15px;">${avatar(initials(facts.reviewerName), 46, 13)}` +
301
- `<div style="flex:1;min-width:0;">` +
302
- `<div style="font-size:16px;margin-bottom:10px;"><span style="font-weight:700;">${escapeHtml(facts.reviewerName)}</span>${credTail}</div>` +
303
- `<blockquote style="margin:0;padding-left:18px;border-left:3px solid var(--ba,#0a1b3f);font-family:var(--font-serif);font-style:italic;font-size:16px;line-height:1.55;color:var(--navy-75);">${escapeHtml(facts.memo)}</blockquote>` +
304
- `<div style="display:flex;gap:22px;margin-top:16px;">` +
305
- profileLink(
306
- facts.profileUrl,
307
- `View ${escapeHtml(first)}'s profile`,
308
- 'font-size:14px;font-weight:700;color:var(--navy);text-decoration:none;cursor:pointer;',
309
- ) +
310
- certificateLink(
311
- facts.certificateUrl,
312
- 'View certificate',
313
- 'font-size:14px;font-weight:700;color:var(--navy);text-decoration:none;cursor:pointer;',
314
- ) +
315
- `</div>` +
463
+ `<div style="border:1px solid var(--navy-10);border-radius:calc(var(--br,14px) * 0.7);padding:24px;font-family:var(--cr-bf);">` +
464
+ memoSection +
465
+ // footer hairline + one trust row: "Verified by CertREV" left, the two actions right
466
+ `<div style="height:1px;background:var(--navy-10);margin:18px 0 14px;"></div>` +
467
+ `<div style="display:flex;align-items:center;justify-content:space-between;gap:16px 22px;flex-wrap:wrap;">` +
468
+ `<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>` +
469
+ `<div style="display:flex;flex-wrap:wrap;align-items:center;gap:10px;">` +
470
+ profile +
471
+ (profile && certificate
472
+ ? '<span aria-hidden="true" style="font-family:var(--font-mono);font-size:10.5px;color:var(--navy-35);">|</span>'
473
+ : '') +
474
+ certificate +
316
475
  `</div>` +
317
476
  `</div>` +
318
- `<div style="height:1px;background:var(--navy-10);margin:18px 0 14px;"></div>` +
319
- `<div style="display:inline-flex;align-items:center;gap:9px;font-size:13px;color:var(--navy-75);">${logo(18, '#0a1b3f')} Verified by <span style="font-family:var(--font-wordmark);font-weight:800;">CertREV</span></div>` +
320
- `<div style="font-size:13px;color:var(--navy-55);margin-top:8px;line-height:1.5;">${escapeHtml(scopeLine)}</div>` +
477
+ scope +
321
478
  `</div>`
322
479
  )
323
480
  }
@@ -326,33 +483,64 @@ function bannerMemoCard(facts: CertBlockFacts): string {
326
483
  // Mode 2 — SIDEBAR: a reviewer card pinned beside the article body
327
484
  // ─────────────────────────────────────────────────────────────────────────────
328
485
 
329
- function sidebarCard(facts: CertBlockFacts): string {
486
+ function sidebarCard(facts: CertBlockFacts, placed: PlacedTest): string {
330
487
  const compensationCue = locked(facts.compensationCue, COMPENSATED_EXPERT_CUE)
331
488
  const scopeLine = locked(facts.scopeLine, CERT_SCOPE_LINE)
332
- const bioPara = facts.bio
333
- ? `<p style="font-size:14px;line-height:1.55;color:var(--navy-75);margin:16px 0 0;">${escapeHtml(facts.bio)}</p>`
489
+ const header = placed('label')
490
+ ? `<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>`
491
+ : ''
492
+ const sidebarAvatar = placed('reviewerPhoto') ? avatar(initials(facts.reviewerName), 46, 13) : ''
493
+ const bioPara =
494
+ placed('bio') && facts.bio
495
+ ? `<p style="font-size:14px;line-height:1.55;color:var(--cr-ink-soft);margin:16px 0 0;">${escapeHtml(facts.bio)}</p>`
496
+ : ''
497
+ const blockquote = placed('memo')
498
+ ? `<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>`
499
+ : ''
500
+ // Disclosure row: cue + ` · ` + scope; the separator only when BOTH land, the row only when either does.
501
+ const cueEl = placed('compensationCue')
502
+ ? `<span style="font-family:var(--font-mono);font-size:10px;letter-spacing:.1em;text-transform:uppercase;">${escapeHtml(compensationCue)}</span>`
503
+ : ''
504
+ const scopeEl = placed('scopeLine') ? escapeHtml(scopeLine) : ''
505
+ const disclosure =
506
+ cueEl || scopeEl
507
+ ? `<div style="font-size:12.5px;line-height:1.5;color:var(--navy-55);margin-top:16px;">${cueEl}${cueEl && scopeEl ? ' · ' : ''}${scopeEl}</div>`
508
+ : ''
509
+ // Footer grid: each cell gated on its link; `1fr` (and no border-right on a lone profile) when only one.
510
+ const hasProfile = placed('profileLink')
511
+ const hasCert = placed('certificateLink')
512
+ const profileBorder = hasProfile && hasCert ? 'border-right:1px solid var(--navy-10);' : ''
513
+ const profileCell = hasProfile
514
+ ? profileLink(
515
+ facts.profileUrl,
516
+ 'View Profile',
517
+ `text-align:center;padding:14px;font-size:14px;font-weight:700;color:var(--navy);text-decoration:none;${profileBorder}cursor:pointer;`,
518
+ )
519
+ : ''
520
+ const certCell = hasCert
521
+ ? certificateLink(
522
+ facts.certificateUrl,
523
+ 'View Certificate',
524
+ 'text-align:center;padding:14px;font-size:14px;font-weight:700;color:var(--navy);text-decoration:none;cursor:pointer;',
525
+ )
334
526
  : ''
527
+ const footer =
528
+ hasProfile || hasCert
529
+ ? `<div style="display:grid;grid-template-columns:${hasProfile && hasCert ? '1fr 1fr' : '1fr'};border-top:1px solid var(--navy-10);">` +
530
+ profileCell +
531
+ certCell +
532
+ `</div>`
533
+ : ''
335
534
  return (
336
- `<div style="background:var(--surface);border:1px solid var(--navy-10);border-radius:var(--br,14px);overflow:hidden;box-shadow:0 4px 18px var(--navy-06);font-family:var(--bf);">` +
337
- `<div style="display:flex;align-items:center;justify-content:center;gap:10px;padding:12px;background:var(--ba,#0a1b3f);color:var(--ba-fg,#fff);font-family:var(--font-mono);font-size:12px;font-weight:700;letter-spacing:.16em;text-transform:uppercase;">${logo(18, 'currentColor')} Expert reviewed</div>` +
535
+ `<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);">` +
536
+ header +
338
537
  `<div style="padding:20px;">` +
339
- `<div style="display:flex;align-items:flex-start;gap:13px;">${avatar(initials(facts.reviewerName), 46, 13)}${verificationBlock(facts, false)}</div>` +
538
+ `<div style="display:flex;align-items:flex-start;gap:13px;">${sidebarAvatar}${verificationBlock(facts, false, placed)}</div>` +
340
539
  bioPara +
341
- `<blockquote style="margin:16px 0 0;padding-left:16px;border-left:3px solid var(--ba,#0a1b3f);font-family:var(--font-serif);font-style:italic;font-size:15px;line-height:1.5;color:var(--navy-75);">${escapeHtml(facts.memo)}</blockquote>` +
342
- `<div style="font-size:12.5px;line-height:1.5;color:var(--navy-55);margin-top:16px;"><span style="font-family:var(--font-mono);font-size:10px;letter-spacing:.1em;text-transform:uppercase;">${escapeHtml(compensationCue)}</span> · ${escapeHtml(scopeLine)}</div>` +
343
- `</div>` +
344
- `<div style="display:grid;grid-template-columns:1fr 1fr;border-top:1px solid var(--navy-10);">` +
345
- profileLink(
346
- facts.profileUrl,
347
- 'View Profile',
348
- 'text-align:center;padding:14px;font-size:14px;font-weight:700;color:var(--navy);text-decoration:none;border-right:1px solid var(--navy-10);cursor:pointer;',
349
- ) +
350
- certificateLink(
351
- facts.certificateUrl,
352
- 'View Certificate',
353
- 'text-align:center;padding:14px;font-size:14px;font-weight:700;color:var(--navy);text-decoration:none;cursor:pointer;',
354
- ) +
540
+ blockquote +
541
+ disclosure +
355
542
  `</div>` +
543
+ footer +
356
544
  `</div>`
357
545
  )
358
546
  }
@@ -361,21 +549,119 @@ function sidebarCard(facts: CertBlockFacts): string {
361
549
  // Mode 3 — FLOATING: a compact pinned pill (a modal pointer, NOT a claim face)
362
550
  // ─────────────────────────────────────────────────────────────────────────────
363
551
 
364
- function floatingPill(facts: CertBlockFacts): string {
552
+ function floatingPill(facts: CertBlockFacts, placed: PlacedTest): string {
553
+ const label = placed('label')
554
+ ? `<span style="font-family:var(--font-mono);font-size:11px;font-weight:700;letter-spacing:.14em;text-transform:uppercase;opacity:.75;">Expert reviewed:</span>`
555
+ : ''
556
+ const profile = placed('profileLink')
557
+ ? profileLink(
558
+ facts.profileUrl,
559
+ 'Profile',
560
+ 'font-size:14px;font-weight:700;text-decoration:underline;text-underline-offset:3px;color:inherit;cursor:pointer;',
561
+ )
562
+ : ''
563
+ const cert = placed('certificateLink')
564
+ ? certificateLink(
565
+ facts.certificateUrl,
566
+ 'Certificate',
567
+ 'font-size:14px;font-weight:700;text-decoration:underline;text-underline-offset:3px;color:inherit;cursor:pointer;',
568
+ )
569
+ : ''
570
+ // The `·` separator only when BOTH links land.
571
+ const sep = placed('profileLink') && placed('certificateLink') ? `<span style="opacity:.5;">·</span>` : ''
572
+ return (
573
+ // v2 chrome-ink (Phase 1): the minimal-tier pill is the SAME trust surface as the header bar — it
574
+ // follows barInk/barInkFg so a themed brand's compact format matches its card (portal parity; the
575
+ // portal `.certrev-pill` reads the same `--certrev-bar-*` vars). Navy/white fallbacks ⇒ byte-identical
576
+ // when unthemed. NOTE: this moves the pill OFF the accent (`--ba`) it used pre-v2 — see the port report.
577
+ `<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);">` +
578
+ label +
579
+ profile +
580
+ sep +
581
+ cert +
582
+ `</div>`
583
+ )
584
+ }
585
+
586
+ // ─────────────────────────────────────────────────────────────────────────────
587
+ // Mode 4 — CUSTOM: the engine's free-composition face — ONE vertical stack of
588
+ // EXACTLY the placed fields, in canonical order, from the SAME escaped / locked() /
589
+ // data-certrev-* building blocks the locked layouts use (so the ftc-guard + crawl
590
+ // monitor see identical markers). Never consults `rung`.
591
+ // ─────────────────────────────────────────────────────────────────────────────
592
+
593
+ function renderCustomFace(facts: CertBlockFacts, placed: PlacedTest): string {
594
+ const parts: string[] = []
595
+
596
+ // 1. label — the navy "Expert reviewed" header bar (sidebar's header markup)
597
+ if (placed('label')) {
598
+ parts.push(
599
+ `<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>`,
600
+ )
601
+ }
602
+
603
+ // 2. authorName — the "Written by" row (subtitle nested iff authorTitle placed + present)
604
+ if (placed('authorName')) {
605
+ const subtitle =
606
+ placed('authorTitle') && facts.authorTitle
607
+ ? `<div style="font-size:13px;color:var(--cr-ink-sub);">${escapeHtml(facts.authorTitle)}</div>`
608
+ : ''
609
+ parts.push(
610
+ `<div>` +
611
+ `<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>` +
612
+ `<div style="display:flex;align-items:center;gap:12px;">${avatar(initials(facts.authorName), 42, 12)}` +
613
+ `<div><div style="font-size:15px;font-weight:700;color:var(--cr-ink);">${escapeHtml(facts.authorName)}</div>${subtitle}</div></div>` +
614
+ `</div>`,
615
+ )
616
+ }
617
+
618
+ // 3. reviewer row — optional photo + the self-gating verification block
619
+ const reviewerAvatar = placed('reviewerPhoto') ? avatar(initials(facts.reviewerName), 46, 13) : ''
620
+ const reviewerBlock = verificationBlock(facts, false, placed)
621
+ if (reviewerAvatar || reviewerBlock) {
622
+ parts.push(`<div style="display:flex;align-items:flex-start;gap:13px;">${reviewerAvatar}${reviewerBlock}</div>`)
623
+ }
624
+
625
+ // 4. bio (sidebar's bio paragraph)
626
+ if (placed('bio') && facts.bio) {
627
+ parts.push(
628
+ `<p style="font-size:14px;line-height:1.55;color:var(--cr-ink-soft);margin:16px 0 0;">${escapeHtml(facts.bio)}</p>`,
629
+ )
630
+ }
631
+
632
+ // 5. memo (sidebar's blockquote)
633
+ if (placed('memo')) {
634
+ parts.push(
635
+ `<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>`,
636
+ )
637
+ }
638
+
639
+ // 6. disclosures (sidebar's cue · scope row; separator only when BOTH land)
640
+ const cueEl = placed('compensationCue')
641
+ ? `<span style="font-family:var(--font-mono);font-size:10px;letter-spacing:.1em;text-transform:uppercase;">${escapeHtml(locked(facts.compensationCue, COMPENSATED_EXPERT_CUE))}</span>`
642
+ : ''
643
+ const scopeEl = placed('scopeLine') ? escapeHtml(locked(facts.scopeLine, CERT_SCOPE_LINE)) : ''
644
+ if (cueEl || scopeEl) {
645
+ parts.push(
646
+ `<div style="font-size:12.5px;line-height:1.5;color:var(--navy-55);margin-top:16px;">${cueEl}${cueEl && scopeEl ? ' · ' : ''}${scopeEl}</div>`,
647
+ )
648
+ }
649
+
650
+ // 7. action row — the memo-card stamp links (same data-certrev-modal-open hooks)
651
+ const first = firstNameOf(facts.reviewerName)
652
+ const profile = placed('profileLink')
653
+ ? profileLink(facts.profileUrl, `${escapeHtml(first)}'s Profile`, STAMP_LINK_STYLE)
654
+ : ''
655
+ const certificate = placed('certificateLink')
656
+ ? certificateLink(facts.certificateUrl, 'Certificate', STAMP_LINK_STYLE)
657
+ : ''
658
+ if (profile || certificate) {
659
+ parts.push(`<div style="display:flex;flex-wrap:wrap;gap:22px;">${profile}${certificate}</div>`)
660
+ }
661
+
365
662
  return (
366
- `<div class="certrev-cert__pill" style="display:inline-flex;align-items:center;gap:14px;padding:12px 22px;border-radius:999px;background:var(--ba,#0a1b3f);color:var(--ba-fg,#fff);box-shadow:0 10px 26px var(--navy-10);">` +
367
- `<span style="font-family:var(--font-mono);font-size:11px;font-weight:700;letter-spacing:.14em;text-transform:uppercase;opacity:.75;">Expert reviewed:</span>` +
368
- profileLink(
369
- facts.profileUrl,
370
- 'Profile',
371
- 'font-size:14px;font-weight:700;text-decoration:underline;text-underline-offset:3px;color:inherit;cursor:pointer;',
372
- ) +
373
- `<span style="opacity:.5;">·</span>` +
374
- certificateLink(
375
- facts.certificateUrl,
376
- 'Certificate',
377
- 'font-size:14px;font-weight:700;text-decoration:underline;text-underline-offset:3px;color:inherit;cursor:pointer;',
378
- ) +
663
+ `<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;">` +
664
+ parts.join('') +
379
665
  `</div>`
380
666
  )
381
667
  }
@@ -391,13 +677,33 @@ function floatingPill(facts: CertBlockFacts): string {
391
677
  */
392
678
  export function renderCertBlock(input: RenderCertBlockInput): string {
393
679
  const theme = resolveBlockTheme(input.theme)
394
- const { facts, mode } = input
680
+ const { facts, mode, face } = input
681
+ const placed = placedTester(face)
395
682
  switch (mode) {
396
- case 'banner':
397
- return root(mode, theme, bannerHeaderCard(facts) + bannerMemoCard(facts))
683
+ case 'banner': {
684
+ // Suppress the memo card entirely when the engine placed none of its fields
685
+ // (avoids an empty bordered shell). Grandfather: all placed ⇒ card renders.
686
+ const memoCard =
687
+ placed('memo') || placed('profileLink') || placed('certificateLink') || placed('scopeLine')
688
+ ? bannerMemoCard(facts, placed)
689
+ : ''
690
+ // First-class memo split (POR-10741): default 'full' is byte-identical to the prior
691
+ // header+memo render; 'header'/'memo' emit only that card so the exporter can place
692
+ // the memo as its own block (retires the CertReviewCard string-split shim).
693
+ const part = input.part ?? 'full'
694
+ const inner =
695
+ part === 'header'
696
+ ? bannerHeaderCard(facts, placed)
697
+ : part === 'memo'
698
+ ? memoCard
699
+ : bannerHeaderCard(facts, placed) + memoCard
700
+ return root(mode, theme, inner, face)
701
+ }
398
702
  case 'sidebar':
399
- return root(mode, theme, sidebarCard(facts))
703
+ return root(mode, theme, sidebarCard(facts, placed), face)
400
704
  case 'floating':
401
- return root(mode, theme, floatingPill(facts))
705
+ return root(mode, theme, floatingPill(facts, placed), face)
706
+ case 'custom':
707
+ return root(mode, theme, renderCustomFace(facts, placed), face)
402
708
  }
403
709
  }