From 35d8caba2a6efec366a8f7419b06c8d9507c08ec Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Fri, 9 Oct 2026 00:02:09 +1100 Subject: [PATCH 1/4] feat(home): lead with demos and simplify demo handoff --- COPY.md | 2 + app/components/DemoStage.vue | 19 +- app/components/_DemoActions.vue | 67 +++++ app/composables/useDemoEngagement.ts | 36 +++ app/pages/_HomeDemoCarousel.vue | 70 ++++++ app/pages/index.vue | 229 +++++++++++++----- app/utils/random-order.ts | 9 + docs/runbooks/demo-measurement.md | 63 +++++ .../marketing/app/components/BoardShell.vue | 26 +- .../app/pages/skills/demos/[...demo].vue | 2 +- layers/marketing/content/pages/privacy.md | 8 +- server/api/events/demo.post.ts | 16 ++ server/presenters/demo-engagement.ts | 3 + server/schemas/demo-engagement.ts | 13 + shared/demo-engagement.ts | 35 +++ test/a11y.nuxt.spec.ts | 9 + test/unit/demo-engagement.test.ts | 34 +++ test/unit/random-order.test.ts | 17 ++ 18 files changed, 590 insertions(+), 68 deletions(-) create mode 100644 app/components/_DemoActions.vue create mode 100644 app/composables/useDemoEngagement.ts create mode 100644 app/pages/_HomeDemoCarousel.vue create mode 100644 app/utils/random-order.ts create mode 100644 docs/runbooks/demo-measurement.md create mode 100644 server/api/events/demo.post.ts create mode 100644 server/presenters/demo-engagement.ts create mode 100644 server/schemas/demo-engagement.ts create mode 100644 shared/demo-engagement.ts create mode 100644 test/unit/demo-engagement.test.ts create mode 100644 test/unit/random-order.test.ts diff --git a/COPY.md b/COPY.md index 6029172c..ff303fcd 100644 --- a/COPY.md +++ b/COPY.md @@ -44,7 +44,9 @@ These exact strings. Do not paraphrase them per page. | Demos page | See what skills make · Each demo is one recorded run: the prompt, and what the Agent built with the Skill. Pick a prompt to see the output, or open it live. | guidance only: the `/skills/demos` H1 and line. The meta title is "Claude skill examples: see what each one makes" for the admitted query | | Demo page | What /{name} made · One recorded run: the prompt, and what the Agent built with the Skill. | guidance only: the H1 and line of a demo page, `/skills/demos///`. The meta title is "{name} skill example: {noun}" for the query " skill example", with `noun` from `DEMO_GROUPS`. The meta description is "{agent} made this {noun} with the /{name} skill from one prompt: “{prompt}”", cut at a word to 160 characters | | Demo groups | Films and launch videos · Landing pages · UI components · Diagrams and explainers | guidance only: `DEMO_GROUPS` in `shared/demo-groups.ts`: the `/skills/demos` group headings and their lines | +| Demo actions | Run it yourself · Agent prompt · Terminal · Copy Agent prompt · Copy command · Copied · Copy demo link · Copied demo link | guidance only: demo stages. The Agent prompt includes the recorded task. The terminal option loads the Skill. Run is hidden on phones. | | Demo stage | Open the demo | guidance only: the demo stage link. On the homepage it opens the demo page; on a demo page it opens the Skill page Demo panel | +| Home demo carousel | Previous demo · Next demo · {n} of {total} | guidance only: the manual homepage demo controls. The position is live text for screen readers. | | Demo panel | Demo · Prompt · Open live demo · Open in a new tab · Run it yourself · All demos | guidance only: the Skill page Demo panel. "Show screenshots" replaces "Open live demo" while the live output shows. "All demos" opens the demo page. It hides for a Skill page only demo, and while the Skill holds a run check flag | | Demo provenance | {Agent logo} {model}, {effort} effort | guidance only: the recording line on the Skill page panel and the homepage stage. Screen readers and the title get "Recorded with {agent}, {model}, {effort} effort". Omit effort when the recording has no evidence for it. No date and no GitHub link: the Skill page is where visitors inspect a Skill | | Demo outdated | Recorded on an older version of this Skill. | guidance only: under the Demo panel when the Skill moved past the recorded commit | diff --git a/app/components/DemoStage.vue b/app/components/DemoStage.vue index d3273713..1cf8abbc 100644 --- a/app/components/DemoStage.vue +++ b/app/components/DemoStage.vue @@ -2,6 +2,7 @@ import type { HomeDemoItem } from '~/utils/home-demos' import { demoPagePath } from '#shared/demo-pages' import { demoCardSkill, demoHasPhoneFrame, demoHref, demoPhoneRatio } from '~/utils/home-demos' +import DemoActions from './_DemoActions.vue' import DemoMedia from './home-demos/_DemoMedia.vue' import DemoRecording from './home-demos/_DemoRecording.vue' import SkillCard from './SkillCard.vue' @@ -27,6 +28,8 @@ const { demo, surface = 'demos', eager = false, showPrompt = false, live = false }>() /** The output page itself, when this stage shows it live. A film always plays as video. */ +const stage = useTemplateRef('stage') +const { record } = useDemoEngagement(() => `${demo.owner}/${demo.repo}/${demo.name}`, surface, stage) const liveUrl = computed(() => live && !demo.video ? demo.liveUrl : null) @@ -44,6 +47,7 @@ const liveUrl = computed(() => live && !demo.video ? demo.liveUrl : null)

