@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
@@ -0,0 +1,286 @@
1
+ /**
2
+ * ─────────────────────────────────────────────────────────────────────────────
3
+ * Design-guide RENDER-DEF support (the themeable-token + subtractive-visibility
4
+ * layer) + the two always-on COMPLIANCE strings.
5
+ * ─────────────────────────────────────────────────────────────────────────────
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.
12
+ *
13
+ * 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:
16
+ *
17
+ * 1. The four allowlisted THEMEABLE tokens `{accentColor, surface, cornerRadius,
18
+ * fontSlot}`, emitted as the exact `--certrev-*` CSS custom properties the
19
+ * Shopify/WordPress engines emit and the WS11 crawl monitor asserts
20
+ * (`--certrev-accent` / `--certrev-surface` / `--certrev-radius` /
21
+ * `--certrev-font`). Each is RE-VALIDATED here at render time (defense in
22
+ * depth over the write-path fail-closed gate) against the SAME strict
23
+ * validators the portal's `tokens.ts` uses, and an invalid value is DROPPED
24
+ * (falls back to the preset default) — never emitted. `fontSlot` resolves to
25
+ * a FIXED font STACK (never a free-form family — the CSS-injection surface).
26
+ * 2. SUBTRACTIVE field-visibility: a brand may only HIDE preset-placed,
27
+ * brand-toggleable fields (`reviewerPhoto`, `memo`) — never add a field the
28
+ * preset doesn't place. Locked fields (cue, scope, links) can never be hidden.
29
+ * 3. The two always-on COMPLIANCE strings a claim-bearing face carries on EVERY
30
+ * rung (design guide §3): the compensation cue and the FTC scope line, both
31
+ * byte-verbatim (the scope line's curly apostrophe U+2019 is load-bearing).
32
+ *
33
+ * FAIL-SAFE: when NO render-def is supplied the THEMING layer contributes nothing
34
+ * — no extra CSS vars, no `data-certrev-def-version`, no visibility subtraction —
35
+ * so the badge's theme output is byte-identical to the un-themed default (the
36
+ * program's no-op / merge-safety device). The compensation cue + scope line + the
37
+ * `data-certrev-strings-version` stamp are the compliance BASELINE and land on
38
+ * every claim face regardless of a def (matching the Shopify/WP engine fix).
39
+ *
40
+ * PURITY: no DOM, no Node builtins, no React — safe on every runtime the main
41
+ * entry targets (edge / Workers / server / client).
42
+ */
43
+
44
+ // ─────────────────────────────────────────────────────────────────────────────
45
+ // Themeable token vocabulary (mirrors portal src/lib/design-guide/tokens.ts)
46
+ // ─────────────────────────────────────────────────────────────────────────────
47
+
48
+ /** The font ENUM. Resolves to a FIXED slot stack — never a free-form family. */
49
+ export const FONT_SLOTS = ['sans', 'serif', 'system'] as const
50
+ export type FontSlot = (typeof FONT_SLOTS)[number]
51
+
52
+ /**
53
+ * The `fontSlot` → fixed font-stack literal. BYTE-IDENTICAL to the Shopify Liquid
54
+ * engine's stacks (`shopify/extensions/certrev-schema/snippets/certrev-badge.liquid`
55
+ * — the only font families the twins use), so a themed brand's Builder projection
56
+ * paints the same typography as its Shopify face. A raw family string in a
57
+ * `font-family` CSS var is a CSS-injection surface AND breaks the "CertREV hosts no
58
+ * fonts" invariant, so the enum → stack indirection is the security boundary.
59
+ */
60
+ export const FONT_STACKS: Readonly<Record<FontSlot, string>> = {
61
+ sans: "'DM Sans', system-ui, -apple-system, BlinkMacSystemFont, sans-serif",
62
+ serif: "'Playfair Display', Georgia, 'Times New Roman', serif",
63
+ system: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
64
+ }
65
+
66
+ /** A fully-resolved themeable token set (a brand overrides a subset of these). */
67
+ export interface CertThemeTokens {
68
+ readonly accentColor: string
69
+ readonly surface: string
70
+ readonly cornerRadius: string
71
+ readonly fontSlot: FontSlot
72
+ }
73
+
74
+ /** The badge fields a render-def may SUBTRACTIVELY hide (brand-toggleable, free). */
75
+ export const HIDEABLE_FIELDS = ['reviewerPhoto', 'memo'] as const
76
+ export type BadgeVisibilityFieldId = (typeof HIDEABLE_FIELDS)[number]
77
+
78
+ /**
79
+ * A brand render-def as it reaches the render edge — the RESOLVED projection the
80
+ * portal's `getActiveRenderDef` → cert-chrome/builder-export path carries (mirrors
81
+ * the Shopify `certrev.render_def` metafield `{v, preset, rung, tokens, hidden}`;
82
+ * cert-block needs only the theming inputs). Every field is OPTIONAL: an absent
83
+ * `renderDef`, an absent token, or an invalid token all fall back to the preset
84
+ * default (fail-safe).
85
+ */
86
+ export interface CertRenderDef {
87
+ /** Partial themeable-token overrides. Absent/invalid keys use the preset default. */
88
+ readonly tokens?: Partial<CertThemeTokens>
89
+ /** SUBTRACTIVE hide-set — the ONLY visibility a brand can express. */
90
+ readonly hidden?: readonly BadgeVisibilityFieldId[]
91
+ /** Monotonic per-brand def version, stamped as `data-certrev-def-version`. */
92
+ readonly version?: number
93
+ }
94
+
95
+ // ─────────────────────────────────────────────────────────────────────────────
96
+ // Per-token validators (strict, CSS-injection-safe — no trim, exact match)
97
+ // ─────────────────────────────────────────────────────────────────────────────
98
+
99
+ /** 3- or 6-digit hex with a leading `#` — opaque by construction (no alpha). */
100
+ const HEX_OPAQUE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/
101
+
102
+ /** A single CSS length: a non-negative number with a `px|rem|em|%` unit, or bare `0`. */
103
+ const RADIUS = /^(?:0|(?:\d+(?:\.\d+)?|\.\d+)(?:px|rem|em|%))$/
104
+
105
+ /** `accentColor` / `surface` — an opaque hex (surface opacity is FORCED for contrast soundness). */
106
+ export function isOpaqueHexColor(value: string): boolean {
107
+ return HEX_OPAQUE.test(value)
108
+ }
109
+
110
+ /** `cornerRadius` — a single unit-bearing CSS length (or bare `0`). */
111
+ export function isCornerRadius(value: string): boolean {
112
+ return RADIUS.test(value)
113
+ }
114
+
115
+ /** Is `value` a known font slot? */
116
+ export function isFontSlot(value: string): value is FontSlot {
117
+ return (FONT_SLOTS as readonly string[]).includes(value)
118
+ }
119
+
120
+ // ─────────────────────────────────────────────────────────────────────────────
121
+ // The two always-on compliance strings (byte-verbatim — never re-word here)
122
+ // ─────────────────────────────────────────────────────────────────────────────
123
+
124
+ /**
125
+ * The compensation cue (design guide "line A"). Rendered beside the reviewer on
126
+ * EVERY claim-bearing face. Byte-exact; the tag-stripped visible text of the cue
127
+ * markup must equal this constant. Mirrors portal `COMPENSATED_EXPERT_CUE`.
128
+ */
129
+ export const COMPENSATED_EXPERT_CUE = 'Compensated expert'
130
+
131
+ /**
132
+ * The FTC scope disclaimer (design guide "line B"). Byte-verbatim; the curly
133
+ * apostrophe (U+2019) in "article’s" is LOAD-BEARING (the MSA §3 references this
134
+ * copy in the present tense). Mirrors portal `FTC_DISCLOSURE_LINE` / `CERT_SCOPE_LINE`.
135
+ */
136
+ export const CERT_SCOPE_LINE =
137
+ 'Independent editorial review of this article’s text and sources. Not a product endorsement, nor medical advice.'
138
+
139
+ /**
140
+ * The delivered display-string EDITION, stamped as `data-certrev-strings-version`
141
+ * so the crawl monitor can assert which verbatim strings a live face should carry.
142
+ * MUST equal the portal's `CERT_DISPLAY_STRINGS_VERSION` (display-strings.ts).
143
+ * Bump in lockstep whenever the cue or scope line changes.
144
+ */
145
+ export const CERT_STRINGS_VERSION = '2026-07'
146
+
147
+ // ─────────────────────────────────────────────────────────────────────────────
148
+ // Resolution
149
+ // ─────────────────────────────────────────────────────────────────────────────
150
+
151
+ /**
152
+ * The validated theme a render-def resolves to. Every field is present ONLY when
153
+ * the def supplied a VALID value for it (an absent/invalid token is dropped so the
154
+ * preset default shows through). `accent` is surfaced for the accent-precedence
155
+ * chain in `resolveDisplay`; `surface`/`cornerRadius`/`fontStack` have no legacy
156
+ * display equivalent and are emitted ONLY on a themed face.
157
+ */
158
+ export interface ResolvedRenderTheme {
159
+ readonly accent?: string
160
+ readonly surface?: string
161
+ readonly cornerRadius?: string
162
+ readonly fontStack?: string
163
+ readonly version?: number
164
+ }
165
+
166
+ /** Resolve + re-validate a render-def's tokens. Invalid values are DROPPED (fail-safe). */
167
+ export function resolveRenderTheme(def?: CertRenderDef): ResolvedRenderTheme {
168
+ if (!def) return {}
169
+ const t = def.tokens ?? {}
170
+ const accent = t.accentColor && isOpaqueHexColor(t.accentColor) ? t.accentColor : undefined
171
+ const surface = t.surface && isOpaqueHexColor(t.surface) ? t.surface : undefined
172
+ const cornerRadius = t.cornerRadius && isCornerRadius(t.cornerRadius) ? t.cornerRadius : undefined
173
+ const fontStack = t.fontSlot && isFontSlot(t.fontSlot) ? FONT_STACKS[t.fontSlot] : undefined
174
+ const version = typeof def.version === 'number' && Number.isFinite(def.version) ? def.version : undefined
175
+ return { accent, surface, cornerRadius, fontStack, version }
176
+ }
177
+
178
+ /** Is a brand-toggleable field hidden by this render-def's subtractive hide-set? */
179
+ export function isFieldHidden(def: CertRenderDef | undefined, id: BadgeVisibilityFieldId): boolean {
180
+ return !!def?.hidden?.includes(id)
181
+ }
182
+
183
+ // ─────────────────────────────────────────────────────────────────────────────
184
+ // Cert-render BLOCK theme — the resolved-token mirror of portal ResolvedRenderDef
185
+ // ─────────────────────────────────────────────────────────────────────────────
186
+ //
187
+ // `renderCertBlock` (render-cert-block.ts) paints the LOCKED CertREV cert design
188
+ // (banner / sidebar / floating) from an ALREADY-RESOLVED token set — the portal
189
+ // bakes `ResolvedRenderDef` (render-def.ts + tokens.ts) and the projection carries
190
+ // its theme half here. This is a WIDER slot vocabulary than the `CertThemeTokens`
191
+ // above (which the legacy `<CertBadge>` uses): it mirrors the portal's FULL
192
+ // five-member `FontSlot` enum + the pre-computed `accentFg` ink, so the block can
193
+ // reproduce the guide's four-font design (DM Sans body, Playfair memo, JetBrains
194
+ // mono labels, Plus Jakarta wordmark). It is kept DISTINCT from `FONT_SLOTS` /
195
+ // `FONT_STACKS` above so the legacy badge's three-slot contract stays byte-frozen.
196
+
197
+ /**
198
+ * The full portal font-slot enum (mirrors portal `src/lib/design-guide/tokens.ts`
199
+ * `FONT_SLOTS`). `grotesk` ⇐ the guide's wordmark slot; `mono` ⇐ the label slot.
200
+ */
201
+ export const RENDER_BLOCK_FONT_SLOTS = ['sans', 'serif', 'system', 'grotesk', 'mono'] as const
202
+ export type RenderBlockFontSlot = (typeof RENDER_BLOCK_FONT_SLOTS)[number]
203
+
204
+ /**
205
+ * The `RenderBlockFontSlot` → fixed family stack. BYTE-IDENTICAL to the portal's
206
+ * `FONT_SLOT_STACKS` (the guide's stacks — the cert-render parity anchor across the
207
+ * twins). Resolved family strings, never `var(...)` indirections. A raw family in a
208
+ * `font-family` CSS var is a CSS-injection surface, so the enum → stack indirection
209
+ * stays the security boundary (a caller can only pass an enum member).
210
+ */
211
+ export const RENDER_BLOCK_FONT_STACKS: Readonly<Record<RenderBlockFontSlot, string>> = {
212
+ sans: "'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif",
213
+ serif: "'Playfair Display', Georgia, 'Times New Roman', serif",
214
+ grotesk: "'Plus Jakarta Sans', system-ui, Helvetica, Arial, sans-serif",
215
+ mono: "'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace",
216
+ system: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif",
217
+ }
218
+
219
+ /** Is `value` a known render-block font slot? */
220
+ export function isRenderBlockFontSlot(value: string): value is RenderBlockFontSlot {
221
+ return (RENDER_BLOCK_FONT_SLOTS as readonly string[]).includes(value)
222
+ }
223
+
224
+ /**
225
+ * The resolved themeable-token set `renderCertBlock` paints — the render-edge mirror
226
+ * of the portal `ResolvedRenderDef` theme half (`tokens` ⊕ the precomputed
227
+ * `accentFg`). `accentFg` is the ink painted over the accent fill — the portal's
228
+ * guide-faithful Rec.601 auto-contrast pick (`#141414` or `#ffffff`), carried
229
+ * pre-computed so this dumb twin never recomputes it.
230
+ */
231
+ export interface ResolvedBlockTheme {
232
+ readonly accentColor: string
233
+ readonly surface: string
234
+ readonly cornerRadius: string
235
+ readonly fontSlot: RenderBlockFontSlot
236
+ readonly accentFg: '#141414' | '#ffffff'
237
+ }
238
+
239
+ /** The CertREV navy default theme (the un-themed face — matches the locked reference `:root`). */
240
+ export const DEFAULT_BLOCK_THEME: ResolvedBlockTheme = {
241
+ accentColor: '#0a1b3f',
242
+ surface: '#ffffff',
243
+ cornerRadius: '14px',
244
+ fontSlot: 'sans',
245
+ accentFg: '#ffffff',
246
+ }
247
+
248
+ /**
249
+ * Re-validate a (possibly partial / untrusted) block theme at the render edge —
250
+ * defense in depth over the portal's fail-closed bake. Any invalid token is DROPPED
251
+ * to the navy default (never emitted into storefront CSS). Mirrors the
252
+ * `resolveRenderTheme` fail-safe posture.
253
+ */
254
+ export function resolveBlockTheme(theme?: Partial<ResolvedBlockTheme>): ResolvedBlockTheme {
255
+ const t = theme ?? {}
256
+ return {
257
+ accentColor: t.accentColor && isOpaqueHexColor(t.accentColor) ? t.accentColor : DEFAULT_BLOCK_THEME.accentColor,
258
+ surface: t.surface && isOpaqueHexColor(t.surface) ? t.surface : DEFAULT_BLOCK_THEME.surface,
259
+ cornerRadius: t.cornerRadius && isCornerRadius(t.cornerRadius) ? t.cornerRadius : DEFAULT_BLOCK_THEME.cornerRadius,
260
+ fontSlot: t.fontSlot && isRenderBlockFontSlot(t.fontSlot) ? t.fontSlot : DEFAULT_BLOCK_THEME.fontSlot,
261
+ accentFg: t.accentFg === '#141414' || t.accentFg === '#ffffff' ? t.accentFg : DEFAULT_BLOCK_THEME.accentFg,
262
+ }
263
+ }
264
+
265
+ /**
266
+ * The locked verification ATTRIBUTION phrase, rendered grouped with the dated
267
+ * verification (`<name>, <credential>` → this → `on <date>`). Byte-exact; mirrors the
268
+ * fixed clause inside portal `buildBylineCredentialed` ("Credential verified by
269
+ * CertREV on …"). Never re-word without contract + legal re-review.
270
+ */
271
+ export const CREDENTIAL_VERIFIED_ATTRIBUTION = 'Credential verified by CertREV'
272
+
273
+ /**
274
+ * The extra `--certrev-*` custom-property declarations a THEMED face emits, in the
275
+ * engine's order (surface → radius → font; accent is emitted separately so the
276
+ * no-def output stays byte-identical). Returns `[]` when no theme is present, so
277
+ * the un-themed badge emits ZERO extra vars. The data-bearing values are already
278
+ * strict-validated; the font is a controlled literal stack.
279
+ */
280
+ export function themeCssVarDeclarations(theme: ResolvedRenderTheme): ReadonlyArray<readonly [string, string]> {
281
+ const out: Array<readonly [string, string]> = []
282
+ if (theme.surface) out.push(['--certrev-surface', theme.surface])
283
+ if (theme.cornerRadius) out.push(['--certrev-radius', theme.cornerRadius])
284
+ if (theme.fontStack) out.push(['--certrev-font', theme.fontStack])
285
+ return out
286
+ }
package/src/index.ts CHANGED
@@ -36,6 +36,41 @@ export {
36
36
  type ResolvedDisplay,
37
37
  resolveDisplay,
38
38
  } from './components/format.js'
