Compare commits

..

4 Commits

Author SHA1 Message Date
Michael B
f0055cba2e prototype(website): always show variant switcher on prototype route
The dev-only guard hid the switcher on the Vercel preview, where it is
actually needed to flip variants. The bar only mounts on the throwaway
/learning-prototype route, so showing it in all builds is safe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 14:42:24 -04:00
Michael B
b8153ed055 prototype(website): move redesign prototype to /learning-prototype
Restore the original /learning page untouched; the three redesign
variants now live on a dedicated throwaway route /learning-prototype
(?variant=a|b|c, ?category=vfx|animations|ads).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 14:27:24 -04:00
Michael B
3fe50b20bb chore: retrigger CI
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 14:17:42 -04:00
Michael B
348019ba9d prototype(website): three /learning redesign variants with category filters
Throwaway prototype answering: what should the /learning page look like
with category filtering (vfx / animations / ads), a featured element,
and category-labelled cards?

Three variants on the existing /learning route, switchable via
?variant=a|b|c (floating dev-only bar, arrow keys cycle):

- A: hero removed; slim header + sticky category filter bar; featured
  tutorial as a double-width lead card that follows the filter
- B: sidebar directory; category nav with counts/blurbs, compact
  featured banner, dense row list instead of a card grid
- C: hero merged with the featured spotlight; one horizontal shelf per
  category with view-all drill-in (maps onto /learning/<category> routes)

Active filter mirrors to ?category=, standing in for the eventual
/learning/vfx-style routes. prototypeData.ts bolts a category field
onto the existing tutorials; assignments are placeholder guesses.

Original page sections are commented out in learning.astro, not deleted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 14:02:45 -04:00
13 changed files with 1195 additions and 124 deletions

View File

@@ -0,0 +1,99 @@
<script setup lang="ts">
// PROTOTYPE — floating variant switcher bar. Not part of the design under
// review. Shown in all builds: it only ever mounts on the throwaway
// /learning-prototype route, which is deleted once a variant wins.
import { onMounted, onUnmounted } from 'vue'
interface PrototypeVariant {
key: string
name: string
}
const { variants, current } = defineProps<{
variants: PrototypeVariant[]
current: string
}>()
const emit = defineEmits<{
'update:current': [key: string]
}>()
const currentIndex = () =>
Math.max(
0,
variants.findIndex((variant) => variant.key === current)
)
const cycle = (direction: 1 | -1) => {
const next = (currentIndex() + direction + variants.length) % variants.length
emit('update:current', variants[next].key)
}
function onKeydown(event: KeyboardEvent) {
const target = event.target as HTMLElement | null
if (
target &&
(target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable)
) {
return
}
if (event.key === 'ArrowLeft') cycle(-1)
if (event.key === 'ArrowRight') cycle(1)
}
onMounted(() => window.addEventListener('keydown', onKeydown))
onUnmounted(() => window.removeEventListener('keydown', onKeydown))
</script>
<template>
<div
class="bg-primary-comfy-yellow fixed bottom-6 left-1/2 z-50 flex -translate-x-1/2 items-center gap-1 rounded-full border-2 border-primary-comfy-ink py-1.5 pr-4 pl-1.5 text-primary-comfy-ink shadow-[0_8px_30px_rgba(0,0,0,0.5)]"
>
<button
type="button"
aria-label="Previous variant"
class="flex size-8 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-primary-comfy-ink/10"
@click="cycle(-1)"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
class="size-4"
aria-hidden="true"
>
<polyline points="15 6 9 12 15 18" />
</svg>
</button>
<span class="text-xs font-bold tracking-wide uppercase select-none">
Prototype {{ current.toUpperCase() }}
{{ variants[currentIndex()].name }}
</span>
<button
type="button"
aria-label="Next variant"
class="flex size-8 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-primary-comfy-ink/10"
@click="cycle(1)"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
class="size-4"
aria-hidden="true"
>
<polyline points="9 6 15 12 9 18" />
</svg>
</button>
</div>
</template>

View File

@@ -0,0 +1,72 @@
<script setup lang="ts">
// PROTOTYPE — /learning redesign: three variants switchable via ?variant=a|b|c
// (floating bottom bar, ← / → keys also cycle). The active category filter is
// mirrored to ?category=vfx|animations|ads, standing in for the eventual
// /learning/vfx-style routes. Mounted client:only, so URL access is safe here.
import { ref, watch } from 'vue'
import type { Locale } from '../../../i18n/translations'
import type { CategoryFilter } from './prototypeData'
import PrototypeSwitcher from '../PrototypeSwitcher.vue'
import VariantA from './VariantA.vue'
import VariantB from './VariantB.vue'
import VariantC from './VariantC.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const VARIANTS = [
{ key: 'a', name: 'Filter bar + featured lead' },
{ key: 'b', name: 'Sidebar directory' },
{ key: 'c', name: 'Spotlight + shelves' }
]
const CATEGORY_KEYS: readonly CategoryFilter[] = [
'all',
'vfx',
'animations',
'ads'
]
const readParam = <T extends string>(
key: string,
allowed: readonly T[],
fallback: T
): T => {
const value = new URLSearchParams(window.location.search).get(key)
return allowed.includes(value as T) ? (value as T) : fallback
}
const variant = ref(
readParam(
'variant',
VARIANTS.map((v) => v.key),
'a'
)
)
const category = ref<CategoryFilter>(
readParam('category', CATEGORY_KEYS, 'all')
)
const syncParam = (key: string, value: string, omitWhen?: string) => {
const url = new URL(window.location.href)
if (value === omitWhen) url.searchParams.delete(key)
else url.searchParams.set(key, value)
history.replaceState(null, '', url)
}
watch(variant, (value) => syncParam('variant', value))
watch(category, (value) => syncParam('category', value, 'all'))
</script>
<template>
<VariantA v-if="variant === 'a'" v-model:category="category" :locale />
<VariantB v-else-if="variant === 'b'" v-model:category="category" :locale />
<VariantC v-else v-model:category="category" :locale />
<PrototypeSwitcher
:variants="VARIANTS"
:current="variant"
@update:current="variant = $event"
/>
</template>

