Skip to content

Commit f4d5012

Browse files
committed
feat(site): lead the hero with the desktop app and add a cursor-aware particle field
- Remove the shields.io npm downloads badge from the hero. - Offer the desktop download for the visitor's platform directly in the hero, reusing the existing desktopDownloads constants, and demote the CLI install command to a secondary path below it. - Move the legacy Python downloads milestone out of the hero; it competes with the download call to action and advertises the previous product. - Add ParticleField: a canvas of small dots that drift behind the page and move away from the pointer. It sits at z-index -1 inside #app, so it never covers content, and it does not mount at all under prefers-reduced-motion or on coarse pointers.
1 parent 4096505 commit f4d5012

2 files changed

Lines changed: 221 additions & 37 deletions

File tree

apps/site/src/App.vue

Lines changed: 65 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { onMounted, onUnmounted, ref } from 'vue';
33
import AgentLoop from './components/AgentLoop.vue';
44
import InstallCommand from './components/InstallCommand.vue';
55
import LegacyDownloadsPopup from './components/LegacyDownloadsPopup.vue';
6+
import ParticleField from './components/ParticleField.vue';
67
import PythinkerMascot from './components/PythinkerMascot.vue';
78
89
const version = __PYTHINKER_VERSION__;
@@ -20,6 +21,18 @@ const desktopDownloads = {
2021
windows: `${DESKTOP_RELEASE_BASE}/Pythinker-${DESKTOP_VERSION}-x64-Setup.exe`,
2122
};
2223
24+
const isWindows = /Win/i.test(navigator.platform || navigator.userAgent);
25+
const heroDownloads = (isWindows
26+
? [
27+
{ id: 'windows', label: 'Download for Windows', note: 'Windows x64', icon: '/brand/windows11.svg', href: desktopDownloads.windows },
28+
{ id: 'mac', label: 'Download for macOS', note: 'Apple Silicon', icon: '/brand/apple.svg', href: desktopDownloads.mac },
29+
]
30+
: [
31+
{ id: 'mac', label: 'Download for macOS', note: 'Apple Silicon', icon: '/brand/apple.svg', href: desktopDownloads.mac },
32+
{ id: 'windows', label: 'Download for Windows', note: 'Windows x64', icon: '/brand/windows11.svg', href: desktopDownloads.windows },
33+
]);
34+
const heroDownloadsPage = `https://github.com/PyModel/pythinker-code/releases/tag/v${DESKTOP_VERSION}`;
35+
2336
const installRows = [
2437
['macOS / Linux', 'curl -fsSL https://code.pythinker.com/pythinker-code/install.sh | bash', '/brand/apple.svg'],
2538
['Windows (PowerShell)', 'irm https://code.pythinker.com/pythinker-code/install.ps1 | iex', '/brand/windows11.svg'],
@@ -211,6 +224,7 @@ onUnmounted(() => {
211224
</script>
212225
213226
<template>
227+
<ParticleField />
214228
<nav class="site-nav" aria-label="Primary navigation">
215229
<div class="container nav-inner">
216230
<a href="/" class="nav-brand"><PythinkerMascot :width="26" :height="32" /><span>Pythinker Code</span></a>
@@ -245,27 +259,29 @@ onUnmounted(() => {
245259
<PythinkerMascot class="hero-mascot" :width="164" :height="205" />
246260
<h1>Pythinker Code</h1>
247261
<p class="hero-accent">Think first, then code.</p>
248-
<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>
249-
<a
250-
class="hero-npm-badge"
251-
href="https://www.npmjs.com/package/@pymodel/pythinker-code"
252-
target="_blank"
253-
rel="noopener"
254-
>
255-
<img
256-
src="https://img.shields.io/npm/dm/%40pymodel%2Fpythinker-code?style=flat&logo=npm&logoColor=white&color=2b89ff&label=downloads"
257-
alt="npm downloads per month for @pymodel/pythinker-code"
258-
width="161"
259-
height="20"
260-
loading="lazy"
261-
/>
262-
</a>
262+
<p class="hero-lead">Pythinker Code is an open-source AI engineering agent. It reads your repo, edits files, runs commands, and iterates until the job is done &mdash; in a native desktop app, your terminal, or VS Code.</p>
263+
<div class="hero-download">
264+
<div class="hero-download-actions">
265+
<a
266+
v-for="(download, index) in heroDownloads"
267+
:key="download.id"
268+
:class="index === 0 ? 'button button-primary' : 'button button-secondary'"
269+
:href="download.href"
270+
rel="noopener"
271+
>
272+
<img :src="download.icon" alt="" aria-hidden="true" class="button-platform-icon" />
273+
{{ download.label }}
274+
</a>
275+
</div>
276+
<p class="hero-download-note">
277+
{{ heroDownloads.map((download) => download.note).join(' · ') }} ·
278+
<a :href="heroDownloadsPage" target="_blank" rel="noopener">All downloads</a>
279+
</p>
280+
</div>
281+
<p class="hero-install-label">Or install the CLI</p>
263282
<div class="hero-install">
264283
<InstallCommand />
265284
</div>
266-
<div class="hero-download-milestone">
267-
<LegacyDownloadsPopup />
268-
</div>
269285
<p class="hero-caption">Free and open source. MIT licensed. macOS, Linux, and Windows.</p>
270286
</div>
271287
</header>
@@ -531,6 +547,10 @@ onUnmounted(() => {
531547
</div>
532548
</div>
533549
</section>
550+
551+
<div class="container milestone-footnote">
552+
<LegacyDownloadsPopup />
553+
</div>
534554
</main>
535555
536556
<footer class="site-footer">
@@ -728,40 +748,46 @@ onUnmounted(() => {
728748
line-height: 1.5;
729749
}
730750
751+
.hero-download {
752+
margin-top: 24px;
753+
}
754+
755+
.hero-download-actions {
756+
display: flex;
757+
flex-wrap: wrap;
758+
justify-content: center;
759+
gap: 10px;
760+
}
761+
731762
.hero-install {
732763
max-width: 720px;
733764
margin: 10px auto 0;
734765
}
735766
736-
.hero-download-milestone {
767+
.milestone-footnote {
737768
display: flex;
738-
margin-top: 14px;
739769
justify-content: center;
770+
margin-block: 48px;
740771
}
741772
742-
.hero-npm-badge {
743-
display: inline-flex;
744-
margin-top: 14px;
745-
border-radius: var(--radius);
746-
opacity: 0.85;
747-
transition: opacity 0.2s ease;
773+
.hero-download-note,
774+
.hero-install-label,
775+
.hero-caption {
776+
color: var(--ink-subtle);
777+
font-size: 13px;
748778
}
749779
750-
.hero-npm-badge:hover {
751-
opacity: 1;
780+
.hero-download-note {
781+
margin-top: 10px;
782+
line-height: 1.38;
752783
}
753784
754-
/* ponytail: width:auto lets the badge grow as the download count does; the width attr only reserves space */
755-
.hero-npm-badge img {
756-
display: block;
757-
width: auto;
758-
height: 20px;
785+
.hero-install-label {
786+
margin-top: 20px;
759787
}
760788
761789
.hero-caption {
762790
margin-top: 12px;
763-
color: var(--ink-subtle);
764-
font-size: 13px;
765791
line-height: 1.38;
766792
}
767793
@@ -830,11 +856,13 @@ onUnmounted(() => {
830856
height: 16px;
831857
}
832858
833-
.desktop-showcase-actions .button {
859+
.desktop-showcase-actions .button,
860+
.hero-download-actions .button {
834861
gap: 8px;
835862
}
836863
837-
.desktop-showcase-actions .button-primary .button-platform-icon {
864+
.desktop-showcase-actions .button-primary .button-platform-icon,
865+
.hero-download-actions .button-primary .button-platform-icon {
838866
filter: brightness(0) invert(1);
839867
}
840868
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
<script setup>
2+
import { nextTick, onMounted, onUnmounted, ref } from 'vue';
3+
4+
const canvas = ref(null);
5+
const enabled = ref(false);
6+
7+
let context;
8+
let animationFrame;
9+
let particles = [];
10+
let viewportWidth = 0;
11+
let viewportHeight = 0;
12+
let mounted = false;
13+
14+
const pointer = { x: Infinity, y: Infinity };
15+
const pointerRadius = 120;
16+
17+
function createParticles() {
18+
const count = Math.max(40, Math.min(90, Math.round((viewportWidth * viewportHeight) / 16000)));
19+
particles = Array.from({ length: count }, () => {
20+
const homeX = Math.random() * viewportWidth;
21+
const homeY = Math.random() * viewportHeight;
22+
return {
23+
homeX,
24+
homeY,
25+
x: homeX,
26+
y: homeY,
27+
velocityX: 0,
28+
velocityY: 0,
29+
driftX: (Math.random() - 0.5) * 0.08,
30+
driftY: (Math.random() - 0.5) * 0.08,
31+
radius: 0.8 + Math.random(),
32+
color: Math.random() < 0.06 ? 'rgba(43, 137, 255, 0.35)' : 'rgba(17, 17, 19, 0.22)',
33+
};
34+
});
35+
}
36+
37+
function resize() {
38+
viewportWidth = window.innerWidth;
39+
viewportHeight = window.innerHeight;
40+
const pixelRatio = Math.min(window.devicePixelRatio || 1, 2);
41+
42+
context.setTransform(1, 0, 0, 1, 0, 0);
43+
canvas.value.width = Math.round(viewportWidth * pixelRatio);
44+
canvas.value.height = Math.round(viewportHeight * pixelRatio);
45+
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
46+
createParticles();
47+
}
48+
49+
function moveParticles() {
50+
for (const particle of particles) {
51+
particle.homeX += particle.driftX;
52+
particle.homeY += particle.driftY;
53+
if (particle.homeX < 0 || particle.homeX > viewportWidth) particle.driftX *= -1;
54+
if (particle.homeY < 0 || particle.homeY > viewportHeight) particle.driftY *= -1;
55+
56+
const deltaX = particle.x - pointer.x;
57+
const deltaY = particle.y - pointer.y;
58+
const distance = Math.hypot(deltaX, deltaY);
59+
60+
if (distance < pointerRadius) {
61+
const force = ((pointerRadius - distance) / pointerRadius) * 0.8;
62+
const safeDistance = Math.max(distance, 0.01);
63+
particle.velocityX += (deltaX / safeDistance) * force;
64+
particle.velocityY += (deltaY / safeDistance) * force;
65+
} else {
66+
particle.velocityX += (particle.homeX - particle.x) * 0.008;
67+
particle.velocityY += (particle.homeY - particle.y) * 0.008;
68+
}
69+
70+
particle.velocityX *= 0.92;
71+
particle.velocityY *= 0.92;
72+
particle.x += particle.velocityX;
73+
particle.y += particle.velocityY;
74+
}
75+
}
76+
77+
function drawParticles() {
78+
context.clearRect(0, 0, viewportWidth, viewportHeight);
79+
moveParticles();
80+
81+
for (const particle of particles) {
82+
context.beginPath();
83+
context.arc(particle.x, particle.y, particle.radius, 0, Math.PI * 2);
84+
context.fillStyle = particle.color;
85+
context.fill();
86+
}
87+
}
88+
89+
function animate() {
90+
drawParticles();
91+
animationFrame = window.requestAnimationFrame(animate);
92+
}
93+
94+
function startAnimation() {
95+
if (animationFrame !== undefined || document.hidden) return;
96+
animationFrame = window.requestAnimationFrame(animate);
97+
}
98+
99+
function stopAnimation() {
100+
if (animationFrame === undefined) return;
101+
window.cancelAnimationFrame(animationFrame);
102+
animationFrame = undefined;
103+
}
104+
105+
function onPointerMove(event) {
106+
pointer.x = event.clientX;
107+
pointer.y = event.clientY;
108+
}
109+
110+
function onVisibilityChange() {
111+
if (document.hidden) stopAnimation();
112+
else startAnimation();
113+
}
114+
115+
onMounted(async () => {
116+
mounted = true;
117+
if (
118+
window.matchMedia('(prefers-reduced-motion: reduce)').matches
119+
|| !window.matchMedia('(pointer: fine)').matches
120+
) return;
121+
122+
enabled.value = true;
123+
await nextTick();
124+
if (!mounted) return;
125+
126+
context = canvas.value?.getContext('2d');
127+
if (!context) return;
128+
129+
resize();
130+
window.addEventListener('pointermove', onPointerMove, { passive: true });
131+
window.addEventListener('resize', resize);
132+
document.addEventListener('visibilitychange', onVisibilityChange);
133+
startAnimation();
134+
});
135+
136+
onUnmounted(() => {
137+
mounted = false;
138+
stopAnimation();
139+
window.removeEventListener('pointermove', onPointerMove);
140+
window.removeEventListener('resize', resize);
141+
document.removeEventListener('visibilitychange', onVisibilityChange);
142+
});
143+
</script>
144+
145+
<template>
146+
<canvas v-if="enabled" ref="canvas" class="particle-field" aria-hidden="true"></canvas>
147+
</template>
148+
149+
<style scoped>
150+
.particle-field {
151+
position: fixed;
152+
z-index: -1;
153+
inset: 0;
154+
pointer-events: none;
155+
}
156+
</style>

0 commit comments

Comments
 (0)