feat(faq): wire Dr. Flask intro video into the phone-frame lightbox
Some checks failed
CI / backend-types (push) Successful in 11s
CI / frontend-build (push) Successful in 17s
CI / agent-tests (push) Failing after 32s
CI / integration (push) Has been skipped

The 85s v2 intro plays click-to-play in the phone mockup with fully custom
controls (play/pause, green seek bar, live timecode, mute, fullscreen) — no
loop, pause on close, Esc/backdrop/X to dismiss. Opens with sound on the cover
click (user gesture); falls back to muted autoplay if the browser blocks it.

- Transcoded the 163 MB / ~15 Mbps export -> 10.8 MB (720x1280, H.264 CRF 28,
  +faststart) so it only downloads when a visitor opts in (preload=metadata).
- Poster = a v2 frame grabbed from the video (drflask-poster.jpg, ~60 KB).
- Source 163 MB master stays untracked in docs/character/.

Verified live via Playwright: video loads (readyState 4, 85s), autoplays on
open, timecode/seek-fill track, play/pause + mute buttons both toggle state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell
2026-06-12 11:37:03 -04:00
parent 907cfcb428
commit 9c9c7a8a97
3 changed files with 277 additions and 6 deletions

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

View File

@@ -1,11 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue' import { ref, computed, nextTick, onMounted, onUnmounted } from 'vue'
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import Icon from '@/components/ds/core/Icon.vue' import Icon from '@/components/ds/core/Icon.vue'
import CorrosionMark from '@/components/brand/CorrosionMark.vue' import CorrosionMark from '@/components/brand/CorrosionMark.vue'
// Dr. Flask, Ph.D. — the chemistry glossary's mascot/cover (placeholder render). // Dr. Flask, Ph.D. — the chemistry glossary's mascot. Cover card (v1 render),
// Web-optimized from docs/character/drflask-final.png. // plus the v2 intro video + a poster frame grabbed from it for the lightbox.
import drFlask from '@/assets/mascots/drflask.png' import drFlask from '@/assets/mascots/drflask.png'
import drFlaskVideo from '@/assets/mascots/drflask-intro.mp4'
import drFlaskPoster from '@/assets/mascots/drflask-poster.jpg'
interface FaqItem { interface FaqItem {
question: string question: string
@@ -222,6 +224,83 @@ function toggle(key: string): void {
openKey.value = openKey.value === key ? null : key openKey.value = openKey.value === key ? null : key
} }
// Dr. Flask intro video — plays in a phone-frame lightbox with custom controls.
const videoOpen = ref<boolean>(false)
const videoEl = ref<HTMLVideoElement | null>(null)
const playing = ref(false)
const isMuted = ref(false)
const curTime = ref(0)
const duration = ref(0)
const progressPct = computed(() =>
duration.value > 0 ? (curTime.value / duration.value) * 100 : 0,
)
function openVideo(): void {
videoOpen.value = true
document.body.style.overflow = 'hidden'
// The cover click is a user gesture, so try to play with sound; if the
// browser's autoplay policy blocks it, fall back to muted playback (always
// allowed) and surface the unmute control.
nextTick(() => {
const v = videoEl.value
if (!v) return
v.currentTime = 0
v.muted = false
isMuted.value = false
v.play().catch(() => {
v.muted = true
isMuted.value = true
v.play().catch(() => {})
})
})
}
function closeVideo(): void {
videoOpen.value = false
document.body.style.overflow = ''
videoEl.value?.pause()
}
function onKeydown(e: KeyboardEvent): void {
if (e.key === 'Escape' && videoOpen.value) closeVideo()
}
function togglePlay(): void {
const v = videoEl.value
if (!v) return
if (v.paused) v.play().catch(() => {})
else v.pause()
}
function toggleMute(): void {
const v = videoEl.value
if (!v) return
v.muted = !v.muted
isMuted.value = v.muted
}
function seek(e: MouseEvent): void {
const v = videoEl.value
if (!v || !duration.value) return
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const pct = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width))
v.currentTime = pct * duration.value
}
function toggleFullscreen(): void {
const v = videoEl.value as (HTMLVideoElement & { webkitEnterFullscreen?: () => void }) | null
if (!v) return
const frame = v.closest('.phone') as (HTMLElement & { requestFullscreen?: () => Promise<void> }) | null
if (document.fullscreenElement) {
void document.exitFullscreen?.()
} else if (frame?.requestFullscreen) {
void frame.requestFullscreen()
} else if (v.webkitEnterFullscreen) {
v.webkitEnterFullscreen() // iOS Safari: only the <video> can go fullscreen
}
}
function fmtTime(s: number): string {
if (!Number.isFinite(s)) return '0:00'
const m = Math.floor(s / 60)
const sec = Math.floor(s % 60)
return `${m}:${sec.toString().padStart(2, '0')}`
}
function itemKey(groupLabel: string, idx: number): string { function itemKey(groupLabel: string, idx: number): string {
return `${groupLabel}-${idx}` return `${groupLabel}-${idx}`
} }
@@ -245,8 +324,12 @@ function initReveal(): void {
document.querySelectorAll('.reveal').forEach((el) => io?.observe(el)) document.querySelectorAll('.reveal').forEach((el) => io?.observe(el))
} }
onMounted(() => { initReveal() }) onMounted(() => { initReveal(); window.addEventListener('keydown', onKeydown) })
onUnmounted(() => { io?.disconnect() }) onUnmounted(() => {
io?.disconnect()
window.removeEventListener('keydown', onKeydown)
document.body.style.overflow = ''
})
</script> </script>
<template> <template>
@@ -322,7 +405,11 @@ onUnmounted(() => { io?.disconnect() })
<section class="sec sec--lab" id="chemistry"> <section class="sec sec--lab" id="chemistry">
<div class="wrap"> <div class="wrap">
<div class="lab-intro reveal"> <div class="lab-intro reveal">
<img :src="drFlask" alt="Dr. Flask, Ph.D. — Chemistry Teacher" class="lab-intro__card" /> <button class="lab-intro__cover" type="button" @click="openVideo" aria-label="Play Dr. Flask intro video">
<img :src="drFlask" alt="Dr. Flask, Ph.D. — Chemistry Teacher" class="lab-intro__card" />
<span class="lab-intro__play"><Icon name="play" :size="24" /></span>
<span class="lab-intro__watch">Watch the intro</span>
</button>
<div class="lab-intro__copy"> <div class="lab-intro__copy">
<span class="eyebrow">Glossary</span> <span class="eyebrow">Glossary</span>
<h2 class="title">Brush up on your chemistry while managing your game server</h2> <h2 class="title">Brush up on your chemistry while managing your game server</h2>
@@ -402,6 +489,48 @@ onUnmounted(() => { io?.disconnect() })
</div> </div>
</div> </div>
</section> </section>
<!-- DR. FLASK INTRO phone-frame lightbox -->
<Teleport to="body">
<div v-if="videoOpen" class="vmodal" @click.self="closeVideo">
<button class="vmodal__close" type="button" @click="closeVideo" aria-label="Close">
<Icon name="x" :size="20" />
</button>
<div class="phone" role="dialog" aria-label="Dr. Flask intro video">
<span class="phone__island" />
<div class="phone__screen">
<video
ref="videoEl"
class="phone__media"
:src="drFlaskVideo"
:poster="drFlaskPoster"
playsinline
preload="metadata"
@click="togglePlay"
@play="playing = true"
@pause="playing = false"
@timeupdate="curTime = videoEl?.currentTime ?? 0"
@loadedmetadata="duration = videoEl?.duration ?? 0"
/>
<div class="phone__controls">
<button class="pc-btn" type="button" @click="togglePlay" :aria-label="playing ? 'Pause' : 'Play'">
<Icon :name="playing ? 'pause' : 'play'" :size="18" />
</button>
<div class="pc-track" @click="seek">
<span class="pc-fill" :style="{ width: progressPct + '%' }" />
</div>
<span class="pc-time">{{ fmtTime(curTime) }} / {{ fmtTime(duration) }}</span>
<button class="pc-btn" type="button" @click="toggleMute" :aria-label="isMuted ? 'Unmute' : 'Mute'">
<Icon :name="isMuted ? 'volume-x' : 'volume-2'" :size="17" />
</button>
<button class="pc-btn" type="button" @click="toggleFullscreen" aria-label="Fullscreen">
<Icon name="maximize" :size="16" />
</button>
</div>
</div>
</div>
</div>
</Teleport>
</template> </template>
<style scoped> <style scoped>
@@ -495,12 +624,56 @@ onUnmounted(() => { io?.disconnect() })
max-width: 920px; max-width: 920px;
margin: 0 auto 40px; margin: 0 auto 40px;
} }
.lab-intro__cover {
position: relative;
display: block;
width: 100%;
padding: 0;
border: 0;
background: none;
cursor: pointer;
border-radius: var(--radius-lg);
}
.lab-intro__card { .lab-intro__card {
display: block;
width: 100%; width: 100%;
height: auto; height: auto;
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
box-shadow: var(--ring-default), 0 18px 40px rgba(0, 0, 0, 0.45); box-shadow: var(--ring-default), 0 18px 40px rgba(0, 0, 0, 0.45);
} }
.lab-intro__play {
position: absolute;
inset: 0;
margin: auto;
width: 58px;
height: 58px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
background: rgba(0, 0, 0, 0.45);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.45), 0 8px 22px rgba(0, 0, 0, 0.45);
transition: transform var(--dur-fast), background var(--dur-fast);
}
.lab-intro__play :deep(svg) { margin-left: 3px; } /* optical-center the play triangle */
.lab-intro__watch {
position: absolute;
left: 0;
right: 0;
bottom: 12px;
text-align: center;
font-size: var(--text-xs);
font-weight: 600;
letter-spacing: 0.02em;
color: #fff;
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.6);
}
.lab-intro__cover:hover .lab-intro__play {
transform: scale(1.08);
background: var(--accent);
color: #06170d;
}
.lab-intro__copy { text-align: left; } .lab-intro__copy { text-align: left; }
.lab-intro__copy .eyebrow { display: block; margin-bottom: 12px; } .lab-intro__copy .eyebrow { display: block; margin-bottom: 12px; }
.lab-intro__copy .title { margin: 0 0 14px; } .lab-intro__copy .title { margin: 0 0 14px; }
@@ -584,8 +757,106 @@ onUnmounted(() => { io?.disconnect() })
} }
.drflask-card__quips strong { color: var(--accent-text); } .drflask-card__quips strong { color: var(--accent-text); }
/* Dr. Flask intro — phone-frame lightbox */
.vmodal {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(6, 8, 10, 0.8);
backdrop-filter: blur(6px);
}
.vmodal__close {
position: absolute;
top: 22px;
right: 22px;
width: 40px;
height: 40px;
border: 0;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
background: rgba(255, 255, 255, 0.08);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16);
cursor: pointer;
transition: background var(--dur-fast);
}
.vmodal__close:hover { background: rgba(255, 255, 255, 0.18); }
.phone {
position: relative;
height: min(78vh, 620px);
aspect-ratio: 9 / 19.5;
padding: 9px;
border-radius: 42px;
background: linear-gradient(155deg, #1c1f24, #0c0d10);
box-shadow: 0 0 0 2px #2b2e34, 0 32px 70px rgba(0, 0, 0, 0.6);
}
.phone__island {
position: absolute;
top: 19px;
left: 50%;
transform: translateX(-50%);
width: 88px;
height: 24px;
border-radius: 14px;
background: #000;
z-index: 2;
}
.phone__screen {
position: relative;
width: 100%;
height: 100%;
border-radius: 33px;
overflow: hidden;
background: #000;
}
.phone__media { width: 100%; height: 100%; object-fit: cover; background: #06070a; cursor: pointer; }
.phone__controls {
position: absolute;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
gap: 11px;
padding: 14px 14px 18px;
color: #fff;
background: linear-gradient(to top, rgba(0, 0, 0, 0.78), transparent);
}
.pc-btn {
display: flex;
align-items: center;
justify-content: center;
flex: none;
padding: 0;
border: 0;
background: none;
color: #fff;
opacity: 0.95;
cursor: pointer;
transition: opacity var(--dur-fast), color var(--dur-fast);
}
.pc-btn:hover { opacity: 1; color: #5bd183; }
.pc-track {
flex: 1;
height: 4px;
border-radius: 3px;
background: rgba(255, 255, 255, 0.3);
position: relative;
cursor: pointer;
}
.pc-fill { position: absolute; left: 0; top: 0; bottom: 0; border-radius: 3px; background: #5bd183; }
.pc-time { font-size: 11px; font-variant-numeric: tabular-nums; opacity: 0.85; flex: none; }
@media (max-width: 720px) { @media (max-width: 720px) {
.lab-intro { grid-template-columns: 1fr; gap: 18px; justify-items: center; text-align: center; } .lab-intro { grid-template-columns: 1fr; gap: 18px; justify-items: center; text-align: center; }
.lab-intro__cover { max-width: 280px; }
.lab-intro__card { max-width: 280px; } .lab-intro__card { max-width: 280px; }
.lab-intro__copy { text-align: center; } .lab-intro__copy { text-align: center; }
.term-grid { grid-template-columns: 1fr; } .term-grid { grid-template-columns: 1fr; }