consent-engine 0.3.2__tar.gz → 0.3.4__tar.gz

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 (60) hide show
  1. {consent_engine-0.3.2 → consent_engine-0.3.4}/PKG-INFO +1 -1
  2. {consent_engine-0.3.2 → consent_engine-0.3.4}/pyproject.toml +1 -1
  3. {consent_engine-0.3.2 → consent_engine-0.3.4}/src/consent_engine/__init__.py +1 -1
  4. {consent_engine-0.3.2 → consent_engine-0.3.4}/src/consent_engine/templates/audit_report.html.j2 +15 -8
  5. {consent_engine-0.3.2 → consent_engine-0.3.4}/src/consent_engine/tools/tool_02_violation_classifier.py +8 -8
  6. {consent_engine-0.3.2 → consent_engine-0.3.4}/src/consent_engine/tools/tool_08_report_generator.py +113 -37
  7. {consent_engine-0.3.2 → consent_engine-0.3.4}/.gitignore +0 -0
  8. {consent_engine-0.3.2 → consent_engine-0.3.4}/LICENSE +0 -0
  9. {consent_engine-0.3.2 → consent_engine-0.3.4}/README.md +0 -0
  10. {consent_engine-0.3.2 → consent_engine-0.3.4}/src/consent_engine/api.py +0 -0
  11. {consent_engine-0.3.2 → consent_engine-0.3.4}/src/consent_engine/audit.py +0 -0
  12. {consent_engine-0.3.2 → consent_engine-0.3.4}/src/consent_engine/cli.py +0 -0
  13. {consent_engine-0.3.2 → consent_engine-0.3.4}/src/consent_engine/config.py +0 -0
  14. {consent_engine-0.3.2 → consent_engine-0.3.4}/src/consent_engine/data/vendor_library/open-cookie-database.csv +0 -0
  15. {consent_engine-0.3.2 → consent_engine-0.3.4}/src/consent_engine/data/vendor_library/vendors.json +0 -0
  16. {consent_engine-0.3.2 → consent_engine-0.3.4}/src/consent_engine/data/wiki/CLAUDE.md +0 -0
  17. {consent_engine-0.3.2 → consent_engine-0.3.4}/src/consent_engine/data/wiki/concepts/cipa-vppa.md +0 -0
  18. {consent_engine-0.3.2 → consent_engine-0.3.4}/src/consent_engine/data/wiki/concepts/cmp-failures.md +0 -0
  19. {consent_engine-0.3.2 → consent_engine-0.3.4}/src/consent_engine/data/wiki/concepts/consent-mode-v2.md +0 -0
  20. {consent_engine-0.3.2 → consent_engine-0.3.4}/src/consent_engine/data/wiki/concepts/dark-patterns.md +0 -0
  21. {consent_engine-0.3.2 → consent_engine-0.3.4}/src/consent_engine/data/wiki/concepts/gpc-signal.md +0 -0
  22. {consent_engine-0.3.2 → consent_engine-0.3.4}/src/consent_engine/data/wiki/concepts/ssgtm-risk.md +0 -0
  23. {consent_engine-0.3.2 → consent_engine-0.3.4}/src/consent_engine/data/wiki/enforcement/emerging-trends.md +0 -0
  24. {consent_engine-0.3.2 → consent_engine-0.3.4}/src/consent_engine/data/wiki/enforcement/gdpr-fines.md +0 -0
  25. {consent_engine-0.3.2 → consent_engine-0.3.4}/src/consent_engine/data/wiki/enforcement/lawsuit-surge.md +0 -0
  26. {consent_engine-0.3.2 → consent_engine-0.3.4}/src/consent_engine/data/wiki/enforcement/live-fines-db.md +0 -0
  27. {consent_engine-0.3.2 → consent_engine-0.3.4}/src/consent_engine/data/wiki/enforcement/us-class-actions.md +0 -0
  28. {consent_engine-0.3.2 → consent_engine-0.3.4}/src/consent_engine/data/wiki/enforcement/us-enforcement.md +0 -0
  29. {consent_engine-0.3.2 → consent_engine-0.3.4}/src/consent_engine/data/wiki/index.md +0 -0
  30. {consent_engine-0.3.2 → consent_engine-0.3.4}/src/consent_engine/data/wiki/log.md +0 -0
  31. {consent_engine-0.3.2 → consent_engine-0.3.4}/src/consent_engine/data/wiki/regulations/ccpa.md +0 -0
  32. {consent_engine-0.3.2 → consent_engine-0.3.4}/src/consent_engine/data/wiki/regulations/gdpr.md +0 -0
  33. {consent_engine-0.3.2 → consent_engine-0.3.4}/src/consent_engine/data/wiki/regulations/quebec-law25.md +0 -0
  34. {consent_engine-0.3.2 → consent_engine-0.3.4}/src/consent_engine/data/wiki/regulations/tcf.md +0 -0
  35. {consent_engine-0.3.2 → consent_engine-0.3.4}/src/consent_engine/data/wiki/regulations/us-state-laws.md +0 -0
  36. {consent_engine-0.3.2 → consent_engine-0.3.4}/src/consent_engine/data/wiki/technical/cmp-profiles.md +0 -0
  37. {consent_engine-0.3.2 → consent_engine-0.3.4}/src/consent_engine/data/wiki/technical/consent-mode-impact.md +0 -0
  38. {consent_engine-0.3.2 → consent_engine-0.3.4}/src/consent_engine/data/wiki/technical/google-tag-gateway.md +0 -0
  39. {consent_engine-0.3.2 → consent_engine-0.3.4}/src/consent_engine/data/wiki/technical/scanner-methodology.md +0 -0
  40. {consent_engine-0.3.2 → consent_engine-0.3.4}/src/consent_engine/llm/__init__.py +0 -0
  41. {consent_engine-0.3.2 → consent_engine-0.3.4}/src/consent_engine/llm/client.py +0 -0
  42. {consent_engine-0.3.2 → consent_engine-0.3.4}/src/consent_engine/mcp_server.py +0 -0
  43. {consent_engine-0.3.2 → consent_engine-0.3.4}/src/consent_engine/models/__init__.py +0 -0
  44. {consent_engine-0.3.2 → consent_engine-0.3.4}/src/consent_engine/models/audit_request.py +0 -0
  45. {consent_engine-0.3.2 → consent_engine-0.3.4}/src/consent_engine/models/audit_result.py +0 -0
  46. {consent_engine-0.3.2 → consent_engine-0.3.4}/src/consent_engine/models/scan_result.py +0 -0
  47. {consent_engine-0.3.2 → consent_engine-0.3.4}/src/consent_engine/models/vendor.py +0 -0
  48. {consent_engine-0.3.2 → consent_engine-0.3.4}/src/consent_engine/templates/audit_deck.marp.md.j2 +0 -0
  49. {consent_engine-0.3.2 → consent_engine-0.3.4}/src/consent_engine/tools/__init__.py +0 -0
  50. {consent_engine-0.3.2 → consent_engine-0.3.4}/src/consent_engine/tools/cmp_clicker.py +0 -0
  51. {consent_engine-0.3.2 → consent_engine-0.3.4}/src/consent_engine/tools/cmp_detector.py +0 -0
  52. {consent_engine-0.3.2 → consent_engine-0.3.4}/src/consent_engine/tools/cmp_injector.py +0 -0
  53. {consent_engine-0.3.2 → consent_engine-0.3.4}/src/consent_engine/tools/jurisdiction_detector.py +0 -0
  54. {consent_engine-0.3.2 → consent_engine-0.3.4}/src/consent_engine/tools/tool_01_gtm_parser.py +0 -0
  55. {consent_engine-0.3.2 → consent_engine-0.3.4}/src/consent_engine/tools/tool_03_browser_scanner.py +0 -0
  56. {consent_engine-0.3.2 → consent_engine-0.3.4}/src/consent_engine/tools/tool_04_har_analyzer.py +0 -0
  57. {consent_engine-0.3.2 → consent_engine-0.3.4}/src/consent_engine/tools/tool_05_vendor_library.py +0 -0
  58. {consent_engine-0.3.2 → consent_engine-0.3.4}/src/consent_engine/tools/tool_06_ssgtm_detector.py +0 -0
  59. {consent_engine-0.3.2 → consent_engine-0.3.4}/src/consent_engine/tools/tool_06b_pixel_detector.py +0 -0
  60. {consent_engine-0.3.2 → consent_engine-0.3.4}/src/consent_engine/tools/tool_07_rag_retriever.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: consent-engine
