@certrev/cert-block 1.1.0 → 1.2.0-canary.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 (100) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/README.md +69 -9
  3. package/dist/builder/cert-chrome-data.d.ts +10 -0
  4. package/dist/builder/cert-chrome-data.d.ts.map +1 -1
  5. package/dist/builder/cert-chrome-data.js.map +1 -1
  6. package/dist/builder/cert-review-card.d.ts.map +1 -1
  7. package/dist/builder/cert-review-card.js +22 -2
  8. package/dist/builder/cert-review-card.js.map +1 -1
  9. package/dist/builder/envelope-face.d.ts +25 -1
  10. package/dist/builder/envelope-face.d.ts.map +1 -1
  11. package/dist/builder/envelope-face.js +40 -0
  12. package/dist/builder/envelope-face.js.map +1 -1
  13. package/dist/builder/index.d.ts +1 -1
  14. package/dist/builder/index.d.ts.map +1 -1
  15. package/dist/builder/index.js +1 -1
  16. package/dist/builder/index.js.map +1 -1
  17. package/dist/components/render-cert-block.d.ts.map +1 -1
  18. package/dist/components/render-cert-block.js +3 -6
  19. package/dist/components/render-cert-block.js.map +1 -1
  20. package/dist/modal/certrev-cert-modal.d.ts +34 -1
  21. package/dist/modal/certrev-cert-modal.d.ts.map +1 -1
  22. package/dist/modal/certrev-cert-modal.js +40 -2
  23. package/dist/modal/certrev-cert-modal.js.map +1 -1
  24. package/dist/modal/certrev-cert.entry.d.ts.map +1 -1
  25. package/dist/modal/certrev-cert.entry.js +2 -4
  26. package/dist/modal/certrev-cert.entry.js.map +1 -1
  27. package/dist/modal/certrev-cert.js +4 -4
  28. package/dist/modal/deprecated.d.ts +15 -0
  29. package/dist/modal/deprecated.d.ts.map +1 -0
  30. package/dist/modal/deprecated.js +22 -0
  31. package/dist/modal/deprecated.js.map +1 -0
  32. package/dist/modal/ftc-disclosure.d.ts +7 -3
  33. package/dist/modal/ftc-disclosure.d.ts.map +1 -1
  34. package/dist/modal/ftc-disclosure.js +7 -3
  35. package/dist/modal/ftc-disclosure.js.map +1 -1
  36. package/dist/modal/index.d.ts +4 -3
  37. package/dist/modal/index.d.ts.map +1 -1
  38. package/dist/modal/index.js +4 -3
  39. package/dist/modal/index.js.map +1 -1
  40. package/dist/modal/interactions.d.ts +20 -4
  41. package/dist/modal/interactions.d.ts.map +1 -1
  42. package/dist/modal/interactions.js +34 -7
  43. package/dist/modal/interactions.js.map +1 -1
  44. package/dist/reader/face.d.ts +12 -0
  45. package/dist/reader/face.d.ts.map +1 -0
  46. package/dist/reader/face.js +19 -0
  47. package/dist/reader/face.js.map +1 -0
  48. package/dist/reader/helpers.d.ts +48 -0
  49. package/dist/reader/helpers.d.ts.map +1 -0
  50. package/dist/reader/helpers.js +166 -0
  51. package/dist/reader/helpers.js.map +1 -0
  52. package/dist/reader/index.d.ts +10 -0
  53. package/dist/reader/index.d.ts.map +1 -0
  54. package/dist/reader/index.js +10 -0
  55. package/dist/reader/index.js.map +1 -0
  56. package/dist/reader/logo.d.ts +4 -0
  57. package/dist/reader/logo.d.ts.map +1 -0
  58. package/dist/reader/logo.js +23 -0
  59. package/dist/reader/logo.js.map +1 -0
  60. package/dist/reader/reader-css.generated.d.ts +2 -0
  61. package/dist/reader/reader-css.generated.d.ts.map +1 -0
  62. package/dist/reader/reader-css.generated.js +6 -0
  63. package/dist/reader/reader-css.generated.js.map +1 -0
  64. package/dist/reader/render.d.ts +26 -0
  65. package/dist/reader/render.d.ts.map +1 -0
  66. package/dist/reader/render.js +230 -0
  67. package/dist/reader/render.js.map +1 -0
  68. package/dist/reader/tokens.d.ts +8 -0
  69. package/dist/reader/tokens.d.ts.map +1 -0
  70. package/dist/reader/tokens.js +65 -0
  71. package/dist/reader/tokens.js.map +1 -0
  72. package/dist/verify/get-verified-delivery.d.ts.map +1 -1
  73. package/dist/verify/get-verified-delivery.js +5 -1
  74. package/dist/verify/get-verified-delivery.js.map +1 -1
  75. package/package.json +7 -1
  76. package/src/builder/cert-chrome-data.ts +10 -0
  77. package/src/builder/cert-review-card.tsx +31 -2
  78. package/src/builder/envelope-face.ts +54 -5
  79. package/src/builder/index.tsx +3 -0
  80. package/src/components/render-cert-block.ts +3 -6
  81. package/src/modal/certrev-cert-modal.ts +45 -2
  82. package/src/modal/certrev-cert.entry.ts +2 -4
  83. package/src/modal/deprecated.ts +25 -0
  84. package/src/modal/ftc-disclosure.ts +7 -3
  85. package/src/modal/index.ts +4 -3
  86. package/src/modal/interactions.ts +37 -7
  87. package/src/reader/face.ts +35 -0
  88. package/src/reader/helpers.ts +178 -0
  89. package/src/reader/index.ts +14 -0
  90. package/src/reader/logo.ts +28 -0
  91. package/src/reader/reader-css.generated.ts +5 -0
  92. package/src/reader/reader.css +550 -0
  93. package/src/reader/render.ts +319 -0
  94. package/src/reader/tokens.ts +76 -0
  95. package/src/verify/get-verified-delivery.ts +5 -1
  96. package/dist/modal/ftc-guard.d.ts +0 -85
  97. package/dist/modal/ftc-guard.d.ts.map +0 -1
  98. package/dist/modal/ftc-guard.js +0 -150
  99. package/dist/modal/ftc-guard.js.map +0 -1
  100. package/src/modal/ftc-guard.ts +0 -174
