Files
librodrome/app/components/book/BookPlayer.vue
2026-02-20 12:55:10 +01:00

1003 lines
28 KiB
Vue

<template>
<Teleport to="body">
<Transition name="immersive">
<div
v-if="isOpen"
class="bp"
:style="sceneVars"
@keydown="handleKeydown"
tabindex="0"
ref="overlayRef"
>
<!-- Animated gradient background -->
<div class="bp-bg" aria-hidden="true">
<div class="bp-orb bp-orb--1" />
<div class="bp-orb bp-orb--2" />
<div class="bp-orb bp-orb--3" />
</div>
<!-- Central glow ring -->
<div
class="bp-glow"
:class="{ 'bp-glow--active': playerStore.isPlaying }"
aria-hidden="true"
/>
<!-- Close -->
<button class="bp-close" @click="close" aria-label="Fermer">
<div class="i-lucide-x h-5 w-5" />
</button>
<!-- PHASE TRANSITIONS -->
<Transition name="phase" mode="out-in">
<!-- INTRO: 3D spinning book -->
<div v-if="phase === 'intro'" key="intro" class="bp-phase bp-intro">
<div class="spin-scene">
<div class="spin-book" @animationend="onSpinEnd">
<div class="spin-face spin-front">
<img src="/images/book-cover-spread.jpg" alt="Couverture" />
</div>
<div class="spin-face spin-back">
<img src="/images/book-cover-spread.jpg" alt="" />
</div>
</div>
</div>
</div>
<!-- COVER: title + CTA -->
<div v-else-if="phase === 'cover'" key="cover" class="bp-phase bp-cover">
<div class="cover-frame">
<img src="/images/book-cover-spread.jpg" :alt="bpContent?.cover.coverAlt ?? 'Couverture'" class="cover-img" />
</div>
<h1 class="cover-title text-gradient">{{ bpContent?.cover.title }}</h1>
<p class="cover-sub">{{ bpContent?.cover.subtitle }}</p>
<p class="cover-desc">
{{ bpContent?.cover.description }}
</p>
<button class="cover-cta" @click="startReading">
{{ bpContent?.cover.cta }}
<div class="i-lucide-arrow-right ml-2 h-5 w-5 inline-block align-middle" />
</button>
</div>
<!-- READING: paginated book reader -->
<div v-else key="reading" class="bp-phase bp-reader">
<!-- Top bar -->
<div class="reader-bar">
<button
class="reader-bar-btn"
@click="showSommaire = !showSommaire"
aria-label="Sommaire"
>
<div class="i-lucide-list h-5 w-5" />
</button>
<div class="reader-bar-title">
<span class="reader-bar-num">{{ chapterIdx + 1 }}.</span>
{{ chapters[chapterIdx].title }}
</div>
<span class="reader-bar-pages">
{{ currentPage + 1 }}<span class="op-40">/</span>{{ totalPages }}
</span>
</div>
<!-- Sommaire sidebar -->
<Transition name="sommaire">
<aside v-if="showSommaire" class="reader-sommaire" @click.self="showSommaire = false">
<div class="sommaire-panel">
<h4 class="sommaire-title">{{ bpContent?.reader.sommaireTitle ?? 'Sommaire' }}</h4>
<button
v-for="(ch, i) in chapters"
:key="i"
class="sommaire-item"
:class="{ 'sommaire-item--active': chapterIdx === i }"
@click="goToChapter(i)"
>
<span class="sommaire-num">{{ i + 1 }}</span>
{{ ch.title }}
</button>
</div>
</aside>
</Transition>
<!-- Content viewport -->
<div class="reader-viewport" ref="viewportEl">
<div
class="reader-columns prose"
ref="contentEl"
:style="contentStyle"
>
<ContentRenderer v-if="activeChapter" :value="activeChapter" />
</div>
<!-- Page turn shadow overlay -->
<div class="reader-shadow" :class="{ visible: isTurning }" />
</div>
<!-- Bottom navigation -->
<div class="reader-nav">
<button
class="reader-nav-btn"
:class="{ 'reader-nav-btn--hidden': currentPage <= 0 && chapterIdx <= 0 }"
@click="prevPage"
aria-label="Page précédente"
>
<div class="i-lucide-chevron-left h-5 w-5" />
</button>
<!-- Song disc (if chapter has a song) -->
<div v-if="chapterSong" class="reader-song">
<div class="reader-disc" :class="{ spinning: playerStore.isPlaying }">
<img src="/images/book-cover-spread.jpg" alt="" class="reader-disc-img" />
<div class="reader-disc-hole" />
</div>
<span class="reader-song-name">{{ chapterSong.title }}</span>
</div>
<div v-else class="reader-song" />
<button
class="reader-nav-btn"
:class="{ 'reader-nav-btn--hidden': currentPage >= totalPages - 1 && chapterIdx >= chapters.length - 1 }"
@click="nextPage"
aria-label="Page suivante"
>
<div class="i-lucide-chevron-right h-5 w-5" />
</button>
</div>
</div>
</Transition>
<!-- Hint -->
<p class="bp-hint">
<template v-if="phase === 'reading'">
<span class="hidden md:inline">{{ bpContent?.reader.hints.desktop }}</span>
<span class="md:hidden">{{ bpContent?.reader.hints.mobile }}</span>
</template>
<template v-else>
<span class="hidden md:inline">{{ bpContent?.reader.hints.default }}</span>
</template>
</p>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
const { data: bpContent } = await usePageContent('book-player')
const COL_GAP = 80
const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
const isOpen = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
const overlayRef = ref<HTMLElement>()
const viewportEl = ref<HTMLElement>()
const contentEl = ref<HTMLElement>()
// ── Phase state ──
const phase = ref<'intro' | 'cover' | 'reading'>('intro')
const chapterIdx = ref(0)
const currentPage = ref(0)
const totalPages = ref(1)
const colWidth = ref(500)
const showSommaire = ref(false)
const isTurning = ref(false)
const { init: initBookData, getSongs, getPrimarySong, getPlaylistOrder } = useBookData()
const audioPlayer = useAudioPlayer()
const playerStore = usePlayerStore()
// ── Content from Nuxt Content ──
const chaptersContent = ref<any[]>([])
const contentLoaded = ref(false)
async function loadContent() {
if (contentLoaded.value) return
try {
const data = await queryCollection('book').order('order', 'ASC').all()
chaptersContent.value = data
contentLoaded.value = true
}
catch (err) {
console.error('Failed to load book content:', err)
}
}
const activeChapter = computed(() => {
if (chapterIdx.value < 0 || !chaptersContent.value.length) return null
return chaptersContent.value[chapterIdx.value] ?? null
})
// ── Chapter metadata ──
const chapters = [
{ slug: 'introduction', title: 'Introduction' },
{ slug: 'de-quel-don-parlons-nous', title: 'De quel don parlons-nous ?' },
{ slug: 'la-mesure-du-don', title: 'La mesure du don' },
{ slug: 'raison-d-etre-d-une-monnaie', title: 'Raison d\'être d\'une monnaie' },
{ slug: 'la-trm', title: 'La TRM' },
{ slug: 'creer-une-economie', title: 'Créer une économie ?' },
{ slug: 'echanger', title: 'Échanger' },
{ slug: 'relation-institutionnelle', title: 'Relation institutionnelle' },
{ slug: 'autres-greffes', title: 'Autres greffes' },
{ slug: 'et-maintenant', title: 'Et maintenant ?… action ?' },
{ slug: 'annexes', title: 'Chapitres annexes' },
]
// ── Per-chapter color hues ──
const chapterHues: [number, number][] = [
[12, 36], // cover (intro + cover phases)
[15, 35], // 1
[350, 15], // 2
[36, 50], // 3
[170, 200], // 4
[220, 250], // 5
[270, 300], // 6
[320, 345], // 7
[150, 170], // 8
[190, 220], // 9
[40, 20], // 10
[210, 240], // 11
]
const sceneVars = computed(() => {
const idx = phase.value === 'reading' ? chapterIdx.value + 1 : 0
const [h1, h2] = chapterHues[idx] ?? chapterHues[0]
return { '--scene-h1': h1, '--scene-h2': h2 } as Record<string, number>
})
const chapterSong = computed(() => {
if (phase.value !== 'reading') return null
return getPrimarySong(chapters[chapterIdx.value].slug)
})
// ── CSS columns pagination ──
const contentStyle = computed(() => ({
columnWidth: colWidth.value + 'px',
columnGap: COL_GAP + 'px',
transform: `translateX(-${currentPage.value * (colWidth.value + COL_GAP)}px)`,
}))
function recalcPages() {
if (!contentEl.value || !viewportEl.value) return
colWidth.value = viewportEl.value.offsetWidth
const sw = contentEl.value.scrollWidth
totalPages.value = Math.max(1, Math.round(sw / (colWidth.value + COL_GAP)))
}
let resizeObs: ResizeObserver | null = null
// Recalc when chapter content changes
watch(activeChapter, async () => {
currentPage.value = 0
// Wait for ContentRenderer to update DOM
await nextTick()
await nextTick()
setTimeout(recalcPages, 100)
})
// ── Phase transitions ──
function onSpinEnd() {
phase.value = 'cover'
}
async function startReading() {
await loadContent()
chapterIdx.value = 0
currentPage.value = 0
phase.value = 'reading'
await nextTick()
await nextTick()
// Set up ResizeObserver
if (viewportEl.value && !resizeObs) {
resizeObs = new ResizeObserver(recalcPages)
resizeObs.observe(viewportEl.value)
}
setTimeout(recalcPages, 200)
}
// ── Navigation ──
function goToChapter(idx: number) {
chapterIdx.value = idx
currentPage.value = 0
showSommaire.value = false
// Play chapter song
const song = getPrimarySong(chapters[idx].slug)
if (song) audioPlayer.loadAndPlay(song)
}
function nextPage() {
if (currentPage.value < totalPages.value - 1) {
triggerTurn()
currentPage.value++
}
else if (chapterIdx.value < chapters.length - 1) {
// Next chapter
goToChapter(chapterIdx.value + 1)
}
}
function prevPage() {
if (currentPage.value > 0) {
triggerTurn()
currentPage.value--
}
else if (chapterIdx.value > 0) {
// Previous chapter, go to last page
chapterIdx.value--
currentPage.value = 0
showSommaire.value = false
const song = getPrimarySong(chapters[chapterIdx.value].slug)
if (song) audioPlayer.loadAndPlay(song)
// After content loads, go to last page
watch(activeChapter, async () => {
await nextTick()
await nextTick()
setTimeout(() => {
recalcPages()
currentPage.value = Math.max(0, totalPages.value - 1)
}, 150)
}, { once: true })
}
}
function triggerTurn() {
isTurning.value = true
setTimeout(() => { isTurning.value = false }, 700)
}
function close() {
isOpen.value = false
}
function handleKeydown(e: KeyboardEvent) {
if (phase.value === 'reading') {
if (e.key === 'ArrowRight') { e.preventDefault(); nextPage() }
else if (e.key === 'ArrowLeft') { e.preventDefault(); prevPage() }
else if (e.key === 'ArrowDown') { e.preventDefault(); if (chapterIdx.value < chapters.length - 1) goToChapter(chapterIdx.value + 1) }
else if (e.key === 'ArrowUp') { e.preventDefault(); if (chapterIdx.value > 0) goToChapter(chapterIdx.value - 1) }
else if (e.key === 'Escape') close()
}
else if (e.key === 'Escape') {
close()
}
}
// ── Touch / swipe ──
let touchStartX = 0
function onTouchStart(e: TouchEvent) {
touchStartX = e.changedTouches[0]?.screenX ?? 0
}
function onTouchEnd(e: TouchEvent) {
const diff = touchStartX - (e.changedTouches[0]?.screenX ?? 0)
if (Math.abs(diff) > 50) {
if (diff > 0) nextPage()
else prevPage()
}
}
// ── Lifecycle ──
watch(isOpen, async (open) => {
if (open) {
phase.value = 'intro'
chapterIdx.value = 0
currentPage.value = 0
showSommaire.value = false
contentLoaded.value = false
await initBookData()
await nextTick()
overlayRef.value?.focus()
overlayRef.value?.addEventListener('touchstart', onTouchStart, { passive: true })
overlayRef.value?.addEventListener('touchend', onTouchEnd, { passive: true })
// Load playlist & play first song
const playlist = getPlaylistOrder()
if (playlist.length) playerStore.setPlaylist(playlist)
const first = getSongs().find(s => s.id === 'chanson-01')
if (first) audioPlayer.loadAndPlay(first)
}
else {
overlayRef.value?.removeEventListener('touchstart', onTouchStart)
overlayRef.value?.removeEventListener('touchend', onTouchEnd)
if (resizeObs) { resizeObs.disconnect(); resizeObs = null }
}
})
watch(isOpen, (open) => {
if (import.meta.client) document.body.style.overflow = open ? 'hidden' : ''
})
onUnmounted(() => {
if (import.meta.client) document.body.style.overflow = ''
if (resizeObs) resizeObs.disconnect()
})
</script>
<style scoped>
/* Animatable hue properties */
@property --scene-h1 {
syntax: '<number>';
inherits: true;
initial-value: 12;
}
@property --scene-h2 {
syntax: '<number>';
inherits: true;
initial-value: 36;
}
/* ═══════════════════════════════════════
BASE OVERLAY
═══════════════════════════════════════ */
.bp {
position: fixed;
inset: 0;
z-index: 60;
background: hsl(20 8% 3%);
display: flex;
flex-direction: column;
align-items: center;
outline: none;
overflow: hidden;
padding-bottom: 4.5rem;
transition: --scene-h1 1.6s ease, --scene-h2 1.6s ease;
}
/* ─── GRADIENT BACKGROUND ─── */
.bp-bg {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
}
.bp-orb {
position: absolute;
border-radius: 50%;
filter: blur(100px);
will-change: transform;
}
.bp-orb--1 {
width: 55vmax; height: 55vmax;
top: -20%; right: -15%;
background: hsl(var(--scene-h1) 65% 40% / 0.18);
animation: orb-1 22s ease-in-out infinite;
}
.bp-orb--2 {
width: 45vmax; height: 45vmax;
bottom: -25%; left: -12%;
background: hsl(var(--scene-h2) 55% 35% / 0.15);
animation: orb-2 28s ease-in-out infinite;
}
.bp-orb--3 {
width: 35vmax; height: 35vmax;
top: 35%; left: 25%;
background: hsl(calc((var(--scene-h1) + var(--scene-h2)) / 2) 45% 30% / 0.1);
animation: orb-3 18s ease-in-out infinite;
}
@keyframes orb-1 {
0%, 100% { transform: translate(0, 0) scale(1); }
33% { transform: translate(-4%, 7%) scale(1.06); }
66% { transform: translate(3%, -4%) scale(0.94); }
}
@keyframes orb-2 {
0%, 100% { transform: translate(0, 0) scale(1); }
33% { transform: translate(7%, -3%) scale(0.95); }
66% { transform: translate(-5%, 5%) scale(1.06); }
}
@keyframes orb-3 {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate(-8%, -6%) scale(1.12); }
}
/* ─── GLOW RING ─── */
.bp-glow {
position: absolute;
top: 50%; left: 50%;
width: min(420px, 65vmin); height: min(420px, 65vmin);
border-radius: 50%;
pointer-events: none;
transform: translate(-50%, -50%);
box-shadow:
0 0 80px 25px hsl(var(--scene-h1) 65% 42% / 0.06),
0 0 160px 50px hsl(var(--scene-h2) 55% 38% / 0.04);
animation: glow-breathe 5s ease-in-out infinite;
}
.bp-glow--active {
box-shadow:
0 0 100px 35px hsl(var(--scene-h1) 70% 45% / 0.14),
0 0 200px 70px hsl(var(--scene-h2) 60% 40% / 0.08);
animation: glow-active 2.5s ease-in-out infinite;
}
@keyframes glow-breathe {
0%, 100% { opacity: 0.5; } 50% { opacity: 1; }
}
@keyframes glow-active {
0%, 100% { opacity: 0.7; transform: translate(-50%, -50%) scale(1); }
50% { opacity: 1; transform: translate(-50%, -50%) scale(1.04); }
}
/* ─── CLOSE ─── */
.bp-close {
position: absolute;
top: 1rem; right: 1rem;
z-index: 75;
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem; height: 2.5rem;
border-radius: 50%;
background: hsl(20 8% 8% / 0.6);
backdrop-filter: blur(12px);
color: hsl(20 8% 55%);
border: 1px solid hsl(20 8% 18% / 0.5);
cursor: pointer;
transition: all 0.3s;
}
.bp-close:hover {
background: hsl(var(--scene-h1) 60% 45% / 0.25);
color: white;
border-color: hsl(var(--scene-h1) 60% 50% / 0.4);
}
/* ─── HINT ─── */
.bp-hint {
position: absolute;
bottom: 5rem;
left: 50%;
transform: translateX(-50%);
z-index: 10;
font-size: 0.68rem;
color: hsl(20 8% 28%);
white-space: nowrap;
}
/* ═══════════════════════════════════════
PHASE CONTAINER
═══════════════════════════════════════ */
.bp-phase {
position: relative;
z-index: 10;
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
flex: 1;
}
/* Phase transitions */
.phase-enter-active { animation: phase-in 0.5s cubic-bezier(0.16, 1, 0.3, 1) both; }
.phase-leave-active { animation: phase-out 0.3s cubic-bezier(0.7, 0, 0.84, 0) both; }
@keyframes phase-in {
from { opacity: 0; transform: scale(0.97); filter: blur(4px); }
to { opacity: 1; transform: scale(1); filter: blur(0); }
}
@keyframes phase-out {
from { opacity: 1; transform: scale(1); filter: blur(0); }
to { opacity: 0; transform: scale(0.97); filter: blur(4px); }
}
/* ═══════════════════════════════════════
INTRO: 3D SPINNING BOOK
═══════════════════════════════════════ */
.bp-intro {
justify-content: center;
}
.spin-scene {
perspective: 1200px;
}
.spin-book {
position: relative;
width: min(220px, 45vw);
aspect-ratio: 3 / 4;
transform-style: preserve-3d;
animation: book-spin 2.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.spin-face {
position: absolute;
inset: 0;
backface-visibility: hidden;
border-radius: 0.5rem;
overflow: hidden;
border: 1px solid hsl(20 8% 18%);
box-shadow: 0 20px 60px hsl(0 0% 0% / 0.5);
}
.spin-front img {
width: 200%;
height: 100%;
object-fit: cover;
transform: translateX(-50%);
}
.spin-back {
transform: rotateY(180deg);
}
.spin-back img {
width: 200%;
height: 100%;
object-fit: cover;
}
@keyframes book-spin {
0% { transform: rotateY(0deg) scale(0.65); opacity: 0; }
8% { opacity: 1; }
45% { transform: rotateY(180deg) scale(0.9); }
75% { transform: rotateY(320deg) scale(1); }
90% { transform: rotateY(352deg) scale(1); }
100% { transform: rotateY(360deg) scale(1); }
}
/* ═══════════════════════════════════════
COVER
═══════════════════════════════════════ */
.bp-cover {
justify-content: center;
text-align: center;
}
.cover-frame {
width: min(200px, 42vw);
aspect-ratio: 3 / 4;
border-radius: 0.625rem;
overflow: hidden;
border: 1px solid hsl(20 8% 18%);
box-shadow:
0 25px 60px hsl(0 0% 0% / 0.5),
0 0 40px hsl(var(--scene-h1) 60% 40% / 0.1);
margin-bottom: 2rem;
animation: cover-float 7s ease-in-out infinite;
}
.cover-img {
width: 200%; height: 100%;
object-fit: cover;
transform: translateX(-50%);
}
@keyframes cover-float {
0%, 100% { transform: translateY(0) rotate(-0.5deg); }
50% { transform: translateY(-10px) rotate(0.5deg); }
}
.cover-title {
font-family: var(--font-display, 'Syne', sans-serif);
font-size: clamp(1.75rem, 5vw, 2.75rem);
font-weight: 800;
line-height: 1.1;
margin-bottom: 0.25rem;
}
.cover-sub {
font-family: var(--font-display, 'Syne', sans-serif);
font-size: clamp(1rem, 3vw, 1.4rem);
color: hsl(20 8% 55%);
margin-bottom: 1.5rem;
}
.cover-desc {
font-size: 0.9rem;
color: hsl(20 8% 45%);
max-width: 26rem;
line-height: 1.65;
margin-bottom: 2rem;
}
.cover-cta {
display: inline-flex;
align-items: center;
padding: 0.75rem 2rem;
border-radius: 9999px;
background: hsl(var(--scene-h1) 70% 45%);
color: white;
font-weight: 600;
font-size: 0.95rem;
border: none;
cursor: pointer;
transition: all 0.35s cubic-bezier(0.16, 1, 0.3, 1);
box-shadow: 0 0 24px hsl(var(--scene-h1) 70% 45% / 0.3);
}
.cover-cta:hover {
background: hsl(var(--scene-h1) 70% 52%);
box-shadow: 0 0 36px hsl(var(--scene-h1) 70% 50% / 0.45);
transform: translateY(-2px);
}
/* ═══════════════════════════════════════
READER
═══════════════════════════════════════ */
.bp-reader {
width: 100%;
max-width: 52rem;
padding: 0 1rem;
gap: 0;
}
/* ─── Top bar ─── */
.reader-bar {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 0;
width: 100%;
}
.reader-bar-btn {
display: flex;
align-items: center;
justify-content: center;
width: 2.25rem; height: 2.25rem;
border-radius: 0.5rem;
background: hsl(20 8% 8% / 0.5);
backdrop-filter: blur(8px);
color: hsl(20 8% 55%);
border: 1px solid hsl(20 8% 16% / 0.5);
cursor: pointer;
transition: all 0.3s;
flex-shrink: 0;
}
.reader-bar-btn:hover {
color: white;
background: hsl(var(--scene-h1) 50% 40% / 0.2);
border-color: hsl(var(--scene-h1) 50% 50% / 0.3);
}
.reader-bar-title {
flex: 1;
font-family: var(--font-display, 'Syne', sans-serif);
font-size: clamp(0.85rem, 2.5vw, 1.1rem);
font-weight: 600;
color: white;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.reader-bar-num {
color: hsl(var(--scene-h1) 65% 55%);
margin-right: 0.375rem;
}
.reader-bar-pages {
font-family: var(--font-mono, monospace);
font-size: 0.75rem;
color: hsl(20 8% 40%);
flex-shrink: 0;
}
/* ─── Sommaire ─── */
.reader-sommaire {
position: absolute;
inset: 0;
z-index: 72;
background: hsl(20 8% 3% / 0.6);
backdrop-filter: blur(4px);
display: flex;
}
.sommaire-panel {
width: min(300px, 80vw);
height: 100%;
background: hsl(20 8% 6% / 0.95);
backdrop-filter: blur(16px);
border-right: 1px solid hsl(20 8% 14%);
padding: 1.5rem;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.sommaire-title {
font-family: var(--font-display, 'Syne', sans-serif);
font-size: 0.85rem;
font-weight: 700;
color: hsl(20 8% 45%);
text-transform: uppercase;
letter-spacing: 0.1em;
margin-bottom: 0.75rem;
}
.sommaire-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.625rem 0.75rem;
border-radius: 0.5rem;
font-size: 0.85rem;
color: hsl(20 8% 55%);
background: transparent;
border: none;
cursor: pointer;
text-align: left;
transition: all 0.25s;
width: 100%;
}
.sommaire-item:hover {
background: hsl(20 8% 12%);
color: white;
}
.sommaire-item--active {
background: hsl(var(--scene-h1) 50% 40% / 0.15);
color: white;
}
.sommaire-num {
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem; height: 1.5rem;
border-radius: 50%;
font-size: 0.65rem;
font-weight: 700;
font-family: var(--font-mono, monospace);
color: hsl(20 8% 40%);
background: hsl(20 8% 12%);
flex-shrink: 0;
}
.sommaire-item--active .sommaire-num {
background: hsl(var(--scene-h1) 65% 42%);
color: white;
}
/* Sommaire slide transition */
.sommaire-enter-active { animation: sommaire-in 0.35s cubic-bezier(0.16, 1, 0.3, 1) both; }
.sommaire-leave-active { animation: sommaire-in 0.25s cubic-bezier(0.7, 0, 0.84, 0) reverse both; }
@keyframes sommaire-in {
from { opacity: 0; }
to { opacity: 1; }
}
.sommaire-enter-active .sommaire-panel { animation: panel-slide 0.35s cubic-bezier(0.16, 1, 0.3, 1) both; }
.sommaire-leave-active .sommaire-panel { animation: panel-slide 0.25s cubic-bezier(0.7, 0, 0.84, 0) reverse both; }
@keyframes panel-slide {
from { transform: translateX(-100%); }
to { transform: translateX(0); }
}
/* ─── Content viewport ─── */
.reader-viewport {
position: relative;
flex: 1;
width: 100%;
overflow: hidden;
border-radius: 0.75rem;
background: hsl(20 8% 5% / 0.4);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid hsl(20 8% 14% / 0.4);
}
.reader-columns {
height: 100%;
column-fill: auto;
padding: 2rem 2.5rem;
transition: transform 0.65s cubic-bezier(0.16, 1, 0.3, 1);
max-width: none;
}
/* Tighten prose for column context */
.reader-columns :deep(h1) {
font-size: clamp(1.5rem, 3.5vw, 2rem);
margin-top: 0;
padding-bottom: 0.5rem;
}
.reader-columns :deep(h2) {
font-size: clamp(1.25rem, 3vw, 1.625rem);
margin-top: 2rem;
}
.reader-columns :deep(h3) {
font-size: clamp(1.065rem, 2.5vw, 1.25rem);
margin-top: 1.75rem;
}
/* Prevent awkward column breaks */
.reader-columns :deep(h2),
.reader-columns :deep(h3) {
break-after: avoid;
}
.reader-columns :deep(p),
.reader-columns :deep(blockquote),
.reader-columns :deep(ul),
.reader-columns :deep(ol) {
break-inside: avoid;
}
/* Page-turn shadow overlay */
.reader-shadow {
position: absolute;
top: 0;
right: 0;
width: 60px;
height: 100%;
pointer-events: none;
opacity: 0;
background: linear-gradient(to left, hsl(20 8% 3% / 0.4), transparent);
transition: opacity 0.15s;
border-radius: 0 0.75rem 0.75rem 0;
}
.reader-shadow.visible {
opacity: 1;
}
/* ─── Bottom nav ─── */
.reader-nav {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0.5rem 0;
gap: 1rem;
}
.reader-nav-btn {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem; height: 2.5rem;
border-radius: 50%;
background: hsl(20 8% 8% / 0.5);
backdrop-filter: blur(8px);
color: hsl(20 8% 55%);
border: 1px solid hsl(20 8% 16% / 0.5);
cursor: pointer;
transition: all 0.3s;
flex-shrink: 0;
}
.reader-nav-btn:hover {
background: hsl(var(--scene-h1) 60% 42% / 0.2);
color: white;
border-color: hsl(var(--scene-h1) 60% 50% / 0.35);
}
.reader-nav-btn--hidden {
opacity: 0;
pointer-events: none;
}
/* Song disc in nav */
.reader-song {
display: flex;
align-items: center;
gap: 0.625rem;
min-width: 0;
}
.reader-disc {
position: relative;
width: 2.25rem; height: 2.25rem;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
border: 2px solid hsl(20 8% 22%);
box-shadow: 0 0 10px hsl(var(--scene-h1) 50% 40% / 0.15);
}
.reader-disc.spinning {
animation: spin-slow 4s linear infinite;
}
.reader-disc-img {
width: 200%; height: 100%;
object-fit: cover;
transform: translateX(-50%);
}
.reader-disc-hole {
position: absolute;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
width: 0.5rem; height: 0.5rem;
border-radius: 50%;
background: hsl(20 8% 5%);
border: 1.5px solid hsl(20 8% 18%);
}
.reader-song-name {
font-size: 0.75rem;
color: hsl(20 8% 50%);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ═══════════════════════════════════════
OVERLAY TRANSITIONS
═══════════════════════════════════════ */
.immersive-enter-active {
animation: bp-enter 0.55s cubic-bezier(0.16, 1, 0.3, 1) both;
}
.immersive-leave-active {
animation: bp-enter 0.35s cubic-bezier(0.7, 0, 0.84, 0) reverse both;
}
@keyframes bp-enter {
from { opacity: 0; transform: scale(1.02); }
to { opacity: 1; transform: scale(1); }
}
/* ─── MOBILE ─── */
@media (max-width: 768px) {
.bp-reader { padding: 0 0.5rem; }
.reader-columns { padding: 1.25rem 1rem; }
.bp-orb { filter: blur(80px); opacity: 0.7; }
.cover-frame { width: min(160px, 40vw); margin-bottom: 1.5rem; }
}
</style>