@certrev/cert-block 0.3.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 -7
  19. package/dist/components/render-cert-block.d.ts.map +1 -1
  20. package/dist/components/render-cert-block.js +304 -80
  21. package/dist/components/render-cert-block.js.map +1 -1
  22. package/dist/components/render-def.d.ts +27 -0
  23. package/dist/components/render-def.d.ts.map +1 -1
  24. package/dist/components/render-def.js +38 -0
  25. package/dist/components/render-def.js.map +1 -1
  26. package/dist/index.d.ts +3 -2
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +3 -1
  29. package/dist/index.js.map +1 -1
  30. package/dist/modal/avatar-resize.d.ts +60 -0
  31. package/dist/modal/avatar-resize.d.ts.map +1 -0
  32. package/dist/modal/avatar-resize.js +91 -0
  33. package/dist/modal/avatar-resize.js.map +1 -0
  34. package/dist/modal/cert-modal-view.d.ts +74 -0
  35. package/dist/modal/cert-modal-view.d.ts.map +1 -0
  36. package/dist/modal/cert-modal-view.js +160 -0
  37. package/dist/modal/cert-modal-view.js.map +1 -0
  38. package/dist/modal/certrev-cert-modal.d.ts +72 -0
  39. package/dist/modal/certrev-cert-modal.d.ts.map +1 -0
  40. package/dist/modal/certrev-cert-modal.js +270 -0
  41. package/dist/modal/certrev-cert-modal.js.map +1 -0
  42. package/dist/modal/certrev-cert-modal.styles.d.ts +35 -0
  43. package/dist/modal/certrev-cert-modal.styles.d.ts.map +1 -0
  44. package/dist/modal/certrev-cert-modal.styles.js +488 -0
  45. package/dist/modal/certrev-cert-modal.styles.js.map +1 -0
  46. package/dist/modal/certrev-cert.entry.d.ts +19 -0
  47. package/dist/modal/certrev-cert.entry.d.ts.map +1 -0
  48. package/dist/modal/certrev-cert.entry.js +39 -0
  49. package/dist/modal/certrev-cert.entry.js.map +1 -0
  50. package/dist/modal/certrev-cert.js +557 -0
  51. package/dist/modal/display-strings.d.ts +22 -0
  52. package/dist/modal/display-strings.d.ts.map +1 -0
  53. package/dist/modal/display-strings.js +35 -0
  54. package/dist/modal/display-strings.js.map +1 -0
  55. package/dist/modal/escape.d.ts +13 -0
  56. package/dist/modal/escape.d.ts.map +1 -0
  57. package/dist/modal/escape.js +20 -0
  58. package/dist/modal/escape.js.map +1 -0
  59. package/dist/modal/font-face.d.ts +4 -0
  60. package/dist/modal/font-face.d.ts.map +1 -0
  61. package/dist/modal/font-face.js +137 -0
  62. package/dist/modal/font-face.js.map +1 -0
  63. package/dist/modal/ftc-disclosure.d.ts +12 -0
  64. package/dist/modal/ftc-disclosure.d.ts.map +1 -0
  65. package/dist/modal/ftc-disclosure.js +12 -0
  66. package/dist/modal/ftc-disclosure.js.map +1 -0
  67. package/dist/modal/ftc-guard.d.ts +50 -0
  68. package/dist/modal/ftc-guard.d.ts.map +1 -0
  69. package/dist/modal/ftc-guard.js +117 -0
  70. package/dist/modal/ftc-guard.js.map +1 -0
  71. package/dist/modal/index.d.ts +20 -0
  72. package/dist/modal/index.d.ts.map +1 -0
  73. package/dist/modal/index.js +25 -0
  74. package/dist/modal/index.js.map +1 -0
  75. package/dist/modal/interactions.d.ts +53 -0
  76. package/dist/modal/interactions.d.ts.map +1 -0
  77. package/dist/modal/interactions.js +147 -0
  78. package/dist/modal/interactions.js.map +1 -0
  79. package/dist/modal/logo-paths.d.ts +15 -0
  80. package/dist/modal/logo-paths.d.ts.map +1 -0
  81. package/dist/modal/logo-paths.js +24 -0
  82. package/dist/modal/logo-paths.js.map +1 -0
  83. package/dist/modal/placement.d.ts +95 -0
  84. package/dist/modal/placement.d.ts.map +1 -0
  85. package/dist/modal/placement.js +302 -0
  86. package/dist/modal/placement.js.map +1 -0
  87. package/dist/modal/revalidate.d.ts +48 -0
  88. package/dist/modal/revalidate.d.ts.map +1 -0
  89. package/dist/modal/revalidate.js +65 -0
  90. package/dist/modal/revalidate.js.map +1 -0
  91. package/dist/modal/verify-client.d.ts +24 -0
  92. package/dist/modal/verify-client.d.ts.map +1 -0
  93. package/dist/modal/verify-client.js +38 -0
  94. package/dist/modal/verify-client.js.map +1 -0
  95. package/package.json +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 +380 -5
  99. package/src/builder/__tests__/builder.test.tsx +71 -0
  100. package/src/builder/cert-chrome-data.ts +159 -0
  101. package/src/builder/cert-review-card.tsx +177 -0
  102. package/src/builder/index.tsx +18 -0
  103. package/src/components/preview-block.ts +74 -0
  104. package/src/components/render-cert-block.ts +387 -102
  105. package/src/components/render-def.ts +55 -0
  106. package/src/index.ts +9 -0
  107. package/src/modal/avatar-resize.ts +99 -0
  108. package/src/modal/cert-modal-view.test.ts +175 -0
  109. package/src/modal/cert-modal-view.ts +210 -0
  110. package/src/modal/certrev-cert-modal.styles.ts +494 -0
  111. package/src/modal/certrev-cert-modal.test.ts +125 -0
  112. package/src/modal/certrev-cert-modal.ts +273 -0
  113. package/src/modal/certrev-cert.entry.ts +44 -0
  114. package/src/modal/display-strings.ts +38 -0
  115. package/src/modal/escape.ts +20 -0
  116. package/src/modal/font-face.ts +138 -0
  117. package/src/modal/ftc-disclosure.ts +13 -0
  118. package/src/modal/ftc-guard.test.ts +153 -0
  119. package/src/modal/ftc-guard.ts +119 -0
  120. package/src/modal/index.ts +34 -0
  121. package/src/modal/interactions.test.ts +250 -0
  122. package/src/modal/interactions.ts +166 -0
  123. package/src/modal/logo-paths.ts +23 -0
  124. package/src/modal/placement.test.ts +398 -0
  125. package/src/modal/placement.ts +331 -0
  126. package/src/modal/revalidate.test.ts +111 -0
  127. package/src/modal/revalidate.ts +81 -0
  128. package/src/modal/verify-client.test.ts +164 -0
  129. package/src/modal/verify-client.ts +42 -0
