@certrev/cert-block 0.1.3 → 0.3.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 (41) hide show
  1. package/dist/components/CertBadge.d.ts +18 -3
  2. package/dist/components/CertBadge.d.ts.map +1 -1
  3. package/dist/components/CertBadge.js +8 -2
  4. package/dist/components/CertBadge.js.map +1 -1
  5. package/dist/components/CertReview.d.ts +7 -0
  6. package/dist/components/CertReview.d.ts.map +1 -1
  7. package/dist/components/CertReview.js +1 -1
  8. package/dist/components/CertReview.js.map +1 -1
  9. package/dist/components/format.d.ts +14 -2
  10. package/dist/components/format.d.ts.map +1 -1
  11. package/dist/components/format.js +18 -5
  12. package/dist/components/format.js.map +1 -1
  13. package/dist/components/render-cert-block.d.ts +91 -0
  14. package/dist/components/render-cert-block.d.ts.map +1 -0
  15. package/dist/components/render-cert-block.js +306 -0
  16. package/dist/components/render-cert-block.js.map +1 -0
  17. package/dist/components/render-def.d.ts +178 -0
  18. package/dist/components/render-def.d.ts.map +1 -0
  19. package/dist/components/render-def.js +204 -0
  20. package/dist/components/render-def.js.map +1 -0
  21. package/dist/index.d.ts +2 -0
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +4 -0
  24. package/dist/index.js.map +1 -1
  25. package/dist/webcomponent/render-badge-html.d.ts +8 -0
  26. package/dist/webcomponent/render-badge-html.d.ts.map +1 -1
  27. package/dist/webcomponent/render-badge-html.js +31 -4
  28. package/dist/webcomponent/render-badge-html.js.map +1 -1
  29. package/package.json +77 -76
  30. package/src/__tests__/__snapshots__/render-cert-block.test.ts.snap +13 -0
  31. package/src/__tests__/components.test.tsx +16 -1
  32. package/src/__tests__/render-cert-block.test.ts +282 -0
  33. package/src/__tests__/render-def.test.tsx +286 -0
  34. package/src/__tests__/webcomponent.test.tsx +10 -0
  35. package/src/components/CertBadge.tsx +56 -4
  36. package/src/components/CertReview.tsx +8 -0
  37. package/src/components/format.ts +22 -5
  38. package/src/components/render-cert-block.ts +424 -0
  39. package/src/components/render-def.ts +286 -0
  40. package/src/index.ts +35 -0
  41. package/src/webcomponent/render-badge-html.ts +46 -4
