diff --git a/COPY.md b/COPY.md index 0fdabbe3..505b749d 100644 --- a/COPY.md +++ b/COPY.md @@ -49,6 +49,10 @@ These exact strings. Do not paraphrase them per page. | 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 | +| Home section: why | What skilld does differently. | guidance only: the homepage Why band heading, after trending. No line under it: the three columns from `HOME_WHY_REASONS` follow directly, each a title, `summary`, and picture. Their skills.sh lines stay on `/vs/skills-sh` | +| Why trust line | Built by Harlan Wilton · Compare with skills.sh | guidance only: the line that closes the homepage Why band. The avatar and name link Harlan's GitHub, and the button opens `/vs/skills-sh`. The columns carry every claim, so the line carries none | +| Why reasons | Human first · Preview the output · Independent and open source · Open trending weights · No more skill bloat · Know what it runs · Know what it costs · No telemetry | guidance only: the reason titles. Each has a one-line `summary` for the homepage and a full `line` for the comparison. The homepage shows the first three, which Harlan chose on 2026-10-07. `WHY_REASONS` in `app/utils/why-skilld.ts` holds each title, its line, and the sourced skills.sh line, and both surfaces read it. The Preview line opens with the Demos line above. Open trending weights links the ranking code, so its weights must match `shared/trending-skill-score.ts`. Know what it runs never claims a Skill is safe | +| Comparison H1 | skilld vs skills.sh | guidance only: the `/vs/skills-sh` H1. Its lead says what skills.sh is before what skilld is. Every skills.sh fact carries its source link and the date it was checked | | Home section: authoring | Write a skill for your project. | guidance only: the homepage section heading for making a Skill | | Nav: developers | Developers | guidance only: the header menu trigger, and the group label in the mobile menu. The menu holds the four items below | | Nav: CLI | CLI · Search, run, install, and keep Skills current. | guidance only: the first compact row in the Developers menu: name, line. Links `/cli`. The mobile menu shows the name only. The line is the CLI line, so change that first | @@ -66,7 +70,7 @@ These exact strings. Do not paraphrase them per page. | CLI claims line | Open-source CLI · No telemetry · 19 Agent targets | guidance only: the `/cli` claims line. The page counts the `--agent` values of `skilld install --help` | | CLI install label | Install the CLI | guidance only: the label beside the CLI install chip on `/cli`. A switch above the chip picks `macOS / Linux`, `Windows`, or `npm`, and `macOS / Linux` is preselected | | CLI install consequence | One native binary. It upgrades itself from signed releases. · Needs Node.js. npm handles upgrades. | guidance only: the line under the CLI install chip on `/cli`. The first is for `macOS / Linux` and `Windows`, the second for `npm`. `crates/skilld-command/src/upgrade.rs` in the CLI decides both. If it changes, change these strings | -| Claim: open source | open-source CLI | guidance only: the CLI. Never call skilld.dev or its site open source | +| Claim: open source | open source | guidance only: the CLI and the site. `skilld-dev/skilld` and `skilld-dev/skilld.dev` are both public under MIT, checked 2026-10-07. If either repository goes private, narrow the claim to the one a reader can clone. "No telemetry" still names the CLI only | | Claim: telemetry | no telemetry | guidance only: the CLI. Its README states that it sends no telemetry or analytics. Never say it of the site, which keeps anonymous analytics | | Claim: privacy | analytics without cookies or IPs | guidance only: analytics and privacy copy. Never the bare "privacy-friendly" | | Claim: comparison | skills.sh alternative | guidance only: always a link to `/vs/skills-sh` | @@ -225,7 +229,6 @@ near-miss is also banned. | people (for our audience) | dev, devs | They are developers, and "people" is a vaguer word doing a smaller job. "Developers" in full is fine where the short form reads clipped | | first person in a CTA | the content, then the destination | "We send this every Monday" spends the words on us. "Trending skills to your inbox every Monday" gives the reader both things that matter | | privacy-friendly (bare) | analytics without cookies or IPs | A bare privacy claim cannot be checked. The mechanism can | -| open source (for the site or skilld.dev) | open-source CLI | A reader must be able to clone what the claim names, and the claim names the CLI | **Exception, the humans-versus-generated claim.** When the contrast is authorship by a human against machine generation, "human" and "person" are the right words and "dev" weakens the diff --git a/app/components/HomeWhy.vue b/app/components/HomeWhy.vue new file mode 100644 index 00000000..b46ec152 --- /dev/null +++ b/app/components/HomeWhy.vue @@ -0,0 +1,105 @@ + + + + + diff --git a/app/components/why/_WhyBehaviorsVisual.vue b/app/components/why/_WhyBehaviorsVisual.vue new file mode 100644 index 00000000..d72552ea --- /dev/null +++ b/app/components/why/_WhyBehaviorsVisual.vue @@ -0,0 +1,103 @@ + + + + + diff --git a/app/components/why/_WhyCostVisual.vue b/app/components/why/_WhyCostVisual.vue new file mode 100644 index 00000000..02bfe7c3 --- /dev/null +++ b/app/components/why/_WhyCostVisual.vue @@ -0,0 +1,117 @@ + + + + + diff --git a/app/components/why/_WhyHumanVisual.vue b/app/components/why/_WhyHumanVisual.vue new file mode 100644 index 00000000..7bf3bca1 --- /dev/null +++ b/app/components/why/_WhyHumanVisual.vue @@ -0,0 +1,272 @@ + + + + + diff --git a/app/components/why/_WhyOpenVisual.vue b/app/components/why/_WhyOpenVisual.vue new file mode 100644 index 00000000..4e6a2ff0 --- /dev/null +++ b/app/components/why/_WhyOpenVisual.vue @@ -0,0 +1,184 @@ + + + + + diff --git a/app/components/why/_WhyPanel.vue b/app/components/why/_WhyPanel.vue new file mode 100644 index 00000000..ed886aac --- /dev/null +++ b/app/components/why/_WhyPanel.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/app/components/why/_WhyPreviewsVisual.vue b/app/components/why/_WhyPreviewsVisual.vue new file mode 100644 index 00000000..0193dc3c --- /dev/null +++ b/app/components/why/_WhyPreviewsVisual.vue @@ -0,0 +1,151 @@ + + + + + diff --git a/app/components/why/_WhyRunVisual.vue b/app/components/why/_WhyRunVisual.vue new file mode 100644 index 00000000..2f9bbac2 --- /dev/null +++ b/app/components/why/_WhyRunVisual.vue @@ -0,0 +1,143 @@ + + + + + diff --git a/app/components/why/_WhyTelemetryVisual.vue b/app/components/why/_WhyTelemetryVisual.vue new file mode 100644 index 00000000..141202d9 --- /dev/null +++ b/app/components/why/_WhyTelemetryVisual.vue @@ -0,0 +1,149 @@ + + + + + diff --git a/app/components/why/_WhyText.vue b/app/components/why/_WhyText.vue new file mode 100644 index 00000000..b0c2789a --- /dev/null +++ b/app/components/why/_WhyText.vue @@ -0,0 +1,30 @@ + + + + + diff --git a/app/components/why/_WhyTrustLine.vue b/app/components/why/_WhyTrustLine.vue new file mode 100644 index 00000000..589a9bfc --- /dev/null +++ b/app/components/why/_WhyTrustLine.vue @@ -0,0 +1,73 @@ + + + + + diff --git a/app/components/why/_WhyVisual.vue b/app/components/why/_WhyVisual.vue new file mode 100644 index 00000000..7af2cc8a --- /dev/null +++ b/app/components/why/_WhyVisual.vue @@ -0,0 +1,38 @@ + + + diff --git a/app/components/why/_WhyWeightsVisual.vue b/app/components/why/_WhyWeightsVisual.vue new file mode 100644 index 00000000..bd0701e3 --- /dev/null +++ b/app/components/why/_WhyWeightsVisual.vue @@ -0,0 +1,113 @@ + + + + + diff --git a/app/pages/index.vue b/app/pages/index.vue index ea25e215..5810025d 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -2,6 +2,7 @@ import type { RecentPublishesResponse } from '~~/server/api/feed/recent-publishes.get' import type { RecentUpdateCard, RecentUpdatesResponse } from '~~/server/api/feed/recent-updates.get' import type { TrendingFeedResponse } from '~~/server/api/feed/trending.get' +import type { RecentPullRequestsResponse } from '#shared/open-source-pull-requests' import type { HomeDemoItem } from '../utils/home-demos' import { agentSetupPrompt } from '#shared/agent-setup' import { WRITING_COMPARISON_LINK } from '#shared/comparison-navigation' @@ -108,6 +109,9 @@ if (import.meta.server) { // Lazy: the section sits under the hero, and its images load lazily anyway. const { data: demosData } = useLazyFetch<{ items: HomeDemoItem[] }>('/api/skill-demos', { key: 'home-skill-demos' }) +// Lazy too: the Why band sits below the fold, and the feed is cached for a day. +const { data: pullsData } = useLazyFetch('/api/feed/recent-pull-requests', { key: 'home-recent-pull-requests' }) + const recentUpdates = computed(() => updatesData.value?.items ?? []) const recentPublishes = computed(() => publishesData.value?.items ?? []) @@ -130,6 +134,9 @@ const showTrending = computed(() => (trendingData.value?.board.length ?? 0) >= H const weekBoardPath = trendingRangeMeta('week').path +/** The first row ranked by posts, for the people picture in the Why band. */ +const whyTrendingRow = computed(() => trendingBoard.value.find(row => row.reason._tag === 'posts') ?? null) + /** Real Skill names from this week's trending repositories, for the hero texture. */ const heroTextureNames = computed(() => trendingData.value?.textureNames ?? []) @@ -321,6 +328,8 @@ function recentRepoDescription(item: RecentRepoUpdate): string { + +
> = { + human: { + id: 'human', + icon: 'i-lucide-users-round', + title: 'Human first', + summary: 'The maintainers who write Skills, and the devs who talk about them.', + line: 'Every Skill names the maintainer who wrote it. Trending shows the devs who posted about it, in their own words.', + skillsSh: 'Shows the owner and the repository. Its leaderboard counts installs.', + link: { label: 'Trending skills', to: '/skills/trending' }, + sources: ['https://skills.sh', 'https://skills.sh/docs/faq'], + }, + previews: { + id: 'previews', + icon: 'i-lucide-eye', + title: 'Preview the output', + summary: 'Compare what Skills make before you spend tokens on a run.', + line: 'Each demo is one recorded run: the prompt, and what the Agent built with the Skill. Compare Skills by their output before you spend tokens on a run.', + skillsSh: 'Shows the SKILL.md and install counts, with no output from a run.', + link: { label: 'All demos', to: '/skills/demos' }, + sources: ['https://skills.sh'], + }, + open: { + id: 'open', + icon: 'i-lucide-git-pull-request', + title: 'Independent and open source', + summary: 'The CLI and this site are open source, and no listing is for sale.', + line: 'Harlan Wilton builds skilld in the open. The CLI and this site are both MIT on GitHub, and every change lands as a public pull request. No listing is for sale.', + skillsSh: 'Vercel operates it. Its CLI is open source under MIT.', + link: { label: 'skilld on GitHub', to: 'https://github.com/skilld-dev', external: true }, + sources: ['https://skills.sh/about', 'https://github.com/vercel-labs/skills'], + }, + weights: { + id: 'weights', + icon: 'i-lucide-scale', + title: 'Open trending weights', + summary: 'Read the code that ranks every trending board.', + line: 'Trending ranks Skills by the devs who post about them, with weights anyone can read. Install counts weigh nothing.', + skillsSh: 'Its leaderboard ranks by installs that its own CLI reports.', + link: { label: 'Read the ranking code', to: TRENDING_SCORE_SOURCE_URL, external: true }, + sources: ['https://skills.sh/docs/faq'], + }, + run: { + id: 'run', + icon: 'i-lucide-feather', + title: 'No more skill bloat', + summary: 'Run a Skill once off. Nothing lands on disk.', + line: 'Run a Skill once off. Your agent reads it for one session, and skilld writes no file, no lockfile entry, and no cache.', + skillsSh: '`skills add` installs the files into your project. `skills use` writes them to a temporary directory.', + link: { label: 'How run works', to: '/cli#run' }, + sources: ['https://github.com/vercel-labs/skills#readme'], + }, + behaviors: { + id: 'behaviors', + icon: 'i-lucide-square-terminal', + title: 'Know what it runs', + summary: 'See what a Skill asks your agent to do before it runs.', + line: 'Skill behaviors flag shell commands, scripts, and network calls, with a link to each line. `skilld run` waits for your approval before remote code or credential reads.', + skillsSh: 'Shows partner audit verdicts from Gen Agent Trust Hub, Socket, and Snyk. skilld shows the same verdicts on each Skill page.', + link: { label: 'Open the pdf Skill', to: '/gh/anthropics/skills/pdf' }, + sources: ['https://skills.sh/audits'], + }, + cost: { + id: 'cost', + icon: 'i-lucide-gauge', + title: 'Know what it costs', + summary: 'See what a Skill costs in tokens before it loads.', + line: 'A Skill page shows what each part costs in tokens: always, when used, and on demand. You see it before your agent loads anything.', + skillsSh: 'Shows the SKILL.md with no token cost. The full file tree needs its API and a Vercel token.', + link: { label: 'Open the pdf Skill', to: '/gh/anthropics/skills/pdf' }, + sources: ['https://skills.sh/docs/api'], + }, + telemetry: { + id: 'telemetry', + icon: 'i-lucide-eye-off', + title: 'No telemetry', + summary: 'The CLI sends no telemetry or analytics.', + line: 'The skilld CLI sends no telemetry or analytics. Its README says so, and the source shows it.', + skillsSh: 'The skills CLI is MIT too. Its telemetry is on by default and sends Skill names and search queries. `DISABLE_TELEMETRY=1` turns it off.', + link: { label: 'Read the source', to: 'https://github.com/skilld-dev/skilld', external: true }, + sources: ['https://github.com/vercel-labs/skills/blob/main/src/telemetry.ts', 'https://skills.sh/docs/cli'], + }, +} + +/** + * The homepage leads with the three reasons Harlan chose on 2026-10-07: the + * people behind Skills, output you can compare, and code anyone can read. + */ +export const HOME_WHY_REASONS: readonly WhyReasonId[] = ['human', 'previews', 'open'] + +/** `/vs/skills-sh` shows every reason, the homepage three first. */ +export const VS_WHY_REASONS: readonly WhyReasonId[] = ['human', 'previews', 'open', 'weights', 'run', 'behaviors', 'cost', 'telemetry'] + +/** Where skills.sh is ahead. A comparison that admits none reads as an ad. */ +export interface SkillsShLead { + title: string + line: string + sources: readonly string[] +} + +export function skillsShLeads(agentTargetCount: number): SkillsShLead[] { + return [ + { + title: 'More Skills', + line: 'skills.sh lists every public Skill its CLI has seen. skilld lists curated Skills only, so a search there finds more.', + sources: ['https://skills.sh/about'], + }, + { + title: 'More agents', + line: `The skills CLI installs into 79 agents. The skilld CLI installs into ${agentTargetCount}, and MCP apps such as ChatGPT search skilld from the chat.`, + sources: ['https://github.com/vercel-labs/skills#readme'], + }, + ] +} + +export interface ComparisonRow { + label: string + skillsSh: string + skilld: string +} + +export function comparisonRows(agentTargetCount: number): ComparisonRow[] { + return [ + { label: 'Use without installing', skillsSh: '`skills use` writes the files to a temporary directory', skilld: '`skilld run` writes nothing' }, + { label: 'Files on the Skill page', skillsSh: 'SKILL.md', skilld: 'Every file, with its token cost' }, + { label: 'Before a run', skillsSh: 'Partner audit verdicts', skilld: 'The same audit verdicts, plus Skill behaviors and an approval stop' }, + { label: 'What ranks a Skill', skillsSh: 'Install counts from CLI telemetry', skilld: 'Devs who posted about it, with open weights' }, + { label: 'Who wrote it', skillsSh: 'Owner and repository', skilld: 'The maintainer, and the exact SKILL.md' }, + { label: 'Output before you run', skillsSh: 'None', skilld: 'Recorded demos' }, + { label: 'CLI telemetry', skillsSh: 'On by default, opt out', skilld: 'None' }, + { label: 'CLI licence', skillsSh: 'MIT', skilld: 'MIT' }, + { label: 'Agents', skillsSh: '79', skilld: `${agentTargetCount}, plus MCP apps` }, + { label: 'Staying current', skillsSh: '`skills update`', skilld: 'Watch for changes, monthly digest' }, + { label: 'Run by', skillsSh: 'Vercel', skilld: 'Harlan Wilton, independent' }, + ] +} diff --git a/layers/marketing/app/pages/vs/skills-sh.vue b/layers/marketing/app/pages/vs/skills-sh.vue index 338810e5..0f2d9bdd 100644 --- a/layers/marketing/app/pages/vs/skills-sh.vue +++ b/layers/marketing/app/pages/vs/skills-sh.vue @@ -1,11 +1,465 @@ + + diff --git a/layers/marketing/content/pages/vs/skills-sh.md b/layers/marketing/content/pages/vs/skills-sh.md deleted file mode 100644 index b0a0781f..00000000 --- a/layers/marketing/content/pages/vs/skills-sh.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -title: 'skilld vs skills.sh: curated Agent Skills with an author' -description: Looking for a skills.sh alternative? skilld is a curated registry where every Skill has a human author and readable source. One command, every Agent. -heading: skilld vs skills.sh -label: Comparison -author: Harlan Wilton -cta: - label: Browse curated Skills - to: /skills -publishedAt: 2026-09-01 -updatedAt: 2026-09-01 ---- - -skills.sh is Vercel's directory of Agent Skills. It indexes a large catalogue, ranks by install count, supports 73 agents, and runs audits on listed skills. If you want breadth and a popularity signal, it does that well. skilld is smaller by design: a person admits each Skill, every listing shows its author and links the source file, and `skilld run` lets your Agent use a Skill without writing a file. - -| | skills.sh | skilld | -|---|---|---| -| Who admits a Skill | Any indexed repository | A person, with a reconstructible reason | -| Ordering signal | Install count | [GitHub](https://github.com) stars, with curation first | -| Provenance shown | Repository link | Author and exact source file, one click | -| Try before install | Install | `skilld run` prints the Skill, writes nothing | -| Staying current | `update --all` syncs files | Source commit tracked; update shows what changed | -| Agents | 73 | Detected Agent target; same command for each | - -Both tools install the same SKILL.md files. The difference is who decided a Skill belongs and what you know about it before you run it. diff --git a/server/api/feed/recent-pull-requests.get.ts b/server/api/feed/recent-pull-requests.get.ts new file mode 100644 index 00000000..32cd9a60 --- /dev/null +++ b/server/api/feed/recent-pull-requests.get.ts @@ -0,0 +1,42 @@ +/** + * Recently merged pull requests from skilld's public repositories, for the + * Independent and open source reason on the homepage and `/vs/skills-sh`. + * + * Two GitHub reads per refresh, cached for a day, so the feed spends about two + * calls a day of the token the registry sync also uses. A failed read throws, + * and the cache keeps serving the last good answer for a week. + */ + +import type { OpenSourceRepository, RecentPullRequestsResponse } from '#shared/open-source-pull-requests' +import { cachedFeed } from '#server/utils/feed-cache' +import { OPEN_SOURCE_REPOSITORIES, recentMergedPullRequests } from '#shared/open-source-pull-requests' +import { defineApiHandler } from '#shared/server/handler' + +/** Rows the pictures show at most. */ +const LIMIT = 6 + +/** Pulls read per repository, enough to fill the rows after bots and unmerged pulls drop out. */ +const PER_REPOSITORY = 20 + +async function fetchClosedPulls(repository: OpenSourceRepository, token: string | undefined): Promise { + return $fetch(`https://api.github.com/repos/${repository}/pulls`, { + query: { state: 'closed', sort: 'updated', direction: 'desc', per_page: PER_REPOSITORY }, + headers: { + 'Accept': 'application/vnd.github+json', + 'User-Agent': 'skilld.dev (+https://skilld.dev)', + 'X-GitHub-Api-Version': '2022-11-28', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + }) +} + +export default defineApiHandler({ + handler: ({ event, platform }): Promise => cachedFeed(event, 'recent-pull-requests', async () => { + const token = platform.env.GITHUB_TOKEN?.trim() || undefined + const pullsByRepository = await Promise.all(OPEN_SOURCE_REPOSITORIES.map(async ({ fullName }) => ({ + repository: fullName, + pulls: await fetchClosedPulls(fullName, token), + }))) + return { items: recentMergedPullRequests(pullsByRepository, LIMIT) } + }, [], { ttl: 86_400, staleTtl: 604_800 }), +}) diff --git a/server/utils/feed-cache.ts b/server/utils/feed-cache.ts index 4351f084..8b8bd7a4 100644 --- a/server/utils/feed-cache.ts +++ b/server/utils/feed-cache.ts @@ -1,8 +1,22 @@ import type { H3Event } from 'h3' import { readThroughCache } from '#shared/server/cache' +/** How long a feed answer stays fresh, then stale, in seconds. */ +export interface FeedCacheWindow { + ttl: number + staleTtl: number +} + +const DEFAULT_WINDOW: FeedCacheWindow = { ttl: 300, staleTtl: 3600 } + /** Keep each feed's last good answer through a bounded D1 outage. */ -export async function cachedFeed(event: H3Event, namespace: string, compute: () => Promise, query: number[] = []): Promise { +export async function cachedFeed( + event: H3Event, + namespace: string, + compute: () => Promise, + query: number[] = [], + window: FeedCacheWindow = DEFAULT_WINDOW, +): Promise { const deployment = event.context.platform?.env?.CF_VERSION_METADATA?.id // A deployment identifies the response schema. Never reuse another release's // answer, and bypass shared storage when that identity is unavailable. @@ -12,10 +26,7 @@ export async function cachedFeed(event: H3Event, namespace: string, compute: const identity = JSON.stringify([deployment, namespace, query]) const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(identity)) const key = [...new Uint8Array(digest)].map(byte => byte.toString(16).padStart(2, '0')).join('') - const value = await readThroughCache(useStorage('edge-cache'), `feed:v1:${key}`, compute, { - ttl: 300, - staleTtl: 3600, - }) + const value = await readThroughCache(useStorage('edge-cache'), `feed:v1:${key}`, compute, window) // Preserve the feed routes' existing browser freshness window. setHeader(event, 'Cache-Control', 'max-age=300') return value diff --git a/shared/open-source-pull-requests.ts b/shared/open-source-pull-requests.ts new file mode 100644 index 00000000..c67c2fa7 --- /dev/null +++ b/shared/open-source-pull-requests.ts @@ -0,0 +1,85 @@ +import { z } from 'zod' + +/** + * Recently merged pull requests from skilld's public repositories, for the + * Independent and open source reason. Pure: the route fetches each + * repository's closed pulls, and this module parses and merges them. + */ + +/** The public repositories the reason links. Both are MIT on GitHub. */ +export const OPEN_SOURCE_REPOSITORIES = [ + { fullName: 'skilld-dev/skilld', label: 'CLI' }, + { fullName: 'skilld-dev/skilld.dev', label: 'site' }, +] as const + +export type OpenSourceRepository = typeof OPEN_SOURCE_REPOSITORIES[number]['fullName'] + +export interface RecentPullRequest { + repository: OpenSourceRepository + number: number + /** The title without its Conventional Commits prefix, such as `feat(home): `. */ + title: string + url: string + /** Epoch seconds. */ + mergedAt: number + author: string + avatarUrl: string | null +} + +export interface RecentPullRequestsResponse { + items: RecentPullRequest[] +} + +/** Only the fields we read from `GET /repos/{owner}/{repo}/pulls`. */ +const GithubPullSchema = z.object({ + number: z.number().int(), + title: z.string(), + html_url: z.string().url(), + merged_at: z.string().nullable(), + user: z.object({ + login: z.string(), + avatar_url: z.string().url().nullable().optional(), + type: z.string().optional(), + }).nullable(), +}) + +const GithubPullsSchema = z.array(GithubPullSchema) + +const CONVENTIONAL_PREFIX = /^[a-z]+(?:\([^)]*\))?!?:\s*/i + +/** + * Merged, human-authored pulls from every repository, newest first. + * + * A pull that closed without merging never shipped, and a bot's dependency + * bump says nothing about who builds skilld, so both stay out. Input that + * fails to parse is skipped per repository, never guessed at. + */ +export function recentMergedPullRequests( + pullsByRepository: ReadonlyArray<{ repository: OpenSourceRepository, pulls: unknown }>, + limit: number, +): RecentPullRequest[] { + return pullsByRepository + .flatMap(({ repository, pulls }) => { + const parsed = GithubPullsSchema.safeParse(pulls) + if (!parsed.success) + return [] + return parsed.data.flatMap((pull): RecentPullRequest[] => { + if (!pull.merged_at || !pull.user || pull.user.type === 'Bot') + return [] + const mergedAt = Math.floor(Date.parse(pull.merged_at) / 1000) + if (!Number.isFinite(mergedAt)) + return [] + return [{ + repository, + number: pull.number, + title: pull.title.replace(CONVENTIONAL_PREFIX, '') || pull.title, + url: pull.html_url, + mergedAt, + author: pull.user.login, + avatarUrl: pull.user.avatar_url ?? null, + }] + }) + }) + .sort((a, b) => b.mergedAt - a.mergedAt) + .slice(0, limit) +} diff --git a/test/a11y.nuxt.spec.ts b/test/a11y.nuxt.spec.ts index 5403052c..943af1d8 100644 --- a/test/a11y.nuxt.spec.ts +++ b/test/a11y.nuxt.spec.ts @@ -1,6 +1,9 @@ import type { AxeResults, RunOptions } from 'axe-core' import type { Component } from 'vue' import type { WeeklyDemoResponse } from '../../server/api/weekly/demo.get' +import type { HomeDemoItem } from '../app/utils/home-demos' +import type { RecentPullRequest } from '../shared/open-source-pull-requests' +import type { TrendingBoardRow } from '../shared/trending-range' import { readdirSync } from 'node:fs' import { resolve } from 'node:path' import { mockNuxtImport, mountSuspended } from '@nuxt/test-utils/runtime' @@ -786,6 +789,117 @@ describe('accessibility: components', () => { wrapper.unmount() }) + /** The head of a trending board, ranked by two posts. */ + const WHY_TRENDING_ROW: TrendingBoardRow = { + key: 'acme/brag/brag', + owner: 'acme', + repo: 'brag', + name: 'brag', + title: 'brag', + to: '/gh/acme/brag', + subtitle: null, + description: 'Makes a launch film.', + stars: 120, + starSeries: [], + names: ['brag'], + skill: { owner: 'acme', repo: 'brag', name: 'brag' }, + reason: { + _tag: 'posts', + posts: [ + { url: 'https://x.com/ada/status/1', text: 'Tried /brag on our launch, it made the whole film.', platform: 'x', handle: 'ada', authorName: 'Ada', authorAvatar: null, likes: 4, when: '1d' }, + { url: 'https://x.com/lin/status/2', text: 'brag is great', platform: 'x', handle: 'lin', authorName: 'Lin', authorAvatar: null, likes: 1, when: '2d' }, + ], + mentionsByDay: [0, 0, 0, 0, 0, 1, 1], + }, + } + + const WHY_DEMO: HomeDemoItem = { + owner: 'acme', + repo: 'brag', + name: 'brag', + skillPath: 'SKILL.md', + makes: 'film', + authorName: 'Ada', + sourceUrl: 'https://github.com/acme/brag/blob/main/SKILL.md', + prompt: 'Make a short launch film for this project.', + agent: 'Claude Code', + model: 'claude-opus-5-5', + recordedAt: '2026-10-01', + shots: [{ src: 'https://media.skilld.dev/brag.png', width: 1440, height: 900, alt: 'The launch film', viewport: 'desktop' }], + liveUrl: null, + video: null, + } + + const WHY_DEMOS: HomeDemoItem[] = [ + { ...WHY_DEMO, name: 'frontend-design', makes: 'landing-page' }, + { ...WHY_DEMO, name: 'impeccable', makes: 'landing-page' }, + WHY_DEMO, + ] + + const WHY_PULLS: RecentPullRequest[] = [ + { repository: 'skilld-dev/skilld.dev', number: 516, title: 'lead the Why band with human, previews, and independence', url: 'https://github.com/skilld-dev/skilld.dev/pull/516', mergedAt: 1_791_300_000, author: 'harlan-zw', avatarUrl: null }, + { repository: 'skilld-dev/skilld', number: 216, title: 'install into the agents the skills CLI supports', url: 'https://github.com/skilld-dev/skilld/pull/216', mergedAt: 1_791_290_000, author: 'harlan-zw', avatarUrl: null }, + ] + + it('homeWhy has no violations, shows three reasons with a picture each, and ends on the comparison', async () => { + const container = createIsolatedContainer() + const wrapper = await mountSuspended( + await loadComponent('HomeWhy'), + { attachTo: container, props: { trendingRow: WHY_TRENDING_ROW, demos: WHY_DEMOS, pulls: WHY_PULLS } }, + ) + const results = await runAxe(container) + expect(results.violations, formatViolations(results)).toHaveLength(0) + + const columns = [...container.querySelectorAll('.home-why__col')] + expect(columns.map(column => column.querySelector('h3')?.textContent?.trim())).toEqual([ + 'Human first', + 'Preview the output', + 'Independent and open source', + ]) + expect(columns.every(column => column.querySelector('.why-panel'))).toBe(true) + // The people picture quotes the first post in the poster's words. + expect(container.textContent).toContain('@ada') + // The open source picture links each merged pull request. + expect(container.querySelector('a[href="https://github.com/skilld-dev/skilld/pull/216"]')).not.toBeNull() + const links = [...container.querySelectorAll('a')].map(link => link.getAttribute('href')) + expect(links.at(-1)).toBe('/vs/skills-sh') + wrapper.unmount() + }) + + it('whyVisual draws every reason without violations, and no demo picture without a demo', async () => { + for (const id of ['human', 'previews', 'open', 'weights', 'run', 'behaviors', 'cost', 'telemetry'] as const) { + const container = createIsolatedContainer() + const wrapper = await mountSuspended( + await loadComponent('why/_WhyVisual'), + { attachTo: container, props: { id, trendingRow: WHY_TRENDING_ROW, demos: WHY_DEMOS, pulls: WHY_PULLS } }, + ) + const results = await runAxe(container) + expect(results.violations, `${id}\n${formatViolations(results)}`).toHaveLength(0) + expect(container.querySelector('.why-panel'), id).not.toBeNull() + wrapper.unmount() + } + + const container = createIsolatedContainer() + const wrapper = await mountSuspended( + await loadComponent('why/_WhyVisual'), + { attachTo: container, props: { id: 'previews', demos: [WHY_DEMO] } }, + ) + // One demo in a group gives nothing to compare, so the picture stays out. + expect(container.querySelector('.why-panel')).toBeNull() + wrapper.unmount() + }) + + it('whyVisual keeps the maintainer and drops the posters when no trending row arrived', async () => { + const container = createIsolatedContainer() + const wrapper = await mountSuspended( + await loadComponent('why/_WhyVisual'), + { attachTo: container, props: { id: 'human', trendingRow: null } }, + ) + expect(container.textContent).toContain('Matt Pocock') + expect(container.textContent).not.toContain('Talked about by') + wrapper.unmount() + }) + it('cliInstallChip has no violations, leads with the native install, and switches platform', async () => { const container = createIsolatedContainer() const wrapper = await mountSuspended( @@ -876,6 +990,17 @@ describe('accessibility: component coverage', () => { 'home-demos/_DemoPicture', // A part of HomeDemos, scanned inside the HomeDemos tests 'home-demos/_DemoRecording', // A part of HomeDemos, scanned inside the HomeDemos tests 'home-demos/_DemoVideo', // A part of HomeDemos; its play rules need a real browser, checked by hand + 'why/_WhyPanel', // The frame of every Why picture, scanned inside the WhyVisual tests + 'why/_WhyRunVisual', // A Why picture, scanned inside the WhyVisual tests + 'why/_WhyBehaviorsVisual', // A Why picture, scanned inside the WhyVisual tests + 'why/_WhyCostVisual', // A Why picture, scanned inside the WhyVisual tests + 'why/_WhyHumanVisual', // A Why picture, scanned inside the WhyVisual tests + 'why/_WhyPreviewsVisual', // A Why picture, scanned inside the WhyVisual tests + 'why/_WhyOpenVisual', // A Why picture, scanned inside the WhyVisual tests + 'why/_WhyWeightsVisual', // A Why picture, scanned inside the WhyVisual tests + 'why/_WhyTelemetryVisual', // A Why picture, scanned inside the WhyVisual tests + 'why/_WhyText', // The words of each reason, scanned inside the HomeWhy test + 'why/_WhyTrustLine', // The line under the Why band, scanned inside the HomeWhy test ] /** diff --git a/test/unit/open-source-pull-requests.test.ts b/test/unit/open-source-pull-requests.test.ts new file mode 100644 index 00000000..27d67bd3 --- /dev/null +++ b/test/unit/open-source-pull-requests.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import { recentMergedPullRequests } from '../../shared/open-source-pull-requests' + +function pull(overrides: Record) { + return { + number: 1, + title: 'feat(home): add the Why band', + html_url: 'https://github.com/skilld-dev/skilld.dev/pull/1', + merged_at: '2026-10-07T01:00:00Z', + user: { login: 'harlan-zw', avatar_url: 'https://avatars.githubusercontent.com/u/5326365?v=4', type: 'User' }, + ...overrides, + } +} + +describe('recentMergedPullRequests', () => { + it('merges both repositories newest first and strips the commit prefix', () => { + const items = recentMergedPullRequests([ + { repository: 'skilld-dev/skilld.dev', pulls: [pull({ number: 516, merged_at: '2026-10-07T03:00:00Z' })] }, + { repository: 'skilld-dev/skilld', pulls: [pull({ number: 211, title: 'chore(release)!: prepare v3.6.3', merged_at: '2026-10-07T05:00:00Z' })] }, + ], 5) + + expect(items.map(item => [item.repository, item.number, item.title])).toEqual([ + ['skilld-dev/skilld', 211, 'prepare v3.6.3'], + ['skilld-dev/skilld.dev', 516, 'add the Why band'], + ]) + expect(items[0]!.mergedAt).toBe(Date.parse('2026-10-07T05:00:00Z') / 1000) + }) + + it('drops unmerged pulls and bot authors', () => { + const items = recentMergedPullRequests([ + { + repository: 'skilld-dev/skilld.dev', + pulls: [ + pull({ number: 1, merged_at: null }), + pull({ number: 2, user: { login: 'renovate[bot]', type: 'Bot' } }), + pull({ number: 3 }), + ], + }, + ], 5) + + expect(items.map(item => item.number)).toEqual([3]) + }) + + it('skips a repository whose answer does not parse, and keeps the other', () => { + const items = recentMergedPullRequests([ + { repository: 'skilld-dev/skilld', pulls: { message: 'API rate limit exceeded' } }, + { repository: 'skilld-dev/skilld.dev', pulls: [pull({ number: 9 })] }, + ], 5) + + expect(items.map(item => item.number)).toEqual([9]) + }) + + it('keeps a title that is only a prefix', () => { + const [item] = recentMergedPullRequests([ + { repository: 'skilld-dev/skilld.dev', pulls: [pull({ title: 'chore: ' })] }, + ], 5) + + expect(item!.title).toBe('chore: ') + }) + + it('stops at the limit', () => { + const pulls = [1, 2, 3, 4].map(number => pull({ number, merged_at: `2026-10-0${number}T00:00:00Z` })) + const items = recentMergedPullRequests([{ repository: 'skilld-dev/skilld.dev', pulls }], 2) + + expect(items.map(item => item.number)).toEqual([4, 3]) + }) +})