feat(landing): refresh hero video and provider support
This commit is contained in:
parent
635d13d804
commit
824f420cf5
23 changed files with 1101 additions and 687 deletions
|
|
@ -18,3 +18,5 @@ pnpm preview
|
|||
- Static-first (SSG) by design.
|
||||
- Locale auto-detection: cookie -> browser settings -> fallback `en`.
|
||||
- Theme auto-detection: localStorage -> system preference -> fallback `light`.
|
||||
- Hero video uses the Mux Player embed. Set `NUXT_PUBLIC_MUX_PLAYBACK_ID` to override the default playback id without changing the code.
|
||||
- Hero background can use a separate Mux asset via `NUXT_PUBLIC_MUX_BACKGROUND_PLAYBACK_ID`; otherwise it reuses `NUXT_PUBLIC_MUX_PLAYBACK_ID`.
|
||||
|
|
|
|||
|
|
@ -34,8 +34,13 @@
|
|||
--cyber-monterey-after-bg:
|
||||
linear-gradient(180deg, rgba(2, 5, 13, 0.92) 0%, rgba(2, 5, 13, 0.62) 15%, rgba(2, 5, 13, 0.08) 44%, rgba(2, 5, 13, 0.68) 100%),
|
||||
radial-gradient(circle at 64% 42%, transparent 0 26%, rgba(2, 5, 13, 0.24) 70%, rgba(2, 5, 13, 0.54) 100%);
|
||||
--cyber-monterey-canvas-opacity: 0.42;
|
||||
--cyber-monterey-canvas-filter: blur(4px) saturate(0.78) brightness(0.62) contrast(1.08);
|
||||
--cyber-monterey-video-opacity: 1;
|
||||
--cyber-monterey-video-filter: blur(0.8px) saturate(1.12) contrast(1.04);
|
||||
--cyber-monterey-video-blend: normal;
|
||||
--cyber-monterey-video-position: 74% 50%;
|
||||
--cyber-monterey-video-scale: 1.16;
|
||||
--cyber-monterey-canvas-opacity: 0.36;
|
||||
--cyber-monterey-canvas-filter: blur(0.5px) saturate(0.92) brightness(0.78) contrast(1.08);
|
||||
--cyber-monterey-canvas-blend: normal;
|
||||
--cyber-background-bg:
|
||||
radial-gradient(circle at 72% 28%, rgba(0, 234, 255, 0.1), transparent 30%),
|
||||
|
|
@ -116,113 +121,6 @@
|
|||
--cyber-frame-cut: 18px;
|
||||
}
|
||||
|
||||
.v-theme--light .cyber-hero {
|
||||
--cyber-bg-0: #f8fcff;
|
||||
--cyber-bg-1: #eaf7fb;
|
||||
--cyber-panel-weak: rgba(255, 255, 255, 0.68);
|
||||
--cyber-panel: rgba(255, 255, 255, 0.78);
|
||||
--cyber-panel-strong: rgba(255, 255, 255, 0.92);
|
||||
--cyber-cyan: #008fb3;
|
||||
--cyber-blue: #2563eb;
|
||||
--cyber-magenta: #b832d8;
|
||||
--cyber-violet: #7c3aed;
|
||||
--cyber-text: #132238;
|
||||
--cyber-muted: #596b83;
|
||||
--cyber-border-cyan: rgba(8, 145, 178, 0.34);
|
||||
--cyber-border-magenta: rgba(184, 50, 216, 0.28);
|
||||
--cyber-panel-bg:
|
||||
linear-gradient(135deg, rgba(255, 255, 255, 0.9), rgba(237, 249, 252, 0.68));
|
||||
--cyber-panel-shadow:
|
||||
0 0 0 1px rgba(8, 145, 178, 0.1) inset,
|
||||
0 18px 48px rgba(8, 145, 178, 0.12),
|
||||
0 0 22px rgba(184, 50, 216, 0.07);
|
||||
--cyber-hero-bg:
|
||||
radial-gradient(circle at 76% 30%, rgba(0, 178, 214, 0.14), transparent 32%),
|
||||
radial-gradient(circle at 86% 70%, rgba(184, 50, 216, 0.13), transparent 36%),
|
||||
linear-gradient(180deg, #fbfdff 0%, #edf8fb 56%, #f8fcff 100%);
|
||||
--cyber-monterey-bg:
|
||||
radial-gradient(circle at 78% 24%, rgba(113, 185, 255, 0.28), transparent 31%),
|
||||
radial-gradient(circle at 22% 72%, rgba(221, 170, 255, 0.24), transparent 38%),
|
||||
radial-gradient(circle at 8% 32%, rgba(101, 218, 255, 0.18), transparent 34%),
|
||||
linear-gradient(180deg, #f8fcff 0%, #eaf7fb 48%, #fbf7ff 100%);
|
||||
--cyber-monterey-before-bg:
|
||||
radial-gradient(circle at 18% 34%, rgba(255, 255, 255, 0.46), rgba(255, 255, 255, 0.14) 34%, transparent 62%),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.22) 0%, rgba(255, 255, 255, 0.08) 42%, rgba(255, 255, 255, 0.03) 64%, rgba(237, 247, 252, 0.2) 100%);
|
||||
--cyber-monterey-after-bg:
|
||||
linear-gradient(180deg, rgba(248, 252, 255, 0.52) 0%, rgba(248, 252, 255, 0.22) 18%, rgba(248, 252, 255, 0.04) 48%, rgba(248, 252, 255, 0.58) 100%),
|
||||
radial-gradient(circle at 64% 42%, transparent 0 26%, rgba(255, 255, 255, 0.16) 70%, rgba(235, 247, 252, 0.42) 100%);
|
||||
--cyber-monterey-canvas-opacity: 1;
|
||||
--cyber-monterey-canvas-filter: blur(1px) saturate(1.34) brightness(1.12) contrast(1.16);
|
||||
--cyber-monterey-canvas-blend: multiply;
|
||||
--cyber-background-bg:
|
||||
radial-gradient(circle at 72% 28%, rgba(0, 178, 214, 0.12), transparent 31%),
|
||||
radial-gradient(circle at 88% 62%, rgba(184, 50, 216, 0.1), transparent 33%),
|
||||
linear-gradient(90deg, transparent 0 64px, rgba(8, 145, 178, 0.06) 65px 66px, transparent 67px 160px),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.5) 0%, rgba(255, 255, 255, 0.08) 38%, rgba(237, 247, 252, 0.64) 100%);
|
||||
--cyber-background-opacity: 0.5;
|
||||
--cyber-background-before-bg:
|
||||
linear-gradient(90deg, transparent 0 8%, rgba(8, 145, 178, 0.16) 8.1% 8.22%, transparent 8.34% 18%, rgba(184, 50, 216, 0.12) 18.1% 18.22%, transparent 18.34% 31%, rgba(8, 145, 178, 0.13) 31.1% 31.24%, transparent 31.36% 44%, rgba(37, 99, 235, 0.12) 44.1% 44.2%, transparent 44.34% 62%, rgba(184, 50, 216, 0.1) 62.1% 62.22%, transparent 62.34% 78%, rgba(8, 145, 178, 0.12) 78.1% 78.22%, transparent 78.34%),
|
||||
repeating-linear-gradient(90deg, transparent 0 78px, rgba(8, 145, 178, 0.06) 80px 82px, transparent 84px 116px),
|
||||
linear-gradient(to top, rgba(8, 145, 178, 0.12) 0%, rgba(8, 145, 178, 0.07) 13%, transparent 31%),
|
||||
linear-gradient(to top, rgba(184, 50, 216, 0.1) 0%, rgba(184, 50, 216, 0.05) 16%, transparent 38%),
|
||||
linear-gradient(to top, rgba(37, 99, 235, 0.09) 0%, rgba(37, 99, 235, 0.04) 20%, transparent 48%);
|
||||
--cyber-background-before-opacity: 0.58;
|
||||
--cyber-background-before-blend: multiply;
|
||||
--cyber-background-after-bg:
|
||||
repeating-linear-gradient(90deg, transparent 0 34px, rgba(8, 145, 178, 0.1) 35px 36px, transparent 37px 110px),
|
||||
repeating-linear-gradient(180deg, transparent 0 28px, rgba(184, 50, 216, 0.08) 29px 30px, transparent 31px 78px),
|
||||
linear-gradient(90deg, transparent, rgba(8, 145, 178, 0.07), transparent);
|
||||
--cyber-background-after-opacity: 0.18;
|
||||
--cyber-background-after-blend: multiply;
|
||||
--cyber-wash-bg:
|
||||
radial-gradient(circle at 18% 44%, rgba(255, 255, 255, 0.28), rgba(255, 255, 255, 0.12) 36%, transparent 60%),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.18) 0%, rgba(237, 248, 252, 0.1) 36%, rgba(255, 255, 255, 0.03) 68%, rgba(251, 247, 255, 0.16) 100%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(248, 252, 255, 0.04) 58%, rgba(248, 252, 255, 0.72));
|
||||
--cyber-gridlines-bg:
|
||||
linear-gradient(rgba(8, 145, 178, 0.055) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(8, 145, 178, 0.045) 1px, transparent 1px);
|
||||
--cyber-gridlines-opacity: 0.2;
|
||||
--cyber-scanlines-bg: repeating-linear-gradient(
|
||||
to bottom,
|
||||
rgba(8, 35, 50, 0.035) 0,
|
||||
rgba(8, 35, 50, 0.035) 1px,
|
||||
transparent 1px,
|
||||
transparent 4px
|
||||
);
|
||||
--cyber-scanlines-opacity: 0.08;
|
||||
--cyber-copy-aura: radial-gradient(circle at 28% 38%, rgba(255, 255, 255, 0.92), rgba(255, 255, 255, 0.38) 58%, transparent 78%);
|
||||
--cyber-title-color: rgba(19, 34, 56, 0.98);
|
||||
--cyber-description-color: rgba(50, 65, 88, 0.82);
|
||||
--cyber-action-primary-color: #061722;
|
||||
--cyber-action-secondary-bg: rgba(255, 255, 255, 0.62);
|
||||
--cyber-action-secondary-hover-bg: rgba(8, 145, 178, 0.08);
|
||||
--cyber-release-color: rgba(50, 65, 88, 0.68);
|
||||
--cyber-scene-floor-bg:
|
||||
radial-gradient(ellipse at 58% 84%, rgba(184, 50, 216, 0.16), transparent 18%),
|
||||
radial-gradient(ellipse at 56% 84%, rgba(8, 145, 178, 0.14), transparent 32%),
|
||||
repeating-radial-gradient(ellipse at 58% 84%, rgba(8, 145, 178, 0.08) 0 1px, transparent 1px 20px);
|
||||
--cyber-scene-floor-opacity: 0.38;
|
||||
--cyber-scene-foreground-bg:
|
||||
linear-gradient(90deg, transparent 0 4%, rgba(8, 145, 178, 0.07) 4.1%, transparent 4.4%),
|
||||
linear-gradient(180deg, transparent 0 88%, rgba(184, 50, 216, 0.06));
|
||||
--cyber-scene-foreground-opacity: 0.48;
|
||||
--cyber-video-frame-bg: rgba(255, 255, 255, 0.7);
|
||||
--cyber-video-content-bg: rgba(8, 20, 34, 0.9);
|
||||
--cyber-card-text: rgba(19, 34, 56, 0.88);
|
||||
--cyber-card-muted: rgba(67, 82, 105, 0.76);
|
||||
--cyber-card-subtle: rgba(67, 82, 105, 0.64);
|
||||
--cyber-card-inset: rgba(255, 255, 255, 0.62);
|
||||
--cyber-feature-shell-bg: transparent;
|
||||
--cyber-feature-rail-bg: transparent;
|
||||
--cyber-feature-rail-shadow:
|
||||
0 0 0 1px rgba(8, 145, 178, 0.12) inset,
|
||||
0 -1px 0 rgba(8, 145, 178, 0.18),
|
||||
0 1px 0 rgba(8, 145, 178, 0.16);
|
||||
--cyber-feature-divider: rgba(8, 145, 178, 0.18);
|
||||
--cyber-feature-title: rgba(19, 34, 56, 0.94);
|
||||
--cyber-feature-text: rgba(67, 82, 105, 0.72);
|
||||
}
|
||||
|
||||
.cyber-panel {
|
||||
position: relative;
|
||||
border: 1px solid var(--cyber-border-cyan);
|
||||
|
|
@ -294,6 +192,65 @@
|
|||
background: var(--cyber-monterey-after-bg);
|
||||
}
|
||||
|
||||
.cyber-hero__background,
|
||||
.cyber-hero__wash,
|
||||
.cyber-hero__monterey::before,
|
||||
.cyber-hero__monterey::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cyber-hero__monterey-video {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 100%;
|
||||
min-height: 100%;
|
||||
overflow: hidden;
|
||||
opacity: var(--cyber-monterey-video-opacity);
|
||||
filter: var(--cyber-monterey-video-filter);
|
||||
mix-blend-mode: var(--cyber-monterey-video-blend);
|
||||
background:
|
||||
var(--cyber-monterey-video-poster) var(--cyber-monterey-video-position) / cover no-repeat,
|
||||
var(--cyber-monterey-bg);
|
||||
transform: scale(var(--cyber-monterey-video-scale));
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
.cyber-hero__monterey-video::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cyber-hero__monterey-video-player {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 100%;
|
||||
min-height: 100%;
|
||||
opacity: 0;
|
||||
background: transparent;
|
||||
object-fit: cover;
|
||||
object-position: var(--cyber-monterey-video-position);
|
||||
--media-object-fit: cover;
|
||||
--media-object-position: var(--cyber-monterey-video-position);
|
||||
transition: opacity 0.45s ease;
|
||||
}
|
||||
|
||||
.cyber-hero__monterey-video-player::part(video) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: var(--cyber-monterey-video-position);
|
||||
}
|
||||
|
||||
.cyber-hero__monterey-video-player--ready {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.cyber-hero__monterey-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
|
@ -490,12 +447,72 @@
|
|||
|
||||
.cyber-hero__description {
|
||||
max-width: 560px;
|
||||
margin: 0 0 30px;
|
||||
margin: 0 0 20px;
|
||||
color: var(--cyber-description-color);
|
||||
font-size: clamp(1rem, 1.08vw, 1.22rem);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.cyber-hero__providers {
|
||||
width: min(680px, 100%);
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
|
||||
.cyber-hero__provider-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 14px 26px;
|
||||
}
|
||||
|
||||
.cyber-hero__provider {
|
||||
--provider-accent: var(--cyber-cyan);
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
gap: 9px;
|
||||
color: var(--cyber-text);
|
||||
text-shadow:
|
||||
0 0 18px color-mix(in srgb, var(--provider-accent) 26%, transparent),
|
||||
0 0 1px rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.cyber-hero__provider--cyan {
|
||||
--provider-accent: var(--cyber-cyan);
|
||||
}
|
||||
|
||||
.cyber-hero__provider--amber {
|
||||
--provider-accent: #ffcf5a;
|
||||
}
|
||||
|
||||
.cyber-hero__provider--magenta {
|
||||
--provider-accent: var(--cyber-magenta);
|
||||
}
|
||||
|
||||
.cyber-hero__provider-icon {
|
||||
display: grid;
|
||||
flex: 0 0 auto;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
place-items: center;
|
||||
color: var(--provider-accent);
|
||||
filter: drop-shadow(0 0 10px color-mix(in srgb, var(--provider-accent) 44%, transparent));
|
||||
}
|
||||
|
||||
.cyber-hero__provider-icon svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.cyber-hero__provider-name {
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--cyber-text);
|
||||
font-size: 0.98rem;
|
||||
font-weight: 900;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.cyber-hero__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
|
@ -1630,6 +1647,32 @@
|
|||
line-height: 1.62;
|
||||
}
|
||||
|
||||
.cyber-hero__providers {
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.cyber-hero__provider-list {
|
||||
gap: 12px 18px;
|
||||
}
|
||||
|
||||
.cyber-hero__provider {
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.cyber-hero__provider-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.cyber-hero__provider-icon svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.cyber-hero__provider-name {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.cyber-hero__actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ const onToggle = () => {
|
|||
:icon="mdiWeatherSunny"
|
||||
variant="text"
|
||||
size="small"
|
||||
aria-label="Toggle theme"
|
||||
:aria-label="t('theme.light')"
|
||||
/>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ import {
|
|||
} from "@mdi/js";
|
||||
import {
|
||||
heroCollaborationFeature,
|
||||
heroFeatureRail,
|
||||
heroReviewerFeatureCard,
|
||||
getLocalizedHeroFeatureRail,
|
||||
getLocalizedHeroReviewerFeatureCard,
|
||||
type HeroMessage,
|
||||
type HeroMessagePhase,
|
||||
} from "~/data/heroScene";
|
||||
|
|
@ -20,6 +20,11 @@ const props = defineProps<{
|
|||
reducedMotion?: boolean;
|
||||
}>();
|
||||
|
||||
const { locale } = useI18n();
|
||||
const localizedHeroFeatureRail = computed(() => getLocalizedHeroFeatureRail(locale.value));
|
||||
const localizedHeroReviewerFeatureCard = computed(() => getLocalizedHeroReviewerFeatureCard(locale.value));
|
||||
const statusLabel = computed(() => locale.value === "ru" ? "Статус:" : "Status:");
|
||||
|
||||
const icons = [
|
||||
mdiRobotOutline,
|
||||
mdiViewDashboardOutline,
|
||||
|
|
@ -76,18 +81,18 @@ const reviewerBubbleText = computed(() => {
|
|||
</RobotSpeechBubble>
|
||||
</Transition>
|
||||
<div class="cyber-feature-rail__reviewer-card cyber-panel">
|
||||
<div class="cyber-feature-rail__reviewer-label">{{ heroReviewerFeatureCard.label }}</div>
|
||||
<div class="cyber-feature-rail__reviewer-label">{{ localizedHeroReviewerFeatureCard.label }}</div>
|
||||
<ul class="cyber-feature-rail__reviewer-tasks">
|
||||
<li v-for="task in heroReviewerFeatureCard.tasks" :key="task">{{ task }}</li>
|
||||
<li v-for="task in localizedHeroReviewerFeatureCard.tasks" :key="task">{{ task }}</li>
|
||||
</ul>
|
||||
<div class="cyber-feature-rail__reviewer-status">
|
||||
<span>Status:</span>
|
||||
<strong>{{ heroReviewerFeatureCard.status }}</strong>
|
||||
<span>{{ statusLabel }}</span>
|
||||
<strong>{{ localizedHeroReviewerFeatureCard.status }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<img
|
||||
class="cyber-feature-rail__robot"
|
||||
:src="heroReviewerFeatureCard.asset"
|
||||
:src="localizedHeroReviewerFeatureCard.asset"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
|
|
@ -95,7 +100,7 @@ const reviewerBubbleText = computed(() => {
|
|||
</div>
|
||||
<div class="cyber-feature-rail cyber-panel">
|
||||
<div
|
||||
v-for="(feature, index) in heroFeatureRail"
|
||||
v-for="(feature, index) in localizedHeroFeatureRail"
|
||||
:key="feature.id"
|
||||
class="cyber-feature-rail__item"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,22 @@ import type { NeatConfig, NeatController } from "@firecms/neat";
|
|||
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||
const isLive = ref(false);
|
||||
const shouldMountBackgroundVideo = ref(false);
|
||||
const isBackgroundVideoReady = ref(false);
|
||||
const hasBackgroundVideoError = ref(false);
|
||||
const config = useRuntimeConfig();
|
||||
const backgroundPlaybackId = computed(() => (
|
||||
String(config.public.muxBackgroundPlaybackId || config.public.muxPlaybackId || "").trim()
|
||||
));
|
||||
const backgroundPosterUrl = computed(() => {
|
||||
if (!backgroundPlaybackId.value) return "";
|
||||
|
||||
const url = new URL(`https://image.mux.com/${encodeURIComponent(backgroundPlaybackId.value)}/thumbnail.jpg`);
|
||||
url.searchParams.set("time", "0.1");
|
||||
url.searchParams.set("width", "1600");
|
||||
url.searchParams.set("fit_mode", "preserve");
|
||||
return url.toString();
|
||||
});
|
||||
|
||||
let gradient: NeatController | null = null;
|
||||
let heroObserver: IntersectionObserver | null = null;
|
||||
|
|
@ -12,6 +28,8 @@ let isVisible = false;
|
|||
let isInitializing = false;
|
||||
let initToken = 0;
|
||||
let revealTimer: number | null = null;
|
||||
let backgroundVideoTimer: number | null = null;
|
||||
let backgroundVideoIdleId: number | null = null;
|
||||
|
||||
const montereyConfig: NeatConfig = {
|
||||
colors: [
|
||||
|
|
@ -35,7 +53,7 @@ const montereyConfig: NeatConfig = {
|
|||
wireframe: false,
|
||||
colorBlending: 9,
|
||||
backgroundColor: "#030012",
|
||||
backgroundAlpha: 1,
|
||||
backgroundAlpha: 0,
|
||||
grainScale: 6,
|
||||
grainSparsity: 0,
|
||||
grainIntensity: 0.1,
|
||||
|
|
@ -137,28 +155,120 @@ function syncGradient() {
|
|||
destroyGradient();
|
||||
}
|
||||
|
||||
function clearBackgroundVideoSchedule() {
|
||||
if (backgroundVideoTimer !== null) {
|
||||
window.clearTimeout(backgroundVideoTimer);
|
||||
backgroundVideoTimer = null;
|
||||
}
|
||||
|
||||
if (backgroundVideoIdleId !== null) {
|
||||
const idleWindow = window as Window & { cancelIdleCallback?: (handle: number) => void };
|
||||
idleWindow.cancelIdleCallback?.(backgroundVideoIdleId);
|
||||
backgroundVideoIdleId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function shouldUseBackgroundVideo() {
|
||||
return Boolean(
|
||||
backgroundPlaybackId.value &&
|
||||
isVisible &&
|
||||
!motionQuery?.matches &&
|
||||
!hasBackgroundVideoError.value,
|
||||
);
|
||||
}
|
||||
|
||||
async function mountBackgroundVideo() {
|
||||
clearBackgroundVideoSchedule();
|
||||
if (shouldMountBackgroundVideo.value || !shouldUseBackgroundVideo()) return;
|
||||
|
||||
try {
|
||||
await import("@mux/mux-video");
|
||||
if (shouldUseBackgroundVideo()) {
|
||||
shouldMountBackgroundVideo.value = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Mux hero background video is unavailable", error);
|
||||
hasBackgroundVideoError.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleBackgroundVideo() {
|
||||
clearBackgroundVideoSchedule();
|
||||
if (shouldMountBackgroundVideo.value || !shouldUseBackgroundVideo()) return;
|
||||
|
||||
const idleWindow = window as Window & {
|
||||
requestIdleCallback?: (callback: () => void, options?: { timeout: number }) => number;
|
||||
};
|
||||
|
||||
if (idleWindow.requestIdleCallback) {
|
||||
backgroundVideoIdleId = idleWindow.requestIdleCallback(() => {
|
||||
backgroundVideoIdleId = null;
|
||||
void mountBackgroundVideo();
|
||||
}, { timeout: 1600 });
|
||||
return;
|
||||
}
|
||||
|
||||
backgroundVideoTimer = window.setTimeout(() => {
|
||||
backgroundVideoTimer = null;
|
||||
void mountBackgroundVideo();
|
||||
}, 450);
|
||||
}
|
||||
|
||||
function stopBackgroundVideo() {
|
||||
clearBackgroundVideoSchedule();
|
||||
shouldMountBackgroundVideo.value = false;
|
||||
isBackgroundVideoReady.value = false;
|
||||
}
|
||||
|
||||
function syncBackgroundVideo() {
|
||||
if (shouldUseBackgroundVideo()) {
|
||||
scheduleBackgroundVideo();
|
||||
return;
|
||||
}
|
||||
|
||||
stopBackgroundVideo();
|
||||
}
|
||||
|
||||
function markBackgroundVideoReady() {
|
||||
if (!shouldUseBackgroundVideo()) return;
|
||||
isBackgroundVideoReady.value = true;
|
||||
}
|
||||
|
||||
function markBackgroundVideoError() {
|
||||
hasBackgroundVideoError.value = true;
|
||||
stopBackgroundVideo();
|
||||
}
|
||||
|
||||
function syncMotionState() {
|
||||
syncGradient();
|
||||
syncBackgroundVideo();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
mobileQuery = window.matchMedia("(max-width: 700px)");
|
||||
motionQuery.addEventListener("change", syncGradient);
|
||||
motionQuery.addEventListener("change", syncMotionState);
|
||||
mobileQuery.addEventListener("change", syncGradient);
|
||||
|
||||
heroObserver = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
isVisible = Boolean(entry?.isIntersecting);
|
||||
syncGradient();
|
||||
syncBackgroundVideo();
|
||||
},
|
||||
{ rootMargin: "160px 0px", threshold: 0.01 },
|
||||
);
|
||||
|
||||
const target = canvasRef.value?.closest(".cyber-hero");
|
||||
if (target) heroObserver.observe(target);
|
||||
syncBackgroundVideo();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
heroObserver?.disconnect();
|
||||
motionQuery?.removeEventListener("change", syncGradient);
|
||||
motionQuery?.removeEventListener("change", syncMotionState);
|
||||
mobileQuery?.removeEventListener("change", syncGradient);
|
||||
stopBackgroundVideo();
|
||||
destroyGradient();
|
||||
});
|
||||
</script>
|
||||
|
|
@ -169,6 +279,40 @@ onBeforeUnmount(() => {
|
|||
:class="{ 'cyber-hero__monterey--live': isLive }"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
class="cyber-hero__monterey-video"
|
||||
:class="{ 'cyber-hero__monterey-video--ready': isBackgroundVideoReady }"
|
||||
:style="{ '--cyber-monterey-video-poster': backgroundPosterUrl ? `url(${backgroundPosterUrl})` : 'none' }"
|
||||
>
|
||||
<ClientOnly>
|
||||
<mux-video
|
||||
v-if="shouldMountBackgroundVideo && backgroundPlaybackId"
|
||||
class="cyber-hero__monterey-video-player"
|
||||
:class="{ 'cyber-hero__monterey-video-player--ready': isBackgroundVideoReady }"
|
||||
:playback-id="backgroundPlaybackId"
|
||||
:poster="backgroundPosterUrl || undefined"
|
||||
stream-type="on-demand"
|
||||
autoplay="muted"
|
||||
muted
|
||||
loop
|
||||
playsinline
|
||||
preload="auto"
|
||||
max-resolution="720p"
|
||||
max-auto-resolution="720p"
|
||||
cap-rendition-to-player-size
|
||||
disable-tracking
|
||||
disable-cookies
|
||||
style="--media-object-fit: cover;"
|
||||
tabindex="-1"
|
||||
metadata-video-id="agent-teams-hero-background"
|
||||
metadata-video-title="Agent Teams hero background"
|
||||
@canplay="markBackgroundVideoReady"
|
||||
@loadeddata="markBackgroundVideoReady"
|
||||
@playing="markBackgroundVideoReady"
|
||||
@error="markBackgroundVideoError"
|
||||
/>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
<canvas ref="canvasRef" class="cyber-hero__monterey-canvas" />
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -7,10 +7,12 @@ const props = defineProps<{
|
|||
activeReceiver?: HeroAgentRole | "video" | null;
|
||||
}>();
|
||||
|
||||
const { locale } = useI18n();
|
||||
const isSender = computed(() => props.activeSender === props.agent.id);
|
||||
const isReceiver = computed(() => props.activeReceiver === props.agent.id);
|
||||
const imageLoading = computed(() => (props.agent.priority ? "eager" : "lazy"));
|
||||
const imageFetchPriority = computed(() => (props.agent.priority ? "high" : "auto"));
|
||||
const statusLabel = computed(() => locale.value === "ru" ? "Статус:" : "Status:");
|
||||
|
||||
const rootStyle = computed(() => ({
|
||||
"--agent-x": String(props.agent.desktop.x),
|
||||
|
|
@ -62,7 +64,7 @@ const rootStyle = computed(() => ({
|
|||
<li v-for="task in agent.tasks" :key="task">{{ task }}</li>
|
||||
</ul>
|
||||
<div class="cyber-agent__status">
|
||||
<span>Status:</span>
|
||||
<span>{{ statusLabel }}</span>
|
||||
<strong>{{ agent.status }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
<script setup lang="ts">
|
||||
import {
|
||||
heroAgents,
|
||||
getLocalizedHeroAgents,
|
||||
type HeroAgentRole,
|
||||
type HeroMessage,
|
||||
type HeroMessagePhase,
|
||||
} from "~/data/heroScene";
|
||||
|
||||
const { locale } = useI18n();
|
||||
const localizedHeroAgents = computed(() => getLocalizedHeroAgents(locale.value));
|
||||
|
||||
const props = defineProps<{
|
||||
message: HeroMessage | null;
|
||||
phase: HeroMessagePhase;
|
||||
|
|
@ -26,7 +29,7 @@ const activeReceiver = computed<HeroAgentRole | "video" | null>(() => (
|
|||
|
||||
<div class="cyber-scene__robots">
|
||||
<CyberHeroRobot
|
||||
v-for="agent in heroAgents"
|
||||
v-for="agent in localizedHeroAgents"
|
||||
:key="agent.id"
|
||||
:agent="agent"
|
||||
:active-sender="activeSender"
|
||||
|
|
|
|||
|
|
@ -1,14 +1,19 @@
|
|||
<script setup lang="ts">
|
||||
const { locale } = useI18n();
|
||||
const isRu = computed(() => locale.value === "ru");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
id="hero-demo"
|
||||
class="cyber-video-frame"
|
||||
role="region"
|
||||
aria-label="Watch Agent Teams demo"
|
||||
:aria-label="isRu ? 'Смотреть демо Agent Teams' : 'Watch Agent Teams demo'"
|
||||
>
|
||||
<div class="cyber-video-frame__bezel" aria-hidden="true" />
|
||||
<div class="cyber-video-frame__status" aria-hidden="true">
|
||||
<span>Team command feed</span>
|
||||
<span>Live demo</span>
|
||||
<span>{{ isRu ? 'Командная лента' : 'Team command feed' }}</span>
|
||||
<span>{{ isRu ? 'Живое демо' : 'Live demo' }}</span>
|
||||
</div>
|
||||
<div class="cyber-video-frame__content">
|
||||
<HeroDemoVideo />
|
||||
|
|
|
|||
52
landing/components/hero/CyberProviderIcon.vue
Normal file
52
landing/components/hero/CyberProviderIcon.vue
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
provider: "codex" | "anthropic" | "opencode";
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
v-if="provider === 'codex'"
|
||||
viewBox="0 0 24 24"
|
||||
role="img"
|
||||
aria-label="OpenAI"
|
||||
>
|
||||
<path
|
||||
d="M9.43799 9.06943V7.09387C9.43799 6.92749 9.50347 6.80267 9.65601 6.71959L13.8206 4.43211C14.3875 4.1202 15.0635 3.9747 15.7611 3.9747C18.3775 3.9747 20.0347 5.9087 20.0347 7.96734C20.0347 8.11288 20.0347 8.27926 20.0128 8.44564L15.6956 6.03335C15.434 5.88785 15.1723 5.88785 14.9107 6.03335L9.43799 9.06943ZM19.1624 16.7637V12.0431C19.1624 11.7519 19.0315 11.544 18.7699 11.3984L13.2972 8.36234L15.0851 7.3849C15.2377 7.30182 15.3686 7.30182 15.5212 7.3849L19.6858 9.67238C20.8851 10.3379 21.6917 11.7519 21.6917 13.1243C21.6917 14.7047 20.7106 16.1604 19.1624 16.7636V16.7637ZM8.15158 12.6047L6.36369 11.6066C6.21114 11.5235 6.14566 11.3986 6.14566 11.2323V6.65735C6.14566 4.43233 7.93355 2.7478 10.3538 2.7478C11.2697 2.7478 12.1199 3.039 12.8396 3.55886L8.54424 5.92959C8.28268 6.07508 8.15181 6.28303 8.15181 6.57427V12.6049L8.15158 12.6047ZM12 14.7258L9.43799 13.3533V10.4421L12 9.06965L14.5618 10.4421V13.3533L12 14.7258ZM13.6461 21.0476C12.7303 21.0476 11.8801 20.7564 11.1604 20.2366L15.4557 17.8658C15.7173 17.7203 15.8482 17.5124 15.8482 17.2211V11.1905L17.658 12.1886C17.8105 12.2717 17.876 12.3965 17.876 12.563V17.1379C17.876 19.3629 16.0662 21.0474 13.6461 21.0474V21.0476ZM8.47863 16.4103L4.314 14.1229C3.11471 13.4573 2.30808 12.0433 2.30808 10.6709C2.30808 9.06965 3.31106 7.6348 4.85903 7.03168V11.773C4.85903 12.0642 4.98995 12.2721 5.25151 12.4177L10.7025 15.4328L8.91464 16.4103C8.76209 16.4934 8.63117 16.4934 8.47863 16.4103ZM8.23892 19.8207C5.77508 19.8207 3.96533 18.0531 3.96533 15.8696C3.96533 15.7032 3.98719 15.5368 4.00886 15.3704L8.30418 17.7412C8.56574 17.8867 8.82752 17.8867 9.08909 17.7412L14.5618 14.726V16.7015C14.5618 16.8679 14.4964 16.9927 14.3438 17.0758L10.1792 19.3633C9.61225 19.6752 8.93631 19.8207 8.23869 19.8207H8.23892ZM13.6461 22.2952C16.2844 22.2952 18.4865 20.5069 18.9882 18.1362C21.4301 17.5331 23 15.3495 23 13.1245C23 11.6688 22.346 10.2548 21.1685 9.23581C21.2775 8.79908 21.343 8.36234 21.343 7.92582C21.343 4.95215 18.8137 2.72691 15.892 2.72691C15.3034 2.72691 14.7365 2.80999 14.1695 2.99726C13.1882 2.08223 11.8364 1.5 10.3538 1.5C7.71557 1.5 5.51352 3.28829 5.01185 5.65902C2.56987 6.26214 1 8.44564 1 10.6707C1 12.1264 1.65404 13.5404 2.83147 14.5594C2.72246 14.9961 2.65702 15.4328 2.65702 15.8694C2.65702 18.8431 5.1863 21.0683 8.108 21.0683C8.69661 21.0683 9.26354 20.9852 9.83046 20.7979C10.8115 21.713 12.1634 22.2952 13.6461 22.2952Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<svg
|
||||
v-else-if="provider === 'anthropic'"
|
||||
viewBox="0 0 24 24"
|
||||
role="img"
|
||||
aria-label="Anthropic"
|
||||
>
|
||||
<path
|
||||
d="M13.7891 3.93164L20.2223 20.0677H23.7502L17.317 3.93164H13.7891Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M6.32538 13.6824L8.52662 8.01177L10.7279 13.6824H6.32538ZM6.68225 3.93164L0.25 20.0677H3.84652L5.16202 16.6791H11.8914L13.2067 20.0677H16.8033L10.371 3.93164H6.68225Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<svg
|
||||
v-else
|
||||
viewBox="0 6 24 30"
|
||||
role="img"
|
||||
aria-label="OpenCode"
|
||||
>
|
||||
<path
|
||||
d="M18 30H6V18H18V30Z"
|
||||
fill="currentColor"
|
||||
opacity="0.48"
|
||||
/>
|
||||
<path
|
||||
d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
|
@ -5,6 +5,7 @@ const { t, locale } = useI18n();
|
|||
const { repoUrl } = useGithubRepo();
|
||||
const { baseURL } = useRuntimeConfig().app;
|
||||
const year = new Date().getFullYear();
|
||||
const authorLabel = computed(() => locale.value === 'ru' ? 'Автор' : 'Author');
|
||||
const docsHref = computed(() => {
|
||||
const base = baseURL.replace(/\/?$/, '/');
|
||||
return `${base}${locale.value === 'ru' ? 'docs/ru/' : 'docs/'}`;
|
||||
|
|
@ -31,7 +32,7 @@ const docsHref = computed(() => {
|
|||
>{{ t('footer.copyright', { year }) }} · {{ t('footer.tagline') }}</span
|
||||
>
|
||||
<div class="app-footer__links">
|
||||
<a class="app-footer__link" href="https://github.com/777genius" target="_blank">Author</a>
|
||||
<a class="app-footer__link" href="https://github.com/777genius" target="_blank">{{ authorLabel }}</a>
|
||||
<span class="app-footer__divider" />
|
||||
<a class="app-footer__link" :href="repoUrl" target="_blank">GitHub</a>
|
||||
<span class="app-footer__divider" />
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ const isRu = computed(() => locale.value === 'ru');
|
|||
|
||||
const navItems = computed(() => [
|
||||
{ href: '#screenshots', label: t('nav.screenshots'), shortLabel: isRu.value ? 'Скрины' : 'Shots' },
|
||||
{ href: docsHref.value, label: 'Docs', shortLabel: 'Docs' },
|
||||
{ href: docsHref.value, label: t('nav.docs'), shortLabel: isRu.value ? 'Док' : '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' },
|
||||
|
|
@ -126,10 +126,10 @@ const navItems = computed(() => [
|
|||
:href="repoUrl"
|
||||
target="_blank"
|
||||
class="app-header__github-btn"
|
||||
aria-label="GitHub"
|
||||
:aria-label="t('nav.viewOnGithub')"
|
||||
>
|
||||
<v-icon :icon="mdiGithub" class="app-header__github-icon" />
|
||||
<span class="app-header__github-text">GitHub</span>
|
||||
<span class="app-header__github-text">{{ t('nav.viewOnGithub') }}</span>
|
||||
</v-btn>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
|
@ -186,7 +186,7 @@ const navItems = computed(() => [
|
|||
--header-height: 126px;
|
||||
--header-panel-height: 86px;
|
||||
--header-action-size: clamp(54px, 3.25vw, 66px);
|
||||
--header-github-width: clamp(150px, 9.7vw, 204px);
|
||||
--header-github-width: clamp(190px, 12vw, 236px);
|
||||
--header-brand-icon: clamp(52px, 3.7vw, 68px);
|
||||
--header-brand-text: clamp(23px, 1.42vw, 32px);
|
||||
|
||||
|
|
@ -503,8 +503,8 @@ const navItems = computed(() => [
|
|||
color: var(--header-cyan) !important;
|
||||
font-family: var(--at-font-mono);
|
||||
font-weight: 800 !important;
|
||||
font-size: clamp(14px, 1vw, 19px) !important;
|
||||
letter-spacing: 0.08em !important;
|
||||
font-size: clamp(13px, 0.86vw, 16px) !important;
|
||||
letter-spacing: 0.06em !important;
|
||||
text-transform: uppercase !important;
|
||||
background: rgba(0, 234, 255, 0.035) !important;
|
||||
box-shadow:
|
||||
|
|
@ -519,6 +519,8 @@ const navItems = computed(() => [
|
|||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-header__github-icon {
|
||||
|
|
@ -527,7 +529,10 @@ const navItems = computed(() => [
|
|||
}
|
||||
|
||||
.app-header__github-text {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-header__github-btn:hover {
|
||||
|
|
@ -551,7 +556,7 @@ const navItems = computed(() => [
|
|||
--header-height: 112px;
|
||||
--header-panel-height: 72px;
|
||||
--header-action-size: 54px;
|
||||
--header-github-width: 124px;
|
||||
--header-github-width: 158px;
|
||||
--header-brand-icon: 44px;
|
||||
--header-brand-text: 15px;
|
||||
}
|
||||
|
|
@ -591,7 +596,13 @@ const navItems = computed(() => [
|
|||
}
|
||||
|
||||
.app-header__github-btn {
|
||||
font-size: 13px !important;
|
||||
padding-inline: 8px !important;
|
||||
font-size: 12px !important;
|
||||
letter-spacing: 0.04em !important;
|
||||
}
|
||||
|
||||
.app-header__github-btn :deep(.v-btn__content) {
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,162 @@
|
|||
<script setup lang="ts">
|
||||
import robotAvatarCyan from "~/assets/images/hero/robots/robot-avatar-cyan-v1.webp";
|
||||
|
||||
const { t } = useI18n()
|
||||
const { t, locale } = useI18n()
|
||||
const comparisonRobotRef = ref<HTMLElement | null>(null)
|
||||
const showComparisonRobotBubble = ref(false)
|
||||
let comparisonRobotObserver: IntersectionObserver | null = null
|
||||
|
||||
const ruNotes: Record<string, string> = {
|
||||
'Messages between separate teams': 'Сообщения между отдельными командами',
|
||||
'Coordination across groups': 'Координация между группами',
|
||||
'Company-scoped org work': 'Оргработа на уровне компании',
|
||||
'Native real-time mailbox': 'Нативный mailbox в реальном времени',
|
||||
'Mailboxes + handoffs': 'Mailbox и handoff',
|
||||
'Comments + @mentions': 'Комментарии и @mentions',
|
||||
'Team mailbox, no UI': 'Командный mailbox, без UI',
|
||||
'Tasks can link to and block each other': 'Задачи могут связываться и блокировать друг друга',
|
||||
'Task deps + grouped work': 'Зависимости задач и группировка работ',
|
||||
'Goals, parent tasks, blockers': 'Цели, родительские задачи, блокеры',
|
||||
'Shared task list': 'Общий список задач',
|
||||
'Task logs + token usage': 'Логи задач и расход токенов',
|
||||
'Session recall, feed, metrics': 'Память сессий, лента, метрики',
|
||||
'Run transcripts + cost audit': 'Транскрипты запусков и аудит стоимости',
|
||||
'Run transcripts + audit log': 'Транскрипты запусков и журнал аудита',
|
||||
'Usage command, no UI': 'Команда usage, без UI',
|
||||
'Auto-attach, agents read & attach': 'Автоприкрепление, агенты читают и добавляют вложения',
|
||||
'Not task-level': 'Не на уровне задач',
|
||||
'Docs, attachments, work products': 'Документы, вложения, рабочие артефакты',
|
||||
'Chat session only': 'Только сессия чата',
|
||||
'Chat images only': 'Только изображения в чате',
|
||||
'Accept / reject individual hunks': 'Принятие или отклонение отдельных фрагментов',
|
||||
'Bring your own review': 'Ревью нужно делать отдельно',
|
||||
'With Git support': 'С поддержкой Git',
|
||||
'Control plane, not editor': 'Панель управления, не редактор',
|
||||
'Full IDE': 'Полная IDE',
|
||||
'Plan, assign, work, and review': 'Планирует, назначает, работает и ревьюит',
|
||||
'Coordinator, grouped work, recovery': 'Координатор, группировка работ, восстановление',
|
||||
'Wake-up runs + governance': 'Отложенные запуски и управление',
|
||||
'Background agents, not teams': 'Фоновые агенты, не команды',
|
||||
'Experimental CLI teams': 'Экспериментальные CLI-команды',
|
||||
'Tasks wait for blockers automatically': 'Задачи автоматически ждут блокеры',
|
||||
'Dependency waves': 'Волны зависимостей',
|
||||
'Blockers + execution locks': 'Блокеры и execution locks',
|
||||
'Team task deps, no UI': 'Зависимости командных задач, без UI',
|
||||
'Agents review each other': 'Агенты ревьюят друг друга',
|
||||
'Merge queue': 'Merge queue',
|
||||
'Merge queue, no diff UI': 'Merge queue, без diff UI',
|
||||
'Approvals + governance': 'Подтверждения и управление',
|
||||
'PR/BugBot only': 'Только PR/BugBot',
|
||||
'Team review, no UI': 'Командное ревью, без UI',
|
||||
'Guided runtime setup': 'Пошаговая настройка runtime',
|
||||
'Manual CLI stack': 'Ручной CLI-стек',
|
||||
'npx + local database': 'npx и локальная база',
|
||||
'CLI + env flag': 'CLI и env-флаг',
|
||||
'5 columns, real-time': '5 колонок, в реальном времени',
|
||||
'Dashboard, not Kanban': 'Панель, не канбан',
|
||||
'7 columns, drag-and-drop': '7 колонок, перетаскивание',
|
||||
'Вызовы tools, reasoning, timeline': 'Вызовы tools, reasoning, timeline',
|
||||
'Feed, metrics, dashboard': 'Лента, метрики, панель',
|
||||
'Agent chat + terminal': 'Чат агента и терминал',
|
||||
'View, stop, open URLs': 'Просмотр, остановка, открытие URL',
|
||||
'Agent health dashboard': 'Dashboard здоровья агентов',
|
||||
'Manual services + previews': 'Ручные сервисы и previews',
|
||||
'Native terminal only': 'Только нативный терминал',
|
||||
'CPU/RAM history for each live teammate': 'История CPU/RAM для каждого живого участника',
|
||||
'Activity/health, not CPU/RAM': 'Активность/здоровье, не CPU/RAM',
|
||||
'Run status/cost, not CPU/RAM': 'Статус/стоимость запуска, не CPU/RAM',
|
||||
'Remote agent/terminal only': 'Только remote agent/terminal',
|
||||
'Accept / reject / comment': 'Принять, отклонить или прокомментировать',
|
||||
'PR/work products, no diff UI': 'PR/рабочие артефакты, без diff UI',
|
||||
'BugBot on PRs': 'BugBot для PR',
|
||||
'Per-action approvals + notifications': 'Подтверждения и уведомления для каждого действия',
|
||||
'Проверки, эскалация, восстановление': 'Проверки, эскалация, восстановление',
|
||||
'Подтверждения на доске, пауза, остановка': 'Подтверждения на доске, пауза, остановка',
|
||||
'Background agents auto-run commands': 'Фоновые агенты сами запускают команды',
|
||||
'Permissions + hooks': 'Permissions и hooks',
|
||||
'Optional': 'Опционально',
|
||||
'Core primitive': 'Ключевая примитивная модель',
|
||||
'Worktrees / branches': 'Worktrees / branches',
|
||||
'Background branches/VMs': 'Фоновые branches/VMs',
|
||||
'Manual worktrees': 'Ручные worktrees',
|
||||
'Claude, Codex, and OpenCode in one team': 'Claude, Codex и OpenCode в одной команде',
|
||||
'Many providers, terminal-first': 'Много провайдеров, terminal-first',
|
||||
'Bring your own agents/runtimes': 'Подключайте своих агентов и runtimes',
|
||||
'Multi-model agents, no shared team': 'Мультимодельные агенты, без общей команды',
|
||||
'Claude-only experimental teams': 'Экспериментальные команды только для Claude',
|
||||
'Teammates, tasks, blockers, handoffs, activity, logs': 'Участники, задачи, блокеры, передачи, активность, логи',
|
||||
'Agent tree + feed panels': 'Дерево агентов и панели ленты',
|
||||
'Org chart/status, not a task/log map': 'Оргструктура/статус, не карта задач и логов',
|
||||
'Watch teammates work and message them directly': 'Смотрите работу участников и пишите им напрямую',
|
||||
'Terminal-based agent sessions': 'Терминальные сессии агентов',
|
||||
'Agents wake up for runs, then sleep': 'Агенты просыпаются для запусков, потом засыпают',
|
||||
'Background agents per task': 'Фоновые агенты на задачу',
|
||||
'CLI teams, no desktop view': 'CLI-команды, без desktop-экрана',
|
||||
'Tasks, logs, Kanban, review, and teammates in one app': 'Задачи, логи, канбан, ревью и участники в одном приложении',
|
||||
'Mail/feed/dashboard across tools': 'Почта, лента и панель между tools',
|
||||
'Board + transcripts, less live teammate view': 'Доска и транскрипты, меньше live-вида участников',
|
||||
'IDE chats/tasks, not team view': 'IDE-чаты/задачи, не командный вид',
|
||||
'No desktop UI': 'Нет desktop UI',
|
||||
'Know who started, who is stuck, and who replied': 'Видно кто стартовал, кто застрял и кто ответил',
|
||||
'Session health, less clear message status': 'Здоровье сессии, менее ясный статус сообщений',
|
||||
'Run status, not live teammate status': 'Статус запуска, не live-статус участника',
|
||||
'CLI mailbox, no visual status': 'CLI mailbox, без визуального статуса',
|
||||
'Roles + approvals, no org chart': 'Роли и подтверждения, без оргструктуры',
|
||||
'Roles + escalation': 'Роли и эскалация',
|
||||
'Org chart + board governance': 'Оргструктура и управление доской',
|
||||
'Team admin only': 'Только администрирование команды',
|
||||
'Cost/token visibility, no hard caps': 'Видимость стоимости/токенов, без жёстких лимитов',
|
||||
'Cost tiers + digest, no hard caps': 'Тарифные уровни и дайджест, без жёстких лимитов',
|
||||
'Per-agent budgets + hard stops': 'Бюджеты на агента и жёсткие остановки',
|
||||
'Usage + BG spend limits': 'Usage и лимиты фоновых расходов',
|
||||
'/usage + workspace limits': '/usage и лимиты workspace',
|
||||
'OSS + free model with no auth, paid providers optional': 'OSS и бесплатная модель без авторизации, платные провайдеры опциональны',
|
||||
'OSS, runtime plans needed': 'OSS, нужны тарифы runtime',
|
||||
'OSS, self-hosted + infra': 'OSS, self-hosted и инфраструктура',
|
||||
'Free + paid usage': 'Бесплатно плюс платное использование',
|
||||
'Claude plan or API usage': 'Claude plan или API usage',
|
||||
}
|
||||
|
||||
function note(text: string): string {
|
||||
return locale.value === 'ru' ? (ruNotes[text] ?? text) : text
|
||||
}
|
||||
|
||||
const sourcesPrefix = computed(() => (
|
||||
locale.value === 'ru'
|
||||
? 'Источники фактов проверены 18 мая 2026:'
|
||||
: 'Fact sources checked on May 18, 2026:'
|
||||
))
|
||||
|
||||
const ruSourceLabels: Record<string, string> = {
|
||||
'detailed research notes': 'подробные заметки исследования',
|
||||
'Gastown provider guide': 'гайд по провайдерам Gastown',
|
||||
'Gastown scheduler': 'планировщик Gastown',
|
||||
'Gastown dashboard source': 'исходники dashboard Gastown',
|
||||
'Gastown release': 'релиз Gastown',
|
||||
'Paperclip adapters': 'адаптеры Paperclip',
|
||||
'Paperclip heartbeat protocol': 'heartbeat-протокол Paperclip',
|
||||
'Paperclip org chart': 'оргструктура Paperclip',
|
||||
'Paperclip OrgChart source': 'исходники OrgChart Paperclip',
|
||||
'Paperclip budgets': 'бюджеты Paperclip',
|
||||
'Paperclip runtime services': 'runtime services Paperclip',
|
||||
'Paperclip Kanban source': 'исходники Kanban Paperclip',
|
||||
'Paperclip work products': 'work products Paperclip',
|
||||
'Paperclip release': 'релиз Paperclip',
|
||||
'Cursor Background Agents': 'фоновые агенты Cursor',
|
||||
'Cursor Diffs & Review': 'diffs и review Cursor',
|
||||
'Cursor pricing': 'цены Cursor',
|
||||
'Claude Code agent teams': 'команды агентов Claude Code',
|
||||
'Claude Code subagents': 'сабагенты Claude Code',
|
||||
'Claude Code workflows': 'workflows Claude Code',
|
||||
'Claude Code costs': 'стоимость Claude Code',
|
||||
'Claude pricing': 'цены Claude',
|
||||
}
|
||||
|
||||
function sourceLabel(label: string): string {
|
||||
return locale.value === 'ru' ? (ruSourceLabels[label] ?? label) : label
|
||||
}
|
||||
|
||||
|
||||
interface CellValue {
|
||||
status: string
|
||||
note?: string
|
||||
|
|
@ -24,211 +175,211 @@ interface ComparisonRow {
|
|||
const rows = computed<ComparisonRow[]>(() => [
|
||||
{
|
||||
feature: t('comparison.features.crossTeam'),
|
||||
us: { status: 'yes', note: 'Messages between separate teams' },
|
||||
gastown: { status: 'partial', note: 'Coordination across groups' },
|
||||
paperclip: { status: 'partial', note: 'Company-scoped org work' },
|
||||
us: { status: 'yes', note: note('Messages between separate teams') },
|
||||
gastown: { status: 'partial', note: note('Coordination across groups') },
|
||||
paperclip: { status: 'partial', note: note('Company-scoped org work') },
|
||||
cursor: { status: 'na' },
|
||||
claudeCli: { status: 'no' },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.agentMessaging'),
|
||||
us: { status: 'yes', note: 'Native real-time mailbox' },
|
||||
gastown: { status: 'yes', note: 'Mailboxes + handoffs' },
|
||||
paperclip: { status: 'partial', note: 'Comments + @mentions' },
|
||||
us: { status: 'yes', note: note('Native real-time mailbox') },
|
||||
gastown: { status: 'yes', note: note('Mailboxes + handoffs') },
|
||||
paperclip: { status: 'partial', note: note('Comments + @mentions') },
|
||||
cursor: { status: 'no' },
|
||||
claudeCli: { status: 'yes', note: 'Team mailbox, no UI' },
|
||||
claudeCli: { status: 'yes', note: note('Team mailbox, no UI') },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.linkedTasks'),
|
||||
us: { status: 'yes', note: 'Tasks can link to and block each other' },
|
||||
gastown: { status: 'partial', note: 'Task deps + grouped work' },
|
||||
paperclip: { status: 'yes', note: 'Goals, parent tasks, blockers' },
|
||||
us: { status: 'yes', note: note('Tasks can link to and block each other') },
|
||||
gastown: { status: 'partial', note: note('Task deps + grouped work') },
|
||||
paperclip: { status: 'yes', note: note('Goals, parent tasks, blockers') },
|
||||
cursor: { status: 'no' },
|
||||
claudeCli: { status: 'yes', note: 'Shared task list' },
|
||||
claudeCli: { status: 'yes', note: note('Shared task list') },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.sessionAnalysis'),
|
||||
us: { status: 'yes', note: 'Task logs + token usage' },
|
||||
gastown: { status: 'partial', note: 'Session recall, feed, metrics' },
|
||||
paperclip: { status: 'partial', note: 'Run transcripts + cost audit' },
|
||||
us: { status: 'yes', note: note('Task logs + token usage') },
|
||||
gastown: { status: 'partial', note: note('Session recall, feed, metrics') },
|
||||
paperclip: { status: 'partial', note: note('Run transcripts + cost audit') },
|
||||
cursor: { status: 'no' },
|
||||
claudeCli: { status: 'partial', note: 'Usage command, no UI' },
|
||||
claudeCli: { status: 'partial', note: note('Usage command, no UI') },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.taskAttachments'),
|
||||
us: { status: 'yes', note: 'Auto-attach, agents read & attach' },
|
||||
gastown: { status: 'no', note: 'Not task-level' },
|
||||
paperclip: { status: 'yes', note: 'Docs, attachments, work products' },
|
||||
cursor: { status: 'partial', note: 'Chat session only' },
|
||||
claudeCli: { status: 'partial', note: 'Chat images only' },
|
||||
us: { status: 'yes', note: note('Auto-attach, agents read & attach') },
|
||||
gastown: { status: 'no', note: note('Not task-level') },
|
||||
paperclip: { status: 'yes', note: note('Docs, attachments, work products') },
|
||||
cursor: { status: 'partial', note: note('Chat session only') },
|
||||
claudeCli: { status: 'partial', note: note('Chat images only') },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.hunkReview'),
|
||||
us: { status: 'yes', note: 'Accept / reject individual hunks' },
|
||||
us: { status: 'yes', note: note('Accept / reject individual hunks') },
|
||||
gastown: { status: 'no' },
|
||||
paperclip: { status: 'no', note: 'Bring your own review' },
|
||||
paperclip: { status: 'no', note: note('Bring your own review') },
|
||||
cursor: { status: 'yes' },
|
||||
claudeCli: { status: 'no' },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.codeEditor'),
|
||||
us: { status: 'yes', note: 'With Git support' },
|
||||
us: { status: 'yes', note: note('With Git support') },
|
||||
gastown: { status: 'no' },
|
||||
paperclip: { status: 'no', note: 'Control plane, not editor' },
|
||||
cursor: { status: 'yes', note: 'Full IDE' },
|
||||
paperclip: { status: 'no', note: note('Control plane, not editor') },
|
||||
cursor: { status: 'yes', note: note('Full IDE') },
|
||||
claudeCli: { status: 'no' },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.fullAutonomy'),
|
||||
us: { status: 'yes', note: 'Plan, assign, work, and review' },
|
||||
gastown: { status: 'yes', note: 'Coordinator, grouped work, recovery' },
|
||||
paperclip: { status: 'yes', note: 'Wake-up runs + governance' },
|
||||
cursor: { status: 'partial', note: 'Background agents, not teams' },
|
||||
claudeCli: { status: 'yes', note: 'Experimental CLI teams' },
|
||||
us: { status: 'yes', note: note('Plan, assign, work, and review') },
|
||||
gastown: { status: 'yes', note: note('Coordinator, grouped work, recovery') },
|
||||
paperclip: { status: 'yes', note: note('Wake-up runs + governance') },
|
||||
cursor: { status: 'partial', note: note('Background agents, not teams') },
|
||||
claudeCli: { status: 'yes', note: note('Experimental CLI teams') },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.taskDeps'),
|
||||
us: { status: 'yes', note: 'Tasks wait for blockers automatically' },
|
||||
gastown: { status: 'yes', note: 'Dependency waves' },
|
||||
paperclip: { status: 'yes', note: 'Blockers + execution locks' },
|
||||
us: { status: 'yes', note: note('Tasks wait for blockers automatically') },
|
||||
gastown: { status: 'yes', note: note('Dependency waves') },
|
||||
paperclip: { status: 'yes', note: note('Blockers + execution locks') },
|
||||
cursor: { status: 'no' },
|
||||
claudeCli: { status: 'yes', note: 'Team task deps, no UI' },
|
||||
claudeCli: { status: 'yes', note: note('Team task deps, no UI') },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.reviewWorkflow'),
|
||||
us: { status: 'yes', note: 'Agents review each other' },
|
||||
gastown: { status: 'partial', note: 'Merge queue' },
|
||||
paperclip: { status: 'yes', note: 'Approvals + governance' },
|
||||
cursor: { status: 'partial', note: 'PR/BugBot only' },
|
||||
claudeCli: { status: 'yes', note: 'Team review, no UI' },
|
||||
us: { status: 'yes', note: note('Agents review each other') },
|
||||
gastown: { status: 'partial', note: note('Merge queue') },
|
||||
paperclip: { status: 'yes', note: note('Approvals + governance') },
|
||||
cursor: { status: 'partial', note: note('PR/BugBot only') },
|
||||
claudeCli: { status: 'yes', note: note('Team review, no UI') },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.zeroSetup'),
|
||||
us: { status: 'yes', note: 'Guided runtime setup' },
|
||||
gastown: { status: 'no', note: 'Manual CLI stack' },
|
||||
paperclip: { status: 'partial', note: 'npx + local database' },
|
||||
us: { status: 'yes', note: note('Guided runtime setup') },
|
||||
gastown: { status: 'no', note: note('Manual CLI stack') },
|
||||
paperclip: { status: 'partial', note: note('npx + local database') },
|
||||
cursor: { status: 'yes' },
|
||||
claudeCli: { status: 'partial', note: 'CLI + env flag' },
|
||||
claudeCli: { status: 'partial', note: note('CLI + env flag') },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.kanban'),
|
||||
us: { status: 'yes', note: '5 columns, real-time' },
|
||||
gastown: { status: 'no', note: 'Dashboard, not Kanban' },
|
||||
paperclip: { status: 'yes', note: '7 columns, drag-and-drop' },
|
||||
us: { status: 'yes', note: note('5 columns, real-time') },
|
||||
gastown: { status: 'no', note: note('Dashboard, not Kanban') },
|
||||
paperclip: { status: 'yes', note: note('7 columns, drag-and-drop') },
|
||||
cursor: { status: 'no' },
|
||||
claudeCli: { status: 'no' },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.execLog'),
|
||||
us: { status: 'yes', note: 'Tool calls, reasoning, timeline' },
|
||||
gastown: { status: 'partial', note: 'Feed, metrics, dashboard' },
|
||||
paperclip: { status: 'yes', note: 'Run transcripts + audit log' },
|
||||
cursor: { status: 'partial', note: 'Agent chat + terminal' },
|
||||
us: { status: 'yes', note: note('Вызовы tools, reasoning, timeline') },
|
||||
gastown: { status: 'partial', note: note('Feed, metrics, dashboard') },
|
||||
paperclip: { status: 'yes', note: note('Run transcripts + audit log') },
|
||||
cursor: { status: 'partial', note: note('Agent chat + terminal') },
|
||||
claudeCli: { status: 'no' },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.liveProcesses'),
|
||||
us: { status: 'yes', note: 'View, stop, open URLs' },
|
||||
gastown: { status: 'partial', note: 'Agent health dashboard' },
|
||||
paperclip: { status: 'partial', note: 'Manual services + previews' },
|
||||
cursor: { status: 'partial', note: 'Native terminal only' },
|
||||
us: { status: 'yes', note: note('View, stop, open URLs') },
|
||||
gastown: { status: 'partial', note: note('Agent health dashboard') },
|
||||
paperclip: { status: 'partial', note: note('Manual services + previews') },
|
||||
cursor: { status: 'partial', note: note('Native terminal only') },
|
||||
claudeCli: { status: 'no' },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.runtimeLoad'),
|
||||
us: { status: 'yes', note: 'CPU/RAM history for each live teammate' },
|
||||
gastown: { status: 'partial', note: 'Activity/health, not CPU/RAM' },
|
||||
paperclip: { status: 'partial', note: 'Run status/cost, not CPU/RAM' },
|
||||
cursor: { status: 'no', note: 'Remote agent/terminal only' },
|
||||
us: { status: 'yes', note: note('CPU/RAM history for each live teammate') },
|
||||
gastown: { status: 'partial', note: note('Activity/health, not CPU/RAM') },
|
||||
paperclip: { status: 'partial', note: note('Run status/cost, not CPU/RAM') },
|
||||
cursor: { status: 'no', note: note('Remote agent/terminal only') },
|
||||
claudeCli: { status: 'no' },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.perTaskReview'),
|
||||
us: { status: 'yes', note: 'Accept / reject / comment' },
|
||||
gastown: { status: 'partial', note: 'Merge queue, no diff UI' },
|
||||
paperclip: { status: 'partial', note: 'PR/work products, no diff UI' },
|
||||
cursor: { status: 'yes', note: 'BugBot on PRs' },
|
||||
us: { status: 'yes', note: note('Accept / reject / comment') },
|
||||
gastown: { status: 'partial', note: note('Merge queue, no diff UI') },
|
||||
paperclip: { status: 'partial', note: note('PR/work products, no diff UI') },
|
||||
cursor: { status: 'yes', note: note('BugBot on PRs') },
|
||||
claudeCli: { status: 'no' },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.flexAutonomy'),
|
||||
us: { status: 'yes', note: 'Per-action approvals + notifications' },
|
||||
gastown: { status: 'yes', note: 'Gates, escalation, recovery' },
|
||||
paperclip: { status: 'yes', note: 'Board approvals, pause, terminate' },
|
||||
cursor: { status: 'partial', note: 'Background agents auto-run commands' },
|
||||
claudeCli: { status: 'yes', note: 'Permissions + hooks' },
|
||||
us: { status: 'yes', note: note('Per-action approvals + notifications') },
|
||||
gastown: { status: 'yes', note: note('Проверки, эскалация, восстановление') },
|
||||
paperclip: { status: 'yes', note: note('Подтверждения на доске, пауза, остановка') },
|
||||
cursor: { status: 'partial', note: note('Background agents auto-run commands') },
|
||||
claudeCli: { status: 'yes', note: note('Permissions + hooks') },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.worktree'),
|
||||
us: { status: 'yes', note: 'Optional' },
|
||||
gastown: { status: 'yes', note: 'Core primitive' },
|
||||
paperclip: { status: 'yes', note: 'Worktrees / branches' },
|
||||
cursor: { status: 'partial', note: 'Background branches/VMs' },
|
||||
claudeCli: { status: 'partial', note: 'Manual worktrees' },
|
||||
us: { status: 'yes', note: note('Optional') },
|
||||
gastown: { status: 'yes', note: note('Core primitive') },
|
||||
paperclip: { status: 'yes', note: note('Worktrees / branches') },
|
||||
cursor: { status: 'partial', note: note('Background branches/VMs') },
|
||||
claudeCli: { status: 'partial', note: note('Manual worktrees') },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.multiAgent'),
|
||||
us: { status: 'yes', note: 'Claude, Codex, and OpenCode in one team' },
|
||||
gastown: { status: 'yes', note: 'Many providers, terminal-first' },
|
||||
paperclip: { status: 'yes', note: 'Bring your own agents/runtimes' },
|
||||
cursor: { status: 'partial', note: 'Multi-model agents, no shared team' },
|
||||
claudeCli: { status: 'partial', note: 'Claude-only experimental teams' },
|
||||
us: { status: 'yes', note: note('Claude, Codex, and OpenCode in one team') },
|
||||
gastown: { status: 'yes', note: note('Many providers, terminal-first') },
|
||||
paperclip: { status: 'yes', note: note('Bring your own agents/runtimes') },
|
||||
cursor: { status: 'partial', note: note('Multi-model agents, no shared team') },
|
||||
claudeCli: { status: 'partial', note: note('Claude-only experimental teams') },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.liveWorkGraph'),
|
||||
us: { status: 'yes', note: 'Teammates, tasks, blockers, handoffs, activity, logs' },
|
||||
gastown: { status: 'partial', note: 'Agent tree + feed panels' },
|
||||
paperclip: { status: 'partial', note: 'Org chart/status, not a task/log map' },
|
||||
us: { status: 'yes', note: note('Teammates, tasks, blockers, handoffs, activity, logs') },
|
||||
gastown: { status: 'partial', note: note('Agent tree + feed panels') },
|
||||
paperclip: { status: 'partial', note: note('Org chart/status, not a task/log map') },
|
||||
cursor: { status: 'no' },
|
||||
claudeCli: { status: 'no' },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.liveTeam'),
|
||||
us: { status: 'yes', note: 'Watch teammates work and message them directly' },
|
||||
gastown: { status: 'partial', note: 'Terminal-based agent sessions' },
|
||||
paperclip: { status: 'partial', note: 'Agents wake up for runs, then sleep' },
|
||||
cursor: { status: 'partial', note: 'Background agents per task' },
|
||||
claudeCli: { status: 'partial', note: 'CLI teams, no desktop view' },
|
||||
us: { status: 'yes', note: note('Watch teammates work and message them directly') },
|
||||
gastown: { status: 'partial', note: note('Terminal-based agent sessions') },
|
||||
paperclip: { status: 'partial', note: note('Agents wake up for runs, then sleep') },
|
||||
cursor: { status: 'partial', note: note('Background agents per task') },
|
||||
claudeCli: { status: 'partial', note: note('CLI teams, no desktop view') },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.teamWorkspace'),
|
||||
us: { status: 'yes', note: 'Tasks, logs, Kanban, review, and teammates in one app' },
|
||||
gastown: { status: 'partial', note: 'Mail/feed/dashboard across tools' },
|
||||
paperclip: { status: 'partial', note: 'Board + transcripts, less live teammate view' },
|
||||
cursor: { status: 'partial', note: 'IDE chats/tasks, not team view' },
|
||||
claudeCli: { status: 'no', note: 'No desktop UI' },
|
||||
us: { status: 'yes', note: note('Tasks, logs, Kanban, review, and teammates in one app') },
|
||||
gastown: { status: 'partial', note: note('Mail/feed/dashboard across tools') },
|
||||
paperclip: { status: 'partial', note: note('Board + transcripts, less live teammate view') },
|
||||
cursor: { status: 'partial', note: note('IDE chats/tasks, not team view') },
|
||||
claudeCli: { status: 'no', note: note('No desktop UI') },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.launchProof'),
|
||||
us: { status: 'yes', note: 'Know who started, who is stuck, and who replied' },
|
||||
gastown: { status: 'partial', note: 'Session health, less clear message status' },
|
||||
paperclip: { status: 'partial', note: 'Run status, not live teammate status' },
|
||||
us: { status: 'yes', note: note('Know who started, who is stuck, and who replied') },
|
||||
gastown: { status: 'partial', note: note('Session health, less clear message status') },
|
||||
paperclip: { status: 'partial', note: note('Run status, not live teammate status') },
|
||||
cursor: { status: 'no' },
|
||||
claudeCli: { status: 'partial', note: 'CLI mailbox, no visual status' },
|
||||
claudeCli: { status: 'partial', note: note('CLI mailbox, no visual status') },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.orgGovernance'),
|
||||
us: { status: 'partial', note: 'Roles + approvals, no org chart' },
|
||||
gastown: { status: 'partial', note: 'Roles + escalation' },
|
||||
paperclip: { status: 'yes', note: 'Org chart + board governance' },
|
||||
cursor: { status: 'partial', note: 'Team admin only' },
|
||||
us: { status: 'partial', note: note('Roles + approvals, no org chart') },
|
||||
gastown: { status: 'partial', note: note('Roles + escalation') },
|
||||
paperclip: { status: 'yes', note: note('Org chart + board governance') },
|
||||
cursor: { status: 'partial', note: note('Team admin only') },
|
||||
claudeCli: { status: 'no' },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.budgetControls'),
|
||||
us: { status: 'partial', note: 'Cost/token visibility, no hard caps' },
|
||||
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: '/usage + workspace limits' },
|
||||
us: { status: 'partial', note: note('Cost/token visibility, no hard caps') },
|
||||
gastown: { status: 'partial', note: note('Cost tiers + digest, no hard caps') },
|
||||
paperclip: { status: 'yes', note: note('Per-agent budgets + hard stops') },
|
||||
cursor: { status: 'partial', note: note('Usage + BG spend limits') },
|
||||
claudeCli: { status: 'partial', note: note('/usage + workspace limits') },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.price'),
|
||||
us: { status: 'free', note: 'OSS + free model with no auth, paid providers optional' },
|
||||
gastown: { status: 'free', note: 'OSS, runtime plans needed' },
|
||||
paperclip: { status: 'free', note: 'OSS, self-hosted + infra' },
|
||||
cursor: { status: 'text', note: 'Free + paid usage' },
|
||||
claudeCli: { status: 'text', note: 'Claude plan or API usage' },
|
||||
us: { status: 'free', note: note('OSS + free model with no auth, paid providers optional') },
|
||||
gastown: { status: 'free', note: note('OSS, runtime plans needed') },
|
||||
paperclip: { status: 'free', note: note('OSS, self-hosted + infra') },
|
||||
cursor: { status: 'text', note: note('Free + paid usage') },
|
||||
claudeCli: { status: 'text', note: note('Claude plan or API usage') },
|
||||
},
|
||||
])
|
||||
|
||||
|
|
@ -343,8 +494,8 @@ function getStatusIcon(status: string): string {
|
|||
case 'yes': return '\u2713'
|
||||
case 'no': return '\u2717'
|
||||
case 'partial': return '\u25D2'
|
||||
case 'na': return 'N/A'
|
||||
case 'free': return 'Free'
|
||||
case 'na': return locale.value === 'ru' ? 'Н/Д' : 'N/A'
|
||||
case 'free': return locale.value === 'ru' ? 'Бесплатно' : 'Free'
|
||||
case 'soon': return '\uD83D\uDCC5'
|
||||
default: return ''
|
||||
}
|
||||
|
|
@ -453,10 +604,10 @@ function getStatusIcon(status: string): string {
|
|||
</div>
|
||||
|
||||
<p class="comparison-section__sources">
|
||||
Fact sources checked on May 18, 2026:
|
||||
{{ sourcesPrefix }}
|
||||
<template v-for="(source, index) in sourceLinks" :key="source.href">
|
||||
<a :href="source.href" target="_blank" rel="noopener noreferrer">
|
||||
{{ source.label }}
|
||||
{{ sourceLabel(source.label) }}
|
||||
</a><span v-if="index < sourceLinks.length - 1">, </span>
|
||||
</template>.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -275,6 +275,7 @@ const releaseDate = computed(() => {
|
|||
day: 'numeric',
|
||||
});
|
||||
});
|
||||
const linuxRobotBubble = computed(() => locale.value === 'ru' ? 'Готов начать!' : 'Ready to start!');
|
||||
|
||||
</script>
|
||||
|
||||
|
|
@ -319,7 +320,7 @@ const releaseDate = computed(() => {
|
|||
class="download-section__card-robot-bubble"
|
||||
tail="right"
|
||||
>
|
||||
Готов начать!
|
||||
{{ linuxRobotBubble }}
|
||||
</RobotSpeechBubble>
|
||||
</Transition>
|
||||
<img
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import {
|
|||
mdiBookOpenPageVariantOutline,
|
||||
mdiDownload,
|
||||
} from "@mdi/js";
|
||||
import { heroMessages, type HeroMessagePhase } from "~/data/heroScene";
|
||||
import { getLocalizedHeroMessages, type HeroMessagePhase } from "~/data/heroScene";
|
||||
|
||||
const { content } = useLandingContent();
|
||||
const { t, locale } = useI18n();
|
||||
|
|
@ -33,7 +33,35 @@ const releaseDate = computed(() => {
|
|||
day: "numeric",
|
||||
});
|
||||
});
|
||||
const activeHeroMessage = computed(() => heroMessages[activeHeroMessageIndex.value] ?? null);
|
||||
const localizedHeroMessages = computed(() => getLocalizedHeroMessages(locale.value));
|
||||
const activeHeroMessage = computed(() => localizedHeroMessages.value[activeHeroMessageIndex.value] ?? null);
|
||||
const supportedProviders = [
|
||||
{
|
||||
id: "codex",
|
||||
name: "Codex",
|
||||
accent: "cyan",
|
||||
},
|
||||
{
|
||||
id: "anthropic",
|
||||
name: "Anthropic",
|
||||
accent: "amber",
|
||||
},
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
accent: "magenta",
|
||||
},
|
||||
] as const;
|
||||
const supportedProvidersLabel = computed(() => (
|
||||
locale.value === "ru"
|
||||
? "Поддерживаем AI-провайдеры"
|
||||
: "Supported AI providers"
|
||||
));
|
||||
const heroSlogan = computed(() => (
|
||||
locale.value === "ru"
|
||||
? "Делайте много, почти ничего не делая"
|
||||
: "Get a lot done by doing very little"
|
||||
));
|
||||
|
||||
const heroDownloadUrl = computed(() => {
|
||||
const asset = downloadStore.selectedAsset;
|
||||
|
|
@ -57,7 +85,7 @@ function setHeroMessageTimer(callback: () => void, delay: number) {
|
|||
function runHeroMessageCycle() {
|
||||
clearHeroMessageTimers();
|
||||
|
||||
if (!isHeroVisible.value || heroReducedMotion.value || heroMessages.length === 0) {
|
||||
if (!isHeroVisible.value || heroReducedMotion.value || localizedHeroMessages.value.length === 0) {
|
||||
heroMessagePhase.value = "cooldown";
|
||||
return;
|
||||
}
|
||||
|
|
@ -73,7 +101,7 @@ function runHeroMessageCycle() {
|
|||
heroMessagePhase.value = "cooldown";
|
||||
}, 3900);
|
||||
setHeroMessageTimer(() => {
|
||||
activeHeroMessageIndex.value = (activeHeroMessageIndex.value + 1) % heroMessages.length;
|
||||
activeHeroMessageIndex.value = (activeHeroMessageIndex.value + 1) % localizedHeroMessages.value.length;
|
||||
runHeroMessageCycle();
|
||||
}, 4700);
|
||||
}
|
||||
|
|
@ -138,13 +166,34 @@ onUnmounted(() => {
|
|||
</h1>
|
||||
|
||||
<p class="cyber-hero__slogan cyber-panel">
|
||||
Get a lot done by doing very little
|
||||
{{ heroSlogan }}
|
||||
</p>
|
||||
|
||||
<p class="cyber-hero__description">
|
||||
{{ content.hero.subtitle }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="cyber-hero__providers"
|
||||
:aria-label="supportedProvidersLabel"
|
||||
>
|
||||
<div class="cyber-hero__provider-list">
|
||||
<div
|
||||
v-for="provider in supportedProviders"
|
||||
:key="provider.id"
|
||||
class="cyber-hero__provider"
|
||||
:class="`cyber-hero__provider--${provider.accent}`"
|
||||
>
|
||||
<span class="cyber-hero__provider-icon" aria-hidden="true">
|
||||
<CyberProviderIcon :provider="provider.id" />
|
||||
</span>
|
||||
<span class="cyber-hero__provider-name">
|
||||
{{ provider.name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cyber-hero__actions">
|
||||
<v-btn
|
||||
variant="flat"
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { register } from 'swiper/element/bundle';
|
|||
import { mdiChevronLeft, mdiChevronRight, mdiClose, mdiArrowExpand } from '@mdi/js';
|
||||
import { screenshots as screenshotData } from '~/data/screenshots';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { t, locale } = useI18n();
|
||||
const { baseURL } = useRuntimeConfig().app;
|
||||
|
||||
register();
|
||||
|
|
@ -21,12 +21,14 @@ type SwiperContainerElement = HTMLElement & {
|
|||
swiper?: SwiperApi;
|
||||
};
|
||||
|
||||
const screenshots = screenshotData.map((s) => ({
|
||||
const screenshots = computed(() => screenshotData.map((s) => ({
|
||||
src: publicPath(s.path),
|
||||
alt: s.alt,
|
||||
alt: locale.value === 'ru' ? (s.ruAlt ?? s.alt) : s.alt,
|
||||
width: s.width,
|
||||
height: s.height,
|
||||
}));
|
||||
})));
|
||||
const prevLabel = computed(() => locale.value === 'ru' ? 'Предыдущий' : 'Previous');
|
||||
const nextLabel = computed(() => locale.value === 'ru' ? 'Следующий' : 'Next');
|
||||
|
||||
const swiperRef = ref<SwiperContainerElement | null>(null);
|
||||
const swiperReady = ref(false);
|
||||
|
|
@ -45,11 +47,11 @@ function closeLightbox() {
|
|||
}
|
||||
|
||||
function lightboxPrev() {
|
||||
lightboxIndex.value = (lightboxIndex.value - 1 + screenshots.length) % screenshots.length;
|
||||
lightboxIndex.value = (lightboxIndex.value - 1 + screenshots.value.length) % screenshots.value.length;
|
||||
}
|
||||
|
||||
function lightboxNext() {
|
||||
lightboxIndex.value = (lightboxIndex.value + 1) % screenshots.length;
|
||||
lightboxIndex.value = (lightboxIndex.value + 1) % screenshots.value.length;
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
|
|
@ -182,14 +184,14 @@ function slideNext() {
|
|||
<!-- Nav buttons -->
|
||||
<button
|
||||
class="screenshots-section__nav screenshots-section__nav--prev"
|
||||
aria-label="Previous"
|
||||
:aria-label="prevLabel"
|
||||
@click="slidePrev"
|
||||
>
|
||||
<v-icon :icon="mdiChevronLeft" size="28" />
|
||||
</button>
|
||||
<button
|
||||
class="screenshots-section__nav screenshots-section__nav--next"
|
||||
aria-label="Next"
|
||||
:aria-label="nextLabel"
|
||||
@click="slideNext"
|
||||
>
|
||||
<v-icon :icon="mdiChevronRight" size="28" />
|
||||
|
|
|
|||
|
|
@ -1,21 +1,37 @@
|
|||
<script setup lang="ts">
|
||||
import { nextTick, ref, onMounted, onUnmounted } from 'vue';
|
||||
import { mdiPlay, mdiPause, mdiVolumeHigh, mdiVolumeOff, mdiFullscreen } from '@mdi/js';
|
||||
import { computed, onMounted, onUnmounted, ref } from "vue";
|
||||
import { mdiPlay } from "@mdi/js";
|
||||
|
||||
const { t } = useI18n();
|
||||
const videoSrc = 'https://github.com/user-attachments/assets/9cae73cd-7f42-46e5-a8fb-ad6d41737ff8';
|
||||
const videoRef = ref<HTMLVideoElement | null>(null);
|
||||
const containerRef = ref<HTMLElement | null>(null);
|
||||
const isPlaying = ref(false);
|
||||
const isMuted = ref(true);
|
||||
const showControls = ref(true);
|
||||
const isLoaded = ref(true);
|
||||
const { t, locale } = useI18n();
|
||||
const config = useRuntimeConfig();
|
||||
const muxAccentColor = "#00f0ff";
|
||||
const muxPrimaryColor = "#e6fbff";
|
||||
const muxSecondaryColor = "#020617";
|
||||
|
||||
const muxPlaybackId = computed(() => String(config.public.muxPlaybackId || "").trim());
|
||||
const videoTitle = computed(() => (
|
||||
locale.value === "ru" ? "Демо-видео Agent Teams" : "Agent Teams demo video"
|
||||
));
|
||||
const muxVideoTitle = computed(() => (
|
||||
locale.value === "ru" ? "Демо Agent Teams" : "Agent Teams demo"
|
||||
));
|
||||
const muxPlayerUrl = computed(() => {
|
||||
if (!muxPlaybackId.value) return "";
|
||||
|
||||
const url = new URL(`https://player.mux.com/${encodeURIComponent(muxPlaybackId.value)}`);
|
||||
url.searchParams.set("accent-color", muxAccentColor);
|
||||
url.searchParams.set("primary-color", muxPrimaryColor);
|
||||
url.searchParams.set("secondary-color", muxSecondaryColor);
|
||||
url.searchParams.set("metadata-video-id", "agent-teams-demo");
|
||||
url.searchParams.set("metadata-video-title", muxVideoTitle.value);
|
||||
url.searchParams.set("metadata-player-name", "Landing hero");
|
||||
url.searchParams.set("title", muxVideoTitle.value);
|
||||
url.searchParams.set("video-title", muxVideoTitle.value);
|
||||
return url.toString();
|
||||
});
|
||||
const isLoaded = ref(false);
|
||||
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() {
|
||||
|
|
@ -28,7 +44,6 @@ function markLoaded() {
|
|||
if (hasError.value) return;
|
||||
isLoaded.value = true;
|
||||
clearLoadFallback();
|
||||
updateLoadProgress();
|
||||
}
|
||||
|
||||
function markError() {
|
||||
|
|
@ -36,270 +51,220 @@ function markError() {
|
|||
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) {
|
||||
markLoaded();
|
||||
video.play()
|
||||
.then(() => {
|
||||
isPlaying.value = true;
|
||||
})
|
||||
.catch(markError);
|
||||
} else {
|
||||
video.pause();
|
||||
isPlaying.value = false;
|
||||
}
|
||||
showControlsBriefly();
|
||||
}
|
||||
|
||||
function toggleMute() {
|
||||
const video = videoRef.value;
|
||||
if (!video) return;
|
||||
video.muted = !video.muted;
|
||||
isMuted.value = video.muted;
|
||||
showControlsBriefly();
|
||||
}
|
||||
|
||||
function toggleFullscreen() {
|
||||
const container = containerRef.value;
|
||||
if (!container) return;
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen();
|
||||
} else {
|
||||
container.requestFullscreen();
|
||||
}
|
||||
showControlsBriefly();
|
||||
}
|
||||
|
||||
function onTimeUpdate() {
|
||||
const video = videoRef.value;
|
||||
if (!video || !video.duration) return;
|
||||
progress.value = (video.currentTime / video.duration) * 100;
|
||||
}
|
||||
|
||||
function onSeek(e: MouseEvent) {
|
||||
const video = videoRef.value;
|
||||
const target = e.currentTarget as HTMLElement;
|
||||
if (!video || !target) return;
|
||||
const rect = target.getBoundingClientRect();
|
||||
const ratio = (e.clientX - rect.left) / rect.width;
|
||||
video.currentTime = ratio * video.duration;
|
||||
showControlsBriefly();
|
||||
}
|
||||
|
||||
function updateLoadProgress() {
|
||||
const video = videoRef.value;
|
||||
if (!video || !video.duration || !video.buffered.length) return;
|
||||
const bufferedEnd = video.buffered.end(video.buffered.length - 1);
|
||||
loadProgress.value = Math.round((bufferedEnd / video.duration) * 100);
|
||||
}
|
||||
|
||||
function showControlsBriefly() {
|
||||
showControls.value = true;
|
||||
if (hideTimer.value) clearTimeout(hideTimer.value);
|
||||
hideTimer.value = setTimeout(() => {
|
||||
if (isPlaying.value) showControls.value = false;
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function onMouseEnter() {
|
||||
showControls.value = true;
|
||||
if (hideTimer.value) clearTimeout(hideTimer.value);
|
||||
}
|
||||
|
||||
function onMouseLeave() {
|
||||
if (isPlaying.value) {
|
||||
hideTimer.value = setTimeout(() => {
|
||||
showControls.value = false;
|
||||
}, 1500);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
const video = videoRef.value;
|
||||
if (video) {
|
||||
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', onVideoEnded);
|
||||
|
||||
if (video.readyState >= HTMLMediaElement.HAVE_METADATA) {
|
||||
markLoaded();
|
||||
} else {
|
||||
video.load();
|
||||
loadFallbackTimer = setTimeout(markLoaded, 1800);
|
||||
}
|
||||
}
|
||||
|
||||
intObserver = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (!entry.isIntersecting && videoRef.value && !videoRef.value.paused) {
|
||||
videoRef.value.pause();
|
||||
isPlaying.value = false;
|
||||
}
|
||||
},
|
||||
{ threshold: 0.2 },
|
||||
);
|
||||
if (containerRef.value) intObserver.observe(containerRef.value);
|
||||
onMounted(() => {
|
||||
loadFallbackTimer = setTimeout(markLoaded, 2500);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
onUnmounted(clearLoadFallback);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="hero-video"
|
||||
@mouseenter="onMouseEnter"
|
||||
@mouseleave="onMouseLeave"
|
||||
>
|
||||
<!-- Loading skeleton -->
|
||||
<div v-if="!isLoaded && !hasError" class="hero-video__skeleton">
|
||||
<div class="hero-video">
|
||||
<div class="hero-video__ambient" aria-hidden="true" />
|
||||
<div class="hero-video__edge hero-video__edge--top" aria-hidden="true" />
|
||||
<div class="hero-video__edge hero-video__edge--bottom" aria-hidden="true" />
|
||||
<div class="hero-video__corner hero-video__corner--tl" aria-hidden="true" />
|
||||
<div class="hero-video__corner hero-video__corner--tr" aria-hidden="true" />
|
||||
<div class="hero-video__corner hero-video__corner--bl" aria-hidden="true" />
|
||||
<div class="hero-video__corner hero-video__corner--br" aria-hidden="true" />
|
||||
|
||||
<ClientOnly>
|
||||
<iframe
|
||||
v-if="muxPlayerUrl && !hasError"
|
||||
class="hero-video__player"
|
||||
:class="{ 'hero-video__player--loaded': isLoaded }"
|
||||
:src="muxPlayerUrl"
|
||||
:title="videoTitle"
|
||||
allow="accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture"
|
||||
allowfullscreen
|
||||
loading="lazy"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
@load="markLoaded"
|
||||
@error="markError"
|
||||
/>
|
||||
|
||||
<template #fallback>
|
||||
<div class="hero-video__skeleton">
|
||||
<div class="hero-video__skeleton-pulse" />
|
||||
<div class="hero-video__skeleton-content">
|
||||
<div class="hero-video__skeleton-spinner" />
|
||||
<span class="hero-video__skeleton-label">{{ t("hero.watchDemo") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
|
||||
<div v-if="!isLoaded && !hasError && muxPlayerUrl" class="hero-video__skeleton">
|
||||
<div class="hero-video__skeleton-pulse" />
|
||||
<div class="hero-video__skeleton-content">
|
||||
<div class="hero-video__skeleton-spinner" />
|
||||
<span class="hero-video__skeleton-label">
|
||||
{{ loadProgress > 0 ? `${loadProgress}%` : t('hero.watchDemo') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="hero-video__skeleton-bar">
|
||||
<div
|
||||
class="hero-video__skeleton-bar-fill"
|
||||
:style="{ width: `${loadProgress}%` }"
|
||||
/>
|
||||
<span class="hero-video__skeleton-label">{{ t("hero.watchDemo") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error fallback -->
|
||||
<div v-if="hasError" class="hero-video__error">
|
||||
<div v-if="hasError || !muxPlayerUrl" class="hero-video__error">
|
||||
<v-icon :icon="mdiPlay" size="36" class="hero-video__error-icon" />
|
||||
<span class="hero-video__error-text">{{ t('hero.videoUnavailable') }}</span>
|
||||
<span class="hero-video__error-text">{{ t("hero.videoUnavailable") }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Video element -->
|
||||
<video
|
||||
v-show="!hasError"
|
||||
ref="videoRef"
|
||||
class="hero-video__player"
|
||||
:class="{ 'hero-video__player--loaded': isLoaded }"
|
||||
preload="metadata"
|
||||
poster="/screenshots/2.jpg"
|
||||
muted
|
||||
playsinline
|
||||
@timeupdate="onTimeUpdate"
|
||||
@click="togglePlay"
|
||||
>
|
||||
<source :src="videoSrc" type="video/mp4">
|
||||
</video>
|
||||
|
||||
<!-- Play overlay (when paused) -->
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="!isPlaying && isLoaded"
|
||||
class="hero-video__play-overlay"
|
||||
@click="togglePlay"
|
||||
>
|
||||
<div class="hero-video__play-btn">
|
||||
<v-icon :icon="mdiPlay" size="36" color="white" />
|
||||
</div>
|
||||
<span class="hero-video__play-label">{{ t('hero.watchDemo') }}</span>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Controls bar -->
|
||||
<Transition name="slide-up">
|
||||
<div
|
||||
v-if="isLoaded && showControls"
|
||||
class="hero-video__controls"
|
||||
>
|
||||
<!-- Progress bar -->
|
||||
<div class="hero-video__progress" @click="onSeek">
|
||||
<div class="hero-video__progress-track">
|
||||
<div
|
||||
class="hero-video__progress-fill"
|
||||
:style="{ width: `${progress}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hero-video__controls-row">
|
||||
<button class="hero-video__control-btn" :aria-label="isPlaying ? 'Pause' : 'Play'" @click.stop="togglePlay">
|
||||
<v-icon :icon="isPlaying ? mdiPause : mdiPlay" size="18" />
|
||||
</button>
|
||||
|
||||
<button class="hero-video__control-btn" :aria-label="isMuted ? 'Unmute' : 'Mute'" @click.stop="toggleMute">
|
||||
<v-icon :icon="isMuted ? mdiVolumeOff : mdiVolumeHigh" size="18" />
|
||||
</button>
|
||||
|
||||
<div class="hero-video__spacer" />
|
||||
|
||||
<button class="hero-video__control-btn" aria-label="Fullscreen" @click.stop="toggleFullscreen">
|
||||
<v-icon :icon="mdiFullscreen" size="18" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<div class="hero-video__scan" aria-hidden="true" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hero-video {
|
||||
--hero-video-cyan: #00f0ff;
|
||||
--hero-video-cyan-soft: rgba(0, 240, 255, 0.22);
|
||||
--hero-video-magenta: #ff2bff;
|
||||
--hero-video-magenta-soft: rgba(255, 43, 255, 0.18);
|
||||
--hero-video-dark: rgba(2, 6, 23, 0.96);
|
||||
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: 16px;
|
||||
background: rgba(10, 10, 15, 0.95);
|
||||
background:
|
||||
radial-gradient(circle at 18% 0%, var(--hero-video-cyan-soft), transparent 42%),
|
||||
radial-gradient(circle at 88% 100%, var(--hero-video-magenta-soft), transparent 38%),
|
||||
var(--hero-video-dark);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(0, 240, 255, 0.15);
|
||||
border: 1px solid rgba(0, 240, 255, 0.34);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
box-shadow:
|
||||
0 20px 60px rgba(0, 0, 0, 0.6),
|
||||
0 0 30px rgba(0, 240, 255, 0.05),
|
||||
inset 0 1px 0 rgba(0, 240, 255, 0.1);
|
||||
0 0 34px rgba(0, 240, 255, 0.18),
|
||||
0 0 52px rgba(255, 43, 255, 0.1),
|
||||
inset 0 1px 0 rgba(230, 251, 255, 0.18);
|
||||
}
|
||||
|
||||
.hero-video::before,
|
||||
.hero-video::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.hero-video::before {
|
||||
inset: 0;
|
||||
border: 1px solid rgba(230, 251, 255, 0.16);
|
||||
border-radius: inherit;
|
||||
box-shadow:
|
||||
inset 0 0 28px rgba(0, 240, 255, 0.08),
|
||||
inset 0 -26px 42px rgba(2, 6, 23, 0.44);
|
||||
}
|
||||
|
||||
.hero-video::after {
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(90deg, transparent 0 7%, rgba(0, 240, 255, 0.1) 7.2% 7.55%, transparent 7.8%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.1), transparent 18%, transparent 78%, rgba(0, 240, 255, 0.1));
|
||||
mix-blend-mode: screen;
|
||||
opacity: 0.32;
|
||||
}
|
||||
|
||||
.hero-video__ambient,
|
||||
.hero-video__scan,
|
||||
.hero-video__edge,
|
||||
.hero-video__corner {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hero-video__ambient {
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(0, 240, 255, 0.14), transparent 28%, transparent 68%, rgba(255, 43, 255, 0.16)),
|
||||
radial-gradient(circle at 50% 50%, transparent 58%, rgba(0, 0, 0, 0.34));
|
||||
mix-blend-mode: screen;
|
||||
opacity: 0.74;
|
||||
}
|
||||
|
||||
.hero-video__scan {
|
||||
inset: 0;
|
||||
z-index: 5;
|
||||
background:
|
||||
repeating-linear-gradient(to bottom, rgba(230, 251, 255, 0.1) 0 1px, transparent 1px 5px),
|
||||
linear-gradient(90deg, transparent, rgba(0, 240, 255, 0.12), transparent);
|
||||
mix-blend-mode: soft-light;
|
||||
opacity: 0.22;
|
||||
}
|
||||
|
||||
.hero-video__edge {
|
||||
left: 18px;
|
||||
right: 18px;
|
||||
z-index: 6;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, var(--hero-video-cyan), var(--hero-video-magenta), transparent);
|
||||
box-shadow: 0 0 16px rgba(0, 240, 255, 0.45);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.hero-video__edge--top {
|
||||
top: 9px;
|
||||
}
|
||||
|
||||
.hero-video__edge--bottom {
|
||||
bottom: 9px;
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.hero-video__corner {
|
||||
z-index: 6;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-color: var(--hero-video-cyan);
|
||||
filter: drop-shadow(0 0 10px rgba(0, 240, 255, 0.54));
|
||||
}
|
||||
|
||||
.hero-video__corner--tl {
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
border-top: 2px solid;
|
||||
border-left: 2px solid;
|
||||
}
|
||||
|
||||
.hero-video__corner--tr {
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
border-top: 2px solid;
|
||||
border-right: 2px solid;
|
||||
}
|
||||
|
||||
.hero-video__corner--bl {
|
||||
bottom: 8px;
|
||||
left: 8px;
|
||||
border-bottom: 2px solid;
|
||||
border-left: 2px solid;
|
||||
}
|
||||
|
||||
.hero-video__corner--br {
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
border-right: 2px solid;
|
||||
border-bottom: 2px solid;
|
||||
}
|
||||
|
||||
/* ─── Video player ─── */
|
||||
.hero-video__player {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: 16px;
|
||||
height: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: 14px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.5s ease;
|
||||
border: none;
|
||||
background: #020617;
|
||||
}
|
||||
|
||||
.hero-video__player--loaded {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ─── Loading skeleton ─── */
|
||||
.hero-video__skeleton {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
|
@ -308,7 +273,8 @@ onUnmounted(() => {
|
|||
justify-content: center;
|
||||
border-radius: 16px;
|
||||
background: rgba(6, 10, 18, 0.96);
|
||||
z-index: 2;
|
||||
z-index: 7;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hero-video__skeleton::before,
|
||||
|
|
@ -381,26 +347,9 @@ onUnmounted(() => {
|
|||
text-shadow: 0 0 16px rgba(0, 240, 255, 0.42);
|
||||
}
|
||||
|
||||
.hero-video__skeleton-bar {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-radius: 0 0 16px 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero-video__skeleton-bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #00f0ff, #ff00ff);
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes skeletonPulse {
|
||||
0%, 100% { opacity: 0.3; }
|
||||
0%,
|
||||
100% { opacity: 0.3; }
|
||||
50% { opacity: 0.8; }
|
||||
}
|
||||
|
||||
|
|
@ -408,15 +357,19 @@ onUnmounted(() => {
|
|||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ─── Error fallback ─── */
|
||||
.hero-video__error {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 7;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
min-height: 280px;
|
||||
padding: 32px;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(0, 234, 255, 0.08), rgba(255, 43, 255, 0.05)),
|
||||
rgba(2, 6, 16, 0.94);
|
||||
}
|
||||
|
||||
.hero-video__error-icon {
|
||||
|
|
@ -430,137 +383,6 @@ onUnmounted(() => {
|
|||
text-align: center;
|
||||
}
|
||||
|
||||
/* ─── Play overlay ─── */
|
||||
.hero-video__play-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
z-index: 3;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hero-video__play-btn {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 240, 255, 0.15);
|
||||
border: 2px solid rgba(0, 240, 255, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 0 30px rgba(0, 240, 255, 0.2);
|
||||
}
|
||||
|
||||
.hero-video__play-btn:hover {
|
||||
background: rgba(0, 240, 255, 0.25);
|
||||
border-color: rgba(0, 240, 255, 0.6);
|
||||
box-shadow: 0 0 40px rgba(0, 240, 255, 0.35);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.hero-video__play-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* ─── Controls bar ─── */
|
||||
.hero-video__controls {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
|
||||
padding: 16px 12px 8px;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.hero-video__controls-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.hero-video__control-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.hero-video__control-btn:hover {
|
||||
background: rgba(0, 240, 255, 0.15);
|
||||
color: #00f0ff;
|
||||
}
|
||||
|
||||
.hero-video__spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ─── Progress bar ─── */
|
||||
.hero-video__progress {
|
||||
padding: 4px 0;
|
||||
cursor: pointer;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.hero-video__progress-track {
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
transition: height 0.2s ease;
|
||||
}
|
||||
|
||||
.hero-video__progress:hover .hero-video__progress-track {
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
.hero-video__progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #00f0ff, #ff00ff);
|
||||
border-radius: 2px;
|
||||
transition: width 0.1s linear;
|
||||
}
|
||||
|
||||
/* ─── Transitions ─── */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-up-enter-active,
|
||||
.slide-up-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.slide-up-enter-from,
|
||||
.slide-up-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
/* ─── Responsive ─── */
|
||||
@media (max-width: 960px) {
|
||||
.hero-video {
|
||||
max-width: 100%;
|
||||
|
|
@ -573,16 +395,17 @@ onUnmounted(() => {
|
|||
}
|
||||
|
||||
.hero-video__player {
|
||||
border-radius: 12px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.hero-video__play-btn {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
.hero-video__edge {
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
}
|
||||
|
||||
.hero-video__play-label {
|
||||
font-size: 11px;
|
||||
.hero-video__corner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -196,3 +196,114 @@ export const heroFeatureRail = [
|
|||
text: "Local-first workflow with task logs, process control, and Git visibility.",
|
||||
},
|
||||
] as const;
|
||||
|
||||
const ruHeroAgentCopy: Record<HeroAgentRole, Pick<HeroAgent, "label" | "status" | "tasks"> & { compactLabel?: string }> = {
|
||||
planner: {
|
||||
label: "Планировщик",
|
||||
compactLabel: "План",
|
||||
status: "Планирует",
|
||||
tasks: ["Анализ требований", "Декомпозиция задач", "Создание плана"],
|
||||
},
|
||||
lead: {
|
||||
label: "Лид",
|
||||
compactLabel: "Лид",
|
||||
status: "Координирует",
|
||||
tasks: ["Архитектура", "Приоритеты", "Координация команды"],
|
||||
},
|
||||
developer: {
|
||||
label: "Разработчик",
|
||||
compactLabel: "Код",
|
||||
status: "Пишет код",
|
||||
tasks: ["Реализация фичи", "Обновление кода", "Запуск проверок"],
|
||||
},
|
||||
reviewer: {
|
||||
label: "Ревьюер",
|
||||
status: "Ревьюит",
|
||||
tasks: ["Ревью кода", "Проверка качества", "Запрос правок"],
|
||||
},
|
||||
tester: { label: "Тестировщик", status: "Тестирует", tasks: [] },
|
||||
researcher: { label: "Ресёрчер", status: "Исследует", tasks: [] },
|
||||
docs: { label: "Документация", status: "Документирует", tasks: [] },
|
||||
ops: { label: "Операции", status: "Следит", tasks: [] },
|
||||
security: { label: "Безопасность", status: "Проверяет", tasks: [] },
|
||||
fixer: { label: "Фиксер", status: "Исправляет", tasks: [] },
|
||||
};
|
||||
|
||||
const ruHeroMessages: Record<string, Pick<HeroMessage, "text" | "response">> = {
|
||||
"plan-ready": { text: "План готов.", response: "Приоритет задан." },
|
||||
"build-ready": { text: "Скоуп задан.", response: "Кодинг начат." },
|
||||
"review-build": { text: "Проверь сборку.", response: "Проверяю качество." },
|
||||
"review-pass": { text: "Ревью пройдено.", response: "Готово к релизу." },
|
||||
};
|
||||
|
||||
const ruHeroFeatureRail: Record<string, { title: string; text: string }> = {
|
||||
autonomous: {
|
||||
title: "Дайте команде цель",
|
||||
text: "Агенты сами разобьют её на задачи и начнут двигаться без микроменеджмента.",
|
||||
},
|
||||
kanban: {
|
||||
title: "Канбан обновляется сам",
|
||||
text: "Карточки двигаются, пока агенты пишут, тестируют, ревьюят и разблокируют друг друга.",
|
||||
},
|
||||
developers: {
|
||||
title: "Подключайте свой AI-стек",
|
||||
text: "Claude, Codex и OpenCode в одном десктопном центре управления.",
|
||||
},
|
||||
secure: {
|
||||
title: "Оставайтесь в контуре",
|
||||
text: "Подключайтесь через комментарии, подтверждения, прямые сообщения и быстрые действия.",
|
||||
},
|
||||
local: {
|
||||
title: "Ваша машина, ваш код",
|
||||
text: "Локальный рабочий процесс с логами задач, управлением процессами и видимостью Git.",
|
||||
},
|
||||
};
|
||||
|
||||
const isRuLocale = (locale: string) => locale.toLowerCase().startsWith("ru");
|
||||
|
||||
export function getLocalizedHeroAgents(locale: string): readonly HeroAgent[] {
|
||||
if (!isRuLocale(locale)) return heroAgents;
|
||||
|
||||
return heroAgents.map((agent) => {
|
||||
const copy = ruHeroAgentCopy[agent.id];
|
||||
return {
|
||||
...agent,
|
||||
label: copy.label,
|
||||
status: copy.status,
|
||||
tasks: copy.tasks,
|
||||
mobile: {
|
||||
...agent.mobile,
|
||||
compactLabel: copy.compactLabel ?? agent.mobile.compactLabel,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function getLocalizedHeroReviewerFeatureCard(locale: string): typeof heroReviewerFeatureCard {
|
||||
if (!isRuLocale(locale)) return heroReviewerFeatureCard;
|
||||
const copy = ruHeroAgentCopy.reviewer;
|
||||
return {
|
||||
...heroReviewerFeatureCard,
|
||||
label: copy.label,
|
||||
status: copy.status,
|
||||
tasks: copy.tasks,
|
||||
};
|
||||
}
|
||||
|
||||
export function getLocalizedHeroMessages(locale: string): readonly HeroMessage[] {
|
||||
if (!isRuLocale(locale)) return heroMessages;
|
||||
|
||||
return heroMessages.map((message) => ({
|
||||
...message,
|
||||
...(ruHeroMessages[message.id] ?? {}),
|
||||
}));
|
||||
}
|
||||
|
||||
export function getLocalizedHeroFeatureRail(locale: string): typeof heroFeatureRail {
|
||||
if (!isRuLocale(locale)) return heroFeatureRail;
|
||||
|
||||
return heroFeatureRail.map((feature) => ({
|
||||
...feature,
|
||||
...(ruHeroFeatureRail[feature.id] ?? {}),
|
||||
})) as typeof heroFeatureRail;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
export type Screenshot = {
|
||||
src: string;
|
||||
alt: string;
|
||||
ruAlt?: string;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
|
@ -10,16 +11,16 @@ export type Screenshot = {
|
|||
* `src` is relative to public/ — prepend baseURL at runtime.
|
||||
*/
|
||||
export const screenshots: (Omit<Screenshot, "src"> & { path: string })[] = [
|
||||
{ path: "screenshots/1.jpg", alt: "Kanban board with agent tasks", width: 1920, height: 1080 },
|
||||
{ path: "screenshots/2.jpg", alt: "Agent team communication", width: 1920, height: 1080 },
|
||||
{ path: "screenshots/3.png", alt: "Code review diff view", width: 1920, height: 1080 },
|
||||
{ path: "screenshots/4.png", alt: "Team management dashboard", width: 1920, height: 1080 },
|
||||
{ path: "screenshots/5.png", alt: "Live process monitoring", width: 1920, height: 1080 },
|
||||
{ path: "screenshots/6.png", alt: "Session context analysis", width: 1920, height: 1080 },
|
||||
{ path: "screenshots/7.png", alt: "Cross-team messaging", width: 1920, height: 1080 },
|
||||
{ path: "screenshots/8.png", alt: "Task details and comments", width: 1920, height: 1080 },
|
||||
{ path: "screenshots/9.png", alt: "Built-in code editor", width: 1920, height: 1080 },
|
||||
{ path: "screenshots/10.png", alt: "Task details with code changes and execution logs", width: 2624, height: 1642 },
|
||||
{ path: "screenshots/11.png", alt: "Agent code review comments and task workflow", width: 2624, height: 1696 },
|
||||
{ path: "screenshots/12.png", alt: "Allow or deny agent actions with live preview", width: 2624, height: 1646 },
|
||||
{ path: "screenshots/1.jpg", alt: "Kanban board with agent tasks", ruAlt: "Канбан-доска с задачами агентов", width: 1920, height: 1080 },
|
||||
{ path: "screenshots/2.jpg", alt: "Agent team communication", ruAlt: "Коммуникация команды агентов", width: 1920, height: 1080 },
|
||||
{ path: "screenshots/3.png", alt: "Code review diff view", ruAlt: "Diff-просмотр для код-ревью", width: 1920, height: 1080 },
|
||||
{ path: "screenshots/4.png", alt: "Team management dashboard", ruAlt: "Панель управления командой", width: 1920, height: 1080 },
|
||||
{ path: "screenshots/5.png", alt: "Live process monitoring", ruAlt: "Мониторинг живых процессов", width: 1920, height: 1080 },
|
||||
{ path: "screenshots/6.png", alt: "Session context analysis", ruAlt: "Анализ контекста сессии", width: 1920, height: 1080 },
|
||||
{ path: "screenshots/7.png", alt: "Cross-team messaging", ruAlt: "Сообщения между командами", width: 1920, height: 1080 },
|
||||
{ path: "screenshots/8.png", alt: "Task details and comments", ruAlt: "Детали задачи и комментарии", width: 1920, height: 1080 },
|
||||
{ path: "screenshots/9.png", alt: "Built-in code editor", ruAlt: "Встроенный редактор кода", width: 1920, height: 1080 },
|
||||
{ path: "screenshots/10.png", alt: "Task details with code changes and execution logs", ruAlt: "Детали задачи с изменениями кода и логами выполнения", width: 2624, height: 1642 },
|
||||
{ path: "screenshots/11.png", alt: "Agent code review comments and task workflow", ruAlt: "Комментарии агента к код-ревью и процессу задачи", width: 2624, height: 1696 },
|
||||
{ path: "screenshots/12.png", alt: "Allow or deny agent actions with live preview", ruAlt: "Разрешение или запрет действий агента с предпросмотром", width: 2624, height: 1646 },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
"download": "Скачать",
|
||||
"pricing": "Бесплатно",
|
||||
"faq": "FAQ",
|
||||
"viewOnGithub": "View on GitHub"
|
||||
"viewOnGithub": "GitHub"
|
||||
},
|
||||
"hero": {
|
||||
"badge": "Agent Teams",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ declare const process: {
|
|||
const siteUrl = process.env.NUXT_PUBLIC_SITE_URL || "https://777genius.github.io/agent-teams-ai";
|
||||
const githubRepo = process.env.NUXT_PUBLIC_GITHUB_REPO || "777genius/agent-teams-ai";
|
||||
const githubReleasesUrl = `https://github.com/${githubRepo}/releases`;
|
||||
const muxPlaybackId = process.env.NUXT_PUBLIC_MUX_PLAYBACK_ID || "qyeNuDjFqoDALK8eB02jMTOWUz006BdIhiqiAip3U00x7I";
|
||||
const muxBackgroundPlaybackId = process.env.NUXT_PUBLIC_MUX_BACKGROUND_PLAYBACK_ID || muxPlaybackId;
|
||||
const baseURL = process.env.NUXT_APP_BASE_URL || "/";
|
||||
const basePrefixedDocsPath = `${baseURL.replace(/\/?$/, "/")}docs`;
|
||||
|
||||
|
|
@ -50,7 +52,7 @@ export default defineNuxtConfig({
|
|||
},
|
||||
vue: {
|
||||
compilerOptions: {
|
||||
isCustomElement: (tag: string) => tag.startsWith("swiper-")
|
||||
isCustomElement: (tag: string) => tag.startsWith("swiper-") || tag === "mux-video"
|
||||
}
|
||||
},
|
||||
vite: {
|
||||
|
|
@ -108,7 +110,9 @@ export default defineNuxtConfig({
|
|||
public: {
|
||||
siteUrl,
|
||||
githubRepo,
|
||||
githubReleasesUrl
|
||||
githubReleasesUrl,
|
||||
muxPlaybackId,
|
||||
muxBackgroundPlaybackId
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
"dependencies": {
|
||||
"@firecms/neat": "^0.8.0",
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@mux/mux-video": "^0.31.0",
|
||||
"@nuxtjs/i18n": "^9.5.6",
|
||||
"@pinia/nuxt": "^0.11.3",
|
||||
"@vueuse/nuxt": "^10.11.1",
|
||||
|
|
|
|||
|
|
@ -4,12 +4,20 @@ export default defineNuxtPlugin({
|
|||
setup(nuxtApp) {
|
||||
const { initTheme } = useBrowserTheme();
|
||||
const { initLocale } = useLocation();
|
||||
let initialized = false;
|
||||
|
||||
initTheme();
|
||||
|
||||
nuxtApp.hook("app:mounted", () => {
|
||||
const initializeBrowserState = () => {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
initTheme();
|
||||
initLocale();
|
||||
});
|
||||
};
|
||||
|
||||
if (nuxtApp.isHydrating) {
|
||||
nuxtApp.hooks.hookOnce("app:suspense:resolve", initializeBrowserState);
|
||||
return;
|
||||
}
|
||||
|
||||
nuxtApp.hook("app:mounted", initializeBrowserState);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -18,13 +18,8 @@ function isThemeName(value: string | null | undefined): value is ThemeName {
|
|||
}
|
||||
|
||||
function resolveInitialTheme(cookieTheme: ThemeName | null): ThemeName {
|
||||
if (import.meta.client) {
|
||||
const saved = localStorage.getItem("theme");
|
||||
if (isThemeName(saved)) return saved;
|
||||
if (cookieTheme) return cookieTheme;
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
|
||||
// Keep the first client render identical to SSR. Browser-only sources
|
||||
// are applied after mount by init-theme-locale.client.ts.
|
||||
return cookieTheme ?? "light";
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue