fix(tests): improve messageId generation for legacy inbox rows

- Enhanced tests to ensure consistent messageId generation for legacy inbox rows lacking a messageId.
- Updated test descriptions for better clarity regarding the new messageId handling.
- Adjusted test expectations to align with the updated behavior of relaying legacy inbox rows with generated messageIds.
This commit is contained in:
iliya 2026-03-23 16:31:37 +02:00
parent 549fce4689
commit e6e89d4ebc
95 changed files with 8449 additions and 0 deletions

52
.github/workflows/landing.yml vendored Normal file
View file

@ -0,0 +1,52 @@
name: Deploy Landing to GitHub Pages
on:
push:
branches: [main]
paths: [landing/**]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies
working-directory: landing
run: npm ci
- name: Generate static site
working-directory: landing
env:
NUXT_APP_BASE_URL: /claude_agent_teams_ui/
run: npx nuxt generate
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v3
with:
path: landing/.output/public
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v4

4
landing/.eslintrc.cjs Normal file
View file

@ -0,0 +1,4 @@
module.exports = {
root: true,
extends: ["@nuxt/eslint-config"],
};

9
landing/.gitignore vendored Normal file
View file

@ -0,0 +1,9 @@
node_modules
.nuxt
.output
.dist
.env
# Large video files
public/video/*.mp4
assets/video/*.mp4

5
landing/.prettierignore Normal file
View file

@ -0,0 +1,5 @@
node_modules
.nuxt
.output
.dist
.env

6
landing/.prettierrc Normal file
View file

@ -0,0 +1,6 @@
{
"singleQuote": true,
"semi": true,
"printWidth": 100,
"trailingComma": "all"
}

20
landing/README.md Normal file
View file

@ -0,0 +1,20 @@
# Claude Agent Teams Landing
## Quick start
```bash
pnpm install
pnpm dev
```
## Build (SSG)
```bash
pnpm generate
pnpm preview
```
## Notes
- Static-first (SSG) by design.
- Locale auto-detection: cookie -> browser settings -> fallback `en`.
- Theme auto-detection: localStorage -> system preference -> fallback `light`.

5
landing/app.vue Normal file
View file

@ -0,0 +1,5 @@
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>

View file

@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="750" viewBox="0 0 1200 750">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#1f2937" />
<stop offset="100%" stop-color="#111827" />
</linearGradient>
</defs>
<rect width="1200" height="750" rx="32" fill="url(#bg)" />
<rect x="80" y="90" width="1040" height="570" rx="24" fill="#0f172a" stroke="#334155" stroke-width="2" />
<rect x="120" y="140" width="320" height="24" rx="12" fill="#6366f1" />
<rect x="120" y="190" width="420" height="16" rx="8" fill="#334155" />
<rect x="120" y="230" width="520" height="16" rx="8" fill="#334155" />
<rect x="120" y="270" width="480" height="16" rx="8" fill="#334155" />
<circle cx="980" cy="190" r="44" fill="#1e293b" stroke="#4b5563" stroke-width="2" />
<text x="120" y="520" font-family="Arial, sans-serif" font-size="32" fill="#e2e8f0">Dark theme</text>
</svg>

After

Width:  |  Height:  |  Size: 946 B

View file

@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="750" viewBox="0 0 1200 750">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#f8fafc" />
<stop offset="100%" stop-color="#e2e8f0" />
</linearGradient>
</defs>
<rect width="1200" height="750" rx="32" fill="url(#bg)" />
<rect x="80" y="90" width="1040" height="570" rx="24" fill="#ffffff" stroke="#cbd5f5" stroke-width="2" />
<rect x="120" y="140" width="320" height="24" rx="12" fill="#6366f1" />
<rect x="120" y="190" width="420" height="16" rx="8" fill="#cbd5e1" />
<rect x="120" y="230" width="520" height="16" rx="8" fill="#cbd5e1" />
<rect x="120" y="270" width="480" height="16" rx="8" fill="#cbd5e1" />
<circle cx="980" cy="190" r="44" fill="#f1f5f9" stroke="#94a3b8" stroke-width="2" />
<text x="120" y="520" font-family="Arial, sans-serif" font-size="32" fill="#1f2937">Light theme</text>
</svg>

After

Width:  |  Height:  |  Size: 947 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View file

@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="750" viewBox="0 0 1200 750">
<rect width="1200" height="750" rx="32" fill="#111827" />
<rect x="80" y="90" width="1040" height="570" rx="24" fill="#0f172a" stroke="#334155" stroke-width="2" />
<rect x="120" y="140" width="420" height="24" rx="12" fill="#ef4444" />
<rect x="120" y="200" width="480" height="16" rx="8" fill="#334155" />
<rect x="120" y="240" width="520" height="16" rx="8" fill="#334155" />
<rect x="120" y="280" width="420" height="16" rx="8" fill="#334155" />
<circle cx="980" cy="260" r="90" fill="#1f2937" stroke="#4b5563" stroke-width="2" />
<circle cx="980" cy="260" r="48" fill="#ef4444" />
<text x="120" y="520" font-family="Arial, sans-serif" font-size="32" fill="#e2e8f0">Recording</text>
</svg>

After

Width:  |  Height:  |  Size: 801 B

View file

@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="750" viewBox="0 0 1200 750">
<rect width="1200" height="750" rx="32" fill="#0f172a" />
<rect x="80" y="90" width="1040" height="570" rx="24" fill="#111827" stroke="#334155" stroke-width="2" />
<rect x="120" y="140" width="420" height="24" rx="12" fill="#22c55e" />
<rect x="120" y="200" width="360" height="16" rx="8" fill="#334155" />
<rect x="120" y="240" width="560" height="16" rx="8" fill="#334155" />
<rect x="120" y="280" width="520" height="16" rx="8" fill="#334155" />
<rect x="760" y="190" width="300" height="260" rx="16" fill="#1f2937" stroke="#4b5563" stroke-width="2" />
<text x="120" y="520" font-family="Arial, sans-serif" font-size="32" fill="#e2e8f0">Settings</text>
</svg>

After

Width:  |  Height:  |  Size: 769 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

BIN
landing/assets/logo-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View file

@ -0,0 +1,76 @@
:root {
color-scheme: light dark;
}
body {
margin: 0;
font-family: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
background: rgb(var(--v-theme-background));
color: rgb(var(--v-theme-on-background));
}
.page {
position: relative;
overflow: clip;
}
.section {
padding: 64px 0;
}
.section-title {
margin-bottom: 24px;
}
.hero {
padding-top: 96px;
}
.anchor-offset {
scroll-margin-top: 96px;
}
/* Monospace accent font for technical elements */
.mono {
font-family: "JetBrains Mono", "Fira Code", monospace;
}
@media (max-width: 960px) {
.page {
padding: 32px 0 64px;
}
.section {
padding: 48px 0;
}
.hero {
padding-top: 72px;
}
.anchor-offset {
scroll-margin-top: 72px;
}
}
@media (max-width: 600px) {
.page {
padding: 24px 0 48px;
}
.section {
padding: 40px 0;
}
.hero {
padding-top: 64px;
}
.anchor-offset {
scroll-margin-top: 64px;
}
}
.app-header {
backdrop-filter: blur(10px);
}

View file

@ -0,0 +1,139 @@
<template>
<div class="page-bg" aria-hidden="true">
<div class="page-bg__grid" />
<div class="page-bg__orb page-bg__orb--1" />
<div class="page-bg__orb page-bg__orb--2" />
<div class="page-bg__orb page-bg__orb--3" />
<div class="page-bg__orb page-bg__orb--4" />
<div class="page-bg__orb page-bg__orb--5" />
<div class="page-bg__orb page-bg__orb--6" />
<div class="page-bg__orb page-bg__orb--7" />
<div class="page-bg__scanline" />
</div>
</template>
<style scoped>
.page-bg {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 0;
overflow: hidden;
}
/* Grid overlay */
.page-bg__grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(0, 240, 255, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 240, 255, 0.03) 1px, transparent 1px);
background-size: 60px 60px;
z-index: 1;
}
/* Scanline effect */
.page-bg__scanline {
position: absolute;
inset: 0;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(0, 240, 255, 0.008) 2px,
rgba(0, 240, 255, 0.008) 4px
);
z-index: 2;
}
.page-bg__orb {
position: absolute;
border-radius: 50%;
filter: blur(140px);
opacity: 0.08;
}
.page-bg__orb--1 {
width: 900px;
height: 900px;
background: #00f0ff;
top: -200px;
right: -150px;
animation: orbDrift1 20s ease-in-out infinite;
}
.page-bg__orb--2 {
width: 700px;
height: 700px;
background: #ff00ff;
top: 300px;
left: -200px;
animation: orbDrift2 25s ease-in-out infinite;
}
.page-bg__orb--3 {
width: 800px;
height: 800px;
background: #39ff14;
top: 1200px;
right: -100px;
opacity: 0.05;
animation: orbDrift1 22s ease-in-out infinite;
}
.page-bg__orb--4 {
width: 700px;
height: 700px;
background: #00f0ff;
top: 2100px;
left: -150px;
opacity: 0.06;
animation: orbDrift2 18s ease-in-out infinite;
}
.page-bg__orb--5 {
width: 750px;
height: 750px;
background: #ff00ff;
top: 2900px;
right: -120px;
opacity: 0.05;
animation: orbDrift1 24s ease-in-out infinite;
}
.page-bg__orb--6 {
width: 700px;
height: 700px;
background: #ffd700;
top: 3600px;
left: -100px;
opacity: 0.04;
animation: orbDrift2 20s ease-in-out infinite;
}
.page-bg__orb--7 {
width: 650px;
height: 650px;
background: #00f0ff;
top: 4300px;
right: -80px;
opacity: 0.05;
animation: orbDrift1 17s ease-in-out infinite;
}
@keyframes orbDrift1 {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(-30px, 20px); }
}
@keyframes orbDrift2 {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(25px, -15px); }
}
@media (prefers-reduced-motion: reduce) {
.page-bg__orb {
animation: none !important;
}
}
</style>

View file

@ -0,0 +1,118 @@
<script setup lang="ts">
interface Props {
flip?: boolean
}
withDefaults(defineProps<Props>(), {
flip: false,
})
</script>
<template>
<div class="section-wave" :class="{ 'section-wave--flip': flip }" aria-hidden="true">
<svg
class="section-wave__svg section-wave__svg--1"
viewBox="0 0 2880 100"
preserveAspectRatio="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
class="section-wave__path"
d="M0,100 L0,60 C360,10 1080,90 1440,60 C1800,10 2520,90 2880,60 L2880,100 Z"
/>
</svg>
<svg
class="section-wave__svg section-wave__svg--2"
viewBox="0 0 2880 100"
preserveAspectRatio="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
class="section-wave__path section-wave__path--2"
d="M0,100 L0,50 C480,85 960,15 1440,50 C1920,85 2400,15 2880,50 L2880,100 Z"
/>
</svg>
</div>
</template>
<style scoped>
.section-wave {
position: relative;
height: 80px;
margin: -24px 0;
pointer-events: none;
z-index: 1;
overflow: hidden;
mask-image: linear-gradient(to bottom, black 0%, black 40%, transparent 100%);
-webkit-mask-image: linear-gradient(to bottom, black 0%, black 40%, transparent 100%);
}
.section-wave--flip {
transform: scaleY(-1);
}
.section-wave__svg {
position: absolute;
bottom: 0;
left: 0;
width: 200%;
height: 100%;
display: block;
will-change: transform;
}
.section-wave__svg--1 {
animation: waveFlow 25s linear infinite;
}
.section-wave__svg--2 {
animation: waveFlow 18s linear infinite reverse;
}
.section-wave__path {
fill: rgba(0, 240, 255, 0.04);
}
.section-wave__path--2 {
fill: rgba(255, 0, 255, 0.025);
}
.v-theme--light .section-wave__path {
fill: rgba(0, 180, 200, 0.05);
}
.v-theme--light .section-wave__path--2 {
fill: rgba(180, 0, 180, 0.03);
}
@keyframes waveFlow {
from {
transform: translateX(0);
}
to {
transform: translateX(-50%);
}
}
@media (prefers-reduced-motion: reduce) {
.section-wave__svg--1,
.section-wave__svg--2 {
animation: none;
}
}
@media (max-width: 960px) {
.section-wave {
height: 60px;
margin: -16px 0;
}
}
@media (max-width: 600px) {
.section-wave {
height: 44px;
margin: -10px 0;
}
}
</style>

View file