View File

@@ -0,0 +1,234 @@
<script setup lang="ts">
// PROTOTYPE VARIANT A — "Filter bar + featured lead".
// The hero is gone: a slim header plus a sticky category filter bar take its
// place, and the featured tutorial becomes a double-width lead card at the top
// of the grid (it follows the active filter).
import { computed, ref } from 'vue'
import type { Locale } from '../../../i18n/translations'
import type { CategoryFilter, PrototypeTutorial } from './prototypeData'
import { getTutorialPosterSrc } from '../../../data/learningTutorials'
import { t } from '../../../i18n/translations'
import Badge from '../../ui/badge/Badge.vue'
import { ButtonMask } from '../../ui/button-mask'
import TutorialDetailDialog from '../../learning/TutorialDetailDialog.vue'
import {
categoryLabel,
categoryOptions,
featuredFor,
filterByCategory
} from './prototypeData'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const category = defineModel<CategoryFilter>('category', { default: 'all' })
const featured = computed(() => featuredFor(category.value))
const rest = computed(() =>
filterByCategory(category.value).filter(
(tutorial) => tutorial.id !== featured.value?.id
)
)
const total = computed(() => filterByCategory(category.value).length)
const activeTutorial = ref<PrototypeTutorial | null>(null)
</script>
<template>
<section class="max-w-9xl mx-auto px-6 pt-16 lg:pt-20">
<header class="flex flex-wrap items-end justify-between gap-6">
<div>
<p
class="text-primary-comfy-yellow text-xs font-semibold tracking-widest uppercase"
>
Learning
</p>
<h1
class="mt-3 text-3xl font-light tracking-tight text-primary-comfy-canvas lg:text-5xl"
>
Tutorials &amp; workflows
</h1>
</div>
<p class="text-primary-warm-gray text-sm">
{{ total }} tutorial{{ total === 1 ? '' : 's' }}
</p>
</header>
</section>
<div
class="sticky top-0 z-30 mt-10 border-y border-white/10 bg-primary-comfy-ink/85 backdrop-blur-md"
>
<nav
class="max-w-9xl mx-auto flex scrollbar-none items-center gap-3 overflow-x-auto px-6 py-4"
aria-label="Category filter"
>
<button
v-for="option in categoryOptions"
:key="option.value"
type="button"
:aria-pressed="category === option.value"
class="shrink-0 cursor-pointer rounded-lg px-4 py-2 text-xs font-semibold tracking-wide whitespace-nowrap transition-colors"
:class="
category === option.value
? 'bg-primary-comfy-yellow text-primary-comfy-ink'
: 'bg-transparency-white-t4 text-primary-warm-gray hover:text-primary-comfy-canvas'
"
@click="category = option.value"
>
{{ option.label }}
<span class="ml-1 opacity-60">
{{ filterByCategory(option.value).length }}
</span>
</button>
</nav>
</div>
<section class="max-w-9xl mx-auto px-6 py-12 lg:py-16">
<ul
class="grid grid-cols-1 gap-x-6 gap-y-10 md:grid-cols-2 lg:grid-cols-3 lg:gap-x-8"
>
<!-- Featured lead card -->
<li v-if="featured" class="md:col-span-2">
<button
type="button"
class="group relative block aspect-video w-full cursor-pointer overflow-hidden rounded-3xl text-left"
:aria-label="`Featured: ${featured.title[locale]}`"
@click="activeTutorial = featured"
>
<video
:src="getTutorialPosterSrc(featured)"
:poster="featured.poster"
class="size-full object-cover"
preload="metadata"
playsinline
muted
></video>
<span
class="absolute inset-0 bg-linear-to-t from-primary-comfy-ink/90 via-primary-comfy-ink/20 to-transparent"
aria-hidden="true"
/>
<span
class="absolute inset-x-0 bottom-0 flex flex-col gap-3 p-6 lg:p-8"
>
<span class="flex items-center gap-3">
<Badge variant="accent">Featured</Badge>
<Badge variant="category">
{{ categoryLabel(featured.category) }}
</Badge>
</span>
<span
class="text-xl font-light text-primary-comfy-canvas lg:text-3xl"
>
{{ t('learning.tutorials.titlePrefix', locale) }}
{{ featured.title[locale] }}
</span>
</span>
<span
class="absolute top-6 right-6 flex size-14 items-center justify-center rounded-full bg-white/25 backdrop-blur-sm transition-transform group-hover:scale-105"
aria-hidden="true"
>
<svg
class="ml-1 size-5 text-white"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<path d="M8 5v14l11-7z" />
</svg>
</span>
</button>
</li>
<!-- Regular cards -->
<li
v-for="tutorial in rest"
:key="tutorial.id"
class="bg-transparency-white-t4 flex flex-col gap-4 overflow-hidden rounded-3xl border-0 p-2"
>
<button
type="button"
class="group relative block aspect-video cursor-pointer overflow-hidden rounded-3xl"
:aria-label="`${t('learning.tutorials.titlePrefix', locale)} ${tutorial.title[locale]}`"
@click="activeTutorial = tutorial"
>
<video
:src="getTutorialPosterSrc(tutorial)"
:poster="tutorial.poster"
class="size-full object-cover"
preload="metadata"
playsinline
muted
></video>
<span
class="absolute inset-0 flex items-center justify-center"
aria-hidden="true"
>
<span
class="flex size-14 items-center justify-center rounded-full bg-white/25 backdrop-blur-sm transition-transform group-hover:scale-105"
>
<svg
class="ml-1 size-5 text-white"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<path d="M8 5v14l11-7z" />
</svg>
</span>
</span>
</button>
<div class="flex flex-col space-y-3 p-4">
<Badge variant="category">
{{ categoryLabel(tutorial.category) }}
</Badge>
<div class="flex items-center justify-between gap-4">
<h3
class="text-sm/snug text-primary-comfy-canvas lg:text-base/snug"
>
{{ t('learning.tutorials.titlePrefix', locale) }}<br />
{{ tutorial.title[locale] }}
</h3>
<ButtonMask
v-if="tutorial.href"
as="a"
:href="tutorial.href"
icon-position="right"
class="shrink-0"
variant="ghost"
size="default"
>
{{ t('cta.tryWorkflow', locale) }}
<template #icon>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
class="size-4"
>
<polyline points="9 6 15 12 9 18" />
</svg>
</template>
</ButtonMask>
</div>
<ul class="flex flex-wrap gap-2">
<li v-for="tag in tutorial.tags" :key="tag">
<Badge>{{ t(tag, locale) }}</Badge>
</li>
</ul>
</div>
</li>
</ul>
<TutorialDetailDialog
v-if="activeTutorial"
:tutorial="activeTutorial"
:locale="locale"
@close="activeTutorial = null"
/>
</section>
</template>

