Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 65 additions & 37 deletions apps/site/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { onMounted, onUnmounted, ref } from 'vue';
import AgentLoop from './components/AgentLoop.vue';
import InstallCommand from './components/InstallCommand.vue';
import LegacyDownloadsPopup from './components/LegacyDownloadsPopup.vue';
import ParticleField from './components/ParticleField.vue';
import PythinkerMascot from './components/PythinkerMascot.vue';

const version = __PYTHINKER_VERSION__;
Expand All @@ -20,6 +21,18 @@ const desktopDownloads = {
windows: `${DESKTOP_RELEASE_BASE}/Pythinker-${DESKTOP_VERSION}-x64-Setup.exe`,
};

const isWindows = /Win/i.test(navigator.platform || navigator.userAgent);
const heroDownloads = (isWindows
? [
{ id: 'windows', label: 'Download for Windows', note: 'Windows x64', icon: '/brand/windows11.svg', href: desktopDownloads.windows },
{ id: 'mac', label: 'Download for macOS', note: 'Apple Silicon', icon: '/brand/apple.svg', href: desktopDownloads.mac },
]
: [
{ id: 'mac', label: 'Download for macOS', note: 'Apple Silicon', icon: '/brand/apple.svg', href: desktopDownloads.mac },
{ id: 'windows', label: 'Download for Windows', note: 'Windows x64', icon: '/brand/windows11.svg', href: desktopDownloads.windows },
]);
const heroDownloadsPage = `https://github.com/PyModel/pythinker-code/releases/tag/v${DESKTOP_VERSION}`;

