feat(landing): refresh cyberpunk landing visuals
2
landing/.gitignore
vendored
|
|
@ -3,6 +3,8 @@ node_modules
|
|||
.output
|
||||
.dist
|
||||
.env
|
||||
--host/
|
||||
product-docs/.vitepress/dist/
|
||||
|
||||
# Large video files
|
||||
public/video/*.mp4
|
||||
|
|
|
|||
BIN
landing/assets/images/hero/robots/robot-avatar-amber-v1.webp
Normal file
|
After Width: | Height: | Size: 147 KiB |
BIN
landing/assets/images/hero/robots/robot-avatar-cyan-v1.webp
Normal file
|
After Width: | Height: | Size: 141 KiB |
BIN
landing/assets/images/hero/robots/robot-avatar-magenta-v1.webp
Normal file
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 126 KiB |
BIN
landing/assets/images/hero/robots/robot-seated-magenta-v1.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
landing/assets/images/hero/robots/robot-seated-magenta-v1.webp
Normal file
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 570 KiB |
|
|
@ -3,7 +3,7 @@ const { baseURL } = useRuntimeConfig().app;
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLink to="/" class="app-logo">
|
||||
<NuxtLink to="/" class="app-logo" :prefetch="false">
|
||||
<img
|
||||
:src="`${baseURL}logo-192.png`"
|
||||
alt="Agent Teams"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -6,7 +6,18 @@ import {
|
|||
mdiShieldCheckOutline,
|
||||
mdiMonitorDashboard,
|
||||
} from "@mdi/js";
|
||||
import { heroFeatureRail } from "~/data/heroScene";
|
||||
import {
|
||||
heroFeatureRail,
|
||||
heroReviewerFeatureCard,
|
||||
type HeroMessage,
|
||||
type HeroMessagePhase,
|
||||
} from "~/data/heroScene";
|
||||
|
||||
const props = defineProps<{
|
||||
activeMessage?: HeroMessage | null;
|
||||
phase?: HeroMessagePhase;
|
||||
reducedMotion?: boolean;
|
||||
}>();
|
||||
|
||||
const icons = [
|
||||
mdiRobotOutline,
|
||||
|
|
@ -15,21 +26,76 @@ const icons = [
|
|||
mdiShieldCheckOutline,
|
||||
mdiMonitorDashboard,
|
||||
] as const;
|
||||
|
||||
const reviewerIsSender = computed(() =>
|
||||
props.activeMessage?.from === "reviewer" && props.phase !== "cooldown",
|
||||
);
|
||||
const reviewerIsReceiver = computed(() =>
|
||||
props.activeMessage?.to === "reviewer" && props.phase === "receiver",
|
||||
);
|
||||
const reviewerIsActive = computed(() => reviewerIsSender.value || reviewerIsReceiver.value);
|
||||
const reviewerBubbleText = computed(() => {
|
||||
if (!props.activeMessage || props.reducedMotion) return null;
|
||||
if (props.activeMessage.from === "reviewer" && (props.phase === "sender" || props.phase === "packet")) {
|
||||
return props.activeMessage.text;
|
||||
}
|
||||
if (props.activeMessage.to === "reviewer" && props.phase === "receiver") {
|
||||
return props.activeMessage.response;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cyber-feature-rail cyber-panel">
|
||||
<div class="cyber-feature-rail-shell">
|
||||
<div
|
||||
v-for="(feature, index) in heroFeatureRail"
|
||||
:key="feature.id"
|
||||
class="cyber-feature-rail__item"
|
||||
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"
|
||||
>
|
||||
<div class="cyber-feature-rail__icon">
|
||||
<v-icon :icon="icons[index]" size="28" />
|
||||
<Transition name="cyber-feature-bubble">
|
||||
<div
|
||||
v-if="reviewerBubbleText"
|
||||
class="cyber-feature-rail__reviewer-bubble"
|
||||
>
|
||||
{{ reviewerBubbleText }}
|
||||
</div>
|
||||
</Transition>
|
||||
<div class="cyber-feature-rail__reviewer-card cyber-panel">
|
||||
<div class="cyber-feature-rail__reviewer-label">{{ heroReviewerFeatureCard.label }}</div>
|
||||
<ul class="cyber-feature-rail__reviewer-tasks">
|
||||
<li v-for="task in heroReviewerFeatureCard.tasks" :key="task">{{ task }}</li>
|
||||
</ul>
|
||||
<div class="cyber-feature-rail__reviewer-status">
|
||||
<span>Status:</span>
|
||||
<strong>{{ heroReviewerFeatureCard.status }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cyber-feature-rail__copy">
|
||||
<div class="cyber-feature-rail__title">{{ feature.title }}</div>
|
||||
<div class="cyber-feature-rail__text">{{ feature.text }}</div>
|
||||
<img
|
||||
class="cyber-feature-rail__robot"
|
||||
:src="heroReviewerFeatureCard.asset"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
>
|
||||
</div>
|
||||
<div class="cyber-feature-rail cyber-panel">
|
||||
<div
|
||||
v-for="(feature, index) in heroFeatureRail"
|
||||
:key="feature.id"
|
||||
class="cyber-feature-rail__item"
|
||||
>
|
||||
<div class="cyber-feature-rail__icon">
|
||||
<v-icon :icon="icons[index]" size="28" />
|
||||
</div>
|
||||
<div class="cyber-feature-rail__copy">
|
||||
<div class="cyber-feature-rail__title">{{ feature.title }}</div>
|
||||
<div class="cyber-feature-rail__text">{{ feature.text }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
<script setup lang="ts">
|
||||
import type { HeroMessage } from "~/data/heroScene";
|
||||
import type { HeroMessage, HeroMessagePhase } from "~/data/heroScene";
|
||||
|
||||
const props = defineProps<{
|
||||
message: HeroMessage | null;
|
||||
phase: "sender" | "packet" | "receiver" | "cooldown";
|
||||
phase: HeroMessagePhase;
|
||||
reducedMotion?: boolean;
|
||||
}>();
|
||||
|
||||
|
|
@ -17,8 +17,12 @@ const receiverStyle = computed(() => ({
|
|||
"--bubble-y": props.message ? String(props.message.toY) : "0",
|
||||
}));
|
||||
|
||||
const showSender = computed(() => props.message && (props.phase === "sender" || props.phase === "packet"));
|
||||
const showReceiver = computed(() => props.message && props.phase === "receiver");
|
||||
const showSender = computed(() =>
|
||||
props.message && props.message.from !== "reviewer" && (props.phase === "sender" || props.phase === "packet"),
|
||||
);
|
||||
const showReceiver = computed(() =>
|
||||
props.message && props.message.to !== "reviewer" && props.phase === "receiver",
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -26,7 +30,8 @@ const showReceiver = computed(() => props.message && props.phase === "receiver")
|
|||
<Transition name="cyber-bubble">
|
||||
<div
|
||||
v-if="showSender && message && !reducedMotion"
|
||||
class="cyber-message cyber-message--sender cyber-panel"
|
||||
class="cyber-message cyber-message--sender"
|
||||
:class="`cyber-message--role-${message.from}`"
|
||||
:style="senderStyle"
|
||||
>
|
||||
{{ message.text }}
|
||||
|
|
@ -36,7 +41,8 @@ const showReceiver = computed(() => props.message && props.phase === "receiver")
|
|||
<Transition name="cyber-bubble">
|
||||
<div
|
||||
v-if="showReceiver && message && !reducedMotion"
|
||||
class="cyber-message cyber-message--receiver cyber-panel"
|
||||
class="cyber-message cyber-message--receiver"
|
||||
:class="`cyber-message--role-${message.to}`"
|
||||
:style="receiverStyle"
|
||||
>
|
||||
{{ message.response }}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ const rootStyle = computed(() => ({
|
|||
"--agent-y": String(props.agent.desktop.y),
|
||||
"--agent-scale": String(props.agent.desktop.scale),
|
||||
"--agent-depth": String(props.agent.desktop.depth),
|
||||
"--agent-face": String(props.agent.facing ?? 1),
|
||||
"--agent-lean": `${props.agent.lean ?? 0}deg`,
|
||||
"--agent-tablet-x": String(props.agent.tablet.x),
|
||||
"--agent-tablet-y": String(props.agent.tablet.y),
|
||||
"--agent-tablet-scale": String(props.agent.tablet.scale),
|
||||
|
|
|
|||
|
|
@ -1,111 +1,26 @@
|
|||
<script setup lang="ts">
|
||||
import {
|
||||
heroAgents,
|
||||
heroConnections,
|
||||
heroMessages,
|
||||
type HeroAgentRole,
|
||||
type HeroMessage,
|
||||
type HeroMessagePhase,
|
||||
} from "~/data/heroScene";
|
||||
|
||||
type MessagePhase = "sender" | "packet" | "receiver" | "cooldown";
|
||||
const props = defineProps<{
|
||||
message: HeroMessage | null;
|
||||
phase: HeroMessagePhase;
|
||||
reducedMotion?: boolean;
|
||||
}>();
|
||||
|
||||
const activeMessageIndex = ref(0);
|
||||
const phase = ref<MessagePhase>("cooldown");
|
||||
const isVisible = ref(false);
|
||||
const reducedMotion = ref(false);
|
||||
const sceneRef = ref<HTMLElement | null>(null);
|
||||
let timers: number[] = [];
|
||||
let observer: IntersectionObserver | null = null;
|
||||
let motionQuery: MediaQueryList | null = null;
|
||||
|
||||
const activeMessage = computed(() => heroMessages[activeMessageIndex.value] ?? null);
|
||||
const activeConnectionId = computed(() => (phase.value === "cooldown" ? null : activeMessage.value?.connectionId ?? null));
|
||||
const activeSender = computed<HeroAgentRole | null>(() => (phase.value === "cooldown" ? null : activeMessage.value?.from ?? null));
|
||||
const activeSender = computed<HeroAgentRole | null>(() => (props.phase === "cooldown" ? null : props.message?.from ?? null));
|
||||
const activeReceiver = computed<HeroAgentRole | "video" | null>(() => (
|
||||
phase.value === "receiver" ? activeMessage.value?.to ?? null : null
|
||||
props.phase === "receiver" ? props.message?.to ?? null : null
|
||||
));
|
||||
|
||||
function clearTimers() {
|
||||
timers.forEach(window.clearTimeout);
|
||||
timers = [];
|
||||
}
|
||||
|
||||
function setTimer(callback: () => void, delay: number) {
|
||||
const id = window.setTimeout(callback, delay);
|
||||
timers.push(id);
|
||||
}
|
||||
|
||||
function runCycle() {
|
||||
clearTimers();
|
||||
|
||||
if (!isVisible.value || reducedMotion.value) {
|
||||
phase.value = "cooldown";
|
||||
return;
|
||||
}
|
||||
|
||||
phase.value = "sender";
|
||||
setTimer(() => {
|
||||
phase.value = "packet";
|
||||
}, 900);
|
||||
setTimer(() => {
|
||||
phase.value = "receiver";
|
||||
}, 2200);
|
||||
setTimer(() => {
|
||||
phase.value = "cooldown";
|
||||
}, 3900);
|
||||
setTimer(() => {
|
||||
activeMessageIndex.value = (activeMessageIndex.value + 1) % heroMessages.length;
|
||||
runCycle();
|
||||
}, 4700);
|
||||
}
|
||||
|
||||
function syncMotion() {
|
||||
reducedMotion.value = Boolean(motionQuery?.matches);
|
||||
runCycle();
|
||||
}
|
||||
|
||||
function onVisibilityChange() {
|
||||
if (document.hidden) {
|
||||
clearTimers();
|
||||
phase.value = "cooldown";
|
||||
return;
|
||||
}
|
||||
runCycle();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
reducedMotion.value = motionQuery.matches;
|
||||
motionQuery.addEventListener("change", syncMotion);
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
|
||||
observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
isVisible.value = entry.isIntersecting;
|
||||
runCycle();
|
||||
},
|
||||
{ threshold: 0.15 },
|
||||
);
|
||||
|
||||
if (sceneRef.value) observer.observe(sceneRef.value);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
clearTimers();
|
||||
observer?.disconnect();
|
||||
motionQuery?.removeEventListener("change", syncMotion);
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="sceneRef" class="cyber-scene">
|
||||
<div class="cyber-scene">
|
||||
<div class="cyber-scene__floor" aria-hidden="true" />
|
||||
<CyberHeroConnectors
|
||||
class="cyber-scene__connectors"
|
||||
:connections="heroConnections"
|
||||
:active-connection-id="activeConnectionId"
|
||||
:reduced-motion="reducedMotion"
|
||||
/>
|
||||
|
||||
<CyberHeroVideoFrame class="cyber-scene__video" />
|
||||
|
||||
|
|
@ -121,7 +36,7 @@ onUnmounted(() => {
|
|||
|
||||
<CyberHeroMessageBubbles
|
||||
class="cyber-scene__messages"
|
||||
:message="activeMessage"
|
||||
:message="message"
|
||||
:phase="phase"
|
||||
:reduced-motion="reducedMotion"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -11,17 +11,7 @@
|
|||
<span>Live demo</span>
|
||||
</div>
|
||||
<div class="cyber-video-frame__content">
|
||||
<ClientOnly>
|
||||
<Suspense>
|
||||
<LazyHeroDemoVideo />
|
||||
<template #fallback>
|
||||
<div class="cyber-video-frame__fallback" />
|
||||
</template>
|
||||
</Suspense>
|
||||
<template #fallback>
|
||||
<div class="cyber-video-frame__fallback" />
|
||||
</template>
|
||||
</ClientOnly>
|
||||
<HeroDemoVideo />
|
||||
</div>
|
||||
<div class="cyber-video-frame__corner cyber-video-frame__corner--tl" aria-hidden="true" />
|
||||
<div class="cyber-video-frame__corner cyber-video-frame__corner--tr" aria-hidden="true" />
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import robotAvatarCyan from "~/assets/images/hero/robots/robot-avatar-cyan-v1.webp";
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const { repoUrl } = useGithubRepo();
|
||||
const { baseURL } = useRuntimeConfig().app;
|
||||
|
|
@ -11,6 +13,14 @@ const docsHref = computed(() => {
|
|||
|
||||
<template>
|
||||
<footer class="app-footer">
|
||||
<img
|
||||
class="app-footer__robot"
|
||||
:src="robotAvatarCyan"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<v-container class="app-footer__inner">
|
||||
<span class="app-footer__copy"
|
||||
>{{ t('footer.copyright', { year }) }} · {{ t('footer.tagline') }}</span
|
||||
|
|
@ -28,8 +38,26 @@ const docsHref = computed(() => {
|
|||
|
||||
<style scoped>
|
||||
.app-footer {
|
||||
position: relative;
|
||||
border-top: 1px solid var(--at-c-border);
|
||||
padding: 20px 0;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.app-footer__robot {
|
||||
position: absolute;
|
||||
right: clamp(22px, 9vw, 148px);
|
||||
bottom: calc(100% - 4px);
|
||||
z-index: 2;
|
||||
width: clamp(76px, 6.2vw, 112px);
|
||||
height: auto;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
transform: translateY(14px) rotate(-2deg);
|
||||
transform-origin: center bottom;
|
||||
filter:
|
||||
drop-shadow(0 16px 22px rgba(0, 0, 0, 0.54))
|
||||
drop-shadow(0 0 18px rgba(0, 234, 255, 0.26));
|
||||
}
|
||||
|
||||
.app-footer__inner {
|
||||
|
|
@ -91,6 +119,10 @@ const docsHref = computed(() => {
|
|||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.app-footer__robot {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-footer__inner {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
|
|
|
|||
|
|
@ -8,26 +8,113 @@ const menuOpen = ref(false);
|
|||
|
||||
const withBase = (path: string) => `${baseURL.replace(/\/?$/, '/')}${path.replace(/^\/+/, '')}`;
|
||||
const docsHref = computed(() => withBase(locale.value === 'ru' ? 'docs/ru/' : 'docs/'));
|
||||
const isRu = computed(() => locale.value === 'ru');
|
||||
|
||||
const navItems = computed(() => [
|
||||
{ href: '#screenshots', label: t('nav.screenshots') },
|
||||
{ href: docsHref.value, label: t('nav.docs') },
|
||||
{ href: '#download', label: t('nav.download') },
|
||||
{ href: '#comparison', label: t('nav.comparison') },
|
||||
{ href: '#pricing', label: t('nav.pricing') },
|
||||
{ href: '#faq', label: t('nav.faq') },
|
||||
{ href: '#screenshots', label: t('nav.screenshots'), shortLabel: isRu.value ? 'Скрины' : 'Shots' },
|
||||
{ href: docsHref.value, label: 'Docs', shortLabel: 'Docs' },
|
||||
{ href: '#download', label: t('nav.download'), shortLabel: isRu.value ? 'Скачать' : 'Get' },
|
||||
{ href: '#comparison', label: t('nav.comparison'), shortLabel: isRu.value ? 'Сравн.' : 'Compare' },
|
||||
{ href: '#pricing', label: t('nav.pricing'), shortLabel: isRu.value ? 'Беспл.' : 'Free' },
|
||||
{ href: '#faq', label: t('nav.faq'), shortLabel: 'FAQ' },
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="app-header">
|
||||
<v-container class="app-header__inner">
|
||||
<svg
|
||||
class="app-header__hud"
|
||||
viewBox="0 0 2048 128"
|
||||
preserveAspectRatio="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="hud-cyan-magenta" x1="0" y1="0" x2="2048" y2="0" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#00eaff" />
|
||||
<stop offset="0.28" stop-color="#7a5cff" />
|
||||
<stop offset="0.52" stop-color="#00eaff" />
|
||||
<stop offset="0.75" stop-color="#ff2bff" />
|
||||
<stop offset="1" stop-color="#00eaff" />
|
||||
</linearGradient>
|
||||
<linearGradient id="hud-panel-fill" x1="0" y1="0" x2="2048" y2="128" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#06152a" stop-opacity="0.94" />
|
||||
<stop offset="0.46" stop-color="#020711" stop-opacity="0.86" />
|
||||
<stop offset="1" stop-color="#07101e" stop-opacity="0.94" />
|
||||
</linearGradient>
|
||||
<filter id="hud-glow" x="-8%" y="-60%" width="116%" height="220%">
|
||||
<feGaussianBlur stdDeviation="3.6" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<pattern id="hud-dot-grid" width="14" height="14" patternUnits="userSpaceOnUse">
|
||||
<circle cx="2" cy="2" r="1.2" fill="#00eaff" opacity="0.16" />
|
||||
</pattern>
|
||||
</defs>
|
||||
|
||||
<g class="app-header__hud-fill">
|
||||
<path d="M22 26H384L438 64L384 102H22Z" />
|
||||
<path d="M456 36H1516L1546 52H1568V76H1546L1516 94H456Z" />
|
||||
<path d="M1608 26H2026V102H1608L1568 86V42Z" />
|
||||
</g>
|
||||
|
||||
<path class="app-header__hud-dots" d="M116 38H424V96H116Z" />
|
||||
|
||||
<g class="app-header__hud-lines app-header__hud-lines--back">
|
||||
<path d="M20 26H384L438 64L384 102H20Z" />
|
||||
<path d="M456 36H1516L1546 52H1568V76H1546L1516 94H456Z" />
|
||||
<path d="M1608 26H2028V102H1608L1568 86V42Z" />
|
||||
<path d="M34 18H372L388 30" />
|
||||
<path d="M462 24H920L944 38H1512" />
|
||||
<path d="M1620 18H2010L2034 38V91L2006 110H1614L1576 96" />
|
||||
<path d="M490 106H862L880 118H1196L1214 106H1512" />
|
||||
</g>
|
||||
|
||||
<g class="app-header__hud-lines app-header__hud-lines--front">
|
||||
<path d="M56 20H368" />
|
||||
<path d="M28 86L54 108H178" />
|
||||
<path d="M374 102H438L458 84" />
|
||||
<path d="M520 42H842L858 52" />
|
||||
<path d="M934 24L1186 24L1166 42H956Z" />
|
||||
<path d="M1248 42H1490L1504 52" />
|
||||
<path d="M1604 98H1868" />
|
||||
<path d="M1888 106H1994L2018 88" />
|
||||
</g>
|
||||
|
||||
<g class="app-header__hud-ticks">
|
||||
<path d="M644 62V78" />
|
||||
<path d="M884 62V78" />
|
||||
<path d="M1138 62V78" />
|
||||
<path d="M1288 62V78" />
|
||||
<path d="M1400 62V78" />
|
||||
<path d="M1532 62V78" />
|
||||
<path d="M1870 62V78" />
|
||||
</g>
|
||||
|
||||
<g class="app-header__hud-microdots">
|
||||
<circle v-for="x in [894, 914, 934, 954, 974, 994, 1014, 1034, 1054, 1074, 1094, 1114]" :key="x" :cx="x" cy="24" r="2.2" />
|
||||
<circle v-for="x in [370, 380, 390]" :key="`l-${x}`" :cx="x" cy="102" r="2.4" />
|
||||
<circle v-for="x in [1892, 1902, 1912]" :key="`r-${x}`" :cx="x" cy="102" r="2.4" />
|
||||
<circle cx="512" cy="26" r="2.8" />
|
||||
<circle cx="524" cy="26" r="2.8" />
|
||||
</g>
|
||||
|
||||
<g class="app-header__hud-energy">
|
||||
<path d="M28 26H384L438 64L384 102H28" />
|
||||
<path d="M456 36H1516L1546 52H1568V76H1546L1516 94H456" />
|
||||
<path d="M1608 26H2026V102H1608L1568 86V42" />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<div class="app-header__brand-frame">
|
||||
<AppLogo />
|
||||
</div>
|
||||
<nav class="app-header__nav">
|
||||
<v-btn v-for="item in navItems" :key="item.href" variant="text" :href="item.href">
|
||||
{{ item.label }}
|
||||
<span class="app-header__nav-label app-header__nav-label--full">{{ item.label }}</span>
|
||||
<span class="app-header__nav-label app-header__nav-label--short">{{ item.shortLabel }}</span>
|
||||
</v-btn>
|
||||
</nav>
|
||||
<div class="app-header__spacer" />
|
||||
|
|
@ -39,9 +126,10 @@ const navItems = computed(() => [
|
|||
:href="repoUrl"
|
||||
target="_blank"
|
||||
class="app-header__github-btn"
|
||||
:prepend-icon="mdiGithub"
|
||||
aria-label="GitHub"
|
||||
>
|
||||
GitHub
|
||||
<v-icon :icon="mdiGithub" class="app-header__github-icon" />
|
||||
<span class="app-header__github-text">GitHub</span>
|
||||
</v-btn>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
|
@ -93,21 +181,27 @@ const navItems = computed(() => [
|
|||
<style scoped>
|
||||
.app-header {
|
||||
--header-cyan: var(--cyber-cyan);
|
||||
--header-violet: var(--cyber-violet);
|
||||
--header-magenta: var(--cyber-magenta);
|
||||
--header-height: 128px;
|
||||
--header-panel-height: 86px;
|
||||
--header-action-size: clamp(54px, 3.25vw, 66px);
|
||||
--header-github-width: clamp(150px, 9.7vw, 204px);
|
||||
--header-brand-icon: clamp(58px, 4.1vw, 76px);
|
||||
--header-brand-text: clamp(24px, 1.55vw, 34px);
|
||||
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: var(--at-z-header);
|
||||
height: 92px;
|
||||
height: var(--header-height);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(2, 5, 13, 0.98), rgba(2, 5, 13, 0.72) 74%, rgba(2, 5, 13, 0.16)),
|
||||
rgba(2, 5, 13, 0.72);
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
box-shadow: 0 16px 42px rgba(0, 0, 0, 0.26);
|
||||
background: transparent;
|
||||
backdrop-filter: none !important;
|
||||
-webkit-backdrop-filter: none !important;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.app-header::before,
|
||||
|
|
@ -116,133 +210,213 @@ const navItems = computed(() => [
|
|||
}
|
||||
|
||||
.v-theme--light .app-header {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(244, 250, 255, 0.96), rgba(244, 250, 255, 0.8) 74%, rgba(244, 250, 255, 0.2)),
|
||||
rgba(244, 250, 255, 0.86);
|
||||
border-bottom-color: rgba(0, 168, 204, 0.34);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.v-theme--dark .app-header {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(2, 5, 13, 0.98), rgba(2, 5, 13, 0.72) 74%, rgba(2, 5, 13, 0.16)),
|
||||
rgba(2, 5, 13, 0.72);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.app-header__inner {
|
||||
position: relative;
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: clamp(386px, 26.3vw, 538px) minmax(560px, 1fr) clamp(366px, 23.2vw, 474px);
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
width: min(1680px, 100vw);
|
||||
width: min(2048px, calc(100vw - 18px));
|
||||
max-width: none !important;
|
||||
height: 100%;
|
||||
padding-inline: 0 !important;
|
||||
}
|
||||
|
||||
.app-header__hud {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.app-header__hud-fill path {
|
||||
fill: url("#hud-panel-fill");
|
||||
opacity: 0.86;
|
||||
}
|
||||
|
||||
.app-header__hud-dots {
|
||||
fill: url("#hud-dot-grid");
|
||||
opacity: 0.56;
|
||||
}
|
||||
|
||||
.app-header__hud-lines path,
|
||||
.app-header__hud-ticks path,
|
||||
.app-header__hud-energy path {
|
||||
fill: none;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.app-header__hud-lines--back path {
|
||||
stroke: url("#hud-cyan-magenta");
|
||||
stroke-width: 2;
|
||||
opacity: 0.66;
|
||||
filter: url("#hud-glow");
|
||||
}
|
||||
|
||||
.app-header__hud-lines--front path {
|
||||
stroke: rgba(0, 234, 255, 0.9);
|
||||
stroke-width: 1.3;
|
||||
opacity: 0.84;
|
||||
}
|
||||
|
||||
.app-header__hud-ticks path {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-header__hud-microdots circle {
|
||||
fill: url("#hud-cyan-magenta");
|
||||
filter: url("#hud-glow");
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.app-header__hud-energy path {
|
||||
stroke: url("#hud-cyan-magenta");
|
||||
stroke-width: 3.2;
|
||||
stroke-linecap: round;
|
||||
stroke-dasharray: 92 620;
|
||||
stroke-dashoffset: 0;
|
||||
opacity: 0.82;
|
||||
filter: url("#hud-glow");
|
||||
animation: headerHudFlow 8.5s linear infinite;
|
||||
}
|
||||
|
||||
.app-header__brand-frame {
|
||||
position: relative;
|
||||
grid-column: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: center;
|
||||
isolation: isolate;
|
||||
height: 76px;
|
||||
min-width: 358px;
|
||||
padding: 0 74px 0 52px;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: var(--header-panel-height);
|
||||
min-width: 0;
|
||||
padding: 0 64px 0 clamp(38px, 4.2vw, 82px);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
clip-path: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.app-header__brand-frame::before,
|
||||
.app-header__brand-frame::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
clip-path: polygon(0 0, calc(100% - 54px) 0, 100% 50%, calc(100% - 54px) 100%, 0 100%, 0 0);
|
||||
}
|
||||
|
||||
.app-header__brand-frame::before {
|
||||
inset: 0;
|
||||
z-index: -2;
|
||||
background: linear-gradient(110deg, rgba(0, 234, 255, 0.92), rgba(47, 125, 255, 0.5) 58%, rgba(0, 234, 255, 0.82));
|
||||
filter: drop-shadow(0 0 16px rgba(0, 234, 255, 0.42));
|
||||
}
|
||||
|
||||
.app-header__brand-frame::after {
|
||||
inset: 1px;
|
||||
z-index: -1;
|
||||
background:
|
||||
linear-gradient(110deg, rgba(5, 14, 31, 0.98), rgba(2, 6, 16, 0.95) 64%, rgba(0, 234, 255, 0.08)),
|
||||
rgba(2, 6, 16, 0.96);
|
||||
overflow: hidden;
|
||||
contain: paint;
|
||||
clip-path: polygon(0 18%, 80% 18%, 100% 50%, 80% 82%, 0 82%);
|
||||
}
|
||||
|
||||
.app-header__brand-frame :deep(.app-logo) {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
gap: 12px;
|
||||
gap: clamp(14px, 1.4vw, 28px);
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
display: inline-grid;
|
||||
grid-template-columns: var(--header-brand-icon) max-content;
|
||||
grid-template-rows: var(--header-brand-icon);
|
||||
align-content: center;
|
||||
justify-content: start;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.app-header__brand-frame :deep(.app-logo__img) {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
width: var(--header-brand-icon);
|
||||
height: var(--header-brand-icon);
|
||||
border-radius: 17px;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.08) inset,
|
||||
0 0 22px rgba(139, 92, 255, 0.36);
|
||||
0 0 0 1px rgba(139, 92, 255, 0.34) inset,
|
||||
0 0 28px rgba(139, 92, 255, 0.44),
|
||||
0 0 38px rgba(0, 234, 255, 0.12);
|
||||
}
|
||||
|
||||
.app-header__brand-frame :deep(.app-logo__text) {
|
||||
font-size: 20px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
font-size: var(--header-brand-text);
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1;
|
||||
letter-spacing: 0;
|
||||
white-space: nowrap;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.app-header__nav {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
align-self: center;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: clamp(22px, 2.7vw, 46px);
|
||||
height: 76px;
|
||||
margin-left: -28px;
|
||||
padding: 0 clamp(34px, 4.4vw, 74px) 0 clamp(70px, 5.6vw, 104px);
|
||||
}
|
||||
--nav-pad-start: clamp(34px, 4cqw, 56px);
|
||||
--nav-pad-end: clamp(38px, 4.5cqw, 68px);
|
||||
|
||||
.app-header__nav::before,
|
||||
.app-header__nav::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(90deg, rgba(0, 234, 255, 0.6), rgba(0, 234, 255, 0.24) 36%, rgba(139, 92, 255, 0.5) 58%, rgba(0, 234, 255, 0.58));
|
||||
opacity: 0.86;
|
||||
box-shadow: 0 0 14px rgba(0, 234, 255, 0.18);
|
||||
top: calc((var(--header-height) - var(--header-panel-height, 86px)) / 2);
|
||||
left: calc(456 / 2048 * 100%);
|
||||
right: calc((2048 - 1568) / 2048 * 100%);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
z-index: 1;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
height: var(--header-panel-height, 86px);
|
||||
padding: 0 var(--nav-pad-end) 0 var(--nav-pad-start);
|
||||
overflow: hidden;
|
||||
container-type: inline-size;
|
||||
contain: paint;
|
||||
clip-path: polygon(0 17%, 94% 17%, 97.4% 36%, 100% 36%, 100% 64%, 97.4% 64%, 94% 83%, 0 83%);
|
||||
}
|
||||
|
||||
.app-header__nav::before {
|
||||
top: 8px;
|
||||
}
|
||||
|
||||
.app-header__nav::after {
|
||||
bottom: 7px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-header__nav :deep(.v-btn) {
|
||||
height: 48px !important;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 64px !important;
|
||||
min-width: 0 !important;
|
||||
padding-inline: clamp(4px, 0.8cqw, 12px) !important;
|
||||
border-radius: 0;
|
||||
color: rgba(244, 247, 255, 0.9) !important;
|
||||
color: rgba(244, 247, 255, 0.88) !important;
|
||||
font-family: var(--at-font-mono);
|
||||
font-size: 13px !important;
|
||||
font-size: clamp(12px, 1.45cqw, 17px) !important;
|
||||
font-weight: 700 !important;
|
||||
letter-spacing: 0.08em !important;
|
||||
letter-spacing: clamp(0.03em, 0.12vw, 0.07em) !important;
|
||||
text-transform: uppercase !important;
|
||||
text-shadow: 0 0 16px rgba(244, 247, 255, 0.16);
|
||||
}
|
||||
|
||||
.app-header__nav :deep(.v-btn:not(:last-child)::after) {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0;
|
||||
width: 1px;
|
||||
height: 22px;
|
||||
background: linear-gradient(180deg, transparent, rgba(0, 234, 255, 0.62), transparent);
|
||||
filter: drop-shadow(0 0 8px rgba(0, 234, 255, 0.34));
|
||||
transform: translateY(-50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.app-header__nav :deep(.v-btn__content) {
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-header__nav-label {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-header__nav-label--short {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-header__nav :deep(.v-btn:hover) {
|
||||
|
|
@ -255,55 +429,105 @@ const navItems = computed(() => [
|
|||
}
|
||||
|
||||
.app-header__desktop-actions {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
position: absolute;
|
||||
top: calc((var(--header-height) - var(--header-panel-height)) / 2);
|
||||
right: calc((2048 - 2026) / 2048 * 100%);
|
||||
left: calc(1568 / 2048 * 100%);
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
var(--header-action-size)
|
||||
var(--header-github-width)
|
||||
var(--header-action-size);
|
||||
z-index: 1;
|
||||
gap: clamp(10px, 1.15vw, 22px);
|
||||
align-items: center;
|
||||
align-self: center;
|
||||
justify-content: flex-end;
|
||||
isolation: isolate;
|
||||
height: 76px;
|
||||
min-width: 328px;
|
||||
padding: 0 32px 0 58px;
|
||||
justify-content: center;
|
||||
justify-items: center;
|
||||
height: var(--header-panel-height);
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
clip-path: none;
|
||||
box-shadow: none;
|
||||
overflow: hidden;
|
||||
contain: paint;
|
||||
clip-path: polygon(8% 18%, 100% 18%, 100% 82%, 8% 82%, 0 64%, 0 36%);
|
||||
}
|
||||
|
||||
.app-header__desktop-actions::before,
|
||||
.app-header__desktop-actions::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
clip-path: polygon(42px 0, 100% 0, 100% 100%, 42px 100%, 0 50%);
|
||||
.app-header__desktop-actions :deep(.v-btn) {
|
||||
align-self: center;
|
||||
justify-self: center;
|
||||
margin: 0 !important;
|
||||
line-height: 1 !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.app-header__desktop-actions::before {
|
||||
inset: 0;
|
||||
z-index: -2;
|
||||
background: linear-gradient(250deg, rgba(0, 234, 255, 0.92), rgba(47, 125, 255, 0.46) 48%, rgba(0, 234, 255, 0.72));
|
||||
filter: drop-shadow(0 0 16px rgba(0, 234, 255, 0.34));
|
||||
.app-header__desktop-actions :deep(.v-btn:not(.app-header__github-btn)) {
|
||||
width: var(--header-action-size) !important;
|
||||
min-width: var(--header-action-size) !important;
|
||||
height: var(--header-action-size) !important;
|
||||
min-height: var(--header-action-size) !important;
|
||||
padding-inline: 0 !important;
|
||||
color: rgba(244, 247, 255, 0.9) !important;
|
||||
}
|
||||
|
||||
.app-header__desktop-actions::after {
|
||||
inset: 1px;
|
||||
z-index: -1;
|
||||
background:
|
||||
linear-gradient(250deg, rgba(5, 14, 31, 0.98), rgba(2, 6, 16, 0.94) 68%, rgba(0, 234, 255, 0.08)),
|
||||
rgba(2, 6, 16, 0.96);
|
||||
.app-header__desktop-actions :deep(.v-btn__content),
|
||||
.app-header__desktop-actions :deep(.v-icon) {
|
||||
margin: 0 !important;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.app-header__desktop-actions :deep(.v-btn__overlay),
|
||||
.app-header__desktop-actions :deep(.v-btn__underlay) {
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.app-header__desktop-actions :deep(.language-switcher__flag-icon) {
|
||||
display: block;
|
||||
width: calc(var(--header-action-size) - 14px);
|
||||
height: calc(var(--header-action-size) - 14px);
|
||||
border-radius: 50%;
|
||||
filter: drop-shadow(0 0 12px rgba(47, 125, 255, 0.34));
|
||||
}
|
||||
|
||||
.app-header__github-btn {
|
||||
min-height: 36px !important;
|
||||
border-color: rgba(0, 234, 255, 0.58) !important;
|
||||
width: var(--header-github-width) !important;
|
||||
height: var(--header-action-size) !important;
|
||||
min-height: var(--header-action-size) !important;
|
||||
min-width: var(--header-github-width) !important;
|
||||
padding-inline: clamp(14px, 1vw, 20px) !important;
|
||||
border-color: rgba(0, 234, 255, 0.76) !important;
|
||||
border-radius: 6px !important;
|
||||
color: var(--header-cyan) !important;
|
||||
font-family: var(--at-font-mono);
|
||||
font-weight: 800 !important;
|
||||
font-size: 12px !important;
|
||||
font-size: clamp(14px, 1vw, 19px) !important;
|
||||
letter-spacing: 0.08em !important;
|
||||
text-transform: uppercase !important;
|
||||
box-shadow: 0 0 16px rgba(0, 234, 255, 0.12);
|
||||
background: rgba(0, 234, 255, 0.035) !important;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(0, 234, 255, 0.1) inset,
|
||||
0 0 20px rgba(0, 234, 255, 0.18);
|
||||
}
|
||||
|
||||
.app-header__github-btn :deep(.v-btn__content) {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.app-header__github-icon {
|
||||
flex: 0 0 auto;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.app-header__github-text {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.app-header__github-btn:hover {
|
||||
|
|
@ -316,21 +540,222 @@ const navItems = computed(() => [
|
|||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 959px) {
|
||||
@keyframes headerHudFlow {
|
||||
to {
|
||||
stroke-dashoffset: -712;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1439px) {
|
||||
.app-header {
|
||||
--header-height: 104px;
|
||||
--header-panel-height: 72px;
|
||||
--header-action-size: 54px;
|
||||
--header-github-width: 124px;
|
||||
--header-brand-icon: 48px;
|
||||
--header-brand-text: 16px;
|
||||
}
|
||||
|
||||
.app-header__inner {
|
||||
grid-template-columns: 296px minmax(0, 1fr) 286px;
|
||||
width: min(100vw - 16px, 1440px);
|
||||
}
|
||||
|
||||
.app-header__brand-frame {
|
||||
height: 72px;
|
||||
padding-left: 38px;
|
||||
padding-right: 38px;
|
||||
}
|
||||
|
||||
.app-header__brand-frame :deep(.app-logo__text) {
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.app-header__nav {
|
||||
--nav-pad-start: clamp(30px, 4.5cqw, 46px);
|
||||
--nav-pad-end: clamp(42px, 5.8cqw, 58px);
|
||||
|
||||
height: 72px;
|
||||
}
|
||||
|
||||
.app-header__nav :deep(.v-btn) {
|
||||
height: 56px !important;
|
||||
padding-inline: 3px !important;
|
||||
font-size: clamp(11.4px, 1.7cqw, 12px) !important;
|
||||
letter-spacing: 0.02em !important;
|
||||
}
|
||||
|
||||
.app-header__desktop-actions {
|
||||
height: var(--header-panel-height);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.app-header__github-btn {
|
||||
font-size: 13px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
.app-header {
|
||||
--header-github-width: 104px;
|
||||
--header-brand-text: 14px;
|
||||
}
|
||||
|
||||
.app-header__inner {
|
||||
grid-template-columns: 260px minmax(0, 1fr) 232px;
|
||||
}
|
||||
|
||||
.app-header__brand-frame {
|
||||
padding-left: 26px;
|
||||
padding-right: 34px;
|
||||
}
|
||||
|
||||
.app-header__nav {
|
||||
--nav-pad-start: 24px;
|
||||
--nav-pad-end: 18px;
|
||||
}
|
||||
|
||||
.app-header__nav :deep(.v-btn) {
|
||||
font-size: 9px !important;
|
||||
letter-spacing: 0.04em !important;
|
||||
}
|
||||
|
||||
.app-header__desktop-actions {
|
||||
right: calc((2048 - 2026) / 2048 * 100%);
|
||||
left: calc(1568 / 2048 * 100%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1279px) and (min-width: 768px) {
|
||||
.app-header {
|
||||
--header-height: 88px;
|
||||
--header-panel-height: 64px;
|
||||
--header-action-size: clamp(40px, 5vw, 48px);
|
||||
--header-brand-icon: clamp(38px, 4.8vw, 44px);
|
||||
--header-brand-text: clamp(11px, 1.35vw, 14px);
|
||||
}
|
||||
|
||||
.app-header__inner {
|
||||
grid-template-columns: clamp(176px, 22vw, 260px) minmax(0, 1fr) clamp(148px, 18vw, 210px);
|
||||
width: min(100vw - 12px, 1239px);
|
||||
}
|
||||
|
||||
.app-header__brand-frame {
|
||||
height: 64px;
|
||||
padding-left: clamp(18px, 2.4vw, 30px);
|
||||
padding-right: clamp(22px, 3vw, 34px);
|
||||
}
|
||||
|
||||
.app-header__brand-frame :deep(.app-logo) {
|
||||
gap: clamp(8px, 1.2vw, 12px);
|
||||
}
|
||||
|
||||
.app-header__brand-frame :deep(.app-logo__text) {
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.app-header__nav {
|
||||
--nav-pad-start: clamp(14px, 3.4cqw, 28px);
|
||||
--nav-pad-end: clamp(18px, 4cqw, 34px);
|
||||
|
||||
top: 12px;
|
||||
left: calc(456 / 2048 * 100%);
|
||||
right: calc((2048 - 1568) / 2048 * 100%);
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.app-header__nav :deep(.v-btn) {
|
||||
height: 48px !important;
|
||||
padding-inline: 2px !important;
|
||||
font-size: clamp(10.8px, 1.7cqw, 11.4px) !important;
|
||||
letter-spacing: 0.01em !important;
|
||||
}
|
||||
|
||||
.app-header__nav::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-header__nav :deep(.v-btn:not(:last-child)::after) {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.app-header__desktop-actions {
|
||||
height: 64px;
|
||||
gap: clamp(6px, 0.9vw, 10px);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@media (max-width: 1279px) and (min-width: 768px) {
|
||||
.app-header {
|
||||
--header-github-width: var(--header-action-size);
|
||||
}
|
||||
|
||||
.app-header__nav-label--full {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-header__nav-label--short {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.app-header__github-btn {
|
||||
padding-inline: 0 !important;
|
||||
}
|
||||
|
||||
.app-header__github-btn :deep(.v-btn__content) {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.app-header__github-text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.app-header {
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.app-header__inner {
|
||||
display: flex;
|
||||
width: min(100% - 32px, 680px);
|
||||
}
|
||||
|
||||
.app-header__hud {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-header__brand-frame {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
align-self: center;
|
||||
height: 48px;
|
||||
padding: 0 42px 0 12px;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.app-header__brand-frame::before,
|
||||
.app-header__brand-frame::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
clip-path: polygon(0 0, calc(100% - 44px) 0, 100% 50%, calc(100% - 44px) 100%, 0 100%, 0 0);
|
||||
}
|
||||
|
||||
.app-header__brand-frame::before {
|
||||
inset: 0;
|
||||
z-index: -2;
|
||||
background: linear-gradient(110deg, rgba(0, 234, 255, 0.92), rgba(47, 125, 255, 0.5) 58%, rgba(0, 234, 255, 0.82));
|
||||
filter: drop-shadow(0 0 16px rgba(0, 234, 255, 0.42));
|
||||
}
|
||||
|
||||
.app-header__brand-frame::after {
|
||||
inset: 1px;
|
||||
z-index: -1;
|
||||
background:
|
||||
linear-gradient(110deg, rgba(5, 14, 31, 0.98), rgba(2, 6, 16, 0.95) 64%, rgba(0, 234, 255, 0.08)),
|
||||
rgba(2, 6, 16, 0.96);
|
||||
}
|
||||
|
||||
.app-header__brand-frame :deep(.app-logo__img) {
|
||||
|
|
@ -353,6 +778,8 @@ const navItems = computed(() => [
|
|||
|
||||
.app-header__mobile-actions {
|
||||
display: flex;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
|
|
@ -363,6 +790,12 @@ const navItems = computed(() => [
|
|||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.app-header__hud-energy path {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-menu-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import robotAvatarCyan from "~/assets/images/hero/robots/robot-avatar-cyan-v1.webp";
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface CellValue {
|
||||
|
|
@ -175,7 +177,7 @@ const rows = computed<ComparisonRow[]>(() => [
|
|||
gastown: { status: 'partial', note: 'Cost tiers + digest, no hard caps' },
|
||||
paperclip: { status: 'yes', note: 'Per-agent budgets + hard stops' },
|
||||
cursor: { status: 'partial', note: 'Usage + BG spend limits' },
|
||||
claudeCli: { status: 'partial', note: '/cost + workspace limits' },
|
||||
claudeCli: { status: 'partial', note: '/usage + workspace limits' },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.price'),
|
||||
|
|
@ -195,6 +197,54 @@ const competitors = [
|
|||
{ key: 'claudeCli', name: 'Claude Code CLI' },
|
||||
]
|
||||
|
||||
const sourceLinks = [
|
||||
{
|
||||
label: 'detailed research notes',
|
||||
href: 'https://github.com/777genius/agent-teams-ai/blob/main/docs/research/gastown-paperclip-comparison-2026-05-16.md',
|
||||
},
|
||||
{ label: 'Gastown README', href: 'https://github.com/gastownhall/gastown' },
|
||||
{
|
||||
label: 'Gastown provider guide',
|
||||
href: 'https://github.com/gastownhall/gastown/blob/main/docs/agent-provider-integration.md',
|
||||
},
|
||||
{
|
||||
label: 'Gastown scheduler',
|
||||
href: 'https://github.com/gastownhall/gastown/blob/main/docs/design/scheduler.md',
|
||||
},
|
||||
{ label: 'Gastown release', href: 'https://github.com/gastownhall/gastown/releases/tag/v1.1.0' },
|
||||
{ label: 'Paperclip README', href: 'https://github.com/paperclipai/paperclip' },
|
||||
{
|
||||
label: 'Paperclip adapters',
|
||||
href: 'https://github.com/paperclipai/paperclip/blob/master/docs/adapters/overview.md',
|
||||
},
|
||||
{
|
||||
label: 'Paperclip budgets',
|
||||
href: 'https://github.com/paperclipai/paperclip/blob/master/docs/guides/board-operator/costs-and-budgets.md',
|
||||
},
|
||||
{
|
||||
label: 'Paperclip runtime services',
|
||||
href: 'https://github.com/paperclipai/paperclip/blob/master/docs/guides/board-operator/execution-workspaces-and-runtime-services.md',
|
||||
},
|
||||
{
|
||||
label: 'Paperclip Kanban source',
|
||||
href: 'https://github.com/paperclipai/paperclip/blob/master/ui/src/components/KanbanBoard.tsx',
|
||||
},
|
||||
{
|
||||
label: 'Paperclip work products',
|
||||
href: 'https://github.com/paperclipai/paperclip/blob/master/packages/shared/src/validators/work-product.ts',
|
||||
},
|
||||
{ label: 'Paperclip release', href: 'https://github.com/paperclipai/paperclip/releases/tag/v2026.513.0' },
|
||||
{ label: 'Cursor Background Agents', href: 'https://docs.cursor.com/en/background-agents' },
|
||||
{ label: 'Cursor Diffs & Review', href: 'https://docs.cursor.com/en/agent/review' },
|
||||
{ label: 'Cursor Bugbot', href: 'https://docs.cursor.com/en/bugbot' },
|
||||
{ label: 'Cursor pricing', href: 'https://docs.cursor.com/en/account/usage' },
|
||||
{ label: 'Claude Code agent teams', href: 'https://code.claude.com/docs/en/agent-teams' },
|
||||
{ label: 'Claude Code subagents', href: 'https://code.claude.com/docs/en/sub-agents' },
|
||||
{ label: 'Claude Code workflows', href: 'https://code.claude.com/docs/en/common-workflows' },
|
||||
{ label: 'Claude Code costs', href: 'https://code.claude.com/docs/en/costs' },
|
||||
{ label: 'Claude pricing', href: 'https://claude.com/pricing' },
|
||||
]
|
||||
|
||||
function getCellClass(cell: CellValue): string {
|
||||
switch (cell.status) {
|
||||
case 'yes': return 'comparison-table__cell--yes'
|
||||
|
|
@ -234,6 +284,14 @@ function getStatusIcon(status: string): string {
|
|||
</div>
|
||||
|
||||
<div class="comparison-table__wrap">
|
||||
<img
|
||||
class="comparison-table__robot"
|
||||
:src="robotAvatarCyan"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<table class="comparison-table">
|
||||
<thead>
|
||||
<tr>
|
||||
|
|
@ -298,6 +356,15 @@ function getStatusIcon(status: string): string {
|
|||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p class="comparison-section__sources">
|
||||
Fact sources checked on May 16, 2026:
|
||||
<template v-for="(source, index) in sourceLinks" :key="source.href">
|
||||
<a :href="source.href" target="_blank" rel="noopener noreferrer">
|
||||
{{ source.label }}
|
||||
</a><span v-if="index < sourceLinks.length - 1">, </span>
|
||||
</template>.
|
||||
</p>
|
||||
</v-container>
|
||||
</section>
|
||||
</template>
|
||||
|
|
@ -305,6 +372,7 @@ function getStatusIcon(status: string): string {
|
|||
<style scoped>
|
||||
.comparison-section {
|
||||
position: relative;
|
||||
--comparison-sticky-header-offset: 76px;
|
||||
}
|
||||
|
||||
.comparison-section__header {
|
||||
|
|
@ -345,6 +413,46 @@ function getStatusIcon(status: string): string {
|
|||
z-index: 1;
|
||||
}
|
||||
|
||||
.comparison-table__robot {
|
||||
position: absolute;
|
||||
right: clamp(28px, 7vw, 96px);
|
||||
bottom: calc(100% - 5px);
|
||||
z-index: 4;
|
||||
width: clamp(82px, 7.2vw, 124px);
|
||||
height: auto;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
transform: translateY(14px) scaleX(-1) rotate(2deg);
|
||||
transform-origin: center bottom;
|
||||
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::selection {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.comparison-section__sources {
|
||||
max-width: 1040px;
|
||||
margin: 18px auto 0;
|
||||
color: rgba(136, 146, 176, 0.82);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.65;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.comparison-section__sources a {
|
||||
color: #00d4e6;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.comparison-section__sources a:hover {
|
||||
color: #00f0ff;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.comparison-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
|
@ -354,12 +462,13 @@ function getStatusIcon(status: string): string {
|
|||
|
||||
/* Header */
|
||||
.comparison-table thead {
|
||||
position: sticky;
|
||||
top: 64px;
|
||||
z-index: 2;
|
||||
position: static;
|
||||
}
|
||||
|
||||
.comparison-table__th {
|
||||
position: sticky;
|
||||
top: var(--comparison-sticky-header-offset);
|
||||
z-index: 3;
|
||||
padding: 16px 12px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
|
|
@ -382,7 +491,7 @@ function getStatusIcon(status: string): string {
|
|||
.comparison-table__th--highlight {
|
||||
color: #00f0ff;
|
||||
background: rgba(0, 18, 20, 0.97);
|
||||
position: relative;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.comparison-table__th--highlight::after {
|
||||
|
|
@ -545,6 +654,18 @@ function getStatusIcon(status: string): string {
|
|||
border-color: rgba(0, 180, 200, 0.2);
|
||||
}
|
||||
|
||||
.v-theme--light .comparison-section__sources {
|
||||
color: rgba(71, 85, 105, 0.82);
|
||||
}
|
||||
|
||||
.v-theme--light .comparison-section__sources a {
|
||||
color: #0891b2;
|
||||
}
|
||||
|
||||
.v-theme--light .comparison-section__sources a:hover {
|
||||
color: #0e7490;
|
||||
}
|
||||
|
||||
.v-theme--light .comparison-table__th {
|
||||
color: #64748b;
|
||||
border-bottom-color: rgba(0, 0, 0, 0.08);
|
||||
|
|
@ -624,6 +745,10 @@ function getStatusIcon(status: string): string {
|
|||
|
||||
/* Responsive */
|
||||
@media (max-width: 960px) {
|
||||
.comparison-section {
|
||||
--comparison-sticky-header-offset: 60px;
|
||||
}
|
||||
|
||||
.comparison-table__wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
|
@ -641,6 +766,12 @@ function getStatusIcon(status: string): string {
|
|||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1600px) {
|
||||
.comparison-section {
|
||||
--comparison-sticky-header-offset: 124px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.comparison-section__title {
|
||||
font-size: 1.6rem;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { mdiApple, mdiMicrosoftWindows, mdiPenguin, mdiDownload, mdiCheckCircle } from '@mdi/js';
|
||||
import robotAvatarSeatedMagenta from '~/assets/images/hero/robots/robot-avatar-seated-magenta-v1.webp';
|
||||
import { downloadAssets } from '~/data/downloads';
|
||||
import type { DownloadOs, DownloadArch } from '~/data/downloads';
|
||||
|
||||
|
|
@ -10,10 +11,203 @@ const { data: releaseData, resolve } = useReleaseDownloads();
|
|||
const { trackDownloadClick } = useAnalytics();
|
||||
const { repoUrl, releaseDownloadUrl } = useGithubRepo();
|
||||
const isMounted = ref(false);
|
||||
const showLinuxRobotMessage = ref(false);
|
||||
const showFallingLinuxRobot = ref(false);
|
||||
const isLinuxRobotDetached = ref(false);
|
||||
const hasLinuxRobotDeparted = ref(false);
|
||||
const linuxRobotFlightState = ref<'idle' | 'falling' | 'landed'>('idle');
|
||||
const fallingLinuxRobotStyle = ref<Record<string, string>>({});
|
||||
let linuxRobotObserver: IntersectionObserver | null = null;
|
||||
let linuxRobotFallRaf = 0;
|
||||
let linuxRobotFallTimer: number | null = null;
|
||||
let lastLinuxRobotScrollY = 0;
|
||||
|
||||
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.3;
|
||||
const landedPageY = target.top + target.height * 0.08 - robotHeight * 0.62;
|
||||
|
||||
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 (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);
|
||||
return;
|
||||
}
|
||||
|
||||
if (linuxRobotFlightState.value === 'landed') return;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
isMounted.value = true;
|
||||
downloadStore.init();
|
||||
|
||||
nextTick(() => {
|
||||
const linuxCard = document.querySelector<HTMLElement>('[data-download-os="linux"]');
|
||||
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);
|
||||
scheduleLinuxRobotFallUpdate();
|
||||
});
|
||||
|
||||
window.addEventListener('scroll', scheduleLinuxRobotFallUpdate, { passive: true });
|
||||
window.addEventListener('resize', scheduleLinuxRobotFallUpdate);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
linuxRobotObserver?.disconnect();
|
||||
linuxRobotObserver = null;
|
||||
if (linuxRobotFallRaf) window.cancelAnimationFrame(linuxRobotFallRaf);
|
||||
linuxRobotFallRaf = 0;
|
||||
clearLinuxRobotFallTimer();
|
||||
window.removeEventListener('scroll', scheduleLinuxRobotFallUpdate);
|
||||
window.removeEventListener('resize', scheduleLinuxRobotFallUpdate);
|
||||
});
|
||||
|
||||
const platformIcons: Record<string, string> = {
|
||||
|
|
@ -91,16 +285,44 @@ const devBranchNote = computed(() =>
|
|||
v-for="(asset, index) in visibleAssets"
|
||||
:key="asset.id"
|
||||
class="download-section__card"
|
||||
:class="{ 'download-section__card--active': downloadStore.selectedId === asset.id }"
|
||||
:class="{
|
||||
'download-section__card--active': downloadStore.selectedId === asset.id,
|
||||
'download-section__card--with-robot': asset.os === 'linux',
|
||||
'download-section__card--robot-flying': asset.os === 'linux' && isLinuxRobotDetached,
|
||||
}"
|
||||
:style="{
|
||||
'--delay': `${index * 0.1}s`,
|
||||
'--accent': platformColors[asset.os] || '#00f0ff',
|
||||
}"
|
||||
:data-download-os="asset.os"
|
||||
@click="downloadStore.setSelected(asset.id)"
|
||||
>
|
||||
<!-- Card glow effect -->
|
||||
<div class="download-section__card-glow" />
|
||||
|
||||
<span
|
||||
v-if="asset.os === 'linux'"
|
||||
class="download-section__card-robot-seat"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Transition name="download-robot-bubble">
|
||||
<span
|
||||
v-if="showLinuxRobotMessage"
|
||||
class="download-section__card-robot-bubble"
|
||||
>
|
||||
Готов начать!
|
||||
</span>
|
||||
</Transition>
|
||||
<img
|
||||
class="download-section__card-robot"
|
||||
:src="robotAvatarSeatedMagenta"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
draggable="false"
|
||||
>
|
||||
</span>
|
||||
|
||||
<!-- Platform icon -->
|
||||
<div class="download-section__card-icon-wrap">
|
||||
<v-icon
|
||||
|
|
@ -158,6 +380,25 @@ const devBranchNote = computed(() =>
|
|||
v{{ releaseVersion }} · {{ releaseDate }}
|
||||
</p>
|
||||
</v-container>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showFallingLinuxRobot"
|
||||
class="download-section__falling-robot"
|
||||
:class="`download-section__falling-robot--${linuxRobotFlightState}`"
|
||||
:style="fallingLinuxRobotStyle"
|
||||
aria-hidden="true"
|
||||
@animationend.self="finishLinuxRobotFall"
|
||||
>
|
||||
<img
|
||||
class="download-section__falling-robot-image"
|
||||
:src="robotAvatarSeatedMagenta"
|
||||
alt=""
|
||||
decoding="async"
|
||||
draggable="false"
|
||||
>
|
||||
</div>
|
||||
</Teleport>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
|
|
@ -230,6 +471,11 @@ const devBranchNote = computed(() =>
|
|||
animation-delay: var(--delay, 0s);
|
||||
}
|
||||
|
||||
.download-section__card--with-robot {
|
||||
overflow: visible;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.download-section__card:hover {
|
||||
transform: translateY(-6px);
|
||||
border-color: rgba(0, 240, 255, 0.2);
|
||||
|
|
@ -248,6 +494,252 @@ const devBranchNote = computed(() =>
|
|||
z-index: 2;
|
||||
}
|
||||
|
||||
.download-section__card-robot-seat {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: calc(100% - 68px);
|
||||
z-index: 4;
|
||||
width: 108px;
|
||||
pointer-events: none;
|
||||
transform: rotate(-5deg);
|
||||
transform-origin: center bottom;
|
||||
filter:
|
||||
drop-shadow(0 12px 18px rgba(0, 0, 0, 0.48))
|
||||
drop-shadow(0 0 14px rgba(255, 43, 255, 0.24));
|
||||
}
|
||||
|
||||
.download-section__card--robot-flying .download-section__card-robot-seat {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.download-section__falling-robot {
|
||||
--fall-x: 0px;
|
||||
--fall-y: 120vh;
|
||||
|
||||
position: fixed;
|
||||
z-index: 28;
|
||||
pointer-events: none;
|
||||
transform-origin: center 72%;
|
||||
will-change: left, top, transform, opacity;
|
||||
filter:
|
||||
drop-shadow(0 18px 26px rgba(0, 0, 0, 0.52))
|
||||
drop-shadow(0 0 18px rgba(255, 43, 255, 0.28));
|
||||
}
|
||||
|
||||
.download-section__falling-robot--falling {
|
||||
animation: downloadFallingRobotDrop 2.85s cubic-bezier(0.34, 0, 0.74, 0.28) forwards;
|
||||
}
|
||||
|
||||
.download-section__falling-robot--landed {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.download-section__falling-robot::after {
|
||||
position: absolute;
|
||||
left: 18%;
|
||||
right: 18%;
|
||||
bottom: 28%;
|
||||
height: 12px;
|
||||
content: "";
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 43, 255, 0.16);
|
||||
filter: blur(10px);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.download-section__falling-robot-image {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
transform:
|
||||
scaleX(-1)
|
||||
rotate(-4deg);
|
||||
transform-origin: center bottom;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.download-section__falling-robot--falling .download-section__falling-robot-image {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.download-section__falling-robot--landed .download-section__falling-robot-image {
|
||||
animation: downloadFallingRobotLanded 2.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes downloadFallingRobotDrop {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0) rotate(-6deg) scale(1);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0.96;
|
||||
transform:
|
||||
translate3d(var(--fall-x), var(--fall-y), 0)
|
||||
rotate(-5deg)
|
||||
scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes downloadFallingRobotFlutter {
|
||||
0%,
|
||||
100% {
|
||||
transform:
|
||||
translate3d(0, 0, 0)
|
||||
scaleX(-1)
|
||||
rotate(-5deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform:
|
||||
translate3d(0, 4px, 0)
|
||||
scaleX(-1)
|
||||
rotate(4deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes downloadFallingRobotLanded {
|
||||
0%,
|
||||
100% {
|
||||
transform:
|
||||
translate3d(0, 0, 0)
|
||||
scaleX(-1)
|
||||
rotate(-4deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform:
|
||||
translate3d(0, -2px, 0)
|
||||
scaleX(-1)
|
||||
rotate(-3deg);
|
||||
}
|
||||
}
|
||||
|
||||
.download-section__card-robot-bubble {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: calc(100% - 18px);
|
||||
z-index: 5;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 30px;
|
||||
padding: 6px 10px;
|
||||
color: #0b1020;
|
||||
font-family: var(--at-font-mono);
|
||||
font-size: 0.66rem;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
letter-spacing: 0;
|
||||
white-space: nowrap;
|
||||
background:
|
||||
radial-gradient(circle at 28% 24%, rgba(255, 255, 255, 0.84), rgba(255, 244, 168, 0.84) 66%, rgba(255, 215, 0, 0.82) 100%);
|
||||
border: 2px solid #050816;
|
||||
border-radius: 999px;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 215, 0, 0.28),
|
||||
0 5px 0 rgba(0, 0, 0, 0.2),
|
||||
0 0 12px rgba(255, 215, 0, 0.14);
|
||||
text-shadow: 1px 1px 0 rgba(255, 255, 255, 0.62);
|
||||
pointer-events: none;
|
||||
transform: rotate(-5deg);
|
||||
transform-origin: right bottom;
|
||||
animation: downloadRobotBubbleFloat 2.6s ease-in-out 0.42s infinite;
|
||||
}
|
||||
|
||||
.download-section__card-robot-bubble::after {
|
||||
position: absolute;
|
||||
top: 52%;
|
||||
right: -28px;
|
||||
width: 30px;
|
||||
height: 12px;
|
||||
content: "";
|
||||
background: rgba(255, 226, 78, 0.96);
|
||||
clip-path: polygon(0 0, 100% 50%, 0 100%);
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.download-section__card-robot-bubble::before {
|
||||
position: absolute;
|
||||
top: 52%;
|
||||
right: -34px;
|
||||
width: 36px;
|
||||
height: 18px;
|
||||
content: "";
|
||||
background: #050816;
|
||||
clip-path: polygon(0 0, 100% 50%, 0 100%);
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.download-robot-bubble-enter-active,
|
||||
.download-robot-bubble-leave-active {
|
||||
transition:
|
||||
opacity 0.26s ease,
|
||||
filter 0.26s ease;
|
||||
}
|
||||
|
||||
.download-robot-bubble-enter-active {
|
||||
animation: downloadRobotBubblePop 0.52s cubic-bezier(0.18, 0.9, 0.2, 1.24);
|
||||
}
|
||||
|
||||
.download-robot-bubble-enter-from,
|
||||
.download-robot-bubble-leave-to {
|
||||
opacity: 0;
|
||||
filter: blur(2px);
|
||||
}
|
||||
|
||||
.download-robot-bubble-leave-active {
|
||||
animation: downloadRobotBubbleExit 0.22s ease forwards;
|
||||
}
|
||||
|
||||
@keyframes downloadRobotBubblePop {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate3d(14px, 18px, 0) scale(0.48) rotate(-13deg);
|
||||
}
|
||||
|
||||
58% {
|
||||
opacity: 1;
|
||||
transform: translate3d(-3px, -4px, 0) scale(1.1) rotate(-4deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0) scale(1) rotate(-5deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes downloadRobotBubbleFloat {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate3d(0, 0, 0) rotate(-5deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate3d(0, -3px, 0) rotate(-4deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes downloadRobotBubbleExit {
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translate3d(8px, 8px, 0) scale(0.85) rotate(-8deg);
|
||||
}
|
||||
}
|
||||
|
||||
.download-section__card-robot {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
height: auto;
|
||||
transform:
|
||||
scaleX(-1)
|
||||
rotate(-4deg);
|
||||
transform-origin: center bottom;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.download-section__card--active:hover {
|
||||
transform: scale(1.08);
|
||||
border-color: rgba(57, 255, 20, 0.5);
|
||||
|
|
@ -502,6 +994,26 @@ const devBranchNote = computed(() =>
|
|||
gap: 20px;
|
||||
}
|
||||
|
||||
.download-section__card-robot-seat {
|
||||
right: 18px;
|
||||
bottom: calc(100% - 54px);
|
||||
width: 84px;
|
||||
}
|
||||
|
||||
.download-section__card-robot-bubble {
|
||||
top: 8px;
|
||||
right: calc(100% - 14px);
|
||||
min-height: 28px;
|
||||
padding: 6px 9px;
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
|
||||
.download-section__card-robot {
|
||||
transform:
|
||||
scaleX(-1)
|
||||
rotate(-4deg);
|
||||
}
|
||||
|
||||
.download-section__card--active {
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
|
@ -557,6 +1069,10 @@ const devBranchNote = computed(() =>
|
|||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.download-section__card-robot-seat {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.download-section__card-icon-wrap {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ const faqIcons = [
|
|||
</div>
|
||||
|
||||
<div class="faq-section__decoration">
|
||||
<div class="faq-section__deco-circle">
|
||||
<div class="faq-section__deco-circle" data-faq-landing-target>
|
||||
<v-icon size="40" class="faq-section__deco-icon" :icon="mdiFrequentlyAskedQuestions" />
|
||||
</div>
|
||||
<div class="faq-section__deco-ring faq-section__deco-ring--1" />
|
||||
|
|
|
|||
|
|
@ -2,33 +2,30 @@
|
|||
import {
|
||||
mdiBookOpenPageVariantOutline,
|
||||
mdiDownload,
|
||||
mdiPlayCircleOutline,
|
||||
} from "@mdi/js";
|
||||
import { heroMessages, type HeroMessagePhase } from "~/data/heroScene";
|
||||
|
||||
const { content } = useLandingContent();
|
||||
const { t, locale } = useI18n();
|
||||
const { baseURL } = useRuntimeConfig().app;
|
||||
const heroRef = ref<HTMLElement | null>(null);
|
||||
const activeHeroMessageIndex = ref(0);
|
||||
const heroMessagePhase = ref<HeroMessagePhase>("cooldown");
|
||||
const isHeroVisible = ref(false);
|
||||
const heroReducedMotion = ref(false);
|
||||
let heroMessageTimers: number[] = [];
|
||||
let heroMessageObserver: IntersectionObserver | null = null;
|
||||
let heroMotionQuery: MediaQueryList | null = null;
|
||||
|
||||
const downloadStore = useDownloadStore();
|
||||
const { resolve, data: releaseData } = useReleaseDownloads();
|
||||
const { repoUrl, latestReleaseUrl, releaseDownloadUrl } = useGithubRepo();
|
||||
const { latestReleaseUrl, releaseDownloadUrl } = useGithubRepo();
|
||||
const withBase = (path: string) => `${baseURL.replace(/\/?$/, "/")}${path.replace(/^\/+/, "")}`;
|
||||
|
||||
useCyberHeroParallax(heroRef);
|
||||
|
||||
const releaseVersion = computed(() => releaseData.value?.version || null);
|
||||
const releaseDate = computed(() => {
|
||||
const raw = releaseData.value?.pubDate;
|
||||
if (!raw) return null;
|
||||
return new Date(raw).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
});
|
||||
|
||||
onMounted(() => downloadStore.init());
|
||||
const activeHeroMessage = computed(() => heroMessages[activeHeroMessageIndex.value] ?? null);
|
||||
|
||||
const heroDownloadUrl = computed(() => {
|
||||
const asset = downloadStore.selectedAsset;
|
||||
|
|
@ -37,13 +34,82 @@ const heroDownloadUrl = computed(() => {
|
|||
return resolve(asset.os, arch)?.url || releaseDownloadUrl(asset.fileName);
|
||||
});
|
||||
|
||||
const devBranchUrl = computed(() => `${repoUrl.value}/tree/dev`);
|
||||
const docsHref = computed(() => withBase(locale.value === "ru" ? "docs/ru/" : "docs/"));
|
||||
const devBranchNote = computed(() =>
|
||||
locale.value === "ru"
|
||||
? "Самая свежая версия в ветке dev - можно развернуть локально."
|
||||
: "Freshest version is on the dev branch - clone and run it locally.",
|
||||
);
|
||||
|
||||
function clearHeroMessageTimers() {
|
||||
heroMessageTimers.forEach(window.clearTimeout);
|
||||
heroMessageTimers = [];
|
||||
}
|
||||
|
||||
function setHeroMessageTimer(callback: () => void, delay: number) {
|
||||
const id = window.setTimeout(callback, delay);
|
||||
heroMessageTimers.push(id);
|
||||
}
|
||||
|
||||
function runHeroMessageCycle() {
|
||||
clearHeroMessageTimers();
|
||||
|
||||
if (!isHeroVisible.value || heroReducedMotion.value || heroMessages.length === 0) {
|
||||
heroMessagePhase.value = "cooldown";
|
||||
return;
|
||||
}
|
||||
|
||||
heroMessagePhase.value = "sender";
|
||||
setHeroMessageTimer(() => {
|
||||
heroMessagePhase.value = "packet";
|
||||
}, 900);
|
||||
setHeroMessageTimer(() => {
|
||||
heroMessagePhase.value = "receiver";
|
||||
}, 2200);
|
||||
setHeroMessageTimer(() => {
|
||||
heroMessagePhase.value = "cooldown";
|
||||
}, 3900);
|
||||
setHeroMessageTimer(() => {
|
||||
activeHeroMessageIndex.value = (activeHeroMessageIndex.value + 1) % heroMessages.length;
|
||||
runHeroMessageCycle();
|
||||
}, 4700);
|
||||
}
|
||||
|
||||
function syncHeroMotion() {
|
||||
heroReducedMotion.value = Boolean(heroMotionQuery?.matches);
|
||||
runHeroMessageCycle();
|
||||
}
|
||||
|
||||
function onHeroVisibilityChange() {
|
||||
if (document.hidden) {
|
||||
clearHeroMessageTimers();
|
||||
heroMessagePhase.value = "cooldown";
|
||||
return;
|
||||
}
|
||||
|
||||
runHeroMessageCycle();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
downloadStore.init();
|
||||
|
||||
heroMotionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
heroReducedMotion.value = heroMotionQuery.matches;
|
||||
heroMotionQuery.addEventListener("change", syncHeroMotion);
|
||||
document.addEventListener("visibilitychange", onHeroVisibilityChange);
|
||||
|
||||
heroMessageObserver = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
isHeroVisible.value = Boolean(entry?.isIntersecting);
|
||||
runHeroMessageCycle();
|
||||
},
|
||||
{ threshold: 0.15 },
|
||||
);
|
||||
|
||||
if (heroRef.value) heroMessageObserver.observe(heroRef.value);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
clearHeroMessageTimers();
|
||||
heroMessageObserver?.disconnect();
|
||||
heroMotionQuery?.removeEventListener("change", syncHeroMotion);
|
||||
document.removeEventListener("visibilitychange", onHeroVisibilityChange);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -56,13 +122,13 @@ const devBranchNote = computed(() =>
|
|||
<v-container class="cyber-hero__container">
|
||||
<div class="cyber-hero__layout">
|
||||
<div class="cyber-hero__copy">
|
||||
<h1 class="cyber-hero__title">
|
||||
<span>Agent{{ " " }}</span>
|
||||
<h1 class="cyber-hero__title" aria-label="Agent Teams">
|
||||
<span>Agent</span>
|
||||
<span class="cyber-hero__title-accent">Teams</span>
|
||||
</h1>
|
||||
|
||||
<p class="cyber-hero__slogan cyber-panel">
|
||||
YOU'RE THE CTO, AGENTS ARE YOUR TEAM.
|
||||
Get a lot done by doing very little
|
||||
</p>
|
||||
|
||||
<p class="cyber-hero__description">
|
||||
|
|
@ -80,15 +146,6 @@ const devBranchNote = computed(() =>
|
|||
>
|
||||
{{ t("hero.downloadNow") }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
size="large"
|
||||
href="#hero-demo"
|
||||
class="cyber-hero__action cyber-hero__action--watch"
|
||||
:prepend-icon="mdiPlayCircleOutline"
|
||||
>
|
||||
{{ t("hero.watchDemo") }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
size="large"
|
||||
|
|
@ -100,26 +157,30 @@ const devBranchNote = computed(() =>
|
|||
</v-btn>
|
||||
</div>
|
||||
|
||||
<a
|
||||
<p
|
||||
v-if="releaseVersion"
|
||||
class="cyber-hero__terminal-note cyber-panel"
|
||||
:href="devBranchUrl"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<span class="cyber-hero__terminal-lines">
|
||||
<span>> {{ devBranchNote }}</span>
|
||||
<span>> Team ready. What shall we build today?</span>
|
||||
<span class="cyber-hero__release">
|
||||
v{{ releaseVersion }}
|
||||
</span>
|
||||
<span v-if="releaseVersion" class="cyber-hero__release">
|
||||
v{{ releaseVersion }}<template v-if="releaseDate"> - {{ releaseDate }}</template>
|
||||
</span>
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CyberHeroScene class="cyber-hero__scene" />
|
||||
<CyberHeroScene
|
||||
class="cyber-hero__scene"
|
||||
:message="activeHeroMessage"
|
||||
:phase="heroMessagePhase"
|
||||
:reduced-motion="heroReducedMotion"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CyberHeroFeatureStrip class="cyber-hero__feature-strip" />
|
||||
<CyberHeroFeatureStrip
|
||||
class="cyber-hero__feature-strip"
|
||||
:active-message="activeHeroMessage"
|
||||
:phase="heroMessagePhase"
|
||||
:reduced-motion="heroReducedMotion"
|
||||
/>
|
||||
</v-container>
|
||||
</section>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -399,7 +399,10 @@ function slideNext() {
|
|||
}
|
||||
|
||||
.screenshots-lightbox__nav {
|
||||
flex-shrink: 0;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
z-index: 2;
|
||||
transform: translateY(-50%);
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
|
|
@ -417,12 +420,20 @@ function slideNext() {
|
|||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.screenshots-lightbox__nav--prev {
|
||||
left: clamp(16px, 4vw, 48px);
|
||||
}
|
||||
|
||||
.screenshots-lightbox__nav--next {
|
||||
right: clamp(16px, 4vw, 48px);
|
||||
}
|
||||
|
||||
.screenshots-lightbox__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
max-width: 90vw;
|
||||
max-width: min(90vw, calc(100vw - 160px));
|
||||
max-height: 85vh;
|
||||
}
|
||||
|
||||
|
|
@ -517,6 +528,10 @@ function slideNext() {
|
|||
display: none;
|
||||
}
|
||||
|
||||
.screenshots-lightbox__content {
|
||||
max-width: 96vw;
|
||||
}
|
||||
|
||||
.screenshots-lightbox {
|
||||
padding: 60px 8px 20px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import { nextTick, ref, onMounted, onUnmounted } from 'vue';
|
||||
import { mdiPlay, mdiPause, mdiVolumeHigh, mdiVolumeOff, mdiFullscreen } from '@mdi/js';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
|
@ -9,20 +9,51 @@ const containerRef = ref<HTMLElement | null>(null);
|
|||
const isPlaying = ref(false);
|
||||
const isMuted = ref(true);
|
||||
const showControls = ref(true);
|
||||
const isLoaded = ref(false);
|
||||
const isLoaded = ref(true);
|
||||
const hasError = ref(false);
|
||||
const progress = ref(0);
|
||||
const loadProgress = ref(0);
|
||||
const hideTimer = ref<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
let intObserver: IntersectionObserver | null = null;
|
||||
let loadFallbackTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function clearLoadFallback() {
|
||||
if (!loadFallbackTimer) return;
|
||||
clearTimeout(loadFallbackTimer);
|
||||
loadFallbackTimer = null;
|
||||
}
|
||||
|
||||
function markLoaded() {
|
||||
if (hasError.value) return;
|
||||
isLoaded.value = true;
|
||||
clearLoadFallback();
|
||||
updateLoadProgress();
|
||||
}
|
||||
|
||||
function markError() {
|
||||
hasError.value = true;
|
||||
clearLoadFallback();
|
||||
}
|
||||
|
||||
function onVideoEnded() {
|
||||
const video = videoRef.value;
|
||||
isPlaying.value = false;
|
||||
showControls.value = true;
|
||||
progress.value = 0;
|
||||
if (video) video.currentTime = 0;
|
||||
}
|
||||
|
||||
function togglePlay() {
|
||||
const video = videoRef.value;
|
||||
if (!video) return;
|
||||
if (video.paused) {
|
||||
video.play();
|
||||
isPlaying.value = true;
|
||||
markLoaded();
|
||||
video.play()
|
||||
.then(() => {
|
||||
isPlaying.value = true;
|
||||
})
|
||||
.catch(markError);
|
||||
} else {
|
||||
video.pause();
|
||||
isPlaying.value = false;
|
||||
|
|
@ -93,19 +124,25 @@ function onMouseLeave() {
|
|||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
const video = videoRef.value;
|
||||
if (video) {
|
||||
// canplay fires earlier than loadeddata — enough to show first frame
|
||||
video.addEventListener('canplay', () => { isLoaded.value = true; }, { once: true });
|
||||
video.addEventListener('error', () => { hasError.value = true; });
|
||||
isMuted.value = video.muted;
|
||||
video.addEventListener('loadedmetadata', markLoaded, { once: true });
|
||||
video.addEventListener('loadeddata', markLoaded, { once: true });
|
||||
video.addEventListener('canplay', markLoaded, { once: true });
|
||||
video.addEventListener('canplaythrough', markLoaded, { once: true });
|
||||
video.addEventListener('error', markError);
|
||||
video.addEventListener('progress', updateLoadProgress);
|
||||
video.addEventListener('ended', () => {
|
||||
isPlaying.value = false;
|
||||
showControls.value = true;
|
||||
progress.value = 0;
|
||||
video.currentTime = 0;
|
||||
});
|
||||
video.addEventListener('ended', onVideoEnded);
|
||||
|
||||
if (video.readyState >= HTMLMediaElement.HAVE_METADATA) {
|
||||
markLoaded();
|
||||
} else {
|
||||
video.load();
|
||||
loadFallbackTimer = setTimeout(markLoaded, 1800);
|
||||
}
|
||||
}
|
||||
|
||||
intObserver = new IntersectionObserver(
|
||||
|
|
@ -122,8 +159,15 @@ onMounted(() => {
|
|||
|
||||
onUnmounted(() => {
|
||||
if (hideTimer.value) clearTimeout(hideTimer.value);
|
||||
clearLoadFallback();
|
||||
if (intObserver) { intObserver.disconnect(); intObserver = null; }
|
||||
videoRef.value?.removeEventListener('loadedmetadata', markLoaded);
|
||||
videoRef.value?.removeEventListener('loadeddata', markLoaded);
|
||||
videoRef.value?.removeEventListener('canplay', markLoaded);
|
||||
videoRef.value?.removeEventListener('canplaythrough', markLoaded);
|
||||
videoRef.value?.removeEventListener('error', markError);
|
||||
videoRef.value?.removeEventListener('progress', updateLoadProgress);
|
||||
videoRef.value?.removeEventListener('ended', onVideoEnded);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
@ -163,7 +207,7 @@ onUnmounted(() => {
|
|||
ref="videoRef"
|
||||
class="hero-video__player"
|
||||
:class="{ 'hero-video__player--loaded': isLoaded }"
|
||||
preload="auto"
|
||||
preload="metadata"
|
||||
poster="/screenshots/2.jpg"
|
||||
muted
|
||||
playsinline
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { computed, watch, onUnmounted } from "vue";
|
||||
import { computed, getCurrentInstance, onUnmounted, watch } from "vue";
|
||||
import type { Ref } from "vue";
|
||||
import { useThemeStore } from "~/stores/theme";
|
||||
|
||||
|
|
@ -54,11 +54,13 @@ export const useBrowserTheme = () => {
|
|||
applyTheme(themeStore.current === "dark" ? "light" : "dark");
|
||||
};
|
||||
|
||||
onUnmounted(() => {
|
||||
if (mediaQuery && mediaQueryHandler) {
|
||||
mediaQuery.removeEventListener("change", mediaQueryHandler);
|
||||
}
|
||||
});
|
||||
if (getCurrentInstance()) {
|
||||
onUnmounted(() => {
|
||||
if (mediaQuery && mediaQueryHandler) {
|
||||
mediaQuery.removeEventListener("change", mediaQueryHandler);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
watch(
|
||||
() => themeStore.current,
|
||||
|
|
|
|||
|
|
@ -1,25 +1,15 @@
|
|||
import type { Ref } from "vue";
|
||||
import { nextTick, onMounted, onUnmounted } from "vue";
|
||||
|
||||
type PointerState = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
export function useCyberHeroParallax(rootRef: Ref<HTMLElement | null>) {
|
||||
let rafId = 0;
|
||||
let bounds: DOMRect | null = null;
|
||||
let reduceMotion: MediaQueryList | null = null;
|
||||
let canHover: MediaQueryList | null = null;
|
||||
let observer: IntersectionObserver | null = null;
|
||||
let isVisible = true;
|
||||
|
||||
const pointer: PointerState = { x: 0, y: 0 };
|
||||
let scrollOffset = 0;
|
||||
|
||||
const shouldRun = () => {
|
||||
if (reduceMotion?.matches) return false;
|
||||
if (canHover && !canHover.matches) return false;
|
||||
return window.innerWidth >= 768 && isVisible;
|
||||
};
|
||||
|
||||
|
|
@ -28,20 +18,17 @@ export function useCyberHeroParallax(rootRef: Ref<HTMLElement | null>) {
|
|||
const root = rootRef.value;
|
||||
if (!root) return;
|
||||
|
||||
root.style.setProperty("--hero-pointer-x", "0");
|
||||
root.style.setProperty("--hero-pointer-y", "0");
|
||||
root.style.setProperty("--hero-tilt-x", "0");
|
||||
root.style.setProperty("--hero-tilt-y", "0");
|
||||
|
||||
if (!shouldRun()) {
|
||||
root.style.setProperty("--hero-pointer-x", "0");
|
||||
root.style.setProperty("--hero-pointer-y", "0");
|
||||
root.style.setProperty("--hero-scroll", "0");
|
||||
root.style.setProperty("--hero-tilt-x", "0");
|
||||
root.style.setProperty("--hero-tilt-y", "0");
|
||||
return;
|
||||
}
|
||||
|
||||
root.style.setProperty("--hero-pointer-x", pointer.x.toFixed(4));
|
||||
root.style.setProperty("--hero-pointer-y", pointer.y.toFixed(4));
|
||||
root.style.setProperty("--hero-scroll", scrollOffset.toFixed(2));
|
||||
root.style.setProperty("--hero-tilt-x", pointer.x.toFixed(4));
|
||||
root.style.setProperty("--hero-tilt-y", pointer.y.toFixed(4));
|
||||
};
|
||||
|
||||
const requestWrite = () => {
|
||||
|
|
@ -49,29 +36,6 @@ export function useCyberHeroParallax(rootRef: Ref<HTMLElement | null>) {
|
|||
rafId = requestAnimationFrame(writeVars);
|
||||
};
|
||||
|
||||
const updateBounds = () => {
|
||||
bounds = rootRef.value?.getBoundingClientRect() ?? null;
|
||||
};
|
||||
|
||||
const onPointerMove = (event: PointerEvent) => {
|
||||
if (!shouldRun()) return;
|
||||
if (!bounds) updateBounds();
|
||||
if (!bounds) return;
|
||||
|
||||
const nextX = ((event.clientX - bounds.left) / bounds.width) * 2 - 1;
|
||||
const nextY = ((event.clientY - bounds.top) / bounds.height) * 2 - 1;
|
||||
|
||||
pointer.x = Math.max(-1, Math.min(1, nextX));
|
||||
pointer.y = Math.max(-1, Math.min(1, nextY));
|
||||
requestWrite();
|
||||
};
|
||||
|
||||
const onPointerLeave = () => {
|
||||
pointer.x = 0;
|
||||
pointer.y = 0;
|
||||
requestWrite();
|
||||
};
|
||||
|
||||
const onScroll = () => {
|
||||
const root = rootRef.value;
|
||||
if (!root || !shouldRun()) return;
|
||||
|
|
@ -81,7 +45,6 @@ export function useCyberHeroParallax(rootRef: Ref<HTMLElement | null>) {
|
|||
};
|
||||
|
||||
const onResize = () => {
|
||||
updateBounds();
|
||||
requestWrite();
|
||||
};
|
||||
|
||||
|
|
@ -91,7 +54,6 @@ export function useCyberHeroParallax(rootRef: Ref<HTMLElement | null>) {
|
|||
if (!root) return;
|
||||
|
||||
reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
canHover = window.matchMedia("(hover: hover) and (pointer: fine)");
|
||||
observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
isVisible = entry.isIntersecting;
|
||||
|
|
@ -101,25 +63,17 @@ export function useCyberHeroParallax(rootRef: Ref<HTMLElement | null>) {
|
|||
);
|
||||
|
||||
observer.observe(root);
|
||||
updateBounds();
|
||||
root.addEventListener("pointermove", onPointerMove, { passive: true });
|
||||
root.addEventListener("pointerleave", onPointerLeave, { passive: true });
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
window.addEventListener("resize", onResize, { passive: true });
|
||||
reduceMotion.addEventListener("change", requestWrite);
|
||||
canHover.addEventListener("change", requestWrite);
|
||||
requestWrite();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
const root = rootRef.value;
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
observer?.disconnect();
|
||||
root?.removeEventListener("pointermove", onPointerMove);
|
||||
root?.removeEventListener("pointerleave", onPointerLeave);
|
||||
window.removeEventListener("scroll", onScroll);
|
||||
window.removeEventListener("resize", onResize);
|
||||
reduceMotion?.removeEventListener("change", requestWrite);
|
||||
canHover?.removeEventListener("change", requestWrite);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,6 @@
|
|||
import robotAmber from "~/assets/images/hero/robots/robot-amber-v1.webp";
|
||||
import robotCyan from "~/assets/images/hero/robots/robot-cyan-v1.webp";
|
||||
import robotMagenta from "~/assets/images/hero/robots/robot-magenta-v1.webp";
|
||||
|
||||
export const HERO_SCENE_VIEWBOX = {
|
||||
width: 1600,
|
||||
height: 900,
|
||||
} as const;
|
||||
import robotAvatarCyan from "~/assets/images/hero/robots/robot-avatar-cyan-v1.webp";
|
||||
import robotAvatarMagenta from "~/assets/images/hero/robots/robot-avatar-magenta-v1.webp";
|
||||
import robotAvatarSeatedMagenta from "~/assets/images/hero/robots/robot-avatar-seated-magenta-v1.webp";
|
||||
|
||||
export const HERO_SCENE_BREAKPOINTS = {
|
||||
desktop: 1200,
|
||||
|
|
@ -25,6 +20,7 @@ export type HeroAgentRole =
|
|||
| "fixer";
|
||||
|
||||
export type HeroAccent = "cyan" | "magenta" | "violet" | "amber" | "red";
|
||||
export type HeroMessagePhase = "sender" | "packet" | "receiver" | "cooldown";
|
||||
|
||||
export type HeroCardSide = "left" | "right" | "bottom";
|
||||
|
||||
|
|
@ -41,6 +37,8 @@ export type HeroAgent = {
|
|||
label: string;
|
||||
asset: string;
|
||||
accent: HeroAccent;
|
||||
facing?: -1 | 1;
|
||||
lean?: number;
|
||||
priority?: boolean;
|
||||
desktop: HeroAgentPosition;
|
||||
tablet: HeroAgentPosition;
|
||||
|
|
@ -53,21 +51,10 @@ export type HeroAgent = {
|
|||
tasks: string[];
|
||||
};
|
||||
|
||||
export type HeroConnection = {
|
||||
id: string;
|
||||
from: HeroAgentRole | "video";
|
||||
to: HeroAgentRole | "video";
|
||||
accent: Extract<HeroAccent, "cyan" | "magenta" | "amber">;
|
||||
pathDesktop: string;
|
||||
packetDelayMs: number;
|
||||
packetDurationMs: number;
|
||||
};
|
||||
|
||||
export type HeroMessage = {
|
||||
id: string;
|
||||
from: HeroAgentRole;
|
||||
to: HeroAgentRole | "video";
|
||||
connectionId: string;
|
||||
text: string;
|
||||
response: string;
|
||||
fromX: number;
|
||||
|
|
@ -80,11 +67,13 @@ export const heroAgents: readonly HeroAgent[] = [
|
|||
{
|
||||
id: "planner",
|
||||
label: "Planner",
|
||||
asset: robotCyan,
|
||||
accent: "cyan",
|
||||
asset: robotAvatarSeatedMagenta,
|
||||
accent: "magenta",
|
||||
facing: 1,
|
||||
lean: -2,
|
||||
priority: true,
|
||||
desktop: { x: 34, y: 12, scale: 0.66, depth: 0.35, card: "right" },
|
||||
tablet: { x: 18, y: 11, scale: 0.55, depth: 0.22, card: "bottom" },
|
||||
desktop: { x: 32.65, y: 19.45, scale: 0.44, depth: 0.35, card: "right" },
|
||||
tablet: { x: 20, y: 31, scale: 0.44, depth: 0.22, card: "bottom" },
|
||||
mobile: { visible: true, order: 1, compactLabel: "Plan" },
|
||||
status: "Planning",
|
||||
tasks: ["Analyze requirements", "Break down tasks", "Create plan"],
|
||||
|
|
@ -92,256 +81,112 @@ export const heroAgents: readonly HeroAgent[] = [
|
|||
{
|
||||
id: "lead",
|
||||
label: "Lead",
|
||||
asset: robotCyan,
|
||||
asset: robotAvatarCyan,
|
||||
accent: "cyan",
|
||||
facing: -1,
|
||||
lean: 2,
|
||||
priority: true,
|
||||
desktop: { x: 55, y: 9, scale: 0.62, depth: 0.32, card: "right" },
|
||||
tablet: { x: 50, y: 8, scale: 0.52, depth: 0.2, card: "bottom" },
|
||||
desktop: { x: 58.9, y: 32.76, scale: 0.48, depth: 0.32, card: "right" },
|
||||
tablet: { x: 50, y: 31, scale: 0.42, depth: 0.2, card: "bottom" },
|
||||
mobile: { visible: true, order: 2, compactLabel: "Lead" },
|
||||
status: "Leading",
|
||||
tasks: ["Define architecture", "Set priorities", "Coordinate team"],
|
||||
},
|
||||
{
|
||||
id: "reviewer",
|
||||
label: "Reviewer",
|
||||
asset: robotMagenta,
|
||||
accent: "magenta",
|
||||
priority: true,
|
||||
desktop: { x: 75, y: 13, scale: 0.58, depth: 0.34, card: "left" },
|
||||
tablet: { x: 82, y: 12, scale: 0.48, depth: 0.22, card: "bottom" },
|
||||
mobile: { visible: true, order: 3, compactLabel: "Review" },
|
||||
status: "Reviewing",
|
||||
tasks: ["Review code", "Check quality", "Request changes"],
|
||||
},
|
||||
{
|
||||
id: "researcher",
|
||||
label: "Researcher",
|
||||
asset: robotCyan,
|
||||
accent: "violet",
|
||||
desktop: { x: 27, y: 39, scale: 0.48, depth: 0.45, card: "right" },
|
||||
tablet: { x: 16, y: 45, scale: 0.44, depth: 0.25, card: "bottom" },
|
||||
mobile: { visible: false },
|
||||
status: "Researching",
|
||||
tasks: ["Research options", "Compare solutions", "Summarize findings"],
|
||||
},
|
||||
{
|
||||
id: "developer",
|
||||
label: "Developer",
|
||||
asset: robotCyan,
|
||||
accent: "cyan",
|
||||
desktop: { x: 74, y: 34, scale: 0.5, depth: 0.52, card: "left" },
|
||||
tablet: { x: 88, y: 44, scale: 0.42, depth: 0.26, card: "bottom" },
|
||||
mobile: { visible: false },
|
||||
status: "Coding",
|
||||
tasks: ["Write code", "Implement feature", "Commit changes"],
|
||||
},
|
||||
{
|
||||
id: "tester",
|
||||
label: "Tester",
|
||||
asset: robotMagenta,
|
||||
asset: robotAvatarMagenta,
|
||||
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"],
|
||||
facing: 1,
|
||||
lean: -1,
|
||||
priority: true,
|
||||
desktop: { x: 72, y: 32.4, scale: 0.48, depth: 0.34, card: "right" },
|
||||
tablet: { x: 80, y: 31, scale: 0.4, depth: 0.22, card: "bottom" },
|
||||
mobile: { visible: true, order: 3, compactLabel: "Code" },
|
||||
status: "Coding",
|
||||
tasks: ["Implement feature", "Update code", "Run checks"],
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const heroConnections: readonly HeroConnection[] = [
|
||||
{
|
||||
id: "planner-lead",
|
||||
from: "planner",
|
||||
to: "lead",
|
||||
accent: "cyan",
|
||||
pathDesktop: "M 545 195 C 680 210, 735 185, 860 190",
|
||||
packetDelayMs: 0,
|
||||
packetDurationMs: 4200,
|
||||
},
|
||||
{
|
||||
id: "lead-reviewer",
|
||||
from: "lead",
|
||||
to: "reviewer",
|
||||
accent: "magenta",
|
||||
pathDesktop: "M 950 205 C 1050 185, 1130 190, 1265 220",
|
||||
packetDelayMs: 700,
|
||||
packetDurationMs: 3900,
|
||||
},
|
||||
{
|
||||
id: "developer-reviewer",
|
||||
from: "developer",
|
||||
to: "reviewer",
|
||||
accent: "magenta",
|
||||
pathDesktop: "M 1390 370 C 1325 320, 1305 270, 1260 230",
|
||||
packetDelayMs: 500,
|
||||
packetDurationMs: 3400,
|
||||
},
|
||||
{
|
||||
id: "researcher-video",
|
||||
from: "researcher",
|
||||
to: "video",
|
||||
accent: "cyan",
|
||||
pathDesktop: "M 520 425 C 625 410, 680 405, 755 420",
|
||||
packetDelayMs: 1100,
|
||||
packetDurationMs: 4400,
|
||||
},
|
||||
{
|
||||
id: "video-tester",
|
||||
from: "video",
|
||||
to: "tester",
|
||||
accent: "magenta",
|
||||
pathDesktop: "M 1290 540 C 1365 555, 1410 575, 1480 615",
|
||||
packetDelayMs: 1300,
|
||||
packetDurationMs: 4100,
|
||||
},
|
||||
{
|
||||
id: "tester-lead",
|
||||
from: "tester",
|
||||
to: "lead",
|
||||
accent: "cyan",
|
||||
pathDesktop: "M 1450 625 C 1365 650, 1170 642, 1030 630 C 940 620, 880 585, 850 515",
|
||||
packetDelayMs: 1800,
|
||||
packetDurationMs: 5200,
|
||||
},
|
||||
{
|
||||
id: "ops-security",
|
||||
from: "ops",
|
||||
to: "security",
|
||||
accent: "amber",
|
||||
pathDesktop: "M 745 740 C 835 725, 910 725, 1000 742",
|
||||
packetDelayMs: 2200,
|
||||
packetDurationMs: 4600,
|
||||
},
|
||||
{
|
||||
id: "security-fixer",
|
||||
from: "security",
|
||||
to: "fixer",
|
||||
accent: "amber",
|
||||
pathDesktop: "M 1100 745 C 1185 725, 1270 730, 1375 755",
|
||||
packetDelayMs: 2600,
|
||||
packetDurationMs: 4600,
|
||||
},
|
||||
] as const;
|
||||
export const heroReviewerFeatureCard = {
|
||||
label: "Reviewer",
|
||||
asset: robotAvatarMagenta,
|
||||
accent: "magenta",
|
||||
status: "Reviewing",
|
||||
tasks: ["Review code", "Check quality", "Request changes"],
|
||||
} as const;
|
||||
|
||||
export const heroMessages: readonly HeroMessage[] = [
|
||||
{
|
||||
id: "code-review",
|
||||
id: "plan-ready",
|
||||
from: "planner",
|
||||
to: "lead",
|
||||
text: "Plan ready.",
|
||||
response: "Priority set.",
|
||||
fromX: 29.2,
|
||||
fromY: 13,
|
||||
toX: 58.8,
|
||||
toY: 8.6,
|
||||
},
|
||||
{
|
||||
id: "build-ready",
|
||||
from: "lead",
|
||||
to: "developer",
|
||||
text: "Build scope set.",
|
||||
response: "Coding started.",
|
||||
fromX: 58.8,
|
||||
fromY: 8.6,
|
||||
toX: 72,
|
||||
toY: 7,
|
||||
},
|
||||
{
|
||||
id: "review-build",
|
||||
from: "developer",
|
||||
to: "reviewer",
|
||||
connectionId: "developer-reviewer",
|
||||
text: "Code ready. Request review.",
|
||||
response: "Review started.",
|
||||
fromX: 78,
|
||||
fromY: 43,
|
||||
toX: 73,
|
||||
toY: 20,
|
||||
text: "Review build.",
|
||||
response: "Checking quality.",
|
||||
fromX: 72,
|
||||
fromY: 7,
|
||||
toX: 84,
|
||||
toY: 82,
|
||||
},
|
||||
{
|
||||
id: "tests-passed",
|
||||
from: "tester",
|
||||
to: "lead",
|
||||
connectionId: "tester-lead",
|
||||
text: "Tests passed. Looks good.",
|
||||
response: "Ship it.",
|
||||
fromX: 78,
|
||||
fromY: 62,
|
||||
toX: 58,
|
||||
toY: 21,
|
||||
},
|
||||
{
|
||||
id: "research-ready",
|
||||
from: "researcher",
|
||||
to: "video",
|
||||
connectionId: "researcher-video",
|
||||
text: "Findings ready.",
|
||||
response: "Plan updated.",
|
||||
fromX: 32,
|
||||
fromY: 45,
|
||||
toX: 50,
|
||||
toY: 53,
|
||||
},
|
||||
{
|
||||
id: "ops-secure",
|
||||
from: "ops",
|
||||
to: "security",
|
||||
connectionId: "ops-security",
|
||||
text: "Deployed to staging.",
|
||||
response: "Dependencies checked.",
|
||||
fromX: 44,
|
||||
fromY: 72,
|
||||
toX: 62,
|
||||
toY: 74,
|
||||
id: "review-pass",
|
||||
from: "reviewer",
|
||||
to: "developer",
|
||||
text: "Review passed.",
|
||||
response: "Ready to ship.",
|
||||
fromX: 84,
|
||||
fromY: 82,
|
||||
toX: 72,
|
||||
toY: 7,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const heroFeatureRail = [
|
||||
{
|
||||
id: "autonomous",
|
||||
title: "Autonomous Team",
|
||||
text: "Specialized agents coordinate work together.",
|
||||
title: "Give the Team a Goal",
|
||||
text: "Agents break it into tasks and start moving without babysitting.",
|
||||
},
|
||||
{
|
||||
id: "kanban",
|
||||
title: "Kanban at Lightspeed",
|
||||
text: "Tasks move as agents build, review, and test.",
|
||||
title: "Kanban That Updates Itself",
|
||||
text: "Cards shift as agents build, test, review, and unblock each other.",
|
||||
},
|
||||
{
|
||||
id: "developers",
|
||||
title: "Built for Developers",
|
||||
text: "Open source, extensible, and API-first.",
|
||||
title: "Bring Your AI Stack",
|
||||
text: "Claude, Codex, and OpenCode teammates in one desktop cockpit.",
|
||||
},
|
||||
{
|
||||
id: "secure",
|
||||
title: "Secure by Default",
|
||||
text: "Your code and data stay protected.",
|
||||
title: "Stay in the Loop",
|
||||
text: "Jump in with comments, approvals, direct messages, or quick actions.",
|
||||
},
|
||||
{
|
||||
id: "local",
|
||||
title: "Local First",
|
||||
text: "Runs on your machine. Your data stays yours.",
|
||||
title: "Your Machine, Your Code",
|
||||
text: "Local-first workflow with task logs, process control, and Git visibility.",
|
||||
},
|
||||
] as const;
|
||||
|
|
|
|||