Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion COPY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<owner>/<repo>/<name>`. The meta title is "{name} skill example: {noun}" for the query "<skill> 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 |
Expand Down
115 changes: 108 additions & 7 deletions app/components/DemoStage.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
<script setup lang="ts">
import type { HomeDemoItem } from '~/utils/home-demos'
import { demoPagePath } from '#shared/demo-pages'
import { demoCardSkill, demoHasPhoneFrame, demoHref, demoPhoneRatio } from '~/utils/home-demos'
import { demoCardSkill, demoHasPhoneFrame, demoHref, demoPhoneRatio, demoRecording } 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'
Expand All @@ -16,8 +17,9 @@ import SkillCard from './SkillCard.vue'
* "Open the demo" goes: the demo's own page, or, on that page, the Skill
* page's Demo panel.
*/
const { demo, surface = 'demos', eager = false, showPrompt = false, live = false, opens = 'demo-page' } = defineProps<{
const { demo, surface = 'demos', eager = false, showPrompt = false, live = false, opens = 'demo-page', presentation = 'full' } = defineProps<{
demo: HomeDemoItem
presentation?: 'full' | 'hero' | 'compact'
/** The analytics surface for the run chip, such as `home-demos`. */
surface?: string
eager?: boolean
Expand All @@ -27,23 +29,39 @@ 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<HTMLElement>('stage')
const { record } = useDemoEngagement(() => `${demo.owner}/${demo.repo}/${demo.name}`, surface, stage)
const recording = computed(() => demoRecording(demo))
const liveUrl = computed(() => live && !demo.video ? demo.liveUrl : null)
</script>

<template>
<div class="demo-stage">
<div class="demo-stage__head">
<DemoRecording :demo />
<div class="demo-stage" :class="{ 'demo-stage--compact': presentation === 'compact' }">
<div v-if="presentation !== 'hero'" class="demo-stage__head">
<details v-if="showPrompt && presentation === 'compact'" class="demo-stage__task">
<summary class="data-label">
<span>Prompt</span>
<DemoRecording :demo />
</summary>
<p v-if="recording.usage" class="data-label demo-stage__usage">
{{ recording.usage.sentence }}
</p>
<p class="demo-stage__prompt">
{{ demo.prompt }}
</p>
</details>
<DemoRecording v-else :demo />
<NuxtLink :to="opens === 'skill-page' ? demoHref(demo) : demoPagePath(demo)" class="demo-stage__open">
Open the demo<span class="sr-only"> of /{{ demo.name }}</span>
<UIcon name="i-lucide-arrow-right" class="size-3.5 shrink-0" aria-hidden="true" />
</NuxtLink>
</div>
<p v-if="showPrompt" class="demo-stage__prompt">
<p v-if="showPrompt && presentation === 'full'" class="demo-stage__prompt">
<span class="data-label mr-2">Prompt</span>{{ demo.prompt }}
</p>
<!-- The whole page, in a window one screen tall. A film fills the window and plays on view. -->
<div
ref="stage"
class="demo-stage__window"
:data-phone="demoHasPhoneFrame(demo) ? '' : undefined"
:style="demo.video
Expand Down Expand Up @@ -71,9 +89,14 @@ const liveUrl = computed(() => live && !demo.video ? demo.liveUrl : null)
layout="row"
metric="none"
:description="false"
:actions="['run']"
:actions="[]"
:surface
/>
<NuxtLink v-if="presentation === 'hero'" :to="demoPagePath(demo)" class="demo-stage__open" :aria-label="`Open the demo of /${demo.name}`">
Open the demo
<UIcon name="i-lucide-arrow-right" class="size-3.5 shrink-0" aria-hidden="true" />
</NuxtLink>
<DemoActions v-else :demo :surface @action="record" />
</div>
</div>
</template>
Expand Down Expand Up @@ -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. */
Expand All @@ -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);
Expand Down
67 changes: 67 additions & 0 deletions app/components/_DemoActions.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<script setup lang="ts">
import type { HomeDemoItem } from '~/utils/home-demos'
import { demoPagePath } from '#shared/demo-pages'
import { skillPageUrl, skillRunCmd, skillRunPrompt } from '#shared/skill-commands'

const { demo, surface } = defineProps<{ demo: HomeDemoItem, surface: string }>()
const emit = defineEmits<{ action: [action: { event: 'share' } | { event: 'copy', format: 'agent' | 'terminal' }] }>()
const format = ref<'agent' | 'terminal'>('agent')
const text = computed(() => format.value === 'agent'
? `${skillRunPrompt(skillPageUrl(demo.owner, demo.repo, demo.name))}\n\n${demo.prompt}`
: skillRunCmd(demo.owner, demo.repo, demo.name))
const target = computed(() => ({ kind: 'skill' as const, owner: demo.owner, name: demo.name }))
const { copy, copied } = useInstallCopy(text, surface, 'run', target)
const error = ref('')
const shareUrl = computed(() => `https://skilld.dev${demoPagePath(demo)}`)
const { copy: copyLink, copied: linkCopied, isSupported: linkSupported } = useClipboard({ source: shareUrl, legacy: true })

async function copyPrompt() {
error.value = ''
const copiedFormat = format.value
const result = await copy()
if (result._tag === 'error')
error.value = result.message
else
emit('action', { event: 'copy', format: copiedFormat })
}
async function share() {
error.value = ''
if (!linkSupported.value) {
error.value = 'Could not copy. Select the link and copy it manually.'
return
}
await copyLink().then(() => emit('action', { event: 'share' })).catch((cause) => {
console.warn('[demo-share] Could not copy demo link:', cause)
error.value = 'Could not copy. Select the link and copy it manually.'
})
}
</script>