@ -0,0 +1,52 @@
<script setup lang="ts">
</script>
<template>
<NuxtLink to="/" class="app-logo">
<img
src="/logo-192.png"
alt="Claude Agent Teams"
class="app-logo__img"
width="36"
height="36"
/>
<span class="app-logo__text">Claude Agent Teams</span>
</NuxtLink>
</template>
<style scoped>
.app-logo {
font-weight: 700;
text-decoration: none;
color: inherit;
display: inline-flex;
align-items: center;
gap: 10px;
line-height: 1;
font-size: 16px;
letter-spacing: 0.02em;
align-self: center;
}
.app-logo__img {
width: 36px;
height: 36px;
border-radius: 10px;
flex-shrink: 0;
object-fit: contain;
}
.app-logo__text {
font-family: "JetBrains Mono", monospace;
font-weight: 700;
font-size: 14px;
letter-spacing: 0.05em;
}
.v-theme--dark .app-logo__text {
background: linear-gradient(135deg, #e0e6ff, #00f0ff);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
</style>

View file

@ -0,0 +1,39 @@
<script setup lang="ts">
import { mdiWeatherSunny, mdiWeatherNight } from '@mdi/js';
const { t } = useI18n();
const { isDark, toggleTheme } = useBrowserTheme();
const { trackThemeToggle } = useAnalytics();
const tooltip = computed(() => isDark.value ? t('theme.light') : t('theme.dark'));
const onToggle = () => {
toggleTheme();
trackThemeToggle(isDark.value ? 'dark' : 'light');
};
</script>
<template>
<ClientOnly>
<v-tooltip :text="tooltip" location="bottom">
<template #activator="{ props }">
<v-btn
v-bind="props"
:icon="isDark ? mdiWeatherSunny : mdiWeatherNight"
variant="text"
size="small"
:aria-label="tooltip"
@click="onToggle"
/>
</template>
</v-tooltip>
<template #fallback>
<v-btn
:icon="mdiWeatherSunny"
variant="text"
size="small"
aria-label="Toggle theme"
/>
</template>
</ClientOnly>
</template>

View file

@ -0,0 +1,73 @@
<script setup lang="ts">
const { t } = useI18n();
const year = new Date().getFullYear();
</script>
<template>
<footer class="app-footer">
<v-container class="app-footer__inner">
<span class="app-footer__copy">{{ t("footer.copyright", { year }) }} · {{ t("footer.tagline") }}</span>
<div class="app-footer__links">
<a class="app-footer__link" href="https://github.com/777genius" target="_blank">Author</a>
<span class="app-footer__divider" />
<a class="app-footer__link" href="https://github.com/777genius/claude_agent_teams_ui" target="_blank">GitHub</a>
</div>
</v-container>
</footer>
</template>
<style scoped>
.app-footer {
border-top: 1px solid rgba(0, 240, 255, 0.08);
padding: 20px 0;
}
.app-footer__inner {
display: flex;
align-items: center;
justify-content: space-between;
}
.app-footer__copy {
font-size: 13px;
opacity: 0.5;
font-family: "JetBrains Mono", monospace;
}
.app-footer__links {
display: flex;
align-items: center;
gap: 12px;
}
.app-footer__link {
color: #00f0ff;
text-decoration: none;
font-size: 13px;
opacity: 0.7;
transition: opacity 0.2s ease;
font-family: "JetBrains Mono", monospace;
}
.app-footer__link:hover {
opacity: 1;
}
.app-footer__divider {
width: 1px;
height: 14px;
background: rgba(0, 240, 255, 0.2);
}
.v-theme--light .app-footer {
border-top-color: rgba(0, 0, 0, 0.08);
}
@media (max-width: 600px) {
.app-footer__inner {
flex-direction: column;
gap: 10px;
text-align: center;
}
}
</style>

View file

@ -0,0 +1,230 @@
<script setup lang="ts">
import { mdiMenu, mdiClose, mdiGithub } from '@mdi/js';
const { t } = useI18n();
const menuOpen = ref(false);
const navItems = computed(() => [
{ id: 'comparison', label: t('nav.comparison') },
{ id: 'download', label: t('nav.download') },
{ id: 'faq', label: t('nav.faq') },
]);
</script>
<template>
<header class="app-header">
<v-container class="app-header__inner">
<AppLogo />
<nav class="app-header__nav">
<v-btn v-for="item in navItems" :key="item.id" variant="text" :href="`#${item.id}`">
{{ item.label }}
</v-btn>
</nav>
<div class="app-header__spacer" />
<div class="app-header__desktop-actions">
<LanguageSwitcher icon-only />
<v-btn
variant="outlined"
size="small"
href="https://github.com/777genius/claude_agent_teams_ui"
target="_blank"
class="app-header__github-btn"
:prepend-icon="mdiGithub"
>
GitHub
</v-btn>
<ThemeToggle />
</div>
<div class="app-header__mobile-actions">
<v-btn :icon="mdiMenu" variant="text" @click="menuOpen = true" />
<Teleport to="body">
<Transition name="mobile-menu-fade">
<div v-if="menuOpen" class="mobile-menu-overlay" @click.self="menuOpen = false">
<div class="mobile-menu">
<div class="mobile-menu__header">
<AppLogo />
<div style="flex: 1" />
<v-btn :icon="mdiClose" variant="text" @click="menuOpen = false" />
</div>
<hr class="mobile-menu__divider" />
<nav class="mobile-menu__list">
<a
v-for="item in navItems"
:key="item.id"
:href="`#${item.id}`"
class="mobile-menu__link"
@click="menuOpen = false"
>
{{ item.label }}
</a>
<a
href="https://github.com/777genius/claude_agent_teams_ui"
target="_blank"
class="mobile-menu__link"
@click="menuOpen = false"
>
GitHub
</a>
</nav>
<hr class="mobile-menu__divider" />
<div class="mobile-menu__actions">
<LanguageSwitcher compact />
<ThemeToggle />
</div>
</div>
</div>
</Transition>
</Teleport>
</div>
</v-container>
</header>
</template>
<style scoped>
.app-header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
height: 64px;
display: flex;
align-items: center;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(0, 240, 255, 0.08);
}
.v-theme--light .app-header {
background: rgba(255, 255, 255, 0.9);
border-bottom-color: rgba(0, 0, 0, 0.06);
}
.v-theme--dark .app-header {
background: rgba(10, 10, 15, 0.9);
}
.app-header__inner {
display: flex;
align-items: center;
flex-wrap: nowrap;
}
.app-header__nav {
display: flex;
align-self: stretch;
align-items: stretch;
margin-left: 48px;
}
.app-header__nav :deep(.v-btn) {
height: 100% !important;
border-radius: 0;
}
.app-header__spacer {
flex: 1;
}
.app-header__desktop-actions {
display: flex;
gap: 8px;
align-items: center;
}
.app-header__github-btn {
border-color: rgba(0, 240, 255, 0.25) !important;
color: #00f0ff !important;
font-weight: 600 !important;
font-size: 12px !important;
letter-spacing: 0.02em !important;
}
.app-header__github-btn:hover {
border-color: rgba(0, 240, 255, 0.5) !important;
background: rgba(0, 240, 255, 0.06) !important;
}
.app-header__mobile-actions {
display: none;
}
@media (max-width: 959px) {
.app-header__nav {
display: none;
}
.app-header__desktop-actions {
display: none;
}
.app-header__mobile-actions {
display: flex;
}
}
.mobile-menu-overlay {
position: fixed;
inset: 0;
z-index: 9999;
background: rgb(var(--v-theme-surface));
}
.mobile-menu {
padding: 16px 16px 24px;
height: 100%;
overflow-y: auto;
}
.mobile-menu__header {
display: flex;
align-items: center;
gap: 12px;
padding-bottom: 12px;
}
.mobile-menu__divider {
border: none;
border-top: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
.mobile-menu__list {
display: flex;
flex-direction: column;
padding: 8px 0;
}
.mobile-menu__link {
display: flex;
align-items: center;
padding: 12px 16px;
font-size: 1rem;
color: rgb(var(--v-theme-on-surface));
text-decoration: none;
border-radius: 8px;
transition: background-color 0.15s;
}
.mobile-menu__link:hover {
background: rgba(var(--v-theme-on-surface), 0.06);
}
.mobile-menu__actions {
display: flex;
flex-direction: row;
gap: 8px;
align-items: center;
justify-content: center;
padding-top: 16px;
}
.mobile-menu-fade-enter-active,
.mobile-menu-fade-leave-active {
transition: opacity 0.2s ease;
}
.mobile-menu-fade-enter-from,
.mobile-menu-fade-leave-to {
opacity: 0;
}
</style>

View file

@ -0,0 +1,156 @@
<script setup lang="ts">
import { supportedLocales } from "~/data/i18n";
import type { LocaleCode } from "~/data/i18n";
import { useLocaleStore } from "~/stores/locale";
const { t, locale } = useI18n();
const nuxtApp = useNuxtApp();
const switchLocalePath = useSwitchLocalePath();
const props = defineProps<{ fullWidth?: boolean; compact?: boolean; iconOnly?: boolean }>();
const localeStore = useLocaleStore();
const flagIconMap: Record<string, string> = {
en: "circle-flags:us",
ru: "circle-flags:ru"
};
const items = computed(() =>
supportedLocales.map((item) => ({
title: item.name,
value: item.code as LocaleCode,
flagIcon: flagIconMap[item.code] ?? "circle-flags:xx"
}))
);
const dropdownItems = computed(() =>
items.value.filter((item) => item.value !== locale.value)
);
const currentFlagIcon = computed(() => {
return flagIconMap[locale.value as string] ?? "circle-flags:xx";
});
const iconMenuOpen = ref(false);
const { trackLanguageSwitch } = useAnalytics();
const onChange = async (value: string | LocaleCode) => {
const nextLocale = value as LocaleCode;
iconMenuOpen.value = false;
trackLanguageSwitch(locale.value as string, nextLocale);
localeStore.setLocale(nextLocale, true);
if (nuxtApp.$i18n?.setLocale) {
await nuxtApp.$i18n.setLocale(nextLocale);
} else {
locale.value = nextLocale;
}
const path = switchLocalePath(nextLocale);
if (path) {
await navigateTo(path);
}
};
</script>
<template>
<!-- Icon-only mode -->
<v-menu v-if="props.iconOnly" v-model="iconMenuOpen" location="bottom end">
<template #activator="{ props: menuProps }">
<v-btn variant="text" v-bind="menuProps" :aria-label="t('language.label')">
<Icon :name="currentFlagIcon" class="language-switcher__flag-icon" />
</v-btn>
</template>
<v-list density="compact" class="language-switcher__menu-list">
<v-list-item
v-for="item in dropdownItems"
:key="item.value"
@click="onChange(item.value)"
>
<template #title>
<span class="language-switcher__item">
<Icon :name="item.flagIcon" class="language-switcher__flag-icon" />
<span>{{ item.title }}</span>
</span>
</template>
</v-list-item>
</v-list>
</v-menu>
<!-- Standard mode with search -->
<v-autocomplete
v-else
:label="props.compact ? undefined : t('language.label')"
:placeholder="props.compact ? t('language.label') : undefined"
:items="dropdownItems"
:model-value="locale"
density="compact"
:variant="props.compact ? 'plain' : 'outlined'"
hide-details
auto-select-first
:menu-props="{ contentClass: 'language-switcher__dropdown' }"
@update:model-value="onChange"
:style="props.fullWidth ? { maxWidth: '100%', width: '100%' } : { maxWidth: '220px' }"
:class="{
'language-switcher--full': props.fullWidth,
'language-switcher--compact': props.compact
}"
:aria-label="t('language.label')"
:single-line="props.compact"
>
<template #selection>
<Icon :name="currentFlagIcon" class="language-switcher__flag-icon" />
</template>
<template #item="{ item, props: itemProps }">
<v-list-item v-bind="itemProps">
<template #title>
<span class="language-switcher__item">
<Icon :name="item.raw.flagIcon" class="language-switcher__flag-icon" />
<span>{{ item.raw.title }}</span>
</span>
</template>
</v-list-item>
</template>
</v-autocomplete>
</template>
<style scoped>
.language-switcher__flag-icon {
width: 22px;
height: 22px;
flex-shrink: 0;
border-radius: 50%;
}
.language-switcher__item {
display: flex;
align-items: center;
gap: 8px;
}
.language-switcher--compact :deep(.v-field) {
min-height: 36px;
}
.language-switcher--compact :deep(.v-field__input) {
padding-top: 6px;
padding-bottom: 6px;
min-height: 36px;
}
.language-switcher--compact {
min-width: 60px;
position: relative;
z-index: 2;
}
.language-switcher--compact :deep(.v-field__outline) {
display: none;
}
.language-switcher--compact :deep(.v-field__overlay) {
background-color: transparent;
}
.language-switcher__menu-list {
min-width: 180px;
}
</style>

View file

@ -0,0 +1,615 @@
<script setup lang="ts">
const { t } = useI18n()
interface CellValue {
status: string
note?: string
}
interface ComparisonRow {
feature: string
us: CellValue
vibeKanban: CellValue
aperant: CellValue
cursor: CellValue
claudeCli: CellValue
}
const rows = computed<ComparisonRow[]>(() => [
{
feature: t('comparison.features.crossTeam'),
us: { status: 'yes' },
vibeKanban: { status: 'no' },
aperant: { status: 'no' },
cursor: { status: 'na' },
claudeCli: { status: 'no' },
},
{
feature: t('comparison.features.agentMessaging'),
us: { status: 'yes', note: 'Native real-time mailbox' },
vibeKanban: { status: 'no', note: 'Agents are independent' },
aperant: { status: 'no', note: 'Fixed pipeline' },
cursor: { status: 'no' },
claudeCli: { status: 'partial', note: 'Built-in (no UI)' },
},
{
feature: t('comparison.features.linkedTasks'),
us: { status: 'yes', note: 'Cross-references in messages' },
vibeKanban: { status: 'partial', note: 'Subtasks only' },
aperant: { status: 'no' },
cursor: { status: 'no' },
claudeCli: { status: 'no' },
},
{
feature: t('comparison.features.sessionAnalysis'),
us: { status: 'yes', note: '6-category token tracking' },
vibeKanban: { status: 'no' },
aperant: { status: 'partial', note: 'Execution logs' },
cursor: { status: 'no' },
claudeCli: { status: 'no' },
},
{
feature: t('comparison.features.taskAttachments'),
us: { status: 'yes', note: 'Auto-attach, agents read & attach' },
vibeKanban: { status: 'no' },
aperant: { status: 'yes', note: 'Images + files' },
cursor: { status: 'partial', note: 'Chat session only' },
claudeCli: { status: 'no' },
},
{
feature: t('comparison.features.hunkReview'),
us: { status: 'yes', note: 'Accept / reject individual hunks' },
vibeKanban: { status: 'no' },
aperant: { status: 'no' },
cursor: { status: 'yes' },
claudeCli: { status: 'no' },
},
{
feature: t('comparison.features.codeEditor'),
us: { status: 'yes', note: 'With Git support' },
vibeKanban: { status: 'no' },
aperant: { status: 'no' },
cursor: { status: 'yes', note: 'Full IDE' },
claudeCli: { status: 'no' },
},
{
feature: t('comparison.features.fullAutonomy'),
us: { status: 'yes', note: 'Create, assign, review end-to-end' },
vibeKanban: { status: 'no', note: 'Human manages tasks' },
aperant: { status: 'no', note: 'Fixed pipeline' },
cursor: { status: 'partial', note: 'Isolated tasks only' },
claudeCli: { status: 'partial', note: 'No UI' },
},
{
feature: t('comparison.features.taskDeps'),
us: { status: 'yes', note: 'Guaranteed ordering' },
vibeKanban: { status: 'no' },
aperant: { status: 'partial', note: 'Within plan only' },
cursor: { status: 'no' },
claudeCli: { status: 'partial', note: 'No UI, no notifications' },
},
{
feature: t('comparison.features.reviewWorkflow'),
us: { status: 'yes', note: 'Agents review each other' },
vibeKanban: { status: 'no' },
aperant: { status: 'partial', note: 'Auto QA pipeline' },
cursor: { status: 'no' },
claudeCli: { status: 'partial', note: 'No UI' },
},
{
feature: t('comparison.features.zeroSetup'),
us: { status: 'yes' },
vibeKanban: { status: 'no', note: 'Config required' },
aperant: { status: 'no', note: 'Config required' },
cursor: { status: 'yes' },
claudeCli: { status: 'partial', note: 'CLI install required' },
},
{
feature: t('comparison.features.kanban'),
us: { status: 'yes', note: '5 columns, real-time' },
vibeKanban: { status: 'yes' },
aperant: { status: 'yes', note: '6 columns (pipeline)' },
cursor: { status: 'no' },
claudeCli: { status: 'no' },
},
{
feature: t('comparison.features.execLog'),
us: { status: 'yes', note: 'Tool calls, reasoning, timeline' },
vibeKanban: { status: 'no' },
aperant: { status: 'yes', note: 'Phase-based logs' },
cursor: { status: 'yes' },
claudeCli: { status: 'no' },
},
{
feature: t('comparison.features.liveProcesses'),
us: { status: 'yes', note: 'View, stop, open URLs' },
vibeKanban: { status: 'no' },
aperant: { status: 'yes', note: '12 agent terminals' },
cursor: { status: 'yes' },
claudeCli: { status: 'no' },
},
{
feature: t('comparison.features.perTaskReview'),
us: { status: 'yes', note: 'Accept / reject / comment' },
vibeKanban: { status: 'partial', note: 'PR-level only' },
aperant: { status: 'partial', note: 'File-level only' },
cursor: { status: 'yes', note: 'BugBot on PRs' },
claudeCli: { status: 'no' },
},
{
feature: t('comparison.features.flexAutonomy'),
us: { status: 'yes', note: 'Granular settings, notifications' },
vibeKanban: { status: 'no' },
aperant: { status: 'partial', note: 'Plan approval only' },
cursor: { status: 'yes' },
claudeCli: { status: 'yes' },
},
{
feature: t('comparison.features.worktree'),
us: { status: 'yes', note: 'Optional' },
vibeKanban: { status: 'partial', note: 'Mandatory' },
aperant: { status: 'partial', note: 'Mandatory' },
cursor: { status: 'yes' },
claudeCli: { status: 'yes' },
},
{
feature: t('comparison.features.multiAgent'),
us: { status: 'soon', note: 'In development' },
vibeKanban: { status: 'yes', note: '6+ agents' },
aperant: { status: 'yes', note: '11 providers' },
cursor: { status: 'yes', note: 'Multi-model' },
claudeCli: { status: 'na' },
},
{
feature: t('comparison.features.price'),
us: { status: 'free' },
vibeKanban: { status: 'text', note: 'Free / $30/mo' },
aperant: { status: 'free' },
cursor: { status: 'text', note: '$0$200/mo' },
claudeCli: { status: 'text', note: 'Claude subscription' },
},
])
const competitors = [
{ key: 'us', name: 'Claude Agent Teams', highlight: true },
{ key: 'vibeKanban', name: 'Vibe Kanban' },
{ key: 'aperant', name: 'Aperant' },
{ key: 'cursor', name: 'Cursor' },
{ key: 'claudeCli', name: 'Claude Code CLI' },
]
function getCellClass(cell: CellValue): string {
switch (cell.status) {
case 'yes': return 'comparison-table__cell--yes'
case 'no': return 'comparison-table__cell--no'
case 'partial': return 'comparison-table__cell--partial'
case 'na': return 'comparison-table__cell--na'
case 'free': return 'comparison-table__cell--free'
case 'soon': return 'comparison-table__cell--soon'
case 'text': return 'comparison-table__cell--text'
default: return 'comparison-table__cell--text'
}
}
function getStatusIcon(status: string): string {
switch (status) {
case 'yes': return '\u2713'
case 'no': return '\u2717'
case 'partial': return '\u25D2'
case 'na': return '\u2014'
case 'free': return 'Free'
case 'soon': return '\uD83D\uDCC5'
default: return ''
}
}
</script>
<template>
<section id="comparison" class="comparison-section section anchor-offset">
<v-container>
<div class="comparison-section__header">
<h2 class="comparison-section__title">
{{ t("comparison.sectionTitle") }}
</h2>
<p class="comparison-section__subtitle">
{{ t("comparison.sectionSubtitle") }}
</p>
</div>
<div class="comparison-table__wrap">
<table class="comparison-table">
<thead>
<tr>
<th class="comparison-table__th comparison-table__th--feature">
{{ t("comparison.feature") }}
</th>
<th
v-for="comp in competitors"
:key="comp.key"
class="comparison-table__th"
:class="{ 'comparison-table__th--highlight': comp.highlight }"
>
{{ comp.name }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(row, index) in rows"
:key="index"
class="comparison-table__row"
>
<td class="comparison-table__td comparison-table__td--feature">
{{ row.feature }}
</td>
<td
v-for="comp in competitors"
:key="comp.key"
class="comparison-table__td"
:class="[
getCellClass(row[comp.key as keyof ComparisonRow] as CellValue),
{ 'comparison-table__td--highlight-col': comp.highlight }
]"
>
<div class="comparison-table__cell-inner">
<span class="comparison-table__cell-content">
<template v-if="(row[comp.key as keyof ComparisonRow] as CellValue).status === 'text'">
{{ (row[comp.key as keyof ComparisonRow] as CellValue).note }}
</template>
<template v-else>
{{ getStatusIcon((row[comp.key as keyof ComparisonRow] as CellValue).status) }}
</template>
</span>
<span
v-if="(row[comp.key as keyof ComparisonRow] as CellValue).note && (row[comp.key as keyof ComparisonRow] as CellValue).status !== 'text'"
class="comparison-table__cell-note"
>
{{ (row[comp.key as keyof ComparisonRow] as CellValue).note }}
</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</v-container>
</section>
</template>
<style scoped>
.comparison-section {
position: relative;
}
.comparison-section__header {
text-align: center;
max-width: 640px;
margin: 0 auto 56px;
position: relative;
z-index: 1;
}
.comparison-section__title {
font-size: 2.4rem;
font-weight: 800;
letter-spacing: -0.03em;
line-height: 1.15;
margin-bottom: 16px;
background: linear-gradient(135deg, #e0e6ff 0%, #00f0ff 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.comparison-section__subtitle {
font-size: 1.1rem;
color: #8892b0;
line-height: 1.6;
margin: 0;
}
/* Table wrapper for horizontal scroll on mobile */
.comparison-table__wrap {
overflow-x: auto;
border-radius: 16px;
border: 1px solid rgba(0, 240, 255, 0.15);
background: rgba(10, 10, 15, 0.6);
backdrop-filter: blur(12px);
position: relative;
z-index: 1;
}
.comparison-table {
width: 100%;
border-collapse: collapse;
min-width: 780px;
font-size: 0.85rem;
}
/* Header */
.comparison-table__th {
padding: 16px 12px;
text-align: center;
font-weight: 600;
font-size: 0.75rem;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #8892b0;
border-bottom: 1px solid rgba(0, 240, 255, 0.1);
white-space: nowrap;
font-family: "JetBrains Mono", monospace;
}
.comparison-table__th--feature {
text-align: left;
padding-left: 20px;
min-width: 180px;
}
.comparison-table__th--highlight {
color: #00f0ff;
background: rgba(0, 240, 255, 0.06);
position: relative;
}
.comparison-table__th--highlight::after {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, #00f0ff, #39ff14);
}
/* Rows */
.comparison-table__row {
transition: background-color 0.15s ease;
}
.comparison-table__row:hover {
background: rgba(0, 240, 255, 0.03);
}
.comparison-table__row:not(:last-child) .comparison-table__td {
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
}
/* Cells */
.comparison-table__td {
padding: 10px 8px;
text-align: center;
vertical-align: middle;
}
.comparison-table__td--feature {
text-align: left;
padding-left: 20px;
color: #e0e6ff;
font-weight: 500;
font-size: 0.85rem;
}
.comparison-table__td--highlight-col {
background: rgba(0, 240, 255, 0.04);
}
.comparison-table__cell-inner {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
}
.comparison-table__cell-content {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 700;
}
.comparison-table__cell-note {
font-size: 0.78rem;
color: #6b7994;
line-height: 1.3;
max-width: 140px;
text-align: center;
white-space: normal;
}
/* Cell status variants */
.comparison-table__cell--yes .comparison-table__cell-content {
color: #39ff14;
background: rgba(57, 255, 20, 0.1);
text-shadow: 0 0 8px rgba(57, 255, 20, 0.4);
}
.comparison-table__cell--no .comparison-table__cell-content {
color: #ff4757;
background: rgba(255, 71, 87, 0.08);
opacity: 0.6;
}
.comparison-table__cell--partial .comparison-table__cell-content {
color: #ffd700;
background: rgba(255, 215, 0, 0.08);
}
.comparison-table__cell--na .comparison-table__cell-content {
color: #4a5568;
background: transparent;
}
.comparison-table__cell--soon .comparison-table__cell-content {
width: auto;
padding: 4px 10px;
font-size: 0.75rem;
color: #00f0ff;
background: rgba(0, 240, 255, 0.08);
font-family: "JetBrains Mono", monospace;
}
.comparison-table__cell--free .comparison-table__cell-content,
.comparison-table__cell--text .comparison-table__cell-content {
width: auto;
padding: 4px 10px;
font-size: 0.75rem;
font-family: "JetBrains Mono", monospace;
letter-spacing: 0.04em;
}
.comparison-table__cell--free .comparison-table__cell-content {
color: #39ff14;
background: rgba(57, 255, 20, 0.1);
text-shadow: 0 0 8px rgba(57, 255, 20, 0.4);
}
.comparison-table__cell--text .comparison-table__cell-content {
color: #8892b0;
background: rgba(255, 255, 255, 0.04);
}
/* Highlight column — our product */
.comparison-table__td--highlight-col.comparison-table__cell--yes .comparison-table__cell-content {
box-shadow: 0 0 12px rgba(57, 255, 20, 0.2);
}
.comparison-table__td--highlight-col.comparison-table__cell--free .comparison-table__cell-content {
box-shadow: 0 0 12px rgba(57, 255, 20, 0.2);
}
/* Light theme */
.v-theme--light .comparison-section__title {
background: linear-gradient(135deg, #1e293b 0%, #0891b2 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.v-theme--light .comparison-section__subtitle {
color: #475569;
}
.v-theme--light .comparison-table__wrap {
background: rgba(255, 255, 255, 0.8);
border-color: rgba(0, 180, 200, 0.2);
}
.v-theme--light .comparison-table__th {
color: #64748b;
border-bottom-color: rgba(0, 0, 0, 0.08);
}
.v-theme--light .comparison-table__th--highlight {
color: #0891b2;
background: rgba(8, 145, 178, 0.06);
}
.v-theme--light .comparison-table__th--highlight::after {
background: linear-gradient(90deg, #0891b2, #059669);
}
.v-theme--light .comparison-table__td--feature {
color: #1e293b;
}
.v-theme--light .comparison-table__row:hover {
background: rgba(8, 145, 178, 0.03);
}
.v-theme--light .comparison-table__row:not(:last-child) .comparison-table__td {
border-bottom-color: rgba(0, 0, 0, 0.05);
}
.v-theme--light .comparison-table__td--highlight-col {
background: rgba(8, 145, 178, 0.04);
}
.v-theme--light .comparison-table__cell-note {
color: #94a3b8;
}
.v-theme--light .comparison-table__cell--yes .comparison-table__cell-content {
color: #059669;
background: rgba(5, 150, 105, 0.1);
text-shadow: none;
}
.v-theme--light .comparison-table__cell--no .comparison-table__cell-content {
color: #dc2626;
background: rgba(220, 38, 38, 0.06);
}
.v-theme--light .comparison-table__cell--partial .comparison-table__cell-content {
color: #d97706;
background: rgba(217, 119, 6, 0.08);
}
.v-theme--light .comparison-table__cell--free .comparison-table__cell-content {
color: #059669;
background: rgba(5, 150, 105, 0.1);
text-shadow: none;
}
.v-theme--light .comparison-table__cell--soon .comparison-table__cell-content {
color: #0891b2;
background: rgba(8, 145, 178, 0.08);
}
.v-theme--light .comparison-table__cell--text .comparison-table__cell-content {
color: #64748b;
background: rgba(0, 0, 0, 0.04);
}
/* Responsive */
@media (max-width: 960px) {
.comparison-section__title {
font-size: 1.85rem;
}
.comparison-section__header {
margin-bottom: 40px;
}
.comparison-section__subtitle {
font-size: 1rem;
}
}
@media (max-width: 600px) {
.comparison-section__title {
font-size: 1.6rem;
}
.comparison-section__header {
margin-bottom: 32px;
}
.comparison-table {
font-size: 0.8rem;
}
.comparison-table__th {
padding: 12px 8px;
font-size: 0.7rem;
}
.comparison-table__td {
padding: 8px 6px;
}
.comparison-table__td--feature {
padding-left: 14px;
font-size: 0.8rem;
}
.comparison-table__cell-note {
font-size: 0.7rem;
max-width: 110px;
}
}
</style>

View file

@ -0,0 +1,496 @@
<script setup lang="ts">
import { mdiApple, mdiMicrosoftWindows, mdiPenguin, mdiDownload, mdiCheckCircle } from '@mdi/js';
import { downloadAssets } from "~/data/downloads";
const { content } = useLandingContent();
const { t, locale } = useI18n();
const downloadStore = useDownloadStore();
const { data: releaseData, resolve } = useReleaseDownloads();
const { trackDownloadClick } = useAnalytics();
onMounted(() => downloadStore.init());
const platformIcons: Record<string, string> = {
macos: mdiApple,
windows: mdiMicrosoftWindows,
linux: mdiPenguin,
};
const platformColors: Record<string, string> = {
macos: "#00f0ff",
windows: "#39ff14",
linux: "#ffd700",
};
const visibleAssets = computed(() =>
downloadAssets.map((asset) => {
if (asset.os !== "macos") return { ...asset };
if (!downloadStore.isMacOs) return { ...asset };
return {
...asset,
archLabel: downloadStore.macArch === "arm64" ? "Apple Silicon" : "Intel",
};
})
);
const getDownloadUrl = (asset: (typeof downloadAssets)[number]) => {
const arch = asset.os === "macos" ? downloadStore.macArch : asset.arch;
return resolve(asset.os, arch)?.url || asset.url;
};
const getDownloadArch = (asset: (typeof downloadAssets)[number]) => {
return asset.os === "macos" ? downloadStore.macArch : asset.arch;
};
const releaseVersion = computed(() => releaseData.value?.version || null);
const releaseDate = computed(() => {
if (!releaseData.value?.pubDate) return '';
return new Date(releaseData.value.pubDate).toLocaleDateString(locale.value, {
year: 'numeric',
month: 'short',
day: 'numeric',
});
});
</script>
<template>
<section id="download" class="download-section section anchor-offset">
<v-container>
<!-- Header -->
<div class="download-section__header">
<h2 class="download-section__title">{{ content.download.title }}</h2>
<p class="download-section__subtitle">{{ content.download.note }}</p>
</div>
<!-- Platform cards -->
<div class="download-section__cards">
<div
v-for="(asset, index) in visibleAssets"
:key="asset.id"
class="download-section__card"
:class="{ 'download-section__card--active': downloadStore.selectedId === asset.id }"
:style="{
'--delay': `${index * 0.1}s`,
'--accent': platformColors[asset.os] || '#00f0ff',
}"
@click="downloadStore.setSelected(asset.id)"
>
<!-- Card glow effect -->
<div class="download-section__card-glow" />
<!-- Platform icon -->
<div class="download-section__card-icon-wrap">
<v-icon size="28" class="download-section__card-icon" :icon="platformIcons[asset.os] || mdiDownload" />
</div>
<!-- Platform info -->
<div class="download-section__card-info">
<h3 class="download-section__card-label">{{ asset.label }}</h3>
<span class="download-section__card-arch">{{ asset.archLabel }}</span>
</div>
<!-- Download button -->
<a
class="download-section__btn"
:href="getDownloadUrl(asset)"
@click.stop="trackDownloadClick({ os: asset.os, arch: getDownloadArch(asset), version: releaseVersion, source: 'download_section' }); downloadStore.setSelected(asset.id)"
>
<v-icon size="18" class="download-section__btn-icon" :icon="mdiDownload" />
<span>{{ t("download.title") }}</span>
</a>
<!-- Active indicator -->
<div
v-if="downloadStore.selectedId === asset.id"
class="download-section__card-indicator"
>
<v-icon size="16" :icon="mdiCheckCircle" />
<span>{{ t("download.detected") }}</span>
</div>
</div>
</div>
<p v-if="releaseVersion" class="download-section__release-info">
v{{ releaseVersion }} · {{ releaseDate }}
</p>
</v-container>
</section>
</template>
<style scoped>
.download-section {
position: relative;
}
/* Header */
.download-section__header {
text-align: center;
max-width: 560px;
margin: 0 auto 56px;
position: relative;
z-index: 1;
}
.download-section__title {
font-size: 2.4rem;
font-weight: 800;
letter-spacing: -0.03em;
line-height: 1.15;
margin-bottom: 16px;
background: linear-gradient(135deg, #e0e6ff 0%, #00f0ff 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.download-section__subtitle {
font-size: 1.1rem;
color: #8892b0;
line-height: 1.6;
margin: 0;
}
/* Cards Grid */
.download-section__cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 18px;
position: relative;
z-index: 1;
max-width: 840px;
margin: 0 auto;
overflow: visible;
padding: 12px 0;
align-items: center;
}
/* Card */
.download-section__card {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 26px 22px 24px;
border-radius: 16px;
background: rgba(10, 10, 15, 0.8);
border: 1px solid rgba(0, 240, 255, 0.08);
backdrop-filter: blur(16px);
cursor: pointer;
transition:
transform 0.35s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.35s cubic-bezier(0.4, 0, 0.2, 1),
border-color 0.35s ease;
overflow: hidden;
animation: downloadFadeUp 0.5s ease both;
animation-delay: var(--delay, 0s);
}
.download-section__card:hover {
transform: translateY(-6px);
border-color: rgba(0, 240, 255, 0.2);
box-shadow:
0 20px 60px rgba(0, 240, 255, 0.08),
0 4px 16px rgba(0, 0, 0, 0.2);
}
.download-section__card--active {
border-color: rgba(57, 255, 20, 0.4);
background: rgba(57, 255, 20, 0.06);
box-shadow:
0 8px 32px rgba(57, 255, 20, 0.1),
0 0 0 2px rgba(57, 255, 20, 0.15);
transform: scale(1.06);
z-index: 2;
}
.download-section__card--active:hover {
transform: scale(1.08);
border-color: rgba(57, 255, 20, 0.5);
box-shadow:
0 20px 60px rgba(57, 255, 20, 0.15),
0 0 0 2px rgba(57, 255, 20, 0.2);
}
/* Card glow */
.download-section__card-glow {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: radial-gradient(
ellipse 80% 60% at 50% 0%,
color-mix(in srgb, var(--accent) 8%, transparent),
transparent 70%
);
pointer-events: none;
opacity: 0;
transition: opacity 0.35s ease;
}
.download-section__card:hover .download-section__card-glow {
opacity: 1;
}
.download-section__card--active .download-section__card-glow {
opacity: 0.7;
background: radial-gradient(
ellipse 80% 60% at 50% 0%,
rgba(57, 255, 20, 0.1),
transparent 70%
);
}
/* Icon wrap */
.download-section__card-icon-wrap {
width: 56px;
height: 56px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(
135deg,
color-mix(in srgb, var(--accent) 12%, transparent),
color-mix(in srgb, var(--accent) 6%, transparent)
);
border: 1px solid color-mix(in srgb, var(--accent) 15%, transparent);
margin-bottom: 14px;
transition: transform 0.35s ease, box-shadow 0.35s ease;
}
.download-section__card:hover .download-section__card-icon-wrap {
transform: scale(1.08);
box-shadow: 0 8px 24px color-mix(in srgb, var(--accent) 15%, transparent);
}
.download-section__card-icon {
color: var(--accent);
}
/* Info */
.download-section__card-info {
margin-bottom: 16px;
}
.download-section__card-label {
font-size: 1.05rem;
font-weight: 700;
margin-bottom: 3px;
letter-spacing: -0.01em;
color: #e0e6ff;
font-family: "JetBrains Mono", monospace;
}
.download-section__card-arch {
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #8892b0;
opacity: 0.7;
}
/* Download button */
.download-section__btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 22px;
border-radius: 10px;
font-size: 0.84rem;
font-weight: 600;
text-decoration: none;
color: #0a0a0f;
background: linear-gradient(135deg, #00f0ff, #39ff14);
transition:
transform 0.25s ease,
box-shadow 0.25s ease,
filter 0.25s ease;
box-shadow: 0 4px 16px rgba(0, 240, 255, 0.3);
font-family: "JetBrains Mono", monospace;
}
.download-section__btn:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0, 240, 255, 0.4);
filter: brightness(1.08);
}
.download-section__btn:active {
transform: translateY(0);
}
.download-section__btn-icon {
color: inherit;
}
/* Active indicator */
.download-section__card-indicator {
display: flex;
align-items: center;
gap: 4px;
margin-top: 10px;
font-size: 0.72rem;
font-weight: 600;
color: #39ff14;
opacity: 0.9;
font-family: "JetBrains Mono", monospace;
}
/* Release info */
.download-section__release-info {
text-align: center;
font-size: 0.78rem;
font-weight: 500;
color: #8892b0;
opacity: 0.5;
margin-top: 24px;
letter-spacing: 0.01em;
position: relative;
z-index: 1;
font-family: "JetBrains Mono", monospace;
}
@keyframes downloadFadeUp {
from {
opacity: 0;
transform: translateY(28px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Light Theme */
.v-theme--light .download-section__title {
background: linear-gradient(135deg, #1e293b 0%, #0891b2 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.v-theme--light .download-section__subtitle {
color: #475569;
}
.v-theme--light .download-section__card {
background: rgba(255, 255, 255, 0.75);
border-color: rgba(0, 0, 0, 0.06);
}
.v-theme--light .download-section__card:hover {
box-shadow: 0 20px 60px rgba(0, 180, 200, 0.1);
}
.v-theme--light .download-section__card--active {
background: rgba(240, 253, 244, 0.9);
border-color: rgba(34, 197, 94, 0.35);
}
.v-theme--light .download-section__card-label {
color: #1e293b;
}
.v-theme--light .download-section__card-arch {
color: #64748b;
}
.v-theme--light .download-section__release-info {
color: #94a3b8;
}
.v-theme--light .download-section__card-indicator {
color: #16a34a;
}
/* Responsive */
@media (max-width: 960px) {
.download-section__cards {
grid-template-columns: 1fr;
max-width: 420px;
margin: 0 auto;
}
.download-section__card {
flex-direction: row;
text-align: left;
padding: 24px 28px;
gap: 20px;
}
.download-section__card--active {
transform: scale(1.03);
order: -1;
}
.download-section__card--active:hover {
transform: scale(1.04);
}
.download-section__card-icon-wrap {
margin-bottom: 0;
width: 60px;
height: 60px;
flex-shrink: 0;
}
.download-section__card-info {
margin-bottom: 0;
flex: 1;
min-width: 0;
}
.download-section__card-indicator {
position: absolute;
top: 12px;
right: 16px;
margin-top: 0;
}
.download-section__title {
font-size: 1.85rem;
}
.download-section__header {
margin-bottom: 40px;
}
.download-section__subtitle {
font-size: 1rem;
}
}
@media (max-width: 600px) {
.download-section__title {
font-size: 1.6rem;
}
.download-section__header {
margin-bottom: 32px;
}
.download-section__card {
padding: 20px 22px;
gap: 16px;
border-radius: 16px;
}
.download-section__card-icon-wrap {
width: 52px;
height: 52px;
border-radius: 14px;
}
.download-section__card-label {
font-size: 1.05rem;
}
.download-section__btn {
padding: 8px 20px;
font-size: 0.85rem;
}
}
</style>

View file

@ -0,0 +1,349 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { mdiRobotOutline, mdiCurrencyUsd, mdiLaptop, mdiCogOutline, mdiShieldLockOutline, mdiRocketLaunchOutline, mdiHelpCircleOutline, mdiFrequentlyAskedQuestions } from '@mdi/js'
const { content } = useLandingContent();
const { t } = useI18n();
const { trackFaqExpand } = useAnalytics();
const openPanels = ref<number[]>([]);
watch(openPanels, (newVal, oldVal) => {
const prev = new Set(oldVal ?? []);
const opened = (newVal ?? []).filter((i) => !prev.has(i));
for (const idx of opened) {
const faq = content.value?.faq?.[idx];
if (faq) trackFaqExpand(faq.id, faq.question);
}
});
const faqIcons = [
mdiRobotOutline,
mdiCurrencyUsd,
mdiLaptop,
mdiCogOutline,
mdiShieldLockOutline,
mdiRocketLaunchOutline,
];
</script>
<template>
<section id="faq" class="faq-section section anchor-offset">
<v-container>
<div class="faq-section__header">
<h2 class="faq-section__title">{{ t('faq.sectionTitle') }}</h2>
<p class="faq-section__subtitle">{{ t('faq.subtitle') }}</p>
</div>
<div class="faq-section__content">
<div class="faq-section__list">
<v-expansion-panels
v-model="openPanels"
multiple
variant="accordion"
class="faq-section__panels"
>
<v-expansion-panel
v-for="(item, index) in content.faq"
:key="item.id"
class="faq-section__panel"
:style="{ '--delay': `${index * 0.08}s` }"
elevation="0"
>
<v-expansion-panel-title class="faq-section__panel-title">
<div class="faq-section__panel-header">
<div class="faq-section__panel-icon-wrap">
<v-icon size="22" class="faq-section__panel-icon" :icon="faqIcons[index] || mdiHelpCircleOutline" />
</div>
<span class="faq-section__panel-question">{{ item.question }}</span>
</div>
</v-expansion-panel-title>
<v-expansion-panel-text class="faq-section__panel-text">
<div class="faq-section__answer" v-html="item.answer" />
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</div>
<div class="faq-section__decoration">
<div class="faq-section__deco-circle">
<v-icon size="40" class="faq-section__deco-icon" :icon="mdiFrequentlyAskedQuestions" />
</div>
<div class="faq-section__deco-ring faq-section__deco-ring--1" />
<div class="faq-section__deco-ring faq-section__deco-ring--2" />
<div class="faq-section__deco-ring faq-section__deco-ring--3" />
<div class="faq-section__deco-dots">
<span v-for="n in 5" :key="n" class="faq-section__deco-dot" :style="{ '--dot-delay': `${n * 0.3}s` }" />
</div>
</div>
</div>
</v-container>
</section>
</template>
<style scoped>
.faq-section {
position: relative;
}
.faq-section__header {
text-align: center;
max-width: 640px;
margin: 0 auto 56px;
position: relative;
z-index: 1;
}
.faq-section__title {
font-size: 2.4rem;
font-weight: 800;
letter-spacing: -0.03em;
line-height: 1.15;
margin-bottom: 16px;
background: linear-gradient(135deg, #e0e6ff 0%, #ffd700 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.faq-section__subtitle {
font-size: 1.1rem;
color: #8892b0;
line-height: 1.6;
margin: 0;
}
.faq-section__content {
display: grid;
grid-template-columns: 1fr 280px;
gap: 56px;
align-items: start;
position: relative;
z-index: 1;
}
.faq-section__list {
min-width: 0;
}
.faq-section__panels {
display: flex;
flex-direction: column;
gap: 12px;
}
.faq-section__panel {
border-radius: 16px !important;
background: rgba(10, 10, 15, 0.8) !important;
border: 1px solid rgba(0, 240, 255, 0.08) !important;
backdrop-filter: blur(12px);
transition: transform 0.3s ease, box-shadow 0.3s ease, border-color 0.3s ease;
overflow: hidden;
animation: faqFadeIn 0.5s ease both;
animation-delay: var(--delay, 0s);
}
.faq-section__panel:hover {
transform: translateY(-2px);
border-color: rgba(0, 240, 255, 0.2) !important;
box-shadow: 0 8px 32px rgba(0, 240, 255, 0.06);
}
.faq-section__panel::after {
display: none;
}
:deep(.faq-section__panel .v-expansion-panel__shadow) {
display: none;
}
.faq-section__panel-title {
padding: 20px 24px !important;
min-height: unset !important;
}
:deep(.faq-section__panel-title .v-expansion-panel-title__overlay) {
opacity: 0 !important;
}
.faq-section__panel-header {
display: flex;
align-items: center;
gap: 16px;
width: 100%;
}
.faq-section__panel-icon-wrap {
flex-shrink: 0;
width: 42px;
height: 42px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, rgba(0, 240, 255, 0.1), rgba(255, 0, 255, 0.08));
border: 1px solid rgba(0, 240, 255, 0.12);
transition: background 0.3s ease;
}
.faq-section__panel:hover .faq-section__panel-icon-wrap {
background: linear-gradient(135deg, rgba(0, 240, 255, 0.16), rgba(255, 0, 255, 0.12));
}
.faq-section__panel-icon {
color: #00f0ff;
}
.faq-section__panel-question {
font-size: 1rem;
font-weight: 600;
line-height: 1.4;
color: #e0e6ff;
}
:deep(.faq-section__panel-text .v-expansion-panel-text__wrapper) {
padding: 0 24px 20px 82px !important;
}
.faq-section__answer {
font-size: 0.95rem;
line-height: 1.7;
color: #8892b0;
}
.faq-section__answer :deep(a) {
color: #00f0ff;
text-decoration: none;
font-weight: 500;
}
.faq-section__answer :deep(a:hover) {
text-decoration: underline;
}
/* Decoration */
.faq-section__decoration {
position: relative;
display: flex;
align-items: center;
justify-content: center;
height: 280px;
align-self: center;
}
.faq-section__deco-circle {
position: relative;
width: 100px;
height: 100px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, rgba(0, 240, 255, 0.1), rgba(255, 0, 255, 0.1));
border: 1px solid rgba(0, 240, 255, 0.2);
z-index: 2;
}
.faq-section__deco-icon {
color: #00f0ff;
}
.faq-section__deco-ring {
position: absolute;
border-radius: 50%;
border: 1px solid rgba(0, 240, 255, 0.1);
animation: faqPulseRing 3.5s ease-in-out infinite;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
.faq-section__deco-ring--1 { width: 148px; height: 148px; animation-delay: 0s; }
.faq-section__deco-ring--2 { width: 200px; height: 200px; animation-delay: 0.7s; }
.faq-section__deco-ring--3 { width: 256px; height: 256px; animation-delay: 1.4s; }
.faq-section__deco-dots {
position: absolute;
inset: 0;
pointer-events: none;
}
.faq-section__deco-dot {
position: absolute;
width: 6px;
height: 6px;
border-radius: 50%;
background: rgba(0, 240, 255, 0.4);
animation: faqDotFloat 4s ease-in-out infinite;
animation-delay: var(--dot-delay, 0s);
}
.faq-section__deco-dot:nth-child(1) { top: 10%; left: 20%; }
.faq-section__deco-dot:nth-child(2) { top: 25%; right: 10%; }
.faq-section__deco-dot:nth-child(3) { bottom: 30%; left: 8%; }
.faq-section__deco-dot:nth-child(4) { bottom: 15%; right: 22%; }
.faq-section__deco-dot:nth-child(5) { top: 50%; right: 2%; }
@keyframes faqFadeIn {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes faqPulseRing {
0%, 100% { opacity: 0.3; transform: translate(-50%, -50%) scale(1); }
50% { opacity: 0.7; transform: translate(-50%, -50%) scale(1.05); }
}
@keyframes faqDotFloat {
0%, 100% { opacity: 0.3; transform: translateY(0); }
50% { opacity: 0.8; transform: translateY(-8px); }
}
/* Light Theme */
.v-theme--light .faq-section__title {
background: linear-gradient(135deg, #1e293b 0%, #d97706 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.v-theme--light .faq-section__subtitle {
color: #475569;
}
.v-theme--light .faq-section__panel {
background: rgba(255, 255, 255, 0.75) !important;
border-color: rgba(0, 0, 0, 0.06) !important;
}
.v-theme--light .faq-section__panel:hover {
box-shadow: 0 8px 32px rgba(0, 180, 200, 0.08);
}
.v-theme--light .faq-section__panel-question {
color: #1e293b;
}
.v-theme--light .faq-section__answer {
color: #475569;
}
@media (max-width: 960px) {
.faq-section__header { margin-bottom: 40px; }
.faq-section__title { font-size: 1.85rem; }
.faq-section__subtitle { font-size: 1rem; }
.faq-section__content { grid-template-columns: 1fr; gap: 40px; }
.faq-section__decoration { display: none; }
}
@media (max-width: 600px) {
.faq-section__header { margin-bottom: 32px; }
.faq-section__title { font-size: 1.6rem; }
.faq-section__panel-title { padding: 16px 18px !important; }
.faq-section__panel-icon-wrap { width: 36px; height: 36px; border-radius: 10px; }
.faq-section__panel-header { gap: 12px; }
.faq-section__panel-question { font-size: 0.92rem; }
:deep(.faq-section__panel-text .v-expansion-panel-text__wrapper) { padding: 0 18px 16px 66px !important; }
.faq-section__answer { font-size: 0.9rem; }
}
</style>

View file

@ -0,0 +1,139 @@
<script setup lang="ts">
import { features } from '~/data/features'
import { useLandingContent } from '~/composables/useLandingContent'
const { content } = useLandingContent();
const { t } = useI18n();
const items = computed(() =>
features
.map((feature) => {
const contentItem = content.value.features.find((item) => item.id === feature.id);
if (!contentItem) return null;
return { ...contentItem, icon: feature.icon, accent: feature.accent };
})
.filter(Boolean)
);
</script>
<template>
<section id="features" class="features-section section anchor-offset">
<v-container>
<div class="features-section__header">
<h2 class="features-section__title">
{{ t("features.sectionTitle") }}
</h2>
<p class="features-section__subtitle">
{{ t("features.sectionSubtitle") }}
</p>
</div>
<v-row justify="center">
<v-col
v-for="(item, index) in items"
:key="item.id"
cols="12"
sm="6"
lg="4"
>
<div
class="features-section__card-wrap"
:style="{ '--delay': `${index * 0.06}s` }"
>
<FeatureCard
:title="item.title"
:description="item.description"
:icon="item.icon"
:accent="item.accent"
/>
</div>
</v-col>
</v-row>
</v-container>
</section>
</template>
<style scoped>
.features-section {
position: relative;
}
.features-section__header {
text-align: center;
max-width: 640px;
margin: 0 auto 56px;
position: relative;
z-index: 1;
}
.features-section__title {
font-size: 2.4rem;
font-weight: 800;
letter-spacing: -0.03em;
line-height: 1.15;
margin-bottom: 16px;
background: linear-gradient(135deg, #e0e6ff 0%, #00f0ff 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.features-section__subtitle {
font-size: 1.1rem;
color: #8892b0;
line-height: 1.6;
margin: 0;
}
.features-section__card-wrap {
animation: fadeInUp 0.5s ease both;
animation-delay: var(--delay, 0s);
height: 100%;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(24px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.v-theme--light .features-section__title {
background: linear-gradient(135deg, #1e293b 0%, #0891b2 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.v-theme--light .features-section__subtitle {
color: #475569;
}
@media (max-width: 960px) {
.features-section__title {
font-size: 1.85rem;
}
.features-section__header {
margin-bottom: 40px;
}
.features-section__subtitle {
font-size: 1rem;
}
}
@media (max-width: 600px) {
.features-section__title {
font-size: 1.6rem;
}
.features-section__header {
margin-bottom: 32px;
}
}
</style>

View file

@ -0,0 +1,350 @@
<script setup lang="ts">
import { mdiRobotOutline, mdiViewDashboardOutline, mdiOpenSourceInitiative } from '@mdi/js';
const { content } = useLandingContent();
const { t } = useI18n();
</script>
<template>
<section id="hero" class="hero-section section anchor-offset">
<v-container class="hero-section__container">
<v-row align="center" justify="space-between">
<!-- Left: Text content -->
<v-col cols="12" md="6" class="hero-section__content">
<h1 class="hero-section__title">
<img
src="/logo-192.png"
alt=""
class="hero-section__logo"
width="56"
height="56"
/>
{{ content.hero.title }}
</h1>
<p class="hero-section__subtitle">
{{ content.hero.subtitle }}
</p>
<div class="hero-section__actions">
<v-btn
variant="flat"
size="large"
href="https://github.com/777genius/claude_agent_teams_ui/releases/latest"
target="_blank"
class="hero-section__btn-primary"
>
{{ t('hero.downloadNow') }}
</v-btn>
<v-btn
variant="outlined"
size="large"
href="#features"
class="hero-section__btn-secondary"
>
{{ t('hero.ctaSecondary') }}
</v-btn>
</div>
<!-- Trust indicators -->
<div class="hero-section__trust">
<div class="hero-section__trust-item">
<v-icon size="16" class="hero-section__trust-icon" :icon="mdiRobotOutline" />
<span>{{ t("hero.trust.agentTeams") }}</span>
</div>
<div class="hero-section__trust-divider" />
<div class="hero-section__trust-item">
<v-icon size="16" class="hero-section__trust-icon" :icon="mdiViewDashboardOutline" />
<span>{{ t("hero.trust.kanban") }}</span>
</div>
<div class="hero-section__trust-divider" />
<div class="hero-section__trust-item">
<v-icon size="16" class="hero-section__trust-icon" :icon="mdiOpenSourceInitiative" />
<span>{{ t("hero.trust.openSource") }}</span>
</div>
</div>
</v-col>
<!-- Right: Demo video -->
<v-col cols="12" md="5" class="hero-section__demo-col">
<div class="hero-section__preview">
<div class="hero-section__preview-glow" />
<ClientOnly>
<HeroDemoVideo />
<template #fallback>
<div class="hero-demo-fallback" />
</template>
</ClientOnly>
</div>
</v-col>
</v-row>
</v-container>
</section>
</template>
<style scoped>
.hero-section {
position: relative;
min-height: 85vh;
display: flex;
align-items: center;
}
.hero-section__container {
position: relative;
z-index: 1;
}
.hero-section__content {
animation: heroFadeIn 0.8s ease both;
}
/* ─── Title ─── */
.hero-section__title {
font-size: 3rem;
font-weight: 800;
letter-spacing: -0.04em;
line-height: 1.1;
margin-bottom: 20px;
background: linear-gradient(135deg, #e0e6ff 0%, #00f0ff 50%, #ff00ff 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
animation: heroFadeIn 0.8s ease both;
animation-delay: 0.2s;
display: flex;
align-items: center;
gap: 16px;
white-space: nowrap;
}
.hero-section__logo {
width: 56px;
height: 56px;
border-radius: 14px;
flex-shrink: 0;
object-fit: contain;
-webkit-text-fill-color: initial;
background: none;
-webkit-background-clip: initial;
background-clip: initial;
}
/* ─── Subtitle ─── */
.hero-section__subtitle {
font-size: 1.2rem;
line-height: 1.7;
color: #8892b0;
opacity: 0.9;
max-width: 480px;
margin-bottom: 36px;
animation: heroFadeIn 0.8s ease both;
animation-delay: 0.3s;
}
/* ─── Actions ─── */
.hero-section__actions {
display: flex;
gap: 14px;
flex-wrap: wrap;
margin-bottom: 40px;
animation: heroFadeIn 0.8s ease both;
animation-delay: 0.4s;
}
.hero-section__btn-primary {
background: linear-gradient(135deg, #00f0ff, #ff00ff) !important;
color: #0a0a0f !important;
font-weight: 700 !important;
letter-spacing: 0.02em !important;
box-shadow: 0 4px 20px rgba(0, 240, 255, 0.3) !important;
transition: all 0.3s ease !important;
}
.hero-section__btn-primary:hover {
box-shadow: 0 6px 30px rgba(0, 240, 255, 0.5) !important;
transform: translateY(-1px) !important;
}
.hero-section__btn-secondary {
border-color: rgba(0, 240, 255, 0.3) !important;
color: #00f0ff !important;
font-weight: 600 !important;
transition: all 0.3s ease !important;
}
.hero-section__btn-secondary:hover {
border-color: rgba(0, 240, 255, 0.5) !important;
background: rgba(0, 240, 255, 0.06) !important;
}
/* ─── Trust indicators ─── */
.hero-section__trust {
display: flex;
align-items: center;
gap: 16px;
animation: heroFadeIn 0.8s ease both;
animation-delay: 0.5s;
}
.hero-section__trust-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.82rem;
font-weight: 500;
color: #8892b0;
}
.hero-section__trust-icon {
color: #00f0ff;
opacity: 0.8;
}
.hero-section__trust-divider {
width: 1px;
height: 16px;
background: rgba(0, 240, 255, 0.2);
}
/* ─── Preview Card ─── */
.hero-section__preview {
position: relative;
width: 100%;
animation: heroSlideUp 0.9s ease both;
animation-delay: 0.3s;
}
.hero-section__preview-glow {
position: absolute;
inset: -2px;
border-radius: 22px;
background: linear-gradient(135deg, rgba(0, 240, 255, 0.2), rgba(255, 0, 255, 0.2), rgba(57, 255, 20, 0.1));
filter: blur(20px);
opacity: 0.4;
z-index: 0;
animation: glowPulse 4s ease-in-out infinite;
}
@keyframes glowPulse {
0%, 100% { opacity: 0.3; transform: scale(1); }
50% { opacity: 0.5; transform: scale(1.02); }
}
/* ─── SSR Fallback ─── */
.hero-demo-fallback {
border-radius: 16px;
background: #0a0a0f;
min-height: 330px;
border: 1px solid rgba(0, 240, 255, 0.1);
}
@media (max-width: 600px) {
.hero-demo-fallback {
min-height: 280px;
}
}
/* ─── Entrance animations ─── */
@keyframes heroFadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes heroSlideUp {
from {
opacity: 0;
transform: translateY(40px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ─── Demo column ─── */
.hero-section__demo-col {
display: flex;
}
@media (max-width: 959px) {
.hero-section__demo-col {
margin-top: 32px;
justify-content: center;
}
}
/* ─── Responsive ─── */
@media (max-width: 960px) {
.hero-section {
min-height: auto;
padding-top: 40px;
}
.hero-section__title {
font-size: 2rem;
white-space: nowrap;
}
.hero-section__logo {
width: 44px;
height: 44px;
border-radius: 12px;
}
.hero-section__subtitle {
font-size: 1.05rem;
}
.hero-section__trust {
flex-wrap: wrap;
gap: 12px;
}
.hero-section__preview {
margin-top: 40px;
}
}
@media (max-width: 600px) {
.hero-section__title {
font-size: 1.6rem;
white-space: nowrap;
gap: 12px;
}
.hero-section__logo {
width: 36px;
height: 36px;
border-radius: 10px;
}
.hero-section__subtitle {
font-size: 0.95rem;
margin-bottom: 28px;
}
.hero-section__actions {
margin-bottom: 28px;
}
.hero-section__trust {
gap: 10px;
}
.hero-section__trust-divider {
display: none;
}
.hero-section__trust-item {
font-size: 0.75rem;
}
}
</style>

View file

@ -0,0 +1,278 @@
<script setup lang="ts">
import { mdiOpenSourceInitiative } from '@mdi/js'
import { useLandingContent } from '~/composables/useLandingContent'
const { content } = useLandingContent()
const { t } = useI18n()
function onGetStarted() {
const el = document.getElementById('download')
if (el) {
el.scrollIntoView({ behavior: 'smooth' })
}
}
</script>
<template>
<section id="pricing" class="pricing-section section anchor-offset">
<v-container>
<div class="pricing-section__header">
<h2 class="pricing-section__title">
{{ t("pricing.sectionTitle") }}
</h2>
<p class="pricing-section__subtitle">
{{ t("pricing.sectionSubtitle") }}
</p>
</div>
<v-row justify="center">
<v-col cols="12" sm="8" lg="5">
<div
v-for="plan in content.pricing"
:key="plan.id"
class="pricing-card pricing-card--highlighted"
>
<div class="pricing-card__popular">
{{ t("pricing.popular") }}
</div>
<div class="pricing-card__header">
<h3 class="pricing-card__name">{{ plan.name }}</h3>
<div class="pricing-card__price-wrap">
<span class="pricing-card__price">{{ plan.price }}</span>
<span class="pricing-card__period">/ {{ plan.period }}</span>
</div>
<p class="pricing-card__description">{{ plan.description }}</p>
</div>
<button
class="pricing-card__btn pricing-card__btn--primary"
@click="onGetStarted()"
>
{{ t("pricing.getStarted") }}
</button>
</div>
</v-col>
</v-row>
<p class="pricing-section__note">
<v-icon size="16" class="pricing-section__note-icon" :icon="mdiOpenSourceInitiative" />
{{ t('pricing.note') }}
</p>
</v-container>
</section>
</template>
<style scoped>
.pricing-section {
position: relative;
}
.pricing-section__header {
text-align: center;
max-width: 640px;
margin: 0 auto 56px;
position: relative;
z-index: 1;
}
.pricing-section__title {
font-size: 2.4rem;
font-weight: 800;
letter-spacing: -0.03em;
line-height: 1.15;
margin-bottom: 16px;
background: linear-gradient(135deg, #e0e6ff 0%, #39ff14 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.pricing-section__subtitle {
font-size: 1.1rem;
color: #8892b0;
line-height: 1.6;
margin: 0;
}
/* Pricing Card */
.pricing-card {
position: relative;
background: rgba(10, 10, 15, 0.8);
border: 1px solid rgba(0, 240, 255, 0.2);
border-radius: 20px;
padding: 36px 28px;
display: flex;
flex-direction: column;
backdrop-filter: blur(12px);
box-shadow: 0 0 40px rgba(0, 240, 255, 0.05);
}
.pricing-card--highlighted {
border-color: rgba(0, 240, 255, 0.3);
background: linear-gradient(
180deg,
rgba(0, 240, 255, 0.06) 0%,
rgba(255, 0, 255, 0.03) 100%
);
}
/* Popular badge */
.pricing-card__popular {
position: absolute;
top: -1px;
right: 24px;
padding: 6px 16px;
border-radius: 0 0 12px 12px;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
background: linear-gradient(135deg, #00f0ff, #39ff14);
color: #0a0a0f;
font-family: "JetBrains Mono", monospace;
}
.pricing-card__header {
margin-bottom: 4px;
}
.pricing-card__name {
font-size: 1.2rem;
font-weight: 700;
margin-bottom: 12px;
color: #e0e6ff;
font-family: "JetBrains Mono", monospace;
}
.pricing-card__price-wrap {
display: flex;
align-items: baseline;
gap: 4px;
margin-bottom: 12px;
}
.pricing-card__price {
font-size: 3rem;
font-weight: 800;
letter-spacing: -0.03em;
line-height: 1;
background: linear-gradient(135deg, #00f0ff, #39ff14);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.pricing-card__period {
font-size: 0.9rem;
opacity: 0.5;
font-weight: 500;
color: #8892b0;
}
.pricing-card__description {
font-size: 0.9rem;
color: #8892b0;
line-height: 1.5;
margin: 0;
}
.pricing-card__btn--primary {
margin-top: 24px;
width: 100%;
padding: 14px 24px;
border-radius: 12px;
font-size: 0.95rem;
font-weight: 700;
cursor: pointer;
transition: all 0.25s ease;
background: linear-gradient(135deg, #00f0ff, #ff00ff);
color: #0a0a0f;
border: none;
box-shadow: 0 4px 20px rgba(0, 240, 255, 0.3);
font-family: "JetBrains Mono", monospace;
letter-spacing: 0.02em;
}
.pricing-card__btn--primary:hover {
box-shadow: 0 6px 30px rgba(0, 240, 255, 0.5);
transform: translateY(-1px);
}
.pricing-section__note {
text-align: center;
margin-top: 32px;
font-size: 0.85rem;
color: #8892b0;
opacity: 0.7;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
position: relative;
z-index: 1;
}
.pricing-section__note-icon {
color: #39ff14;
opacity: 0.7;
}
/* Light theme */
.v-theme--light .pricing-section__title {
background: linear-gradient(135deg, #1e293b 0%, #059669 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.v-theme--light .pricing-section__subtitle {
color: #475569;
}
.v-theme--light .pricing-card {
background: rgba(255, 255, 255, 0.8);
border-color: rgba(0, 180, 200, 0.2);
}
.v-theme--light .pricing-card__name {
color: #1e293b;
}
.v-theme--light .pricing-card__description {
color: #475569;
}
/* Responsive */
@media (max-width: 960px) {
.pricing-section__title {
font-size: 1.85rem;
}
.pricing-section__header {
margin-bottom: 40px;
}
.pricing-section__subtitle {
font-size: 1rem;
}
}
@media (max-width: 600px) {
.pricing-section__title {
font-size: 1.6rem;
}
.pricing-section__header {
margin-bottom: 32px;
}
.pricing-card {
padding: 28px 22px;
}
.pricing-card__price {
font-size: 2.4rem;
}
}
</style>

View file

@ -0,0 +1,499 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue';
import { register } from 'swiper/element/bundle';
import { mdiChevronLeft, mdiChevronRight, mdiClose, mdiArrowExpand } from '@mdi/js';
const { t } = useI18n();
register();
const screenshots = [
{ src: '/screenshots/1.jpg', alt: 'Kanban board with agent tasks' },
{ src: '/screenshots/2.jpg', alt: 'Agent team communication' },
{ src: '/screenshots/3.png', alt: 'Code review diff view' },
{ src: '/screenshots/4.png', alt: 'Team management dashboard' },
{ src: '/screenshots/5.png', alt: 'Live process monitoring' },
{ src: '/screenshots/6.png', alt: 'Session context analysis' },
{ src: '/screenshots/7.png', alt: 'Cross-team messaging' },
{ src: '/screenshots/8.png', alt: 'Task details and comments' },
{ src: '/screenshots/9.png', alt: 'Built-in code editor' },
];
const swiperRef = ref<HTMLElement | null>(null);
const lightboxOpen = ref(false);
const lightboxIndex = ref(0);
function openLightbox(index: number) {
lightboxIndex.value = index;
lightboxOpen.value = true;
document.body.style.overflow = 'hidden';
}
function closeLightbox() {
lightboxOpen.value = false;
document.body.style.overflow = '';
}
function lightboxPrev() {
lightboxIndex.value = (lightboxIndex.value - 1 + screenshots.length) % screenshots.length;
}
function lightboxNext() {
lightboxIndex.value = (lightboxIndex.value + 1) % screenshots.length;
}
function onKeydown(e: KeyboardEvent) {
if (!lightboxOpen.value) return;
if (e.key === 'Escape') closeLightbox();
if (e.key === 'ArrowLeft') lightboxPrev();
if (e.key === 'ArrowRight') lightboxNext();
}
onMounted(() => {
window.addEventListener('keydown', onKeydown);
if (swiperRef.value) {
Object.assign(swiperRef.value, {
slidesPerView: 1.2,
spaceBetween: 16,
centeredSlides: true,
loop: true,
grabCursor: true,
autoplay: {
delay: 4000,
disableOnInteraction: true,
pauseOnMouseEnter: true,
},
pagination: {
clickable: true,
},
breakpoints: {
600: {
slidesPerView: 1.5,
spaceBetween: 20,
},
960: {
slidesPerView: 2.2,
spaceBetween: 24,
},
1264: {
slidesPerView: 2.5,
spaceBetween: 28,
},
},
});
(swiperRef.value as any).initialize();
}
});
onUnmounted(() => {
window.removeEventListener('keydown', onKeydown);
if (lightboxOpen.value) {
document.body.style.overflow = '';
}
});
function slidePrev() {
(swiperRef.value as any)?.swiper?.slidePrev();
}
function slideNext() {
(swiperRef.value as any)?.swiper?.slideNext();
}
</script>
<template>
<section id="screenshots" class="screenshots-section section anchor-offset">
<v-container>
<div class="screenshots-section__header">
<h2 class="screenshots-section__title">
{{ t('screenshots.sectionTitle') }}
</h2>
<p class="screenshots-section__subtitle">
{{ t('screenshots.sectionSubtitle') }}
</p>
</div>
</v-container>
<div class="screenshots-section__carousel-wrap">
<swiper-container
ref="swiperRef"
init="false"
class="screenshots-section__swiper"
>
<swiper-slide
v-for="(shot, idx) in screenshots"
:key="idx"
class="screenshots-section__slide"
>
<div class="screenshots-section__card" @click="openLightbox(idx)">
<img
:src="shot.src"
:alt="shot.alt"
class="screenshots-section__img"
loading="lazy"
/>
<div class="screenshots-section__card-overlay">
<v-icon :icon="mdiArrowExpand" size="24" />
</div>
</div>
</swiper-slide>
</swiper-container>
<!-- Nav buttons -->
<button
class="screenshots-section__nav screenshots-section__nav--prev"
aria-label="Previous"
@click="slidePrev"
>
<v-icon :icon="mdiChevronLeft" size="28" />
</button>
<button
class="screenshots-section__nav screenshots-section__nav--next"
aria-label="Next"
@click="slideNext"
>
<v-icon :icon="mdiChevronRight" size="28" />
</button>
</div>
<!-- Lightbox -->
<Teleport to="body">
<Transition name="lightbox-fade">
<div
v-if="lightboxOpen"
class="screenshots-lightbox"
@click.self="closeLightbox"
>
<button class="screenshots-lightbox__close" @click="closeLightbox">
<v-icon :icon="mdiClose" size="28" />
</button>
<button class="screenshots-lightbox__nav screenshots-lightbox__nav--prev" @click="lightboxPrev">
<v-icon :icon="mdiChevronLeft" size="36" />
</button>
<div class="screenshots-lightbox__content">
<img
:src="screenshots[lightboxIndex].src"
:alt="screenshots[lightboxIndex].alt"
class="screenshots-lightbox__img"
/>
<div class="screenshots-lightbox__counter">
{{ lightboxIndex + 1 }} / {{ screenshots.length }}
</div>
</div>
<button class="screenshots-lightbox__nav screenshots-lightbox__nav--next" @click="lightboxNext">
<v-icon :icon="mdiChevronRight" size="36" />
</button>
</div>
</Transition>
</Teleport>
</section>
</template>
<style scoped>
.screenshots-section {
position: relative;
overflow: visible;
}
.screenshots-section__header {
text-align: center;
max-width: 640px;
margin: 0 auto 48px;
position: relative;
z-index: 1;
}
.screenshots-section__title {
font-size: 2.4rem;
font-weight: 800;
letter-spacing: -0.03em;
line-height: 1.15;
margin-bottom: 16px;
background: linear-gradient(135deg, #e0e6ff 0%, #00f0ff 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.screenshots-section__subtitle {
font-size: 1.1rem;
color: #8892b0;
line-height: 1.6;
margin: 0;
}
/* ─── Carousel ─── */
.screenshots-section__carousel-wrap {
position: relative;
max-width: 1400px;
margin: 0 auto;
padding: 0 60px 40px;
overflow: visible;
}
.screenshots-section__swiper {
overflow: visible;
padding-bottom: 120px;
}
.screenshots-section__slide {
height: auto;
}
.screenshots-section__card {
position: relative;
border-radius: 12px;
overflow: hidden;
border: 1px solid rgba(0, 240, 255, 0.1);
background: rgba(10, 10, 15, 0.6);
cursor: pointer;
transition: transform 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease;
}
.screenshots-section__card:hover {
transform: translateY(-4px);
border-color: rgba(0, 240, 255, 0.3);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4), 0 0 20px rgba(0, 240, 255, 0.08);
}
.screenshots-section__card-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
opacity: 0;
transition: opacity 0.3s ease;
color: #fff;
}
.screenshots-section__card:hover .screenshots-section__card-overlay {
opacity: 1;
}
.screenshots-section__img {
width: 100%;
height: auto;
display: block;
}
/* ─── Nav buttons ─── */
.screenshots-section__nav {
position: absolute;
top: 50%;
transform: translateY(calc(-50% - 20px));
z-index: 10;
width: 44px;
height: 44px;
border-radius: 50%;
border: 1px solid rgba(0, 240, 255, 0.2);
background: rgba(10, 10, 15, 0.85);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
color: #00f0ff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.screenshots-section__nav:hover {
background: rgba(0, 240, 255, 0.12);
border-color: rgba(0, 240, 255, 0.4);
box-shadow: 0 0 16px rgba(0, 240, 255, 0.15);
}
.screenshots-section__nav--prev {
left: 4px;
}
.screenshots-section__nav--next {
right: 4px;
}
/* ─── Swiper pagination ─── */
.screenshots-section__swiper::part(pagination) {
bottom: -20px;
position: relative;
}
.screenshots-section__swiper::part(bullet) {
width: 8px;
height: 8px;
background: rgba(0, 240, 255, 0.3);
opacity: 1;
}
.screenshots-section__swiper::part(bullet-active) {
background: #00f0ff;
width: 24px;
border-radius: 4px;
}
/* ─── Lightbox ─── */
.screenshots-lightbox {
position: fixed;
inset: 0;
z-index: 10000;
background: rgba(0, 0, 0, 0.92);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
padding: 40px 16px;
}
.screenshots-lightbox__close {
position: absolute;
top: 16px;
right: 16px;
z-index: 2;
width: 44px;
height: 44px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.15);
background: rgba(255, 255, 255, 0.08);
color: #fff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s ease;
}
.screenshots-lightbox__close:hover {
background: rgba(255, 255, 255, 0.18);
}
.screenshots-lightbox__nav {
flex-shrink: 0;
width: 48px;
height: 48px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.15);
background: rgba(255, 255, 255, 0.06);
color: #fff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s ease;
}
.screenshots-lightbox__nav:hover {
background: rgba(255, 255, 255, 0.15);
}
.screenshots-lightbox__content {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
max-width: 90vw;
max-height: 85vh;
}
.screenshots-lightbox__img {
max-width: 100%;
max-height: calc(85vh - 40px);
object-fit: contain;
border-radius: 8px;
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.6);
}
.screenshots-lightbox__counter {
font-size: 14px;
color: rgba(255, 255, 255, 0.6);
font-family: "JetBrains Mono", monospace;
letter-spacing: 0.05em;
}
/* ─── Lightbox transition ─── */
.lightbox-fade-enter-active,
.lightbox-fade-leave-active {
transition: opacity 0.25s ease;
}
.lightbox-fade-enter-from,
.lightbox-fade-leave-to {
opacity: 0;
}
/* ─── Light theme ─── */
.v-theme--light .screenshots-section__title {
background: linear-gradient(135deg, #1e293b 0%, #0891b2 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.v-theme--light .screenshots-section__subtitle {
color: #475569;
}
.v-theme--light .screenshots-section__card {
background: rgba(255, 255, 255, 0.8);
border-color: rgba(0, 0, 0, 0.08);
}
.v-theme--light .screenshots-section__card:hover {
border-color: rgba(0, 139, 178, 0.3);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.12);
}
.v-theme--light .screenshots-section__nav {
background: rgba(255, 255, 255, 0.9);
border-color: rgba(0, 0, 0, 0.1);
color: #0891b2;
}
.v-theme--light .screenshots-section__nav:hover {
background: rgba(0, 139, 178, 0.1);
border-color: rgba(0, 139, 178, 0.3);
}
/* ─── Responsive ─── */
@media (max-width: 960px) {
.screenshots-section__title {
font-size: 1.85rem;
}
.screenshots-section__header {
margin-bottom: 40px;
}
.screenshots-section__subtitle {
font-size: 1rem;
}
.screenshots-section__nav {
display: none;
}
}
@media (max-width: 600px) {
.screenshots-section__title {
font-size: 1.6rem;
}
.screenshots-section__header {
margin-bottom: 32px;
}
.screenshots-lightbox__nav {
display: none;
}
.screenshots-lightbox {
padding: 60px 8px 20px;
}
}
</style>

View file

@ -0,0 +1,339 @@
<script setup lang="ts">
import { useDisplay } from 'vuetify'
import { testimonials } from '~/data/testimonials'
import { useLandingContent } from '~/composables/useLandingContent'
const { content } = useLandingContent();
const { t } = useI18n();
const { smAndUp } = useDisplay();
const expanded = ref(false);
const items = computed(() =>
testimonials
.map((entry) => {
const contentItem = content.value.testimonials?.find((item) => item.id === entry.id);
if (!contentItem) return null;
return { ...contentItem, avatar: entry.avatar };
})
.filter(Boolean)
);
const visibleItems = computed(() => {
if (expanded.value) return items.value;
return items.value.slice(0, smAndUp.value ? 4 : 2);
});
const hasMore = computed(() =>
!expanded.value && items.value.length > (smAndUp.value ? 4 : 2)
);
const getInitial = (name: string) => name.charAt(0).toUpperCase();
</script>
<template>
<section id="testimonials" class="testimonials-section section anchor-offset">
<v-container>
<div class="testimonials-section__header">
<h2 class="testimonials-section__title">
{{ t("testimonials.sectionTitle") }}
</h2>
<p class="testimonials-section__subtitle">
{{ t("testimonials.sectionSubtitle") }}
</p>
</div>
<v-row justify="center">
<v-col
v-for="(item, index) in visibleItems"
:key="item.id"
cols="12"
sm="6"
>
<div
class="testimonials-section__card-wrap"
:style="{ '--delay': `${index * 0.08}s` }"
>
<div class="testimonial-card">
<div class="testimonial-card__quote">"</div>
<p class="testimonial-card__text">{{ item.text }}</p>
<div class="testimonial-card__author">
<div
class="testimonial-card__avatar"
:style="{ background: item.avatar }"
>
{{ getInitial(item.name) }}
</div>
<div class="testimonial-card__info">
<span class="testimonial-card__name">{{ item.name }}</span>
<span class="testimonial-card__role">{{ item.role }}</span>
</div>
</div>
</div>
</div>
</v-col>
</v-row>
<div v-if="hasMore || expanded" class="testimonials-section__toggle">
<button
class="testimonials-section__toggle-btn"
@click="expanded = !expanded"
>
{{ expanded ? t('testimonials.showLess') : t('testimonials.showMore') }}
</button>
</div>
<p class="testimonials-section__feedback-cta">
{{ t('testimonials.feedbackCta') }}
<a href="https://github.com/777genius/claude_agent_teams_ui/issues" target="_blank" class="testimonials-section__email">GitHub</a>
</p>
</v-container>
</section>
</template>
<style scoped>
.testimonials-section {
position: relative;
}
.testimonials-section__header {
text-align: center;
max-width: 640px;
margin: 0 auto 56px;
position: relative;
z-index: 1;
}
.testimonials-section__title {
font-size: 2.4rem;
font-weight: 800;
letter-spacing: -0.03em;
line-height: 1.15;
margin-bottom: 16px;
background: linear-gradient(135deg, #e0e6ff 0%, #ff00ff 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.testimonials-section__subtitle {
font-size: 1.1rem;
color: #8892b0;
line-height: 1.6;
margin: 0;
}
.testimonials-section__card-wrap {
animation: fadeInUp 0.5s ease both;
animation-delay: var(--delay, 0s);
height: 100%;
}
.testimonial-card {
position: relative;
padding: 32px 28px 28px;
border-radius: 20px;
background: rgba(255, 255, 255, 0.6);
backdrop-filter: blur(16px);
border: 1px solid rgba(0, 0, 0, 0.06);
height: 100%;
display: flex;
flex-direction: column;
transition: transform 0.25s ease, box-shadow 0.25s ease;
}
.testimonial-card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.08);
}
.testimonial-card__quote {
position: absolute;
top: 16px;
right: 24px;
font-size: 4rem;
font-weight: 800;
line-height: 1;
opacity: 0.08;
pointer-events: none;
font-family: Georgia, serif;
}
.testimonial-card__text {
font-size: 0.95rem;
line-height: 1.7;
opacity: 0.85;
margin: 0 0 24px;
flex: 1;
}
.testimonial-card__author {
display: flex;
align-items: center;
gap: 12px;
}
.testimonial-card__avatar {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-weight: 700;
font-size: 1rem;
flex-shrink: 0;
}
.testimonial-card__info {
display: flex;
flex-direction: column;
}
.testimonial-card__name {
font-weight: 600;
font-size: 0.9rem;
line-height: 1.3;
}
.testimonial-card__role {
font-size: 0.8rem;
opacity: 0.5;
line-height: 1.3;
}
.testimonials-section__toggle {
display: flex;
justify-content: center;
margin-top: 32px;
position: relative;
z-index: 1;
}
.testimonials-section__toggle-btn {
padding: 10px 28px;
border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.1);
background: rgba(255, 255, 255, 0.5);
backdrop-filter: blur(8px);
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
color: inherit;
}
.testimonials-section__toggle-btn:hover {
background: rgba(255, 255, 255, 0.8);
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
}
.v-theme--dark .testimonials-section__toggle-btn {
border-color: rgba(255, 255, 255, 0.1);
background: rgba(30, 41, 59, 0.5);
color: #e2e8f0;
}
.v-theme--dark .testimonials-section__toggle-btn:hover {
background: rgba(30, 41, 59, 0.8);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
}
.testimonials-section__feedback-cta {
text-align: center;
margin-top: 32px;
font-size: 0.9rem;
opacity: 0.5;
position: relative;
z-index: 1;
}
.testimonials-section__email {
color: #00f0ff;
text-decoration: none;
font-weight: 500;
}
.testimonials-section__email:hover {
text-decoration: underline;
}
.v-theme--dark .testimonials-section__email {
color: #00f0ff;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(24px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Dark theme */
.v-theme--dark .testimonials-section__title {
background: linear-gradient(135deg, #e2e8f0 0%, #a5b4fc 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.v-theme--dark .testimonials-section__subtitle {
color: #94a3b8;
opacity: 0.8;
}
.v-theme--dark .testimonial-card {
background: rgba(30, 41, 59, 0.6);
border-color: rgba(255, 255, 255, 0.06);
}
.v-theme--dark .testimonial-card:hover {
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.3);
}
.v-theme--dark .testimonial-card__text {
color: #cbd5e1;
}
.v-theme--dark .testimonial-card__name {
color: #e2e8f0;
}
.v-theme--dark .testimonial-card__role {
color: #64748b;
}
@media (max-width: 960px) {
.testimonials-section__title {
font-size: 1.85rem;
}
.testimonials-section__header {
margin-bottom: 40px;
}
.testimonials-section__subtitle {
font-size: 1rem;
}
}
@media (max-width: 600px) {
.testimonials-section__title {
font-size: 1.6rem;
}
.testimonials-section__header {
margin-bottom: 32px;
}
.testimonial-card {
padding: 24px 20px 20px;
}
}
</style>

View file

@ -0,0 +1,199 @@
<script setup lang="ts">
defineProps<{
title: string;
description: string;
icon: string;
accent?: string;
}>();
</script>
<template>
<div class="feature-card" :style="{ '--accent': accent || '#6366f1' }">
<div class="feature-card__header">
<div class="feature-card__icon-wrap">
<div class="feature-card__icon-bg" />
<v-icon :icon="icon" size="22" class="feature-card__icon" />
</div>
<h3 class="feature-card__title">{{ title }}</h3>
</div>
<p class="feature-card__desc">{{ description }}</p>
<div class="feature-card__shine" />
</div>
</template>
<style scoped>
.feature-card {
position: relative;
overflow: hidden;
padding: 20px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.35s cubic-bezier(0.4, 0, 0.2, 1),
border-color 0.35s ease;
height: 100%;
display: flex;
flex-direction: column;
}
.feature-card:hover {
transform: translateY(-4px);
border-color: var(--accent);
box-shadow: 0 16px 32px -10px rgba(0, 0, 0, 0.25),
0 0 0 1px var(--accent),
0 0 50px -20px var(--accent);
}
.feature-card__header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 10px;
}
.feature-card__icon-wrap {
position: relative;
width: 40px;
height: 40px;
min-width: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.feature-card__icon-bg {
position: absolute;
inset: 0;
border-radius: 12px;
background: var(--accent);
opacity: 0.12;
transition: opacity 0.35s ease, transform 0.35s ease;
}
.feature-card:hover .feature-card__icon-bg {
opacity: 0.22;
transform: scale(1.1);
}
.feature-card__icon {
position: relative;
z-index: 1;
color: var(--accent) !important;
}
.feature-card__title {
font-size: 1.05rem;
font-weight: 700;
letter-spacing: -0.01em;
margin: 0;
line-height: 1.3;
}
.feature-card__desc {
font-size: 0.85rem;
line-height: 1.6;
opacity: 0.7;
margin: 0;
flex-grow: 1;
}
.feature-card__shine {
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(
circle at 30% 30%,
rgba(255, 255, 255, 0.04) 0%,
transparent 60%
);
pointer-events: none;
transition: opacity 0.35s ease;
opacity: 0;
}
.feature-card:hover .feature-card__shine {
opacity: 1;
}
/* Dark theme adjustments */
.v-theme--dark .feature-card {
background: rgba(30, 41, 59, 0.6);
border-color: rgba(148, 163, 184, 0.1);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
.v-theme--dark .feature-card:hover {
background: rgba(30, 41, 59, 0.8);
box-shadow: 0 16px 32px -10px rgba(0, 0, 0, 0.4),
0 0 0 1px var(--accent),
0 0 50px -20px var(--accent);
}
.v-theme--dark .feature-card__title {
color: #e2e8f0;
}
.v-theme--dark .feature-card__desc {
color: #94a3b8;
opacity: 0.85;
}
.v-theme--dark .feature-card__icon-bg {
opacity: 0.18;
}
.v-theme--dark .feature-card:hover .feature-card__icon-bg {
opacity: 0.3;
}
.v-theme--dark .feature-card__shine {
background: radial-gradient(
circle at 30% 30%,
rgba(255, 255, 255, 0.06) 0%,
transparent 60%
);
}
/* Light theme adjustments */
.v-theme--light .feature-card {
background: rgba(255, 255, 255, 0.7);
border-color: rgba(0, 0, 0, 0.06);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
}
.v-theme--light .feature-card:hover {
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 16px 32px -10px rgba(0, 0, 0, 0.1),
0 0 0 1px var(--accent),
0 0 50px -20px var(--accent);
}
.v-theme--light .feature-card__desc {
opacity: 0.6;
}
@media (max-width: 960px) {
.feature-card {
padding: 16px;
}
.feature-card__icon-wrap {
width: 36px;
height: 36px;
min-width: 36px;
}
.feature-card__icon-bg {
border-radius: 10px;
}
.feature-card__header {
gap: 10px;
}
}
</style>

View file

@ -0,0 +1,513 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue';
import { mdiRobotOutline, mdiCheckCircleOutline, mdiCodeBraces, mdiMessageTextOutline } from '@mdi/js';
// State machine for demo cycle
type DemoState = 'idle' | 'working' | 'reviewing' | 'done';
const state = ref<DemoState>('idle');
// Animated task text
const currentTask = ref('');
const taskFading = ref(false);
const TASKS = [
'Implementing auth middleware...',
'Writing unit tests for API...',
'Reviewing PR #42 changes...',
'Setting up CI/CD pipeline...',
'Refactoring database layer...',
];
let taskIndex = 0;
let charTimer: ReturnType<typeof setTimeout> | null = null;
// Agent activity indicators
const agents = ref([
{ name: 'Lead', color: '#00f0ff', status: 'idle' as string, icon: mdiRobotOutline },
{ name: 'Dev-1', color: '#ff00ff', status: 'idle' as string, icon: mdiCodeBraces },
{ name: 'Dev-2', color: '#39ff14', status: 'idle' as string, icon: mdiMessageTextOutline },
]);
// Kanban mini-board
const kanbanTasks = ref([
{ id: 1, text: 'Auth API', col: 'todo' as string },
{ id: 2, text: 'Unit tests', col: 'todo' as string },
{ id: 3, text: 'CI setup', col: 'todo' as string },
]);
function typeNextChar(text: string, index: number) {
if (index >= text.length) { charTimer = null; return; }
currentTask.value = text.slice(0, index + 1);
const ch = text[index];
let delay = 30;
if (ch === '.' || ch === ',') delay = 100;
else if (ch === ' ') delay = 10;
charTimer = setTimeout(() => typeNextChar(text, index + 1), delay);
}
function stopTextAnimation() {
if (charTimer) { clearTimeout(charTimer); charTimer = null; }
}
// Timer management
const timers: number[] = [];
function safeTimeout(fn: () => void, ms: number) {
const id = window.setTimeout(fn, ms);
timers.push(id);
return id;
}
function clearAllTimers() {
timers.forEach(clearTimeout);
timers.length = 0;
stopTextAnimation();
}
// IntersectionObserver
const containerRef = ref<HTMLElement | null>(null);
const isVisible = ref(false);
let intObserver: IntersectionObserver | null = null;
// Demo cycle
let cycleRunning = false;
function runCycle() {
if (!cycleRunning) return;
// Reset
state.value = 'idle';
currentTask.value = '';
taskFading.value = false;
kanbanTasks.value = [
{ id: 1, text: 'Auth API', col: 'todo' },
{ id: 2, text: 'Unit tests', col: 'todo' },
{ id: 3, text: 'CI setup', col: 'todo' },
];
agents.value.forEach(a => a.status = 'idle');
safeTimeout(() => {
if (!cycleRunning) return;
// Phase 1: Working
state.value = 'working';
agents.value[0].status = 'active';
agents.value[1].status = 'active';
kanbanTasks.value[0].col = 'progress';
const task = TASKS[taskIndex % TASKS.length];
taskIndex++;
typeNextChar(task, 0);
safeTimeout(() => {
if (!cycleRunning) return;
kanbanTasks.value[1].col = 'progress';
agents.value[2].status = 'active';
}, 1200);
safeTimeout(() => {
if (!cycleRunning) return;
// Phase 2: Reviewing
state.value = 'reviewing';
kanbanTasks.value[0].col = 'review';
agents.value[0].status = 'reviewing';
safeTimeout(() => {
if (!cycleRunning) return;
// Phase 3: Done
state.value = 'done';
kanbanTasks.value[0].col = 'done';
kanbanTasks.value[1].col = 'review';
agents.value[0].status = 'done';
agents.value[1].status = 'reviewing';
safeTimeout(() => {
if (!cycleRunning) return;
kanbanTasks.value[1].col = 'done';
kanbanTasks.value[2].col = 'progress';
agents.value[1].status = 'done';
safeTimeout(() => {
taskFading.value = true;
safeTimeout(() => {
if (cycleRunning) runCycle();
}, 800);
}, 2000);
}, 1500);
}, 1500);
}, 2500);
}, 1500);
}
function startDemo() {
if (cycleRunning) return;
cycleRunning = true;
runCycle();
}
function stopDemo() {
cycleRunning = false;
clearAllTimers();
state.value = 'idle';
currentTask.value = '';
taskFading.value = false;
}
watch(isVisible, (visible) => {
if (visible) startDemo();
else stopDemo();
});
onMounted(() => {
intObserver = new IntersectionObserver(
([entry]) => { isVisible.value = entry.isIntersecting; },
{ threshold: 0.1 },
);
if (containerRef.value) intObserver.observe(containerRef.value);
});
onUnmounted(() => {
stopDemo();
if (intObserver) { intObserver.disconnect(); intObserver = null; }
});
function colColor(col: string) {
switch (col) {
case 'todo': return '#64748b';
case 'progress': return '#00f0ff';
case 'review': return '#ffd700';
case 'done': return '#39ff14';
default: return '#64748b';
}
}
function statusDotColor(status: string) {
switch (status) {
case 'active': return '#00f0ff';
case 'reviewing': return '#ffd700';
case 'done': return '#39ff14';
default: return '#64748b';
}
}
</script>
<template>
<div ref="containerRef" class="hero-demo" role="img" aria-label="Agent team demo">
<div class="hero-demo__content">
<!-- Header -->
<div class="hero-demo__header">
<div class="hero-demo__title-row">
<span class="hero-demo__title">Agent Teams</span>
<span class="hero-demo__badge-live">
<span class="hero-demo__live-dot" />
LIVE
</span>
</div>
</div>
<!-- Agents row -->
<div class="hero-demo__agents">
<div
v-for="agent in agents"
:key="agent.name"
class="hero-demo__agent"
>
<div class="hero-demo__agent-avatar" :style="{ borderColor: agent.color }">
<v-icon :icon="agent.icon" size="16" :style="{ color: agent.color }" />
</div>
<span class="hero-demo__agent-name">{{ agent.name }}</span>
<span
class="hero-demo__agent-dot"
:style="{ background: statusDotColor(agent.status) }"
/>
</div>
</div>
<!-- Mini kanban -->
<div class="hero-demo__kanban">
<div v-for="col in ['todo', 'progress', 'review', 'done']" :key="col" class="hero-demo__kanban-col">
<div class="hero-demo__kanban-label" :style="{ color: colColor(col) }">
{{ col === 'progress' ? 'IN PROGRESS' : col.toUpperCase() }}
</div>
<div class="hero-demo__kanban-cards">
<TransitionGroup name="kanban-card">
<div
v-for="task in kanbanTasks.filter(t => t.col === col)"
:key="task.id"
class="hero-demo__kanban-card"
:style="{ borderLeftColor: colColor(col) }"
>
{{ task.text }}
</div>
</TransitionGroup>
</div>
</div>
</div>
<!-- Activity log -->
<div class="hero-demo__log">
<div class="hero-demo__log-line">
<v-icon :icon="mdiCheckCircleOutline" size="14" style="color: #39ff14; flex-shrink: 0" />
<span
class="hero-demo__log-text"
:class="{ 'hero-demo__log-text--fading': taskFading }"
>{{ currentTask || 'Waiting for tasks...' }}</span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.hero-demo {
position: relative;
z-index: 1;
border-radius: 16px;
background: rgba(10, 10, 15, 0.95);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(0, 240, 255, 0.15);
overflow: hidden;
min-height: 330px;
box-shadow:
0 20px 60px rgba(0, 0, 0, 0.6),
0 0 30px rgba(0, 240, 255, 0.05),
inset 0 1px 0 rgba(0, 240, 255, 0.1);
}
.hero-demo__content {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
padding: 16px;
min-height: 330px;
gap: 12px;
}
/* ─── Header ─── */
.hero-demo__header {
display: flex;
justify-content: space-between;
align-items: center;
}
.hero-demo__title-row {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
justify-content: space-between;
}
.hero-demo__title {
font-size: 15px;
font-weight: 700;
color: #e0e6ff;
font-family: "JetBrains Mono", monospace;
letter-spacing: 0.05em;
}
.hero-demo__badge-live {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 10px;
border-radius: 100px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.1em;
color: #39ff14;
background: rgba(57, 255, 20, 0.1);
border: 1px solid rgba(57, 255, 20, 0.2);
}
.hero-demo__live-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #39ff14;
animation: livePulse 2s ease-in-out infinite;
}
@keyframes livePulse {
0%, 100% { opacity: 1; box-shadow: 0 0 4px rgba(57, 255, 20, 0.6); }
50% { opacity: 0.4; box-shadow: none; }
}
/* ─── Agents ─── */
.hero-demo__agents {
display: flex;
gap: 12px;
}
.hero-demo__agent {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
flex: 1;
}
.hero-demo__agent-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
border: 1.5px solid;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.3);
flex-shrink: 0;
}
.hero-demo__agent-name {
font-size: 11px;
color: #a0a8c0;
font-weight: 600;
font-family: "JetBrains Mono", monospace;
}
.hero-demo__agent-dot {
width: 6px;
height: 6px;
border-radius: 50%;
margin-left: auto;
transition: background 0.3s ease;
flex-shrink: 0;
}
/* ─── Kanban ─── */
.hero-demo__kanban {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 6px;
flex: 1;
}
.hero-demo__kanban-col {
display: flex;
flex-direction: column;
gap: 4px;
}
.hero-demo__kanban-label {
font-size: 9px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 4px 0;
font-family: "JetBrains Mono", monospace;
opacity: 0.7;
}
.hero-demo__kanban-cards {
display: flex;
flex-direction: column;
gap: 3px;
min-height: 60px;
}
.hero-demo__kanban-card {
font-size: 10px;
padding: 6px 8px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.04);
border-left: 2px solid;
color: #c8d6e5;
transition: all 0.4s ease;
font-family: "JetBrains Mono", monospace;
}
/* Card transition */
.kanban-card-enter-active,
.kanban-card-leave-active {
transition: all 0.4s ease;
}
.kanban-card-enter-from {
opacity: 0;
transform: translateX(-8px);
}
.kanban-card-leave-to {
opacity: 0;
transform: translateX(8px);
}
/* ─── Log ─── */
.hero-demo__log {
padding: 8px 10px;
border-radius: 8px;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(0, 240, 255, 0.08);
}
.hero-demo__log-line {
display: flex;
align-items: center;
gap: 8px;
}
.hero-demo__log-text {
font-size: 12px;
color: #a0a8c0;
font-family: "JetBrains Mono", monospace;
transition: opacity 0.5s ease;
}
.hero-demo__log-text--fading {
opacity: 0;
}
/* ─── Responsive ─── */
@media (max-width: 960px) {
.hero-demo {
max-width: 460px;
margin: 0 auto;
}
}
@media (max-width: 600px) {
.hero-demo {
border-radius: 12px;
min-height: 280px;
}
.hero-demo__content {
padding: 12px;
min-height: 280px;
gap: 8px;
}
.hero-demo__title {
font-size: 13px;
}
.hero-demo__agents {
gap: 6px;
}
.hero-demo__agent {
padding: 4px 6px;
}
.hero-demo__agent-name {
font-size: 9px;
}
.hero-demo__kanban-label {
font-size: 8px;
}
.hero-demo__kanban-card {
font-size: 9px;
padding: 4px 6px;
}
.hero-demo__log-text {
font-size: 10px;
}
}
</style>

View file

@ -0,0 +1,444 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import { mdiPlay, mdiPause, mdiVolumeHigh, mdiVolumeOff, mdiFullscreen } from '@mdi/js';
const { t } = useI18n();
const videoRef = ref<HTMLVideoElement | null>(null);
const containerRef = ref<HTMLElement | null>(null);
const isPlaying = ref(false);
const isMuted = ref(true);
const showControls = ref(true);
const isLoaded = ref(false);
const hasError = ref(false);
const progress = ref(0);
const hideTimer = ref<ReturnType<typeof setTimeout> | null>(null);
let intObserver: IntersectionObserver | null = null;
function togglePlay() {
const video = videoRef.value;
if (!video) return;
if (video.paused) {
video.play();
isPlaying.value = true;
} else {
video.pause();
isPlaying.value = false;
}
showControlsBriefly();
}
function toggleMute() {
const video = videoRef.value;
if (!video) return;
video.muted = !video.muted;
isMuted.value = video.muted;
showControlsBriefly();
}
function toggleFullscreen() {
const container = containerRef.value;
if (!container) return;
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
container.requestFullscreen();
}
showControlsBriefly();
}
function onTimeUpdate() {
const video = videoRef.value;
if (!video || !video.duration) return;
progress.value = (video.currentTime / video.duration) * 100;
}
function onSeek(e: MouseEvent) {
const video = videoRef.value;
const target = e.currentTarget as HTMLElement;
if (!video || !target) return;
const rect = target.getBoundingClientRect();
const ratio = (e.clientX - rect.left) / rect.width;
video.currentTime = ratio * video.duration;
showControlsBriefly();
}
function showControlsBriefly() {
showControls.value = true;
if (hideTimer.value) clearTimeout(hideTimer.value);
hideTimer.value = setTimeout(() => {
if (isPlaying.value) showControls.value = false;
}, 3000);
}
function onMouseEnter() {
showControls.value = true;
if (hideTimer.value) clearTimeout(hideTimer.value);
}
function onMouseLeave() {
if (isPlaying.value) {
hideTimer.value = setTimeout(() => {
showControls.value = false;
}, 1500);
}
}
onMounted(() => {
const video = videoRef.value;
if (video) {
video.addEventListener('loadeddata', () => { isLoaded.value = true; });
video.addEventListener('error', () => { hasError.value = true; });
video.addEventListener('ended', () => {
isPlaying.value = false;
showControls.value = true;
progress.value = 0;
video.currentTime = 0;
});
}
intObserver = new IntersectionObserver(
([entry]) => {
if (!entry.isIntersecting && videoRef.value && !videoRef.value.paused) {
videoRef.value.pause();
isPlaying.value = false;
}
},
{ threshold: 0.2 },
);
if (containerRef.value) intObserver.observe(containerRef.value);
});
onUnmounted(() => {
if (hideTimer.value) clearTimeout(hideTimer.value);
if (intObserver) { intObserver.disconnect(); intObserver = null; }
});
</script>
<template>
<div
ref="containerRef"
class="hero-video"
@mouseenter="onMouseEnter"
@mouseleave="onMouseLeave"
>
<!-- Loading skeleton -->
<div v-if="!isLoaded && !hasError" class="hero-video__skeleton">
<div class="hero-video__skeleton-pulse" />
<v-icon :icon="mdiPlay" size="48" class="hero-video__skeleton-icon" />
</div>
<!-- Error fallback -->
<div v-if="hasError" class="hero-video__error">
<v-icon :icon="mdiPlay" size="36" class="hero-video__error-icon" />
<span class="hero-video__error-text">{{ t('hero.videoUnavailable') }}</span>
</div>
<!-- Video element -->
<video
v-show="!hasError"
ref="videoRef"
class="hero-video__player"
:class="{ 'hero-video__player--loaded': isLoaded }"
preload="metadata"
muted
playsinline
@timeupdate="onTimeUpdate"
@click="togglePlay"
>
<source src="/video/demo.mp4" type="video/mp4" />
</video>
<!-- Play overlay (when paused) -->
<Transition name="fade">
<div
v-if="!isPlaying && isLoaded"
class="hero-video__play-overlay"
@click="togglePlay"
>
<div class="hero-video__play-btn">
<v-icon :icon="mdiPlay" size="36" color="white" />
</div>
<span class="hero-video__play-label">{{ t('hero.watchDemo') }}</span>
</div>
</Transition>
<!-- Controls bar -->
<Transition name="slide-up">
<div
v-if="isLoaded && showControls"
class="hero-video__controls"
>
<!-- Progress bar -->
<div class="hero-video__progress" @click="onSeek">
<div class="hero-video__progress-track">
<div
class="hero-video__progress-fill"
:style="{ width: `${progress}%` }"
/>
</div>
</div>
<div class="hero-video__controls-row">
<button class="hero-video__control-btn" @click.stop="togglePlay" :aria-label="isPlaying ? 'Pause' : 'Play'">
<v-icon :icon="isPlaying ? mdiPause : mdiPlay" size="18" />
</button>
<button class="hero-video__control-btn" @click.stop="toggleMute" :aria-label="isMuted ? 'Unmute' : 'Mute'">
<v-icon :icon="isMuted ? mdiVolumeOff : mdiVolumeHigh" size="18" />
</button>
<div class="hero-video__spacer" />
<button class="hero-video__control-btn" @click.stop="toggleFullscreen" aria-label="Fullscreen">
<v-icon :icon="mdiFullscreen" size="18" />
</button>
</div>
</div>
</Transition>
</div>
</template>
<style scoped>
.hero-video {
position: relative;
z-index: 1;
border-radius: 16px;
background: rgba(10, 10, 15, 0.95);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(0, 240, 255, 0.15);
overflow: hidden;
cursor: pointer;
box-shadow:
0 20px 60px rgba(0, 0, 0, 0.6),
0 0 30px rgba(0, 240, 255, 0.05),
inset 0 1px 0 rgba(0, 240, 255, 0.1);
}
/* ─── Video player ─── */
.hero-video__player {
display: block;
width: 100%;
height: auto;
border-radius: 16px;
opacity: 0;
transition: opacity 0.5s ease;
}
.hero-video__player--loaded {
opacity: 1;
}
/* ─── Loading skeleton ─── */
.hero-video__skeleton {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 16px;
background: rgba(10, 10, 15, 0.95);
z-index: 2;
}
.hero-video__skeleton-pulse {
position: absolute;
inset: 0;
background: linear-gradient(
135deg,
rgba(0, 240, 255, 0.03) 0%,
rgba(255, 0, 255, 0.03) 50%,
rgba(0, 240, 255, 0.03) 100%
);
animation: skeletonPulse 2s ease-in-out infinite;
}
.hero-video__skeleton-icon {
color: rgba(0, 240, 255, 0.4);
z-index: 1;
}
@keyframes skeletonPulse {
0%, 100% { opacity: 0.3; }
50% { opacity: 0.8; }
}
/* ─── Error fallback ─── */
.hero-video__error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
min-height: 280px;
padding: 32px;
}
.hero-video__error-icon {
color: rgba(0, 240, 255, 0.3);
}
.hero-video__error-text {
font-size: 13px;
color: #8892b0;
font-family: "JetBrains Mono", monospace;
text-align: center;
}
/* ─── Play overlay ─── */
.hero-video__play-overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
background: rgba(0, 0, 0, 0.4);
z-index: 3;
cursor: pointer;
}
.hero-video__play-btn {
width: 64px;
height: 64px;
border-radius: 50%;
background: rgba(0, 240, 255, 0.15);
border: 2px solid rgba(0, 240, 255, 0.4);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
box-shadow: 0 0 30px rgba(0, 240, 255, 0.2);
}
.hero-video__play-btn:hover {
background: rgba(0, 240, 255, 0.25);
border-color: rgba(0, 240, 255, 0.6);
box-shadow: 0 0 40px rgba(0, 240, 255, 0.35);
transform: scale(1.05);
}
.hero-video__play-label {
font-size: 13px;
font-weight: 600;
color: rgba(255, 255, 255, 0.8);
font-family: "JetBrains Mono", monospace;
letter-spacing: 0.05em;
text-transform: uppercase;
}
/* ─── Controls bar ─── */
.hero-video__controls {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
padding: 16px 12px 8px;
z-index: 4;
}
.hero-video__controls-row {
display: flex;
align-items: center;
gap: 4px;
}
.hero-video__control-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 6px;
border: none;
background: transparent;
color: rgba(255, 255, 255, 0.8);
cursor: pointer;
transition: all 0.2s ease;
}
.hero-video__control-btn:hover {
background: rgba(0, 240, 255, 0.15);
color: #00f0ff;
}
.hero-video__spacer {
flex: 1;
}
/* ─── Progress bar ─── */
.hero-video__progress {
padding: 4px 0;
cursor: pointer;
margin-bottom: 4px;
}
.hero-video__progress-track {
width: 100%;
height: 3px;
background: rgba(255, 255, 255, 0.15);
border-radius: 2px;
overflow: hidden;
transition: height 0.2s ease;
}
.hero-video__progress:hover .hero-video__progress-track {
height: 5px;
}
.hero-video__progress-fill {
height: 100%;
background: linear-gradient(90deg, #00f0ff, #ff00ff);
border-radius: 2px;
transition: width 0.1s linear;
}
/* ─── Transitions ─── */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.slide-up-enter-active,
.slide-up-leave-active {
transition: all 0.3s ease;
}
.slide-up-enter-from,
.slide-up-leave-to {
opacity: 0;
transform: translateY(8px);
}
/* ─── Responsive ─── */
@media (max-width: 960px) {
.hero-video {
max-width: 100%;
}
}
@media (max-width: 600px) {
.hero-video {
border-radius: 12px;
}
.hero-video__player {
border-radius: 12px;
}
.hero-video__play-btn {
width: 52px;
height: 52px;
}
.hero-video__play-label {
font-size: 11px;
}
}
</style>

View file

@ -0,0 +1,24 @@
type DownloadEventParams = {
os: string;
arch: string;
version?: string | null;
source: string;
};
export const useAnalytics = () => {
const trackNavClick = (_target: string) => {};
const trackLanguageSwitch = (_from: string, _to: string) => {};
const trackThemeToggle = (_theme: "light" | "dark") => {};
const trackDownloadClick = (_params: DownloadEventParams) => {};
const trackSectionView = (_sectionId: string) => {};
const trackFaqExpand = (_faqId: string, _question: string) => {};
return {
trackNavClick,
trackLanguageSwitch,
trackThemeToggle,
trackDownloadClick,
trackSectionView,
trackFaqExpand,
};
};

View file

@ -0,0 +1,70 @@
import { computed, watch, onUnmounted } from "vue";
import { useThemeStore } from "~/stores/theme";
export const useBrowserTheme = () => {
const themeStore = useThemeStore();
const { $vuetifyTheme } = useNuxtApp();
const vuetifyTheme = $vuetifyTheme as {
global: { name: import("vue").Ref<string>; current: import("vue").Ref<any> };
change: (name: string) => void;
} | null;
let mediaQueryHandler: ((event: MediaQueryListEvent) => void) | null = null;
let mediaQuery: MediaQueryList | null = null;
const applyVuetifyTheme = (name: "light" | "dark") => {
if (!vuetifyTheme) return;
if (typeof vuetifyTheme.change === "function") {
vuetifyTheme.change(name);
} else {
vuetifyTheme.global.name.value = name;
}
};
const applyTheme = (name: "light" | "dark") => {
themeStore.setTheme(name, true);
applyVuetifyTheme(name);
};
const initTheme = () => {
if (!process.client) return;
const initialTheme = themeStore.getInitialTheme();
themeStore.setTheme(initialTheme, false);
applyVuetifyTheme(initialTheme);
if (process.client && !themeStore.userSelected) {
mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
mediaQueryHandler = (event: MediaQueryListEvent) => {
if (!themeStore.userSelected) {
const newTheme = event.matches ? "dark" : "light";
themeStore.setTheme(newTheme, false);
applyVuetifyTheme(newTheme);
}
};
mediaQuery.addEventListener("change", mediaQueryHandler);
}
};
const toggleTheme = () => {
applyTheme(themeStore.current === "dark" ? "light" : "dark");
};
onUnmounted(() => {
if (mediaQuery && mediaQueryHandler) {
mediaQuery.removeEventListener("change", mediaQueryHandler);
}
});
watch(
() => themeStore.current,
(value) => {
applyVuetifyTheme(value as "light" | "dark");
}
);
return {
currentTheme: computed(() => themeStore.current),
isDark: computed(() => themeStore.current === "dark"),
initTheme,
toggleTheme
};
};

View file

@ -0,0 +1,10 @@
import { computed } from "vue";
import { getContent } from "~/data/content";
import type { LocaleCode } from "~/data/i18n";
export const useLandingContent = () => {
const { locale } = useI18n();
const content = computed(() => getContent(locale.value as LocaleCode));
return { content };
};

View file

@ -0,0 +1,39 @@
import { supportedLocales } from "~/data/i18n";
import { useLocaleStore } from "~/stores/locale";
export const useLocation = () => {
const nuxtApp = useNuxtApp();
const i18n = nuxtApp.$i18n;
const localeStore = useLocaleStore();
const cookie = useCookie("i18n_redirected", { default: () => "" });
const getBrowserLocale = () => {
if (!process.client) return "en";
const browserLocale = navigator.language || "en";
const normalized = browserLocale.split("-")[0].toLowerCase();
const supported = supportedLocales.map((item) => item.code);
return supported.includes(normalized) ? normalized : "en";
};
const initLocale = () => {
if (cookie.value) {
localeStore.setLocale(cookie.value, false);
if (i18n?.setLocale) {
i18n.setLocale(cookie.value);
} else if (i18n?.locale?.value) {
i18n.locale.value = cookie.value;
}
return;
}
const detected = getBrowserLocale();
localeStore.setLocale(detected, false);
if (i18n?.setLocale) {
i18n.setLocale(detected);
} else if (i18n?.locale?.value) {
i18n.locale.value = detected;
}
cookie.value = detected;
};
return { initLocale, getBrowserLocale };
};

View file

@ -0,0 +1,165 @@
import { computed } from "vue";
import { supportedLocales, defaultLocale } from "~/data/i18n";
import { getContent } from "~/data/content";
import type { LocaleCode } from "~/data/i18n";
type PageSeoImage = {
url: string;
width?: number;
height?: number;
type?: string;
alt?: string;
};
type PageSeoOptions = {
type?: "website" | "article";
robots?: string;
image?: PageSeoImage;
};
export const usePageSeo = (titleKey: string, descriptionKey: string, options: PageSeoOptions = {}) => {
const { t, locale } = useI18n();
const route = useRoute();
const config = useRuntimeConfig();
const siteUrl = config.public.siteUrl || "https://example.com";
const siteName = (config as any)?.site?.name || "Claude Agent Teams";
const switchLocale = useSwitchLocalePath();
const title = computed(() => t(titleKey));
const description = computed(() => t(descriptionKey));
const canonicalPath = computed(() => route.path);
const canonicalUrl = computed(() => `${siteUrl}${canonicalPath.value}`);
const resolvedImage = computed<PageSeoImage>(() => {
if (options.image) return options.image;
return {
url: "/og-image.png",
width: 1200,
height: 630,
type: "image/png",
alt: `${siteName} — AI agent orchestration`
};
});
const resolvedImageUrl = computed(() => {
// Если сборщик вернул относительный путь — сделаем абсолютный.
const url = resolvedImage.value.url;
return url.startsWith("http") ? url : new URL(url, siteUrl).toString();
});
useSeoMeta({
title,
description,
ogTitle: title,
ogDescription: description,
ogType: options.type || "website",
ogSiteName: siteName,
ogUrl: canonicalUrl,
ogImage: resolvedImageUrl,
ogImageType: computed(() => resolvedImage.value.type),
ogImageWidth: computed(() => (resolvedImage.value.width ? String(resolvedImage.value.width) : undefined)),
ogImageHeight: computed(() => (resolvedImage.value.height ? String(resolvedImage.value.height) : undefined)),
ogImageAlt: computed(() => resolvedImage.value.alt),
twitterCard: "summary_large_image",
twitterTitle: title,
twitterDescription: description,
twitterImage: resolvedImageUrl,
twitterImageAlt: computed(() => resolvedImage.value.alt),
robots:
options.robots ||
"index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1"
});
useHead(() => {
const links = supportedLocales.map((locale) => {
const path = switchLocale(locale.code) || canonicalPath.value;
return {
rel: "alternate",
hreflang: locale.code,
href: `${siteUrl}${path}`
};
});
const defaultPath = switchLocale(defaultLocale) || canonicalPath.value;
links.push({ rel: "alternate", hreflang: "x-default", href: `${siteUrl}${defaultPath}` });
links.push({ rel: "canonical", href: canonicalUrl.value });
const jsonLd: any[] = [
{
"@context": "https://schema.org",
"@type": "WebSite",
name: siteName,
url: siteUrl
},
{
"@context": "https://schema.org",
"@type": "Organization",
name: siteName,
url: siteUrl,
logo: `${siteUrl}/favicon.ico`,
sameAs: [
`https://github.com/${config.public.githubRepo}`
]
}
];
// Для главной и страницы скачивания добавим более "вкусную" разметку.
const isDownload = canonicalPath.value.endsWith("/download");
const isHome = canonicalPath.value === "/";
if (isHome || isDownload) {
jsonLd.push({
"@context": "https://schema.org",
"@type": "SoftwareApplication",
name: siteName,
applicationCategory: "BusinessApplication",
operatingSystem: "Windows, macOS, Linux",
description: description.value,
url: canonicalUrl.value,
offers: {
"@type": "Offer",
price: "0",
priceCurrency: "USD"
},
downloadUrl: config.public.githubReleasesUrl || `https://github.com/${config.public.githubRepo}/releases`
});
}
// FAQ rich snippets — Google показывает их прямо в выдаче
if (isHome) {
const content = getContent(locale.value as LocaleCode);
if (content.faq?.length) {
jsonLd.push({
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: content.faq.map((item) => ({
"@type": "Question",
name: item.question,
acceptedAnswer: {
"@type": "Answer",
// HTML-теги из ответа убираем для JSON-LD
text: item.answer.replace(/<[^>]*>/g, "")
}
}))
});
}
}
return {
htmlAttrs: { lang: locale.value || "en" },
link: links,
meta: [
{ name: "author", content: "Claude Agent Teams" },
{ name: "application-name", content: siteName },
{ name: "apple-mobile-web-app-title", content: siteName },
{ name: "format-detection", content: "telephone=no" },
{ name: "theme-color", content: "#00f0ff" },
{ name: "keywords", content: "claude code, agent teams, AI agents, kanban board, code review, multi-agent orchestration, desktop app, free, open source" }
],
script: jsonLd.map((item) => ({
type: "application/ld+json",
children: JSON.stringify(item)
}))
};
});
};

View file

@ -0,0 +1,60 @@
import { ref, onMounted, onUnmounted, nextTick } from "vue";
/**
* Параллакс-эффект для фоновых орбов через одну секцию.
* На мобилке отключён мешает touch-скроллу и жрёт батарею.
*/
export const useParallaxSections = (speed = 0.1) => {
const containerRef = ref<HTMLElement | null>(null);
let ticking = false;
let targets: { bg: HTMLElement; section: HTMLElement }[] = [];
function collect() {
if (!containerRef.value) return;
targets = [];
const sections = containerRef.value.querySelectorAll(".section");
sections.forEach((section, i) => {
if (i % 2 === 0) return;
const bg = section.querySelector<HTMLElement>('[class*="__bg"]');
if (!bg) return;
bg.style.willChange = "transform";
targets.push({ bg, section: section as HTMLElement });
});
}
function update() {
for (const { bg, section } of targets) {
const rect = section.getBoundingClientRect();
const offset = rect.top * speed;
bg.style.transform = `translateY(${offset}px)`;
}
}
function onScroll() {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
update();
ticking = false;
});
}
onMounted(async () => {
if (window.innerWidth < 768) return;
await nextTick();
// Ждём пока lazy-компоненты прогрузятся
setTimeout(() => {
collect();
update();
}, 600);
window.addEventListener("scroll", onScroll, { passive: true });
});
onUnmounted(() => {
window.removeEventListener("scroll", onScroll);
targets = [];
});
return { containerRef };
};

View file

@ -0,0 +1,24 @@
import { computed, onMounted, ref } from "vue";
import { detectMacArch, detectPlatform } from "~/utils/platform";
export const usePlatform = () => {
const platform = ref("unknown");
const arch = ref("unknown");
onMounted(() => {
const ua = navigator.userAgent;
platform.value = detectPlatform(ua);
if (platform.value === "macos") {
arch.value = detectMacArch(ua);
}
});
const label = computed(() => {
if (platform.value === "macos") return "macOS";
if (platform.value === "windows") return "Windows";
if (platform.value === "linux") return "Linux";
return "your OS";
});
return { platform, arch, label };
};

View file

@ -0,0 +1,174 @@
import type { DownloadArch, DownloadOs } from "~/data/downloads";
// --- Типы GitHub API ---
type ReleaseAsset = {
name: string;
browser_download_url: string;
size: number;
};
type GitHubRelease = {
tag_name: string;
name: string;
body: string;
published_at: string;
assets: ReleaseAsset[];
};
// --- Типы нашего API ---
type Variant = { url: string | null; platformKey: string | null; version: string | null };
type DownloadsApiResponse = {
ok: boolean;
source: "github-releases";
fetchedAt: string;
version: string | null;
notes: string | null;
pubDate: string | null;
variants: {
macos: { arm64: Variant; x64: Variant; universal: Variant };
windows: { x64: Variant };
linux: { appimage: Variant; deb: Variant };
};
};
type ResolveResult = { url: string; version: string | null } | null;
// --- Парсинг GitHub Release → наш формат ---
const CACHE_KEY = "cat_releases";
const CACHE_TTL = 10 * 60 * 1000; // 10 минут
const emptyVariant: Variant = { url: null, platformKey: null, version: null };
function findAsset(assets: ReleaseAsset[], pattern: RegExp): ReleaseAsset | null {
return assets.find((a) => pattern.test(a.name)) || null;
}
function toVariant(asset: ReleaseAsset | null, version: string | null): Variant {
if (!asset) return { ...emptyVariant };
return { url: asset.browser_download_url, platformKey: asset.name, version };
}
function parseGitHubRelease(release: GitHubRelease): DownloadsApiResponse {
const version = release.tag_name?.replace(/^v/, "") || null;
const assets = (release.assets || []).filter(
(a) => !a.name.endsWith(".sig") && !a.name.endsWith(".json") && !a.name.endsWith(".tar.gz")
);
return {
ok: assets.length > 0,
source: "github-releases",
fetchedAt: new Date().toISOString(),
version,
notes: release.body || null,
pubDate: release.published_at || null,
variants: {
macos: {
arm64: toVariant(findAsset(assets, /[-_]arm64\.dmg$/i), version),
x64: toVariant(findAsset(assets, /[-_]x64\.dmg$/i), version),
universal: { ...emptyVariant },
},
windows: {
x64: toVariant(
findAsset(assets, /[-_]Setup\.exe$/i) || findAsset(assets, /\.exe$/i) || findAsset(assets, /\.msi$/i),
version
),
},
linux: {
appimage: toVariant(findAsset(assets, /\.AppImage$/i), version),
deb: toVariant(findAsset(assets, /\.deb$/i), version),
},
},
};
}
// --- sessionStorage кеш ---
function readCache(): DownloadsApiResponse | null {
try {
const raw = sessionStorage.getItem(CACHE_KEY);
if (!raw) return null;
const { ts, data } = JSON.parse(raw);
if (Date.now() - ts > CACHE_TTL) return null;
return data;
} catch {
return null;
}
}
function writeCache(data: DownloadsApiResponse): void {
try {
sessionStorage.setItem(CACHE_KEY, JSON.stringify({ ts: Date.now(), data }));
} catch {
// sessionStorage может быть недоступен (private mode и т.д.)
}
}
// --- Composable ---
export const useReleaseDownloads = () => {
const config = useRuntimeConfig();
const githubRepo = (config.public.githubRepo as string) || "777genius/claude_agent_teams_ui";
const fallbackUrl =
(config.public.githubReleasesUrl as string) ||
`https://github.com/${githubRepo}/releases`;
// useAsyncData дедуплицирует запросы по ключу — все компоненты шарят один результат
const { data, pending, error } = useAsyncData<DownloadsApiResponse>("releases", async () => {
const cached = readCache();
if (cached) return cached;
const release = await $fetch<GitHubRelease>(
`https://api.github.com/repos/${githubRepo}/releases/latest`,
{
headers: { Accept: "application/vnd.github+json" },
}
);
const parsed = parseGitHubRelease(release);
writeCache(parsed);
return parsed;
}, {
server: false,
lazy: true,
});
const resolve = (os: DownloadOs, arch: DownloadArch | "unknown"): ResolveResult => {
const api = data.value;
if (!api?.ok) return null;
if (os === "windows") {
const v = api.variants.windows.x64;
return v.url ? { url: v.url, version: v.version || api.version } : null;
}
if (os === "linux") {
const v = api.variants.linux.appimage.url ? api.variants.linux.appimage : api.variants.linux.deb;
return v.url ? { url: v.url, version: v.version || api.version } : null;
}
// macOS: сначала universal, потом по архитектуре
if (os === "macos") {
const universal = api.variants.macos.universal;
if (universal.url) return { url: universal.url, version: universal.version || api.version };
const byArch = arch === "arm64" ? api.variants.macos.arm64 : api.variants.macos.x64;
if (byArch.url) return { url: byArch.url, version: byArch.version || api.version };
const any = api.variants.macos.arm64.url ? api.variants.macos.arm64 : api.variants.macos.x64;
return any.url ? { url: any.url, version: any.version || api.version } : null;
}
return null;
};
const resolveUrlOrFallback = (os: DownloadOs, arch: DownloadArch | "unknown"): string => {
return resolve(os, arch)?.url || fallbackUrl;
};
return { data, pending, error, fallbackUrl, resolve, resolveUrlOrFallback };
};

View file

@ -0,0 +1,35 @@
import { sectionOrder } from "~/data/sections";
export const useTrackSections = () => {
if (!import.meta.client) return;
const { trackSectionView } = useAnalytics();
const seen = new Set<string>();
let observer: IntersectionObserver | null = null;
onMounted(() => {
observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const id = entry.target.id;
if (!id || seen.has(id)) continue;
seen.add(id);
trackSectionView(id);
observer?.unobserve(entry.target);
}
},
{ threshold: 0.3, rootMargin: "0px 0px -10% 0px" },
);
for (const sectionId of sectionOrder) {
const el = document.getElementById(sectionId);
if (el) observer.observe(el);
}
});
onUnmounted(() => {
observer?.disconnect();
});
};

102
landing/content/en.json Normal file
View file

@ -0,0 +1,102 @@
{
"hero": {
"title": "Claude Agent Teams",
"subtitle": "You're the CTO, agents are your team. They handle tasks themselves, message each other, review each other's code. You just look at the kanban board and drink coffee."
},
"features": [
{
"id": "agentTeams",
"title": "Agent Teams",
"description": "Create teams with different roles. Agents work autonomously in parallel, communicate with each other, and collaborate across teams."
},
{
"id": "kanban",
"title": "Kanban Board",
"description": "Tasks change status in real-time as agents work. Drag, assign, review — all on a visual board."
},
{
"id": "codeReview",
"title": "Code Review",
"description": "Diff view per task with accept, reject, and comment. Built-in code editor with Git support."
},
{
"id": "crossTeam",
"title": "Cross-Team Communication",
"description": "Agents message each other within and across teams. Direct messaging, task comments, and quick actions."
},
{
"id": "soloMode",
"title": "Solo Mode",
"description": "Start with a single agent that self-manages tasks. Expand to a full team whenever you need more power."
},
{
"id": "liveProcesses",
"title": "Live Processes",
"description": "See running agents, open URLs in browser, monitor token usage and session context in real-time."
}
],
"faq": [
{
"id": "whatIsIt",
"question": "What is Claude Agent Teams?",
"answer": "A desktop app that lets you assemble AI agent teams powered by Claude Code. Each agent has a role, works autonomously, and collaborates with teammates — all managed through a kanban board."
},
{
"id": "isFree",
"question": "Is it really free?",
"answer": "Yes. 100% free, open source, no API keys required. The app runs entirely locally using your existing Claude Code setup."
},
{
"id": "platforms",
"question": "Which platforms are supported?",
"answer": "macOS (Apple Silicon and Intel), Windows, and Linux."
},
{
"id": "howItWorks",
"question": "How does it work?",
"answer": "Install the app, create a team, assign roles — agents start working in parallel. You monitor progress on the kanban board, review code diffs, and communicate with agents directly."
},
{
"id": "privacy",
"question": "Is my code private?",
"answer": "Everything runs locally on your machine. No data is sent to external servers. Your code, conversations, and agent activity stay entirely private."
},
{
"id": "requirements",
"question": "What do I need to get started?",
"answer": "Just install the app — it includes built-in Claude Code installation and authentication. Zero-setup onboarding gets you running in minutes."
}
],
"download": {
"title": "Download",
"note": "Choose your platform and start building with AI agent teams."
},
"testimonials": [
{ "id": "user1", "name": "Alex K.", "role": "Tech Lead", "text": "Finally, a tool that lets me manage AI agents like I manage my engineering team. The kanban board is a game-changer for keeping track of parallel agent work." },
{ "id": "user2", "name": "Sarah M.", "role": "Full-stack Developer", "text": "Solo mode is perfect for quick tasks. When I need more firepower, I spin up a full team in seconds. The cross-team communication just works." },
{ "id": "user3", "name": "David R.", "role": "Senior Engineer", "text": "The code review workflow is brilliant — diff view per task, accept/reject, comments. It's like having a team of junior devs that actually follow instructions." },
{ "id": "user4", "name": "Yuki T.", "role": "DevOps Engineer", "text": "Live process monitoring and context tracking are incredibly useful. I can see exactly what each agent is doing and how much context they're using." },
{ "id": "user5", "name": "Maria S.", "role": "Indie Developer", "text": "Zero-setup onboarding is real — installed the app, authenticated once, and had agents working on my codebase within 5 minutes. No API keys, no config files." },
{ "id": "user6", "name": "Chris L.", "role": "Startup CTO", "text": "This completely changed how I prototype. I set up agent teams for different parts of the stack and let them work in parallel. 10x productivity boost, no joke." }
],
"pricing": [
{
"id": "free",
"name": "Free Forever",
"price": "$0",
"period": "forever",
"description": "Everything included. No limits, no API keys, no credit card.",
"features": [
"Unlimited agent teams",
"Kanban board with real-time updates",
"Code review with diff view",
"Cross-team communication",
"Solo & team modes",
"Live process monitoring",
"Built-in code editor",
"MCP integration"
],
"highlighted": true
}
]
}

102
landing/content/ru.json Normal file
View file

@ -0,0 +1,102 @@
{
"hero": {
"title": "Claude Agent Teams",
"subtitle": "Вы — CTO, агенты — ваша команда. Они сами берут задачи, переписываются друг с другом, ревьюят код друг друга. А вы просто смотрите на канбан-доску и пьёте кофе."
},
"features": [
{
"id": "agentTeams",
"title": "Команды агентов",
"description": "Создавайте команды с разными ролями. Агенты работают автономно и параллельно, общаются друг с другом и сотрудничают между командами."
},
{
"id": "kanban",
"title": "Канбан-доска",
"description": "Задачи меняют статус в реальном времени. Перетаскивайте, назначайте, ревьюте — всё на визуальной доске."
},
{
"id": "codeReview",
"title": "Код-ревью",
"description": "Diff-просмотр по каждой задаче с одобрением, отклонением и комментариями. Встроенный редактор кода с поддержкой Git."
},
{
"id": "crossTeam",
"title": "Межкомандная коммуникация",
"description": "Агенты общаются внутри и между командами. Прямые сообщения, комментарии к задачам и быстрые действия."
},
{
"id": "soloMode",
"title": "Соло-режим",
"description": "Начните с одного агента, который сам управляет задачами. Расширяйте до полной команды когда нужно больше мощности."
},
{
"id": "liveProcesses",
"title": "Живые процессы",
"description": "Следите за запущенными агентами, открывайте URL в браузере, мониторьте использование токенов и контекста в реальном времени."
}
],
"faq": [
{
"id": "whatIsIt",
"question": "Что такое Claude Agent Teams?",
"answer": "Десктопное приложение, которое позволяет собирать команды ИИ-агентов на базе Claude Code. Каждый агент имеет роль, работает автономно и взаимодействует с тиммейтами — всё управляется через канбан-доску."
},
{
"id": "isFree",
"question": "Это действительно бесплатно?",
"answer": "Да. 100% бесплатно, открытый исходный код, API-ключи не требуются. Приложение работает полностью локально, используя вашу существующую настройку Claude Code."
},
{
"id": "platforms",
"question": "Какие платформы поддерживаются?",
"answer": "macOS (Apple Silicon и Intel), Windows и Linux."
},
{
"id": "howItWorks",
"question": "Как это работает?",
"answer": "Установите приложение, создайте команду, назначьте роли — агенты начинают работать параллельно. Следите за прогрессом на канбан-доске, ревьюте diff кода и общайтесь с агентами напрямую."
},
{
"id": "privacy",
"question": "Мой код в безопасности?",
"answer": "Всё работает локально на вашей машине. Никакие данные не отправляются на внешние серверы. Ваш код, разговоры и активность агентов остаются полностью приватными."
},
{
"id": "requirements",
"question": "Что нужно для начала?",
"answer": "Просто установите приложение — оно включает встроенную установку и аутентификацию Claude Code. Онбординг без настройки запустит вас за минуты."
}
],
"download": {
"title": "Скачать",
"note": "Выберите платформу и начните работать с командами ИИ-агентов."
},
"testimonials": [
{ "id": "user1", "name": "Алексей К.", "role": "Tech Lead", "text": "Наконец инструмент, который позволяет мне управлять ИИ-агентами так же, как я управляю инженерной командой. Канбан-доска — это прорыв для отслеживания параллельной работы агентов." },
{ "id": "user2", "name": "Сара М.", "role": "Full-stack разработчик", "text": "Соло-режим идеален для быстрых задач. Когда нужно больше мощности, развёртываю полную команду за секунды. Межкомандная коммуникация просто работает." },
{ "id": "user3", "name": "Дмитрий Р.", "role": "Senior Engineer", "text": "Ревью кода — блестяще реализовано. Diff-просмотр по задаче, одобрение/отклонение, комментарии. Как команда джунов, которые реально следуют инструкциям." },
{ "id": "user4", "name": "Юки Т.", "role": "DevOps Engineer", "text": "Мониторинг процессов и отслеживание контекста невероятно полезны. Вижу, что делает каждый агент и сколько контекста использует." },
{ "id": "user5", "name": "Мария С.", "role": "Инди-разработчик", "text": "Онбординг без настройки — реальность. Установила приложение, авторизовалась один раз, и агенты работали с моей кодовой базой за 5 минут. Без API-ключей, без конфигов." },
{ "id": "user6", "name": "Крис Л.", "role": "Startup CTO", "text": "Это полностью изменило мой подход к прототипированию. Настраиваю команды агентов для разных частей стека и пускаю их работать параллельно. 10x прирост продуктивности, без шуток." }
],
"pricing": [
{
"id": "free",
"name": "Бесплатно навсегда",
"price": "$0",
"period": "навсегда",
"description": "Всё включено. Без лимитов, без API-ключей, без кредитной карты.",
"features": [
"Безлимитные команды агентов",
"Канбан-доска с обновлениями в реальном времени",
"Код-ревью с diff-просмотром",
"Межкомандная коммуникация",
"Соло и командный режимы",
"Мониторинг живых процессов",
"Встроенный редактор кода",
"Интеграция MCP"
],
"highlighted": true
}
]
}

13
landing/data/content.ts Normal file
View file

@ -0,0 +1,13 @@
import en from "~/content/en.json";
import ru from "~/content/ru.json";
import type { LandingContent, LocalizedContent } from "~/types/content";
import type { LocaleCode } from "~/data/i18n";
export const contentByLocale: LocalizedContent = {
en,
ru
};
export const getContent = (locale: LocaleCode): LandingContent => {
return contentByLocale[locale] ?? contentByLocale.en;
};

29
landing/data/downloads.ts Normal file
View file

@ -0,0 +1,29 @@
export type DownloadOs = "macos" | "windows" | "linux";
export type DownloadArch = "arm64" | "x64" | "universal";
export const downloadAssets = [
{
id: "macos",
os: "macos",
arch: "universal",
label: "macOS",
archLabel: "Apple Silicon / Intel",
url: "https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI-arm64.dmg"
},
{
id: "windows-x64",
os: "windows",
arch: "x64",
label: "Windows",
archLabel: "64-bit",
url: "https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI-Setup.exe"
},
{
id: "linux-appimage",
os: "linux",
arch: "x64",
label: "Linux",
archLabel: "64-bit",
url: "https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI.AppImage"
}
] as const;

8
landing/data/faq.ts Normal file
View file

@ -0,0 +1,8 @@
export const faqItems = [
{ id: "whatIsIt" },
{ id: "isFree" },
{ id: "platforms" },
{ id: "howItWorks" },
{ id: "privacy" },
{ id: "requirements" }
] as const;

10
landing/data/features.ts Normal file
View file

@ -0,0 +1,10 @@
import { mdiAccountGroupOutline, mdiViewDashboardOutline, mdiCodeBracesBox, mdiMessageTextOutline, mdiAccountOutline, mdiChartTimelineVariant } from '@mdi/js'
export const features = [
{ id: "agentTeams", icon: mdiAccountGroupOutline, key: "agentTeams", accent: "#00f0ff" },
{ id: "kanban", icon: mdiViewDashboardOutline, key: "kanban", accent: "#ff00ff" },
{ id: "codeReview", icon: mdiCodeBracesBox, key: "codeReview", accent: "#39ff14" },
{ id: "crossTeam", icon: mdiMessageTextOutline, key: "crossTeam", accent: "#ffd700" },
{ id: "soloMode", icon: mdiAccountOutline, key: "soloMode", accent: "#00f0ff" },
{ id: "liveProcesses", icon: mdiChartTimelineVariant, key: "liveProcesses", accent: "#ff00ff" }
] as const;

38
landing/data/i18n.ts Normal file
View file

@ -0,0 +1,38 @@
export type LocaleCode = "en" | "ru";
export const supportedLocales = [
{ code: "en", iso: "en-US", name: "English", flag: "\u{1F1FA}\u{1F1F8}", file: "en.json" },
{ code: "ru", iso: "ru-RU", name: "Русский", flag: "\u{1F1F7}\u{1F1FA}", file: "ru.json" }
] as const;
export const defaultLocale: LocaleCode = "en";
export const pages = [
"/",
"/download"
] as const;
/** Pages for sitemap */
export const sitemapPages = [
"/",
"/download"
] as const;
/** Generates i18n routes for a given list of pages */
const buildI18nRoutes = (source: readonly string[]): string[] => {
const routes: string[] = [];
for (const page of source) {
routes.push(page);
for (const locale of supportedLocales) {
if (locale.code === defaultLocale) continue;
routes.push(page === "/" ? `/${locale.code}` : `/${locale.code}${page}`);
}
}
return routes;
};
/** All i18n routes (for prerender) */
export const generateI18nRoutes = (): string[] => buildI18nRoutes(pages);
/** i18n routes for sitemap only */
export const generateSitemapRoutes = (): string[] => buildI18nRoutes(sitemapPages);

9
landing/data/sections.ts Normal file
View file

@ -0,0 +1,9 @@
export const sectionOrder = [
"hero",
"features",
"screenshots",
"pricing",
"testimonials",
"download",
"faq"
] as const;

View file

@ -0,0 +1,8 @@
export const testimonials = [
{ id: "user1", avatar: "#00f0ff" },
{ id: "user2", avatar: "#ff00ff" },
{ id: "user3", avatar: "#39ff14" },
{ id: "user4", avatar: "#ffd700" },
{ id: "user5", avatar: "#00f0ff" },
{ id: "user6", avatar: "#ff00ff" },
] as const;

View file

@ -0,0 +1,49 @@
# Архитектурные инварианты (Landing)
Этот файл — про правила, которые защищают лендинг от “тихой деградации” (SEO/SSG/перф/поддерживаемость).
Если правило нарушается — это не вкусовщина, это потенциальный регресс.
## 1) SSG — не обсуждается
- Лендинг деплоится как **статик**. Мы не рассчитываем на backend на каждый запрос.
- Любые идеи “а давайте по IP определим язык/страницу” — это **отдельная задача** (edge/runtime), не часть текущего лендинга.
## 2) Контент и i18n — два слоя
- **Микрокопирайт** (кнопки/лейблы/мелкие подписи) — `landing/locales/*`.
- **Контент секций** (FAQ, список фич, провайдеры, тексты блоков) — `landing/content/{locale}.*` с одинаковой структурой.
- **Стабильные id**:
- `id` элементов (FAQ/фичи/провайдеры) не меняем.
- Меняем текст — да. Меняем `id` — только если сознательно ломаем совместимость и понимаем последствия.
## 3) Store-лимиты (чтобы не раздуть проект)
Pinia используем только там, где реально есть общий state:
- `theme` (dark/light)
- `locale` (выбранный язык)
- `download` (os/arch/preferredMacArch/selectedAsset)
Всё остальное — props + данные/контент. Никаких “store для секции Features”.
## 4) Источник правды по URL (i18n + SSG + sitemap)
Один источник правды:
- список поддерживаемых локалей
- список страниц, которые должны существовать
- правила генерации i18n-роутов
Из этого должны получаться:
- пререндер-роуты для SSG
- sitemap
- корректные `alternate`/`canonical`
## 5) Downloads — ответственность и процесс обновления
- Есть один способ обновлять релизы/ссылки, понятный команде.
- Если используем статичный `landing/data/downloads.ts`:
- обновление версии/URL/sha/размера — часть релиз-процесса
- перед деплоем прогоняем быструю проверку ссылок (хотя бы HEAD/GET на 200)
## 6) Аналитика и согласие
- По умолчанию **не отправляем аналитику до пользовательского действия**, если проекту нужен consent (зависит от политики/юрисдикции).
- Никаких персональных данных, никаких “текстов транскрипций”, никаких ключей.
## 7) Минимальные бюджеты качества
- Главная страница: Lighthouse без красных метрик (LCP/CLS).
- A11y: клавиатура/фокус/label/alt — не “по настроению”, а обязательный критерий готовности.

1027
landing/docs/LANDING_PLAN.md Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,52 @@
## Итерация 00 — фиксация требований и источников данных
### Цель
Согласовать “что именно делаем”, чтобы не переписывать UX/архитектуру на ходу.
### Входные решения (обязательные)
- **Карусель скриншотов**: Swiper Vue (`https://swiperjs.com/vue`), на десктопе видно **несколько** скринов одновременно.
- **Download Section**:
- Автоопределение ОС.
- Если не определили — показываем все ОС.
- Для macOS — учёт **Apple Silicon vs Intel** (если детект не точный — дать выбор пользователю).
- **Privacy**:
- Локальное хранение API ключей.
- Опциональное использование собственных API ключей.
- Нет облачного хранилища транскрипций.
- **Open Source**: да, часть компонентов/модулей будет open-source (маркетинг/доверие).
- **Статистика загрузок**: не делаем.
- **Аналитика**: GA4 + события скачивания и ключевых действий.
- **Деплой**: Render, **SSG**.
- **State management**: Pinia (общее состояние не размазываем по компонентам).
- **Code quality**: ESLint (`@nuxt/eslint`) + Prettier.
- **Структура**: добавляем `landing/data`, `landing/types`, `landing/utils` + правило `composables/` vs `utils/`.
### Важно про SSG (чтобы не было сюрпризов в конце)
- Лендинг остаётся **статическим**: без собственного backend на каждый запрос.
- Поэтому **IP-геолокацию на сервере не делаем** (в чистом SSG её просто негде исполнять).
- Автовыбор языка: **cookie/localStorage (если пользователь выбирал)****настройки браузера** → fallback на `en`. Всё остальное (гео, Accept-Language на сервере) — только если появится отдельный runtime/edge, но это уже другая задача.
### Решения/данные, которые нужно получить от владельца проекта
- **Релизы/артефакты**: где лежат ссылки на `.dmg/.exe/.msi/.deb/.AppImage`, как формируется URL (GitHub Releases / отдельный CDN).
- **macOS артефакты**: есть ли separate сборки `arm64` и `x64` или один universal.
- **GA4**:
- Measurement ID вида `G-XXXXXXXXXX` (и подтверждение, что GA4 property уже создан).
- Список обязательных событий/параметров (если есть требования маркетинга).
### Контракт по загрузкам (нужно согласовать один раз)
- **Источник правды**: GitHub Releases (рекомендуется) или статичный список в `landing/data/downloads.ts` (если релизы ведутся вручную).
- **Нейминг ассетов**: как минимум, чтобы можно было однозначно сопоставить `os + arch + extension` (например `VoiceToText-1.2.3-mac-arm64.dmg`).
- **Страница /download**: откуда берём “версия/размер/дата” (из релиза или руками в data).
- **Проверки**: если есть sha256/подпись — решаем, показываем ли это пользователю (желательно да).
### Критерии готовности
- Все пункты выше подтверждены.
- Есть источник правды по ссылкам загрузок и форматам релизов.
- Определены обязательные GA4 события и параметры.
- Подтверждено, что лендинг действительно деплоится как **статик** (SSG), без server runtime.
### Чеклист перепроверки (прогнать 23 раза)
- Нет “опциональных” фич, которые фактически обязательны.
- Нет скрытых зависимостей (например, server API для статистики).
- SSG и Render не конфликтуют с выбранными модулями.

View file

@ -0,0 +1,47 @@
## Итерация 01 — каркас проекта + качество кода + базовая архитектура
### Цель
Поднять Nuxt 3 проект под SSG, включить Vuetify, i18n, Pinia, ESLint+Prettier и заложить архитектурные правила.
### Ключевые принципы (чтобы потом не переделывать)
- Лендинг деплоится как **статик (SSG)**, поэтому не закладываем фичи, которые требуют сервер на каждый запрос.
- Источник правды по контенту: `landing/data/*` + `landing/locales/*`. Компоненты секций рендерят данные, а не “хранят смысл внутри себя”.
- Логика, которую хочется тестировать, живёт в `landing/utils/*`. Всё что про Nuxt/Vue — в `composables/*`.
### Шаги
1) **Создать проект `landing/` (Nuxt 3)**:
- SSG-ориентация: конфиг и скрипты сборки/генерации.
2) **Подключить Vuetify 3**:
- Material 3, темы (dark/light), сохранение выбора.
3) **Подключить i18n**:
- 6 языков, стратегия URL (prefix_except_default).
- Автовыбор языка при первом визите: cookie (если выбирали) → настройки браузера → fallback.
- Важно: IP-гео в рамках статического лендинга не используем.
4) **Подключить Pinia**:
- Stores для: темы, локали, платформы/архитектуры, ссылок загрузки.
5) **ESLint + Prettier**:
- `@nuxt/eslint`, Prettier, единые правила форматирования.
- Команды `lint`, `lint:fix`, `format`.
6) **Структура папок**:
- Добавить `landing/data`, `landing/types`, `landing/utils`.
- Зафиксировать правило: composables = Nuxt/Vue, utils = чистые функции.
### Выход (deliverables)
- Проект собирается и генерится в SSG режиме.
- Локализация/темы/Pinia подключены и работают в минимальном виде.
- Линтер/форматтер настроены, есть команды в `package.json`.
- Созданы заготовки данных и типов (без бизнес-логики секций).
- Есть короткий README как запускать/собирать локально (одинаково для всей команды).
### Критерии готовности
- `pnpm/yarn/npm` workflow согласован и описан.
- `generate`/`build`/`preview` проходят локально.
- ESLint и Prettier не конфликтуют (нет “пинпонга” форматирования).
- Подтвержден publish directory для статического хостинга (чтобы потом не гадать на деплое).
### Чеклист перепроверки (23 прохода)
- SSG: нет использования SSR-only API в runtime.
- i18n: URL стратегия соответствует требованиям SEO.
- Pinia: состояния не дублируются в компонентах.
- Минимальная страница открывается без ошибок с отключённым JS (хотя бы смысловой контент).

