@certrev/cert-block 0.5.5 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. package/CHANGELOG.md +189 -25
  2. package/LICENSE +21 -0
  3. package/README.md +415 -54
  4. package/dist/builder/cert-chrome-data.d.ts +26 -8
  5. package/dist/builder/cert-chrome-data.d.ts.map +1 -1
  6. package/dist/builder/cert-chrome-data.js +5 -5
  7. package/dist/builder/cert-chrome-data.js.map +1 -1
  8. package/dist/builder/cert-review-card.d.ts +35 -5
  9. package/dist/builder/cert-review-card.d.ts.map +1 -1
  10. package/dist/builder/cert-review-card.js +117 -34
  11. package/dist/builder/cert-review-card.js.map +1 -1
  12. package/dist/builder/index.d.ts +33 -16
  13. package/dist/builder/index.d.ts.map +1 -1
  14. package/dist/builder/index.js +33 -16
  15. package/dist/builder/index.js.map +1 -1
  16. package/dist/components/CertBadge.d.ts +2 -2
  17. package/dist/components/CertBadge.d.ts.map +1 -1
  18. package/dist/components/CertBadge.js +11 -5
  19. package/dist/components/CertBadge.js.map +1 -1
  20. package/dist/components/CertJsonLd.d.ts.map +1 -1
  21. package/dist/components/CertJsonLd.js.map +1 -1
  22. package/dist/components/CertRevBacklink.d.ts.map +1 -1
  23. package/dist/components/CertRevBacklink.js +3 -2
  24. package/dist/components/CertRevBacklink.js.map +1 -1
  25. package/dist/components/CertReview.d.ts +1 -1
  26. package/dist/components/ExpertBio.d.ts.map +1 -1
  27. package/dist/components/ExpertBio.js +12 -4
  28. package/dist/components/ExpertBio.js.map +1 -1
  29. package/dist/components/escape.d.ts +28 -1
  30. package/dist/components/escape.d.ts.map +1 -1
  31. package/dist/components/escape.js +37 -8
  32. package/dist/components/escape.js.map +1 -1
  33. package/dist/components/format.d.ts +51 -2
  34. package/dist/components/format.d.ts.map +1 -1
  35. package/dist/components/format.js +103 -6
  36. package/dist/components/format.js.map +1 -1
  37. package/dist/components/preview-block.d.ts +1 -1
  38. package/dist/components/preview-block.js +3 -3
  39. package/dist/components/preview-block.js.map +1 -1
  40. package/dist/components/rel.d.ts +24 -0
  41. package/dist/components/rel.d.ts.map +1 -0
  42. package/dist/components/rel.js +24 -0
  43. package/dist/components/rel.js.map +1 -0
  44. package/dist/components/render-cert-block.d.ts +50 -22
  45. package/dist/components/render-cert-block.d.ts.map +1 -1
  46. package/dist/components/render-cert-block.js +146 -80
  47. package/dist/components/render-cert-block.js.map +1 -1
  48. package/dist/components/render-def.d.ts +13 -14
  49. package/dist/components/render-def.d.ts.map +1 -1
  50. package/dist/components/render-def.js +14 -15
  51. package/dist/components/render-def.js.map +1 -1
  52. package/dist/index.d.ts +1 -0
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +4 -2
  55. package/dist/index.js.map +1 -1
  56. package/dist/jsonld/project.d.ts +46 -13
  57. package/dist/jsonld/project.d.ts.map +1 -1
  58. package/dist/jsonld/project.js +139 -56
  59. package/dist/jsonld/project.js.map +1 -1
  60. package/dist/modal/avatar-resize.d.ts +10 -10
  61. package/dist/modal/avatar-resize.js +14 -14
  62. package/dist/modal/avatar-resize.js.map +1 -1
  63. package/dist/modal/cert-modal-view.d.ts +27 -7
  64. package/dist/modal/cert-modal-view.d.ts.map +1 -1
  65. package/dist/modal/cert-modal-view.js +49 -25
  66. package/dist/modal/cert-modal-view.js.map +1 -1
  67. package/dist/modal/certrev-cert-modal.d.ts +5 -5
  68. package/dist/modal/certrev-cert-modal.js +9 -9
  69. package/dist/modal/certrev-cert-modal.js.map +1 -1
  70. package/dist/modal/certrev-cert-modal.styles.d.ts +6 -6
  71. package/dist/modal/certrev-cert-modal.styles.d.ts.map +1 -1
  72. package/dist/modal/certrev-cert-modal.styles.js +8 -8
  73. package/dist/modal/certrev-cert-modal.styles.js.map +1 -1
  74. package/dist/modal/certrev-cert.entry.d.ts +8 -6
  75. package/dist/modal/certrev-cert.entry.d.ts.map +1 -1
  76. package/dist/modal/certrev-cert.entry.js +9 -7
  77. package/dist/modal/certrev-cert.entry.js.map +1 -1
  78. package/dist/modal/certrev-cert.js +6 -6
  79. package/dist/modal/display-strings.d.ts +25 -5
  80. package/dist/modal/display-strings.d.ts.map +1 -1
  81. package/dist/modal/display-strings.js +27 -5
  82. package/dist/modal/display-strings.js.map +1 -1
  83. package/dist/modal/escape.d.ts +26 -3
  84. package/dist/modal/escape.d.ts.map +1 -1
  85. package/dist/modal/escape.js +32 -3
  86. package/dist/modal/escape.js.map +1 -1
  87. package/dist/modal/font-face.d.ts.map +1 -1
  88. package/dist/modal/font-face.js +8 -11
  89. package/dist/modal/font-face.js.map +1 -1
  90. package/dist/modal/ftc-disclosure.d.ts +2 -2
  91. package/dist/modal/ftc-disclosure.js +2 -2
  92. package/dist/modal/ftc-guard.d.ts +44 -9
  93. package/dist/modal/ftc-guard.d.ts.map +1 -1
  94. package/dist/modal/ftc-guard.js +50 -17
  95. package/dist/modal/ftc-guard.js.map +1 -1
  96. package/dist/modal/index.d.ts +1 -1
  97. package/dist/modal/index.js +1 -1
  98. package/dist/modal/interactions.d.ts +2 -2
  99. package/dist/modal/interactions.js +2 -2
  100. package/dist/modal/logo-paths.js +2 -2
  101. package/dist/modal/logo-paths.js.map +1 -1
  102. package/dist/modal/placement.d.ts +4 -3
  103. package/dist/modal/placement.d.ts.map +1 -1
  104. package/dist/modal/placement.js +7 -6
  105. package/dist/modal/placement.js.map +1 -1
  106. package/dist/modal/revalidate.d.ts +2 -3
  107. package/dist/modal/revalidate.d.ts.map +1 -1
  108. package/dist/modal/revalidate.js +2 -3
  109. package/dist/modal/revalidate.js.map +1 -1
  110. package/dist/modal/verify-client.d.ts +2 -2
  111. package/dist/modal/verify-client.js +2 -2
  112. package/dist/verify/cache.d.ts +21 -6
  113. package/dist/verify/cache.d.ts.map +1 -1
  114. package/dist/verify/cache.js +29 -8
  115. package/dist/verify/cache.js.map +1 -1
  116. package/dist/verify/fetch-with-deadline.d.ts +35 -0
  117. package/dist/verify/fetch-with-deadline.d.ts.map +1 -0
  118. package/dist/verify/fetch-with-deadline.js +55 -0
  119. package/dist/verify/fetch-with-deadline.js.map +1 -0
  120. package/dist/verify/get-verified-envelope.d.ts +23 -9
  121. package/dist/verify/get-verified-envelope.d.ts.map +1 -1
  122. package/dist/verify/get-verified-envelope.js +89 -40
  123. package/dist/verify/get-verified-envelope.js.map +1 -1
  124. package/dist/verify/resolve-kid.d.ts +3 -0
  125. package/dist/verify/resolve-kid.d.ts.map +1 -1
  126. package/dist/verify/resolve-kid.js +17 -8
  127. package/dist/verify/resolve-kid.js.map +1 -1
  128. package/dist/webcomponent/certrev-badge.d.ts.map +1 -1
  129. package/dist/webcomponent/certrev-badge.js +24 -2
  130. package/dist/webcomponent/certrev-badge.js.map +1 -1
  131. package/dist/webcomponent/render-badge-html.d.ts +1 -1
  132. package/dist/webcomponent/render-badge-html.d.ts.map +1 -1
  133. package/dist/webcomponent/render-badge-html.js +36 -17
  134. package/dist/webcomponent/render-badge-html.js.map +1 -1
  135. package/package.json +5 -2
  136. package/src/builder/cert-chrome-data.ts +26 -8
  137. package/src/builder/cert-review-card.tsx +149 -38
  138. package/src/builder/index.tsx +34 -17
  139. package/src/components/CertBadge.tsx +14 -9
  140. package/src/components/CertJsonLd.tsx +1 -1
  141. package/src/components/CertRevBacklink.tsx +10 -3
  142. package/src/components/CertReview.tsx +1 -1
  143. package/src/components/ExpertBio.tsx +15 -8
  144. package/src/components/escape.ts +37 -7
  145. package/src/components/format.ts +102 -6
  146. package/src/components/preview-block.ts +3 -3
  147. package/src/components/rel.ts +23 -0
  148. package/src/components/render-cert-block.ts +192 -109
  149. package/src/components/render-def.ts +14 -15
  150. package/src/index.ts +4 -2
  151. package/src/jsonld/project.ts +155 -59
  152. package/src/modal/avatar-resize.ts +14 -14
  153. package/src/modal/cert-modal-view.ts +52 -27
  154. package/src/modal/certrev-cert-modal.styles.ts +8 -8
  155. package/src/modal/certrev-cert-modal.ts +9 -9
  156. package/src/modal/certrev-cert.entry.ts +9 -7
  157. package/src/modal/display-strings.ts +26 -5
  158. package/src/modal/escape.ts +32 -4
  159. package/src/modal/font-face.ts +8 -11
  160. package/src/modal/ftc-disclosure.ts +2 -2
  161. package/src/modal/ftc-guard.ts +73 -18
  162. package/src/modal/index.ts +1 -1
  163. package/src/modal/interactions.ts +2 -2
  164. package/src/modal/logo-paths.ts +2 -2
  165. package/src/modal/placement.ts +7 -6
  166. package/src/modal/revalidate.ts +2 -3
  167. package/src/modal/verify-client.ts +2 -2
  168. package/src/verify/cache.ts +34 -8
  169. package/src/verify/fetch-with-deadline.ts +60 -0
  170. package/src/verify/get-verified-envelope.ts +131 -48
  171. package/src/verify/resolve-kid.ts +22 -6
  172. package/src/webcomponent/certrev-badge.ts +24 -2
  173. package/src/webcomponent/render-badge-html.ts +66 -19
  174. package/src/__tests__/__snapshots__/render-cert-block.test.ts.snap +0 -17
  175. package/src/__tests__/components.test.tsx +0 -206
  176. package/src/__tests__/preview-block.test.ts +0 -84
  177. package/src/__tests__/project.test.ts +0 -128
  178. package/src/__tests__/readme-version.test.ts +0 -51
  179. package/src/__tests__/render-cert-block.test.ts +0 -657
  180. package/src/__tests__/render-def.test.tsx +0 -295
  181. package/src/__tests__/verify.test.ts +0 -239
  182. package/src/__tests__/webcomponent.test.tsx +0 -116
  183. package/src/builder/__tests__/builder.test.tsx +0 -158
  184. package/src/modal/cert-modal-view.test.ts +0 -175
  185. package/src/modal/certrev-cert-modal.test.ts +0 -125
  186. package/src/modal/ftc-guard.test.ts +0 -153
  187. package/src/modal/interactions.test.ts +0 -250
  188. package/src/modal/placement.test.ts +0 -398
  189. package/src/modal/revalidate.test.ts +0 -111
  190. package/src/modal/verify-client.test.ts +0 -164