39
+ // ── Cert-render BLOCK — the LOCKED 3-mode cert design as an SSR-safe HTML string ──
40
+ export {
41
+ type CertBlockFacts,
42
+ type CertBlockMode,
43
+ type RenderCertBlockInput,
44
+ renderCertBlock,
45
+ } from './components/render-cert-block.js'
46
+ // ── Design-guide render-def support (WS6: themeable tokens + subtractive visibility) ──
47
+ export {
48
+ type BadgeVisibilityFieldId,
49
+ CERT_SCOPE_LINE,
50
+ CERT_STRINGS_VERSION,
51
+ type CertRenderDef,
52
+ type CertThemeTokens,
53
+ COMPENSATED_EXPERT_CUE,
54
+ CREDENTIAL_VERIFIED_ATTRIBUTION,
55
+ DEFAULT_BLOCK_THEME,
56
+ FONT_SLOTS,
57
+ FONT_STACKS,
58
+ type FontSlot,
59
+ HIDEABLE_FIELDS,
60
+ isCornerRadius,
61
+ isFieldHidden,
62
+ isFontSlot,
63
+ isOpaqueHexColor,
64
+ isRenderBlockFontSlot,
65
+ RENDER_BLOCK_FONT_SLOTS,
66
+ RENDER_BLOCK_FONT_STACKS,
67
+ type RenderBlockFontSlot,
68
+ type ResolvedBlockTheme,
69
+ type ResolvedRenderTheme,
70
+ resolveBlockTheme,
71
+ resolveRenderTheme,
72
+ themeCssVarDeclarations,
73
+ } from './components/render-def.js'
39
74
  // ── Contract surface (types + kernel) — re-export the binding point ────────────────
