Skip to content

Commit edebd17

Browse files
authored
feat(demos): show each demo's picture in its social card (#537)
1 parent 02f2090 commit edebd17

5 files changed

Lines changed: 167 additions & 10 deletions

File tree

‎DESIGN.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,9 @@ Rules for every texture:
347347
| Skill | Name, then owner, then the curator note or the curator count |
348348
| Collection | Name, description, curator, then the curator note or the first Skills |
349349
| Curator | Avatar, name, handle with counts, description, then the first Skills |
350+
| Demo | The demo's picture under a band: the lockup, then "What /{name} made" and the recording line. Its own frame, below |
351+
352+
**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.
350353

351354
### Motifs
352355

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
<script setup lang="ts">
2+
import { ogCount, ogText } from '../../utils/og-props'
3+
import { OG_INK, OG_MUTED, OG_RULE, OG_SURFACE } from '../../utils/og-style'
4+
5+
// Declared as the wire types the OG image URL can deliver, not as the types the
6+
// template wants. A size arrives as a number or a string.
7+
const props = defineProps<{
8+
name?: string | number | null
9+
recording?: string | number | null
10+
image?: string | number | null
11+
imageWidth?: string | number | null
12+
imageHeight?: string | number | null
13+
}>()
14+
15+
// A demo card is a window onto what the Agent made, under a band that names the
16+
// demo and how it was recorded. The picture fills the window's width from its
17+
// top edge, so a page shows its header and a film its frame, and the card's
18+
// bottom edge cuts the rest.
19+
const WIDTH = 1200
20+
const GUTTER = 56
21+
const BAND = 104
22+
const WINDOW_WIDTH = WIDTH - GUTTER * 2
23+
// Room for the title right of the lockup, and the average advance of one
24+
// character, in em, of a title that is mostly a Skill name.
25+
const TITLE_ROOM = 880
26+
const TITLE_EM = 0.5
27+
28+
const safeTitle = computed(() => `What /${ogText(props.name)} made`)
29+
// A Skill name runs to 64 characters, so a long one shrinks to keep one line.
30+
const titleSize = computed(() => Math.max(20, Math.min(30, Math.floor(TITLE_ROOM / (safeTitle.value.length * TITLE_EM)))))
31+
const safeRecording = computed(() => ogText(props.recording))
32+
const safeImage = computed(() => ogText(props.image))
33+
const scaledHeight = computed(() => {
34+
const naturalWidth = ogCount(props.imageWidth)
35+
const naturalHeight = ogCount(props.imageHeight)
36+
return naturalWidth && naturalHeight ? Math.round(naturalHeight * WINDOW_WIDTH / naturalWidth) : 0
37+
})
38+
</script>
39+
40+
<template>
41+
<div
42+
data-theme="dark"
43+
class="flex flex-col w-full h-full relative overflow-hidden font-sans"
44+
:style="{ background: 'oklch(0.14 0.008 60)', color: OG_INK }"
45+
>
46+
<div
47+
class="flex items-center justify-between shrink-0"
48+
:style="{ height: `${BAND}px`, paddingLeft: `${GUTTER}px`, paddingRight: `${GUTTER}px` }"
49+
>
50+
<OgBrand :size="30" />
51+
<div class="flex flex-col items-end" :style="{ gap: '6px' }">
52+
<span :style="{ fontSize: `${titleSize}px`, fontWeight: 600, letterSpacing: '-0.03em', lineHeight: 1.1 }">
53+
{{ safeTitle }}
54+
</span>
55+
<span v-if="safeRecording" class="font-mono" :style="{ fontSize: '18px', color: OG_MUTED }">
56+
{{ safeRecording }}
57+
</span>
58+
</div>
59+
</div>
60+
<div
61+
class="flex overflow-hidden"
62+
:style="{
63+
marginLeft: `${GUTTER}px`,
64+
width: `${WINDOW_WIDTH}px`,
65+
flexGrow: 1,
66+
background: OG_SURFACE,
67+
border: `1px solid ${OG_RULE}`,
68+
borderBottom: 'none',
69+
borderTopLeftRadius: '14px',
70+
borderTopRightRadius: '14px',
71+
}"
72+
>
73+
<img
74+
v-if="safeImage && scaledHeight"
75+
:src="safeImage"
76+
alt=""
77+
:width="WINDOW_WIDTH"
78+
:height="scaledHeight"
79+
:style="{ width: `${WINDOW_WIDTH}px`, height: `${scaledHeight}px`, flexShrink: 0 }"
80+
>
81+
</div>
82+
</div>
83+
</template>