@@ -4,10 +4,20 @@
4
4
  * ─────────────────────────────────────────────────────────────────────────────
5
5
  *
6
6
  * Paints the owner-approved cert render (decision 2026-07-17) in one of three
7
- * placement modes — `banner`, `sidebar`, `floating` — from RESOLVED theme tokens +
8
- * the cert facts. It returns a self-contained HTML string that renders with ZERO
9
- * JavaScript (the CertREV modal + the reviewer-bio accordion are progressive
10
- * enhancement, wired by the shared embed script via the `data-certrev-*` hooks).
7
+ * placement modes — `sidebar` (the DEFAULT), `banner`, `floating` — plus the engine's
8
+ * free-composition `custom` face, from RESOLVED theme tokens + the cert facts. It
9
+ * returns a self-contained HTML string that renders with ZERO JavaScript (the CertREV
10
+ * modal + the reviewer-bio accordion are progressive enhancement, wired by the shared
11
+ * embed script via the `data-certrev-*` hooks).
12
+ *
13
+ * THE DEFAULT PLACEMENT IS `sidebar`, AND IT IS NAMED IN EXACTLY ONE PLACE — the
14
+ * `case 'sidebar':` that falls into `default:` at the bottom of this file. Nothing
15
+ * else in the package (this renderer, the Builder card, the preview) may name a
16
+ * placement fallback of its own: the switch previously had no `default:` at all, so
17
+ * an untyped `mode` off CMS JSON returned `undefined` from a `: string` function and
18
+ * landed in `dangerouslySetInnerHTML` as an empty div — a 200 with the card silently
19
+ * gone. Declared default and runtime fallback are now physically the same line and
20
+ * cannot drift.
11
21
  *