View file

@ -0,0 +1,45 @@
## Итерация 02 — страницы и секции (UI) на данных из `data/`
### Цель
Собрать UI лендинга с секциями из плана, но без “случайного” стейта в компонентах: данные/конфиги лежат в `landing/data`, общее состояние — в Pinia.
### Правила, чтобы лендинг было легко менять
- **Порядок секций** задаётся конфигом (например `landing/data/sections.ts`), чтобы можно было переставлять/скрывать блоки без переписывания `pages/index.vue`.
- **Тексты и контент**: разделяем “микрокопирайт” и “контент секций”.
- Микрокопирайт (кнопки, лейблы, короткие подписи) — через i18n (`landing/locales/*`).
- Контент секций (FAQ, список фич, описания провайдеров, тексты блоков) — через локализованные контент-файлы по структуре (например `landing/content/{locale}.ts` или `landing/content/{locale}.json`).
- В `landing/data/*` храним **структуру и стабильные id**, а не длинные строки и не “россыпь ключей”.
- Правило простое: чтобы изменить один пункт FAQ, не нужно искать 10 ключей — открыл контент-файл нужной локали и поправил.
- **UI-атомы** (`components/ui/*`) не знают про бизнес и не ходят в store. Они получают props и рендерят.
- **Секции** (`components/sections/*`) отвечают за композицию: берут данные из `data/*`, нужные значения из store и связывают это с UI.
### Шаги
1) **Layout**:
- `layouts/default.vue`: header/footer, контейнер, базовые отступы.
2) **Страница `/`**:
- Hero, Features, Providers, Screenshots, Download, Privacy, FAQ.
3) **Страница `/download`**:
- Платформо-специфичный блок + инструкции установки.
4) **Секция Privacy**:
- Чётко подсветить: локальное хранение ключей, опциональные ключи, отсутствие облачного хранилища.
- Отдельный блок: “часть компонентов open-source” (без излишних обещаний).
5) **i18n покрытие**:
- Все тексты вытащены в locales, не оставляем хардкод строк.
6) **Data-driven подход**:
- Карточки фич, FAQ, провайдеры, список платформ — из `landing/data/*`.
7) **Навигация по секциям**:
- Якоря/scroll в рамках страницы так, чтобы это было доступно (фокус/URL, без поломки истории).
### Критерии готовности
- Компоненты секций не содержат бизнес-данных “внутри себя”.
- Минимум логики внутри шаблонов, максимум — через computed/props и util-функции.
- i18n покрывает весь UI без пропусков.
- Любую секцию можно скрыть конфигом, и страница остаётся валидной.
- Для изображений есть корректные `alt`/подписи, а для кнопок — понятные названия/label.
### Чеклист перепроверки
- Нет дублирования конфигов секций.
- Секции легко переставить/скрыть, не ломая остальное.
- Нет лишних сторонних зависимостей “на всякий случай”.
- На мобильных нет “сломанных” отступов/переполнений (особенно в таблицах/списках).

