feat: editorial review dashboard + elite-grade pilot batch (5 SKUs)

Ships the second dashboard surface — a Pattern Library + Preview Theatre — that
presents the 4-section PDP pilot batch back to Umar, compliance, and the board
in an editorial format. Adds the full data layer that drives it: 5 source-backed
per-SKU drafts at QA 100/100, 15 competitor PDP semantic extracts, PubMed
evidence packs, EFSA claims library extension, JV brand voice guide, hand-curated
product FAQs, and the Matrixify-ready CSV exports for Lewis.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Omair Saleh
2026-06-02 18:20:17 +08:00
parent e470e113ee
commit 056c47581f
3302 changed files with 391779 additions and 854 deletions
+226
View File
@@ -0,0 +1,226 @@
<template>
<div class="shell">
<header class="masthead">
<div class="masthead-inner">
<div class="brand">
<span class="brand-eyebrow">Just Vitamins</span>
<span class="brand-title">Editorial Review</span>
</div>
<nav class="modes" aria-label="View mode">
<router-link
class="mode"
:class="{ 'is-active': isPatterns }"
to="/patterns"
>
<span class="mode-num">01</span>
<span class="mode-label">Pattern Library</span>
</router-link>
<router-link
class="mode"
:class="{ 'is-active': isPreview }"
to="/preview"
>
<span class="mode-num">02</span>
<span class="mode-label">Preview Theatre</span>
</router-link>
</nav>
<div class="masthead-meta">
<span class="meta">Pilot batch · 2 Jun 2026</span>
</div>
</div>
<div class="masthead-rule"></div>
</header>
<main class="content">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</main>
<footer class="footer">
<div class="footer-inner">
<span class="meta">5 pilots · 15 references · 100/100 QA</span>
<span class="meta">Hand-set in Fraunces &amp; General Sans</span>
</div>
</footer>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const isPatterns = computed(() => route.path.startsWith('/patterns'))
const isPreview = computed(() => route.path.startsWith('/preview'))
</script>
<style scoped>
.shell {
display: flex;
flex-direction: column;
min-height: 100vh;
}
/* MASTHEAD ------------------------------------------------------------ */
.masthead {
position: sticky;
top: 0;
z-index: 50;
background: var(--bg-paper);
}
.masthead-inner {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: end;
gap: var(--s-7);
padding: var(--s-6) var(--s-7) var(--s-4);
max-width: var(--col-full);
margin: 0 auto;
}
.brand {
display: flex;
flex-direction: column;
gap: var(--s-1);
justify-self: start;
}
.brand-eyebrow {
font-family: var(--font-display);
font-style: italic;
font-variation-settings: 'opsz' 24, 'WONK' 1;
font-size: var(--t-sm);
color: var(--ink-soft);
letter-spacing: 0.01em;
}
.brand-title {
font-family: var(--font-display);
font-size: var(--t-2xl);
font-variation-settings: 'opsz' 60, 'SOFT' 30, 'WONK' 0;
letter-spacing: -0.025em;
line-height: 1;
color: var(--ink);
}
/* Mode toggle — large editorial-style nav, not chips */
.modes {
display: flex;
gap: var(--s-7);
align-items: baseline;
justify-self: center;
}
.mode {
position: relative;
text-decoration: none;
display: inline-flex;
align-items: baseline;
gap: var(--s-2);
padding-bottom: var(--s-2);
transition: color var(--dur) var(--ease);
color: var(--ink-muted);
}
.mode-num {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.1em;
color: inherit;
align-self: center;
margin-top: -2px;
}
.mode-label {
font-family: var(--font-display);
font-style: italic;
font-variation-settings: 'opsz' 36, 'WONK' 1, 'SOFT' 20;
font-size: var(--t-xl);
font-weight: 400;
letter-spacing: -0.01em;
}
.mode::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: -2px;
height: 2px;
background: var(--ink);
transform: scaleX(0);
transform-origin: left;
transition: transform var(--dur-slow) var(--ease-out);
}
.mode.is-active {
color: var(--ink);
}
.mode.is-active::after {
transform: scaleX(1);
}
.mode:hover {
color: var(--ink-soft);
}
.masthead-meta {
justify-self: end;
align-self: end;
}
.masthead-rule {
height: 1px;
background: var(--rule);
max-width: var(--col-full);
margin: 0 auto;
}
/* CONTENT ------------------------------------------------------------ */
.content {
flex: 1;
padding: var(--s-8) 0 var(--s-9);
min-height: 60vh;
}
/* FOOTER ------------------------------------------------------------ */
.footer {
padding: var(--s-6) 0;
border-top: 1px solid var(--rule);
margin-top: var(--s-7);
}
.footer-inner {
max-width: var(--col-full);
margin: 0 auto;
padding: 0 var(--s-7);
display: flex;
justify-content: space-between;
gap: var(--s-4);
}
/* Responsive ------------------------------------------------------ */
@media (max-width: 900px) {
.masthead-inner {
grid-template-columns: 1fr;
gap: var(--s-4);
padding: var(--s-5) var(--s-5) var(--s-3);
}
.modes {
justify-self: start;
gap: var(--s-5);
}
.mode-label { font-size: var(--t-lg); }
.masthead-meta { display: none; }
.footer-inner { padding: 0 var(--s-5); flex-wrap: wrap; }
}
</style>
@@ -0,0 +1,85 @@
/* Tiny composable that pre-loads the editorial data layer:
- pilot summary (5 SKUs at-a-glance)
- per-SKU drafts (full content + QA)
- competitor index (15 brands)
- per-brand extracts
- product truth (used for the Preview Theatre price/format)
*/
import { ref, shallowRef } from 'vue'
const BASE = '/api/editorial'
const _summary = shallowRef(null)
const _skuMap = shallowRef({})
const _competitorIndex = shallowRef(null)
const _competitorMap = shallowRef({})
const _truthMap = shallowRef({})
const loading = ref(false)
const error = ref(null)
async function fetchJson(path) {
const res = await fetch(path)
if (!res.ok) throw new Error(`${path}${res.status}`)
return res.json()
}
async function loadSummary() {
if (_summary.value) return _summary.value
_summary.value = await fetchJson(`${BASE}/pilot`)
return _summary.value
}
async function loadSku(sku) {
if (_skuMap.value[sku]) return _skuMap.value[sku]
const doc = await fetchJson(`${BASE}/pilot/${encodeURIComponent(sku)}`)
_skuMap.value = { ..._skuMap.value, [sku]: doc }
return doc
}
async function loadAllPilots() {
const s = await loadSummary()
await Promise.all(s.perSku.map(p => loadSku(p.sku)))
return s.perSku.map(p => _skuMap.value[p.sku]).filter(Boolean)
}
async function loadCompetitors() {
if (_competitorIndex.value) return _competitorIndex.value
_competitorIndex.value = await fetchJson(`${BASE}/competitors`)
return _competitorIndex.value
}
async function loadCompetitor(handle) {
if (_competitorMap.value[handle]) return _competitorMap.value[handle]
const doc = await fetchJson(`${BASE}/competitor/${encodeURIComponent(handle)}`)
_competitorMap.value = { ..._competitorMap.value, [handle]: doc }
return doc
}
async function loadAllCompetitors() {
const idx = await loadCompetitors()
const docs = await Promise.all(idx.extracts.map(c => loadCompetitor(c.handle).catch(() => null)))
return docs.filter(Boolean)
}
async function loadTruth(sku) {
if (_truthMap.value[sku]) return _truthMap.value[sku]
const doc = await fetchJson(`${BASE}/product-truth/${encodeURIComponent(sku)}`)
_truthMap.value = { ..._truthMap.value, [sku]: doc }
return doc
}
export function useEditorialApi() {
return {
loading,
error,
loadSummary,
loadSku,
loadAllPilots,
loadCompetitors,
loadCompetitor,
loadAllCompetitors,
loadTruth,
cache: { summary: _summary, skuMap: _skuMap, competitorIndex: _competitorIndex, competitorMap: _competitorMap, truthMap: _truthMap }
}
}
+28
View File
@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Just Vitamins — Editorial Review</title>
<meta name="theme-color" content="#F4EFE6" />
<!-- Display: Fraunces (variable; optical sizing + soft axis + wonk for character).
Body: General Sans (Indian Type Foundry, free, characterful geometric).
Mono: JetBrains Mono (for citation IDs / source refs). -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="preconnect" href="https://api.fontshare.com" />
<link
href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght,SOFT,WONK@9..144,300..900,0..100,0..1&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
<link
href="https://api.fontshare.com/v2/css?f[]=general-sans@400,500,600,700&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="app"></div>
<script type="module" src="./main.js"></script>
</body>
</html>
+24
View File
@@ -0,0 +1,24 @@
import { createApp } from 'vue'
import { createRouter, createWebHashHistory } from 'vue-router'
import App from './App.vue'
import './styles/base.css'
import PatternLibrary from './views/PatternLibrary.vue'
import PreviewTheatre from './views/PreviewTheatre.vue'
const routes = [
{ path: '/', redirect: '/patterns' },
{ path: '/patterns', name: 'patterns', component: PatternLibrary, meta: { title: 'Pattern Library' } },
{ path: '/patterns/:section', name: 'patterns-section', component: PatternLibrary, meta: { title: 'Pattern Library' } },
{ path: '/preview', redirect: '/preview/JV-D1000' },
{ path: '/preview/:sku', name: 'preview', component: PreviewTheatre, meta: { title: 'Preview Theatre' } }
]
const router = createRouter({
history: createWebHashHistory('/editorial/'),
routes,
scrollBehavior: () => ({ top: 0 })
})
createApp(App).use(router).mount('#app')
+229
View File
@@ -0,0 +1,229 @@
/* Editorial base — opinionated typography + reset.
We hand-set type rather than relying on Tailwind defaults so every
text element is intentional. Fraunces opsz is wired to scale optically. */
@import './tokens.css';
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: 100%;
text-size-adjust: 100%;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
font-family: var(--font-sans);
font-size: var(--t-base);
line-height: var(--lh-normal);
color: var(--ink);
background: var(--bg-paper);
font-feature-settings: 'ss01', 'ss02', 'cv11';
letter-spacing: -0.005em;
min-height: 100vh;
}
/* Subtle paper grain — barely visible, lifts the canvas */
body::before {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
z-index: 1;
background-image:
radial-gradient(circle at 20% 30%, rgba(27,24,19,0.012) 0, transparent 60%),
radial-gradient(circle at 80% 70%, rgba(27,24,19,0.018) 0, transparent 60%);
mix-blend-mode: multiply;
}
#app {
position: relative;
z-index: 2;
min-height: 100vh;
}
/* Display headings — Fraunces with editorial spacing */
h1, h2, h3, h4 {
font-family: var(--font-display);
font-weight: 400;
line-height: var(--lh-tight);
letter-spacing: -0.02em;
color: var(--ink);
text-wrap: balance;
font-variation-settings: 'opsz' 96, 'SOFT' 30, 'WONK' 0;
}
h1 {
font-size: var(--t-5xl);
font-variation-settings: 'opsz' 144, 'SOFT' 50, 'WONK' 0;
letter-spacing: -0.035em;
}
h2 {
font-size: var(--t-3xl);
font-variation-settings: 'opsz' 96, 'SOFT' 30;
letter-spacing: -0.025em;
}
h3 {
font-size: var(--t-2xl);
font-variation-settings: 'opsz' 60, 'SOFT' 20;
letter-spacing: -0.018em;
}
h4 {
font-size: var(--t-xl);
font-variation-settings: 'opsz' 30;
letter-spacing: -0.012em;
}
p { line-height: var(--lh-relaxed); text-wrap: pretty; }
p + p { margin-top: var(--s-3); }
a {
color: var(--ink);
text-decoration-color: var(--rule);
text-decoration-thickness: 1px;
text-underline-offset: 3px;
transition: text-decoration-color var(--dur) var(--ease);
}
a:hover { text-decoration-color: var(--accent-sage); }
/* Eyebrow / label — Fraunces small italic for editorial flavour */
.eyebrow {
font-family: var(--font-display);
font-style: italic;
font-variation-settings: 'opsz' 30, 'WONK' 1;
font-size: var(--t-sm);
font-weight: 400;
color: var(--ink-soft);
letter-spacing: 0.01em;
}
/* Caption / meta — General Sans tight */
.meta {
font-family: var(--font-sans);
font-size: var(--t-xs);
letter-spacing: 0.06em;
text-transform: uppercase;
font-weight: 500;
color: var(--ink-muted);
}
/* Source citation chip */
.cite {
font-family: var(--font-mono);
font-size: 10.5px;
letter-spacing: -0.01em;
color: var(--ink-muted);
background: var(--bg-cream-alt);
padding: 2px 6px;
border-radius: var(--r-sm);
word-break: break-all;
}
/* Hairline rule */
hr {
border: 0;
height: 1px;
background: var(--rule);
margin: 0;
}
hr.thick { background: var(--rule-strong); height: 1px; }
/* Buttons */
button {
font-family: inherit;
font-size: inherit;
cursor: pointer;
border: none;
background: none;
color: inherit;
}
.btn {
display: inline-flex;
align-items: center;
gap: var(--s-2);
padding: 10px 18px;
font-family: var(--font-sans);
font-size: var(--t-sm);
font-weight: 500;
letter-spacing: 0.02em;
color: var(--ink);
background: transparent;
border: 1px solid var(--rule-strong);
border-radius: 999px;
transition: all var(--dur) var(--ease);
white-space: nowrap;
}
.btn:hover {
background: var(--ink);
color: var(--bg-paper);
border-color: var(--ink);
}
.btn.is-primary {
background: var(--ink);
color: var(--bg-paper);
border-color: var(--ink);
}
.btn.is-primary:hover {
background: var(--accent-sage);
border-color: var(--accent-sage);
}
.btn.is-quiet {
border: 1px solid var(--rule);
color: var(--ink-soft);
}
.btn.is-quiet:hover {
background: var(--bg-cream-alt);
color: var(--ink);
border-color: var(--rule-strong);
}
/* Status pip */
.pip {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--ink-muted);
}
.pip.sage { background: var(--accent-sage); }
.pip.ochre { background: var(--accent-ochre); }
.pip.rust { background: var(--accent-rust); }
.pip.faint { background: var(--rule-strong); }
/* Page layout helpers */
.page {
max-width: var(--col-full);
margin: 0 auto;
padding: 0 var(--s-7);
}
.page-narrow { max-width: var(--col-narrow); margin: 0 auto; padding: 0 var(--s-5); }
.page-mid { max-width: var(--col-mid); margin: 0 auto; padding: 0 var(--s-5); }
.page-wide { max-width: var(--col-wide); margin: 0 auto; padding: 0 var(--s-5); }
/* Top-level fade for route changes */
.fade-enter-active, .fade-leave-active {
transition: opacity var(--dur) var(--ease);
}
.fade-enter-from, .fade-leave-to {
opacity: 0;
}
@media (max-width: 900px) {
h1 { font-size: var(--t-3xl); }
h2 { font-size: var(--t-2xl); }
.page { padding: 0 var(--s-5); }
}
@media print {
body { background: white; color: black; }
body::before { display: none; }
}
+90
View File
@@ -0,0 +1,90 @@
/* Editorial tokens — warm apothecary editorial.
Reference: Wild Nutrition / Aesop / Ancient & Brave.
NOT a generic dashboard palette. Cream-parchment ground, deep ink,
muted sage for verified, ochre for caution. JV purple kept for tiny
brand-accent moments only. */
:root {
/* Canvas */
--bg-paper: #F4EFE6;
--bg-cream-alt: #EDE5D5;
--bg-ink: #1B1813;
--bg-sage-soft: #DDE4D3;
--bg-ochre-soft: #F1E3D0;
/* Ink */
--ink: #1B1813;
--ink-soft: #4A413B;
--ink-muted: #8B7F73;
--ink-on-dark: #F4EFE6;
/* Rules + lines */
--rule: rgba(27, 24, 19, 0.15);
--rule-strong: rgba(27, 24, 19, 0.4);
--rule-faint: rgba(27, 24, 19, 0.08);
/* Accents (use sparingly — one per screen) */
--accent-sage: #5C6B4A; /* approved / verified */
--accent-ochre: #B8732C; /* hold / needs review */
--accent-rust: #8E3B25; /* rejected / blocked */
--accent-jv: #4F3A8F; /* JV brand — ONE element max per screen */
/* Typography */
--font-display: 'Fraunces', 'Tiempos Headline', Georgia, 'New York', serif;
--font-sans: 'General Sans', 'Switzer', -apple-system, BlinkMacSystemFont, 'Helvetica Neue', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, 'SFMono-Regular', Menlo, monospace;
/* Type scale — editorial proportions */
--t-xs: 11px;
--t-sm: 13px;
--t-base: 15px;
--t-lg: 17px;
--t-xl: 22px;
--t-2xl: 28px;
--t-3xl: 38px;
--t-4xl: 52px;
--t-5xl: 72px;
--t-6xl: 104px;
/* Line height */
--lh-tight: 1.06;
--lh-snug: 1.15;
--lh-normal: 1.45;
--lh-relaxed: 1.65;
/* Spacing — editorial rhythm (8-px base) */
--s-1: 4px;
--s-2: 8px;
--s-3: 12px;
--s-4: 16px;
--s-5: 24px;
--s-6: 32px;
--s-7: 48px;
--s-8: 64px;
--s-9: 96px;
--s-10: 128px;
/* Page widths */
--col-narrow: 560px;
--col-mid: 780px;
--col-wide: 1100px;
--col-full: 1400px;
/* Radii — restrained */
--r-sm: 2px;
--r-md: 4px;
--r-lg: 8px;
/* Motion */
--ease: cubic-bezier(0.22, 0.65, 0.32, 1);
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--dur-fast: 180ms;
--dur: 260ms;
--dur-slow: 420ms;
/* Shadows — minimal, soft */
--shadow-soft: 0 1px 2px rgba(27, 24, 19, 0.04),
0 12px 32px -8px rgba(27, 24, 19, 0.08);
--shadow-lift: 0 2px 4px rgba(27, 24, 19, 0.06),
0 24px 48px -12px rgba(27, 24, 19, 0.12);
}
+955
View File
@@ -0,0 +1,955 @@
<template>
<div class="patterns">
<!-- HERO ------------------------------------------------------ -->
<section class="hero page">
<div class="hero-grid">
<div class="hero-text">
<span class="eyebrow">Section by section</span>
<h1 class="hero-title">
Pick the<br />
<em>pattern</em>
</h1>
<p class="hero-lede">
For each of the four new PDP sections, here are five Just Vitamins drafts
on the left and the references they were measured against on the right.
Choose the structural pattern you want carried across the catalogue.
</p>
<div class="hero-meta">
<span class="meta">5 pilots</span>
<span class="hero-dot"></span>
<span class="meta">15 references</span>
<span class="hero-dot"></span>
<span class="meta">QA 100/100</span>
</div>
</div>
<div class="hero-ornament">
<div class="ornament-stack">
<div class="ornament-rule"></div>
<div class="ornament-num">04</div>
<div class="ornament-label">sections in scope</div>
</div>
</div>
</div>
</section>
<!-- SECTION TABS --------------------------------------------- -->
<nav class="section-tabs page" aria-label="Section">
<div class="tabs-rule"></div>
<div class="tabs-row">
<button
v-for="(s, i) in SECTIONS"
:key="s.key"
class="section-tab"
:class="{ 'is-active': activeKey === s.key }"
@click="select(s.key)"
>
<span class="tab-num">{{ String(i + 1).padStart(2, '0') }}</span>
<span class="tab-name">{{ s.label }}</span>
<span class="tab-meta">
{{ jvCount(s.key) }} JV · {{ refCount(s.key) }} ref
</span>
</button>
</div>
</nav>
<!-- ACTIVE SECTION SPREAD ------------------------------------ -->
<section v-if="active" class="spread page">
<header class="spread-header">
<div>
<span class="eyebrow">Section {{ activeIndex + 1 }} of 4</span>
<h2 class="spread-title">{{ active.label }}</h2>
<p class="spread-blurb">{{ active.blurb }}</p>
</div>
<div class="spread-meta">
<span class="meta">Recommended template</span>
<strong class="spread-rec">{{ recommendedFor(active.key) }}</strong>
</div>
</header>
<div class="spread-rule"></div>
<div class="cols">
<!-- JV COLUMN ------------------------------------------ -->
<div class="col col-jv">
<div class="col-head">
<span class="col-num">A.</span>
<span class="col-title">Just Vitamins drafts</span>
<span class="col-rule"></span>
</div>
<div v-if="loading" class="loading">Loading drafts</div>
<article
v-else
v-for="pilot in pilots"
:key="pilot.sku"
class="specimen specimen-jv"
:class="{ 'is-focused': focusSku === pilot.sku }"
@click="focusSku = pilot.sku"
>
<header class="specimen-head">
<div>
<span class="meta">{{ pilot.sku }} · {{ pilot.category }}</span>
<h3 class="specimen-title">{{ pilot.name }}</h3>
</div>
<span class="pip sage" :title="`QA ${pilot.qa.overallScore}`"></span>
</header>
<div class="specimen-body">
<component :is="JvRenderers[active.key]" :pilot="pilot" />
</div>
<footer class="specimen-cite">
<span class="cite-label">Source</span>
<span class="cite">{{ shortCite(jvSource(pilot, active.key)) }}</span>
</footer>
</article>
</div>
<!-- REFERENCE COLUMN ------------------------------------ -->
<div class="col col-ref">
<div class="col-head">
<span class="col-num">B.</span>
<span class="col-title">Reference patterns</span>
<span class="col-rule"></span>
</div>
<div v-if="loading" class="loading">Loading references</div>
<article
v-else
v-for="comp in compsForActive"
:key="comp.handle"
class="specimen specimen-ref"
:class="{ 'is-recommended': isRecommended(comp.handle) }"
>
<header class="specimen-head">
<div>
<span class="meta">{{ comp.brand }} · {{ comp.category }}</span>
<h3 class="specimen-title specimen-ref-title">
{{ refTitle(comp, active.key) }}
</h3>
</div>
<span class="ref-tag" v-if="isRecommended(comp.handle)">
<em>our pick</em>
</span>
</header>
<div class="specimen-body">
<component :is="RefRenderers[active.key]" :extract="comp" />
</div>
<footer class="specimen-actions">
<a class="action-link" :href="comp.url" target="_blank" rel="noopener">
view source
</a>
<button
class="action-link is-action"
@click="lockPattern(comp)"
>
use as template
</button>
</footer>
</article>
</div>
</div>
</section>
<!-- LOCK CONFIRMATION TOAST -->
<transition name="fade">
<div v-if="locked" class="locked-toast">
<span class="eyebrow">Pattern locked</span>
<strong>{{ locked.brand }} {{ locked.sectionLabel }}</strong>
<span class="locked-meta">applied to {{ active.label.toLowerCase() }}</span>
</div>
</transition>
</div>
</template>
<script setup>
import { ref, computed, watch, h, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useEditorialApi } from '../composables/useEditorialApi.js'
const SECTIONS = [
{ key: 'scientificStudy', label: 'Scientific Studies', blurb: 'EFSA-authorised wording, peer-reviewed evidence, regulator-precise stat tiles.' },
{ key: 'timelineBlock', label: 'Results Timeline', blurb: 'Phase-by-phase narrative of what customers experience week-by-week or month-by-month, grounded in real reviews.' },
{ key: 'comparisonTable', label: 'Comparison Table', blurb: 'How Just Vitamins compares against the brands the customer is also weighing up.' },
{ key: 'faqBlock', label: 'FAQs', blurb: 'Three product-specific questions plus four standard pairs, in elite brand voice.' }
]
const RECOMMENDED = {
scientificStudy: { handle: 'nothing-fishy-collagen', brand: 'Nothing Fishy', sectionLabel: 'clinical citation depth' },
timelineBlock: { handle: 'heights-biotic', brand: 'Heights', sectionLabel: 'Month 1-4 cadence' },
comparisonTable: { handle: 'club-earlybird-brain-drops', brand: 'Club EarlyBird', sectionLabel: 'feature matrix' },
faqBlock: { handle: 'elavate-collagen', brand: 'Elavate', sectionLabel: 'depth of coverage' }
}
const route = useRoute()
const router = useRouter()
const api = useEditorialApi()
const loading = ref(true)
const pilots = ref([])
const competitors = ref([])
const focusSku = ref(null)
const locked = ref(null)
const activeKey = computed(() => {
const r = route.params.section
return SECTIONS.find(s => s.key === r) ? r : SECTIONS[0].key
})
const active = computed(() => SECTIONS.find(s => s.key === activeKey.value))
const activeIndex = computed(() => SECTIONS.findIndex(s => s.key === activeKey.value))
function select(key) {
router.push({ name: 'patterns-section', params: { section: key } })
}
onMounted(async () => {
loading.value = true
try {
pilots.value = await api.loadAllPilots()
competitors.value = await api.loadAllCompetitors()
focusSku.value = pilots.value[0]?.sku || null
} finally {
loading.value = false
}
})
// Counters
function jvCount(_key) { return pilots.value.length }
function refCount(key) {
return competitors.value.filter(c => sectionPresent(c, key)).length
}
function sectionPresent(comp, key) {
const map = {
scientificStudy: 'scientific_study',
timelineBlock: 'results_timeline',
comparisonTable: 'comparison_table',
faqBlock: 'faq_block'
}
return Boolean(comp?.sections?.[map[key]]?.present)
}
const compsForActive = computed(() => {
return competitors.value
.filter(c => sectionPresent(c, activeKey.value))
.sort((a, b) => Number(isRecommended(b.handle)) - Number(isRecommended(a.handle)))
})
function isRecommended(handle) {
return RECOMMENDED[activeKey.value]?.handle === handle
}
function recommendedFor(key) {
const r = RECOMMENDED[key]
return r ? `${r.brand}${r.sectionLabel}` : '—'
}
function lockPattern(comp) {
locked.value = {
brand: comp.brand,
sectionLabel: SECTIONS.find(s => s.key === activeKey.value)?.label
}
setTimeout(() => { locked.value = null }, 3200)
}
// ---------- Citation helpers ----------
function jvSource(pilot, key) {
const src = pilot[key]?.sources?.[0] || ''
return src
}
function shortCite(s) {
if (!s) return '—'
return s.replace('data/intelligence/', '…/').replace('data/sources/', '…/').slice(0, 80)
}
// ---------- Refs key mapping helpers ----------
function refTitle(comp, key) {
const map = {
scientificStudy: 'scientific_study',
timelineBlock: 'results_timeline',
comparisonTable: 'comparison_table',
faqBlock: 'faq_block'
}
const sec = comp.sections?.[map[key]] || {}
return sec.headline || sec.section_title || '—'
}
// ============================================================
// RENDERERS — JV and competitor versions of each section
// ============================================================
const JvRenderers = {
scientificStudy: {
props: ['pilot'],
setup(props) {
return () => {
const ss = props.pilot.scientificStudy
return h('div', { class: 'render render-sci' }, [
h('h4', { class: 'render-headline' }, ss.headline),
h('p', { class: 'render-body' }, ss.body_copy),
ss.stats?.length ? h('div', { class: 'stat-row' },
ss.stats.slice(0, 3).map(s =>
h('div', { class: 'stat-tile' }, [
h('div', { class: 'stat-value' }, s.value),
h('div', { class: 'stat-label' }, s.label)
])
)
) : null
])
}
}
},
timelineBlock: {
props: ['pilot'],
setup(props) {
return () => {
const tb = props.pilot.timelineBlock
return h('div', { class: 'render render-timeline' }, [
h('h4', { class: 'render-headline' }, tb.headline),
tb.subtitle ? h('div', { class: 'render-subtitle' }, tb.subtitle) : null,
h('div', { class: 'phase-grid' },
tb.phases?.slice(0, 4).map(p =>
h('div', { class: 'phase-card' }, [
h('div', { class: 'phase-label' }, p.phase_label),
h('div', { class: 'phase-body' }, p.body_copy)
])
)
)
])
}
}
},
comparisonTable: {
props: ['pilot'],
setup(props) {
return () => {
const ct = props.pilot.comparisonTable
const cols = ct.columns || []
const rows = (ct.rows || []).slice(0, 4)
return h('div', { class: 'render render-cmp' }, [
h('h4', { class: 'render-headline' }, ct.section_title),
h('table', { class: 'cmp-table' }, [
h('thead', null, h('tr', null,
[h('th', null, 'Feature'), ...cols.map(c =>
h('th', { class: { 'is-us': c.is_us } }, c.name)
)]
)),
h('tbody', null, rows.map(r =>
h('tr', null, [
h('td', { class: 'feat' }, r.feature_label),
...cols.map((_c, i) => {
const v = r.values?.[String(i)]?.value || ''
return h('td', null, v.slice(0, 60))
})
])
))
])
])
}
}
},
faqBlock: {
props: ['pilot'],
setup(props) {
return () => {
const fb = props.pilot.faqBlock
const pairs = (fb.pairs || []).slice(0, 3)
return h('div', { class: 'render render-faq' }, [
h('h4', { class: 'render-headline' }, fb.section_title),
h('dl', { class: 'faq-list' },
pairs.flatMap(p => [
h('dt', { class: 'faq-q' }, p.question),
h('dd', { class: 'faq-a' }, p.answer)
])
)
])
}
}
}
}
const RefRenderers = {
scientificStudy: {
props: ['extract'],
setup(props) {
return () => {
const s = props.extract.sections?.scientific_study || {}
return h('div', { class: 'render render-sci' }, [
s.body_copy ? h('p', { class: 'render-body' }, s.body_copy) : null,
s.stat_tiles?.length ? h('div', { class: 'stat-row' },
s.stat_tiles.slice(0, 3).map(t =>
h('div', { class: 'stat-tile stat-tile-quiet' }, [
h('div', { class: 'stat-value' }, t.value || '—'),
h('div', { class: 'stat-label' }, t.label)
])
)
) : null,
s.citations?.length ? h('div', { class: 'cite-row' }, [
h('span', { class: 'meta' }, `${s.citations.length} citation${s.citations.length === 1 ? '' : 's'}`)
]) : null
])
}
}
},
timelineBlock: {
props: ['extract'],
setup(props) {
return () => {
const t = props.extract.sections?.results_timeline || {}
return h('div', { class: 'render render-timeline' }, [
t.intro_copy ? h('div', { class: 'render-subtitle' }, t.intro_copy) : null,
h('div', { class: 'phase-grid' },
(t.phases || []).slice(0, 4).map(p =>
h('div', { class: 'phase-card phase-card-quiet' }, [
h('div', { class: 'phase-label' }, p.phase_label),
h('div', { class: 'phase-body' }, p.body_copy || '')
])
)
)
])
}
}
},
comparisonTable: {
props: ['extract'],
setup(props) {
return () => {
const c = props.extract.sections?.comparison_table || {}
const headers = c.column_headers || []
const rows = (c.rows || []).slice(0, 4)
if (!headers.length) return h('div', { class: 'render-body' }, c.notes || 'Comparison present but not extractable as table.')
return h('div', { class: 'render render-cmp' }, [
h('table', { class: 'cmp-table cmp-table-quiet' }, [
h('thead', null, h('tr', null, headers.map(hd => h('th', null, hd)))),
h('tbody', null, rows.map(r =>
h('tr', null, [
h('td', { class: 'feat' }, r.feature_label),
h('td', null, (r.us_value || '').slice(0, 60)),
...(r.competitor_values || []).slice(0, 2).map(cv => h('td', null, (cv || '').slice(0, 60)))
])
))
])
])
}
}
},
faqBlock: {
props: ['extract'],
setup(props) {
return () => {
const f = props.extract.sections?.faq_block || {}
const pairs = (f.pairs || []).slice(0, 3)
return h('div', { class: 'render render-faq' }, [
h('dl', { class: 'faq-list faq-list-quiet' },
pairs.flatMap(p => [
h('dt', { class: 'faq-q' }, p.question),
h('dd', { class: 'faq-a' }, p.answer)
])
)
])
}
}
}
}
</script>
<style scoped>
.patterns { padding-bottom: var(--s-9); }
/* HERO ----------------------------------------------------- */
.hero { padding-top: var(--s-7); padding-bottom: var(--s-7); }
.hero-grid {
display: grid;
grid-template-columns: 1.5fr 1fr;
gap: var(--s-8);
align-items: end;
}
.hero-title {
font-size: var(--t-5xl);
font-variation-settings: 'opsz' 144, 'SOFT' 80, 'WONK' 0;
line-height: 0.95;
letter-spacing: -0.04em;
margin-top: var(--s-3);
}
.hero-title em {
font-style: italic;
font-variation-settings: 'opsz' 144, 'SOFT' 100, 'WONK' 1;
color: var(--accent-sage);
}
.hero-lede {
margin-top: var(--s-5);
max-width: 56ch;
font-size: var(--t-lg);
line-height: var(--lh-relaxed);
color: var(--ink-soft);
}
.hero-meta {
margin-top: var(--s-6);
display: flex;
gap: var(--s-3);
align-items: center;
}
.hero-dot {
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--ink-muted);
}
.hero-ornament {
display: flex;
justify-content: flex-end;
align-items: flex-end;
padding-bottom: var(--s-2);
}
.ornament-stack {
text-align: right;
display: flex;
flex-direction: column;
gap: var(--s-2);
align-items: flex-end;
}
.ornament-rule { width: 80px; height: 1px; background: var(--rule-strong); }
.ornament-num {
font-family: var(--font-display);
font-size: var(--t-6xl);
line-height: 0.8;
font-variation-settings: 'opsz' 144, 'SOFT' 100, 'WONK' 1, 'wght' 300;
color: var(--ink);
letter-spacing: -0.05em;
}
.ornament-label {
font-family: var(--font-display);
font-style: italic;
font-size: var(--t-sm);
color: var(--ink-muted);
letter-spacing: 0.01em;
font-variation-settings: 'opsz' 24, 'WONK' 1;
}
/* TABS ----------------------------------------------------- */
.section-tabs { margin-top: var(--s-7); }
.tabs-rule { height: 1px; background: var(--rule); }
.tabs-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
border-bottom: 1px solid var(--rule);
}
.section-tab {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--s-2);
padding: var(--s-4) var(--s-4);
text-align: left;
position: relative;
background: transparent;
transition: background var(--dur) var(--ease);
}
.section-tab:hover { background: var(--bg-cream-alt); }
.section-tab::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: -1px;
height: 2px;
background: var(--ink);
transform: scaleX(0);
transform-origin: left;
transition: transform var(--dur) var(--ease-out);
}
.section-tab.is-active::after { transform: scaleX(1); }
.tab-num {
font-family: var(--font-mono);
font-size: 10px;
color: var(--ink-muted);
letter-spacing: 0.1em;
}
.tab-name {
font-family: var(--font-display);
font-style: italic;
font-size: var(--t-xl);
font-variation-settings: 'opsz' 36, 'WONK' 1, 'SOFT' 20;
letter-spacing: -0.012em;
color: var(--ink);
}
.tab-meta {
font-size: var(--t-xs);
color: var(--ink-muted);
letter-spacing: 0.04em;
text-transform: uppercase;
}
/* SPREAD --------------------------------------------------- */
.spread { padding-top: var(--s-7); }
.spread-header {
display: grid;
grid-template-columns: 1.5fr 1fr;
align-items: end;
gap: var(--s-6);
margin-bottom: var(--s-4);
}
.spread-title {
font-size: var(--t-4xl);
font-variation-settings: 'opsz' 96, 'SOFT' 40;
letter-spacing: -0.03em;
margin-top: var(--s-2);
}
.spread-blurb {
max-width: 60ch;
margin-top: var(--s-3);
color: var(--ink-soft);
}
.spread-meta {
text-align: right;
display: flex;
flex-direction: column;
gap: var(--s-1);
}
.spread-rec {
font-family: var(--font-display);
font-style: italic;
font-variation-settings: 'opsz' 30, 'WONK' 1;
font-size: var(--t-lg);
color: var(--accent-sage);
font-weight: 400;
}
.spread-rule {
height: 1px;
background: var(--rule-strong);
margin: var(--s-5) 0 var(--s-6);
}
.cols {
display: grid;
grid-template-columns: 1fr 1.2fr;
gap: var(--s-7);
align-items: start;
}
.col-head {
display: flex;
align-items: center;
gap: var(--s-3);
margin-bottom: var(--s-5);
}
.col-num {
font-family: var(--font-display);
font-style: italic;
font-variation-settings: 'opsz' 36, 'WONK' 1, 'SOFT' 30;
font-size: var(--t-2xl);
color: var(--ink);
line-height: 1;
}
.col-title {
font-family: var(--font-display);
font-size: var(--t-lg);
font-variation-settings: 'opsz' 30;
letter-spacing: -0.015em;
}
.col-rule {
flex: 1;
height: 1px;
background: var(--rule);
}
/* SPECIMEN ------------------------------------------------- */
.specimen {
display: flex;
flex-direction: column;
gap: var(--s-4);
padding: var(--s-5);
border: 1px solid var(--rule);
background: var(--bg-paper);
margin-bottom: var(--s-5);
transition: all var(--dur) var(--ease);
}
.specimen-jv {
cursor: pointer;
position: relative;
}
.specimen-jv::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 2px;
background: transparent;
transition: background var(--dur) var(--ease);
}
.specimen-jv:hover {
border-color: var(--rule-strong);
}
.specimen-jv.is-focused::before { background: var(--ink); }
.specimen-jv.is-focused {
background: var(--bg-cream-alt);
border-color: var(--rule-strong);
}
.specimen-ref.is-recommended {
border-color: var(--accent-sage);
background: var(--bg-sage-soft);
background: linear-gradient(180deg, var(--bg-paper), var(--bg-sage-soft));
}
.specimen-ref.is-recommended .specimen-title {
color: var(--ink);
}
.specimen-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--s-4);
}
.specimen-title {
font-size: var(--t-xl);
font-variation-settings: 'opsz' 36, 'SOFT' 20;
letter-spacing: -0.015em;
line-height: 1.15;
margin-top: var(--s-1);
}
.specimen-ref-title {
font-style: italic;
font-variation-settings: 'opsz' 36, 'WONK' 1, 'SOFT' 30;
color: var(--ink-soft);
}
.ref-tag {
font-family: var(--font-display);
font-style: italic;
font-size: var(--t-sm);
color: var(--accent-sage);
border: 1px solid var(--accent-sage);
padding: 2px 10px;
border-radius: 999px;
font-variation-settings: 'opsz' 24, 'WONK' 1;
}
.specimen-body { font-size: var(--t-base); }
.specimen-cite {
display: flex;
align-items: center;
gap: var(--s-2);
border-top: 1px dashed var(--rule);
padding-top: var(--s-3);
}
.cite-label {
font-size: var(--t-xs);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--ink-muted);
}
.specimen-actions {
display: flex;
justify-content: space-between;
align-items: center;
border-top: 1px dashed var(--rule);
padding-top: var(--s-3);
}
.action-link {
font-family: var(--font-sans);
font-size: var(--t-sm);
color: var(--ink-soft);
text-decoration: none;
letter-spacing: 0.01em;
transition: color var(--dur) var(--ease);
}
.action-link:hover { color: var(--ink); }
.action-link.is-action {
color: var(--accent-sage);
font-weight: 500;
border: none;
background: none;
cursor: pointer;
}
.action-link.is-action:hover { color: var(--ink); }
/* RENDERERS (shared subtle look) --------------------------- */
:deep(.render-headline) {
font-family: var(--font-display);
font-size: var(--t-lg);
font-variation-settings: 'opsz' 24, 'SOFT' 20;
letter-spacing: -0.012em;
margin-bottom: var(--s-2);
line-height: 1.2;
}
:deep(.render-body) {
font-size: var(--t-sm);
line-height: var(--lh-relaxed);
color: var(--ink-soft);
}
:deep(.render-subtitle) {
font-family: var(--font-display);
font-style: italic;
font-size: var(--t-sm);
color: var(--ink-muted);
margin-bottom: var(--s-3);
font-variation-settings: 'opsz' 24, 'WONK' 1;
}
:deep(.stat-row) {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--s-3);
margin-top: var(--s-4);
}
:deep(.stat-tile) {
border-top: 1px solid var(--ink);
padding-top: var(--s-2);
}
:deep(.stat-tile-quiet) { border-top-color: var(--rule); }
:deep(.stat-value) {
font-family: var(--font-display);
font-size: var(--t-xl);
font-variation-settings: 'opsz' 36, 'SOFT' 20;
letter-spacing: -0.02em;
line-height: 1;
}
:deep(.stat-label) {
font-size: 11px;
color: var(--ink-muted);
letter-spacing: 0.01em;
margin-top: var(--s-2);
line-height: 1.3;
}
:deep(.phase-grid) {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--s-3);
}
:deep(.phase-card) {
padding: var(--s-3) var(--s-4);
background: var(--bg-cream-alt);
border-radius: var(--r-sm);
}
:deep(.phase-card-quiet) {
background: transparent;
border: 1px dashed var(--rule);
}
:deep(.phase-label) {
font-family: var(--font-display);
font-style: italic;
font-size: var(--t-sm);
font-variation-settings: 'opsz' 24, 'WONK' 1;
color: var(--accent-sage);
margin-bottom: var(--s-2);
}
:deep(.phase-body) {
font-size: var(--t-xs);
line-height: 1.5;
color: var(--ink-soft);
}
:deep(.cmp-table) {
width: 100%;
font-size: var(--t-xs);
border-collapse: collapse;
margin-top: var(--s-3);
}
:deep(.cmp-table th),
:deep(.cmp-table td) {
text-align: left;
padding: 6px 8px;
border-bottom: 1px solid var(--rule-faint);
vertical-align: top;
letter-spacing: -0.005em;
}
:deep(.cmp-table th) {
font-family: var(--font-sans);
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--ink-muted);
font-weight: 600;
}
:deep(.cmp-table th.is-us) { color: var(--accent-sage); }
:deep(.cmp-table .feat) {
font-weight: 500;
color: var(--ink);
white-space: nowrap;
}
:deep(.faq-list) { display: flex; flex-direction: column; gap: var(--s-3); margin-top: var(--s-3); }
:deep(.faq-q) {
font-family: var(--font-display);
font-style: italic;
font-size: var(--t-sm);
font-variation-settings: 'opsz' 24, 'WONK' 1;
color: var(--ink);
}
:deep(.faq-a) {
font-size: var(--t-xs);
line-height: 1.55;
color: var(--ink-soft);
margin-left: var(--s-4);
border-left: 1px solid var(--rule);
padding-left: var(--s-3);
}
/* LOCK TOAST ----------------------------------------------- */
.locked-toast {
position: fixed;
bottom: var(--s-7);
left: 50%;
transform: translateX(-50%);
background: var(--bg-ink);
color: var(--ink-on-dark);
padding: var(--s-3) var(--s-5);
border-radius: 999px;
display: flex;
align-items: center;
gap: var(--s-3);
box-shadow: var(--shadow-lift);
z-index: 100;
}
.locked-toast .eyebrow {
color: var(--accent-sage);
font-size: 11px;
letter-spacing: 0.1em;
text-transform: uppercase;
font-style: normal;
font-family: var(--font-sans);
font-weight: 500;
}
.locked-toast strong {
font-family: var(--font-display);
font-variation-settings: 'opsz' 30, 'SOFT' 20;
font-weight: 400;
font-size: var(--t-base);
color: var(--ink-on-dark);
}
.locked-meta {
font-size: var(--t-xs);
color: rgba(244, 239, 230, 0.6);
}
.loading {
padding: var(--s-6);
color: var(--ink-muted);
font-style: italic;
font-family: var(--font-display);
}
/* Responsive ------------------------------------------------ */
@media (max-width: 1100px) {
.cols { grid-template-columns: 1fr; gap: var(--s-6); }
}
@media (max-width: 900px) {
.hero-grid { grid-template-columns: 1fr; }
.hero-ornament { display: none; }
.tabs-row { grid-template-columns: repeat(2, 1fr); }
.spread-header { grid-template-columns: 1fr; }
.spread-meta { text-align: left; }
}
</style>
File diff suppressed because it is too large Load Diff