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
7 changed files with 1072 additions and 0 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>