3
- Version: 0.3.2
3
+ Version: 0.3.4
4
4
  Summary: Forensic consent-compliance audit engine. Deterministic by design.
5
5
  Project-URL: Homepage, https://github.com/kb223/consent-engine
6
6
  Project-URL: Repository, https://github.com/kb223/consent-engine
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "consent-engine"
7
- version = "0.3.2"
7
+ version = "0.3.4"
8
8
  description = "Forensic consent-compliance audit engine. Deterministic by design."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -8,4 +8,4 @@ Public package surface:
8
8
  - consent_engine.llm.client LiteLLM-wrapped chat surface (agentic layer)
9
9
  """
10
10
 
11
- __version__ = "0.3.2"
11
+ __version__ = "0.3.4"
@@ -692,8 +692,8 @@
692
692
  <!-- REVENUE RECOVERY ESTIMATE (signal variant only) -->
693
693
  <div class="recovery-panel">
694
694
  <div class="recovery-head">
695
- <span class="recovery-tag">Recoverable Ad Revenue</span>
696
- <h3 class="recovery-title">Estimated scale unlock from fixing this stack</h3>
695
+ <span class="recovery-tag">Recoverable Conversion Value</span>
696
+ <h3 class="recovery-title">Monthly conversion value recoverable by fixing this stack</h3>
697
697
  </div>
698
698
  <div class="recovery-figure">
699
699
  <div class="recovery-range">
@@ -701,19 +701,24 @@
701
701
  <span class="recovery-unit">/ month</span>
702
702
  </div>
703
703
  <div class="recovery-annual">
704
- ≈ ${{ "{:,}".format(recovery.annual_recoverable_usd) }} / year at current spend
704
+ ≈ ${{ "{:,}".format(recovery.annual_recoverable_usd) }} / year &nbsp;·&nbsp; brand tier: <strong>{{ recovery.brand_tier_label }}</strong>
705
705
  </div>
706
706
  </div>
707
707
  <div class="recovery-grid">
708
708
  <div class="recovery-cell">
709
709
  <div class="recovery-cell-label">Monthly ad spend</div>
710
710
  <div class="recovery-cell-value">${{ "{:,}".format(recovery.monthly_ad_spend_usd) }}</div>
711
- <div class="recovery-cell-sub">{{ 'self-reported' if recovery.ad_spend_user_provided else 'ICP default — override with real spend' }}</div>
711
+ <div class="recovery-cell-sub">{{ 'self-reported' if recovery.ad_spend_user_provided else 'auto-estimated from brand tier — override with --monthly-ad-spend' }}</div>
712
+ </div>
713
+ <div class="recovery-cell">
714
+ <div class="recovery-cell-label">Ad-attributable revenue</div>
715
+ <div class="recovery-cell-value">${{ "{:,}".format(recovery.ad_attributable_revenue_usd) }}</div>
716
+ <div class="recovery-cell-sub">monthly, at {{ recovery.roas_multiplier }}× ROAS</div>
712
717
  </div>
713
718
  <div class="recovery-cell">
714
719
  <div class="recovery-cell-label">Signal gap</div>
715
720
  <div class="recovery-cell-value">{{ recovery.signal_gap_pct }}%</div>
716
- <div class="recovery-cell-sub">of consent-denied traffic leaking</div>
721
+ <div class="recovery-cell-sub">of opt-out traffic leaking</div>
717
722
  </div>
718
723
  <div class="recovery-cell">
719
724
  <div class="recovery-cell-label">Recovery uplift</div>
@@ -722,9 +727,11 @@
722
727
  </div>
723
728
  </div>
724
729
  <p class="recovery-footnote">
725
- Formula: monthly ad spend × {{ recovery.consent_loss_rate_pct }}% CA consent-denial rate × {{ recovery.signal_gap_pct }}% signal gap × {{ recovery.recovery_uplift_pct }}% recapture.
726
- Re-run with a self-reported ad spend for a sharper estimate. Recovery is realized by routing post-consent signal
727
- through server-side GTM Meta CAPI, Google Enhanced Conversions, and TikTok Events API.
730
+ Formula: monthly ad spend × {{ recovery.roas_multiplier }}× ROAS × {{ recovery.consent_loss_rate_pct }}% US opt-out market × {{ recovery.signal_gap_pct }}% signal gap × {{ recovery.recovery_uplift_pct }}% recapture =
731
+ <strong>${{ "{:,}".format(recovery.monthly_recoverable_usd) }}/month recovered conversion value</strong>.
732
+ Brand tier ({{ recovery.brand_tier_label }}) is auto-derived from scan signals — vendor breadth, sGTM presence, CMP sophistication so the math defaults to the
733
+ scale of the audited site rather than a generic mid-market anchor. Re-run with <code>--monthly-ad-spend</code> for a sharper estimate.
734
+ Recovery is realized by routing post-consent signal through server-side GTM → Meta CAPI, Google Enhanced Conversions, and TikTok Events API.
728
735
  </p>
729
736
  </div>
730
737
  {% endif %}
@@ -118,14 +118,14 @@ def classify_finding(
118
118
  # narrower fix path than a non-Google vendor firing.
119
119
  return (
120
120
  ViolationStatus.CONFIRMED,
121
- f"ACM misconfiguration. GCS=G100 means cookieless pings are "
122
- f"firing correctly, but _ga/_ga_<id> cookies were ALSO set on "
123
- f"this fresh-context session. Per Google's docs, denied "
124
- f"analytics_storage should suppress both. Fix path: GA4 admin "
125
- f"-> Consent Mode = Advanced, and verify the GA4 Configuration "
126
- f"tag in GTM has all four storage signals (ad_storage, "
127
- f"analytics_storage, ad_user_data, ad_personalization) wired "
128
- f"as Additional Consent Settings.",
121
+ "ACM misconfiguration. GCS=G100 means cookieless pings are "
122
+ "firing correctly, but _ga/_ga_<id> cookies were ALSO set on "
123
+ "this fresh-context session. Per Google's docs, denied "
124
+ "analytics_storage should suppress both. Fix path: GA4 admin "
125
+ "-> Consent Mode = Advanced, and verify the GA4 Configuration "
126
+ "tag in GTM has all four storage signals (ad_storage, "
127
+ "analytics_storage, ad_user_data, ad_personalization) wired "
128
+ "as Additional Consent Settings.",
129
129
  )
130
130
 
131
131
  elif gcs_granted:
@@ -22,14 +22,63 @@ from consent_engine.tools.tool_07_rag_retriever import WikiPage
22
22
 
23
23
  ReportVariant = Literal["signal", "compliance"]
24
24
 
25
- # Default assumed monthly ad spend if the buyer doesn't self-report one.
26
- # Calibrated to the ICP (brands spending $20k+/mo on paid)mid-market anchor.
27
- _DEFAULT_AD_SPEND_USD: int = 50_000
28
- # Average consent opt-out rate in California (CCPA/CPRA markets).
29
- _CA_OPTOUT_RATE: float = 0.25
25
+ # Share of US population in CCPA-like opt-out-protected states (CA, CO, CT, VA,
26
+ # UT, TX, OR, MT, IA, DE, NJ, NH, TN, NE, MN, MD, RI plus pending ~35% of US
27
+ # population is in a state with a binding opt-out right as of 2026).
28
+ _US_OPTOUT_MARKET_RATE: float = 0.35
30
29
  # Share of consent-denied traffic that can be recovered via proper sGTM + CAPI + ACM.
31
30
  _RECOVERY_UPLIFT: float = 0.50
32
31
 
32
+ # Brand-tier auto-estimation: scan signals → (label, default monthly ad spend,
33
+ # typical ROAS multiplier). Used when the buyer hasn't self-reported
34
+ # --monthly-ad-spend, so the dollar math defaults to numbers that match the
35
+ # actual scale of the company being audited (not a generic mid-market anchor).
36
+ _BRAND_TIERS: list[tuple[str, int, float]] = [
37
+ # (tier_label, monthly_ad_spend_usd, roas_multiplier)
38
+ ("Global Enterprise", 10_000_000, 7.0), # multi-domain, sGTM, 15+ vendors
39
+ ("National Enterprise", 2_000_000, 6.0), # single-domain, sophisticated CMP, 8+ vendors
40
+ ("Mid-Large / Multi-Channel", 500_000, 5.5), # OneTrust/Truyo/Cookiebot, 5-7 vendors
41
+ ("Mid-Market", 100_000, 5.0), # established CMP, 3-5 vendors
42
+ ("SMB", 20_000, 4.0), # basic stack, <3 vendors
43
+ ]
44
+
45
+
46
+ def _estimate_brand_tier(audit_result: AuditResult) -> tuple[str, int, float]:
47
+ """Return ``(tier_label, monthly_ad_spend_usd, roas_multiplier)`` from the scan.
48
+
49
+ Used when the buyer doesn't self-report ``--monthly-ad-spend``. Scores brand
50
+ sophistication from observable signals (vendor count, sGTM presence,
51
+ enterprise CMP) and maps to the matching tier in ``_BRAND_TIERS``. Bigger
52
+ brands get bigger defaults so the recoverable-revenue math doesn't default
53
+ to peanut numbers when the audit is run against a national enterprise site.
54
+ """
55
+ vendor_count = len(audit_result.findings)
56
+ pixel_count = len(audit_result.pixel_firings)
57
+ has_ssgtm = audit_result.ssgtm_detected
58
+ # Enterprise-grade CMPs — running these signals significant procurement +
59
+ # legal involvement, which correlates with enterprise ad budgets.
60
+ enterprise_cmp = {
61
+ "OneTrust", "Truyo", "TrustArc", "Usercentrics", "Sourcepoint",
62
+ "Didomi", "Cookiebot", "Ketch",
63
+ }
64
+ has_enterprise_cmp = audit_result.detected_cmp in enterprise_cmp
65
+
66
+ score = 0
67
+ score += min(vendor_count, 10) # up to 10 from vendor breadth
68
+ score += min(pixel_count, 6) # up to 6 from pixel endpoint diversity
69
+ score += 6 if has_ssgtm else 0 # sGTM = enterprise infrastructure
70
+ score += 3 if has_enterprise_cmp else 0
71
+
72
+ if score >= 18:
73
+ return _BRAND_TIERS[0] # Global Enterprise
74
+ if score >= 13:
75
+ return _BRAND_TIERS[1] # National Enterprise
76
+ if score >= 8:
77
+ return _BRAND_TIERS[2] # Mid-Large
78
+ if score >= 4:
79
+ return _BRAND_TIERS[3] # Mid-Market
80
+ return _BRAND_TIERS[4] # SMB
81
+
33
82
  _TEMPLATES_DIR = Path(__file__).parent.parent / "templates"
34
83
 
35
84
  # KJB logo — read from bundled PNG so it renders in Playwright file:// PDF context
@@ -101,22 +150,36 @@ def estimate_recoverable_revenue(
101
150
  audit_result: AuditResult,
102
151
  monthly_ad_spend_usd: int | None = None,
103
152
  ) -> dict[str, int | float | str | bool]:
104
- """Estimate the monthly ad revenue recoverable by fixing the measurement stack.
153
+ """Estimate the monthly **conversion value** recoverable by fixing the stack.
154
+
155
+ Formula:
156
+ ad_revenue = monthly_ad_spend × ROAS
157
+ monthly_recovered = ad_revenue × US_opt_out_rate × signal_gap × recovery_uplift
105
158
 
106
- Formula: ``monthly_recoverable = ad_spend * consent_loss_rate * signal_gap_score``
159
+ The output is *recovered conversion value*, not "ad spend not wasted" — that
160
+ distinction matters at enterprise scale, where a 4% signal gap on $5M/mo of
161
+ ad-attributable revenue ($30M+ with ROAS) is six figures, not four.
107
162
 
108
- ``signal_gap_score`` scales 0.0 1.0 based on observable defects in the scan:
109
- each confirmed cookie violation, post-denial pixel firing, sGTM presence, and
110
- partial/broken Consent Mode state adds to the gap. The gap is capped at 1.0
111
- (a fully broken stack losing all consent-denied traffic back to ad AI).
163
+ Auto-tiers brand size from scan signals when the buyer hasn't self-reported
164
+ ``--monthly-ad-spend``. Without auto-tiering, the formula defaulted to a
165
+ mid-market anchor that understates enterprise impact by 50-100×.
112
166
 
113
- Kept deliberately simple so it's defensible in a sales conversation:
114
- numbers come from the same AuditResult the rest of the report is built on.
167
+ ``signal_gap`` scales 0.0 1.0 based on observable defects in the scan:
168
+ each confirmed cookie violation, post-denial pixel firing, sGTM presence,
169
+ and partial/broken Consent Mode state adds to the gap. Capped at 1.0.
170
+
171
+ Kept defensible in a sales conversation: every input is either user-reported
172
+ or derived from the same AuditResult the rest of the report is built on.
115
173
  """
