@certrev/cert-block 0.1.2 → 0.2.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 (47) hide show
  1. package/dist/builder/index.d.ts +54 -52
  2. package/dist/builder/index.d.ts.map +1 -1
  3. package/dist/builder/index.js +64 -47
  4. package/dist/builder/index.js.map +1 -1
  5. package/dist/components/CertBadge.d.ts +18 -3
  6. package/dist/components/CertBadge.d.ts.map +1 -1
  7. package/dist/components/CertBadge.js +8 -2
  8. package/dist/components/CertBadge.js.map +1 -1
  9. package/dist/components/CertReview.d.ts +7 -0
  10. package/dist/components/CertReview.d.ts.map +1 -1
  11. package/dist/components/CertReview.js +1 -1
  12. package/dist/components/CertReview.js.map +1 -1
  13. package/dist/components/format.d.ts +14 -2
  14. package/dist/components/format.d.ts.map +1 -1
  15. package/dist/components/format.js +18 -5
  16. package/dist/components/format.js.map +1 -1
  17. package/dist/components/render-cert-block.d.ts +89 -0
  18. package/dist/components/render-cert-block.d.ts.map +1 -0
  19. package/dist/components/render-cert-block.js +282 -0
  20. package/dist/components/render-cert-block.js.map +1 -0
  21. package/dist/components/render-def.d.ts +178 -0
  22. package/dist/components/render-def.d.ts.map +1 -0
  23. package/dist/components/render-def.js +204 -0
  24. package/dist/components/render-def.js.map +1 -0
  25. package/dist/index.d.ts +2 -0
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +4 -0
  28. package/dist/index.js.map +1 -1
  29. package/dist/webcomponent/render-badge-html.d.ts +8 -0
  30. package/dist/webcomponent/render-badge-html.d.ts.map +1 -1
  31. package/dist/webcomponent/render-badge-html.js +31 -4
  32. package/dist/webcomponent/render-badge-html.js.map +1 -1
  33. package/package.json +3 -2
  34. package/src/__tests__/__snapshots__/render-cert-block.test.ts.snap +13 -0
  35. package/src/__tests__/components.test.tsx +16 -1
  36. package/src/__tests__/render-cert-block.test.ts +259 -0
  37. package/src/__tests__/render-def.test.tsx +286 -0
  38. package/src/__tests__/webcomponent.test.tsx +10 -0
  39. package/src/builder/__tests__/builder.test.tsx +38 -51
  40. package/src/builder/index.tsx +86 -64
  41. package/src/components/CertBadge.tsx +56 -4
  42. package/src/components/CertReview.tsx +8 -0
  43. package/src/components/format.ts +22 -5
  44. package/src/components/render-cert-block.ts +403 -0
  45. package/src/components/render-def.ts +286 -0
  46. package/src/index.ts +35 -0
  47. package/src/webcomponent/render-badge-html.ts +46 -4
@@ -1,81 +1,68 @@
1
1
  /**
2
- * SSR-render tests for the Builder.io adapter (`@certrev/cert-block/builder`), exercised
3
- * through `react-dom/server`'s `renderToStaticMarkup` — the same path Hydrogen/Next use to
4
- * produce crawlable HTML on the edge. These prove the adapter's load-bearing contract:
2
+ * SSR-render tests for the Builder.io adapter (`@certrev/cert-block/builder`), the panel-validated
3
+ * "ambient-URL anchor" model — exercised through `renderToStaticMarkup` (the edge SSR path).
5
4
  *
6
- * • a render-verdict placement renders the in-DOM cert badge (crawlable);
7
- * • EVERY non-render path — suppress verdict, unknown placement, missing placementId —
8
- * renders NOTHING (fail-closed survives the CMS boundary);
9
- * • when one Content tree mixes a valid + a revoked placement (the /builder-cert proof
10
- * shape), only the valid one renders.
11
- *
12
- * Authority comes solely from the loader-supplied verdict in <CertVerifyProvider>; the
13
- * `placementId` is an opaque pointer the CMS cannot use to manufacture a badge.
5
+ * Contract proved here:
6
+ * • the anchor renders the CURRENT page's verified badge — and badge ONLY (JSON-LD is emitted
7
+ * server-side, decoupled from the visual slot);
8
+ * • EVERY non-render path (suppress verdict, no credential for the page) renders nothing in
9
+ * production (fail-closed);
10
+ * • in the Builder editor (`isEditing`) with no live credential, a truthful placeholder shows —
11
+ * never a fake badge, and never any JSON-LD;
12
+ * • the registration is ZERO-input: the editor cannot choose or forge which credential renders.
14
13
  */