+
@@ -152,6 +157,14 @@ const liveUrl = computed(() => live && !demo.video ? demo.liveUrl : null) block-size: auto; aspect-ratio: var(--phone-ratio, 4 / 5); } + + .demo-stage__id :deep(.skill-card--row) { + padding-block: 0.5rem; + } + + .demo-stage__id :deep(.skill-card__row-end) { + display: none; + } } /* The live page scrolls inside its own frame. */ @@ -174,6 +187,10 @@ const liveUrl = computed(() => live && !demo.video ? demo.liveUrl : null) /* The Skill row's hover fill follows the rounded corners of everything around it. */ .demo-stage__id { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 0.5rem; margin-block-start: 0.25rem; overflow: hidden; border-radius: var(--ui-radius); diff --git a/app/components/_DemoActions.vue b/app/components/_DemoActions.vue new file mode 100644 index 00000000..204c9979 --- /dev/null +++ b/app/components/_DemoActions.vue @@ -0,0 +1,67 @@ + + + + + diff --git a/app/composables/useDemoEngagement.ts b/app/composables/useDemoEngagement.ts new file mode 100644 index 00000000..aae83356 --- /dev/null +++ b/app/composables/useDemoEngagement.ts @@ -0,0 +1,36 @@ +import type { MaybeRefOrGetter, Ref } from 'vue' +import type { DemoCampaign, DemoEngagement } from '#shared/demo-engagement' +import { demoElapsedBucket, demoFrameIsVisible } from '#shared/demo-engagement' + +export function useDemoEngagement(slug: MaybeRefOrGetter, surface: string, stage: Readonly>) { + const route = useRoute() + const exposed = new Set() + const firstSeen = new Map() + const visible = ref(false) + const campaign = computed(() => { + const value = route.query.campaign + return value === 'demo-component' || value === 'demo-page' || value === 'demo-motion' ? value : 'direct' + }) + function record(action: { event: 'exposure' } | { event: 'share' } | { event: 'copy', format: 'agent' | 'terminal' }) { + const key = toValue(slug) + const since = firstSeen.get(key) + const context = { slug: key, surface, campaign: campaign.value } + const body: DemoEngagement = action.event === 'exposure' + ? { ...context, ...action } + : { ...context, ...action, elapsed: demoElapsedBucket(since === undefined ? null : performance.now() - since) } + void $fetch('/api/events/demo', { method: 'POST', body }).catch((error) => { + console.warn('[demo-engagement] Could not record demo event:', error) + }) + } + useIntersectionObserver(stage, ([entry]) => { + visible.value = demoFrameIsVisible(entry) + }, { threshold: 0.5 }) + watch([() => toValue(slug), visible], ([key, isVisible]) => { + if (!isVisible || exposed.has(key)) + return + exposed.add(key) + firstSeen.set(key, performance.now()) + record({ event: 'exposure' }) + }) + return { record } +} diff --git a/app/pages/_HomeDemoCarousel.vue b/app/pages/_HomeDemoCarousel.vue new file mode 100644 index 00000000..237b1ad1 --- /dev/null +++ b/app/pages/_HomeDemoCarousel.vue @@ -0,0 +1,70 @@ + + + + + diff --git a/app/pages/index.vue b/app/pages/index.vue index 4cad92f0..e0a3d219 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -11,6 +11,8 @@ import OutcomeClusterGrid from '../components/OutcomeClusterGrid.vue' import { AGENT_LOGOS } from '../utils/agent-logos' import { MORE_AGENT_COUNT } from '../utils/agent-reach' import { homeDemoFeed } from '../utils/home-demos' +import { shuffled } from '../utils/random-order' +import HomeDemoCarousel from './_HomeDemoCarousel.vue' const setupPrompt = agentSetupPrompt() const heroAgentsLabel = `Works with ${AGENT_LOGOS.map(agent => agent.label).join(', ')}, and ${MORE_AGENT_COUNT} more Agents` @@ -106,10 +108,16 @@ if (import.meta.server) { serverTimingHeader.value = homeDataTimings.join(', ') } -// Lazy: the section sits under the hero, and its images load lazily anyway. -const { data: demosData } = useLazyFetch('/api/skill-demos', { - key: 'home-skill-demos-v2', - transform: homeDemoFeed, +// The lead demo belongs in the server-rendered opening screen. +const { data: demosData } = await useFetch('/api/skill-demos', { + key: 'home-skill-demos-v3', + transform: (feed) => { + const home = homeDemoFeed(feed) + const hero = feed.items.find(demo => demo.owner === 'vojtaholik' && demo.repo === 'good-css' && demo.name === 'good-css') ?? null + const candidates = feed.items.filter(demo => demo.makes === 'ui-component' || demo.makes === 'landing-page') + const heroDemos = hero ? [hero, ...candidates.filter(demo => demo !== hero)].slice(0, 6) : candidates.slice(0, 6) + return { ...home, heroDemos, items: home.items.filter(demo => demo !== hero) } + }, }) // Lazy too: the Why band sits below the fold, and the feed is cached for a day. @@ -126,6 +134,17 @@ function onAvatarError(owner: string) { const trendingBoard = computed(() => (trendingData.value?.board ?? []).filter(row => !missingAvatars.value.has(row.owner)), ) +const trendingOrder = ref([]) +onMounted(() => { + trendingOrder.value = shuffled(trendingBoard.value.map(row => row.key), Math.random) +}) +const homeTrendingRows = computed(() => { + const rows = (trendingData.value?.board ?? []) + .map((row, index) => ({ row, rank: index + 1 })) + .filter(({ row }) => !missingAvatars.value.has(row.owner)) + const positions = new Map(trendingOrder.value.map((key, index) => [key, index])) + return rows.toSorted((a, b) => (positions.get(a.row.key) ?? a.rank) - (positions.get(b.row.key) ?? b.rank)) +}) /** * The section is hidden entirely when the board comes up short. A trending @@ -223,7 +242,7 @@ function recentRepoDescription(item: RecentRepoUpdate): string {