@certrev/cert-block 0.5.4 → 1.0.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 (189) hide show
  1. package/CHANGELOG.md +195 -12
  2. package/LICENSE +21 -0
  3. package/README.md +422 -50
  4. package/dist/builder/cert-chrome-data.d.ts +26 -8
  5. package/dist/builder/cert-chrome-data.d.ts.map +1 -1
  6. package/dist/builder/cert-chrome-data.js +5 -5
  7. package/dist/builder/cert-chrome-data.js.map +1 -1
  8. package/dist/builder/cert-review-card.d.ts +35 -5
  9. package/dist/builder/cert-review-card.d.ts.map +1 -1
  10. package/dist/builder/cert-review-card.js +115 -34
  11. package/dist/builder/cert-review-card.js.map +1 -1
  12. package/dist/builder/index.d.ts +33 -16
  13. package/dist/builder/index.d.ts.map +1 -1
  14. package/dist/builder/index.js +33 -16
  15. package/dist/builder/index.js.map +1 -1
  16. package/dist/components/CertBadge.d.ts +2 -2
  17. package/dist/components/CertBadge.d.ts.map +1 -1
  18. package/dist/components/CertBadge.js +11 -5
  19. package/dist/components/CertBadge.js.map +1 -1
  20. package/dist/components/CertJsonLd.d.ts.map +1 -1
  21. package/dist/components/CertJsonLd.js.map +1 -1
  22. package/dist/components/CertRevBacklink.d.ts.map +1 -1
  23. package/dist/components/CertRevBacklink.js +3 -2
  24. package/dist/components/CertRevBacklink.js.map +1 -1
  25. package/dist/components/CertReview.d.ts +1 -1
  26. package/dist/components/ExpertBio.d.ts.map +1 -1
  27. package/dist/components/ExpertBio.js +12 -4
  28. package/dist/components/ExpertBio.js.map +1 -1
  29. package/dist/components/escape.d.ts +28 -1
  30. package/dist/components/escape.d.ts.map +1 -1
  31. package/dist/components/escape.js +37 -8
  32. package/dist/components/escape.js.map +1 -1
  33. package/dist/components/format.d.ts +51 -2
  34. package/dist/components/format.d.ts.map +1 -1
  35. package/dist/components/format.js +103 -6
  36. package/dist/components/format.js.map +1 -1
  37. package/dist/components/preview-block.d.ts +1 -1
  38. package/dist/components/preview-block.js +3 -3
  39. package/dist/components/preview-block.js.map +1 -1
  40. package/dist/components/rel.d.ts +24 -0
  41. package/dist/components/rel.d.ts.map +1 -0
  42. package/dist/components/rel.js +24 -0
  43. package/dist/components/rel.js.map +1 -0
  44. package/dist/components/render-cert-block.d.ts +50 -22
  45. package/dist/components/render-cert-block.d.ts.map +1 -1
  46. package/dist/components/render-cert-block.js +146 -80
  47. package/dist/components/render-cert-block.js.map +1 -1
  48. package/dist/components/render-def.d.ts +13 -14
  49. package/dist/components/render-def.d.ts.map +1 -1
  50. package/dist/components/render-def.js +14 -15
  51. package/dist/components/render-def.js.map +1 -1
  52. package/dist/index.d.ts +1 -0
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +4 -2
  55. package/dist/index.js.map +1 -1
  56. package/dist/jsonld/project.d.ts +46 -13
  57. package/dist/jsonld/project.d.ts.map +1 -1
  58. package/dist/jsonld/project.js +139 -56
  59. package/dist/jsonld/project.js.map +1 -1
  60. package/dist/modal/avatar-resize.d.ts +10 -10
  61. package/dist/modal/avatar-resize.js +14 -14
  62. package/dist/modal/avatar-resize.js.map +1 -1
  63. package/dist/modal/cert-modal-view.d.ts +27 -7
  64. package/dist/modal/cert-modal-view.d.ts.map +1 -1
  65. package/dist/modal/cert-modal-view.js +49 -25
  66. package/dist/modal/cert-modal-view.js.map +1 -1
  67. package/dist/modal/certrev-cert-modal.d.ts +5 -5
  68. package/dist/modal/certrev-cert-modal.js +9 -9
  69. package/dist/modal/certrev-cert-modal.js.map +1 -1
  70. package/dist/modal/certrev-cert-modal.styles.d.ts +6 -6
  71. package/dist/modal/certrev-cert-modal.styles.d.ts.map +1 -1
  72. package/dist/modal/certrev-cert-modal.styles.js +8 -8
  73. package/dist/modal/certrev-cert-modal.styles.js.map +1 -1
  74. package/dist/modal/certrev-cert.entry.d.ts +8 -6
  75. package/dist/modal/certrev-cert.entry.d.ts.map +1 -1
  76. package/dist/modal/certrev-cert.entry.js +9 -7
  77. package/dist/modal/certrev-cert.entry.js.map +1 -1
  78. package/dist/modal/certrev-cert.js +6 -6
  79. package/dist/modal/display-strings.d.ts +25 -5
  80. package/dist/modal/display-strings.d.ts.map +1 -1
  81. package/dist/modal/display-strings.js +27 -5
  82. package/dist/modal/display-strings.js.map +1 -1
  83. package/dist/modal/escape.d.ts +26 -3
  84. package/dist/modal/escape.d.ts.map +1 -1
  85. package/dist/modal/escape.js +32 -3
  86. package/dist/modal/escape.js.map +1 -1
  87. package/dist/modal/font-face.d.ts.map +1 -1
  88. package/dist/modal/font-face.js +8 -11
  89. package/dist/modal/font-face.js.map +1 -1
  90. package/dist/modal/ftc-disclosure.d.ts +2 -2
  91. package/dist/modal/ftc-disclosure.js +2 -2
  92. package/dist/modal/ftc-guard.d.ts +44 -9
  93. package/dist/modal/ftc-guard.d.ts.map +1 -1
  94. package/dist/modal/ftc-guard.js +50 -17
  95. package/dist/modal/ftc-guard.js.map +1 -1
  96. package/dist/modal/index.d.ts +1 -1
  97. package/dist/modal/index.js +1 -1
  98. package/dist/modal/interactions.d.ts +2 -2
  99. package/dist/modal/interactions.js +2 -2
  100. package/dist/modal/logo-paths.js +2 -2
  101. package/dist/modal/logo-paths.js.map +1 -1
  102. package/dist/modal/placement.d.ts +4 -3
  103. package/dist/modal/placement.d.ts.map +1 -1
  104. package/dist/modal/placement.js +7 -6
  105. package/dist/modal/placement.js.map +1 -1
  106. package/dist/modal/revalidate.d.ts +2 -3
  107. package/dist/modal/revalidate.d.ts.map +1 -1
  108. package/dist/modal/revalidate.js +2 -3
  109. package/dist/modal/revalidate.js.map +1 -1
  110. package/dist/modal/verify-client.d.ts +2 -2
  111. package/dist/modal/verify-client.js +2 -2
  112. package/dist/verify/cache.d.ts +21 -6
  113. package/dist/verify/cache.d.ts.map +1 -1
  114. package/dist/verify/cache.js +29 -8
  115. package/dist/verify/cache.js.map +1 -1
  116. package/dist/verify/fetch-with-deadline.d.ts +35 -0
  117. package/dist/verify/fetch-with-deadline.d.ts.map +1 -0
  118. package/dist/verify/fetch-with-deadline.js +55 -0
  119. package/dist/verify/fetch-with-deadline.js.map +1 -0
  120. package/dist/verify/get-verified-envelope.d.ts +23 -9
  121. package/dist/verify/get-verified-envelope.d.ts.map +1 -1
  122. package/dist/verify/get-verified-envelope.js +89 -40
  123. package/dist/verify/get-verified-envelope.js.map +1 -1
  124. package/dist/verify/resolve-kid.d.ts +3 -0
  125. package/dist/verify/resolve-kid.d.ts.map +1 -1
  126. package/dist/verify/resolve-kid.js +17 -8
  127. package/dist/verify/resolve-kid.js.map +1 -1
  128. package/dist/webcomponent/certrev-badge.d.ts.map +1 -1
  129. package/dist/webcomponent/certrev-badge.js +24 -2
  130. package/dist/webcomponent/certrev-badge.js.map +1 -1
  131. package/dist/webcomponent/render-badge-html.d.ts +1 -1
  132. package/dist/webcomponent/render-badge-html.d.ts.map +1 -1
  133. package/dist/webcomponent/render-badge-html.js +36 -17
  134. package/dist/webcomponent/render-badge-html.js.map +1 -1
  135. package/package.json +14 -2
  136. package/src/builder/cert-chrome-data.ts +26 -8
  137. package/src/builder/cert-review-card.tsx +147 -38
  138. package/src/builder/index.tsx +34 -17
  139. package/src/components/CertBadge.tsx +14 -9
  140. package/src/components/CertJsonLd.tsx +1 -1
  141. package/src/components/CertRevBacklink.tsx +10 -3
  142. package/src/components/CertReview.tsx +1 -1
  143. package/src/components/ExpertBio.tsx +12 -7
  144. package/src/components/escape.ts +37 -7
  145. package/src/components/format.ts +102 -6
  146. package/src/components/preview-block.ts +3 -3
  147. package/src/components/rel.ts +23 -0
  148. package/src/components/render-cert-block.ts +192 -109
  149. package/src/components/render-def.ts +14 -15
  150. package/src/index.ts +4 -2
  151. package/src/jsonld/project.ts +155 -59
  152. package/src/modal/avatar-resize.ts +14 -14
  153. package/src/modal/cert-modal-view.ts +52 -27
  154. package/src/modal/certrev-cert-modal.styles.ts +8 -8
  155. package/src/modal/certrev-cert-modal.ts +9 -9
  156. package/src/modal/certrev-cert.entry.ts +9 -7
  157. package/src/modal/display-strings.ts +26 -5
  158. package/src/modal/escape.ts +32 -4
  159. package/src/modal/font-face.ts +8 -11
  160. package/src/modal/ftc-disclosure.ts +2 -2
  161. package/src/modal/ftc-guard.ts +73 -18
  162. package/src/modal/index.ts +1 -1
  163. package/src/modal/interactions.ts +2 -2
  164. package/src/modal/logo-paths.ts +2 -2
  165. package/src/modal/placement.ts +7 -6
  166. package/src/modal/revalidate.ts +2 -3
  167. package/src/modal/verify-client.ts +2 -2
  168. package/src/verify/cache.ts +34 -8
  169. package/src/verify/fetch-with-deadline.ts +60 -0
  170. package/src/verify/get-verified-envelope.ts +131 -48
  171. package/src/verify/resolve-kid.ts +22 -6
  172. package/src/webcomponent/certrev-badge.ts +24 -2
  173. package/src/webcomponent/render-badge-html.ts +66 -19
  174. package/src/__tests__/__snapshots__/render-cert-block.test.ts.snap +0 -17
  175. package/src/__tests__/components.test.tsx +0 -206
  176. package/src/__tests__/preview-block.test.ts +0 -84
  177. package/src/__tests__/project.test.ts +0 -128
  178. package/src/__tests__/render-cert-block.test.ts +0 -657
  179. package/src/__tests__/render-def.test.tsx +0 -295
  180. package/src/__tests__/verify.test.ts +0 -239
  181. package/src/__tests__/webcomponent.test.tsx +0 -116
  182. package/src/builder/__tests__/builder.test.tsx +0 -158
  183. package/src/modal/cert-modal-view.test.ts +0 -175
  184. package/src/modal/certrev-cert-modal.test.ts +0 -125
  185. package/src/modal/ftc-guard.test.ts +0 -153
  186. package/src/modal/interactions.test.ts +0 -250
  187. package/src/modal/placement.test.ts +0 -398
  188. package/src/modal/revalidate.test.ts +0 -111
  189. package/src/modal/verify-client.test.ts +0 -164