@@ -16,7 +16,9 @@
16
16
  import { describe, expect, it } from 'vitest'
17
17
  import {
18
18
  CERT_SCOPE_LINE,
19
+ type CertBlockFace,
19
20
  type CertBlockFacts,
21
+ type CertBlockLayout,
20
22
  type CertBlockMode,
21
23
  COMPENSATED_EXPERT_CUE,
22
24
  CREDENTIAL_VERIFIED_ATTRIBUTION,
@@ -48,6 +50,17 @@ const THEMED: ResolvedBlockTheme = {
48
50
  accentFg: '#141414',
49
51
  }
50
52
 
53
+ /**
54
+ * A v2 brand-ink themed brand (e.l.f.-shaped): a black seal bar + near-black free-content ink on
55
+ * top of the accent/surface/corner/font theming. `barInkFg` is DERIVED at the edge from `barInk`
56
+ * (accentFg('#000000') = '#ffffff'), so it need not be supplied here.
57
+ */
58
+ const THEMED_INK: ResolvedBlockTheme = {
59
+ ...THEMED,
60
+ barInk: '#000000',
61
+ inkColor: '#1a1a1a',
62
+ }
63
+
51
64
  const MODES: CertBlockMode[] = ['banner', 'sidebar', 'floating']
52
65
  const CLAIM_FACES: CertBlockMode[] = ['banner', 'sidebar']
53
66
 
@@ -90,6 +103,53 @@ describe('renderCertBlock — locked-design snapshots', () => {
90
103
  expect(renderCertBlock({ mode, theme: THEMED, facts: FACTS })).toMatchSnapshot()
91
104
  })
92
105
  }