174
+ # Brand-tier auto-estimation provides defaults when the buyer hasn't
175
+ # self-reported spend. ROAS is always derived from the tier — even with a
176
+ # user-reported spend, we want the recovery framing in revenue terms.
177
+ tier_label, tier_default_spend, roas = _estimate_brand_tier(audit_result)
178
+
116
179
  spend = (
117
180
  monthly_ad_spend_usd
118
181
  if monthly_ad_spend_usd and monthly_ad_spend_usd > 0
119
- else _DEFAULT_AD_SPEND_USD
182
+ else tier_default_spend
120
183
  )
121
184
 
122
185
  violations = [f for f in audit_result.findings if f.status == ViolationStatus.CONFIRMED]
@@ -151,7 +214,12 @@ def estimate_recoverable_revenue(
151
214
  gap += 0.05 # no CMP detected = no consent plumbing
152
215
  gap = min(gap, 1.0)
153
216
 
154
- monthly_recoverable = int(spend * _CA_OPTOUT_RATE * gap * _RECOVERY_UPLIFT)
217
+ # Ad-attributable monthly revenue = spend × ROAS (e.g. $2M × 6x = $12M).
218
+ ad_attributable_revenue = int(spend * roas)
219
+ # Monthly recovered conversion value at full signal gap on opt-out traffic.
220
+ monthly_recoverable = int(
221
+ ad_attributable_revenue * _US_OPTOUT_MARKET_RATE * gap * _RECOVERY_UPLIFT
222
+ )
155
223
  monthly_recoverable_low = int(monthly_recoverable * 0.6)
156
224
  monthly_recoverable_high = int(monthly_recoverable * 1.4)
157
225
  annual_recoverable = monthly_recoverable * 12
@@ -159,8 +227,11 @@ def estimate_recoverable_revenue(
159
227
  return {
160
228
  "monthly_ad_spend_usd": spend,
161
229
  "ad_spend_user_provided": bool(monthly_ad_spend_usd and monthly_ad_spend_usd > 0),
230
+ "brand_tier_label": tier_label,
231
+ "roas_multiplier": roas,
232
+ "ad_attributable_revenue_usd": ad_attributable_revenue,
162
233
  "signal_gap_pct": round(gap * 100, 1),
163
- "consent_loss_rate_pct": int(_CA_OPTOUT_RATE * 100),
234
+ "consent_loss_rate_pct": int(_US_OPTOUT_MARKET_RATE * 100),
164
235
  "recovery_uplift_pct": int(_RECOVERY_UPLIFT * 100),
165
236
  "monthly_recoverable_usd": monthly_recoverable,
166
237
  "monthly_recoverable_low_usd": monthly_recoverable_low,
@@ -1060,14 +1131,17 @@ def generate_marp_slides(
1060
1131
  )
1061
1132
 
1062
1133
  def _metric_card(label: str, value: str, sub: str, accent: str) -> str:
1134
+ # Top-row card. Sized to align visually with the bottom-row scenario
1135
+ # cards on the financial-exposure slide — matching padding + flex base
1136
+ # so the two rows render at equal heights regardless of value width.
1063
1137
  return (
1064
- f'<div style="flex:1;background:#ffffff;border-radius:10px;padding:20px;'
1065
- f'border-top:3px solid {accent};">'
1066
- f'<div style="color:#4b5563;font-size:0.58em;text-transform:uppercase;'
1067
- f"letter-spacing:0.15em;margin-bottom:6px;font-family:'Inter';font-weight:600;\">{label}</div>"
1068
- f"<div style=\"font-family:'Inter';font-weight:800;font-size:2.1em;color:{accent};"
1069
- f'line-height:1;margin-bottom:4px;">{value}</div>'
1070
- f'<div style="color:#6b7280;font-size:0.65em;font-weight:200;">{sub}</div>'
1138
+ f'<div style="flex:1 1 0;min-width:0;background:#ffffff;border-radius:10px;'
1139
+ f'padding:12px 14px;border-top:3px solid {accent};">'
1140
+ f'<div style="color:#4b5563;font-size:0.55em;text-transform:uppercase;'
1141
+ f"letter-spacing:0.14em;margin-bottom:4px;font-family:'Inter';font-weight:600;\">{label}</div>"
1142
+ f"<div style=\"font-family:'Inter';font-weight:800;font-size:1.7em;color:{accent};"
1143
+ f'line-height:1.1;margin-bottom:4px;letter-spacing:-0.01em;">{value}</div>'
1144
+ f'<div style="color:#6b7280;font-size:0.55em;font-weight:300;line-height:1.45;">{sub}</div>'
1071
1145
  f"</div>"
1072
1146
  )
1073
1147
 
@@ -1319,8 +1393,8 @@ def generate_marp_slides(
1319
1393
  if violations or pixel_violations:
1320
1394
  v_count = len(violations)
1321
1395
  p_count = len(pixel_violations)
1322
- # Statutory rates row
1323
- _exposure_html = '<div style="display:flex;gap:8px;margin-top:14px;">'
1396
+ # Statutory rates row — top of slide
1397
+ _exposure_html = '<div style="display:flex;gap:8px;margin-top:14px;align-items:stretch;">'
1324
1398
  _exposure_html += _metric_card(
1325
1399
  "CCPA / CPRA", "$7,500", "per intentional violation · per consumer", "#3d6abb"
1326
1400
  )
@@ -1356,20 +1430,22 @@ def generate_marp_slides(
1356
1430
  return f"${n/1_000_000:.1f}M"
1357
1431
  return f"${n/1_000:.0f}K"
1358
1432
 
1359
- # Scenario cards — compact variant (smaller value font keeps the range on one line
1360
- # so the benchmarks footnote doesn't overflow the slide)
1361
- _exposure_html += '<div style="display:flex;gap:8px;margin-top:8px;">'
1433
+ # Scenario cards — bottom row. Equal width via flex:1 1 0, min-width:0
1434
+ # so wide ranges can shrink inside their column instead of pushing the
1435
+ # row past the slide bounds. Smaller value font (1.05em) matches the
1436
+ # top-row card hierarchy and stays on one line at any tier.
1437
+ _exposure_html += '<div style="display:flex;gap:8px;margin-top:8px;align-items:stretch;">'
1362
1438
  for label, optouts, statutory, s_low, s_high in _scenarios:
1363
1439
  _exposure_html += (
1364
- f'<div style="flex:1;background:#ffffff;border-radius:10px;padding:12px 16px;'
1365
- f'border-top:3px solid #ef4444;">'
1366
- f'<div style="color:#4b5563;font-size:0.58em;text-transform:uppercase;'
1367
- f"letter-spacing:0.15em;margin-bottom:4px;font-family:'Inter';font-weight:600;\">{label}</div>"
1368
- f"<div style=\"font-family:'Inter';font-weight:800;font-size:1.5em;color:#ef4444;"
1369
- f'line-height:1.1;margin-bottom:3px;white-space:nowrap;">'
1440
+ f'<div style="flex:1 1 0;min-width:0;background:#ffffff;border-radius:10px;'
1441
+ f'padding:12px 14px;border-top:3px solid #ef4444;">'
1442
+ f'<div style="color:#4b5563;font-size:0.55em;text-transform:uppercase;'
1443
+ f"letter-spacing:0.14em;margin-bottom:4px;font-family:'Inter';font-weight:600;\">{label}</div>"
1444
+ f"<div style=\"font-family:'Inter';font-weight:800;font-size:1.05em;color:#ef4444;"
1445
+ f'line-height:1.15;margin-bottom:4px;letter-spacing:-0.01em;">'
1370
1446
  f"{_fmt(s_low)}–{_fmt(s_high)}</div>"
1371
- f'<div style="color:#6b7280;font-size:0.6em;font-weight:200;line-height:1.4;">'
1372
- f"{optouts:,} CA opt-outs/mo · statutory max {_fmt(statutory)}/yr</div>"
1447
+ f'<div style="color:#6b7280;font-size:0.55em;font-weight:300;line-height:1.45;">'
1448
+ f"{optouts:,} CA opt-outs/mo<br>max {_fmt(statutory)}/yr</div>"
1373
1449
  f"</div>"
1374
1450
  )
1375
1451
  _exposure_html += "</div>"
@@ -1809,7 +1885,7 @@ style: |
1809
1885
 
1810
1886
  {"---" + chr(10) + chr(10) + _pixel_marp_section if _pixel_marp_section else ""}
1811
1887
 
1812
- {"---" + chr(10) + chr(10) + "### RISK QUANTIFICATION" + chr(10) + chr(10) + "# Financial Exposure Estimate" + chr(10) + chr(10) + _exposure_html if (violations or pixel_violations) else ""}
1888
+ {"---" + chr(10) + chr(10) + "<!-- _class: compact -->" + chr(10) + chr(10) + "### RISK QUANTIFICATION" + chr(10) + chr(10) + "# Financial Exposure Estimate" + chr(10) + chr(10) + _exposure_html if (violations or pixel_violations) else ""}
1813
1889
 
1814
1890
  {_gpc_slide_md}
1815
1891
 
File without changes
File without changes