‎app/utils/home-demos.ts‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export interface HomeDemoItem {
2222
model: string
2323
/** `YYYY-MM-DD`. */
2424
recordedAt: string
25-
shots: { src: string, width: number, height: number, alt: string, viewport: 'desktop' | 'mobile' }[]
25+
shots: HomeDemoShot[]
2626
/** A Skill whose output is a film. The poster is a JPG frame; `src` is a muted H.264 MP4. */
2727
/** The sandboxed output page of a page demo; null for a film. */
2828
liveUrl: string | null
@@ -37,7 +37,15 @@ export interface HomeDemoVideo {
3737
durationSeconds: number
3838
}
3939

40-
export type HomeDemoShot = HomeDemoItem['shots'][number]
40+
export interface HomeDemoShot {
41+
src: string
42+
width: number
43+
height: number
44+
alt: string
45+
viewport: 'desktop' | 'mobile'
46+
/** The first screen, when the shot runs longer than one. */
47+
poster: { src: string, width: number, height: number } | null
48+
}
4149

4250
/** Fewer than this reads as a broken feature, as with trending. */
4351
export const HOME_DEMOS_MIN = 3
@@ -104,6 +112,26 @@ export function demoScreens(shot: HomeDemoShot): number {
104112
return Math.max(1, Math.round(shot.height / screenHeight))
105113
}
106114

115+
export interface DemoPicture {
116+
src: string
117+
width: number
118+
height: number
119+
}
120+
121+
/**
122+
* The picture a demo page's social card shows: a film's poster frame, or the
123+
* first screen of the desktop page. A long page never serves whole, since a
124+
* 6000px shot would shrink to a strip. Undefined when the demo has neither;
125+
* the page keeps its text card then.
126+
*/
127+
export function demoSocialPicture(demo: HomeDemoItem): DemoPicture | undefined {
128+
if (demo.video)
129+
return { src: demo.video.poster, width: demo.video.width, height: demo.video.height }
130+
const shot = demo.shots.find(shot => shot.viewport === 'desktop')
131+
const screen = shot?.poster ?? (shot && demoScreens(shot) === 1 ? shot : undefined)
132+
return screen && { src: screen.src, width: screen.width, height: screen.height }
133+
}
134+
107135
/** The Skill as `SkillCard` reads it, so a demo names its author the way every Skill embed does. */
108136
export function demoCardSkill(demo: HomeDemoItem): SkillCardSkill {
109137
return {

‎layers/marketing/app/pages/skills/demos/[...demo].vue‎

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<script setup lang="ts">
22
import type { HomeDemoItem } from '~~/app/utils/home-demos'
33
import { setResponseHeaders } from 'h3'
4-
import { demoKey } from '~~/app/utils/home-demos'
4+
import { demoKey, demoRecording, demoSocialPicture } from '~~/app/utils/home-demos'
55
import { resolveAuthorName } from '~~/app/utils/skill-byline'
66
import { demoNoun, groupDemos } from '#shared/demo-groups'
77
import { demoPagePath, DEMOS_PATH, MIN_INDEXABLE_DEMOS, parseDemoRoute } from '#shared/demo-pages'
@@ -107,9 +107,24 @@ useHead({
107107
link: [{ rel: 'canonical', href: () => `https://skilld.dev${canonicalPath.value}` }],
108108
})
109109
110-
defineOgImage('Page.takumi', pageDemo.value
111-
? { title: `What /${pageDemo.value.name} made`, description: clip(pageDemo.value.prompt, 120) }
112-
: { title: 'See what skills make', description: 'Recorded runs of agent skills: the prompt, and what the Agent made.' }, { alt: pageDemo.value ? `What /${pageDemo.value.name} made, on skilld` : 'Skill demos on skilld' })
110+
// A shared demo page shows what the Agent made: the film's poster frame or the
111+
// page's first screen. The board, and a demo without such a picture, keep the
112+
// text card.
113+
const socialPicture = pageDemo.value ? demoSocialPicture(pageDemo.value) : undefined
114+
if (pageDemo.value && socialPicture) {
115+
defineOgImage('Demo.takumi', {
116+
name: pageDemo.value.name,
117+
recording: demoRecording(pageDemo.value).sentence,
118+
image: socialPicture.src,
119+
imageWidth: socialPicture.width,
120+
imageHeight: socialPicture.height,
121+
}, { alt: `What /${pageDemo.value.name} made, on skilld` })
122+
}
123+
else {
124+
defineOgImage('Page.takumi', pageDemo.value
125+
? { title: `What /${pageDemo.value.name} made`, description: clip(pageDemo.value.prompt, 120) }
126+
: { title: 'See what skills make', description: 'Recorded runs of agent skills: the prompt, and what the Agent made.' }, { alt: pageDemo.value ? `What /${pageDemo.value.name} made, on skilld` : 'Skill demos on skilld' })
127+
}
113128
</script>
114129

115130
<template>

‎test/unit/home-demos.test.ts‎

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { HomeDemoItem } from '../../app/utils/home-demos'
22
import { describe, expect, it } from 'vitest'
3-
import { demoPhoneRatio, demoRecording, formatDemoDuration } from '../../app/utils/home-demos'
3+
import { demoPhoneRatio, demoRecording, demoSocialPicture, formatDemoDuration } from '../../app/utils/home-demos'
44

55
function demo(overrides: Partial<HomeDemoItem> = {}): HomeDemoItem {
66
return {
@@ -10,13 +10,14 @@ function demo(overrides: Partial<HomeDemoItem> = {}): HomeDemoItem {
1010
skillPath: '/gh/emilkowalski/skills/emil-design-eng',
1111
authorName: 'Emil Kowalski',
1212
sourceUrl: 'https://github.com/emilkowalski/skills/blob/main/skills/emil-design-eng/SKILL.md',
13+
makes: 'ui-component',
1314
prompt: 'Build toast.html.',
1415
agent: 'Claude Code',
1516
model: 'claude-opus-5-5',
1617
recordedAt: '2026-10-06',
1718
shots: [
18-
{ src: '/d.jpg', width: 1440, height: 900, alt: 'Desktop', viewport: 'desktop' },
19-
{ src: '/m.jpg', width: 390, height: 844, alt: 'Phone', viewport: 'mobile' },
19+
{ src: '/d.jpg', width: 1440, height: 900, alt: 'Desktop', viewport: 'desktop', poster: null },
20+
{ src: '/m.jpg', width: 390, height: 844, alt: 'Phone', viewport: 'mobile', poster: null },
2021
],
2122
...overrides,
2223
}
@@ -50,11 +51,38 @@ describe('demoPhoneRatio', () => {
5051
})
5152

5253
it('leaves a long phone page to the take frame', () => {
53-
const long = demo({ shots: [demo().shots[0]!, { src: '/m.jpg', width: 390, height: 5091, alt: 'Phone', viewport: 'mobile' }] })
54+
const long = demo({ shots: [demo().shots[0]!, { src: '/m.jpg', width: 390, height: 5091, alt: 'Phone', viewport: 'mobile', poster: null }] })
5455
expect(demoPhoneRatio(long)).toBeUndefined()
5556
})
5657

5758
it('never applies to a film', () => {
5859
expect(demoPhoneRatio(demo({ video: { src: '/f.mp4', poster: '/p.jpg', width: 1280, height: 720, durationSeconds: 6 } }))).toBeUndefined()
5960
})
6061
})
62+
63+
describe('demoSocialPicture', () => {
64+
const film = { src: 'https://media.example/f.mp4', poster: 'https://media.example/p.jpg', width: 1280, height: 720, durationSeconds: 6 }
65+
66+
it('shows a one-screen page as its desktop shot', () => {
67+
expect(demoSocialPicture(demo())).toEqual({ src: '/d.jpg', width: 1440, height: 900 })
68+
})
69+
70+
it('shows a long page as its first screen, never the whole page', () => {
71+
const long = demo({
72+
shots: [{ src: '/d.jpg', width: 1440, height: 6000, alt: 'Desktop', viewport: 'desktop', poster: { src: '/d-poster.jpg', width: 1440, height: 900 } }],
73+
})
74+
expect(demoSocialPicture(long)).toEqual({ src: '/d-poster.jpg', width: 1440, height: 900 })
75+
})
76+
77+
it('shows a film as its poster frame', () => {
78+
expect(demoSocialPicture(demo({ video: film }))).toEqual({ src: 'https://media.example/p.jpg', width: 1280, height: 720 })
79+
})
80+
81+
it('gives no picture for a long page that has no first screen', () => {
82+
expect(demoSocialPicture(demo({ shots: [{ src: '/d.jpg', width: 1440, height: 6000, alt: 'Desktop', viewport: 'desktop', poster: null }] }))).toBeUndefined()
83+
})
84+
85+
it('gives no picture for a demo recorded only on a phone', () => {
86+
expect(demoSocialPicture(demo({ shots: [{ src: '/m.jpg', width: 390, height: 844, alt: 'Phone', viewport: 'mobile', poster: null }] }))).toBeUndefined()
87+
})
88+
})

0 commit comments

Comments
 (0)