Skip to content

Commit 431b2fe

Browse files
committed
feat(site): animate the landing page mascot
1 parent 97afb85 commit 431b2fe

2 files changed

Lines changed: 105 additions & 2 deletions

File tree

807 KB
Binary file not shown.

apps/site/src/App.vue

Lines changed: 105 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ const vscodeInstallCommand = 'code --install-extension pythoughts.pythinker-code
6161
6262
const copiedCommand = ref('');
6363
const mobileMenu = ref(null);
64+
const heroVideo = ref(null);
65+
const heroVideoPlaying = ref(false);
6466
const menuButton = ref(null);
6567
const menuOpen = ref(false);
6668
const activeTerminalDemo = ref(terminalDemos[0]);
@@ -70,6 +72,7 @@ let resetTimer;
7072
let menuCloseTimer;
7173
let terminalTimer;
7274
let revealObserver;
75+
let reducedMotionQuery;
7376
let menuOpenedByHover = false;
7477
7578
function fallbackCopy(text) {
@@ -168,11 +171,32 @@ function onDocumentKeydown(event) {
168171
if (event.key === 'Escape') closeMenu();
169172
}
170173
174+
async function playHeroVideo() {
175+
try {
176+
await heroVideo.value?.play();
177+
} catch {
178+
heroVideoPlaying.value = false;
179+
}
180+
}
181+
182+
function syncHeroMotionPreference() {
183+
if (reducedMotionQuery.matches) heroVideo.value?.pause();
184+
else void playHeroVideo();
185+
}
186+
187+
function toggleHeroVideo() {
188+
if (heroVideo.value?.paused) void playHeroVideo();
189+
else heroVideo.value?.pause();
190+
}
191+
171192
onMounted(() => {
172193
document.addEventListener('pointerdown', onDocumentPointerdown);
173194
document.addEventListener('keydown', onDocumentKeydown);
195+
reducedMotionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
196+
reducedMotionQuery.addEventListener('change', syncHeroMotionPreference);
197+
syncHeroMotionPreference();
174198
playTerminalDemo();
175-
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
199+
if (reducedMotionQuery.matches) return;
176200
177201
revealObserver = new IntersectionObserver((entries) => {
178202
for (const entry of entries) {
@@ -190,6 +214,7 @@ onUnmounted(() => {
190214
clearTimeout(menuCloseTimer);
191215
clearTerminalPlayback();
192216
revealObserver?.disconnect();
217+
reducedMotionQuery?.removeEventListener('change', syncHeroMotionPreference);
193218
document.removeEventListener('pointerdown', onDocumentPointerdown);
194219
document.removeEventListener('keydown', onDocumentKeydown);
195220
});
@@ -225,7 +250,34 @@ onUnmounted(() => {
225250
<main id="top">
226251
<header class="hero container">
227252
<div class="hero-copy">
228-
<PythinkerMascot :width="72" :height="90" />
253+
<button
254+
class="hero-mascot-video"
255+
type="button"
256+
:aria-label="heroVideoPlaying ? 'Pause mascot animation' : 'Play mascot animation'"
257+
@click="toggleHeroVideo"
258+
>
259+
<video
260+
ref="heroVideo"
261+
autoplay
262+
muted
263+
loop
264+
playsinline
265+
preload="metadata"
266+
width="1280"
267+
height="720"
268+
aria-hidden="true"
269+
@play="heroVideoPlaying = true"
270+
@pause="heroVideoPlaying = false"
271+
>
272+
<source src="/pythinker-hero-loop.mp4" type="video/mp4" />
273+
</video>
274+
<span class="hero-video-toggle-icon" aria-hidden="true">
275+
<svg viewBox="0 0 20 20">
276+
<path v-if="heroVideoPlaying" d="M5 4h3v12H5zM12 4h3v12h-3z" />
277+
<path v-else d="m6 4 10 6-10 6z" />
278+
</svg>
279+
</span>
280+
</button>
229281
<h1>Pythinker Code</h1>
230282
<p class="hero-accent">Think first, then code.</p>
231283
<p class="hero-lead">An open-source AI engineering agent for your terminal. It reads your repo, edits files, runs commands, and iterates until the job is done.</p>
@@ -722,6 +774,57 @@ onUnmounted(() => {
722774
line-height: 1.38;
723775
}
724776
777+
.hero-mascot-video {
778+
position: relative;
779+
display: block;
780+
width: clamp(160px, 16vw, 200px);
781+
margin-inline: auto;
782+
padding: 0;
783+
border: 0;
784+
appearance: none;
785+
aspect-ratio: 4 / 3;
786+
background: transparent;
787+
color: var(--ink);
788+
cursor: pointer;
789+
}
790+
791+
.hero-mascot-video:focus-visible {
792+
outline: 2px solid var(--accent);
793+
outline-offset: 4px;
794+
border-radius: var(--radius-sm);
795+
}
796+
797+
.hero-mascot-video video {
798+
display: block;
799+
width: 100%;
800+
height: 100%;
801+
object-fit: cover;
802+
-webkit-mask-image: radial-gradient(ellipse 50% 50% at center, black 58%, transparent 100%);
803+
mask-image: radial-gradient(ellipse 50% 50% at center, black 58%, transparent 100%);
804+
mix-blend-mode: multiply;
805+
pointer-events: none;
806+
}
807+
808+
.hero-video-toggle-icon {
809+
position: absolute;
810+
right: 4px;
811+
bottom: 4px;
812+
display: grid;
813+
width: 28px;
814+
height: 28px;
815+
border: 1px solid var(--hairline);
816+
border-radius: 50%;
817+
background: rgba(255, 255, 255, 0.92);
818+
box-shadow: 0 2px 8px rgba(16, 38, 60, 0.12);
819+
place-items: center;
820+
}
821+
822+
.hero-video-toggle-icon svg {
823+
width: 12px;
824+
height: 12px;
825+
fill: currentColor;
826+
}
827+
725828
.works-with {
726829
display: flex;
727830
width: 100%;

0 commit comments

Comments
 (0)