@@ -0,0 +1,319 @@
1
+ /**
2
+ * ─────────────────────────────────────────────────────────────────────────────
3
+ * `renderReaderFace` — the certrev.com "reader" face, as the Builder card paints it (1.2.0)
4
+ * ─────────────────────────────────────────────────────────────────────────────
5
+ *
6
+ * The same markup, byte for byte, as the Shopify `certrev-reader-{banner,memo,sidebar,pill}` snippets, which are the
7
+ * portal's canonical render of the face (the "What your reader sees" demo on certrev.com). Styling is `reader.css`
8
+ * (the generated reader section of the Shopify stylesheet, `READER_CSS`); this file only emits the DOM it targets.
9
+ * The golden tests compare the sections to the portal's golden pages, and the differential tests compare edge cases to
10
+ * the portal's own Liquid harness output, so the two renderers cannot drift without a test saying so.
11
+ *
12
+ * It is a NEW face, selected only by a delivered render def that says `face: 'reader'` (see `envelopeCardHtml`).
13
+ * Nothing in the legacy `renderCertBlock` is read or changed here.
14
+ *
15
+ * The facts are read exactly as the snippets read the same envelope:
16
+ * - the card renders only with a reviewer name (the memo and sidebar too); the pill needs only a verify URL;
17
+ * - the credential is each credential's `cardLabel` (else `abbreviation`), joined; the "Credential verified by CertREV"
18
+ * attribution is gated on a displayed credential, and "on {date}" binds `expert.credentialVerifiedAt` and nothing
19
+ * else (no fallback to the certified date: with no date only the undated, still-true attribution prints);
20
+ * - the compensation cue renders ONLY when the envelope carries `expert.compensationCue`; the def cannot hide it, and
21
+ * its words are fixed. `illustrative` (a CertREV preview, never a live page) fills the cue's slot with the demo's
22
+ * "Illustrative example" label when the envelope has no cue; a real cue always wins the slot;
23
+ * - the scope line is never hideable: the card carries it when there is no memo, the memo card and the sidebar always;
24
+ * - the def's `hidden` set removes the avatars (`reviewerPhoto`), `display.showExpertPhoto === false` keeps the
25
+ * silhouette and never requests the photo, `display.showAuthor === false` drops the author column.
26
+ *
27
+ * Every interpolated field is escaped and every URL passes `safeUrl`. A hostile type (a number, an object) renders as
28
+ * nothing, never as a throw: see `helpers.ts`.
29
+ */
30
+ import { asText } from '../components/escape.js'
31
+ import { CERTREV_LINK_REL } from '../components/rel.js'
32
+ import { CERT_SCOPE_LINE, CERT_STRINGS_VERSION, COMPENSATED_EXPERT_CUE } from '../components/render-def.js'
33
+ import type { CertContent } from '../contract/kernel.js'
34
+ import { readerFace } from './face.js'
35
+ import {
36
+ cleaned,
37
+ contains,
38
+ esc,
39
+ firstName,
40
+ isBlank,
41
+ isPresent,
42
+ isTruthy,
43
+ longDate,
44
+ type Rec,
45
+ rec,
46
+ safeUrl,
47
+ textOr,
48
+ } from './helpers.js'
49
+ import { readerLogo } from './logo.js'
50
+ import { readerTokens } from './tokens.js'
51
+
52
+ export type ReaderLayout = 'banner' | 'sidebar' | 'floating'
53
+
54
+ /** Which of the banner's two cards to paint: both (the page default), the byline card alone, or the memo card alone. */
55
+ export type ReaderPart = 'full' | 'header' | 'memo'
56
+
57
+ export interface ReaderFaceInput {
58
+ /** The verified envelope's `payload.content`. */
59
+ readonly content: CertContent
60
+ /** The delivered render def (already known to say `face: 'reader'`); read for tokens and `hidden`. */
61
+ readonly def?: unknown
62
+ readonly layout: ReaderLayout
63
+ /** Banner only; the sidebar and the floating pill are one section each. Defaults to `full`. */
64
+ readonly part?: ReaderPart
65
+ /** The envelope's `payload.certId`, stamped as `data-certrev-cert-id` (the revalidation hook reads it). */
66
+ readonly certId?: string
67
+ /** A CertREV preview (never a live page): label a cue-less article "Illustrative example". */
68
+ readonly illustrative?: boolean
69
+ }
70
+
71
+ /*
72
+ * Every reader anchor carries the package's own link policy, `CERTREV_LINK_REL` (`nofollow sponsored noopener`), the
73
+ * same as the legacy faces: a Builder page's CertREV links are a vendor-required placement and must keep the
74
+ * sponsored/nofollow signal. The Shopify snippets (and so the portal's goldens) say `rel="noopener noreferrer"`; that is
75
+ * theirs, not the target. It is the ONE byte the reader differs from them in, and `reader-link-rel.test.ts` pins that:
76
+ * the golden and differential tests rewrite the Liquid's rel on anchors to this value and compare everything else exactly.
77
+ */
78
+
79
+ /** What every section of one render shares: the facts read once from the envelope and the def. */
80
+ interface Facts {
81
+ readonly content: Rec
82
+ readonly expert: Rec
83
+ readonly disp: Rec
84
+ readonly def: unknown
85
+ readonly certId: string
86
+ readonly ename: string
87
+ readonly hideAvatar: boolean
88
+ readonly showPhoto: boolean
89
+ /** The joined credential labels, '' when there are none. */
90
+ readonly creds: string
91
+ readonly hasCue: boolean
92
+ readonly illus: boolean
93
+ readonly noMemo: boolean
94
+ readonly purl: string
95
+ readonly vurl: string
96
+ readonly style: string
97
+ readonly fg: string
98
+ }
99
+
100
+ function readFacts(input: ReaderFaceInput): Facts {
101
+ const content = rec(input.content)
102
+ const expert = rec(content.expert)
103
+ const disp = rec(content.display)
104
+ const def = input.def
105
+ const rd = rec(def)
106
+
107
+ const hideAvatar = contains(rd.hidden, 'reviewerPhoto')
108
+ const showPhoto = disp.showExpertPhoto !== false && !hideAvatar
109
+
110
+ // POR-9738: each credential's cardLabel over its bare abbreviation, joined with ', ' (labels are not trimmed).
111
+ const labels: string[] = []
112
+ if (Array.isArray(expert.credentials)) {
113
+ for (const credential of expert.credentials) {
114
+ const cr = rec(credential)
115
+ const label = asText(isPresent(cr.cardLabel) ? cr.cardLabel : cr.abbreviation)
116
+ if (!isBlank(label)) labels.push(label)
117
+ }
118
+ }
119
+
120
+ const hasCue = isPresent(expert.compensationCue)
121
+ const { style, fg } = readerTokens(def)
122
+ return {
123
+ content,
124
+ expert,
125
+ disp,
126
+ def,
127
+ certId: asText(input.certId),
128
+ ename: textOr(expert.displayName),
129
+ hideAvatar,
130
+ showPhoto,
131
+ creds: labels.join(', '),
132
+ hasCue,
133
+ illus: input.illustrative === true && !hasCue,
134
+ noMemo: isBlank(content.memo),
135
+ purl: cleaned(safeUrl(expert.profileUrl)),
136
+ vurl: cleaned(safeUrl(content.verifyUrl)),
137
+ style,
138
+ fg,
139
+ }
140
+ }
141
+
142
+ /** The `<section>` open tag every reader section shares. `badge` is the `.certrev-badge` hook the revalidation reads. */
143
+ function sectionOpen(f: Facts, badge: boolean, aria: string): string {
144
+ const cls = badge ? 'certrev-reader certrev-badge' : 'certrev-reader'
145
+ // `{% if rd %}`: any def that is not nil/false stamps its version.
146
+ const version = isTruthy(f.def) ? ` data-certrev-def-version="${esc(rec(f.def).v)}"` : ''
147
+ return (
148
+ `<section class="${cls}" style="${f.style}"${version} data-certrev-strings-version="${CERT_STRINGS_VERSION}" ` +
149
+ `data-certrev-cert-id="${esc(f.certId)}" aria-label="${esc(aria)}">`
150
+ )
151
+ }
152
+
153
+ const anchor = (href: string, modal: string, label: string): string =>
154
+ `<a href="${esc(href)}" target="_blank" rel="${CERTREV_LINK_REL}" data-certrev-modal-open${modal}>${label}</a>`
155
+
156
+ /** The words the memo-less banner and the memo link row use for the profile link. */
157
+ function profileLabel(f: Facts): string {
158
+ const first = cleaned(firstName(f.ename))
159
+ return first !== '' ? `View ${esc(first)}&rsquo;s profile` : 'View profile'
160
+ }
161
+
162
+ const credentialAria = (f: Facts): string => `Content reviewed by ${f.ename}${f.creds !== '' ? `, ${f.creds}` : ''}`
163
+
164
+ /** The compensation cue's slot at the head of the scope line (memo footer, sidebar). */
165
+ function scopeLead(f: Facts): string {
166
+ if (f.hasCue) return `<em>${COMPENSATED_EXPERT_CUE}</em> · `
167
+ if (f.illus) return '<em>Illustrative example</em> · '
168
+ return ''
169
+ }
170
+
171
+ const SCOPE = `<span data-ftc-line>${CERT_SCOPE_LINE}</span>`
172
+
173
+ /** The "{credential} · Credential verified by CertREV[ on {date}]" muted line (the credential part is the caller's). */
174
+ function verifiedLine(f: Facts, withCredentials: boolean): string {
175
+ if (f.creds === '') return ''
176
+ const date = cleaned(isBlank(f.expert.credentialVerifiedAt) ? '' : longDate(f.expert.credentialVerifiedAt))
177
+ const on = date !== '' ? ` on ${esc(date)}` : ''
178
+ const lead = withCredentials ? `${esc(f.creds)} · ` : ''
179
+ return `<em>${lead}Credential verified by CertREV${on}</em>`
180
+ }
181
+
182
+ // ─────────────────────────────────────────────────────────────────────────────
183
+ // Banner: the "Expert reviewed & certified" card, and the expert memo card beneath it
184
+ // ─────────────────────────────────────────────────────────────────────────────
185
+
186
+ function bannerCard(f: Facts): string {
187
+ if (f.ename === '') return ''
188
+ const author = rec(f.content.author)
189
+ const authorName = textOr(author.name)
190
+ const authorTitle = textOr(author.title)
191
+ const hasAuthor = f.disp.showAuthor !== false && authorName !== '' && authorName !== f.ename
192
+ const avatar = (photo: unknown, size: 'sm' | ''): string =>
193
+ f.hideAvatar ? '' : readerFace({ photo, show: f.showPhoto, size, width: 84 })
194
+
195
+ const authorBy = hasAuthor
196
+ ? `<div class="certrev-r-by">${avatar(author.photoUrl, 'sm')}<div>\n <b>${esc(authorName)}</b>` +
197
+ `${authorTitle !== '' ? `<em>${esc(authorTitle)}</em>` : ''}<span class="certrev-r-role">Written by</span>\n </div>\n </div>`
198
+ : ''
199
+ const expertBy =
200
+ `<div class="certrev-r-by">${avatar(f.expert.photoUrl, '')}<div>\n <b>${esc(f.ename)}</b>` +
201
+ `${verifiedLine(f, true)}<span class="certrev-r-role on">Reviewed by</span>\n </div>\n </div>`
202
+
203
+ const certifiedAt = f.content.certifiedAt
204
+ const certified = isBlank(certifiedAt) ? '' : cleaned(longDate(certifiedAt))
205
+
206
+ // A memo-less article gets the profile and certificate row, so the certificate stays reachable.
207
+ let links = ''
208
+ if (f.noMemo && (f.purl !== '' || f.vurl !== '')) {
209
+ links =
210
+ '<div class="certrev-r-links">' +
211
+ (f.purl !== '' ? anchor(f.purl, '="expert"', profileLabel(f)) : '') +
212
+ (f.vurl !== '' ? anchor(f.vurl, '', 'View certificate') : '') +
213
+ '</div>'
214
+ }
215
+
216
+ const hasFoot = certified !== '' || f.hasCue || f.illus
217
+ const rule = hasFoot || f.noMemo ? '<div class="certrev-r-rule"></div>' : ''
218
+
219
+ let foot = ''
220
+ if (hasFoot) {
221
+ const when =
222
+ certified !== ''
223
+ ? `<span><em>Certified</em><time datetime="${esc(certifiedAt)}">${esc(certified)}</time></span>`
224
+ : '<span></span>'
225
+ let cue = ''
226
+ if (f.hasCue) {
227
+ cue = `<span>Compensated ${f.purl !== '' ? anchor(f.purl, '="expert"', 'expert') : 'expert'}</span>`
228
+ } else if (f.illus) {
229
+ cue = '<span>Illustrative example</span>'
230
+ }
231
+ foot = `<div class="certrev-r-foot">${when}${cue}</div>`
232
+ }
233
+
234
+ // POR-9317: the memo card carries the scope line when there is a memo; with none this card is the sole claim face.
235
+ const scope = f.noMemo ? `<div class="certrev-r-scope" data-ftc-disclosure>${SCOPE}</div>` : ''
236
+
237
+ return (
238
+ `${sectionOpen(f, true, credentialAria(f))}\n <div class="certrev-r-card">\n` +
239
+ ` <div class="certrev-r-cap">${readerLogo(f.fg === '#ffffff' ? 'white' : 'mono', 18, 'CertREV')}Expert reviewed &amp; certified</div>\n` +
240
+ ` <div class="certrev-r-cap-body">\n <div class="certrev-r-bys${hasAuthor ? '' : ' solo'}">${authorBy}${expertBy}\n </div>` +
241
+ `${links}${rule}${foot}${scope}</div>\n </div>\n</section>`
242
+ )
243
+ }
244
+
245
+ function bannerMemo(f: Facts): string {
246
+ if (f.ename === '' || isBlank(f.content.memo)) return ''
247
+ const links =
248
+ f.purl !== '' || f.vurl !== ''
249
+ ? '<div class="certrev-r-links">' +
250
+ (f.purl !== '' ? anchor(f.purl, '="expert"', profileLabel(f)) : '') +
251
+ (f.vurl !== '' ? anchor(f.vurl, '', 'View certificate') : '') +
252
+ '</div>'
253
+ : ''
254
+ const avatar = f.hideAvatar ? '' : readerFace({ photo: f.expert.photoUrl, show: f.showPhoto, size: 'lg', width: 112 })
255
+ return (
256
+ `${sectionOpen(f, false, `Expert memo by ${f.ename}`)}\n <div class="certrev-r-card pad">\n` +
257
+ ' <div class="certrev-r-kick">Expert memo</div>\n' +
258
+ ` <div class="certrev-r-memo">${avatar}<div>\n` +
259
+ ` <div class="certrev-r-memo-name"><b>${esc(f.ename)}</b>${f.creds !== '' ? `, ${esc(f.creds)}` : ''}</div>\n` +
260
+ ` <blockquote>${esc(f.content.memo)}</blockquote>${links}</div>\n </div>\n` +
261
+ ' <div class="certrev-r-rule"></div>\n <div class="certrev-r-vfoot">\n' +
262
+ ` <span class="certrev-r-verified">${readerLogo('mono', 17, '')}Verified by <b>CertREV</b></span>\n` +
263
+ ` <span class="certrev-r-scope" data-ftc-disclosure>${scopeLead(f)}${SCOPE}</span>\n </div>\n </div>\n</section>`
264
+ )
265
+ }
266
+
267
+ // ─────────────────────────────────────────────────────────────────────────────
268
+ // Sidebar and floating pill
269
+ // ─────────────────────────────────────────────────────────────────────────────
270
+
271
+ function sidebar(f: Facts): string {
272
+ if (f.ename === '') return ''
273
+ const bio = f.disp.showBio !== false && !isBlank(f.expert.bio) ? asText(f.expert.bio) : ''
274
+ const avatar = f.hideAvatar ? '' : readerFace({ photo: f.expert.photoUrl, show: f.showPhoto, size: 'lg', width: 112 })
275
+ const both = f.purl !== '' && f.vurl !== ''
276
+ const links =
277
+ f.purl !== '' || f.vurl !== ''
278
+ ? `<div class="certrev-r-side-links${both ? '' : ' single'}">` +
279
+ (f.purl !== '' ? anchor(f.purl, '="expert"', 'View Profile') : '') +
280
+ (f.vurl !== '' ? anchor(f.vurl, '', 'View Certificate') : '') +
281
+ '</div>'
282
+ : ''
283
+ return (
284
+ `${sectionOpen(f, true, credentialAria(f))}\n <div class="certrev-r-side">\n` +
285
+ ` <div class="certrev-r-cap center">${readerLogo(f.fg === '#ffffff' ? 'white' : 'mono', 18, 'CertREV')}Expert reviewed</div>\n` +
286
+ ` <div class="certrev-r-side-body">\n <div class="certrev-r-by">${avatar}<div>\n` +
287
+ ` <b>${esc(f.ename)}${f.creds !== '' ? `, ${esc(f.creds)}` : ''}</b>${verifiedLine(f, false)}</div>\n </div>` +
288
+ `${bio !== '' ? `<p>${esc(bio)}</p>` : ''}` +
289
+ `${isBlank(f.content.memo) ? '' : `<blockquote>${esc(f.content.memo)}</blockquote>`}` +
290
+ `<div class="certrev-r-scope" data-ftc-disclosure>${scopeLead(f)}${SCOPE}</div>\n </div>` +
291
+ `${links}</div>\n</section>`
292
+ )
293
+ }
294
+
295
+ function pill(f: Facts): string {
296
+ if (f.vurl === '') return ''
297
+ const profile = f.purl !== '' ? `${anchor(f.purl, '="expert"', 'Profile')}<i aria-hidden="true">·</i>` : ''
298
+ return (
299
+ `${sectionOpen(f, true, 'Expert reviewed')}\n <div class="certrev-r-pill">\n <span>Expert reviewed:</span>` +
300
+ `${profile}${anchor(f.vurl, '', 'Certificate')}\n </div>\n</section>`
301
+ )
302
+ }
303
+
304
+ /**
305
+ * The reader face's HTML for one layout. '' where the face paints nothing (no reviewer name; a pill with no safe
306
+ * verify URL; a banner `part` that has no block): the caller treats '' as "render nothing", never as an empty section.
307
+ *
308
+ * A def with no `layout` meaning here (`custom`, or any value that is not banner, sidebar or floating) is the
309
+ * caller's to resolve before it calls: this takes the layout it will paint.
310
+ */
311
+ export function renderReaderFace(input: ReaderFaceInput): string {
312
+ const f = readFacts(input)
313
+ if (input.layout === 'sidebar') return sidebar(f)
314
+ if (input.layout === 'floating') return pill(f)
315
+ const part = input.part ?? 'full'
316
+ if (part === 'header') return bannerCard(f)
317
+ if (part === 'memo') return bannerMemo(f)
318
+ return [bannerCard(f), bannerMemo(f)].filter((block) => block !== '').join('\n')
319
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * `certrev-reader-tokens`: the four CSS custom properties a reader section carries on its root, from the render def.
3
+ *
4
+ * --cr-ba the accent the card cap and pill paint: the def's `barInk`, else its `accentColor`, else navy
5
+ * --cr-ba-fg the ink on that accent: white, or `#141414` when the def baked it for the colour that painted
6
+ * --cr-br the corner radius
7
+ * --cr-bf the font stack for the brand's `fontSlot`
8
+ *
9
+ * The values land inside a double-quoted `style` attribute, so a colour or radius that could break out of it
10
+ * (`; ( ) { } < > "` or a space) is dropped to its default rather than escaped into something half valid. The variable
11
+ * names are the reader stylesheet's (`reader.css`), which `.certrev-reader` declares defaults for.
12
+ */
13
+ import { asText } from '../components/escape.js'
14
+ import { esc, rec, textOr } from './helpers.js'
15
+
16
+ /** The accent when the def has none: CertREV navy. */
17
+ const DEFAULT_ACCENT = '#0A1B3F'
18
+ const DEFAULT_RADIUS = '14px'
19
+ /** The foreground the def bakes for a light accent; anything else is the white default. */
20
+ const DARK_INK = '#141414'
21
+
22
+ /**
23
+ * The font stack per `fontSlot`, in one table so the Liquid `certrev-reader-tokens` snippet and this one are compared
24
+ * on a single list. An unknown or missing slot is the sans stack, as in the snippet. The serif and grotesk stacks name
25
+ * the reader's own `@font-face` aliases first ("CertREV Reader Serif" / "CertREV Reader Grotesk", declared in
26
+ * `reader.css`), then the legacy family and the generic fallbacks, as the snippet's `--cr-bf` does.
27
+ */
28
+ function fontStack(slot: string): string {
29
+ switch (slot) {
30
+ case 'serif':
31
+ return "'CertREV Reader Serif', 'Playfair Display', ui-serif, Georgia, serif"
32
+ case 'grotesk':
33
+ return "'CertREV Reader Grotesk', 'Plus Jakarta Sans', ui-sans-serif, system-ui, sans-serif"
34
+ case 'mono':
35
+ return "'JetBrains Mono', ui-monospace, monospace"
36
+ case 'system':
37
+ return "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif"
38
+ default:
39
+ return "'DM Sans', ui-sans-serif, system-ui, sans-serif"
40
+ }
41
+ }
42
+
43
+ /** A token value that cannot leave the style attribute, or '' (which the caller defaults). */
44
+ function guarded(value: unknown): string {
45
+ const text = textOr(value)
46
+ return /[;(){}<>" ]/.test(text) ? '' : text
47
+ }
48
+
49
+ export interface ReaderTokens {
50
+ /** The value of the section's `style` attribute. */
51
+ readonly style: string
52
+ /** The ink on the accent: `#ffffff` or `#141414`. The logo is white on the first and mono on the second. */
53
+ readonly fg: string
54
+ }
55
+
56
+ export function readerTokens(def: unknown): ReaderTokens {
57
+ const rd = rec(def)
58
+ const tokens = rec(rd.tokens)
59
+
60
+ const barInk = guarded(tokens.barInk)
61
+ const accent = guarded(tokens.accentColor)
62
+ const ba = barInk || accent || DEFAULT_ACCENT
63
+
64
+ // The ink follows the value that painted the accent: a def with a bar ink takes the foreground baked for it
65
+ // (barInkFg); one that paints its accent colour takes the one baked for that colour (accentFg).
66
+ let fg = '#ffffff'
67
+ if (barInk !== '') {
68
+ if (rd.barInkFg === DARK_INK) fg = DARK_INK
69
+ } else if (accent !== '' && rd.accentFg === DARK_INK) {
70
+ fg = DARK_INK
71
+ }
72
+
73
+ const br = guarded(tokens.cornerRadius) || DEFAULT_RADIUS
74
+ const bf = fontStack(asText(tokens.fontSlot))
75
+ return { style: `--cr-ba:${esc(ba)};--cr-ba-fg:${fg};--cr-br:${esc(br)};--cr-bf:${bf}`, fg }
76
+ }
@@ -99,7 +99,7 @@ const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'obj
99
99
  */
100
100
  export function parseRenderDef(value: unknown): CertChromeRenderDef | null {
101
101
  if (!isRecord(value)) return null
102
- const { v, preset, rung, tokens, hidden, barInkFg, layout, customFace } = value
102
+ const { v, preset, rung, tokens, hidden, barInkFg, accentFg, face: faceName, layout, customFace } = value
103
103
  if (typeof v !== 'number' || !Number.isFinite(v)) return null
104
104
  if (!isString(preset) || !isString(rung) || !isRecord(tokens)) return null
105
105
  if (!Array.isArray(hidden) || !hidden.every(isString)) return null
@@ -121,6 +121,10 @@ export function parseRenderDef(value: unknown): CertChromeRenderDef | null {
121
121
  rung,
122
122
  tokens: tokenOut,
123
123
  ...(barInkFg === '#141414' || barInkFg === '#ffffff' ? { barInkFg } : {}),
124
+ // 1.2.0: the reader face's two keys. 1.1.x dropped both; they are kept only when well-formed, so a def that
125
+ // carries neither parses to exactly the object 1.1.1 returned for it.
126
+ ...(accentFg === '#141414' || accentFg === '#ffffff' ? { accentFg } : {}),
127
+ ...(isString(faceName) ? { face: faceName } : {}),
124
128
  hidden: [...hidden],
125
129
  ...(layout !== undefined ? { layout } : {}),
126
130
  ...(face ? { customFace: face } : {}),
@@ -1,85 +0,0 @@
1
- /**
2
- * FTC disclosure tamper guard for the NATIVE Shopify cert render (iframe→native parity).
3
- *
4
- * In the cross-origin iframe model the expert-memo embed inlined the portal's
5
- * `ftcTamperGuardScript()`: a kill switch that blanks the memo if the verbatim FTC
6
- * material-connection disclosure is stripped, hidden, or altered — making the MSA §3
7
- * anti-stripping covenant TECHNICALLY enforced, not just contractual. The native memo renders
8
- * in the host theme's DOM (no sandbox), so without this it would lose that runtime enforcement.
9
- * This module restores it: on load + on later DOM mutation it verifies the in-DOM
10
- * `[data-ftc-line]` is present, carries the EXACT verbatim text, and is actually visible; if not,
11
- * it neutralizes the memo with a neutral notice so the memo NEVER renders without its disclosure.
12
- *
13
- * Native-specific semantics (differ from the iframe, which only existed when a memo did):
14
- * - Enforcement is keyed on the MEMO element (`.certrev-memo`). A certified article with NO
15
- * memo legitimately has no `.certrev-memo` and no disclosure — the guard does NOT fire
16
- * (no false positive on the no-memo path).
17
- * - On tamper it neutralizes ONLY the memo (never `document.body` — we're in the host page).
18
- * - The contributors card is governed by the badge re-verify (`revalidate.ts`), not this guard;
19
- * the card carries only the one-word "Compensated" cue, never the guarded disclosure.
20
- * - COVERAGE, and it is narrower than it reads: `.certrev-memo` / `.certrev-memo-wrap` are portal
21
- * LIQUID markup — NOTHING in this package emits either. So the kill switch covers the Liquid
22
- * memo surface and nothing else. `<CertBadge>` and `render-badge-html.ts` emit the same
23
- * `[data-ftc-line]` hook inside a `.certrev-badge` (no memo), and `renderCertBlock` emits no
24
- * hook at all; on those surfaces a pass verifies nothing and says so (`'unguarded'`) rather
25
- * than reporting success, and no observer is installed at all. Widening enforcement to the
26
- * badge is a live design decision, not an omission — see `FtcEnforcementResult`.
27
- *
28
- * Pure DOM, no crypto, no network. The expected text is the single source of truth
29
- * `FTC_DISCLOSURE_LINE` (imported, not copied — so a reword can never desync this guard).
30
- */
31
- export declare const FTC_LINE_SELECTOR = "[data-ftc-line]";
32
- export declare const MEMO_SELECTOR = ".certrev-memo";
33
- export declare const MEMO_WRAP_SELECTOR = ".certrev-memo-wrap";
34
- /** Marker on the neutral notice node that replaces a tampered memo (testable / idempotent). */
35
- export declare const FTC_NEUTRALIZED_ATTR = "data-certrev-ftc-neutralized";
36
- /** True when a memo's disclosure line is present, exactly verbatim, and visible. */
37
- export declare function isDisclosureIntact(memo: Element): boolean;
38
- /**
39
- * Replace a tampered memo with a neutral, self-contained notice — no reviewer attribution, no
40
- * seal — so the surface can never read as an endorsement without its required disclosure.
41
- */
42
- export declare function neutralizeMemo(memo: Element): void;
43
- /**
44
- * What ONE enforcement pass actually established:
45
- * - `'intact'` — at least one memo was checked, and every one carried its verbatim, visible line.
46
- * - `'tampered'` — at least one memo failed the check and was neutralized.
47
- * - `'unguarded'` — the document holds no `.certrev-memo`, so the pass checked nothing.
48
- *
49
- * `'unguarded'` used to be reported as `true`, indistinguishable from `'intact'`, and that
50
- * conflation was a correctness bug, not a cosmetic one: `.certrev-memo` is portal Liquid markup
51
- * this package never emits, so on every React / Web Component / `renderCertBlock` surface the pass
52
- * finds nothing and answered "all intact" about a disclosure it had never looked at — the kill
53
- * switch silently reporting success on exactly the surfaces it does not protect.
54
- *
55
- * The legitimate no-memo certified article (card only, no memo, no disclosure owed) lands here too,
56
- * and the guard genuinely cannot tell the two apart from the DOM alone. That is the point of a
57
- * third state rather than a second boolean: "nothing was verified" is the honest answer to both,
58
- * and it is the caller — which knows which surface it rendered — that can tell whether that is
59
- * expected or a coverage gap.
60
- */
61
- export type FtcEnforcementResult = 'intact' | 'tampered' | 'unguarded';
62
- /**
63
- * Enforce the disclosure across every memo in the doc: neutralize any whose disclosure isn't
64
- * intact. A doc with no `.certrev-memo` is a no-op — see `FtcEnforcementResult` for why that is
65
- * `'unguarded'` and not success.
66
- */
67
- export declare function enforceFtcDisclosure(doc: Document): FtcEnforcementResult;
68
- /**
69
- * Install the guard once per document: enforce now (on DOM ready) and re-enforce on later DOM
70
- * mutations within the guarded subtree(s) — so a host script that strips / hides / edits the
71
- * disclosure after load still trips the kill switch. Idempotent.
72
- *
73
- * The observer scope is the memo wrap(s) — placement.ts relocates a wrap as a node, so the observer
74
- * tracks it wherever it moves — plus any memo sitting outside a wrap. When the document has
75
- * NEITHER, NO observer is installed at all. That bail-out is the whole storefront case, not an edge
76
- * one: this package emits neither selector (both are portal Liquid), so the old `<body>` fallback
77
- * was the only branch a React / Web Component / `renderCertBlock` page could take, and it pinned a
78
- * subtree + attributes + characterData observer — no `attributeFilter`, no coalescing — on the
79
- * host's entire page for the life of the tab, re-running an enforce pass on EVERY mutation a busy
80
- * storefront makes, to query a selector that could never match. A guard watching a page it cannot
81
- * act on is pure cost; `enforceFtcDisclosure`'s `'unguarded'` result is the correctness half of the
82
- * same gap.
83
- */
84
- export declare function installFtcGuard(doc: Document): void;
85
- //# sourceMappingURL=ftc-guard.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"ftc-guard.d.ts","sourceRoot":"","sources":["../../src/modal/ftc-guard.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAIH,eAAO,MAAM,iBAAiB,oBAAoB,CAAA;AAClD,eAAO,MAAM,aAAa,kBAAkB,CAAA;AAC5C,eAAO,MAAM,kBAAkB,uBAAuB,CAAA;AACtD,+FAA+F;AAC/F,eAAO,MAAM,oBAAoB,iCAAiC,CAAA;AAgClE,oFAAoF;AACpF,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,OAAO,GAAG,OAAO,CAKzD;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,OAAO,GAAG,IAAI,CAUlD;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,MAAM,oBAAoB,GAAG,QAAQ,GAAG,UAAU,GAAG,WAAW,CAAA;AAEtE;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,QAAQ,GAAG,oBAAoB,CAWxE;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,QAAQ,GAAG,IAAI,CA0BnD"}
@@ -1,150 +0,0 @@
1
- /**
2
- * FTC disclosure tamper guard for the NATIVE Shopify cert render (iframe→native parity).
3
- *
4
- * In the cross-origin iframe model the expert-memo embed inlined the portal's
5
- * `ftcTamperGuardScript()`: a kill switch that blanks the memo if the verbatim FTC
6
- * material-connection disclosure is stripped, hidden, or altered — making the MSA §3
7
- * anti-stripping covenant TECHNICALLY enforced, not just contractual. The native memo renders
8
- * in the host theme's DOM (no sandbox), so without this it would lose that runtime enforcement.
9
- * This module restores it: on load + on later DOM mutation it verifies the in-DOM
10
- * `[data-ftc-line]` is present, carries the EXACT verbatim text, and is actually visible; if not,
11
- * it neutralizes the memo with a neutral notice so the memo NEVER renders without its disclosure.
12
- *
13
- * Native-specific semantics (differ from the iframe, which only existed when a memo did):
14
- * - Enforcement is keyed on the MEMO element (`.certrev-memo`). A certified article with NO
15
- * memo legitimately has no `.certrev-memo` and no disclosure — the guard does NOT fire
16
- * (no false positive on the no-memo path).
17
- * - On tamper it neutralizes ONLY the memo (never `document.body` — we're in the host page).
18
- * - The contributors card is governed by the badge re-verify (`revalidate.ts`), not this guard;
19
- * the card carries only the one-word "Compensated" cue, never the guarded disclosure.
20
- * - COVERAGE, and it is narrower than it reads: `.certrev-memo` / `.certrev-memo-wrap` are portal
21
- * LIQUID markup — NOTHING in this package emits either. So the kill switch covers the Liquid
22
- * memo surface and nothing else. `<CertBadge>` and `render-badge-html.ts` emit the same
23
- * `[data-ftc-line]` hook inside a `.certrev-badge` (no memo), and `renderCertBlock` emits no
24
- * hook at all; on those surfaces a pass verifies nothing and says so (`'unguarded'`) rather
25
- * than reporting success, and no observer is installed at all. Widening enforcement to the
26
- * badge is a live design decision, not an omission — see `FtcEnforcementResult`.
27
- *
28
- * Pure DOM, no crypto, no network. The expected text is the single source of truth
29
- * `FTC_DISCLOSURE_LINE` (imported, not copied — so a reword can never desync this guard).
30
- */
31
- import { FTC_DISCLOSURE_LINE } from './ftc-disclosure.js';
32
- export const FTC_LINE_SELECTOR = '[data-ftc-line]';
33
- export const MEMO_SELECTOR = '.certrev-memo';
34
- export const MEMO_WRAP_SELECTOR = '.certrev-memo-wrap';
35
- /** Marker on the neutral notice node that replaces a tampered memo (testable / idempotent). */
36
- export const FTC_NEUTRALIZED_ATTR = 'data-certrev-ftc-neutralized';
37
- /**
38
- * Link qualification for the notice's CertREV anchor — a CertREV link on a brand's page that the
39
- * brand did not author, so it carries the same qualification as every other anchor this package
40
- * emits. Deliberately a LOCAL literal rather than an import of `CERTREV_LINK_REL`
41
- * (`../components/rel.ts`): `src/modal/` imports NOTHING from `src/components/` by design — every
42
- * shared string here (`ftc-disclosure.ts`, `logo-paths.ts`, `display-strings.ts`) is a modal-local
43
- * copy so the browser IIFE never drags the SSR renderer in. `ftc-guard.test.ts` imports the
44
- * components constant and asserts this value stays a superset of it, so the copies cannot drift.
45
- *
46
- * `noreferrer` rides ON TOP of the shared tokens (which omit it): this is the only anchor the
47
- * package opens with `target="_blank"`, and it fires from a page whose operator has just stripped
48
- * the disclosure — withholding the referrer there is worth the token.
49
- */
50
- const NOTICE_LINK_REL = 'nofollow sponsored noopener noreferrer';
51
- /** Documents already guarded, so a double script-load can't double-bind. */
52
- const guardedDocs = new WeakSet();
53
- /** Visible = not display:none / visibility:hidden / opacity:0 and a non-zero box. */
54
- function isVisible(el) {
55
- const win = el.ownerDocument.defaultView;
56
- // No view (detached / SSR): we can't measure, so don't treat as hidden — the text/presence
57
- // checks still apply; only the layout-based hide vector is skipped.
58
- if (!win)
59
- return true;
60
- const s = win.getComputedStyle(el);
61
- if (s.display === 'none' || s.visibility === 'hidden' || Number.parseFloat(s.opacity || '1') === 0)
62
- return false;
63
- const r = el.getBoundingClientRect();
64
- return r.width > 0 && r.height > 0;
65
- }
66
- /** True when a memo's disclosure line is present, exactly verbatim, and visible. */
67
- export function isDisclosureIntact(memo) {
68
- const line = memo.querySelector(FTC_LINE_SELECTOR);
69
- if (!line)
70
- return false;
71
- if ((line.textContent || '').trim() !== FTC_DISCLOSURE_LINE)
72
- return false;
73
- return isVisible(line);
74
- }
75
- /**
76
- * Replace a tampered memo with a neutral, self-contained notice — no reviewer attribution, no
77
- * seal — so the surface can never read as an endorsement without its required disclosure.
78
- */
79
- export function neutralizeMemo(memo) {
80
- const doc = memo.ownerDocument;
81
- const notice = doc.createElement('div');
82
- notice.setAttribute(FTC_NEUTRALIZED_ATTR, '');
83
- notice.style.cssText =
84
- 'font-family:system-ui,-apple-system,sans-serif;font-size:12px;color:#555;padding:12px;line-height:1.4';
85
- notice.innerHTML =
86
- 'This CertREV verification cannot be displayed because its required disclosure was not shown. ' +
87
- `<a href="https://certrev.com" style="color:#555" target="_blank" rel="${NOTICE_LINK_REL}">Learn more</a>.`;
88
- memo.replaceWith(notice);
89
- }
90
- /**
91
- * Enforce the disclosure across every memo in the doc: neutralize any whose disclosure isn't
92
- * intact. A doc with no `.certrev-memo` is a no-op — see `FtcEnforcementResult` for why that is
93
- * `'unguarded'` and not success.
94
- */
95
- export function enforceFtcDisclosure(doc) {
96
- const memos = Array.from(doc.querySelectorAll(MEMO_SELECTOR));
97
- if (!memos.length)
98
- return 'unguarded';
99
- let result = 'intact';
100
- for (const memo of memos) {
101
- if (!isDisclosureIntact(memo)) {
102
- result = 'tampered';
103
- neutralizeMemo(memo);
104
- }
105
- }
106
- return result;
107
- }
108
- /**
109
- * Install the guard once per document: enforce now (on DOM ready) and re-enforce on later DOM
110
- * mutations within the guarded subtree(s) — so a host script that strips / hides / edits the
111
- * disclosure after load still trips the kill switch. Idempotent.
112
- *
113
- * The observer scope is the memo wrap(s) — placement.ts relocates a wrap as a node, so the observer
114
- * tracks it wherever it moves — plus any memo sitting outside a wrap. When the document has
115
- * NEITHER, NO observer is installed at all. That bail-out is the whole storefront case, not an edge
116
- * one: this package emits neither selector (both are portal Liquid), so the old `<body>` fallback
117
- * was the only branch a React / Web Component / `renderCertBlock` page could take, and it pinned a
118
- * subtree + attributes + characterData observer — no `attributeFilter`, no coalescing — on the
119
- * host's entire page for the life of the tab, re-running an enforce pass on EVERY mutation a busy
120
- * storefront makes, to query a selector that could never match. A guard watching a page it cannot
121
- * act on is pure cost; `enforceFtcDisclosure`'s `'unguarded'` result is the correctness half of the
122
- * same gap.
123
- */
124
- export function installFtcGuard(doc) {
125
- if (guardedDocs.has(doc))
126
- return;
127
- guardedDocs.add(doc);
128
- const run = () => {
129
- enforceFtcDisclosure(doc);
130
- };
131
- const start = () => {
132
- run();
133
- const MO = doc.defaultView?.MutationObserver;
134
- if (!MO)
135
- return;
136
- // The guarded roots: the stable wrap(s), plus any memo not inside one (in the Liquid shape a
137
- // wrap always contains its memo, so this is normally just the wraps). No root ⇒ no observer.
138
- const wraps = Array.from(doc.querySelectorAll(MEMO_WRAP_SELECTOR));
139
- const looseMemos = Array.from(doc.querySelectorAll(MEMO_SELECTOR)).filter((memo) => !memo.closest(MEMO_WRAP_SELECTOR));
140
- for (const node of [...wraps, ...looseMemos]) {
141
- const mo = new MO(run);
142
- mo.observe(node, { childList: true, subtree: true, characterData: true, attributes: true });
143
- }
144
- };
145
- if (doc.readyState === 'loading')
146
- doc.addEventListener('DOMContentLoaded', start, { once: true });
147
- else
148
- start();
149
- }
150
- //# sourceMappingURL=ftc-guard.js.map