View File

@@ -0,0 +1,278 @@
<script setup lang="ts">
// PROTOTYPE VARIANT B — "Sidebar directory".
// No hero at all: a persistent left sidebar carries the page title and the
// category nav (with counts and blurbs), while the content column shows a
// compact featured banner followed by a dense, scannable row list — a
// directory, not a gallery.
import { computed, ref } from 'vue'
import type { Locale } from '../../../i18n/translations'
import type { CategoryFilter, PrototypeTutorial } from './prototypeData'
import { getTutorialPosterSrc } from '../../../data/learningTutorials'
import { t } from '../../../i18n/translations'
import Badge from '../../ui/badge/Badge.vue'
import { ButtonMask } from '../../ui/button-mask'
import TutorialDetailDialog from '../../learning/TutorialDetailDialog.vue'
import {
categoryLabel,
categoryOptions,
featuredFor,
filterByCategory
} from './prototypeData'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const category = defineModel<CategoryFilter>('category', { default: 'all' })
const featured = computed(() => featuredFor(category.value))
const rows = computed(() =>
filterByCategory(category.value).filter(
(tutorial) => tutorial.id !== featured.value?.id
)
)
const activeTutorial = ref<PrototypeTutorial | null>(null)
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-20">
<div class="flex flex-col gap-10 lg:flex-row lg:gap-16">
<!-- Sidebar -->
<aside class="lg:w-72 lg:shrink-0">
<div class="lg:sticky lg:top-10">
<h1
class="text-3xl font-light tracking-tight text-primary-comfy-canvas lg:text-4xl"
>
Learning
</h1>
<p class="text-primary-warm-gray mt-3 text-sm/relaxed">
Hands-on ComfyUI tutorials and workflows, by discipline.
</p>
<nav
class="mt-8 flex scrollbar-none gap-3 overflow-x-auto lg:flex-col lg:overflow-visible"
aria-label="Category filter"
>
<button
v-for="option in categoryOptions"
:key="option.value"
type="button"
:aria-pressed="category === option.value"
class="shrink-0 cursor-pointer rounded-xl px-4 py-3 text-left transition-colors lg:w-full"
:class="
category === option.value
? 'bg-primary-comfy-yellow text-primary-comfy-ink'
: 'bg-transparency-white-t4 text-primary-comfy-canvas hover:bg-white/10'
"
@click="category = option.value"
>
<span class="flex items-baseline justify-between gap-6">
<span class="text-xs font-semibold tracking-wide uppercase">
{{ option.label }}
</span>
<span
class="text-xs tabular-nums"
:class="
category === option.value
? 'text-primary-comfy-ink/70'
: 'text-primary-warm-gray'
"
>
{{ filterByCategory(option.value).length }}
</span>
</span>
<span
class="mt-1 hidden text-[11px]/snug lg:block"
:class="
category === option.value
? 'text-primary-comfy-ink/70'
: 'text-primary-warm-gray'
"
>
{{ option.blurb }}
</span>
</button>
</nav>
</div>
</aside>
<!-- Content column -->
<div class="min-w-0 flex-1">
<!-- Featured banner -->
<article
v-if="featured"
class="bg-transparency-white-t4 rounded-4.5xl grid items-center gap-6 p-5 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)] lg:gap-10 lg:p-8"
>
<div class="flex flex-col gap-4">
<div class="flex items-center gap-3">
<Badge variant="accent">Featured</Badge>
<Badge variant="category">
{{ categoryLabel(featured.category) }}
</Badge>
</div>
<h2
class="text-2xl font-light tracking-tight text-primary-comfy-canvas lg:text-3xl"
>
{{ t('learning.tutorials.titlePrefix', locale) }}
{{ featured.title[locale] }}
</h2>
<ul class="flex flex-wrap gap-2">
<li v-for="tag in featured.tags" :key="tag">
<Badge variant="subtle">{{ t(tag, locale) }}</Badge>
</li>
</ul>
<div v-if="featured.href">
<ButtonMask
as="a"
:href="featured.href"
icon-position="right"
variant="ghost"
size="default"
>
{{ t('cta.tryWorkflow', locale) }}
<template #icon>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
class="size-4"
>
<polyline points="9 6 15 12 9 18" />
</svg>
</template>
</ButtonMask>
</div>
</div>
<button
type="button"
class="group relative block aspect-video cursor-pointer overflow-hidden rounded-3xl"
:aria-label="`Play ${featured.title[locale]}`"
@click="activeTutorial = featured"
>
<video
:src="getTutorialPosterSrc(featured)"
:poster="featured.poster"
class="size-full object-cover"
preload="metadata"
playsinline
muted
></video>
<span
class="absolute inset-0 flex items-center justify-center"
aria-hidden="true"
>
<span
class="flex size-14 items-center justify-center rounded-full bg-white/25 backdrop-blur-sm transition-transform group-hover:scale-105"
>
<svg
class="ml-1 size-5 text-white"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<path d="M8 5v14l11-7z" />
</svg>
</span>
</span>
</button>
</article>
<!-- Row list -->
<ul class="mt-6 divide-y divide-white/10">
<li
v-for="tutorial in rows"
:key="tutorial.id"
class="flex flex-wrap items-center gap-4 py-5 lg:flex-nowrap lg:gap-6"
>
<button
type="button"
class="group relative block aspect-video w-36 shrink-0 cursor-pointer overflow-hidden rounded-2xl lg:w-44"
:aria-label="`Play ${tutorial.title[locale]}`"
@click="activeTutorial = tutorial"
>
<video
:src="getTutorialPosterSrc(tutorial)"
:poster="tutorial.poster"
class="size-full object-cover"
preload="metadata"
playsinline
muted
></video>
<span
class="absolute inset-0 flex items-center justify-center opacity-0 transition-opacity group-hover:opacity-100"
aria-hidden="true"
>
<span
class="flex size-9 items-center justify-center rounded-full bg-white/25 backdrop-blur-sm"
>
<svg
class="ml-0.5 size-4 text-white"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<path d="M8 5v14l11-7z" />
</svg>
</span>
</span>
</button>
<div class="min-w-0 flex-1">
<Badge variant="category" size="xs">
{{ categoryLabel(tutorial.category) }}
</Badge>
<h3
class="mt-1 text-sm/snug text-primary-comfy-canvas lg:text-base/snug"
>
{{ t('learning.tutorials.titlePrefix', locale) }}
{{ tutorial.title[locale] }}
</h3>
<ul class="mt-2 flex flex-wrap gap-2">
<li v-for="tag in tutorial.tags" :key="tag">
<Badge size="xs">{{ t(tag, locale) }}</Badge>
</li>
</ul>
</div>
<ButtonMask
v-if="tutorial.href"
as="a"
:href="tutorial.href"
icon-position="right"
class="shrink-0"
variant="ghost"
size="default"
>
{{ t('cta.tryWorkflow', locale) }}
<template #icon>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
class="size-4"
>
<polyline points="9 6 15 12 9 18" />
</svg>
</template>
</ButtonMask>
</li>
</ul>
</div>
</div>
<TutorialDetailDialog
v-if="activeTutorial"
:tutorial="activeTutorial"
:locale="locale"
@close="activeTutorial = null"
/>
</section>
</template>

