diff --git a/DESIGN.md b/DESIGN.md
index c71f3f6c..8433592b 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -347,6 +347,9 @@ Rules for every texture:
| Skill | Name, then owner, then the curator note or the curator count |
| Collection | Name, description, curator, then the curator note or the first Skills |
| Curator | Avatar, name, handle with counts, description, then the first Skills |
+| Demo | The demo's picture under a band: the lockup, then "What /{name} made" and the recording line. Its own frame, below |
+
+**Demo card.** A demo page shares what the Agent made, so its card is a picture first, and `OgLayout` does not frame it. `OgImage/Demo.takumi.vue` puts the film's poster frame, or the page's first screen, in a window that fills the card below a band. The band has the lockup on the left. On the right, it has "What /{name} made" and the recording line. It has no Braille names field: the picture is the content. Rose inside the picture is depicted content, so the lockup's dot stays the card's one rose element.
### Motifs
diff --git a/app/components/OgImage/Demo.takumi.vue b/app/components/OgImage/Demo.takumi.vue
new file mode 100644
index 00000000..b6326c68
--- /dev/null
+++ b/app/components/OgImage/Demo.takumi.vue
@@ -0,0 +1,83 @@
+
+
+
+
+
+
+
+
+ {{ safeTitle }}
+
+
+ {{ safeRecording }}
+
+
+
+
+
+
+
+
diff --git a/app/utils/home-demos.ts b/app/utils/home-demos.ts
index c28e9270..25429a99 100644
--- a/app/utils/home-demos.ts
+++ b/app/utils/home-demos.ts
@@ -22,7 +22,7 @@ export interface HomeDemoItem {
model: string
/** `YYYY-MM-DD`. */
recordedAt: string
- shots: { src: string, width: number, height: number, alt: string, viewport: 'desktop' | 'mobile' }[]
+ shots: HomeDemoShot[]
/** A Skill whose output is a film. The poster is a JPG frame; `src` is a muted H.264 MP4. */
/** The sandboxed output page of a page demo; null for a film. */
liveUrl: string | null
@@ -37,7 +37,15 @@ export interface HomeDemoVideo {
durationSeconds: number
}
-export type HomeDemoShot = HomeDemoItem['shots'][number]
+export interface HomeDemoShot {
+ src: string
+ width: number
+ height: number
+ alt: string
+ viewport: 'desktop' | 'mobile'
+ /** The first screen, when the shot runs longer than one. */
+ poster: { src: string, width: number, height: number } | null
+}
/** Fewer than this reads as a broken feature, as with trending. */
export const HOME_DEMOS_MIN = 3
@@ -104,6 +112,26 @@ export function demoScreens(shot: HomeDemoShot): number {
return Math.max(1, Math.round(shot.height / screenHeight))
}
+export interface DemoPicture {
+ src: string
+ width: number
+ height: number
+}
+
+/**
+ * The picture a demo page's social card shows: a film's poster frame, or the
+ * first screen of the desktop page. A long page never serves whole, since a
+ * 6000px shot would shrink to a strip. Undefined when the demo has neither;
+ * the page keeps its text card then.
+ */
+export function demoSocialPicture(demo: HomeDemoItem): DemoPicture | undefined {
+ if (demo.video)
+ return { src: demo.video.poster, width: demo.video.width, height: demo.video.height }
+ const shot = demo.shots.find(shot => shot.viewport === 'desktop')
+ const screen = shot?.poster ?? (shot && demoScreens(shot) === 1 ? shot : undefined)
+ return screen && { src: screen.src, width: screen.width, height: screen.height }
+}
+
/** The Skill as `SkillCard` reads it, so a demo names its author the way every Skill embed does. */
export function demoCardSkill(demo: HomeDemoItem): SkillCardSkill {
return {
diff --git a/layers/marketing/app/pages/skills/demos/[...demo].vue b/layers/marketing/app/pages/skills/demos/[...demo].vue
index 8dd6cf33..0ec770cd 100644
--- a/layers/marketing/app/pages/skills/demos/[...demo].vue
+++ b/layers/marketing/app/pages/skills/demos/[...demo].vue
@@ -1,7 +1,7 @@
diff --git a/test/unit/home-demos.test.ts b/test/unit/home-demos.test.ts
index 5644e7b8..29927156 100644
--- a/test/unit/home-demos.test.ts
+++ b/test/unit/home-demos.test.ts
@@ -1,6 +1,6 @@
import type { HomeDemoItem } from '../../app/utils/home-demos'
import { describe, expect, it } from 'vitest'
-import { demoPhoneRatio, demoRecording, formatDemoDuration } from '../../app/utils/home-demos'
+import { demoPhoneRatio, demoRecording, demoSocialPicture, formatDemoDuration } from '../../app/utils/home-demos'
function demo(overrides: Partial = {}): HomeDemoItem {
return {
@@ -10,13 +10,14 @@ function demo(overrides: Partial = {}): HomeDemoItem {
skillPath: '/gh/emilkowalski/skills/emil-design-eng',
authorName: 'Emil Kowalski',
sourceUrl: 'https://github.com/emilkowalski/skills/blob/main/skills/emil-design-eng/SKILL.md',
+ makes: 'ui-component',
prompt: 'Build toast.html.',
agent: 'Claude Code',
model: 'claude-opus-5-5',
recordedAt: '2026-10-06',
shots: [
- { src: '/d.jpg', width: 1440, height: 900, alt: 'Desktop', viewport: 'desktop' },
- { src: '/m.jpg', width: 390, height: 844, alt: 'Phone', viewport: 'mobile' },
+ { src: '/d.jpg', width: 1440, height: 900, alt: 'Desktop', viewport: 'desktop', poster: null },
+ { src: '/m.jpg', width: 390, height: 844, alt: 'Phone', viewport: 'mobile', poster: null },
],
...overrides,
}
@@ -50,7 +51,7 @@ describe('demoPhoneRatio', () => {
})
it('leaves a long phone page to the take frame', () => {
- const long = demo({ shots: [demo().shots[0]!, { src: '/m.jpg', width: 390, height: 5091, alt: 'Phone', viewport: 'mobile' }] })
+ const long = demo({ shots: [demo().shots[0]!, { src: '/m.jpg', width: 390, height: 5091, alt: 'Phone', viewport: 'mobile', poster: null }] })
expect(demoPhoneRatio(long)).toBeUndefined()
})
@@ -58,3 +59,30 @@ describe('demoPhoneRatio', () => {
expect(demoPhoneRatio(demo({ video: { src: '/f.mp4', poster: '/p.jpg', width: 1280, height: 720, durationSeconds: 6 } }))).toBeUndefined()
})
})
+
+describe('demoSocialPicture', () => {
+ const film = { src: 'https://media.example/f.mp4', poster: 'https://media.example/p.jpg', width: 1280, height: 720, durationSeconds: 6 }
+
+ it('shows a one-screen page as its desktop shot', () => {
+ expect(demoSocialPicture(demo())).toEqual({ src: '/d.jpg', width: 1440, height: 900 })
+ })
+
+ it('shows a long page as its first screen, never the whole page', () => {
+ const long = demo({
+ shots: [{ src: '/d.jpg', width: 1440, height: 6000, alt: 'Desktop', viewport: 'desktop', poster: { src: '/d-poster.jpg', width: 1440, height: 900 } }],
+ })
+ expect(demoSocialPicture(long)).toEqual({ src: '/d-poster.jpg', width: 1440, height: 900 })
+ })
+
+ it('shows a film as its poster frame', () => {
+ expect(demoSocialPicture(demo({ video: film }))).toEqual({ src: 'https://media.example/p.jpg', width: 1280, height: 720 })
+ })
+
+ it('gives no picture for a long page that has no first screen', () => {
+ expect(demoSocialPicture(demo({ shots: [{ src: '/d.jpg', width: 1440, height: 6000, alt: 'Desktop', viewport: 'desktop', poster: null }] }))).toBeUndefined()
+ })
+
+ it('gives no picture for a demo recorded only on a phone', () => {
+ expect(demoSocialPicture(demo({ shots: [{ src: '/m.jpg', width: 390, height: 844, alt: 'Phone', viewport: 'mobile', poster: null }] }))).toBeUndefined()
+ })
+})