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 .output
.dist .dist
.env .env
--host/
product-docs/.vitepress/dist/
# Large video files # Large video files
public/video/*.mp4 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> </script>
<template> <template>
<NuxtLink to="/" class="app-logo"> <NuxtLink to="/" class="app-logo" :prefetch="false">
<img <img
:src="`${baseURL}logo-192.png`" :src="`${baseURL}logo-192.png`"
alt="Agent Teams" alt="Agent Teams"

View file

@ -6,10 +6,11 @@ const { isDark, toggleTheme } = useBrowserTheme();
const { trackThemeToggle } = useAnalytics(); const { trackThemeToggle } = useAnalytics();
const tooltip = computed(() => isDark.value ? t('theme.light') : t('theme.dark')); const tooltip = computed(() => isDark.value ? t('theme.light') : t('theme.dark'));
const icon = computed(() => isDark.value ? mdiWeatherNight : mdiWeatherSunny);
const onToggle = () => { const onToggle = () => {
toggleTheme(); const theme = toggleTheme();
trackThemeToggle(isDark.value ? 'dark' : 'light'); trackThemeToggle(theme);
}; };
</script> </script>
@ -19,7 +20,7 @@ const onToggle = () => {
<template #activator="{ props }"> <template #activator="{ props }">
<v-btn <v-btn
v-bind="props" v-bind="props"
:icon="isDark ? mdiWeatherSunny : mdiWeatherNight" :icon="icon"
variant="text" variant="text"
size="small" size="small"
:aria-label="tooltip" :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, mdiShieldCheckOutline,
mdiMonitorDashboard, mdiMonitorDashboard,
} from "@mdi/js"; } 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 = [ const icons = [
mdiRobotOutline, mdiRobotOutline,
@ -15,21 +27,85 @@ const icons = [
mdiShieldCheckOutline, mdiShieldCheckOutline,
mdiMonitorDashboard, mdiMonitorDashboard,
] as const; ] 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> </script>
<template> <template>
<div class="cyber-feature-rail cyber-panel"> <div class="cyber-feature-rail-shell">
<div <img
v-for="(feature, index) in heroFeatureRail" class="cyber-feature-rail__collaboration"
:key="feature.id" :src="heroCollaborationFeature.asset"
class="cyber-feature-rail__item" alt=""
loading="lazy"
decoding="async"
aria-hidden="true"
> >
<div class="cyber-feature-rail__icon"> <div
<v-icon :icon="icons[index]" size="28" /> 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>
<div class="cyber-feature-rail__copy"> <img
<div class="cyber-feature-rail__title">{{ feature.title }}</div> class="cyber-feature-rail__robot"
<div class="cyber-feature-rail__text">{{ feature.text }}</div> :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> </div>
</div> </div>

View file

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

View file

@ -1,111 +1,26 @@
<script setup lang="ts"> <script setup lang="ts">
import { import {
heroAgents, heroAgents,
heroConnections,
heroMessages,
type HeroAgentRole, type HeroAgentRole,
type HeroMessage,
type HeroMessagePhase,
} from "~/data/heroScene"; } from "~/data/heroScene";
type MessagePhase = "sender" | "packet" | "receiver" | "cooldown"; const props = defineProps<{
message: HeroMessage | null;
phase: HeroMessagePhase;
reducedMotion?: boolean;
}>();
const activeMessageIndex = ref(0); const activeSender = computed<HeroAgentRole | null>(() => (props.phase === "cooldown" ? null : props.message?.from ?? null));
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 activeReceiver = computed<HeroAgentRole | "video" | 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> </script>
<template> <template>
<div ref="sceneRef" class="cyber-scene"> <div class="cyber-scene">
<div class="cyber-scene__floor" aria-hidden="true" /> <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" /> <CyberHeroVideoFrame class="cyber-scene__video" />
@ -121,7 +36,7 @@ onUnmounted(() => {
<CyberHeroMessageBubbles <CyberHeroMessageBubbles
class="cyber-scene__messages" class="cyber-scene__messages"
:message="activeMessage" :message="message"
:phase="phase" :phase="phase"
:reduced-motion="reducedMotion" :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> <span>Live demo</span>
</div> </div>
<div class="cyber-video-frame__content"> <div class="cyber-video-frame__content">
<ClientOnly> <HeroDemoVideo />
<Suspense>
<LazyHeroDemoVideo />
<template #fallback>
<div class="cyber-video-frame__fallback" />
</template>
</Suspense>
<template #fallback>
<div class="cyber-video-frame__fallback" />
</template>
</ClientOnly>
</div> </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--tl" aria-hidden="true" />
<div class="cyber-video-frame__corner cyber-video-frame__corner--tr" 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"> <script setup lang="ts">
import robotLeadLounge from "~/assets/images/footer/robot-lead-lounge-v1.webp";
const { t, locale } = useI18n(); const { t, locale } = useI18n();
const { repoUrl } = useGithubRepo(); const { repoUrl } = useGithubRepo();
const { baseURL } = useRuntimeConfig().app; const { baseURL } = useRuntimeConfig().app;
@ -11,6 +13,30 @@ const docsHref = computed(() => {
<template> <template>
<footer class="app-footer"> <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"> <v-container class="app-footer__inner">
<span class="app-footer__copy" <span class="app-footer__copy"
>{{ t('footer.copyright', { year }) }} · {{ t('footer.tagline') }}</span >{{ t('footer.copyright', { year }) }} · {{ t('footer.tagline') }}</span
@ -28,8 +54,79 @@ const docsHref = computed(() => {
<style scoped> <style scoped>
.app-footer { .app-footer {
position: relative;
border-top: 1px solid var(--at-c-border); border-top: 1px solid var(--at-c-border);
padding: 20px 0; 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 { .app-footer__inner {
@ -91,6 +188,10 @@ const docsHref = computed(() => {
} }
@media (max-width: 600px) { @media (max-width: 600px) {
.app-footer__robot-stage {
display: none;
}
.app-footer__inner { .app-footer__inner {
flex-direction: column; flex-direction: column;
gap: 10px; gap: 10px;

View file

@ -8,26 +8,113 @@ const menuOpen = ref(false);
const withBase = (path: string) => `${baseURL.replace(/\/?$/, '/')}${path.replace(/^\/+/, '')}`; const withBase = (path: string) => `${baseURL.replace(/\/?$/, '/')}${path.replace(/^\/+/, '')}`;
const docsHref = computed(() => withBase(locale.value === 'ru' ? 'docs/ru/' : 'docs/')); const docsHref = computed(() => withBase(locale.value === 'ru' ? 'docs/ru/' : 'docs/'));
const isRu = computed(() => locale.value === 'ru');
const navItems = computed(() => [ const navItems = computed(() => [
{ href: '#screenshots', label: t('nav.screenshots') }, { href: '#screenshots', label: t('nav.screenshots'), shortLabel: isRu.value ? 'Скрины' : 'Shots' },
{ href: docsHref.value, label: t('nav.docs') }, { href: docsHref.value, label: 'Docs', shortLabel: 'Docs' },
{ href: '#download', label: t('nav.download') }, { href: '#download', label: t('nav.download'), shortLabel: isRu.value ? 'Скачать' : 'Get' },
{ href: '#comparison', label: t('nav.comparison') }, { href: '#comparison', label: t('nav.comparison'), shortLabel: isRu.value ? 'Сравн.' : 'Compare' },
{ href: '#pricing', label: t('nav.pricing') }, { href: '#pricing', label: t('nav.pricing'), shortLabel: isRu.value ? 'Беспл.' : 'Free' },
{ href: '#faq', label: t('nav.faq') }, { href: '#faq', label: t('nav.faq'), shortLabel: 'FAQ' },
]); ]);
</script> </script>
<template> <template>
<header class="app-header"> <header class="app-header">
<v-container class="app-header__inner"> <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"> <div class="app-header__brand-frame">
<AppLogo /> <AppLogo />
</div> </div>
<nav class="app-header__nav"> <nav class="app-header__nav">
<v-btn v-for="item in navItems" :key="item.href" variant="text" :href="item.href"> <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> </v-btn>
</nav> </nav>
<div class="app-header__spacer" /> <div class="app-header__spacer" />
@ -39,9 +126,10 @@ const navItems = computed(() => [
:href="repoUrl" :href="repoUrl"
target="_blank" target="_blank"
class="app-header__github-btn" 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> </v-btn>
<ThemeToggle /> <ThemeToggle />
</div> </div>
@ -93,21 +181,27 @@ const navItems = computed(() => [
<style scoped> <style scoped>
.app-header { .app-header {
--header-cyan: var(--cyber-cyan); --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; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
right: 0; right: 0;
z-index: var(--at-z-header); z-index: var(--at-z-header);
height: 92px; height: var(--header-height);
display: flex; display: flex;
align-items: center; align-items: center;
background: background: transparent;
linear-gradient(180deg, rgba(2, 5, 13, 0.98), rgba(2, 5, 13, 0.72) 74%, rgba(2, 5, 13, 0.16)), backdrop-filter: none !important;
rgba(2, 5, 13, 0.72); -webkit-backdrop-filter: none !important;
backdrop-filter: blur(18px); box-shadow: none;
-webkit-backdrop-filter: blur(18px);
box-shadow: 0 16px 42px rgba(0, 0, 0, 0.26);
} }
.app-header::before, .app-header::before,
@ -116,133 +210,213 @@ const navItems = computed(() => [
} }
.v-theme--light .app-header { .v-theme--light .app-header {
background: background: transparent;
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);
} }
.v-theme--dark .app-header { .v-theme--dark .app-header {
background: background: transparent;
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);
} }
.app-header__inner { .app-header__inner {
position: relative; 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; align-items: center;
flex-wrap: nowrap; width: min(2048px, calc(100vw - 18px));
width: min(1680px, 100vw);
max-width: none !important; max-width: none !important;
height: 100%; height: 100%;
padding-inline: 0 !important; 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 { .app-header__brand-frame {
position: relative; position: relative;
grid-column: 1;
display: flex; display: flex;
align-items: center; align-items: center;
align-self: center; align-self: center;
isolation: isolate; z-index: 1;
height: 76px; width: 100%;
min-width: 358px; height: var(--header-panel-height);
padding: 0 74px 0 52px; min-width: 0;
padding: 0 64px 0 clamp(38px, 4.2vw, 82px);
background: transparent; background: transparent;
border: 0; border: 0;
clip-path: none; overflow: hidden;
box-shadow: none; contain: paint;
} clip-path: polygon(0 18%, 80% 18%, 100% 50%, 80% 82%, 0 82%);
.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);
} }
.app-header__brand-frame :deep(.app-logo) { .app-header__brand-frame :deep(.app-logo) {
position: relative; position: relative;
z-index: 1; gap: clamp(14px, 1.4vw, 28px);
gap: 12px; height: 100%;
min-width: 0; 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) { .app-header__brand-frame :deep(.app-logo__img) {
width: 44px; width: var(--header-brand-icon);
height: 44px; height: var(--header-brand-icon);
border-radius: 12px; border-radius: 17px;
box-shadow: box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.08) inset, 0 0 0 1px rgba(139, 92, 255, 0.34) inset,
0 0 22px rgba(139, 92, 255, 0.36); 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) { .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; font-weight: 800;
letter-spacing: 0.02em; line-height: 1;
letter-spacing: 0;
white-space: nowrap; white-space: nowrap;
transform: none;
} }
.app-header__nav { .app-header__nav {
position: relative; --nav-pad-start: clamp(34px, 4cqw, 56px);
display: flex; --nav-pad-end: clamp(38px, 4.5cqw, 68px);
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);
}
.app-header__nav::before,
.app-header__nav::after {
content: "";
position: absolute; position: absolute;
left: 0; top: calc((var(--header-height) - var(--header-panel-height, 86px)) / 2);
right: 0; left: calc(456 / 2048 * 100%);
height: 1px; right: calc((2048 - 1568) / 2048 * 100%);
pointer-events: none; display: grid;
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)); grid-template-columns: repeat(6, minmax(0, 1fr));
opacity: 0.86; z-index: 1;
box-shadow: 0 0 14px rgba(0, 234, 255, 0.18); 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 { .app-header__nav::before {
top: 8px; display: none;
}
.app-header__nav::after {
bottom: 7px;
} }
.app-header__nav :deep(.v-btn) { .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; 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-family: var(--at-font-mono);
font-size: 13px !important; font-size: clamp(12px, 1.45cqw, 17px) !important;
font-weight: 700 !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-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) { .app-header__nav :deep(.v-btn:hover) {
@ -255,55 +429,105 @@ const navItems = computed(() => [
} }
.app-header__desktop-actions { .app-header__desktop-actions {
position: relative; position: absolute;
display: flex; top: calc((var(--header-height) - var(--header-panel-height)) / 2);
gap: 12px; 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-items: center;
align-self: center; align-self: center;
justify-content: flex-end; justify-content: center;
isolation: isolate; justify-items: center;
height: 76px; height: var(--header-panel-height);
min-width: 328px; min-width: 0;
padding: 0 32px 0 58px; padding: 0;
border: 0; border: 0;
background: transparent; background: transparent;
clip-path: none; overflow: hidden;
box-shadow: none; 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 :deep(.v-btn) {
.app-header__desktop-actions::after { align-self: center;
content: ""; justify-self: center;
position: absolute; margin: 0 !important;
pointer-events: none; line-height: 1 !important;
clip-path: polygon(42px 0, 100% 0, 100% 100%, 42px 100%, 0 50%); transform: none !important;
} }
.app-header__desktop-actions::before { .app-header__desktop-actions :deep(.v-btn:not(.app-header__github-btn)) {
inset: 0; width: var(--header-action-size) !important;
z-index: -2; min-width: var(--header-action-size) !important;
background: linear-gradient(250deg, rgba(0, 234, 255, 0.92), rgba(47, 125, 255, 0.46) 48%, rgba(0, 234, 255, 0.72)); height: var(--header-action-size) !important;
filter: drop-shadow(0 0 16px rgba(0, 234, 255, 0.34)); min-height: var(--header-action-size) !important;
padding-inline: 0 !important;
color: rgba(244, 247, 255, 0.9) !important;
} }
.app-header__desktop-actions::after { .app-header__desktop-actions :deep(.v-btn__content),
inset: 1px; .app-header__desktop-actions :deep(.v-icon) {
z-index: -1; margin: 0 !important;
background: align-items: center;
linear-gradient(250deg, rgba(5, 14, 31, 0.98), rgba(2, 6, 16, 0.94) 68%, rgba(0, 234, 255, 0.08)), justify-content: center;
rgba(2, 6, 16, 0.96); 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 { .app-header__github-btn {
min-height: 36px !important; width: var(--header-github-width) !important;
border-color: rgba(0, 234, 255, 0.58) !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; color: var(--header-cyan) !important;
font-family: var(--at-font-mono); font-family: var(--at-font-mono);
font-weight: 800 !important; font-weight: 800 !important;
font-size: 12px !important; font-size: clamp(14px, 1vw, 19px) !important;
letter-spacing: 0.08em !important; letter-spacing: 0.08em !important;
text-transform: uppercase !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 { .app-header__github-btn:hover {
@ -316,21 +540,222 @@ const navItems = computed(() => [
display: none; 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 { .app-header {
height: 64px; height: 64px;
} }
.app-header__inner { .app-header__inner {
display: flex;
width: min(100% - 32px, 680px); width: min(100% - 32px, 680px);
} }
.app-header__hud {
display: none;
}
.app-header__brand-frame { .app-header__brand-frame {
min-width: 0; min-width: 0;
flex: 1; flex: 1;
align-self: center; align-self: center;
height: 48px; height: 48px;
padding: 0 42px 0 12px; 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) { .app-header__brand-frame :deep(.app-logo__img) {
@ -353,6 +778,8 @@ const navItems = computed(() => [
.app-header__mobile-actions { .app-header__mobile-actions {
display: flex; display: flex;
position: relative;
z-index: 1;
margin-left: 10px; 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 { .mobile-menu-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;

View file

@ -1,5 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import robotAvatarCyan from "~/assets/images/hero/robots/robot-avatar-cyan-v1.webp";
const { t } = useI18n() const { t } = useI18n()
const comparisonRobotRef = ref<HTMLElement | null>(null)
const showComparisonRobotBubble = ref(false)
let comparisonRobotObserver: IntersectionObserver | null = null
interface CellValue { interface CellValue {
status: string status: string
@ -175,7 +180,7 @@ const rows = computed<ComparisonRow[]>(() => [
gastown: { status: 'partial', note: 'Cost tiers + digest, no hard caps' }, gastown: { status: 'partial', note: 'Cost tiers + digest, no hard caps' },
paperclip: { status: 'yes', note: 'Per-agent budgets + hard stops' }, paperclip: { status: 'yes', note: 'Per-agent budgets + hard stops' },
cursor: { status: 'partial', note: 'Usage + BG spend limits' }, 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'), feature: t('comparison.features.price'),
@ -195,6 +200,78 @@ const competitors = [
{ key: 'claudeCli', name: 'Claude Code CLI' }, { 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 { function getCellClass(cell: CellValue): string {
switch (cell.status) { switch (cell.status) {
case 'yes': return 'comparison-table__cell--yes' case 'yes': return 'comparison-table__cell--yes'
@ -234,6 +311,28 @@ function getStatusIcon(status: string): string {
</div> </div>
<div class="comparison-table__wrap"> <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"> <table class="comparison-table">
<thead> <thead>
<tr> <tr>
@ -298,6 +397,15 @@ function getStatusIcon(status: string): string {
</tbody> </tbody>
</table> </table>
</div> </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> </v-container>
</section> </section>
</template> </template>
@ -346,6 +454,168 @@ function getStatusIcon(status: string): string {
z-index: 1; 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 { .comparison-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
@ -547,6 +817,18 @@ function getStatusIcon(status: string): string {
border-color: rgba(0, 180, 200, 0.2); 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 { .v-theme--light .comparison-table__th {
color: #64748b; color: #64748b;
border-bottom-color: rgba(0, 0, 0, 0.08); border-bottom-color: rgba(0, 0, 0, 0.08);

View file

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { mdiApple, mdiMicrosoftWindows, mdiPenguin, mdiDownload, mdiCheckCircle } from '@mdi/js'; 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 { downloadAssets } from '~/data/downloads';
import type { DownloadOs, DownloadArch } from '~/data/downloads'; import type { DownloadOs, DownloadArch } from '~/data/downloads';
@ -8,12 +9,219 @@ const { t, locale } = useI18n();
const downloadStore = useDownloadStore(); const downloadStore = useDownloadStore();
const { data: releaseData, resolve } = useReleaseDownloads(); const { data: releaseData, resolve } = useReleaseDownloads();
const { trackDownloadClick } = useAnalytics(); const { trackDownloadClick } = useAnalytics();
const { repoUrl, releaseDownloadUrl } = useGithubRepo(); const { releaseDownloadUrl } = useGithubRepo();
const isMounted = ref(false); 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(() => { onMounted(() => {
isMounted.value = true; isMounted.value = true;
downloadStore.init(); 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> = { 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> </script>
<template> <template>
@ -91,16 +293,44 @@ const devBranchNote = computed(() =>
v-for="(asset, index) in visibleAssets" v-for="(asset, index) in visibleAssets"
:key="asset.id" :key="asset.id"
class="download-section__card" 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="{ :style="{
'--delay': `${index * 0.1}s`, '--delay': `${index * 0.1}s`,
'--accent': platformColors[asset.os] || '#00f0ff', '--accent': platformColors[asset.os] || '#00f0ff',
}" }"
:data-download-os="asset.os"
@click="downloadStore.setSelected(asset.id)" @click="downloadStore.setSelected(asset.id)"
> >
<!-- Card glow effect --> <!-- Card glow effect -->
<div class="download-section__card-glow" /> <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 --> <!-- Platform icon -->
<div class="download-section__card-icon-wrap"> <div class="download-section__card-icon-wrap">
<v-icon <v-icon
@ -145,19 +375,29 @@ const devBranchNote = computed(() =>
</div> </div>
</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"> <p v-if="isMounted && releaseVersion" class="download-section__release-info">
v{{ releaseVersion }} · {{ releaseDate }} v{{ releaseVersion }} · {{ releaseDate }}
</p> </p>
</v-container> </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> </section>
</template> </template>
@ -230,6 +470,11 @@ const devBranchNote = computed(() =>
animation-delay: var(--delay, 0s); animation-delay: var(--delay, 0s);
} }
.download-section__card--with-robot {
overflow: visible;
z-index: 3;
}
.download-section__card:hover { .download-section__card:hover {
transform: translateY(-6px); transform: translateY(-6px);
border-color: rgba(0, 240, 255, 0.2); border-color: rgba(0, 240, 255, 0.2);
@ -248,6 +493,252 @@ const devBranchNote = computed(() =>
z-index: 2; 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 { .download-section__card--active:hover {
transform: scale(1.08); transform: scale(1.08);
border-color: rgba(57, 255, 20, 0.5); border-color: rgba(57, 255, 20, 0.5);
@ -395,38 +886,6 @@ const devBranchNote = computed(() =>
font-family: 'JetBrains Mono', monospace; 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 { @keyframes downloadFadeUp {
from { from {
opacity: 0; opacity: 0;
@ -502,6 +961,26 @@ const devBranchNote = computed(() =>
gap: 20px; 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 { .download-section__card--active {
transform: scale(1.03); transform: scale(1.03);
} }
@ -557,6 +1036,10 @@ const devBranchNote = computed(() =>
border-radius: 16px; border-radius: 16px;
} }
.download-section__card-robot-seat {
display: none;
}
.download-section__card-icon-wrap { .download-section__card-icon-wrap {
width: 52px; width: 52px;
height: 52px; height: 52px;

View file

@ -66,7 +66,7 @@ const faqIcons = [
</div> </div>
<div class="faq-section__decoration"> <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" /> <v-icon size="40" class="faq-section__deco-icon" :icon="mdiFrequentlyAskedQuestions" />
</div> </div>
<div class="faq-section__deco-ring faq-section__deco-ring--1" /> <div class="faq-section__deco-ring faq-section__deco-ring--1" />

View file

@ -2,33 +2,38 @@
import { import {
mdiBookOpenPageVariantOutline, mdiBookOpenPageVariantOutline,
mdiDownload, mdiDownload,
mdiPlayCircleOutline,
} from "@mdi/js"; } from "@mdi/js";
import { heroMessages, type HeroMessagePhase } from "~/data/heroScene";
const { content } = useLandingContent(); const { content } = useLandingContent();
const { t, locale } = useI18n(); const { t, locale } = useI18n();
const { baseURL } = useRuntimeConfig().app; const { baseURL } = useRuntimeConfig().app;
const heroRef = ref<HTMLElement | null>(null); 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 downloadStore = useDownloadStore();
const { resolve, data: releaseData } = useReleaseDownloads(); const { resolve, data: releaseData } = useReleaseDownloads();
const { repoUrl, latestReleaseUrl, releaseDownloadUrl } = useGithubRepo(); const { latestReleaseUrl, releaseDownloadUrl } = useGithubRepo();
const withBase = (path: string) => `${baseURL.replace(/\/?$/, "/")}${path.replace(/^\/+/, "")}`; const withBase = (path: string) => `${baseURL.replace(/\/?$/, "/")}${path.replace(/^\/+/, "")}`;
useCyberHeroParallax(heroRef); useCyberHeroParallax(heroRef);
const releaseVersion = computed(() => releaseData.value?.version || null); const releaseVersion = computed(() => releaseData.value?.version || null);
const releaseDate = computed(() => { const releaseDate = computed(() => {
const raw = releaseData.value?.pubDate; if (!releaseData.value?.pubDate) return "";
if (!raw) return null; return new Date(releaseData.value.pubDate).toLocaleDateString(locale.value, {
return new Date(raw).toLocaleDateString("en-US", {
year: "numeric", year: "numeric",
month: "short", month: "short",
day: "numeric", day: "numeric",
}); });
}); });
const activeHeroMessage = computed(() => heroMessages[activeHeroMessageIndex.value] ?? null);
onMounted(() => downloadStore.init());
const heroDownloadUrl = computed(() => { const heroDownloadUrl = computed(() => {
const asset = downloadStore.selectedAsset; const asset = downloadStore.selectedAsset;
@ -37,17 +42,87 @@ const heroDownloadUrl = computed(() => {
return resolve(asset.os, arch)?.url || releaseDownloadUrl(asset.fileName); 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 docsHref = computed(() => withBase(locale.value === "ru" ? "docs/ru/" : "docs/"));
const devBranchNote = computed(() =>
locale.value === "ru" function clearHeroMessageTimers() {
? "Самая свежая версия в ветке dev - можно развернуть локально." heroMessageTimers.forEach(window.clearTimeout);
: "Freshest version is on the dev branch - clone and run it locally.", 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> </script>
<template> <template>
<section id="hero" ref="heroRef" class="hero-section cyber-hero section anchor-offset" data-cyber-hero> <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__background" aria-hidden="true" />
<div class="cyber-hero__wash" aria-hidden="true" /> <div class="cyber-hero__wash" aria-hidden="true" />
<div class="cyber-hero__gridlines" aria-hidden="true" /> <div class="cyber-hero__gridlines" aria-hidden="true" />
@ -56,13 +131,13 @@ const devBranchNote = computed(() =>
<v-container class="cyber-hero__container"> <v-container class="cyber-hero__container">
<div class="cyber-hero__layout"> <div class="cyber-hero__layout">
<div class="cyber-hero__copy"> <div class="cyber-hero__copy">
<h1 class="cyber-hero__title"> <h1 class="cyber-hero__title" aria-label="Agent Teams">
<span>Agent{{ " " }}</span> <span>Agent</span>
<span class="cyber-hero__title-accent">Teams</span> <span class="cyber-hero__title-accent">Teams</span>
</h1> </h1>
<p class="cyber-hero__slogan cyber-panel"> <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>
<p class="cyber-hero__description"> <p class="cyber-hero__description">
@ -80,15 +155,6 @@ const devBranchNote = computed(() =>
> >
{{ t("hero.downloadNow") }} {{ t("hero.downloadNow") }}
</v-btn> </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 <v-btn
variant="outlined" variant="outlined"
size="large" size="large"
@ -100,26 +166,33 @@ const devBranchNote = computed(() =>
</v-btn> </v-btn>
</div> </div>
<a <p
v-if="releaseVersion"
class="cyber-hero__terminal-note cyber-panel" class="cyber-hero__terminal-note cyber-panel"
:href="devBranchUrl"
target="_blank"
rel="noopener"
> >
<span class="cyber-hero__terminal-lines"> <span class="cyber-hero__release">
<span>&gt; {{ devBranchNote }}</span> v{{ releaseVersion }}
<span>&gt; Team ready. What shall we build today?</span> <span v-if="releaseDate" class="cyber-hero__release-date">
· {{ releaseDate }}
</span>
</span> </span>
<span v-if="releaseVersion" class="cyber-hero__release"> </p>
v{{ releaseVersion }}<template v-if="releaseDate"> - {{ releaseDate }}</template>
</span>
</a>
</div> </div>
<CyberHeroScene class="cyber-hero__scene" /> <CyberHeroScene
class="cyber-hero__scene"
:message="activeHeroMessage"
:phase="heroMessagePhase"
:reduced-motion="heroReducedMotion"
/>
</div> </div>
<CyberHeroFeatureStrip class="cyber-hero__feature-strip" /> <CyberHeroFeatureStrip
class="cyber-hero__feature-strip"
:active-message="activeHeroMessage"
:phase="heroMessagePhase"
:reduced-motion="heroReducedMotion"
/>
</v-container> </v-container>
</section> </section>
</template> </template>

View file

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

View file

@ -1,5 +1,5 @@
<script setup lang="ts"> <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'; import { mdiPlay, mdiPause, mdiVolumeHigh, mdiVolumeOff, mdiFullscreen } from '@mdi/js';
const { t } = useI18n(); const { t } = useI18n();
@ -9,20 +9,51 @@ const containerRef = ref<HTMLElement | null>(null);
const isPlaying = ref(false); const isPlaying = ref(false);
const isMuted = ref(true); const isMuted = ref(true);
const showControls = ref(true); const showControls = ref(true);
const isLoaded = ref(false); const isLoaded = ref(true);
const hasError = ref(false); const hasError = ref(false);
const progress = ref(0); const progress = ref(0);
const loadProgress = ref(0); const loadProgress = ref(0);
const hideTimer = ref<ReturnType<typeof setTimeout> | null>(null); const hideTimer = ref<ReturnType<typeof setTimeout> | null>(null);
let intObserver: IntersectionObserver | 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() { function togglePlay() {
const video = videoRef.value; const video = videoRef.value;
if (!video) return; if (!video) return;
if (video.paused) { if (video.paused) {
video.play(); markLoaded();
isPlaying.value = true; video.play()
.then(() => {
isPlaying.value = true;
})
.catch(markError);
} else { } else {
video.pause(); video.pause();
isPlaying.value = false; isPlaying.value = false;
@ -93,19 +124,25 @@ function onMouseLeave() {
} }
} }
onMounted(() => { onMounted(async () => {
await nextTick();
const video = videoRef.value; const video = videoRef.value;
if (video) { if (video) {
// canplay fires earlier than loadeddata enough to show first frame isMuted.value = video.muted;
video.addEventListener('canplay', () => { isLoaded.value = true; }, { once: true }); video.addEventListener('loadedmetadata', markLoaded, { once: true });
video.addEventListener('error', () => { hasError.value = 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('progress', updateLoadProgress);
video.addEventListener('ended', () => { video.addEventListener('ended', onVideoEnded);
isPlaying.value = false;
showControls.value = true; if (video.readyState >= HTMLMediaElement.HAVE_METADATA) {
progress.value = 0; markLoaded();
video.currentTime = 0; } else {
}); video.load();
loadFallbackTimer = setTimeout(markLoaded, 1800);
}
} }
intObserver = new IntersectionObserver( intObserver = new IntersectionObserver(
@ -122,8 +159,15 @@ onMounted(() => {
onUnmounted(() => { onUnmounted(() => {
if (hideTimer.value) clearTimeout(hideTimer.value); if (hideTimer.value) clearTimeout(hideTimer.value);
clearLoadFallback();
if (intObserver) { intObserver.disconnect(); intObserver = null; } 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('progress', updateLoadProgress);
videoRef.value?.removeEventListener('ended', onVideoEnded);
}); });
</script> </script>
@ -163,7 +207,7 @@ onUnmounted(() => {
ref="videoRef" ref="videoRef"
class="hero-video__player" class="hero-video__player"
:class="{ 'hero-video__player--loaded': isLoaded }" :class="{ 'hero-video__player--loaded': isLoaded }"
preload="auto" preload="metadata"
poster="/screenshots/2.jpg" poster="/screenshots/2.jpg"
muted muted
playsinline 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 type { Ref } from "vue";
import { useThemeStore } from "~/stores/theme"; import { useThemeStore } from "~/stores/theme";
type ThemeName = "light" | "dark";
type VuetifyThemeInstance = { type VuetifyThemeInstance = {
global: { global: {
name: Ref<string>; name: Ref<string>;
@ -10,40 +12,109 @@ type VuetifyThemeInstance = {
change?: (name: string) => void; change?: (name: string) => void;
}; };
function isThemeName(value: string | null | undefined): value is ThemeName {
return value === "dark" || value === "light";
}
export const useBrowserTheme = () => { export const useBrowserTheme = () => {
const themeStore = useThemeStore(); const themeStore = useThemeStore();
const { $vuetifyTheme } = useNuxtApp(); const { $vuetifyTheme } = useNuxtApp();
const vuetifyTheme = $vuetifyTheme as VuetifyThemeInstance | null; const vuetifyTheme = $vuetifyTheme as VuetifyThemeInstance | null;
const documentTheme = ref<ThemeName | null>(null);
let mediaQueryHandler: ((event: MediaQueryListEvent) => void) | null = null; let mediaQueryHandler: ((event: MediaQueryListEvent) => void) | null = null;
let mediaQuery: MediaQueryList | null = null; let mediaQuery: MediaQueryList | null = null;
let themeClassObserver: MutationObserver | null = null;
const applyVuetifyTheme = (name: "light" | "dark") => { const getDocumentTheme = (): ThemeName | null => {
if (!vuetifyTheme) return; if (!import.meta.client) return null;
if (typeof vuetifyTheme.change === "function") {
vuetifyTheme.change(name); const appClass = document.querySelector(".v-application")?.classList;
} else { if (appClass?.contains("v-theme--dark")) return "dark";
vuetifyTheme.global.name.value = name; if (appClass?.contains("v-theme--light")) return "light";
} return null;
}; };
const applyTheme = (name: "light" | "dark") => { const refreshDocumentTheme = () => {
themeStore.setTheme(name, true); 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); 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 = () => { const initTheme = () => {
if (!import.meta.client) return; if (!import.meta.client) return;
const initialTheme = themeStore.getInitialTheme(); const initialTheme = themeStore.getInitialTheme();
themeStore.setTheme(initialTheme, false); applyTheme(initialTheme, false);
applyVuetifyTheme(initialTheme);
if (mediaQuery && mediaQueryHandler) {
mediaQuery.removeEventListener("change", mediaQueryHandler);
mediaQuery = null;
mediaQueryHandler = null;
}
if (!themeStore.userSelected) { if (!themeStore.userSelected) {
mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
mediaQueryHandler = (event: MediaQueryListEvent) => { mediaQueryHandler = (event: MediaQueryListEvent) => {
if (!themeStore.userSelected) { if (!themeStore.userSelected) {
const newTheme = event.matches ? "dark" : "light"; const newTheme = event.matches ? "dark" : "light";
themeStore.setTheme(newTheme, false); applyTheme(newTheme, false);
applyVuetifyTheme(newTheme);
} }
}; };
mediaQuery.addEventListener("change", mediaQueryHandler); mediaQuery.addEventListener("change", mediaQueryHandler);
@ -51,25 +122,54 @@ export const useBrowserTheme = () => {
}; };
const toggleTheme = () => { const toggleTheme = () => {
applyTheme(themeStore.current === "dark" ? "light" : "dark"); const appliedTheme = syncStoreFromAppliedTheme();
return applyTheme(appliedTheme === "dark" ? "light" : "dark");
}; };
onUnmounted(() => { if (getCurrentInstance()) {
if (mediaQuery && mediaQueryHandler) { onMounted(() => {
mediaQuery.removeEventListener("change", mediaQueryHandler); refreshDocumentTheme();
} observeDocumentTheme();
}); });
onUnmounted(() => {
if (mediaQuery && mediaQueryHandler) {
mediaQuery.removeEventListener("change", mediaQueryHandler);
}
themeClassObserver?.disconnect();
});
}
watch( watch(
() => themeStore.current, () => themeStore.current,
(value) => { (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 { return {
currentTheme: computed(() => themeStore.current), currentTheme,
isDark: computed(() => themeStore.current === "dark"), isDark,
initTheme, initTheme,
toggleTheme toggleTheme
}; };

View file

@ -1,25 +1,15 @@
import type { Ref } from "vue"; import type { Ref } from "vue";
import { nextTick, onMounted, onUnmounted } from "vue"; import { nextTick, onMounted, onUnmounted } from "vue";
type PointerState = {
x: number;
y: number;
};
export function useCyberHeroParallax(rootRef: Ref<HTMLElement | null>) { export function useCyberHeroParallax(rootRef: Ref<HTMLElement | null>) {
let rafId = 0; let rafId = 0;
let bounds: DOMRect | null = null;
let reduceMotion: MediaQueryList | null = null; let reduceMotion: MediaQueryList | null = null;
let canHover: MediaQueryList | null = null;
let observer: IntersectionObserver | null = null; let observer: IntersectionObserver | null = null;
let isVisible = true; let isVisible = true;
const pointer: PointerState = { x: 0, y: 0 };
let scrollOffset = 0; let scrollOffset = 0;
const shouldRun = () => { const shouldRun = () => {
if (reduceMotion?.matches) return false; if (reduceMotion?.matches) return false;
if (canHover && !canHover.matches) return false;
return window.innerWidth >= 768 && isVisible; return window.innerWidth >= 768 && isVisible;
}; };
@ -28,20 +18,17 @@ export function useCyberHeroParallax(rootRef: Ref<HTMLElement | null>) {
const root = rootRef.value; const root = rootRef.value;
if (!root) return; 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()) { 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-scroll", "0");
root.style.setProperty("--hero-tilt-x", "0");
root.style.setProperty("--hero-tilt-y", "0");
return; 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-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 = () => { const requestWrite = () => {
@ -49,29 +36,6 @@ export function useCyberHeroParallax(rootRef: Ref<HTMLElement | null>) {
rafId = requestAnimationFrame(writeVars); 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 onScroll = () => {
const root = rootRef.value; const root = rootRef.value;
if (!root || !shouldRun()) return; if (!root || !shouldRun()) return;
@ -81,7 +45,6 @@ export function useCyberHeroParallax(rootRef: Ref<HTMLElement | null>) {
}; };
const onResize = () => { const onResize = () => {
updateBounds();
requestWrite(); requestWrite();
}; };
@ -91,7 +54,6 @@ export function useCyberHeroParallax(rootRef: Ref<HTMLElement | null>) {
if (!root) return; if (!root) return;
reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
canHover = window.matchMedia("(hover: hover) and (pointer: fine)");
observer = new IntersectionObserver( observer = new IntersectionObserver(
([entry]) => { ([entry]) => {
isVisible = entry.isIntersecting; isVisible = entry.isIntersecting;
@ -101,25 +63,17 @@ export function useCyberHeroParallax(rootRef: Ref<HTMLElement | null>) {
); );
observer.observe(root); observer.observe(root);
updateBounds();
root.addEventListener("pointermove", onPointerMove, { passive: true });
root.addEventListener("pointerleave", onPointerLeave, { passive: true });
window.addEventListener("scroll", onScroll, { passive: true }); window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onResize, { passive: true }); window.addEventListener("resize", onResize, { passive: true });
reduceMotion.addEventListener("change", requestWrite); reduceMotion.addEventListener("change", requestWrite);
canHover.addEventListener("change", requestWrite);
requestWrite(); requestWrite();
}); });
onUnmounted(() => { onUnmounted(() => {
const root = rootRef.value;
if (rafId) cancelAnimationFrame(rafId); if (rafId) cancelAnimationFrame(rafId);
observer?.disconnect(); observer?.disconnect();
root?.removeEventListener("pointermove", onPointerMove);
root?.removeEventListener("pointerleave", onPointerLeave);
window.removeEventListener("scroll", onScroll); window.removeEventListener("scroll", onScroll);
window.removeEventListener("resize", onResize); window.removeEventListener("resize", onResize);
reduceMotion?.removeEventListener("change", requestWrite); 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 robotAvatarCyan from "~/assets/images/hero/robots/robot-avatar-cyan-cat-v1.webp";
import robotCyan from "~/assets/images/hero/robots/robot-cyan-v1.webp"; import robotAvatarReviewerTeal from "~/assets/images/hero/robots/robot-avatar-reviewer-teal-v1.webp";
import robotMagenta from "~/assets/images/hero/robots/robot-magenta-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";
export const HERO_SCENE_VIEWBOX = { import robotRedPurpleHandshake from "~/assets/images/hero/robots/robot-red-purple-handshake-v1.webp";
width: 1600,
height: 900,
} as const;
export const HERO_SCENE_BREAKPOINTS = { export const HERO_SCENE_BREAKPOINTS = {
desktop: 1200, desktop: 1200,
@ -25,6 +22,7 @@ export type HeroAgentRole =
| "fixer"; | "fixer";
export type HeroAccent = "cyan" | "magenta" | "violet" | "amber" | "red"; export type HeroAccent = "cyan" | "magenta" | "violet" | "amber" | "red";
export type HeroMessagePhase = "sender" | "packet" | "receiver" | "cooldown";
export type HeroCardSide = "left" | "right" | "bottom"; export type HeroCardSide = "left" | "right" | "bottom";
@ -41,6 +39,8 @@ export type HeroAgent = {
label: string; label: string;
asset: string; asset: string;
accent: HeroAccent; accent: HeroAccent;
facing?: -1 | 1;
lean?: number;
priority?: boolean; priority?: boolean;
desktop: HeroAgentPosition; desktop: HeroAgentPosition;
tablet: HeroAgentPosition; tablet: HeroAgentPosition;
@ -53,21 +53,10 @@ export type HeroAgent = {
tasks: string[]; 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 = { export type HeroMessage = {
id: string; id: string;
from: HeroAgentRole; from: HeroAgentRole;
to: HeroAgentRole | "video"; to: HeroAgentRole | "video";
connectionId: string;
text: string; text: string;
response: string; response: string;
fromX: number; fromX: number;
@ -80,11 +69,13 @@ export const heroAgents: readonly HeroAgent[] = [
{ {
id: "planner", id: "planner",
label: "Planner", label: "Planner",
asset: robotCyan, asset: robotAvatarSeatedMagenta,
accent: "cyan", accent: "magenta",
facing: 1,
lean: -2,
priority: true, priority: true,
desktop: { x: 34, y: 12, scale: 0.66, depth: 0.35, card: "right" }, desktop: { x: 32.65, y: 19.45, scale: 0.44, depth: 0.35, card: "right" },
tablet: { x: 18, y: 11, scale: 0.55, depth: 0.22, card: "bottom" }, tablet: { x: 20, y: 31, scale: 0.44, depth: 0.22, card: "bottom" },
mobile: { visible: true, order: 1, compactLabel: "Plan" }, mobile: { visible: true, order: 1, compactLabel: "Plan" },
status: "Planning", status: "Planning",
tasks: ["Analyze requirements", "Break down tasks", "Create plan"], tasks: ["Analyze requirements", "Break down tasks", "Create plan"],
@ -92,256 +83,116 @@ export const heroAgents: readonly HeroAgent[] = [
{ {
id: "lead", id: "lead",
label: "Lead", label: "Lead",
asset: robotCyan, asset: robotAvatarCyan,
accent: "cyan", accent: "cyan",
facing: -1,
lean: 2,
priority: true, priority: true,
desktop: { x: 55, y: 9, scale: 0.62, depth: 0.32, card: "right" }, desktop: { x: 58.9, y: 32.76, scale: 0.48, depth: 0.32, card: "right" },
tablet: { x: 50, y: 8, scale: 0.52, depth: 0.2, card: "bottom" }, tablet: { x: 50, y: 31, scale: 0.42, depth: 0.2, card: "bottom" },
mobile: { visible: true, order: 2, compactLabel: "Lead" }, mobile: { visible: true, order: 2, compactLabel: "Lead" },
status: "Leading", status: "Leading",
tasks: ["Define architecture", "Set priorities", "Coordinate team"], 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", id: "developer",
label: "Developer", label: "Developer",
asset: robotCyan, asset: robotAvatarYellow,
accent: "cyan", accent: "amber",
desktop: { x: 74, y: 34, scale: 0.5, depth: 0.52, card: "left" }, facing: 1,
tablet: { x: 88, y: 44, scale: 0.42, depth: 0.26, card: "bottom" }, lean: -1,
mobile: { visible: false }, 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", status: "Coding",
tasks: ["Write code", "Implement feature", "Commit changes"], tasks: ["Implement feature", "Update code", "Run checks"],
},
{
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"],
}, },
] as const; ] as const;
export const heroConnections: readonly HeroConnection[] = [ export const heroReviewerFeatureCard = {
{ label: "Reviewer",
id: "planner-lead", asset: robotAvatarReviewerTeal,
from: "planner", accent: "cyan",
to: "lead", status: "Reviewing",
accent: "cyan", tasks: ["Review code", "Check quality", "Request changes"],
pathDesktop: "M 545 195 C 680 210, 735 185, 860 190", } as const;
packetDelayMs: 0,
packetDurationMs: 4200, export const heroCollaborationFeature = {
}, asset: robotRedPurpleHandshake,
{ } as const;
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 heroMessages: readonly HeroMessage[] = [ 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", from: "developer",
to: "reviewer", to: "reviewer",
connectionId: "developer-reviewer", text: "Review build.",
text: "Code ready. Request review.", response: "Checking quality.",
response: "Review started.", fromX: 72,
fromX: 78, fromY: 7,
fromY: 43, toX: 84,
toX: 73, toY: 82,
toY: 20,
}, },
{ {
id: "tests-passed", id: "review-pass",
from: "tester", from: "reviewer",
to: "lead", to: "developer",
connectionId: "tester-lead", text: "Review passed.",
text: "Tests passed. Looks good.", response: "Ready to ship.",
response: "Ship it.", fromX: 84,
fromX: 78, fromY: 82,
fromY: 62, toX: 72,
toX: 58, toY: 7,
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,
}, },
] as const; ] as const;
export const heroFeatureRail = [ export const heroFeatureRail = [
{ {
id: "autonomous", id: "autonomous",
title: "Autonomous Team", title: "Give the Team a Goal",
text: "Specialized agents coordinate work together.", text: "Agents break it into tasks and start moving without babysitting.",
}, },
{ {
id: "kanban", id: "kanban",
title: "Kanban at Lightspeed", title: "Kanban That Updates Itself",
text: "Tasks move as agents build, review, and test.", text: "Cards shift as agents build, test, review, and unblock each other.",
}, },
{ {
id: "developers", id: "developers",
title: "Built for Developers", title: "Bring Your AI Stack",
text: "Open source, extensible, and API-first.", text: "Claude, Codex, and OpenCode teammates in one desktop cockpit.",
}, },
{ {
id: "secure", id: "secure",
title: "Secure by Default", title: "Stay in the Loop",
text: "Your code and data stay protected.", text: "Jump in with comments, approvals, direct messages, or quick actions.",
}, },
{ {
id: "local", id: "local",
title: "Local First", title: "Your Machine, Your Code",
text: "Runs on your machine. Your data stays yours.", text: "Local-first workflow with task logs, process control, and Git visibility.",
}, },
] as const; ] as const;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -6,6 +6,7 @@
"": { "": {
"name": "agent-teams-landing", "name": "agent-teams-landing",
"dependencies": { "dependencies": {
"@firecms/neat": "^0.8.0",
"@mdi/js": "^7.4.47", "@mdi/js": "^7.4.47",
"@nuxtjs/i18n": "^9.5.6", "@nuxtjs/i18n": "^9.5.6",
"@pinia/nuxt": "^0.11.3", "@pinia/nuxt": "^0.11.3",
@ -1314,6 +1315,12 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "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": { "node_modules/@humanfs/core": {
"version": "0.19.1", "version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",

View file

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

View file

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

View file

@ -2,6 +2,8 @@ import "vuetify/styles";
import { createVuetify } from "vuetify"; import { createVuetify } from "vuetify";
import { aliases, mdi } from "vuetify/iconsets/mdi-svg"; import { aliases, mdi } from "vuetify/iconsets/mdi-svg";
type ThemeName = "light" | "dark";
const brand = { const brand = {
cyan: "#00f0ff", cyan: "#00f0ff",
magenta: "#ff00ff", magenta: "#ff00ff",
@ -11,9 +13,28 @@ const brand = {
darkSurface: "#12121a" 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({ export default defineNuxtPlugin({
name: "vuetify", name: "vuetify",
setup(nuxtApp) { setup(nuxtApp) {
const themeCookie = useCookie<ThemeName | null>("theme");
const cookieTheme = isThemeName(themeCookie.value) ? themeCookie.value : null;
const defaultTheme = resolveInitialTheme(cookieTheme);
const vuetify = createVuetify({ const vuetify = createVuetify({
icons: { icons: {
defaultSet: "mdi", defaultSet: "mdi",
@ -21,7 +42,7 @@ export default defineNuxtPlugin({
sets: { mdi } sets: { mdi }
}, },
theme: { theme: {
defaultTheme: "dark", defaultTheme,
themes: { themes: {
light: { light: {
colors: { colors: {

View file

@ -1,30 +1,60 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
type ThemeName = "light" | "dark"; 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", { export const useThemeStore = defineStore("theme", {
state: () => ({ state: () => ({
current: "dark" as ThemeName, current: "light" as ThemeName,
userSelected: false userSelected: false
}), }),
actions: { actions: {
getInitialTheme(): ThemeName { getInitialTheme(): ThemeName {
if (!import.meta.client) return "dark"; if (!import.meta.client) return "light";
const saved = localStorage.getItem("theme");
if (saved === "dark" || saved === "light") { const saved = localStorage.getItem(themeCookieName);
if (isThemeName(saved)) {
this.userSelected = true; this.userSelected = true;
persistTheme(saved);
return saved; return saved;
} }
const cookieTheme = getCookieTheme();
if (cookieTheme) {
this.userSelected = true;
persistTheme(cookieTheme);
return cookieTheme;
}
if (window.matchMedia("(prefers-color-scheme: dark)").matches) { if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
return "dark"; return "dark";
} }
return "dark"; return "light";
}, },
setTheme(theme: ThemeName, fromUser: boolean) { setTheme(theme: ThemeName, fromUser: boolean) {
this.current = theme; this.current = theme;
if (import.meta.client && fromUser) { if (import.meta.client && fromUser) {
this.userSelected = true; this.userSelected = true;
localStorage.setItem("theme", theme); persistTheme(theme);
} }
} }
} }