View File

@@ -0,0 +1,291 @@
<script setup lang="ts">
// PROTOTYPE VARIANT C — "Spotlight hero + category shelves".
// The hero earns its keep by merging with the featured element: headline on
// the left, featured video playing inline on the right. Below, one horizontal
// shelf per category (VFX / Animations / Ads); "View all" drills into a
// single-category grid — this is the shape that maps most directly onto
// /learning/vfx-style routes.
import { computed, ref } from 'vue'
import type { Locale } from '../../../i18n/translations'
import type { CategoryFilter, PrototypeTutorial } from './prototypeData'
import { getTutorialPosterSrc } from '../../../data/learningTutorials'
import { t } from '../../../i18n/translations'
import Badge from '../../ui/badge/Badge.vue'
import { ButtonMask } from '../../ui/button-mask'
import VideoPlayer from '../../common/VideoPlayer.vue'
import TutorialDetailDialog from '../../learning/TutorialDetailDialog.vue'
import {
categoryLabel,
featuredFor,
filterByCategory,
realCategories
} from './prototypeData'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const category = defineModel<CategoryFilter>('category', { default: 'all' })
const featured = computed(() => featuredFor('all'))
const drilledIn = computed(() => category.value !== 'all')
const drilledTutorials = computed(() => filterByCategory(category.value))
const drilledLabel = computed(() =>
category.value === 'all' ? '' : categoryLabel(category.value)
)
const activeTutorial = ref<PrototypeTutorial | null>(null)
</script>
<template>
<!-- Spotlight hero: headline + featured element merged -->
<section class="max-w-9xl mx-auto px-6 pt-20 pb-8 lg:pt-24 lg:pb-12">
<div class="grid items-center gap-10 lg:grid-cols-2 lg:gap-16">
<div class="flex flex-col gap-6">
<h1
class="text-3xl leading-[110%] font-light tracking-tight text-primary-comfy-canvas lg:text-5xl"
>
{{ t('learning.heroTitle.before', locale) }}
<span class="text-primary-comfy-yellow">ComfyUI</span
>{{ t('learning.heroTitle.after', locale) }}
{{ t('learning.heroTitle.line2', locale) }}
</h1>
<div v-if="featured" class="flex flex-col gap-4">
<div class="flex items-center gap-3">
<Badge variant="accent">Featured</Badge>
<Badge variant="category">
{{ categoryLabel(featured.category) }}
</Badge>
</div>
<p class="text-sm/relaxed text-primary-comfy-canvas lg:text-base">
{{ t('learning.tutorials.titlePrefix', locale) }}
{{ featured.title[locale] }}
</p>
<div v-if="featured.href">
<ButtonMask
as="a"
:href="featured.href"
icon-position="right"
variant="ghost"
size="default"
>
{{ t('cta.tryWorkflow', locale) }}
<template #icon>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
class="size-4"
>
<polyline points="9 6 15 12 9 18" />
</svg>
</template>
</ButtonMask>
</div>
</div>
</div>
<div
v-if="featured"
class="border-primary-warm-gray rounded-4.5xl border p-4"
>
<VideoPlayer
:locale
:src="featured.videoSrc"
:poster="featured.poster"
minimal
/>
</div>
</div>
</section>
<!-- Drilled-in single-category grid -->
<section v-if="drilledIn" class="max-w-9xl mx-auto px-6 py-8 lg:py-12">
<button
type="button"
class="text-primary-warm-gray flex cursor-pointer items-center gap-2 text-xs font-semibold tracking-wide uppercase transition-colors hover:text-primary-comfy-canvas"
@click="category = 'all'"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
class="size-3"
aria-hidden="true"
>
<polyline points="15 6 9 12 15 18" />
</svg>
All categories
</button>
<h2
class="mt-4 mb-10 text-3xl font-light tracking-tight text-primary-comfy-canvas lg:text-5xl"
>
{{ drilledLabel }}
<span class="text-primary-warm-gray text-xl lg:text-3xl">
{{ drilledTutorials.length }} tutorial{{
drilledTutorials.length === 1 ? '' : 's'
}}
</span>
</h2>
<ul
class="grid grid-cols-1 gap-x-6 gap-y-10 md:grid-cols-2 lg:grid-cols-3 lg:gap-x-8"
>
<li
v-for="tutorial in drilledTutorials"
:key="tutorial.id"
class="bg-transparency-white-t4 flex flex-col gap-4 overflow-hidden rounded-3xl p-2"
>
<button
type="button"
class="group relative block aspect-video cursor-pointer overflow-hidden rounded-3xl"
:aria-label="`Play ${tutorial.title[locale]}`"
@click="activeTutorial = tutorial"
>
<video
:src="getTutorialPosterSrc(tutorial)"
:poster="tutorial.poster"
class="size-full object-cover"
preload="metadata"
playsinline
muted
></video>
<span
class="absolute inset-0 flex items-center justify-center"
aria-hidden="true"
>
<span
class="flex size-14 items-center justify-center rounded-full bg-white/25 backdrop-blur-sm transition-transform group-hover:scale-105"
>
<svg
class="ml-1 size-5 text-white"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<path d="M8 5v14l11-7z" />
</svg>
</span>
</span>
</button>
<div class="flex flex-col gap-2 p-4">
<Badge variant="category">
{{ categoryLabel(tutorial.category) }}
</Badge>
<h3 class="text-sm/snug text-primary-comfy-canvas lg:text-base/snug">
{{ t('learning.tutorials.titlePrefix', locale) }}
{{ tutorial.title[locale] }}
</h3>
</div>
</li>
</ul>
</section>
<!-- Category shelves -->
<template v-else>
<section
v-for="option in realCategories"
:key="option.value"
class="max-w-9xl mx-auto px-6 py-8 lg:py-10"
>
<header class="mb-6 flex items-end justify-between gap-6">
<div>
<h2
class="text-2xl font-light tracking-tight text-primary-comfy-canvas lg:text-4xl"
>
{{ option.label }}
</h2>
<p class="text-primary-warm-gray mt-1 text-sm">
{{ option.blurb }}
</p>
</div>
<button
type="button"
class="text-primary-comfy-yellow flex shrink-0 cursor-pointer items-center gap-2 text-xs font-semibold tracking-wide uppercase transition-opacity hover:opacity-80"
@click="category = option.value"
>
View all {{ filterByCategory(option.value).length }}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
class="size-3"
aria-hidden="true"
>
<polyline points="9 6 15 12 9 18" />
</svg>
</button>
</header>
<ul
class="flex snap-x scrollbar-none gap-6 overflow-x-auto pb-2 lg:gap-8"
>
<li
v-for="tutorial in filterByCategory(option.value)"
:key="tutorial.id"
class="bg-transparency-white-t4 flex w-72 shrink-0 snap-start flex-col gap-3 overflow-hidden rounded-3xl p-2 lg:w-80"
>
<button
type="button"
class="group relative block aspect-video cursor-pointer overflow-hidden rounded-3xl"
:aria-label="`Play ${tutorial.title[locale]}`"
@click="activeTutorial = tutorial"
>
<video
:src="getTutorialPosterSrc(tutorial)"
:poster="tutorial.poster"
class="size-full object-cover"
preload="metadata"
playsinline
muted
></video>
<span
class="absolute inset-0 flex items-center justify-center"
aria-hidden="true"
>
<span
class="flex size-12 items-center justify-center rounded-full bg-white/25 backdrop-blur-sm transition-transform group-hover:scale-105"
>
<svg
class="ml-0.5 size-4 text-white"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<path d="M8 5v14l11-7z" />
</svg>
</span>
</span>
</button>
<div class="flex flex-col gap-2 p-3">
<Badge variant="category" size="xs">
{{ categoryLabel(tutorial.category) }}
</Badge>
<h3 class="text-sm/snug text-primary-comfy-canvas">
{{ t('learning.tutorials.titlePrefix', locale) }}
{{ tutorial.title[locale] }}
</h3>
</div>
</li>
</ul>
</section>
</template>
<TutorialDetailDialog
v-if="activeTutorial"
:tutorial="activeTutorial"
:locale="locale"
@close="activeTutorial = null"
/>
</template>

