From 82cbb79276f200d6a124dde64c97611ee115101b Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Fri, 9 Oct 2026 16:49:57 +1100 Subject: [PATCH 1/6] feat(demos): support writing previews --- COPY.md | 2 + GLOSSARY.md | 3 + app/components/DemoStage.vue | 7 +- app/components/home-demos/_DemoWriting.vue | 203 ++++++++++++++++++ app/utils/home-demos.ts | 3 + app/utils/writing-demo-example.ts | 143 ++++++++++++ .../app/pages/skills/demos/[...demo].vue | 41 +++- layers/registry/app/components/_SkillDemo.vue | 6 +- layers/registry/server/utils/skill-demos.ts | 21 +- shared/demo-groups.ts | 6 + shared/writing-demo.ts | 16 ++ shared/writing-markdown.ts | 27 +++ test/a11y.nuxt.spec.ts | 27 +++ test/unit/writing-demo.test.ts | 60 ++++++ 14 files changed, 551 insertions(+), 14 deletions(-) create mode 100644 app/components/home-demos/_DemoWriting.vue create mode 100644 app/utils/writing-demo-example.ts create mode 100644 shared/writing-demo.ts create mode 100644 shared/writing-markdown.ts create mode 100644 test/unit/writing-demo.test.ts diff --git a/COPY.md b/COPY.md index 4bd7de0e..e443188b 100644 --- a/COPY.md +++ b/COPY.md @@ -44,6 +44,8 @@ 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 | +| Writing demo | Writing · Articles, READMEs, and PR descriptions. · Document · Version · Original · No Skill · Markdown | guidance only: writing support on the existing demos board. Show the document, two selectors, and a collapsed Markdown source. The recorded output option uses the Skill name. No decorative text. | +| Writing demo example | Example data · Example rewrite · Example data. No Skills were run. | guidance only: `/skills/demos?example=writing` uses handwritten UI fixtures. Keep the disclosure visible. Show no recording metadata or run action for fixtures. | | 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. | diff --git a/GLOSSARY.md b/GLOSSARY.md index 358d6dbf..7fa02240 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -118,6 +118,9 @@ It grants no trust or search indexing. Existing Skills retain source refresh. **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. +**Writing:** a demo can show Markdown documents. Each document keeps its original, the rewrite without a Skill, and the Skill output. +UI fixtures are labelled Example data. They are not recorded runs or evaluation results. + ### preview **Is:** the step of watching a Skill's demo before running it. A preview never executes a Skill. diff --git a/app/components/DemoStage.vue b/app/components/DemoStage.vue index 6ac74fb3..03d7bafb 100644 --- a/app/components/DemoStage.vue +++ b/app/components/DemoStage.vue @@ -5,6 +5,7 @@ import { demoCardSkill, demoHasPhoneFrame, demoHref, demoPhoneRatio, demoRecordi import DemoActions from './_DemoActions.vue' import DemoMedia from './home-demos/_DemoMedia.vue' import DemoRecording from './home-demos/_DemoRecording.vue' +import DemoWriting from './home-demos/_DemoWriting.vue' import SkillCard from './SkillCard.vue' /** @@ -32,7 +33,7 @@ const { demo, surface = 'demos', eager = false, showPrompt = false, live = false const stage = useTemplateRef('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) +const liveUrl = computed(() => live && !demo.video && !demo.writing ? demo.liveUrl : null) @@ -168,6 +176,41 @@ const versionSelectId = useId() overflow-x: auto; } +/* GitHub document typography, using the existing Markdown and rangi infrastructure. */ +.writing-demo__github { + max-inline-size: none; + padding: clamp(1rem, 4vw, 2rem); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif; + line-height: 1.5; +} + +.writing-demo__github :deep(h1), +.writing-demo__github :deep(h2) { + padding-block-end: 0.3em; + border-block-end: 1px solid var(--ui-border); + margin-block: 1.5rem 1rem; + line-height: 1.25; +} + +.writing-demo__github :deep(h1) { font-size: 2rem; } +.writing-demo__github :deep(h2) { font-size: 1.5rem; } +.writing-demo__github :deep(h3) { font-size: 1.25rem; } +.writing-demo__github :deep(:first-child) { margin-block-start: 0; } +.writing-demo__github :deep(li) { margin-block: 0.25em; } +.writing-demo__github :deep(code) { font-size: 85%; } +.writing-demo__github :deep(code:not(pre code)) { border: 0; } +.writing-demo__github :deep(pre) { + border: 0; + font-size: 85%; + line-height: 1.45; +} +.writing-demo__github :deep(pre code) { font-size: inherit; } +.writing-demo__github :deep(a) { color: light-dark(#0969da, #58a6ff); text-decoration: none; } +.writing-demo__github :deep(a:hover) { text-decoration: underline; } +.writing-demo__document :deep(a[target="_blank"]::after) { content: none; } +.writing-demo__github :deep(blockquote) { border-inline-start-width: 0.25em; font-style: normal; } +.writing-demo__github :deep(input[type="checkbox"]) { margin-inline-end: 0.5em; } + .writing-demo__markdown { margin-block-start: 0.25rem; } @@ -182,7 +225,7 @@ const versionSelectId = useId() cursor: pointer; } -.writing-demo__markdown pre { +.writing-demo__markdown :deep(pre) { max-block-size: 24rem; overflow: auto; padding: 1rem; diff --git a/app/utils/home-demos.ts b/app/utils/home-demos.ts index e878c331..7a8f91f9 100644 --- a/app/utils/home-demos.ts +++ b/app/utils/home-demos.ts @@ -83,7 +83,7 @@ export function homeDemoFeed(feed: { items: HomeDemoItem[] }) { */ export const DEMO_PHONE_MEDIA = '(max-width: 39.99rem)' -export function demoKey(demo: HomeDemoItem): string { +export function demoKey(demo: Pick): string { return `${demo.owner}/${demo.repo}/${demo.name}` } diff --git a/app/utils/writing-demo-example.ts b/app/utils/writing-demo-example.ts index 77aa5b53..b0230a93 100644 --- a/app/utils/writing-demo-example.ts +++ b/app/utils/writing-demo-example.ts @@ -1,10 +1,21 @@ import type { WritingDemo } from '#shared/writing-demo' +/** Candidates for future recordings. All rows share the same UI fixtures. */ +export const writingDemoSkills = [ + { owner: 'blader', repo: 'humanizer', name: 'humanizer' }, + { owner: 'hardikpandya', repo: 'stop-slop', name: 'stop-slop' }, + { owner: 'petergyang', repo: 'no-ai-slop', name: 'no-ai-slop' }, + { owner: 'elithrar', repo: 'dotfiles', name: 'anti-slop' }, + { owner: 'kmaida', repo: 'deslop-skills', name: 'deslop-writing' }, + { owner: 'harlan-zw', repo: 'brundlefly', name: 'write-human' }, +] as const + /** Handwritten UI fixtures. These are not recordings or results from any Skill. */ export const writingDemoExample: WritingDemo = { documents: [ { id: 'article', + format: 'article', label: 'Blog article', original: `# Keeping a reading list that works for you @@ -44,6 +55,7 @@ You don't need to finish the list. You need to find something worth reading when }, { id: 'readme', + format: 'readme', label: 'Package README', original: `# Margin @@ -110,6 +122,7 @@ const cssLinks = filterLinks(links, 'CSS') }, { id: 'pr', + format: 'pr', label: 'PR description', original: `# Add category filtering to the reading list diff --git a/layers/marketing/app/pages/skills/demos/[...demo].vue b/layers/marketing/app/pages/skills/demos/[...demo].vue index 4dd2b55d..945651f8 100644 --- a/layers/marketing/app/pages/skills/demos/[...demo].vue +++ b/layers/marketing/app/pages/skills/demos/[...demo].vue @@ -4,7 +4,7 @@ import { setResponseHeaders } from 'h3' import DemoWriting from '~~/app/components/home-demos/_DemoWriting.vue' import { demoKey, demoRecording, demoSocialPicture } from '~~/app/utils/home-demos' import { resolveAuthorName } from '~~/app/utils/skill-byline' -import { writingDemoExample } from '~~/app/utils/writing-demo-example' +import { writingDemoExample, writingDemoSkills } from '~~/app/utils/writing-demo-example' import { DEMO_GROUPS, demoNoun, groupDemos } from '#shared/demo-groups' import { demoPagePath, DEMOS_PATH, MIN_INDEXABLE_DEMOS, parseDemoRoute } from '#shared/demo-pages' import { githubAvatarProxyUrl } from '#shared/image-proxy' @@ -45,8 +45,10 @@ const groups = computed(() => { const route = useRoute() const demoRoute = computed(() => parseDemoRoute(route.params.demo)) -// UI fixtures have no Skill attribution, recording metadata, run action, or sitemap entry. +// Candidate rows share UI fixtures, without claiming a Skill produced them. const writingExample = computed(() => demoRoute.value._tag === 'index' && route.query.example === 'writing') +const writingCandidates = computed(() => writingDemoSkills.filter(skill => !demos.value.some(demo => demoKey(demo) === demoKey(skill)))) +const writingCandidate = computed(() => writingCandidates.value.find(skill => demoKey(skill) === route.query.skill) ?? writingCandidates.value[0]) function findDemo(key: string): HomeDemoItem | undefined { return demos.value.find(demo => demoKey(demo).toLowerCase() === key.toLowerCase()) @@ -180,14 +182,19 @@ else { @@ -233,9 +240,6 @@ else { transition: background-color 150ms ease; } -.demos-rail__pick--example { - grid-template-columns: auto minmax(0, 1fr); -} @media (hover: hover) { .demos-rail__pick:hover { diff --git a/shared/writing-demo.ts b/shared/writing-demo.ts index 7080e1c9..d9e027e3 100644 --- a/shared/writing-demo.ts +++ b/shared/writing-demo.ts @@ -7,6 +7,7 @@ export const writingDemoSchema = z.object({ documents: z.array(z.object({ id: z.string().regex(/^[a-z][a-z0-9-]*$/), label: z.string().trim().min(1), + format: z.enum(['article', 'readme', 'pr']), original: markdown, baseline: markdown, output: markdown, diff --git a/shared/writing-markdown.ts b/shared/writing-markdown.ts index 122e02ad..ec4ea1d5 100644 --- a/shared/writing-markdown.ts +++ b/shared/writing-markdown.ts @@ -1,5 +1,6 @@ import type { Renderer } from 'marked' import { Marked } from 'marked' +import { escapeHtml, highlightToHtml } from './highlight' function escapeAttribute(value: string): string { return value.replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '<').replaceAll('>', '>') @@ -12,6 +13,7 @@ export function renderWritingMarkdown(markdown: string): string { gfm: true, renderer: { html: () => '', + code: ({ text, lang }) => highlightToHtml(text, lang) ?? `
${escapeHtml(text)}
\n`, // Generated images are not part of a writing demo. Show their alt text without making a request. image: ({ text }) => escapeAttribute(text), link(this: Renderer, { href, tokens }) { diff --git a/test/a11y.nuxt.spec.ts b/test/a11y.nuxt.spec.ts index d9ae15c9..8741d43c 100644 --- a/test/a11y.nuxt.spec.ts +++ b/test/a11y.nuxt.spec.ts @@ -96,8 +96,8 @@ describe('accessibility: components', () => { outputLabel: 'Example rewrite', writing: { documents: [ - { id: 'article', label: 'Blog article', original: '# Original article', baseline: '# Baseline article', output: '# Edited article' }, - { id: 'readme', label: 'Package README', original: '# Original README', baseline: '# Baseline README', output: '# Edited README' }, + { id: 'article', format: 'article', label: 'Blog article', original: '# Original article', baseline: '# Baseline article', output: '# Edited article' }, + { id: 'readme', format: 'readme', label: 'Package README', original: '# Original README', baseline: '# Baseline README', output: '# Edited README' }, ], }, }, @@ -107,6 +107,7 @@ describe('accessibility: components', () => { await documentSelect!.setValue('readme') await versionSelect!.setValue('original') expect(wrapper.find('article').text()).toBe('Original README') + expect(wrapper.find('article').classes()).toContain('writing-demo__github') wrapper.find('details').element.open = true await nextTick() expect(wrapper.find('pre').text()).toBe('# Original README') diff --git a/test/unit/writing-demo.test.ts b/test/unit/writing-demo.test.ts index e1b07cbc..dcd7bb74 100644 --- a/test/unit/writing-demo.test.ts +++ b/test/unit/writing-demo.test.ts @@ -3,7 +3,7 @@ import { parseSkillDemoRecord, presentSkillDemo } from '../../layers/registry/se import { renderWritingMarkdown } from '../../shared/writing-markdown' const writing = { - documents: [{ id: 'article', label: 'Blog article', original: '# Original', baseline: '# Baseline', output: '# Rewrite' }], + documents: [{ id: 'article', label: 'Blog article', format: 'article', original: '# Original', baseline: '# Baseline', output: '# Rewrite' }], } const recording = { @@ -43,6 +43,21 @@ describe('writing demo recordings', () => { }) describe('renderWritingMarkdown', () => { + it('highlights fenced code and escapes unsupported languages', () => { + const html = renderWritingMarkdown('```ts\nconst total = 1\n```\n\n```unknown\n\n```') + expect(html).toContain('class="shj-kwd"') + expect(html).toContain('>const') + expect(html).toContain('<script>alert(1)</script>') + expect(html).not.toContain('