40
75
  // NOTE: the test/dev fixtures (makeSignedEnvelope, makeMockPayload, FIXTURE_KID) are
41
76
  // Node-only (they sign with node:crypto) and ship from the './fixtures' subpath, NOT here.
@@ -14,11 +14,25 @@
14
14
 
15
15
  import { escapeAttribute, escapeHtml, safeCssColor, safeHttpUrl } from '../components/escape.js'
16
16
  import { credentialSuffix, formatDate, resolveDisplay } from '../components/format.js'
17
+ import {
18
+ CERT_SCOPE_LINE,
19
+ CERT_STRINGS_VERSION,
20
+ type CertRenderDef,
21
+ resolveRenderTheme,
22
+ themeCssVarDeclarations,
23
+ } from '../components/render-def.js'
17
24
  import type { CertPayload } from '../contract/kernel.js'
18
25
 
19
26
  export interface RenderBadgeOptions {
20
27
  readonly accentColor?: string
21
28
  readonly badgeStyle?: 'full' | 'compact'
29
+ /**
30
+ * The brand render-def (WS6). ABSENT → the badge renders the CertREV preset
31
+ * default (theme output byte-identical to un-themed). PRESENT → validated
32
+ * themeable tokens are emitted as `--certrev-*` CSS vars + subtractive
33
+ * visibility applies. Mirrors `<CertBadge>`'s `renderDef` prop exactly.
34
+ */
35
+ readonly renderDef?: CertRenderDef
22
36
  }