106
+ for (const mode of CLAIM_FACES) {
107
+ it(`${mode} (v2 brand-ink: barInk + inkColor) matches the locked snapshot`, () => {
108
+ expect(renderCertBlock({ mode, theme: THEMED_INK, facts: FACTS })).toMatchSnapshot()
109
+ })
110
+ }
111
+ })
112
+
113
+ // ─────────────────────────────────────────────────────────────────────────────
114
+ // First-class banner memo split (POR-10741) — retires the CertReviewCard string-hack
115
+ // ─────────────────────────────────────────────────────────────────────────────
116
+
117
+ describe('renderCertBlock — banner memo split (part)', () => {
118
+ it("default (no part) is byte-identical to part:'full'", () => {
119
+ expect(renderCertBlock({ mode: 'banner', facts: FACTS })).toBe(
120
+ renderCertBlock({ mode: 'banner', part: 'full', facts: FACTS }),
121
+ )
122
+ })
123
+
124
+ it("part:'full' === header + memo (the two split parts recompose the whole card set)", () => {
125
+ const header = renderCertBlock({ mode: 'banner', part: 'header', facts: FACTS })
126
+ const memo = renderCertBlock({ mode: 'banner', part: 'memo', facts: FACTS })
127
+ // Each part is a self-contained themed root; concatenating the header's inner + the memo's
128
+ // inner reproduces the full render's inner (same root shell, split content).
129
+ const inner = (html: string) => html.slice(html.indexOf('>') + 1, html.lastIndexOf('</div>'))
130
+ const full = renderCertBlock({ mode: 'banner', part: 'full', facts: FACTS })
131
+ expect(inner(header) + inner(memo)).toBe(inner(full))
132
+ })
133
+
134
+ it("part:'header' renders the header card WITHOUT the memo quote", () => {
135
+ const header = renderCertBlock({ mode: 'banner', part: 'header', facts: FACTS })
136
+ expect(header.startsWith('<div class="certrev-cert certrev-cert--banner')).toBe(true)
137
+ expect(header).toContain('Expert reviewed')
138
+ expect(header).not.toContain(FACTS.memo)
139
+ })
140
+
141
+ it("part:'memo' renders the memo card WITHOUT the header eyebrow", () => {
142
+ const memo = renderCertBlock({ mode: 'banner', part: 'memo', facts: FACTS })
143
+ expect(memo.startsWith('<div class="certrev-cert certrev-cert--banner')).toBe(true)
144
+ expect(memo).toContain(FACTS.memo)
145
+ expect(memo).not.toContain('Expert reviewed')
146
+ })
147
+
148
+ it('part is ignored outside banner mode (sidebar renders identically with or without it)', () => {
149
+ expect(renderCertBlock({ mode: 'sidebar', part: 'header', facts: FACTS })).toBe(
150
+ renderCertBlock({ mode: 'sidebar', facts: FACTS }),
151
+ )
152
+ })
93
153
  })
94
154
 
95
155
  // ─────────────────────────────────────────────────────────────────────────────
@@ -166,7 +226,11 @@ describe('theming', () => {
166
226
  expect(html).toContain('--ba:#0a1b3f')
167
227
  expect(html).toContain('--ba-fg:#ffffff')
168
228
  expect(html).toContain('--br:14px')
169
- expect(html).toContain(`--bf:${RENDER_BLOCK_FONT_STACKS.sans}`)
229
+ // The body font is a host-overridable HOOK (POR-10742): the resolved stack is the FALLBACK
230
+ // inside `--cr-bf:var(--bf,<stack>)`, and the root must NOT hard-declare a bare `--bf:` (which
231
+ // would shadow a host page's `--bf` and force `!important`).
232
+ expect(html).toContain(`--cr-bf:var(--bf,${RENDER_BLOCK_FONT_STACKS.sans})`)
233
+ expect(html).not.toContain(';--bf:')
170
234
  expect(html).toContain('--surface:#ffffff')
171
235
  expect(html).toContain('data-certrev-strings-version="2026-07"')
172
236
  })
@@ -176,7 +240,7 @@ describe('theming', () => {
176
240
  expect(html).toContain('--ba:#E8603C')
177
241
  expect(html).toContain('--ba-fg:#141414')
178
242
  expect(html).toContain('--br:22px')
179
- expect(html).toContain(`--bf:${RENDER_BLOCK_FONT_STACKS.grotesk}`)
243
+ expect(html).toContain(`--cr-bf:var(--bf,${RENDER_BLOCK_FONT_STACKS.grotesk})`)
180
244
  expect(html).toContain('--surface:#faf8f4')
181
245
  })
182
246
 
@@ -201,7 +265,7 @@ describe('theming', () => {
201
265
  expect(html).toContain('--ba:#0a1b3f')
202
266
  expect(html).toContain('--ba-fg:#ffffff')
203
267
  expect(html).toContain('--br:14px')
204
- expect(html).toContain(`--bf:${RENDER_BLOCK_FONT_STACKS.sans}`)
268
+ expect(html).toContain(`--cr-bf:var(--bf,${RENDER_BLOCK_FONT_STACKS.sans})`)
205
269
  })
206
270
 
207
271
  it('the fixed CertREV logo chrome renders on the banner + sidebar headers and the memo credit', () => {
@@ -219,6 +283,94 @@ describe('theming', () => {
219
283
  })
220
284
  })
221
285
 
