From 271d1ebe46f65c85033a13bbcfac276641e52648 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 7 Oct 2026 14:03:53 +1100 Subject: [PATCH] feat(demos): add a shipreel demo shown on its Skill page only Some demos suit their Skill page but not the /skills/demos board, the homepage, or a demo page of their own. A skillPageOnly entry keeps the demo on its Skill page alone, and the recorder sets it with --skill-page-only. --- COPY.md | 2 +- GLOSSARY.md | 2 ++ .../registry/app/components/SkillDetail.vue | 2 +- layers/registry/app/components/_SkillDemo.vue | 7 ++-- layers/registry/server/data/skill-demos.json | 32 +++++++++++++++++ layers/registry/server/utils/skill-demos.ts | 18 ++++++++-- scripts/record-skill-demo.ts | 35 +++++++++++-------- test/unit/skill-demos.test.ts | 14 ++++++++ 8 files changed, 90 insertions(+), 22 deletions(-) diff --git a/COPY.md b/COPY.md index 0fdabbe3..95ff20ef 100644 --- a/COPY.md +++ b/COPY.md @@ -45,7 +45,7 @@ These exact strings. Do not paraphrase them per page. | 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 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 and hides while the Skill holds a run check flag | +| 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}, such as Opus 5.5 | guidance only: the recording line on the Skill page panel and the homepage stage. Screen readers and the title get "Recorded with {agent}, {model}". 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/GLOSSARY.md b/GLOSSARY.md index 39b5b234..26c697e7 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -98,6 +98,8 @@ Collisions **Rule:** a demo shows the Agent's output, never the Skill author's work. It always names how it was recorded. +**Placement:** a demo shows on its Skill page, the homepage, `/skills/demos`, and its own demo page. A Skill page only demo shows on its Skill page alone. + **Groups:** demos group by what the Skill makes, on `/skills/demos`: films and launch videos, landing pages, UI components, and diagrams and explainers. A group is not a track: tracks sort Skills by kind of work. ### preview diff --git a/layers/registry/app/components/SkillDetail.vue b/layers/registry/app/components/SkillDetail.vue index d684cc69..f665af2b 100644 --- a/layers/registry/app/components/SkillDetail.vue +++ b/layers/registry/app/components/SkillDetail.vue @@ -1722,7 +1722,7 @@ useHead(computed(() => ({
- +
diff --git a/layers/registry/app/components/_SkillDemo.vue b/layers/registry/app/components/_SkillDemo.vue index a92851fc..29327e71 100644 --- a/layers/registry/app/components/_SkillDemo.vue +++ b/layers/registry/app/components/_SkillDemo.vue @@ -9,12 +9,13 @@ import SkillDemoShot from './_SkillDemoShot.vue' * Skill, and how it was recorded. Screenshots paint first. The live output * loads only on request, in a frame with no same-origin access, because the * route serves it under a CSP sandbox too. "All demos" opens the demo's own - * page, the board of every demo with this one on the stage. A Skill with a run - * check flag is off the board, so its panel has no such link. + * page, the board of every demo with this one on the stage. A Skill page only + * demo, or one whose Skill holds a run check flag, is off the board, so its + * panel has no such link. */ const { demo, onBoard = true } = defineProps<{ demo: SkillDemoView - /** False while the Skill holds a run check flag: the board leaves it out, and its demo page answers 404. */ + /** False for a Skill page only demo, or while the Skill holds a run check flag: the board leaves it out, and its demo page answers 404. */ onBoard?: boolean }>() diff --git a/layers/registry/server/data/skill-demos.json b/layers/registry/server/data/skill-demos.json index 50858231..bab88fa9 100644 --- a/layers/registry/server/data/skill-demos.json +++ b/layers/registry/server/data/skill-demos.json @@ -580,6 +580,38 @@ } ] }, + { + "owner": "thebstar", + "repo": "shipreel", + "name": "shipreel", + "makes": "film", + "authorName": "Bikram Sutar", + "sourceUrl": "https://github.com/theBstar/shipreel/blob/main/plugins/shipreel/skills/shipreel/SKILL.md", + "prompt": "Make a shipreel for the idempotent-bookings branch. It has no pull request yet, so read the change from git against main. Don't stop to ask me questions. Save the final video as walkthrough.mp4 in this folder.", + "setup": "The folder held Mossgrove, a fictional garden-plot booking API, with the change on its own branch. Its shipreel.yml set a local Piper voice and a 60-second target.", + "skillPageOnly": true, + "agent": "Claude Code", + "agentVersion": "2.1.292", + "model": "claude-opus-5-5", + "skillCommit": "0d6978b76e0ce94355ea896ff164a359a4fe94c6", + "recordedAt": "2026-10-07", + "video": { + "file": "video-7c69af2173.mp4", + "poster": "poster-b751e14017.jpg", + "width": 1280, + "height": 720, + "durationSeconds": 62.7 + }, + "shots": [ + { + "file": "poster-b751e14017.jpg", + "width": 1280, + "height": 720, + "viewport": "desktop", + "alt": "A frame from the video the Agent rendered for: Make a shipreel for the idempotent-bookings branch. It has no pull request yet, so read the change from git against main" + } + ] + }, { "owner": "tt-a1i", "repo": "archify", diff --git a/layers/registry/server/utils/skill-demos.ts b/layers/registry/server/utils/skill-demos.ts index 71b40f95..5f371ece 100644 --- a/layers/registry/server/utils/skill-demos.ts +++ b/layers/registry/server/utils/skill-demos.ts @@ -18,6 +18,10 @@ import manifest from '../data/skill-demos.json' * Media lives at `DEMO_MEDIA_ORIGIN`, the bucket's custom domain, under * content-hashed names, so a URL never changes what it serves. * + * A demo marked `skillPageOnly` shows on its Skill page and nowhere else: + * not on `/skills/demos`, the homepage, a demo page of its own, or the + * `demos` sitemap. + * * Cull path: delete the entry, its `server/demos////` * folder, and the bucket's `demos////` prefix. */ @@ -72,12 +76,16 @@ const demoSchema = z.object({ recordedAt: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), /** Lower comes first on the homepage. Unpinned demos follow, newest first. */ pin: z.number().int().positive().optional(), + /** Shown on the Skill page alone: left off the board, the homepage, and the `demos` sitemap, with no demo page. */ + skillPageOnly: z.literal(true).optional(), /** File name inside `server/demos////`, served sandboxed as the live demo. */ outputFile: z.string().regex(/^[\w-]+\.html$/).optional(), video: videoSchema.optional(), shots: z.array(shotSchema).min(1), }).refine(demo => demo.outputFile !== undefined || demo.video !== undefined, { message: 'A demo needs an output page or a video.', +}).refine(demo => !(demo.skillPageOnly && demo.pin), { + message: 'A Skill page only demo takes no pin: a pin orders the homepage, which leaves it out.', }) export type SkillDemoRecord = z.infer @@ -101,12 +109,13 @@ export function listSkillDemos(demos: readonly SkillDemoRecord[] = SKILL_DEMOS): } /** - * The demos a visitor may see. A demo whose Skill holds a run check flag stays - * out until a check passes: a visitor who watches it would copy a run command + * The demos the board, the homepage, and the demo pages show. A Skill page + * only demo stays out. So does a demo whose Skill holds a run check flag, + * until a check passes: a visitor who watches it would copy a run command * that fails. */ export function listShownSkillDemos(flagged: ReadonlySet, demos: readonly SkillDemoRecord[] = SKILL_DEMOS): readonly SkillDemoRecord[] { - return listSkillDemos(demos).filter(demo => !flagged.has(runCheckFlagKey(demo.owner, demo.repo, demo.name))) + return listSkillDemos(demos).filter(demo => !demo.skillPageOnly && !flagged.has(runCheckFlagKey(demo.owner, demo.repo, demo.name))) } export interface DemoSitemapEntry { @@ -171,6 +180,8 @@ export interface SkillDemoView { recordedAt: string /** True when the Skill moved past the commit the demo recorded. */ outdated: boolean + /** Shown on the Skill page alone, so the Demo panel links to no demo page. */ + skillPageOnly: boolean /** The sandboxed output page, when the demo kept one. */ liveUrl: string | null video: SkillDemoVideo | null @@ -196,6 +207,7 @@ export function presentSkillDemo(demo: SkillDemoRecord, currentCommit: string | skillCommit: demo.skillCommit, recordedAt: demo.recordedAt, outdated: currentCommit !== null && currentCommit !== demo.skillCommit, + skillPageOnly: demo.skillPageOnly ?? false, liveUrl: demo.outputFile ? `${path}/live` : null, video: demo.video ? { diff --git a/scripts/record-skill-demo.ts b/scripts/record-skill-demo.ts index 91baf5e4..0bea6551 100644 --- a/scripts/record-skill-demo.ts +++ b/scripts/record-skill-demo.ts @@ -6,7 +6,9 @@ * pnpm demo:record owner/repo/skill --makes film --prompt "..." --output launch.mp4 --seed ./site --setup "The folder held ..." * * `--makes` is the demo's group on /skills/demos: one of DEMO_MAKES in - * shared/demo-groups.ts. + * shared/demo-groups.ts. `--skill-page-only` keeps the demo on its Skill page + * alone: off /skills/demos, the homepage, and the demos sitemap. A re-record + * keeps an entry Skill page only. * pnpm demo:record owner/repo/skill --reshoot * pnpm demo:record owner/repo/skill --makes slides --prompt "..." --output deck.html --resume /tmp/skilld-demo-XXXX --model claude-opus-5-5 * @@ -140,6 +142,7 @@ interface DemoEntry { skillCommit: string recordedAt: string pin?: number + skillPageOnly?: true outputFile?: string video?: Video shots: Shot[] @@ -148,7 +151,7 @@ interface DemoEntry { type OutputKind = 'page' | 'video' type Parsed - = | { _tag: 'record', owner: string, repo: string, name: string, makes: DemoMakes, prompt: string, output: string, kind: OutputKind, seed: string | null, setup: string | null, resume: { dir: string, model: string } | null } + = | { _tag: 'record', owner: string, repo: string, name: string, makes: DemoMakes, prompt: string, output: string, kind: OutputKind, seed: string | null, setup: string | null, skillPageOnly: boolean, resume: { dir: string, model: string } | null } | { _tag: 'reshoot', owner: string, repo: string, name: string } | { _tag: 'usage', message: string } @@ -157,14 +160,15 @@ function parseInput(argv: string[]): Parsed { args: argv, allowPositionals: true, options: { - prompt: { type: 'string' }, - makes: { type: 'string' }, - output: { type: 'string', default: 'index.html' }, - seed: { type: 'string' }, - resume: { type: 'string' }, - model: { type: 'string' }, - setup: { type: 'string' }, - reshoot: { type: 'boolean', default: false }, + 'prompt': { type: 'string' }, + 'makes': { type: 'string' }, + 'output': { type: 'string', default: 'index.html' }, + 'seed': { type: 'string' }, + 'resume': { type: 'string' }, + 'model': { type: 'string' }, + 'setup': { type: 'string' }, + 'reshoot': { type: 'boolean', default: false }, + 'skill-page-only': { type: 'boolean', default: false }, }, }) const [ref] = positionals @@ -187,7 +191,7 @@ function parseInput(argv: string[]): Parsed { if (values.resume && !values.model) return { _tag: 'usage', message: 'A --resume folder needs --model: the model the interrupted run used.' } const resumed = values.resume && values.model ? { dir: resolve(values.resume), model: values.model } : null - return { _tag: 'record', owner, repo, name, makes, prompt: values.prompt, output: values.output, kind, seed: values.seed ? resolve(values.seed) : null, setup: values.setup ?? null, resume: resumed } + return { _tag: 'record', owner, repo, name, makes, prompt: values.prompt, output: values.output, kind, seed: values.seed ? resolve(values.seed) : null, setup: values.setup ?? null, skillPageOnly: values['skill-page-only'], resume: resumed } } interface SkillSource { @@ -425,7 +429,9 @@ async function writeEntry(entry: DemoEntry): Promise { const key = (demo: DemoEntry) => `${demo.owner}/${demo.repo}/${demo.name}`.toLowerCase() const previous = manifest.demos.find(demo => key(demo) === key(entry)) const demos = manifest.demos.filter(demo => key(demo) !== key(entry)) - demos.push(previous?.pin && !entry.pin ? { ...entry, pin: previous.pin } : entry) + // A re-record keeps the earlier placement. A pin orders the homepage, so a Skill page only demo takes none. + const placed: DemoEntry = previous?.skillPageOnly ? { ...entry, skillPageOnly: true } : entry + demos.push(previous?.pin && !placed.pin && !placed.skillPageOnly ? { ...placed, pin: previous.pin } : placed) demos.sort((a, b) => key(a).localeCompare(key(b))) await writeFile(MANIFEST, `${JSON.stringify({ demos }, null, 2)}\n`) } @@ -496,7 +502,7 @@ async function reshoot(owner: string, repo: string, name: string): Promise async function main(): Promise { const input = parseInput(process.argv.slice(2)) if (input._tag === 'usage') { - console.error(`${input.message}\nUsage: pnpm demo:record owner/repo/skill --makes --prompt "..." [--output index.html|film.mp4] [--seed dir --setup "..."]\n pnpm demo:record owner/repo/skill --reshoot`) + console.error(`${input.message}\nUsage: pnpm demo:record owner/repo/skill --makes --prompt "..." [--output index.html|film.mp4] [--seed dir --setup "..."] [--skill-page-only]\n pnpm demo:record owner/repo/skill --reshoot`) process.exitCode = 2 return } @@ -504,7 +510,7 @@ async function main(): Promise { await reshoot(input.owner, input.repo, input.name) return } - const { owner, repo, name, makes, prompt, output, kind, seed, setup } = input + const { owner, repo, name, makes, prompt, output, kind, seed, setup, skillPageOnly } = input const skillRef = `${owner}/${repo}/${name}` const source = await skillSource(owner, repo, name) const pinned = await pinnedRun(skillRef) @@ -542,6 +548,7 @@ async function main(): Promise { sourceUrl: source.sourceUrl, prompt, ...(setup ? { setup } : {}), + ...(skillPageOnly ? { skillPageOnly: true as const } : {}), agent: 'Claude Code', agentVersion: agent.version, model: agent.model, diff --git a/test/unit/skill-demos.test.ts b/test/unit/skill-demos.test.ts index ec6ec293..177d7f79 100644 --- a/test/unit/skill-demos.test.ts +++ b/test/unit/skill-demos.test.ts @@ -73,6 +73,10 @@ describe('findSkillDemo', () => { expect(findSkillDemo('Anthropics', 'Skills', 'Frontend-Design', [demo()])?.name).toBe('frontend-design') }) + it('finds a Skill page only demo, so its Skill page still shows it', () => { + expect(findSkillDemo('anthropics', 'skills', 'frontend-design', [demo({ skillPageOnly: true })])?.name).toBe('frontend-design') + }) + it('returns nothing for a Skill without a demo', () => { expect(findSkillDemo('anthropics', 'skills', 'pdf', [demo()])).toBeNull() }) @@ -91,6 +95,11 @@ describe('listShownSkillDemos', () => { const shown = listShownSkillDemos(new Set(['anthropics/skills/flagged']), [demo({ name: 'Flagged' }), demo({ name: 'kept' })]) expect(shown.map(entry => entry.name)).toEqual(['kept']) }) + + it('leaves out a Skill page only demo', () => { + const shown = listShownSkillDemos(new Set(), [demo({ name: 'own-page', skillPageOnly: true }), demo({ name: 'kept' })]) + expect(shown.map(entry => entry.name)).toEqual(['kept']) + }) }) describe('listDemoSitemapEntries', () => { @@ -106,4 +115,9 @@ describe('listDemoSitemapEntries', () => { it('lists nothing once flags drop the shown demos below six, where the pages answer noindex', () => { expect(listDemoSitemapEntries(new Set(['anthropics/skills/skill-0']), six)).toEqual([]) }) + + it('never lists a Skill page only demo, nor counts it toward six', () => { + const [first, ...rest] = six + expect(listDemoSitemapEntries(new Set(), [{ ...first!, skillPageOnly: true }, ...rest])).toEqual([]) + }) })