const installRows = [
['macOS / Linux', 'curl -fsSL https://code.pythinker.com/pythinker-code/install.sh | bash', '/brand/apple.svg'],
['Windows (PowerShell)', 'irm https://code.pythinker.com/pythinker-code/install.ps1 | iex', '/brand/windows11.svg'],
Expand Down Expand Up @@ -211,6 +224,7 @@ onUnmounted(() => {
</script>

<template>
<ParticleField />
<nav class="site-nav" aria-label="Primary navigation">
<div class="container nav-inner">
<a href="/" class="nav-brand"><PythinkerMascot :width="26" :height="32" /><span>Pythinker Code</span></a>
Expand Down Expand Up @@ -245,27 +259,29 @@ onUnmounted(() => {
<PythinkerMascot class="hero-mascot" :width="164" :height="205" />
<h1>Pythinker Code</h1>
<p class="hero-accent">Think first, then code.</p>
<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>
<a
class="hero-npm-badge"
href="https://www.npmjs.com/package/@pymodel/pythinker-code"
target="_blank"
rel="noopener"
>
<img
src="https://img.shields.io/npm/dm/%40pymodel%2Fpythinker-code?style=flat&logo=npm&logoColor=white&color=2b89ff&label=downloads"
alt="npm downloads per month for @pymodel/pythinker-code"
width="161"
height="20"
loading="lazy"
/>
</a>
<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>
<div class="hero-download">
<div class="hero-download-actions">
<a
v-for="(download, index) in heroDownloads"
:key="download.id"
:class="index === 0 ? 'button button-primary' : 'button button-secondary'"
:href="download.href"
rel="noopener"
>
<img :src="download.icon" alt="" aria-hidden="true" class="button-platform-icon" />
{{ download.label }}
</a>
</div>
<p class="hero-download-note">
{{ heroDownloads.map((download) => download.note).join(' · ') }} ·
<a :href="heroDownloadsPage" target="_blank" rel="noopener">All downloads</a>
</p>
</div>
<p class="hero-install-label">Or install the CLI</p>
<div class="hero-install">
<InstallCommand />
</div>
<div class="hero-download-milestone">
<LegacyDownloadsPopup />
</div>
<p class="hero-caption">Free and open source. MIT licensed. macOS, Linux, and Windows.</p>
</div>
</header>
Expand Down Expand Up @@ -531,6 +547,10 @@ onUnmounted(() => {
</div>
</div>
</section>

<div class="container milestone-footnote">
<LegacyDownloadsPopup />
</div>
</main>

<footer class="site-footer">
Expand Down Expand Up @@ -728,40 +748,46 @@ onUnmounted(() => {
line-height: 1.5;
}

.hero-download {
margin-top: 24px;
}

.hero-download-actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
}

.hero-install {
max-width: 720px;
margin: 10px auto 0;
}

.hero-download-milestone {
.milestone-footnote {
display: flex;
margin-top: 14px;
justify-content: center;
margin-block: 48px;
}

.hero-npm-badge {
display: inline-flex;
margin-top: 14px;
border-radius: var(--radius);
opacity: 0.85;
transition: opacity 0.2s ease;
.hero-download-note,
.hero-install-label,
.hero-caption {
color: var(--ink-subtle);
font-size: 13px;
}

.hero-npm-badge:hover {
opacity: 1;
.hero-download-note {
margin-top: 10px;
line-height: 1.38;
}

/* ponytail: width:auto lets the badge grow as the download count does; the width attr only reserves space */
.hero-npm-badge img {
display: block;
width: auto;
height: 20px;
.hero-install-label {
margin-top: 20px;
}

.hero-caption {
margin-top: 12px;
color: var(--ink-subtle);
font-size: 13px;
line-height: 1.38;
}

Expand Down Expand Up @@ -830,11 +856,13 @@ onUnmounted(() => {
height: 16px;
}

.desktop-showcase-actions .button {
.desktop-showcase-actions .button,
.hero-download-actions .button {
gap: 8px;
}

.desktop-showcase-actions .button-primary .button-platform-icon {
.desktop-showcase-actions .button-primary .button-platform-icon,
.hero-download-actions .button-primary .button-platform-icon {
filter: brightness(0) invert(1);
}

Expand Down
158 changes: 158 additions & 0 deletions apps/site/src/components/ParticleField.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
<script setup>
import { nextTick, onMounted, onUnmounted, ref } from 'vue';

const canvas = ref(null);
const enabled = ref(false);

let context;
let animationFrame;
let particles = [];
let viewportWidth = 0;
let viewportHeight = 0;
let mounted = false;

const pointer = { x: Infinity, y: Infinity };
const pointerRadius = 120;

function createParticles() {
const count = Math.max(40, Math.min(90, Math.round((viewportWidth * viewportHeight) / 16000)));
particles = Array.from({ length: count }, () => {
const homeX = Math.random() * viewportWidth;
const homeY = Math.random() * viewportHeight;
return {
homeX,
homeY,
x: homeX,
y: homeY,
velocityX: 0,
velocityY: 0,
driftX: (Math.random() - 0.5) * 0.08,
driftY: (Math.random() - 0.5) * 0.08,
radius: 0.8 + Math.random(),
color: Math.random() < 0.06 ? 'rgba(43, 137, 255, 0.35)' : 'rgba(17, 17, 19, 0.22)',
};
});
}

function resize() {
viewportWidth = window.innerWidth;
viewportHeight = window.innerHeight;
const pixelRatio = Math.min(window.devicePixelRatio || 1, 2);

context.setTransform(1, 0, 0, 1, 0, 0);
canvas.value.width = Math.round(viewportWidth * pixelRatio);
canvas.value.height = Math.round(viewportHeight * pixelRatio);
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
createParticles();
}

function moveParticles() {
for (const particle of particles) {
particle.homeX += particle.driftX;
particle.homeY += particle.driftY;
if (particle.homeX < 0 || particle.homeX > viewportWidth) particle.driftX *= -1;
if (particle.homeY < 0 || particle.homeY > viewportHeight) particle.driftY *= -1;

const deltaX = particle.x - pointer.x;
const deltaY = particle.y - pointer.y;
const distance = Math.hypot(deltaX, deltaY);

if (distance < pointerRadius) {
const force = ((pointerRadius - distance) / pointerRadius) * 0.8;
const safeDistance = Math.max(distance, 0.01);
particle.velocityX += (deltaX / safeDistance) * force;
particle.velocityY += (deltaY / safeDistance) * force;
} else {
particle.velocityX += (particle.homeX - particle.x) * 0.008;
particle.velocityY += (particle.homeY - particle.y) * 0.008;
}

particle.velocityX *= 0.92;
particle.velocityY *= 0.92;
particle.x += particle.velocityX;
particle.y += particle.velocityY;
}
}

function drawParticles() {
context.clearRect(0, 0, viewportWidth, viewportHeight);
moveParticles();

for (const particle of particles) {
context.beginPath();
context.arc(particle.x, particle.y, particle.radius, 0, Math.PI * 2);
context.fillStyle = particle.color;
context.fill();
}
}

function animate() {
drawParticles();
animationFrame = window.requestAnimationFrame(animate);
}

function startAnimation() {
if (animationFrame !== undefined || document.hidden) return;
animationFrame = window.requestAnimationFrame(animate);
}

function stopAnimation() {
if (animationFrame === undefined) return;
window.cancelAnimationFrame(animationFrame);
animationFrame = undefined;
}

function onPointerMove(event) {
pointer.x = event.clientX;
pointer.y = event.clientY;
}

function onVisibilityChange() {
if (document.hidden) stopAnimation();
else startAnimation();
}

onMounted(async () => {
mounted = true;
if (
window.matchMedia('(prefers-reduced-motion: reduce)').matches
|| !window.matchMedia('(pointer: fine)').matches
) return;

enabled.value = true;
await nextTick();
if (!mounted) return;

context = canvas.value?.getContext('2d');
if (!context) return;

resize();
window.addEventListener('pointermove', onPointerMove, { passive: true });
window.addEventListener('resize', resize);
document.addEventListener('visibilitychange', onVisibilityChange);
startAnimation();
});

onUnmounted(() => {
mounted = false;
stopAnimation();
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('resize', resize);
document.removeEventListener('visibilitychange', onVisibilityChange);
});
</script>

<template>
<canvas v-if="enabled" ref="canvas" class="particle-field" aria-hidden="true"></canvas>
</template>

<style scoped>
.particle-field {
position: fixed;
z-index: -1;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
</style>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Loading