@certrev/cert-block 1.0.3 → 1.1.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.
Files changed (88) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/README.md +25 -6
  3. package/dist/builder/cert-chrome-data.d.ts +26 -1
  4. package/dist/builder/cert-chrome-data.d.ts.map +1 -1
  5. package/dist/builder/cert-chrome-data.js +1 -0
  6. package/dist/builder/cert-chrome-data.js.map +1 -1
  7. package/dist/builder/cert-review-card.d.ts +35 -2
  8. package/dist/builder/cert-review-card.d.ts.map +1 -1
  9. package/dist/builder/cert-review-card.js +112 -2
  10. package/dist/builder/cert-review-card.js.map +1 -1
  11. package/dist/builder/delivery-runtime.d.ts +23 -0
  12. package/dist/builder/delivery-runtime.d.ts.map +1 -0
  13. package/dist/builder/delivery-runtime.js +38 -0
  14. package/dist/builder/delivery-runtime.js.map +1 -0
  15. package/dist/builder/envelope-face.d.ts +63 -0
  16. package/dist/builder/envelope-face.d.ts.map +1 -0
  17. package/dist/builder/envelope-face.js +151 -0
  18. package/dist/builder/envelope-face.js.map +1 -0
  19. package/dist/builder/index.d.ts +15 -3
  20. package/dist/builder/index.d.ts.map +1 -1
  21. package/dist/builder/index.js +14 -2
  22. package/dist/builder/index.js.map +1 -1
  23. package/dist/builder/issuer-key.d.ts +12 -0
  24. package/dist/builder/issuer-key.d.ts.map +1 -0
  25. package/dist/builder/issuer-key.js +14 -0
  26. package/dist/builder/issuer-key.js.map +1 -0
  27. package/dist/components/memo-toggle.d.ts +42 -0
  28. package/dist/components/memo-toggle.d.ts.map +1 -0
  29. package/dist/components/memo-toggle.js +60 -0
  30. package/dist/components/memo-toggle.js.map +1 -0
  31. package/dist/components/render-cert-block.d.ts +9 -0
  32. package/dist/components/render-cert-block.d.ts.map +1 -1
  33. package/dist/components/render-cert-block.js +83 -25
  34. package/dist/components/render-cert-block.js.map +1 -1
  35. package/dist/index.d.ts +2 -0
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +3 -0
  38. package/dist/index.js.map +1 -1
  39. package/dist/modal/certrev-cert-modal.d.ts +34 -1
  40. package/dist/modal/certrev-cert-modal.d.ts.map +1 -1
  41. package/dist/modal/certrev-cert-modal.js +40 -2
  42. package/dist/modal/certrev-cert-modal.js.map +1 -1
  43. package/dist/modal/certrev-cert.entry.d.ts.map +1 -1
  44. package/dist/modal/certrev-cert.entry.js +2 -4
  45. package/dist/modal/certrev-cert.entry.js.map +1 -1
  46. package/dist/modal/certrev-cert.js +4 -4
  47. package/dist/modal/deprecated.d.ts +15 -0
  48. package/dist/modal/deprecated.d.ts.map +1 -0
  49. package/dist/modal/deprecated.js +22 -0
  50. package/dist/modal/deprecated.js.map +1 -0
  51. package/dist/modal/ftc-disclosure.d.ts +7 -3
  52. package/dist/modal/ftc-disclosure.d.ts.map +1 -1
  53. package/dist/modal/ftc-disclosure.js +7 -3
  54. package/dist/modal/ftc-disclosure.js.map +1 -1
  55. package/dist/modal/index.d.ts +4 -3
  56. package/dist/modal/index.d.ts.map +1 -1
  57. package/dist/modal/index.js +4 -3
  58. package/dist/modal/index.js.map +1 -1
  59. package/dist/modal/interactions.d.ts +20 -4
  60. package/dist/modal/interactions.d.ts.map +1 -1
  61. package/dist/modal/interactions.js +34 -7
  62. package/dist/modal/interactions.js.map +1 -1
  63. package/dist/verify/get-verified-delivery.d.ts +91 -0
  64. package/dist/verify/get-verified-delivery.d.ts.map +1 -0
  65. package/dist/verify/get-verified-delivery.js +157 -0
  66. package/dist/verify/get-verified-delivery.js.map +1 -0
  67. package/package.json +5 -1
  68. package/src/builder/cert-chrome-data.ts +27 -0
  69. package/src/builder/cert-review-card.tsx +143 -3
  70. package/src/builder/delivery-runtime.ts +54 -0
  71. package/src/builder/envelope-face.ts +173 -0
  72. package/src/builder/index.tsx +23 -1
  73. package/src/builder/issuer-key.ts +13 -0
  74. package/src/components/memo-toggle.ts +63 -0
  75. package/src/components/render-cert-block.ts +124 -28
  76. package/src/index.ts +28 -0
  77. package/src/modal/certrev-cert-modal.ts +45 -2
  78. package/src/modal/certrev-cert.entry.ts +2 -4
  79. package/src/modal/deprecated.ts +25 -0
  80. package/src/modal/ftc-disclosure.ts +7 -3
  81. package/src/modal/index.ts +4 -3
  82. package/src/modal/interactions.ts +37 -7
  83. package/src/verify/get-verified-delivery.ts +211 -0
  84. package/dist/modal/ftc-guard.d.ts +0 -85
  85. package/dist/modal/ftc-guard.d.ts.map +0 -1
  86. package/dist/modal/ftc-guard.js +0 -150
  87. package/dist/modal/ftc-guard.js.map +0 -1
  88. package/src/modal/ftc-guard.ts +0 -174