286
+ // ─────────────────────────────────────────────────────────────────────────────
287
+ // V2 brand-ink theming (barInk + inkColor) — parity with the portal Liquid/CSS
288
+ // engines. The bar/ink wire vars are emitted ONLY when the def sets the token;
289
+ // unset ⇒ neither (byte-identical navy default). The FREE-content ink follows the
290
+ // brand ink; the LOCKED legal disclosures stay on the navy trust ink.
291
+ // ─────────────────────────────────────────────────────────────────────────────
292
+
293
+ describe('v2 brand-ink theming — barInk + inkColor (portal parity)', () => {
294
+ it('unset ⇒ NEITHER the bar wire vars NOR --certrev-ink is emitted (byte-clean default)', () => {
295
+ const html = renderCertBlock({ mode: 'banner', facts: FACTS })
296
+ expect(html).not.toContain('--certrev-bar-bg:#')
297
+ expect(html).not.toContain('--certrev-bar-fg:#')
298
+ expect(html).not.toContain('--certrev-ink:#')
299
+ // the --cr-ink family is ALWAYS defined; unthemed it is the EXACT navy literals (no color-mix)
300
+ expect(html).toContain('--cr-ink:var(--certrev-ink,#0a1b3f)')
301
+ expect(html).toContain('--cr-ink-sub:rgba(10,27,63,.55)') // === --navy-55
302
+ expect(html).toContain('--cr-ink-soft:rgba(10,27,63,.75)') // === --navy-75
303
+ // the header bar consumes the navy/white fallbacks (byte-identical)
304
+ expect(html).toContain('background:var(--certrev-bar-bg,#0a1b3f);color:var(--certrev-bar-fg,#fff)')
305
+ })
306
+
307
+ it('barInk set ⇒ emits --certrev-bar-bg + the DERIVED --certrev-bar-fg (accentFg)', () => {
308
+ const html = renderCertBlock({ mode: 'banner', theme: { ...THEMED, barInk: '#000000' }, facts: FACTS })
309
+ expect(html).toContain('--certrev-bar-bg:#000000')
310
+ // accentFg('#000000') = '#ffffff' (a dark bar takes a white foreground)
311
+ expect(html).toContain('--certrev-bar-fg:#ffffff')
312
+ })
313
+
314
+ it('barInkFg is DERIVED at the edge — a LIGHT bar → #141414, and a bogus input barInkFg is ignored', () => {
315
+ const html = renderCertBlock({
316
+ mode: 'floating',
317
+ // pass a WRONG barInkFg — resolveBlockTheme recomputes it from barInk via accentFg
318
+ theme: { ...THEMED, barInk: '#f5f5f5', barInkFg: '#ffffff' },
319
+ facts: FACTS,
320
+ })
321
+ expect(html).toContain('--certrev-bar-bg:#f5f5f5')
322
+ expect(html).toContain('--certrev-bar-fg:#141414') // light bar → dark ink, recomputed
323
+ expect(html).not.toContain('--certrev-bar-fg:#ffffff')
324
+ })
325
+
326
+ it('inkColor set ⇒ emits --certrev-ink + derives the muted scale off --cr-ink via color-mix', () => {
327
+ const html = renderCertBlock({ mode: 'banner', theme: { ...THEMED, inkColor: '#1a1a1a' }, facts: FACTS })
328
+ expect(html).toContain('--certrev-ink:#1a1a1a')
329
+ expect(html).toContain('--cr-ink:var(--certrev-ink,#0a1b3f)')
330
+ expect(html).toContain('--cr-ink-sub:color-mix(in srgb,var(--cr-ink) 55%,transparent)')
331
+ expect(html).toContain('--cr-ink-soft:color-mix(in srgb,var(--cr-ink) 75%,transparent)')
332
+ })
333
+
334
+ it('FREE content follows the brand ink; LOCKED legal disclosures stay on the navy trust ink', () => {
335
+ const html = renderCertBlock({ mode: 'banner', theme: THEMED_INK, facts: FACTS })
336
+ // FREE — names ride --cr-ink; the memo/bio ride --cr-ink-soft; title/credential/initials --cr-ink-sub
337
+ expect(html).toContain('font-weight:700;color:var(--cr-ink);">Jane Doe, MD') // reviewer name
338
+ expect(html).toContain('color:var(--cr-ink);">The Editorial Team') // author name
339
+ expect(html).toContain('color:var(--cr-ink-soft);white-space:pre-line;">Patients ask me') // memo body
340
+ // LOCKED — the legal/trust text is NEVER repointed to --cr-ink (proves the free/locked boundary)
341
+ expect(html).toContain('color:var(--navy-55);line-height:1.45;margin-top:3px;">Credential verified by CertREV')
342
+ expect(html).toContain(`color:var(--navy-55);margin-top:8px;line-height:1.5;">${CERT_SCOPE_LINE}</div>`) // FTC scope
343
+ expect(html).toContain('color:var(--navy);margin-bottom:11px;">Reviewed by') // section label
344
+ expect(html).toContain('text-transform:uppercase;color:var(--navy-55);"><span>Certified') // certified date + cue row
345
+ // and the legal text never carries a --cr-ink token
346
+ expect(html).not.toMatch(/--cr-ink[^;]*;[^"]*">Credential verified by CertREV/)
347
+ expect(html).not.toMatch(/--cr-ink[^;]*;[^"]*">Independent editorial review/)
348
+ })
349
+
350
+ it('barInk themes the pill (floating) — portal parity: the pill follows the BAR, not the accent', () => {
351
+ const html = renderCertBlock({ mode: 'floating', theme: THEMED_INK, facts: FACTS })
352
+ expect(html).toContain('--certrev-bar-bg:#000000')
353
+ expect(html).toContain('background:var(--certrev-bar-bg,#0a1b3f);color:var(--certrev-bar-fg,#fff)')
354
+ })
355
+
356
+ it('an invalid barInk / inkColor fails SAFE — dropped, never emitted (bar + ink stay navy)', () => {
357
+ const html = renderCertBlock({
358
+ mode: 'banner',
359
+ theme: {
360
+ ...THEMED,
361
+ barInk: 'black;}</style>' as ResolvedBlockTheme['barInk'],
362
+ inkColor: '#12' as ResolvedBlockTheme['inkColor'],
363
+ },
364
+ facts: FACTS,
365
+ })
366
+ expect(html).not.toContain('--certrev-bar-bg:#')
367
+ expect(html).not.toContain('--certrev-ink:#')
368
+ expect(html).not.toContain('black;}')
369
+ // the free-content ink falls back to the navy literals
370
+ expect(html).toContain('--cr-ink-sub:rgba(10,27,63,.55)')
371
+ })
372
+ })
373
+
222
374
  // ─────────────────────────────────────────────────────────────────────────────
223
375
  // Escaping — storefront HTML built by hand
224
376
  // ─────────────────────────────────────────────────────────────────────────────
@@ -270,8 +422,8 @@ describe('reviewer name — honorific stripping (Liquid parity)', () => {
270
422
  // "Dr. Marcus Ellison" → "ME" avatar, never "DM"
271
423
  expect(html).toContain('flex-shrink:0;">ME<')
272
424
  expect(html).not.toContain('flex-shrink:0;">DM<')
273
- // "View Marcus's profile", never "View Dr.'s profile" (apostrophe-agnostic)
274
- expect(html).toMatch(/View Marcus.s profile/)
425
+ // "Marcus's Profile", never "Dr.'s Profile" (apostrophe-agnostic)
426
+ expect(html).toMatch(/Marcus.s Profile/)
275
427
  expect(html).not.toMatch(/View Dr\..s profile/)
276
428
  })
277
429
 
@@ -280,3 +432,226 @@ describe('reviewer name — honorific stripping (Liquid parity)', () => {
280
432
  expect(html).toContain('flex-shrink:0;">JD<')
281
433
  })
282
434
  })
