@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.
- package/CHANGELOG.md +195 -12
- package/LICENSE +21 -0
- package/README.md +422 -50
- package/dist/builder/cert-chrome-data.d.ts +26 -8
- package/dist/builder/cert-chrome-data.d.ts.map +1 -1
- package/dist/builder/cert-chrome-data.js +5 -5
- package/dist/builder/cert-chrome-data.js.map +1 -1
- package/dist/builder/cert-review-card.d.ts +35 -5
- package/dist/builder/cert-review-card.d.ts.map +1 -1
- package/dist/builder/cert-review-card.js +115 -34
- package/dist/builder/cert-review-card.js.map +1 -1
- package/dist/builder/index.d.ts +33 -16
- package/dist/builder/index.d.ts.map +1 -1
- package/dist/builder/index.js +33 -16
- package/dist/builder/index.js.map +1 -1
- package/dist/components/CertBadge.d.ts +2 -2
- package/dist/components/CertBadge.d.ts.map +1 -1
- package/dist/components/CertBadge.js +11 -5
- package/dist/components/CertBadge.js.map +1 -1
- package/dist/components/CertJsonLd.d.ts.map +1 -1
- package/dist/components/CertJsonLd.js.map +1 -1
- package/dist/components/CertRevBacklink.d.ts.map +1 -1
- package/dist/components/CertRevBacklink.js +3 -2
- package/dist/components/CertRevBacklink.js.map +1 -1
- package/dist/components/CertReview.d.ts +1 -1
- package/dist/components/ExpertBio.d.ts.map +1 -1
- package/dist/components/ExpertBio.js +12 -4
- package/dist/components/ExpertBio.js.map +1 -1
- package/dist/components/escape.d.ts +28 -1
- package/dist/components/escape.d.ts.map +1 -1
- package/dist/components/escape.js +37 -8
- package/dist/components/escape.js.map +1 -1
- package/dist/components/format.d.ts +51 -2
- package/dist/components/format.d.ts.map +1 -1
- package/dist/components/format.js +103 -6
- package/dist/components/format.js.map +1 -1
- package/dist/components/preview-block.d.ts +1 -1
- package/dist/components/preview-block.js +3 -3
- package/dist/components/preview-block.js.map +1 -1
- package/dist/components/rel.d.ts +24 -0
- package/dist/components/rel.d.ts.map +1 -0
- package/dist/components/rel.js +24 -0
- package/dist/components/rel.js.map +1 -0
- package/dist/components/render-cert-block.d.ts +50 -22
- package/dist/components/render-cert-block.d.ts.map +1 -1
- package/dist/components/render-cert-block.js +146 -80
- package/dist/components/render-cert-block.js.map +1 -1
- package/dist/components/render-def.d.ts +13 -14
- package/dist/components/render-def.d.ts.map +1 -1
- package/dist/components/render-def.js +14 -15
- package/dist/components/render-def.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/jsonld/project.d.ts +46 -13
- package/dist/jsonld/project.d.ts.map +1 -1
- package/dist/jsonld/project.js +139 -56
- package/dist/jsonld/project.js.map +1 -1
- package/dist/modal/avatar-resize.d.ts +10 -10
- package/dist/modal/avatar-resize.js +14 -14
- package/dist/modal/avatar-resize.js.map +1 -1
- package/dist/modal/cert-modal-view.d.ts +27 -7
- package/dist/modal/cert-modal-view.d.ts.map +1 -1
- package/dist/modal/cert-modal-view.js +49 -25
- package/dist/modal/cert-modal-view.js.map +1 -1
- package/dist/modal/certrev-cert-modal.d.ts +5 -5
- package/dist/modal/certrev-cert-modal.js +9 -9
- package/dist/modal/certrev-cert-modal.js.map +1 -1
- package/dist/modal/certrev-cert-modal.styles.d.ts +6 -6
- package/dist/modal/certrev-cert-modal.styles.d.ts.map +1 -1
- package/dist/modal/certrev-cert-modal.styles.js +8 -8
- package/dist/modal/certrev-cert-modal.styles.js.map +1 -1
- package/dist/modal/certrev-cert.entry.d.ts +8 -6
- package/dist/modal/certrev-cert.entry.d.ts.map +1 -1
- package/dist/modal/certrev-cert.entry.js +9 -7
- package/dist/modal/certrev-cert.entry.js.map +1 -1
- package/dist/modal/certrev-cert.js +6 -6
- package/dist/modal/display-strings.d.ts +25 -5
- package/dist/modal/display-strings.d.ts.map +1 -1
- package/dist/modal/display-strings.js +27 -5
- package/dist/modal/display-strings.js.map +1 -1
- package/dist/modal/escape.d.ts +26 -3
- package/dist/modal/escape.d.ts.map +1 -1
- package/dist/modal/escape.js +32 -3
- package/dist/modal/escape.js.map +1 -1
- package/dist/modal/font-face.d.ts.map +1 -1
- package/dist/modal/font-face.js +8 -11
- package/dist/modal/font-face.js.map +1 -1
- package/dist/modal/ftc-disclosure.d.ts +2 -2
- package/dist/modal/ftc-disclosure.js +2 -2
- package/dist/modal/ftc-guard.d.ts +44 -9
- package/dist/modal/ftc-guard.d.ts.map +1 -1
- package/dist/modal/ftc-guard.js +50 -17
- package/dist/modal/ftc-guard.js.map +1 -1
- package/dist/modal/index.d.ts +1 -1
- package/dist/modal/index.js +1 -1
- package/dist/modal/interactions.d.ts +2 -2
- package/dist/modal/interactions.js +2 -2
- package/dist/modal/logo-paths.js +2 -2
- package/dist/modal/logo-paths.js.map +1 -1
- package/dist/modal/placement.d.ts +4 -3
- package/dist/modal/placement.d.ts.map +1 -1
- package/dist/modal/placement.js +7 -6
- package/dist/modal/placement.js.map +1 -1
- package/dist/modal/revalidate.d.ts +2 -3
- package/dist/modal/revalidate.d.ts.map +1 -1
- package/dist/modal/revalidate.js +2 -3
- package/dist/modal/revalidate.js.map +1 -1
- package/dist/modal/verify-client.d.ts +2 -2
- package/dist/modal/verify-client.js +2 -2
- package/dist/verify/cache.d.ts +21 -6
- package/dist/verify/cache.d.ts.map +1 -1
- package/dist/verify/cache.js +29 -8
- package/dist/verify/cache.js.map +1 -1
- package/dist/verify/fetch-with-deadline.d.ts +35 -0
- package/dist/verify/fetch-with-deadline.d.ts.map +1 -0
- package/dist/verify/fetch-with-deadline.js +55 -0
- package/dist/verify/fetch-with-deadline.js.map +1 -0
- package/dist/verify/get-verified-envelope.d.ts +23 -9
- package/dist/verify/get-verified-envelope.d.ts.map +1 -1
- package/dist/verify/get-verified-envelope.js +89 -40
- package/dist/verify/get-verified-envelope.js.map +1 -1
- package/dist/verify/resolve-kid.d.ts +3 -0
- package/dist/verify/resolve-kid.d.ts.map +1 -1
- package/dist/verify/resolve-kid.js +17 -8
- package/dist/verify/resolve-kid.js.map +1 -1
- package/dist/webcomponent/certrev-badge.d.ts.map +1 -1
- package/dist/webcomponent/certrev-badge.js +24 -2
- package/dist/webcomponent/certrev-badge.js.map +1 -1
- package/dist/webcomponent/render-badge-html.d.ts +1 -1
- package/dist/webcomponent/render-badge-html.d.ts.map +1 -1
- package/dist/webcomponent/render-badge-html.js +36 -17
- package/dist/webcomponent/render-badge-html.js.map +1 -1
- package/package.json +14 -2
- package/src/builder/cert-chrome-data.ts +26 -8
- package/src/builder/cert-review-card.tsx +147 -38
- package/src/builder/index.tsx +34 -17
- package/src/components/CertBadge.tsx +14 -9
- package/src/components/CertJsonLd.tsx +1 -1
- package/src/components/CertRevBacklink.tsx +10 -3
- package/src/components/CertReview.tsx +1 -1
- package/src/components/ExpertBio.tsx +12 -7
- package/src/components/escape.ts +37 -7
- package/src/components/format.ts +102 -6
- package/src/components/preview-block.ts +3 -3
- package/src/components/rel.ts +23 -0
- package/src/components/render-cert-block.ts +192 -109
- package/src/components/render-def.ts +14 -15
- package/src/index.ts +4 -2
- package/src/jsonld/project.ts +155 -59
- package/src/modal/avatar-resize.ts +14 -14
- package/src/modal/cert-modal-view.ts +52 -27
- package/src/modal/certrev-cert-modal.styles.ts +8 -8
- package/src/modal/certrev-cert-modal.ts +9 -9
- package/src/modal/certrev-cert.entry.ts +9 -7
- package/src/modal/display-strings.ts +26 -5
- package/src/modal/escape.ts +32 -4
- package/src/modal/font-face.ts +8 -11
- package/src/modal/ftc-disclosure.ts +2 -2
- package/src/modal/ftc-guard.ts +73 -18
- package/src/modal/index.ts +1 -1
- package/src/modal/interactions.ts +2 -2
- package/src/modal/logo-paths.ts +2 -2
- package/src/modal/placement.ts +7 -6
- package/src/modal/revalidate.ts +2 -3
- package/src/modal/verify-client.ts +2 -2
- package/src/verify/cache.ts +34 -8
- package/src/verify/fetch-with-deadline.ts +60 -0
- package/src/verify/get-verified-envelope.ts +131 -48
- package/src/verify/resolve-kid.ts +22 -6
- package/src/webcomponent/certrev-badge.ts +24 -2
- package/src/webcomponent/render-badge-html.ts +66 -19
- package/src/__tests__/__snapshots__/render-cert-block.test.ts.snap +0 -17
- package/src/__tests__/components.test.tsx +0 -206
- package/src/__tests__/preview-block.test.ts +0 -84
- package/src/__tests__/project.test.ts +0 -128
- package/src/__tests__/render-cert-block.test.ts +0 -657
- package/src/__tests__/render-def.test.tsx +0 -295
- package/src/__tests__/verify.test.ts +0 -239
- package/src/__tests__/webcomponent.test.tsx +0 -116
- package/src/builder/__tests__/builder.test.tsx +0 -158
- package/src/modal/cert-modal-view.test.ts +0 -175
- package/src/modal/certrev-cert-modal.test.ts +0 -125
- package/src/modal/ftc-guard.test.ts +0 -153
- package/src/modal/interactions.test.ts +0 -250
- package/src/modal/placement.test.ts +0 -398
- package/src/modal/revalidate.test.ts +0 -111
- package/src/modal/verify-client.test.ts +0 -164
|
@@ -4,10 +4,20 @@
|
|
|
4
4
|
* ─────────────────────────────────────────────────────────────────────────────
|
|
5
5
|
*
|
|
6
6
|
* Paints the owner-approved cert render (decision 2026-07-17) in one of three
|
|
7
|
-
* placement modes — `
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* enhancement, wired by the shared
|
|
7
|
+
* placement modes — `sidebar` (the DEFAULT), `banner`, `floating` — plus the engine's
|
|
8
|
+
* free-composition `custom` face, from RESOLVED theme tokens + the cert facts. It
|
|
9
|
+
* returns a self-contained HTML string that renders with ZERO JavaScript (the CertREV
|
|
10
|
+
* modal + the reviewer-bio accordion are progressive enhancement, wired by the shared
|
|
11
|
+
* embed script via the `data-certrev-*` hooks).
|
|
12
|
+
*
|
|
13
|
+
* THE DEFAULT PLACEMENT IS `sidebar`, AND IT IS NAMED IN EXACTLY ONE PLACE — the
|
|
14
|
+
* `case 'sidebar':` that falls into `default:` at the bottom of this file. Nothing
|
|
15
|
+
* else in the package (this renderer, the Builder card, the preview) may name a
|
|
16
|
+
* placement fallback of its own: the switch previously had no `default:` at all, so
|
|
17
|
+
* an untyped `mode` off CMS JSON returned `undefined` from a `: string` function and
|
|
18
|
+
* landed in `dangerouslySetInnerHTML` as an empty div — a 200 with the card silently
|
|
19
|
+
* gone. Declared default and runtime fallback are now physically the same line and
|
|
20
|
+
* cannot drift.
|
|
11
21
|
*
|
|
12
22
|
* WHY A STRING (not a React component): the block is emitted into storefront HTML
|
|
13
23
|
* across every render surface (Shopify Liquid, WordPress PHP, the JS engine, and
|
|
@@ -55,7 +65,8 @@
|
|
|
55
65
|
*/
|
|
56
66
|
|
|
57
67
|
import { escapeAttribute, escapeHtml, safeHttpUrl } from './escape.js'
|
|
58
|
-
import { formatDate } from './format.js'
|
|
68
|
+
import { dedupeCredential, formatDate } from './format.js'
|
|
69
|
+
import { CERTREV_LINK_REL } from './rel.js'
|
|
59
70
|
import {
|
|
60
71
|
CERT_SCOPE_LINE,
|
|
61
72
|
CERT_STRINGS_VERSION,
|
|
@@ -85,10 +96,15 @@ export interface CertBlockFace {
|
|
|
85
96
|
}
|
|
86
97
|
|
|
87
98
|
/**
|
|
88
|
-
* The cert
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
99
|
+
* The cert FACTS a face renders — the per-cert data, all of it escaped.
|
|
100
|
+
*
|
|
101
|
+
* The compliance copy is NOT here. The scope line and the credential attribution are
|
|
102
|
+
* rendered straight from `CERT_SCOPE_LINE` / `CREDENTIAL_VERIFIED_ATTRIBUTION`: they
|
|
103
|
+
* are locked strings per the CertREV Display Guide (the scope line's curly apostrophe
|
|
104
|
+
* is pinned to MSA §3 — see render-def.ts), so a caller has no legitimate reason to
|
|
105
|
+
* supply them and taking them as input made compliance copy rewritable from a CMS
|
|
106
|
+
* field. `compensationCue` survives as an input for exactly ONE decision — whether the
|
|
107
|
+
* reviewer was compensated at all — never for the wording.
|
|
92
108
|
*/
|
|
93
109
|
export interface CertBlockFacts {
|
|
94
110
|
/** The content author (the "Written by" column). */
|
|
@@ -103,25 +119,34 @@ export interface CertBlockFacts {
|
|
|
103
119
|
readonly credentialVerifiedAt: string
|
|
104
120
|
/** ISO instant the article was certified (the "Certified <date>" footer). */
|
|
105
121
|
readonly certifiedAt: string
|
|
106
|
-
/**
|
|
107
|
-
|
|
122
|
+
/**
|
|
123
|
+
* The expert's memo (the quote / blockquote). OPTIONAL, exactly like `bio`: a face may
|
|
124
|
+
* legally carry no memo, and an empty one must drop the heading + quote rather than paint
|
|
125
|
+
* an "Expert memo" label over an empty `<p>`.
|
|
126
|
+
*/
|
|
127
|
+
readonly memo?: string
|
|
108
128
|
/** The expert's bio (banner accordion / sidebar paragraph). Omitted when absent. */
|
|
109
129
|
readonly bio?: string
|
|
110
130
|
/** The expert profile URL (the "Profile" link + modal `expert` hook). */
|
|
111
131
|
readonly profileUrl: string
|
|
112
132
|
/** The certificate URL (the "Certificate" link + modal hook). */
|
|
113
133
|
readonly certificateUrl: string
|
|
114
|
-
/**
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
134
|
+
/**
|
|
135
|
+
* WHETHER there is a material connection to disclose — never its WORDING.
|
|
136
|
+
*
|
|
137
|
+
* `null` ⇒ the cue is OMITTED entirely: the pro-bono reviewer, who has no compensation
|
|
138
|
+
* to disclose (the same contract `modal/cert-modal-view.ts` already honours). Any other
|
|
139
|
+
* value — absent, or a supplied string — renders the LOCKED `COMPENSATED_EXPERT_CUE`
|
|
140
|
+
* constant; a supplied string is IGNORED. The scope line still renders for a pro-bono
|
|
141
|
+
* reviewer: it makes no compensation claim, so it is true for volunteers too.
|
|
142
|
+
*/
|
|
143
|
+
readonly compensationCue?: string | null
|
|
120
144
|
}
|
|
121
145
|
|
|
122
146
|
/** The full input to `renderCertBlock`. */
|
|
123
147
|
export interface RenderCertBlockInput {
|
|
124
|
-
|
|
148
|
+
/** The placement face. ABSENT or UNRECOGNISED ⇒ `sidebar` (see the file header). */
|
|
149
|
+
readonly mode?: CertBlockLayout
|
|
125
150
|
/** RESOLVED theme tokens (the portal bakes these). Absent/invalid tokens fall back to navy. */
|
|
126
151
|
readonly theme?: Partial<ResolvedBlockTheme>
|
|
127
152
|
readonly facts: CertBlockFacts
|
|
@@ -131,8 +156,8 @@ export interface RenderCertBlockInput {
|
|
|
131
156
|
*/
|
|
132
157
|
readonly face?: CertBlockFace
|
|
133
158
|
/**
|
|
134
|
-
* BANNER memo placement
|
|
135
|
-
* string-hack
|
|
159
|
+
* BANNER memo placement — the first-class split that retires the CertReviewCard
|
|
160
|
+
* string-hack. Default `'full'` = header card + memo card in ONE root, BYTE-IDENTICAL
|
|
136
161
|
* to prior behavior. `'header'` / `'memo'` render ONLY that card in its own themed root,
|
|
137
162
|
* so a headless exporter can place the memo as its OWN block under the article body
|
|
138
163
|
* (the "banner → body → memo" layout) WITHOUT string-splitting the rendered HTML — and
|
|
@@ -206,16 +231,6 @@ function firstNameOf(name: string): string {
|
|
|
206
231
|
return clean.split(/\s+/)[0] || clean
|
|
207
232
|
}
|
|
208
233
|
|
|
209
|
-
/**
|
|
210
|
-
* A LOCKED display string: use the caller's value ONLY when it is a non-blank
|
|
211
|
-
* string, else the byte-verbatim constant. The three compliance strings
|
|
212
|
-
* (compensation cue, scope line, credential attribution) are undroppable — an
|
|
213
|
-
* omitted OR empty/whitespace override must never blank the disclosure.
|
|
214
|
-
*/
|
|
215
|
-
function locked(supplied: string | undefined, constant: string): string {
|
|
216
|
-
return supplied?.trim() ? supplied : constant
|
|
217
|
-
}
|
|
218
|
-
|
|
219
234
|
/** Field-placement predicate every renderer consults. */
|
|
220
235
|
type PlacedTest = (fieldId: string) => boolean
|
|
221
236
|
|
|
@@ -229,9 +244,25 @@ function placedTester(face?: CertBlockFace): PlacedTest {
|
|
|
229
244
|
return (id) => set.has(id)
|
|
230
245
|
}
|
|
231
246
|
|
|
247
|
+
/**
|
|
248
|
+
* Is there actually a bio to expand? The banner's accordion affordance — role=button,
|
|
249
|
+
* tabindex, aria-expanded, the caret, the pointer cursor — is gated on THIS, not on the
|
|
250
|
+
* reviewer row being non-empty. Gating on the row announced "Read more about Dr. X,
|
|
251
|
+
* button, collapsed" over nothing whenever the reviewer had no bio (WCAG 2.1 4.1.2 name/
|
|
252
|
+
* role/value, and 2.1.1 keyboard: a focus stop that does nothing).
|
|
253
|
+
*/
|
|
254
|
+
function hasExpandableBio(facts: CertBlockFacts, placed: PlacedTest): boolean {
|
|
255
|
+
return placed('bio') && !!facts.bio
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Whether the material-connection cue renders: placed by the engine AND not the pro-bono `null`. */
|
|
259
|
+
function showsCompensationCue(facts: CertBlockFacts, placed: PlacedTest): boolean {
|
|
260
|
+
return placed('compensationCue') && facts.compensationCue !== null
|
|
261
|
+
}
|
|
262
|
+
|
|
232
263
|
/**
|
|
233
264
|
* The memo/custom action-link stamp style — the JetBrains-mono uppercase lockup that
|
|
234
|
-
* matches the badge footer
|
|
265
|
+
* matches the badge footer; navy so it still reads as an action.
|
|
235
266
|
*/
|
|
236
267
|
const STAMP_LINK_STYLE =
|
|
237
268
|
'font-family:var(--font-mono);font-size:10.5px;font-weight:500;letter-spacing:.14em;text-transform:uppercase;color:var(--navy);text-decoration:none;cursor:pointer;'
|
|
@@ -245,22 +276,29 @@ function avatar(text: string, size: number, fontSize: number): string {
|
|
|
245
276
|
)
|
|
246
277
|
}
|
|
247
278
|
|
|
248
|
-
/**
|
|
279
|
+
/**
|
|
280
|
+
* "Jane Doe, MD" (or just the name when there is no credential) — escaped, and never the
|
|
281
|
+
* same credential twice. A display name that arrives already carrying its own post-nominal
|
|
282
|
+
* ("Dr. Erik Schraga, MD") used to have the credential appended regardless, which put
|
|
283
|
+
* "Dr. Erik Schraga, MD, MD, EM" on a live customer page — see `dedupeCredential` in
|
|
284
|
+
* format.ts for the rule, and for why the stored data is still the real repair.
|
|
285
|
+
*/
|
|
249
286
|
function nameWithCredential(name: string, credential: string): string {
|
|
250
287
|
const n = escapeHtml(name)
|
|
251
|
-
|
|
288
|
+
const tail = dedupeCredential(name, credential)
|
|
289
|
+
return tail ? `${n}, ${escapeHtml(tail)}` : n
|
|
252
290
|
}
|
|
253
291
|
|
|
254
292
|
/** The reviewer profile link (carries the modal `expert` hook + a real href fallback). */
|
|
255
293
|
function profileLink(url: string, inner: string, style: string): string {
|
|
256
294
|
const href = safeHttpUrl(url) ?? '#'
|
|
257
|
-
return `<a href="${escapeAttribute(href)}" data-certrev-modal-open="expert" rel="
|
|
295
|
+
return `<a href="${escapeAttribute(href)}" data-certrev-modal-open="expert" rel="${CERTREV_LINK_REL}" style="${style}">${inner}</a>`
|
|
258
296
|
}
|
|
259
297
|
|
|
260
298
|
/** The certificate link (carries the modal hook + a real href fallback). */
|
|
261
299
|
function certificateLink(url: string, inner: string, style: string): string {
|
|
262
300
|
const href = safeHttpUrl(url) ?? '#'
|
|
263
|
-
return `<a href="${escapeAttribute(href)}" data-certrev-modal-open rel="
|
|
301
|
+
return `<a href="${escapeAttribute(href)}" data-certrev-modal-open rel="${CERTREV_LINK_REL}" style="${style}">${inner}</a>`
|
|
264
302
|
}
|
|
265
303
|
|
|
266
304
|
/**
|
|
@@ -273,14 +311,21 @@ function verificationBlock(facts: CertBlockFacts, withAccordion: boolean, placed
|
|
|
273
311
|
const showName = placed('bylinePlain') || showCredentialed
|
|
274
312
|
// Nothing placed ⇒ empty (the caller drops the wrapping toggle div).
|
|
275
313
|
if (!showName) return ''
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
314
|
+
// An UNPARSEABLE or blank verification date is dropped, not echoed. The old
|
|
315
|
+
// `formatDate(x) ?? escapeHtml(x)` asserted "Credential verified by CertREV on
|
|
316
|
+
// not-a-date", and left "on " dangling when blank — a disclosure that names a date
|
|
317
|
+
// it cannot stand behind is worse than one that names none. The attribution itself
|
|
318
|
+
// survives: it is true independently of when the credential was checked.
|
|
319
|
+
const verified = formatDate(facts.credentialVerifiedAt)
|
|
320
|
+
const showBio = withAccordion && hasExpandableBio(facts, placed)
|
|
279
321
|
const caret = showBio ? CARET : ''
|
|
280
322
|
const nameSize = withAccordion ? '15px' : '16px'
|
|
281
323
|
const muteSize = withAccordion ? '13px' : '12.5px'
|
|
324
|
+
// `hidden` alone, NOT an inline `display:none` — the accordion is revealed by an author
|
|
325
|
+
// rule on the toggled `.open` class (see modal/interactions.ts), and an inline declaration
|
|
326
|
+
// is the one thing such a rule can never beat.
|
|
282
327
|
const bio = showBio
|
|
283
|
-
? `<div class="
|
|
328
|
+
? `<div class="acc-bio" hidden style="margin-top:8px;font-size:12.5px;line-height:1.5;color:var(--cr-ink-soft);">${escapeHtml(facts.bio ?? '')}</div>`
|
|
284
329
|
: ''
|
|
285
330
|
// The credential suffix + the GROUPED attribution/date lines are all-or-nothing on
|
|
286
331
|
// `bylineCredentialed`; a plain byline shows the name only.
|
|
@@ -288,8 +333,8 @@ function verificationBlock(facts: CertBlockFacts, withAccordion: boolean, placed
|
|
|
288
333
|
? nameWithCredential(facts.reviewerName, facts.credential)
|
|
289
334
|
: escapeHtml(facts.reviewerName)
|
|
290
335
|
const attribution = showCredentialed
|
|
291
|
-
? `<div style="font-size:${muteSize};color:var(--navy-55);line-height:1.45;margin-top:3px;">${escapeHtml(
|
|
292
|
-
`<div style="font-size:${muteSize};color:var(--navy-55);line-height:1.35;">on ${verified}</div>`
|
|
336
|
+
? `<div style="font-size:${muteSize};color:var(--navy-55);line-height:1.45;margin-top:3px;">${escapeHtml(CREDENTIAL_VERIFIED_ATTRIBUTION)}</div>` +
|
|
337
|
+
(verified ? `<div style="font-size:${muteSize};color:var(--navy-55);line-height:1.35;">on ${verified}</div>` : '')
|
|
293
338
|
: ''
|
|
294
339
|
return (
|
|
295
340
|
`<div>` +
|
|
@@ -313,7 +358,7 @@ function rootStyle(theme: ResolvedBlockTheme): string {
|
|
|
313
358
|
`--ba:${theme.accentColor}`,
|
|
314
359
|
`--ba-fg:${theme.accentFg}`,
|
|
315
360
|
`--br:${theme.cornerRadius}`,
|
|
316
|
-
// The body font is a host-overridable HOOK
|
|
361
|
+
// The body font is a host-overridable HOOK: `--cr-bf` resolves a host-page
|
|
317
362
|
// `--bf` (set on any ancestor) with the fontSlot stack as the FALLBACK — so a storefront
|
|
318
363
|
// can theme the card font via plain CSS, no `!important` (the old inline `--bf:<stack>`
|
|
319
364
|
// declaration shadowed a host `--bf`). Unset ⇒ resolves to the stack ⇒ byte-identical render.
|
|
@@ -360,12 +405,11 @@ function root(mode: CertBlockLayout, theme: ResolvedBlockTheme, inner: string, f
|
|
|
360
405
|
}
|
|
361
406
|
|
|
362
407
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
363
|
-
//
|
|
408
|
+
// Face — BANNER: a certification header card + a separate expert-memo card
|
|
364
409
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
365
410
|
|
|
366
411
|
function bannerHeaderCard(facts: CertBlockFacts, placed: PlacedTest): string {
|
|
367
|
-
const
|
|
368
|
-
const certified = formatDate(facts.certifiedAt) ?? escapeHtml(facts.certifiedAt)
|
|
412
|
+
const certified = formatDate(facts.certifiedAt)
|
|
369
413
|
const subtitle =
|
|
370
414
|
placed('authorTitle') && facts.authorTitle
|
|
371
415
|
? `<div style="font-size:13px;color:var(--cr-ink-sub);">${escapeHtml(facts.authorTitle)}</div>`
|
|
@@ -374,7 +418,7 @@ function bannerHeaderCard(facts: CertBlockFacts, placed: PlacedTest): string {
|
|
|
374
418
|
// target (data-certrev-modal-open); this focusable <a> is the keyboard / SR / no-JS control.
|
|
375
419
|
const certHref = safeHttpUrl(facts.certificateUrl) ?? '#'
|
|
376
420
|
const headerArrow =
|
|
377
|
-
`<a href="${escapeAttribute(certHref)}" data-certrev-modal-open rel="
|
|
421
|
+
`<a href="${escapeAttribute(certHref)}" data-certrev-modal-open rel="${CERTREV_LINK_REL}" aria-label="View certificate" ` +
|
|
378
422
|
`style="display:inline-flex;align-items:center;flex-shrink:0;color:inherit;opacity:.72;text-decoration:none;">` +
|
|
379
423
|
`<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14"></path><path d="m12 5 7 7-7 7"></path></svg></a>`
|
|
380
424
|
// navy header — the trust bar stays fixed navy (accent theming is Phase B); mark + eyebrow + cert arrow
|
|
@@ -398,13 +442,33 @@ function bannerHeaderCard(facts: CertBlockFacts, placed: PlacedTest): string {
|
|
|
398
442
|
// reviewed by — emphasized label (navy, 700): the certification's authority voice
|
|
399
443
|
const reviewerAvatar = placed('reviewerPhoto') ? avatar(initials(facts.reviewerName), 42, 12) : ''
|
|
400
444
|
const reviewerBlock = verificationBlock(facts, true, placed)
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
445
|
+
// The interactive affordance is gated on there being a bio to expand — NOT on the
|
|
446
|
+
// reviewer row existing. `data-certrev-acc` is the hook modal/interactions.ts actually
|
|
447
|
+
// binds (click + Enter/Space); the old `data-certrev-bio-toggle` had zero consumers
|
|
448
|
+
// repo-wide, so the row was focusable, announced as a collapsed button, and inert.
|
|
449
|
+
const revRow = reviewerBlock
|
|
450
|
+
? hasExpandableBio(facts, placed)
|
|
451
|
+
? `<div class="certrev-cert__rev" data-certrev-acc role="button" tabindex="0" aria-expanded="false" aria-label="Read more about ${escapeAttribute(facts.reviewerName)}" style="display:flex;align-items:flex-start;gap:12px;cursor:pointer;border-radius:8px;transition:opacity .15s;">` +
|
|
452
|
+
`${reviewerAvatar}${reviewerBlock}` +
|
|
453
|
+
`</div>`
|
|
454
|
+
: `<div class="certrev-cert__rev" style="display:flex;align-items:flex-start;gap:12px;">` +
|
|
455
|
+
`${reviewerAvatar}${reviewerBlock}` +
|
|
456
|
+
`</div>`
|
|
405
457
|
: ''
|
|
406
|
-
// "Certified <date>" is card chrome
|
|
407
|
-
|
|
458
|
+
// The mono footer row: "Certified <date>" is card chrome, but an unusable `certifiedAt`
|
|
459
|
+
// used to emit the bare word "Certified" followed by nothing — so it is gated on the date
|
|
460
|
+
// PARSING, the same family as the memo/verification-date gates. The cue is engine-placed
|
|
461
|
+
// AND dropped for the pro-bono (`null`) reviewer. Neither ⇒ the hairline + row go too.
|
|
462
|
+
const certifiedSpan = certified ? `<span>Certified <span style="margin-left:8px;">${certified}</span></span>` : ''
|
|
463
|
+
const cueSpan = showsCompensationCue(facts, placed) ? `<span>${escapeHtml(COMPENSATED_EXPERT_CUE)}</span>` : ''
|
|
464
|
+
const footer =
|
|
465
|
+
certifiedSpan || cueSpan
|
|
466
|
+
? `<div style="height:1px;background:var(--navy-10);margin:16px 0 14px;"></div>` +
|
|
467
|
+
`<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;font-family:var(--font-mono);font-size:10.5px;font-weight:500;letter-spacing:.14em;text-transform:uppercase;color:var(--navy-55);">` +
|
|
468
|
+
certifiedSpan +
|
|
469
|
+
cueSpan +
|
|
470
|
+
`</div>`
|
|
471
|
+
: ''
|
|
408
472
|
return (
|
|
409
473
|
`<div style="border:1px solid var(--navy-10);border-radius:calc(var(--br,14px) * 0.7);overflow:hidden;font-family:var(--cr-bf);">` +
|
|
410
474
|
header +
|
|
@@ -413,43 +477,43 @@ function bannerHeaderCard(facts: CertBlockFacts, placed: PlacedTest): string {
|
|
|
413
477
|
authorColumn +
|
|
414
478
|
`<div>` +
|
|
415
479
|
`<div style="font-family:var(--font-mono);font-size:10px;font-weight:700;letter-spacing:.14em;text-transform:uppercase;color:var(--navy);margin-bottom:11px;">Reviewed by</div>` +
|
|
416
|
-
|
|
480
|
+
revRow +
|
|
417
481
|
`</div>` +
|
|
418
482
|
`</div>` +
|
|
419
483
|
// hairline + mono footer — "Certified <date>" + "Compensated expert", UNIFORM navy-55
|
|
420
|
-
|
|
421
|
-
`<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;font-family:var(--font-mono);font-size:10.5px;font-weight:500;letter-spacing:.14em;text-transform:uppercase;color:var(--navy-55);">` +
|
|
422
|
-
`<span>Certified <span style="margin-left:8px;">${certified}</span></span>` +
|
|
423
|
-
cueSpan +
|
|
424
|
-
`</div>` +
|
|
484
|
+
footer +
|
|
425
485
|
`</div>` +
|
|
426
486
|
`</div>`
|
|
427
487
|
)
|
|
428
488
|
}
|
|
429
489
|
|
|
430
490
|
function bannerMemoCard(facts: CertBlockFacts, placed: PlacedTest): string {
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
491
|
+
// The memo card composes `<name><, credential>` as two spans rather than through
|
|
492
|
+
// `nameWithCredential`, so it needs the SAME duplicate guard — the doubled "MD, MD" was
|
|
493
|
+
// visible on this card too. Gated on what SURVIVES the dedupe, not on the raw field: a
|
|
494
|
+
// credential the name already carries must drop the tail span whole, comma and all.
|
|
495
|
+
const credential = placed('bylineCredentialed') ? dedupeCredential(facts.reviewerName, facts.credential) : ''
|
|
496
|
+
const credTail = credential ? `<span style="color:var(--cr-ink-sub);">, ${escapeHtml(credential)}</span>` : ''
|
|
436
497
|
const first = firstNameOf(facts.reviewerName)
|
|
437
498
|
// The eyebrow + reviewer name row + memo quote are grouped under `memo`; the avatar
|
|
438
|
-
// inside it is gated on `reviewerPhoto`.
|
|
499
|
+
// inside it is gated on `reviewerPhoto`. Gated on the memo's CONTENT as well as its
|
|
500
|
+
// placement (the pattern `bio` already follows): placement alone painted an "Expert
|
|
501
|
+
// memo" heading over an empty `<p>` for a contract-legal empty memo.
|
|
439
502
|
const memoAvatar = placed('reviewerPhoto') ? avatar(initials(facts.reviewerName), 46, 13) : ''
|
|
440
|
-
const memoSection =
|
|
441
|
-
|
|
442
|
-
`<div style="
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
503
|
+
const memoSection =
|
|
504
|
+
placed('memo') && facts.memo
|
|
505
|
+
? `<div style="font-family:var(--font-mono);font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:var(--navy-55);margin-bottom:14px;">Expert memo</div>` +
|
|
506
|
+
`<div style="display:flex;gap:15px;">${memoAvatar}` +
|
|
507
|
+
`<div style="flex:1;min-width:0;">` +
|
|
508
|
+
`<div style="font-size:16px;line-height:1.2;color:var(--cr-ink);"><span style="font-weight:700;">${escapeHtml(facts.reviewerName)}</span>${credTail}</div>` +
|
|
509
|
+
// Readable, UPRIGHT DM Sans quote (the display-serif italic hurt legibility);
|
|
510
|
+
// pre-line preserves the reviewer's paragraph breaks. No left accent bar (matches the guide).
|
|
511
|
+
`<p style="margin:11px 0 14px;font-size:15.5px;line-height:1.6;color:var(--cr-ink-soft);white-space:pre-line;">${escapeHtml(facts.memo)}</p>` +
|
|
512
|
+
`</div>` +
|
|
513
|
+
`</div>`
|
|
514
|
+
: ''
|
|
451
515
|
// The memo footer's profile/certificate actions read as the JetBrains-mono uppercase stamp
|
|
452
|
-
// that matches the badge footer lockup
|
|
516
|
+
// that matches the badge footer lockup — navy, so they still read as actions.
|
|
453
517
|
const profile = placed('profileLink')
|
|
454
518
|
? profileLink(facts.profileUrl, `${escapeHtml(first)}'s Profile`, STAMP_LINK_STYLE)
|
|
455
519
|
: ''
|
|
@@ -457,7 +521,7 @@ function bannerMemoCard(facts: CertBlockFacts, placed: PlacedTest): string {
|
|
|
457
521
|
? certificateLink(facts.certificateUrl, 'Certificate', STAMP_LINK_STYLE)
|
|
458
522
|
: ''
|
|
459
523
|
const scope = placed('scopeLine')
|
|
460
|
-
? `<div style="font-size:13px;color:var(--navy-55);margin-top:8px;line-height:1.5;">${escapeHtml(
|
|
524
|
+
? `<div style="font-size:13px;color:var(--navy-55);margin-top:8px;line-height:1.5;">${escapeHtml(CERT_SCOPE_LINE)}</div>`
|
|
461
525
|
: ''
|
|
462
526
|
return (
|
|
463
527
|
`<div style="border:1px solid var(--navy-10);border-radius:calc(var(--br,14px) * 0.7);padding:24px;font-family:var(--cr-bf);">` +
|
|
@@ -480,12 +544,10 @@ function bannerMemoCard(facts: CertBlockFacts, placed: PlacedTest): string {
|
|
|
480
544
|
}
|
|
481
545
|
|
|
482
546
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
483
|
-
//
|
|
547
|
+
// Face — SIDEBAR (the DEFAULT): a reviewer card pinned beside the article body
|
|
484
548
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
485
549
|
|
|
486
550
|
function sidebarCard(facts: CertBlockFacts, placed: PlacedTest): string {
|
|
487
|
-
const compensationCue = locked(facts.compensationCue, COMPENSATED_EXPERT_CUE)
|
|
488
|
-
const scopeLine = locked(facts.scopeLine, CERT_SCOPE_LINE)
|
|
489
551
|
const header = placed('label')
|
|
490
552
|
? `<div style="display:flex;align-items:center;justify-content:center;gap:10px;padding:12px;background:var(--certrev-bar-bg,#0a1b3f);color:var(--certrev-bar-fg,#fff);font-family:var(--font-mono);font-size:12px;font-weight:700;letter-spacing:.16em;text-transform:uppercase;">${logo(18)} Expert reviewed</div>`
|
|
491
553
|
: ''
|
|
@@ -494,14 +556,17 @@ function sidebarCard(facts: CertBlockFacts, placed: PlacedTest): string {
|
|
|
494
556
|
placed('bio') && facts.bio
|
|
495
557
|
? `<p style="font-size:14px;line-height:1.55;color:var(--cr-ink-soft);margin:16px 0 0;">${escapeHtml(facts.bio)}</p>`
|
|
496
558
|
: ''
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
559
|
+
// Gated on CONTENT as well as placement (like `bioPara` above) — placement alone rendered
|
|
560
|
+
// an empty accent-bordered blockquote for a contract-legal empty memo.
|
|
561
|
+
const blockquote =
|
|
562
|
+
placed('memo') && facts.memo
|
|
563
|
+
? `<blockquote style="margin:16px 0 0;padding-left:16px;border-left:3px solid var(--ba,#0a1b3f);font-size:15px;line-height:1.5;color:var(--cr-ink-soft);white-space:pre-line;">${escapeHtml(facts.memo)}</blockquote>`
|
|
564
|
+
: ''
|
|
500
565
|
// Disclosure row: cue + ` · ` + scope; the separator only when BOTH land, the row only when either does.
|
|
501
|
-
const cueEl = placed
|
|
502
|
-
? `<span style="font-family:var(--font-mono);font-size:10px;letter-spacing:.1em;text-transform:uppercase;">${escapeHtml(
|
|
566
|
+
const cueEl = showsCompensationCue(facts, placed)
|
|
567
|
+
? `<span style="font-family:var(--font-mono);font-size:10px;letter-spacing:.1em;text-transform:uppercase;">${escapeHtml(COMPENSATED_EXPERT_CUE)}</span>`
|
|
503
568
|
: ''
|
|
504
|
-
const scopeEl = placed('scopeLine') ? escapeHtml(
|
|
569
|
+
const scopeEl = placed('scopeLine') ? escapeHtml(CERT_SCOPE_LINE) : ''
|
|
505
570
|
const disclosure =
|
|
506
571
|
cueEl || scopeEl
|
|
507
572
|
? `<div style="font-size:12.5px;line-height:1.5;color:var(--navy-55);margin-top:16px;">${cueEl}${cueEl && scopeEl ? ' · ' : ''}${scopeEl}</div>`
|
|
@@ -546,7 +611,7 @@ function sidebarCard(facts: CertBlockFacts, placed: PlacedTest): string {
|
|
|
546
611
|
}
|
|
547
612
|
|
|
548
613
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
549
|
-
//
|
|
614
|
+
// Face — FLOATING: a compact pinned pill (a modal pointer, NOT a claim face)
|
|
550
615
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
551
616
|
|
|
552
617
|
function floatingPill(facts: CertBlockFacts, placed: PlacedTest): string {
|
|
@@ -584,10 +649,17 @@ function floatingPill(facts: CertBlockFacts, placed: PlacedTest): string {
|
|
|
584
649
|
}
|
|
585
650
|
|
|
586
651
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
587
|
-
//
|
|
588
|
-
// EXACTLY the placed fields, in canonical order, from the SAME escaped
|
|
589
|
-
// data-certrev
|
|
590
|
-
//
|
|
652
|
+
// Face — CUSTOM: the engine's free-composition face — ONE vertical stack of
|
|
653
|
+
// EXACTLY the placed fields, in canonical order, from the SAME escaped, constant-fed,
|
|
654
|
+
// `data-certrev-*`-hooked building blocks the locked layouts use, so the custom face
|
|
655
|
+
// carries the same markers as banner/sidebar and the crawl monitor reads them alike.
|
|
656
|
+
//
|
|
657
|
+
// NOT the ftc-guard, though — and the comment here claimed otherwise for as long as the
|
|
658
|
+
// face has existed. `modal/ftc-guard.ts` looks for `data-ftc-line` / `data-ftc-disclosure`
|
|
659
|
+
// hooks, which `CertBadge.tsx` and `render-badge-html.ts` emit and this renderer emits at
|
|
660
|
+
// NONE of its three scope-line sites. The kill switch therefore cannot see anything
|
|
661
|
+
// renderCertBlock produces, on ANY face. Whether it should is a live design question
|
|
662
|
+
// (which surfaces the switch covers) — not something to settle by quietly adding hooks.
|
|
591
663
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
592
664
|
|
|
593
665
|
function renderCustomFace(facts: CertBlockFacts, placed: PlacedTest): string {
|
|
@@ -629,18 +701,18 @@ function renderCustomFace(facts: CertBlockFacts, placed: PlacedTest): string {
|
|
|
629
701
|
)
|
|
630
702
|
}
|
|
631
703
|
|
|
632
|
-
// 5. memo (sidebar's blockquote)
|
|
633
|
-
if (placed('memo')) {
|
|
704
|
+
// 5. memo (sidebar's blockquote) — placement AND content, like `bio` directly above
|
|
705
|
+
if (placed('memo') && facts.memo) {
|
|
634
706
|
parts.push(
|
|
635
707
|
`<blockquote style="margin:16px 0 0;padding-left:16px;border-left:3px solid var(--ba,#0a1b3f);font-size:15px;line-height:1.5;color:var(--cr-ink-soft);white-space:pre-line;">${escapeHtml(facts.memo)}</blockquote>`,
|
|
636
708
|
)
|
|
637
709
|
}
|
|
638
710
|
|
|
639
711
|
// 6. disclosures (sidebar's cue · scope row; separator only when BOTH land)
|
|
640
|
-
const cueEl = placed
|
|
641
|
-
? `<span style="font-family:var(--font-mono);font-size:10px;letter-spacing:.1em;text-transform:uppercase;">${escapeHtml(
|
|
712
|
+
const cueEl = showsCompensationCue(facts, placed)
|
|
713
|
+
? `<span style="font-family:var(--font-mono);font-size:10px;letter-spacing:.1em;text-transform:uppercase;">${escapeHtml(COMPENSATED_EXPERT_CUE)}</span>`
|
|
642
714
|
: ''
|
|
643
|
-
const scopeEl = placed('scopeLine') ? escapeHtml(
|
|
715
|
+
const scopeEl = placed('scopeLine') ? escapeHtml(CERT_SCOPE_LINE) : ''
|
|
644
716
|
if (cueEl || scopeEl) {
|
|
645
717
|
parts.push(
|
|
646
718
|
`<div style="font-size:12.5px;line-height:1.5;color:var(--navy-55);margin-top:16px;">${cueEl}${cueEl && scopeEl ? ' · ' : ''}${scopeEl}</div>`,
|
|
@@ -671,15 +743,19 @@ function renderCustomFace(facts: CertBlockFacts, placed: PlacedTest): string {
|
|
|
671
743
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
672
744
|
|
|
673
745
|
/**
|
|
674
|
-
* Render the LOCKED CertREV cert design as an SSR-safe HTML string for one
|
|
675
|
-
*
|
|
676
|
-
*
|
|
746
|
+
* Render the LOCKED CertREV cert design as an SSR-safe HTML string for one placement
|
|
747
|
+
* face. Returns a self-contained `<div class="certrev-cert …">` that needs no JS to
|
|
748
|
+
* render (the modal + bio accordion progressively enhance).
|
|
749
|
+
*
|
|
750
|
+
* `mode` absent or unrecognised ⇒ `sidebar`. Each arm passes its own LITERAL to
|
|
751
|
+
* `root()` — never `input.mode` — so an untyped `mode` can reach neither the root class
|
|
752
|
+
* name nor the `data-certrev-mode` attribute.
|
|
677
753
|
*/
|
|
678
754
|
export function renderCertBlock(input: RenderCertBlockInput): string {
|
|
679
755
|
const theme = resolveBlockTheme(input.theme)
|
|
680
|
-
const { facts,
|
|
756
|
+
const { facts, face } = input
|
|
681
757
|
const placed = placedTester(face)
|
|
682
|
-
switch (mode) {
|
|
758
|
+
switch (input.mode) {
|
|
683
759
|
case 'banner': {
|
|
684
760
|
// Suppress the memo card entirely when the engine placed none of its fields
|
|
685
761
|
// (avoids an empty bordered shell). Grandfather: all placed ⇒ card renders.
|
|
@@ -687,23 +763,30 @@ export function renderCertBlock(input: RenderCertBlockInput): string {
|
|
|
687
763
|
placed('memo') || placed('profileLink') || placed('certificateLink') || placed('scopeLine')
|
|
688
764
|
? bannerMemoCard(facts, placed)
|
|
689
765
|
: ''
|
|
690
|
-
// First-class memo split
|
|
766
|
+
// First-class memo split: default 'full' is byte-identical to the prior
|
|
691
767
|
// header+memo render; 'header'/'memo' emit only that card so the exporter can place
|
|
692
|
-
// the memo as its own block (retires the CertReviewCard string-split shim).
|
|
693
|
-
|
|
768
|
+
// the memo as its own block (retires the CertReviewCard string-split shim). An
|
|
769
|
+
// unknown `part` falls back to 'full' by this written rule, not by a catch-all arm.
|
|
770
|
+
const part = input.part === 'header' || input.part === 'memo' ? input.part : 'full'
|
|
694
771
|
const inner =
|
|
695
772
|
part === 'header'
|
|
696
773
|
? bannerHeaderCard(facts, placed)
|
|
697
774
|
: part === 'memo'
|
|
698
775
|
? memoCard
|
|
699
776
|
: bannerHeaderCard(facts, placed) + memoCard
|
|
700
|
-
return root(
|
|
777
|
+
return root('banner', theme, inner, face)
|
|
701
778
|
}
|
|
702
|
-
case 'sidebar':
|
|
703
|
-
return root(mode, theme, sidebarCard(facts, placed), face)
|
|
704
779
|
case 'floating':
|
|
705
|
-
return root(
|
|
780
|
+
return root('floating', theme, floatingPill(facts, placed), face)
|
|
706
781
|
case 'custom':
|
|
707
|
-
return root(
|
|
782
|
+
return root('custom', theme, renderCustomFace(facts, placed), face)
|
|
783
|
+
// THE default placement, named here and nowhere else: the declared default and the
|
|
784
|
+
// runtime fallback are the same line, so they cannot drift apart. Naming `sidebar` as a
|
|
785
|
+
// real case also keeps the exhaustiveness read over CertBlockLayout honest — a reader
|
|
786
|
+
// sees all four faces handled — while the shared `default:` catches untyped input.
|
|
787
|
+
// biome-ignore lint/complexity/noUselessSwitchCase: the redundancy IS the mechanism (see above)
|
|
788
|
+
case 'sidebar':
|
|
789
|
+
default:
|
|
790
|
+
return root('sidebar', theme, sidebarCard(facts, placed), face)
|
|
708
791
|
}
|
|
709
792
|
}
|
|
@@ -4,19 +4,18 @@
|
|
|
4
4
|
* layer) + the two always-on COMPLIANCE strings.
|
|
5
5
|
* ─────────────────────────────────────────────────────────────────────────────
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* that must paint the SAME compliant, optionally-themed badge from the SAME facts.
|
|
7
|
+
* This is the Builder / headless projection twin's half of the "law-as-code"
|
|
8
|
+
* contract: cert-block is one of the four render surfaces (Shopify Liquid,
|
|
9
|
+
* WordPress PHP, JS engine, and this) that must paint the SAME compliant,
|
|
10
|
+
* optionally-themed badge from the SAME facts.
|
|
12
11
|
*
|
|
13
12
|
* cert-block is a self-contained npm package — it does NOT import the portal's
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* design-guide enforcement core. Instead it mirrors, at the RENDER edge, the
|
|
14
|
+
* small subset that shape the projection carries:
|
|
16
15
|
*
|
|
17
16
|
* 1. The four allowlisted THEMEABLE tokens `{accentColor, surface, cornerRadius,
|
|
18
17
|
* fontSlot}`, emitted as the exact `--certrev-*` CSS custom properties the
|
|
19
|
-
* Shopify/WordPress engines emit and the
|
|
18
|
+
* Shopify/WordPress engines emit and the crawl monitor asserts
|
|
20
19
|
* (`--certrev-accent` / `--certrev-surface` / `--certrev-radius` /
|
|
21
20
|
* `--certrev-font`). Each is RE-VALIDATED here at render time (defense in
|
|
22
21
|
* depth over the write-path fail-closed gate) against the SAME strict
|
|
@@ -42,7 +41,7 @@
|
|
|
42
41
|
*/
|
|
43
42
|
|
|
44
43
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
45
|
-
// Themeable token vocabulary (mirrors portal
|
|
44
|
+
// Themeable token vocabulary (mirrors the portal's design-guide token module)
|
|
46
45
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
47
46
|
|
|
48
47
|
/** The font ENUM. Resolves to a FIXED slot stack — never a free-form family. */
|
|
@@ -52,9 +51,9 @@ export type FontSlot = (typeof FONT_SLOTS)[number]
|
|
|
52
51
|
/**
|
|
53
52
|
* The `fontSlot` → fixed font-stack literal — the render-def theme map the webcomponent MODAL
|
|
54
53
|
* (`resolveRenderTheme` → `renderBadgeHtml`) and the legacy `<CertBadge>` resolve. The three original
|
|
55
|
-
* slots (sans/serif/system) stay BYTE-IDENTICAL to the Shopify Liquid engine's
|
|
56
|
-
*
|
|
57
|
-
*
|
|
54
|
+
* slots (sans/serif/system) stay BYTE-IDENTICAL to the Shopify Liquid engine's badge-snippet
|
|
55
|
+
* stacks, so a themed brand's popup modal and its inline Liquid card paint the same
|
|
56
|
+
* typography — the load-bearing card==modal invariant.
|
|
58
57
|
* grotesk (Plus Jakarta Sans) + mono (JetBrains Mono) are ADDED at the shared guide values —
|
|
59
58
|
* byte-identical to portal `FONT_SLOT_STACKS`, this package's `RENDER_BLOCK_FONT_STACKS`, and the
|
|
60
59
|
* Liquid twin's added branches — so card == modal holds for them too. A raw family string in a
|
|
@@ -203,8 +202,8 @@ export function isFieldHidden(def: CertRenderDef | undefined, id: BadgeVisibilit
|
|
|
203
202
|
// map pins them to the guide table; grotesk/mono are byte-identical across both.
|
|
204
203
|
|
|
205
204
|
/**
|
|
206
|
-
* The full portal font-slot enum (mirrors portal
|
|
207
|
-
* `
|
|
205
|
+
* The full portal font-slot enum (mirrors the portal design-guide's `FONT_SLOTS`).
|
|
206
|
+
* `grotesk` ⇐ the guide's wordmark slot; `mono` ⇐ the label slot.
|
|
208
207
|
*/
|
|
209
208
|
export const RENDER_BLOCK_FONT_SLOTS = ['sans', 'serif', 'system', 'grotesk', 'mono'] as const
|
|
210
209
|
export type RenderBlockFontSlot = (typeof RENDER_BLOCK_FONT_SLOTS)[number]
|
|
@@ -244,7 +243,7 @@ function parseHexColor(hex: string): { r: number; g: number; b: number } | null
|
|
|
244
243
|
|
|
245
244
|
/**
|
|
246
245
|
* AUTO-CONTRAST PICKER — the ink painted over an opaque fill. BYTE-FAITHFUL to the portal's
|
|
247
|
-
*
|
|
246
|
+
* design-guide `accentFg`: Rec.601 perceptual luma with a strict `> 0.62`
|
|
248
247
|
* cutoff, returning EXACTLY `#141414` (near-black) or `#ffffff`; an unparseable value falls back
|
|
249
248
|
* to the dark ink (safe default). Used at the render edge to DERIVE `barInkFg` from a themed
|
|
250
249
|
* `barInk` (v2 Phase 1), so the bar foreground always clears its own fill with no separate stored
|
package/src/index.ts
CHANGED
|
@@ -36,12 +36,14 @@ export {
|
|
|
36
36
|
type ResolvedDisplay,
|
|
37
37
|
resolveDisplay,
|
|
38
38
|
} from './components/format.js'
|
|
39
|
-
// ── Pre-sales PREVIEW block — static, non-delivery, hard-bounded
|
|
39
|
+
// ── Pre-sales PREVIEW block — static, non-delivery, hard-bounded ──
|
|
40
40
|
export {
|
|
41
41
|
type PreviewBlockOptions,
|
|
42
42
|
previewExpiry,
|
|
43
43
|
renderPreviewBlock,
|
|
44
44
|
} from './components/preview-block.js'
|
|
45
|
+
// ── Link qualification — the `rel` every anchor this package emits carries (read-only) ──
|
|
46
|
+
export { CERTREV_LINK_REL } from './components/rel.js'
|
|
45
47
|
// ── Cert-render BLOCK — the LOCKED 3-mode cert design as an SSR-safe HTML string ──
|
|
46
48
|
export {
|
|
47
49
|
type CertBlockFace,
|
|
@@ -51,7 +53,7 @@ export {
|
|
|
51
53
|
type RenderCertBlockInput,
|
|
52
54
|
renderCertBlock,
|
|
53
55
|
} from './components/render-cert-block.js'
|
|
54
|
-
// ── Design-guide render-def support (
|
|
56
|
+
// ── Design-guide render-def support (themeable tokens + subtractive visibility) ──
|
|
55
57
|
export {
|
|
56
58
|
accentFg,
|
|
57
59
|
type BadgeVisibilityFieldId,
|