@@ -0,0 +1,173 @@
1
+ /**
2
+ * ─────────────────────────────────────────────────────────────────────────────
3
+ * `envelopeCardInput` — the Builder card's face, from VERIFIED facts + the brand's def (1.1.0)
4
+ * ─────────────────────────────────────────────────────────────────────────────
5
+ *
6
+ * The one mapping from a verified payload's `content` and the Delivery API's `renderDef` sibling
7
+ * to the `renderCertBlock` input the registered `CertReviewCard` paints. It is its own module,
8
+ * react-free, and exported on the `./builder/face` subpath so the portal's server bundle (which
9
+ * cannot load `./builder`: that entry creates a React context at import) renders its preview
10
+ * through THIS function instead of a copy of it. A preview and the live card that share the
11
+ * mapping cannot disagree about it.
12
+ *
13
+ * The facts are read exactly as the Shopify app embed reads the same envelope:
14
+ * - the credential is each verified credential's `cardLabel` (else `abbreviation`), joined, and
15
+ * only with the dated verification beside it; no date, no credential (the name-only face);
16
+ * - the compensation cue's PRESENCE is the only thing read; the words are the locked constant,
17
+ * and an absent or null cue is the pro-bono face with no cue;
18
+ * - the def's `hidden` set removes only what is hideable (`HIDEABLE_FIELDS`); a def cannot hide
19
+ * the scope line, the credential verification or the certificate link.
20
+ *
21
+ * The face is the PHONE-aware one (`responsive`): on a narrow viewport the banner header stacks and
22
+ * a long memo collapses behind a toggle the card binds (`memo-toggle.ts`); a wider viewport paints
23
+ * exactly what it would without it.
24
+ *
25
+ * The caller has already run the kernel. This function never decides WHETHER to render a cert;
26
+ * it only returns null when the verified content cannot make a face (no reviewer, no verify URL).
27
+ */
28
+
29
+ import type {
30
+ CertBlockFace,
31
+ CertBlockLayout,
32
+ CertBlockMode,
33
+ RenderCertBlockInput,
34
+ } from '../components/render-cert-block.js'
35
+ import { accentFg, HIDEABLE_FIELDS, isOpaqueHexColor, type ResolvedBlockTheme } from '../components/render-def.js'
36
+ import type { CertContent } from '../contract/kernel.js'
37
+ import type { CertChromeRenderDef } from './cert-chrome-data.js'
38
+
39
+ /**
40
+ * Every field id `renderCertBlock` places, in its canonical order. A def-driven face places all of
41
+ * them except what the def hides; `renderCertBlock` still gates each on its fact being present.
42
+ */
43
+ export const CERT_FACE_FIELDS = [
44
+ 'label',
45
+ 'authorName',
46
+ 'authorTitle',
47
+ 'reviewerPhoto',
48
+ 'bylineCredentialed',
49
+ 'bylinePlain',
50
+ 'bio',
51
+ 'memo',
52
+ 'profileLink',
53
+ 'certificateLink',
54
+ 'scopeLine',
55
+ 'compensationCue',
56
+ ] as const
57
+
58
+ const MODES: readonly CertBlockMode[] = ['banner', 'sidebar', 'floating']
59
+
60
+ function isMode(v: unknown): v is CertBlockMode {
61
+ return typeof v === 'string' && (MODES as readonly string[]).includes(v)
62
+ }
63
+
64
+ function text(v: unknown): string {
65
+ return typeof v === 'string' ? v : ''
66
+ }
67
+
68
+ function optionalText(v: unknown): string | undefined {
69
+ return text(v).trim() !== '' ? (v as string) : undefined
70
+ }
71
+
72
+ export interface EnvelopeCardOptions {
73
+ /** The placement the entry/editor chose. A recognised mode wins over the def's layout. */
74
+ readonly mode?: unknown
75
+ /** Banner memo placement, passed through as on the options path. */
76
+ readonly part?: unknown
77
+ }
78
+
79
+ /** The def's theme tokens as `renderCertBlock` theme input, with the accent ink derived. */
80
+ export function themeFromDef(def: CertChromeRenderDef | null): Partial<ResolvedBlockTheme> | undefined {
81
+ const tokens = def?.tokens
82
+ if (!tokens) return undefined
83
+ const accentColor = optionalText(tokens.accentColor)
84
+ return {
85
+ accentColor,
86
+ surface: optionalText(tokens.surface),
87
+ cornerRadius: optionalText(tokens.cornerRadius),
88
+ fontSlot: optionalText(tokens.fontSlot) as ResolvedBlockTheme['fontSlot'] | undefined,
89
+ // The ink painted over the accent fill, derived the way the portal derives it, so a black
90
+ // accent carries white ink. Omitted for an accent `resolveBlockTheme` would reject anyway.
91
+ ...(accentColor && isOpaqueHexColor(accentColor) ? { accentFg: accentFg(accentColor) } : {}),
92
+ barInk: optionalText(tokens.barInk),
93
+ inkColor: optionalText(tokens.inkColor),
94
+ }
95
+ }
96
+
97
+ /** The face a def asks for: its custom composition, or every field minus the hideable ones it hides. */
98
+ export function faceFromDef(def: CertChromeRenderDef | null, layout: CertBlockLayout): CertBlockFace | undefined {
99
+ if (!def) return undefined
100
+ if (layout === 'custom') return { rung: def.rung, placedFields: [...(def.customFace?.placedFields ?? [])] }
101
+ const hidden = new Set(def.hidden.filter((id) => (HIDEABLE_FIELDS as readonly string[]).includes(id)))
102
+ return { rung: def.rung, placedFields: CERT_FACE_FIELDS.filter((id) => !hidden.has(id)) }
103
+ }
104
+
105
+ /**
106
+ * The layout: an editor-chosen mode, else the def's own layout (a custom face only when the def
107
+ * carries one), else banner, which is what the Shopify embed paints for a def with no layout and
108
+ * what every exported Builder entry has painted since 0.5.
109
+ */
110
+ export function layoutFor(def: CertChromeRenderDef | null, mode: unknown): CertBlockLayout {
111
+ if (isMode(mode)) return mode
112
+ if (def?.layout === 'custom' && def.customFace) return 'custom'
113
+ if (isMode(def?.layout)) return def.layout
114
+ return 'banner'
115
+ }
116
+
117
+ /** The fused credential label the envelope's verified credentials stand for, or '' (name-only). */
118
+ export function credentialLabel(expert: Record<string, unknown>): { credential: string; verifiedAt: string } | null {
119
+ const verifiedAt = optionalText(expert.credentialVerifiedAt)
120
+ if (!verifiedAt) return null
121
+ const creds = Array.isArray(expert.credentials) ? expert.credentials : []
122
+ const credential = creds
123
+ .map((c) => {
124
+ const rec = (typeof c === 'object' && c !== null ? c : {}) as Record<string, unknown>
125
+ return optionalText(rec.cardLabel) ?? optionalText(rec.abbreviation)
126
+ })
127
+ .filter((l): l is string => l !== undefined)
128
+ .join(', ')
129
+ return credential ? { credential, verifiedAt } : null
130
+ }
131
+
132
+ /**
133
+ * The exact `renderCertBlock` input for a verified envelope's content and the brand's def, or
134
+ * null when the content cannot make a face.
135
+ */
136
+ export function envelopeCardInput(
137
+ content: CertContent,
138
+ renderDef: CertChromeRenderDef | null,
139
+ options: EnvelopeCardOptions = {},
140
+ ): RenderCertBlockInput | null {
141
+ const expert = (content?.expert ?? {}) as unknown as Record<string, unknown>
142
+ const author = (content?.author ?? {}) as unknown as Record<string, unknown>
143
+ const reviewerName = text(expert.displayName)
144
+ const verifyUrl = text(content?.verifyUrl)
145
+ if (!reviewerName || !verifyUrl) return null
146
+ const cred = credentialLabel(expert)
147
+ const layout = layoutFor(renderDef, options.mode)
148
+ const face = faceFromDef(renderDef, layout)
149
+ const theme = themeFromDef(renderDef)
150
+ const part =
151
+ options.part === 'full' || options.part === 'header' || options.part === 'memo' ? options.part : undefined
152
+ return {
153
+ mode: layout,
154
+ ...(theme ? { theme } : {}),
155
+ ...(face ? { face } : {}),
156
+ ...(part ? { part } : {}),
157
+ responsive: true,
158
+ facts: {
159
+ authorName: optionalText(author.name) ?? 'Editorial',
160
+ authorTitle: optionalText(author.title),
161
+ reviewerName,
162
+ credential: cred?.credential ?? '',
163
+ credentialVerifiedAt: cred?.verifiedAt ?? '',
164
+ certifiedAt: text(content.certifiedAt),
165
+ memo: text(content.memo),
166
+ bio: optionalText(expert.bio),
167
+ profileUrl: text(expert.profileUrl),
168
+ certificateUrl: verifyUrl,
169
+ // Presence only: a cue string renders the locked constant; absent or null is pro-bono.
170
+ compensationCue: optionalText(expert.compensationCue) ? undefined : null,
171
+ },
172
+ }
173
+ }
@@ -12,7 +12,7 @@
12
12
  * another article, and there is no `placementId` input to forge or mis-select.
