diff --git a/COPY.md b/COPY.md index 6029172c..2223ddd1 100644 --- a/COPY.md +++ b/COPY.md @@ -44,8 +44,12 @@ 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 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 | +| 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. The hero shows the output, author row, and this link only. 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 page prompt | Prompt | guidance only: the selected demo page keeps its recorded task in an expandable disclosure beside the model and effort, above the output frame. | | 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 token usage | {n} tokens · Input: {input}; cached input: {cached}; output: {output} | guidance only: recording metadata. Counts come from recorded usage. Cached input is included in input. Omit unavailable counts. | | 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 | | Home section: changes | Keep up with skill changes. | guidance only: the homepage section heading for watching and the digest | diff --git a/app/components/DemoStage.vue b/app/components/DemoStage.vue index d3273713..6ac74fb3 100644 --- a/app/components/DemoStage.vue +++ b/app/components/DemoStage.vue @@ -1,7 +1,8 @@ @@ -152,6 +175,80 @@ 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; + } +} + +/* Give interactive pages most of the screen, while keeping their actions in reach. */ +.demo-stage--compact .demo-stage__window { + block-size: clamp(24rem, 68svh, 52rem); + aspect-ratio: auto; +} + +.demo-stage--compact .demo-stage__window[data-film] { + block-size: auto; + aspect-ratio: var(--video-w) / var(--video-h); + inline-size: min(100%, calc(68svh * var(--video-w) / var(--video-h))); +} + +.demo-stage--compact .demo-stage__id { + position: sticky; + inset-block-end: 0; + z-index: 2; + border-block-start: 1px solid var(--ui-border); + background: var(--ui-bg); + padding-block-end: env(safe-area-inset-bottom, 0px); +} + +.demo-stage--compact .demo-stage__head { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; +} + +.demo-stage--compact .demo-stage__open { + grid-area: 1 / 2; +} + +.demo-stage__task { + grid-area: 1 / 1 / auto / -1; +} + +.demo-stage__task summary { + display: flex; + align-items: baseline; + gap: 0.75rem; + padding-inline-end: 8rem; + min-block-size: 2.75rem; + padding-block: 0.75rem; + cursor: pointer; +} + +.demo-stage__usage { + margin-block: 0.5rem; +} + +.demo-stage__task summary::before { + content: '▸'; +} + +.demo-stage__task[open] summary::before { + content: '▾'; +} + +.demo-stage__task :deep(.demo-recording__part) { + white-space: normal; +} + +.demo-stage__task summary:focus-visible { + outline: 2px solid var(--ui-border-accented); + outline-offset: 2px; } /* The live page scrolls inside its own frame. */ @@ -174,6 +271,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/components/home-demos/_DemoRecording.vue b/app/components/home-demos/_DemoRecording.vue index 64516235..02785d69 100644 --- a/app/components/home-demos/_DemoRecording.vue +++ b/app/components/home-demos/_DemoRecording.vue @@ -13,9 +13,15 @@ const recording = computed(() => demoRecording(demo)) @@ -38,7 +44,9 @@ const recording = computed(() => demoRecording(demo)) } .demo-recording__icon { - flex: none; + display: inline-block; + vertical-align: -0.125rem; + margin-inline-end: 0.375rem; inline-size: 0.875rem; block-size: 0.875rem; } 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..7361c8ff --- /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..bc2ba1af 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 {