435
+
436
+ // ─────────────────────────────────────────────────────────────────────────────
437
+ // WS-B — placedFields-strict rendering: visibility is the engine's face alone,
438
+ // `rung` is a stamp (never a branch), and an ABSENT face grandfathers 0.3.0.
439
+ // ─────────────────────────────────────────────────────────────────────────────
440
+
441
+ /** Every engine field id renderCertBlock knows how to paint. */
442
+ const ALL_FIELDS = [
443
+ 'label',
444
+ 'authorName',
445
+ 'authorTitle',
446
+ 'bylinePlain',
447
+ 'bylineCredentialed',
448
+ 'reviewerPhoto',
449
+ 'bio',
450
+ 'memo',
451
+ 'compensationCue',
452
+ 'scopeLine',
453
+ 'profileLink',
454
+ 'certificateLink',
455
+ ] as const
456
+
457
+ /** A full claim face (everything placed) — the strict analogue of the grandfathered full-facts render. */
458
+ const FULL_FACE: CertBlockFace = { rung: 'credentialed', placedFields: [...ALL_FIELDS] }
459
+
460
+ /** Drop the observability-only rung attribute so a strict render can be compared to its grandfathered twin. */
461
+ function stripRung(html: string): string {
462
+ return html.replace(/ data-certrev-rung="[^"]*"/g, '')
463
+ }
464
+
465
+ /** A face placing everything EXCEPT the given ids. */
466
+ function faceExcept(...omit: string[]): CertBlockFace {
467
+ return { rung: 'credentialed', placedFields: ALL_FIELDS.filter((f) => !omit.includes(f)) }
468
+ }
469
+
470
+ describe('WS-B: face-absent grandfathers the 0.3.0 full-facts render byte-for-byte', () => {
471
+ for (const mode of MODES) {
472
+ it(`${mode}: a full-face strict render === the grandfathered render (modulo the rung stamp)`, () => {
473
+ const grandfathered = renderCertBlock({ mode, facts: FACTS })
474
+ const strictFull = renderCertBlock({ mode, facts: FACTS, face: FULL_FACE })
475
+ // the ONLY difference a full face introduces is the observability stamp
476
+ expect(strictFull).toContain('data-certrev-rung="credentialed"')
477
+ expect(stripRung(strictFull)).toBe(grandfathered)
478
+ })
479
+ }
480
+
481
+ it('an absent face emits NO rung attribute (grandfather stays byte-clean)', () => {
482
+ expect(renderCertBlock({ mode: 'banner', facts: FACTS })).not.toContain('data-certrev-rung')
483
+ })
484
+ })
485
+
486
+ describe('WS-B: strict placedFields — a minimal face renders only what it placed', () => {
487
+ const MINIMAL: CertBlockFace = { rung: 'minimal', placedFields: ['label', 'profileLink', 'certificateLink'] }
488
+
489
+ it('custom: [label, profileLink, certificateLink] paints exactly those, nothing else', () => {
490
+ const html = renderCertBlock({ mode: 'custom', facts: FACTS, face: MINIMAL })
491
+ // the placed fields
492
+ expect(html).toContain('Expert reviewed')
493
+ expect(html).toMatch(/Jane's Profile/)
494
+ expect(html).toContain('>Certificate</a>')
495
+ // everything NOT placed is absent
496
+ expect(html).not.toContain('Written by')
497
+ expect(html).not.toContain('Jane Doe') // no byline / reviewer name
498
+ expect(html).not.toContain('Expert memo')
499
+ expect(html).not.toContain(FACTS.memo)
500
+ expect(html).not.toContain(FACTS.bio)
501
+ expect(html).not.toContain(CERT_SCOPE_LINE)
502
+ expect(html).not.toContain(CREDENTIAL_VERIFIED_ATTRIBUTION)
503
+ expect(visibleText(html)).not.toContain(COMPENSATED_EXPERT_CUE)
504
+ // the custom face still carries the strict + observability markers
505
+ expect(html).toContain('data-certrev-strings-version="2026-07"')
506
+ expect(html).toContain('data-certrev-rung="minimal"')
507
+ expect(html).toContain('data-certrev-mode="custom"')
508
+ })
509
+
510
+ it('banner: a minimal face drops the author + reviewer body + compensation cue', () => {
511
+ const html = renderCertBlock({ mode: 'banner', facts: FACTS, face: MINIMAL })
512
+ expect(html).toContain('Expert reviewed') // label header
513
+ expect(html).not.toContain('Written by')
514
+ expect(html).not.toContain('Jane Doe') // no byline placed → no reviewer name anywhere
515
+ expect(html).not.toContain(CREDENTIAL_VERIFIED_ATTRIBUTION)
516
+ expect(visibleText(html)).not.toContain(COMPENSATED_EXPERT_CUE)
517
+ // the memo card still renders because profile/certificate are placed
518
+ expect(html).toMatch(/Jane's Profile/)
519
+ expect(html).toContain('>Certificate</a>')
520
+ })
521
+ })
522
+
523
+ describe('WS-B: a claim face paints all disclosures byte-verbatim', () => {
524
+ const CLAIM: CertBlockFace = {
525
+ rung: 'memo',
526
+ placedFields: [
527
+ 'label',
528
+ 'bylineCredentialed',
529
+ 'reviewerPhoto',
530
+ 'memo',
531
+ 'compensationCue',
532
+ 'scopeLine',
533
+ 'profileLink',
534
+ 'certificateLink',
535
+ ],
536
+ }
537
+
538
+ for (const mode of ['banner', 'sidebar', 'custom'] as CertBlockLayout[]) {
539
+ it(`${mode}: the compensation cue + FTC scope line + dated credential verification all render`, () => {
540
+ const html = renderCertBlock({ mode, facts: FACTS, face: CLAIM })
541
+ expect(visibleText(html)).toContain(COMPENSATED_EXPERT_CUE)
542
+ expect(html).toContain(CERT_SCOPE_LINE) // byte-exact incl. the U+2019 apostrophe
543
+ expect(html).toContain(CREDENTIAL_VERIFIED_ATTRIBUTION)
544
+ expect(html).toContain('on Apr 14, 2026')
545
+ // the credentialed byline + its dated verification stay GROUPED, in order
546
+ const iName = html.indexOf('Jane Doe, MD')
547
+ const iAttr = html.indexOf(CREDENTIAL_VERIFIED_ATTRIBUTION)
548
+ const iDate = html.indexOf('on Apr 14, 2026')
549
+ expect(iName).toBeGreaterThanOrEqual(0)
550
+ expect(iAttr).toBeGreaterThan(iName)
551
+ expect(iDate).toBeGreaterThan(iAttr)
552
+ })
553
+ }
554
+ })
555
+
556
+ describe('WS-B: the credentialed byline group is all-or-nothing on `bylineCredentialed`', () => {
557
+ it('sidebar: a PLAIN byline shows the name only — no credential suffix, no attribution/date', () => {
558
+ const face: CertBlockFace = { rung: 'named', placedFields: ['label', 'bylinePlain', 'reviewerPhoto'] }
559
+ const html = renderCertBlock({ mode: 'sidebar', facts: FACTS, face })
560
+ expect(html).toContain('Jane Doe')
561
+ expect(html).not.toContain('Jane Doe, MD')
562
+ expect(html).not.toContain(CREDENTIAL_VERIFIED_ATTRIBUTION)
563
+ expect(html).not.toContain('on Apr 14, 2026')
564
+ })
565
+
566
+ it('sidebar: a CREDENTIALED byline fuses the credential + the grouped attribution/date', () => {
567
+ const face: CertBlockFace = { rung: 'credentialed', placedFields: ['label', 'bylineCredentialed'] }
568
+ const html = renderCertBlock({ mode: 'sidebar', facts: FACTS, face })
569
+ expect(html).toContain('Jane Doe, MD')
570
+ expect(html).toContain(CREDENTIAL_VERIFIED_ATTRIBUTION)
571
+ expect(html).toContain('on Apr 14, 2026')
572
+ })
573
+ })
574
+
575
+ describe('WS-B: per-field subtraction drops exactly that markup', () => {
576
+ it('banner: omitting reviewerPhoto removes the reviewer avatar', () => {
577
+ expect(renderCertBlock({ mode: 'banner', facts: FACTS, face: FULL_FACE })).toContain('flex-shrink:0;">JD<')
578
+ expect(renderCertBlock({ mode: 'banner', facts: FACTS, face: faceExcept('reviewerPhoto') })).not.toContain(
579
+ 'flex-shrink:0;">JD<',
580
+ )
581
+ })
582
+
583
+ it('banner: omitting authorName removes the "Written by" column (and collapses the grid to 1fr)', () => {
584
+ const html = renderCertBlock({ mode: 'banner', facts: FACTS, face: faceExcept('authorName') })
585
+ expect(html).not.toContain('Written by')
586
+ expect(html).toContain('grid-template-columns:1fr;')
587
+ })
588
+
589
+ it('banner: omitting memo removes the memo body', () => {
590
+ const html = renderCertBlock({ mode: 'banner', facts: FACTS, face: faceExcept('memo') })
591
+ expect(html).not.toContain('Expert memo')
592
+ expect(html).not.toContain(FACTS.memo)
593
+ // but the trust footer + links survive (profile/certificate still placed)
594
+ expect(html).toContain('Verified by')
595
+ })
596
+
597
+ it('banner: omitting bio removes the hidden bio accordion', () => {
598
+ const html = renderCertBlock({ mode: 'banner', facts: FACTS, face: faceExcept('bio') })
599
+ expect(html).not.toContain(FACTS.bio)
600
+ expect(html).not.toContain('certrev-cert__bio')
601
+ })
602
+
603
+ it('sidebar: omitting BOTH links removes the entire footer grid', () => {
604
+ const html = renderCertBlock({
605
+ mode: 'sidebar',
606
+ facts: FACTS,
607
+ face: faceExcept('profileLink', 'certificateLink'),
608
+ })
609
+ expect(html).not.toContain('View Profile')
610
+ expect(html).not.toContain('>Certificate</a>')
611
+ expect(html).not.toContain('border-top:1px solid var(--navy-10);')
612
+ })
613
+
614
+ it('sidebar: a lone profile link drops its border-right and collapses the grid to 1fr', () => {
615
+ const html = renderCertBlock({ mode: 'sidebar', facts: FACTS, face: faceExcept('certificateLink') })
616
+ expect(html).toContain('View Profile')
617
+ expect(html).not.toContain('>Certificate</a>')
618
+ expect(html).toContain('grid-template-columns:1fr;border-top')
619
+ expect(html).not.toContain('border-right:1px solid var(--navy-10);')
620
+ })
621
+ })
622
+
623
+ describe('WS-B: custom face — a compliant vertical stack in canonical order', () => {
624
+ it('renders placed fields top-to-bottom: label → reviewer → memo → disclosures → actions', () => {
625
+ const html = renderCertBlock({ mode: 'custom', facts: FACTS, face: FULL_FACE })
626
+ const order = [
627
+ html.indexOf('Expert reviewed'), // 1. label
628
+ html.indexOf('Written by'), // 2. author
629
+ html.indexOf('Jane Doe, MD'), // 3. reviewer byline
630
+ html.indexOf(FACTS.bio), // 4. bio
631
+ html.indexOf(FACTS.memo), // 5. memo
632
+ html.indexOf(CERT_SCOPE_LINE), // 6. disclosures
633
+ html.indexOf("Jane's Profile"), // 7. actions
634
+ ]
635
+ for (const i of order) expect(i).toBeGreaterThanOrEqual(0)
636
+ const sorted = [...order].sort((a, b) => a - b)
637
+ expect(order).toEqual(sorted)
638
+ // the modal + strings + rung markers ride the same building blocks as the locked layouts
639
+ expect(html).toContain('data-certrev-modal-open')
640
+ expect(html).toContain('data-certrev-strings-version="2026-07"')
641
+ expect(html).toContain('data-certrev-rung="credentialed"')
642
+ // it is a single flex-column stack
643
+ expect(html).toContain('flex-direction:column;gap:14px;')
644
+ })
645
+ })
646
+
647
+ describe('WS-B: `rung` is a stamp, never a branch — it never changes visibility', () => {
648
+ for (const mode of ['banner', 'sidebar', 'floating', 'custom'] as CertBlockLayout[]) {
649
+ it(`${mode}: two faces with the same placedFields but different rung render identical HTML (modulo the rung attr)`, () => {
650
+ const placedFields = [...ALL_FIELDS]
651
+ const a = renderCertBlock({ mode, facts: FACTS, face: { rung: 'minimal', placedFields } })
652
+ const b = renderCertBlock({ mode, facts: FACTS, face: { rung: 'credentialed', placedFields } })
653
+ expect(a).not.toBe(b) // the rung attr differs
654
+ expect(stripRung(a)).toBe(stripRung(b)) // …but nothing else does
655
+ })
656
+ }
657
+ })
@@ -66,3 +66,74 @@ describe('Builder adapter — CertRevAnchor render', () => {
66
66
  expect(html).not.toContain('data-certrev-editor-placeholder')
67
67
  })
68
68
  })