13
13
  *
14
14
  * THE PUSH KIT DOES NOT INHERIT THAT PROPERTY, and this module exports it too. `CertReviewCard` /
15
- * `certRevCertComponent` register the 19 wire-type fields — `reviewerName`,
15
+ * `certRevCertComponent` register the 20 wire-type fields — `reviewerName`,
16
16
  * `credentialVerification`, `verifyUrl`, `memo` — as Builder INPUTS, and the card renders what
17
17
  * those inputs hold; it runs no signature check and consults no verdict. `advanced: true` folds
18
18
  * them out of the editor's default view, which is a UI reveal and not a lock, and `safeHttpUrl`
@@ -22,6 +22,17 @@
22
22
  * narrows it. See "PUSH trust model" in the package README for the full statement — including
23
23
  * what a revocation upstream does and does not reach.
24
24
  *
25
+ * 1.1.0 NARROWS THAT FOR A BLOCK CARRYING THE `delivery` MARKER (`{ v: 1, baseUrl }`, which the
26
+ * CertREV exporter writes). Such a block ignores every other option: it reads the signed envelope
27
+ * for the entry it renders inside (`builderContext.content.id`) from the CertREV Delivery API on
28
+ * `baseUrl` (an https certrev.com origin, nothing else), verifies it against the baked
29
+ * `cert-issuer-1` key, re-judges revocation and expiry at every render, and paints the brand's
30
+ * render def served beside it. A revoked, expired, unverifiable or unreachable cert, a marker that
31
+ * is not exactly that shape, or a missing entry id renders nothing. A block WITHOUT the marker
32
+ * (every entry exported before 1.1.0) renders from its options exactly as 1.0.3 did, so the
33
+ * paragraph above still describes it: an editor who deletes the marker gets the options face back,
34
+ * which is the 1.0.3 trust boundary, not a new one.
35
+ *
25
36
  * Three things the card DOES enforce, and they are the limit of it: no chrome at all without a