View File

@@ -0,0 +1,73 @@
// PROTOTYPE — throwaway data layer for the /learning redesign prototype.
// Assigns each existing tutorial a category (vfx | animations | ads) and picks
// a featured item. In production the category would live on LearningTutorial
// itself and drive /learning/vfx-style routes; here it's bolted on so the
// variants have something real to filter.
import type { LearningTutorial } from '../../../data/learningTutorials'
import { learningTutorials } from '../../../data/learningTutorials'
type LearningCategory = 'vfx' | 'animations' | 'ads'
export type CategoryFilter = 'all' | LearningCategory
export type PrototypeTutorial = LearningTutorial & {
category: LearningCategory
}
// PROTOTYPE guesses — the real assignment is a content decision.
const categoryById: Record<string, LearningCategory> = {
cleanplate_walkthrough_v03: 'vfx',
deaging_workflow_v03: 'animations',
frame_adjustments_demo_v03: 'animations',
mattes_and_utilities_v03: 'vfx',
seedance_demo_comfyui_v03: 'ads',
skyreplacement_smaller_v06: 'vfx'
}
const prototypeTutorials: readonly PrototypeTutorial[] = learningTutorials.map(
(tutorial) => ({
...tutorial,
category: categoryById[tutorial.id] ?? 'vfx'
})
)
// Same tutorial the current FeaturedWorkflowSection showcases.
const FEATURED_ID = 'skyreplacement_smaller_v06'
interface CategoryOption {
value: CategoryFilter
label: string
blurb: string
}
export const categoryOptions: readonly CategoryOption[] = [
{ value: 'all', label: 'All', blurb: 'Every tutorial and workflow' },
{ value: 'vfx', label: 'VFX', blurb: 'Compositing, cleanup and shot work' },
{
value: 'animations',
label: 'Animations',
blurb: 'Motion, retiming and character'
},
{ value: 'ads', label: 'Ads', blurb: 'Product shots and campaign assets' }
]
export const realCategories = categoryOptions.filter(
(option): option is CategoryOption & { value: LearningCategory } =>
option.value !== 'all'
)
export const categoryLabel = (category: LearningCategory): string =>
realCategories.find((option) => option.value === category)?.label ?? category
export const filterByCategory = (
category: CategoryFilter
): readonly PrototypeTutorial[] =>
category === 'all'
? prototypeTutorials
: prototypeTutorials.filter((tutorial) => tutorial.category === category)
/** Featured item for the current filter — the global pick when visible, else the category's first. */
export const featuredFor = (category: CategoryFilter): PrototypeTutorial => {
const pool = filterByCategory(category)
return pool.find((tutorial) => tutorial.id === FEATURED_ID) ?? pool[0]
}

