Files
justvitamin/src-editorial/views/PreviewTheatre.vue
T
Omair Saleh e3fd4dc806 feat(editorial): curated scientific studies with real PMID citations
Replaces the JSON-path source list with structured CitationLink objects
(label / href / meta / type) so every citation in the dashboard resolves to a
real URL — PubMed PMIDs, EFSA opinions, EU regulation register. Adds a
hand-curated scientificStudyDraft per pilot SKU with study framing first and
EFSA verbatim wording quoted separately, matching the compliance pattern
Wild Nutrition, Ritual, Heights, and Nothing Fishy use.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 19:22:40 +08:00

1320 lines
40 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="theatre">
<!-- HEADER + CONTROLS ---------------------------------------- -->
<section class="head page">
<div class="head-grid">
<div class="head-left">
<span class="eyebrow">PDP Preview Theatre</span>
<h1 class="head-title">
See it<br />
<em>as it ships</em>
</h1>
<p class="head-lede">
The full PDP render for each pilot SKU every section in elite voice,
citation-linked, compliance-clean. Switch the render skin to inspect the
same content through a competitor's typography &amp; colour.
</p>
</div>
<div class="head-right">
<div class="select-block">
<span class="meta">Product</span>
<div class="sku-chips">
<router-link
v-for="p in pilots"
:key="p.sku"
class="sku-chip"
:class="{ 'is-active': activeSku === p.sku }"
:to="`/preview/${p.sku}`"
>
<span class="chip-sku">{{ p.sku.replace('JV-', '') }}</span>
<span class="chip-name">{{ p.name }}</span>
</router-link>
</div>
</div>
<div class="select-block select-block--skin">
<span class="meta">Render as</span>
<div class="skin-row">
<button
v-for="skin in SKINS"
:key="skin.key"
class="skin-pill"
:class="{ 'is-active': activeSkin === skin.key }"
:style="{ '--pill': skin.swatch }"
@click="activeSkin = skin.key"
>
<span class="skin-swatch"></span>
{{ skin.label }}
</button>
</div>
</div>
</div>
</div>
</section>
<!-- PDP STAGE -------------------------------------------- -->
<section v-if="pilot" class="stage page">
<div class="stage-frame" :class="`skin-${activeSkin}`">
<!-- Faux browser chrome to feel "live" -->
<header class="stage-chrome">
<div class="chrome-dots">
<span></span><span></span><span></span>
</div>
<div class="chrome-url">
<span>justvitamins.co.uk/products/{{ pilot.handle || pilot.sku.toLowerCase() }}</span>
</div>
<div class="chrome-skin">Rendered as {{ activeSkinLabel }}</div>
</header>
<!-- Above-the-fold hero -->
<section class="atf">
<div class="atf-image">
<div class="image-card">
<span class="image-meta">{{ pilot.sku }}</span>
<span class="image-title">{{ truth?.title || pilot.name }}</span>
<span class="image-strength" v-if="truth?.strength">{{ truth.strength }}</span>
</div>
</div>
<div class="atf-text">
<span class="breadcrumb-meta">Home / {{ catLabel(pilot.category) }} / {{ truth?.title || pilot.name }}</span>
<h2 class="product-name">{{ truth?.title || pilot.name }}</h2>
<ul class="atf-bullets">
<li v-for="b in truthBullets" :key="b">✓ {{ b }}</li>
</ul>
<div class="variants" v-if="truth?.variants?.length">
<span
v-for="v in truth.variants"
:key="v.sku"
class="variant-pill"
>
{{ v.option }} · £{{ v.price }}
</span>
</div>
<button class="cta-btn">Add to bag</button>
</div>
</section>
<!-- Section 01 — Scientific Studies -->
<section class="pdp-section" id="sci">
<header class="section-header">
<span class="section-num">01</span>
<div>
<span class="section-eyebrow">Scientific Studies</span>
<h3 class="section-title">{{ pilot.scientificStudy.headline }}</h3>
</div>
<div class="section-actions">
<span class="approve-state" :class="approvedState('scientificStudy')">{{ approveLabel('scientificStudy') }}</span>
<button class="action-btn" @click="approve('scientificStudy')">Approve ✓</button>
<button class="action-btn is-quiet" @click="openCite('scientificStudy')">Cite ↗</button>
</div>
</header>
<div class="section-body sci-body">
<!-- Study framing: actual journal-cited prose, not vague "research suggests" -->
<p
v-for="(para, i) in sciBodyParagraphs"
:key="`sci-p-${i}`"
class="sci-prose"
>{{ para }}</p>
<!-- Authorised health claim — verbatim, set apart visually so it
reads as a regulator quote, not marketing copy. -->
<aside v-if="pilot.scientificStudy.efsaClaim?.verbatim" class="efsa-quote">
<span class="efsa-eyebrow">Authorised health claim</span>
<blockquote>"{{ pilot.scientificStudy.efsaClaim.verbatim }}"</blockquote>
<p v-if="pilot.scientificStudy.efsaClaim.context" class="efsa-context">
{{ pilot.scientificStudy.efsaClaim.context }}
</p>
</aside>
<!-- Stat tiles — each linked to its real source URL -->
<div class="stat-strip">
<component
v-for="s in pilot.scientificStudy.stats?.slice(0, 3)"
:key="s.label"
:is="s.source && /^https?:/.test(s.source) ? 'a' : 'div'"
:href="s.source && /^https?:/.test(s.source) ? s.source : null"
:target="s.source && /^https?:/.test(s.source) ? '_blank' : null"
rel="noopener"
class="stat"
:class="{ 'stat-clickable': s.source && /^https?:/.test(s.source) }"
>
<div class="stat-num">{{ s.value }}</div>
<div class="stat-label">{{ s.label }}</div>
<span v-if="s.source && /^https?:/.test(s.source)" class="stat-cite">view source ↗</span>
</component>
</div>
<!-- References — every citation a real URL, clickable. No file paths. -->
<div v-if="(pilot.scientificStudy.sourceLinks || []).length" class="references">
<span class="references-label">References</span>
<ul class="references-list">
<li v-for="(c, i) in pilot.scientificStudy.sourceLinks" :key="`sci-ref-${i}`">
<a v-if="c.href" :href="c.href" target="_blank" rel="noopener">{{ c.label }}</a>
<span v-else>{{ c.label }}</span>
<span v-if="c.meta" class="ref-meta">{{ c.meta }}</span>
</li>
</ul>
</div>
</div>
</section>
<!-- Section 02 — Timeline -->
<section class="pdp-section" id="timeline">
<header class="section-header">
<span class="section-num">02</span>
<div>
<span class="section-eyebrow">{{ pilot.timelineBlock.subtitle || 'Your results timeline' }}</span>
<h3 class="section-title">{{ pilot.timelineBlock.headline }}</h3>
</div>
<div class="section-actions">
<span class="approve-state" :class="approvedState('timelineBlock')">{{ approveLabel('timelineBlock') }}</span>
<button class="action-btn" @click="approve('timelineBlock')">Approve ✓</button>
<button class="action-btn is-quiet" @click="openCite('timelineBlock')">Cite ↗</button>
</div>
</header>
<div class="section-body">
<p class="intro-copy">{{ pilot.timelineBlock.intro_copy }}</p>
<div class="timeline-grid">
<article
v-for="(p, i) in pilot.timelineBlock.phases"
:key="p.handle"
class="phase"
>
<div class="phase-marker">
<span class="phase-tick">{{ String(i + 1).padStart(2, '0') }}</span>
</div>
<div class="phase-content">
<div class="phase-label">{{ p.phase_label }}</div>
<p class="phase-body">{{ p.body_copy }}</p>
</div>
</article>
</div>
</div>
</section>
<!-- Section 03 — Comparison -->
<section class="pdp-section" id="cmp">
<header class="section-header">
<span class="section-num">03</span>
<div>
<span class="section-eyebrow">How we compare</span>
<h3 class="section-title">{{ pilot.comparisonTable.section_title }}</h3>
</div>
<div class="section-actions">
<span class="approve-state" :class="approvedState('comparisonTable')">{{ approveLabel('comparisonTable') }}</span>
<button class="action-btn" @click="approve('comparisonTable')">Approve ✓</button>
<button class="action-btn is-quiet" @click="openCite('comparisonTable')">Cite ↗</button>
</div>
</header>
<div class="section-body">
<p v-if="pilot.comparisonTable.subtitle" class="intro-copy">{{ pilot.comparisonTable.subtitle }}</p>
<div class="cmp-wrap">
<table class="cmp">
<thead>
<tr>
<th class="feature-col">Feature</th>
<th v-for="c in pilot.comparisonTable.columns" :key="c.handle" :class="{ 'col-us': c.is_us }">
{{ c.name }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="r in pilot.comparisonTable.rows" :key="r.handle">
<td class="feature">{{ r.feature_label }}</td>
<td
v-for="(c, i) in pilot.comparisonTable.columns"
:key="c.handle"
:class="{ 'cell-us': c.is_us }"
>
{{ valueAt(r, i) }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- Section 04 — FAQs -->
<section class="pdp-section" id="faq">
<header class="section-header">
<span class="section-num">04</span>
<div>
<span class="section-eyebrow">Get answers</span>
<h3 class="section-title">{{ pilot.faqBlock.section_title }}</h3>
</div>
<div class="section-actions">
<span class="approve-state" :class="approvedState('faqBlock')">{{ approveLabel('faqBlock') }}</span>
<button class="action-btn" @click="approve('faqBlock')">Approve ✓</button>
<button class="action-btn is-quiet" @click="openCite('faqBlock')">Cite ↗</button>
</div>
</header>
<div class="section-body">
<div class="faq-stack">
<details v-for="(p, i) in pilot.faqBlock.pairs" :key="p.handle" :open="i < 2">
<summary>
<span class="faq-num">{{ String(i + 1).padStart(2, '0') }}</span>
<span class="faq-question">{{ p.question }}</span>
<span class="faq-scope" :class="`scope-${p.scope}`">{{ p.scope }}</span>
</summary>
<div class="faq-answer">{{ p.answer }}</div>
</details>
</div>
<a class="faq-cta" href="#">{{ pilot.faqBlock.cta_label }} →</a>
</div>
</section>
</div>
<!-- Approval summary footer ------------------------ -->
<aside class="approval-summary">
<div class="approval-summary-inner">
<div class="approval-stat">
<span class="approval-num">{{ approvedCount }}/4</span>
<span class="meta">sections approved on this SKU</span>
</div>
<div class="approval-state-list">
<span v-for="key in SECTION_KEYS" :key="key" class="state-chip" :class="approvedState(key)">
{{ shortLabel(key) }}
</span>
</div>
<button class="btn is-primary" :disabled="approvedCount < 4">
<span v-if="approvedCount < 4">Approve all to ship</span>
<span v-else>Ship to Lewis →</span>
</button>
</div>
</aside>
</section>
<!-- CITATION DRAWER -->
<transition name="slide-up">
<div v-if="citeOpen" class="cite-drawer">
<div class="cite-drawer-inner">
<header class="cite-head">
<span class="eyebrow">Citation chain</span>
<h3 class="cite-title">{{ citeTitle }}</h3>
<button class="cite-close" @click="citeOpen = false">close ×</button>
</header>
<ul class="cite-list">
<li v-for="(c, i) in citeSources" :key="i" class="cite-row">
<span class="cite-num">{{ String(i + 1).padStart(2, '0') }}</span>
<div class="cite-body">
<a v-if="c.href" class="cite-link" :href="c.href" target="_blank" rel="noopener">{{ c.label }}</a>
<span v-else class="cite-text">{{ c.label }}</span>
<span v-if="c.meta" class="cite-meta">{{ c.meta }}</span>
<span v-if="c.type" class="cite-tag" :data-type="c.type">{{ c.type }}</span>
</div>
</li>
</ul>
</div>
</div>
</transition>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useEditorialApi } from '../composables/useEditorialApi.js'
const SKINS = [
{ key: 'jv', label: 'JV native', swatch: '#5C6B4A' },
{ key: 'wild', label: 'Wild Nutrition', swatch: '#A89A82' },
{ key: 'heights', label: 'Heights', swatch: '#1F2D4F' },
{ key: 'ritual', label: 'Ritual', swatch: '#F2A85B' },
{ key: 'fishy', label: 'Nothing Fishy', swatch: '#1A3C2A' }
]
const SECTION_KEYS = ['scientificStudy', 'timelineBlock', 'comparisonTable', 'faqBlock']
const route = useRoute()
const router = useRouter()
const api = useEditorialApi()
const pilots = ref([])
const pilot = ref(null)
const truth = ref(null)
const activeSkin = ref('jv')
const approvals = ref({}) // per-SKU-per-section
const citeOpen = ref(false)
const citeKey = ref(null)
const activeSku = computed(() => route.params.sku || pilots.value[0]?.sku || 'JV-D1000')
const activeSkinLabel = computed(() => SKINS.find(s => s.key === activeSkin.value)?.label || '')
// Split the curated Scientific Studies body into paragraphs on blank lines.
// The generator emits study framing → EFSA verbatim quote → context, separated
// by '\n\n'. We render each as a distinct <p> so the quote sets visually apart.
const sciBodyParagraphs = computed(() => {
const raw = (pilot.value?.scientificStudy?.body_copy || '').trim()
if (!raw) return []
// Strip the verbatim authorised-claim line — it gets its own block.
return raw
.split(/\n\s*\n/)
.map(p => p.trim())
.filter(p => p && !p.startsWith('Authorised health claim:'))
.filter(p => !p.startsWith('Authorised European Union nutrient claim'))
})
const truthBullets = computed(() => {
if (!truth.value) return []
const out = []
if (truth.value.format) out.push(`Format: ${truth.value.format}`)
if (truth.value.strength) out.push(`Strength: ${truth.value.strength}`)
if (truth.value.tags) {
const dietary = truth.value.tags.split(',').map(t => t.trim()).filter(t => /vegan|vegetarian|gluten|kosher|halal/i.test(t))
if (dietary.length) out.push(`Suitable for: ${dietary.join(', ')}`)
}
if (truth.value.strapline) out.push(truth.value.strapline)
return out.slice(0, 4)
})
const approvedCount = computed(() => {
const sk = approvals.value[activeSku.value] || {}
return SECTION_KEYS.filter(k => sk[k]).length
})
function approve(key) {
const cur = approvals.value[activeSku.value] || {}
approvals.value = { ...approvals.value, [activeSku.value]: { ...cur, [key]: !cur[key] } }
}
function approvedState(key) {
const sk = approvals.value[activeSku.value] || {}
return sk[key] ? 'is-approved' : 'is-pending'
}
function approveLabel(key) {
const sk = approvals.value[activeSku.value] || {}
return sk[key] ? 'Approved' : 'Draft'
}
function shortLabel(key) {
const map = { scientificStudy: 'Sci', timelineBlock: 'Time', comparisonTable: 'Cmp', faqBlock: 'FAQ' }
return map[key]
}
function valueAt(row, i) {
const v = row.values?.[String(i)]?.value || ''
return v.length > 80 ? v.slice(0, 80) + '' : v
}
function catLabel(c) {
return { vitamin: 'Vitamins', mineral: 'Minerals', botanical: 'Botanicals', collagen: 'Collagen' }[c] || c
}
// Citation drawer
// Prefer the structured sourceLinks array (objects with label/href/meta/type)
// over the legacy sources string array. Show only readable citations, never
// raw file paths.
const citeSources = computed(() => {
if (!pilot.value || !citeKey.value) return []
const section = pilot.value[citeKey.value] || {}
const structured = Array.isArray(section.sourceLinks) ? section.sourceLinks : null
if (structured && structured.length) return structured
// Fallback: convert legacy string sources into citation-link shape.
return (section.sources || [])
.filter(s => typeof s === 'string')
.filter(s => !s.startsWith('data/') && !s.startsWith('(no ')) // hide internal paths
.map(s => {
// Heuristic: pull out a URL if present in the string.
const urlMatch = s.match(/https?:\/\/\S+/)
if (urlMatch) {
const label = s.replace(urlMatch[0], '').replace(/[—\-:\s]+$/, '').trim() || urlMatch[0]
return { type: 'other', label, href: urlMatch[0] }
}
return { type: 'other', label: s }
})
})
const citeTitle = computed(() => {
if (!citeKey.value) return ''
const map = { scientificStudy: 'Scientific Studies', timelineBlock: 'Results Timeline', comparisonTable: 'Comparison Table', faqBlock: 'FAQs' }
return map[citeKey.value]
})
function openCite(key) {
citeKey.value = key
citeOpen.value = true
}
// Data loading
async function loadPilot(sku) {
pilot.value = null
truth.value = null
pilot.value = await api.loadSku(sku)
try { truth.value = await api.loadTruth(sku) } catch { truth.value = null }
}
onMounted(async () => {
pilots.value = await api.loadAllPilots()
if (!route.params.sku && pilots.value.length) {
router.replace(`/preview/${pilots.value[0].sku}`)
} else {
await loadPilot(activeSku.value)
}
})
watch(() => route.params.sku, async (sku) => {
if (sku) await loadPilot(sku)
})
</script>
<style scoped>
.theatre { padding-bottom: var(--s-9); }
/* HEAD -------------------------------------------------------- */
.head { padding-top: var(--s-7); padding-bottom: var(--s-7); }
.head-grid {
display: grid;
grid-template-columns: 1.2fr 1fr;
gap: var(--s-8);
align-items: end;
}
.head-title {
font-size: var(--t-5xl);
font-variation-settings: 'opsz' 144, 'SOFT' 80;
line-height: 0.95;
letter-spacing: -0.04em;
margin-top: var(--s-3);
}
.head-title em {
font-style: italic;
font-variation-settings: 'opsz' 144, 'SOFT' 100, 'WONK' 1;
color: var(--accent-sage);
}
.head-lede {
margin-top: var(--s-5);
max-width: 56ch;
font-size: var(--t-lg);
color: var(--ink-soft);
}
.head-right {
display: flex;
flex-direction: column;
gap: var(--s-5);
}
.select-block { display: flex; flex-direction: column; gap: var(--s-3); }
.sku-chips {
display: flex;
flex-wrap: wrap;
gap: var(--s-2);
}
.sku-chip {
border: 1px solid var(--rule);
background: var(--bg-paper);
padding: var(--s-3) var(--s-4);
display: flex;
flex-direction: column;
gap: 2px;
border-radius: var(--r-sm);
text-decoration: none;
transition: all var(--dur) var(--ease);
min-width: 140px;
}
.sku-chip:hover {
border-color: var(--rule-strong);
background: var(--bg-cream-alt);
}
.sku-chip.is-active {
border-color: var(--ink);
background: var(--ink);
color: var(--bg-paper);
}
.chip-sku {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.06em;
color: var(--ink-muted);
}
.sku-chip.is-active .chip-sku { color: rgba(244, 239, 230, 0.55); }
.chip-name {
font-family: var(--font-display);
font-size: var(--t-sm);
font-variation-settings: 'opsz' 24, 'SOFT' 20;
line-height: 1.15;
letter-spacing: -0.01em;
}
.skin-row { display: flex; flex-wrap: wrap; gap: var(--s-2); }
.skin-pill {
display: inline-flex;
align-items: center;
gap: var(--s-2);
padding: 6px 12px;
border-radius: 999px;
border: 1px solid var(--rule);
font-family: var(--font-sans);
font-size: var(--t-xs);
letter-spacing: 0.02em;
color: var(--ink-soft);
background: transparent;
transition: all var(--dur) var(--ease);
}
.skin-pill:hover { border-color: var(--rule-strong); color: var(--ink); }
.skin-pill.is-active {
background: var(--ink);
color: var(--bg-paper);
border-color: var(--ink);
}
.skin-swatch {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--pill);
display: inline-block;
}
/* STAGE ------------------------------------------------------ */
.stage { padding-top: var(--s-5); }
.stage-frame {
border: 1px solid var(--rule-strong);
border-radius: var(--r-md);
background: var(--bg-paper);
overflow: hidden;
box-shadow: var(--shadow-soft);
transition: background var(--dur-slow) var(--ease), color var(--dur-slow) var(--ease);
}
.stage-chrome {
display: flex;
align-items: center;
gap: var(--s-4);
padding: var(--s-3) var(--s-4);
background: var(--bg-cream-alt);
border-bottom: 1px solid var(--rule);
font-size: var(--t-xs);
color: var(--ink-muted);
}
.chrome-dots { display: flex; gap: 6px; }
.chrome-dots span {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--rule-strong);
}
.chrome-url {
flex: 1;
text-align: center;
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: -0.01em;
}
.chrome-skin {
font-family: var(--font-display);
font-style: italic;
font-size: var(--t-xs);
letter-spacing: 0.01em;
font-variation-settings: 'opsz' 18, 'WONK' 1;
}
/* ABOVE-THE-FOLD --------------------------------------------- */
.atf {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--s-7);
padding: var(--s-8) var(--s-7);
align-items: center;
}
.atf-image {
display: flex;
justify-content: center;
align-items: center;
padding: var(--s-5);
}
.image-card {
width: 100%;
aspect-ratio: 3 / 4;
max-width: 320px;
background: var(--bg-cream-alt);
border: 1px solid var(--rule);
border-radius: var(--r-sm);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--s-2);
padding: var(--s-5);
text-align: center;
}
.image-meta {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.1em;
color: var(--ink-muted);
text-transform: uppercase;
}
.image-title {
font-family: var(--font-display);
font-size: var(--t-xl);
font-variation-settings: 'opsz' 36, 'SOFT' 30;
line-height: 1.1;
letter-spacing: -0.012em;
}
.image-strength {
font-family: var(--font-display);
font-style: italic;
font-size: var(--t-lg);
color: var(--ink-soft);
font-variation-settings: 'opsz' 30, 'WONK' 1;
}
.breadcrumb-meta {
font-size: var(--t-xs);
color: var(--ink-muted);
letter-spacing: 0.04em;
text-transform: uppercase;
}
.product-name {
font-size: var(--t-3xl);
font-variation-settings: 'opsz' 96, 'SOFT' 40;
letter-spacing: -0.03em;
margin-top: var(--s-2);
}
.atf-bullets {
margin-top: var(--s-5);
list-style: none;
display: flex;
flex-direction: column;
gap: var(--s-2);
}
.atf-bullets li {
font-size: var(--t-sm);
color: var(--ink-soft);
}
.variants {
margin-top: var(--s-5);
display: flex;
gap: var(--s-2);
}
.variant-pill {
padding: var(--s-2) var(--s-4);
border: 1px solid var(--rule-strong);
border-radius: var(--r-sm);
font-size: var(--t-sm);
font-family: var(--font-sans);
}
.cta-btn {
margin-top: var(--s-5);
padding: 14px 32px;
background: var(--ink);
color: var(--bg-paper);
border-radius: var(--r-sm);
font-family: var(--font-sans);
font-weight: 500;
letter-spacing: 0.05em;
text-transform: uppercase;
font-size: var(--t-sm);
cursor: pointer;
transition: background var(--dur) var(--ease);
}
.cta-btn:hover { background: var(--accent-sage); }
/* PDP SECTION ------------------------------------------------- */
.pdp-section {
border-top: 1px solid var(--rule);
padding: var(--s-8) var(--s-7);
}
.section-header {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: end;
gap: var(--s-5);
margin-bottom: var(--s-6);
}
.section-num {
font-family: var(--font-display);
font-style: italic;
font-variation-settings: 'opsz' 96, 'WONK' 1, 'SOFT' 80, 'wght' 300;
font-size: var(--t-4xl);
line-height: 0.8;
color: var(--ink);
letter-spacing: -0.04em;
}
.section-eyebrow {
font-family: var(--font-display);
font-style: italic;
font-variation-settings: 'opsz' 24, 'WONK' 1;
font-size: var(--t-sm);
color: var(--ink-muted);
letter-spacing: 0.01em;
display: block;
}
.section-title {
font-size: var(--t-2xl);
font-variation-settings: 'opsz' 60, 'SOFT' 30;
letter-spacing: -0.02em;
margin-top: var(--s-1);
line-height: 1.15;
}
.section-actions { display: flex; gap: var(--s-2); align-items: center; }
.approve-state {
font-family: var(--font-sans);
font-size: 10px;
padding: 4px 9px;
border-radius: 999px;
letter-spacing: 0.1em;
text-transform: uppercase;
font-weight: 600;
}
.approve-state.is-pending { background: var(--bg-cream-alt); color: var(--ink-muted); }
.approve-state.is-approved { background: var(--accent-sage); color: var(--bg-paper); }
.action-btn {
font-family: var(--font-sans);
font-size: var(--t-xs);
letter-spacing: 0.02em;
padding: 7px 14px;
border-radius: var(--r-sm);
border: 1px solid var(--ink);
background: var(--ink);
color: var(--bg-paper);
transition: all var(--dur) var(--ease);
}
.action-btn:hover { background: var(--accent-sage); border-color: var(--accent-sage); }
.action-btn.is-quiet {
background: transparent;
color: var(--ink);
border-color: var(--rule-strong);
}
.action-btn.is-quiet:hover {
background: var(--bg-cream-alt);
border-color: var(--ink);
color: var(--ink);
}
/* Section body */
.section-body { max-width: var(--col-wide); }
.sci-prose {
font-family: var(--font-display);
font-size: var(--t-xl);
font-variation-settings: 'opsz' 36, 'SOFT' 30;
line-height: var(--lh-snug);
letter-spacing: -0.012em;
color: var(--ink);
max-width: 60ch;
}
.intro-copy {
font-family: var(--font-display);
font-style: italic;
font-variation-settings: 'opsz' 24, 'WONK' 1, 'SOFT' 20;
font-size: var(--t-lg);
color: var(--ink-soft);
max-width: 56ch;
margin-bottom: var(--s-6);
}
.stat-strip {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--s-7);
margin-top: var(--s-7);
padding-top: var(--s-5);
border-top: 1px solid var(--rule);
max-width: var(--col-mid);
}
.stat-num {
font-family: var(--font-display);
font-size: var(--t-4xl);
font-variation-settings: 'opsz' 96, 'SOFT' 60;
letter-spacing: -0.03em;
line-height: 1;
color: var(--accent-sage);
}
.stat-label {
font-size: 11px;
letter-spacing: 0.04em;
margin-top: var(--s-3);
color: var(--ink-muted);
text-transform: uppercase;
line-height: 1.4;
}
.study-link {
display: inline-block;
margin-top: var(--s-6);
font-family: var(--font-sans);
font-size: var(--t-sm);
color: var(--ink-soft);
text-decoration: underline;
text-decoration-color: var(--accent-sage);
text-underline-offset: 4px;
}
/* EFSA verbatim quote — set apart visually so it reads as a regulator quote,
not marketing copy. Cream block, rule on the left, italic Fraunces. */
.efsa-quote {
margin: var(--s-6) 0;
padding: var(--s-5) var(--s-6);
background: var(--bg-cream-alt);
border-left: 2px solid var(--accent-sage);
max-width: var(--col-mid);
}
.efsa-eyebrow {
display: block;
font-family: var(--font-sans);
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent-sage);
margin-bottom: var(--s-2);
font-weight: 600;
}
.efsa-quote blockquote {
font-family: var(--font-display);
font-style: italic;
font-variation-settings: 'opsz' 36, 'WONK' 1, 'SOFT' 30;
font-size: var(--t-xl);
line-height: 1.3;
color: var(--ink);
letter-spacing: -0.012em;
}
.efsa-context {
margin-top: var(--s-3);
font-size: var(--t-xs);
line-height: 1.55;
color: var(--ink-muted);
}
/* Clickable stat tile — when the source is a real URL, the whole tile links */
.stat-clickable {
text-decoration: none;
color: inherit;
position: relative;
transition: opacity var(--dur) var(--ease);
}
.stat-clickable:hover { opacity: 0.7; }
.stat-cite {
display: block;
margin-top: var(--s-2);
font-family: var(--font-sans);
font-size: 10px;
letter-spacing: 0.05em;
color: var(--accent-sage);
text-transform: uppercase;
}
/* References footer — real citations as clickable links, not file paths */
.references {
margin-top: var(--s-7);
padding-top: var(--s-5);
border-top: 1px solid var(--rule);
max-width: var(--col-mid);
}
.references-label {
font-family: var(--font-sans);
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--ink-muted);
font-weight: 600;
}
.references-list {
list-style: none;
margin-top: var(--s-3);
display: flex;
flex-direction: column;
gap: var(--s-3);
}
.references-list li {
display: flex;
flex-direction: column;
gap: 2px;
font-size: var(--t-sm);
line-height: 1.45;
}
.references-list a {
color: var(--ink);
text-decoration: underline;
text-decoration-color: var(--accent-sage);
text-underline-offset: 3px;
}
.references-list a:hover { color: var(--accent-sage); }
.ref-meta {
font-family: var(--font-mono);
font-size: 11px;
color: var(--ink-muted);
letter-spacing: -0.01em;
}
/* Timeline */
.timeline-grid {
display: grid;
grid-template-columns: 1fr;
gap: 0;
border-top: 1px solid var(--rule);
}
.phase {
display: grid;
grid-template-columns: 80px 1fr;
gap: var(--s-5);
padding: var(--s-5) 0;
border-bottom: 1px solid var(--rule-faint);
align-items: start;
}
.phase-marker {
display: flex;
justify-content: flex-start;
}
.phase-tick {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.08em;
color: var(--accent-sage);
padding: 4px 8px;
border: 1px solid var(--accent-sage);
border-radius: var(--r-sm);
}
.phase-label {
font-family: var(--font-display);
font-variation-settings: 'opsz' 36, 'SOFT' 30;
font-size: var(--t-xl);
letter-spacing: -0.015em;
margin-bottom: var(--s-2);
}
.phase-body {
font-size: var(--t-base);
line-height: var(--lh-relaxed);
color: var(--ink-soft);
max-width: 64ch;
}
/* Comparison */
.cmp-wrap { overflow-x: auto; }
.cmp {
width: 100%;
border-collapse: collapse;
font-size: var(--t-sm);
margin-top: var(--s-3);
}
.cmp th, .cmp td {
text-align: left;
padding: var(--s-3) var(--s-4);
border-bottom: 1px solid var(--rule);
vertical-align: top;
}
.cmp thead th {
font-family: var(--font-sans);
font-size: var(--t-xs);
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--ink-muted);
font-weight: 600;
border-bottom: 2px solid var(--ink);
}
.cmp thead th.col-us { color: var(--accent-sage); }
.cmp td.feature {
font-weight: 500;
color: var(--ink);
}
.cmp td.cell-us {
background: var(--bg-sage-soft);
color: var(--ink);
font-weight: 500;
}
/* FAQ */
.faq-stack {
display: flex;
flex-direction: column;
border-top: 1px solid var(--rule);
}
.faq-stack details {
border-bottom: 1px solid var(--rule);
padding: var(--s-5) 0;
}
.faq-stack summary {
list-style: none;
cursor: pointer;
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: var(--s-4);
}
.faq-stack summary::-webkit-details-marker { display: none; }
.faq-num {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.06em;
color: var(--ink-muted);
}
.faq-question {
font-family: var(--font-display);
font-size: var(--t-lg);
font-variation-settings: 'opsz' 24, 'SOFT' 20;
letter-spacing: -0.012em;
color: var(--ink);
}
.faq-scope {
font-family: var(--font-sans);
font-size: 10px;
padding: 3px 8px;
border-radius: 999px;
letter-spacing: 0.06em;
text-transform: uppercase;
background: var(--bg-cream-alt);
color: var(--ink-muted);
}
.faq-scope.scope-product { background: var(--bg-sage-soft); color: var(--accent-sage); }
.faq-answer {
margin-top: var(--s-3);
font-size: var(--t-base);
line-height: var(--lh-relaxed);
color: var(--ink-soft);
max-width: 64ch;
padding-left: 28px;
}
.faq-cta {
display: inline-block;
margin-top: var(--s-5);
font-family: var(--font-sans);
font-size: var(--t-sm);
color: var(--accent-sage);
text-decoration: none;
letter-spacing: 0.01em;
}
.faq-cta:hover { color: var(--ink); }
/* APPROVAL SUMMARY ---------------------------------------- */
.approval-summary {
margin-top: var(--s-7);
border: 1px solid var(--rule-strong);
background: var(--bg-paper);
border-radius: var(--r-md);
box-shadow: var(--shadow-soft);
}
.approval-summary-inner {
display: grid;
grid-template-columns: auto 1fr auto;
gap: var(--s-5);
padding: var(--s-5) var(--s-6);
align-items: center;
}
.approval-num {
font-family: var(--font-display);
font-size: var(--t-3xl);
font-variation-settings: 'opsz' 96, 'SOFT' 60;
color: var(--ink);
display: block;
line-height: 1;
}
.approval-state-list { display: flex; gap: var(--s-2); flex-wrap: wrap; }
.state-chip {
font-family: var(--font-sans);
font-size: 11px;
padding: 4px 10px;
border-radius: 999px;
letter-spacing: 0.02em;
background: var(--bg-cream-alt);
color: var(--ink-muted);
border: 1px solid var(--rule);
}
.state-chip.is-approved { background: var(--accent-sage); color: var(--bg-paper); border-color: var(--accent-sage); }
/* CITATION DRAWER ----------------------------------------- */
.cite-drawer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 200;
background: var(--bg-ink);
color: var(--ink-on-dark);
border-top: 1px solid var(--rule-strong);
max-height: 50vh;
overflow-y: auto;
}
.cite-drawer-inner {
max-width: var(--col-full);
margin: 0 auto;
padding: var(--s-6) var(--s-7);
}
.cite-head {
display: grid;
grid-template-columns: 1fr auto;
gap: var(--s-4);
align-items: center;
margin-bottom: var(--s-5);
}
.cite-head .eyebrow { color: var(--accent-sage); }
.cite-title {
font-size: var(--t-2xl);
color: var(--ink-on-dark);
font-variation-settings: 'opsz' 60;
}
.cite-close {
border: 1px solid rgba(244, 239, 230, 0.4);
color: var(--ink-on-dark);
padding: 6px 12px;
border-radius: 999px;
font-size: var(--t-sm);
}
.cite-list {
list-style: none;
display: flex;
flex-direction: column;
gap: var(--s-3);
}
.cite-list li {
display: grid;
grid-template-columns: 36px 1fr;
gap: var(--s-3);
padding: var(--s-3) 0;
border-bottom: 1px solid rgba(244, 239, 230, 0.1);
}
.cite-num {
font-family: var(--font-mono);
color: rgba(244, 239, 230, 0.45);
font-size: var(--t-xs);
}
.cite-mono {
font-family: var(--font-mono);
font-size: var(--t-xs);
color: var(--ink-on-dark);
word-break: break-all;
}
.cite-row {
display: grid !important;
grid-template-columns: 36px 1fr !important;
}
.cite-body { display: flex; flex-direction: column; gap: 4px; }
.cite-link {
color: var(--ink-on-dark);
text-decoration: underline;
text-decoration-color: var(--accent-sage);
text-underline-offset: 3px;
font-family: var(--font-display);
font-size: var(--t-base);
font-variation-settings: 'opsz' 24, 'SOFT' 20;
letter-spacing: -0.005em;
}
.cite-link:hover { color: var(--accent-sage); }
.cite-text {
font-family: var(--font-display);
font-size: var(--t-base);
font-variation-settings: 'opsz' 24;
color: var(--ink-on-dark);
}
.cite-meta {
font-family: var(--font-mono);
font-size: 11px;
color: rgba(244, 239, 230, 0.55);
letter-spacing: -0.01em;
}
.cite-tag {
display: inline-block;
font-family: var(--font-sans);
font-size: 9px;
letter-spacing: 0.12em;
text-transform: uppercase;
font-weight: 600;
padding: 2px 6px;
border-radius: 2px;
margin-top: 4px;
align-self: flex-start;
background: rgba(244, 239, 230, 0.08);
color: rgba(244, 239, 230, 0.75);
}
.cite-tag[data-type="pubmed"] { background: rgba(92, 107, 74, 0.25); color: #B8C2B0; }
.cite-tag[data-type="efsa"],
.cite-tag[data-type="regulation"] { background: rgba(184, 115, 44, 0.18); color: #D6A47A; }
.slide-up-enter-active, .slide-up-leave-active { transition: transform var(--dur) var(--ease-out), opacity var(--dur) var(--ease); }
.slide-up-enter-from, .slide-up-leave-to { transform: translateY(100%); opacity: 0; }
/* ============================================================
* COMPETITOR SKIN OVERRIDES
* Same DOM, different visual language. Re-skin token by token.
* ============================================================ */
/* Wild Nutrition — warm taupe, more refined, Times-y serif */
.stage-frame.skin-wild {
--bg-paper: #FBF8F3;
--bg-cream-alt: #F1E8DB;
--bg-sage-soft: #E8E2D5;
--ink: #2A2622;
--ink-soft: #5A4F44;
--accent-sage: #948161;
--font-display: 'Fraunces', Georgia, serif;
background: #FBF8F3;
}
.stage-frame.skin-wild .stat-num { color: #948161; font-variation-settings: 'opsz' 96, 'wght' 300; }
.stage-frame.skin-wild .section-num { color: #948161; font-variation-settings: 'opsz' 144, 'wght' 200; }
.stage-frame.skin-wild .cta-btn { background: #2A2622; }
/* Heights — navy + cream, sans-heavy */
.stage-frame.skin-heights {
--bg-paper: #F3EFE4;
--bg-cream-alt: #E8E1D0;
--bg-sage-soft: #DBE2EB;
--ink: #1F2D4F;
--ink-soft: #4B5878;
--accent-sage: #1F2D4F;
--font-display: 'General Sans', sans-serif;
background: #F3EFE4;
}
.stage-frame.skin-heights .stat-num,
.stage-frame.skin-heights .section-num,
.stage-frame.skin-heights .product-name,
.stage-frame.skin-heights .section-title,
.stage-frame.skin-heights .phase-label,
.stage-frame.skin-heights .image-title,
.stage-frame.skin-heights .faq-question,
.stage-frame.skin-heights .sci-prose,
.stage-frame.skin-heights .head-title {
font-family: 'General Sans', sans-serif;
font-style: normal;
font-weight: 600;
font-variation-settings: normal;
letter-spacing: -0.02em;
}
.stage-frame.skin-heights .intro-copy { font-family: 'General Sans', sans-serif; font-style: normal; font-variation-settings: normal; }
.stage-frame.skin-heights .cta-btn { background: #1F2D4F; }
/* Ritual — bright white, single brand-color accent */
.stage-frame.skin-ritual {
--bg-paper: #FFFFFF;
--bg-cream-alt: #F5F1EB;
--bg-sage-soft: #FFEED9;
--ink: #11243A;
--ink-soft: #475569;
--accent-sage: #F2A85B;
--rule: rgba(17, 36, 58, 0.12);
--rule-strong: rgba(17, 36, 58, 0.3);
background: #FFFFFF;
}
.stage-frame.skin-ritual .stat-num { color: #F2A85B; }
.stage-frame.skin-ritual .cta-btn { background: #11243A; }
.stage-frame.skin-ritual .cta-btn:hover { background: #F2A85B; color: #11243A; }
/* Nothing Fishy — deep green, modern */
.stage-frame.skin-fishy {
--bg-paper: #EFE9DD;
--bg-cream-alt: #DCD3C3;
--bg-sage-soft: #C8D3B8;
--ink: #1A3C2A;
--ink-soft: #406548;
--accent-sage: #1A3C2A;
background: #EFE9DD;
}
.stage-frame.skin-fishy .section-num { color: #1A3C2A; font-variation-settings: 'opsz' 144, 'wght' 500; }
.stage-frame.skin-fishy .stat-num { color: #1A3C2A; }
.stage-frame.skin-fishy .cta-btn { background: #1A3C2A; }
/* Responsive ------------------------------------------------ */
@media (max-width: 1100px) {
.atf { grid-template-columns: 1fr; }
.head-grid { grid-template-columns: 1fr; }
.section-header { grid-template-columns: 1fr; }
.section-actions { flex-wrap: wrap; }
.approval-summary-inner { grid-template-columns: 1fr; gap: var(--s-3); }
}
@media (max-width: 900px) {
.stat-strip { grid-template-columns: 1fr 1fr; gap: var(--s-4); }
.pdp-section { padding: var(--s-6) var(--s-5); }
.atf { padding: var(--s-5); }
.phase { grid-template-columns: 1fr; gap: var(--s-2); }
}
</style>