View file

@ -0,0 +1,67 @@
## Итерация 03 — Swiper карусель + Download UX (OS + macOS arch)
### Цель
Сделать две “ключевые” UX части максимально качественно: карусель скриншотов и умный блок скачивания.
### Часть A: Screenshots (Swiper)
#### Требования
- Swiper Vue (`https://swiperjs.com/vue`)
- Desktop: на экране одновременно видно **несколько** скринов.
- Mobile: 1 скрин, свайп, pagination.
#### План реализации
- Компонент `ScreenshotCarousel.vue`:
- `breakpoints` для `slidesPerView` (примерно: 1 / 2 / 3 / 4 в зависимости от ширины)
- `spaceBetween`, `grabCursor`, `keyboard`, `a11y`
- `lazy` для изображений (если уместно)
- `landing/data/screenshots.ts`: массив скринов, подписи, alt-тексты, темы (dark/light)
#### Критерии готовности
- Desktop показывает 24 скрина одновременно.
- Навигация доступна клавиатурой.
- Alt тексты корректны.
- Изображения не “раздувают” страницу: есть ограничение по размерам, понятные форматы, нет тяжёлых файлов “на авось”.
### Часть B: Download Section (OS + macOS arch)
#### Требования
- Определяем ОС.
- Если неизвестно — показываем все.
- macOS: учитываем Apple Silicon vs Intel:
- если детект уверенный — автоселект
- если нет — выбор пользователю
#### План реализации
- `landing/utils/platform.ts`: чистые функции:
- parse platform (mac/windows/linux/unknown)
- parse arch (arm64/x64/unknown) через `userAgentData` где доступно
- важно: не “угадывать” там, где точности нет — лучше показать выбор и запомнить решение пользователя
- `landing/stores/downloadStore.ts`:
- хранит `platform`, `arch`, `preferredMacArch` (если пользователь выбрал), `selectedAsset`
- умеет fallback-логики и приоритеты отображения
- `landing/data/downloads.ts`:
- описывает доступные артефакты (os, arch, extension, url, label)
#### Контракт UI/данных (чтобы потом не разъезжалось)
- В `downloads.ts` у каждого ассета есть:
- `os`: `macos | windows | linux`
- `arch`: `arm64 | x64 | universal | unknown`
- `variant`: например `dmg | zip | exe | msi | appimage | deb | rpm` (что реально есть)
- `url`, `labelKey` (i18n), опционально `sha256`, `sizeBytes`
- Блок download умеет:
- показывать “рекомендованный” вариант сверху (если уверенно определили)
- всегда давать способ выбрать альтернативу (особенно для macOS)
- сохранять выбор пользователя (локально)
#### Критерии готовности
- Для “unknown” показывается полный список ОС.
- Для macOS всегда понятен выбор: Silicon/Intel.
- Выбор пользователя запоминается.
- Логика выбора ассета покрыта тестами на основные кейсы (хотя бы таблица кейсов).
- Все ссылки валидны (нет 404 в конфиге), плюс есть быстрый способ проверить это локально.
### Чеклист перепроверки (несколько проходов)
- Проверка на macOS Safari/Chrome, Windows, Linux (эмуляция user agent).
- A11y: keyboard, screen reader labels.
- Логика не завязана на Nuxt в `utils/`.
- Если `userAgentData` недоступен, UX остаётся понятным (никакой “магии”, только явный выбор).