View File

@@ -0,0 +1,25 @@
---
// PROTOTYPE ROUTE — throwaway. Three redesign variants of the /learning page
// (category filters vfx/animations/ads, featured element, category-labelled
// cards), switchable via ?variant=a|b|c. The real /learning page is untouched.
// Delete this file once a variant has been chosen and folded into /learning.
import BaseLayout from '../layouts/BaseLayout.astro'
import LearningRedesignPrototype from '../components/prototype/learning-redesign/LearningRedesignPrototype.vue'
import CallToActionSection from '../components/common/CallToActionSection.vue'
import { getRoutes } from '../config/routes'
import { externalLinks } from '../config/routes'
const routes = getRoutes('en')
---
<BaseLayout title="Learning (Prototype) — Comfy">
<LearningRedesignPrototype client:only="vue" />
<CallToActionSection
headingKey="learning.cta.heading"
primaryLabelKey="learning.cta.contactSales"
primaryHref={routes.contact}
secondaryLabelKey="cta.tryWorkflow"
secondaryHref={externalLinks.workflows}
client:visible
/>
</BaseLayout>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 89 KiB

View File

@@ -30,7 +30,6 @@
"content": "content",
"audioProgress": "Audio progress",
"viewImageOfTotal": "View image {index} of {total}",
"imageIndexOfTotal": "{index} / {total}",
"viewVideoOfTotal": "View video {index} of {total}",
"imageLightbox": "Image preview",
"imagePreview": "Image preview - Use arrow keys to navigate between images",

View File

@@ -17,7 +17,7 @@ const meta: Meta<typeof ImagePreview> = {
docs: {
description: {
component:
'Node output image preview. Controls (download, edit/mask, prev/next navigation) sit in a row below the image, kept off the media itself; arrow keys also navigate between images.'
'Node output image preview with navigation dots, keyboard controls, and hover action buttons (download, remove, edit/mask).'
}
}
},

View File