15
14
  import { renderToStaticMarkup } from 'react-dom/server'
16
15
  import { describe, expect, it } from 'vitest'
17
16
  import { makeMockPayload } from '../../contract/fixtures.js'
18
17
  import type { CertVerdict } from '../../contract/kernel.js'
19
- import { CertReviewBlock, CertVerifyProvider, certReviewBuilderComponent, type VerifiedPlacements } from '../index.js'
18
+ import { CertRevAnchor, CertRevProvider, certRevAnchorComponent, type CurrentCredential } from '../index.js'
20
19
 
21
20
  const renderVerdict: CertVerdict = { decision: 'render', payload: makeMockPayload() }
22
21
  const suppressVerdict: CertVerdict = { decision: 'suppress', reason: 'revoked' }
23
22
 
24
- function renderBlock(placements: VerifiedPlacements, placementId?: string): string {
23
+ function render(current: CurrentCredential | null, isEditing = false): string {
25
24
  return renderToStaticMarkup(
26
- <CertVerifyProvider value={placements}>
27
- <CertReviewBlock placementId={placementId} />
28
- </CertVerifyProvider>,
25
+ <CertRevProvider current={current} isEditing={isEditing}>
26
+ <CertRevAnchor />
27
+ </CertRevProvider>,
29
28
  )
30
29
  }
31
30
 
32
- describe('Builder adapter — registration', () => {
33
- it('registers as "CertREV Review" with a single required string `placementId` input', () => {
34
- expect(certReviewBuilderComponent.name).toBe('CertREV Review')
35
- expect(certReviewBuilderComponent.component).toBe(CertReviewBlock)
36
- const input = certReviewBuilderComponent.inputs?.find((i) => i.name === 'placementId')
37
- expect(input).toBeDefined()
38
- expect(input?.required).toBe(true)
39
- expect(input?.type).toBe('string')
31
+ describe('Builder adapter — registration (zero-input anchor)', () => {
32
+ it('registers "CertREV Review" with NO inputs (editor places it; system resolves the credential)', () => {
33
+ expect(certRevAnchorComponent.name).toBe('CertREV Review')
34
+ expect(certRevAnchorComponent.component).toBe(CertRevAnchor)
35
+ expect(certRevAnchorComponent.inputs).toEqual([])
40
36
  })
41
37
  })
42
38
 
43
- describe('Builder adapter — CertReviewBlock render', () => {
44
- it('renders the verified badge for a render-verdict placement (in-DOM, crawlable)', () => {
45
- const html = renderBlock({ 'p-valid': { verdict: renderVerdict, pageUrl: 'https://brand.example.com/a' } }, 'p-valid')
39
+ describe('Builder adapter — CertRevAnchor render', () => {
40
+ it('renders the current page’s verified badge — and ONLY the badge (no JSON-LD; that is server-side)', () => {
41
+ const html = render({ verdict: renderVerdict, pageUrl: 'https://brand.example.com/a' })
46
42
  expect(html).toContain('certrev-badge')
47
43
  expect(html).toContain('Dr. Jane Doe')
48
- expect(html).toContain('data-certrev-cert-id="cert_fixture_001"')
44
+ expect(html).not.toContain('application/ld+json') // JSON-LD decoupled to the article template
49
45
  })
50
46
 
51
- it('FAIL-CLOSED: a suppress-verdict placement renders nothing', () => {
52
- expect(renderBlock({ 'p-revoked': { verdict: suppressVerdict } }, 'p-revoked')).toBe('')
47
+ it('FAIL-CLOSED: a suppress verdict renders nothing', () => {
48
+ expect(render({ verdict: suppressVerdict })).toBe('')
53
49
  })
54
50
 
55
- it('FAIL-CLOSED: an unknown placementId renders nothing', () => {
56
- expect(renderBlock({ 'p-valid': { verdict: renderVerdict } }, 'p-not-here')).toBe('')
51
+ it('FAIL-CLOSED: no credential for this page renders nothing in production', () => {
52
+ expect(render(null, false)).toBe('')
57
53
  })
58
54
 
59
- it('FAIL-CLOSED: a missing placementId renders nothing', () => {
60
- expect(renderBlock({ 'p-valid': { verdict: renderVerdict } }, undefined)).toBe('')
55
+ it('EDITOR: no live credential + isEditing renders a truthful placeholder (no badge, no JSON-LD)', () => {
56
+ const html = render(null, true)
57
+ expect(html).toContain('data-certrev-editor-placeholder')
58
+ expect(html).toContain('resolves at publish')
59
+ expect(html).not.toContain('certrev-badge')
60
+ expect(html).not.toContain('application/ld+json')
61
61
  })
62
62
 
63
- it('mixed content (valid + revoked in one Content tree): only the valid placement renders', () => {
64
- const placements: VerifiedPlacements = {
65
- 'p-valid': { verdict: renderVerdict, pageUrl: 'https://brand.example.com/a' },
66
- 'p-revoked': { verdict: suppressVerdict },
67
- }
68
- const html = renderToStaticMarkup(
69
- <CertVerifyProvider value={placements}>
70
- <div id="valid-block">
71
- <CertReviewBlock placementId="p-valid" />
72
- </div>
73
- <div id="revoked-block">
74
- <CertReviewBlock placementId="p-revoked" />
75
- </div>
76
- </CertVerifyProvider>,
77
- )
78
- expect(html).toContain('certrev-badge') // valid rendered
79
- expect(html).toContain('<div id="revoked-block"></div>') // revoked empty — fail-closed through the CMS
63
+ it('EDITOR: a live verified credential renders the REAL badge even in editing mode (true draft-verify)', () => {
64
+ const html = render({ verdict: renderVerdict }, true)
65
+ expect(html).toContain('certrev-badge')
66
+ expect(html).not.toContain('data-certrev-editor-placeholder')
80
67
  })
81
68
  })
@@ -1,100 +1,122 @@
1
1
  /**
2
- * @certrev/cert-block/builder — the Builder.io adapter for the cert-block render edge.
2
+ * @certrev/cert-block/builder — Builder.io adapter, "ambient-URL anchor" model.
3
3
  *
4
- * Lets a Builder.io content editor place a "CertREV Review" block into a page; the
5
- * headless app's loader (Hydrogen/Next/Remix) crypto-verifies that placement's signed
6
- * envelope and supplies the verdict via React context; this block renders cert-block's
7
- * <CertReview> from the VERIFIED verdict — and renders NOTHING (fail-closed) on any
8
- * suppress or unknown placement.
4
+ * Validated by an independent architecture panel (Codex gpt-5.5 + Gemini, blind): a visual
5
+ * page builder must NOT choose WHICH credential renders — that invites cross-article misuse and
6
+ * re-opens the wrong-subject surface. Instead:
7
+ * • the CMS controls LAYOUT — an editor drags a ZERO-INPUT <CertRevAnchor/> to position the
8
+ * badge on the page;
9
+ * • the SYSTEM controls TRUTH — the headless loader resolves THIS page's credential from its
10
+ * own URL (Track-1 delivery), crypto-verifies it (fail-closed VerdictKernel), and supplies
11
+ * the verdict via context.
12
+ * The anchor renders the current page's verified badge or NOTHING. It cannot be pointed at
13
+ * another article, and there is no `placementId` input to forge or mis-select.
9
14
  *
10
- * THE TRUST BOUNDARY: the credential is never trusted from CMS content. A Builder block
11
- * only carries an opaque `placementId` pointer; the authority comes solely from the
12
- * loader-side WebCrypto verdict supplied through <CertVerifyProvider>. So a revoked /
13
- * tampered / expired / wrong-subject placement that an editor drops into Builder renders
14
- * nothing, even though the block is present in the published content.
15
+ * JSON-LD is DECOUPLED (panel finding): the anchor renders the BADGE only (`omitJsonLd`). The
16
+ * article template emits the schema.org JSON-LD server-side from the same verified verdict, so a
17
+ * page builder can't duplicate, move, or omit structured data. (See the route example.)
15
18
  *
16
- * cert-block carries NO dependency on `@builder.io/sdk-react` — the registration's shape is
17
- * declared structurally below, so the emitted JS pulls no Builder code and the type resolves
18
- * without Builder installed. The consumer (who already has Builder) passes
19
- * `certReviewBuilderComponent` to Builder's `<Content customComponents={[...]}>`; it is
20
- * assignable to Builder's `RegisteredComponent` there.
19
+ * EDGE-CACHE DISCIPLINE (panel finding): the consumer's loader MUST verify per request as a
20
+ * dynamic edge subrequest and must NOT long-cache the rendered badge HTML — a revoked/expired
21
+ * credential sitting in a stale edge cache would defeat fail-closed. Bound any positive-verdict
22
+ * cache by min(expiry, revocation-TTL, content-version).
23
+ *
24
+ * cert-block carries no Builder dependency: the registration shape is declared structurally, and
25
+ * `isEditing` is a passed-in flag (the consumer supplies Builder's `isPreviewing()`).
21
26
  */
22
27
  import { createContext, type ReactNode, useContext } from 'react'
23
28
  import { CertReview } from '../components/CertReview.js'
24
29
  import type { CertVerdict } from '../contract/kernel.js'
25
30
 
26
- /** A loader-verified placement: the verdict cert-block renders from, plus the canonical
27
- * page URL for JSON-LD @id alignment. */
28
- export interface VerifiedPlacement {
31
+ /** The current page's credential, resolved + verified by the loader (Track-1 delivery). */
32
+ export interface CurrentCredential {
29
33
  readonly verdict: CertVerdict
34
+ /** Canonical page URL for JSON-LD @id alignment (used by the server-side JSON-LD, not the anchor). */
30
35
  readonly pageUrl?: string
31
36
  }
32
37
 
33
- /** Map of `placementId` → the loader's verified result. Built server-side in the loader
34
- * (it crypto-verifies each placement) and handed to <CertVerifyProvider>. */
35
- export type VerifiedPlacements = Record<string, VerifiedPlacement>
38
+ interface CertRevContextValue {
39
+ readonly current: CurrentCredential | null
40
+ readonly isEditing: boolean
41
+ }
36
42
 
37
- const CertVerifyContext = createContext<VerifiedPlacements>({})
43
+ const CertRevContext = createContext<CertRevContextValue>({ current: null, isEditing: false })
38
44
 
39
- /** Wraps Builder's <Content>; carries the loader's verified verdicts down to every
40
- * CertReview block, keyed by `placementId`. */
41
- export function CertVerifyProvider({
42
- value,
45
+ /**
46
+ * Supplies THIS page's loader-verified credential to the anchor. `current` is the verdict for
47
+ * the current page (or null if the page has no delivered credential). `isEditing` is the
48
+ * consumer's Builder `isPreviewing()` result — it ONLY toggles a design-time placeholder and
49
+ * NEVER affects the published render.
50
+ */
51
+ export function CertRevProvider({
52
+ current,
53
+ isEditing = false,
43
54
  children,
44
55
  }: {
45
- readonly value: VerifiedPlacements
56
+ readonly current: CurrentCredential | null
57
+ readonly isEditing?: boolean
46
58
  readonly children: ReactNode
47
59
  }) {
48
- return <CertVerifyContext.Provider value={value}>{children}</CertVerifyContext.Provider>
60
+ return <CertRevContext.Provider value={{ current, isEditing }}>{children}</CertRevContext.Provider>
49
61
  }
50
62
 
51
- export interface CertReviewBlockProps {
52
- /** The CertREV placement id the editor set on this Builder block. */
53
- readonly placementId?: string
63
+ /**
64
+ * Design-time placeholder shown ONLY in the Builder editor when the current page has no live
65
+ * verified credential. Truthful (never a fake badge), emits NO JSON-LD, and never renders in
66
+ * production — so it cannot leak to or be crawled on the live site.
67
+ */
68
+ export function CertRevEditorPlaceholder() {
69
+ return (
70
+ <div
71
+ data-certrev-editor-placeholder=""
72
+ style={{
73
+ border: '1px dashed #e6007e',
74
+ borderRadius: 8,
75
+ padding: '10px 14px',
76
+ font: '13px system-ui',
77
+ color: '#6b7280',
78
+ background: '#fff5fa',
79
+ }}
80
+ >
81
+ CertREV Review — <b>resolves at publish</b> from issuer verification. The expert badge appears here once this
82
+ page’s article is certified, and only while the credential is valid.
83
+ </div>
84
+ )
54
85
  }
55
86
 
56
87
  /**
57
- * The component Builder renders for a "CertREV Review" block. It pulls the loader-verified
58
- * verdict for its `placementId` from context and renders <CertReview>. Unknown placement
59
- * OR a non-`render` verdict → renders nothing. cert-block's <CertReview> ALSO suppresses on
60
- * a non-render verdict, so the boundary fails closed twice over (defense in depth).
88
+ * The Builder block. ZERO inputs — a layout anchor only. Renders the current page's VERIFIED
89
+ * badge (badge only; JSON-LD is emitted server-side), the editor placeholder in design mode, or
90
+ * NOTHING in production (fail-closed). The credential is whatever Track-1 delivered + verified for
91
+ * THIS page; the editor can neither choose nor forge it.
61
92
  */
62
- export function CertReviewBlock({ placementId }: CertReviewBlockProps) {
63
- const placements = useContext(CertVerifyContext)
64
- const placement = placementId ? placements[placementId] : undefined
65
- if (!placement) return null
66
- return <CertReview verdict={placement.verdict} pageUrl={placement.pageUrl} />
93
+ export function CertRevAnchor() {
94
+ const { current, isEditing } = useContext(CertRevContext)
95
+ if (current && current.verdict.decision === 'render') {
96
+ return <CertReview verdict={current.verdict} pageUrl={current.pageUrl} omitJsonLd />
97
+ }
98
+ if (isEditing) return <CertRevEditorPlaceholder />
99
+ return null
67
100
  }
68
101
 
69
102
  /**
70
- * Structural shape of `@builder.io/sdk-react`'s `RegisteredComponent` — exactly the subset
71
- * cert-block populates. Declared locally (not imported) so cert-block needs no Builder
72
- * build- or runtime-dependency; the object below is assignable to Builder's
73
- * `RegisteredComponent` at the consumer's `<Content customComponents={[...]}>` call site.
103
+ * Structural shape of `@builder.io/sdk-react`'s `RegisteredComponent` — the subset cert-block
104
+ * populates. Declared locally so cert-block needs no Builder build/runtime dependency; assignable
105
+ * to Builder's `RegisteredComponent` at the consumer's `<Content customComponents={[...]}>`.
74
106
  */
75
- export interface CertReviewBuilderRegistration {
76
- component: typeof CertReviewBlock
107
+ export interface CertRevBuilderRegistration {
108
+ component: typeof CertRevAnchor
77
109
  name: string
78
- inputs: Array<{ name: string; type: 'string'; required?: boolean; helperText?: string }>
110
+ inputs: never[]
79
111
  }
80
112
 
81
113
  /**
82
- * The registration object to pass to Builder's `<Content customComponents={[...]}>`.
83
- * Editors see a "CertREV Review" component with one input — the placement id. Because the
84
- * block is matched by NAME at render time, this also works for content created via the
85
- * Builder Write API (no visual-editor registration required for rendering).
114
+ * The registration to pass to Builder's `<Content customComponents={[...]}>`. ZERO inputs by
115
+ * design — the editor places it (layout); the system resolves the credential from the page URL
116
+ * (truth). Matched by NAME at render, so Write-API content works without visual-editor setup.
86
117
  */
87
- export const certReviewBuilderComponent: CertReviewBuilderRegistration = {
88
- component: CertReviewBlock,
118
+ export const certRevAnchorComponent: CertRevBuilderRegistration = {
119
+ component: CertRevAnchor,
89
120
  name: 'CertREV Review',
90
- inputs: [
91
- {
92
- name: 'placementId',
93
- type: 'string',
94
- required: true,
95
- helperText:
96
- 'CertREV placement id (which certified article this badge attests). The headless ' +
97
- 'loader verifies that placement’s signed envelope; only a render-verdict shows a badge.',
98
- },
99
- ],
121
+ inputs: [],
100
122
  }
@@ -11,23 +11,44 @@
11
11
  * an inline custom property (no color-only meaning — the credential text carries it), an
12
12
  * expert photo with meaningful alt text, and a verify link with a descriptive label.
13
13
  *
14
- * `badgeStyle: 'compact'` collapses to a single line (name + credentials + verify link);
15
- * `'full'` shows the photo, certified/updated dates, and the memo (per display flags).
14
+ * `badgeStyle: 'compact'` collapses the reviewer block to a single line (name + credentials);
15
+ * `'full'` shows the photo, certified/updated dates, and the memo (per display flags). BOTH
16
+ * styles carry the compensation cue + FTC scope line (the always-on compliance disclosures).
17
+ *
18
+ * THEMING (WS6): presentation defaults come from `payload.content.display`; an optional
19
+ * `renderDef` prop layers a brand's validated themeable tokens (emitted as `--certrev-*`
20
+ * CSS custom properties) and its subtractive field-visibility on top. No `renderDef` → the
21
+ * CertREV preset default (theme output byte-identical to un-themed).
16
22
  */
17
23
 
18
24
  import type { CertContent, CertPayload } from '../contract/kernel.js'
19
25
  import { safeHttpUrl } from './escape.js'
20
26
  import { credentialSuffix, expertNameWithCredentials, formatDate, resolveDisplay } from './format.js'
27
+ import {
28
+ CERT_SCOPE_LINE,
29
+ CERT_STRINGS_VERSION,
30
+ type CertRenderDef,
31
+ resolveRenderTheme,
32
+ themeCssVarDeclarations,
33
+ } from './render-def.js'
21
34
 
22
35
  export interface CertBadgeProps {
23
36
  /** The cryptographically-verified payload (from `getVerifiedEnvelope`). */
24
37
  readonly payload: CertPayload
25
- /** Optional accent override (else `display.accentColor`, else CertREV default). */
38
+ /** Optional accent override (else render-def, else `display.accentColor`, else CertREV default). */
26
39
  readonly accentColor?: string
27
40
  /** Extra class names appended to the root element's class list. */
28
41
  readonly className?: string
29
42
  /** Override the badge style independent of the signed display config. */
30
43
  readonly badgeStyle?: 'full' | 'compact'
44
+ /**
45
+ * The brand render-def (WS6). ABSENT → the badge renders the CertREV preset
46
+ * default (fail-safe; theme output byte-identical to un-themed). PRESENT → its
47
+ * validated themeable tokens are emitted as `--certrev-*` CSS custom properties
48
+ * and its subtractive hide-set removes brand-toggleable fields. Invalid tokens
49
+ * are dropped (defense-in-depth over the write-path gate).
50
+ */
51
+ readonly renderDef?: CertRenderDef
31
52
  }
32
53
 
33
54
  const ROOT_CLASS = 'certrev-badge'
@@ -70,7 +91,8 @@ function ExpertPhoto({ content, show }: { content: CertContent; show: boolean })
70
91
  export function CertBadge(props: CertBadgeProps) {
71
92
  const { payload } = props
72
93
  const content = payload.content
73
- const display = resolveDisplay(content.display, props.accentColor)
94
+ const display = resolveDisplay(content.display, props.accentColor, props.renderDef)
95
+ const theme = resolveRenderTheme(props.renderDef)
74
96
  const style = props.badgeStyle ?? display.badgeStyle
75
97
  const verifyUrl = safeHttpUrl(content.verifyUrl)
76
98
  const profileUrl = safeHttpUrl(content.expert.profileUrl)
@@ -82,10 +104,13 @@ export function CertBadge(props: CertBadgeProps) {
82
104
  const rootClass = `${ROOT_CLASS} ${ROOT_CLASS}--${style}${props.className ? ` ${props.className}` : ''}`
83
105
  // Accent is exposed as a custom property so brand themes can read it, and applied
84
106
  // directly to the accent border so the component is self-styling without a stylesheet.
107
+ // A THEMED brand additionally emits the surface/radius/font vars; with no render-def
108
+ // `themeCssVarDeclarations` is empty, so the un-themed style is byte-identical.
85
109
  const rootStyle: Record<string, string> = {
86
110
  ['--certrev-accent']: accent,
87
111
  borderInlineStartColor: accent,
88
112
  }
113
+ for (const [name, value] of themeCssVarDeclarations(theme)) rootStyle[name] = value
89
114
 
90
115
  const expertName = (
91
116
  <span className={`${ROOT_CLASS}__expert-name`}>
@@ -99,6 +124,8 @@ export function CertBadge(props: CertBadgeProps) {
99
124
  className={rootClass}
100
125
  style={rootStyle}
101
126
  data-certrev-cert-id={payload.certId}
127
+ data-certrev-strings-version={CERT_STRINGS_VERSION}
128
+ data-certrev-def-version={theme.version}
102
129
  aria-label={`Content reviewed by ${expertNameWithCredentials(content)}`}
103
130
  >
104
131
  <div className={`${ROOT_CLASS}__header`}>
@@ -149,6 +176,31 @@ export function CertBadge(props: CertBadgeProps) {
149
176
  </>
150
177
  ) : null}
151
178
 
179
+ {/* Design guide §3: the compensation cue + FTC scope line are LOCKED on every
180
+ claim-bearing face (named / credentialed / memo) — with or without a memo —
181
+ so a brand can never show an endorsement impression without its disclosures.
182
+ cert-block only ever renders a claim face (it always carries the reviewer
183
+ byline), so both lines render on both the full AND compact styles. This is
184
+ the compliance BASELINE — it lands regardless of a render-def, matching the
185
+ Shopify/WordPress engine fix. The cue's "expert" links to the reviewer's
186
+ CertREV profile (the whole cue tag-strips to the byte-verbatim
187
+ COMPENSATED_EXPERT_CUE). The scope line is byte-verbatim CERT_SCOPE_LINE. */}
188
+ <div className={`${ROOT_CLASS}__disclosure`}>
189
+ <span className={`${ROOT_CLASS}__compensated-cue`}>
190
+ Compensated{' '}
191
+ {profileUrl ? (
192
+ <a className={`${ROOT_CLASS}__cue-link`} href={profileUrl} rel="noopener">
193
+ expert
194
+ </a>
195
+ ) : (
196
+ 'expert'
197
+ )}
198
+ </span>
199
+ <p className={`${ROOT_CLASS}__ftc`} data-ftc-disclosure>
200
+ <span data-ftc-line>{CERT_SCOPE_LINE}</span>
201
+ </p>
202
+ </div>
203
+
152
204
  {verifyUrl ? (
153
205
  <a
154
206
  className={`${ROOT_CLASS}__verify`}
@@ -11,6 +11,7 @@
11
11
  import type { CertVerdict } from '../contract/kernel.js'
12
12
  import { CertBadge } from './CertBadge.js'
13
13
  import { CertJsonLd } from './CertJsonLd.js'
14
+ import type { CertRenderDef } from './render-def.js'
14
15
 
15
16
  export interface CertReviewProps {
16
17
  /** The verdict from `getVerifiedEnvelope` (or a direct kernel call). */
@@ -20,6 +21,12 @@ export interface CertReviewProps {
20
21
  readonly accentColor?: string
21
22
  readonly className?: string
22
23
  readonly badgeStyle?: 'full' | 'compact'
24
+ /**
25
+ * The brand render-def (WS6). Forwarded to `<CertBadge>` for themeable-token
26
+ * emission + subtractive visibility; absent → the CertREV preset default. The
27
+ * JSON-LD is presentation-blind, so the def never touches the structured data.
28
+ */
29
+ readonly renderDef?: CertRenderDef
23
30
  /** Omit the JSON-LD (e.g. the page already emits it elsewhere). Default false. */
24
31
  readonly omitJsonLd?: boolean
25
32
  }
@@ -35,6 +42,7 @@ export function CertReview(props: CertReviewProps) {
35
42
  accentColor={props.accentColor}
36
43
  className={props.className}
37
44
  badgeStyle={props.badgeStyle}
45
+ renderDef={props.renderDef}
38
46
  />
39
47
  {props.omitJsonLd ? null : <CertJsonLd payload={payload} pageUrl={props.pageUrl} />}
40
48
  </>
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import type { CertContent, CertDisplayConfig } from '../contract/kernel.js'
7
+ import { type CertRenderDef, isFieldHidden, resolveRenderTheme } from './render-def.js'
7
8
 
8
9
  /** Default accent if the brand didn't pin one (CertREV teal). */
9
10
  export const DEFAULT_ACCENT = '#0f766e'
@@ -16,13 +17,29 @@ export interface ResolvedDisplay {
16
17
  readonly badgeStyle: 'full' | 'compact'
17
18
  }
18
19
 
19
- /** Resolve the optional display config to concrete presentation flags + accent. */
20
- export function resolveDisplay(display: CertDisplayConfig | undefined, accentOverride?: string): ResolvedDisplay {
20
+ /**
21
+ * Resolve the optional display config + optional brand render-def to concrete
22
+ * presentation flags + accent.
23
+ *
24
+ * Accent PRECEDENCE mirrors the Shopify Liquid engine exactly: an explicit
25
+ * `accentOverride` (a block/prop override) wins, then the render-def's validated
26
+ * accent (a THEMED brand), then the signed display's accent, else the CertREV
27
+ * default. Visibility is SUBTRACTIVE: the render-def's hide-set can only turn a
28
+ * preset-placed field OFF (never on) — a hidden `reviewerPhoto` / `memo` wins over
29
+ * a display flag that shows it. When no render-def is supplied the theme resolves
30
+ * empty, so accent + visibility are byte-identical to the pre-theming behavior.
31
+ */
32
+ export function resolveDisplay(
33
+ display: CertDisplayConfig | undefined,
34
+ accentOverride?: string,
35
+ renderDef?: CertRenderDef,
36
+ ): ResolvedDisplay {
37
+ const theme = resolveRenderTheme(renderDef)
21
38
  return {
22
- accentColor: accentOverride ?? display?.accentColor ?? DEFAULT_ACCENT,
23
- showExpertPhoto: display?.showExpertPhoto ?? true,
39
+ accentColor: accentOverride ?? theme.accent ?? display?.accentColor ?? DEFAULT_ACCENT,
40
+ showExpertPhoto: (display?.showExpertPhoto ?? true) && !isFieldHidden(renderDef, 'reviewerPhoto'),
24
41
  showAuthor: display?.showAuthor ?? true,
25
- showMemo: display?.showMemo ?? true,
42
+ showMemo: (display?.showMemo ?? true) && !isFieldHidden(renderDef, 'memo'),
26
43
  badgeStyle: display?.badgeStyle ?? 'full',
27
44
  }
28
45
  }