12
22
  * WHY A STRING (not a React component): the block is emitted into storefront HTML
13
23
  * across every render surface (Shopify Liquid, WordPress PHP, the JS engine, and
@@ -55,7 +65,8 @@
55
65
  */
56
66
 
57
67
  import { escapeAttribute, escapeHtml, safeHttpUrl } from './escape.js'
58
- import { formatDate } from './format.js'
68
+ import { dedupeCredential, formatDate } from './format.js'
69
+ import { CERTREV_LINK_REL } from './rel.js'
59
70
  import {
60
71
  CERT_SCOPE_LINE,
61
72
  CERT_STRINGS_VERSION,
@@ -85,10 +96,15 @@ export interface CertBlockFace {
85
96
  }
86
97
 
87
98
  /**
88
- * The cert facts + LOCKED display strings a face renders. Facts are escaped; the
89
- * three locked strings (`compensationCue`, `scopeLine`, `credentialLine`) are
90
- * byte-verbatim per the CertREV Display Guide and default to the mirrored constants
91
- * when a caller omits them — so the compliance baseline is exact even if unsupplied.
99
+ * The cert FACTS a face renders — the per-cert data, all of it escaped.
100
+ *
101
+ * The compliance copy is NOT here. The scope line and the credential attribution are
102
+ * rendered straight from `CERT_SCOPE_LINE` / `CREDENTIAL_VERIFIED_ATTRIBUTION`: they
103
+ * are locked strings per the CertREV Display Guide (the scope line's curly apostrophe
104
+ * is pinned to MSA §3 — see render-def.ts), so a caller has no legitimate reason to
105
+ * supply them and taking them as input made compliance copy rewritable from a CMS
106
+ * field. `compensationCue` survives as an input for exactly ONE decision — whether the
107
+ * reviewer was compensated at all — never for the wording.
92
108
  */
93
109
  export interface CertBlockFacts {
94
110
  /** The content author (the "Written by" column). */
@@ -103,25 +119,34 @@ export interface CertBlockFacts {
103
119
  readonly credentialVerifiedAt: string
104
120
  /** ISO instant the article was certified (the "Certified <date>" footer). */
105
121
  readonly certifiedAt: string
106
- /** The expert's memo (rendered as the serif blockquote). */
107
- readonly memo: string
122
+ /**
123
+ * The expert's memo (the quote / blockquote). OPTIONAL, exactly like `bio`: a face may
124
+ * legally carry no memo, and an empty one must drop the heading + quote rather than paint
125
+ * an "Expert memo" label over an empty `<p>`.
126
+ */
127
+ readonly memo?: string
108
128
  /** The expert's bio (banner accordion / sidebar paragraph). Omitted when absent. */
109
129
  readonly bio?: string
110
130
  /** The expert profile URL (the "Profile" link + modal `expert` hook). */
111
131
  readonly profileUrl: string
112
132
  /** The certificate URL (the "Certificate" link + modal hook). */
113
133
  readonly certificateUrl: string
114
- /** LOCKED — the compensation cue. Default: `COMPENSATED_EXPERT_CUE`. */
115
- readonly compensationCue?: string
116
- /** LOCKED — the byte-exact FTC scope line. Default: `CERT_SCOPE_LINE`. */
117
- readonly scopeLine?: string
118
- /** LOCKED — the verification attribution. Default: `CREDENTIAL_VERIFIED_ATTRIBUTION`. */
119
- readonly credentialLine?: string
134
+ /**
135
+ * WHETHER there is a material connection to disclose — never its WORDING.
136
+ *
137
+ * `null` ⇒ the cue is OMITTED entirely: the pro-bono reviewer, who has no compensation
138
+ * to disclose (the same contract `modal/cert-modal-view.ts` already honours). Any other
139
+ * value — absent, or a supplied string — renders the LOCKED `COMPENSATED_EXPERT_CUE`
140
+ * constant; a supplied string is IGNORED. The scope line still renders for a pro-bono
141
+ * reviewer: it makes no compensation claim, so it is true for volunteers too.
142
+ */
143
+ readonly compensationCue?: string | null
120
144
  }
121
145
 
122
146
  /** The full input to `renderCertBlock`. */
123
147
  export interface RenderCertBlockInput {
124
- readonly mode: CertBlockLayout
148
+ /** The placement face. ABSENT or UNRECOGNISED ⇒ `sidebar` (see the file header). */
149
+ readonly mode?: CertBlockLayout
125
150
  /** RESOLVED theme tokens (the portal bakes these). Absent/invalid tokens fall back to navy. */
126
151
  readonly theme?: Partial<ResolvedBlockTheme>
127
152
  readonly facts: CertBlockFacts
@@ -131,8 +156,8 @@ export interface RenderCertBlockInput {
131
156
  */
132
157
  readonly face?: CertBlockFace
133
158
  /**
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
159
+ * BANNER memo placement — the first-class split that retires the CertReviewCard
160
+ * string-hack. Default `'full'` = header card + memo card in ONE root, BYTE-IDENTICAL
136
161
  * to prior behavior. `'header'` / `'memo'` render ONLY that card in its own themed root,
137
162
  * so a headless exporter can place the memo as its OWN block under the article body
138
163
  * (the "banner → body → memo" layout) WITHOUT string-splitting the rendered HTML — and
@@ -206,16 +231,6 @@ function firstNameOf(name: string): string {
206
231
  return clean.split(/\s+/)[0] || clean
207
232
  }
208
233
 
209
- /**
210
- * A LOCKED display string: use the caller's value ONLY when it is a non-blank
211
- * string, else the byte-verbatim constant. The three compliance strings
212
- * (compensation cue, scope line, credential attribution) are undroppable — an
213
- * omitted OR empty/whitespace override must never blank the disclosure.
214
- */
215
- function locked(supplied: string | undefined, constant: string): string {
216
- return supplied?.trim() ? supplied : constant
217
- }
218
-
219
234
  /** Field-placement predicate every renderer consults. */
220
235
  type PlacedTest = (fieldId: string) => boolean
221
236
 
@@ -229,9 +244,25 @@ function placedTester(face?: CertBlockFace): PlacedTest {
229
244
  return (id) => set.has(id)
230
245
  }
231
246
 
247
+ /**
248
+ * Is there actually a bio to expand? The banner's accordion affordance — role=button,
249
+ * tabindex, aria-expanded, the caret, the pointer cursor — is gated on THIS, not on the
250
+ * reviewer row being non-empty. Gating on the row announced "Read more about Dr. X,
251
+ * button, collapsed" over nothing whenever the reviewer had no bio (WCAG 2.1 4.1.2 name/
252
+ * role/value, and 2.1.1 keyboard: a focus stop that does nothing).
253
+ */
254
+ function hasExpandableBio(facts: CertBlockFacts, placed: PlacedTest): boolean {
255
+ return placed('bio') && !!facts.bio
256
+ }
257
+
258
+ /** Whether the material-connection cue renders: placed by the engine AND not the pro-bono `null`. */
259
+ function showsCompensationCue(facts: CertBlockFacts, placed: PlacedTest): boolean {
260
+ return placed('compensationCue') && facts.compensationCue !== null
261
+ }
262
+
232
263
  /**
233
264
  * 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.
265
+ * matches the badge footer; navy so it still reads as an action.
235
266
  */
236
267
  const STAMP_LINK_STYLE =
237
268
  '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;'
@@ -245,22 +276,29 @@ function avatar(text: string, size: number, fontSize: number): string {
245
276
  )
246
277
  }
247
278
 
248
- /** "Jane Doe, MD" (or just the name when there is no credential) — escaped. */
279
+ /**
280
+ * "Jane Doe, MD" (or just the name when there is no credential) — escaped, and never the
281
+ * same credential twice. A display name that arrives already carrying its own post-nominal
282
+ * ("Dr. Erik Schraga, MD") used to have the credential appended regardless, which put
283
+ * "Dr. Erik Schraga, MD, MD, EM" on a live customer page — see `dedupeCredential` in
284
+ * format.ts for the rule, and for why the stored data is still the real repair.
285
+ */
249
286
  function nameWithCredential(name: string, credential: string): string {
250
287
  const n = escapeHtml(name)
251
- return credential ? `${n}, ${escapeHtml(credential)}` : n
288
+ const tail = dedupeCredential(name, credential)
289
+ return tail ? `${n}, ${escapeHtml(tail)}` : n
252
290
  }
253
291
 
254
292
  /** The reviewer profile link (carries the modal `expert` hook + a real href fallback). */
255
293
  function profileLink(url: string, inner: string, style: string): string {
256
294
  const href = safeHttpUrl(url) ?? '#'
257
- return `<a href="${escapeAttribute(href)}" data-certrev-modal-open="expert" rel="noopener" style="${style}">${inner}</a>`
295
+ return `<a href="${escapeAttribute(href)}" data-certrev-modal-open="expert" rel="${CERTREV_LINK_REL}" style="${style}">${inner}</a>`
258
296
  }
259
297
 
260
298
  /** The certificate link (carries the modal hook + a real href fallback). */
261
299
  function certificateLink(url: string, inner: string, style: string): string {
262
300
  const href = safeHttpUrl(url) ?? '#'
263
- return `<a href="${escapeAttribute(href)}" data-certrev-modal-open rel="noopener" style="${style}">${inner}</a>`
301
+ return `<a href="${escapeAttribute(href)}" data-certrev-modal-open rel="${CERTREV_LINK_REL}" style="${style}">${inner}</a>`
264
302
  }
265
303
 
266
304
  /**
@@ -273,14 +311,21 @@ function verificationBlock(facts: CertBlockFacts, withAccordion: boolean, placed
273
311
  const showName = placed('bylinePlain') || showCredentialed
274
312
  // Nothing placed ⇒ empty (the caller drops the wrapping toggle div).
275
313
  if (!showName) return ''
276
- const credentialLine = locked(facts.credentialLine, CREDENTIAL_VERIFIED_ATTRIBUTION)
277
- const verified = formatDate(facts.credentialVerifiedAt) ?? escapeHtml(facts.credentialVerifiedAt)
278
- const showBio = withAccordion && placed('bio') && !!facts.bio
314
+ // An UNPARSEABLE or blank verification date is dropped, not echoed. The old
315
+ // `formatDate(x) ?? escapeHtml(x)` asserted "Credential verified by CertREV on
316
+ // not-a-date", and left "on " dangling when blank — a disclosure that names a date
317
+ // it cannot stand behind is worse than one that names none. The attribution itself
318
+ // survives: it is true independently of when the credential was checked.
319
+ const verified = formatDate(facts.credentialVerifiedAt)
320
+ const showBio = withAccordion && hasExpandableBio(facts, placed)
279
321
  const caret = showBio ? CARET : ''
280
322
  const nameSize = withAccordion ? '15px' : '16px'
281
323
  const muteSize = withAccordion ? '13px' : '12.5px'
324
+ // `hidden` alone, NOT an inline `display:none` — the accordion is revealed by an author
325
+ // rule on the toggled `.open` class (see modal/interactions.ts), and an inline declaration
326
+ // is the one thing such a rule can never beat.
282
327
  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>`
328
+ ? `<div class="acc-bio" hidden style="margin-top:8px;font-size:12.5px;line-height:1.5;color:var(--cr-ink-soft);">${escapeHtml(facts.bio ?? '')}</div>`
284
329
  : ''
285
330
  // The credential suffix + the GROUPED attribution/date lines are all-or-nothing on
286
331
  // `bylineCredentialed`; a plain byline shows the name only.
@@ -288,8 +333,8 @@ function verificationBlock(facts: CertBlockFacts, withAccordion: boolean, placed
288
333
  ? nameWithCredential(facts.reviewerName, facts.credential)
289
334
  : escapeHtml(facts.reviewerName)
290
335
  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>`
336
+ ? `<div style="font-size:${muteSize};color:var(--navy-55);line-height:1.45;margin-top:3px;">${escapeHtml(CREDENTIAL_VERIFIED_ATTRIBUTION)}</div>` +
337
+ (verified ? `<div style="font-size:${muteSize};color:var(--navy-55);line-height:1.35;">on ${verified}</div>` : '')
293
338
  : ''
294
339
  return (
295
340
  `<div>` +
@@ -313,7 +358,7 @@ function rootStyle(theme: ResolvedBlockTheme): string {
313
358
  `--ba:${theme.accentColor}`,
314
359
  `--ba-fg:${theme.accentFg}`,
315
360
  `--br:${theme.cornerRadius}`,
316
- // The body font is a host-overridable HOOK (POR-10742): `--cr-bf` resolves a host-page
361
+ // The body font is a host-overridable HOOK: `--cr-bf` resolves a host-page
317
362
  // `--bf` (set on any ancestor) with the fontSlot stack as the FALLBACK — so a storefront
318
363
  // can theme the card font via plain CSS, no `!important` (the old inline `--bf:<stack>`
319
364
  // declaration shadowed a host `--bf`). Unset ⇒ resolves to the stack ⇒ byte-identical render.
@@ -360,12 +405,11 @@ function root(mode: CertBlockLayout, theme: ResolvedBlockTheme, inner: string, f
360
405
  }
361
406
 
362
407
  // ─────────────────────────────────────────────────────────────────────────────
363
- // Mode 1 — BANNER: a certification header card + a separate expert-memo card
408
+ // Face — BANNER: a certification header card + a separate expert-memo card
364
409
  // ─────────────────────────────────────────────────────────────────────────────
365
410
 
366
411
  function bannerHeaderCard(facts: CertBlockFacts, placed: PlacedTest): string {
367
- const compensationCue = locked(facts.compensationCue, COMPENSATED_EXPERT_CUE)
368
- const certified = formatDate(facts.certifiedAt) ?? escapeHtml(facts.certifiedAt)
412
+ const certified = formatDate(facts.certifiedAt)
369
413
  const subtitle =
370
414
  placed('authorTitle') && facts.authorTitle
371
415
  ? `<div style="font-size:13px;color:var(--cr-ink-sub);">${escapeHtml(facts.authorTitle)}</div>`
@@ -374,7 +418,7 @@ function bannerHeaderCard(facts: CertBlockFacts, placed: PlacedTest): string {
374
418
  // target (data-certrev-modal-open); this focusable <a> is the keyboard / SR / no-JS control.
375
419
  const certHref = safeHttpUrl(facts.certificateUrl) ?? '#'
376
420
  const headerArrow =
377
- `<a href="${escapeAttribute(certHref)}" data-certrev-modal-open rel="noopener" aria-label="View certificate" ` +
421
+ `<a href="${escapeAttribute(certHref)}" data-certrev-modal-open rel="${CERTREV_LINK_REL}" aria-label="View certificate" ` +
378
422
  `style="display:inline-flex;align-items:center;flex-shrink:0;color:inherit;opacity:.72;text-decoration:none;">` +
379
423
  `<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
424
  // navy header — the trust bar stays fixed navy (accent theming is Phase B); mark + eyebrow + cert arrow
@@ -398,13 +442,33 @@ function bannerHeaderCard(facts: CertBlockFacts, placed: PlacedTest): string {
398
442
  // reviewed by — emphasized label (navy, 700): the certification's authority voice
399
443
  const reviewerAvatar = placed('reviewerPhoto') ? avatar(initials(facts.reviewerName), 42, 12) : ''
400
444
  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>`
445
+ // The interactive affordance is gated on there being a bio to expand — NOT on the
446
+ // reviewer row existing. `data-certrev-acc` is the hook modal/interactions.ts actually
447
+ // binds (click + Enter/Space); the old `data-certrev-bio-toggle` had zero consumers
448
+ // repo-wide, so the row was focusable, announced as a collapsed button, and inert.
449
+ const revRow = reviewerBlock
450
+ ? hasExpandableBio(facts, placed)
451
+ ? `<div class="certrev-cert__rev" data-certrev-acc role="button" tabindex="0" aria-expanded="false" 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;">` +
452
+ `${reviewerAvatar}${reviewerBlock}` +
453
+ `</div>`
454
+ : `<div class="certrev-cert__rev" style="display:flex;align-items:flex-start;gap:12px;">` +
455
+ `${reviewerAvatar}${reviewerBlock}` +
456
+ `</div>`
405
457
  : ''
406
- // "Certified <date>" is card chrome (renders with the card); the cue is engine-placed
407
- const cueSpan = placed('compensationCue') ? `<span>${escapeHtml(compensationCue)}</span>` : ''
458
+ // The mono footer row: "Certified <date>" is card chrome, but an unusable `certifiedAt`
459
+ // used to emit the bare word "Certified" followed by nothing — so it is gated on the date
460
+ // PARSING, the same family as the memo/verification-date gates. The cue is engine-placed
461
+ // AND dropped for the pro-bono (`null`) reviewer. Neither ⇒ the hairline + row go too.
462
+ const certifiedSpan = certified ? `<span>Certified <span style="margin-left:8px;">${certified}</span></span>` : ''
463
+ const cueSpan = showsCompensationCue(facts, placed) ? `<span>${escapeHtml(COMPENSATED_EXPERT_CUE)}</span>` : ''
464
+ const footer =
465
+ certifiedSpan || cueSpan
466
+ ? `<div style="height:1px;background:var(--navy-10);margin:16px 0 14px;"></div>` +
467
+ `<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);">` +
468
+ certifiedSpan +
469
+ cueSpan +
470
+ `</div>`
471
+ : ''
408
472
  return (
409
473
  `<div style="border:1px solid var(--navy-10);border-radius:calc(var(--br,14px) * 0.7);overflow:hidden;font-family:var(--cr-bf);">` +
410
474
  header +
@@ -413,43 +477,43 @@ function bannerHeaderCard(facts: CertBlockFacts, placed: PlacedTest): string {
413
477
  authorColumn +
414
478
  `<div>` +
415
479
  `<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 +
480
+ revRow +
417
481
  `</div>` +
418
482
  `</div>` +
419
483
  // hairline + mono footer — "Certified <date>" + "Compensated expert", UNIFORM navy-55
420
- `<div style="height:1px;background:var(--navy-10);margin:16px 0 14px;"></div>` +
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 +
424
- `</div>` +
484
+ footer +
425
485
  `</div>` +
426
486
  `</div>`
427
487
  )
428
488
  }
429
489
 
430
490
  function bannerMemoCard(facts: CertBlockFacts, placed: PlacedTest): string {
431
- const scopeLine = locked(facts.scopeLine, CERT_SCOPE_LINE)
432
- const credTail =
433
- placed('bylineCredentialed') && facts.credential
434
- ? `<span style="color:var(--cr-ink-sub);">, ${escapeHtml(facts.credential)}</span>`
435
- : ''
491
+ // The memo card composes `<name><, credential>` as two spans rather than through
492
+ // `nameWithCredential`, so it needs the SAME duplicate guard — the doubled "MD, MD" was
493
+ // visible on this card too. Gated on what SURVIVES the dedupe, not on the raw field: a
494
+ // credential the name already carries must drop the tail span whole, comma and all.
495
+ const credential = placed('bylineCredentialed') ? dedupeCredential(facts.reviewerName, facts.credential) : ''
496
+ const credTail = credential ? `<span style="color:var(--cr-ink-sub);">, ${escapeHtml(credential)}</span>` : ''
436
497
  const first = firstNameOf(facts.reviewerName)
437
498
  // The eyebrow + reviewer name row + memo quote are grouped under `memo`; the avatar
438
- // inside it is gated on `reviewerPhoto`.
499
+ // inside it is gated on `reviewerPhoto`. Gated on the memo's CONTENT as well as its
500
+ // placement (the pattern `bio` already follows): placement alone painted an "Expert
501
+ // memo" heading over an empty `<p>` for a contract-legal empty memo.
439
502
  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
- : ''
503
+ const memoSection =
504
+ placed('memo') && facts.memo
505
+ ? `<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>` +
506
+ `<div style="display:flex;gap:15px;">${memoAvatar}` +
507
+ `<div style="flex:1;min-width:0;">` +
508
+ `<div style="font-size:16px;line-height:1.2;color:var(--cr-ink);"><span style="font-weight:700;">${escapeHtml(facts.reviewerName)}</span>${credTail}</div>` +
509
+ // Readable, UPRIGHT DM Sans quote (the display-serif italic hurt legibility);
510
+ // pre-line preserves the reviewer's paragraph breaks. No left accent bar (matches the guide).
511
+ `<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>` +
512
+ `</div>` +
513
+ `</div>`
514
+ : ''
451
515
  // 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.
516
+ // that matches the badge footer lockup — navy, so they still read as actions.
453
517
  const profile = placed('profileLink')
454
518
  ? profileLink(facts.profileUrl, `${escapeHtml(first)}'s Profile`, STAMP_LINK_STYLE)
455
519
  : ''
@@ -457,7 +521,7 @@ function bannerMemoCard(facts: CertBlockFacts, placed: PlacedTest): string {
457
521
  ? certificateLink(facts.certificateUrl, 'Certificate', STAMP_LINK_STYLE)
458
522
  : ''
459
523
  const scope = placed('scopeLine')
460
- ? `<div style="font-size:13px;color:var(--navy-55);margin-top:8px;line-height:1.5;">${escapeHtml(scopeLine)}</div>`
524
+ ? `<div style="font-size:13px;color:var(--navy-55);margin-top:8px;line-height:1.5;">${escapeHtml(CERT_SCOPE_LINE)}</div>`
461
525
  : ''
462
526
  return (
463
527
  `<div style="border:1px solid var(--navy-10);border-radius:calc(var(--br,14px) * 0.7);padding:24px;font-family:var(--cr-bf);">` +
@@ -480,12 +544,10 @@ function bannerMemoCard(facts: CertBlockFacts, placed: PlacedTest): string {
480
544
  }
481
545
 
482
546
  // ─────────────────────────────────────────────────────────────────────────────
483
- // Mode 2 — SIDEBAR: a reviewer card pinned beside the article body
547
+ // Face — SIDEBAR (the DEFAULT): a reviewer card pinned beside the article body
484
548
  // ─────────────────────────────────────────────────────────────────────────────
485
549
 
486
550
  function sidebarCard(facts: CertBlockFacts, placed: PlacedTest): string {
487
- const compensationCue = locked(facts.compensationCue, COMPENSATED_EXPERT_CUE)
488
- const scopeLine = locked(facts.scopeLine, CERT_SCOPE_LINE)
489
551
  const header = placed('label')
490
552
  ? `<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
553
  : ''
@@ -494,14 +556,17 @@ function sidebarCard(facts: CertBlockFacts, placed: PlacedTest): string {
494
556
  placed('bio') && facts.bio
495
557
  ? `<p style="font-size:14px;line-height:1.55;color:var(--cr-ink-soft);margin:16px 0 0;">${escapeHtml(facts.bio)}</p>`
496
558
  : ''
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
- : ''
559
+ // Gated on CONTENT as well as placement (like `bioPara` above) — placement alone rendered
560
+ // an empty accent-bordered blockquote for a contract-legal empty memo.
561
+ const blockquote =
562
+ placed('memo') && facts.memo
563
+ ? `<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>`
564
+ : ''
500
565
  // 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>`
566
+ const cueEl = showsCompensationCue(facts, placed)
567
+ ? `<span style="font-family:var(--font-mono);font-size:10px;letter-spacing:.1em;text-transform:uppercase;">${escapeHtml(COMPENSATED_EXPERT_CUE)}</span>`
503
568
  : ''
504
- const scopeEl = placed('scopeLine') ? escapeHtml(scopeLine) : ''
569
+ const scopeEl = placed('scopeLine') ? escapeHtml(CERT_SCOPE_LINE) : ''
505
570
  const disclosure =
506
571
  cueEl || scopeEl
507
572
  ? `<div style="font-size:12.5px;line-height:1.5;color:var(--navy-55);margin-top:16px;">${cueEl}${cueEl && scopeEl ? ' · ' : ''}${scopeEl}</div>`
@@ -546,7 +611,7 @@ function sidebarCard(facts: CertBlockFacts, placed: PlacedTest): string {
546
611
  }
547
612
 
548
613
  // ─────────────────────────────────────────────────────────────────────────────
549
- // Mode 3 — FLOATING: a compact pinned pill (a modal pointer, NOT a claim face)
614
+ // Face — FLOATING: a compact pinned pill (a modal pointer, NOT a claim face)
550
615
  // ─────────────────────────────────────────────────────────────────────────────
551
616
 
552
617
  function floatingPill(facts: CertBlockFacts, placed: PlacedTest): string {
@@ -584,10 +649,17 @@ function floatingPill(facts: CertBlockFacts, placed: PlacedTest): string {
584
649
  }
585
650
 
586
651
  // ─────────────────────────────────────────────────────────────────────────────
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`.
652
+ // Face — CUSTOM: the engine's free-composition face — ONE vertical stack of
653
+ // EXACTLY the placed fields, in canonical order, from the SAME escaped, constant-fed,
654
+ // `data-certrev-*`-hooked building blocks the locked layouts use, so the custom face
655
+ // carries the same markers as banner/sidebar and the crawl monitor reads them alike.
656
+ //
657
+ // NOT the ftc-guard, though — and the comment here claimed otherwise for as long as the
658
+ // face has existed. `modal/ftc-guard.ts` looks for `data-ftc-line` / `data-ftc-disclosure`
659
+ // hooks, which `CertBadge.tsx` and `render-badge-html.ts` emit and this renderer emits at
660
+ // NONE of its three scope-line sites. The kill switch therefore cannot see anything
661
+ // renderCertBlock produces, on ANY face. Whether it should is a live design question
662
+ // (which surfaces the switch covers) — not something to settle by quietly adding hooks.
591
663
  // ─────────────────────────────────────────────────────────────────────────────
592
664
 
593
665
  function renderCustomFace(facts: CertBlockFacts, placed: PlacedTest): string {
@@ -629,18 +701,18 @@ function renderCustomFace(facts: CertBlockFacts, placed: PlacedTest): string {
629
701
  )
630
702
  }
631
703
 
632
- // 5. memo (sidebar's blockquote)
633
- if (placed('memo')) {
704
+ // 5. memo (sidebar's blockquote) — placement AND content, like `bio` directly above
705
+ if (placed('memo') && facts.memo) {
634
706
  parts.push(
635
707
  `<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
708
  )
637
709
  }
638
710
 
639
711
  // 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>`
712
+ const cueEl = showsCompensationCue(facts, placed)
713
+ ? `<span style="font-family:var(--font-mono);font-size:10px;letter-spacing:.1em;text-transform:uppercase;">${escapeHtml(COMPENSATED_EXPERT_CUE)}</span>`
642
714
  : ''
643
- const scopeEl = placed('scopeLine') ? escapeHtml(locked(facts.scopeLine, CERT_SCOPE_LINE)) : ''
715
+ const scopeEl = placed('scopeLine') ? escapeHtml(CERT_SCOPE_LINE) : ''
644
716
  if (cueEl || scopeEl) {
645
717
  parts.push(
646
718
  `<div style="font-size:12.5px;line-height:1.5;color:var(--navy-55);margin-top:16px;">${cueEl}${cueEl && scopeEl ? ' · ' : ''}${scopeEl}</div>`,
@@ -671,15 +743,19 @@ function renderCustomFace(facts: CertBlockFacts, placed: PlacedTest): string {
671
743
  // ─────────────────────────────────────────────────────────────────────────────
672
744
 
673
745
  /**
674
- * Render the LOCKED CertREV cert design as an SSR-safe HTML string for one of the
675
- * three placement modes. Returns a self-contained `<div class="certrev-cert …">`
676
- * that needs no JS to render (the modal + bio accordion progressively enhance).
746
+ * Render the LOCKED CertREV cert design as an SSR-safe HTML string for one placement
747
+ * face. Returns a self-contained `<div class="certrev-cert …">` that needs no JS to
748
+ * render (the modal + bio accordion progressively enhance).
749
+ *
750
+ * `mode` absent or unrecognised ⇒ `sidebar`. Each arm passes its own LITERAL to
751
+ * `root()` — never `input.mode` — so an untyped `mode` can reach neither the root class
752
+ * name nor the `data-certrev-mode` attribute.
677
753
  */
678
754
  export function renderCertBlock(input: RenderCertBlockInput): string {
679
755
  const theme = resolveBlockTheme(input.theme)
680
- const { facts, mode, face } = input
756
+ const { facts, face } = input
681
757
  const placed = placedTester(face)
682
- switch (mode) {
758
+ switch (input.mode) {
683
759
  case 'banner': {
684
760
  // Suppress the memo card entirely when the engine placed none of its fields
685
761
  // (avoids an empty bordered shell). Grandfather: all placed ⇒ card renders.
@@ -687,23 +763,30 @@ export function renderCertBlock(input: RenderCertBlockInput): string {
687
763
  placed('memo') || placed('profileLink') || placed('certificateLink') || placed('scopeLine')
688
764
  ? bannerMemoCard(facts, placed)
689
765
  : ''
690
- // First-class memo split (POR-10741): default 'full' is byte-identical to the prior
766
+ // First-class memo split: default 'full' is byte-identical to the prior
691
767
  // 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'
768
+ // the memo as its own block (retires the CertReviewCard string-split shim). An
769
+ // unknown `part` falls back to 'full' by this written rule, not by a catch-all arm.
770
+ const part = input.part === 'header' || input.part === 'memo' ? input.part : 'full'
694
771
  const inner =
695
772
  part === 'header'
696
773
  ? bannerHeaderCard(facts, placed)
697
774
  : part === 'memo'
698
775
  ? memoCard
699
776
  : bannerHeaderCard(facts, placed) + memoCard
700
- return root(mode, theme, inner, face)
777
+ return root('banner', theme, inner, face)
701
778
  }
702
- case 'sidebar':
703
- return root(mode, theme, sidebarCard(facts, placed), face)
704
779
  case 'floating':
705
- return root(mode, theme, floatingPill(facts, placed), face)
780
+ return root('floating', theme, floatingPill(facts, placed), face)
706
781
  case 'custom':
707
- return root(mode, theme, renderCustomFace(facts, placed), face)
782
+ return root('custom', theme, renderCustomFace(facts, placed), face)
783
+ // THE default placement, named here and nowhere else: the declared default and the
784
+ // runtime fallback are the same line, so they cannot drift apart. Naming `sidebar` as a
785
+ // real case also keeps the exhaustiveness read over CertBlockLayout honest — a reader
786
+ // sees all four faces handled — while the shared `default:` catches untyped input.
787
+ // biome-ignore lint/complexity/noUselessSwitchCase: the redundancy IS the mechanism (see above)
788
+ case 'sidebar':
789
+ default:
790
+ return root('sidebar', theme, sidebarCard(facts, placed), face)
708
791
  }
709
792
  }
@@ -4,19 +4,18 @@
4
4
  * layer) + the two always-on COMPLIANCE strings.
5
5
  * ─────────────────────────────────────────────────────────────────────────────
6
6
  *
7
- * WS6 of the unified cert-render / design-guide build
8
- * (~/.claude-account-m/plans/smooth-meandering-graham.md). This is the Builder /
9
- * headless projection twin's half of the "law-as-code" contract: cert-block is one
10
- * of the four render surfaces (Shopify Liquid, WordPress PHP, JS engine, and this)
11
- * that must paint the SAME compliant, optionally-themed badge from the SAME facts.
7
+ * This is the Builder / headless projection twin's half of the "law-as-code"
8
+ * contract: cert-block is one of the four render surfaces (Shopify Liquid,
9
+ * WordPress PHP, JS engine, and this) that must paint the SAME compliant,
10
+ * optionally-themed badge from the SAME facts.
12
11
  *
13
12
  * cert-block is a self-contained npm package — it does NOT import the portal's
14
- * `@/lib/design-guide` enforcement core. Instead it mirrors, at the RENDER edge,
15
- * the small subset that shape the projection carries:
13
+ * design-guide enforcement core. Instead it mirrors, at the RENDER edge, the
14
+ * small subset that shape the projection carries:
16
15
  *
17
16
  * 1. The four allowlisted THEMEABLE tokens `{accentColor, surface, cornerRadius,
18
17
  * fontSlot}`, emitted as the exact `--certrev-*` CSS custom properties the
19
- * Shopify/WordPress engines emit and the WS11 crawl monitor asserts
18
+ * Shopify/WordPress engines emit and the crawl monitor asserts
20
19
  * (`--certrev-accent` / `--certrev-surface` / `--certrev-radius` /
21
20
  * `--certrev-font`). Each is RE-VALIDATED here at render time (defense in
22
21
  * depth over the write-path fail-closed gate) against the SAME strict
@@ -42,7 +41,7 @@
42
41
  */
43
42
 
44
43
  // ─────────────────────────────────────────────────────────────────────────────
45
- // Themeable token vocabulary (mirrors portal src/lib/design-guide/tokens.ts)
44
+ // Themeable token vocabulary (mirrors the portal's design-guide token module)
46
45
  // ─────────────────────────────────────────────────────────────────────────────
47
46
 
48
47
  /** The font ENUM. Resolves to a FIXED slot stack — never a free-form family. */
@@ -52,9 +51,9 @@ export type FontSlot = (typeof FONT_SLOTS)[number]
52
51
  /**
53
52
  * The `fontSlot` → fixed font-stack literal — the render-def theme map the webcomponent MODAL
54
53
  * (`resolveRenderTheme` → `renderBadgeHtml`) and the legacy `<CertBadge>` resolve. The three original
55
- * slots (sans/serif/system) stay BYTE-IDENTICAL to the Shopify Liquid engine's stacks
56
- * (`shopify/extensions/certrev-schema/snippets/certrev-badge.liquid`), so a themed brand's popup modal
57
- * and its inline Liquid card paint the same typography — the load-bearing card==modal invariant.
54
+ * slots (sans/serif/system) stay BYTE-IDENTICAL to the Shopify Liquid engine's badge-snippet
55
+ * stacks, so a themed brand's popup modal and its inline Liquid card paint the same
56
+ * typography — the load-bearing card==modal invariant.
58
57
  * grotesk (Plus Jakarta Sans) + mono (JetBrains Mono) are ADDED at the shared guide values —
59
58
  * byte-identical to portal `FONT_SLOT_STACKS`, this package's `RENDER_BLOCK_FONT_STACKS`, and the
60
59
  * Liquid twin's added branches — so card == modal holds for them too. A raw family string in a
@@ -203,8 +202,8 @@ export function isFieldHidden(def: CertRenderDef | undefined, id: BadgeVisibilit
203
202
  // map pins them to the guide table; grotesk/mono are byte-identical across both.
204
203
 
205
204
  /**
206
- * The full portal font-slot enum (mirrors portal `src/lib/design-guide/tokens.ts`
207
- * `FONT_SLOTS`). `grotesk` ⇐ the guide's wordmark slot; `mono` ⇐ the label slot.
205
+ * The full portal font-slot enum (mirrors the portal design-guide's `FONT_SLOTS`).
206
+ * `grotesk` ⇐ the guide's wordmark slot; `mono` ⇐ the label slot.
208
207
  */
209
208
  export const RENDER_BLOCK_FONT_SLOTS = ['sans', 'serif', 'system', 'grotesk', 'mono'] as const
210
209
  export type RenderBlockFontSlot = (typeof RENDER_BLOCK_FONT_SLOTS)[number]
@@ -244,7 +243,7 @@ function parseHexColor(hex: string): { r: number; g: number; b: number } | null
244
243
 
245
244
  /**
246
245
  * AUTO-CONTRAST PICKER — the ink painted over an opaque fill. BYTE-FAITHFUL to the portal's
247
- * `accentFg` (src/lib/design-guide/tokens.ts): Rec.601 perceptual luma with a strict `> 0.62`
246
+ * design-guide `accentFg`: Rec.601 perceptual luma with a strict `> 0.62`
248
247
  * cutoff, returning EXACTLY `#141414` (near-black) or `#ffffff`; an unparseable value falls back
249
248
  * to the dark ink (safe default). Used at the render edge to DERIVE `barInkFg` from a themed
250
249
  * `barInk` (v2 Phase 1), so the bar foreground always clears its own fill with no separate stored
package/src/index.ts CHANGED
@@ -36,12 +36,14 @@ export {
36
36
  type ResolvedDisplay,
37
37
  resolveDisplay,
38
38
  } from './components/format.js'
39
- // ── Pre-sales PREVIEW block — static, non-delivery, hard-bounded (POR-10743) ──
39
+ // ── Pre-sales PREVIEW block — static, non-delivery, hard-bounded ──
40
40
  export {
41
41
  type PreviewBlockOptions,
42
42
  previewExpiry,
43
43
  renderPreviewBlock,
44
44
  } from './components/preview-block.js'
45
+ // ── Link qualification — the `rel` every anchor this package emits carries (read-only) ──
46
+ export { CERTREV_LINK_REL } from './components/rel.js'
45
47
  // ── Cert-render BLOCK — the LOCKED 3-mode cert design as an SSR-safe HTML string ──
46
48
  export {
47
49
  type CertBlockFace,
@@ -51,7 +53,7 @@ export {
51
53
  type RenderCertBlockInput,
52
54
  renderCertBlock,
53
55
  } from './components/render-cert-block.js'
54
- // ── Design-guide render-def support (WS6: themeable tokens + subtractive visibility) ──
56
+ // ── Design-guide render-def support (themeable tokens + subtractive visibility) ──
55
57
  export {
56
58
  accentFg,
57
59
  type BadgeVisibilityFieldId,