@certrev/cert-block 1.0.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +89 -0
  2. package/README.md +6 -1
  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 +41 -4
  8. package/dist/builder/cert-review-card.d.ts.map +1 -1
  9. package/dist/builder/cert-review-card.js +133 -7
  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 +15 -3
  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 +80 -19
  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/verify/get-verified-delivery.d.ts +91 -0
  40. package/dist/verify/get-verified-delivery.d.ts.map +1 -0
  41. package/dist/verify/get-verified-delivery.js +157 -0
  42. package/dist/verify/get-verified-delivery.js.map +1 -0
  43. package/package.json +5 -1
  44. package/src/builder/cert-chrome-data.ts +27 -0
  45. package/src/builder/cert-review-card.tsx +170 -10
  46. package/src/builder/delivery-runtime.ts +54 -0
  47. package/src/builder/envelope-face.ts +173 -0
  48. package/src/builder/index.tsx +24 -2
  49. package/src/builder/issuer-key.ts +13 -0
  50. package/src/components/memo-toggle.ts +63 -0
  51. package/src/components/render-cert-block.ts +121 -22
  52. package/src/index.ts +28 -0
  53. package/src/verify/get-verified-delivery.ts +211 -0
@@ -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>`
@@ -662,7 +761,7 @@ function floatingPill(facts: CertBlockFacts, placed: PlacedTest): string {
662
761
  // (which surfaces the switch covers) — not something to settle by quietly adding hooks.
663
762
  // ─────────────────────────────────────────────────────────────────────────────
664
763
 
665
- function renderCustomFace(facts: CertBlockFacts, placed: PlacedTest): string {
764
+ function renderCustomFace(facts: CertBlockFacts, placed: PlacedTest, responsive = false): string {
666
765
  const parts: string[] = []
667
766
 
668
767
  // 1. label — the navy "Expert reviewed" header bar (sidebar's header markup)
@@ -703,9 +802,7 @@ function renderCustomFace(facts: CertBlockFacts, placed: PlacedTest): string {
703
802
 
704
803
  // 5. memo (sidebar's blockquote) — placement AND content, like `bio` directly above
705
804
  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
- )
805
+ parts.push(memoQuote(facts, responsive, 'custom'))
709
806
  }
710
807
 
711
808
  // 6. disclosures (sidebar's cue · scope row; separator only when BOTH land)
@@ -755,13 +852,14 @@ export function renderCertBlock(input: RenderCertBlockInput): string {
755
852
  const theme = resolveBlockTheme(input.theme)
756
853
  const { facts, face } = input
757
854
  const placed = placedTester(face)
855
+ const responsive = input.responsive === true
758
856
  switch (input.mode) {
759
857
  case 'banner': {
760
858
  // Suppress the memo card entirely when the engine placed none of its fields
761
859
  // (avoids an empty bordered shell). Grandfather: all placed ⇒ card renders.
762
860
  const memoCard =
763
861
  placed('memo') || placed('profileLink') || placed('certificateLink') || placed('scopeLine')
764
- ? bannerMemoCard(facts, placed)
862
+ ? bannerMemoCard(facts, placed, responsive)
765
863
  : ''
766
864
  // First-class memo split: default 'full' is byte-identical to the prior
767
865
  // header+memo render; 'header'/'memo' emit only that card so the exporter can place
@@ -770,16 +868,17 @@ export function renderCertBlock(input: RenderCertBlockInput): string {
770
868
  const part = input.part === 'header' || input.part === 'memo' ? input.part : 'full'
771
869
  const inner =
772
870
  part === 'header'
773
- ? bannerHeaderCard(facts, placed)
871
+ ? bannerHeaderCard(facts, placed, responsive)
774
872
  : part === 'memo'
775
873
  ? memoCard
776
- : bannerHeaderCard(facts, placed) + memoCard
777
- return root('banner', theme, inner, face)
874
+ : bannerHeaderCard(facts, placed, responsive) + memoCard
875
+ return root('banner', theme, inner, face, responsive)
778
876
  }
779
877
  case 'floating':
878
+ // The pill has no memo and no columns: nothing on it changes on a phone.
780
879
  return root('floating', theme, floatingPill(facts, placed), face)
781
880
  case 'custom':
782
- return root('custom', theme, renderCustomFace(facts, placed), face)
881
+ return root('custom', theme, renderCustomFace(facts, placed, responsive), face, responsive)
783
882
  // THE default placement, named here and nowhere else: the declared default and the
784
883
  // runtime fallback are the same line, so they cannot drift apart. Naming `sidebar` as a
785
884
  // real case also keeps the exhaustiveness read over CertBlockLayout honest — a reader
@@ -787,6 +886,6 @@ export function renderCertBlock(input: RenderCertBlockInput): string {
787
886
  // biome-ignore lint/complexity/noUselessSwitchCase: the redundancy IS the mechanism (see above)
788
887
  case 'sidebar':
789
888
  default:
790
- return root('sidebar', theme, sidebarCard(facts, placed), face)
889
+ return root('sidebar', theme, sidebarCard(facts, placed, responsive), face, responsive)
791
890
  }
792
891
  }
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,
@@ -0,0 +1,211 @@
1
+ /**
2
+ * ─────────────────────────────────────────────────────────────────────────────
3
+ * getVerifiedDelivery — the envelope PLUS the brand's render def, from the Delivery API
4
+ * ─────────────────────────────────────────────────────────────────────────────
5
+ *
6
+ * 1.1.0. The Delivery API answers a Builder placement with the signed artifact and, beside it,
7
+ * the brand's resolved render-def projection as a top-level `renderDef` sibling (the same shape a
8
+ * Shopify store carries in its `render_def` metafield). The envelope's signature covers `payload`
9
+ * only, so the sibling rides the response without touching the signed bytes, and a tombstone
10
+ * carries none. `getVerifiedEnvelope` keeps discarding it; this reads it.
11
+ *
12
+ * The same fetch, deadline, kernel and fail-closed rules as `getVerifiedEnvelope` (every sourcing
13
+ * failure reports the reason it actually is; nothing throws into the render path). Two things are
14
+ * different, and both are the point:
15
+ *
16
+ * 1. WHAT IS CACHED is the cryptographically verified PAYLOAD, not the verdict. The lifecycle
17
+ * policy (revoked, expired) and the subject match are re-run by `renderVerdict` on EVERY call,
18
+ * with the caller's clock. So a payload that was valid when fetched and expires while cached
19
+ * stops rendering at its `expiresAt`, not at the next refetch; and a refetch after the TTL
20
+ * picks up a tombstone (revocation) or a changed def with no write on the brand's side.
21
+ * 2. The CACHE KEY is the Delivery URL plus the rendering placement, so a staging and a
22
+ * production origin never share an entry, and neither do two placements.
23
+ *
24
+ * `peekVerifiedDelivery` is the synchronous read of the same cache: a React component suspends on
25
+ * `getVerifiedDelivery` once, then reads the settled value without a second promise (which is what
26
+ * keeps a streaming SSR render from suspending forever on a fresh promise every retry).
27
+ */
28
+
29
+ import type { CertChromeRenderDef } from '../builder/cert-chrome-data.js'
30
+ import type {
31
+ CertDeliveryArtifact,
32
+ CertPayload,
33
+ CertSuppressReason,
34
+ CertVerdict,
35
+ RenderContext,
36
+ ResolvePublicKeyByKid,
37
+ } from '../contract/kernel.js'
38
+ import { renderVerdict, verifyArtifact } from '../contract/kernel.js'
39
+ import { TtlCache } from './cache.js'
40
+ import { DEFAULT_FETCH_TIMEOUT_MS, fetchWithDeadline } from './fetch-with-deadline.js'
41
+
42
+ /** What one Delivery read settles to, as cached: a verified payload + its def, or the reason not. */
43
+ export type CachedDelivery =
44
+ | { readonly kind: 'verified'; readonly payload: CertPayload; readonly renderDef: CertChromeRenderDef | null }
45
+ | { readonly kind: 'suppressed'; readonly reason: CertSuppressReason }
46
+
47
+ /** The answer for one render: the verdict AT THIS RENDER'S CLOCK, and the def to paint it with. */
48
+ export interface VerifiedDelivery {
49
+ readonly verdict: CertVerdict
50
+ /** The brand's render def, or null (no def resolved, a malformed one, or a suppress verdict). */
51
+ readonly renderDef: CertChromeRenderDef | null
52
+ }
53
+
54
+ export interface GetVerifiedDeliveryOptions {
55
+ /** Base URL of the CertREV app that serves the Delivery API, e.g. 'https://portal.certrev.com'. */
56
+ readonly baseUrl: string
57
+ readonly platform: string
58
+ readonly externalId: string
59
+ /** kid → public-key resolver. Required: no verify without keys. */
60
+ readonly resolveKid: ResolvePublicKeyByKid
61
+ /** Render context: the platform + externalId this edge IS, and the render's clock (`now`). */
62
+ readonly context: RenderContext
63
+ readonly fetchImpl?: typeof fetch
64
+ /** Cache override. Defaults to the module-level shared delivery cache. */
65
+ readonly cache?: TtlCache<CachedDelivery>
66
+ /** TTL in ms for a VERIFIED read (default: the cache's positive TTL, 60_000 on the shared one). */
67
+ readonly ttlMs?: number
68
+ /** Deadline in ms for the fetch (default `DEFAULT_FETCH_TIMEOUT_MS`, 3s). */
69
+ readonly timeoutMs?: number
70
+ }
71
+
72
+ /** Shared per-process cache: 60s for a verified read, 5s for a suppressed one. */
73
+ const sharedDeliveryCache = new TtlCache<CachedDelivery>({ ttlMs: 60_000, negativeTtlMs: 5_000 })
74
+
75
+ function suppressed(reason: CertSuppressReason): CachedDelivery {
76
+ return { kind: 'suppressed', reason }
77
+ }
78
+
79
+ export function deliveryUrl(baseUrl: string, platform: string, externalId: string): string {
80
+ const base = baseUrl.replace(/\/+$/, '')
81
+ return `${base}/api/cert/v1/delivery/${encodeURIComponent(platform)}/${encodeURIComponent(externalId)}`
82
+ }
83
+
84
+ /** Cache key: the URL (origin included) × the placement rendering it. No clock: the TTL bounds time. */
85
+ export function deliveryCacheKey(
86
+ opts: Pick<GetVerifiedDeliveryOptions, 'baseUrl' | 'platform' | 'externalId' | 'context'>,
87
+ ): string {
88
+ return `delivery:${deliveryUrl(opts.baseUrl, opts.platform, opts.externalId)}|${encodeURIComponent(opts.context.platform)}:${encodeURIComponent(opts.context.externalId)}`
89
+ }
90
+
91
+ const isString = (v: unknown): v is string => typeof v === 'string'
92
+ const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
93
+
94
+ /**
95
+ * The def sibling, shape-checked. Anything that is not the metafield shape is null, which paints
96
+ * the preset default (the same thing an absent def paints): a def is presentation, never a claim,
97
+ * so a malformed one degrades the styling and nothing else. The TOKEN VALUES are not judged here;
98
+ * `resolveBlockTheme` re-validates each at paint time and drops what is not a safe value.
99
+ */
100
+ export function parseRenderDef(value: unknown): CertChromeRenderDef | null {
101
+ if (!isRecord(value)) return null
102
+ const { v, preset, rung, tokens, hidden, barInkFg, layout, customFace } = value
103
+ if (typeof v !== 'number' || !Number.isFinite(v)) return null
104
+ if (!isString(preset) || !isString(rung) || !isRecord(tokens)) return null
105
+ if (!Array.isArray(hidden) || !hidden.every(isString)) return null
106
+ if (layout !== undefined && !isString(layout)) return null
107
+ let face: CertChromeRenderDef['customFace']
108
+ if (customFace !== undefined) {
109
+ if (!isRecord(customFace) || !Array.isArray(customFace.placedFields) || !customFace.placedFields.every(isString)) {
110
+ return null
111
+ }
112
+ face = { placedFields: [...customFace.placedFields] }
113
+ }
114
+ const tokenOut: Record<string, string> = {}
115
+ for (const key of ['accentColor', 'surface', 'cornerRadius', 'fontSlot', 'barInk', 'inkColor'] as const) {
116
+ if (isString(tokens[key])) tokenOut[key] = tokens[key]
117
+ }
118
+ return {
119
+ v,
120
+ preset,
121
+ rung,
122
+ tokens: tokenOut,
123
+ ...(barInkFg === '#141414' || barInkFg === '#ffffff' ? { barInkFg } : {}),
124
+ hidden: [...hidden],
125
+ ...(layout !== undefined ? { layout } : {}),
126
+ ...(face ? { customFace: face } : {}),
127
+ }
128
+ }
129
+
130
+ /** Fetch, parse and cryptographically verify one Delivery response. Never throws. */
131
+ async function loadDelivery(opts: GetVerifiedDeliveryOptions): Promise<CachedDelivery> {
132
+ const fetchImpl = opts.fetchImpl ?? globalThis.fetch
133
+ let res: Response
134
+ try {
135
+ res = await fetchWithDeadline(
136
+ fetchImpl,
137
+ deliveryUrl(opts.baseUrl, opts.platform, opts.externalId),
138
+ { headers: { accept: 'application/json' } },
139
+ opts.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS,
140
+ )
141
+ } catch {
142
+ // Unreachable, DNS/TLS, or the deadline. Same least-wrong member as getVerifiedEnvelope.
143
+ return suppressed('unsupported_contract_version')
144
+ }
145
+ // Nothing served for this placement: never certified, or withdrawn. A LIVE revocation is a 200
146
+ // tombstone, so a 4xx here genuinely means "no credential for this subject".
147
+ if (res.status === 404 || res.status === 410) return suppressed('subject_mismatch')
148
+ if (!res.ok) return suppressed('unsupported_contract_version')
149
+ let body: unknown
150
+ try {
151
+ body = await res.json()
152
+ } catch {
153
+ return suppressed('unsupported_contract_version')
154
+ }
155
+ if (!isRecord(body)) return suppressed('unsupported_contract_version')
156
+ let verdict: CertVerdict
157
+ try {
158
+ // `verifyArtifact`, so a tombstone verifies as itself and reports 'revoked'. The kernel reads
159
+ // `payload` + `signature` (or the tombstone's own fields); the `renderDef` sibling is not
160
+ // part of what is verified, and is never read unless the artifact verified.
161
+ verdict = await verifyArtifact(body as unknown as CertDeliveryArtifact, opts.resolveKid, { ...opts.context })
162
+ } catch {
163
+ return suppressed('unknown_key')
164
+ }
165
+ if (verdict.decision !== 'render') return suppressed(verdict.reason)
166
+ return { kind: 'verified', payload: verdict.payload, renderDef: parseRenderDef(body.renderDef) }
167
+ }
168
+
169
+ /** Apply the render-time policy to a cached read: lifecycle + subject at THIS render's clock. */
170
+ export function settleDelivery(cached: CachedDelivery, context: RenderContext): VerifiedDelivery {
171
+ if (cached.kind === 'suppressed') return { verdict: { decision: 'suppress', reason: cached.reason }, renderDef: null }
172
+ const verdict = renderVerdict(cached.payload, context)
173
+ return { verdict, renderDef: verdict.decision === 'render' ? cached.renderDef : null }
174
+ }
175
+
176
+ /**
177
+ * Fetch (if needed), verify, cache, and settle one Delivery read for one placement. FAIL-CLOSED:
178
+ * the verdict is `render` only when the artifact verified against `resolveKid` AND, at
179
+ * `context.now`, the payload is unrevoked, unexpired and for this placement.
180
+ */
181
+ export async function getVerifiedDelivery(opts: GetVerifiedDeliveryOptions): Promise<VerifiedDelivery> {
182
+ const cache = opts.cache ?? sharedDeliveryCache
183
+ const cached = await cache.getOrLoad(
184
+ deliveryCacheKey(opts),
185
+ () => loadDelivery(opts),
186
+ (v) => v.kind === 'suppressed',
187
+ opts.ttlMs,
188
+ )
189
+ return settleDelivery(cached, opts.context)
190
+ }
191
+
192
+ /**
193
+ * The synchronous read: the settled answer when a fresh read is cached, else `undefined` (the
194
+ * caller then awaits `getVerifiedDelivery`). Never fetches.
195
+ */
196
+ export function peekVerifiedDelivery(
197
+ opts: Pick<GetVerifiedDeliveryOptions, 'baseUrl' | 'platform' | 'externalId' | 'context' | 'cache'>,
198
+ ): VerifiedDelivery | undefined {
199
+ const cached = (opts.cache ?? sharedDeliveryCache).peek(deliveryCacheKey(opts))
200
+ return cached === undefined ? undefined : settleDelivery(cached, opts.context)
201
+ }
202
+
203
+ /** Drop every cached read for one placement (e.g. from a revocation webhook). */
204
+ export function invalidateDelivery(
205
+ opts: Pick<GetVerifiedDeliveryOptions, 'baseUrl' | 'platform' | 'externalId' | 'context'>,
206
+ cache: TtlCache<CachedDelivery> = sharedDeliveryCache,
207
+ ): void {
208
+ cache.delete(deliveryCacheKey(opts))
209
+ }
210
+
211
+ export { sharedDeliveryCache }