26
37
  * `reviewerName` and a `verifyUrl`; a credential renders only welded to its dated verification
27
38
  * (the fused pair); and the FTC disclosures render from locked constants, so rewriting the
@@ -144,8 +155,10 @@ export {
144
155
  type BuilderCertChromeData,
145
156
  type BuilderCertChromeKey,
146
157
  type CertChromeRenderDef,
158
+ type CertDeliveryMarker,
147
159
  } from './cert-chrome-data.js'
148
160
  export {
161
+ acceptedDeliveryMarker,
149
162
  BUILDER_REGISTRATION,
150
163
  type BuilderInput,
151
164
  CERT_COMPONENT_NAME,
@@ -157,3 +170,12 @@ export {
157
170
  PLACEMENT_INPUTS,
158
171
  WIRE_INPUTS,
159
172
  } from './cert-review-card.js'
173
+ export {
174
+ CERT_FACE_FIELDS,
175
+ credentialLabel,
176
+ type EnvelopeCardOptions,
177
+ envelopeCardInput,
178
+ faceFromDef,
179
+ layoutFor,
180
+ themeFromDef,
181
+ } from './envelope-face.js'
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The CertREV issuer PUBLIC key the envelope-backed Builder card verifies against (1.1.0).
3
+ *
4
+ * A copy of `CERT_ISSUER_KID` / `CERT_ISSUER_PUBLIC_KEY_PEM` in `modal/certrev-cert.entry.ts`, not
5
+ * an import of it: that entry registers a custom element and starts the storefront DOM passes at
6
+ * import, which a React server render must not do, and moving the constant out of it would change
7
+ * the storefront bundle the Shopify extension ships byte-for-byte. The two copies are pinned equal by
8
+ * `builder/__tests__/issuer-key.test.ts`, so a rotation that updates one and not the other fails.
9
+ */
10
+ export const CERT_ISSUER_KID = 'cert-issuer-1'
11
+ export const CERT_ISSUER_PUBLIC_KEY_PEM = `-----BEGIN PUBLIC KEY-----
12
+ MCowBQYDK2VwAyEAMWN956IOPjpAq900dL428VzA28TO/pVXnq3brwqUmwM=
13
+ -----END PUBLIC KEY-----`
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The PHONE face's collapsible memo (1.1.0): the markup contract `renderCertBlock` emits when its
3
+ * input asks for the responsive face, and the one toggle every host wires to it.
4
+ *
5
+ * On a viewport `NARROW_VIEWPORT` or narrower a long memo is clamped to `MEMO_COLLAPSED_LINES`
6
+ * lines behind a native `<button>` that expands it; wider viewports show the whole memo and no
7
+ * button. The state lives on the memo box (`data-certrev-memo="collapsed" | "open"`), never on a
8
+ * class, so a host stylesheet's `.open` rules cannot reach it; the button mirrors it on
9
+ * `aria-expanded` and in its label.
10
+ *
11
+ * Pure DOM, no React: the Builder card binds it on its wrapper, and any other host that paints the
12
+ * responsive face (a portal preview, a web component) calls the same function.
13
+ */
14
+
15
+ /** The memo box: the memo text and its toggle. The value is the state. */
16
+ export const MEMO_ATTR = 'data-certrev-memo'
17
+ /** The clamped text inside the box. */
18
+ export const MEMO_TEXT_ATTR = 'data-certrev-memo-text'
19
+ /** The expand/collapse button inside the box. */
20
+ export const MEMO_TOGGLE_ATTR = 'data-certrev-memo-toggle'
21
+
22
+ export const MEMO_EXPAND_LABEL = 'Read the full memo'
23
+ export const MEMO_COLLAPSE_LABEL = 'Show less'
24
+
25
+ /** The viewport at or under which the phone face applies. */
26
+ export const NARROW_VIEWPORT = '(max-width: 640px)'
27
+ /** Lines a collapsed memo keeps. */
28
+ export const MEMO_COLLAPSED_LINES = 4
29
+
30
+ /**
31
+ * A memo shorter than this, with fewer line breaks than `MEMO_COLLAPSE_MIN_BREAKS`, fits its
32
+ * collapsed lines on a phone (about 33 characters a line at 393px), so it gets no toggle: a button
33
+ * that reveals nothing is noise.
34
+ */
35
+ export const MEMO_COLLAPSE_MIN_CHARS = 160
36
+ export const MEMO_COLLAPSE_MIN_BREAKS = MEMO_COLLAPSED_LINES
37
+
38
+ /** Is this memo long enough to collapse on a phone? (No memo never does.) */
39
+ export function memoCollapses(memo: string | null | undefined): boolean {
40
+ if (!memo) return false
41
+ return memo.length > MEMO_COLLAPSE_MIN_CHARS || memo.split('\n').length - 1 >= MEMO_COLLAPSE_MIN_BREAKS
42
+ }
43
+
44
+ /** The toggle a click landed on (the button or anything inside it), or null. */
45
+ export function memoToggleFor(target: EventTarget | null): Element | null {
46
+ const el = target as Element | null
47
+ if (!el || typeof el.closest !== 'function') return null
48
+ return el.closest(`[${MEMO_TOGGLE_ATTR}]`)
49
+ }
50
+
51
+ /**
52
+ * Flip the memo box a toggle belongs to, mirroring the state on the button. Returns the new state
53
+ * (true = expanded), or null when the button is not inside a memo box.
54
+ */
55
+ export function toggleMemo(button: Element): boolean | null {
56
+ const box = button.closest(`[${MEMO_ATTR}]`)
57
+ if (!box) return null
58
+ const open = box.getAttribute(MEMO_ATTR) !== 'open'
59
+ box.setAttribute(MEMO_ATTR, open ? 'open' : 'collapsed')
60
+ button.setAttribute('aria-expanded', open ? 'true' : 'false')
61
+ button.textContent = open ? MEMO_COLLAPSE_LABEL : MEMO_EXPAND_LABEL
62
+ return open
63
+ }
@@ -66,6 +66,15 @@
66
66
 
67
67
  import { escapeAttribute, escapeHtml, safeHttpUrl } from './escape.js'
68
68
  import { dedupeCredential, formatDate } from './format.js'
69
+ import {
70
+ MEMO_ATTR,
71
+ MEMO_COLLAPSED_LINES,
72
+ MEMO_EXPAND_LABEL,
73
+ MEMO_TEXT_ATTR,
74
+ MEMO_TOGGLE_ATTR,
75
+ memoCollapses,
76
+ NARROW_VIEWPORT,
77
+ } from './memo-toggle.js'
69
78
  import { CERTREV_LINK_REL } from './rel.js'
70
79
  import {
71
80
  CERT_SCOPE_LINE,
@@ -165,6 +174,15 @@ export interface RenderCertBlockInput {
165
174
  * Ignored outside banner mode.
166
175
  */
167
176
  readonly part?: 'full' | 'header' | 'memo'
177
+ /**
178
+ * 1.1.0: the PHONE face. ABSENT ⇒ byte-identical to 1.0.3. `true` ⇒ at `NARROW_VIEWPORT` or
179
+ * narrower the banner header stacks its author and reviewer columns, and a long memo
180
+ * (`memoCollapses`) is clamped to `MEMO_COLLAPSED_LINES` lines behind a native toggle
181
+ * (`memo-toggle.ts`) on every face that paints one. Wider viewports paint the same card as
182
+ * without it. The Builder card's envelope face sets it; the options path and every other
183
+ * surface leave it off.
184
+ */
185
+ readonly responsive?: boolean
168
186
  }
169
187
 
170
188
  // ─────────────────────────────────────────────────────────────────────────────
@@ -267,6 +285,77 @@ function showsCompensationCue(facts: CertBlockFacts, placed: PlacedTest): boolea
267
285
  const STAMP_LINK_STYLE =
268
286
  '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;'
269
287
 
288
+ // ─────────────────────────────────────────────────────────────────────────────
289
+ // The PHONE face (1.1.0, `responsive`): one scoped stylesheet per root, and the
290
+ // collapsible memo markup `memo-toggle.ts` flips
291
+ // ─────────────────────────────────────────────────────────────────────────────
292
+
293
+ /**
294
+ * The phone rules, scoped to a responsive root so a host page's other markup is never touched.
295
+ * Every rule lives inside the media query: a desktop viewport, or a host that strips the
296
+ * `<style>`, paints exactly the 1.0.3 card (the toggle stays hidden by its inline `display:none`,
297
+ * which only the `!important` rule here overrides).
298
+ */
299
+ const RESPONSIVE_STYLE =
300
+ `<style>@media ${NARROW_VIEWPORT}{` +
301
+ `.certrev-cert[data-certrev-responsive] [data-certrev-stack]{grid-template-columns:1fr!important}` +
302
+ `.certrev-cert[data-certrev-responsive] [${MEMO_ATTR}="collapsed"] [${MEMO_TEXT_ATTR}]{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:${MEMO_COLLAPSED_LINES};line-clamp:${MEMO_COLLAPSED_LINES};overflow:hidden}` +
303
+ `.certrev-cert[data-certrev-responsive] [${MEMO_TOGGLE_ATTR}]{display:inline-flex!important}` +
304
+ `}</style>`
305
+
306
+ /**
307
+ * The memo toggle: a native button (keyboard and screen reader for free), a 44px touch target,
308
+ * styled as the memo footer's stamp links. Hidden INLINE rather than with `hidden`, so a host
309
+ * stylesheet's `[hidden]{display:none!important}` cannot pin it shut on a phone.
310
+ */
311
+ const MEMO_TOGGLE_STYLE =
312
+ 'display:none;align-items:center;min-height:44px;padding:0;margin:0;border:0;background:none;cursor:pointer;font-family:var(--font-mono);font-size:10.5px;font-weight:700;letter-spacing:.14em;text-transform:uppercase;color:var(--navy);text-decoration:underline;text-underline-offset:3px;'
313
+
314
+ /** A stable short id for the memo text (the toggle's `aria-controls`); FNV-1a, base 36. */
315
+ function memoId(seed: string): string {
316
+ let h = 0x811c9dc5
317
+ for (let i = 0; i < seed.length; i++) {
318
+ h ^= seed.charCodeAt(i)
319
+ h = Math.imul(h, 0x01000193)
320
+ }
321
+ return `certrev-memo-${(h >>> 0).toString(36)}`
322
+ }
323
+
324
+ /** The attributes and toggle a collapsible memo adds; all empty when it does not collapse. */
325
+ interface MemoCollapse {
326
+ /** On the box that holds the memo text and its toggle: the state. */
327
+ readonly box: string
328
+ /** On the memo text element: its id and the clamp hook. */
329
+ readonly text: string
330
+ readonly toggle: string
331
+ }
332
+
333
+ const NO_MEMO_COLLAPSE: MemoCollapse = { box: '', text: '', toggle: '' }
334
+
335
+ /**
336
+ * Collapsed by default: the phone rule clamps the text, and the desktop never reads the state.
337
+ * The clamp is visual only, so a screen reader reads the whole memo in either state and hears
338
+ * the toggle as "Read the full memo, button, collapsed".
339
+ */
340
+ function memoCollapse(facts: CertBlockFacts, responsive: boolean, mode: CertBlockLayout): MemoCollapse {
341
+ if (!responsive || !memoCollapses(facts.memo)) return NO_MEMO_COLLAPSE
342
+ const id = memoId(`${mode}\n${facts.certificateUrl}\n${facts.memo}`)
343
+ return {
344
+ box: ` ${MEMO_ATTR}="collapsed"`,
345
+ text: ` id="${id}" ${MEMO_TEXT_ATTR}`,
346
+ toggle:
347
+ `<button type="button" ${MEMO_TOGGLE_ATTR} aria-expanded="false" aria-controls="${id}" ` +
348
+ `style="${MEMO_TOGGLE_STYLE}">${MEMO_EXPAND_LABEL}</button>`,
349
+ }
350
+ }
351
+
352
+ /** The sidebar/custom memo quote, collapsible on a phone. */
353
+ function memoQuote(facts: CertBlockFacts, responsive: boolean, mode: CertBlockLayout): string {
354
+ const collapse = memoCollapse(facts, responsive, mode)
355
+ const quote = `<blockquote${collapse.text} 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>`
356
+ return collapse.toggle ? `<div${collapse.box}>${quote}${collapse.toggle}</div>` : quote
357
+ }
358
+
270
359
  /** A round avatar tile with the given initials. */
271
360
  function avatar(text: string, size: number, fontSize: number): string {
272
361
  return (
@@ -394,13 +483,22 @@ function rootStyle(theme: ResolvedBlockTheme): string {
394
483
  ].join(';')
395
484
  }
396
485
 
397
- function root(mode: CertBlockLayout, theme: ResolvedBlockTheme, inner: string, face?: CertBlockFace): string {
486
+ function root(
487
+ mode: CertBlockLayout,
488
+ theme: ResolvedBlockTheme,
489
+ inner: string,
490
+ face?: CertBlockFace,
491
+ responsive = false,
492
+ ): string {
398
493
  // The rung is an OBSERVABILITY stamp for the crawl monitor — only emitted when a
399
494
  // face is present, so the grandfathered (face-absent) output stays byte-identical.
400
495
  const rungAttr = face ? ` data-certrev-rung="${escapeAttribute(face.rung)}"` : ''
496
+ // The phone face (1.1.0): the scope its rules key on, and the rules, only when asked for.
497
+ const responsiveAttr = responsive ? ' data-certrev-responsive' : ''
498
+ const style = responsive ? RESPONSIVE_STYLE : ''
401
499
  return (
402
500
  `<div class="certrev-cert certrev-cert--${mode}" data-certrev-mode="${mode}" ` +
403
- `data-certrev-strings-version="${CERT_STRINGS_VERSION}"${rungAttr} style="${rootStyle(theme)}">${inner}</div>`
501
+ `data-certrev-strings-version="${CERT_STRINGS_VERSION}"${rungAttr}${responsiveAttr} style="${rootStyle(theme)}">${style}${inner}</div>`
404
502
  )
405
503
  }
406
504
 
@@ -408,7 +506,7 @@ function root(mode: CertBlockLayout, theme: ResolvedBlockTheme, inner: string, f
408
506
  // Face — BANNER: a certification header card + a separate expert-memo card
409
507
  // ─────────────────────────────────────────────────────────────────────────────
410
508
 
411
- function bannerHeaderCard(facts: CertBlockFacts, placed: PlacedTest): string {
509
+ function bannerHeaderCard(facts: CertBlockFacts, placed: PlacedTest, responsive = false): string {
412
510
  const certified = formatDate(facts.certifiedAt)
413
511
  const subtitle =
414
512
  placed('authorTitle') && facts.authorTitle
@@ -473,7 +571,8 @@ function bannerHeaderCard(facts: CertBlockFacts, placed: PlacedTest): string {
473
571
  `<div style="border:1px solid var(--navy-10);border-radius:calc(var(--br,14px) * 0.7);overflow:hidden;font-family:var(--cr-bf);">` +
474
572
  header +
475
573
  `<div style="padding:22px 24px 18px;">` +
476
- `<div style="display:grid;grid-template-columns:${gridCols};gap:22px;">` +
574
+ // On a phone (responsive) the two columns stack: the stylesheet keys on `data-certrev-stack`.
575
+ `<div${responsive ? ' data-certrev-stack' : ''} style="display:grid;grid-template-columns:${gridCols};gap:22px;">` +
477
576
  authorColumn +
478
577
  `<div>` +
479
578
  `<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>` +
@@ -487,7 +586,7 @@ function bannerHeaderCard(facts: CertBlockFacts, placed: PlacedTest): string {
487
586
  )
488
587
  }
489
588
 
490
- function bannerMemoCard(facts: CertBlockFacts, placed: PlacedTest): string {
589
+ function bannerMemoCard(facts: CertBlockFacts, placed: PlacedTest, responsive = false): string {
491
590
  // The memo card composes `<name><, credential>` as two spans rather than through
492
591
  // `nameWithCredential`, so it needs the SAME duplicate guard — the doubled "MD, MD" was
493
592
  // visible on this card too. Gated on what SURVIVES the dedupe, not on the raw field: a
@@ -500,15 +599,18 @@ function bannerMemoCard(facts: CertBlockFacts, placed: PlacedTest): string {
500
599
  // placement (the pattern `bio` already follows): placement alone painted an "Expert
501
600
  // memo" heading over an empty `<p>` for a contract-legal empty memo.
502
601
  const memoAvatar = placed('reviewerPhoto') ? avatar(initials(facts.reviewerName), 46, 13) : ''
602
+ // On a phone a long memo collapses; the name row stays visible above it (the box is the column).
603
+ const collapse = memoCollapse(facts, responsive, 'banner')
503
604
  const memoSection =
504
605
  placed('memo') && facts.memo
505
606
  ? `<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
607
  `<div style="display:flex;gap:15px;">${memoAvatar}` +
507
- `<div style="flex:1;min-width:0;">` +
608
+ `<div${collapse.box} style="flex:1;min-width:0;">` +
508
609
  `<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
610
  // Readable, UPRIGHT DM Sans quote (the display-serif italic hurt legibility);
510
611
  // 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>` +
612
+ `<p${collapse.text} 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>` +
613
+ collapse.toggle +
512
614
  `</div>` +
513
615
  `</div>`
514
616
  : ''
@@ -547,7 +649,7 @@ function bannerMemoCard(facts: CertBlockFacts, placed: PlacedTest): string {
547
649
  // Face — SIDEBAR (the DEFAULT): a reviewer card pinned beside the article body
548
650
  // ─────────────────────────────────────────────────────────────────────────────
549
651
 
550
- function sidebarCard(facts: CertBlockFacts, placed: PlacedTest): string {
652
+ function sidebarCard(facts: CertBlockFacts, placed: PlacedTest, responsive = false): string {
551
653
  const header = placed('label')
552
654
  ? `<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>`
553
655
  : ''
@@ -558,10 +660,7 @@ function sidebarCard(facts: CertBlockFacts, placed: PlacedTest): string {
558
660
  : ''
559
661
  // Gated on CONTENT as well as placement (like `bioPara` above) — placement alone rendered
560
662
  // 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
- : ''
663
+ const blockquote = placed('memo') && facts.memo ? memoQuote(facts, responsive, 'sidebar') : ''
565
664
  // Disclosure row: cue + ` · ` + scope; the separator only when BOTH land, the row only when either does.
566
665
  const cueEl = showsCompensationCue(facts, placed)
567
666
  ? `<span style="font-family:var(--font-mono);font-size:10px;letter-spacing:.1em;text-transform:uppercase;">${escapeHtml(COMPENSATED_EXPERT_CUE)}</span>`
@@ -654,15 +753,12 @@ function floatingPill(facts: CertBlockFacts, placed: PlacedTest): string {
654
753
  // `data-certrev-*`-hooked building blocks the locked layouts use, so the custom face
655
754
  // carries the same markers as banner/sidebar and the crawl monitor reads them alike.
656
755
  //
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.
756
+ // No runtime guard watches any of it: the `modal/ftc-guard.ts` kill switch (which never saw
757
+ // this renderer's output anyway: it keyed on `.certrev-memo`) was removed in 1.1.1. Whether a
758
+ // face shows its scope line is decided by its configuration, not policed in the page.
663
759
  // ─────────────────────────────────────────────────────────────────────────────
664
760
 
665
- function renderCustomFace(facts: CertBlockFacts, placed: PlacedTest): string {
761
+ function renderCustomFace(facts: CertBlockFacts, placed: PlacedTest, responsive = false): string {
666
762
  const parts: string[] = []
667
763
 
668
764
  // 1. label — the navy "Expert reviewed" header bar (sidebar's header markup)
@@ -703,9 +799,7 @@ function renderCustomFace(facts: CertBlockFacts, placed: PlacedTest): string {
703
799
 
704
800
  // 5. memo (sidebar's blockquote) — placement AND content, like `bio` directly above
705
801
  if (placed('memo') && facts.memo) {
706
- parts.push(
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>`,
708
- )
802
+ parts.push(memoQuote(facts, responsive, 'custom'))
709
803
  }
710
804
 
711
805
  // 6. disclosures (sidebar's cue · scope row; separator only when BOTH land)
@@ -755,13 +849,14 @@ export function renderCertBlock(input: RenderCertBlockInput): string {
755
849
  const theme = resolveBlockTheme(input.theme)
756
850
  const { facts, face } = input
757
851
  const placed = placedTester(face)
852
+ const responsive = input.responsive === true
758
853
  switch (input.mode) {
759
854
  case 'banner': {
760
855
  // Suppress the memo card entirely when the engine placed none of its fields
761
856
  // (avoids an empty bordered shell). Grandfather: all placed ⇒ card renders.
762
857
  const memoCard =
763
858
  placed('memo') || placed('profileLink') || placed('certificateLink') || placed('scopeLine')
764
- ? bannerMemoCard(facts, placed)
859
+ ? bannerMemoCard(facts, placed, responsive)
765
860
  : ''
766
861
  // First-class memo split: default 'full' is byte-identical to the prior
767
862
  // header+memo render; 'header'/'memo' emit only that card so the exporter can place
@@ -770,16 +865,17 @@ export function renderCertBlock(input: RenderCertBlockInput): string {
770
865
  const part = input.part === 'header' || input.part === 'memo' ? input.part : 'full'
771
866
  const inner =
772
867
  part === 'header'
773
- ? bannerHeaderCard(facts, placed)
868
+ ? bannerHeaderCard(facts, placed, responsive)
774
869
  : part === 'memo'
775
870
  ? memoCard
776
- : bannerHeaderCard(facts, placed) + memoCard
777
- return root('banner', theme, inner, face)
871
+ : bannerHeaderCard(facts, placed, responsive) + memoCard
872
+ return root('banner', theme, inner, face, responsive)
778
873
  }
779
874
  case 'floating':
875
+ // The pill has no memo and no columns: nothing on it changes on a phone.
780
876
  return root('floating', theme, floatingPill(facts, placed), face)
781
877
  case 'custom':
782
- return root('custom', theme, renderCustomFace(facts, placed), face)
878
+ return root('custom', theme, renderCustomFace(facts, placed, responsive), face, responsive)
783
879
  // THE default placement, named here and nowhere else: the declared default and the
784
880
  // runtime fallback are the same line, so they cannot drift apart. Naming `sidebar` as a
785
881
  // real case also keeps the exhaustiveness read over CertBlockLayout honest — a reader
@@ -787,6 +883,6 @@ export function renderCertBlock(input: RenderCertBlockInput): string {
787
883
  // biome-ignore lint/complexity/noUselessSwitchCase: the redundancy IS the mechanism (see above)
788
884
  case 'sidebar':
789
885
  default:
790
- return root('sidebar', theme, sidebarCard(facts, placed), face)
886
+ return root('sidebar', theme, sidebarCard(facts, placed, responsive), face, responsive)
791
887
  }
792
888
  }
package/src/index.ts CHANGED
@@ -36,6 +36,21 @@ export {
36
36
  type ResolvedDisplay,
37
37
  resolveDisplay,
38
38
  } from './components/format.js'
39
+ // ── The PHONE face's collapsible memo (1.1.0): the markup contract + the one toggle ──
40
+ export {
41
+ MEMO_ATTR,
42
+ MEMO_COLLAPSE_LABEL,
43
+ MEMO_COLLAPSE_MIN_BREAKS,
44
+ MEMO_COLLAPSE_MIN_CHARS,
45
+ MEMO_COLLAPSED_LINES,
46
+ MEMO_EXPAND_LABEL,
47
+ MEMO_TEXT_ATTR,
48
+ MEMO_TOGGLE_ATTR,
49
+ memoCollapses,
50
+ memoToggleFor,
51
+ NARROW_VIEWPORT,
52
+ toggleMemo,
53
+ } from './components/memo-toggle.js'
39
54
  // ── Pre-sales PREVIEW block — static, non-delivery, hard-bounded ──
40
55
  export {
41
56
  type PreviewBlockOptions,
@@ -118,6 +133,19 @@ export {
118
133
  serializeJsonLdForScript,
119
134
  } from './jsonld/project.js'
120
135
  export { TtlCache, type TtlCacheOptions } from './verify/cache.js'
136
+ export {
137
+ type CachedDelivery,
138
+ deliveryCacheKey,
139
+ deliveryUrl,
140
+ type GetVerifiedDeliveryOptions,
141
+ getVerifiedDelivery,
142
+ invalidateDelivery,
143
+ parseRenderDef,
144
+ peekVerifiedDelivery,
145
+ settleDelivery,
146
+ sharedDeliveryCache,
147
+ type VerifiedDelivery,
148
+ } from './verify/get-verified-delivery.js'
121
149
  // ── Verify layer ──────────────────────────────────────────────────────────────────
122
150
  export {
123
151
  type EnvelopeSource,