Compare commits
7 Commits
split/node
...
feat/home-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
619d6d3bf9 | ||
|
|
f6099e418b | ||
|
|
95d5127d5b | ||
|
|
7a145d2fc6 | ||
|
|
a7cc2f87bf | ||
|
|
969e32afc1 | ||
|
|
949af59c4f |
@@ -1,5 +1,6 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import type { Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
@@ -153,6 +154,76 @@ test.describe('Product showcase accordion @interaction', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Hero image picker @interaction', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
})
|
||||
|
||||
// Exclude the glitch swap's outgoing image, which lingers in the DOM until
|
||||
// the transition settles, so the locator always resolves to the current one.
|
||||
const activeImage = (page: Page) =>
|
||||
page.locator(
|
||||
'[data-testid="hero-active-image"]:visible:not(.hero-glitch-leave-active)'
|
||||
)
|
||||
|
||||
const outputImage = (page: Page) =>
|
||||
page.locator(
|
||||
'[data-testid="hero-output-image"]:visible:not(.hero-glitch-leave-active)'
|
||||
)
|
||||
|
||||
test('defaults to the portrait variant with its thumbnail selected', async ({
|
||||
page
|
||||
}) => {
|
||||
await expect(activeImage(page)).toHaveAttribute(
|
||||
'src',
|
||||
/input-portrait\.png/
|
||||
)
|
||||
await expect(outputImage(page).first()).toHaveAttribute(
|
||||
'src',
|
||||
/output-cyberpunk\.png/
|
||||
)
|
||||
await expect(
|
||||
page.getByRole('button', { name: /portrait/i })
|
||||
).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
|
||||
test('hovering a thumbnail swaps the large image and moves selection', async ({
|
||||
page
|
||||
}) => {
|
||||
const deerThumb = page.getByRole('button', { name: /deer/i })
|
||||
await deerThumb.hover()
|
||||
|
||||
await expect(activeImage(page)).toHaveAttribute('src', /input-deer\.png/)
|
||||
await expect(deerThumb).toHaveAttribute('aria-pressed', 'true')
|
||||
await expect(
|
||||
page.getByRole('button', { name: /portrait/i })
|
||||
).toHaveAttribute('aria-pressed', 'false')
|
||||
})
|
||||
|
||||
test('selecting an input cascades the matching output', async ({ page }) => {
|
||||
await page.getByRole('button', { name: /vase/i }).click()
|
||||
await expect(outputImage(page).first()).toHaveAttribute(
|
||||
'src',
|
||||
/output-vase\.png/
|
||||
)
|
||||
|
||||
await page.getByRole('button', { name: /deer/i }).click()
|
||||
await expect(outputImage(page).first()).toHaveAttribute(
|
||||
'src',
|
||||
/output-deer\.png/
|
||||
)
|
||||
})
|
||||
|
||||
test('thumbnails are keyboard operable', async ({ page }) => {
|
||||
const mirrorThumb = page.getByRole('button', { name: /mirror/i })
|
||||
await mirrorThumb.focus()
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
await expect(activeImage(page)).toHaveAttribute('src', /input-mirror\.png/)
|
||||
await expect(mirrorThumb).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Video player @interaction', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route(
|
||||
|
||||
@@ -34,7 +34,6 @@
|
||||
"lenis": "catalog:",
|
||||
"posthog-js": "catalog:",
|
||||
"reka-ui": "catalog:",
|
||||
"three": "catalog:",
|
||||
"vue": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
|
||||
BIN
apps/website/public/images/hero/input-deer.png
Normal file
|
After Width: | Height: | Size: 356 KiB |
BIN
apps/website/public/images/hero/input-mirror.png
Normal file
|
After Width: | Height: | Size: 333 KiB |
BIN
apps/website/public/images/hero/input-portrait.png
Normal file
|
After Width: | Height: | Size: 379 KiB |
BIN
apps/website/public/images/hero/input-vase.png
Normal file
|
After Width: | Height: | Size: 351 KiB |
BIN
apps/website/public/images/hero/output-cyberpunk.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
apps/website/public/images/hero/output-deer.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
apps/website/public/images/hero/output-mirror.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
apps/website/public/images/hero/output-vase.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
89
apps/website/src/components/home/HeroColorNode.vue
Normal file
@@ -0,0 +1,89 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { colorPresets, colorSwatches } from './useHeroControls'
|
||||
import type { HeroControls } from './useHeroControls'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { controls, locale = 'en' } = defineProps<{
|
||||
controls: HeroControls
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const { colorPresetId, swatchId, colorIntensity } = controls
|
||||
|
||||
const remixLabel = computed(
|
||||
() => `${t('hero.color.remix', locale)} ${colorIntensity.value}%`
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<div
|
||||
class="flex gap-1.5"
|
||||
role="group"
|
||||
:aria-label="t('hero.color.palette', locale)"
|
||||
>
|
||||
<button
|
||||
v-for="s in colorSwatches"
|
||||
:key="s.id"
|
||||
type="button"
|
||||
:aria-pressed="s.id === swatchId"
|
||||
:aria-label="t(s.labelKey, locale)"
|
||||
:style="{ backgroundColor: `rgb(${s.rgb})` }"
|
||||
:class="
|
||||
cn(
|
||||
'size-5 rounded-full ring-1 ring-white/20 transition-transform ring-inset',
|
||||
s.id === swatchId
|
||||
? 'scale-110 ring-2 ring-white/80'
|
||||
: 'opacity-75 hover:opacity-100'
|
||||
)
|
||||
"
|
||||
@pointerdown.stop
|
||||
@click.stop="swatchId = s.id"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-1.5">
|
||||
<button
|
||||
v-for="p in colorPresets"
|
||||
:key="p.id"
|
||||
type="button"
|
||||
:aria-pressed="p.id === colorPresetId"
|
||||
:class="
|
||||
cn(
|
||||
'rounded-md px-1.5 py-1 text-[0.6rem] font-medium uppercase transition-colors',
|
||||
p.id === colorPresetId
|
||||
? 'bg-primary-comfy-yellow text-primary-comfy-ink'
|
||||
: 'bg-white/5 text-primary-comfy-canvas hover:bg-white/10'
|
||||
)
|
||||
"
|
||||
@pointerdown.stop
|
||||
@click.stop="colorPresetId = p.id"
|
||||
>
|
||||
{{ t(p.labelKey, locale) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label class="flex flex-col gap-1">
|
||||
<span
|
||||
class="text-primary-warm-gray text-[0.6rem] font-medium tracking-wide uppercase"
|
||||
>
|
||||
{{ remixLabel }}
|
||||
</span>
|
||||
<input
|
||||
v-model.number="colorIntensity"
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
:aria-label="remixLabel"
|
||||
class="accent-primary-comfy-yellow h-1 w-full cursor-pointer"
|
||||
@pointerdown.stop
|
||||
@click.stop
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
440
apps/website/src/components/home/HeroGraph.vue
Normal file
@@ -0,0 +1,440 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { useResizeObserver } from '@vueuse/core'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import HeroColorNode from './HeroColorNode.vue'
|
||||
import HeroGraphNode from './HeroGraphNode.vue'
|
||||
import HeroHeadline from './HeroHeadline.vue'
|
||||
import HeroImagePicker from './HeroImagePicker.vue'
|
||||
import HeroLightingNode from './HeroLightingNode.vue'
|
||||
import HeroOutputFrame from './HeroOutputFrame.vue'
|
||||
import { imageVariants, textureImage } from './heroGraphData'
|
||||
import { computeWires } from './heroGraphWires'
|
||||
import type { NodeId, Point, Rect } from './heroGraphWires'
|
||||
import { useHeroControls } from './useHeroControls'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const controls = useHeroControls()
|
||||
const { activeNode } = controls
|
||||
|
||||
const activeId = ref<string>(imageVariants[0].id)
|
||||
const activeVariant = computed(
|
||||
() => imageVariants.find((v) => v.id === activeId.value) ?? imageVariants[0]
|
||||
)
|
||||
|
||||
// The desktop graph is authored in a fixed design coordinate space and scaled
|
||||
// as a single unit to fit the viewport width, so wires and the OUTPUT bleed are
|
||||
// preserved on every screen. Node positions are live state so they can be
|
||||
// dragged; widths are fixed per node and heights are measured once for wiring.
|
||||
const STAGE_W = 1600
|
||||
const STAGE_H = 780
|
||||
const MAX_SCALE = 1.3
|
||||
|
||||
const NODE_W: Record<NodeId, number> = {
|
||||
image: 300,
|
||||
texture: 200,
|
||||
color: 210,
|
||||
lighting: 210,
|
||||
output: 760
|
||||
}
|
||||
|
||||
// Whole graph is nudged left of the stage centre so the OUTPUT node bleeds less
|
||||
// far off the right edge.
|
||||
const positions = ref<Record<NodeId, Point>>({
|
||||
image: { x: 16, y: 28 },
|
||||
texture: { x: 52, y: 512 },
|
||||
color: { x: 404, y: 446 },
|
||||
lighting: { x: 662, y: 446 },
|
||||
output: { x: 956, y: 110 }
|
||||
})
|
||||
|
||||
const frameRef = ref<HTMLElement>()
|
||||
const stageRef = ref<HTMLElement>()
|
||||
const scale = ref(1)
|
||||
const heights = ref<Record<string, number>>({})
|
||||
|
||||
// Heights are read from layout offsets (not getBoundingClientRect) so they stay
|
||||
// in unscaled design coordinates regardless of the stage's scale transform.
|
||||
function measureHeights() {
|
||||
const stage = stageRef.value
|
||||
if (!stage) return
|
||||
const next: Record<string, number> = {}
|
||||
stage.querySelectorAll<HTMLElement>('[data-node]').forEach((el) => {
|
||||
next[el.dataset.node ?? ''] = el.offsetHeight
|
||||
})
|
||||
heights.value = next
|
||||
}
|
||||
|
||||
function updateScale() {
|
||||
const width = frameRef.value?.clientWidth ?? STAGE_W
|
||||
scale.value = Math.min(width / STAGE_W, MAX_SCALE)
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
updateScale()
|
||||
measureHeights()
|
||||
}
|
||||
|
||||
useResizeObserver(frameRef, refresh)
|
||||
|
||||
const stageStyle = computed(() => ({
|
||||
width: `${STAGE_W}px`,
|
||||
height: `${STAGE_H}px`,
|
||||
transform: `translateX(-50%) scale(${scale.value})`
|
||||
}))
|
||||
|
||||
function nodeStyle(id: NodeId) {
|
||||
return {
|
||||
left: `${positions.value[id].x}px`,
|
||||
top: `${positions.value[id].y}px`,
|
||||
width: `${NODE_W[id]}px`
|
||||
}
|
||||
}
|
||||
|
||||
// Wires recompute from live positions + measured heights, so they track the
|
||||
// nodes synchronously while dragging with no measure round-trip.
|
||||
const anchors = computed<Record<NodeId, Rect>>(() => {
|
||||
const ids = Object.keys(positions.value) as NodeId[]
|
||||
return Object.fromEntries(
|
||||
ids.map((id) => [
|
||||
id,
|
||||
{ ...positions.value[id], w: NODE_W[id], h: heights.value[id] ?? 0 }
|
||||
])
|
||||
) as Record<NodeId, Rect>
|
||||
})
|
||||
|
||||
const dragging = ref<NodeId | null>(null)
|
||||
let drag = { id: '' as NodeId, pointerId: -1, px: 0, py: 0, ox: 0, oy: 0 }
|
||||
|
||||
function onPointerDown(id: NodeId, e: PointerEvent) {
|
||||
if (e.button !== 0) return
|
||||
drag = {
|
||||
id,
|
||||
pointerId: e.pointerId,
|
||||
px: e.clientX,
|
||||
py: e.clientY,
|
||||
ox: positions.value[id].x,
|
||||
oy: positions.value[id].y
|
||||
}
|
||||
dragging.value = id
|
||||
}
|
||||
|
||||
// A small threshold keeps taps on the image picker from registering as drags.
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (dragging.value == null || e.pointerId !== drag.pointerId) return
|
||||
const dx = e.clientX - drag.px
|
||||
const dy = e.clientY - drag.py
|
||||
if (Math.hypot(dx, dy) < 4) return
|
||||
positions.value[drag.id] = {
|
||||
x: drag.ox + dx / scale.value,
|
||||
y: drag.oy + dy / scale.value
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
dragging.value = null
|
||||
}
|
||||
|
||||
// Listeners live on window so a drag continues even when the pointer outruns the
|
||||
// node; registered in onMounted to keep window off the SSR path.
|
||||
onMounted(() => {
|
||||
void nextTick(refresh)
|
||||
window.addEventListener('pointermove', onPointerMove)
|
||||
window.addEventListener('pointerup', onPointerUp)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('pointermove', onPointerMove)
|
||||
window.removeEventListener('pointerup', onPointerUp)
|
||||
})
|
||||
|
||||
const wires = computed(() => computeWires(anchors.value))
|
||||
|
||||
const dots = computed<{ p: Point; accent: boolean }[]>(() =>
|
||||
wires.value.flatMap((w) => [
|
||||
{ p: w.from, accent: w.accent },
|
||||
{ p: w.to, accent: w.accent }
|
||||
])
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative w-full">
|
||||
<!-- Desktop / large screens: a fixed design stage scaled to fit the width -->
|
||||
<div
|
||||
ref="frameRef"
|
||||
class="relative hidden aspect-1600/780 max-h-[1000px] w-full lg:block"
|
||||
>
|
||||
<div
|
||||
ref="stageRef"
|
||||
class="absolute top-0 left-1/2 origin-top"
|
||||
:style="stageStyle"
|
||||
>
|
||||
<svg
|
||||
class="pointer-events-none absolute inset-0 size-full overflow-visible"
|
||||
:viewBox="`0 0 ${STAGE_W} ${STAGE_H}`"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
v-for="(wire, i) in wires"
|
||||
:key="i"
|
||||
:d="wire.d"
|
||||
:stroke="
|
||||
wire.accent
|
||||
? 'var(--color-primary-comfy-yellow)'
|
||||
: 'rgba(255,255,255,0.16)'
|
||||
"
|
||||
:stroke-width="wire.accent ? 2 : 1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<circle
|
||||
v-for="(dot, i) in dots"
|
||||
:key="`d${i}`"
|
||||
:cx="dot.p.x"
|
||||
:cy="dot.p.y"
|
||||
:r="dot.accent ? 4 : 3"
|
||||
:fill="
|
||||
dot.accent
|
||||
? 'var(--color-primary-comfy-yellow)'
|
||||
: 'rgba(255,255,255,0.3)'
|
||||
"
|
||||
/>
|
||||
<!-- Energy pulses that flow toward the OUTPUT while a control node is
|
||||
engaged; idle-hidden via opacity, animated through CSS. -->
|
||||
<g :class="cn(activeNode && 'hero-wire-active')">
|
||||
<path
|
||||
v-for="(wire, i) in wires"
|
||||
:key="`p${i}`"
|
||||
:d="wire.d"
|
||||
class="hero-wire-pulse"
|
||||
stroke="var(--color-primary-comfy-yellow)"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
pathLength="1"
|
||||
stroke-dasharray="0.18 0.82"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<div class="absolute top-[150px] left-[636px] z-20 -translate-x-1/2">
|
||||
<HeroHeadline :locale />
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-node="image"
|
||||
:class="
|
||||
cn(
|
||||
'absolute cursor-grab touch-none select-none active:cursor-grabbing',
|
||||
dragging === 'image' && 'z-30 cursor-grabbing'
|
||||
)
|
||||
"
|
||||
:style="nodeStyle('image')"
|
||||
@pointerdown="onPointerDown('image', $event)"
|
||||
>
|
||||
<HeroGraphNode :label="t('hero.node.image', locale)" accent>
|
||||
<HeroImagePicker
|
||||
:variants="imageVariants"
|
||||
:active-id="activeId"
|
||||
:locale
|
||||
@select="(id) => (activeId = id)"
|
||||
/>
|
||||
</HeroGraphNode>
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-node="texture"
|
||||
:class="
|
||||
cn(
|
||||
'absolute cursor-grab touch-none select-none active:cursor-grabbing',
|
||||
dragging === 'texture' && 'z-30 cursor-grabbing'
|
||||
)
|
||||
"
|
||||
:style="nodeStyle('texture')"
|
||||
@pointerdown="onPointerDown('texture', $event)"
|
||||
>
|
||||
<HeroGraphNode :label="t('hero.node.texture', locale)" accent>
|
||||
<div class="aspect-square w-full overflow-hidden rounded-xl">
|
||||
<img
|
||||
:src="textureImage.src"
|
||||
:alt="t(textureImage.altKey, locale)"
|
||||
draggable="false"
|
||||
class="size-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</HeroGraphNode>
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-node="color"
|
||||
:class="
|
||||
cn(
|
||||
'absolute cursor-grab touch-none select-none active:cursor-grabbing',
|
||||
dragging === 'color' && 'z-30 cursor-grabbing'
|
||||
)
|
||||
"
|
||||
:style="nodeStyle('color')"
|
||||
@pointerdown="onPointerDown('color', $event)"
|
||||
@pointerenter="activeNode = 'color'"
|
||||
@pointerleave="activeNode = null"
|
||||
>
|
||||
<HeroGraphNode
|
||||
:label="t('hero.node.color', locale)"
|
||||
:active="activeNode === 'color'"
|
||||
>
|
||||
<HeroColorNode :controls :locale />
|
||||
</HeroGraphNode>
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-node="lighting"
|
||||
:class="
|
||||
cn(
|
||||
'absolute cursor-grab touch-none select-none active:cursor-grabbing',
|
||||
dragging === 'lighting' && 'z-30 cursor-grabbing'
|
||||
)
|
||||
"
|
||||
:style="nodeStyle('lighting')"
|
||||
@pointerdown="onPointerDown('lighting', $event)"
|
||||
@pointerenter="activeNode = 'lighting'"
|
||||
@pointerleave="activeNode = null"
|
||||
>
|
||||
<HeroGraphNode
|
||||
:label="t('hero.node.lighting', locale)"
|
||||
:active="activeNode === 'lighting'"
|
||||
>
|
||||
<HeroLightingNode :controls :locale />
|
||||
</HeroGraphNode>
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-node="output"
|
||||
:class="
|
||||
cn(
|
||||
'absolute cursor-grab touch-none select-none active:cursor-grabbing',
|
||||
dragging === 'output' && 'z-30 cursor-grabbing'
|
||||
)
|
||||
"
|
||||
:style="nodeStyle('output')"
|
||||
@pointerdown="onPointerDown('output', $event)"
|
||||
>
|
||||
<HeroGraphNode :label="t('hero.node.output', locale)">
|
||||
<HeroOutputFrame
|
||||
:controls
|
||||
:variant="activeVariant"
|
||||
:locale
|
||||
class="h-[560px]"
|
||||
/>
|
||||
</HeroGraphNode>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile / tablet: a compact connected graph that fits one screen — the
|
||||
IMAGE selector forks into COLOR + LIGHTING, which merge into the live
|
||||
OUTPUT. Connectors are decorative SVGs aligned to the 2-column grid. -->
|
||||
<div class="flex flex-col items-center px-5 pt-3 pb-8 lg:hidden">
|
||||
<HeroHeadline :locale compact />
|
||||
|
||||
<div class="mt-3 w-full max-w-sm sm:max-w-md">
|
||||
<HeroGraphNode :label="t('hero.node.image', locale)" accent>
|
||||
<HeroImagePicker
|
||||
:variants="imageVariants"
|
||||
:active-id="activeId"
|
||||
:locale
|
||||
hide-preview
|
||||
thumb-class="h-14"
|
||||
@select="(id) => (activeId = id)"
|
||||
/>
|
||||
</HeroGraphNode>
|
||||
|
||||
<div class="relative h-6 w-full" aria-hidden="true">
|
||||
<svg
|
||||
class="absolute inset-0 size-full"
|
||||
viewBox="0 0 100 36"
|
||||
preserveAspectRatio="none"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M50 3 C 50 22 25 14 25 34"
|
||||
stroke="rgba(255,255,255,0.22)"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
<path
|
||||
d="M50 3 C 50 22 75 14 75 34"
|
||||
stroke="rgba(255,255,255,0.22)"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow absolute top-0 left-1/2 size-1.5 -translate-x-1/2 rounded-full"
|
||||
/>
|
||||
<span
|
||||
class="absolute bottom-0 left-1/4 size-1.5 -translate-x-1/2 rounded-full bg-white/40"
|
||||
/>
|
||||
<span
|
||||
class="absolute bottom-0 left-3/4 size-1.5 -translate-x-1/2 rounded-full bg-white/40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 items-stretch gap-2">
|
||||
<HeroGraphNode :label="t('hero.node.color', locale)">
|
||||
<HeroColorNode :controls :locale />
|
||||
</HeroGraphNode>
|
||||
<HeroGraphNode :label="t('hero.node.lighting', locale)">
|
||||
<HeroLightingNode :controls :locale />
|
||||
</HeroGraphNode>
|
||||
</div>
|
||||
|
||||
<div class="relative h-6 w-full" aria-hidden="true">
|
||||
<svg
|
||||
class="absolute inset-0 size-full"
|
||||
viewBox="0 0 100 36"
|
||||
preserveAspectRatio="none"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M25 2 C 25 18 50 14 50 33"
|
||||
stroke="rgba(255,255,255,0.22)"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
<path
|
||||
d="M75 2 C 75 18 50 14 50 33"
|
||||
stroke="rgba(255,255,255,0.22)"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="absolute top-0 left-1/4 size-1.5 -translate-x-1/2 rounded-full bg-white/40"
|
||||
/>
|
||||
<span
|
||||
class="absolute top-0 left-3/4 size-1.5 -translate-x-1/2 rounded-full bg-white/40"
|
||||
/>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow absolute bottom-0 left-1/2 size-1.5 -translate-x-1/2 rounded-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<HeroGraphNode :label="t('hero.node.output', locale)" accent>
|
||||
<HeroOutputFrame
|
||||
:controls
|
||||
:variant="activeVariant"
|
||||
:locale
|
||||
class="h-[150px]"
|
||||
/>
|
||||
</HeroGraphNode>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
40
apps/website/src/components/home/HeroGraphNode.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
const {
|
||||
label,
|
||||
accent = false,
|
||||
active = false,
|
||||
class: customClass = ''
|
||||
} = defineProps<{
|
||||
label: string
|
||||
accent?: boolean
|
||||
active?: boolean
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'bg-transparency-ink-t80 rounded-2xl border p-3 backdrop-blur-sm transition-shadow duration-500 ease-out hover:shadow-[0_0_45px_-15px_rgb(242_255_89/0.35)]',
|
||||
accent ? 'border-primary-comfy-yellow' : 'border-white/10',
|
||||
active &&
|
||||
'border-primary-comfy-yellow/70 shadow-[0_0_45px_-10px_rgb(242_255_89/0.5)]',
|
||||
customClass
|
||||
)
|
||||
"
|
||||
>
|
||||
<span
|
||||
class="text-primary-warm-gray block pl-1 text-[0.7rem] font-medium tracking-[0.22em] uppercase"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
<div class="mt-2">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
81
apps/website/src/components/home/HeroHeadline.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en', compact = false } = defineProps<{
|
||||
locale?: Locale
|
||||
compact?: boolean
|
||||
}>()
|
||||
|
||||
const lines = computed(() => t('hero.title', locale).split('\n'))
|
||||
|
||||
const size = computed(() => (compact ? 'text-3xl sm:text-4xl' : 'text-5xl'))
|
||||
|
||||
// Desktop splits the two lines apart so the liquid link reads as a bridge
|
||||
// travelling between them; mobile keeps them tightly stacked (no link there).
|
||||
const lineGap = computed(() => (compact ? '-mt-2' : 'mt-4'))
|
||||
|
||||
const pill =
|
||||
'inline-block rounded-2xl px-5 py-2 font-formula-narrow leading-none font-semibold uppercase'
|
||||
|
||||
// PP Formula Narrow sits high in its em box; nudge the glyphs down so they read
|
||||
// optically centered inside the highlighter block.
|
||||
const inner = 'relative top-[0.06em] inline-block'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<div class="inline-grid">
|
||||
<!-- Liquid yellow backing: the two pills merge through the goo filter
|
||||
(defined once in HeroSection). Text is transparent here, present only
|
||||
so each pill sizes to its line. -->
|
||||
<div
|
||||
class="relative col-start-1 row-start-1 flex flex-col items-center"
|
||||
style="filter: url(#hero-goo)"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span
|
||||
:class="cn(pill, size, 'bg-primary-comfy-yellow text-transparent')"
|
||||
>
|
||||
{{ lines[0] }}
|
||||
</span>
|
||||
<span
|
||||
:class="
|
||||
cn(pill, size, 'bg-primary-comfy-yellow text-transparent', lineGap)
|
||||
"
|
||||
>
|
||||
{{ lines[1] }}
|
||||
</span>
|
||||
<span
|
||||
v-if="!compact"
|
||||
class="hero-liquid-link bg-primary-comfy-yellow pointer-events-none absolute top-1/2 left-1/2 h-9 w-5 rounded-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Crisp dark text on top of the liquid backing -->
|
||||
<h1 class="col-start-1 row-start-1 flex flex-col items-center">
|
||||
<span :class="cn(pill, size, 'text-primary-comfy-ink')">
|
||||
<span :class="inner">{{ lines[0] }}</span>
|
||||
</span>
|
||||
<span :class="cn(pill, size, 'text-primary-comfy-ink', lineGap)">
|
||||
<span :class="inner">{{ lines[1] }}</span>
|
||||
</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<p
|
||||
:class="
|
||||
cn(
|
||||
'max-w-md text-primary-comfy-canvas',
|
||||
compact ? 'mt-5 hidden text-sm/relaxed sm:block' : 'mt-8 text-base'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ t('hero.subtitle', locale) }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
110
apps/website/src/components/home/HeroImagePicker.vue
Normal file
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { ImageVariant } from './heroGraphData'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const {
|
||||
variants,
|
||||
activeId,
|
||||
locale = 'en',
|
||||
previewSrc,
|
||||
previewAlt,
|
||||
previewTestId = 'hero-active-image',
|
||||
hint,
|
||||
hidePreview = false,
|
||||
thumbClass = 'aspect-square'
|
||||
} = defineProps<{
|
||||
variants: readonly ImageVariant[]
|
||||
activeId: string
|
||||
locale?: Locale
|
||||
// Override the large preview with the result the active input produces, so the
|
||||
// mobile card can lead with the OUTPUT while the thumbnails stay the selector.
|
||||
previewSrc?: string
|
||||
previewAlt?: string
|
||||
previewTestId?: string
|
||||
hint?: string
|
||||
// Render only the thumbnail strip, e.g. beneath a standalone output frame.
|
||||
hidePreview?: boolean
|
||||
// Sizing for each thumbnail; defaults to square, overridable for tight rows.
|
||||
thumbClass?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ select: [id: string] }>()
|
||||
|
||||
const active = computed(
|
||||
() => variants.find((v) => v.id === activeId) ?? variants[0]
|
||||
)
|
||||
|
||||
const preview = computed(() => ({
|
||||
src: previewSrc ?? active.value.src,
|
||||
alt: previewAlt ?? t(active.value.altKey, locale)
|
||||
}))
|
||||
|
||||
// Hover (mouse) and focus (keyboard) both swap the active variant. Click stays
|
||||
// as the activation path for touch, where there is no hover.
|
||||
function selectOnHover(id: string, e: PointerEvent) {
|
||||
if (e.pointerType === 'mouse') emit('select', id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
v-if="!hidePreview"
|
||||
class="relative aspect-square w-full overflow-hidden rounded-xl"
|
||||
>
|
||||
<Transition name="hero-glitch">
|
||||
<img
|
||||
:key="preview.src"
|
||||
:src="preview.src"
|
||||
:alt="preview.alt"
|
||||
:data-testid="previewTestId"
|
||||
draggable="false"
|
||||
class="absolute inset-0 size-full object-cover"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<p v-if="hint" class="text-primary-warm-gray pl-1 text-xs/relaxed">
|
||||
{{ hint }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="mt-2 flex gap-2"
|
||||
role="group"
|
||||
:aria-label="t('hero.image.pickerLabel', locale)"
|
||||
>
|
||||
<button
|
||||
v-for="variant in variants"
|
||||
:key="variant.id"
|
||||
type="button"
|
||||
:aria-pressed="variant.id === activeId"
|
||||
:aria-label="t(variant.altKey, locale)"
|
||||
:class="
|
||||
cn(
|
||||
'focus-visible:outline-primary-comfy-yellow relative flex-1 overflow-hidden rounded-md transition-opacity focus-visible:outline-2 focus-visible:outline-offset-2',
|
||||
thumbClass,
|
||||
variant.id === activeId
|
||||
? 'ring-primary-comfy-yellow opacity-100 ring-2'
|
||||
: 'opacity-50 hover:opacity-90'
|
||||
)
|
||||
"
|
||||
@pointerenter="selectOnHover(variant.id, $event)"
|
||||
@focus="emit('select', variant.id)"
|
||||
@click="emit('select', variant.id)"
|
||||
>
|
||||
<img
|
||||
:src="variant.src"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
draggable="false"
|
||||
class="size-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
117
apps/website/src/components/home/HeroLightingNode.vue
Normal file
@@ -0,0 +1,117 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { lightModes } from './useHeroControls'
|
||||
import type { HeroControls } from './useHeroControls'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { controls, locale = 'en' } = defineProps<{
|
||||
controls: HeroControls
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const { lightModeId, lightIntensity, lightDir, setLightFromUnit } = controls
|
||||
|
||||
const intensityLabel = computed(
|
||||
() => `${t('hero.light.intensity', locale)} ${lightIntensity.value}%`
|
||||
)
|
||||
|
||||
const dotStyle = computed(() => ({
|
||||
left: `${lightDir.value.x * 100}%`,
|
||||
top: `${lightDir.value.y * 100}%`
|
||||
}))
|
||||
|
||||
const pad = ref<HTMLElement>()
|
||||
const dragging = ref(false)
|
||||
|
||||
function applyFromEvent(e: PointerEvent) {
|
||||
const el = pad.value
|
||||
if (!el) return
|
||||
const r = el.getBoundingClientRect()
|
||||
setLightFromUnit(
|
||||
(e.clientX - r.left) / r.width,
|
||||
(e.clientY - r.top) / r.height
|
||||
)
|
||||
}
|
||||
|
||||
function onDown(e: PointerEvent) {
|
||||
dragging.value = true
|
||||
pad.value?.setPointerCapture(e.pointerId)
|
||||
applyFromEvent(e)
|
||||
}
|
||||
|
||||
function onMove(e: PointerEvent) {
|
||||
if (dragging.value) applyFromEvent(e)
|
||||
}
|
||||
|
||||
function onUp(e: PointerEvent) {
|
||||
dragging.value = false
|
||||
pad.value?.releasePointerCapture?.(e.pointerId)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
ref="pad"
|
||||
class="relative size-12 shrink-0 touch-none rounded-full bg-white/5 ring-1 ring-white/10 ring-inset"
|
||||
:aria-label="t('hero.light.direction', locale)"
|
||||
@pointerdown.stop.prevent="onDown"
|
||||
@pointermove.stop="onMove"
|
||||
@pointerup.stop="onUp"
|
||||
@pointercancel.stop="onUp"
|
||||
>
|
||||
<div
|
||||
class="hero-light-glow pointer-events-none absolute inset-0 rounded-full"
|
||||
/>
|
||||
<div
|
||||
class="bg-primary-comfy-yellow pointer-events-none absolute size-3 -translate-1/2 rounded-full shadow-[0_0_10px_2px_rgb(242_255_89/0.6)]"
|
||||
:style="dotStyle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label class="flex flex-1 flex-col gap-1">
|
||||
<span
|
||||
class="text-primary-warm-gray text-[0.6rem] font-medium tracking-wide uppercase"
|
||||
>
|
||||
{{ intensityLabel }}
|
||||
</span>
|
||||
<input
|
||||
v-model.number="lightIntensity"
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
:aria-label="intensityLabel"
|
||||
class="accent-primary-comfy-yellow h-1 w-full cursor-pointer"
|
||||
@pointerdown.stop
|
||||
@click.stop
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-1.5">
|
||||
<button
|
||||
v-for="m in lightModes"
|
||||
:key="m.id"
|
||||
type="button"
|
||||
:aria-pressed="m.id === lightModeId"
|
||||
:class="
|
||||
cn(
|
||||
'rounded-md px-1.5 py-1 text-[0.6rem] font-medium uppercase transition-colors',
|
||||
m.id === lightModeId
|
||||
? 'bg-primary-comfy-yellow text-primary-comfy-ink'
|
||||
: 'bg-white/5 text-primary-comfy-canvas hover:bg-white/10'
|
||||
)
|
||||
"
|
||||
@pointerdown.stop
|
||||
@click.stop="lightModeId = m.id"
|
||||
>
|
||||
{{ t(m.labelKey, locale) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
109
apps/website/src/components/home/HeroOutputFrame.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import type { ImageVariant } from './heroGraphData'
|
||||
import type { HeroControls } from './useHeroControls'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const {
|
||||
controls,
|
||||
variant,
|
||||
locale = 'en',
|
||||
class: customClass = ''
|
||||
} = defineProps<{
|
||||
controls: HeroControls
|
||||
variant: ImageVariant
|
||||
locale?: Locale
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const {
|
||||
activeNode,
|
||||
reducedMotion,
|
||||
pointer,
|
||||
outputFilter,
|
||||
colorLayerStyle,
|
||||
lightLayerStyle,
|
||||
lightMode
|
||||
} = controls
|
||||
|
||||
const metaText = computed(
|
||||
() =>
|
||||
`${t('hero.output.grade', locale)} · ${t('hero.output.colorActive', locale)} · ${t(lightMode.value.labelKey, locale)} ${t('hero.output.lightingSuffix', locale)}`
|
||||
)
|
||||
|
||||
function onMove(e: PointerEvent) {
|
||||
if (reducedMotion.value || e.pointerType !== 'mouse') return
|
||||
const r = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
pointer.value = {
|
||||
x: (e.clientX - r.left) / r.width,
|
||||
y: (e.clientY - r.top) / r.height
|
||||
}
|
||||
}
|
||||
|
||||
function onLeave() {
|
||||
pointer.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'relative w-full overflow-hidden rounded-xl transition-shadow duration-500',
|
||||
activeNode && 'hero-output-live',
|
||||
customClass
|
||||
)
|
||||
"
|
||||
@pointermove="onMove"
|
||||
@pointerleave="onLeave"
|
||||
>
|
||||
<div class="relative size-full" :style="{ filter: outputFilter }">
|
||||
<Transition name="hero-glitch">
|
||||
<img
|
||||
:key="variant.output.src"
|
||||
:src="variant.output.src"
|
||||
:alt="t(variant.output.altKey, locale)"
|
||||
data-testid="hero-output-image"
|
||||
draggable="false"
|
||||
class="absolute inset-0 size-full object-cover"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 transition-opacity duration-500"
|
||||
:style="colorLayerStyle"
|
||||
/>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 mix-blend-screen transition-opacity duration-500"
|
||||
:style="lightLayerStyle"
|
||||
/>
|
||||
<div
|
||||
v-if="activeNode"
|
||||
class="hero-output-sweep pointer-events-none absolute inset-0"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="pointer-events-none absolute inset-x-0 bottom-0 flex items-center justify-between gap-2 bg-linear-to-t from-black/70 to-transparent px-3 pt-6 pb-2"
|
||||
>
|
||||
<span class="truncate font-mono text-[0.6rem] text-white/70">
|
||||
{{ metaText }}
|
||||
</span>
|
||||
<span class="flex shrink-0 items-center gap-1">
|
||||
<span
|
||||
class="hero-live-dot bg-primary-comfy-yellow size-1.5 rounded-full"
|
||||
/>
|
||||
<span
|
||||
class="text-[0.6rem] font-medium tracking-wide whitespace-nowrap text-white/70 uppercase"
|
||||
>
|
||||
{{ t('hero.output.live', locale) }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,55 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
import HeroGraph from './HeroGraph.vue'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { externalLinks } from '../../config/routes'
|
||||
import { useHeroLogo } from '../../composables/useHeroLogo'
|
||||
import { t } from '../../i18n/translations'
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const logoContainer = ref<HTMLElement>()
|
||||
const { loaded: logoLoaded } = useHeroLogo(logoContainer)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="max-w-9xl relative mx-auto flex min-h-auto flex-col lg:flex-row lg:items-center"
|
||||
>
|
||||
<div
|
||||
ref="logoContainer"
|
||||
class="relative flex aspect-square w-full flex-1 items-center justify-center"
|
||||
>
|
||||
<img
|
||||
v-show="!logoLoaded"
|
||||
src="https://media.comfy.org/website/homepage/hero-logo-seq/Logo00.webp"
|
||||
alt="Comfy logo"
|
||||
class="w-3/5"
|
||||
/>
|
||||
</div>
|
||||
<section class="relative overflow-hidden bg-primary-comfy-ink">
|
||||
<!-- Gooey filter that merges the headline's two highlighter blocks into a
|
||||
single liquid shape. Referenced by HeroHeadline via filter: url(#…). -->
|
||||
<svg class="absolute size-0" aria-hidden="true">
|
||||
<defs>
|
||||
<filter id="hero-goo">
|
||||
<feGaussianBlur in="SourceGraphic" stdDeviation="7" result="b" />
|
||||
<feColorMatrix
|
||||
in="b"
|
||||
mode="matrix"
|
||||
values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 22 -10"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
<div class="flex-1 px-6 py-12 lg:px-16">
|
||||
<h1
|
||||
class="text-primary-comfy-canvas text-4xl font-light whitespace-pre-line lg:text-6xl"
|
||||
>
|
||||
{{ t('hero.title', locale) }}
|
||||
</h1>
|
||||
|
||||
<p
|
||||
class="text-primary-comfy-canvas mt-8 max-w-lg text-sm/relaxed lg:text-base"
|
||||
>
|
||||
{{ t('hero.subtitle', locale) }}
|
||||
</p>
|
||||
|
||||
<BrandButton
|
||||
:href="externalLinks.workflows"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
class="mt-8 w-full p-4 uppercase lg:w-auto lg:min-w-60"
|
||||
>
|
||||
{{ t('hero.runFirstWorkflow', locale) }}
|
||||
</BrandButton>
|
||||
</div>
|
||||
<HeroGraph :locale />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
57
apps/website/src/components/home/heroGraphData.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { TranslationKey } from '../../i18n/translations'
|
||||
|
||||
interface NodeImage {
|
||||
src: string
|
||||
altKey: TranslationKey
|
||||
}
|
||||
|
||||
export interface ImageVariant extends NodeImage {
|
||||
id: string
|
||||
// The result this input produces, shown in the OUTPUT node. Swapping the
|
||||
// input cascades its matching output through the graph.
|
||||
output: NodeImage
|
||||
}
|
||||
|
||||
export const imageVariants = [
|
||||
{
|
||||
id: 'v1',
|
||||
src: '/images/hero/input-portrait.png',
|
||||
altKey: 'hero.image.variant1',
|
||||
output: {
|
||||
src: '/images/hero/output-cyberpunk.png',
|
||||
altKey: 'hero.output.variant1'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'v2',
|
||||
src: '/images/hero/input-vase.png',
|
||||
altKey: 'hero.image.variant2',
|
||||
output: {
|
||||
src: '/images/hero/output-vase.png',
|
||||
altKey: 'hero.output.variant2'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'v3',
|
||||
src: '/images/hero/input-deer.png',
|
||||
altKey: 'hero.image.variant3',
|
||||
output: {
|
||||
src: '/images/hero/output-deer.png',
|
||||
altKey: 'hero.output.variant3'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'v4',
|
||||
src: '/images/hero/input-mirror.png',
|
||||
altKey: 'hero.image.variant4',
|
||||
output: {
|
||||
src: '/images/hero/output-mirror.png',
|
||||
altKey: 'hero.output.variant4'
|
||||
}
|
||||
}
|
||||
] as const satisfies readonly ImageVariant[]
|
||||
|
||||
export const textureImage: NodeImage = {
|
||||
src: '/images/hero/input-vase.png',
|
||||
altKey: 'hero.image.texture'
|
||||
}
|
||||
76
apps/website/src/components/home/heroGraphWires.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { NodeId, Rect } from './heroGraphWires'
|
||||
import { computeWires, connections, spline } from './heroGraphWires'
|
||||
|
||||
// Cubic command shape: "M sx sy C c1x c1y c2x c2y ex ey"
|
||||
function controlPoints(d: string) {
|
||||
const [sx, sy, c1x, c1y, c2x, c2y, ex, ey] = d
|
||||
.replace(/[MC]/g, ' ')
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map(Number)
|
||||
return { sx, sy, c1x, c1y, c2x, c2y, ex, ey }
|
||||
}
|
||||
|
||||
describe('spline', () => {
|
||||
it('departs and arrives horizontally for side ports even when the vertical gap dominates', () => {
|
||||
// A short rightward run with a large vertical rise: must still leave/enter
|
||||
// along x so it reads left-to-right, not as a top-down drop.
|
||||
const { sx, sy, c1x, c1y, c2x, c2y, ex, ey } = controlPoints(
|
||||
spline({ x: 0, y: 200 }, { x: 120, y: 0 }, 'h')
|
||||
)
|
||||
expect(c1y).toBe(sy)
|
||||
expect(c2y).toBe(ey)
|
||||
expect(c1x).toBeGreaterThan(sx)
|
||||
expect(c2x).toBeLessThan(ex)
|
||||
})
|
||||
|
||||
it('departs and arrives vertically for stacked ports', () => {
|
||||
const { sx, sy, c1x, c1y, c2x, c2y, ex, ey } = controlPoints(
|
||||
spline({ x: 100, y: 0 }, { x: 140, y: 400 }, 'v')
|
||||
)
|
||||
expect(c1x).toBe(sx)
|
||||
expect(c2x).toBe(ex)
|
||||
expect(c1y).toBeGreaterThan(sy)
|
||||
expect(c2y).toBeLessThan(ey)
|
||||
})
|
||||
})
|
||||
|
||||
describe('connections', () => {
|
||||
it('feeds the color remixer from texture, not directly from image', () => {
|
||||
const pairs = connections.map((c) => `${c.from}->${c.to}`)
|
||||
expect(pairs).toContain('texture->color')
|
||||
expect(pairs).not.toContain('image->color')
|
||||
})
|
||||
|
||||
it('chains image → texture → color → lighting → output', () => {
|
||||
expect(connections.map((c) => `${c.from}->${c.to}`)).toEqual([
|
||||
'image->texture',
|
||||
'texture->color',
|
||||
'color->lighting',
|
||||
'lighting->output'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeWires', () => {
|
||||
const anchors: Record<NodeId, Rect> = {
|
||||
image: { x: 60, y: 28, w: 300, h: 395 },
|
||||
texture: { x: 96, y: 500, w: 200, h: 225 },
|
||||
color: { x: 470, y: 470, w: 150, h: 180 },
|
||||
lighting: { x: 720, y: 500, w: 168, h: 179 },
|
||||
output: { x: 1000, y: 110, w: 760, h: 611 }
|
||||
}
|
||||
|
||||
it('only emits wires for nodes that have been measured', () => {
|
||||
expect(computeWires({ image: anchors.image }).length).toBe(0)
|
||||
expect(computeWires(anchors).length).toBe(connections.length)
|
||||
})
|
||||
|
||||
it('marks the image → texture wire as the accent wire', () => {
|
||||
const wires = computeWires(anchors)
|
||||
expect(wires[0].accent).toBe(true)
|
||||
expect(wires.slice(1).every((w) => !w.accent)).toBe(true)
|
||||
})
|
||||
})
|
||||
110
apps/website/src/components/home/heroGraphWires.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
export type NodeId = 'image' | 'texture' | 'color' | 'lighting' | 'output'
|
||||
|
||||
export interface Point {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface Rect extends Point {
|
||||
w: number
|
||||
h: number
|
||||
}
|
||||
|
||||
export interface Wire {
|
||||
d: string
|
||||
from: Point
|
||||
to: Point
|
||||
accent: boolean
|
||||
}
|
||||
|
||||
type Axis = 'h' | 'v'
|
||||
type Port = (r: Rect) => Point
|
||||
|
||||
const rightPort =
|
||||
(f = 0.5): Port =>
|
||||
(r) => ({ x: r.x + r.w, y: r.y + r.h * f })
|
||||
const leftPort =
|
||||
(f = 0.5): Port =>
|
||||
(r) => ({ x: r.x, y: r.y + r.h * f })
|
||||
const bottomPort =
|
||||
(f = 0.5): Port =>
|
||||
(r) => ({ x: r.x + r.w * f, y: r.y + r.h })
|
||||
const topPort =
|
||||
(f = 0.5): Port =>
|
||||
(r) => ({ x: r.x + r.w * f, y: r.y })
|
||||
|
||||
function clampOffset(d: number): number {
|
||||
return Math.min(Math.max(Math.abs(d) * 0.5, 55), 120)
|
||||
}
|
||||
|
||||
// Soft cubic whose tangents follow the connected ports: side ports (right→left)
|
||||
// depart horizontally, stacked ports (bottom→top) depart vertically. Keeping the
|
||||
// tangent on the port axis stops a wire between side ports from reading as a
|
||||
// top-down drop when the vertical gap happens to exceed the horizontal one.
|
||||
export function spline(s: Point, e: Point, axis: Axis): string {
|
||||
if (axis === 'h') {
|
||||
const off = Math.sign(e.x - s.x || 1) * clampOffset(e.x - s.x)
|
||||
return `M ${s.x} ${s.y} C ${s.x + off} ${s.y} ${e.x - off} ${e.y} ${e.x} ${e.y}`
|
||||
}
|
||||
const off = Math.sign(e.y - s.y || 1) * clampOffset(e.y - s.y)
|
||||
return `M ${s.x} ${s.y} C ${s.x} ${s.y + off} ${e.x} ${e.y - off} ${e.x} ${e.y}`
|
||||
}
|
||||
|
||||
interface Connection {
|
||||
from: NodeId
|
||||
to: NodeId
|
||||
fromPort: Port
|
||||
toPort: Port
|
||||
axis: Axis
|
||||
accent?: boolean
|
||||
}
|
||||
|
||||
export const connections: Connection[] = [
|
||||
{
|
||||
from: 'image',
|
||||
to: 'texture',
|
||||
fromPort: bottomPort(0.4),
|
||||
toPort: topPort(0.5),
|
||||
axis: 'v',
|
||||
accent: true
|
||||
},
|
||||
{
|
||||
from: 'texture',
|
||||
to: 'color',
|
||||
fromPort: rightPort(0.5),
|
||||
toPort: leftPort(0.5),
|
||||
axis: 'h'
|
||||
},
|
||||
{
|
||||
from: 'color',
|
||||
to: 'lighting',
|
||||
fromPort: rightPort(0.5),
|
||||
toPort: leftPort(0.45),
|
||||
axis: 'h'
|
||||
},
|
||||
{
|
||||
from: 'lighting',
|
||||
to: 'output',
|
||||
fromPort: rightPort(0.4),
|
||||
toPort: leftPort(0.4),
|
||||
axis: 'h'
|
||||
}
|
||||
]
|
||||
|
||||
export function computeWires(anchors: Partial<Record<NodeId, Rect>>): Wire[] {
|
||||
return connections.flatMap((c) => {
|
||||
const fr = anchors[c.from]
|
||||
const to = anchors[c.to]
|
||||
if (!fr || !to) return []
|
||||
const from = c.fromPort(fr)
|
||||
const dest = c.toPort(to)
|
||||
return [
|
||||
{
|
||||
from,
|
||||
to: dest,
|
||||
accent: c.accent ?? false,
|
||||
d: spline(from, dest, c.axis)
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
222
apps/website/src/components/home/useHeroControls.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { usePreferredReducedMotion } from '@vueuse/core'
|
||||
import { clamp } from 'es-toolkit'
|
||||
import { computed, ref } from 'vue'
|
||||
import type { CSSProperties } from 'vue'
|
||||
|
||||
import type { Point } from './heroGraphWires'
|
||||
import type { TranslationKey } from '../../i18n/translations'
|
||||
|
||||
type ColorPresetId = 'cyberpunk' | 'film' | 'dream' | 'editorial'
|
||||
type LightModeId = 'soft' | 'rim' | 'neon' | 'studio'
|
||||
type HeroNodeId = 'color' | 'lighting'
|
||||
|
||||
interface ColorPreset {
|
||||
id: ColorPresetId
|
||||
labelKey: TranslationKey
|
||||
saturate: number
|
||||
contrast: number
|
||||
hue: number
|
||||
blend: CSSProperties['mixBlendMode']
|
||||
}
|
||||
|
||||
interface ColorSwatch {
|
||||
id: string
|
||||
labelKey: TranslationKey
|
||||
// Space-separated RGB channels, ready for `rgb(<rgb> / <alpha>)`.
|
||||
rgb: string
|
||||
}
|
||||
|
||||
interface LightMode {
|
||||
id: LightModeId
|
||||
labelKey: TranslationKey
|
||||
spread: number
|
||||
core: number
|
||||
rim: number
|
||||
tint: string
|
||||
}
|
||||
|
||||
export const colorPresets: ColorPreset[] = [
|
||||
{
|
||||
id: 'cyberpunk',
|
||||
labelKey: 'hero.color.preset.cyberpunk',
|
||||
saturate: 1.3,
|
||||
contrast: 1.1,
|
||||
hue: 8,
|
||||
blend: 'overlay'
|
||||
},
|
||||
{
|
||||
id: 'film',
|
||||
labelKey: 'hero.color.preset.film',
|
||||
saturate: 0.9,
|
||||
contrast: 1.2,
|
||||
hue: -6,
|
||||
blend: 'overlay'
|
||||
},
|
||||
{
|
||||
id: 'dream',
|
||||
labelKey: 'hero.color.preset.dream',
|
||||
saturate: 1.2,
|
||||
contrast: 0.95,
|
||||
hue: 22,
|
||||
blend: 'soft-light'
|
||||
},
|
||||
{
|
||||
id: 'editorial',
|
||||
labelKey: 'hero.color.preset.editorial',
|
||||
saturate: 0.78,
|
||||
contrast: 1.08,
|
||||
hue: 0,
|
||||
blend: 'overlay'
|
||||
}
|
||||
]
|
||||
|
||||
export const colorSwatches: ColorSwatch[] = [
|
||||
{ id: 'magenta', labelKey: 'hero.color.swatch.magenta', rgb: '255 0 140' },
|
||||
{ id: 'cyan', labelKey: 'hero.color.swatch.cyan', rgb: '0 209 255' },
|
||||
{ id: 'lime', labelKey: 'hero.color.swatch.lime', rgb: '170 255 90' },
|
||||
{ id: 'amber', labelKey: 'hero.color.swatch.amber', rgb: '255 176 74' },
|
||||
{ id: 'violet', labelKey: 'hero.color.swatch.violet', rgb: '150 110 255' }
|
||||
]
|
||||
|
||||
export const lightModes: LightMode[] = [
|
||||
{
|
||||
id: 'soft',
|
||||
labelKey: 'hero.light.mode.soft',
|
||||
spread: 72,
|
||||
core: 0.4,
|
||||
rim: 0,
|
||||
tint: '255 244 224'
|
||||
},
|
||||
{
|
||||
id: 'rim',
|
||||
labelKey: 'hero.light.mode.rim',
|
||||
spread: 56,
|
||||
core: 0.3,
|
||||
rim: 0.55,
|
||||
tint: '176 214 255'
|
||||
},
|
||||
{
|
||||
id: 'neon',
|
||||
labelKey: 'hero.light.mode.neon',
|
||||
spread: 62,
|
||||
core: 0.5,
|
||||
rim: 0.3,
|
||||
tint: '206 120 255'
|
||||
},
|
||||
{
|
||||
id: 'studio',
|
||||
labelKey: 'hero.light.mode.studio',
|
||||
spread: 88,
|
||||
core: 0.62,
|
||||
rim: 0,
|
||||
tint: '255 255 255'
|
||||
}
|
||||
]
|
||||
|
||||
// Centralizes the hero's interactive grade so the desktop graph and the mobile
|
||||
// column drive the same OUTPUT overlays. Returns plain refs (mutated directly by
|
||||
// the node controls) plus computed CSS the output frame binds inline.
|
||||
export function useHeroControls() {
|
||||
const motionPref = usePreferredReducedMotion()
|
||||
const reducedMotion = computed(() => motionPref.value === 'reduce')
|
||||
|
||||
const colorPresetId = ref<ColorPresetId>('cyberpunk')
|
||||
const swatchId = ref(colorSwatches[0].id)
|
||||
const colorIntensity = ref(72)
|
||||
|
||||
const lightModeId = ref<LightModeId>('neon')
|
||||
const lightIntensity = ref(58)
|
||||
const lightDir = ref<Point>({ x: 0.64, y: 0.3 })
|
||||
|
||||
const activeNode = ref<HeroNodeId | null>(null)
|
||||
// Cursor position over the output (0..1), letting the light drift toward the
|
||||
// pointer on fine-pointer devices; null when absent or motion-reduced.
|
||||
const pointer = ref<Point | null>(null)
|
||||
|
||||
const colorPreset = computed(
|
||||
() =>
|
||||
colorPresets.find((p) => p.id === colorPresetId.value) ?? colorPresets[0]
|
||||
)
|
||||
const swatch = computed(
|
||||
() => colorSwatches.find((s) => s.id === swatchId.value) ?? colorSwatches[0]
|
||||
)
|
||||
const lightMode = computed(
|
||||
() => lightModes.find((m) => m.id === lightModeId.value) ?? lightModes[0]
|
||||
)
|
||||
|
||||
const outputFilter = computed(() => {
|
||||
const c = colorIntensity.value / 100
|
||||
const l = lightIntensity.value / 100
|
||||
const p = colorPreset.value
|
||||
const saturate = 1 + (p.saturate - 1) * c
|
||||
const contrast = 1 + (p.contrast - 1) * c
|
||||
const hue = p.hue * c
|
||||
const brightness = 1 + l * (lightMode.value.core - 0.3) * 0.45
|
||||
return `saturate(${saturate.toFixed(3)}) contrast(${contrast.toFixed(3)}) hue-rotate(${hue.toFixed(1)}deg) brightness(${brightness.toFixed(3)})`
|
||||
})
|
||||
|
||||
const colorLayerStyle = computed<CSSProperties>(() => ({
|
||||
backgroundImage: `linear-gradient(125deg, rgb(${swatch.value.rgb} / 0.55), rgb(${swatch.value.rgb} / 0.06) 72%)`,
|
||||
mixBlendMode: colorPreset.value.blend,
|
||||
opacity: (colorIntensity.value / 100) * 0.5
|
||||
}))
|
||||
|
||||
const lightPos = computed<Point>(() => {
|
||||
const base = lightDir.value
|
||||
const p = pointer.value
|
||||
if (!p || reducedMotion.value) return base
|
||||
return {
|
||||
x: base.x + (p.x - base.x) * 0.3,
|
||||
y: base.y + (p.y - base.y) * 0.3
|
||||
}
|
||||
})
|
||||
|
||||
const lightLayerStyle = computed<CSSProperties>(() => {
|
||||
const m = lightMode.value
|
||||
const x = (lightPos.value.x * 100).toFixed(1)
|
||||
const y = (lightPos.value.y * 100).toFixed(1)
|
||||
const layers = [
|
||||
`radial-gradient(circle at ${x}% ${y}%, rgb(${m.tint} / 0.9), transparent ${m.spread}%)`
|
||||
]
|
||||
if (m.rim > 0) {
|
||||
layers.push(
|
||||
`linear-gradient(105deg, transparent 58%, rgb(${m.tint} / ${m.rim}))`
|
||||
)
|
||||
}
|
||||
return {
|
||||
backgroundImage: layers.join(', '),
|
||||
opacity: (lightIntensity.value / 100) * 0.72
|
||||
}
|
||||
})
|
||||
|
||||
function setLightFromUnit(x: number, y: number) {
|
||||
const dx = x - 0.5
|
||||
const dy = y - 0.5
|
||||
const dist = Math.hypot(dx, dy)
|
||||
const max = 0.5
|
||||
const scaled = dist > max ? max / dist : 1
|
||||
lightDir.value = {
|
||||
x: clamp(0.5 + dx * scaled, 0, 1),
|
||||
y: clamp(0.5 + dy * scaled, 0, 1)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
reducedMotion,
|
||||
colorPresetId,
|
||||
swatchId,
|
||||
colorIntensity,
|
||||
lightModeId,
|
||||
lightIntensity,
|
||||
lightDir,
|
||||
lightMode,
|
||||
activeNode,
|
||||
pointer,
|
||||
outputFilter,
|
||||
colorLayerStyle,
|
||||
lightLayerStyle,
|
||||
setLightFromUnit
|
||||
}
|
||||
}
|
||||
|
||||
export type HeroControls = ReturnType<typeof useHeroControls>
|
||||
@@ -49,6 +49,82 @@ const translations = {
|
||||
en: 'Run your first workflow',
|
||||
'zh-CN': '运行你的第一个工作流'
|
||||
},
|
||||
'hero.node.image': { en: 'IMAGE', 'zh-CN': '图像' },
|
||||
'hero.node.texture': { en: 'TEXTURE', 'zh-CN': '纹理' },
|
||||
'hero.node.lighting': { en: 'LIGHTING', 'zh-CN': '光照' },
|
||||
'hero.node.color': { en: 'COLOR REMIXER', 'zh-CN': '颜色重映射' },
|
||||
'hero.node.output': { en: 'OUTPUT', 'zh-CN': '输出' },
|
||||
'hero.image.variant1': {
|
||||
en: 'Portrait under multicolor studio lighting',
|
||||
'zh-CN': '多彩影棚灯光下的人像'
|
||||
},
|
||||
'hero.image.variant2': {
|
||||
en: 'Vase rendered as a rainbow normal map',
|
||||
'zh-CN': '以彩虹法线贴图渲染的花瓶'
|
||||
},
|
||||
'hero.image.variant3': {
|
||||
en: 'Deer in a forest with chromatic aberration',
|
||||
'zh-CN': '带色散效果的森林中的鹿'
|
||||
},
|
||||
'hero.image.variant4': {
|
||||
en: 'Two people at a mirror, glitch lighting',
|
||||
'zh-CN': '镜前两人,故障风格灯光'
|
||||
},
|
||||
'hero.image.texture': {
|
||||
en: 'Texture pass: rainbow normal-map vase',
|
||||
'zh-CN': '纹理处理:彩虹法线贴图花瓶'
|
||||
},
|
||||
'hero.output.variant1': {
|
||||
en: 'Output: cyberpunk portrait among glowing CRT screens',
|
||||
'zh-CN': '输出:发光 CRT 屏幕中的赛博朋克人像'
|
||||
},
|
||||
'hero.output.variant2': {
|
||||
en: 'Output: ceramic vase relit on a plinth in a dim library',
|
||||
'zh-CN': '输出:昏暗图书馆中重新打光、置于基座上的陶瓷花瓶'
|
||||
},
|
||||
'hero.output.variant3': {
|
||||
en: 'Output: iridescent deer before a neon city skyline',
|
||||
'zh-CN': '输出:霓虹城市天际线前的虹彩鹿'
|
||||
},
|
||||
'hero.output.variant4': {
|
||||
en: 'Output: couple at a cracked mirror in neon bathroom light',
|
||||
'zh-CN': '输出:霓虹浴室灯光下裂镜前的情侣'
|
||||
},
|
||||
'hero.image.pickerLabel': {
|
||||
en: 'Choose input image variant',
|
||||
'zh-CN': '选择输入图像变体'
|
||||
},
|
||||
'hero.image.hint': {
|
||||
en: 'Tap a source image to remix the result',
|
||||
'zh-CN': '点按源图像以重新生成结果'
|
||||
},
|
||||
'hero.color.preset.cyberpunk': { en: 'Cyberpunk', 'zh-CN': '赛博朋克' },
|
||||
'hero.color.preset.film': { en: 'Film', 'zh-CN': '胶片' },
|
||||
'hero.color.preset.dream': { en: 'Dream', 'zh-CN': '梦境' },
|
||||
'hero.color.preset.editorial': { en: 'Editorial', 'zh-CN': '杂志' },
|
||||
'hero.color.remix': { en: 'Remix', 'zh-CN': '重混' },
|
||||
'hero.color.palette': { en: 'Choose accent color', 'zh-CN': '选择强调色' },
|
||||
'hero.color.swatch.magenta': { en: 'Magenta', 'zh-CN': '品红' },
|
||||
'hero.color.swatch.cyan': { en: 'Cyan', 'zh-CN': '青色' },
|
||||
'hero.color.swatch.lime': { en: 'Lime', 'zh-CN': '青柠' },
|
||||
'hero.color.swatch.amber': { en: 'Amber', 'zh-CN': '琥珀' },
|
||||
'hero.color.swatch.violet': { en: 'Violet', 'zh-CN': '紫罗兰' },
|
||||
'hero.light.mode.soft': { en: 'Soft', 'zh-CN': '柔光' },
|
||||
'hero.light.mode.rim': { en: 'Rim', 'zh-CN': '轮廓光' },
|
||||
'hero.light.mode.neon': { en: 'Neon', 'zh-CN': '霓虹' },
|
||||
'hero.light.mode.studio': { en: 'Studio', 'zh-CN': '影棚' },
|
||||
'hero.light.intensity': { en: 'Light', 'zh-CN': '光强' },
|
||||
'hero.light.direction': { en: 'Light direction', 'zh-CN': '光照方向' },
|
||||
'hero.output.grade': {
|
||||
en: 'cinematic-grade_v2',
|
||||
'zh-CN': 'cinematic-grade_v2'
|
||||
},
|
||||
'hero.output.colorActive': {
|
||||
en: 'color remap active',
|
||||
'zh-CN': '色彩重映射已启用'
|
||||
},
|
||||
'hero.output.lightingSuffix': { en: 'lighting', 'zh-CN': '光照' },
|
||||
'hero.output.live': { en: 'Live preview', 'zh-CN': '实时预览' },
|
||||
|
||||
// ProductShowcaseSection
|
||||
'showcase.subtitle1': {
|
||||
|
||||
@@ -215,6 +215,158 @@
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Gentle chromatic crossfade used when hero node images swap. The incoming
|
||||
frame eases in with a soft RGB split that resolves smoothly while the
|
||||
outgoing frame fades beneath it — no positional jumps. Reduced-motion users
|
||||
get a near-instant swap via the global override below. */
|
||||
@keyframes hero-glitch-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
filter: drop-shadow(7px 0 0 rgb(255 0 92 / 0.5))
|
||||
drop-shadow(-7px 0 0 rgb(0 209 255 / 0.5));
|
||||
}
|
||||
60% {
|
||||
opacity: 1;
|
||||
filter: drop-shadow(3px 0 0 rgb(255 0 92 / 0.28))
|
||||
drop-shadow(-3px 0 0 rgb(0 209 255 / 0.28));
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
filter: none;
|
||||
}
|
||||
}
|
||||
|
||||
.hero-glitch-enter-active,
|
||||
.hero-glitch-leave-active {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.hero-glitch-enter-active {
|
||||
animation: hero-glitch-in 0.55s ease-out both;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.hero-glitch-leave-active {
|
||||
transition: opacity 0.55s ease;
|
||||
}
|
||||
|
||||
.hero-glitch-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* The two headline lines are split apart with a gap; #hero-goo only bridges
|
||||
them where this blob sits, so as the blob glides side to side it drags a
|
||||
liquid link back and forth between the words. Travel is kept well inside the
|
||||
narrower lower line (~±72px) so the bridge glides through the centre without
|
||||
reaching either edge and poking out past a line. */
|
||||
@keyframes hero-liquid-link {
|
||||
0% {
|
||||
transform: translate(calc(-50% - 64px), -50%) scale(1, 1.03);
|
||||
}
|
||||
50% {
|
||||
transform: translate(calc(-50% + 64px), -50%) scale(1, 1.03);
|
||||
}
|
||||
100% {
|
||||
transform: translate(calc(-50% - 64px), -50%) scale(1, 1.03);
|
||||
}
|
||||
}
|
||||
|
||||
/* Slow, mostly-horizontal glide keeps the liquid link premium and readable. */
|
||||
.hero-liquid-link {
|
||||
animation: hero-liquid-link 5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Workflow connectors: a short bright dash flows from each upstream node toward
|
||||
the OUTPUT while a control node is engaged. path-length is normalized to 1 so
|
||||
the dash travels at a consistent rate regardless of wire length. */
|
||||
.hero-wire-pulse {
|
||||
stroke-dashoffset: 1;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.hero-wire-active .hero-wire-pulse {
|
||||
opacity: 0.9;
|
||||
animation: hero-wire-flow 1.2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes hero-wire-flow {
|
||||
from {
|
||||
stroke-dashoffset: 1;
|
||||
}
|
||||
to {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* OUTPUT frame responds to an engaged control node with a soft neon rim and a
|
||||
slow specular sweep — premium, not flashy. */
|
||||
.hero-output-live {
|
||||
box-shadow:
|
||||
0 0 0 1px rgb(242 255 89 / 0.35),
|
||||
0 0 45px -8px rgb(242 255 89 / 0.45);
|
||||
}
|
||||
|
||||
.hero-output-sweep::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 45%;
|
||||
background: linear-gradient(
|
||||
100deg,
|
||||
transparent,
|
||||
rgb(255 255 255 / 0.16),
|
||||
transparent
|
||||
);
|
||||
animation: hero-output-sweep 2.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes hero-output-sweep {
|
||||
0% {
|
||||
transform: translateX(-120%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(160%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes hero-live-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.4;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.hero-live-dot {
|
||||
animation: hero-live-pulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Soft breathing glow inside the LIGHTING direction pad. */
|
||||
.hero-light-glow {
|
||||
background: radial-gradient(
|
||||
circle at 50% 40%,
|
||||
rgb(242 255 89 / 0.25),
|
||||
transparent 65%
|
||||
);
|
||||
animation: hero-light-breathe 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes hero-light-breathe {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
|
||||
3
pnpm-lock.yaml
generated
@@ -983,9 +983,6 @@ importers:
|
||||
reka-ui:
|
||||
specifier: 'catalog:'
|
||||
version: 2.5.0(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3))
|
||||
three:
|
||||
specifier: 'catalog:'
|
||||
version: 0.184.0
|
||||
vue:
|
||||
specifier: 'catalog:'
|
||||
version: 3.5.34(typescript@5.9.3)
|
||||
|
||||