@@ -28,10 +28,7 @@ const i18n = createI18n({
editOrMaskImage: 'Edit or mask image',
downloadImage: 'Download image',
removeImage: 'Remove image',
previousImage: 'Previous image',
nextImage: 'Next image',
viewImageOfTotal: 'View image {index} of {total}',
imageIndexOfTotal: '{index} / {total}',
imagePreview:
'Image preview - Use arrow keys to navigate between images',
errorLoadingImage: 'Error loading image',
@@ -114,30 +111,26 @@ describe('ImagePreview', () => {
screen.getByText('Calculating dimensions')
})
it('shows prev/next navigation buttons for multiple images in gallery mode', async () => {
it('shows navigation dots for multiple images in gallery mode', async () => {
renderImagePreview()
const user = userEvent.setup()
await switchToGallery(user)
expect(
screen.getByRole('button', { name: 'Previous image' })
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Next image' })
).toBeInTheDocument()
const navigationDots = screen.getAllByRole('button', {
name: /View image/
})
expect(navigationDots).toHaveLength(2)
})
it('does not show prev/next navigation for a single image', () => {
it('does not show navigation dots for single image', () => {
renderImagePreview({
imageUrls: [defaultProps.imageUrls[0]]
})
expect(
screen.queryByRole('button', { name: 'Previous image' })
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Next image' })
).not.toBeInTheDocument()
const navigationDots = screen.queryAllByRole('button', {
name: /View image/
})
expect(navigationDots).toHaveLength(0)
})
it('does not show mask/edit button for multiple images in gallery mode', async () => {
@@ -195,7 +188,7 @@ describe('ImagePreview', () => {
expect(downloadFile).toHaveBeenCalledWith(defaultProps.imageUrls[0])
})
it('switches images with the next button', async () => {
it('switches images when navigation dots are clicked', async () => {
renderImagePreview()
const user = userEvent.setup()
await switchToGallery(user)
@@ -206,7 +199,11 @@ describe('ImagePreview', () => {
defaultProps.imageUrls[0]
)
await user.click(screen.getByRole('button', { name: 'Next image' }))
// Click second navigation dot
const navigationDots = screen.getAllByRole('button', {
name: /View image/
})
await user.click(navigationDots[1])
await nextTick()
expect(screen.getByRole('img')).toHaveAttribute(
@@ -215,31 +212,25 @@ describe('ImagePreview', () => {
)
})
it('wraps to the last image with the previous button', async () => {
it('marks active navigation dot with aria-current', async () => {
renderImagePreview()
const user = userEvent.setup()
await switchToGallery(user)
await user.click(screen.getByRole('button', { name: 'Previous image' }))
const navigationDots = screen.getAllByRole('button', {
name: /View image/
})
// First dot should be active
expect(navigationDots[0]).toHaveAttribute('aria-current', 'true')
expect(navigationDots[1]).not.toHaveAttribute('aria-current')
await user.click(navigationDots[1])
await nextTick()
expect(screen.getByRole('img')).toHaveAttribute(
'src',
defaultProps.imageUrls[1]
)
})
it('shows the current image position in the counter', async () => {
renderImagePreview()
const user = userEvent.setup()
await switchToGallery(user)
screen.getByText('1 / 2')
await user.click(screen.getByRole('button', { name: 'Next image' }))
await nextTick()
screen.getByText('2 / 2')
// Second dot should now be active
expect(navigationDots[0]).not.toHaveAttribute('aria-current')
expect(navigationDots[1]).toHaveAttribute('aria-current', 'true')
})
it('has proper accessibility attributes', () => {
@@ -257,7 +248,11 @@ describe('ImagePreview', () => {
expect(screen.getByRole('img')).toHaveAttribute('alt', 'View image 1 of 2')
await user.click(screen.getByRole('button', { name: 'Next image' }))
// Switch to second image
const navigationDots = screen.getAllByRole('button', {
name: /View image/
})
await user.click(navigationDots[1])
await nextTick()
expect(screen.getByRole('img')).toHaveAttribute('alt', 'View image 2 of 2')
@@ -437,7 +432,7 @@ describe('ImagePreview', () => {
expect(mainImg).toHaveAttribute('src', defaultProps.imageUrls[1])
})
it('shows back-to-grid button in gallery mode', async () => {
it('shows back-to-grid button next to navigation dots', async () => {
renderImagePreview()
const user = userEvent.setup()
await switchToGallery(user)
@@ -507,8 +502,9 @@ describe('ImagePreview', () => {
container.querySelector('[aria-busy="true"]')
).not.toBeInTheDocument()
// Advance to the next (identical) image
await user.click(screen.getByRole('button', { name: 'Next image' }))
// Click second navigation dot to cycle
const dots = screen.getAllByRole('button', { name: /View image/ })
await user.click(dots[1])
await nextTick()
// Advance past the delayed loader timeout

View File

@@ -1,7 +1,7 @@
<template>
<div
v-if="imageUrls.length > 0"
class="image-preview relative flex size-full min-h-55 min-w-16 flex-col justify-center px-2"
class="image-preview group relative flex size-full min-h-55 min-w-16 flex-col justify-center px-2"
@keydown="handleKeyDown"
>
<!-- Grid View -->
@@ -47,7 +47,7 @@
<div
v-if="viewMode === 'gallery'"
ref="galleryPanelEl"
class="relative flex min-h-0 w-full flex-1 cursor-pointer overflow-hidden rounded-sm bg-transparent"
class="group/panel relative flex min-h-0 w-full flex-1 cursor-pointer overflow-hidden rounded-sm bg-transparent"
tabindex="0"
role="region"
:aria-roledescription="$t('g.imageGallery')"
@@ -101,6 +101,44 @@
@load="handleImageLoad"
@error="handleImageError"
/>
<!-- Floating Action Buttons (appear on hover and focus) -->
<div
class="actions invisible absolute top-2 right-2 flex gap-1 group-focus-within/panel:visible group-hover/panel:visible"
>
<!-- Mask/Edit Button -->
<button
v-if="!hasMultipleImages && !imageError && !currentImageIsHdr"
:class="actionButtonClass"
:title="$t('g.editOrMaskImage')"
:aria-label="$t('g.editOrMaskImage')"
@click="handleEditMask"
>
<i-comfy:mask class="size-4" />
</button>
<!-- Download Button -->
<button
v-if="!imageError"
:class="actionButtonClass"
:title="$t('g.downloadImage')"
:aria-label="$t('g.downloadImage')"
@click="handleDownload"
>
<i class="icon-[lucide--download] size-4" />
</button>
<!-- Back to Grid Button -->
<button
v-if="hasMultipleImages"
:class="actionButtonClass"
:title="$t('g.viewGrid')"
:aria-label="$t('g.viewGrid')"
@click="viewMode = 'grid'"
>
<i class="icon-[lucide--layout-grid] size-4" />
</button>
</div>
</div>
<!-- Image Dimensions (gallery mode only) -->
@@ -123,71 +161,35 @@
</span>
</div>
<!-- Controls (below image, kept off the media) -->
<!-- Multiple Images Navigation (gallery mode only) -->
<div
v-if="viewMode === 'gallery' && (hasMultipleImages || !imageError)"
class="flex items-center justify-between gap-2 pt-2"
v-if="viewMode === 'gallery' && hasMultipleImages"
class="flex flex-wrap items-center justify-center gap-1 pt-4"
>
<!-- Action buttons -->
<div class="flex items-center gap-1">
<button
v-if="hasMultipleImages"
:class="controlButtonClass"
:title="$t('g.viewGrid')"
:aria-label="$t('g.viewGrid')"
@click="viewMode = 'grid'"
>
<i class="icon-[lucide--layout-grid] size-4" />
</button>
<button
v-if="!hasMultipleImages && !imageError && !currentImageIsHdr"
:class="controlButtonClass"
:title="$t('g.editOrMaskImage')"
:aria-label="$t('g.editOrMaskImage')"
@click="handleEditMask"
>
<i-comfy:mask class="size-4" />
</button>
<button
v-if="!imageError"
:class="controlButtonClass"
:title="$t('g.downloadImage')"
:aria-label="$t('g.downloadImage')"
@click="handleDownload"
>
<i class="icon-[lucide--download] size-4" />
</button>
</div>
<!-- Back to Grid button -->
<button
class="mr-1 flex cursor-pointer items-center justify-center rounded-sm border-0 bg-transparent p-0.5 text-base-foreground/50 transition-colors hover:text-base-foreground"
:title="$t('g.viewGrid')"
:aria-label="$t('g.viewGrid')"
@click="viewMode = 'grid'"
>
<i class="icon-[lucide--layout-grid] size-3.5" />
</button>
<!-- Previous / Next navigation -->
<div v-if="hasMultipleImages" class="flex items-center gap-1">
<button
:class="controlButtonClass"
:title="$t('g.previousImage')"
:aria-label="$t('g.previousImage')"
@click="goToPrevious"
>
<i class="icon-[lucide--chevron-left] size-4" />
</button>
<span
class="min-w-10 text-center text-xs text-base-foreground/70 tabular-nums"
>
{{
$t('g.imageIndexOfTotal', {
index: currentIndex + 1,
total: imageUrls.length
})
}}
</span>
<button
:class="controlButtonClass"
:title="$t('g.nextImage')"
:aria-label="$t('g.nextImage')"
@click="goToNext"
>
<i class="icon-[lucide--chevron-right] size-4" />
</button>
</div>
<!-- Navigation Dots -->
<button
v-for="(_, index) in imageUrls"
:key="index"
:class="getNavigationDotClass(index)"
:aria-current="index === currentIndex ? 'true' : undefined"
:aria-label="
$t('g.viewImageOfTotal', {
index: index + 1,
total: imageUrls.length
})
"
@click="setCurrentIndex(index)"
/>
</div>
</div>
</template>
@@ -208,6 +210,7 @@ import type { NodeId } from '@/types/nodeId'
import { isHdrImageUrl } from '@/utils/hdrFormatUtil'
import { getGridThumbnailUrl } from '@/utils/imageUtil'
import { resolveNode } from '@/utils/litegraphUtil'
import { cn } from '@comfyorg/tailwind-utils'
interface ImagePreviewProps {
/** Array of image URLs to display */
@@ -223,8 +226,8 @@ const maskEditor = useMaskEditor()
const nodeOutputStore = useNodeOutputStore()
const toastStore = useToastStore()
const controlButtonClass =
'flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-lg border-0 bg-transparent text-base-foreground/60 transition-colors duration-200 hover:bg-base-foreground/10 hover:text-base-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-base-foreground'
const actionButtonClass =
'flex h-8 min-h-8 cursor-pointer items-center justify-center rounded-lg border-0 bg-base-foreground p-2 text-base-background shadow-interface transition-colors duration-200 hover:bg-base-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-base-foreground focus-visible:ring-offset-2'
type ViewMode = 'gallery' | 'grid'
@@ -353,18 +356,6 @@ function setCurrentIndex(index: number) {
}
}
function goToPrevious() {
setCurrentIndex(
currentIndex.value > 0 ? currentIndex.value - 1 : imageUrls.length - 1
)
}
function goToNext() {
setCurrentIndex(
currentIndex.value < imageUrls.length - 1 ? currentIndex.value + 1 : 0
)
}
async function openImageInGallery(index: number) {
setCurrentIndex(index)
viewMode.value = 'gallery'
@@ -381,6 +372,15 @@ function handleGridClick(index: number) {
void openImageInGallery(index)
}
function getNavigationDotClass(index: number) {
return cn(
'size-2 cursor-pointer rounded-full border-0 p-0 transition-all duration-200',
index === currentIndex.value
? 'bg-base-foreground'
: 'bg-base-foreground/50 hover:bg-base-foreground/80'
)
}
function handleKeyDown(event: KeyboardEvent) {
if (
event.key === 'Escape' &&
@@ -397,11 +397,15 @@ function handleKeyDown(event: KeyboardEvent) {
switch (event.key) {
case 'ArrowLeft':
event.preventDefault()
goToPrevious()
setCurrentIndex(
currentIndex.value > 0 ? currentIndex.value - 1 : imageUrls.length - 1
)
break
case 'ArrowRight':
event.preventDefault()
goToNext()
setCurrentIndex(
currentIndex.value < imageUrls.length - 1 ? currentIndex.value + 1 : 0
)
break
case 'Home':
event.preventDefault()