View file

@ -0,0 +1,64 @@
## Итерация 04 — аналитика (GA4), SEO, SSG и деплой на Render
### Цель
Подключить аналитику без “магии” и сделать финальную полировку под SSG + Render.
### Аналитика (GA4)
#### Требования
- GA4, события:
- `download_click`
- `download_page_view`
- `language_change`
- `theme_change`
- `faq_open`
- `cta_view_features_click`
#### План
- Конфиг через env (например `NUXT_PUBLIC_GA_ID`).
- Единая утилита/композабл `useAnalytics()`:
- гарантирует отсутствие вызовов на сервере
- нормализует payload (platform, arch, locale, version)
- События из UI триггерятся централизованно (минимум дублей).
#### Важно
Я не могу “привязать к своему гугл аккаунту” или создать property сам — нужен Measurement ID/доступы от владельца. В плане и коде делаем всё так, чтобы подключение было 1 строкой после получения ID.
#### Минимальный контракт событий (чтобы аналитика была полезной)
- `download_click`: `os`, `arch`, `variant`, `version` (если есть), `locale`, `source` (hero/download-page/footer)
- `language_change`: `from`, `to`
- `theme_change`: `from`, `to`
- `faq_open`: `item_id`
- `cta_view_features_click`: `source`
- Все события: без персональных данных, без текста транскрипций, без key-материалов (приватность соблюдаем).
### SEO
- Meta теги по языкам, og/twitter, robots/sitemap.
- Проверка корректности canonical/alternate (i18n).
- Семантика: один `h1` на страницу, корректные заголовки секций (`h2/h3`), читаемая структура.
- Изображения: `alt`, `width/height` чтобы не было CLS.
### SSG
- Проверка, что все страницы генерятся.
- Нет runtime зависимостей от SSR.
- Это именно **SSG (пререндер страниц)**, а не SPA: важно для SEO.
- Отдельно проверяем генерацию i18n-роутов (все локали, которые включены).
### Render
- Документируем шаги деплоя под static hosting Render:
- build command
- publish directory
- переменные окружения (GA ID)
- Документируем кэширование статических ассетов (правильные headers на стороне Render).
### Критерии готовности
- События отправляются один раз и с корректными параметрами.
- SSG сборка стабильна.
- Документирован деплой на Render.
- Lighthouse (desktop/mobile) без красных метрик на главной (особенно LCP/CLS).
### Чеклист перепроверки (23 прохода)
- События: нет лишних/дублирующихся триггеров.
- SEO: title/description на всех локалях.
- Статические ассеты оптимизированы (разумные размеры).
- Нет трекинга до пользовательского действия, если потребуется баннер согласия (это заранее решаем политикой проекта).