69
+
70
+ // ── POR-10721 W1/W3: the PUSH kit (CertReviewCard) + contract lock ──
71
+ import {
72
+ BUILDER_CERT_CHROME_KEYS,
73
+ BUILDER_REGISTRATION,
74
+ type BuilderCertChromeData,
75
+ CERT_COMPONENT_NAME,
76
+ CertReviewCard,
77
+ WIRE_INPUTS,
78
+ } from '../index.js'
79
+
80
+ describe('POR-10721 W3 — Builder kit contract lock (inputs ⇄ wire-type parity)', () => {
81
+ it('WIRE_INPUTS names are EXACTLY the wire-type keys, in order (no silent drift)', () => {
82
+ expect(WIRE_INPUTS.map((i) => i.name)).toEqual([...BUILDER_CERT_CHROME_KEYS])
83
+ })
84
+ it('BUILDER_REGISTRATION carries every wire input + the placement inputs, named canonically', () => {
85
+ expect(BUILDER_REGISTRATION.name).toBe(CERT_COMPONENT_NAME)
86
+ const inputNames = new Set(BUILDER_REGISTRATION.inputs.map((i) => i.name))
87
+ for (const k of BUILDER_CERT_CHROME_KEYS) expect(inputNames.has(k)).toBe(true)
88
+ expect(inputNames.has('mode')).toBe(true) // a placement input, NOT a wire-type key
89
+ })
90
+ })
91
+
92
+ describe('POR-10721 W1 — CertReviewCard render + the fused-credential R1 gate', () => {
93
+ const base: Partial<BuilderCertChromeData> = {
94
+ reviewerName: 'Dr. Jane Doe',
95
+ verifyUrl: 'https://certrev.com/verify/abc',
96
+ bylinePlain: 'Reviewed on 2026-03-08 by Dr. Jane Doe.',
97
+ certifiedAt: '2026-03-08',
98
+ scopeLine: 'Independent editorial review.',
99
+ display: { showExpertPhoto: true, showCredentials: true },
100
+ renderDef: null,
101
+ stringsVersion: '2026-07',
102
+ }
103
+ it('renders a certified face (the reviewer name)', () => {
104
+ expect(renderToStaticMarkup(<CertReviewCard {...base} />)).toContain('Dr. Jane Doe')
105
+ })
106
+ it('name-only when credentialVerification is null — a credential never leaks without its date', () => {
107
+ expect(renderToStaticMarkup(<CertReviewCard {...base} credentialVerification={null} />)).not.toContain(
108
+ 'FAAAAI',
109
+ )
110
+ })
111
+ it('renders the credential ONLY as the fused (credential + verifiedAt) pair', () => {
112
+ const html = renderToStaticMarkup(
113
+ <CertReviewCard {...base} credentialVerification={{ credential: 'FAAAAI', verifiedAt: '2026-03-01' }} />,
114
+ )
115
+ expect(html).toContain('FAAAAI')
116
+ })
117
+ it('showCredentials:false collapses to name-only even with the pair present', () => {
118
+ const html = renderToStaticMarkup(
119
+ <CertReviewCard
120
+ {...base}
121
+ display={{ showExpertPhoto: true, showCredentials: false }}
122
+ credentialVerification={{ credential: 'FAAAAI', verifiedAt: '2026-03-01' }}
123
+ />,
124
+ )
125
+ expect(html).not.toContain('FAAAAI')
126
+ })
127
+ it('returns nothing pre-cert (no reviewerName / verifyUrl)', () => {
128
+ expect(renderToStaticMarkup(<CertReviewCard />)).toBe('')
129
+ })
130
+ it('passes `part` through to renderCertBlock (POR-10741 — header excludes the memo, memo excludes the header)', () => {
131
+ const withMemo = { ...base, memo: 'Checked the claims against current guidance; accurate.' }
132
+ const header = renderToStaticMarkup(<CertReviewCard {...withMemo} part="header" />)
133
+ const memo = renderToStaticMarkup(<CertReviewCard {...withMemo} part="memo" />)
134
+ expect(header).toContain('Expert reviewed')
135
+ expect(header).not.toContain('Checked the claims against current guidance; accurate.')
136
+ expect(memo).toContain('Checked the claims against current guidance; accurate.')
137
+ expect(memo).not.toContain('Expert reviewed')
138
+ })
139
+ })