@@ -9,21 +9,33 @@
9
9
  * server pre-render) produce the same structured data, and a snapshot diff is meaningful.
10
10
  *
11
11
  * DESIGN GOAL: MERGE, don't collide.
12
- * A brand page usually already emits an `Article` (or `BlogPosting`) graph — Yoast on
13
- * WordPress, the theme on Shopify, the framework on headless. If we emit a SECOND
14
- * top-level `Article`, crawlers see two competing primary entities. Instead we emit a
15
- * `@graph` whose Article node carries the SAME `@id` the page's primary article uses
16
- * (the page URL + `#article`, the Yoast convention) so a consumer MERGES our
17
- * `reviewedBy` / `dateModified` into the existing node by `@id` rather than duplicating
18
- * it. Our own nodes (the review, the expert Person, the CertREV org) use CertREV-scoped
19
- * `@id`s that cannot clash with the host page's nodes.
12
+ * A brand page usually already emits a `WebPage` + `Article` (or `BlogPosting`) graph —
13
+ * Yoast on WordPress, the theme on Shopify, the framework on headless. If we emit a
14
+ * SECOND top-level `Article`, crawlers see two competing primary entities. Instead we
15
+ * emit a `@graph` whose WebPage / Article nodes carry the SAME `@id`s the page's own
16
+ * nodes use (the unmodified canonical URL for the page, the page URL + `#article` for
17
+ * the article — the Yoast convention) so a consumer MERGES our `reviewedBy` /
18
+ * `lastReviewed` / `dateModified` into the existing nodes by `@id` rather than
19
+ * duplicating them. Our own nodes are keyed to the CERTIFICATION instead (the verify
20
+ * URL, the expert's profile, the fixed CertREV organization id) — never under the host
21
+ * page's `@id` namespace — so they cannot clash with the host page's nodes.
20
22
  *
21
23
  * The Person we attach is the reviewing EXPERT (schema.org `reviewedBy`), which is the
22
24
  * E-E-A-T signal CertREV exists to add — not the author. The author (content byline) is
23
25
  * attached as `author` only when present + distinct.
26
+ *
27
+ * VISIBILITY: "Don't mark up content that is not visible to readers" (Google's
28
+ * structured-data guidelines) is a HARD rule here, not a nicety. Every field on this
29
+ * graph that mirrors something the card paints behind a visibility flag — the memo, the
30
+ * author byline, the reviewer photo — is gated by the SAME `resolveDisplay` the card
31
+ * uses (which folds in the render-def's subtractive hide-set), so the graph can never
32
+ * assert to a crawler what the reader cannot see. Re-deriving those flags here instead
33
+ * of importing the card's resolver is exactly how the two would silently drift.
24
34
  */
25
35
 
26
- import type { CertContent, CertPayload } from '../contract/kernel.js'
36
+ import { type ResolvedDisplay, resolveDisplay } from '../components/format.js'
37
+ import type { CertRenderDef } from '../components/render-def.js'
38
+ import type { CertPayload } from '../contract/kernel.js'
27
39
 
28
40
  /** A JSON-LD node/graph value. Loose by design — this is wire JSON, not a TS model. */
29
41
  export type JsonLdValue = Record<string, unknown>
@@ -31,12 +43,19 @@ export type JsonLdValue = Record<string, unknown>
31
43
  export interface ProjectJsonLdOptions {
32
44
  /**
33
45
  * The canonical page URL the article renders on. Used to derive the primary
34
- * Article node `@id` (`${pageUrl}#article`) so we merge into the host page's graph
35
- * instead of colliding. When omitted, the first `subject.canonicalUrls` entry is
36
- * used; when there is none either, a CertREV-scoped article `@id` is derived from
37
- * `verifyUrl` (still mergeable, just not aligned to a host node).
46
+ * WebPage / Article node `@id`s (`${pageUrl}` and `${pageUrl}#article`) so we merge
47
+ * into the host page's graph instead of colliding. When omitted, the first
48
+ * `subject.canonicalUrls` entry is used; when there is none either, the WebPage node
49
+ * is DROPPED and a CertREV-scoped article `@id` is derived from `verifyUrl` (still
50
+ * mergeable, just not aligned to a host node).
38
51
  */
39
52
  readonly pageUrl?: string
53
+ /**
54
+ * The brand render-def in force for THIS face. Read for its SUBTRACTIVE hide-set only
55
+ * (its theming half means nothing to structured data): a brand that hides the memo or
56
+ * the reviewer photo on the card must not have it published in the graph either.
57
+ */
58
+ readonly renderDef?: CertRenderDef
40
59
  /**
41
60
  * When true (default), emit the wrapping `{ "@context": "...", "@graph": [...] }`.
42
61
  * When false, return just the array of nodes — for a caller that merges them into an
@@ -47,6 +66,22 @@ export interface ProjectJsonLdOptions {
47
66
 
48
67
  const SCHEMA_CONTEXT = 'https://schema.org'
49
68
 
69
+ /**
70
+ * THE CertREV organization node `@id` — a hard CONSTANT, not a per-payload derivation.
71
+ *
72
+ * It used to be `${new URL(verifyUrl).origin}/#certrev-organization` under a comment
73
+ * claiming "one stable CertREV organization node across all pages", which it was not: a
74
+ * cert served from a second verify origin (a staging/preview host, a vanity verify
75
+ * domain, a future regional origin) minted a SECOND CertREV org node that would never
76
+ * merge with the first. CertREV is ONE real-world organization; its node id must not
77
+ * move because of which host happened to serve a verify link. Portal must emit this
78
+ * exact literal for the two halves to merge — see the package README.
79
+ */
80
+ export const CERTREV_ORGANIZATION_ID = 'https://certrev.com/#certrev-organization'
81
+
82
+ /** CertREV's canonical homepage — the `url` of the organization node above, same reasoning. */
83
+ export const CERTREV_ORGANIZATION_URL = 'https://certrev.com'
84
+
50
85
  /**
51
86
  * Strip the query AND fragment from a URL for a stable, mergeable @id. The Yoast / host
52
87
  * convention keys the primary Article node on `{path}#article` with no query — so a page
@@ -58,48 +93,73 @@ function baseUrl(url: string): string {
58
93
  return url.slice(0, cut)
59
94
  }
60
95
 
96
+ /**
97
+ * The page this face renders on — the caller's `pageUrl` first (it knows the actual
98
+ * request), else the payload's primary canonical. Null when neither is known, which is
99
+ * the ONLY honest answer: we then assert nothing about any page.
100
+ */
101
+ function pageBaseUrl(payload: CertPayload, opts: ProjectJsonLdOptions): string | null {
102
+ const page = opts.pageUrl ?? payload.subject.canonicalUrls[0]
103
+ return page ? baseUrl(page) : null
104
+ }
105
+
61
106
  /** Derive the primary Article `@id`, aligned to the Yoast/host convention when we can. */
62
107
  function articleId(payload: CertPayload, opts: ProjectJsonLdOptions): string {
63
- const page = opts.pageUrl ?? payload.subject.canonicalUrls[0]
64
- if (page) return `${baseUrl(page)}#article`
108
+ const page = pageBaseUrl(payload, opts)
109
+ if (page) return `${page}#article`
65
110
  // No page URL known — derive a stable, mergeable id from the verify URL.
66
111
  return `${baseUrl(payload.content.verifyUrl)}#article`
67
112
  }
68
113
 
69
- /** CertREV-scoped node ids — namespaced under the verify URL so they never clash. */
70
- function reviewId(content: CertContent): string {
71
- return `${content.verifyUrl}#certrev-review`
114
+ /** CertREV-scoped node ids — namespaced under the certification, so they never clash. */
115
+ function reviewId(payload: CertPayload): string {
116
+ return `${payload.content.verifyUrl}#certrev-review`
72
117
  }
73
- function expertId(content: CertContent): string {
74
- return content.expert.profileUrl ? `${content.expert.profileUrl}#person` : `${content.verifyUrl}#expert`
118
+ function expertId(payload: CertPayload): string {
119
+ const { expert, verifyUrl } = payload.content
120
+ return expert.profileUrl ? `${expert.profileUrl}#person` : `${verifyUrl}#expert`
75
121
  }
76
- function orgId(content: CertContent): string {
77
- // One stable CertREV organization node across all pages: derive from the verify
78
- // URL origin so it dedupes cleanly when a page hosts multiple certified articles.
79
- try {
80
- return `${new URL(content.verifyUrl).origin}/#certrev-organization`
81
- } catch {
82
- return 'https://certrev.com/#certrev-organization'
83
- }
122
+
123
+ /**
124
+ * The `YYYY-MM-DD` head of an ISO-8601 instant, or null. `lastReviewed`'s schema.org
125
+ * range is `Date` — not `DateTime` — so the certified instant is truncated to its
126
+ * calendar day. String-sliced rather than `Date`-parsed on purpose: parsing would
127
+ * re-render the day in the runtime's timezone and break the determinism invariant
128
+ * (server and edge could disagree by a day).
129
+ */
130
+ function isoDate(value: unknown): string | null {
131
+ if (typeof value !== 'string') return null
132
+ const match = /^\d{4}-\d{2}-\d{2}/.exec(value)
133
+ return match ? match[0] : null
84
134
  }
85
135
 
86
136
  /** Build the reviewing-expert Person node (the E-E-A-T signal). */
87
- function expertPersonNode(payload: CertPayload): JsonLdValue {
137
+ function expertPersonNode(payload: CertPayload, display: ResolvedDisplay): JsonLdValue {
88
138
  const { expert } = payload.content
89
139
  const node: JsonLdValue = {
90
140
  '@type': 'Person',
91
- '@id': expertId(payload.content),
141
+ '@id': expertId(payload),
92
142
  name: expert.displayName,
93
143
  }
94
144
  if (expert.profileUrl) node.url = expert.profileUrl
95
- if (expert.photoUrl) node.image = expert.photoUrl
145
+ // The photo is a brand-toggleable (`free`) field: a face that hides the reviewer photo
146
+ // must not publish one either. `showExpertPhoto` already folds in the render-def's
147
+ // `reviewerPhoto` hide-set. Deliberately NOT gated on `badgeStyle` — a compact card
148
+ // omits the photo as a LAYOUT choice, and the same payload may render full elsewhere on
149
+ // the page, so keying the graph to it would make the projection slot-dependent.
150
+ if (display.showExpertPhoto && expert.photoUrl) node.image = expert.photoUrl
96
151
  if (expert.credentials.length > 0) {
97
152
  // hasCredential → EducationalOccupationalCredential per credential; also a flat
98
- // honorificSuffix list for consumers that don't read hasCredential.
153
+ // honorificSuffix list for consumers that don't read hasCredential. `recognizedBy`
154
+ // (a Credential property whose expected type is Organization) names CertREV as the
155
+ // body that verified it — the structured-data form of the face's locked
156
+ // "Credential verified by CertREV" attribution, and what the reviewedby-schema
157
+ // validator looks for.
99
158
  node.hasCredential = expert.credentials.map((c) => ({
100
159
  '@type': 'EducationalOccupationalCredential',
101
160
  name: c.fullName,
102
161
  alternateName: c.abbreviation,
162
+ recognizedBy: { '@id': CERTREV_ORGANIZATION_ID },
103
163
  }))
104
164
  node.honorificSuffix = expert.credentials.map((c) => c.abbreviation).join(', ')
105
165
  }
@@ -107,54 +167,83 @@ function expertPersonNode(payload: CertPayload): JsonLdValue {
107
167
  }
108
168
 
109
169
  /** Build the CertREV organization node (publisher of the review credential). */
110
- function certRevOrgNode(payload: CertPayload): JsonLdValue {
111
- let url = 'https://certrev.com'
112
- try {
113
- url = new URL(payload.content.verifyUrl).origin
114
- } catch {
115
- /* keep default */
116
- }
170
+ function certRevOrgNode(): JsonLdValue {
117
171
  return {
118
172
  '@type': 'Organization',
119
- '@id': orgId(payload.content),
173
+ '@id': CERTREV_ORGANIZATION_ID,
120
174
  name: 'CertREV',
121
- url,
175
+ url: CERTREV_ORGANIZATION_URL,
122
176
  }
123
177
  }
124
178
 
125
179
  /** Build the Review node binding the article to its expert review. */
126
- function reviewNode(payload: CertPayload): JsonLdValue {
180
+ function reviewNode(payload: CertPayload, opts: ProjectJsonLdOptions, display: ResolvedDisplay): JsonLdValue {
127
181
  const node: JsonLdValue = {
128
182
  '@type': 'Review',
129
- '@id': reviewId(payload.content),
130
- itemReviewed: { '@id': articleId(payload, {}) },
131
- author: { '@id': expertId(payload.content) },
132
- publisher: { '@id': orgId(payload.content) },
183
+ '@id': reviewId(payload),
184
+ // The SAME opts the Article node is built from. Passing `{}` here (as this did)
185
+ // pointed `itemReviewed` at the payload's canonical while the Article node carried
186
+ // the caller's `pageUrl` — an orphaned reference on every page whose URL differs
187
+ // from `canonicalUrls[0]` (locale prefix, `www`, trailing slash, a syndicated copy).
188
+ itemReviewed: { '@id': articleId(payload, opts) },
189
+ author: { '@id': expertId(payload) },
190
+ publisher: { '@id': CERTREV_ORGANIZATION_ID },
133
191
  datePublished: payload.content.certifiedAt,
134
192
  url: payload.content.verifyUrl,
135
193
  }
136
- if (payload.content.memo) node.reviewBody = payload.content.memo
194
+ // The memo is the one piece of REVIEW PROSE on the face, and it is brand-toggleable.
195
+ // Publishing `reviewBody` off a hidden memo marks up text no reader can see.
196
+ if (display.showMemo && payload.content.memo) node.reviewBody = payload.content.memo
137
197
  return node
138
198
  }
139
199
 
200
+ /**
201
+ * The host page node — where `lastReviewed` and `reviewedBy` actually belong: BOTH are
202
+ * schema.org `WebPage` properties (`lastReviewed`: "Date on which the content on this web
203
+ * page was last reviewed for accuracy and/or completeness", range `Date`; `reviewedBy`:
204
+ * "People or organizations that have reviewed the content on this web page", range
205
+ * `Organization`/`Person`). Keyed on the UNMODIFIED canonical URL, the Yoast WebPage
206
+ * convention, so this merges into the host's existing WebPage node instead of adding a
207
+ * competing one — and, like the Article node, it carries ONLY the certification-relevant
208
+ * properties so a merge never overwrites the host's richer page fields.
209
+ *
210
+ * Returns null when no page URL is known: `lastReviewed` on a page we cannot name would
211
+ * either be a dangling node or — worse — an assertion about the CertREV verify page,
212
+ * which is not the reviewed content.
213
+ */
214
+ function webPageNode(payload: CertPayload, opts: ProjectJsonLdOptions): JsonLdValue | null {
215
+ const id = pageBaseUrl(payload, opts)
216
+ if (!id) return null
217
+ const lastReviewed = isoDate(payload.content.certifiedAt)
218
+ return {
219
+ '@type': 'WebPage',
220
+ '@id': id,
221
+ url: id,
222
+ ...(lastReviewed ? { lastReviewed } : {}),
223
+ reviewedBy: { '@id': expertId(payload) },
224
+ }
225
+ }
226
+
140
227
  /**
141
228
  * The primary Article node — carries the SAME `@id` the host page's article uses so a
142
229
  * consumer merges our `reviewedBy` / `dateModified` into the existing node rather than
143
230
  * duplicating the primary entity. We deliberately emit ONLY the certification-relevant
144
- * properties (reviewedBy, dateModified, plus author when distinct) — not headline, body,
145
- * image, etc. — so we never overwrite the host's richer Article fields on merge.
231
+ * properties (reviewedBy, dateModified, plus author when distinct AND shown) — not
232
+ * headline, body, image, etc. — so we never overwrite the host's richer Article fields
233
+ * on merge.
146
234
  */
147
- function articleNode(payload: CertPayload, opts: ProjectJsonLdOptions): JsonLdValue {
235
+ function articleNode(payload: CertPayload, opts: ProjectJsonLdOptions, display: ResolvedDisplay): JsonLdValue {
148
236
  const { content } = payload
149
237
  const node: JsonLdValue = {
150
238
  '@type': 'Article',
151
239
  '@id': articleId(payload, opts),
152
- reviewedBy: { '@id': expertId(content) },
153
- review: { '@id': reviewId(content) },
240
+ reviewedBy: { '@id': expertId(payload) },
241
+ review: { '@id': reviewId(payload) },
154
242
  }
155
243
  if (content.contentModifiedAt) node.dateModified = content.contentModifiedAt
156
- // Author only when present + distinct from the expert (don't double-attribute).
157
- if (content.author.name && content.author.name !== content.expert.displayName) {
244
+ // Author only when the face SHOWS the byline (a `free`, brand-toggleable field) and it
245
+ // is present + distinct from the expert (don't double-attribute).
246
+ if (display.showAuthor && content.author.name && content.author.name !== content.expert.displayName) {
158
247
  const author: JsonLdValue = { '@type': 'Person', name: content.author.name }
159
248
  if (content.author.title) author.jobTitle = content.author.title
160
249
  node.author = author
@@ -166,16 +255,23 @@ function articleNode(payload: CertPayload, opts: ProjectJsonLdOptions): JsonLdVa
166
255
  * Project the verified certification facts into a schema.org `@graph`.
167
256
  *
168
257
  * @param payload The cryptographically-verified payload (the same facts the badge renders).
169
- * @param opts Page URL for @id alignment + graph-wrapping control.
258
+ * @param opts Page URL for @id alignment, the render-def whose hide-set gates
259
+ * visibility-bound fields, + graph-wrapping control.
170
260
  * @returns A `{ "@context", "@graph" }` object (wrapGraph !== false) OR a bare
171
261
  * array of nodes (wrapGraph === false) for merge-into-existing-graph callers.
172
262
  */
173
263
  export function projectCertJsonLd(payload: CertPayload, opts: ProjectJsonLdOptions = {}): JsonLdValue | JsonLdValue[] {
264
+ // ONE resolution of the visibility flags, shared by every node — the same resolver
265
+ // (and therefore the same answer) the rendered card uses. No accent override: theming
266
+ // is meaningless to structured data.
267
+ const display = resolveDisplay(payload.content.display, undefined, opts.renderDef)
268
+ const page = webPageNode(payload, opts)
174
269
  const nodes: JsonLdValue[] = [
175
- articleNode(payload, opts),
176
- reviewNode(payload),
177
- expertPersonNode(payload),
178
- certRevOrgNode(payload),
270
+ ...(page ? [page] : []),
271
+ articleNode(payload, opts, display),
272
+ reviewNode(payload, opts, display),
273
+ expertPersonNode(payload, display),
274
+ certRevOrgNode(),
179
275
  ]
180
276
  if (opts.wrapGraph === false) return nodes
181
277
  return { '@context': SCHEMA_CONTEXT, '@graph': nodes }
@@ -1,15 +1,15 @@
1
- // ⚠ VENDORED, BYTE-FAITHFUL relocation of portal `src/lib/cert-delivery/avatar-resize.ts`
2
- // (POR-10721 W2). The body is byte-identical to the source the deployed `certrev-cert.js`
3
- // was bundled from — keep it that way. Configurable asset origins for other headless
4
- // brands are a deliberate post-relocation follow-up (byte-parity takes precedence now).
1
+ // ⚠ VENDORED, BYTE-FAITHFUL relocation of the portal's `avatar-resize` module. The body is
2
+ // byte-identical to the source the deployed `certrev-cert.js` was bundled from — keep it that
3
+ // way. Configurable asset origins for other headless brands are a deliberate post-relocation
4
+ // follow-up (byte-parity takes precedence now).
5
5
  /**
6
- * Avatar right-sizing (POR-10470) — the SINGLE source of truth for how a cert contributor/expert
6
+ * Avatar right-sizing — the SINGLE source of truth for how a cert contributor/expert
7
7
  * headshot URL is rewritten to a display-sized derivative. Referenced by the cert MODAL (TS,
8
8
  * cert-modal-view.ts), mirrored by the Liquid snippet (certrev-avatar-url.liquid), and by the
9
- * Cloudflare size-allowlist WAF rule (infra/terraform/cloudflare). Keep the three in lockstep — the
9
+ * Cloudflare size-allowlist WAF rule. Keep the three in lockstep — the
10
10
  * `avatar-resize.test.ts` `SIZES`/param assertions are the tripwire.
11
11
  *
12
- * Architecture (Fable-reviewed): the signed cert envelope carries ONE canonical full-res photoUrl
12
+ * Architecture: the signed cert envelope carries ONE canonical full-res photoUrl
13
13
  * (also the JSON-LD Person.image — never shrunk). Display sizing is a PRESENTATION concern, applied
14
14
  * here via Cloudflare Image Resizing on the same zone as assets.certrev.com:
15
15
  * https://assets.certrev.com/cdn-cgi/image/width=W,height=W,fit=cover,format=auto/<path>
@@ -17,20 +17,20 @@
17
17
  * allowlist is needed; format=auto serves webp/avif; the transform is cached + billed per-unique.
18
18
  *
19
19
  * ENABLEMENT GATE (`AVATAR_RESIZE_ENABLED`): OFF until Cloudflare Image Resizing is enabled on the
20
- * certrev.com zone AND a real photo is verified to resize (POR-10470 gate). While OFF, avatars use
20
+ * certrev.com zone AND a real photo is verified to resize. While OFF, avatars use
21
21
  * the raw canonical URL — byte-identical to today's behavior — so the extension can deploy for the
22
22
  * font de-block with ZERO avatar regression (a cdn-cgi/image URL 404s → initials fallback if the
23
23
  * feature isn't live). Going live is a one-line flip here + in certrev-avatar-url.liquid, then the
24
- * raw branch is removed (flag-debt: tracked as the POR-10470 avatar go-live follow-up).
24
+ * raw branch is removed (flag-debt: the avatar go-live follow-up).
25
25
  */
26
26
 
27
27
  /**
28
- * Enablement gate — LIVE as of 2026-07-20 (POR-10656). Cloudflare Image Resizing (Transformations)
29
- * is enabled on the certrev.com zone (`image_resizing = on`), and the size-allowlist WAF rule
30
- * (ruleset 7c9df62e88cb4713b18f12853651af08) is verified enforcing on `/cdn-cgi/image/` paths — a real
31
- * photo resizes (`width=84` → 200 image/jpeg) while a disallowed size (`width=999`) is 403-blocked.
28
+ * Enablement gate — LIVE as of 2026-07-20. Cloudflare Image Resizing (Transformations)
29
+ * is enabled on the certrev.com zone (`image_resizing = on`), and the size-allowlist WAF rule is
30
+ * verified enforcing on `/cdn-cgi/image/` paths — a real photo resizes (`width=84` → 200
31
+ * image/jpeg) while a disallowed size (`width=999`) is 403-blocked.
32
32
  * Kept as a one-line kill-switch: flip back to `false` + redeploy to instantly revert to raw URLs if
33
- * resizing ever misbehaves (removing the raw branch entirely is the flag-debt follow-up, POR-10680).
33
+ * resizing ever misbehaves (removing the raw branch entirely is the flag-debt follow-up).
34
34
  */
35
35
  export const AVATAR_RESIZE_ENABLED = true;
36
36
 
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Pure envelope → modal-HTML mapping for `<certrev-cert-modal>` (POR-10102).
2
+ * Pure envelope → modal-HTML mapping for `<certrev-cert-modal>`.
3
3
  *
4
4
  * DOM-free and side-effect-free so it unit-tests without a browser: given the
5
5
  * signed envelope's `payload.content`, it returns the inner HTML of the certificate
@@ -11,19 +11,23 @@
11
11
  * - Every dynamic value is `escapeHtml`'d (the envelope is signed, but a reviewer
12
12
  * name / memo is still attacker-adjacent content).
13
13
  * - Only `http(s)` hrefs render (mirrors the Liquid `certrev-safe-url` guard).
14
- * - Verified-only fields (POR-10091): the bio binds the envelope's verified
14
+ * - Verified-only fields: the bio binds the envelope's verified
15
15
  * `background`/`bio`; this module never reaches for a raw column.
16
16
  * - Graceful degradation: a missing article title drops the "Certifies the article"
17
17
  * block; a missing display cert id drops that meta column; no credentials → no
18
18
  * chips / no creds line; no bio → no bio paragraph. Nothing renders an empty gap.
19
- * - WS4a (display eviction) accept-on-read: an ABSENT `display` block resolves to the
19
+ * - Display-eviction accept-on-read: an ABSENT `display` block resolves to the
20
20
  * preset default (show-all) — every read below is `display?.x !== false` / `=== false`,
21
21
  * so `undefined` never suppresses a field. The modal is the LOCKED navy trust chrome and
22
22
  * is deliberately NOT themeable by a brand render-def; it reads visibility off the signed
23
23
  * envelope only, so no render-def wiring belongs here.
24
+ * - Type tolerance, not just shape tolerance: `content` is a `JSON.parse(...) as` cast off an
25
+ * envelope whose SIGNATURE was checked and whose SHAPE never was, so every read below goes
26
+ * through `asText` before it touches a string method. `(x || '').trim()` looks defensive but
27
+ * throws on a number, and a throw here is the dialog the reader just clicked failing to open.
24
28
  */
25
29
 
26
- import { escapeHtml } from './escape.js'
30
+ import { asText, escapeHtml } from './escape.js'
27
31
  import { resizeAvatarUrl } from './avatar-resize.js'
28
32
  import { CERT_SCOPE_LINE, formatCertDisplayDate } from './display-strings.js'
29
33
  import { checkIcon, chevronMark, verifiedStamp } from './certrev-cert-modal.styles.js'
@@ -41,7 +45,7 @@ export interface CertModalExpert {
41
45
  photoUrl?: string | null
42
46
  bio?: string | null
43
47
  background?: string | null
44
- /** POR-10496: the pre-composed material-connection label ('Compensated expert') or null/absent for
48
+ /** The pre-composed material-connection label ('Compensated expert') or null/absent for
45
49
  * a pro-bono reviewer. Present → the disclosure footer renders the label; null/absent → label omitted. */
46
50
  compensationCue?: string | null
47
51
  }
@@ -56,12 +60,29 @@ export interface CertModalContent {
56
60
  display?: { showExpertPhoto?: boolean | null; showBio?: boolean | null } | null
57
61
  }
58
62
 
63
+ /**
64
+ * The `rel` every CertREV anchor in this module carries.
65
+ *
66
+ * `../components/rel.ts` (`CERTREV_LINK_REL`) is the SOURCE OF TRUTH for the qualification
67
+ * tokens and the policy reasoning behind them — read it there. The value is duplicated rather
68
+ * than imported because `src/modal/` is bundled standalone into the dependency-free
69
+ * `certrev-cert.js` IIFE (`build:modal-bundle`) and imports NOTHING from `src/components/`;
70
+ * crossing that boundary for a string constant would pull the SSR module graph into a
71
+ * storefront asset. This copy adds `noreferrer` on top of the shared tokens: both anchors here
72
+ * are `target="_blank"`, where it still earns its place.
73
+ *
74
+ * Keep in lockstep with `CERTREV_LINK_REL` — `nofollow` + `sponsored` are the qualification the
75
+ * link-spam policy names, and dropping either from one copy silently un-qualifies half the
76
+ * package's links.
77
+ */
78
+ export const CERTREV_MODAL_LINK_REL = 'nofollow sponsored noopener noreferrer'
79
+
59
80
  const HONORIFIC_RE = /^(dr|prof|professor|mr|mrs|ms|mx|rev|sir|dame)\.?\s+/i
60
81
 
61
82
  /** The bare given+family name: strip the leading honorific AND the trailing post-nominal
62
83
  * (`, MD`), so "Dr. Erik Schraga, MD" → "Erik Schraga". */
63
84
  function coreName(displayName: string): string {
64
- const withoutHonorific = displayName.replace(HONORIFIC_RE, '').trim()
85
+ const withoutHonorific = asText(displayName).replace(HONORIFIC_RE, '').trim()
65
86
  const comma = withoutHonorific.indexOf(',')
66
87
  return (comma === -1 ? withoutHonorific : withoutHonorific.slice(0, comma)).trim()
67
88
  }
@@ -82,24 +103,28 @@ export function signatureName(displayName: string): string {
82
103
 
83
104
  /** `https://certrev.com/verify/x` → `certrev.com/verify/x` (footer URL line). */
84
105
  export function stripProtocol(url: string): string {
85
- return url.replace(/^https?:\/\//i, '').replace(/\/$/, '')
106
+ return asText(url).replace(/^https?:\/\//i, '').replace(/\/$/, '')
86
107
  }
87
108
 
88
109
  /** Return the url only if it is an absolute http(s) URL, else `#` (safe-url guard). */
89
110
  export function safeHref(url: string | null | undefined): string {
90
- if (!url) return '#'
91
- return /^https?:\/\//i.test(url.trim()) ? url.trim() : '#'
111
+ const href = asText(url).trim()
112
+ if (!href) return '#'
113
+ return /^https?:\/\//i.test(href) ? href : '#'
92
114
  }
93
115
 
94
116
  /** A single credential's display label (cardLabel ?? fullName ?? abbreviation). */
95
- function credLabel(c: CertModalCredential): string {
96
- return (c.cardLabel || c.fullName || c.abbreviation || '').trim()
117
+ function credLabel(c: CertModalCredential | null | undefined): string {
118
+ return (asText(c?.cardLabel) || asText(c?.fullName) || asText(c?.abbreviation)).trim()
97
119
  }
98
120
 
99
121
  /** The verified credential labels, in order, de-duped + non-empty. */
100
122
  function credLabels(expert: CertModalExpert | null | undefined): string[] {
101
123
  const out: string[] = []
102
- for (const c of expert?.credentials ?? []) {
124
+ // `Array.isArray`, not `?? []`: a `credentials` that parsed as an object is not iterable, and
125
+ // `for…of` over one throws before any of the per-entry guards below get a chance to run.
126
+ const credentials = Array.isArray(expert?.credentials) ? expert.credentials : []
127
+ for (const c of credentials) {
103
128
  const label = credLabel(c)
104
129
  if (label && !out.includes(label)) out.push(label)
105
130
  }
@@ -109,7 +134,7 @@ function credLabels(expert: CertModalExpert | null | undefined): string[] {
109
134
  /** The reviewer bio — verified background preferred, then verified bio. */
110
135
  function bioText(expert: CertModalExpert | null | undefined, display: CertModalContent['display']): string {
111
136
  if (display?.showBio === false) return ''
112
- return (expert?.background || expert?.bio || '').trim()
137
+ return (asText(expert?.background) || asText(expert?.bio)).trim()
113
138
  }
114
139
 
115
140
  /** An avatar cell — the cert-frozen photo (when shown) or the initials chip. */
@@ -119,9 +144,9 @@ function avatarHtml(
119
144
  sizeClass: string,
120
145
  showPhoto: boolean,
121
146
  ): string {
122
- const photo = (expert?.photoUrl || '').trim()
147
+ const photo = asText(expert?.photoUrl).trim()
123
148
  if (showPhoto && /^https?:\/\//i.test(photo)) {
124
- // Display px from the size class; request the @2x derivative for retina crispness (POR-10470).
149
+ // Display px from the size class; request the @2x derivative for retina crispness.
125
150
  // resizeAvatarUrl is host-guarded + enablement-gated: until Cloudflare Image Resizing is live it
126
151
  // returns the raw canonical URL, so the modal never 404s an unresized host.
127
152
  const displayPx = sizeClass.includes('--64') ? 64 : 56
@@ -142,14 +167,14 @@ function certifiedChip(cls: string): string {
142
167
  }
143
168
 
144
169
  /**
145
- * The FTC disclosure footer (POR-10496), rendered on both dialogs. The material-connection cue
170
+ * The FTC disclosure footer, rendered on both dialogs. The material-connection cue
146
171
  * (`expert.compensationCue`, a FINISHED string off the signed envelope) leads ONLY when the reviewer
147
172
  * was compensated; a pro-bono reviewer (null/absent cue) drops the label but keeps the scope line —
148
173
  * which makes no compensation claim, so it is true for volunteers too and renders unconditionally.
149
174
  * The scope line is the verbatim FTC copy (`CERT_SCOPE_LINE`), never re-assembled here.
150
175
  */
151
176
  function disclosureFooter(expert: CertModalExpert | null | undefined): string {
152
- const cue = (expert?.compensationCue || '').trim()
177
+ const cue = asText(expert?.compensationCue).trim()
153
178
  const cueHtml = cue ? `<span class="crm-disclosure__cue">${escapeHtml(cue)}</span> ` : ''
154
179
  return `<div class="crm-disclosure">${cueHtml}${escapeHtml(CERT_SCOPE_LINE)}</div>`
155
180
  }
@@ -161,15 +186,15 @@ function disclosureFooter(expert: CertModalExpert | null | undefined): string {
161
186
  */
162
187
  export function renderCertDialogInner(content: CertModalContent): string | null {
163
188
  const expert = content.expert ?? null
164
- const name = (expert?.displayName || '').trim()
189
+ const name = asText(expert?.displayName).trim()
165
190
  if (!name) return null
166
191
 
167
192
  const showPhoto = content.display?.showExpertPhoto !== false
168
193
  const creds = credLabels(expert).join(' · ')
169
194
  const issued = formatCertDisplayDate(content.certifiedAt) ?? ''
170
- const certId = (content.displayCertId || '').trim()
171
- const title = (content.articleTitle || '').trim()
172
- const verifyUrl = (content.verifyUrl || '').trim()
195
+ const certId = asText(content.displayCertId).trim()
196
+ const title = asText(content.articleTitle).trim()
197
+ const verifyUrl = asText(content.verifyUrl).trim()
173
198
  const sig = signatureName(name)
174
199
 
175
200
  const certifiesBlock = title
@@ -182,17 +207,17 @@ export function renderCertDialogInner(content: CertModalContent): string | null
182
207
 
183
208
  const urlLine = verifyUrl ? `<div class="crm-sig__url">${escapeHtml(stripProtocol(verifyUrl))}</div>` : ''
184
209
 
185
- return `${CLOSE_BUTTON}${headHtml('Certificate of Expert Review')}<div class="crm-body--cert"><div class="crm-expert-row">${avatarHtml(expert, name, 'crm-avatar--56', showPhoto)}<div class="crm-expert-row__info"><div class="crm-expert-row__eyebrow">Reviewing Expert</div><div class="crm-expert-row__name">${escapeHtml(name)}</div>${creds ? `<div class="crm-expert-row__creds">${escapeHtml(creds)}</div>` : ''}</div></div>${certifiesBlock}<div class="crm-meta"><div class="crm-meta__col"><div class="crm-meta__label">Issued</div><div class="crm-meta__value">${escapeHtml(issued)}</div></div>${certIdCol}<div class="crm-meta__col"><div class="crm-meta__label">Status</div><div class="crm-meta__status">${certifiedChip('crm-chip')}</div></div></div><div class="crm-sig"><div><div class="crm-sig__name">${escapeHtml(sig)}</div><div class="crm-sig__rule"></div>${urlLine}</div>${verifiedStamp('cert')}</div>${disclosureFooter(expert)}<div class="crm-actions"><a class="crm-cta" href="${escapeHtml(safeHref(verifyUrl))}" target="_blank" rel="noopener noreferrer">View full certificate</a><button type="button" class="crm-ghost" data-certrev-modal-close>Close</button></div></div>`
210
+ return `${CLOSE_BUTTON}${headHtml('Certificate of Expert Review')}<div class="crm-body--cert"><div class="crm-expert-row">${avatarHtml(expert, name, 'crm-avatar--56', showPhoto)}<div class="crm-expert-row__info"><div class="crm-expert-row__eyebrow">Reviewing Expert</div><div class="crm-expert-row__name">${escapeHtml(name)}</div>${creds ? `<div class="crm-expert-row__creds">${escapeHtml(creds)}</div>` : ''}</div></div>${certifiesBlock}<div class="crm-meta"><div class="crm-meta__col"><div class="crm-meta__label">Issued</div><div class="crm-meta__value">${escapeHtml(issued)}</div></div>${certIdCol}<div class="crm-meta__col"><div class="crm-meta__label">Status</div><div class="crm-meta__status">${certifiedChip('crm-chip')}</div></div></div><div class="crm-sig"><div><div class="crm-sig__name">${escapeHtml(sig)}</div><div class="crm-sig__rule"></div>${urlLine}</div>${verifiedStamp('cert')}</div>${disclosureFooter(expert)}<div class="crm-actions"><a class="crm-cta" href="${escapeHtml(safeHref(verifyUrl))}" target="_blank" rel="${CERTREV_MODAL_LINK_REL}">View full certificate</a><button type="button" class="crm-ghost" data-certrev-modal-close>Close</button></div></div>`
186
211
  }
187
212
 
188
213
  /**
189
- * The reviewer/profile modal (4b) inner HTML. Returns `null` when there is no
190
- * reviewer name (fail-closed). The affiliation subtitle is intentionally omitted
191
- * (no verified data source — POR-10102 decision).
214
+ * The reviewer/profile modal inner HTML. Returns `null` when there is no
215
+ * reviewer name (fail-closed). The affiliation subtitle is intentionally omitted:
216
+ * there is no verified data source for it.
192
217
  */
193
218
  export function renderExpertDialogInner(content: CertModalContent): string | null {
194
219
  const expert = content.expert ?? null
195
- const name = (expert?.displayName || '').trim()
220
+ const name = asText(expert?.displayName).trim()
196
221
  if (!name) return null
197
222
 
198
223
  const showPhoto = content.display?.showExpertPhoto !== false
@@ -206,5 +231,5 @@ export function renderExpertDialogInner(content: CertModalContent): string | nul
206
231
  const bioHtml = bio ? `<p class="crm-bio">${escapeHtml(bio)}</p>` : ''
207
232
  const trustText = date ? `Independently reviewed · Certified ${escapeHtml(date)}` : 'Independently reviewed'
208
233
 
209
- return `${CLOSE_BUTTON}${headHtml('Verified reviewer')}<div class="crm-body--expert"><div class="crm-id">${avatarHtml(expert, name, 'crm-avatar--64', showPhoto)}<div class="crm-id__info"><div class="crm-id__name">${escapeHtml(name)}</div></div></div>${chipsHtml}${bioHtml}<div class="crm-trust">${certifiedChip('crm-trust__chip')}<span class="crm-trust__text">${trustText}</span></div>${disclosureFooter(expert)}<div class="crm-actions"><a class="crm-cta" href="${escapeHtml(safeHref(expert?.profileUrl))}" target="_blank" rel="noopener noreferrer">View full profile on CertREV</a><button type="button" class="crm-ghost" data-certrev-modal-close>Close</button></div></div>`
234
+ return `${CLOSE_BUTTON}${headHtml('Verified reviewer')}<div class="crm-body--expert"><div class="crm-id">${avatarHtml(expert, name, 'crm-avatar--64', showPhoto)}<div class="crm-id__info"><div class="crm-id__name">${escapeHtml(name)}</div></div></div>${chipsHtml}${bioHtml}<div class="crm-trust">${certifiedChip('crm-trust__chip')}<span class="crm-trust__text">${trustText}</span></div>${disclosureFooter(expert)}<div class="crm-actions"><a class="crm-cta" href="${escapeHtml(safeHref(expert?.profileUrl))}" target="_blank" rel="${CERTREV_MODAL_LINK_REL}">View full profile on CertREV</a><button type="button" class="crm-ghost" data-certrev-modal-close>Close</button></div></div>`
210
235
  }
@@ -1,8 +1,7 @@
1
1
  /**
2
- * Scoped styles + inline SVGs for the `<certrev-cert-modal>` Shadow-DOM element
3
- * (POR-10102). The single source of truth for the certificate (3a) + reviewer (4b)
4
- * modal look, ported byte-faithfully from the Claude Design reference
5
- * (`docs/claude/design/certificate-modals/`, footer variant 3a + reviewer 4b).
2
+ * Scoped styles + inline SVGs for the `<certrev-cert-modal>` Shadow-DOM element.
3
+ * The single source of truth for the certificate + reviewer modal look, ported
4
+ * byte-faithfully from the approved design reference.
6
5
  *
7
6
  * Because the element renders inside a shadow root, this CSS is fully scoped: the
8
7
  * brand storefront theme cannot bleed in, and these rules never leak out — so the
@@ -16,7 +15,8 @@
16
15
  import { LOGO_PATHS } from './logo-paths.js'
17
16
 
18
17
  /** The four-chevron CertREV mark (viewBox 0 0 375 375), tinted via the passed fill. Path data is
19
- * single-sourced from src/lib/design-guide/logo.ts (the SOT the codegen mirrors into every twin). */
18
+ * single-sourced from the portal design-guide's logo module (the SOT the codegen mirrors into
19
+ * every twin). */
20
20
  export function chevronMark(size: number, fill: string): string {
21
21
  const paths = LOGO_PATHS.map((d) => `<path fill="${fill}" d="${d}"/>`).join('')
22
22
  return `<svg width="${size}" height="${size}" viewBox="0 0 375 375" aria-hidden="true" style="display:block;flex-shrink:0">${paths}</svg>`
@@ -52,7 +52,7 @@ export const CERT_MODAL_FONT_FAMILIES = [
52
52
  'Allura',
53
53
  ] as const
54
54
 
55
- // The modal's fonts are self-hosted (POR-10657): the standalone loader (certrev-cert-modal.ts)
55
+ // The modal's fonts are self-hosted: the standalone loader (certrev-cert-modal.ts)
56
56
  // injects the shared CERT_FONT_FACE_CSS @font-face block (R2 URLs) instead of a render-blocking
57
57
  // Google Fonts stylesheet. In the theme-app-extension the host page already declares these via
58
58
  // certrev-cert.css, so `data-fonts="host"` suppresses the injection entirely.
@@ -96,7 +96,7 @@ export const CERT_MODAL_CSS = `
96
96
  background: var(--cr-white);
97
97
  color: var(--cr-navy);
98
98
  font-family: var(--cr-sans);
99
- /* fixed, NOT relative (POR-10473): a :modal dialog (showModal) renders in the TOP LAYER; a
99
+ /* fixed, NOT relative: a :modal dialog (showModal) renders in the TOP LAYER; a
100
100
  * relative dialog anchors to its scrolled document position, so once the page is scrolled the
101
101
  * panel renders off-screen while the ::backdrop still covers the viewport (measured top -1972 at
102
102
  * scrollY 2537). fixed is viewport-anchored (the UA inset:0 + margin:auto centers it) and still
@@ -462,7 +462,7 @@ export const CERT_MODAL_CSS = `
462
462
  color: var(--cr-navy-50);
463
463
  }
464
464
 
465
- /* FTC disclosure footer (POR-10496) — material-connection cue (compensated only) + scope line
465
+ /* FTC disclosure footer — material-connection cue (compensated only) + scope line
466
466
  * (always). Rendered on both the certificate + reviewer dialogs, immediately above the actions. */
467
467
  .crm-disclosure {
468
468
  margin-top: 14px;