feat: results page redesign — modal form, paint-style cards, session save

The post-quiz results page is restructured around progressive disclosure
and the embossed-paint visual language used on the homepage CTAs.

Capture form
- Moved into a viewport-scaled modal with sticky header (title + close)
  and sticky footer (submit). Body scrolls between them.
- Privacy note now sits inside the form, above the newsletter checkbox,
  so the message is part of the consent flow rather than a footnote.
- Close button is the secondary white-with-border style; the X glyph is
  drawn from two CSS strokes so it stays pixel-centred regardless of
  font metrics.
- iOS-Safari-safe scroll lock: body becomes position:fixed with the
  saved scrollY pinned via inline top, restored on close.

Result panels
- Recommendations / Budget / Timeline use the white-paint container
  (#FAFAFA) and inner cards switch to the #F0F0F0 paint look that the
  quiz q-opt items already used. Text colours flip to the dark palette
  for legibility.
- Risk banner gets four paint variants — maroon (critical), amber
  (high), olive (medium), green (low / "PREPARED") — each rendered via
  ::before with the paintGlossBtn filter. Text is warm cream #f4ecd8.
- Narrative ("Your Personal Analysis") wears the same white-paint frame
  with a slightly greyer header band.
- Headings (risk title, protein offer, panel headers, narrative title)
  switched from DM Serif Display to Barlow to share the button typeface.

Scenario tabs
- Container loses its border + padding; inactive tabs are quiet text
  buttons. Active tab wears the green-paint CTA fill.
- Mobile lays them out as a 2×2 grid; desktop is a single row of 4.

Other tweaks
- Results section bumped 15% wider on desktop (max-width 690px).
- Site header gets a soft white fade on results-active so content
  scrolling underneath doesn't clash with the logo.
- Modifier (⚙) toggle hidden via a single display:none rule, styles
  preserved so we can re-enable it later.
- Protein button has a responsive label: "Secure Source Now" on mobile,
  "Secure protein source now" on desktop. Arrow icons removed from
  protein, retake, send-plan buttons. Unified primary-button typography
  across the site (var(--font-body), 400 weight, 13px, 0.2em tracking).

Session persistence
- Quiz progress (answers, currentQ, lang, scenario, stage) is saved to
  sessionStorage under "kammergut.state.v1" — purely client-side, dies
  when the tab closes. Stale sessions (>4h) are discarded on load.
- Reload mid-quiz or mid-results re-enters the right view. restartQuiz
  clears the saved session.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-05-10 08:00:03 +01:00
parent acfc7a0bde
commit 25e7e29ad7
2 changed files with 687 additions and 189 deletions

View File

@@ -319,7 +319,10 @@
<div class="protein-offer-badge">🔒 <span data-i18n="protein_offer_badge">Exclusive DACH Region Priority Access</span></div>
<div class="protein-offer-title" data-i18n="protein_offer_title">Secure your high-grade animal protein source</div>
<div class="protein-offer-body" data-i18n="protein_offer_body">You indicated that your protein supply may not be secure in a crisis. We have identified an exclusive, verified source of high-grade animal protein available with priority access for Deepstock members in Germany, Austria, and Switzerland.</div>
<a class="protein-offer-btn" href="PROTEIN_OFFER_URL" target="_blank" rel="noopener noreferrer" data-i18n="protein_offer_btn"> Secure My Protein Source Now</a>
<a class="protein-offer-btn" href="PROTEIN_OFFER_URL" target="_blank" rel="noopener noreferrer">
<span class="po-label-mobile" data-i18n="protein_offer_btn_short">Secure Source Now</span>
<span class="po-label-desktop" data-i18n="protein_offer_btn">Secure protein source now</span>
</a>
</div>
<!-- AI NARRATIVE SECTION -->
@@ -339,123 +342,134 @@
<div class="narrative-body hidden" id="narrative-text"></div>
</div>
<div class="narrative-cta hidden" id="narrative-cta">
<button class="narrative-cta-btn" @click="scrollToForm" data-i18n="cta_scroll">
Fill in your details below to receive your personalised plan by email
<button class="narrative-cta-btn" @click="openCaptureModal" data-i18n="cta_scroll">
Receive Plan by email
</button>
</div>
</div>
<!-- CAPTURE FORM -->
<div class="capture-form-wrap reveal-section" id="capture-form-wrap">
<!-- CAPTURE FORM (modal) -->
<div class="form-modal" id="capture-modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="capture-modal-title" @click="onModalBackdropClick">
<div class="form-modal-panel" @click.stop>
<div class="form-modal-header">
<div class="modal-title" id="capture-modal-title" data-i18n="capture_title">Personal Details</div>
<button type="button" class="form-modal-close" @click="closeCaptureModal" aria-label="Close">×</button>
</div>
<div class="capture-title" data-i18n="capture_title">Personal Details</div>
<div class="capture-sub" data-i18n="capture_sub">Enter your details and we'll email your complete survival plan immediately — including all recommendations below.</div>
<div class="form-modal-body" id="form-modal-body">
<!-- Success state replaces the form after submit -->
<div class="capture-success hidden" id="capture-success">
<div style="font-size:36px;margin-bottom:8px;text-align:center"></div>
<div style="font-family:var(--font-display);font-weight:800;font-size:22px;color:var(--green-bright);margin-bottom:8px;text-align:center" data-i18n="success_title">Plan sent!</div>
<div style="font-size:14px;color:var(--text-dim);line-height:1.6;text-align:center" data-i18n="success_text">Check your inbox your personalised plan is on its way.</div>
<div style="margin-top:14px;padding:12px 16px;background:rgba(212,168,32,0.1);border:1px solid rgba(212,168,32,0.35);border-radius:8px;text-align:center">
<div style="font-size:13px;color:#D4A820;line-height:1.7" data-i18n="success_spam">📬 Don't see it? Check your <strong>spam or junk folder</strong>. If it's there, please mark it as <strong>"Not Spam"</strong> this ensures future updates reach your inbox directly, and helps us improve deliverability for everyone.</div>
</div>
<div style="margin-top:14px;text-align:center">
<button class="narrative-cta-btn" @click="closeAndScrollToRecs" data-i18n="success_explore">Explore Recommendations</button>
</div>
</div>
<form id="capture-form" @submit="submitCapture">
<input type="hidden" id="h_location" name="location"/>
<input type="hidden" id="h_household" name="household_size"/>
<input type="hidden" id="h_water" name="water_access"/>
<input type="hidden" id="h_food" name="food_reserves"/>
<input type="hidden" id="h_medical" name="medical_needs"/>
<input type="hidden" id="h_sanitation" name="sanitation"/>
<input type="hidden" id="h_budget" name="budget_eur"/>
<input type="hidden" id="h_priority" name="priorities"/>
<input type="hidden" id="h_protein" name="protein_access"/>
<input type="hidden" id="h_risk_score" name="risk_score"/>
<input type="hidden" id="h_risk_level" name="risk_level"/>
<input type="hidden" id="h_lang" name="language_used"/>
<input type="hidden" id="h_timestamp" name="submitted_at"/>
<input type="hidden" id="h_scenarios" name="scenarios"/>
<input type="hidden" id="h_protein_pref" name="protein_preference"/>
<input type="hidden" id="h_protein_security" name="protein_security"/>
<div class="capture-sub" data-i18n="capture_sub">Enter your details and we'll email your complete survival plan immediately — including all recommendations below.</div>
<div class="form-grid">
<div class="form-row">
<div class="form-field">
<label class="field-label"><span data-i18n="field_name">First Name</span> <span style="color:var(--red)">*</span></label>
<input class="form-input" type="text" id="f_name" name="first_name" required placeholder="First name"/>
<form id="capture-form" @submit="submitCapture">
<input type="hidden" id="h_location" name="location"/>
<input type="hidden" id="h_household" name="household_size"/>
<input type="hidden" id="h_water" name="water_access"/>
<input type="hidden" id="h_food" name="food_reserves"/>
<input type="hidden" id="h_medical" name="medical_needs"/>
<input type="hidden" id="h_sanitation" name="sanitation"/>
<input type="hidden" id="h_budget" name="budget_eur"/>
<input type="hidden" id="h_priority" name="priorities"/>
<input type="hidden" id="h_protein" name="protein_access"/>
<input type="hidden" id="h_risk_score" name="risk_score"/>
<input type="hidden" id="h_risk_level" name="risk_level"/>
<input type="hidden" id="h_lang" name="language_used"/>
<input type="hidden" id="h_timestamp" name="submitted_at"/>
<input type="hidden" id="h_scenarios" name="scenarios"/>
<input type="hidden" id="h_protein_pref" name="protein_preference"/>
<input type="hidden" id="h_protein_security" name="protein_security"/>
<div class="form-grid">
<div class="form-row">
<div class="form-field">
<label class="field-label"><span data-i18n="field_name">First Name</span> <span style="color:var(--red)">*</span></label>
<input class="form-input" type="text" id="f_name" name="first_name" required placeholder="First name"/>
</div>
<div class="form-field">
<label class="field-label"><span data-i18n="field_lastname">Last Name</span> <span style="color:var(--red)">*</span></label>
<input class="form-input" type="text" id="f_lastname" name="last_name" required placeholder="Last name"/>
</div>
</div>
<div class="form-field">
<label class="field-label"><span data-i18n="field_email">Email</span> <span style="color:var(--red)">*</span></label>
<input class="form-input" type="email" id="f_email" name="email" required placeholder="your@email.com"/>
</div>
<div class="form-row">
<div class="form-field">
<label class="field-label"><span data-i18n="field_city">City</span> <span style="color:var(--red)">*</span></label>
<input class="form-input" type="text" id="f_city" name="city" required placeholder="Your city"/>
</div>
<div class="form-field">
<label class="field-label"><span data-i18n="field_country">Country</span> <span style="color:var(--red)">*</span></label>
<select class="form-select" id="f_country" name="country" required @change="onCountryChange">
<option value="">—</option>
<option value="AT">🇦🇹 Austria</option>
<option value="DE">🇩🇪 Germany</option>
<option value="CH">🇨🇭 Switzerland</option>
<option value="US">🇺🇸 United States</option>
<option value="CA">🇨🇦 Canada</option>
<option value="GB">🇬🇧 United Kingdom</option>
<option value="FR">🇫🇷 France</option>
<option value="NL">🇳🇱 Netherlands</option>
<option value="BE">🇧🇪 Belgium</option>
<option value="ES">🇪🇸 Spain</option>
<option value="IT">🇮🇹 Italy</option>
<option value="PL">🇵🇱 Poland</option>
<option value="SE">🇸🇪 Sweden</option>
<option value="NO">🇳🇴 Norway</option>
<option value="DK">🇩🇰 Denmark</option>
<option value="OTHER">Other</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-field">
<label class="field-label"><span data-i18n="field_phone">Phone (optional)</span></label>
<input class="form-input" type="tel" id="f_phone" name="phone" placeholder="+43 or +1 ..."/>
</div>
<div class="form-field">
<label class="field-label"><span data-i18n="field_pref_lang">Preferred language</span></label>
<select class="form-select" id="f_pref_lang" name="preferred_language">
<option value="en">English</option>
<option value="de">Deutsch</option>
<option value="fr">Français</option>
<option value="es">Español</option>
</select>
</div>
</div>
<div class="form-field">
<label class="field-label"><span data-i18n="field_protein_label">🥩 Protein access (optional)</span></label>
<textarea class="form-input" id="f_protein_detail" name="protein_detail" rows="3"
placeholder="e.g. free-range eggs from neighbour, fishing lake nearby, hunter in family..."></textarea>
<div style="font-size:11px;color:var(--text-dim);margin-top:4px;font-style:italic" data-i18n="protein_note">Helps us personalise your food recommendations.</div>
</div>
<div class="form-privacy" data-i18n="privacy_note">🔒 Your data is never sold. Unsubscribe anytime. GDPR compliant.</div>
<label class="newsletter-check">
<input type="checkbox" id="f_newsletter" name="newsletter" value="yes" checked/>
<span class="check-box"></span>
<span class="check-label" data-i18n="newsletter_label">Yes, send me weekly preparedness updates from Deepstock</span>
</label>
</div>
<div class="form-field">
<label class="field-label"><span data-i18n="field_lastname">Last Name</span> <span style="color:var(--red)">*</span></label>
<input class="form-input" type="text" id="f_lastname" name="last_name" required placeholder="Last name"/>
</div>
</div>
<div class="form-field">
<label class="field-label"><span data-i18n="field_email">Email</span> <span style="color:var(--red)">*</span></label>
<input class="form-input" type="email" id="f_email" name="email" required placeholder="your@email.com"/>
</div>
<div class="form-row">
<div class="form-field">
<label class="field-label"><span data-i18n="field_city">City</span> <span style="color:var(--red)">*</span></label>
<input class="form-input" type="text" id="f_city" name="city" required placeholder="Your city"/>
</div>
<div class="form-field">
<label class="field-label"><span data-i18n="field_country">Country</span> <span style="color:var(--red)">*</span></label>
<select class="form-select" id="f_country" name="country" required @change="onCountryChange">
<option value="">—</option>
<option value="AT">🇦🇹 Austria</option>
<option value="DE">🇩🇪 Germany</option>
<option value="CH">🇨🇭 Switzerland</option>
<option value="US">🇺🇸 United States</option>
<option value="CA">🇨🇦 Canada</option>
<option value="GB">🇬🇧 United Kingdom</option>
<option value="FR">🇫🇷 France</option>
<option value="NL">🇳🇱 Netherlands</option>
<option value="BE">🇧🇪 Belgium</option>
<option value="ES">🇪🇸 Spain</option>
<option value="IT">🇮🇹 Italy</option>
<option value="PL">🇵🇱 Poland</option>
<option value="SE">🇸🇪 Sweden</option>
<option value="NO">🇳🇴 Norway</option>
<option value="DK">🇩🇰 Denmark</option>
<option value="OTHER">Other</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-field">
<label class="field-label"><span data-i18n="field_phone">Phone (optional)</span></label>
<input class="form-input" type="tel" id="f_phone" name="phone" placeholder="+43 or +1 ..."/>
</div>
<div class="form-field">
<label class="field-label"><span data-i18n="field_pref_lang">Preferred language</span></label>
<select class="form-select" id="f_pref_lang" name="preferred_language">
<option value="en">English</option>
<option value="de">Deutsch</option>
<option value="fr">Français</option>
<option value="es">Español</option>
</select>
</div>
</div>
<div class="form-field">
<label class="field-label"><span data-i18n="field_protein_label">🥩 Protein access (optional)</span></label>
<textarea class="form-input" id="f_protein_detail" name="protein_detail" rows="3"
placeholder="e.g. free-range eggs from neighbour, fishing lake nearby, hunter in family..."></textarea>
<div style="font-size:11px;color:var(--text-dim);margin-top:4px;font-style:italic" data-i18n="protein_note">Helps us personalise your food recommendations.</div>
</div>
<label class="newsletter-check">
<input type="checkbox" id="f_newsletter" name="newsletter" value="yes" checked/>
<span class="check-box"></span>
<span class="check-label" data-i18n="newsletter_label">Yes, send me weekly preparedness updates from Deepstock</span>
</label>
<button class="capture-submit" type="submit" id="capture-submit-btn">
<span data-i18n="capture_btn">Send Me My Plan →</span>
</form>
</div>
<div class="form-modal-footer" id="form-modal-footer">
<button class="capture-submit" type="submit" form="capture-form" id="capture-submit-btn">
<span data-i18n="capture_btn">Send Me My Plan</span>
</button>
</div>
</form>
<div class="capture-success hidden" id="capture-success">
<div style="font-size:36px;margin-bottom:8px;text-align:center">✅</div>
<div style="font-family:var(--font-display);font-weight:800;font-size:22px;color:var(--green-bright);margin-bottom:8px;text-align:center" data-i18n="success_title">Plan sent!</div>
<div style="font-size:14px;color:var(--text-dim);line-height:1.6;text-align:center" data-i18n="success_text">Check your inbox — your personalised plan is on its way.</div>
<div style="margin-top:14px;padding:12px 16px;background:rgba(212,168,32,0.1);border:1px solid rgba(212,168,32,0.35);border-radius:8px;text-align:center">
<div style="font-size:13px;color:#D4A820;line-height:1.7" data-i18n="success_spam">📬 Don't see it? Check your <strong>spam or junk folder</strong>. If it's there, please mark it as <strong>"Not Spam"</strong> — this ensures future updates reach your inbox directly, and helps us improve deliverability for everyone.</div>
</div>
<div style="margin-top:14px;text-align:center">
<button class="narrative-cta-btn" @click="scrollToRecs" data-i18n="success_explore">↓ Now explore your full recommendations below</button>
</div>
</div>
<div style="font-size:11px;color:var(--muted);margin-top:12px;text-align:center;line-height:1.7" data-i18n="privacy_note">🔒 Your data is never sold. Unsubscribe anytime. GDPR compliant.</div>
</div>
<!-- RECOMMENDATIONS + SCENARIO TABS — collapsible panels -->
@@ -500,7 +514,7 @@
</div>
</details>
<button class="restart-btn" @click="restartQuiz" data-i18n="restart_btn">Retake Assessment</button>
<button class="restart-btn" @click="restartQuiz" data-i18n="restart_btn">Retake Assessment</button>
</section>
<!-- ABOUT -->
@@ -556,7 +570,7 @@ const T = {
panel_recs_hint: "Tap a scenario to compare",
panel_budget: "💰 Budget Plan",
panel_timeline: "⏱ Action Timeline",
restart_btn: "Retake Assessment",
restart_btn: "Retake Assessment",
about_title: "Why Deepstock?",
about_text: "Built by preparedness researchers and city-dwelling practitioners. Every recommendation is sourced, tested, and city-apartment-compatible.",
affiliate_note: "* This site uses affiliate links. When you purchase through our links, we may earn a commission at no extra cost to you. This helps keep the platform free.",
@@ -596,10 +610,11 @@ const T = {
protein_offer_badge:"Exclusive — DACH Region Priority Access",
protein_offer_title:"Secure your high-grade animal protein source",
protein_offer_body:"You indicated your protein supply may not be secure in a crisis. We have identified an exclusive, verified source of high-grade animal protein — available with priority access for Deepstock members in Germany, Austria, and Switzerland.",
protein_offer_btn:"Secure My Protein Source Now",
protein_offer_btn:"Secure protein source now",
protein_offer_btn_short:"Secure Source Now",
capture_header:"Personal Details",
cta_scroll:"↓ Fill in your details below to receive your personalised plan by email",
success_explore:"↓ Now explore your full recommendations below",
cta_scroll:"Receive Plan by email",
success_explore:"Explore Recommendations",
success_spam:"📬 Don't see it? Check your spam or junk folder. If it's there, please mark it as Not Spam — this ensures future updates reach your inbox directly, and helps us improve deliverability for everyone.",
view_amazon: "View on Amazon",
bcat_water: "Water",
@@ -655,7 +670,7 @@ const T = {
panel_recs_hint: "Szenario antippen zum Vergleichen",
panel_budget: "💰 Budgetplan",
panel_timeline: "⏱ Aktionsplan",
restart_btn: "Neu starten",
restart_btn: "Neu starten",
about_title: "Warum Kammergut?",
about_text: "Entwickelt von Vorsorge-Forschern und Stadtbewohnern. Jede Empfehlung ist recherchiert, getestet und für Stadtwohnungen geeignet.",
affiliate_note: "* Diese Website nutzt Affiliate-Links. Beim Kauf über unsere Links erhalten wir eine Provision ohne Mehrkosten für dich. So bleibt die Plattform kostenlos.",
@@ -695,10 +710,11 @@ const T = {
protein_offer_badge:"Exklusiv — DACH Region Prioritätszugang",
protein_offer_title:"Sichere deine hochwertige tierische Proteinquelle",
protein_offer_body:"Du hast angegeben, dass deine Proteinversorgung in einer Krise möglicherweise nicht gesichert ist. Wir haben eine exklusive, geprüfte Quelle für hochwertiges tierisches Protein identifiziert — mit Prioritätszugang für Kammergut Mitglieder in Deutschland, Österreich und der Schweiz.",
protein_offer_btn:"→ Jetzt Proteinquelle sichern",
protein_offer_btn:"Proteinquelle sichern",
protein_offer_btn_short:"Quelle sichern",
capture_header:"Persönliche Daten",
cta_scroll:"↓ Fülle deine Daten aus um deinen personalisierten Plan per E-Mail zu erhalten",
success_explore:"↓ Entdecke jetzt deine vollständigen Empfehlungen unten",
cta_scroll:"Plan per E-Mail erhalten",
success_explore:"Empfehlungen ansehen",
success_spam:"📬 Nicht erhalten? Schau in deinen Spam-Ordner. Falls du es dort findest, markiere es bitte als Kein Spam — so landen zukünftige Updates direkt in deinem Posteingang und du hilfst uns die Zustellbarkeit für alle zu verbessern.",
view_amazon: "Bei Amazon ansehen",
bcat_water: "Wasser",
@@ -739,6 +755,7 @@ function setLang(lang) {
el.placeholder = el.getAttribute('data-ph-' + lang)
})
if (currentQ !== null && currentQ < QUESTIONS.length) renderQuestion()
saveState()
}
function t(key) { return T[currentLang][key] || T['en'][key] || key }
@@ -880,6 +897,44 @@ let currentScenario = 1
let riskScore = 0
let riskLevelStr = ''
// Session persistence — keep the user's progress across reloads inside
// the same browser tab. `sessionStorage` is the right scope: progress
// dies when the tab closes (privacy-friendly default for a lead form),
// but reload / nav-away-and-back restores. Bump the key suffix when the
// QUESTIONS shape changes incompatibly.
const STATE_KEY = 'kammergut.state.v1'
function getStage() {
const results = document.getElementById('results-section')
const quiz = document.getElementById('quiz-section')
if (results && results.classList.contains('active')) return 'results'
if (quiz && !quiz.classList.contains('hidden')) return 'quiz'
return 'home'
}
function saveState() {
const stage = getStage()
if (stage === 'home') { clearState(); return }
try {
sessionStorage.setItem(STATE_KEY, JSON.stringify({
stage, currentQ, answers, currentScenario,
currentLang, ts: Date.now(),
}))
} catch {}
}
function clearState() {
try { sessionStorage.removeItem(STATE_KEY) } catch {}
}
function loadState() {
try {
const raw = sessionStorage.getItem(STATE_KEY)
if (!raw) return null
const s = JSON.parse(raw)
// Discard sessions older than 4 hours so a stale tab doesn't drop
// the user mid-flow on a different day.
if (s.ts && Date.now() - s.ts > 4 * 60 * 60 * 1000) return null
return s
} catch { return null }
}
// ══════════════════════════════════════
// QUIZ FLOW
// ══════════════════════════════════════
@@ -979,6 +1034,7 @@ function selectOption(qid, val, el) {
document.querySelectorAll('.q-opt').forEach(o => o.classList.remove('selected'))
el.classList.add('selected')
document.getElementById('btn-next').disabled = false
saveState()
}
function toggleMulti(qid, val, el) {
@@ -986,19 +1042,21 @@ function toggleMulti(qid, val, el) {
const idx = answers[qid].indexOf(val)
if (idx >= 0) { answers[qid].splice(idx, 1); el.classList.remove('selected') }
else { answers[qid].push(val); el.classList.add('selected') }
saveState()
}
function updateSlider(val, qid) {
answers[qid] = parseInt(val)
document.getElementById('slider-display').innerHTML = `${parseInt(val).toLocaleString()}`
saveState()
}
function nextQ() {
if (currentQ < QUESTIONS.length - 1) { currentQ++; renderQuestion() }
if (currentQ < QUESTIONS.length - 1) { currentQ++; renderQuestion(); saveState() }
else showResults()
}
function prevQ() { if (currentQ > 0) { currentQ--; renderQuestion() } }
function prevQ() { if (currentQ > 0) { currentQ--; renderQuestion(); saveState() } }
function restartQuiz() {
const rs = document.getElementById('results-section')
@@ -1012,6 +1070,11 @@ function restartQuiz() {
const quiz = document.getElementById('quiz-section')
if(quiz) quiz.classList.add('hidden')
document.body.classList.remove('quiz-active')
document.body.classList.remove('results-active')
// Wipe any in-progress session state so the home view is fresh.
currentQ = 0; answers = {}; currentScenario = 1; riskScore = 0; riskLevelStr = ''
clearState()
// Restore the locked-viewport layout that startQuiz had to undo for
// the scrollable quiz/results pages — without this the hero on the
@@ -1181,10 +1244,12 @@ function getScenarioRecs(scenario) {
function showResults() {
document.getElementById('quiz-section').classList.add('hidden')
document.body.classList.remove('quiz-active')
document.body.classList.add('results-active')
const results = document.getElementById('results-section')
results.style.display = 'block'
results.classList.add('active')
window.scrollTo({ top: 0, behavior: 'smooth' })
saveState()
riskScore = calcRisk()
checkProteinOffer()
@@ -1220,6 +1285,7 @@ function showResults() {
function showScenario(n) {
currentScenario = n
saveState()
document.querySelectorAll('.s-tab').forEach(tab => {
tab.classList.remove('active-s1','active-s2','active-s3','active-s4')
if (parseInt(tab.dataset.s) === n) tab.classList.add(`active-s${n}`)
@@ -1359,8 +1425,12 @@ function submitCapture(e) {
const showSuccess = () => {
const form = document.getElementById('capture-form')
const sub = document.querySelector('#capture-modal .capture-sub')
const success = document.getElementById('capture-success')
const footer = document.getElementById('form-modal-footer')
if(form) form.style.display = 'none'
if(sub) sub.style.display = 'none'
if(footer) footer.style.display = 'none'
if(success) { success.classList.remove('hidden'); success.style.display = 'block' }
}
@@ -1545,14 +1615,10 @@ function getFallbackNarrativeText() {
}
function revealSections() {
// Capture form first, then the three collapsible result panels (recs is open
// by default, budget + timeline collapsed for progressive disclosure).
const ids = [
'capture-form-wrap',
'panel-recs',
'panel-budget',
'panel-timeline',
]
// The three collapsible result panels (recs is open by default,
// budget + timeline collapsed for progressive disclosure). The capture
// form lives in a modal now and reveals on click instead of scroll.
const ids = ['panel-recs', 'panel-budget', 'panel-timeline']
ids.forEach((id, i) => {
setTimeout(() => {
const el = document.getElementById(id)
@@ -1561,14 +1627,41 @@ function revealSections() {
})
}
function scrollToForm() {
const el = document.getElementById('capture-form-wrap')
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' })
// Scroll-lock the page behind the modal. Saving and restoring scrollY +
// position:fixed on body is the iOS-Safari-safe pattern; plain
// overflow:hidden alone doesn't stop touch-driven body scroll there.
let savedScrollY = 0
function openCaptureModal() {
const modal = document.getElementById('capture-modal')
if (!modal) return
savedScrollY = window.scrollY || window.pageYOffset || 0
modal.classList.add('open')
modal.setAttribute('aria-hidden', 'false')
document.body.style.top = `-${savedScrollY}px`
document.body.classList.add('modal-open')
}
function closeCaptureModal() {
const modal = document.getElementById('capture-modal')
if (!modal) return
modal.classList.remove('open')
modal.setAttribute('aria-hidden', 'true')
document.body.classList.remove('modal-open')
document.body.style.top = ''
window.scrollTo(0, savedScrollY)
}
function onModalBackdropClick(e) {
// Only close when the click is on the overlay itself (the panel stops
// propagation so clicks inside the form don't bubble up).
if (e.target === e.currentTarget) closeCaptureModal()
}
function scrollToRecs() {
const el = document.getElementById('recs-anchor')
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
function closeAndScrollToRecs() {
closeCaptureModal()
setTimeout(() => scrollToRecs(), 250)
}
function checkProteinOffer() {
const offerEl = document.getElementById('protein-offer-section')
@@ -1778,16 +1871,63 @@ onMounted(() => {
window.nextQ = nextQ
window.prevQ = prevQ
window.showScenario = showScenario
window.scrollToForm = scrollToForm
window.openCaptureModal = openCaptureModal
window.closeCaptureModal = closeCaptureModal
window.scrollToRecs = scrollToRecs
window.closeAndScrollToRecs = closeAndScrollToRecs
window.updateRegionFromCountry = updateRegionFromCountry
window.updateRegionIndicator = updateRegionIndicator
window.showRegionPicker = showRegionPicker
// Initial render
setLang('en')
// ESC closes the capture modal
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
const modal = document.getElementById('capture-modal')
if (modal && modal.classList.contains('open')) closeCaptureModal()
}
})
// Initial render — pull saved session state (if any) so reload mid-quiz
// restores the user where they left off. Lang sets first so renderQuestion
// / renderResults pick up the right strings.
const saved = loadState()
const lang = (saved && saved.currentLang) || 'en'
setLang(lang)
updateRegionIndicator()
if (saved && saved.stage && saved.stage !== 'home') {
answers = saved.answers || {}
currentQ = typeof saved.currentQ === 'number' ? saved.currentQ : 0
currentScenario = saved.currentScenario || 1
if (saved.stage === 'quiz') {
// Re-enter quiz mode without resetting state — startQuiz wipes
// answers, so call its DOM-only side effects then renderQuestion.
const hide = id => { const el = document.getElementById(id) || document.querySelector('.' + id); if(el) el.classList.add('hidden') }
hide('hero-section'); hide('about-section')
const strip = document.querySelector('.scenario-strip')
if(strip) strip.classList.add('hidden')
document.body.style.overflow = 'auto'
document.body.style.height = 'auto'
const app = document.querySelector('.app')
if (app) { app.style.overflow = 'visible'; app.style.height = 'auto' }
const quizEl = document.getElementById('quiz-section')
if(quizEl) quizEl.classList.remove('hidden')
document.body.classList.add('quiz-active')
renderQuestion()
} else if (saved.stage === 'results') {
// Same DOM unwind as quiz, then jump straight to results.
document.body.style.overflow = 'auto'
document.body.style.height = 'auto'
const app = document.querySelector('.app')
if (app) { app.style.overflow = 'visible'; app.style.height = 'auto' }
const hero = document.getElementById('hero-section')
if(hero) hero.classList.add('hidden')
const strip = document.querySelector('.scenario-strip')
if(strip) strip.classList.add('hidden')
showResults()
}
}
// Wire up scenario strip + paint picker after DOM is mounted
initScenarioStrip()
initPaintPicker()

View File

@@ -494,63 +494,213 @@ input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; width: 22px;
.btn-next:disabled { background: var(--muted); box-shadow: none; cursor: not-allowed; color: var(--deep); }
/* ── RESULTS ── */
.results-section { padding: 24px 20px 80px; max-width: 600px; margin: 0 auto; width: 100%; display: none; }
.results-section { padding: 84px 20px 80px; max-width: 600px; margin: 0 auto; width: 100%; display: none; }
@media (min-width: 768px) {
/* Desktop — bump the content column ~15% so the result panels and
recommendation cards have more breathing room on a wide viewport. */
.results-section { max-width: 690px; }
}
.results-section.active { display: block; }
.risk-banner { padding: 24px; border-radius: var(--radius-lg); margin-bottom: 24px; text-align: center; animation: fadeUp 0.4s ease both; }
.risk-level { font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase; margin-bottom: 8px; opacity: 0.8; }
.risk-title { font-family: var(--font-display); font-weight: 900; font-size: 36px; letter-spacing: -0.01em; line-height: 1; margin-bottom: 8px; }
.risk-desc { font-size: 14px; opacity: 0.85; line-height: 1.6; }
.risk-critical { background: var(--red-dim); border: 1px solid rgba(90,154,120,0.35); color: var(--white); }
.risk-high { background: var(--orange-dim); border: 1px solid rgba(74,138,104,0.35); color: var(--white); }
.risk-medium { background: var(--accent-dim); border: 1px solid rgba(90,154,120,0.25); color: var(--white); }
.risk-low { background: var(--green-dim); border: 1px solid rgba(90,154,120,0.35); color: var(--white); }
/* Risk banner — wrapper provides positioning + padding so risk-* variants
can paint their fill via ::before (the dark-green paint variant uses an
SVG gloss filter that needs an isolated stacking context). */
.risk-banner {
position: relative;
isolation: isolate;
overflow: hidden;
padding: 28px 24px;
border-radius: var(--radius-lg);
margin-bottom: 24px;
text-align: center;
animation: fadeUp 0.4s ease both;
}
.risk-level { font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase; margin-bottom: 10px; opacity: 0.85; }
/* Heading typeface unified with the button font (Barlow / var(--font-body))
instead of the DM Serif display face — keeps the assessment headings in
the same family as the CTA buttons. */
.risk-title {
font-family: var(--font-body);
font-weight: 800;
font-size: 36px;
letter-spacing: 0.05em;
text-transform: uppercase;
line-height: 1;
margin-bottom: 10px;
}
.risk-desc { font-size: 14px; opacity: 0.9; line-height: 1.6; }
/* Risk variants — each state wears a paint card in its own deep tint.
::before carries the painted fill (paintGlossBtn filter for the glossy
embossed look that the CTA buttons use); the wrapper itself is just
the drop shadow + positioning context. Text colours flip to warm cream
for legibility on the dark backgrounds. */
.risk-critical,
.risk-high,
.risk-medium,
.risk-low {
background: transparent;
border: none;
box-shadow: 0 8px 18px rgba(0,0,0,0.18);
}
.risk-critical::before,
.risk-high::before,
.risk-medium::before,
.risk-low::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
z-index: -1;
filter: url(#paintGlossBtn);
-webkit-filter: url(#paintGlossBtn);
}
.risk-critical::before { background: #5A1818; } /* deep maroon — critical */
.risk-high::before { background: #5A3818; } /* deep amber — high */
.risk-medium::before { background: #4A3A10; } /* dark olive — moderate */
.risk-low::before { background: #2A3010; } /* dark green — prepared */
.scenario-tabs { display: flex; gap: 0; background: var(--panel); border: 1px solid var(--border); border-radius: 0; padding: 4px; margin-bottom: 24px; overflow: hidden; animation: fadeUp 0.4s 0.1s ease both; }
.s-tab { flex: 1; padding: 10px 6px; border: none; background: none; border-radius: 0; font-family: var(--font-display); font-weight: 700; font-size: 12px; letter-spacing: 0.04em; color: var(--text-dim); cursor: pointer; transition: var(--trans); text-align: center; line-height: 1.3; }
.s-tab.active-s1 { background: rgba(90,154,120,0.1); color: var(--red); }
.s-tab.active-s2 { background: rgba(184,152,106,0.1); color: var(--orange); }
.s-tab.active-s3 { background: rgba(90,154,120,0.08); color: var(--yellow); }
.s-tab.active-s4 { background: rgba(106,170,138,0.1); color: var(--green-bright); }
.risk-critical .risk-level,
.risk-critical .risk-title,
.risk-critical .risk-desc,
.risk-high .risk-level,
.risk-high .risk-title,
.risk-high .risk-desc,
.risk-medium .risk-level,
.risk-medium .risk-title,
.risk-medium .risk-desc,
.risk-low .risk-level,
.risk-low .risk-title,
.risk-low .risk-desc { color: #f4ecd8; }
/* Scenario tabs — flat row inside the white-paint Recommendations panel.
No outer container border/padding (the panel frame already provides
that); inactive tabs are quiet text buttons. The active tab wears the
green-paint CTA look (same paint gloss + #2a3010 fill as the protein
button) so the current scenario reads at a glance. */
.scenario-tabs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
background: transparent;
border: none;
border-radius: 0;
padding: 0;
margin-bottom: 16px;
animation: fadeUp 0.4s 0.1s ease both;
}
@media (min-width: 768px) {
/* Desktop has room for one row of 4. */
.scenario-tabs { grid-template-columns: repeat(4, 1fr); }
}
.s-tab {
flex: 1;
position: relative;
isolation: isolate;
padding: 10px 6px;
border: none;
background: transparent;
border-radius: 4px;
font-family: var(--font-body);
font-weight: 400;
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-dim);
cursor: pointer;
transition: color 0.2s ease;
text-align: center;
line-height: 1.3;
}
.s-tab:hover { color: var(--text); }
/* Active state — green-paint button. ::before holds the glossy fill so
the text colour stays solid against the dark green. */
.s-tab.active-s1,
.s-tab.active-s2,
.s-tab.active-s3,
.s-tab.active-s4 {
color: #f4ecd8;
}
.s-tab.active-s1::before,
.s-tab.active-s2::before,
.s-tab.active-s3::before,
.s-tab.active-s4::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
z-index: -1;
background: #2a3010;
filter: url(#paintGlossBtn);
-webkit-filter: url(#paintGlossBtn);
transition: filter 0.6s ease;
}
/* Recommendation cards — wear the white-paint inner-item look (#F0F0F0 +
embossed shadow + inset highlight), parallel to the .q-opt items inside
the question card. The rec-cards sit inside the .result-panel which is
#FAFAFA, mirroring the question-card / q-opt nesting. Text colours flip
to dark for legibility on the light background. */
.rec-cards { animation: fadeUp 0.4s 0.15s ease both; }
.rec-card { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius-lg); margin-bottom: 16px; overflow: hidden; }
.rec-header { padding: 16px 20px; display: flex; align-items: center; gap: 12px; border-bottom: 1px solid var(--border); }
.rec-card {
background: #F0F0F0;
border: 1px solid rgba(0,0,0,0.06);
border-radius: var(--radius);
margin-bottom: 12px;
overflow: hidden;
box-shadow: 0 3px 6px rgba(0,0,0,0.06), inset 0 1px 0 rgba(255,255,255,0.7);
}
.rec-header { padding: 14px 16px; display: flex; align-items: center; gap: 12px; border-bottom: 1px solid rgba(0,0,0,0.06); }
.rec-icon { width: 40px; height: 40px; border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 20px; flex-shrink: 0; }
.rec-cat { font-family: var(--font-mono); font-size: 10px; color: var(--text-dim); letter-spacing: 0.12em; text-transform: uppercase; }
.rec-title { font-family: var(--font-display); font-weight: 400; font-size: 18px; color: var(--white); line-height: 1.2; }
.rec-title { font-family: var(--font-display); font-weight: 400; font-size: 18px; color: var(--text); line-height: 1.2; }
.priority-badge { margin-left: auto; padding: 4px 10px; border-radius: 99px; font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.1em; font-weight: 700; flex-shrink: 0; }
.p-critical { background: rgba(90,154,120,0.08); color: var(--red); border: 1px solid rgba(90,154,120,0.2); }
.p-high { background: rgba(184,152,106,0.08); color: var(--orange); border: 1px solid rgba(184,152,106,0.2); }
.p-medium { background: rgba(90,154,120,0.06); color: var(--yellow); border: 1px solid rgba(90,154,120,0.15); }
.p-critical { background: rgba(90,154,120,0.10); color: var(--red); border: 1px solid rgba(90,154,120,0.30); }
.p-high { background: rgba(184,152,106,0.12); color: var(--orange); border: 1px solid rgba(184,152,106,0.30); }
.p-medium { background: rgba(90,154,120,0.08); color: var(--green-bright); border: 1px solid rgba(90,154,120,0.20); }
.rec-body { padding: 16px 20px; }
.rec-body { padding: 14px 16px; }
.rec-items { display: flex; flex-direction: column; gap: 12px; }
.rec-item { display: flex; flex-direction: column; gap: 8px; padding-bottom: 12px; border-bottom: 1px solid var(--border); }
.rec-item { display: flex; flex-direction: column; gap: 8px; padding-bottom: 12px; border-bottom: 1px solid rgba(0,0,0,0.06); }
.rec-item:last-child { border-bottom: none; padding-bottom: 0; }
.item-name { font-weight: 600; font-size: 15px; color: var(--bright); }
.item-name { font-weight: 600; font-size: 15px; color: var(--text); }
.item-why { font-size: 13px; color: var(--text-dim); line-height: 1.55; }
.item-cost { font-family: var(--font-mono); font-size: 13px; color: var(--green-bright); }
.affiliate-btn { display: inline-flex; align-items: center; gap: 6px; padding: 8px 14px; background: var(--accent-dim); border: 1px solid var(--accent); border-radius: 0; color: #88BBFF; font-size: 12px; font-weight: 600; text-decoration: none; cursor: pointer; transition: var(--trans); width: fit-content; }
.affiliate-btn:hover { background: rgba(74,143,224,0.2); }
.affiliate-btn {
display: inline-flex; align-items: center; gap: 6px;
padding: 8px 14px;
background: rgba(0,0,0,0.04);
border: 1px solid rgba(0,0,0,0.10);
border-radius: 0;
color: var(--text);
font-size: 12px; font-weight: 600;
text-decoration: none; cursor: pointer;
transition: var(--trans); width: fit-content;
}
.affiliate-btn:hover { background: rgba(0,0,0,0.08); }
/* Budget meter — same logic as the rec-cards: text flips to dark since
the .result-panel underneath is white-paint. The .budget-meter wrapper
itself is stripped to transparent in the panel-body override (line ~615),
so only the inner element colours need adjusting here. */
.budget-meter { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 20px; margin-bottom: 20px; animation: fadeUp 0.4s 0.2s ease both; }
.bm-title { font-family: var(--font-display); font-weight: 700; font-size: 16px; color: var(--white); margin-bottom: 16px; }
.bm-title { font-family: var(--font-display); font-weight: 700; font-size: 16px; color: var(--text); margin-bottom: 16px; }
.budget-cats { display: flex; flex-direction: column; gap: 10px; }
.bcat { display: flex; align-items: center; gap: 10px; }
.bcat-label { font-size: 13px; color: var(--text-dim); width: 100px; flex-shrink: 0; }
.bcat-bar-wrap { flex: 1; height: 6px; background: var(--muted); border-radius: 99px; overflow: hidden; }
.bcat-bar-wrap { flex: 1; height: 6px; background: rgba(0,0,0,0.08); border-radius: 99px; overflow: hidden; }
.bcat-bar { height: 100%; border-radius: 99px; transition: width 0.8s cubic-bezier(0.4,0,0.2,1); }
.bcat-cost { font-family: var(--font-mono); font-size: 12px; color: var(--bright); width: 60px; text-align: right; flex-shrink: 0; }
.bcat-cost { font-family: var(--font-mono); font-size: 12px; color: var(--text); width: 60px; text-align: right; flex-shrink: 0; }
/* Timeline — same dark-on-light flip. Connector line uses a subtle dark
border colour so it reads on the white-paint panel. */
.timeline { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 20px; margin-bottom: 20px; animation: fadeUp 0.4s 0.25s ease both; }
.tl-title { font-family: var(--font-display); font-weight: 700; font-size: 16px; color: var(--white); margin-bottom: 16px; }
.tl-title { font-family: var(--font-display); font-weight: 700; font-size: 16px; color: var(--text); margin-bottom: 16px; }
.tl-items { display: flex; flex-direction: column; gap: 0; }
.tl-item { display: flex; gap: 16px; padding-bottom: 16px; position: relative; }
.tl-item:last-child { padding-bottom: 0; }
.tl-item:not(:last-child)::before { content: ''; position: absolute; left: 15px; top: 32px; bottom: 0; width: 2px; background: var(--border); }
.tl-dot { width: 32px; height: 32px; border-radius: 50%; background: var(--panel); border: 2px solid; display: flex; align-items: center; justify-content: center; font-size: 14px; flex-shrink: 0; position: relative; z-index: 1; }
.tl-item:not(:last-child)::before { content: ''; position: absolute; left: 15px; top: 32px; bottom: 0; width: 2px; background: rgba(0,0,0,0.10); }
.tl-dot { width: 32px; height: 32px; border-radius: 50%; background: #F0F0F0; border: 2px solid; display: flex; align-items: center; justify-content: center; font-size: 14px; flex-shrink: 0; position: relative; z-index: 1; }
.tl-when { font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--text-dim); margin-bottom: 4px; }
.tl-action { font-size: 14px; color: var(--bright); line-height: 1.5; }
.tl-action { font-size: 14px; color: var(--text); line-height: 1.5; }
.tl-cost { font-family: var(--font-mono); font-size: 12px; color: var(--green-bright); margin-top: 4px; }
/* ── RESULT PANELS — collapsible accordions for progressive disclosure.
@@ -580,12 +730,12 @@ input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; width: 22px;
.result-panel-header::-webkit-details-marker { display: none; }
.result-panel-header:hover { background: rgba(0,0,0,0.02); }
.rp-title {
font-family: var(--font-display);
font-family: var(--font-body);
font-weight: 700;
font-size: 17px;
font-size: 16px;
color: var(--text);
flex: 1;
letter-spacing: 0.01em;
letter-spacing: 0.02em;
line-height: 1.3;
}
.rp-hint {
@@ -656,6 +806,29 @@ input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; width: 22px;
/* ── SINGLE FOLD: hide non-essential sections on load ──
(header stays visible with a transparent background on the homepage) */
.site-header { background: transparent; backdrop-filter: none; -webkit-backdrop-filter: none; border-bottom: none; }
/* Results-page header — soft white fade so content doesn't visually
clash with the logo / language toggle when scrolling underneath
(especially noticeable on mobile). The header itself becomes a
translucent white veil; a ::after pseudo extends ~28px below it
with a fade-to-transparent so the boundary feels organic. */
body.results-active .site-header {
background: rgba(250,250,250,0.86);
-webkit-backdrop-filter: blur(20px);
backdrop-filter: blur(20px);
}
body.results-active .site-header::after {
content: '';
position: absolute;
top: 100%;
left: 0;
right: 0;
height: 28px;
background: linear-gradient(180deg, rgba(250,250,250,0.86) 0%, rgba(250,250,250,0) 100%);
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
pointer-events: none;
}
.scenario-strip,
.email-section,
.about-section { display: none !important; }
@@ -738,6 +911,9 @@ body.paint-green .qpb-logo {
}
/* Modifier toggle ⚙ — wears the same white-paint look as the lang-toggle */
/* Hidden for now — styles preserved so we can re-enable the modifier
panel later by removing this single rule. */
.mod-open-btn { display: none !important; }
.mod-open-btn {
background: #F0F0F0;
border: 1px solid rgba(0,0,0,0.06);
@@ -907,27 +1083,33 @@ body.mod-open .mod-panel { display: block; }
/* ── NARRATIVE SECTION ── */
/* Narrative card — white-paint container that matches the form / result
panels (#FAFAFA, embossed shadow, inset highlight). The header takes a
slightly greyer tint (#F0F0F0) so it reads as a subtle band above the
body — same nesting as the result-panel header. */
.narrative-section {
background: var(--card);
border: 1px solid var(--border);
background: #FAFAFA;
border: 1px solid rgba(0,0,0,0.06);
border-radius: var(--radius-lg);
margin-bottom: 24px;
overflow: hidden;
animation: fadeUp 0.4s ease both;
box-shadow: 0 7px 10.6px rgba(0,0,0,0.12), inset 0 1px 0 rgba(255,255,255,0.7);
}
.narrative-header {
padding: 16px 20px;
background: rgba(90,154,120,0.03);
border-bottom: 1px solid var(--border);
padding: 14px 18px;
background: #F0F0F0;
border-bottom: 1px solid rgba(0,0,0,0.06);
display: flex;
align-items: center;
gap: 10px;
}
.narrative-title {
font-family: var(--font-display);
font-weight: 800;
font-size: 17px;
color: var(--white);
font-family: var(--font-body);
font-weight: 700;
font-size: 16px;
color: var(--text);
letter-spacing: 0.02em;
}
.narrative-tag {
margin-left: auto;
@@ -950,7 +1132,7 @@ body.mod-open .mod-panel { display: block; }
font-family: var(--font-display);
font-weight: 800;
font-size: 16px;
color: var(--white);
color: var(--text);
margin: 20px 0 8px;
letter-spacing: 0.02em;
}
@@ -958,7 +1140,7 @@ body.mod-open .mod-panel { display: block; }
.narrative-body p { margin: 0 0 12px; }
.narrative-body ul { margin: 8px 0 12px; padding-left: 20px; }
.narrative-body li { margin-bottom: 6px; }
.narrative-body strong { color: var(--bright); }
.narrative-body strong { color: var(--text); font-weight: 700; }
.narrative-loading {
padding: 32px 24px;
text-align: center;
@@ -1136,13 +1318,14 @@ body.mod-open .mod-panel { display: block; }
/* ── PROPRIETARY PROTEIN OFFER ── */
.protein-offer {
background: var(--green-dim);
border: 1px solid rgba(90,154,120,0.45);
background: #E5F0E0;
border: 1px solid rgba(90,154,120,0.30);
border-radius: var(--radius-lg);
padding: 24px;
margin-bottom: 20px;
animation: fadeUp 0.5s ease both;
display: none;
box-shadow: 0 7px 10.6px rgba(0,0,0,0.08), inset 0 1px 0 rgba(255,255,255,0.7);
}
.protein-offer.show { display: block; }
.protein-offer-badge {
@@ -1161,12 +1344,13 @@ body.mod-open .mod-panel { display: block; }
margin-bottom: 12px;
}
.protein-offer-title {
font-family: var(--font-display);
font-weight: 900;
font-size: 22px;
font-family: var(--font-body);
font-weight: 700;
font-size: 20px;
color: var(--white);
margin-bottom: 8px;
line-height: 1.2;
line-height: 1.25;
letter-spacing: 0.01em;
}
.protein-offer-body {
font-size: 14px;
@@ -1195,6 +1379,12 @@ body.mod-open .mod-panel { display: block; }
justify-content: center;
}
.protein-offer-btn:hover { background: #32C070; transform: translateY(-1px); }
/* Responsive label — short on mobile (narrow card), long on desktop. */
.protein-offer-btn .po-label-desktop { display: none; }
@media (min-width: 768px) {
.protein-offer-btn .po-label-mobile { display: none; }
.protein-offer-btn .po-label-desktop { display: inline; }
}
.scenario-strip{
overflow:hidden;
@@ -1230,7 +1420,8 @@ body.mod-open .mod-panel { display: block; }
.cta-btn,
.btn-next,
.narrative-cta-btn,
.capture-submit {
.capture-submit,
.protein-offer-btn {
position: relative;
isolation: isolate;
background: transparent;
@@ -1240,7 +1431,8 @@ body.mod-open .mod-panel { display: block; }
.cta-btn::before,
.btn-next::before,
.narrative-cta-btn::before,
.capture-submit::before {
.capture-submit::before,
.protein-offer-btn::before {
content: '';
position: absolute;
inset: 0;
@@ -1274,7 +1466,8 @@ body.mod-open .mod-panel { display: block; }
.cta-btn:hover,
.btn-next:hover,
.narrative-cta-btn:hover,
.capture-submit:hover {
.capture-submit:hover,
.protein-offer-btn:hover {
background: transparent;
color: #fff8e8;
transform: none;
@@ -1283,7 +1476,172 @@ body.mod-open .mod-panel { display: block; }
.cta-btn:active,
.btn-next:active,
.narrative-cta-btn:active,
.capture-submit:active { opacity: 0.9; }
.capture-submit:active,
.protein-offer-btn:active { opacity: 0.9; }
/* Green-paint variant — the protein-offer button always wears the green
paint regardless of body.paint-* state (parallels body.paint-green
.cta-btn::before, but pinned to this button as a one-off). */
.protein-offer-btn { color: #f4ecd8; }
.protein-offer-btn::before {
background: #2a3010;
filter: url(#paintGlossBtn);
-webkit-filter: url(#paintGlossBtn);
}
.protein-offer-btn:hover::before {
filter: url(#paintGlossBtnHover);
-webkit-filter: url(#paintGlossBtnHover);
}
/* Unified button typography — every primary CTA wears the .cta-btn font
(var(--font-body), 400 weight, 13px, 0.2em tracking, uppercase) so the
results-page actions match the homepage "Begin" button. Padding and
width stay per-button so visual hierarchy is preserved. Place after
the per-button rules above so this wins the cascade. */
.cta-btn,
.btn-next,
.btn-back,
.narrative-cta-btn,
.capture-submit,
.protein-offer-btn,
.restart-btn,
.form-modal-close {
font-family: var(--font-body);
font-weight: 400;
font-size: 13px;
letter-spacing: 0.2em;
text-transform: uppercase;
}
/* ── FORM MODAL ──
Layout: fixed overlay with the panel scaled to the viewport. Panel is a
flex column — header (sticky) / body (scrollable) / footer (sticky) —
so the submit button stays in view while long forms scroll inside. */
.form-modal {
position: fixed;
inset: 0;
z-index: 200;
display: none;
padding: 16px;
background: rgba(20, 20, 20, 0.55);
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
align-items: center;
justify-content: center;
}
.form-modal.open {
display: flex;
animation: fadeUp 0.22s ease;
}
.form-modal-panel {
position: relative;
width: 100%;
max-width: 580px;
max-height: calc(100dvh - 32px);
display: flex;
flex-direction: column;
background: #FAFAFA;
border: 1px solid rgba(0,0,0,0.06);
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: 0 12px 32px rgba(0,0,0,0.25), inset 0 1px 0 rgba(255,255,255,0.7);
animation: fadeUp 0.3s ease both;
}
.form-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 18px;
border-bottom: 1px solid rgba(0,0,0,0.06);
flex-shrink: 0;
}
.form-modal-header .modal-title {
font-family: var(--font-display);
font-weight: 700;
font-size: 18px;
color: var(--text);
letter-spacing: 0.01em;
line-height: 1.2;
}
.form-modal-body {
flex: 1 1 auto;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 18px;
}
.form-modal-body .capture-sub {
margin-bottom: 16px;
}
.form-modal-footer {
flex-shrink: 0;
padding: 14px 18px max(14px, env(safe-area-inset-bottom));
border-top: 1px solid rgba(0,0,0,0.06);
background: #FAFAFA;
}
.form-modal-footer .capture-submit {
width: 100%;
margin: 0;
}
/* Close button — secondary style: white card with subtle border (parallels
the .btn-back / restart-btn flat panel look). The × is drawn from two
CSS strokes via ::before / ::after so it sits pixel-centred regardless
of font metrics; the button's text content is sized to 0 so the inline
"×" character isn't rendered. */
.form-modal-close {
position: relative;
width: 34px;
height: 34px;
border: 1px solid rgba(0,0,0,0.12);
border-radius: 4px;
background: #FAFAFA;
color: var(--text);
padding: 0;
cursor: pointer;
flex-shrink: 0;
font-size: 0; /* hides the literal "×" character */
line-height: 0;
transition: background 0.15s ease, border-color 0.15s ease;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.7);
}
.form-modal-close::before,
.form-modal-close::after {
content: '';
position: absolute;
left: 50%;
top: 50%;
width: 14px;
height: 1.5px;
background: var(--text);
border-radius: 1px;
transform-origin: center;
}
.form-modal-close::before { transform: translate(-50%, -50%) rotate(45deg); }
.form-modal-close::after { transform: translate(-50%, -50%) rotate(-45deg); }
.form-modal-close:hover { background: #FFFFFF; border-color: rgba(0,0,0,0.20); }
.form-modal-close:active { background: #F0F0F0; }
/* In-form privacy note — sits between the protein field and newsletter
checkbox (above the fixed footer submit). */
.form-privacy {
font-size: 11px;
color: var(--muted);
text-align: center;
line-height: 1.7;
margin: 4px 0 0;
}
/* iOS-Safari-safe scroll lock — overflow:hidden alone leaks touch scroll
on iOS, so we also pin position:fixed and width:100%. JS sets the
`top` inline style to -savedScrollY so the page stays where it was,
then restores scrollY on close. */
body.modal-open {
overflow: hidden;
position: fixed;
width: 100%;
left: 0;
right: 0;
}
.btn-next:disabled,
.btn-next:disabled:hover {
opacity: 0.4;