View file

@ -0,0 +1,18 @@
# Landing (Voice-to-Text) — планы итераций
Здесь лежат **планы итераций** для разработки лендинга.
Правило процесса:
1) Сначала уточняем требования и фиксируем их в планах итераций (максимально подробно).
2) Затем **несколько раз перепроверяем** планы (логика, полнота, риски, несостыковки, критерии “готово”).
3) Только после этого начинаем реализацию строго по шагам.
Общее правило качества (Definition of Done для любой итерации):
- **SSG-совместимость**: нет логики, которая требует сервер на каждом запросе (лендинг — статический).
- **Контент редактируемый**: тексты/списки/ссылки лежат в `landing/data/*` и `landing/content/*`/`landing/locales/*`, а не “зашиты” в секциях.
- **SEO**: корректные `title/description`, `og/twitter`, `canonical`, `alternate` (i18n), sitemap/robots.
- **A11y**: навигация с клавиатуры, корректные подписи/alt, адекватный фокус, контраст.
- **Производительность**: изображения оптимизированы, нет тяжёлых блокирующих ресурсов, разумные размеры бандла.
- **Проверяемость**: ключевая логика (platform/arch, выбор ассета) вынесена в `utils/` и покрыта тестами (минимум smoke).
Рекомендация: держать под рукой `landing/docs/ARCHITECTURE_GUARDRAILS.md` — там перечислены инварианты, которые защищают от регрессов (SSG, i18n, sitemap, downloads, analytics).