<template>
<div class="demo-actions">
<UPopover class="demo-actions__run">
<UButton label="Run it yourself" color="neutral" variant="outline" class="min-h-11" />
<template #content>
<div class="w-[min(28rem,calc(100vw-2rem))] space-y-3 p-4 demo-actions__panel">
<div class="flex gap-2">
<UButton label="Agent prompt" :aria-pressed="format === 'agent'" color="neutral" variant="outline" class="demo-actions__format min-h-11" @click="format = 'agent'" />
<UButton label="Terminal" :aria-pressed="format === 'terminal'" color="neutral" variant="outline" class="demo-actions__format min-h-11" @click="format = 'terminal'" />
</div>
<pre class="max-h-64 overflow-auto whitespace-pre-wrap break-words rounded border border-default p-3 text-xs">{{ text }}</pre>
<UButton :label="copied ? 'Copied' : format === 'agent' ? 'Copy Agent prompt' : 'Copy command'" color="neutral" variant="outline" class="min-h-11" @click="copyPrompt" />
</div>
</template>
</UPopover>
<UButton :icon="linkCopied ? 'i-lucide-check' : 'i-lucide-link'" :aria-label="linkCopied ? 'Copied demo link' : 'Copy demo link'" color="neutral" variant="ghost" class="min-h-11 min-w-11 justify-center" @click="share" />
<span class="sr-only" role="status">{{ linkCopied ? 'Copied demo link' : copied ? 'Copied' : '' }}</span>
<p v-if="error" role="alert" class="basis-full text-xs text-error">
{{ error }} <a :href="shareUrl" class="underline">{{ shareUrl }}</a>
</p>
</div>
</template>

<style scoped>
.demo-actions__format[aria-pressed='true'] { background: var(--ui-bg-accented); color: var(--ui-text-highlighted); }
.demo-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; }
@media (max-width: 39.99rem) { .demo-actions__run { display: none; } }
</style>
14 changes: 11 additions & 3 deletions app/components/home-demos/_DemoRecording.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,15 @@ const recording = computed(() => demoRecording(demo))

<template>
<span class="demo-recording" :title="recording.sentence">
<UIcon :name="recording.icon" class="demo-recording__icon" aria-hidden="true" />
<span class="demo-recording__part" aria-hidden="true">{{ recording.model }}</span>
<span class="demo-recording__part" aria-hidden="true">
<UIcon :name="recording.icon" class="demo-recording__icon" aria-hidden="true" />
{{ recording.model }}
</span>
<span class="sr-only">{{ recording.sentence }}</span>
<span v-if="recording.usage" class="demo-recording__usage" :title="recording.usage.sentence">
<span aria-hidden="true">{{ recording.usage.label }}</span>
<span class="sr-only">{{ recording.usage.label }}. {{ recording.usage.sentence }}</span>
</span>
</span>
</template>

Expand All @@ -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;
}
Expand Down
36 changes: 36 additions & 0 deletions app/composables/useDemoEngagement.ts
Original file line number Diff line number Diff line change
@@ -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<string>, surface: string, stage: Readonly<Ref<HTMLElement | null>>) {
const route = useRoute()
const exposed = new Set<string>()
const firstSeen = new Map<string, number>()
const visible = ref(false)
const campaign = computed<DemoCampaign>(() => {
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 }
}
70 changes: 70 additions & 0 deletions app/pages/_HomeDemoCarousel.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<script setup lang="ts">
import type { HomeDemoItem } from '../utils/home-demos'
import DemoStage from '../components/DemoStage.vue'
import { shuffled } from '../utils/random-order'

const { demos } = defineProps<{ demos: readonly HomeDemoItem[] }>()
const order = shallowRef<readonly HomeDemoItem[]>(demos)
const picked = ref(0)
const current = computed(() => order.value[picked.value])
const stageId = useId()

// Hydrate the cached page before choosing a fresh order for this visit.
onMounted(() => {
order.value = shuffled(demos, Math.random)
})

function move(direction: -1 | 1): void {
picked.value = (picked.value + direction + order.value.length) % order.value.length
}
</script>

<template>
<div v-if="current" class="home-demo-carousel">
<div :id="stageId">
<DemoStage :demo="current" eager presentation="hero" surface="home-hero-demo" />
</div>
<div v-if="order.length > 1" class="home-demo-carousel__controls">
<UButton
icon="i-lucide-arrow-left"
color="neutral"
variant="ghost"
aria-label="Previous demo"
:aria-controls="stageId"
class="min-h-11 min-w-11 justify-center"
@click="move(-1)"
/>
<span class="font-mono text-xs text-muted" aria-live="polite" aria-atomic="true">{{ picked + 1 }} of {{ order.length }}</span>
<UButton
icon="i-lucide-arrow-right"
color="neutral"
variant="ghost"
aria-label="Next demo"
:aria-controls="stageId"
class="min-h-11 min-w-11 justify-center"
@click="move(1)"
/>
</div>
</div>
</template>

<style scoped>
.home-demo-carousel {
container-type: inline-size;
}

/* Each pick gets the same frame, including demos with a shorter phone shot. */
.home-demo-carousel :deep(.demo-stage__window) {
block-size: clamp(16rem, 62.5cqi, 24rem);
aspect-ratio: auto;
inline-size: 100%;
}

.home-demo-carousel__controls {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
margin-block-start: 0.5rem;
}
</style>
Loading
Loading