@certrev/cert-block 0.3.0 → 0.5.1
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/README.md +48 -1
- package/dist/builder/cert-chrome-data.d.ts +129 -0
- package/dist/builder/cert-chrome-data.d.ts.map +1 -0
- package/dist/builder/cert-chrome-data.js +59 -0
- package/dist/builder/cert-chrome-data.js.map +1 -0
- package/dist/builder/cert-review-card.d.ts +68 -0
- package/dist/builder/cert-review-card.d.ts.map +1 -0
- package/dist/builder/cert-review-card.js +129 -0
- package/dist/builder/cert-review-card.js.map +1 -0
- package/dist/builder/index.d.ts +2 -0
- package/dist/builder/index.d.ts.map +1 -1
- package/dist/builder/index.js +3 -0
- package/dist/builder/index.js.map +1 -1
- package/dist/components/preview-block.d.ts +43 -0
- package/dist/components/preview-block.d.ts.map +1 -0
- package/dist/components/preview-block.js +53 -0
- package/dist/components/preview-block.js.map +1 -0
- package/dist/components/render-cert-block.d.ts +50 -7
- package/dist/components/render-cert-block.d.ts.map +1 -1
- package/dist/components/render-cert-block.js +304 -80
- package/dist/components/render-cert-block.js.map +1 -1
- package/dist/components/render-def.d.ts +27 -0
- package/dist/components/render-def.d.ts.map +1 -1
- package/dist/components/render-def.js +38 -0
- package/dist/components/render-def.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/modal/avatar-resize.d.ts +60 -0
- package/dist/modal/avatar-resize.d.ts.map +1 -0
- package/dist/modal/avatar-resize.js +91 -0
- package/dist/modal/avatar-resize.js.map +1 -0
- package/dist/modal/cert-modal-view.d.ts +74 -0
- package/dist/modal/cert-modal-view.d.ts.map +1 -0
- package/dist/modal/cert-modal-view.js +160 -0
- package/dist/modal/cert-modal-view.js.map +1 -0
- package/dist/modal/certrev-cert-modal.d.ts +72 -0
- package/dist/modal/certrev-cert-modal.d.ts.map +1 -0
- package/dist/modal/certrev-cert-modal.js +270 -0
- package/dist/modal/certrev-cert-modal.js.map +1 -0
- package/dist/modal/certrev-cert-modal.styles.d.ts +35 -0
- package/dist/modal/certrev-cert-modal.styles.d.ts.map +1 -0
- package/dist/modal/certrev-cert-modal.styles.js +488 -0
- package/dist/modal/certrev-cert-modal.styles.js.map +1 -0
- package/dist/modal/certrev-cert.entry.d.ts +19 -0
- package/dist/modal/certrev-cert.entry.d.ts.map +1 -0
- package/dist/modal/certrev-cert.entry.js +39 -0
- package/dist/modal/certrev-cert.entry.js.map +1 -0
- package/dist/modal/certrev-cert.js +557 -0
- package/dist/modal/display-strings.d.ts +22 -0
- package/dist/modal/display-strings.d.ts.map +1 -0
- package/dist/modal/display-strings.js +35 -0
- package/dist/modal/display-strings.js.map +1 -0
- package/dist/modal/escape.d.ts +13 -0
- package/dist/modal/escape.d.ts.map +1 -0
- package/dist/modal/escape.js +20 -0
- package/dist/modal/escape.js.map +1 -0
- package/dist/modal/font-face.d.ts +4 -0
- package/dist/modal/font-face.d.ts.map +1 -0
- package/dist/modal/font-face.js +137 -0
- package/dist/modal/font-face.js.map +1 -0
- package/dist/modal/ftc-disclosure.d.ts +12 -0
- package/dist/modal/ftc-disclosure.d.ts.map +1 -0
- package/dist/modal/ftc-disclosure.js +12 -0
- package/dist/modal/ftc-disclosure.js.map +1 -0
- package/dist/modal/ftc-guard.d.ts +50 -0
- package/dist/modal/ftc-guard.d.ts.map +1 -0
- package/dist/modal/ftc-guard.js +117 -0
- package/dist/modal/ftc-guard.js.map +1 -0
- package/dist/modal/index.d.ts +20 -0
- package/dist/modal/index.d.ts.map +1 -0
- package/dist/modal/index.js +25 -0
- package/dist/modal/index.js.map +1 -0
- package/dist/modal/interactions.d.ts +53 -0
- package/dist/modal/interactions.d.ts.map +1 -0
- package/dist/modal/interactions.js +147 -0
- package/dist/modal/interactions.js.map +1 -0
- package/dist/modal/logo-paths.d.ts +15 -0
- package/dist/modal/logo-paths.d.ts.map +1 -0
- package/dist/modal/logo-paths.js +24 -0
- package/dist/modal/logo-paths.js.map +1 -0
- package/dist/modal/placement.d.ts +95 -0
- package/dist/modal/placement.d.ts.map +1 -0
- package/dist/modal/placement.js +302 -0
- package/dist/modal/placement.js.map +1 -0
- package/dist/modal/revalidate.d.ts +48 -0
- package/dist/modal/revalidate.d.ts.map +1 -0
- package/dist/modal/revalidate.js +65 -0
- package/dist/modal/revalidate.js.map +1 -0
- package/dist/modal/verify-client.d.ts +24 -0
- package/dist/modal/verify-client.d.ts.map +1 -0
- package/dist/modal/verify-client.js +38 -0
- package/dist/modal/verify-client.js.map +1 -0
- package/package.json +14 -4
- package/src/__tests__/__snapshots__/render-cert-block.test.ts.snap +10 -6
- package/src/__tests__/preview-block.test.ts +84 -0
- package/src/__tests__/render-cert-block.test.ts +380 -5
- package/src/builder/__tests__/builder.test.tsx +71 -0
- package/src/builder/cert-chrome-data.ts +159 -0
- package/src/builder/cert-review-card.tsx +177 -0
- package/src/builder/index.tsx +18 -0
- package/src/components/preview-block.ts +74 -0
- package/src/components/render-cert-block.ts +387 -102
- package/src/components/render-def.ts +55 -0
- package/src/index.ts +9 -0
- package/src/modal/avatar-resize.ts +99 -0
- package/src/modal/cert-modal-view.test.ts +175 -0
- package/src/modal/cert-modal-view.ts +210 -0
- package/src/modal/certrev-cert-modal.styles.ts +494 -0
- package/src/modal/certrev-cert-modal.test.ts +125 -0
- package/src/modal/certrev-cert-modal.ts +273 -0
- package/src/modal/certrev-cert.entry.ts +44 -0
- package/src/modal/display-strings.ts +38 -0
- package/src/modal/escape.ts +20 -0
- package/src/modal/font-face.ts +138 -0
- package/src/modal/ftc-disclosure.ts +13 -0
- package/src/modal/ftc-guard.test.ts +153 -0
- package/src/modal/ftc-guard.ts +119 -0
- package/src/modal/index.ts +34 -0
- package/src/modal/interactions.test.ts +250 -0
- package/src/modal/interactions.ts +166 -0
- package/src/modal/logo-paths.ts +23 -0
- package/src/modal/placement.test.ts +398 -0
- package/src/modal/placement.ts +331 -0
- package/src/modal/revalidate.test.ts +111 -0
- package/src/modal/revalidate.ts +81 -0
- package/src/modal/verify-client.test.ts +164 -0
- package/src/modal/verify-client.ts +42 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FTC disclosure tamper guard for the NATIVE Shopify cert render (iframe→native parity).
|
|
3
|
+
*
|
|
4
|
+
* In the cross-origin iframe model the expert-memo embed inlined `ftcTamperGuardScript()`
|
|
5
|
+
* (src/lib/embed/ftc-disclosure.ts): a kill switch that blanks the memo if the verbatim FTC
|
|
6
|
+
* material-connection disclosure is stripped, hidden, or altered — making the MSA §3
|
|
7
|
+
* anti-stripping covenant TECHNICALLY enforced, not just contractual. The native memo renders
|
|
8
|
+
* in the host theme's DOM (no sandbox), so without this it would lose that runtime enforcement.
|
|
9
|
+
* This module restores it: on load + on later DOM mutation it verifies the in-DOM
|
|
10
|
+
* `[data-ftc-line]` is present, carries the EXACT verbatim text, and is actually visible; if not,
|
|
11
|
+
* it neutralizes the memo with a neutral notice so the memo NEVER renders without its disclosure.
|
|
12
|
+
*
|
|
13
|
+
* Native-specific semantics (differ from the iframe, which only existed when a memo did):
|
|
14
|
+
* - Enforcement is keyed on the MEMO element (`.certrev-memo`). A certified article with NO
|
|
15
|
+
* memo legitimately has no `.certrev-memo` and no disclosure — the guard does NOT fire
|
|
16
|
+
* (no false positive on the no-memo path).
|
|
17
|
+
* - On tamper it neutralizes ONLY the memo (never `document.body` — we're in the host page).
|
|
18
|
+
* - The contributors card is governed by the badge re-verify (`revalidate.ts`), not this guard;
|
|
19
|
+
* the card carries only the one-word "Compensated" cue, never the guarded disclosure.
|
|
20
|
+
*
|
|
21
|
+
* Pure DOM, no crypto, no network. The expected text is the single source of truth
|
|
22
|
+
* `FTC_DISCLOSURE_LINE` (imported, not copied — so a reword can never desync this guard).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { FTC_DISCLOSURE_LINE } from './ftc-disclosure.js'
|
|
26
|
+
|
|
27
|
+
export const FTC_LINE_SELECTOR = '[data-ftc-line]'
|
|
28
|
+
export const MEMO_SELECTOR = '.certrev-memo'
|
|
29
|
+
export const MEMO_WRAP_SELECTOR = '.certrev-memo-wrap'
|
|
30
|
+
/** Marker on the neutral notice node that replaces a tampered memo (testable / idempotent). */
|
|
31
|
+
export const FTC_NEUTRALIZED_ATTR = 'data-certrev-ftc-neutralized'
|
|
32
|
+
|
|
33
|
+
/** Documents already guarded, so a double script-load can't double-bind. */
|
|
34
|
+
const guardedDocs = new WeakSet<Document>()
|
|
35
|
+
|
|
36
|
+
/** Visible = not display:none / visibility:hidden / opacity:0 and a non-zero box. */
|
|
37
|
+
function isVisible(el: Element): boolean {
|
|
38
|
+
const win = el.ownerDocument.defaultView
|
|
39
|
+
// No view (detached / SSR): we can't measure, so don't treat as hidden — the text/presence
|
|
40
|
+
// checks still apply; only the layout-based hide vector is skipped.
|
|
41
|
+
if (!win) return true
|
|
42
|
+
const s = win.getComputedStyle(el)
|
|
43
|
+
if (s.display === 'none' || s.visibility === 'hidden' || Number.parseFloat(s.opacity || '1') === 0) return false
|
|
44
|
+
const r = el.getBoundingClientRect()
|
|
45
|
+
return r.width > 0 && r.height > 0
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** True when a memo's disclosure line is present, exactly verbatim, and visible. */
|
|
49
|
+
export function isDisclosureIntact(memo: Element): boolean {
|
|
50
|
+
const line = memo.querySelector(FTC_LINE_SELECTOR)
|
|
51
|
+
if (!line) return false
|
|
52
|
+
if ((line.textContent || '').trim() !== FTC_DISCLOSURE_LINE) return false
|
|
53
|
+
return isVisible(line)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Replace a tampered memo with a neutral, self-contained notice — no reviewer attribution, no
|
|
58
|
+
* seal — so the surface can never read as an endorsement without its required disclosure.
|
|
59
|
+
*/
|
|
60
|
+
export function neutralizeMemo(memo: Element): void {
|
|
61
|
+
const doc = memo.ownerDocument
|
|
62
|
+
const notice = doc.createElement('div')
|
|
63
|
+
notice.setAttribute(FTC_NEUTRALIZED_ATTR, '')
|
|
64
|
+
notice.style.cssText =
|
|
65
|
+
'font-family:system-ui,-apple-system,sans-serif;font-size:12px;color:#555;padding:12px;line-height:1.4'
|
|
66
|
+
notice.innerHTML =
|
|
67
|
+
'This CertREV verification cannot be displayed because its required disclosure was not shown. ' +
|
|
68
|
+
'<a href="https://certrev.com" style="color:#555" target="_blank" rel="noopener noreferrer">Learn more</a>.'
|
|
69
|
+
memo.replaceWith(notice)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Enforce the disclosure across every memo in the doc: neutralize any whose disclosure isn't
|
|
74
|
+
* intact. A doc with no `.certrev-memo` (no-memo article) is a no-op. Returns true if all memos
|
|
75
|
+
* present were intact (or none existed).
|
|
76
|
+
*/
|
|
77
|
+
export function enforceFtcDisclosure(doc: Document): boolean {
|
|
78
|
+
let allIntact = true
|
|
79
|
+
for (const memo of Array.from(doc.querySelectorAll(MEMO_SELECTOR))) {
|
|
80
|
+
if (!isDisclosureIntact(memo)) {
|
|
81
|
+
allIntact = false
|
|
82
|
+
neutralizeMemo(memo)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return allIntact
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Install the guard once per document: enforce now (on DOM ready) and re-enforce on later DOM
|
|
90
|
+
* mutations within the memo wrap(s) — so a host script that strips / hides / edits the disclosure
|
|
91
|
+
* after load still trips the kill switch. Scoping the observer to the small `.certrev-memo-wrap`
|
|
92
|
+
* subtree(s) keeps it cheap on a busy storefront page (cf. the iframe, which observed its whole
|
|
93
|
+
* — tiny — body). Idempotent.
|
|
94
|
+
*/
|
|
95
|
+
export function installFtcGuard(doc: Document): void {
|
|
96
|
+
if (guardedDocs.has(doc)) return
|
|
97
|
+
guardedDocs.add(doc)
|
|
98
|
+
|
|
99
|
+
const run = (): void => {
|
|
100
|
+
enforceFtcDisclosure(doc)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const start = (): void => {
|
|
104
|
+
run()
|
|
105
|
+
const MO = doc.defaultView?.MutationObserver
|
|
106
|
+
if (!MO) return
|
|
107
|
+
// Observe the stable memo wrap(s) — placement.js relocates the wrap as a node, so the
|
|
108
|
+
// observer tracks it wherever it moves. Fall back to <body> only if no wrap exists.
|
|
109
|
+
const wraps = Array.from(doc.querySelectorAll(MEMO_WRAP_SELECTOR))
|
|
110
|
+
const scope = wraps.length ? wraps : doc.body ? [doc.body] : []
|
|
111
|
+
for (const node of scope) {
|
|
112
|
+
const mo = new MO(run)
|
|
113
|
+
mo.observe(node, { childList: true, subtree: true, characterData: true, attributes: true })
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (doc.readyState === 'loading') doc.addEventListener('DOMContentLoaded', start, { once: true })
|
|
118
|
+
else start()
|
|
119
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@certrev/cert-block/modal` — the browser cert MODAL + badge-revalidation Web Component
|
|
3
|
+
* (POR-10721 W2). A BYTE-FAITHFUL relocation of portal `src/lib/cert-delivery/wc/`; the IIFE
|
|
4
|
+
* built from `certrev-cert.entry.ts` (`build:modal-bundle`) is structurally byte-identical to the
|
|
5
|
+
* Shopify-deployed `certrev-cert.js`, so a headless brand can drop the SAME verified modal onto its
|
|
6
|
+
* storefront (`<script src=".../dist/modal/certrev-cert.js">`) instead of vendoring a copy.
|
|
7
|
+
*
|
|
8
|
+
* Importing this module as ESM has the SAME side effect as loading the IIFE: it registers the
|
|
9
|
+
* `<certrev-cert-modal>` element and (in a browser) runs placement + interactions + FTC guard +
|
|
10
|
+
* badge revalidation. Bundler users: `import '@certrev/cert-block/modal'`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// Side-effect: register the element + (in a browser) kick off the DOM passes.
|
|
14
|
+
import "./certrev-cert.entry.js";
|
|
15
|
+
|
|
16
|
+
// The published issuer PUBLIC key — re-exported so a portal-side test can assert the package's
|
|
17
|
+
// bundled key matches portal's signer (`CERT_ENVELOPE_*`) after portal re-points at this package
|
|
18
|
+
// (the cross-repo "published key sync" guard that used to live in wc/revalidate.test.ts).
|
|
19
|
+
export {
|
|
20
|
+
CERT_ISSUER_KID,
|
|
21
|
+
CERT_ISSUER_PUBLIC_KEY_PEM,
|
|
22
|
+
} from "./certrev-cert.entry.js";
|
|
23
|
+
|
|
24
|
+
// Composable entry points for advanced consumers (the IIFE wires these itself).
|
|
25
|
+
export { CERT_MODAL_TAG } from "./certrev-cert-modal.js";
|
|
26
|
+
export { installFtcGuard } from "./ftc-guard.js";
|
|
27
|
+
export { initCertInteractionsOnReady } from "./interactions.js";
|
|
28
|
+
export { initCertPlacement } from "./placement.js";
|
|
29
|
+
export {
|
|
30
|
+
initBadgeRevalidation,
|
|
31
|
+
type RevalidateDeps,
|
|
32
|
+
revalidateBadge,
|
|
33
|
+
} from "./revalidate.js";
|
|
34
|
+
export { createDeliveryVerifier } from "./verify-client.js";
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* Unit tests for the cert-card interactions layer — accordion contributor bios (POR-9827 chevron
|
|
4
|
+
* model) + the in-page certificate/profile modals. Pure DOM, delegated handlers.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
8
|
+
import {
|
|
9
|
+
initCertInteractions,
|
|
10
|
+
modalTargetKind,
|
|
11
|
+
openCertModal,
|
|
12
|
+
openModal,
|
|
13
|
+
swapBrokenAvatar,
|
|
14
|
+
sweepBrokenAvatars,
|
|
15
|
+
toggleAccordion,
|
|
16
|
+
} from "./interactions.js";
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
document.head.innerHTML = "";
|
|
20
|
+
document.body.innerHTML = "";
|
|
21
|
+
// Fresh document flag each test isn't possible (same global doc), so the install guard is
|
|
22
|
+
// exercised explicitly in its own test; other tests call initCertInteractions once.
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
/** A contributors card with an accordion reviewer row (name/credentials split + hidden acc-bio)
|
|
26
|
+
* and the footer "Compensated expert" modal trigger — the POR-9827 chevron-model markup. */
|
|
27
|
+
function mountCard(): {
|
|
28
|
+
card: HTMLElement;
|
|
29
|
+
row: HTMLElement;
|
|
30
|
+
bio: HTMLElement;
|
|
31
|
+
} {
|
|
32
|
+
const card = document.createElement("section");
|
|
33
|
+
card.className = "certrev-badge certrev-card";
|
|
34
|
+
card.innerHTML = `
|
|
35
|
+
<div class="card-body">
|
|
36
|
+
<div class="contributors">
|
|
37
|
+
<div class="contributor clickable" data-certrev-acc role="button" tabindex="0" aria-expanded="false">
|
|
38
|
+
<div class="person-row"><div class="info">
|
|
39
|
+
<div class="name">Jane Doe <svg class="name-caret"></svg></div>
|
|
40
|
+
<div class="credentials">RDN, PhD</div>
|
|
41
|
+
<div class="label expert">Reviewed by</div>
|
|
42
|
+
<div class="acc-bio">Twenty years in clinical nutrition.</div>
|
|
43
|
+
</div></div>
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
<div class="card-footer"><span class="compensated-cue">Compensated <a class="cert-link" href="https://certrev.com/experts/jane" data-certrev-modal-open="expert">expert</a></span></div>
|
|
47
|
+
</div>`;
|
|
48
|
+
document.body.appendChild(card);
|
|
49
|
+
return {
|
|
50
|
+
card,
|
|
51
|
+
row: card.querySelector("[data-certrev-acc]")!,
|
|
52
|
+
bio: card.querySelector(".acc-bio")!,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
type StubModal = HTMLElement & { open: ReturnType<typeof vi.fn> };
|
|
57
|
+
|
|
58
|
+
/** A stand-in `<certrev-cert-modal>` with a controllable `open()` — the interactions layer only
|
|
59
|
+
* routes to the element's `.open(kind)`; the element itself owns rendering (tested separately). */
|
|
60
|
+
function mountModalElement(returns = true): StubModal {
|
|
61
|
+
const el = document.createElement("certrev-cert-modal") as StubModal;
|
|
62
|
+
el.open = vi.fn(() => returns);
|
|
63
|
+
document.body.appendChild(el);
|
|
64
|
+
return el;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe("toggleAccordion (pure)", () => {
|
|
68
|
+
it("toggles the row open and mirrors the state on aria-expanded", () => {
|
|
69
|
+
const { row } = mountCard();
|
|
70
|
+
toggleAccordion(row);
|
|
71
|
+
expect(row.classList.contains("open")).toBe(true);
|
|
72
|
+
expect(row.getAttribute("aria-expanded")).toBe("true");
|
|
73
|
+
toggleAccordion(row);
|
|
74
|
+
expect(row.classList.contains("open")).toBe(false);
|
|
75
|
+
expect(row.getAttribute("aria-expanded")).toBe("false");
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("openCertModal", () => {
|
|
80
|
+
it('routes to the <certrev-cert-modal> element open("cert")', () => {
|
|
81
|
+
const el = mountModalElement(true);
|
|
82
|
+
expect(openCertModal(document)).toBe(true);
|
|
83
|
+
expect(el.open).toHaveBeenCalledWith("cert");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("returns false (caller falls back to the link) when no modal element exists", () => {
|
|
87
|
+
expect(openCertModal(document)).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("returns whatever the element.open returns — false lets the link href fall back", () => {
|
|
91
|
+
mountModalElement(false);
|
|
92
|
+
expect(openCertModal(document)).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("modal routing (POR-10102 — unified element)", () => {
|
|
97
|
+
it('modalTargetKind routes "expert" to the reviewer and everything else to the certificate', () => {
|
|
98
|
+
expect(modalTargetKind("expert")).toBe("expert");
|
|
99
|
+
expect(modalTargetKind("cert")).toBe("cert");
|
|
100
|
+
expect(modalTargetKind("")).toBe("cert");
|
|
101
|
+
expect(modalTargetKind(null)).toBe("cert");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("openModal opens the reviewer dialog via the element (and returns false when it is absent)", () => {
|
|
105
|
+
expect(openModal(document, "expert")).toBe(false);
|
|
106
|
+
const el = mountModalElement(true);
|
|
107
|
+
expect(openModal(document, "expert")).toBe(true);
|
|
108
|
+
expect(el.open).toHaveBeenCalledWith("expert");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('the "Compensated expert" cue opens the reviewer modal and prevents navigation', () => {
|
|
112
|
+
const { card, row } = mountCard();
|
|
113
|
+
const el = mountModalElement(true);
|
|
114
|
+
initCertInteractions(document);
|
|
115
|
+
const link = card.querySelector(
|
|
116
|
+
'[data-certrev-modal-open="expert"]',
|
|
117
|
+
) as HTMLElement;
|
|
118
|
+
const evt = new MouseEvent("click", { bubbles: true, cancelable: true });
|
|
119
|
+
link.dispatchEvent(evt);
|
|
120
|
+
expect(el.open).toHaveBeenCalledWith("expert");
|
|
121
|
+
expect(evt.defaultPrevented).toBe(true);
|
|
122
|
+
// The modal open is NOT an accordion toggle.
|
|
123
|
+
expect(row.classList.contains("open")).toBe(false);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("broken avatar → initials fallback", () => {
|
|
128
|
+
it("swapBrokenAvatar replaces a broken img with the matching initials span", () => {
|
|
129
|
+
const img = document.createElement("img");
|
|
130
|
+
img.className = "avatar";
|
|
131
|
+
img.setAttribute("data-certrev-initials", "JS");
|
|
132
|
+
img.src = "https://broken.example/x.jfif";
|
|
133
|
+
document.body.appendChild(img);
|
|
134
|
+
swapBrokenAvatar(img);
|
|
135
|
+
const span = document.body.querySelector("span.avatar.avatar--initials");
|
|
136
|
+
expect(span).not.toBeNull();
|
|
137
|
+
expect(span!.textContent).toBe("JS");
|
|
138
|
+
expect(span!.getAttribute("aria-hidden")).toBe("true");
|
|
139
|
+
expect(document.body.querySelector("img")).toBeNull();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("uses the memo base class when that is what the img carries", () => {
|
|
143
|
+
const memoImg = document.createElement("img");
|
|
144
|
+
memoImg.className = "certrev-memo__avatar";
|
|
145
|
+
memoImg.setAttribute("data-certrev-initials", "CY");
|
|
146
|
+
document.body.appendChild(memoImg);
|
|
147
|
+
swapBrokenAvatar(memoImg);
|
|
148
|
+
expect(
|
|
149
|
+
document.body.querySelector(
|
|
150
|
+
"span.certrev-memo__avatar.certrev-memo__avatar--initials",
|
|
151
|
+
)?.textContent,
|
|
152
|
+
).toBe("CY");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("does nothing for an img without the initials attribute", () => {
|
|
156
|
+
const img = document.createElement("img");
|
|
157
|
+
img.className = "avatar";
|
|
158
|
+
document.body.appendChild(img);
|
|
159
|
+
swapBrokenAvatar(img);
|
|
160
|
+
expect(document.body.querySelector("img")).not.toBeNull();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("sweepBrokenAvatars swaps an avatar that already failed before init (no error event re-fires)", () => {
|
|
164
|
+
const img = document.createElement("img");
|
|
165
|
+
img.className = "avatar";
|
|
166
|
+
img.setAttribute("data-certrev-initials", "ZZ");
|
|
167
|
+
// Simulate an image that 404'd during parse: complete=true, naturalWidth=0, no future error.
|
|
168
|
+
Object.defineProperty(img, "complete", { value: true });
|
|
169
|
+
Object.defineProperty(img, "naturalWidth", { value: 0 });
|
|
170
|
+
document.body.appendChild(img);
|
|
171
|
+
sweepBrokenAvatars(document);
|
|
172
|
+
expect(
|
|
173
|
+
document.body.querySelector("span.avatar--initials")?.textContent,
|
|
174
|
+
).toBe("ZZ");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("sweepBrokenAvatars leaves a successfully-loaded avatar alone", () => {
|
|
178
|
+
const img = document.createElement("img");
|
|
179
|
+
img.className = "avatar";
|
|
180
|
+
img.setAttribute("data-certrev-initials", "OK");
|
|
181
|
+
Object.defineProperty(img, "complete", { value: true });
|
|
182
|
+
Object.defineProperty(img, "naturalWidth", { value: 56 }); // loaded
|
|
183
|
+
document.body.appendChild(img);
|
|
184
|
+
sweepBrokenAvatars(document);
|
|
185
|
+
expect(
|
|
186
|
+
document.body.querySelector("img[data-certrev-initials]"),
|
|
187
|
+
).not.toBeNull();
|
|
188
|
+
expect(document.body.querySelector("span.avatar--initials")).toBeNull();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("the capture-phase error listener swaps a broken avatar on its error event", () => {
|
|
192
|
+
const img = document.createElement("img");
|
|
193
|
+
img.className = "avatar";
|
|
194
|
+
img.setAttribute("data-certrev-initials", "AB");
|
|
195
|
+
document.body.appendChild(img);
|
|
196
|
+
initCertInteractions(document);
|
|
197
|
+
img.dispatchEvent(new Event("error")); // error doesn't bubble → handler is capture-phase
|
|
198
|
+
expect(
|
|
199
|
+
document.body.querySelector("span.avatar--initials")?.textContent,
|
|
200
|
+
).toBe("AB");
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe("initCertInteractions — delegated accordion + modal", () => {
|
|
205
|
+
it("a row click opens the accordion; a second click closes it", () => {
|
|
206
|
+
const { row } = mountCard();
|
|
207
|
+
initCertInteractions(document);
|
|
208
|
+
row.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
209
|
+
expect(row.classList.contains("open")).toBe(true);
|
|
210
|
+
expect(row.getAttribute("aria-expanded")).toBe("true");
|
|
211
|
+
row.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
212
|
+
expect(row.classList.contains("open")).toBe(false);
|
|
213
|
+
expect(row.getAttribute("aria-expanded")).toBe("false");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("a click on a LINK inside the card does not toggle the accordion (the link has its own action)", () => {
|
|
217
|
+
const { card, row } = mountCard();
|
|
218
|
+
// Give the row an inner link to prove the guard (e.g. a future inline control).
|
|
219
|
+
const a = document.createElement("a");
|
|
220
|
+
a.href = "https://certrev.com/x";
|
|
221
|
+
a.textContent = "link";
|
|
222
|
+
row.querySelector(".info")!.appendChild(a);
|
|
223
|
+
initCertInteractions(document);
|
|
224
|
+
a.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
225
|
+
expect(row.classList.contains("open")).toBe(false);
|
|
226
|
+
expect(card.querySelector("[data-certrev-acc].open")).toBeNull();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("Enter on the role=button row toggles the accordion (a div fires no native click)", () => {
|
|
230
|
+
const { row } = mountCard();
|
|
231
|
+
initCertInteractions(document);
|
|
232
|
+
row.dispatchEvent(
|
|
233
|
+
new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
|
|
234
|
+
);
|
|
235
|
+
expect(row.classList.contains("open")).toBe(true);
|
|
236
|
+
row.dispatchEvent(
|
|
237
|
+
new KeyboardEvent("keydown", { key: " ", bubbles: true }),
|
|
238
|
+
);
|
|
239
|
+
expect(row.classList.contains("open")).toBe(false);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("install is idempotent (second call does not double-bind — one click toggles exactly once)", () => {
|
|
243
|
+
const { row } = mountCard();
|
|
244
|
+
initCertInteractions(document);
|
|
245
|
+
initCertInteractions(document); // no-op second install
|
|
246
|
+
row.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
247
|
+
// A double-bound handler would toggle twice (open → closed); one binding leaves it open.
|
|
248
|
+
expect(row.classList.contains("open")).toBe(true);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cert-card INTERACTIONS for the native Shopify cert experience.
|
|
3
|
+
*
|
|
4
|
+
* Progressive enhancement, delegated from the document (one listener, survives the placement
|
|
5
|
+
* reposition). Two behaviours (POR-9827 chevron model):
|
|
6
|
+
*
|
|
7
|
+
* • Accordion bios — a contributor row carrying `[data-certrev-acc]` toggles its `.open` class
|
|
8
|
+
* (revealing the row's `.acc-bio` in place; the name caret rotates via CSS) and mirrors the
|
|
9
|
+
* state on `aria-expanded`. A click on a link inside the row (e.g. the "Compensated expert"
|
|
10
|
+
* cue) is NOT a toggle. Keyboard: Enter/Space on the role=button row.
|
|
11
|
+
* • In-page modals: a `[data-certrev-modal-open]` control opens the unified
|
|
12
|
+
* `<certrev-cert-modal>` element (POR-10102) via its `.open('cert'|'expert')` method. Bare /
|
|
13
|
+
* `"cert"` opens the certificate dialog; `"expert"` opens the reviewer dialog. With no JS / no
|
|
14
|
+
* element / no envelope a link control falls back to its href (the certificate or the CertREV
|
|
15
|
+
* profile page). The element owns its OWN close plumbing (× / Close / backdrop / Esc) inside its
|
|
16
|
+
* shadow root — a shadow click is retargeted to the host, so a document delegate can't see it.
|
|
17
|
+
*
|
|
18
|
+
* No crypto, no network — pure DOM. Idempotent install (guarded so a double-load can't double-bind).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { CERT_MODAL_TAG } from './certrev-cert-modal.js'
|
|
22
|
+
|
|
23
|
+
export const ACC_ATTR = 'data-certrev-acc'
|
|
24
|
+
export const MODAL_OPEN_ATTR = 'data-certrev-modal-open'
|
|
25
|
+
export const INITIALS_ATTR = 'data-certrev-initials'
|
|
26
|
+
export const CARD_SELECTOR = '.certrev-card'
|
|
27
|
+
|
|
28
|
+
/** The dialog a `data-certrev-modal-open` control targets. */
|
|
29
|
+
export type ModalKind = 'cert' | 'expert'
|
|
30
|
+
|
|
31
|
+
/** The `<certrev-cert-modal>` element's public surface (defined in certrev-cert-modal.ts). */
|
|
32
|
+
interface CertModalElement extends HTMLElement {
|
|
33
|
+
open(kind: ModalKind): boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Avatar base classes whose `--initials` variant is the SSR no-photo fallback (kept in lockstep
|
|
37
|
+
* with certrev-badge.liquid / certrev-memo.liquid). A broken <img> is swapped to `{base} {base}--initials`. */
|
|
38
|
+
const AVATAR_BASE_CLASSES = ['avatar', 'certrev-memo__avatar'] as const
|
|
39
|
+
|
|
40
|
+
/** Documents that already have the delegated handlers, so a double script-load can't double-bind. */
|
|
41
|
+
const installedDocs = new WeakSet<Document>()
|
|
42
|
+
|
|
43
|
+
/** Toggle a contributor row's accordion bio open/closed (the `.open` class drives the CSS reveal +
|
|
44
|
+
* caret rotation) and mirror the state on aria-expanded. */
|
|
45
|
+
export function toggleAccordion(row: Element): void {
|
|
46
|
+
const open = row.classList.toggle('open')
|
|
47
|
+
row.setAttribute('aria-expanded', open ? 'true' : 'false')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Resolve a `data-certrev-modal-open` value to the dialog it opens. `"expert"` opens the reviewer
|
|
51
|
+
* modal; anything else (the bare attribute, or `"cert"`) opens the certificate modal. */
|
|
52
|
+
export function modalTargetKind(value: string | null): ModalKind {
|
|
53
|
+
return value === 'expert' ? 'expert' : 'cert'
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Open a cert dialog via the `<certrev-cert-modal>` element. Returns false (so the caller lets an
|
|
57
|
+
* href fall back) when the element is absent or declines (no envelope + no fetch source). */
|
|
58
|
+
export function openModal(doc: Document, kind: ModalKind): boolean {
|
|
59
|
+
const el = doc.querySelector(CERT_MODAL_TAG) as CertModalElement | null
|
|
60
|
+
if (el && typeof el.open === 'function') return el.open(kind)
|
|
61
|
+
return false
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Open the page's certificate modal (backward-compatible wrapper over openModal). */
|
|
65
|
+
export function openCertModal(doc: Document): boolean {
|
|
66
|
+
return openModal(doc, 'cert')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function asElement(target: EventTarget | null): Element | null {
|
|
70
|
+
return target && (target as Element).closest ? (target as Element) : null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Replace a broken avatar `<img>` with its initials placeholder — the SAME markup the Liquid emits
|
|
75
|
+
* when no photo URL is present. The SSR can't know a present URL will 404 (the canary's expert photo
|
|
76
|
+
* does), so without this a broken-image icon shows instead of the clean initials circle.
|
|
77
|
+
*/
|
|
78
|
+
export function swapBrokenAvatar(img: HTMLImageElement): void {
|
|
79
|
+
const initials = img.getAttribute(INITIALS_ATTR)
|
|
80
|
+
if (initials == null) return
|
|
81
|
+
const base = AVATAR_BASE_CLASSES.find((c) => img.classList.contains(c))
|
|
82
|
+
if (!base) return
|
|
83
|
+
const span = img.ownerDocument.createElement('span')
|
|
84
|
+
span.className = `${base} ${base}--initials`
|
|
85
|
+
span.setAttribute('aria-hidden', 'true')
|
|
86
|
+
span.textContent = initials
|
|
87
|
+
img.replaceWith(span)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Swap every avatar that has ALREADY failed to load. A 404 fires `error` during parse — often
|
|
92
|
+
* before this bundle runs — and `error` doesn't re-fire, so the live `error` listener alone misses
|
|
93
|
+
* pre-init failures (observed on the canary). Safe to call repeatedly; only acts on broken imgs.
|
|
94
|
+
*/
|
|
95
|
+
export function sweepBrokenAvatars(doc: Document): void {
|
|
96
|
+
for (const img of Array.from(doc.querySelectorAll<HTMLImageElement>(`img[${INITIALS_ATTR}]`))) {
|
|
97
|
+
if (img.complete && img.naturalWidth === 0) swapBrokenAvatar(img)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Install the delegated click + keydown handlers on the document, once. */
|
|
102
|
+
export function initCertInteractions(doc: Document): void {
|
|
103
|
+
if (installedDocs.has(doc)) return
|
|
104
|
+
installedDocs.add(doc)
|
|
105
|
+
|
|
106
|
+
doc.addEventListener('click', (e) => {
|
|
107
|
+
const el = asElement(e.target)
|
|
108
|
+
if (!el) return
|
|
109
|
+
|
|
110
|
+
// Modal open — intercept only when the element actually shows the dialog; else let the link navigate.
|
|
111
|
+
// (Close is owned by the <certrev-cert-modal> element inside its shadow root.)
|
|
112
|
+
const opener = el.closest(`[${MODAL_OPEN_ATTR}]`)
|
|
113
|
+
if (opener) {
|
|
114
|
+
if (openModal(doc, modalTargetKind(opener.getAttribute(MODAL_OPEN_ATTR)))) e.preventDefault()
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Accordion — but never when a link inside the row was clicked (it has its own action).
|
|
119
|
+
const row = el.closest(`[${ACC_ATTR}]`)
|
|
120
|
+
if (row && !el.closest('a, button')) toggleAccordion(row)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
// Broken avatar photos → initials placeholder. `error` events don't bubble, so listen in the
|
|
124
|
+
// CAPTURE phase. One-shot per img (replaceWith removes it, so it can't re-fire).
|
|
125
|
+
doc.addEventListener(
|
|
126
|
+
'error',
|
|
127
|
+
(e) => {
|
|
128
|
+
const t = e.target as Element | null
|
|
129
|
+
if (t && t.tagName === 'IMG' && t.hasAttribute(INITIALS_ATTR)) swapBrokenAvatar(t as HTMLImageElement)
|
|
130
|
+
},
|
|
131
|
+
true,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
// Catch avatars that already failed before this listener attached (see sweepBrokenAvatars).
|
|
135
|
+
sweepBrokenAvatars(doc)
|
|
136
|
+
|
|
137
|
+
// Keyboard parity: Enter/Space activates a role=button accordion row or a modal-open row (a <div>
|
|
138
|
+
// fires no native click on Enter). Native <a>/<button> triggers handle their own activation
|
|
139
|
+
// (their click reaches the click handler above), so skip those here to avoid a double-fire.
|
|
140
|
+
doc.addEventListener('keydown', (e) => {
|
|
141
|
+
if (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'Spacebar') return
|
|
142
|
+
const el = asElement(e.target)
|
|
143
|
+
if (!el || el.closest('a, button')) return
|
|
144
|
+
|
|
145
|
+
const opener = el.closest(`[${MODAL_OPEN_ATTR}]`)
|
|
146
|
+
if (opener) {
|
|
147
|
+
if (openModal(doc, modalTargetKind(opener.getAttribute(MODAL_OPEN_ATTR)))) {
|
|
148
|
+
e.preventDefault()
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const row = el.closest(`[${ACC_ATTR}]`)
|
|
154
|
+
if (row) {
|
|
155
|
+
e.preventDefault()
|
|
156
|
+
toggleAccordion(row)
|
|
157
|
+
}
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Run interactions setup once the DOM is parsed. */
|
|
162
|
+
export function initCertInteractionsOnReady(doc: Document): void {
|
|
163
|
+
const run = (): void => initCertInteractions(doc)
|
|
164
|
+
if (doc.readyState === 'loading') doc.addEventListener('DOMContentLoaded', run, { once: true })
|
|
165
|
+
else run()
|
|
166
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// ⚠ VENDORED, BYTE-FAITHFUL copy of portal `src/lib/design-guide/logo.ts` `LOGO_PATHS`
|
|
2
|
+
// (POR-10721 W2). Byte-identical to `../components/render-cert-block.ts`'s private copy;
|
|
3
|
+
// kept modal-local so the browser bundle never pulls in the SSR renderer. Unifying the
|
|
4
|
+
// package's two copies onto one shared leaf module is a post-publish follow-up.
|
|
5
|
+
/**
|
|
6
|
+
* The CertREV 4-chevron logo mark — the canonical SVG path data (the four `d`
|
|
7
|
+
* attributes, drawn in a `0 0 375 375` viewBox). This is the SINGLE SOURCE for
|
|
8
|
+
* the mark across every cert-render surface (the standalone /embed renderers,
|
|
9
|
+
* the <certrev-cert-modal> web component, the Shopify Liquid snippets, the
|
|
10
|
+
* WordPress PHP renderer, and @certrev/cert-block).
|
|
11
|
+
*
|
|
12
|
+
* The paths were previously hand-copied into ~13 files; each of those should
|
|
13
|
+
* import THIS instead of re-declaring them. The codegen
|
|
14
|
+
* (`scripts/build-cert-design-artifacts.ts`) stamps these into
|
|
15
|
+
* `generated/design-tokens.json` + the WordPress `class-certrev-design-tokens.php`
|
|
16
|
+
* so the Liquid/PHP twins can never drift from this array.
|
|
17
|
+
*/
|
|
18
|
+
export const LOGO_PATHS = [
|
|
19
|
+
"M67.59 221.77L138.77 292.95C145.8 299.98 145.8 311.44 138.77 318.47L123.02 334.22C116 341.25 104.54 341.25 97.5 334.22L26.32 263.04C19.29 256.01 19.29 244.55 26.32 237.52L42.07 221.77C49.1 214.74 60.56 214.74 67.59 221.77Z",
|
|
20
|
+
"M96.79 335.08L81.12 319.42C73.8 312.1 73.8 300.23 81.12 292.92L315.88 58.16C323.2 50.85 335.06 50.85 342.38 58.16L358.04 73.83C365.36 81.14 365.36 93.01 358.04 100.33L123.29 335.08C115.97 342.4 104.1 342.4 96.79 335.08Z",
|
|
21
|
+
"M203.5 335.14L187.83 319.48C180.52 312.16 180.52 300.29 187.83 292.98L315.88 164.93C323.2 157.62 335.06 157.62 342.38 164.93L358.04 180.6C365.36 187.92 365.36 199.78 358.04 207.1L230 335.14C222.68 342.46 210.82 342.46 203.5 335.14Z",
|
|
22
|
+
"M310.21 335.2L294.54 319.54C287.23 312.22 287.23 300.36 294.54 293.04L315.88 271.71C323.2 264.39 335.06 264.39 342.38 271.71L358.04 287.37C365.36 294.69 365.36 306.55 358.04 313.87L336.71 335.2C329.39 342.52 317.53 342.52 310.21 335.2Z",
|
|
23
|
+
] as const;
|