245
landing/error.vue Normal file
View file

@ -0,0 +1,245 @@
<script setup lang="ts">
import { mdiHome } from '@mdi/js'
import type { NuxtError } from "#app";
const props = defineProps<{
error: NuxtError;
}>();
const { t } = useI18n();
const statusCode = computed(() => props.error?.statusCode || 404);
const isNotFound = computed(() => statusCode.value === 404);
const handleGoHome = () => clearError({ redirect: "/" });
</script>
<template>
<v-app>
<div class="error-page">
<!-- Фоновые орбы -->
<div class="error-page__bg">
<div class="error-page__orb error-page__orb--1" />
<div class="error-page__orb error-page__orb--2" />
<div class="error-page__grid-pattern" />
</div>
<v-container class="error-page__container">
<!-- Код ошибки -->
<span class="error-page__code">{{ statusCode }}</span>
<!-- Заголовок -->
<h1 class="error-page__title">
{{ isNotFound ? t("error.notFoundTitle") : t("error.genericTitle") }}
</h1>
<!-- Описание -->
<p class="error-page__description">
{{ isNotFound ? t("error.notFoundDescription") : t("error.genericDescription") }}
</p>
<!-- Кнопка -->
<v-btn
size="large"
color="primary"
class="error-page__btn"
@click="handleGoHome"
>
<v-icon start :icon="mdiHome" />
{{ t("error.goHome") }}
</v-btn>
</v-container>
</div>
</v-app>
</template>
<style scoped>
.error-page {
position: relative;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
/* ─── Background ─── */
.error-page__bg {
position: absolute;
inset: 0;
pointer-events: none;
overflow: hidden;
}
.error-page__orb {
position: absolute;
border-radius: 50%;
filter: blur(120px);
opacity: 0.1;
}
.error-page__orb--1 {
width: 600px;
height: 600px;
background: #6366f1;
top: -200px;
right: -100px;
animation: orbDrift1 20s ease-in-out infinite;
}
.error-page__orb--2 {
width: 450px;
height: 450px;
background: #ec4899;
bottom: -150px;
left: -80px;
animation: orbDrift2 25s ease-in-out infinite;
}
.error-page__grid-pattern {
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(99, 102, 241, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(99, 102, 241, 0.03) 1px, transparent 1px);
background-size: 48px 48px;
mask-image: radial-gradient(ellipse 80% 70% at 50% 50%, black, transparent);
}
@keyframes orbDrift1 {
0%, 100% { transform: translate(0, 0); }
33% { transform: translate(25px, 15px); }
66% { transform: translate(-15px, 10px); }
}
@keyframes orbDrift2 {
0%, 100% { transform: translate(0, 0); }
33% { transform: translate(-20px, -10px); }
66% { transform: translate(10px, -20px); }
}
/* ─── Content ─── */
.error-page__container {
position: relative;
z-index: 1;
text-align: center;
max-width: 600px;
}
.error-page__code {
display: block;
font-size: 8rem;
font-weight: 800;
letter-spacing: -0.04em;
line-height: 1;
margin-bottom: 16px;
background: linear-gradient(135deg, #6366f1 0%, #ec4899 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
animation: fadeIn 0.6s ease both;
}
.error-page__title {
font-size: 2rem;
font-weight: 700;
letter-spacing: -0.02em;
margin-bottom: 12px;
animation: fadeIn 0.6s ease both;
animation-delay: 0.1s;
}
.error-page__description {
font-size: 1.1rem;
line-height: 1.6;
opacity: 0.6;
margin-bottom: 36px;
animation: fadeIn 0.6s ease both;
animation-delay: 0.2s;
}
.error-page__btn {
font-weight: 600 !important;
animation: fadeIn 0.6s ease both;
animation-delay: 0.3s;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ─── Dark ─── */
.v-theme--dark .error-page__orb {
opacity: 0.14;
}
.v-theme--dark .error-page__orb--1 {
background: #818cf8;
}
.v-theme--dark .error-page__orb--2 {
background: #f472b6;
}
.v-theme--dark .error-page__code {
background: linear-gradient(135deg, #a5b4fc 0%, #f9a8d4 100%);
-webkit-background-clip: text;
background-clip: text;
}
.v-theme--dark .error-page__title {
color: #e2e8f0;
}
.v-theme--dark .error-page__description {
color: #94a3b8;
opacity: 0.8;
}
.v-theme--dark .error-page__grid-pattern {
background-image:
linear-gradient(rgba(129, 140, 248, 0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(129, 140, 248, 0.04) 1px, transparent 1px);
}
/* ─── Light ─── */
.v-theme--light .error-page__orb {
opacity: 0.06;
}
.v-theme--light .error-page__code {
background: linear-gradient(135deg, #4f46e5 0%, #db2777 100%);
-webkit-background-clip: text;
background-clip: text;
}
.v-theme--light .error-page__title {
color: #1e293b;
}
.v-theme--light .error-page__description {
color: #475569;
}
/* ─── Responsive ─── */
@media (max-width: 600px) {
.error-page__code {
font-size: 5rem;
}
.error-page__title {
font-size: 1.5rem;
}
.error-page__description {
font-size: 0.95rem;
}
}
</style>

View file

@ -0,0 +1,6 @@
// @ts-check
import withNuxt from './.nuxt/eslint.config.mjs'
export default withNuxt(
// Your custom configs here
)

View file

@ -0,0 +1,16 @@
<template>
<v-app class="app-layout">
<AppHeader />
<main class="app-layout__main">
<slot />
</main>
<AppFooter />
</v-app>
</template>
<style scoped>
.app-layout__main {
flex: 1;
padding-top: 64px;
}
</style>

110
landing/locales/en.json Normal file
View file

@ -0,0 +1,110 @@
{
"nav": {
"features": "Features",
"comparison": "Compare",
"download": "Download",
"faq": "FAQ",
"viewOnGithub": "View on GitHub"
},
"hero": {
"badge": "Claude Agent Teams",
"downloadNow": "Download Now",
"ctaPrimary": "Download for {platform}",
"ctaSecondary": "View Features",
"preview": "Product preview",
"trust": {
"agentTeams": "Agent Teams",
"kanban": "Kanban Board",
"openSource": "Open Source"
},
"watchDemo": "Watch Demo",
"videoUnavailable": "Video unavailable"
},
"download": {
"title": "Download",
"detected": "Detected",
"systemRequirements": "System requirements",
"version": "Version {version}"
},
"theme": {
"dark": "Dark",
"light": "Light"
},
"language": {
"label": "Language"
},
"features": {
"sectionTitle": "Everything you need for AI agent orchestration",
"sectionSubtitle": "Powerful tools that make multi-agent collaboration actually work."
},
"pricing": {
"sectionTitle": "100% Free. No strings attached.",
"sectionSubtitle": "Open source, no API keys, no configuration. Just install and go.",
"getStarted": "Download Now",
"popular": "Free Forever",
"note": "100% open source. No API keys. No configuration. Runs entirely locally."
},
"testimonials": {
"sectionTitle": "What developers say",
"sectionSubtitle": "Real feedback from real builders",
"showMore": "Show more",
"showLess": "Show less",
"feedbackCta": "Want to share your experience? Open an issue on"
},
"faq": {
"sectionTitle": "Got questions? We've got answers",
"subtitle": "Everything you need to know about Claude Agent Teams"
},
"comparison": {
"sectionTitle": "How we compare",
"sectionSubtitle": "Feature-by-feature comparison with other AI coding tools.",
"feature": "Feature",
"features": {
"crossTeam": "Cross-team communication",
"agentMessaging": "Agent-to-agent messaging",
"linkedTasks": "Linked tasks",
"sessionAnalysis": "Session analysis",
"taskAttachments": "Task attachments",
"hunkReview": "Hunk-level review",
"codeEditor": "Built-in code editor",
"fullAutonomy": "Full autonomy",
"taskDeps": "Task dependencies",
"reviewWorkflow": "Review workflow",
"zeroSetup": "Zero setup",
"kanban": "Kanban board",
"execLog": "Execution log viewer",
"liveProcesses": "Live processes",
"perTaskReview": "Per-task code review",
"flexAutonomy": "Flexible autonomy",
"worktree": "Git worktree isolation",
"multiAgent": "Multi-agent backend",
"price": "Price"
}
},
"screenshots": {
"sectionTitle": "See it in action",
"sectionSubtitle": "Real screenshots from the app — kanban board, code review, agent teams, and more."
},
"common": {
"learnMore": "Learn more"
},
"footer": {
"copyright": "© {year} Claude Agent Teams",
"tagline": "AI agent orchestration for developers",
"links": {
"github": "GitHub",
"docs": "Documentation"
}
},
"meta": {
"homeTitle": "Claude Agent Teams — AI Agent Orchestration for Developers",
"homeDescription": "Free, open-source desktop app for assembling AI agent teams. Kanban board, code review, cross-team communication. Runs entirely locally."
},
"error": {
"notFoundTitle": "Page not found",
"notFoundDescription": "The page you are looking for does not exist or has been moved.",
"genericTitle": "Something went wrong",
"genericDescription": "An unexpected error occurred. Please try again later.",
"goHome": "Go to homepage"
}
}

110
landing/locales/ru.json Normal file
View file

@ -0,0 +1,110 @@
{
"nav": {
"features": "Возможности",
"comparison": "Сравнение",
"download": "Скачать",
"faq": "FAQ",
"viewOnGithub": "View on GitHub"
},
"hero": {
"badge": "Claude Agent Teams",
"downloadNow": "Скачать",
"ctaPrimary": "Скачать для {platform}",
"ctaSecondary": "Посмотреть возможности",
"preview": "Превью продукта",
"trust": {
"agentTeams": "Команды агентов",
"kanban": "Канбан-доска",
"openSource": "Open Source"
},
"watchDemo": "Смотреть демо",
"videoUnavailable": "Видео недоступно"
},
"download": {
"title": "Скачать",
"detected": "Определено",
"systemRequirements": "Системные требования",
"version": "Версия {version}"
},
"theme": {
"dark": "Тёмная",
"light": "Светлая"
},
"language": {
"label": "Язык"
},
"features": {
"sectionTitle": "Всё для оркестрации ИИ-агентов",
"sectionSubtitle": "Мощные инструменты, которые делают мультиагентную совместную работу реальностью."
},
"pricing": {
"sectionTitle": "100% Бесплатно. Без подвоха.",
"sectionSubtitle": "Открытый код, без API-ключей, без конфигурации. Просто установите и работайте.",
"getStarted": "Скачать",
"popular": "Бесплатно навсегда",
"note": "100% открытый код. Без API-ключей. Без конфигурации. Работает полностью локально."
},
"testimonials": {
"sectionTitle": "Что говорят разработчики",
"sectionSubtitle": "Реальные отзывы от реальных разработчиков",
"showMore": "Показать ещё",
"showLess": "Свернуть",
"feedbackCta": "Хотите поделиться опытом? Создайте issue на"
},
"faq": {
"sectionTitle": "Есть вопросы? У нас есть ответы",
"subtitle": "Всё, что нужно знать о Claude Agent Teams"
},
"comparison": {
"sectionTitle": "Сравнение с конкурентами",
"sectionSubtitle": "Подробное сравнение возможностей с другими AI-инструментами для разработки.",
"feature": "Возможность",
"features": {
"crossTeam": "Межкомандная коммуникация",
"agentMessaging": "Обмен сообщениями между агентами",
"linkedTasks": "Связанные задачи",
"sessionAnalysis": "Анализ сессий",
"taskAttachments": "Вложения к задачам",
"hunkReview": "Ревью на уровне хунков",
"codeEditor": "Встроенный редактор кода",
"fullAutonomy": "Полная автономность",
"taskDeps": "Зависимости задач",
"reviewWorkflow": "Процесс ревью",
"zeroSetup": "Без настройки",
"kanban": "Канбан-доска",
"execLog": "Просмотр логов выполнения",
"liveProcesses": "Живые процессы",
"perTaskReview": "Код-ревью по задачам",
"flexAutonomy": "Гибкая автономность",
"worktree": "Изоляция Git worktree",
"multiAgent": "Мультиагентный бэкенд",
"price": "Цена"
}
},
"screenshots": {
"sectionTitle": "Посмотрите в действии",
"sectionSubtitle": "Реальные скриншоты приложения — канбан-доска, код-ревью, команды агентов и многое другое."
},
"common": {
"learnMore": "Подробнее"
},
"footer": {
"copyright": "© {year} Claude Agent Teams",
"tagline": "Оркестрация ИИ-агентов для разработчиков",
"links": {
"github": "GitHub",
"docs": "Документация"
}
},
"meta": {
"homeTitle": "Claude Agent Teams — Оркестрация ИИ-агентов для разработчиков",
"homeDescription": "Бесплатное десктопное приложение с открытым кодом для управления командами ИИ-агентов. Канбан-доска, код-ревью, межкомандная коммуникация. Работает полностью локально."
},
"error": {
"notFoundTitle": "Страница не найдена",
"notFoundDescription": "Страница, которую вы ищете, не существует или была перемещена.",
"genericTitle": "Что-то пошло не так",
"genericDescription": "Произошла непредвиденная ошибка. Попробуйте позже.",
"goHome": "На главную"
}
}

99
landing/nuxt.config.ts Normal file
View file

@ -0,0 +1,99 @@
import vuetify from "vite-plugin-vuetify";
import { generateI18nRoutes, supportedLocales } from "./data/i18n";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
declare const process: any;
const siteUrl = process.env.NUXT_PUBLIC_SITE_URL || "https://claude-agent-teams.dev";
const githubRepo = process.env.NUXT_PUBLIC_GITHUB_REPO || "777genius/claude_agent_teams_ui";
const githubReleasesUrl = `https://github.com/${githubRepo}/releases`;
export default defineNuxtConfig({
compatibilityDate: "2026-01-19",
ssr: true,
experimental: {
inlineSSRStyles: false
},
app: {
head: {
link: [
{ rel: "icon", type: "image/x-icon", href: "/favicon.ico" },
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
{ rel: "preconnect", href: "https://fonts.gstatic.com", crossorigin: "" },
{ rel: "preload", href: "https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&family=JetBrains+Mono:wght@400;600&display=swap", as: "style" },
{ rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&family=JetBrains+Mono:wght@400;600&display=swap" }
]
}
},
modules: [
"@pinia/nuxt",
"@nuxtjs/i18n",
"@vueuse/nuxt",
"nuxt-icon",
"@nuxt/eslint"
],
css: ["~/assets/styles/main.scss"],
components: [
{
path: "~/components",
pathPrefix: false
}
],
build: {
transpile: ["vuetify"]
},
vue: {
compilerOptions: {
isCustomElement: (tag: string) => tag.startsWith("swiper-")
}
},
vite: {
plugins: [vuetify({ autoImport: true })]
},
nitro: {
compressPublicAssets: true,
prerender: {
routes: [
...generateI18nRoutes(),
]
}
},
routeRules: {
"/_nuxt/**": {
headers: { "Cache-Control": "public, max-age=31536000, immutable" }
}
},
i18n: {
restructureDir: false,
locales: [...supportedLocales] as any,
defaultLocale: "en",
strategy: "prefix_except_default",
lazy: true,
langDir: "locales",
bundle: {
optimizeTranslationDirective: false
},
detectBrowserLanguage: {
useCookie: true,
cookieKey: "i18n_redirected",
redirectOn: "root",
alwaysRedirect: false,
fallbackLocale: "en"
}
},
// @ts-expect-error - field provided by nuxt modules
site: {
url: siteUrl,
name: "Claude Agent Teams"
},
runtimeConfig: {
github: {
token: process.env.GITHUB_TOKEN
},
public: {
siteUrl,
githubRepo,
githubReleasesUrl
}
}
});

37
landing/package.json Normal file
View file

@ -0,0 +1,37 @@
{
"name": "claude-agent-teams-landing",
"private": true,
"type": "module",
"scripts": {
"dev": "nuxt dev",
"build": "nuxt build",
"generate": "nuxt generate",
"preview": "nuxt preview",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier . --check",
"format:fix": "prettier . --write"
},
"dependencies": {
"@mdi/js": "^7.4.47",
"@nuxtjs/i18n": "^9.5.6",
"@pinia/nuxt": "^0.11.3",
"@vueuse/nuxt": "^10.11.1",
"nuxt": "^3.20.2",
"nuxt-icon": "^0.6.10",
"pinia": "^3.0.4",
"swiper": "^12.1.2",
"vue": "^3.5.27",
"vue-i18n": "^11.2.8",
"vue-router": "^4.6.4",
"vuetify": "^3.11.6"
},
"devDependencies": {
"@mdi/font": "^7.4.47",
"@nuxt/eslint": "^1.12.1",
"eslint": "^9.39.2",
"prettier": "^3.8.0",
"sass": "^1.97.2",
"vite-plugin-vuetify": "^2.1.3"
}
}

View file

@ -0,0 +1,13 @@
<script setup lang="ts">
const { content } = useLandingContent();
usePageSeo("meta.homeTitle", "meta.homeDescription");
</script>
<template>
<v-container class="section">
<h1 class="text-h4 section-title">{{ content.download.title }}</h1>
<p class="text-body-2 mb-6">{{ content.download.note }}</p>
<DownloadSection />
</v-container>
</template>

24
landing/pages/index.vue Normal file
View file

@ -0,0 +1,24 @@
<script setup lang="ts">
usePageSeo("meta.homeTitle", "meta.homeDescription");
useTrackSections();
const { containerRef } = useParallaxSections();
</script>
<template>
<div ref="containerRef" class="page">
<PageBackground />
<HeroSection />
<SectionDivider />
<LazyScreenshotsSection />
<SectionDivider :flip="true" />
<LazyDownloadSection />
<SectionDivider />
<LazyComparisonSection />
<SectionDivider :flip="true" />
<LazyPricingSection />
<SectionDivider />
<LazyFAQSection />
<!-- <LazyFeaturesSection /> -->
<!-- <LazyTestimonialsSection /> -->
</div>
</template>

View file

@ -0,0 +1,14 @@
export default defineNuxtPlugin({
name: "init-theme-locale",
dependsOn: ["vuetify"],
setup(nuxtApp) {
const { initTheme } = useBrowserTheme();
const { initLocale } = useLocation();
// Run after hydration to avoid SSR/CSR mismatches.
nuxtApp.hook("app:mounted", () => {
initTheme();
initLocale();
});
}
});

View file

@ -0,0 +1,40 @@
import "vuetify/styles";
import { createVuetify } from "vuetify";
import { aliases, mdi } from "vuetify/iconsets/mdi-svg";
export default defineNuxtPlugin({
name: "vuetify",
setup(nuxtApp) {
const vuetify = createVuetify({
icons: {
defaultSet: "mdi",
aliases,
sets: { mdi }
},
theme: {
defaultTheme: "dark",
themes: {
light: {
colors: {
primary: "#00f0ff",
secondary: "#ff00ff",
background: "#f0f2f5",
surface: "#ffffff"
}
},
dark: {
colors: {
primary: "#00f0ff",
secondary: "#ff00ff",
background: "#0a0a0f",
surface: "#12121a"
}
}
}
}
});
nuxtApp.vueApp.use(vuetify);
nuxtApp.provide("vuetifyTheme", vuetify.theme);
}
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
landing/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

BIN
landing/public/logo-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
landing/public/og-image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 588 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 664 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 674 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 597 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 666 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 770 KiB

View file

@ -0,0 +1,45 @@
import { defineStore } from "pinia";
import { downloadAssets } from "~/data/downloads";
import type { DownloadArch, DownloadOs } from "~/data/downloads";
import { detectMacArch, detectPlatform } from "~/utils/platform";
export const useDownloadStore = defineStore("download", {
state: () => ({
os: "unknown" as DownloadOs | "unknown",
arch: "unknown" as DownloadArch | "unknown",
selectedId: ""
}),
getters: {
assets: () => downloadAssets,
selectedAsset(state) {
return downloadAssets.find((asset) => asset.id === state.selectedId);
},
isMacOs(state): boolean {
return state.os === "macos";
},
macArch(state): "arm64" | "x64" {
return state.arch === "arm64" ? "arm64" : "x64";
}
},
actions: {
init() {
if (!process.client) return;
const ua = navigator.userAgent;
const os = detectPlatform(ua);
this.os = os === "unknown" ? "unknown" : os;
if (this.os === "macos") {
this.arch = detectMacArch(ua) as DownloadArch;
} else if (this.os !== "unknown") {
this.arch = "x64";
}
// Для macOS — одна карточка, матчим по OS
const match = downloadAssets.find((asset) => asset.os === this.os);
if (match) {
this.selectedId = match.id;
}
},
setSelected(id: string) {
this.selectedId = id;
}
}
});

16
landing/stores/locale.ts Normal file
View file

@ -0,0 +1,16 @@
import { defineStore } from "pinia";
export const useLocaleStore = defineStore("locale", {
state: () => ({
current: "en",
userSelected: false
}),
actions: {
setLocale(locale: string, fromUser: boolean) {
this.current = locale;
if (fromUser) {
this.userSelected = true;
}
}
}
});

31
landing/stores/theme.ts Normal file
View file

@ -0,0 +1,31 @@
import { defineStore } from "pinia";
type ThemeName = "light" | "dark";
export const useThemeStore = defineStore("theme", {
state: () => ({
current: "dark" as ThemeName,
userSelected: false
}),
actions: {
getInitialTheme(): ThemeName {
if (!process.client) return "dark";
const saved = localStorage.getItem("theme");
if (saved === "dark" || saved === "light") {
this.userSelected = true;
return saved;
}
if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
return "dark";
}
return "dark";
},
setTheme(theme: ThemeName, fromUser: boolean) {
this.current = theme;
if (process.client && fromUser) {
this.userSelected = true;
localStorage.setItem("theme", theme);
}
}
}
});

9
landing/tsconfig.json Normal file
View file

@ -0,0 +1,9 @@
{
"extends": "./.nuxt/tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"~/*": ["./*"]
}
}
}

51
landing/types/content.ts Normal file
View file

@ -0,0 +1,51 @@
import type { LocaleCode } from "~/data/i18n";
export interface FeatureItem {
id: string;
title: string;
description: string;
}
export interface FaqItem {
id: string;
question: string;
answer: string;
}
export interface HeroContent {
title: string;
subtitle: string;
}
export interface DownloadContent {
title: string;
note: string;
}
export interface PricingPlan {
id: string;
name: string;
price: string;
period: string;
description: string;
features: string[];
highlighted?: boolean;
}
export interface TestimonialItem {
id: string;
name: string;
role: string;
text: string;
}
export interface LandingContent {
hero: HeroContent;
features: FeatureItem[];
faq: FaqItem[];
download: DownloadContent;
pricing: PricingPlan[];
testimonials: TestimonialItem[];
}
export type LocalizedContent = Record<LocaleCode, LandingContent>;

View file

@ -0,0 +1,2 @@
export type PlatformOs = "macos" | "windows" | "linux" | "unknown";
export type PlatformArch = "arm64" | "x64" | "universal" | "unknown";

34
landing/utils/platform.ts Normal file
View file

@ -0,0 +1,34 @@
import type { PlatformArch, PlatformOs } from "~/types/platform";
export const detectPlatform = (userAgent: string): PlatformOs => {
const ua = userAgent.toLowerCase();
if (ua.includes("mac")) return "macos";
if (ua.includes("win")) return "windows";
if (ua.includes("linux")) return "linux";
return "unknown";
};
export const detectMacArch = (userAgent: string): PlatformArch => {
const ua = userAgent.toLowerCase();
if (ua.includes("arm") || ua.includes("aarch64")) return "arm64";
// Браузеры на Apple Silicon всё равно шлют "Intel Mac OS X" в UA,
// поэтому проверяем GPU через WebGL — Apple Silicon репортится как "Apple M1/M2/..."
if (typeof document !== "undefined") {
try {
const canvas = document.createElement("canvas");
const gl = canvas.getContext("webgl2") || canvas.getContext("webgl");
if (gl) {
const dbg = gl.getExtension("WEBGL_debug_renderer_info");
if (dbg) {
const renderer = gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) as string;
if (/apple\s*m\d|apple\s*gpu/i.test(renderer)) return "arm64";
}
}
} catch {
// WebGL недоступен — fallback на x64
}
}
return "x64";
};