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:
@@ -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 & 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 }
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user