Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions layers/registry/app/components/SkillDetail.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ import { partitionMetadataEntries } from '../utils/skill-metadata'
import { resolveSkillPageState } from '../utils/skill-page-state'
import { resolveSkillRawUrl } from '../utils/skill-raw-url'
import { resolveSkillTitle } from '../utils/skill-title'
import { isUniqueValueTreatment } from '../utils/skill-unique-value-experiment'
import SkillCommandPanel from './_SkillCommandPanel.vue'
import SkillReceiptsPanel from './_SkillReceiptsPanel.vue'
import SkillThirdPartyChecks from './_SkillThirdPartyChecks.vue'
import SkillUniqueValue from './_SkillUniqueValue.vue'

const props = defineProps<{
owner: string
Expand All @@ -22,6 +24,8 @@ const owner = computed(() => props.owner)
const repo = computed(() => props.repo)
const name = computed(() => props.name)
const slug = computed(() => `${owner.value}/${repo.value}/${name.value}`)
// SEO experiment C (skill-unique-value-experiment.ts). Same page for every visitor.
const uniqueValueTreatment = computed(() => isUniqueValueTreatment(slug.value))

interface RelatedSkill {
name: string
Expand Down Expand Up @@ -1345,8 +1349,26 @@ useHead(computed(() => ({

<div class="mx-auto max-w-5xl px-4 sm:px-6 py-8 md:py-10 lg:grid lg:grid-cols-12 lg:gap-8 lg:items-start">
<div class="lg:col-span-8 space-y-10 md:space-y-12">
<SkillUniqueValue
v-if="uniqueValueTreatment"
:owner="data.owner"
:repo="data.repo"
:name="data.name"
:description="data.description"
:stars="data.stars"
:pushed-at="data.pushedAt"
:maturity="data.maturity"
:assets="data.assets"
:asset-count="data.assetCount"
:content-html="data.contentHtml"
:compatibility="typeof data.frontmatter?.compatibility === 'string' ? data.frontmatter.compatibility : null"
:curators="data.curators"
:skill-file-url="skillFileUrl"
:github-url="githubUrl"
:registry-path="data.registryPath"
/>
<section
v-if="data.contentHtml"
v-else-if="data.contentHtml"
class="skill-content-section"
aria-labelledby="content-heading"
>
Expand Down Expand Up @@ -1781,6 +1803,7 @@ useHead(computed(() => ({
</section>

<section
v-if="!uniqueValueTreatment"
class="2xl:hidden"
aria-labelledby="files-heading"
>
Expand Down Expand Up @@ -2098,7 +2121,7 @@ useHead(computed(() => ({
</template>
</div>

<template v-if="data && !error">
<template v-if="data && !error && !uniqueValueTreatment">
<USeparator />
<section
ref="relatedSlot"
Expand Down
244 changes: 244 additions & 0 deletions layers/registry/app/components/_SkillUniqueValue.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
<script setup lang="ts">
// SEO experiment C, treatment pages only. See skill-unique-value-experiment.ts.
// Every block is built from data skilld already holds. Delete with the gate.
import type { RepoHistoryResponse } from '../../server/api/repos/[owner]/[repo]/history.get'
import type { SkillFileEntry } from '../utils/skill-unique-value'
import { useTimeAgo } from '@vueuse/core'
import RepoSparkline from '../pages/gh/[owner]/[repo]/_RepoSparkline.vue'
import { excerptSkillHtml, resolveSkillCommands, resolveSkillFileFacts } from '../utils/skill-unique-value'

interface RelatedItem {
name: string
owner: string
repo: string
displayName: string
description: string | null
registryPath: string
}

interface RelatedResponse {
relatedRepoSkills: RelatedItem[]
relatedOwnerSkills: RelatedItem[]
coOccurrenceSkills: RelatedItem[]
semanticSiblings: RelatedItem[]
}

const props = defineProps<{
owner: string
repo: string
name: string
description: string | null
stars: number
pushedAt: string | null
maturity: { sinceUpdateDays: number, cadence: 'active' | 'steady' | 'dormant' } | null
assets: SkillFileEntry[]
assetCount: number
contentHtml: string | null
compatibility: string | null
curators: { handle: string, collectionName: string, collectionSlug: string, reason?: string }[]
skillFileUrl: string
githubUrl: string
registryPath: string
}>()

// Both requests run on the server, so the blocks sit in the HTML a crawler
// reads. Only the ten treatment pages pay for them. Related Skills come from
// the edge-cached endpoint that the browser already uses.
const { data: related } = await useFetch<RelatedResponse>(
() => `/api/skill-related/${props.owner}/${props.repo}/${props.name}`,
{ key: () => `uv-related:${props.owner}/${props.repo}/${props.name}` },
)
const { data: history } = await useFetch<RepoHistoryResponse>(
() => `/api/repos/${props.owner}/${props.repo}/history`,
{ key: () => `uv-history:${props.owner}/${props.repo}` },
)

const files = computed(() => resolveSkillFileFacts({ assets: props.assets, total: props.assetCount }))
const commands = computed(() => resolveSkillCommands(props))
const excerpt = computed(() => excerptSkillHtml(props.contentHtml, { description: props.description }))
const markdownUrl = computed(() => `${props.registryPath}.md`)

const pushedAtAgo = useTimeAgo(() => new Date(props.pushedAt ?? 0))

const MONTH_FORMAT = new Intl.DateTimeFormat('en-GB', { month: 'short', year: 'numeric', timeZone: 'UTC' })
const starTrend = computed(() => {
const h = history.value?.starHistory
if (h?._tag === 'ready')
return { _tag: 'ready' as const, points: h.points }
if (h?._tag === 'collecting')
return { _tag: 'tracking' as const, label: `Tracking since ${MONTH_FORMAT.format(h.trackedSince * 1000)}` }
return null
})

const relatedGroups = computed(() => {
const r = related.value
if (!r)
return []
return [
{ label: 'Similar', items: r.semanticSiblings },
{ label: 'Paired with', items: r.coOccurrenceSkills },
{ label: `From ${props.owner}/${props.repo}`, items: r.relatedRepoSkills },
{ label: `Other by ${props.owner}`, items: r.relatedOwnerSkills },
]
.map(g => ({ ...g, items: g.items.slice(0, 6) }))
.filter(g => g.items.length > 0)
})

const CADENCE_LABEL = { active: 'active', steady: 'steady', dormant: 'dormant' } as const
</script>

<template>
<div class="space-y-10 md:space-y-12" data-testid="skill-unique-value">
<section aria-labelledby="uv-facts-heading">
<h2 id="uv-facts-heading" class="section-label mb-3">
Facts from the repository
</h2>
<dl class="grid gap-3 sm:grid-cols-2">
<div class="rounded-lg border border-default bg-muted/30 p-3 sm:col-span-2">
<dt class="data-label">
Run command
</dt>
<dd class="mt-2">
<code class="install-command install-command--wrap block">{{ commands.run }}</code>
<p class="mt-2 text-sm text-muted">
Your Agent reads the Skill now. Nothing lands in your repository.
</p>
<p class="data-label mt-4">
Install command
</p>
<code class="install-command install-command--wrap mt-2 block">{{ commands.install }}</code>
<p class="mt-2 text-sm text-muted">
Install it to keep the Skill in every session.
</p>
</dd>
</div>

<div class="rounded-lg border border-default bg-muted/30 p-3">
<dt class="data-label">
Files beside SKILL.md
</dt>
<dd class="mt-2 text-sm">
<template v-if="files._tag === 'only-skill-md'">
None. This Skill is one file.
</template>
<template v-else>
<p class="font-mono tabular-nums">
{{ files.total.toLocaleString() }} files
</p>
<p class="mt-1 text-muted">
{{ files.markdown.toLocaleString() }} markdown, {{ files.code.toLocaleString() }} code
</p>
<p v-if="files.codePaths.length" class="mt-1 break-words font-mono text-xs text-muted">
{{ files.codePaths.slice(0, 3).join(', ') }}<template v-if="files.codePaths.length > 3">
and {{ (files.codePaths.length - 3).toLocaleString() }} more
</template>
</p>
</template>
</dd>
</div>

<div class="rounded-lg border border-default bg-muted/30 p-3">
<dt class="data-label">
GitHub stars
</dt>
<dd class="mt-2 flex min-h-8 items-end justify-between gap-3">
<span class="font-mono text-xl font-medium tabular-nums">{{ stars.toLocaleString() }}</span>
<span class="h-7 w-20 shrink-0 sm:w-24">
<RepoSparkline
v-if="starTrend?._tag === 'ready'"
:points="starTrend.points"
label="GitHub stars"
approximate
/>
<span v-else-if="starTrend" class="block text-right text-[10px] leading-tight text-muted">
{{ starTrend.label }}
</span>
</span>
</dd>
</div>

<div v-if="pushedAt" class="rounded-lg border border-default bg-muted/30 p-3">
<dt class="data-label">
Repository updated
</dt>
<dd class="mt-2 text-sm">
<p class="font-mono">
{{ pushedAtAgo }}
</p>
<p v-if="maturity" class="mt-1 text-muted">
Update pace: {{ CADENCE_LABEL[maturity.cadence] }}
</p>
</dd>
</div>

<div v-if="compatibility" class="rounded-lg border border-default bg-muted/30 p-3">
<dt class="data-label">
Agent compatibility
</dt>
<dd class="mt-2 text-sm">
{{ compatibility }}
</dd>
</div>

<div
v-if="curators.length"
class="rounded-lg border border-default bg-muted/30 p-3 sm:col-span-2"
>
<dt class="data-label">
In collections
</dt>
<dd class="mt-2 space-y-2 text-sm">
<p v-for="curator in curators" :key="`${curator.handle}/${curator.collectionSlug}`">
<NuxtLink
:to="`/@${curator.handle}/${curator.collectionSlug}`"
class="font-mono hover:text-highlighted"
>
{{ curator.collectionName }}
</NuxtLink>
by @{{ curator.handle }}<template v-if="curator.reason">
: {{ curator.reason }}
</template>
</p>
</dd>
</div>
</dl>
</section>

<section aria-labelledby="uv-excerpt-heading">
<h2 id="uv-excerpt-heading" class="section-label mb-3">
Excerpt from SKILL.md
</h2>
<div class="rounded-lg border border-default p-4 sm:p-6">
<!-- eslint-disable-next-line vue/no-v-html -- HTML from the same sanitising renderer as the full body -->
<div v-if="excerpt" class="skill-prose" v-html="excerpt" />
</div>
<p class="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted">
<a
:href="skillFileUrl || githubUrl"
target="_blank"
rel="noopener"
class="font-mono hover:text-default transition-colors"
>
Read the full SKILL.md on GitHub
</a>
<a :href="markdownUrl" class="font-mono hover:text-default transition-colors">
Full SKILL.md as markdown
</a>
</p>
</section>

<section v-if="relatedGroups.length" aria-labelledby="uv-related-heading">
<h2 id="uv-related-heading" class="section-label mb-3">
Related skills
</h2>
<div class="space-y-6">
<div v-for="group in relatedGroups" :key="group.label">
<h3 class="mb-2 font-mono text-xs text-muted">
{{ group.label }}
</h3>
<SkillSourceList :items="group.items" variant="grid" :aria-label="`${group.label} skills`" />
</div>
</div>
</section>
</div>
</template>
74 changes: 74 additions & 0 deletions layers/registry/app/utils/skill-unique-value-experiment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* SEO experiment C, "unique value": the gate and the two groups. Delete this
* file after the decision. Nothing else decides which Skill page is treated.
*
* Started 2026-09-30. Read the result at the Search Console gate on 2026-11-11.
* Google holds the curated Skill URLs as "Discovered, currently not indexed",
* and about 72% of a Skill page's text is the GitHub SKILL.md word for word.
* The question: do pages that carry data GitHub lacks get crawled and indexed
* sooner than pages that stay as they are?
*
* Treatment pages show the run command with install beneath it, the file inventory, the
* repository star trend, related Skills, and a SKILL.md excerpt in place of the
* full body. Every visitor sees the same page, crawler or not. Control pages
* stay unchanged.
*
* Every Skill below sits on a trending list (week, month, or all-time), in the
* production skills sitemap, and was indexable on 2026-09-30. A parallel
* experiment keeps only trending Skills indexable, so a control outside those
* lists would leave the index. Membership came from `/api/feed/trending`,
* `/api/skills/leaderboard`, and the sitemap, checked against `/api/skills`,
* not against an HTTP status. Only Skills whose canonical page is the
* three-segment `SkillDetail` route qualify; single-Skill repositories render
* on the repository hub. Twenty-two Skills qualified. Two were dropped and the
* rest were paired by repository stars. Within each pair, the treatment Skill
* alternates between the higher and the lower star count.
*
* Cull path: after the decision, delete this file, `skill-unique-value.ts`,
* `_SkillUniqueValue.vue`, and the `isUniqueValueTreatment` branches in
* `SkillDetail.vue`. If the treatment wins, roll the template out by
* removing the gate; if not, remove the template.
*/
/*
* 2026-09-30: swapped `onmax/nuxt-skills/arkenv` (treatment) for `ferdinandobons/startup-skill/startup-pitch`,
* and `pbakaus/agent-reviews/resolve-agent-reviews` (control) for `browser-use/plugins/browser-use`.
* The trending-only rule (#322) left both originals off every board, so they went
* `noindex,follow` and no longer matched their indexable partners.
*/
export const UNIQUE_VALUE_EXPERIMENT = {
startedOn: '2026-09-30',
endsOn: '2026-11-11',
/** Treatment: `owner/repo/name`. */
treatment: [
'obra/superpowers/writing-plans',
'imbad0202/academic-research-skills/academic-pipeline',
'emilkowalski/skills/emil-design-eng',
'vercel-labs/agent-skills/react-best-practices',
'ibelick/ui-skills/create-design-md',
'remotion-dev/skills/remotion-best-practices',
'dimillian/skills/bug-hunt-swarm',
'ferdinandobons/startup-skill/startup-pitch',
'neondatabase/agent-skills/neon-postgres',
'clerk/skills/clerk-orgs',
],
/** Control: the star-matched partner of the treatment Skill at the same index. */
control: [
'mattpocock/skills/ask-matt',
'coreyhaines31/marketingskills/ai-seo',
'kepano/obsidian-skills/knap',
'othmanadi/planning-with-files/planning-with-files',
'microsoft/playwright-cli/playwright-cli',
'antfu/skills/nitro',
'addyosmani/web-quality-skills/accessibility',
'getsentry/skills/prompt-optimizer',
'browser-use/plugins/browser-use',
'prisma/skills/prisma-database-setup',
],
} as const

const TREATMENT = new Set<string>(UNIQUE_VALUE_EXPERIMENT.treatment.map(slug => slug.toLowerCase()))

/** True when the Skill page `owner/repo/name` gets the experiment template. */
export function isUniqueValueTreatment(slug: string): boolean {
return TREATMENT.has(slug.toLowerCase())
}
Loading