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 @@
+
+
+
+
@@ -290,9 +320,6 @@ function recentRepoDescription(item: RecentRepoUpdate): string {
scroll into view, so their code stays out of the scripts the first paint
waits on.
-->
-
-
-
-
+
+
+
+
+
+
+
@@ -732,6 +765,38 @@ function recentRepoDescription(item: RecentRepoUpdate): string {
padding-bottom: 4.5rem;
}
+.home-hero__content--demo {
+ display: grid;
+ grid-template-areas: 'intro' 'demo' 'support';
+ gap: 1.5rem;
+}
+
+.home-hero__intro {
+ grid-area: intro;
+ min-inline-size: 0;
+}
+
+.home-hero__demo {
+ grid-area: demo;
+ min-inline-size: 0;
+}
+
+.home-hero__demo :deep(.demo-stage__prompt) {
+ display: -webkit-box;
+ overflow: hidden;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
+ line-clamp: 2;
+}
+
+.home-hero__support {
+ grid-area: support;
+ min-inline-size: 0;
+}
+
/* Phones and tablets: a strip under the copy, clear of every line of text. */
.home-hero__texture {
--brand-dot: var(--ui-text-muted);
@@ -770,6 +835,49 @@ function recentRepoDescription(item: RecentRepoUpdate): string {
linear-gradient(to bottom, transparent, #000 18%, #000 82%, transparent);
mask-composite: intersect;
}
+
+ .home-hero__content--demo {
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+ grid-template-rows: 1fr min-content min-content 1fr;
+ grid-template-areas: '. demo' 'intro demo' 'support demo' '. demo';
+ gap: 1rem 3rem;
+ padding-block: 3rem;
+ align-items: start;
+ text-align: start;
+ }
+
+ .home-hero--demo .home-hero__texture {
+ mask-image:
+ linear-gradient(
+ to right,
+ #000 0,
+ #000 calc(50% - 40rem),
+ transparent calc(50% - 36rem),
+ transparent calc(50% + 36rem),
+ #000 calc(50% + 40rem),
+ #000 100%
+ ),
+ linear-gradient(to bottom, transparent, #000 18%, #000 82%, transparent);
+ }
+
+ .home-hero__content--demo .home-hero-title {
+ font-size: clamp(2.75rem, 4vw, 3.5rem);
+ }
+
+ .home-hero__content--demo .home-hero__caption,
+ .home-hero__content--demo .home-hero__agents {
+ margin-inline: 0;
+ }
+
+ .home-hero__demo :deep(.skill-card--row) {
+ grid-template-columns: minmax(0, 1fr) auto;
+ grid-template-areas: 'id end';
+ column-gap: 1rem;
+ }
+
+ .home-hero__demo :deep(.skill-card__row-end) {
+ margin-block-start: 0;
+ }
}
/* Inline-block drops the space the template formatter leaves inside a link,
@@ -816,6 +924,17 @@ function recentRepoDescription(item: RecentRepoUpdate): string {
padding-inline: 0.6em;
color: var(--ui-text-dimmed);
}
+
+ .home-hero__content--demo .home-hero__claims {
+ flex-wrap: wrap;
+ justify-content: start;
+ gap: 0.25rem 1rem;
+ margin-top: 0;
+ }
+
+ .home-hero__content--demo .home-hero__claims > li + li::before {
+ content: none;
+ }
}
.home-hero__agents {
diff --git a/app/utils/random-order.ts b/app/utils/random-order.ts
new file mode 100644
index 00000000..85722b8d
--- /dev/null
+++ b/app/utils/random-order.ts
@@ -0,0 +1,9 @@
+/** Change presentation order while leaving the source data intact. */
+export function shuffled(items: readonly T[], random: () => number): T[] {
+ const result = [...items]
+ for (let index = result.length - 1; index > 0; index--) {
+ const target = Math.floor(random() * (index + 1))
+ ;[result[index], result[target]] = [result[target]!, result[index]!]
+ }
+ return result
+}
diff --git a/docs/runbooks/demo-measurement.md b/docs/runbooks/demo-measurement.md
new file mode 100644
index 00000000..90d2300b
--- /dev/null
+++ b/docs/runbooks/demo-measurement.md
@@ -0,0 +1,63 @@
+# Demo measurement
+
+The anonymous discovery loop measures actions, not identified visitors.
+A copy shows intent. It does not prove that an Agent ran a Skill.
+
+## Events
+
+`POST /api/events/demo` accepts only fixed event shapes.
+It rejects identity fields, task text, unknown fields, and arbitrary campaign names.
+
+An exposure means at least half the output frame entered view.
+Each stage counts each demo once during that page visit.
+A carousel return does not count another exposure.
+Separate stages and reloads count separately.
+
+Agent and terminal copies count only after the clipboard succeeds.
+The Agent prompt includes the recorded task.
+A share count means the demo link was copied, not opened or posted.
+
+Elapsed time uses five buckets: unseen, under 10 seconds, 10 to 29 seconds,
+30 to 119 seconds, and 120 seconds or more.
+The clock starts at the first exposure and includes time away from the tab.
+It measures time before the action, not attention.
+
+Campaign labels are `demo-component`, `demo-page`, and `demo-motion`.
+Any other query value becomes `direct`.
+Links use `?campaign=