diff --git a/DESIGN.md b/DESIGN.md index c8f33823..102999c1 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -183,6 +183,13 @@ Use `UiTooltip` (not `UTooltip`) for any tooltip that needs more than a plain st - **Data labels**: top-level metrics are singular and labeled ("12 skills", "3 curators"); expanded views show the full breakdown ### Bound Components +- **Skill embeds**: every Skill shown as an item uses ``. Do not hand-write Skill markup. Props fit it to the context: + - `layout`: `card` for a grid tile or an article embed, `row` for a ledger line, `compact` for a sidebar or an index. + - `byline`: `full` by default, `repo` where the page names the owner, `none` where it names the Repository. + - `metric`: one of `stars`, `likes` or `updated`, or `none`. + - `actions`: `run` by default. `like` and `source` are opt-in. + - `rank`, `note` and `trending`, plus the `meta`, `footer`, `actions` and `aside` slots for facts only one context has. + `/_playground/skill-cards` renders every context in development. - **Skill source lists**: use `SkillSourceList` when a shortlist needs visible author, handle, repository, and source-linked skill name. `stream` is a vertically scrollable discovery preview; `grid` is for bounded related-skill groups. Idle auto-scroll pauses for hover, focus, touch, and reduced motion. Mixed 20-item discovery streams should represent at least 10 people and cap each person at 2 skills. - **Install commands**: never render one as raw text. Use `` so `npx skilld add` stays quiet and the target carries the accent. Long commands wrap (`wrap`); they never get a horizontal scrollbar inside a card. @@ -462,3 +469,4 @@ These are editorial sets, not necessarily database collections yet. If a set has - **Trending rows scan in one pass**: the rank is a plain mono number, and the first three are a step darker. Each metadata line runs source, stars, then the signal that ranked the row, and an empty slot renders nothing. Descriptions stop at two lines, the run chip is as wide as its command, and the post carousel shows whole cards only. The sidebar invitation drops the email preview and the card. Its button is the rail's one solid rose element, so the install chip beside it inks its dot. Harlan asked for this polish on 2026-10-06. - **A six-step lifecycle band replaces the four tiles**: under the hero, one band maps the whole product in its real order: find, run, install or fork, keep current, make, build on. The site and the CLI share one line. Each step is one link: a mono `01` marker and step name, then one sans line that opens with its pitch line where COPY.md has one. A line of stone dots joins the steps. Run, the default, carries the band's one rose dot, so no step lights rose on hover. Six columns from 1024px; below that a vertical list with the dots down the left. Keep the band no taller than the tiles it replaced: the hero and the band must fit a 1440×900 viewport. Harlan approved it on 2026-10-06. - **A Developers menu groups the build surfaces**: the desktop header keeps Trending Skills and Find Skills as links, and folds CLI, MCP server, SDK, Make a skill and Skillgen into one Developers menu, as nuxtseo.com does. The trigger is a button, so a keyboard opens the panel without leaving the page; the panel's last link reaches `/developers`. Each item is a card with a lucide icon tile and one `.editorial-atmosphere` layer. The three ways to connect share the top row, and the two authoring cards share the row below. CLI takes rose because it is the recommended path; the others take ember or stone. The mobile menu lists the same five items as a plain group. Harlan asked for this on 2026-10-06. +- **One Skill card**: `SkillCard` renders every Skill embed, in three layouts. Each opens with one identity unit: the avatar beside the mono name, over a byline of author and Repository. Then come at most two lines of description and one line of facts with a dashed `run` pill. The pill is the run chip's material without `npx skilld`. Its click copies the full command, and its dot stays stone until hover, focus or copy. Rows read in three zones, who, what and the facts, and stack like a borderless card under 40rem. Compact entries drop the description. Cards show no SKILL.md icon and no heart by default: with few accounts yet, a row of zero hearts reads as an empty room. Harlan chose this from five takes, one by Fable, on 2026-10-06. It supersedes the `/skills` table and its "What it does" column. Trending and track boards render the same rows: the reason a Skill ranked fills the meta line, and the posts about it sit under its words as an aside. The home freshness ledgers and the community top Skill use compact entries. Search results keep their own rows, because they are a keyboard picker, not an embed. diff --git a/app/components/SkillCard.vue b/app/components/SkillCard.vue index 0afedbbb..3388729a 100644 --- a/app/components/SkillCard.vue +++ b/app/components/SkillCard.vue @@ -1,353 +1,563 @@ + + diff --git a/app/components/SkillTable.vue b/app/components/SkillTable.vue deleted file mode 100644 index 72dd41e9..00000000 --- a/app/components/SkillTable.vue +++ /dev/null @@ -1,306 +0,0 @@ - - - - - diff --git a/app/components/community/_CommunityCreator.vue b/app/components/community/_CommunityCreator.vue index 3594ef54..6ccb029e 100644 --- a/app/components/community/_CommunityCreator.vue +++ b/app/components/community/_CommunityCreator.vue @@ -17,13 +17,6 @@ const contributionSummary = computed(() => [ ? `${creator.skillCount} ${creator.skillCount === 1 ? 'skill' : 'skills'}` : null, ].filter(Boolean).join(' · ')) - -function formatStars(stars: number): string { - return new Intl.NumberFormat('en', { - notation: stars >= 1000 ? 'compact' : 'standard', - maximumFractionDigits: 1, - }).format(stars) -} @@ -249,7 +223,7 @@ function formatStars(stars: number): string { } @media (hover: hover) { - .community-contribution:hover { + .community-contribution:not(.community-contribution--skill):hover { background: var(--ui-bg-muted); } } diff --git a/app/components/skill-card/_SkillCardIdentity.vue b/app/components/skill-card/_SkillCardIdentity.vue new file mode 100644 index 00000000..2bdc4edb --- /dev/null +++ b/app/components/skill-card/_SkillCardIdentity.vue @@ -0,0 +1,181 @@ + + + + + diff --git a/app/components/skill-card/_SkillCardMetric.vue b/app/components/skill-card/_SkillCardMetric.vue new file mode 100644 index 00000000..5100510e --- /dev/null +++ b/app/components/skill-card/_SkillCardMetric.vue @@ -0,0 +1,40 @@ + + + + + diff --git a/app/components/skill-card/_SkillCardRun.vue b/app/components/skill-card/_SkillCardRun.vue new file mode 100644 index 00000000..e267a3ca --- /dev/null +++ b/app/components/skill-card/_SkillCardRun.vue @@ -0,0 +1,148 @@ + + + + + diff --git a/app/pages/@[login]/[slug].vue b/app/pages/@[login]/[slug].vue index b546c9c4..fe46c260 100644 --- a/app/pages/@[login]/[slug].vue +++ b/app/pages/@[login]/[slug].vue @@ -61,23 +61,6 @@ defineOgImage('Collection.takumi', { }, { alt: () => `${collectionOgProps.value?.name ?? 'Collection'} by @${login.value} on skilld`, }) - -function collectionSkillPath(skill: { registryPath: string }) { - return skill.registryPath -} - -function collectionSkillLabel(skill: { repo: string, name?: string | null }) { - return skill.name ? `/${skill.name}` : skill.repo -} - -function collectionSkillTitle(skill: { repo: string, name?: string | null, displayName?: string | null }) { - return skill.displayName || collectionSkillLabel(skill) -} - -function collectionSkillMeta(skill: { owner: string, repo: string, name?: string | null }) { - const source = `${skill.owner}/${skill.repo}` - return skill.name ? `${collectionSkillLabel(skill)} · ${source}` : source -} diff --git a/layers/marketing/app/components/content/ComparisonSkillCard.vue b/layers/marketing/app/components/content/ComparisonSkillCard.vue index caaf0c56..92794886 100644 --- a/layers/marketing/app/components/content/ComparisonSkillCard.vue +++ b/layers/marketing/app/components/content/ComparisonSkillCard.vue @@ -14,28 +14,18 @@ const skill = computed(() => ({ repo, name, registryPath: to, - slug: `${owner}/${repo}/${name}`, description, skillFileUrl: source, })) - - diff --git a/layers/marketing/app/pages/skills/best.vue b/layers/marketing/app/pages/skills/best.vue index 9187b067..abf1945c 100644 --- a/layers/marketing/app/pages/skills/best.vue +++ b/layers/marketing/app/pages/skills/best.vue @@ -1,6 +1,4 @@ - - diff --git a/layers/registry/app/pages/gh/[owner]/[repo]/_RepoSkillDependencies.vue b/layers/registry/app/pages/gh/[owner]/[repo]/_RepoSkillDependencies.vue new file mode 100644 index 00000000..b883a478 --- /dev/null +++ b/layers/registry/app/pages/gh/[owner]/[repo]/_RepoSkillDependencies.vue @@ -0,0 +1,38 @@ + + + diff --git a/layers/registry/app/pages/gh/[owner]/[repo]/index.vue b/layers/registry/app/pages/gh/[owner]/[repo]/index.vue index 48e86012..0fccf830 100644 --- a/layers/registry/app/pages/gh/[owner]/[repo]/index.vue +++ b/layers/registry/app/pages/gh/[owner]/[repo]/index.vue @@ -7,7 +7,7 @@ import { entityRobots } from '#shared/entity-robots' import { avatarProxyUrl } from '#shared/image-proxy' import { resolveMissingRepoRedirect } from '../../../../utils/missing-repo-recovery' import { parseRepoSkillSort, REPO_SKILL_SORT_OPTIONS, sortRepoSkills } from '../../../../utils/repo-skill-layout' -import RepoSkillCard from './_RepoSkillCard.vue' +import RepoSkillDependencies from './_RepoSkillDependencies.vue' import RepoSparkline from './_RepoSparkline.vue' const route = useRoute() @@ -611,11 +611,17 @@ useHead(computed(() => ({ v-else-if="sortedRepoSkills.length && !groupedSkills" class="grid gap-3" > - + :skill + byline="none" + metric="updated" + > + +
({
- + :skill + byline="none" + metric="updated" + > + +
diff --git a/layers/registry/app/pages/gh/[owner]/index.vue b/layers/registry/app/pages/gh/[owner]/index.vue index 7213e782..39c77738 100644 --- a/layers/registry/app/pages/gh/[owner]/index.vue +++ b/layers/registry/app/pages/gh/[owner]/index.vue @@ -533,7 +533,7 @@ useSchemaOrg(computed(() => { v-for="skill in skillsByRepo.get(repo.repo) ?? []" :key="skill.slug" > - + diff --git a/shared/track-board.ts b/shared/track-board.ts index 426cefb8..14c34a74 100644 --- a/shared/track-board.ts +++ b/shared/track-board.ts @@ -145,6 +145,8 @@ function talkedRow(skill: TrackTalkedInput, clockSeconds: number): TrendingBoard return { key: skill.registryPath, owner: skill.owner, + repo: skill.repo, + name: skill.name, title: skill.canonicalName, to: skill.registryPath, subtitle: `${skill.owner}/${skill.repo}`, @@ -166,6 +168,8 @@ function memberRow(member: TrackMemberInput): TrendingBoardRow { return { key: member.registryPath, owner: member.owner, + repo: member.repo, + name: member.name, title: member.displayName || member.name, to: member.registryPath, subtitle: `${member.owner}/${member.repo}`, diff --git a/shared/trending-range.ts b/shared/trending-range.ts index 28665657..2ab13a5f 100644 --- a/shared/trending-range.ts +++ b/shared/trending-range.ts @@ -369,6 +369,10 @@ export interface TrendingBoardRow { key: string /** Whose avatar the row shows. */ owner: string + /** The Repository the row's Skill lives in. */ + repo: string + /** The Skill directory name the row links to, the last segment of `owner/repo/name`. */ + name: string /** The row's primary link text. */ title: string /** Where the primary link goes. */ @@ -490,6 +494,8 @@ export function leaderboardBoardRows( return { key: `${item.owner}/${item.repo}/${item.topSkill.name}`, owner: item.owner, + repo: item.repo, + name: item.topSkill.name, title: item.topSkill.name, to: item.topSkill.registryPath, subtitle: `${item.owner}/${item.repo}`, diff --git a/test/a11y.spec.ts b/test/a11y.spec.ts index eeb3474f..4664e5cd 100644 --- a/test/a11y.spec.ts +++ b/test/a11y.spec.ts @@ -235,24 +235,27 @@ describe('accessibility: components', () => { wrapper.unmount() }) - it('skillTable has no violations', async () => { + it.each(['card', 'row', 'compact'] as const)('skillCard has no violations as a %s', async (layout) => { const container = createIsolatedContainer() const wrapper = await mountSuspended( - await loadComponent('SkillTable'), + await loadComponent('SkillCard'), { attachTo: container, props: { - skills: [{ + skill: { owner: 'antfu', repo: 'skills', name: 'vite', - slug: 'antfu/vite', + registryPath: '/gh/antfu/skills/vite', description: 'Vite configuration conventions.', stars: 1200, - modifiedAt: 1_760_000_000, + authorName: 'Anthony Fu', official: true, - }], - ariaLabel: 'All skills', + }, + layout, + rank: 1, + note: 'The one to start with.', + actions: ['run', 'source'], }, }, ) @@ -683,7 +686,9 @@ describe('accessibility: component coverage', () => { 'OgBrand', // OG image component, rendered server-side only 'OgLayout', // OG image layout component, rendered server-side only 'OgLines', // OG image text component, rendered server-side only - 'SkillCard', // Tested at page level + 'skill-card/_SkillCardIdentity', // A part of SkillCard, scanned inside every SkillCard test + 'skill-card/_SkillCardMetric', // A part of SkillCard, scanned inside every SkillCard test + 'skill-card/_SkillCardRun', // A part of SkillCard, scanned inside every SkillCard test 'SkillReceiptsBadge', // Tested at page level 'StatsBars', // Decorative chart, tested at page level 'StatsHBar', // Decorative chart, tested at page level @@ -710,7 +715,7 @@ describe('accessibility: component coverage', () => { it('records only the components an axe test actually mounted', () => { expect([...testedComponents]).toContain('AgentTargets') - expect([...testedComponents]).not.toContain('SkillCard') + expect([...testedComponents]).not.toContain('SkillReceiptsBadge') }) it('covers every component with an axe test or a documented skip', () => { diff --git a/test/unit/community-creator-ui.test.ts b/test/unit/community-creator-ui.test.ts index 9257ae03..dd2f1d5d 100644 --- a/test/unit/community-creator-ui.test.ts +++ b/test/unit/community-creator-ui.test.ts @@ -134,33 +134,13 @@ describe('community creator collection preview', () => { }) describe('community creator top skill', () => { - it('links the most popular skill to its canonical page with compact star counts', async () => { + it('links the most popular skill to its canonical page, with its Repository and stars', async () => { const wrapper = await mountCreator() - const link = wrapper.get('a[href="/gh/antfu/skills/nuxt"]') - expect(link.text()).toContain('Nuxt') - expect(link.text()).toContain('antfu/skills') - expect(link.text()).toContain('1.2K GitHub stars') - - wrapper.unmount() - }) - - it('uses the singular star unit and an uncompacted count below a thousand', async () => { - const wrapper = await mountCreator({ - topSkill: { ...creatorFixture().topSkill!, stars: 1 }, - }) - - expect(wrapper.text()).toContain('1 GitHub star') - - wrapper.unmount() - }) - - it('falls back to the raw skill name when it has no display name', async () => { - const wrapper = await mountCreator({ - topSkill: { ...creatorFixture().topSkill!, displayName: null }, - }) - - expect(wrapper.get('a[href="/gh/antfu/skills/nuxt"]').text()).toContain('nuxt') + const section = wrapper.get('section[aria-label="Most starred skill by Harlan Wilton"]') + expect(section.get('a[href="/gh/antfu/skills/nuxt"]').text()).toBe('/nuxt') + expect(section.text()).toContain('skills') + expect(section.text()).toContain('1.2k GitHub stars') wrapper.unmount() }) diff --git a/test/unit/install-copy.test.ts b/test/unit/install-copy.test.ts index cd6fd5ed..d3bffc65 100644 --- a/test/unit/install-copy.test.ts +++ b/test/unit/install-copy.test.ts @@ -50,15 +50,14 @@ describe('install copy', () => { await import('../../app/components/SkillCard.vue').then(module => module.default), { props: { - skill: { owner: 'antfu', repo: 'skills', name: 'vite', slug: 'antfu/vite', stars: 12 }, - showLike: false, + skill: { owner: 'antfu', repo: 'skills', name: 'vite', registryPath: '/gh/antfu/skills/vite', stars: 12 }, }, }, ) const copyButton = wrapper.findAll('button') - .find(button => button.attributes('aria-label')?.includes('Copy run command')) - expect(copyButton, 'skill card copy button missing its run-command label').toBeTruthy() + .find(button => button.text().includes('Copy run command for /vite')) + expect(copyButton, 'skill card run pill missing its accessible name').toBeTruthy() await copyButton!.trigger('click') await flushPromises() diff --git a/test/unit/repo-skill-dependencies-card.test.ts b/test/unit/repo-skill-dependencies-card.test.ts deleted file mode 100644 index 0ed386d0..00000000 --- a/test/unit/repo-skill-dependencies-card.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { mountSuspended } from '@nuxt/test-utils/runtime' -import { describe, expect, it } from 'vitest' -import RepoSkillCard from '../../layers/registry/app/pages/gh/[owner]/[repo]/_RepoSkillCard.vue' - -const baseSkill = { - owner: 'antfu', - repo: 'skills', - name: 'nuxt', - description: 'Build full-stack Vue applications with Nuxt.', - modifiedAt: 1_700_000_000, -} - -// LikeButton owns its own auth and fetch wiring, and is not what this card decides. -// UTooltip needs a TooltipProvider ancestor that only the app shell installs; the -// stub keeps its text observable without mounting the shell. -const mountOptions = { - global: { - stubs: { - LikeButton: { template: '' }, - UTooltip: { props: ['text'], template: '
' }, - }, - }, -} - -function mountCard(skill: Record) { - return mountSuspended(RepoSkillCard, { ...mountOptions, props: { skill: { ...baseSkill, ...skill } } }) -} - -function dependencyLinks(wrapper: Awaited>) { - return wrapper.findAll('a') - .filter(link => link.attributes('aria-label') === undefined) - .map(link => ({ text: link.text(), href: link.attributes('href') })) -} - -describe('repository skill dependency links', () => { - it('links each dependency to its canonical skill page', async () => { - const wrapper = await mountCard({ dependencies: ['tailwind', 'typescript'] }) - - expect(wrapper.text()).toContain('Requires') - expect(dependencyLinks(wrapper)).toEqual([ - { text: '/tailwind', href: '/gh/antfu/skills/tailwind' }, - { text: '/typescript', href: '/gh/antfu/skills/typescript' }, - ]) - - wrapper.unmount() - }) - - it('keeps dependency links outside the card-wide primary link so they stay clickable', async () => { - const wrapper = await mountCard({ dependencies: ['tailwind'] }) - - const primary = wrapper.get('a[aria-label="/nuxt"]') - const dependency = wrapper.get('a[href="/gh/antfu/skills/tailwind"]') - - expect(primary.element.contains(dependency.element)).toBe(false) - expect(dependency.classes()).toContain('pointer-events-auto') - - wrapper.unmount() - }) - - it('caps visible dependencies at three and summarises the rest behind one control', async () => { - const wrapper = await mountCard({ - dependencies: ['a', 'b', 'c', 'd', 'e'], - }) - - expect(dependencyLinks(wrapper).map(link => link.text)).toEqual(['/a', '/b', '/c']) - const overflow = wrapper.get('[aria-label$="more dependencies: /d, /e"]') - expect(overflow.text()).toBe('+2') - expect(overflow.attributes('aria-label')).toBe('2 more dependencies: /d, /e') - expect(wrapper.get('[data-tooltip]').attributes('data-tooltip')).toBe('/d, /e') - - wrapper.unmount() - }) - - it('shows no overflow control when every dependency fits', async () => { - const wrapper = await mountCard({ dependencies: ['a', 'b', 'c'] }) - - expect(wrapper.find('[aria-label$="more dependencies"]').exists()).toBe(false) - expect(wrapper.text()).not.toContain('+') - - wrapper.unmount() - }) - - it('omits the whole metadata footer when there is nothing to report', async () => { - const wrapper = await mountCard({ dependencies: [], modifiedAt: null }) - - expect(wrapper.text()).not.toContain('Requires') - expect(wrapper.find('time').exists()).toBe(false) - - wrapper.unmount() - }) -}) - -describe('repository skill card metadata', () => { - it('reports a per-skill update time as a relative machine-readable value', async () => { - const wrapper = await mountCard({}) - - const time = wrapper.get('time') - expect(time.attributes('datetime')).toBe(new Date(1_700_000_000 * 1000).toISOString()) - expect(time.text()).toMatch(/^Updated .+ago$/) - expect(wrapper.text()).not.toContain('Added') - - wrapper.unmount() - }) - - it('ignores an unusable timestamp instead of rendering an invalid date', async () => { - const wrapper = await mountCard({ modifiedAt: 0 }) - - expect(wrapper.find('time').exists()).toBe(false) - - wrapper.unmount() - }) - - it('carries no repo-level trust or star metadata', async () => { - const wrapper = await mountCard({ dependencies: ['tailwind'] }) - - expect(wrapper.text()).not.toMatch(/stars?/i) - expect(wrapper.html()).not.toContain('trustTier') - - wrapper.unmount() - }) -}) diff --git a/test/unit/repo-skill-dependencies.test.ts b/test/unit/repo-skill-dependencies.test.ts new file mode 100644 index 00000000..ec4abc86 --- /dev/null +++ b/test/unit/repo-skill-dependencies.test.ts @@ -0,0 +1,59 @@ +import { mountSuspended } from '@nuxt/test-utils/runtime' +import { describe, expect, it } from 'vitest' +import RepoSkillDependencies from '../../layers/registry/app/pages/gh/[owner]/[repo]/_RepoSkillDependencies.vue' + +// UTooltip needs a TooltipProvider ancestor that only the app shell installs; the +// stub keeps its text observable without mounting the shell. +const mountOptions = { + global: { + stubs: { + UTooltip: { props: ['text'], template: '
' }, + }, + }, +} + +function mountDependencies(dependencies: string[]) { + return mountSuspended(RepoSkillDependencies, { + ...mountOptions, + props: { owner: 'antfu', repo: 'skills', dependencies }, + }) +} + +function links(wrapper: Awaited>) { + return wrapper.findAll('a').map(link => ({ text: link.text(), href: link.attributes('href') })) +} + +describe('repository skill dependency links', () => { + it('links each dependency to its canonical skill page', async () => { + const wrapper = await mountDependencies(['tailwind', 'typescript']) + + expect(wrapper.text()).toContain('Requires') + expect(links(wrapper)).toEqual([ + { text: '/tailwind', href: '/gh/antfu/skills/tailwind' }, + { text: '/typescript', href: '/gh/antfu/skills/typescript' }, + ]) + + wrapper.unmount() + }) + + it('caps visible dependencies at three and summarises the rest behind one control', async () => { + const wrapper = await mountDependencies(['a', 'b', 'c', 'd', 'e']) + + expect(links(wrapper).map(link => link.text)).toEqual(['/a', '/b', '/c']) + const overflow = wrapper.get('[aria-label$="more dependencies: /d, /e"]') + expect(overflow.text()).toBe('+2') + expect(overflow.attributes('aria-label')).toBe('2 more dependencies: /d, /e') + expect(wrapper.get('[data-tooltip]').attributes('data-tooltip')).toBe('/d, /e') + + wrapper.unmount() + }) + + it('shows no overflow control when every dependency fits', async () => { + const wrapper = await mountDependencies(['a', 'b', 'c']) + + expect(wrapper.find('[aria-label$="more dependencies"]').exists()).toBe(false) + expect(wrapper.text()).not.toContain('+') + + wrapper.unmount() + }) +}) diff --git a/test/unit/skill-card-condensed.test.ts b/test/unit/skill-card-condensed.test.ts deleted file mode 100644 index 285392fb..00000000 --- a/test/unit/skill-card-condensed.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { mountSuspended } from '@nuxt/test-utils/runtime' -import { describe, expect, it } from 'vitest' -import SkillCard from '../../app/components/SkillCard.vue' - -const skill = { - owner: 'antfu', - repo: 'skills', - name: 'nuxt', - slug: 'antfu/skills/nuxt', - description: 'Build full-stack Vue applications with Nuxt.', - stars: 1234, - modifiedAt: 1_700_000_000, -} - -// LikeButton owns its own auth and fetch wiring; stubbing it keeps this file -// about what SkillCard decides to render. -const mountOptions = { - global: { stubs: { LikeButton: { template: '' } } }, -} - -function mountCard(props: Record) { - return mountSuspended(SkillCard, { ...mountOptions, props: { skill, ...props } }) -} - -describe('skillCard variants', () => { - it('defaults to the grid variant', async () => { - const wrapper = await mountCard({}) - - expect(wrapper.get('a').attributes('aria-label')).toBe('/nuxt by antfu') - - wrapper.unmount() - }) - - it('drops the owner from the condensed link label, since the row already sits under one owner', async () => { - const condensed = await mountCard({ variant: 'condensed' }) - const grid = await mountCard({ variant: 'grid' }) - - expect(condensed.get('a').attributes('aria-label')).toBe('/nuxt') - expect(grid.get('a').attributes('aria-label')).toBe('/nuxt by antfu') - - condensed.unmount() - grid.unmount() - }) - - it('suppresses the repeated owner path in condensed mode even when asked for it', async () => { - const condensed = await mountCard({ variant: 'condensed', showOwnerPath: true }) - const grid = await mountCard({ variant: 'grid', showOwnerPath: true }) - - expect(condensed.text()).not.toContain('antfu') - expect(grid.text()).toContain('antfu') - - condensed.unmount() - grid.unmount() - }) - - it('omits the heart in condensed mode, which reserves no room for a second control', async () => { - const condensed = await mountCard({ variant: 'condensed' }) - const grid = await mountCard({ variant: 'grid' }) - - expect(condensed.find('[data-testid="like-button"]').exists()).toBe(false) - expect(grid.find('[data-testid="like-button"]').exists()).toBe(true) - - condensed.unmount() - grid.unmount() - }) - - it('honours showLike: false on a variant that would otherwise show the heart', async () => { - const wrapper = await mountCard({ variant: 'grid', showLike: false }) - - expect(wrapper.find('[data-testid="like-button"]').exists()).toBe(false) - - wrapper.unmount() - }) -}) - -describe('skillCard signal', () => { - it('reports GitHub stars, and nothing else, as the automatic signal', async () => { - const wrapper = await mountCard({}) - - const signal = wrapper.get('[title$="GitHub stars"]') - expect(signal.attributes('title')).toBe('1,234 GitHub stars') - expect(wrapper.text()).not.toContain('installs') - - wrapper.unmount() - }) - - it('shows no signal for an unstarred skill', async () => { - const wrapper = await mountSuspended(SkillCard, { - ...mountOptions, - props: { skill: { ...skill, stars: 0 } }, - }) - - expect(wrapper.find('[title$="GitHub stars"]').exists()).toBe(false) - - wrapper.unmount() - }) - - it('can be switched off entirely', async () => { - const wrapper = await mountCard({ signal: 'none' }) - - expect(wrapper.find('[title$="GitHub stars"]').exists()).toBe(false) - - wrapper.unmount() - }) -}) - -describe('skillCard timestamps', () => { - it('renders a machine-readable time element rather than a server-formatted string', async () => { - const wrapper = await mountCard({ variant: 'grid', timestampLabel: 'Updated' }) - - const time = wrapper.get('time') - expect(time.attributes('datetime')).toBe(new Date(1_700_000_000 * 1000).toISOString()) - - wrapper.unmount() - }) - - it('defaults condensed rows to a relative time and grid cards to an absolute date', async () => { - const condensed = await mountCard({ variant: 'condensed', timestampLabel: 'Updated' }) - const grid = await mountCard({ variant: 'grid', timestampLabel: 'Updated' }) - - expect(condensed.get('time').text()).toMatch(/ago$/) - expect(grid.get('time').text()).toMatch(/^\w+ \d+, \d{4}$/) - - condensed.unmount() - grid.unmount() - }) - - it('lets a caller override the format the variant would pick', async () => { - const wrapper = await mountCard({ - variant: 'condensed', - timestampLabel: 'Updated', - timestampFormat: 'absolute', - }) - - expect(wrapper.get('time').text()).toMatch(/^\w+ \d+, \d{4}$/) - - wrapper.unmount() - }) -}) diff --git a/test/unit/skill-card-view.test.ts b/test/unit/skill-card-view.test.ts new file mode 100644 index 00000000..b39ce0b2 --- /dev/null +++ b/test/unit/skill-card-view.test.ts @@ -0,0 +1,109 @@ +import type { SkillCardSkill } from '../../app/types/skill-card' +import type { SkillCardOptions } from '../../app/utils/skill-card-view' +import { describe, expect, it } from 'vitest' +import { buildSkillCardView } from '../../app/utils/skill-card-view' + +const skill: SkillCardSkill = { + owner: 'antfu', + repo: 'skills', + name: 'nuxt', + registryPath: '/gh/antfu/skills/nuxt', + description: ' Build full-stack Vue applications with Nuxt. ', + stars: 12_345, + likeCount: 1, + modifiedAt: 1_700_000_000, + authorName: 'Anthony Fu', + skillFileUrl: 'https://github.com/antfu/skills/blob/main/skills/nuxt/SKILL.md', +} + +const card: SkillCardOptions = { layout: 'card', byline: 'full', metric: 'stars', trending: false } +const run = { copied: false, copy: () => {} } + +function view(overrides: Partial = {}, options: Partial = {}) { + return buildSkillCardView({ ...skill, ...overrides }, { ...card, ...options }, run) +} + +describe('skill card view', () => { + it('names the Skill as a slash command and credits the author by name', () => { + const result = view() + + expect(result.title).toBe('/nuxt') + expect(result.label).toBe('/nuxt by Anthony Fu') + expect(result.source).toBe('antfu/skills') + expect(result.description).toBe('Build full-stack Vue applications with Nuxt.') + }) + + it('credits the handle when GitHub has no distinct name for the owner', () => { + const result = view({ authorName: 'antfu' }) + + expect(result.author).toBeNull() + expect(result.label).toBe('/nuxt by antfu') + }) + + it('hides zero stars, which can mean the stars were never fetched', () => { + expect(view({ stars: 0 }).metric).toBeNull() + expect(view({ stars: null }).metric).toBeNull() + expect(view().metric).toMatchObject({ _tag: 'stars', text: '12k', title: '12,345 GitHub stars' }) + }) + + it('counts stars in the singular for one star', () => { + expect(view({ stars: 1 }).metric).toMatchObject({ text: '1', title: '1 GitHub star' }) + }) + + it('counts likes in the singular for one like', () => { + expect(view({}, { metric: 'likes' }).metric).toMatchObject({ _tag: 'likes', text: '1', title: '1 like' }) + expect(view({ likeCount: 0 }, { metric: 'likes' }).metric).toMatchObject({ text: '0', title: '0 likes' }) + }) + + it('dates an update from the push when the Skill has no change time', () => { + const result = view({ modifiedAt: null, pushedAt: 1_600_000_000 }, { metric: 'updated' }) + + expect(result.metric).toMatchObject({ _tag: 'updated', iso: new Date(1_600_000_000 * 1000).toISOString() }) + expect(view({ modifiedAt: 0, pushedAt: null }, { metric: 'updated' }).metric).toBeNull() + }) + + it('offers only the run command on a card, and nothing on a compact entry', () => { + const card = view() + const compact = view({}, { layout: 'compact' }) + + expect(card.run?.command).toBe('npx skilld run antfu/skills/nuxt') + expect([card.like, card.sourceUrl]).toEqual([null, null]) + expect([compact.run, compact.like, compact.sourceUrl]).toEqual([null, null, null]) + }) + + it('shows likes and SKILL.md only where the context asks', () => { + const result = view({}, { actions: ['like', 'source'] }) + + expect(result.like).toEqual({ count: 1 }) + expect(result.sourceUrl).toBe(skill.skillFileUrl) + }) + + it('drops the description from a compact entry unless the context asks for it', () => { + expect(view({}, { layout: 'compact' }).description).toBeNull() + expect(view({}, { layout: 'compact', description: true }).description).toBe('Build full-stack Vue applications with Nuxt.') + }) + + it('drops the source link when the Skill has no known SKILL.md', () => { + expect(view({ skillFileUrl: null }, { actions: ['source'] }).sourceUrl).toBeNull() + }) + + it('keeps only the controls the context asks for', () => { + const result = view({}, { actions: ['source'] }) + + expect(result.run).toBeNull() + expect(result.like).toBeNull() + expect(result.sourceUrl).toBe(skill.skillFileUrl) + }) + + it('omits the description and a blank note when the context hides them', () => { + const result = view({}, { description: false, note: ' ' }) + + expect(result.description).toBeNull() + expect(result.note).toBeNull() + }) + + it('names the analytics surface after the layout unless the context names one', () => { + expect(view({}, { layout: 'row' }).surface).toBe('skill-row') + expect(view({}, { surface: 'collection' }).surface).toBe('collection') + }) +}) diff --git a/test/unit/skill-table-ranking.test.ts b/test/unit/skill-table-ranking.test.ts deleted file mode 100644 index 1e0985a6..00000000 --- a/test/unit/skill-table-ranking.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { mountSuspended } from '@nuxt/test-utils/runtime' - -describe('skill table ranking signal', () => { - it('shows likes when the table is ranked by likes', async () => { - const wrapper = await mountSuspended( - await import('../../app/components/SkillTable.vue').then(module => module.default), - { - props: { - metric: 'likes', - skills: [{ - owner: 'antfu', - repo: 'skills', - name: 'nuxt', - slug: 'antfu/skills/nuxt', - description: 'Nuxt framework guidance.', - stars: 12_000, - likeCount: 7, - }], - }, - }, - ) - - expect(wrapper.get('.skill-table__metric-heading').text()).toBe('Likes') - expect(wrapper.get('.skill-table__metric').text()).toBe('7') - - wrapper.unmount() - }) -}) diff --git a/test/unit/skills-registry-loading-state.test.ts b/test/unit/skills-registry-loading-state.test.ts index e2d0b207..cf8c5870 100644 --- a/test/unit/skills-registry-loading-state.test.ts +++ b/test/unit/skills-registry-loading-state.test.ts @@ -50,7 +50,7 @@ async function mountSkillsPage() { { global: { stubs: { - SkillTable: true, + SkillCard: true, }, }, },