diff --git a/COPY.md b/COPY.md index 2543378b..49292e6d 100644 --- a/COPY.md +++ b/COPY.md @@ -23,9 +23,11 @@ These exact strings. Do not paraphrase them per page. | Site description | Curated agent skills by humans, written by real maintainers in their own GitHub repos | `nuxt.config.ts` site description (feeds every page's meta description) | | Elevator pitch | Skilld is a curated registry of agent skills written by real people in the GitHub repos you already depend on. One install command, every agent; watch your stack and get a digest when it changes. | guidance only: no page ships it yet (use it where two sentences fit) | | Home H1 | Hyped agent skills, no bloat | guidance only: the homepage H1. The page draws the rose brand dot as its full stop, so never type a period | -| Home intro | Run a skill once and nothing lands on disk. Fork or install the ones you keep. Every skill comes from its maintainer's repo, and a person reads it before it's listed. | guidance only: the homepage intro under the H1 | -| Home claims line | Open-source CLI · Analytics without cookies or IPs · A skills.sh alternative | guidance only: the homepage claims line. The last item links to `/vs/skills-sh` | -| Promo label | Teach your agent skilld | guidance only: the homepage promo chip beside `npx skilld install skilld --global` | +| Home verb line | search · run · install · keep current | guidance only: the homepage line under the H1. Each verb links to its `/cli` section: `#search`, `#run`, `#install`, `#update` | +| Home CLI link | The skilld CLI → | guidance only: the homepage link to `/cli` at the end of the verb line. The arrow is an icon | +| Home caption | skilld.dev finds and tracks skills. The CLI runs or installs them in 19 agents. | guidance only: the homepage caption under the verb line. 19 is `AGENT_TARGETS` in the CLI's `crates/skilld-core/src/target.rs`. If that count changes, change this string | +| Home claims line | Open-source CLI · Analytics without cookies or IPs · A skills.sh alternative | guidance only: the homepage claims line under the search. The third item links to `/vs/skills-sh`, and the promo label closes the line | +| Promo label | Teach your agent skilld | guidance only: the last item of the homepage claims line. It opens a popover with `npx skilld install skilld --global` | | Tile: run | No more skill bloat: Run skills once off, fork, or install. | guidance only: a homepage tile, with the title before the colon. The run chip motif pairs with it | | Tile: trending | Stay hyped: What devs talk about on X and Bluesky, weekly and monthly. | guidance only: a homepage tile. The braille spark motif pairs with it | | Tile: changes | Keep updated: Watch repos and get a digest when their skills change. | guidance only: a homepage tile. The change grid motif pairs with it | diff --git a/DESIGN.md b/DESIGN.md index 6698a7f9..01bad96b 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -434,12 +434,11 @@ These are editorial sets, not necessarily database collections yet. If a set has - **Unknown install counts stay hidden**: an install value of zero can represent missing ingestion data, so cards omit the metric visually and semantically until a positive count is known. - **Task-first homepage**: orientation and outcome discovery precede editorial inventory. Confirmed in the 2026-07 homepage rethink. - **Frontend is a rotatable editorial focus**: it may dominate the current feature band but must not redefine skilld as a frontend-only registry. -- **One composition per homepage band**: split hero opening, work-track index, lead-and-supporting editorial pick, activity ledger, and contribution ledger. Repeating equal card grids weakens the page narrative. +- **One composition per homepage band**: centered hero opening, work-track index, lead-and-supporting editorial pick, activity ledger, and contribution ledger. Repeating equal card grids weakens the page narrative. - **Evidence before freshness**: show provenance, editorial rationale, and a real install command before recent activity. - **Section atmosphere**: low-chroma warm-stone and rose gradients may distinguish homepage bands. They stay masked to edges, use mode-specific opacity, and never sit behind data as a full-strength fill. - **Collections remain primary curation**: directory links may be quiet, but Collections retain stronger hierarchy than publisher taxonomy. -- **Split hero with proof rail**: the hero pairs an H1, one explanatory paragraph, and two directional CTAs with a live rail of person-authored skills. The rail is the provenance claim in evidence; inline install and search stay out of the hero. -- **The hero runs wider than the bands below it**: hero content sits in `max-w-7xl` while editorial bands stay at `max-w-5xl`, so the headline and rail get room without widening the reading measure of the page. +- **Centered quiet hero**: the hero stacks a centered H1, the CLI verb line, one caption, one calm search field, and one mono claims line. The H1 full stop is the band's only rose dot. The Braille names texture sits at the sides in stone at low strength, with a clear column behind the copy. Chosen 2026-10-06 as take C of the hero lab. - **Self-selecting work tracks**: the discovery grid asks what the visitor works on, not what the agent should do. Each track carries author avatars and a skill/author count, and empty tracks never render. - **Homepage patterns are journey primitives**: atmosphere, outcome indexes, evidence ledgers, provenance lines, and lead/supporting picks now form the reusable editorial layer. Reuse their rules on discovery routes while preserving a distinct composition for each page. - **Compact registry openings**: `/community`, `/skills`, and `/skills/trending` use `CompactPageHeader` with nothing but a page name and orientation copy; controls belong with the results they filter. Outcome pages retain plain-language mastheads. Skill detail remains dense and unchanged. diff --git a/app/assets/css/main.css b/app/assets/css/main.css index 2f8d9439..301c25c6 100644 --- a/app/assets/css/main.css +++ b/app/assets/css/main.css @@ -330,41 +330,6 @@ padding: 1.5rem; } -.home-display { - font-size: clamp(3rem, 1.6rem + 6.2vw, 6.75rem); - line-height: 1.04; -} - -/* - * Split hero: the headline shares the band with the proof rail, so it trades - * peak size for line spacing that lets three lines of display type breathe. - */ -.home-display--split { - font-size: clamp(2.75rem, 1.5rem + 4.4vw, 5.25rem); - line-height: 1.08; -} - -.home-hero-grid { - display: grid; - align-items: start; - min-width: 0; - gap: 3rem; - align-items: center; -} - -@media (min-width: 64rem) { - .home-hero-grid { - grid-template-columns: minmax(0, 1.15fr) minmax(22rem, 0.85fr); - gap: 4rem; - } -} - -@media (min-width: 80rem) { - .home-hero-grid { - gap: 5rem; - } -} - .home-section-title { font-size: clamp(2.25rem, 1.65rem + 2.6vw, 4.25rem); font-weight: 600; @@ -1029,22 +994,12 @@ background: transparent !important; } -/* Hero: compact category line, search and install slots above the fold. */ +/* Hero: the centered H1. */ .home-hero-title { font-size: clamp(2.25rem, 1.3rem + 3.4vw, 4.25rem); line-height: 1.06; } -.home-hero-slots { - display: grid; - gap: 0.75rem; - max-width: 36rem; -} - -.home-hero-slot { - min-width: 0; -} - .home-make-grid { display: grid; gap: 1rem; @@ -1205,28 +1160,6 @@ } } -/* The braille names texture: behind the hero, masked clear of the headline. - Below 64rem the copy stacks above the rail, so the names sit behind the rail. */ -.home-hero-texture { - position: absolute; - inset-inline: 0; - bottom: 0; - z-index: 0; - height: 22rem; - pointer-events: none; - mask-image: linear-gradient(to bottom, transparent, #000 40%); - -webkit-mask-image: linear-gradient(to bottom, transparent, #000 40%); -} - -@media (min-width: 64rem) { - .home-hero-texture { - top: 0; - height: auto; - mask-image: linear-gradient(to right, transparent 57%, #000 80%); - -webkit-mask-image: linear-gradient(to right, transparent 57%, #000 80%); - } -} - /* The H1 full stop is the brand dot, the hero's rose element. */ .home-hero-dot { display: inline-block; @@ -1249,14 +1182,6 @@ text-decoration-color: currentColor; } -.home-hero-promo { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0.5rem 0.75rem; - max-width: 36rem; -} - /* Trending cards: the repository link covers the card, and the run chip and the source link sit above it. */ .home-trending-card { @@ -1298,43 +1223,6 @@ outline: none; } -/* the homepage hero: named agent row under the search. Proof of "every agent". */ -.home-hero-agents { - display: flex; - flex-wrap: wrap; - column-gap: 1rem; - row-gap: 0.5rem; - font-family: var(--font-mono); - font-size: 0.75rem; - letter-spacing: 0.01em; - color: var(--ui-text-muted); -} - -.home-hero-agent { - position: relative; - display: inline-flex; - align-items: center; - gap: 0.375rem; - padding: 0.3rem 0.55rem; - border: 1px solid var(--ui-border); - border-radius: calc(var(--ui-radius) * 0.75); - background: color-mix(in oklab, var(--ui-bg) 70%, transparent); - opacity: 0.75; - transition: opacity 200ms ease-out, border-color 200ms ease-out, transform 200ms ease-out, box-shadow 200ms ease-out; -} - -/* Bloom on hover: the tile lifts and the rose glows under it. */ -.home-hero-agent:hover { - opacity: 1; - border-color: color-mix(in oklab, var(--ui-primary) 50%, transparent); - transform: translateY(-1px); - box-shadow: 0 8px 22px -12px var(--ui-primary); -} - -.home-hero-agents { - column-gap: 0.5rem; -} - /* The homepage authoring link opens the setup wizard. */ .home-make-strip { display: grid; @@ -1356,27 +1244,12 @@ } } -.home-hero-rail [class*="stream"] { - scrollbar-width: none; -} - -.home-hero-rail [class*="stream"]::-webkit-scrollbar { - display: none; -} - @media (max-width: 40rem) { .home-hero-title { font-size: 2.75rem; } } -/* Emphasis surface, one per page: the search carries a rose-tinted bevel. */ -.home-hero-search :is(input, [role="combobox"]) { - box-shadow: - inset 0 1px 0 0 color-mix(in oklab, var(--ui-primary) 22%, rgb(255 255 255 / 0.06)), - 0 1px 0 0 rgb(0 0 0 / 0.25); -} - /* Section watermarks: a big word printed in our dot grid, fading to the edges. */ .home-wm { position: relative; @@ -1474,7 +1347,7 @@ z-index: 1; } -/* Ecosystem tiles under the authoring strip. Same chip as the agent row. */ +/* Ecosystem tiles under the authoring strip. */ .home-eco-logos { display: flex; flex-wrap: wrap; @@ -1521,35 +1394,12 @@ display: none; } -/* The rail owns its scrollbar. The component's scoped rule loads last, so the - override has to name the same element. */ -.home-page .home-hero-rail .skill-source-stream { - scrollbar-width: none; - scrollbar-gutter: auto; -} - -.home-page .home-hero-rail .skill-source-stream::-webkit-scrollbar { - display: none; -} - -/* Keep the provenance preview short on phones so discovery follows sooner. */ -@media (max-width: 39.999rem) { - .home-page .home-hero-rail .skill-source-stream { - block-size: 18rem; - } -} - /* Tiles recede on their border, not on opacity. Dimmed text read as disabled. */ -.home-hero-agent, .home-eco-logo, .home-eco-logo[data-state="live"] { opacity: 1; } -.home-hero-agent { - font-size: 0.8125rem; -} - /* Two h3 steps under the heading scale, not four. */ .home-page .home-freshness-primary-title, .home-page .home-freshness-secondary-title { @@ -1558,12 +1408,6 @@ line-height: 1.2; } -/* A whole row fits inside the fade, so no row is ever half legible. */ -.home-page .home-hero-rail .skill-source-stream { - mask-image: linear-gradient(to bottom, transparent 0%, black 13%, black 90%, transparent 100%); - -webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 13%, black 90%, transparent 100%); -} - /* Short data labels were breaking across lines at 390px. */ .home-page .data-label { hyphens: none; diff --git a/app/components/HomeSearch.vue b/app/components/HomeSearch.vue index 63501e85..7df63235 100644 --- a/app/components/HomeSearch.vue +++ b/app/components/HomeSearch.vue @@ -128,11 +128,15 @@ const listboxId = computed(() => (open.value && rows.value.length ? 'skill-searc const activeDescendant = computed(() => open.value && keyboardSelection.value && activeRow.value ? `skill-search-row-${activeIndex.value}` : undefined, ) +const submitDisabled = computed(() => state.value._tag === 'repository' && state.value.status._tag !== 'idle') +const submitLabel = computed(() => (state.value._tag === 'repository' ? 'Index repository' : 'Search')) + + diff --git a/app/data/homepage-person-skills.ts b/app/data/homepage-person-skills.ts deleted file mode 100644 index a1a7280c..00000000 --- a/app/data/homepage-person-skills.ts +++ /dev/null @@ -1,151 +0,0 @@ -import type { SkillSourceItem } from '../types/skill-source' -import { canonicalRepoSkillPath } from '#shared/skill-routes' - -const fallbackSkills = [ - { - owner: 'obra', - repo: 'superpowers', - name: 'using-superpowers', - displayName: 'using-superpowers', - maintainerName: 'Jesse Vincent', - }, - { - owner: 'mattpocock', - repo: 'skills', - name: 'grill-me', - displayName: 'grill-me', - maintainerName: 'Matt Pocock', - }, - { - owner: 'garrytan', - repo: 'gstack', - name: 'retro', - displayName: 'retro', - maintainerName: 'Garry Tan', - }, - { - owner: 'pbakaus', - repo: 'impeccable', - name: 'impeccable', - displayName: 'impeccable', - maintainerName: 'Paul Bakaus', - }, - { - owner: 'kepano', - repo: 'obsidian-skills', - name: 'obsidian-bases', - displayName: 'obsidian-bases', - maintainerName: 'Steph Ango', - }, - { - owner: 'jimliu', - repo: 'baoyu-skills', - name: 'baoyu-image-gen', - displayName: 'baoyu-image-gen', - maintainerName: 'Jim Liu 宝玉', - }, - { - owner: 'ibelick', - repo: 'ui-skills', - name: 'fixing-motion-performance', - displayName: 'fixing-motion-performance', - maintainerName: 'Julien Thibeaut', - }, - { - owner: 'antfu', - repo: 'skills', - name: 'vite', - displayName: 'vite', - maintainerName: 'Anthony Fu', - }, - { - owner: 'dimillian', - repo: 'skills', - name: 'swiftui-liquid-glass', - displayName: 'swiftui-liquid-glass', - maintainerName: 'Thomas Ricouard', - }, - { - owner: 'hyf0', - repo: 'vue-skills', - name: 'vue-debug-guides', - displayName: 'vue-debug-guides', - maintainerName: 'hyf0', - }, - { - owner: 'obra', - repo: 'superpowers', - name: 'writing-plans', - displayName: 'writing-plans', - maintainerName: 'Jesse Vincent', - }, - { - owner: 'mattpocock', - repo: 'skills', - name: 'improve-codebase-architecture', - displayName: 'improve-codebase-architecture', - maintainerName: 'Matt Pocock', - }, - { - owner: 'garrytan', - repo: 'gstack', - name: 'autoplan', - displayName: 'autoplan', - maintainerName: 'Garry Tan', - }, - { - owner: 'kepano', - repo: 'obsidian-skills', - name: 'json-canvas', - displayName: 'json-canvas', - maintainerName: 'Steph Ango', - }, - { - owner: 'jimliu', - repo: 'baoyu-skills', - name: 'baoyu-markdown-to-html', - displayName: 'baoyu-markdown-to-html', - maintainerName: 'Jim Liu 宝玉', - }, - { - owner: 'ibelick', - repo: 'ui-skills', - name: 'fixing-accessibility', - displayName: 'fixing-accessibility', - maintainerName: 'Julien Thibeaut', - }, - { - owner: 'antfu', - repo: 'skills', - name: 'vitest', - displayName: 'vitest', - maintainerName: 'Anthony Fu', - }, - { - owner: 'dimillian', - repo: 'skills', - name: 'swiftui-performance-audit', - displayName: 'swiftui-performance-audit', - maintainerName: 'Thomas Ricouard', - }, - { - owner: 'hyf0', - repo: 'vue-skills', - name: 'vue-best-practices', - displayName: 'vue-best-practices', - maintainerName: 'hyf0', - }, - { - owner: 'addyosmani', - repo: 'web-quality-skills', - name: 'performance', - displayName: 'performance', - maintainerName: 'Addy Osmani', - }, -] as const - -export const homepagePersonSkillFallbacks: readonly SkillSourceItem[] = fallbackSkills.map(skill => ({ - ...skill, - // Every curated fallback repository holds several Skills. - registryPath: canonicalRepoSkillPath({ ...skill, repoSkillCount: 2 }), -})) diff --git a/app/pages/index.vue b/app/pages/index.vue index 79adb9f3..70780694 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -2,20 +2,10 @@ import type { RecentPublishesResponse } from '~~/server/api/feed/recent-publishes.get' import type { RecentUpdateCard, RecentUpdatesResponse } from '~~/server/api/feed/recent-updates.get' import type { TrendingFeedItem, TrendingFeedResponse } from '~~/server/api/feed/trending.get' -import type { SkillSourceItem } from '../types/skill-source' -import type { FeaturedPersonSection } from '../utils/homepage-person-skills' import { WRITING_COMPARISON_LINK } from '#shared/comparison-navigation' import { avatarProxyUrl, githubAvatarProxyUrl } from '#shared/image-proxy' import { TRENDING_RANGES } from '#shared/trending-range' -import { AGENT_LOGOS } from '~/utils/agent-logos' import OutcomeClusterGrid from '../components/OutcomeClusterGrid.vue' -import { homepagePersonSkillFallbacks } from '../data/homepage-person-skills' -import { - HOMEPAGE_PERSON_MINIMUM, - selectHomepagePersonSkills, - selectHomepageTrendingSkills, - uniqueByOwner, -} from '../utils/homepage-person-skills' const title = 'Taste-tested agent skills ecosystem · skilld' const description = 'Agent skills written by their maintainers and read by a person before they go in. See what devs are sharing this week, find what your agent needs, and keep up when it changes.' @@ -77,8 +67,8 @@ const [ // the edge for 5 minutes, so it costs a cache read rather than a query. withHomeDataTiming('home-trending', useFetch('/api/feed/trending', { key: 'home-trending-v2', - // The hero rail shows one skill per author, so it needs more repos than - // the six-card section below it. + // The hero texture prints Skill names from every repo it gets, so it asks + // for more repos than the six-card section below it. query: { limit: 24 }, // Repositories only. The named Skills, with their posts and star series, // serve `/skills/trending` and would ride in this page's payload unread. @@ -95,7 +85,7 @@ const recentUpdates = computed(() => updatesData.value?.items ?? []) const recentPublishes = computed(() => publishesData.value?.items ?? []) const trendingRepos = computed(() => trendingData.value?.items ?? []) -/** The section below the fold stays a six-card grid whatever the rail uses. */ +/** The section below the fold stays a six-card grid whatever the texture uses. */ const trendingSectionRepos = computed(() => trendingRepos.value.slice(0, 6)) /** @@ -128,73 +118,16 @@ const heroTextureNames = computed(() => trendingRepos.value.flatMap(repo => repo.skills.map(skill => `${repo.owner}/${repo.repo}/${skill.name}`)), ) -interface FeaturedPeopleResponse { - devSections: FeaturedPersonSection[] -} - -// The hero rail is decoration on top of the headline, so it loads after -// hydration and falls back to a hand-picked set when the live data is thin. -const { data: peopleSkillsData, execute: loadPeopleSkills } = await useFetch('/api/skills/featured', { - key: 'home-person-skills-v1', - query: { orgs: 0, perOrg: 1, devs: 24, perDev: 3 }, - server: false, - lazy: true, - immediate: false, -}) - -const fallbackPersonNamesByOwner = new Map( - homepagePersonSkillFallbacks.map(skill => [skill.owner, skill.maintainerName ?? skill.owner]), -) - -const heroSkillCards = computed(() => { - const liveSkills = selectHomepagePersonSkills( - peopleSkillsData.value?.devSections ?? [], - fallbackPersonNamesByOwner, - ) - - return liveSkills.length >= HOMEPAGE_PERSON_MINIMUM - ? liveSkills - : uniqueByOwner(homepagePersonSkillFallbacks) -}) - /** - * Hero rail contents: one skill per author from this week's trending repos, - * padded with the evergreen set so the stream stays deep enough to scroll. - * - * Flattened from repos to skills because the rail shows one skill per card, - * while the section further down shows repos with their quoted evidence. The - * two are different granularities of the same signal rather than a duplicate. - * - * Falls back to the curated person-authored rail when trending is too thin. - * The hero is the first thing anyone sees, so an empty or one-card rail there - * is worse than showing the evergreen set. + * The CLI's own line, "Search, run, install, and keep them current", as doors + * into the sections of the /cli page. */ -const homepageTrendingSelection = computed(() => selectHomepageTrendingSkills( - trendingRepos.value.flatMap(repo => - repo.skills.map(skill => ({ - owner: repo.owner, - repo: repo.repo, - name: skill.name, - displayName: skill.displayName, - registryPath: skill.registryPath, - description: skill.description, - context: trendingShareLabel(repo.authorCount), - })), - ), - homepagePersonSkillFallbacks, -)) - -const heroTrendingCards = computed(() => { - const selection = homepageTrendingSelection.value - return selection._tag === 'trending' ? selection.items : heroSkillCards.value -}) - -const heroShowsTrending = computed(() => homepageTrendingSelection.value._tag === 'trending') - -onMounted(() => { - if (homepageTrendingSelection.value._tag === 'fallback') - void loadPeopleSkills() -}) +const heroVerbs = [ + { label: 'search', to: '/cli#search' }, + { label: 'run', to: '/cli#run' }, + { label: 'install', to: '/cli#install' }, + { label: 'keep current', to: '/cli#update' }, +] as const /** * Track order, measured 2026-09-04 rather than assumed. @@ -247,9 +180,6 @@ const authoringEcosystems = [ */ const trendingDoorTarget = computed(() => (showTrending.value ? '#trending' : '/skills/trending')) -// Named agent row under the search: proof of "every agent". -const heroAgentLogos = AGENT_LOGOS - const renderNow = useState('render:now', () => Number(new Date())) function formatRelative(ts: number): string { @@ -298,72 +228,68 @@ function recentUpdateDescription(item: RecentUpdateCard): string { + + diff --git a/app/utils/homepage-person-skills.ts b/app/utils/homepage-person-skills.ts deleted file mode 100644 index 7df88ba6..00000000 --- a/app/utils/homepage-person-skills.ts +++ /dev/null @@ -1,142 +0,0 @@ -import type { SkillSourceItem } from '../types/skill-source' -import { formatGithubStars } from './github-stars' - -/** Ceiling on the hero rail; twenty rows fill the stream without ten extra avatars. */ -export const HOMEPAGE_SKILL_LIMIT = 20 -/** - * Below this many separate authors the trending feed is too thin to lead the - * rail, and the evergreen person-authored set takes over instead. - */ -export const HOMEPAGE_TRENDING_MINIMUM = 6 -/** Below this many people the live person feed loses to the hand-picked fallbacks. */ -export const HOMEPAGE_PERSON_MINIMUM = 10 - -export interface FeaturedPersonSkill { - owner: string - repo: string - name: string - displayName: string - description: string | null - stars: number - registryPath: string -} - -export interface FeaturedPersonSection { - owner: string - repo: string - displayName: string - skills: FeaturedPersonSkill[] -} - -export type HomepageTrendingSelection - = | { _tag: 'trending', items: readonly SkillSourceItem[] } - | { _tag: 'fallback' } - -/** - * One skill per author, first occurrence wins. - * - * The rail is a list of people as much as a list of skills: a repository with - * ten skills used to fill ten consecutive rows with the same face, which read - * as one author dominating the week rather than as ten things worth reading. - */ -export function uniqueByOwner(items: readonly SkillSourceItem[]): SkillSourceItem[] { - const seen = new Set() - const result: SkillSourceItem[] = [] - for (const item of items) { - if (seen.has(item.owner)) - continue - seen.add(item.owner) - result.push(item) - } - return result -} - -/** - * Hero rail from the trending feed, one skill per author, padded with the - * evergreen fallbacks so the stream stays deep enough to scroll. - * - * `fallback` when fewer than HOMEPAGE_TRENDING_MINIMUM authors are trending: - * the caller then loads the live person feed instead. - */ -export function selectHomepageTrendingSkills( - trending: readonly Omit[], - fallbacks: readonly SkillSourceItem[], -): HomepageTrendingSelection { - const namesByOwner = new Map(fallbacks.map(item => [item.owner, item.maintainerName ?? item.owner])) - const unique = uniqueByOwner(trending.map(item => ({ - ...item, - maintainerName: namesByOwner.get(item.owner) ?? item.owner, - }))) - if (unique.length < HOMEPAGE_TRENDING_MINIMUM) - return { _tag: 'fallback' } - - const owners = new Set(unique.map(item => item.owner)) - const padding = uniqueByOwner(fallbacks).filter(item => !owners.has(item.owner)) - return { _tag: 'trending', items: [...unique, ...padding].slice(0, HOMEPAGE_SKILL_LIMIT) } -} - -interface PersonSkillBucket { - owner: string - displayName: string - skills: FeaturedPersonSkill[] -} - -/** The live person feed as rail rows: each person's most-starred skill, one row each. */ -export function selectHomepagePersonSkills( - sections: readonly FeaturedPersonSection[], - fallbackNamesByOwner: ReadonlyMap, -): SkillSourceItem[] { - const buckets = new Map() - const seenSkills = new Set() - - for (const section of sections) { - const existing = buckets.get(section.owner) - const bucket = existing ?? { - owner: section.owner, - displayName: section.displayName, - skills: [], - } - - if (bucket.displayName === bucket.owner && section.displayName !== section.owner) - bucket.displayName = section.displayName - - for (const skill of section.skills) { - const key = `${skill.owner}/${skill.repo}/${skill.name}` - if (skill.name === 'skill' || seenSkills.has(key)) - continue - - seenSkills.add(key) - bucket.skills.push(skill) - } - - if (!existing) - buckets.set(section.owner, bucket) - } - - const result: SkillSourceItem[] = [] - - for (const person of buckets.values()) { - const skill = [...person.skills] - .sort((left, right) => right.stars - left.stars || left.name.localeCompare(right.name))[0] - if (!skill) - continue - - result.push({ - owner: skill.owner, - repo: skill.repo, - name: skill.name, - displayName: skill.displayName, - registryPath: skill.registryPath, - maintainerName: person.displayName !== person.owner - ? person.displayName - : fallbackNamesByOwner.get(person.owner) ?? person.owner, - description: skill.description, - context: skill.stars > 0 ? `${formatGithubStars(skill.stars)} GitHub stars` : null, - }) - - if (result.length === HOMEPAGE_SKILL_LIMIT) - break - } - - return result -} diff --git a/test/unit/home-search.test.ts b/test/unit/home-search.test.ts index 14c00d91..746c3830 100644 --- a/test/unit/home-search.test.ts +++ b/test/unit/home-search.test.ts @@ -87,16 +87,20 @@ describe('homepage search interactions', () => { expect(navigate).toHaveBeenCalledWith(path) }) - it('uses the Search button for the full result page even after arrow selection', async () => { + it('uses the submit control for the full result page even after arrow selection', async () => { const wrapper = await mountSearch() await wrapper.get('input').trigger('keydown', { key: 'ArrowDown' }) - const button = wrapper.findAll('button').find(button => button.text().trim() === 'Search') - expect(button, 'Search button must submit the full query').toBeDefined() - await button!.trigger('click') + await wrapper.get('button[aria-label="Search"]').trigger('click') await flushPromises() expect(navigate).toHaveBeenCalledWith({ path: '/skills', query: { q: 'vue testing' } }) }) + it('offers no submit control until there is a query', async () => { + query.value = ' ' + const wrapper = await mountSearch() + expect(wrapper.get('button[type="submit"]').isVisible()).toBe(false) + }) + it('reopens the panel when the already focused input is clicked', async () => { const wrapper = await mountSearch() const input = wrapper.get('input') @@ -166,9 +170,7 @@ describe('homepage search interactions', () => { await wrapper.get('input').trigger('keydown', { key: 'Enter' }) } else { - const button = wrapper.findAll('button').find(button => button.text().trim() === 'Index repository') - expect(button).toBeDefined() - await button!.trigger('click') + await wrapper.get('button[aria-label="Index repository"]').trigger('click') } await flushPromises() expect(submitRepository).toHaveBeenCalledExactlyOnceWith(repository) @@ -181,10 +183,9 @@ describe('homepage search interactions', () => { rows.value = [{ _tag: 'repository', repository }] stateOverride.value = { _tag: 'repository', repository, status: { _tag: 'pending', progress: { _tag: 'queued' } }, rows: rows.value } const wrapper = await mountSearch() - const button = wrapper.findAll('button').find(button => button.text().trim() === 'Index repository') - expect(button).toBeDefined() - expect(button!.attributes('disabled')).toBeDefined() - await button!.trigger('click') + const button = wrapper.get('button[aria-label="Index repository"]') + expect(button.attributes('disabled')).toBeDefined() + await button.trigger('click') await wrapper.get('input').trigger('keydown', { key: 'Enter' }) await flushPromises() expect(submitRepository).not.toHaveBeenCalled() diff --git a/test/unit/skill-source-list.test.ts b/test/unit/skill-source-list.test.ts index 478e5a8d..9519be98 100644 --- a/test/unit/skill-source-list.test.ts +++ b/test/unit/skill-source-list.test.ts @@ -1,13 +1,11 @@ import { mountSuspended } from '@nuxt/test-utils/runtime' import { describe, expect, it } from 'vitest' import { nextTick } from 'vue' -import { homepagePersonSkillFallbacks } from '../../app/data/homepage-person-skills' -import { - HOMEPAGE_SKILL_LIMIT, - HOMEPAGE_TRENDING_MINIMUM, - selectHomepagePersonSkills, - selectHomepageTrendingSkills, -} from '../../app/utils/homepage-person-skills' + +const sourceItems = [ + { owner: 'antfu', repo: 'skills', name: 'vite', displayName: 'vite', registryPath: '/gh/antfu/skills/vite', maintainerName: 'Anthony Fu' }, + { owner: 'addyosmani', repo: 'agent-skills', name: 'api-and-interface-design', displayName: 'api-and-interface-design', registryPath: '/gh/addyosmani/agent-skills/api-and-interface-design', maintainerName: 'Addy Osmani' }, +] describe('skill source list', () => { it('links to the canonical Skill path supplied by the server', async () => { @@ -32,94 +30,12 @@ describe('skill source list', () => { wrapper.unmount() }) - it('ships twenty person-authored homepage fallbacks', () => { - expect(homepagePersonSkillFallbacks).toHaveLength(20) - expect(new Set(homepagePersonSkillFallbacks.map(skill => skill.owner)).size).toBeGreaterThanOrEqual(10) - expect([...Map.groupBy(homepagePersonSkillFallbacks, skill => skill.owner).values()] - .every(skills => skills.length <= 2)).toBe(true) - expect(homepagePersonSkillFallbacks.every(skill => skill.maintainerName)).toBe(true) - expect(homepagePersonSkillFallbacks.every(skill => skill.displayName === skill.name)).toBe(true) - }) - - it('shows one skill per person from the live feed', () => { - const sections = Array.from({ length: 12 }, (_, personIndex) => ({ - owner: `person-${personIndex}`, - repo: 'skills', - displayName: `Person ${personIndex}`, - skills: Array.from({ length: 5 }, (_, skillIndex) => ({ - owner: `person-${personIndex}`, - repo: 'skills', - name: `skill-${skillIndex}`, - displayName: `Skill ${skillIndex}`, - description: null, - stars: 100 - skillIndex, - registryPath: `/gh/person-${personIndex}/skills/skill-${skillIndex}`, - })), - })) - const selected = selectHomepagePersonSkills(sections, new Map()) - - expect(selected).toHaveLength(12) - expect(new Set(selected.map(skill => skill.owner)).size).toBe(12) - // The most-starred skill represents each person. - expect(selected.every(skill => skill.name === 'skill-0')).toBe(true) - }) - - it('keeps one skill per author in the trending rail and pads it with fallbacks', () => { - // Three authors with five skills each, the shape a multi-skill repository produces. - const items = Array.from({ length: 45 }, (_, index) => ({ - ...homepagePersonSkillFallbacks[0]!, - owner: `author-${index % 9}`, - name: `trending-${index}`, - displayName: `Trending ${index}`, - })) - - const selection = selectHomepageTrendingSkills(items, homepagePersonSkillFallbacks) - expect(selection._tag).toBe('trending') - if (selection._tag !== 'trending') - return - - const owners = selection.items.map(skill => skill.owner) - expect(new Set(owners).size).toBe(owners.length) - expect(owners.slice(0, 9)).toEqual(Array.from({ length: 9 }, (_, index) => `author-${index}`)) - expect(selection.items.length).toBeGreaterThan(9) - expect(selection.items.length).toBeLessThanOrEqual(HOMEPAGE_SKILL_LIMIT) - }) - - it('asks for fallback skills when too few authors are trending', () => { - const items = Array.from({ length: 30 }, (_, index) => ({ - ...homepagePersonSkillFallbacks[0]!, - owner: `author-${index % (HOMEPAGE_TRENDING_MINIMUM - 1)}`, - name: `trending-${index}`, - })) - - expect(selectHomepageTrendingSkills(items, homepagePersonSkillFallbacks)).toEqual({ _tag: 'fallback' }) - }) - - it('attributes trending skills to repository maintainers', () => { - const items = Array.from({ length: HOMEPAGE_TRENDING_MINIMUM }, (_, index) => ({ - owner: index === 0 ? 'addyosmani' : `author-${index}`, - repo: 'skills', - name: 'api-and-interface-design', - displayName: 'API and interface design', - registryPath: `/gh/${index === 0 ? 'addyosmani' : `author-${index}`}/skills/api-and-interface-design`, - maintainerName: 'Yarchi', - })) - - const selection = selectHomepageTrendingSkills(items, homepagePersonSkillFallbacks) - expect(selection._tag).toBe('trending') - if (selection._tag !== 'trending') - return - - expect(selection.items[0]?.maintainerName).toBe('Addy Osmani') - expect(selection.items[1]?.maintainerName).toBe('author-1') - }) - it('preserves a focused item until live data can replace it safely', async () => { const container = document.createElement('div') const outsideButton = document.createElement('button') document.body.append(container, outsideButton) - const initialItems = homepagePersonSkillFallbacks.slice(0, 2) + const initialItems = sourceItems const liveItems = initialItems.map((item, index) => ({ ...item, name: `live-skill-${index}`, @@ -164,7 +80,7 @@ describe('skill source list', () => { await import('../../app/components/SkillSourceList.vue').then(module => module.default), { props: { - items: homepagePersonSkillFallbacks.slice(0, 2), + items: sourceItems, variant: 'stream', ariaLabel: 'Person-authored skills', },