@@ -0,0 +1,286 @@
1
+ /**
2
+ * WS6 — design-guide render-def support (themeable tokens + subtractive visibility)
3
+ * + the two always-on compliance strings.
4
+ *
5
+ * Two planes, both exercised through the SSR paths (React `renderToStaticMarkup` +
6
+ * the framework-agnostic `renderBadgeHtml` string renderer — the crawlable output):
7
+ * 1. the PURE resolution helpers (validators, `resolveRenderTheme`, visibility,
8
+ * the fixed font stacks, the byte-verbatim strings);
9
+ * 2. the RENDERED faces — a themed def emits the four `--certrev-*` vars + the
10
+ * def-version stamp + subtractive visibility; NO def is theme-byte-identical
11
+ * to the un-themed default; an invalid token is DROPPED (fail-safe); and the
12
+ * compensation cue + FTC scope line + strings-version stamp land on EVERY
13
+ * claim face (the compliance baseline, with or without a def).
14
+ */
15
+
16
+ import { renderToStaticMarkup } from 'react-dom/server'
17
+ import { describe, expect, it } from 'vitest'
18
+ import { CertBadge } from '../components/CertBadge.js'
19
+ import { CertReview } from '../components/CertReview.js'
20
+ import {
21
+ CERT_SCOPE_LINE,
22
+ CERT_STRINGS_VERSION,
23
+ type CertRenderDef,
24
+ COMPENSATED_EXPERT_CUE,
25
+ type FontSlot,
26
+ FONT_STACKS,
27
+ isCornerRadius,
28
+ isFieldHidden,
29
+ isFontSlot,
30
+ isOpaqueHexColor,
31
+ resolveRenderTheme,
32
+ themeCssVarDeclarations,
33
+ } from '../components/render-def.js'
34
+ import { makeMockPayload } from '../contract/fixtures.js'
35
+ import { renderBadgeHtml } from '../webcomponent/render-badge-html.js'
36
+
37
+ const payload = makeMockPayload()
38
+
39
+ /** A fully-themed, valid render-def (every token overridden, both fields hidden). */
40
+ const THEMED: CertRenderDef = {
41
+ tokens: { accentColor: '#123456', surface: '#eeeeee', cornerRadius: '8px', fontSlot: 'serif' },
42
+ hidden: ['reviewerPhoto', 'memo'],
43
+ version: 4,
44
+ }
45
+
46
+ /** Tag-strip a fragment of HTML to its visible text (collapse whitespace). */
47
+ function visibleText(html: string): string {
48
+ return html
49
+ .replace(/<[^>]+>/g, ' ')
50
+ .replace(/\s+/g, ' ')
51
+ .trim()
52
+ }
53
+
54
+ // ─────────────────────────────────────────────────────────────────────────────
55
+ // Pure helpers
56
+ // ─────────────────────────────────────────────────────────────────────────────
57
+
58
+ describe('token validators (mirror portal src/lib/design-guide/tokens.ts)', () => {
59
+ it('isOpaqueHexColor accepts 3/6-digit hex, rejects alpha / functions / keywords', () => {
60
+ for (const ok of ['#fff', '#FFFFFF', '#0a1b3f', '#123']) expect(isOpaqueHexColor(ok)).toBe(true)
61
+ for (const bad of ['#ffff', '#ffffffaa', '#12', '#1234567', 'transparent', 'rgb(0,0,0)', 'red', '', ' #fff'])
62
+ expect(isOpaqueHexColor(bad)).toBe(false)
63
+ })
64
+
65
+ it('isCornerRadius accepts a bare 0 or a single unit length, rejects unitless / calc / spaces', () => {
66
+ for (const ok of ['0', '12px', '0.5rem', '.5em', '100%', '4px']) expect(isCornerRadius(ok)).toBe(true)
67
+ for (const bad of ['12', '12 px', 'calc(1px)', '1px 2px', '', 'px', '-4px']) expect(isCornerRadius(bad)).toBe(false)
68
+ })
69
+
70
+ it('isFontSlot admits only the three enum members', () => {
71
+ for (const ok of ['sans', 'serif', 'system']) expect(isFontSlot(ok)).toBe(true)
72
+ for (const bad of ['mono', 'Arial', '', 'SANS']) expect(isFontSlot(bad)).toBe(false)
73
+ })
74
+ })
75
+
76
+ describe('FONT_STACKS — byte-identical to the Shopify Liquid engine literals', () => {
77
+ it('matches the exact stacks (the parity anchor across the twins)', () => {
78
+ expect(FONT_STACKS.sans).toBe("'DM Sans', system-ui, -apple-system, BlinkMacSystemFont, sans-serif")
79
+ expect(FONT_STACKS.serif).toBe("'Playfair Display', Georgia, 'Times New Roman', serif")
80
+ expect(FONT_STACKS.system).toBe("system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif")
81
+ })
82
+ })
83
+
84
+ describe('resolveRenderTheme — validates + drops (fail-safe)', () => {
85
+ it('returns {} for an absent def', () => {
86
+ expect(resolveRenderTheme(undefined)).toEqual({})
87
+ })
88
+
89
+ it('resolves every valid token + the fontSlot → fixed stack + the version', () => {
90
+ expect(resolveRenderTheme(THEMED)).toEqual({
91
+ accent: '#123456',
92
+ surface: '#eeeeee',
93
+ cornerRadius: '8px',
94
+ fontStack: FONT_STACKS.serif,
95
+ version: 4,
96
+ })
97
+ })
98
+
99
+ it('DROPS every invalid token (alpha surface, function accent, calc radius, unknown slot)', () => {
100
+ const resolved = resolveRenderTheme({
101
+ tokens: {
102
+ accentColor: 'rgb(0,0,0)',
103
+ surface: '#ffffffaa',
104
+ cornerRadius: 'calc(1px)',
105
+ fontSlot: 'mono' as FontSlot,
106
+ },
107
+ version: 2,
108
+ })
109
+ expect(resolved).toEqual({
110
+ accent: undefined,
111
+ surface: undefined,
112
+ cornerRadius: undefined,
113
+ fontStack: undefined,
114
+ version: 2,
115
+ })
116
+ })
117
+
118
+ it('drops a non-finite version', () => {
119
+ expect(resolveRenderTheme({ version: Number.NaN }).version).toBeUndefined()
120
+ })
121
+ })
122
+
123
+ describe('subtractive visibility + var declarations', () => {
124
+ it('isFieldHidden is true only for a listed field', () => {
125
+ expect(isFieldHidden({ hidden: ['memo'] }, 'memo')).toBe(true)
126
+ expect(isFieldHidden({ hidden: ['memo'] }, 'reviewerPhoto')).toBe(false)
127
+ expect(isFieldHidden(undefined, 'memo')).toBe(false)
128
+ })
129
+
130
+ it('themeCssVarDeclarations is empty for an un-themed face and ordered surface→radius→font', () => {
131
+ expect(themeCssVarDeclarations({})).toEqual([])
132
+ expect(themeCssVarDeclarations(resolveRenderTheme(THEMED))).toEqual([
133
+ ['--certrev-surface', '#eeeeee'],
134
+ ['--certrev-radius', '8px'],
135
+ ['--certrev-font', FONT_STACKS.serif],
136
+ ])
137
+ })
138
+ })
139
+
140
+ describe('the two always-on compliance strings are byte-verbatim', () => {
141
+ it('cue + strings-version', () => {
142
+ expect(COMPENSATED_EXPERT_CUE).toBe('Compensated expert')
143
+ expect(CERT_STRINGS_VERSION).toBe('2026-07')
144
+ })
145
+
146
+ it('the scope line carries the load-bearing curly apostrophe U+2019', () => {
147
+ expect(CERT_SCOPE_LINE).toBe(
148
+ 'Independent editorial review of this article’s text and sources. Not a product endorsement, nor medical advice.',
149
+ )
150
+ expect(CERT_SCOPE_LINE).toContain('’')
151
+ expect(CERT_SCOPE_LINE).not.toContain("'") // straight apostrophe would be a contract divergence
152
+ })
153
+ })
154
+
155
+ // ─────────────────────────────────────────────────────────────────────────────
156
+ // Rendered — React <CertBadge>
157
+ // ─────────────────────────────────────────────────────────────────────────────
158
+
159
+ describe('<CertBadge> themed', () => {
160
+ it('emits the four --certrev-* vars, the def-version stamp, and hides subtracted fields', () => {
161
+ const html = renderToStaticMarkup(<CertBadge payload={payload} renderDef={THEMED} />)
162
+ expect(html).toContain('--certrev-accent:#123456')
163
+ expect(html).toContain('--certrev-surface:#eeeeee')
164
+ expect(html).toContain('--certrev-radius:8px')
165
+ // React entity-encodes the font stack's single quotes; the family content is intact.
166
+ expect(html).toContain('--certrev-font:')
167
+ expect(html).toContain('Playfair Display')
168
+ expect(html).toContain('data-certrev-def-version="4"')
169
+ // subtractive visibility: reviewerPhoto + memo hidden
170
+ expect(html).not.toContain('__photo')
171
+ expect(html).not.toContain('retinol claims')
172
+ })
173
+
174
+ it('an unthemed badge emits NO theme vars + NO def-version stamp (theme byte-neutral)', () => {
175
+ const html = renderToStaticMarkup(<CertBadge payload={payload} />)
176
+ expect(html).not.toContain('--certrev-surface')
177
+ expect(html).not.toContain('--certrev-radius')
178
+ expect(html).not.toContain('--certrev-font')
179
+ expect(html).not.toContain('data-certrev-def-version')
180
+ })
181
+ })
182
+
183
+ describe('compliance baseline — cue + scope + strings-version on EVERY claim face', () => {
184
+ it('full + unthemed: cue, verbatim scope line, and the strings-version stamp all present', () => {
185
+ const html = renderToStaticMarkup(<CertBadge payload={payload} />)
186
+ expect(html).toContain('data-certrev-strings-version="2026-07"')
187
+ expect(html).toContain(CERT_SCOPE_LINE)
188
+ expect(html).toContain('data-ftc-line')
189
+ expect(visibleText(html)).toContain(COMPENSATED_EXPERT_CUE)
190
+ })
191
+
192
+ it('compact ALSO carries the cue + scope (a compact claim badge is still a claim face)', () => {
193
+ const html = renderToStaticMarkup(<CertBadge payload={payload} badgeStyle="compact" />)
194
+ expect(html).toContain('certrev-badge--compact')
195
+ expect(html).toContain(CERT_SCOPE_LINE)
196
+ expect(visibleText(html)).toContain(COMPENSATED_EXPERT_CUE)
197
+ })
198
+
199
+ it('the cue links "expert" to the reviewer profile; falls back to plain text when unsafe', () => {
200
+ const linked = renderToStaticMarkup(<CertBadge payload={payload} />)
201
+ expect(linked).toContain('>expert</a>')
202
+ const p = makeMockPayload({
203
+ content: { ...payload.content, expert: { ...payload.content.expert, profileUrl: 'javascript:alert(1)' } },
204
+ })
205
+ const unlinked = renderToStaticMarkup(<CertBadge payload={p} />)
206
+ expect(unlinked).not.toContain('javascript:')
207
+ expect(visibleText(unlinked)).toContain(COMPENSATED_EXPERT_CUE)
208
+ })
209
+ })
210
+
211
+ describe('<CertReview> forwards the render-def to the badge', () => {
212
+ it('a themed def reaches the badge; the JSON-LD stays presentation-blind', () => {
213
+ const html = renderToStaticMarkup(<CertReview verdict={{ decision: 'render', payload }} renderDef={THEMED} />)
214
+ expect(html).toContain('--certrev-surface:#eeeeee')
215
+ expect(html).toContain('data-certrev-def-version="4"')
216
+ // the projector never sees the def — the graph is byte-unchanged
217
+ expect(html).toContain('application/ld+json')
218
+ })
219
+ })
220
+
221
+ // ─────────────────────────────────────────────────────────────────────────────
222
+ // Rendered — the framework-agnostic string path (renderBadgeHtml)
223
+ // ─────────────────────────────────────────────────────────────────────────────
224
+
225
+ describe('renderBadgeHtml themed (the crawlable string path)', () => {
226
+ it('emits each --certrev-* var exactly once; the font stack is the RAW fixed literal (byte-exact)', () => {
227
+ const html = renderBadgeHtml(payload, { renderDef: THEMED })
228
+ expect(html).toContain('--certrev-accent:#123456')
229
+ expect(html).toContain('--certrev-surface:#eeeeee')
230
+ expect(html).toContain('--certrev-radius:8px')
231
+ expect(html).toContain(`--certrev-font:${FONT_STACKS.serif}`) // byte-exact, single quotes raw
232
+ for (const v of ['--certrev-accent:', '--certrev-surface:', '--certrev-radius:', '--certrev-font:'])
233
+ expect((html.match(new RegExp(v.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')) ?? []).length).toBe(1)
234
+ expect(html).toContain('data-certrev-def-version="4"')
235
+ expect(html).toContain('data-certrev-strings-version="2026-07"')
236
+ expect(html).not.toContain('__photo') // reviewerPhoto hidden
237
+ expect(html).not.toContain('__memo') // memo hidden
238
+ })
239
+
240
+ it('the compliance baseline is present: verbatim scope line + the cue', () => {
241
+ const html = renderBadgeHtml(payload, { renderDef: THEMED })
242
+ expect(html).toContain(CERT_SCOPE_LINE)
243
+ expect(html).toContain('data-ftc-line')
244
+ expect(visibleText(html)).toContain(COMPENSATED_EXPERT_CUE)
245
+ })
246
+
247
+ it('DROPS every invalid token (fail-safe to preset default); no injection reaches the style', () => {
248
+ const html = renderBadgeHtml(payload, {
249
+ renderDef: {
250
+ tokens: {
251
+ accentColor: 'rgb(0,0,0)',
252
+ surface: '#ffffffaa',
253
+ cornerRadius: 'calc(1px)',
254
+ fontSlot: 'mono' as FontSlot,
255
+ },
256
+ },
257
+ })
258
+ expect(html).not.toContain('rgb(0,0,0)')
259
+ expect(html).not.toContain('#ffffffaa')
260
+ expect(html).not.toContain('calc(')
261
+ expect(html).not.toContain('--certrev-surface')
262
+ expect(html).not.toContain('--certrev-radius')
263
+ expect(html).not.toContain('--certrev-font')
264
+ // the invalid accent fell back to the signed display accent (never the rgb)
265
+ expect(html).toContain('--certrev-accent:#7c3aed')
266
+ })
267
+
268
+ it('un-themed: theme output is BYTE-IDENTICAL to the pre-WS6 single-accent form', () => {
269
+ const html = renderBadgeHtml(payload)
270
+ expect(html).toContain('style="--certrev-accent:#7c3aed;border-inline-start-color:#7c3aed"')
271
+ expect(html).not.toContain('--certrev-surface')
272
+ expect(html).not.toContain('data-certrev-def-version')
273
+ // baseline compliance strings still land on the un-themed face
274
+ expect(html).toContain('data-certrev-strings-version="2026-07"')
275
+ expect(html).toContain(CERT_SCOPE_LINE)
276
+ })
277
+
278
+ it('renders the cue as plain text when the profile URL is unsafe/absent', () => {
279
+ const p = makeMockPayload({
280
+ content: { ...payload.content, expert: { ...payload.content.expert, profileUrl: 'javascript:alert(1)' } },
281
+ })
282
+ const html = renderBadgeHtml(p)
283
+ expect(html).not.toContain('javascript:')
284
+ expect(html).toContain('Compensated expert')
285
+ })
286
+ })
@@ -71,6 +71,16 @@ describe('renderBadgeHtml (the shared, framework-agnostic markup)', () => {
71
71
  expect(html).not.toContain('__memo')
72
72
  expect(html).not.toContain('__dates')
73
73
  })
74
+
75
+ it('carries the always-on compliance baseline (cue + verbatim scope line + strings-version) on every face', () => {
76
+ const html = renderBadgeHtml(payload)
77
+ expect(html).toContain('data-certrev-strings-version="2026-07"')
78
+ expect(html).toContain(
79
+ 'Independent editorial review of this article’s text and sources. Not a product endorsement, nor medical advice.',
80
+ )
81
+ expect(html).toContain('data-ftc-line')
82
+ expect(html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ')).toContain('Compensated expert')
83
+ })
74
84
  })
75
85
 
76
86
  describe('<certrev-badge> custom element', () => {
@@ -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
  }