Compare commits

...

7 Commits

Author SHA1 Message Date
Marwan Mostafa
619d6d3bf9 feat(website): interactive color + lighting nodes drive the live hero output
Fill the empty Color Remixer and Lighting nodes with real controls (swatches,
mood presets, intensity sliders, a light-direction pad) that grade the OUTPUT
in real time via CSS overlays and filters. Add a live metadata bar, active node
glow, and connectors that pulse toward the output when a node is engaged.

Rework the mobile hero into a compact connected graph that fits one screen:
IMAGE forks into Color + Lighting which merge into the live output. Slow the
headline liquid link and respect prefers-reduced-motion throughout.
2026-06-30 13:41:57 +03:00
Marwan Mostafa
f6099e418b feat(website): tighten mobile hero to lead with output and remix picker
Collapse the mobile hero into a single OUTPUT card that leads with the
result and keeps the input thumbnails directly beneath it, so a tap swaps
the visible output with no scroll. Add a remix hint and thin the headline's
liquid connector.
2026-06-30 13:41:56 +03:00
Marwan Mostafa
95d5127d5b feat(website): drag hero nodes and animate the split headline
- Make graph nodes draggable with live-routed wires; suppress the native
  image drag-out on the IMAGE node while keeping its picker clickable
- Rewire inputs into an image->texture->color->lighting->output chain and
  route side-port wires horizontally; extract the wire geometry into a
  tested heroGraphWires module
- Split the headline into two highlighter lines joined by a liquid link
  that glides side to side through the goo filter
- Nudge the whole graph left so the OUTPUT node bleeds less to the right
2026-06-30 13:41:56 +03:00
Marwan Mostafa
7a145d2fc6 fix(website): liquid headline, smoother wires, less output bleed
Move the gooey liquid effect onto the headline so the two highlighter blocks
merge into one liquid shape (crisp text layered over a goo-filtered yellow
backing); the IMAGE->TEXTURE link is a plain wire again. Cap the wire control
offsets so the splines stay smooth without twirling. Pull the OUTPUT node in so
most of it stays on screen, and keep the headline optically centered.
2026-06-30 13:41:56 +03:00
Marwan Mostafa
a7cc2f87bf fix(website): rebalance hero layout, center headline, liquid links
Fill the viewport width on large screens instead of centering with margins, so
the IMAGE node sits near the left edge and the OUTPUT bleeds off the right
(no more right-leaning gap). Widen the design stage and move the headline into
the clear gap between IMAGE and OUTPUT, with the COLOR/LIGHTING placeholders
dropped to the lower band so nothing overlaps the copy. Optically center the
headline text inside the highlighter blocks. Replace the wiggly bezier wires
with tangent-aware S-curves plus endpoint dots, and render the IMAGE->TEXTURE
connection as a gooey liquid link.
2026-06-30 13:41:55 +03:00
Marwan Mostafa
969e32afc1 feat(website): make hero responsive across all screens
Lay the desktop node graph out in a fixed 1440x760 design stage that scales
as a single unit to fit any width, so nodes and wires never collide and the
OUTPUT bleed is preserved proportionally from 1024px up. Wires are measured in
design coordinates via layout offsets, independent of the scale transform.
Extract the headline into HeroHeadline (shared by both layouts) and give phones
and tablets a centered, width-capped stacked column.
2026-06-30 13:41:55 +03:00
Marwan Mostafa
949af59c4f feat(website): interactive node-graph hero
Replace the homepage hero with a free-floating node-graph behind a yellow
highlighter headline. The IMAGE node is a hover-driven image picker: hovering
(or focusing) a thumbnail crossfades the large input and cascades the matching
result through the OUTPUT node, with a smooth chromatic transition.

- New HeroGraph/HeroGraphNode/HeroImagePicker components and heroGraphData
- SVG bezier wires anchored to measured node rects, recomputed on resize
- Per-variant input/output image pairs; graceful stacked mobile layout
- Remove the old canvas hero (useHeroLogo) and the unused three dependency
- e2e coverage for hover/keyboard selection and the output cascade
2026-06-30 13:41:55 +03:00
25 changed files with 1767 additions and 49 deletions

View File

@@ -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(

View File

@@ -34,7 +34,6 @@
"lenis": "catalog:",
"posthog-js": "catalog:",
"reka-ui": "catalog:",
"three": "catalog:",
"vue": "catalog:",
"zod": "catalog:"
},

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 333 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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>

View 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'
}

View 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)
})
})

View 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)
}
]
})
}

View 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>

View File

@@ -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': {

View File

@@ -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
View File

@@ -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)