merge: bring landing visual updates into dev

This commit is contained in:
777genius 2026-05-17 23:51:25 +03:00
commit f4fb428b80
51 changed files with 3198 additions and 1001 deletions

2
landing/.gitignore vendored
View file

@ -3,6 +3,8 @@ node_modules
.output
.dist
.env
--host/
product-docs/.vitepress/dist/
# Large video files
public/video/*.mp4

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 570 KiB

File diff suppressed because it is too large Load diff

View file

@ -3,7 +3,7 @@ const { baseURL } = useRuntimeConfig().app;
</script>
<template>
<NuxtLink to="/" class="app-logo">
<NuxtLink to="/" class="app-logo" :prefetch="false">
<img
:src="`${baseURL}logo-192.png`"
alt="Agent Teams"

View file

@ -6,10 +6,11 @@ const { isDark, toggleTheme } = useBrowserTheme();
const { trackThemeToggle } = useAnalytics();
const tooltip = computed(() => isDark.value ? t('theme.light') : t('theme.dark'));
const icon = computed(() => isDark.value ? mdiWeatherNight : mdiWeatherSunny);
const onToggle = () => {
toggleTheme();
trackThemeToggle(isDark.value ? 'dark' : 'light');
const theme = toggleTheme();
trackThemeToggle(theme);
};
</script>
@ -19,7 +20,7 @@ const onToggle = () => {
<template #activator="{ props }">
<v-btn
v-bind="props"
:icon="isDark ? mdiWeatherSunny : mdiWeatherNight"
:icon="icon"
variant="text"
size="small"
:aria-label="tooltip"

View file

@ -1,62 +0,0 @@
<script setup lang="ts">
import { HERO_SCENE_VIEWBOX, type HeroConnection } from "~/data/heroScene";
defineProps<{
connections: readonly HeroConnection[];
activeConnectionId?: string | null;
reducedMotion?: boolean;
}>();
</script>
<template>
<svg
class="cyber-connectors"
:viewBox="`0 0 ${HERO_SCENE_VIEWBOX.width} ${HERO_SCENE_VIEWBOX.height}`"
aria-hidden="true"
>
<g class="cyber-connectors__paths">
<template v-for="connection in connections" :key="connection.id">
<path
class="cyber-connectors__path-glow"
:class="[
`cyber-connectors__path-glow--${connection.accent}`,
{ 'cyber-connectors__path-glow--active': activeConnectionId === connection.id },
]"
:d="connection.pathDesktop"
vector-effect="non-scaling-stroke"
/>
<path
:id="`cyber-path-${connection.id}`"
class="cyber-connectors__path"
:class="[
`cyber-connectors__path--${connection.accent}`,
{ 'cyber-connectors__path--active': activeConnectionId === connection.id },
]"
:d="connection.pathDesktop"
vector-effect="non-scaling-stroke"
/>
</template>
</g>
<g v-if="!reducedMotion" class="cyber-connectors__packets">
<circle
v-for="connection in connections"
:key="`packet-${connection.id}`"
class="cyber-connectors__packet"
:class="[
`cyber-connectors__packet--${connection.accent}`,
{ 'cyber-connectors__packet--active': activeConnectionId === connection.id },
]"
r="4"
>
<animateMotion
:dur="`${connection.packetDurationMs}ms`"
repeatCount="indefinite"
:begin="`${connection.packetDelayMs}ms`"
>
<mpath :href="`#cyber-path-${connection.id}`" />
</animateMotion>
</circle>
</g>
</svg>
</template>

View file

@ -6,7 +6,19 @@ import {
mdiShieldCheckOutline,
mdiMonitorDashboard,
} from "@mdi/js";
import { heroFeatureRail } from "~/data/heroScene";
import {
heroCollaborationFeature,
heroFeatureRail,
heroReviewerFeatureCard,
type HeroMessage,
type HeroMessagePhase,
} from "~/data/heroScene";
const props = defineProps<{
activeMessage?: HeroMessage | null;
phase?: HeroMessagePhase;
reducedMotion?: boolean;
}>();
const icons = [
mdiRobotOutline,
@ -15,21 +27,85 @@ const icons = [
mdiShieldCheckOutline,
mdiMonitorDashboard,
] as const;
const reviewerIsSender = computed(() =>
props.activeMessage?.from === "reviewer" && props.phase !== "cooldown",
);
const reviewerIsReceiver = computed(() =>
props.activeMessage?.to === "reviewer" && props.phase === "receiver",
);
const reviewerIsActive = computed(() => reviewerIsSender.value || reviewerIsReceiver.value);
const reviewerBubbleText = computed(() => {
if (!props.activeMessage || props.reducedMotion) return null;
if (props.activeMessage.from === "reviewer" && (props.phase === "sender" || props.phase === "packet")) {
return props.activeMessage.text;
}
if (props.activeMessage.to === "reviewer" && props.phase === "receiver") {
return props.activeMessage.response;
}
return null;
});
</script>
<template>
<div class="cyber-feature-rail cyber-panel">
<div
v-for="(feature, index) in heroFeatureRail"
:key="feature.id"
class="cyber-feature-rail__item"
<div class="cyber-feature-rail-shell">
<img
class="cyber-feature-rail__collaboration"
:src="heroCollaborationFeature.asset"
alt=""
loading="lazy"
decoding="async"
aria-hidden="true"
>
<div class="cyber-feature-rail__icon">
<v-icon :icon="icons[index]" size="28" />
<div
class="cyber-feature-rail__reviewer"
:class="{
'cyber-feature-rail__reviewer--active': reviewerIsActive,
'cyber-feature-rail__reviewer--sending': reviewerIsSender,
'cyber-feature-rail__reviewer--receiving': reviewerIsReceiver,
}"
aria-hidden="true"
>
<Transition name="cyber-feature-bubble">
<CyberHeroSpeechBubble
v-if="reviewerBubbleText"
class="cyber-feature-rail__reviewer-bubble"
role="reviewer"
>
{{ reviewerBubbleText }}
</CyberHeroSpeechBubble>
</Transition>
<div class="cyber-feature-rail__reviewer-card cyber-panel">
<div class="cyber-feature-rail__reviewer-label">{{ heroReviewerFeatureCard.label }}</div>
<ul class="cyber-feature-rail__reviewer-tasks">
<li v-for="task in heroReviewerFeatureCard.tasks" :key="task">{{ task }}</li>
</ul>
<div class="cyber-feature-rail__reviewer-status">
<span>Status:</span>
<strong>{{ heroReviewerFeatureCard.status }}</strong>
</div>
</div>
<div class="cyber-feature-rail__copy">
<div class="cyber-feature-rail__title">{{ feature.title }}</div>
<div class="cyber-feature-rail__text">{{ feature.text }}</div>
<img
class="cyber-feature-rail__robot"
:src="heroReviewerFeatureCard.asset"
alt=""
loading="lazy"
decoding="async"
>
</div>
<div class="cyber-feature-rail cyber-panel">
<div
v-for="(feature, index) in heroFeatureRail"
:key="feature.id"
class="cyber-feature-rail__item"
>
<div class="cyber-feature-rail__icon">
<v-icon :icon="icons[index]" size="28" />
</div>
<div class="cyber-feature-rail__copy">
<div class="cyber-feature-rail__title">{{ feature.title }}</div>
<div class="cyber-feature-rail__text">{{ feature.text }}</div>
</div>
</div>
</div>
</div>

View file

@ -1,9 +1,9 @@
<script setup lang="ts">
import type { HeroMessage } from "~/data/heroScene";
import type { HeroMessage, HeroMessagePhase } from "~/data/heroScene";
const props = defineProps<{
message: HeroMessage | null;
phase: "sender" | "packet" | "receiver" | "cooldown";
phase: HeroMessagePhase;
reducedMotion?: boolean;
}>();
@ -17,34 +17,40 @@ const receiverStyle = computed(() => ({
"--bubble-y": props.message ? String(props.message.toY) : "0",
}));
const showSender = computed(() => props.message && (props.phase === "sender" || props.phase === "packet"));
const showReceiver = computed(() => props.message && props.phase === "receiver");
const showSender = computed(() =>
props.message && props.message.from !== "reviewer" && (props.phase === "sender" || props.phase === "packet"),
);
const showReceiver = computed(() =>
props.message && props.message.to !== "reviewer" && props.phase === "receiver",
);
</script>
<template>
<div class="cyber-messages" aria-hidden="true">
<Transition name="cyber-bubble">
<div
<CyberHeroSpeechBubble
v-if="showSender && message && !reducedMotion"
class="cyber-message cyber-message--sender cyber-panel"
:style="senderStyle"
variant="sender"
:role="message.from"
:bubble-style="senderStyle"
>
{{ message.text }}
</div>
</CyberHeroSpeechBubble>
</Transition>
<Transition name="cyber-bubble">
<div
<CyberHeroSpeechBubble
v-if="showReceiver && message && !reducedMotion"
class="cyber-message cyber-message--receiver cyber-panel"
:style="receiverStyle"
variant="receiver"
:role="message.to"
:bubble-style="receiverStyle"
>
{{ message.response }}
</div>
</CyberHeroSpeechBubble>
</Transition>
<div v-if="reducedMotion" class="cyber-message cyber-message--static cyber-panel">
<CyberHeroSpeechBubble v-if="reducedMotion" class="cyber-panel" variant="static">
Agents coordinate work automatically.
</div>
</CyberHeroSpeechBubble>
</div>
</template>

View file

@ -0,0 +1,174 @@
<script setup lang="ts">
import type { NeatConfig, NeatController } from "@firecms/neat";
const canvasRef = ref<HTMLCanvasElement | null>(null);
const isLive = ref(false);
let gradient: NeatController | null = null;
let heroObserver: IntersectionObserver | null = null;
let motionQuery: MediaQueryList | null = null;
let mobileQuery: MediaQueryList | null = null;
let isVisible = false;
let isInitializing = false;
let initToken = 0;
let revealTimer: number | null = null;
const montereyConfig: NeatConfig = {
colors: [
{ color: "#130437", enabled: true },
{ color: "#B34BD0", enabled: true },
{ color: "#210751", enabled: true },
{ color: "#3511A5", enabled: true },
{ color: "#8F3E8D", enabled: false },
{ color: "#FF9A9E", enabled: false },
],
speed: 4.8,
horizontalPressure: 7,
verticalPressure: 3,
waveFrequencyX: 0,
waveFrequencyY: 0,
waveAmplitude: 0,
shadows: 4,
highlights: 0,
colorBrightness: 1.92,
colorSaturation: 2.18,
wireframe: false,
colorBlending: 9,
backgroundColor: "#030012",
backgroundAlpha: 1,
grainScale: 6,
grainSparsity: 0,
grainIntensity: 0.1,
grainSpeed: 0,
resolution: 0.32,
yOffset: 150,
flowDistortionA: 0.4,
flowDistortionB: 10,
flowScale: 3.3,
flowEase: 0.37,
enableProceduralTexture: false,
textureVoidLikelihood: 0.06,
textureVoidWidthMin: 10,
textureVoidWidthMax: 500,
textureBandDensity: 0.8,
textureColorBlending: 0.06,
textureSeed: 333,
textureEase: 0.38,
proceduralBackgroundColor: "#003FFF",
textureShapeTriangles: 20,
textureShapeCircles: 15,
textureShapeBars: 15,
textureShapeSquiggles: 10,
yOffsetWaveMultiplier: 4.5,
yOffsetColorMultiplier: 4.8,
yOffsetFlowMultiplier: 5.2,
flowEnabled: true,
};
function supportsWebGl() {
try {
const canvas = document.createElement("canvas");
const context = canvas.getContext("webgl2") || canvas.getContext("webgl");
const isSupported = Boolean(context);
context?.getExtension("WEBGL_lose_context")?.loseContext();
return isSupported;
} catch {
return false;
}
}
function shouldUseLiveGradient() {
return Boolean(
canvasRef.value &&
isVisible &&
!motionQuery?.matches &&
!mobileQuery?.matches &&
supportsWebGl(),
);
}
function destroyGradient() {
initToken += 1;
if (revealTimer !== null) {
window.clearTimeout(revealTimer);
revealTimer = null;
}
gradient?.destroy();
gradient = null;
isLive.value = false;
}
async function initGradient() {
if (gradient || isInitializing || !shouldUseLiveGradient()) return;
const token = initToken;
isInitializing = true;
try {
const { NeatGradient } = await import("@firecms/neat");
if (token !== initToken || !canvasRef.value || !shouldUseLiveGradient()) return;
gradient = new NeatGradient({
ref: canvasRef.value,
...montereyConfig,
resolution: window.devicePixelRatio > 1 ? 0.24 : 0.34,
});
revealTimer = window.setTimeout(() => {
revealTimer = null;
if (token === initToken && gradient && shouldUseLiveGradient()) {
isLive.value = true;
}
}, 180);
} catch (error) {
console.warn("Monterey hero background is unavailable", error);
destroyGradient();
} finally {
isInitializing = false;
}
}
function syncGradient() {
if (shouldUseLiveGradient()) {
void initGradient();
return;
}
destroyGradient();
}
onMounted(() => {
motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
mobileQuery = window.matchMedia("(max-width: 700px)");
motionQuery.addEventListener("change", syncGradient);
mobileQuery.addEventListener("change", syncGradient);
heroObserver = new IntersectionObserver(
([entry]) => {
isVisible = Boolean(entry?.isIntersecting);
syncGradient();
},
{ rootMargin: "160px 0px", threshold: 0.01 },
);
const target = canvasRef.value?.closest(".cyber-hero");
if (target) heroObserver.observe(target);
});
onBeforeUnmount(() => {
heroObserver?.disconnect();
motionQuery?.removeEventListener("change", syncGradient);
mobileQuery?.removeEventListener("change", syncGradient);
destroyGradient();
});
</script>
<template>
<div
class="cyber-hero__monterey"
:class="{ 'cyber-hero__monterey--live': isLive }"
aria-hidden="true"
>
<canvas ref="canvasRef" class="cyber-hero__monterey-canvas" />
</div>
</template>

View file

@ -17,6 +17,8 @@ const rootStyle = computed(() => ({
"--agent-y": String(props.agent.desktop.y),
"--agent-scale": String(props.agent.desktop.scale),
"--agent-depth": String(props.agent.desktop.depth),
"--agent-face": String(props.agent.facing ?? 1),
"--agent-lean": `${props.agent.lean ?? 0}deg`,
"--agent-tablet-x": String(props.agent.tablet.x),
"--agent-tablet-y": String(props.agent.tablet.y),
"--agent-tablet-scale": String(props.agent.tablet.scale),

View file

@ -1,111 +1,26 @@
<script setup lang="ts">
import {
heroAgents,
heroConnections,
heroMessages,
type HeroAgentRole,
type HeroMessage,
type HeroMessagePhase,
} from "~/data/heroScene";
type MessagePhase = "sender" | "packet" | "receiver" | "cooldown";
const props = defineProps<{
message: HeroMessage | null;
phase: HeroMessagePhase;
reducedMotion?: boolean;
}>();
const activeMessageIndex = ref(0);
const phase = ref<MessagePhase>("cooldown");
const isVisible = ref(false);
const reducedMotion = ref(false);
const sceneRef = ref<HTMLElement | null>(null);
let timers: number[] = [];
let observer: IntersectionObserver | null = null;
let motionQuery: MediaQueryList | null = null;
const activeMessage = computed(() => heroMessages[activeMessageIndex.value] ?? null);
const activeConnectionId = computed(() => (phase.value === "cooldown" ? null : activeMessage.value?.connectionId ?? null));
const activeSender = computed<HeroAgentRole | null>(() => (phase.value === "cooldown" ? null : activeMessage.value?.from ?? null));
const activeSender = computed<HeroAgentRole | null>(() => (props.phase === "cooldown" ? null : props.message?.from ?? null));
const activeReceiver = computed<HeroAgentRole | "video" | null>(() => (
phase.value === "receiver" ? activeMessage.value?.to ?? null : null
props.phase === "receiver" ? props.message?.to ?? null : null
));
function clearTimers() {
timers.forEach(window.clearTimeout);
timers = [];
}
function setTimer(callback: () => void, delay: number) {
const id = window.setTimeout(callback, delay);
timers.push(id);
}
function runCycle() {
clearTimers();
if (!isVisible.value || reducedMotion.value) {
phase.value = "cooldown";
return;
}
phase.value = "sender";
setTimer(() => {
phase.value = "packet";
}, 900);
setTimer(() => {
phase.value = "receiver";
}, 2200);
setTimer(() => {
phase.value = "cooldown";
}, 3900);
setTimer(() => {
activeMessageIndex.value = (activeMessageIndex.value + 1) % heroMessages.length;
runCycle();
}, 4700);
}
function syncMotion() {
reducedMotion.value = Boolean(motionQuery?.matches);
runCycle();
}
function onVisibilityChange() {
if (document.hidden) {
clearTimers();
phase.value = "cooldown";
return;
}
runCycle();
}
onMounted(() => {
motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
reducedMotion.value = motionQuery.matches;
motionQuery.addEventListener("change", syncMotion);
document.addEventListener("visibilitychange", onVisibilityChange);
observer = new IntersectionObserver(
([entry]) => {
isVisible.value = entry.isIntersecting;
runCycle();
},
{ threshold: 0.15 },
);
if (sceneRef.value) observer.observe(sceneRef.value);
});
onUnmounted(() => {
clearTimers();
observer?.disconnect();
motionQuery?.removeEventListener("change", syncMotion);
document.removeEventListener("visibilitychange", onVisibilityChange);
});
</script>
<template>
<div ref="sceneRef" class="cyber-scene">
<div class="cyber-scene">
<div class="cyber-scene__floor" aria-hidden="true" />
<CyberHeroConnectors
class="cyber-scene__connectors"
:connections="heroConnections"
:active-connection-id="activeConnectionId"
:reduced-motion="reducedMotion"
/>
<CyberHeroVideoFrame class="cyber-scene__video" />
@ -121,7 +36,7 @@ onUnmounted(() => {
<CyberHeroMessageBubbles
class="cyber-scene__messages"
:message="activeMessage"
:message="message"
:phase="phase"
:reduced-motion="reducedMotion"
/>

View file

@ -0,0 +1,25 @@
<script setup lang="ts">
import type { CSSProperties } from "vue";
const props = withDefaults(defineProps<{
variant?: "sender" | "receiver" | "static";
role?: string | null;
bubbleStyle?: CSSProperties;
}>(), {
variant: "sender",
role: null,
bubbleStyle: undefined,
});
const bubbleClasses = computed(() => [
"cyber-message",
`cyber-message--${props.variant}`,
props.role ? `cyber-message--role-${props.role}` : null,
]);
</script>
<template>
<div :class="bubbleClasses" :style="bubbleStyle">
<slot />
</div>
</template>

View file

@ -11,17 +11,7 @@
<span>Live demo</span>
</div>
<div class="cyber-video-frame__content">
<ClientOnly>
<Suspense>
<LazyHeroDemoVideo />
<template #fallback>
<div class="cyber-video-frame__fallback" />
</template>
</Suspense>
<template #fallback>
<div class="cyber-video-frame__fallback" />
</template>
</ClientOnly>
<HeroDemoVideo />
</div>
<div class="cyber-video-frame__corner cyber-video-frame__corner--tl" aria-hidden="true" />
<div class="cyber-video-frame__corner cyber-video-frame__corner--tr" aria-hidden="true" />

View file

@ -1,4 +1,6 @@
<script setup lang="ts">
import robotLeadLounge from "~/assets/images/footer/robot-lead-lounge-v1.webp";
const { t, locale } = useI18n();
const { repoUrl } = useGithubRepo();
const { baseURL } = useRuntimeConfig().app;
@ -11,6 +13,30 @@ const docsHref = computed(() => {
<template>
<footer class="app-footer">
<div class="app-footer__robot-stage">
<span class="app-footer__robot-bubble">
<svg
class="app-footer__robot-bubble-shape"
viewBox="0 0 92 62"
aria-hidden="true"
focusable="false"
>
<path
class="app-footer__robot-bubble-fill"
d="M18 5H58C73 5 84 14 84 27C84 40 73 47 59 47H52L61 58L39 47H18C9 47 4 38 4 26C4 14 9 5 18 5Z"
/>
</svg>
<span class="app-footer__robot-bubble-text">{{ t('footer.robotBubble') }}</span>
</span>
<img
class="app-footer__robot"
:src="robotLeadLounge"
alt=""
loading="lazy"
decoding="async"
draggable="false"
>
</div>
<v-container class="app-footer__inner">
<span class="app-footer__copy"
>{{ t('footer.copyright', { year }) }} · {{ t('footer.tagline') }}</span
@ -28,8 +54,79 @@ const docsHref = computed(() => {
<style scoped>
.app-footer {
position: relative;
border-top: 1px solid var(--at-c-border);
padding: 20px 0;
isolation: isolate;
}
.app-footer__robot-stage {
position: absolute;
right: clamp(24px, 7vw, 112px);
bottom: calc(100% - 5px);
z-index: 2;
width: clamp(178px, 16vw, 236px);
pointer-events: none;
user-select: none;
transform: translateY(3px) rotate(-1deg);
transform-origin: 54% bottom;
filter:
drop-shadow(0 14px 18px rgba(0, 0, 0, 0.52))
drop-shadow(0 0 14px rgba(130, 255, 0, 0.2));
}
.app-footer__robot {
display: block;
width: 100%;
height: auto;
}
.app-footer__robot-bubble {
position: absolute;
top: -28px;
left: -18px;
z-index: 3;
display: block;
width: 72px;
height: 49px;
color: #07111d;
font-family: var(--at-font-mono);
font-size: 0.62rem;
font-weight: 900;
line-height: 1;
letter-spacing: 0;
white-space: nowrap;
text-shadow: 1px 1px 0 rgba(255, 255, 255, 0.62);
transform: rotate(-2deg);
transform-origin: 72% 74%;
filter:
drop-shadow(0 3px 0 rgba(0, 0, 0, 0.18))
drop-shadow(0 0 9px rgba(255, 215, 0, 0.14));
}
.app-footer__robot-bubble-shape {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
}
.app-footer__robot-bubble-fill {
fill: #fff09a;
stroke: #050816;
stroke-width: 4.6;
stroke-linejoin: round;
stroke-linecap: round;
}
.app-footer__robot-bubble-text {
position: absolute;
top: 11px;
left: 0;
z-index: 3;
width: 54px;
text-align: center;
}
.app-footer__inner {
@ -91,6 +188,10 @@ const docsHref = computed(() => {
}
@media (max-width: 600px) {
.app-footer__robot-stage {
display: none;
}
.app-footer__inner {
flex-direction: column;
gap: 10px;

View file

@ -8,26 +8,113 @@ const menuOpen = ref(false);
const withBase = (path: string) => `${baseURL.replace(/\/?$/, '/')}${path.replace(/^\/+/, '')}`;
const docsHref = computed(() => withBase(locale.value === 'ru' ? 'docs/ru/' : 'docs/'));
const isRu = computed(() => locale.value === 'ru');
const navItems = computed(() => [
{ href: '#screenshots', label: t('nav.screenshots') },
{ href: docsHref.value, label: t('nav.docs') },
{ href: '#download', label: t('nav.download') },
{ href: '#comparison', label: t('nav.comparison') },
{ href: '#pricing', label: t('nav.pricing') },
{ href: '#faq', label: t('nav.faq') },
{ href: '#screenshots', label: t('nav.screenshots'), shortLabel: isRu.value ? 'Скрины' : 'Shots' },
{ href: docsHref.value, label: 'Docs', shortLabel: 'Docs' },
{ href: '#download', label: t('nav.download'), shortLabel: isRu.value ? 'Скачать' : 'Get' },
{ href: '#comparison', label: t('nav.comparison'), shortLabel: isRu.value ? 'Сравн.' : 'Compare' },
{ href: '#pricing', label: t('nav.pricing'), shortLabel: isRu.value ? 'Беспл.' : 'Free' },
{ href: '#faq', label: t('nav.faq'), shortLabel: 'FAQ' },
]);
</script>
<template>
<header class="app-header">
<v-container class="app-header__inner">
<svg
class="app-header__hud"
viewBox="0 0 2048 128"
preserveAspectRatio="none"
aria-hidden="true"
>
<defs>
<linearGradient id="hud-cyan-magenta" x1="0" y1="0" x2="2048" y2="0" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#00eaff" />
<stop offset="0.28" stop-color="#7a5cff" />
<stop offset="0.52" stop-color="#00eaff" />
<stop offset="0.75" stop-color="#ff2bff" />
<stop offset="1" stop-color="#00eaff" />
</linearGradient>
<linearGradient id="hud-panel-fill" x1="0" y1="0" x2="2048" y2="128" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#06152a" stop-opacity="0.94" />
<stop offset="0.46" stop-color="#020711" stop-opacity="0.86" />
<stop offset="1" stop-color="#07101e" stop-opacity="0.94" />
</linearGradient>
<filter id="hud-glow" x="-8%" y="-60%" width="116%" height="220%">
<feGaussianBlur stdDeviation="3.6" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<pattern id="hud-dot-grid" width="14" height="14" patternUnits="userSpaceOnUse">
<circle cx="2" cy="2" r="1.2" fill="#00eaff" opacity="0.16" />
</pattern>
</defs>
<g class="app-header__hud-fill">
<path d="M22 26H384L438 64L384 102H22Z" />
<path d="M456 36H1516L1546 52H1568V76H1546L1516 94H456Z" />
<path d="M1608 26H2026V102H1608L1568 86V42Z" />
</g>
<path class="app-header__hud-dots" d="M116 38H424V96H116Z" />
<g class="app-header__hud-lines app-header__hud-lines--back">
<path d="M20 26H384L438 64L384 102H20Z" />
<path d="M456 36H1516L1546 52H1568V76H1546L1516 94H456Z" />
<path d="M1608 26H2028V102H1608L1568 86V42Z" />
<path d="M34 18H372L388 30" />
<path d="M462 24H920L944 38H1512" />
<path d="M1620 18H2010L2034 38V91L2006 110H1614L1576 96" />
<path d="M490 106H862L880 118H1196L1214 106H1512" />
</g>
<g class="app-header__hud-lines app-header__hud-lines--front">
<path d="M56 20H368" />
<path d="M28 86L54 108H178" />
<path d="M374 102H438L458 84" />
<path d="M520 42H842L858 52" />
<path d="M934 24L1186 24L1166 42H956Z" />
<path d="M1248 42H1490L1504 52" />
<path d="M1604 98H1868" />
<path d="M1888 106H1994L2018 88" />
</g>
<g class="app-header__hud-ticks">
<path d="M644 62V78" />
<path d="M884 62V78" />
<path d="M1138 62V78" />
<path d="M1288 62V78" />
<path d="M1400 62V78" />
<path d="M1532 62V78" />
<path d="M1870 62V78" />
</g>
<g class="app-header__hud-microdots">
<circle v-for="x in [894, 914, 934, 954, 974, 994, 1014, 1034, 1054, 1074, 1094, 1114]" :key="x" :cx="x" cy="24" r="2.2" />
<circle v-for="x in [370, 380, 390]" :key="`l-${x}`" :cx="x" cy="102" r="2.4" />
<circle v-for="x in [1892, 1902, 1912]" :key="`r-${x}`" :cx="x" cy="102" r="2.4" />
<circle cx="512" cy="26" r="2.8" />
<circle cx="524" cy="26" r="2.8" />
</g>
<g class="app-header__hud-energy">
<path d="M28 26H384L438 64L384 102H28" />
<path d="M456 36H1516L1546 52H1568V76H1546L1516 94H456" />
<path d="M1608 26H2026V102H1608L1568 86V42" />
</g>
</svg>
<div class="app-header__brand-frame">
<AppLogo />
</div>
<nav class="app-header__nav">
<v-btn v-for="item in navItems" :key="item.href" variant="text" :href="item.href">
{{ item.label }}
<span class="app-header__nav-label app-header__nav-label--full">{{ item.label }}</span>
<span class="app-header__nav-label app-header__nav-label--short">{{ item.shortLabel }}</span>
</v-btn>
</nav>
<div class="app-header__spacer" />
@ -39,9 +126,10 @@ const navItems = computed(() => [
:href="repoUrl"
target="_blank"
class="app-header__github-btn"
:prepend-icon="mdiGithub"
aria-label="GitHub"
>
GitHub
<v-icon :icon="mdiGithub" class="app-header__github-icon" />
<span class="app-header__github-text">GitHub</span>
</v-btn>
<ThemeToggle />
</div>
@ -93,21 +181,27 @@ const navItems = computed(() => [
<style scoped>
.app-header {
--header-cyan: var(--cyber-cyan);
--header-violet: var(--cyber-violet);
--header-magenta: var(--cyber-magenta);
--header-height: 128px;
--header-panel-height: 86px;
--header-action-size: clamp(54px, 3.25vw, 66px);
--header-github-width: clamp(150px, 9.7vw, 204px);
--header-brand-icon: clamp(58px, 4.1vw, 76px);
--header-brand-text: clamp(24px, 1.55vw, 34px);
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: var(--at-z-header);
height: 92px;
height: var(--header-height);
display: flex;
align-items: center;
background:
linear-gradient(180deg, rgba(2, 5, 13, 0.98), rgba(2, 5, 13, 0.72) 74%, rgba(2, 5, 13, 0.16)),
rgba(2, 5, 13, 0.72);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
box-shadow: 0 16px 42px rgba(0, 0, 0, 0.26);
background: transparent;
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
box-shadow: none;
}
.app-header::before,
@ -116,133 +210,213 @@ const navItems = computed(() => [
}
.v-theme--light .app-header {
background:
linear-gradient(180deg, rgba(244, 250, 255, 0.96), rgba(244, 250, 255, 0.8) 74%, rgba(244, 250, 255, 0.2)),
rgba(244, 250, 255, 0.86);
border-bottom-color: rgba(0, 168, 204, 0.34);
background: transparent;
}
.v-theme--dark .app-header {
background:
linear-gradient(180deg, rgba(2, 5, 13, 0.98), rgba(2, 5, 13, 0.72) 74%, rgba(2, 5, 13, 0.16)),
rgba(2, 5, 13, 0.72);
background: transparent;
}
.app-header__inner {
position: relative;
display: flex;
display: grid;
grid-template-columns: clamp(386px, 26.3vw, 538px) minmax(560px, 1fr) clamp(366px, 23.2vw, 474px);
align-items: center;
flex-wrap: nowrap;
width: min(1680px, 100vw);
width: min(2048px, calc(100vw - 18px));
max-width: none !important;
height: 100%;
padding-inline: 0 !important;
}
.app-header__hud {
position: absolute;
inset: 0;
z-index: 0;
width: 100%;
height: 100%;
pointer-events: none;
overflow: visible;
}
.app-header__hud-fill path {
fill: url("#hud-panel-fill");
opacity: 0.86;
}
.app-header__hud-dots {
fill: url("#hud-dot-grid");
opacity: 0.56;
}
.app-header__hud-lines path,
.app-header__hud-ticks path,
.app-header__hud-energy path {
fill: none;
vector-effect: non-scaling-stroke;
}
.app-header__hud-lines--back path {
stroke: url("#hud-cyan-magenta");
stroke-width: 2;
opacity: 0.66;
filter: url("#hud-glow");
}
.app-header__hud-lines--front path {
stroke: rgba(0, 234, 255, 0.9);
stroke-width: 1.3;
opacity: 0.84;
}
.app-header__hud-ticks path {
display: none;
}
.app-header__hud-microdots circle {
fill: url("#hud-cyan-magenta");
filter: url("#hud-glow");
opacity: 0.72;
}
.app-header__hud-energy path {
stroke: url("#hud-cyan-magenta");
stroke-width: 3.2;
stroke-linecap: round;
stroke-dasharray: 92 620;
stroke-dashoffset: 0;
opacity: 0.82;
filter: url("#hud-glow");
animation: headerHudFlow 8.5s linear infinite;
}
.app-header__brand-frame {
position: relative;
grid-column: 1;
display: flex;
align-items: center;
align-self: center;
isolation: isolate;
height: 76px;
min-width: 358px;
padding: 0 74px 0 52px;
z-index: 1;
width: 100%;
height: var(--header-panel-height);
min-width: 0;
padding: 0 64px 0 clamp(38px, 4.2vw, 82px);
background: transparent;
border: 0;
clip-path: none;
box-shadow: none;
}
.app-header__brand-frame::before,
.app-header__brand-frame::after {
content: "";
position: absolute;
pointer-events: none;
clip-path: polygon(0 0, calc(100% - 54px) 0, 100% 50%, calc(100% - 54px) 100%, 0 100%, 0 0);
}
.app-header__brand-frame::before {
inset: 0;
z-index: -2;
background: linear-gradient(110deg, rgba(0, 234, 255, 0.92), rgba(47, 125, 255, 0.5) 58%, rgba(0, 234, 255, 0.82));
filter: drop-shadow(0 0 16px rgba(0, 234, 255, 0.42));
}
.app-header__brand-frame::after {
inset: 1px;
z-index: -1;
background:
linear-gradient(110deg, rgba(5, 14, 31, 0.98), rgba(2, 6, 16, 0.95) 64%, rgba(0, 234, 255, 0.08)),
rgba(2, 6, 16, 0.96);
overflow: hidden;
contain: paint;
clip-path: polygon(0 18%, 80% 18%, 100% 50%, 80% 82%, 0 82%);
}
.app-header__brand-frame :deep(.app-logo) {
position: relative;
z-index: 1;
gap: 12px;
gap: clamp(14px, 1.4vw, 28px);
height: 100%;
min-width: 0;
align-items: center;
display: inline-grid;
grid-template-columns: var(--header-brand-icon) max-content;
grid-template-rows: var(--header-brand-icon);
align-content: center;
justify-content: start;
justify-items: center;
}
.app-header__brand-frame :deep(.app-logo__img) {
width: 44px;
height: 44px;
border-radius: 12px;
width: var(--header-brand-icon);
height: var(--header-brand-icon);
border-radius: 17px;
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.08) inset,
0 0 22px rgba(139, 92, 255, 0.36);
0 0 0 1px rgba(139, 92, 255, 0.34) inset,
0 0 28px rgba(139, 92, 255, 0.44),
0 0 38px rgba(0, 234, 255, 0.12);
}
.app-header__brand-frame :deep(.app-logo__text) {
font-size: 20px;
display: inline-flex;
align-items: center;
height: auto;
max-width: 100%;
overflow: hidden;
font-size: var(--header-brand-text);
font-weight: 800;
letter-spacing: 0.02em;
line-height: 1;
letter-spacing: 0;
white-space: nowrap;
transform: none;
}
.app-header__nav {
position: relative;
display: flex;
flex: 1 1 auto;
align-self: center;
align-items: center;
justify-content: flex-start;
gap: clamp(22px, 2.7vw, 46px);
height: 76px;
margin-left: -28px;
padding: 0 clamp(34px, 4.4vw, 74px) 0 clamp(70px, 5.6vw, 104px);
}
--nav-pad-start: clamp(34px, 4cqw, 56px);
--nav-pad-end: clamp(38px, 4.5cqw, 68px);
.app-header__nav::before,
.app-header__nav::after {
content: "";
position: absolute;
left: 0;
right: 0;
height: 1px;
pointer-events: none;
background: linear-gradient(90deg, rgba(0, 234, 255, 0.6), rgba(0, 234, 255, 0.24) 36%, rgba(139, 92, 255, 0.5) 58%, rgba(0, 234, 255, 0.58));
opacity: 0.86;
box-shadow: 0 0 14px rgba(0, 234, 255, 0.18);
top: calc((var(--header-height) - var(--header-panel-height, 86px)) / 2);
left: calc(456 / 2048 * 100%);
right: calc((2048 - 1568) / 2048 * 100%);
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
z-index: 1;
min-width: 0;
align-items: center;
height: var(--header-panel-height, 86px);
padding: 0 var(--nav-pad-end) 0 var(--nav-pad-start);
overflow: hidden;
container-type: inline-size;
contain: paint;
clip-path: polygon(0 17%, 94% 17%, 97.4% 36%, 100% 36%, 100% 64%, 97.4% 64%, 94% 83%, 0 83%);
}
.app-header__nav::before {
top: 8px;
}
.app-header__nav::after {
bottom: 7px;
display: none;
}
.app-header__nav :deep(.v-btn) {
height: 48px !important;
position: relative;
z-index: 1;
width: 100%;
height: 64px !important;
min-width: 0 !important;
padding-inline: clamp(4px, 0.8cqw, 12px) !important;
border-radius: 0;
color: rgba(244, 247, 255, 0.9) !important;
color: rgba(244, 247, 255, 0.88) !important;
font-family: var(--at-font-mono);
font-size: 13px !important;
font-size: clamp(12px, 1.45cqw, 17px) !important;
font-weight: 700 !important;
letter-spacing: 0.08em !important;
letter-spacing: clamp(0.03em, 0.12vw, 0.07em) !important;
text-transform: uppercase !important;
text-shadow: 0 0 16px rgba(244, 247, 255, 0.16);
}
.app-header__nav :deep(.v-btn:not(:last-child)::after) {
content: "";
position: absolute;
top: 50%;
right: 0;
width: 1px;
height: 22px;
background: linear-gradient(180deg, transparent, rgba(0, 234, 255, 0.62), transparent);
filter: drop-shadow(0 0 8px rgba(0, 234, 255, 0.34));
transform: translateY(-50%);
pointer-events: none;
}
.app-header__nav :deep(.v-btn__content) {
max-width: 100%;
min-width: 0;
justify-content: center;
overflow: hidden;
white-space: nowrap;
}
.app-header__nav-label {
display: inline-block;
max-width: 100%;
overflow: hidden;
white-space: nowrap;
}
.app-header__nav-label--short {
display: none;
}
.app-header__nav :deep(.v-btn:hover) {
@ -255,55 +429,105 @@ const navItems = computed(() => [
}
.app-header__desktop-actions {
position: relative;
display: flex;
gap: 12px;
position: absolute;
top: calc((var(--header-height) - var(--header-panel-height)) / 2);
right: calc((2048 - 2026) / 2048 * 100%);
left: calc(1568 / 2048 * 100%);
display: grid;
grid-template-columns:
var(--header-action-size)
var(--header-github-width)
var(--header-action-size);
z-index: 1;
gap: clamp(10px, 1.15vw, 22px);
align-items: center;
align-self: center;
justify-content: flex-end;
isolation: isolate;
height: 76px;
min-width: 328px;
padding: 0 32px 0 58px;
justify-content: center;
justify-items: center;
height: var(--header-panel-height);
min-width: 0;
padding: 0;
border: 0;
background: transparent;
clip-path: none;
box-shadow: none;
overflow: hidden;
contain: paint;
clip-path: polygon(8% 18%, 100% 18%, 100% 82%, 8% 82%, 0 64%, 0 36%);
}
.app-header__desktop-actions::before,
.app-header__desktop-actions::after {
content: "";
position: absolute;
pointer-events: none;
clip-path: polygon(42px 0, 100% 0, 100% 100%, 42px 100%, 0 50%);
.app-header__desktop-actions :deep(.v-btn) {
align-self: center;
justify-self: center;
margin: 0 !important;
line-height: 1 !important;
transform: none !important;
}
.app-header__desktop-actions::before {
inset: 0;
z-index: -2;
background: linear-gradient(250deg, rgba(0, 234, 255, 0.92), rgba(47, 125, 255, 0.46) 48%, rgba(0, 234, 255, 0.72));
filter: drop-shadow(0 0 16px rgba(0, 234, 255, 0.34));
.app-header__desktop-actions :deep(.v-btn:not(.app-header__github-btn)) {
width: var(--header-action-size) !important;
min-width: var(--header-action-size) !important;
height: var(--header-action-size) !important;
min-height: var(--header-action-size) !important;
padding-inline: 0 !important;
color: rgba(244, 247, 255, 0.9) !important;
}
.app-header__desktop-actions::after {
inset: 1px;
z-index: -1;
background:
linear-gradient(250deg, rgba(5, 14, 31, 0.98), rgba(2, 6, 16, 0.94) 68%, rgba(0, 234, 255, 0.08)),
rgba(2, 6, 16, 0.96);
.app-header__desktop-actions :deep(.v-btn__content),
.app-header__desktop-actions :deep(.v-icon) {
margin: 0 !important;
align-items: center;
justify-content: center;
line-height: 1;
}
.app-header__desktop-actions :deep(.v-btn__overlay),
.app-header__desktop-actions :deep(.v-btn__underlay) {
border-radius: inherit;
}
.app-header__desktop-actions :deep(.language-switcher__flag-icon) {
display: block;
width: calc(var(--header-action-size) - 14px);
height: calc(var(--header-action-size) - 14px);
border-radius: 50%;
filter: drop-shadow(0 0 12px rgba(47, 125, 255, 0.34));
}
.app-header__github-btn {
min-height: 36px !important;
border-color: rgba(0, 234, 255, 0.58) !important;
width: var(--header-github-width) !important;
height: var(--header-action-size) !important;
min-height: var(--header-action-size) !important;
min-width: var(--header-github-width) !important;
padding-inline: clamp(14px, 1vw, 20px) !important;
border-color: rgba(0, 234, 255, 0.76) !important;
border-radius: 6px !important;
color: var(--header-cyan) !important;
font-family: var(--at-font-mono);
font-weight: 800 !important;
font-size: 12px !important;
font-size: clamp(14px, 1vw, 19px) !important;
letter-spacing: 0.08em !important;
text-transform: uppercase !important;
box-shadow: 0 0 16px rgba(0, 234, 255, 0.12);
background: rgba(0, 234, 255, 0.035) !important;
box-shadow:
0 0 0 1px rgba(0, 234, 255, 0.1) inset,
0 0 20px rgba(0, 234, 255, 0.18);
}
.app-header__github-btn :deep(.v-btn__content) {
display: inline-flex;
gap: 8px;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.app-header__github-icon {
flex: 0 0 auto;
font-size: 1.1em;
}
.app-header__github-text {
line-height: 1;
}
.app-header__github-btn:hover {
@ -316,21 +540,222 @@ const navItems = computed(() => [
display: none;
}
@media (max-width: 959px) {
@keyframes headerHudFlow {
to {
stroke-dashoffset: -712;
}
}
@media (max-width: 1439px) {
.app-header {
--header-height: 104px;
--header-panel-height: 72px;
--header-action-size: 54px;
--header-github-width: 124px;
--header-brand-icon: 48px;
--header-brand-text: 16px;
}
.app-header__inner {
grid-template-columns: 296px minmax(0, 1fr) 286px;
width: min(100vw - 16px, 1440px);
}
.app-header__brand-frame {
height: 72px;
padding-left: 38px;
padding-right: 38px;
}
.app-header__brand-frame :deep(.app-logo__text) {
letter-spacing: 0.03em;
}
.app-header__nav {
--nav-pad-start: clamp(30px, 4.5cqw, 46px);
--nav-pad-end: clamp(42px, 5.8cqw, 58px);
height: 72px;
}
.app-header__nav :deep(.v-btn) {
height: 56px !important;
padding-inline: 3px !important;
font-size: clamp(11.4px, 1.7cqw, 12px) !important;
letter-spacing: 0.02em !important;
}
.app-header__desktop-actions {
height: var(--header-panel-height);
gap: 8px;
}
.app-header__github-btn {
font-size: 13px !important;
}
}
@media (max-width: 1120px) {
.app-header {
--header-github-width: 104px;
--header-brand-text: 14px;
}
.app-header__inner {
grid-template-columns: 260px minmax(0, 1fr) 232px;
}
.app-header__brand-frame {
padding-left: 26px;
padding-right: 34px;
}
.app-header__nav {
--nav-pad-start: 24px;
--nav-pad-end: 18px;
}
.app-header__nav :deep(.v-btn) {
font-size: 9px !important;
letter-spacing: 0.04em !important;
}
.app-header__desktop-actions {
right: calc((2048 - 2026) / 2048 * 100%);
left: calc(1568 / 2048 * 100%);
}
}
@media (max-width: 1279px) and (min-width: 768px) {
.app-header {
--header-height: 88px;
--header-panel-height: 64px;
--header-action-size: clamp(40px, 5vw, 48px);
--header-brand-icon: clamp(38px, 4.8vw, 44px);
--header-brand-text: clamp(11px, 1.35vw, 14px);
}
.app-header__inner {
grid-template-columns: clamp(176px, 22vw, 260px) minmax(0, 1fr) clamp(148px, 18vw, 210px);
width: min(100vw - 12px, 1239px);
}
.app-header__brand-frame {
height: 64px;
padding-left: clamp(18px, 2.4vw, 30px);
padding-right: clamp(22px, 3vw, 34px);
}
.app-header__brand-frame :deep(.app-logo) {
gap: clamp(8px, 1.2vw, 12px);
}
.app-header__brand-frame :deep(.app-logo__text) {
letter-spacing: 0.02em;
}
.app-header__nav {
--nav-pad-start: clamp(14px, 3.4cqw, 28px);
--nav-pad-end: clamp(18px, 4cqw, 34px);
top: 12px;
left: calc(456 / 2048 * 100%);
right: calc((2048 - 1568) / 2048 * 100%);
height: 64px;
}
.app-header__nav :deep(.v-btn) {
height: 48px !important;
padding-inline: 2px !important;
font-size: clamp(10.8px, 1.7cqw, 11.4px) !important;
letter-spacing: 0.01em !important;
}
.app-header__nav::before {
display: none;
}
.app-header__nav :deep(.v-btn:not(:last-child)::after) {
height: 18px;
}
.app-header__desktop-actions {
height: 64px;
gap: clamp(6px, 0.9vw, 10px);
}
}
@media (max-width: 1279px) and (min-width: 768px) {
.app-header {
--header-github-width: var(--header-action-size);
}
.app-header__nav-label--full {
display: none;
}
.app-header__nav-label--short {
display: inline-block;
}
.app-header__github-btn {
padding-inline: 0 !important;
}
.app-header__github-btn :deep(.v-btn__content) {
gap: 0;
}
.app-header__github-text {
display: none;
}
}
@media (max-width: 767px) {
.app-header {
height: 64px;
}
.app-header__inner {
display: flex;
width: min(100% - 32px, 680px);
}
.app-header__hud {
display: none;
}
.app-header__brand-frame {
min-width: 0;
flex: 1;
align-self: center;
height: 48px;
padding: 0 42px 0 12px;
isolation: isolate;
}
.app-header__brand-frame::before,
.app-header__brand-frame::after {
content: "";
position: absolute;
pointer-events: none;
clip-path: polygon(0 0, calc(100% - 44px) 0, 100% 50%, calc(100% - 44px) 100%, 0 100%, 0 0);
}
.app-header__brand-frame::before {
inset: 0;
z-index: -2;
background: linear-gradient(110deg, rgba(0, 234, 255, 0.92), rgba(47, 125, 255, 0.5) 58%, rgba(0, 234, 255, 0.82));
filter: drop-shadow(0 0 16px rgba(0, 234, 255, 0.42));
}
.app-header__brand-frame::after {
inset: 1px;
z-index: -1;
background:
linear-gradient(110deg, rgba(5, 14, 31, 0.98), rgba(2, 6, 16, 0.95) 64%, rgba(0, 234, 255, 0.08)),
rgba(2, 6, 16, 0.96);
}
.app-header__brand-frame :deep(.app-logo__img) {
@ -353,6 +778,8 @@ const navItems = computed(() => [
.app-header__mobile-actions {
display: flex;
position: relative;
z-index: 1;
margin-left: 10px;
}
@ -363,6 +790,12 @@ const navItems = computed(() => [
}
}
@media (prefers-reduced-motion: reduce) {
.app-header__hud-energy path {
animation: none;
}
}
.mobile-menu-overlay {
position: fixed;
inset: 0;

View file

@ -1,5 +1,10 @@
<script setup lang="ts">
import robotAvatarCyan from "~/assets/images/hero/robots/robot-avatar-cyan-v1.webp";
const { t } = useI18n()
const comparisonRobotRef = ref<HTMLElement | null>(null)
const showComparisonRobotBubble = ref(false)
let comparisonRobotObserver: IntersectionObserver | null = null
interface CellValue {
status: string
@ -175,7 +180,7 @@ const rows = computed<ComparisonRow[]>(() => [
gastown: { status: 'partial', note: 'Cost tiers + digest, no hard caps' },
paperclip: { status: 'yes', note: 'Per-agent budgets + hard stops' },
cursor: { status: 'partial', note: 'Usage + BG spend limits' },
claudeCli: { status: 'partial', note: '/cost + workspace limits' },
claudeCli: { status: 'partial', note: '/usage + workspace limits' },
},
{
feature: t('comparison.features.price'),
@ -195,6 +200,78 @@ const competitors = [
{ key: 'claudeCli', name: 'Claude Code CLI' },
]
const sourceLinks = [
{
label: 'detailed research notes',
href: 'https://github.com/777genius/agent-teams-ai/blob/main/docs/research/gastown-paperclip-comparison-2026-05-16.md',
},
{ label: 'Gastown README', href: 'https://github.com/gastownhall/gastown' },
{
label: 'Gastown provider guide',
href: 'https://github.com/gastownhall/gastown/blob/main/docs/agent-provider-integration.md',
},
{
label: 'Gastown scheduler',
href: 'https://github.com/gastownhall/gastown/blob/main/docs/design/scheduler.md',
},
{ label: 'Gastown release', href: 'https://github.com/gastownhall/gastown/releases/tag/v1.1.0' },
{ label: 'Paperclip README', href: 'https://github.com/paperclipai/paperclip' },
{
label: 'Paperclip adapters',
href: 'https://github.com/paperclipai/paperclip/blob/master/docs/adapters/overview.md',
},
{
label: 'Paperclip budgets',
href: 'https://github.com/paperclipai/paperclip/blob/master/docs/guides/board-operator/costs-and-budgets.md',
},
{
label: 'Paperclip runtime services',
href: 'https://github.com/paperclipai/paperclip/blob/master/docs/guides/board-operator/execution-workspaces-and-runtime-services.md',
},
{
label: 'Paperclip Kanban source',
href: 'https://github.com/paperclipai/paperclip/blob/master/ui/src/components/KanbanBoard.tsx',
},
{
label: 'Paperclip work products',
href: 'https://github.com/paperclipai/paperclip/blob/master/packages/shared/src/validators/work-product.ts',
},
{ label: 'Paperclip release', href: 'https://github.com/paperclipai/paperclip/releases/tag/v2026.513.0' },
{ label: 'Cursor Background Agents', href: 'https://docs.cursor.com/en/background-agents' },
{ label: 'Cursor Diffs & Review', href: 'https://docs.cursor.com/en/agent/review' },
{ label: 'Cursor Bugbot', href: 'https://docs.cursor.com/en/bugbot' },
{ label: 'Cursor pricing', href: 'https://docs.cursor.com/en/account/usage' },
{ label: 'Claude Code agent teams', href: 'https://code.claude.com/docs/en/agent-teams' },
{ label: 'Claude Code subagents', href: 'https://code.claude.com/docs/en/sub-agents' },
{ label: 'Claude Code workflows', href: 'https://code.claude.com/docs/en/common-workflows' },
{ label: 'Claude Code costs', href: 'https://code.claude.com/docs/en/costs' },
{ label: 'Claude pricing', href: 'https://claude.com/pricing' },
]
onMounted(() => {
if (!comparisonRobotRef.value) return
comparisonRobotObserver = new IntersectionObserver(
([entry]) => {
if (!entry?.isIntersecting) return
showComparisonRobotBubble.value = true
comparisonRobotObserver?.disconnect()
comparisonRobotObserver = null
},
{
rootMargin: '0px 0px -12% 0px',
threshold: 0.35,
},
)
comparisonRobotObserver.observe(comparisonRobotRef.value)
})
onUnmounted(() => {
comparisonRobotObserver?.disconnect()
comparisonRobotObserver = null
})
function getCellClass(cell: CellValue): string {
switch (cell.status) {
case 'yes': return 'comparison-table__cell--yes'
@ -234,6 +311,28 @@ function getStatusIcon(status: string): string {
</div>
<div class="comparison-table__wrap">
<span
ref="comparisonRobotRef"
class="comparison-table__robot"
aria-hidden="true"
>
<Transition name="comparison-robot-bubble">
<span
v-if="showComparisonRobotBubble"
class="comparison-table__robot-bubble"
>
{{ t("comparison.robotBubble") }}
</span>
</Transition>
<img
class="comparison-table__robot-image"
:src="robotAvatarCyan"
alt=""
loading="lazy"
decoding="async"
draggable="false"
>
</span>
<table class="comparison-table">
<thead>
<tr>
@ -298,6 +397,15 @@ function getStatusIcon(status: string): string {
</tbody>
</table>
</div>
<p class="comparison-section__sources">
Fact sources checked on May 16, 2026:
<template v-for="(source, index) in sourceLinks" :key="source.href">
<a :href="source.href" target="_blank" rel="noopener noreferrer">
{{ source.label }}
</a><span v-if="index < sourceLinks.length - 1">, </span>
</template>.
</p>
</v-container>
</section>
</template>
@ -346,6 +454,168 @@ function getStatusIcon(status: string): string {
z-index: 1;
}
.comparison-table__robot {
position: absolute;
right: clamp(28px, 7vw, 96px);
bottom: calc(100% - 5px);
z-index: 4;
width: clamp(82px, 7.2vw, 124px);
height: auto;
pointer-events: none;
user-select: none;
transform: translateY(4px) rotate(-0.5deg);
transform-origin: center bottom;
animation: comparisonRobotIdle 5.2s ease-in-out infinite;
filter:
drop-shadow(0 18px 22px rgba(0, 0, 0, 0.5))
drop-shadow(0 0 18px rgba(0, 234, 255, 0.26));
}
.comparison-table__robot-image {
display: block;
width: 100%;
height: auto;
transform:
scaleX(-1)
rotate(2deg);
transform-origin: center bottom;
user-select: none;
}
.comparison-table__robot::selection {
background: transparent;
}
.comparison-table__robot-bubble {
position: absolute;
top: 10px;
right: calc(100% + 12px);
z-index: 5;
display: inline-flex;
align-items: center;
min-height: 30px;
padding: 6px 10px;
color: #07111d;
font-family: var(--at-font-mono);
font-size: 0.66rem;
font-weight: 900;
line-height: 1;
letter-spacing: 0;
white-space: nowrap;
background:
radial-gradient(circle at 26% 22%, rgba(255, 255, 255, 0.88), rgba(255, 244, 168, 0.86) 66%, rgba(255, 215, 0, 0.84) 100%);
border: 2px solid #050816;
border-radius: 999px;
box-shadow:
0 0 0 1px rgba(255, 215, 0, 0.28),
0 5px 0 rgba(0, 0, 0, 0.2),
0 0 12px rgba(255, 215, 0, 0.14);
text-shadow: 1px 1px 0 rgba(255, 255, 255, 0.62);
transform: rotate(-5deg);
transform-origin: right bottom;
animation: comparisonRobotBubbleFloat 2.6s ease-in-out 0.42s infinite;
}
.comparison-table__robot-bubble::before {
position: absolute;
top: 52%;
right: -30px;
width: 32px;
height: 18px;
content: "";
background: #050816;
clip-path: polygon(0 0, 100% 50%, 0 100%);
transform: translateY(-50%);
}
.comparison-table__robot-bubble::after {
position: absolute;
top: 52%;
right: -24px;
width: 26px;
height: 12px;
content: "";
background: rgba(255, 226, 78, 0.96);
clip-path: polygon(0 0, 100% 50%, 0 100%);
transform: translateY(-50%);
}
.comparison-robot-bubble-enter-active,
.comparison-robot-bubble-leave-active {
transition:
opacity 0.26s ease,
filter 0.26s ease;
}
.comparison-robot-bubble-enter-active {
animation: comparisonRobotBubblePop 0.52s cubic-bezier(0.18, 0.9, 0.2, 1.24);
}
.comparison-robot-bubble-enter-from,
.comparison-robot-bubble-leave-to {
opacity: 0;
filter: blur(2px);
}
@keyframes comparisonRobotIdle {
0%,
100% {
transform: translate3d(0, 4px, 0) rotate(-0.55deg);
}
50% {
transform: translate3d(1px, 3px, 0) rotate(0.75deg);
}
}
@keyframes comparisonRobotBubblePop {
0% {
opacity: 0;
transform: translate3d(14px, 18px, 0) scale(0.48) rotate(-13deg);
}
58% {
opacity: 1;
transform: translate3d(-3px, -4px, 0) scale(1.1) rotate(-4deg);
}
100% {
opacity: 1;
transform: translate3d(0, 0, 0) scale(1) rotate(-5deg);
}
}
@keyframes comparisonRobotBubbleFloat {
0%,
100% {
transform: translate3d(0, 0, 0) rotate(-5deg);
}
50% {
transform: translate3d(0, -2px, 0) rotate(-4deg);
}
}
.comparison-section__sources {
max-width: 1040px;
margin: 18px auto 0;
color: rgba(136, 146, 176, 0.82);
font-size: 0.78rem;
line-height: 1.65;
position: relative;
z-index: 1;
}
.comparison-section__sources a {
color: #00d4e6;
text-decoration: none;
}
.comparison-section__sources a:hover {
color: #00f0ff;
text-decoration: underline;
}
.comparison-table {
width: 100%;
border-collapse: collapse;
@ -547,6 +817,18 @@ function getStatusIcon(status: string): string {
border-color: rgba(0, 180, 200, 0.2);
}
.v-theme--light .comparison-section__sources {
color: rgba(71, 85, 105, 0.82);
}
.v-theme--light .comparison-section__sources a {
color: #0891b2;
}
.v-theme--light .comparison-section__sources a:hover {
color: #0e7490;
}
.v-theme--light .comparison-table__th {
color: #64748b;
border-bottom-color: rgba(0, 0, 0, 0.08);

View file

@ -1,5 +1,6 @@
<script setup lang="ts">
import { mdiApple, mdiMicrosoftWindows, mdiPenguin, mdiDownload, mdiCheckCircle } from '@mdi/js';
import robotAvatarSeatedMagenta from '~/assets/images/hero/robots/robot-avatar-seated-magenta-v1.webp';
import { downloadAssets } from '~/data/downloads';
import type { DownloadOs, DownloadArch } from '~/data/downloads';
@ -8,12 +9,219 @@ const { t, locale } = useI18n();
const downloadStore = useDownloadStore();
const { data: releaseData, resolve } = useReleaseDownloads();
const { trackDownloadClick } = useAnalytics();
const { repoUrl, releaseDownloadUrl } = useGithubRepo();
const { releaseDownloadUrl } = useGithubRepo();
const isMounted = ref(false);
const showLinuxRobotMessage = ref(false);
const showFallingLinuxRobot = ref(false);
const isLinuxRobotDetached = ref(false);
const hasLinuxRobotDeparted = ref(false);
const linuxRobotFlightState = ref<'idle' | 'falling' | 'landed'>('idle');
const fallingLinuxRobotStyle = ref<Record<string, string>>({});
let linuxRobotObserver: IntersectionObserver | null = null;
let linuxRobotFallRaf = 0;
let linuxRobotFallTimer: number | null = null;
let lastLinuxRobotScrollY = 0;
let faqLandingResizeObserver: ResizeObserver | null = null;
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function getPageRect(element: HTMLElement) {
const rect = element.getBoundingClientRect();
return {
left: rect.left + window.scrollX,
top: rect.top + window.scrollY,
right: rect.right + window.scrollX,
bottom: rect.bottom + window.scrollY,
width: rect.width,
height: rect.height,
};
}
function clearLinuxRobotFallTimer() {
if (linuxRobotFallTimer === null) return;
window.clearTimeout(linuxRobotFallTimer);
linuxRobotFallTimer = null;
}
function resetLinuxRobotFall(options: { keepSourceHidden?: boolean } = {}) {
clearLinuxRobotFallTimer();
showFallingLinuxRobot.value = false;
isLinuxRobotDetached.value = options.keepSourceHidden || hasLinuxRobotDeparted.value;
linuxRobotFlightState.value = 'idle';
fallingLinuxRobotStyle.value = {};
}
function getLinuxRobotFallMetrics() {
const sourceRobot = document.querySelector<HTMLElement>('.download-section__card-robot');
const downloadSection = document.querySelector<HTMLElement>('#download');
const faqTarget = document.querySelector<HTMLElement>('[data-faq-landing-target]');
if (!sourceRobot || !downloadSection || !faqTarget) return null;
const sourceViewport = sourceRobot.getBoundingClientRect();
const download = getPageRect(downloadSection);
const target = getPageRect(faqTarget);
const robotWidth = clamp(sourceViewport.width, 92, 112);
const robotHeight = sourceViewport.height * (robotWidth / sourceViewport.width);
const landedPageX = target.left + target.width * 0.5 - robotWidth * 0.5;
const landedPageY = target.top - robotHeight * 0.58;
return {
sourceViewport,
download,
landedPageX,
landedPageY,
robotWidth,
};
}
function getLinuxRobotLandedStyle(metrics: NonNullable<ReturnType<typeof getLinuxRobotFallMetrics>>) {
return {
left: `${metrics.landedPageX}px`,
top: `${metrics.landedPageY}px`,
width: `${metrics.robotWidth}px`,
opacity: '1',
transform: 'translate3d(0, 0, 0) rotate(-5deg) scale(0.95)',
};
}
function finishLinuxRobotFall() {
clearLinuxRobotFallTimer();
if (linuxRobotFlightState.value !== 'falling') return;
const metrics = getLinuxRobotFallMetrics();
if (!metrics) {
resetLinuxRobotFall();
return;
}
linuxRobotFlightState.value = 'landed';
showFallingLinuxRobot.value = true;
isLinuxRobotDetached.value = true;
fallingLinuxRobotStyle.value = getLinuxRobotLandedStyle(metrics);
}
function launchLinuxRobotFall(metrics: NonNullable<ReturnType<typeof getLinuxRobotFallMetrics>>) {
clearLinuxRobotFallTimer();
const endX = metrics.landedPageX - window.scrollX;
const endY = metrics.landedPageY - window.scrollY;
const startX = metrics.sourceViewport.left;
const startY = metrics.sourceViewport.top;
linuxRobotFlightState.value = 'falling';
showFallingLinuxRobot.value = true;
isLinuxRobotDetached.value = true;
hasLinuxRobotDeparted.value = true;
fallingLinuxRobotStyle.value = {
left: `${startX}px`,
top: `${startY}px`,
width: `${metrics.robotWidth}px`,
opacity: '1',
'--fall-x': `${endX - startX}px`,
'--fall-y': `${endY - startY}px`,
};
linuxRobotFallTimer = window.setTimeout(finishLinuxRobotFall, 3000);
}
function scheduleLinuxRobotFallUpdate() {
if (linuxRobotFallRaf) return;
linuxRobotFallRaf = window.requestAnimationFrame(updateLinuxRobotFall);
}
function updateLinuxRobotFall() {
linuxRobotFallRaf = 0;
const currentScrollY = window.scrollY;
const scrollingUp = currentScrollY < lastLinuxRobotScrollY - 4;
lastLinuxRobotScrollY = currentScrollY;
if (window.innerWidth <= 960 || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
resetLinuxRobotFall();
return;
}
const metrics = getLinuxRobotFallMetrics();
if (!metrics) {
resetLinuxRobotFall();
return;
}
const viewportHeight = window.innerHeight;
const startScroll = metrics.download.bottom - viewportHeight * 0.72;
if (linuxRobotFlightState.value === 'landed') {
fallingLinuxRobotStyle.value = getLinuxRobotLandedStyle(metrics);
return;
}
if (currentScrollY < startScroll) {
resetLinuxRobotFall({ keepSourceHidden: hasLinuxRobotDeparted.value });
return;
}
if (scrollingUp) {
resetLinuxRobotFall({ keepSourceHidden: hasLinuxRobotDeparted.value });
return;
}
if (hasLinuxRobotDeparted.value && linuxRobotFlightState.value === 'idle') return;
if (linuxRobotFlightState.value === 'idle') {
launchLinuxRobotFall(metrics);
}
}
onMounted(() => {
isMounted.value = true;
downloadStore.init();
nextTick(() => {
const linuxCard = document.querySelector<HTMLElement>('[data-download-os="linux"]');
const faqTarget = document.querySelector<HTMLElement>('[data-faq-landing-target]');
if (!linuxCard) return;
linuxRobotObserver = new IntersectionObserver(
([entry]) => {
if (!entry?.isIntersecting) return;
showLinuxRobotMessage.value = true;
linuxRobotObserver?.disconnect();
linuxRobotObserver = null;
},
{
rootMargin: '0px 0px -18% 0px',
threshold: 0.45,
},
);
linuxRobotObserver.observe(linuxCard);
if (faqTarget) {
faqLandingResizeObserver = new ResizeObserver(scheduleLinuxRobotFallUpdate);
faqLandingResizeObserver.observe(faqTarget);
}
scheduleLinuxRobotFallUpdate();
});
window.addEventListener('scroll', scheduleLinuxRobotFallUpdate, { passive: true });
window.addEventListener('resize', scheduleLinuxRobotFallUpdate);
window.visualViewport?.addEventListener('resize', scheduleLinuxRobotFallUpdate);
});
onUnmounted(() => {
linuxRobotObserver?.disconnect();
linuxRobotObserver = null;
faqLandingResizeObserver?.disconnect();
faqLandingResizeObserver = null;
if (linuxRobotFallRaf) window.cancelAnimationFrame(linuxRobotFallRaf);
linuxRobotFallRaf = 0;
clearLinuxRobotFallTimer();
window.removeEventListener('scroll', scheduleLinuxRobotFallUpdate);
window.removeEventListener('resize', scheduleLinuxRobotFallUpdate);
window.visualViewport?.removeEventListener('resize', scheduleLinuxRobotFallUpdate);
});
const platformIcons: Record<string, string> = {
@ -68,12 +276,6 @@ const releaseDate = computed(() => {
});
});
const devBranchUrl = computed(() => `${repoUrl.value}/tree/dev`);
const devBranchNote = computed(() =>
locale.value === 'ru'
? 'Самая свежая версия доступна в ветке dev - можно развернуть локально.'
: 'Freshest version is available on the dev branch - clone and run it locally.',
);
</script>
<template>
@ -91,16 +293,44 @@ const devBranchNote = computed(() =>
v-for="(asset, index) in visibleAssets"
:key="asset.id"
class="download-section__card"
:class="{ 'download-section__card--active': downloadStore.selectedId === asset.id }"
:class="{
'download-section__card--active': downloadStore.selectedId === asset.id,
'download-section__card--with-robot': asset.os === 'linux',
'download-section__card--robot-flying': asset.os === 'linux' && isLinuxRobotDetached,
}"
:style="{
'--delay': `${index * 0.1}s`,
'--accent': platformColors[asset.os] || '#00f0ff',
}"
:data-download-os="asset.os"
@click="downloadStore.setSelected(asset.id)"
>
<!-- Card glow effect -->
<div class="download-section__card-glow" />
<span
v-if="asset.os === 'linux'"
class="download-section__card-robot-seat"
aria-hidden="true"
>
<Transition name="download-robot-bubble">
<span
v-if="showLinuxRobotMessage"
class="download-section__card-robot-bubble"
>
Готов начать!
</span>
</Transition>
<img
class="download-section__card-robot"
:src="robotAvatarSeatedMagenta"
alt=""
loading="lazy"
decoding="async"
draggable="false"
>
</span>
<!-- Platform icon -->
<div class="download-section__card-icon-wrap">
<v-icon
@ -145,19 +375,29 @@ const devBranchNote = computed(() =>
</div>
</div>
<a
class="download-section__dev-note"
:href="devBranchUrl"
target="_blank"
rel="noopener"
>
{{ devBranchNote }}
</a>
<p v-if="isMounted && releaseVersion" class="download-section__release-info">
v{{ releaseVersion }} · {{ releaseDate }}
</p>
</v-container>
<Teleport to="body">
<div
v-if="showFallingLinuxRobot"
class="download-section__falling-robot"
:class="`download-section__falling-robot--${linuxRobotFlightState}`"
:style="fallingLinuxRobotStyle"
aria-hidden="true"
@animationend.self="finishLinuxRobotFall"
>
<img
class="download-section__falling-robot-image"
:src="robotAvatarSeatedMagenta"
alt=""
decoding="async"
draggable="false"
>
</div>
</Teleport>
</section>
</template>
@ -230,6 +470,11 @@ const devBranchNote = computed(() =>
animation-delay: var(--delay, 0s);
}
.download-section__card--with-robot {
overflow: visible;
z-index: 3;
}
.download-section__card:hover {
transform: translateY(-6px);
border-color: rgba(0, 240, 255, 0.2);
@ -248,6 +493,252 @@ const devBranchNote = computed(() =>
z-index: 2;
}
.download-section__card-robot-seat {
position: absolute;
right: 12px;
bottom: calc(100% - 68px);
z-index: 4;
width: 108px;
pointer-events: none;
transform: rotate(-5deg);
transform-origin: center bottom;
filter:
drop-shadow(0 12px 18px rgba(0, 0, 0, 0.48))
drop-shadow(0 0 14px rgba(255, 43, 255, 0.24));
}
.download-section__card--robot-flying .download-section__card-robot-seat {
opacity: 0;
}
.download-section__falling-robot {
--fall-x: 0px;
--fall-y: 120vh;
position: fixed;
z-index: 28;
pointer-events: none;
transform-origin: center 72%;
will-change: left, top, transform, opacity;
filter:
drop-shadow(0 18px 26px rgba(0, 0, 0, 0.52))
drop-shadow(0 0 18px rgba(255, 43, 255, 0.28));
}
.download-section__falling-robot--falling {
animation: downloadFallingRobotDrop 2.85s cubic-bezier(0.34, 0, 0.74, 0.28) forwards;
}
.download-section__falling-robot--landed {
position: absolute;
}
.download-section__falling-robot::after {
position: absolute;
left: 18%;
right: 18%;
bottom: 28%;
height: 12px;
content: "";
border-radius: 999px;
background: rgba(255, 43, 255, 0.16);
filter: blur(10px);
opacity: 0.7;
}
.download-section__falling-robot-image {
position: relative;
z-index: 1;
display: block;
width: 100%;
height: auto;
transform:
scaleX(-1)
rotate(-4deg);
transform-origin: center bottom;
user-select: none;
}
.download-section__falling-robot--falling .download-section__falling-robot-image {
animation: none;
}
.download-section__falling-robot--landed .download-section__falling-robot-image {
animation: downloadFallingRobotLanded 2.8s ease-in-out infinite;
}
@keyframes downloadFallingRobotDrop {
0% {
opacity: 1;
transform: translate3d(0, 0, 0) rotate(-6deg) scale(1);
}
100% {
opacity: 0.96;
transform:
translate3d(var(--fall-x), var(--fall-y), 0)
rotate(355deg)
scale(0.95);
}
}
@keyframes downloadFallingRobotFlutter {
0%,
100% {
transform:
translate3d(0, 0, 0)
scaleX(-1)
rotate(-5deg);
}
50% {
transform:
translate3d(0, 4px, 0)
scaleX(-1)
rotate(4deg);
}
}
@keyframes downloadFallingRobotLanded {
0%,
100% {
transform:
translate3d(0, 0, 0)
scaleX(-1)
rotate(-4deg);
}
50% {
transform:
translate3d(0, -2px, 0)
scaleX(-1)
rotate(-3deg);
}
}
.download-section__card-robot-bubble {
position: absolute;
top: 12px;
right: calc(100% - 18px);
z-index: 5;
display: inline-flex;
align-items: center;
min-height: 30px;
padding: 6px 10px;
color: #0b1020;
font-family: var(--at-font-mono);
font-size: 0.66rem;
font-weight: 900;
line-height: 1;
letter-spacing: 0;
white-space: nowrap;
background:
radial-gradient(circle at 28% 24%, rgba(255, 255, 255, 0.84), rgba(255, 244, 168, 0.84) 66%, rgba(255, 215, 0, 0.82) 100%);
border: 2px solid #050816;
border-radius: 999px;
box-shadow:
0 0 0 1px rgba(255, 215, 0, 0.28),
0 5px 0 rgba(0, 0, 0, 0.2),
0 0 12px rgba(255, 215, 0, 0.14);
text-shadow: 1px 1px 0 rgba(255, 255, 255, 0.62);
pointer-events: none;
transform: rotate(-5deg);
transform-origin: right bottom;
animation: downloadRobotBubbleFloat 2.6s ease-in-out 0.42s infinite;
}
.download-section__card-robot-bubble::after {
position: absolute;
top: 52%;
right: -28px;
width: 30px;
height: 12px;
content: "";
background: rgba(255, 226, 78, 0.96);
clip-path: polygon(0 0, 100% 50%, 0 100%);
transform: translateY(-50%);
}
.download-section__card-robot-bubble::before {
position: absolute;
top: 52%;
right: -34px;
width: 36px;
height: 18px;
content: "";
background: #050816;
clip-path: polygon(0 0, 100% 50%, 0 100%);
transform: translateY(-50%);
}
.download-robot-bubble-enter-active,
.download-robot-bubble-leave-active {
transition:
opacity 0.26s ease,
filter 0.26s ease;
}
.download-robot-bubble-enter-active {
animation: downloadRobotBubblePop 0.52s cubic-bezier(0.18, 0.9, 0.2, 1.24);
}
.download-robot-bubble-enter-from,
.download-robot-bubble-leave-to {
opacity: 0;
filter: blur(2px);
}
.download-robot-bubble-leave-active {
animation: downloadRobotBubbleExit 0.22s ease forwards;
}
@keyframes downloadRobotBubblePop {
0% {
opacity: 0;
transform: translate3d(14px, 18px, 0) scale(0.48) rotate(-13deg);
}
58% {
opacity: 1;
transform: translate3d(-3px, -4px, 0) scale(1.1) rotate(-4deg);
}
100% {
opacity: 1;
transform: translate3d(0, 0, 0) scale(1) rotate(-5deg);
}
}
@keyframes downloadRobotBubbleFloat {
0%,
100% {
transform: translate3d(0, 0, 0) rotate(-5deg);
}
50% {
transform: translate3d(0, -3px, 0) rotate(-4deg);
}
}
@keyframes downloadRobotBubbleExit {
to {
opacity: 0;
transform: translate3d(8px, 8px, 0) scale(0.85) rotate(-8deg);
}
}
.download-section__card-robot {
display: block;
width: 100%;
max-width: none;
height: auto;
transform:
scaleX(-1)
rotate(-4deg);
transform-origin: center bottom;
user-select: none;
}
.download-section__card--active:hover {
transform: scale(1.08);
border-color: rgba(57, 255, 20, 0.5);
@ -395,38 +886,6 @@ const devBranchNote = computed(() =>
font-family: 'JetBrains Mono', monospace;
}
.download-section__dev-note {
display: flex;
width: fit-content;
max-width: min(620px, calc(100vw - 32px));
margin: 18px auto 0;
padding: 8px 12px;
border: 1px solid rgba(0, 240, 255, 0.12);
border-radius: 10px;
background: rgba(0, 240, 255, 0.035);
color: #00f0ff;
font-family: 'JetBrains Mono', monospace;
font-size: 0.76rem;
line-height: 1.55;
text-align: center;
text-decoration: none;
opacity: 0.82;
position: relative;
z-index: 1;
transition:
border-color 0.2s ease,
background 0.2s ease,
color 0.2s ease,
opacity 0.2s ease;
}
.download-section__dev-note:hover {
border-color: rgba(57, 255, 20, 0.24);
background: rgba(57, 255, 20, 0.045);
color: #39ff14;
opacity: 1;
}
@keyframes downloadFadeUp {
from {
opacity: 0;
@ -502,6 +961,26 @@ const devBranchNote = computed(() =>
gap: 20px;
}
.download-section__card-robot-seat {
right: 18px;
bottom: calc(100% - 54px);
width: 84px;
}
.download-section__card-robot-bubble {
top: 8px;
right: calc(100% - 14px);
min-height: 28px;
padding: 6px 9px;
font-size: 0.6rem;
}
.download-section__card-robot {
transform:
scaleX(-1)
rotate(-4deg);
}
.download-section__card--active {
transform: scale(1.03);
}
@ -557,6 +1036,10 @@ const devBranchNote = computed(() =>
border-radius: 16px;
}
.download-section__card-robot-seat {
display: none;
}
.download-section__card-icon-wrap {
width: 52px;
height: 52px;

View file

@ -66,7 +66,7 @@ const faqIcons = [
</div>
<div class="faq-section__decoration">
<div class="faq-section__deco-circle">
<div class="faq-section__deco-circle" data-faq-landing-target>
<v-icon size="40" class="faq-section__deco-icon" :icon="mdiFrequentlyAskedQuestions" />
</div>
<div class="faq-section__deco-ring faq-section__deco-ring--1" />

View file

@ -2,33 +2,38 @@
import {
mdiBookOpenPageVariantOutline,
mdiDownload,
mdiPlayCircleOutline,
} from "@mdi/js";
import { heroMessages, type HeroMessagePhase } from "~/data/heroScene";
const { content } = useLandingContent();
const { t, locale } = useI18n();
const { baseURL } = useRuntimeConfig().app;
const heroRef = ref<HTMLElement | null>(null);
const activeHeroMessageIndex = ref(0);
const heroMessagePhase = ref<HeroMessagePhase>("cooldown");
const isHeroVisible = ref(false);
const heroReducedMotion = ref(false);
let heroMessageTimers: number[] = [];
let heroMessageObserver: IntersectionObserver | null = null;
let heroMotionQuery: MediaQueryList | null = null;
const downloadStore = useDownloadStore();
const { resolve, data: releaseData } = useReleaseDownloads();
const { repoUrl, latestReleaseUrl, releaseDownloadUrl } = useGithubRepo();
const { latestReleaseUrl, releaseDownloadUrl } = useGithubRepo();
const withBase = (path: string) => `${baseURL.replace(/\/?$/, "/")}${path.replace(/^\/+/, "")}`;
useCyberHeroParallax(heroRef);
const releaseVersion = computed(() => releaseData.value?.version || null);
const releaseDate = computed(() => {
const raw = releaseData.value?.pubDate;
if (!raw) return null;
return new Date(raw).toLocaleDateString("en-US", {
if (!releaseData.value?.pubDate) return "";
return new Date(releaseData.value.pubDate).toLocaleDateString(locale.value, {
year: "numeric",
month: "short",
day: "numeric",
});
});
onMounted(() => downloadStore.init());
const activeHeroMessage = computed(() => heroMessages[activeHeroMessageIndex.value] ?? null);
const heroDownloadUrl = computed(() => {
const asset = downloadStore.selectedAsset;
@ -37,17 +42,87 @@ const heroDownloadUrl = computed(() => {
return resolve(asset.os, arch)?.url || releaseDownloadUrl(asset.fileName);
});
const devBranchUrl = computed(() => `${repoUrl.value}/tree/dev`);
const docsHref = computed(() => withBase(locale.value === "ru" ? "docs/ru/" : "docs/"));
const devBranchNote = computed(() =>
locale.value === "ru"
? "Самая свежая версия в ветке dev - можно развернуть локально."
: "Freshest version is on the dev branch - clone and run it locally.",
);
function clearHeroMessageTimers() {
heroMessageTimers.forEach(window.clearTimeout);
heroMessageTimers = [];
}
function setHeroMessageTimer(callback: () => void, delay: number) {
const id = window.setTimeout(callback, delay);
heroMessageTimers.push(id);
}
function runHeroMessageCycle() {
clearHeroMessageTimers();
if (!isHeroVisible.value || heroReducedMotion.value || heroMessages.length === 0) {
heroMessagePhase.value = "cooldown";
return;
}
heroMessagePhase.value = "sender";
setHeroMessageTimer(() => {
heroMessagePhase.value = "packet";
}, 900);
setHeroMessageTimer(() => {
heroMessagePhase.value = "receiver";
}, 2200);
setHeroMessageTimer(() => {
heroMessagePhase.value = "cooldown";
}, 3900);
setHeroMessageTimer(() => {
activeHeroMessageIndex.value = (activeHeroMessageIndex.value + 1) % heroMessages.length;
runHeroMessageCycle();
}, 4700);
}
function syncHeroMotion() {
heroReducedMotion.value = Boolean(heroMotionQuery?.matches);
runHeroMessageCycle();
}
function onHeroVisibilityChange() {
if (document.hidden) {
clearHeroMessageTimers();
heroMessagePhase.value = "cooldown";
return;
}
runHeroMessageCycle();
}
onMounted(() => {
downloadStore.init();
heroMotionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
heroReducedMotion.value = heroMotionQuery.matches;
heroMotionQuery.addEventListener("change", syncHeroMotion);
document.addEventListener("visibilitychange", onHeroVisibilityChange);
heroMessageObserver = new IntersectionObserver(
([entry]) => {
isHeroVisible.value = Boolean(entry?.isIntersecting);
runHeroMessageCycle();
},
{ threshold: 0.15 },
);
if (heroRef.value) heroMessageObserver.observe(heroRef.value);
});
onUnmounted(() => {
clearHeroMessageTimers();
heroMessageObserver?.disconnect();
heroMotionQuery?.removeEventListener("change", syncHeroMotion);
document.removeEventListener("visibilitychange", onHeroVisibilityChange);
});
</script>
<template>
<section id="hero" ref="heroRef" class="hero-section cyber-hero section anchor-offset" data-cyber-hero>
<CyberHeroMontereyBackground />
<div class="cyber-hero__background" aria-hidden="true" />
<div class="cyber-hero__wash" aria-hidden="true" />
<div class="cyber-hero__gridlines" aria-hidden="true" />
@ -56,13 +131,13 @@ const devBranchNote = computed(() =>
<v-container class="cyber-hero__container">
<div class="cyber-hero__layout">
<div class="cyber-hero__copy">
<h1 class="cyber-hero__title">
<span>Agent{{ " " }}</span>
<h1 class="cyber-hero__title" aria-label="Agent Teams">
<span>Agent</span>
<span class="cyber-hero__title-accent">Teams</span>
</h1>
<p class="cyber-hero__slogan cyber-panel">
YOU'RE THE CTO, AGENTS ARE YOUR TEAM.
Get a lot done by doing very little
</p>
<p class="cyber-hero__description">
@ -80,15 +155,6 @@ const devBranchNote = computed(() =>
>
{{ t("hero.downloadNow") }}
</v-btn>
<v-btn
variant="outlined"
size="large"
href="#hero-demo"
class="cyber-hero__action cyber-hero__action--watch"
:prepend-icon="mdiPlayCircleOutline"
>
{{ t("hero.watchDemo") }}
</v-btn>
<v-btn
variant="outlined"
size="large"
@ -100,26 +166,33 @@ const devBranchNote = computed(() =>
</v-btn>
</div>
<a
<p
v-if="releaseVersion"
class="cyber-hero__terminal-note cyber-panel"
:href="devBranchUrl"
target="_blank"
rel="noopener"
>
<span class="cyber-hero__terminal-lines">
<span>&gt; {{ devBranchNote }}</span>
<span>&gt; Team ready. What shall we build today?</span>
<span class="cyber-hero__release">
v{{ releaseVersion }}
<span v-if="releaseDate" class="cyber-hero__release-date">
· {{ releaseDate }}
</span>
</span>
<span v-if="releaseVersion" class="cyber-hero__release">
v{{ releaseVersion }}<template v-if="releaseDate"> - {{ releaseDate }}</template>
</span>
</a>
</p>
</div>
<CyberHeroScene class="cyber-hero__scene" />
<CyberHeroScene
class="cyber-hero__scene"
:message="activeHeroMessage"
:phase="heroMessagePhase"
:reduced-motion="heroReducedMotion"
/>
</div>
<CyberHeroFeatureStrip class="cyber-hero__feature-strip" />
<CyberHeroFeatureStrip
class="cyber-hero__feature-strip"
:active-message="activeHeroMessage"
:phase="heroMessagePhase"
:reduced-motion="heroReducedMotion"
/>
</v-container>
</section>
</template>

View file

@ -399,7 +399,10 @@ function slideNext() {
}
.screenshots-lightbox__nav {
flex-shrink: 0;
position: absolute;
top: 50%;
z-index: 2;
transform: translateY(-50%);
width: 48px;
height: 48px;
border-radius: 50%;
@ -417,12 +420,20 @@ function slideNext() {
background: rgba(255, 255, 255, 0.15);
}
.screenshots-lightbox__nav--prev {
left: clamp(16px, 4vw, 48px);
}
.screenshots-lightbox__nav--next {
right: clamp(16px, 4vw, 48px);
}
.screenshots-lightbox__content {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
max-width: 90vw;
max-width: min(90vw, calc(100vw - 160px));
max-height: 85vh;
}
@ -517,6 +528,10 @@ function slideNext() {
display: none;
}
.screenshots-lightbox__content {
max-width: 96vw;
}
.screenshots-lightbox {
padding: 60px 8px 20px;
}

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import { nextTick, ref, onMounted, onUnmounted } from 'vue';
import { mdiPlay, mdiPause, mdiVolumeHigh, mdiVolumeOff, mdiFullscreen } from '@mdi/js';
const { t } = useI18n();
@ -9,20 +9,51 @@ const containerRef = ref<HTMLElement | null>(null);
const isPlaying = ref(false);
const isMuted = ref(true);
const showControls = ref(true);
const isLoaded = ref(false);
const isLoaded = ref(true);
const hasError = ref(false);
const progress = ref(0);
const loadProgress = ref(0);
const hideTimer = ref<ReturnType<typeof setTimeout> | null>(null);
let intObserver: IntersectionObserver | null = null;
let loadFallbackTimer: ReturnType<typeof setTimeout> | null = null;
function clearLoadFallback() {
if (!loadFallbackTimer) return;
clearTimeout(loadFallbackTimer);
loadFallbackTimer = null;
}
function markLoaded() {
if (hasError.value) return;
isLoaded.value = true;
clearLoadFallback();
updateLoadProgress();
}
function markError() {
hasError.value = true;
clearLoadFallback();
}
function onVideoEnded() {
const video = videoRef.value;
isPlaying.value = false;
showControls.value = true;
progress.value = 0;
if (video) video.currentTime = 0;
}
function togglePlay() {
const video = videoRef.value;
if (!video) return;
if (video.paused) {
video.play();
isPlaying.value = true;
markLoaded();
video.play()
.then(() => {
isPlaying.value = true;
})
.catch(markError);
} else {
video.pause();
isPlaying.value = false;
@ -93,19 +124,25 @@ function onMouseLeave() {
}
}
onMounted(() => {
onMounted(async () => {
await nextTick();
const video = videoRef.value;
if (video) {
// canplay fires earlier than loadeddata enough to show first frame
video.addEventListener('canplay', () => { isLoaded.value = true; }, { once: true });
video.addEventListener('error', () => { hasError.value = true; });
isMuted.value = video.muted;
video.addEventListener('loadedmetadata', markLoaded, { once: true });
video.addEventListener('loadeddata', markLoaded, { once: true });
video.addEventListener('canplay', markLoaded, { once: true });
video.addEventListener('canplaythrough', markLoaded, { once: true });
video.addEventListener('error', markError);
video.addEventListener('progress', updateLoadProgress);
video.addEventListener('ended', () => {
isPlaying.value = false;
showControls.value = true;
progress.value = 0;
video.currentTime = 0;
});
video.addEventListener('ended', onVideoEnded);
if (video.readyState >= HTMLMediaElement.HAVE_METADATA) {
markLoaded();
} else {
video.load();
loadFallbackTimer = setTimeout(markLoaded, 1800);
}
}
intObserver = new IntersectionObserver(
@ -122,8 +159,15 @@ onMounted(() => {
onUnmounted(() => {
if (hideTimer.value) clearTimeout(hideTimer.value);
clearLoadFallback();
if (intObserver) { intObserver.disconnect(); intObserver = null; }
videoRef.value?.removeEventListener('loadedmetadata', markLoaded);
videoRef.value?.removeEventListener('loadeddata', markLoaded);
videoRef.value?.removeEventListener('canplay', markLoaded);
videoRef.value?.removeEventListener('canplaythrough', markLoaded);
videoRef.value?.removeEventListener('error', markError);
videoRef.value?.removeEventListener('progress', updateLoadProgress);
videoRef.value?.removeEventListener('ended', onVideoEnded);
});
</script>
@ -163,7 +207,7 @@ onUnmounted(() => {
ref="videoRef"
class="hero-video__player"
:class="{ 'hero-video__player--loaded': isLoaded }"
preload="auto"
preload="metadata"
poster="/screenshots/2.jpg"
muted
playsinline

View file

@ -1,7 +1,9 @@
import { computed, watch, onUnmounted } from "vue";
import { computed, getCurrentInstance, onMounted, onUnmounted, ref, watch } from "vue";
import type { Ref } from "vue";
import { useThemeStore } from "~/stores/theme";
type ThemeName = "light" | "dark";
type VuetifyThemeInstance = {
global: {
name: Ref<string>;
@ -10,40 +12,109 @@ type VuetifyThemeInstance = {
change?: (name: string) => void;
};
function isThemeName(value: string | null | undefined): value is ThemeName {
return value === "dark" || value === "light";
}
export const useBrowserTheme = () => {
const themeStore = useThemeStore();
const { $vuetifyTheme } = useNuxtApp();
const vuetifyTheme = $vuetifyTheme as VuetifyThemeInstance | null;
const documentTheme = ref<ThemeName | null>(null);
let mediaQueryHandler: ((event: MediaQueryListEvent) => void) | null = null;
let mediaQuery: MediaQueryList | null = null;
let themeClassObserver: MutationObserver | 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 getDocumentTheme = (): ThemeName | null => {
if (!import.meta.client) return null;
const appClass = document.querySelector(".v-application")?.classList;
if (appClass?.contains("v-theme--dark")) return "dark";
if (appClass?.contains("v-theme--light")) return "light";
return null;
};
const applyTheme = (name: "light" | "dark") => {
themeStore.setTheme(name, true);
const refreshDocumentTheme = () => {
documentTheme.value = getDocumentTheme();
return documentTheme.value;
};
const applyDocumentTheme = (name: ThemeName) => {
if (!import.meta.client) return;
document.querySelectorAll(".v-application").forEach((app) => {
app.classList.toggle("v-theme--dark", name === "dark");
app.classList.toggle("v-theme--light", name === "light");
});
documentTheme.value = name;
};
const getAppliedTheme = (): ThemeName => {
const domTheme = getDocumentTheme() ?? documentTheme.value;
if (domTheme) return domTheme;
const vuetifyName = vuetifyTheme?.global.name.value;
if (isThemeName(vuetifyName)) return vuetifyName;
return themeStore.current;
};
const syncStoreFromAppliedTheme = () => {
const appliedTheme = getAppliedTheme();
if (themeStore.current !== appliedTheme) {
themeStore.setTheme(appliedTheme, false);
}
return appliedTheme;
};
const applyVuetifyTheme = (name: ThemeName) => {
if (!vuetifyTheme) return;
if (vuetifyTheme.change) {
vuetifyTheme.change(name);
return;
}
vuetifyTheme.global.name.value = name;
};
const applyTheme = (name: ThemeName, fromUser = true) => {
applyVuetifyTheme(name);
applyDocumentTheme(name);
themeStore.setTheme(name, fromUser);
return name;
};
const observeDocumentTheme = () => {
if (!import.meta.client || themeClassObserver) return;
const app = document.querySelector(".v-application");
if (!app) return;
refreshDocumentTheme();
themeClassObserver = new MutationObserver(() => {
refreshDocumentTheme();
});
themeClassObserver.observe(app, { attributes: true, attributeFilter: ["class"] });
};
const initTheme = () => {
if (!import.meta.client) return;
const initialTheme = themeStore.getInitialTheme();
themeStore.setTheme(initialTheme, false);
applyVuetifyTheme(initialTheme);
applyTheme(initialTheme, false);
if (mediaQuery && mediaQueryHandler) {
mediaQuery.removeEventListener("change", mediaQueryHandler);
mediaQuery = null;
mediaQueryHandler = null;
}
if (!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);
applyTheme(newTheme, false);
}
};
mediaQuery.addEventListener("change", mediaQueryHandler);
@ -51,25 +122,54 @@ export const useBrowserTheme = () => {
};
const toggleTheme = () => {
applyTheme(themeStore.current === "dark" ? "light" : "dark");
const appliedTheme = syncStoreFromAppliedTheme();
return applyTheme(appliedTheme === "dark" ? "light" : "dark");
};
onUnmounted(() => {
if (mediaQuery && mediaQueryHandler) {
mediaQuery.removeEventListener("change", mediaQueryHandler);
}
});
if (getCurrentInstance()) {
onMounted(() => {
refreshDocumentTheme();
observeDocumentTheme();
});
onUnmounted(() => {
if (mediaQuery && mediaQueryHandler) {
mediaQuery.removeEventListener("change", mediaQueryHandler);
}
themeClassObserver?.disconnect();
});
}
watch(
() => themeStore.current,
(value) => {
applyVuetifyTheme(value as "light" | "dark");
applyVuetifyTheme(value);
}
);
if (vuetifyTheme) {
watch(
() => vuetifyTheme.global.name.value,
(value) => {
if (isThemeName(value) && themeStore.current !== value) {
themeStore.setTheme(value, false);
}
}
);
}
const currentTheme = computed(() => {
if (documentTheme.value) return documentTheme.value;
const vuetifyName = vuetifyTheme?.global.name.value;
return isThemeName(vuetifyName) ? vuetifyName : themeStore.current;
});
const isDark = computed(() => currentTheme.value === "dark");
return {
currentTheme: computed(() => themeStore.current),
isDark: computed(() => themeStore.current === "dark"),
currentTheme,
isDark,
initTheme,
toggleTheme
};

View file

@ -1,25 +1,15 @@
import type { Ref } from "vue";
import { nextTick, onMounted, onUnmounted } from "vue";
type PointerState = {
x: number;
y: number;
};
export function useCyberHeroParallax(rootRef: Ref<HTMLElement | null>) {
let rafId = 0;
let bounds: DOMRect | null = null;
let reduceMotion: MediaQueryList | null = null;
let canHover: MediaQueryList | null = null;
let observer: IntersectionObserver | null = null;
let isVisible = true;
const pointer: PointerState = { x: 0, y: 0 };
let scrollOffset = 0;
const shouldRun = () => {
if (reduceMotion?.matches) return false;
if (canHover && !canHover.matches) return false;
return window.innerWidth >= 768 && isVisible;
};
@ -28,20 +18,17 @@ export function useCyberHeroParallax(rootRef: Ref<HTMLElement | null>) {
const root = rootRef.value;
if (!root) return;
root.style.setProperty("--hero-pointer-x", "0");
root.style.setProperty("--hero-pointer-y", "0");
root.style.setProperty("--hero-tilt-x", "0");
root.style.setProperty("--hero-tilt-y", "0");
if (!shouldRun()) {
root.style.setProperty("--hero-pointer-x", "0");
root.style.setProperty("--hero-pointer-y", "0");
root.style.setProperty("--hero-scroll", "0");
root.style.setProperty("--hero-tilt-x", "0");
root.style.setProperty("--hero-tilt-y", "0");
return;
}
root.style.setProperty("--hero-pointer-x", pointer.x.toFixed(4));
root.style.setProperty("--hero-pointer-y", pointer.y.toFixed(4));
root.style.setProperty("--hero-scroll", scrollOffset.toFixed(2));
root.style.setProperty("--hero-tilt-x", pointer.x.toFixed(4));
root.style.setProperty("--hero-tilt-y", pointer.y.toFixed(4));
};
const requestWrite = () => {
@ -49,29 +36,6 @@ export function useCyberHeroParallax(rootRef: Ref<HTMLElement | null>) {
rafId = requestAnimationFrame(writeVars);
};
const updateBounds = () => {
bounds = rootRef.value?.getBoundingClientRect() ?? null;
};
const onPointerMove = (event: PointerEvent) => {
if (!shouldRun()) return;
if (!bounds) updateBounds();
if (!bounds) return;
const nextX = ((event.clientX - bounds.left) / bounds.width) * 2 - 1;
const nextY = ((event.clientY - bounds.top) / bounds.height) * 2 - 1;
pointer.x = Math.max(-1, Math.min(1, nextX));
pointer.y = Math.max(-1, Math.min(1, nextY));
requestWrite();
};
const onPointerLeave = () => {
pointer.x = 0;
pointer.y = 0;
requestWrite();
};
const onScroll = () => {
const root = rootRef.value;
if (!root || !shouldRun()) return;
@ -81,7 +45,6 @@ export function useCyberHeroParallax(rootRef: Ref<HTMLElement | null>) {
};
const onResize = () => {
updateBounds();
requestWrite();
};
@ -91,7 +54,6 @@ export function useCyberHeroParallax(rootRef: Ref<HTMLElement | null>) {
if (!root) return;
reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
canHover = window.matchMedia("(hover: hover) and (pointer: fine)");
observer = new IntersectionObserver(
([entry]) => {
isVisible = entry.isIntersecting;
@ -101,25 +63,17 @@ export function useCyberHeroParallax(rootRef: Ref<HTMLElement | null>) {
);
observer.observe(root);
updateBounds();
root.addEventListener("pointermove", onPointerMove, { passive: true });
root.addEventListener("pointerleave", onPointerLeave, { passive: true });
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onResize, { passive: true });
reduceMotion.addEventListener("change", requestWrite);
canHover.addEventListener("change", requestWrite);
requestWrite();
});
onUnmounted(() => {
const root = rootRef.value;
if (rafId) cancelAnimationFrame(rafId);
observer?.disconnect();
root?.removeEventListener("pointermove", onPointerMove);
root?.removeEventListener("pointerleave", onPointerLeave);
window.removeEventListener("scroll", onScroll);
window.removeEventListener("resize", onResize);
reduceMotion?.removeEventListener("change", requestWrite);
canHover?.removeEventListener("change", requestWrite);
});
}

View file

@ -1,11 +1,8 @@
import robotAmber from "~/assets/images/hero/robots/robot-amber-v1.webp";
import robotCyan from "~/assets/images/hero/robots/robot-cyan-v1.webp";
import robotMagenta from "~/assets/images/hero/robots/robot-magenta-v1.webp";
export const HERO_SCENE_VIEWBOX = {
width: 1600,
height: 900,
} as const;
import robotAvatarCyan from "~/assets/images/hero/robots/robot-avatar-cyan-cat-v1.webp";
import robotAvatarReviewerTeal from "~/assets/images/hero/robots/robot-avatar-reviewer-teal-v1.webp";
import robotAvatarSeatedMagenta from "~/assets/images/hero/robots/robot-avatar-seated-magenta-v1.webp";
import robotAvatarYellow from "~/assets/images/hero/robots/robot-avatar-yellow-star-v1.webp";
import robotRedPurpleHandshake from "~/assets/images/hero/robots/robot-red-purple-handshake-v1.webp";
export const HERO_SCENE_BREAKPOINTS = {
desktop: 1200,
@ -25,6 +22,7 @@ export type HeroAgentRole =
| "fixer";
export type HeroAccent = "cyan" | "magenta" | "violet" | "amber" | "red";
export type HeroMessagePhase = "sender" | "packet" | "receiver" | "cooldown";
export type HeroCardSide = "left" | "right" | "bottom";
@ -41,6 +39,8 @@ export type HeroAgent = {
label: string;
asset: string;
accent: HeroAccent;
facing?: -1 | 1;
lean?: number;
priority?: boolean;
desktop: HeroAgentPosition;
tablet: HeroAgentPosition;
@ -53,21 +53,10 @@ export type HeroAgent = {
tasks: string[];
};
export type HeroConnection = {
id: string;
from: HeroAgentRole | "video";
to: HeroAgentRole | "video";
accent: Extract<HeroAccent, "cyan" | "magenta" | "amber">;
pathDesktop: string;
packetDelayMs: number;
packetDurationMs: number;
};
export type HeroMessage = {
id: string;
from: HeroAgentRole;
to: HeroAgentRole | "video";
connectionId: string;
text: string;
response: string;
fromX: number;
@ -80,11 +69,13 @@ export const heroAgents: readonly HeroAgent[] = [
{
id: "planner",
label: "Planner",
asset: robotCyan,
accent: "cyan",
asset: robotAvatarSeatedMagenta,
accent: "magenta",
facing: 1,
lean: -2,
priority: true,
desktop: { x: 34, y: 12, scale: 0.66, depth: 0.35, card: "right" },
tablet: { x: 18, y: 11, scale: 0.55, depth: 0.22, card: "bottom" },
desktop: { x: 32.65, y: 19.45, scale: 0.44, depth: 0.35, card: "right" },
tablet: { x: 20, y: 31, scale: 0.44, depth: 0.22, card: "bottom" },
mobile: { visible: true, order: 1, compactLabel: "Plan" },
status: "Planning",
tasks: ["Analyze requirements", "Break down tasks", "Create plan"],
@ -92,256 +83,116 @@ export const heroAgents: readonly HeroAgent[] = [
{
id: "lead",
label: "Lead",
asset: robotCyan,
asset: robotAvatarCyan,
accent: "cyan",
facing: -1,
lean: 2,
priority: true,
desktop: { x: 55, y: 9, scale: 0.62, depth: 0.32, card: "right" },
tablet: { x: 50, y: 8, scale: 0.52, depth: 0.2, card: "bottom" },
desktop: { x: 58.9, y: 32.76, scale: 0.48, depth: 0.32, card: "right" },
tablet: { x: 50, y: 31, scale: 0.42, depth: 0.2, card: "bottom" },
mobile: { visible: true, order: 2, compactLabel: "Lead" },
status: "Leading",
tasks: ["Define architecture", "Set priorities", "Coordinate team"],
},
{
id: "reviewer",
label: "Reviewer",
asset: robotMagenta,
accent: "magenta",
priority: true,
desktop: { x: 75, y: 13, scale: 0.58, depth: 0.34, card: "left" },
tablet: { x: 82, y: 12, scale: 0.48, depth: 0.22, card: "bottom" },
mobile: { visible: true, order: 3, compactLabel: "Review" },
status: "Reviewing",
tasks: ["Review code", "Check quality", "Request changes"],
},
{
id: "researcher",
label: "Researcher",
asset: robotCyan,
accent: "violet",
desktop: { x: 27, y: 39, scale: 0.48, depth: 0.45, card: "right" },
tablet: { x: 16, y: 45, scale: 0.44, depth: 0.25, card: "bottom" },
mobile: { visible: false },
status: "Researching",
tasks: ["Research options", "Compare solutions", "Summarize findings"],
},
{
id: "developer",
label: "Developer",
asset: robotCyan,
accent: "cyan",
desktop: { x: 74, y: 34, scale: 0.5, depth: 0.52, card: "left" },
tablet: { x: 88, y: 44, scale: 0.42, depth: 0.26, card: "bottom" },
mobile: { visible: false },
asset: robotAvatarYellow,
accent: "amber",
facing: 1,
lean: -1,
priority: true,
desktop: { x: 72, y: 32.4, scale: 0.48, depth: 0.34, card: "right" },
tablet: { x: 80, y: 31, scale: 0.4, depth: 0.22, card: "bottom" },
mobile: { visible: true, order: 3, compactLabel: "Code" },
status: "Coding",
tasks: ["Write code", "Implement feature", "Commit changes"],
},
{
id: "tester",
label: "Tester",
asset: robotMagenta,
accent: "magenta",
desktop: { x: 72, y: 59, scale: 0.48, depth: 0.58, card: "left" },
tablet: { x: 76, y: 77, scale: 0.4, depth: 0.28, card: "bottom" },
mobile: { visible: false },
status: "Testing",
tasks: ["Write tests", "Run tests", "Report issues"],
},
{
id: "docs",
label: "Docs",
asset: robotMagenta,
accent: "violet",
desktop: { x: 30, y: 64, scale: 0.43, depth: 0.55, card: "right" },
tablet: { x: 25, y: 78, scale: 0.36, depth: 0.28, card: "bottom" },
mobile: { visible: false },
status: "Writing",
tasks: ["Write docs", "API reference", "Examples"],
},
{
id: "ops",
label: "Ops",
asset: robotAmber,
accent: "amber",
desktop: { x: 43, y: 84, scale: 0.46, depth: 0.7, card: "right" },
tablet: { x: 42, y: 83, scale: 0.38, depth: 0.34, card: "bottom" },
mobile: { visible: false },
status: "Deploying",
tasks: ["Deploy services", "Monitor health", "Manage infra"],
},
{
id: "security",
label: "Security",
asset: robotAmber,
accent: "red",
desktop: { x: 63, y: 85, scale: 0.42, depth: 0.68, card: "right" },
tablet: { x: 60, y: 82, scale: 0.34, depth: 0.32, card: "bottom" },
mobile: { visible: false },
status: "Secure",
tasks: ["Scan dependencies", "Check permissions", "Security review"],
},
{
id: "fixer",
label: "Fixer",
asset: robotAmber,
accent: "amber",
desktop: { x: 69, y: 83, scale: 0.42, depth: 0.72, card: "left" },
tablet: { x: 90, y: 82, scale: 0.36, depth: 0.34, card: "bottom" },
mobile: { visible: false },
status: "Fixing",
tasks: ["Fix issues", "Refactor code", "Optimize"],
tasks: ["Implement feature", "Update code", "Run checks"],
},
] as const;
export const heroConnections: readonly HeroConnection[] = [
{
id: "planner-lead",
from: "planner",
to: "lead",
accent: "cyan",
pathDesktop: "M 545 195 C 680 210, 735 185, 860 190",
packetDelayMs: 0,
packetDurationMs: 4200,
},
{
id: "lead-reviewer",
from: "lead",
to: "reviewer",
accent: "magenta",
pathDesktop: "M 950 205 C 1050 185, 1130 190, 1265 220",
packetDelayMs: 700,
packetDurationMs: 3900,
},
{
id: "developer-reviewer",
from: "developer",
to: "reviewer",
accent: "magenta",
pathDesktop: "M 1390 370 C 1325 320, 1305 270, 1260 230",
packetDelayMs: 500,
packetDurationMs: 3400,
},
{
id: "researcher-video",
from: "researcher",
to: "video",
accent: "cyan",
pathDesktop: "M 520 425 C 625 410, 680 405, 755 420",
packetDelayMs: 1100,
packetDurationMs: 4400,
},
{
id: "video-tester",
from: "video",
to: "tester",
accent: "magenta",
pathDesktop: "M 1290 540 C 1365 555, 1410 575, 1480 615",
packetDelayMs: 1300,
packetDurationMs: 4100,
},
{
id: "tester-lead",
from: "tester",
to: "lead",
accent: "cyan",
pathDesktop: "M 1450 625 C 1365 650, 1170 642, 1030 630 C 940 620, 880 585, 850 515",
packetDelayMs: 1800,
packetDurationMs: 5200,
},
{
id: "ops-security",
from: "ops",
to: "security",
accent: "amber",
pathDesktop: "M 745 740 C 835 725, 910 725, 1000 742",
packetDelayMs: 2200,
packetDurationMs: 4600,
},
{
id: "security-fixer",
from: "security",
to: "fixer",
accent: "amber",
pathDesktop: "M 1100 745 C 1185 725, 1270 730, 1375 755",
packetDelayMs: 2600,
packetDurationMs: 4600,
},
] as const;
export const heroReviewerFeatureCard = {
label: "Reviewer",
asset: robotAvatarReviewerTeal,
accent: "cyan",
status: "Reviewing",
tasks: ["Review code", "Check quality", "Request changes"],
} as const;
export const heroCollaborationFeature = {
asset: robotRedPurpleHandshake,
} as const;
export const heroMessages: readonly HeroMessage[] = [
{
id: "code-review",
id: "plan-ready",
from: "planner",
to: "lead",
text: "Plan ready.",
response: "Priority set.",
fromX: 29.2,
fromY: 13,
toX: 58.8,
toY: 8.6,
},
{
id: "build-ready",
from: "lead",
to: "developer",
text: "Build scope set.",
response: "Coding started.",
fromX: 58.8,
fromY: 8.6,
toX: 72,
toY: 7,
},
{
id: "review-build",
from: "developer",
to: "reviewer",
connectionId: "developer-reviewer",
text: "Code ready. Request review.",
response: "Review started.",
fromX: 78,
fromY: 43,
toX: 73,
toY: 20,
text: "Review build.",
response: "Checking quality.",
fromX: 72,
fromY: 7,
toX: 84,
toY: 82,
},
{
id: "tests-passed",
from: "tester",
to: "lead",
connectionId: "tester-lead",
text: "Tests passed. Looks good.",
response: "Ship it.",
fromX: 78,
fromY: 62,
toX: 58,
toY: 21,
},
{
id: "research-ready",
from: "researcher",
to: "video",
connectionId: "researcher-video",
text: "Findings ready.",
response: "Plan updated.",
fromX: 32,
fromY: 45,
toX: 50,
toY: 53,
},
{
id: "ops-secure",
from: "ops",
to: "security",
connectionId: "ops-security",
text: "Deployed to staging.",
response: "Dependencies checked.",
fromX: 44,
fromY: 72,
toX: 62,
toY: 74,
id: "review-pass",
from: "reviewer",
to: "developer",
text: "Review passed.",
response: "Ready to ship.",
fromX: 84,
fromY: 82,
toX: 72,
toY: 7,
},
] as const;
export const heroFeatureRail = [
{
id: "autonomous",
title: "Autonomous Team",
text: "Specialized agents coordinate work together.",
title: "Give the Team a Goal",
text: "Agents break it into tasks and start moving without babysitting.",
},
{
id: "kanban",
title: "Kanban at Lightspeed",
text: "Tasks move as agents build, review, and test.",
title: "Kanban That Updates Itself",
text: "Cards shift as agents build, test, review, and unblock each other.",
},
{
id: "developers",
title: "Built for Developers",
text: "Open source, extensible, and API-first.",
title: "Bring Your AI Stack",
text: "Claude, Codex, and OpenCode teammates in one desktop cockpit.",
},
{
id: "secure",
title: "Secure by Default",
text: "Your code and data stay protected.",
title: "Stay in the Loop",
text: "Jump in with comments, approvals, direct messages, or quick actions.",
},
{
id: "local",
title: "Local First",
text: "Runs on your machine. Your data stays yours.",
title: "Your Machine, Your Code",
text: "Local-first workflow with task logs, process control, and Git visibility.",
},
] as const;

View file

@ -64,6 +64,7 @@
"sectionTitle": "كيف نقارن",
"sectionSubtitle": "مقارنة تفصيلية للمميزات مع أدوات البرمجة بالذكاء الاصطناعي الأخرى.",
"feature": "الميزة",
"robotBubble": "احكم بنفسك",
"features": {
"crossTeam": "التواصل بين الفرق",
"agentMessaging": "مراسلة بين الوكلاء",
@ -98,6 +99,7 @@
"footer": {
"copyright": "© {year} Agent Teams",
"tagline": "تنسيق وكلاء الذكاء الاصطناعي للمطورين",
"robotBubble": "أنا أنتظر",
"links": {
"github": "GitHub",
"docs": "التوثيق"

View file

@ -64,6 +64,7 @@
"sectionTitle": "Wie wir im Vergleich abschneiden",
"sectionSubtitle": "Funktionsvergleich mit anderen KI-Coding-Tools.",
"feature": "Funktion",
"robotBubble": "Urteile selbst",
"features": {
"crossTeam": "Teamübergreifende Kommunikation",
"agentMessaging": "Agent-zu-Agent-Messaging",
@ -98,6 +99,7 @@
"footer": {
"copyright": "© {year} Agent Teams",
"tagline": "KI-Agenten-Orchestrierung für Entwickler",
"robotBubble": "Ich warte",
"links": {
"github": "GitHub",
"docs": "Dokumentation"

View file

@ -64,6 +64,7 @@
"sectionTitle": "How we compare",
"sectionSubtitle": "Feature-by-feature comparison with other AI coding tools.",
"feature": "Feature",
"robotBubble": "Judge for yourself",
"features": {
"crossTeam": "Cross-team communication",
"agentMessaging": "Agent-to-agent messaging",
@ -98,6 +99,7 @@
"footer": {
"copyright": "© {year} Agent Teams",
"tagline": "AI agent orchestration for developers",
"robotBubble": "I'm waiting",
"links": {
"github": "GitHub",
"docs": "Documentation"

View file

@ -64,6 +64,7 @@
"sectionTitle": "Cómo nos comparamos",
"sectionSubtitle": "Comparación detallada de funciones con otras herramientas de programación con IA.",
"feature": "Función",
"robotBubble": "Juzga tú",
"features": {
"crossTeam": "Comunicación entre equipos",
"agentMessaging": "Mensajería entre agentes",
@ -98,6 +99,7 @@
"footer": {
"copyright": "© {year} Agent Teams",
"tagline": "Orquestación de agentes IA para desarrolladores",
"robotBubble": "Estoy esperando",
"links": {
"github": "GitHub",
"docs": "Documentación"

View file

@ -64,6 +64,7 @@
"sectionTitle": "Comment nous nous comparons",
"sectionSubtitle": "Comparaison fonctionnalité par fonctionnalité avec d'autres outils IA.",
"feature": "Fonctionnalité",
"robotBubble": "Juge par toi-même",
"features": {
"crossTeam": "Communication inter-équipes",
"agentMessaging": "Messagerie entre agents",
@ -98,6 +99,7 @@
"footer": {
"copyright": "© {year} Agent Teams",
"tagline": "Orchestration d'agents IA pour développeurs",
"robotBubble": "J'attends",
"links": {
"github": "GitHub",
"docs": "Documentation"

View file

@ -64,6 +64,7 @@
"sectionTitle": "तुलना करें",
"sectionSubtitle": "अन्य AI कोडिंग टूल्स के साथ सुविधा-दर-सुविधा तुलना।",
"feature": "सुविधा",
"robotBubble": "खुद फैसला करें",
"features": {
"crossTeam": "क्रॉस-टीम संचार",
"agentMessaging": "एजेंट-टू-एजेंट मैसेजिंग",
@ -98,6 +99,7 @@
"footer": {
"copyright": "© {year} Agent Teams",
"tagline": "डेवलपर्स के लिए AI एजेंट ऑर्केस्ट्रेशन",
"robotBubble": "मैं इंतज़ार कर रहा हूँ",
"links": {
"github": "GitHub",
"docs": "दस्तावेज़"

View file

@ -64,6 +64,7 @@
"sectionTitle": "他ツールとの比較",
"sectionSubtitle": "他のAIコーディングツールとの機能比較。",
"feature": "機能",
"robotBubble": "自分で判断して",
"features": {
"crossTeam": "チーム間コミュニケーション",
"agentMessaging": "エージェント間メッセージング",
@ -98,6 +99,7 @@
"footer": {
"copyright": "© {year} Agent Teams",
"tagline": "開発者向けAIエージェントオーケストレーション",
"robotBubble": "待ってるよ",
"links": {
"github": "GitHub",
"docs": "ドキュメント"

View file

@ -64,6 +64,7 @@
"sectionTitle": "Como nos comparamos",
"sectionSubtitle": "Comparação detalhada de recursos com outras ferramentas de programação com IA.",
"feature": "Recurso",
"robotBubble": "Julgue você",
"features": {
"crossTeam": "Comunicação entre equipes",
"agentMessaging": "Mensagens entre agentes",
@ -98,6 +99,7 @@
"footer": {
"copyright": "© {year} Agent Teams",
"tagline": "Orquestração de agentes IA para desenvolvedores",
"robotBubble": "Estou esperando",
"links": {
"github": "GitHub",
"docs": "Documentação"

View file

@ -64,6 +64,7 @@
"sectionTitle": "Сравнение с конкурентами",
"sectionSubtitle": "Подробное сравнение возможностей с другими AI-инструментами для разработки.",
"feature": "Возможность",
"robotBubble": "Суди сам",
"features": {
"crossTeam": "Межкомандная коммуникация",
"agentMessaging": "Обмен сообщениями между агентами",
@ -98,6 +99,7 @@
"footer": {
"copyright": "© {year} Agent Teams",
"tagline": "Оркестрация ИИ-агентов для разработчиков",
"robotBubble": "Я жду",
"links": {
"github": "GitHub",
"docs": "Документация"

View file

@ -64,6 +64,7 @@
"sectionTitle": "功能对比",
"sectionSubtitle": "与其他 AI 编程工具的逐项功能对比。",
"feature": "功能",
"robotBubble": "你来判断",
"features": {
"crossTeam": "跨团队通信",
"agentMessaging": "智能体间消息",
@ -98,6 +99,7 @@
"footer": {
"copyright": "© {year} Agent Teams",
"tagline": "面向开发者的 AI 智能体编排",
"robotBubble": "我在等你",
"links": {
"github": "GitHub",
"docs": "文档"

View file

@ -6,6 +6,7 @@
"": {
"name": "agent-teams-landing",
"dependencies": {
"@firecms/neat": "^0.8.0",
"@mdi/js": "^7.4.47",
"@nuxtjs/i18n": "^9.5.6",
"@pinia/nuxt": "^0.11.3",
@ -1314,6 +1315,12 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@firecms/neat": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/@firecms/neat/-/neat-0.8.0.tgz",
"integrity": "sha512-gwvYd63voJa+ZtEt6SW3toJwVx9smisKuXE7vsXvZtlGPzsWpR1lzaltIsijuPkg8Qj/ybS2tEdsttWE7g2KsA==",
"license": "MIT AND Commons-Clause"
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",

View file

@ -18,6 +18,7 @@
"format:fix": "prettier . --write"
},
"dependencies": {
"@firecms/neat": "^0.8.0",
"@mdi/js": "^7.4.47",
"@nuxtjs/i18n": "^9.5.6",
"@pinia/nuxt": "^0.11.3",

View file

@ -5,7 +5,8 @@ export default defineNuxtPlugin({
const { initTheme } = useBrowserTheme();
const { initLocale } = useLocation();
// Run after hydration to avoid SSR/CSR mismatches.
initTheme();
nuxtApp.hook("app:mounted", () => {
initTheme();
initLocale();

View file

@ -2,6 +2,8 @@ import "vuetify/styles";
import { createVuetify } from "vuetify";
import { aliases, mdi } from "vuetify/iconsets/mdi-svg";
type ThemeName = "light" | "dark";
const brand = {
cyan: "#00f0ff",
magenta: "#ff00ff",
@ -11,9 +13,28 @@ const brand = {
darkSurface: "#12121a"
};
function isThemeName(value: string | null | undefined): value is ThemeName {
return value === "dark" || value === "light";
}
function resolveInitialTheme(cookieTheme: ThemeName | null): ThemeName {
if (import.meta.client) {
const saved = localStorage.getItem("theme");
if (isThemeName(saved)) return saved;
if (cookieTheme) return cookieTheme;
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
return cookieTheme ?? "light";
}
export default defineNuxtPlugin({
name: "vuetify",
setup(nuxtApp) {
const themeCookie = useCookie<ThemeName | null>("theme");
const cookieTheme = isThemeName(themeCookie.value) ? themeCookie.value : null;
const defaultTheme = resolveInitialTheme(cookieTheme);
const vuetify = createVuetify({
icons: {
defaultSet: "mdi",
@ -21,7 +42,7 @@ export default defineNuxtPlugin({
sets: { mdi }
},
theme: {
defaultTheme: "dark",
defaultTheme,
themes: {
light: {
colors: {

View file

@ -1,30 +1,60 @@
import { defineStore } from "pinia";
type ThemeName = "light" | "dark";
const themeCookieName = "theme";
function isThemeName(value: string | null | undefined): value is ThemeName {
return value === "dark" || value === "light";
}
function getCookieTheme(): ThemeName | null {
if (!import.meta.client) return null;
const cookie = document.cookie
.split("; ")
.find((item) => item.startsWith(`${themeCookieName}=`));
const value = cookie ? decodeURIComponent(cookie.split("=").slice(1).join("=")) : null;
return isThemeName(value) ? value : null;
}
function persistTheme(theme: ThemeName) {
localStorage.setItem(themeCookieName, theme);
document.cookie = `${themeCookieName}=${theme}; Path=/; Max-Age=31536000; SameSite=Lax`;
}
export const useThemeStore = defineStore("theme", {
state: () => ({
current: "dark" as ThemeName,
current: "light" as ThemeName,
userSelected: false
}),
actions: {
getInitialTheme(): ThemeName {
if (!import.meta.client) return "dark";
const saved = localStorage.getItem("theme");
if (saved === "dark" || saved === "light") {
if (!import.meta.client) return "light";
const saved = localStorage.getItem(themeCookieName);
if (isThemeName(saved)) {
this.userSelected = true;
persistTheme(saved);
return saved;
}
const cookieTheme = getCookieTheme();
if (cookieTheme) {
this.userSelected = true;
persistTheme(cookieTheme);
return cookieTheme;
}
if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
return "dark";
}
return "dark";
return "light";
},
setTheme(theme: ThemeName, fromUser: boolean) {
this.current = theme;
if (import.meta.client && fromUser) {
this.userSelected = true;
localStorage.setItem("theme", theme);
persistTheme(theme);
}
}
}