23
37
 
24
38
  const C = 'certrev-badge'
@@ -35,7 +49,8 @@ function attr(name: string, value: string | null | undefined): string {
35
49
  */
36
50
  export function renderBadgeHtml(payload: CertPayload, opts: RenderBadgeOptions = {}): string {
37
51
  const content = payload.content
38
- const display = resolveDisplay(content.display, opts.accentColor)
52
+ const display = resolveDisplay(content.display, opts.accentColor, opts.renderDef)
53
+ const theme = resolveRenderTheme(opts.renderDef)
39
54
  const style = opts.badgeStyle ?? display.badgeStyle
40
55
  const accent = safeCssColor(display.accentColor) ?? '#0f766e'
41
56
  const verifyUrl = safeHttpUrl(content.verifyUrl)
@@ -91,16 +106,43 @@ export function renderBadgeHtml(payload: CertPayload, opts: RenderBadgeOptions =
91
106
  if (dates.length) body += `<dl class="${C}__dates">${dates.join('')}</dl>`
92
107
  }
93
108
 
109
+ // Design guide §3: the compensation cue + FTC scope line are LOCKED on every
110
+ // claim-bearing face (cert-block always carries the reviewer byline, so both
111
+ // render on the full AND compact styles). The compliance BASELINE — present
112
+ // regardless of a render-def, matching the Shopify/WordPress engine fix. The
113
+ // cue's "expert" links to the reviewer profile; the whole cue tag-strips to the
114
+ // byte-verbatim COMPENSATED_EXPERT_CUE. The scope line is byte-verbatim
115
+ // CERT_SCOPE_LINE (its curly apostrophe U+2019 survives escapeHtml untouched).
116
+ const cueInner = profileUrl
117
+ ? `Compensated <a class="${C}__cue-link" href="${escapeAttribute(profileUrl)}" rel="noopener">expert</a>`
118
+ : 'Compensated expert'
119
+ const disclosure =
120
+ `<div class="${C}__disclosure">` +
121
+ `<span class="${C}__compensated-cue">${cueInner}</span>` +
122
+ `<p class="${C}__ftc" data-ftc-disclosure><span data-ftc-line>${escapeHtml(CERT_SCOPE_LINE)}</span></p>` +
123
+ `</div>`
124
+
94
125
  const verify = verifyUrl
95
126
  ? `<a class="${C}__verify" href="${escapeAttribute(verifyUrl)}" rel="noopener" aria-label="Verify this certification on CertREV">Verify on CertREV</a>`
96
127
  : ''
97
128
 
98
129
  const ariaLabel = `Content reviewed by ${content.expert.displayName}${suffix ? `, ${suffix}` : ''}`
99
- const rootStyle = `--certrev-accent:${accent};border-inline-start-color:${accent}`
130
+ // Accent LEADS so the un-themed output is byte-identical to the pre-WS6 single-var
131
+ // form. A THEMED brand appends surface/radius/font: the data-bearing tokens ride
132
+ // escaped (a no-op for validated hex/length — defense in depth), the font is a
133
+ // controlled literal stack emitted raw. The whole string is NOT re-escaped
134
+ // (the font stack's single quotes are valid inside a double-quoted attribute and
135
+ // re-escaping would corrupt them).
136
+ let rootStyle = `--certrev-accent:${escapeAttribute(accent)};border-inline-start-color:${escapeAttribute(accent)}`
137
+ for (const [name, value] of themeCssVarDeclarations(theme)) {
138
+ rootStyle += name === '--certrev-font' ? `;${name}:${value}` : `;${name}:${escapeAttribute(value)}`
139
+ }
100
140
 
101
141
  return (
102
- `<section class="${C} ${C}--${escapeAttribute(style)}" style="${escapeAttribute(rootStyle)}"` +
142
+ `<section class="${C} ${C}--${escapeAttribute(style)}" style="${rootStyle}"` +
103
143
  attr('data-certrev-cert-id', payload.certId) +
104
- ` aria-label="${escapeAttribute(ariaLabel)}">${header}${body}${verify}</section>`
144
+ ` data-certrev-strings-version="${CERT_STRINGS_VERSION}"` +
145
+ attr('data-certrev-def-version', theme.version != null ? String(theme.version) : null) +
146
+ ` aria-label="${escapeAttribute(ariaLabel)}">${header}${body}${disclosure}${verify}</section>`
105
147
  )
106
148
  }