Skip to content

Commit 6b60d2e

Browse files
fix(skills): close the SkillCard review findings from #453 (#457)
Co-authored-by: Harlan GitHub Agent <harlan-github-agent[bot]@users.noreply.github.com>
1 parent b5fda11 commit 6b60d2e

10 files changed

Lines changed: 193 additions & 149 deletions

File tree

‎DESIGN.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -470,4 +470,4 @@ These are editorial sets, not necessarily database collections yet. If a set has
470470
- **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.
471471
- **A Developers menu groups the build surfaces**: the desktop header keeps Trending Skills and Find Skills as links, and folds CLI, MCP server, SDK and Make a skill 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`. Harlan asked for this on 2026-10-06.
472472
- **The Developers menu is a bento**: the three ways to connect stack as compact rows, each a lucide icon tile, a name and one line. Make a skill fills the second column as the one large card, with an ember atmosphere and the File minimap in its corner. It names both routes: the guides and Skillgen. `/make-skill` is the one entry for authors and routes to either, so Skillgen has no card of its own. The mobile menu lists the same four items as a plain group. Harlan chose this over five uniform cards on 2026-10-06.
473-
- **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.
473+
- **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. This replaces the row layout in "Trending rows lead with the Skill", "Trending shares the /skills shell" and "Trending rows scan in one pass": posts now sit under the Skill's words, not beside it, and the dashed `run` pill replaces the full run chip. The /skills shell, the sidebar and the rank styling stand.

‎app/assets/css/main.css‎

Lines changed: 0 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -590,86 +590,6 @@
590590
border-bottom: 1px solid var(--ui-border);
591591
}
592592

593-
.home-freshness-row {
594-
display: grid;
595-
min-width: 0;
596-
grid-template-columns: auto minmax(0, 1fr) auto auto;
597-
align-items: center;
598-
gap: 0.75rem;
599-
padding-inline: 0.75rem;
600-
transition: background-color 200ms ease-out;
601-
}
602-
603-
.home-freshness-row--primary {
604-
min-block-size: 5rem;
605-
}
606-
607-
.home-freshness-row--secondary {
608-
min-block-size: 4.5rem;
609-
padding-inline: 0.5rem;
610-
}
611-
612-
.home-freshness-row:focus-visible {
613-
z-index: 1;
614-
outline: 2px solid var(--ui-color-primary-500) !important;
615-
outline-offset: -3px !important;
616-
}
617-
618-
.home-freshness-avatar {
619-
flex: none;
620-
border: 2px solid var(--ui-bg);
621-
border-radius: 9999px;
622-
outline: 1px solid var(--ui-border);
623-
background: var(--ui-bg-muted);
624-
object-fit: cover;
625-
}
626-
627-
.home-freshness-avatar--primary {
628-
width: 2.25rem;
629-
height: 2.25rem;
630-
}
631-
632-
.home-freshness-avatar--secondary {
633-
width: 2rem;
634-
height: 2rem;
635-
}
636-
637-
.home-freshness-row-title,
638-
.home-freshness-row-description {
639-
display: block;
640-
overflow: hidden;
641-
text-overflow: ellipsis;
642-
white-space: nowrap;
643-
}
644-
645-
.home-freshness-row-title {
646-
font-family: var(--font-mono);
647-
font-size: 0.875rem;
648-
font-weight: 500;
649-
line-height: 1.25rem;
650-
}
651-
652-
.home-freshness-row-description {
653-
margin-top: 0.125rem;
654-
font-size: 0.8125rem;
655-
line-height: 1.25rem;
656-
color: var(--ui-text-muted);
657-
}
658-
659-
.home-freshness-time {
660-
flex: none;
661-
font-family: var(--font-mono);
662-
font-size: 0.75rem;
663-
font-variant-numeric: tabular-nums;
664-
line-height: 1rem;
665-
color: var(--ui-text-muted);
666-
}
667-
668-
.home-freshness-arrow {
669-
color: var(--ui-text-muted);
670-
transition: color 200ms ease-out;
671-
}
672-
673593
.home-freshness-state {
674594
display: flex;
675595
min-width: 0;
@@ -718,16 +638,6 @@
718638
}
719639
}
720640

721-
@media (hover: hover) {
722-
.home-freshness-row:hover {
723-
background: var(--ui-bg-muted);
724-
}
725-
726-
.home-freshness-row:hover .home-freshness-arrow {
727-
color: var(--ui-text);
728-
}
729-
}
730-
731641
@media (forced-colors: active) {
732642
.editorial-atmosphere {
733643
display: none;

‎app/components/SkillCard.vue‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ const { copy, copied } = useInstallCopy(
8080
const view = computed(() => buildSkillCardView(
8181
skill,
8282
{ layout, byline, metric, actions, description, rank, note, trending, surface },
83-
{ copied: copied.value, copy: () => { void copy(runCommand.value) } },
83+
{ copied: copied.value, copy: () => copy(runCommand.value) },
8484
))
8585
8686
const rankText = computed(() => view.value.rank == null ? null : String(view.value.rank).padStart(2, '0'))

‎app/components/skill-card/_SkillCardRun.vue‎

Lines changed: 71 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ import type { SkillCardView } from '~/types/skill-card'
99
*
1010
* The dot stays stone until a visitor reaches for it, so a grid of cards
1111
* spends no rose at rest.
12+
*
13+
* If the clipboard refuses, the command appears beside the pill, selected,
14+
* as the run chip does, so a visitor can still copy it with the keyboard.
1215
*/
1316
const { run, title } = defineProps<{
1417
run: NonNullable<SkillCardView['run']>
@@ -17,9 +20,27 @@ const { run, title } = defineProps<{
1720
}>()
1821
1922
const marching = ref(false)
23+
const refused = ref(false)
24+
const status = ref('')
25+
const commandRef = useTemplateRef<HTMLElement>('command')
26+
27+
function selectCommand() {
28+
const el = commandRef.value
29+
const selection = window.getSelection()
30+
if (el && selection)
31+
selection.selectAllChildren(el)
32+
}
2033
2134
async function onCopy() {
22-
run.copy()
35+
const result = await run.copy()
36+
if (result._tag === 'error') {
37+
refused.value = true
38+
status.value = 'Selected. Copy it with your keyboard.'
39+
await nextTick()
40+
selectCommand()
41+
return
42+
}
43+
status.value = 'Copied.'
2344
// Restart the march if a second copy lands before the first one ends.
2445
marching.value = false
2546
await nextTick()
@@ -30,24 +51,58 @@ async function onCopy() {
3051
</script>
3152

3253
<template>
33-
<button
34-
type="button"
35-
class="skill-run"
36-
:class="[run.copied && 'skill-run--copied', marching && 'skill-run--march']"
37-
:title="run.command"
38-
@click.stop.prevent="onCopy"
39-
>
40-
<svg class="skill-run__edge" aria-hidden="true" focusable="false">
41-
<rect width="100%" height="100%" rx="7.5" ry="7.5" @animationend="marching = false" />
42-
</svg>
43-
<span class="skill-run__dot" aria-hidden="true" />
44-
<span aria-hidden="true">{{ run.copied ? 'copied' : 'run' }}</span>
45-
<span class="sr-only">Copy run command for {{ title }}</span>
46-
<span class="sr-only" aria-live="polite">{{ run.copied ? 'Copied.' : '' }}</span>
47-
</button>
54+
<span class="skill-run-wrap">
55+
<button
56+
type="button"
57+
class="skill-run"
58+
:class="[run.copied && 'skill-run--copied', marching && 'skill-run--march']"
59+
:title="run.command"
60+
@click.stop.prevent="onCopy"
61+
>
62+
<svg class="skill-run__edge" aria-hidden="true" focusable="false">
63+
<rect width="100%" height="100%" rx="7.5" ry="7.5" @animationend="marching = false" />
64+
</svg>
65+
<span class="skill-run__dot" aria-hidden="true" />
66+
<span aria-hidden="true">{{ run.copied ? 'copied' : 'run' }}</span>
67+
<span class="sr-only">Copy run command for {{ title }}</span>
68+
</button>
69+
<code v-if="refused" ref="command" class="skill-run__manual">{{ run.command }}</code>
70+
<span class="skill-run__status" :class="{ 'sr-only': !refused }" aria-live="polite">{{ status }}</span>
71+
</span>
4872
</template>
4973

5074
<style scoped>
75+
.skill-run-wrap {
76+
position: relative;
77+
display: inline-flex;
78+
min-inline-size: 0;
79+
max-inline-size: 100%;
80+
flex-wrap: wrap;
81+
align-items: center;
82+
justify-content: flex-end;
83+
gap: 0.25rem 0.5rem;
84+
}
85+
86+
/* Selected on a refused copy, so the keyboard can take it. */
87+
.skill-run__manual {
88+
min-inline-size: 0;
89+
max-inline-size: 100%;
90+
overflow-wrap: anywhere;
91+
font-family: var(--font-mono);
92+
font-size: 0.6875rem;
93+
line-height: 1.4;
94+
color: var(--ui-text);
95+
user-select: all;
96+
}
97+
98+
.skill-run__status {
99+
flex-basis: 100%;
100+
text-align: end;
101+
font-family: var(--font-mono);
102+
font-size: 0.6875rem;
103+
color: var(--ui-text-muted);
104+
}
105+
51106
.skill-run {
52107
--skill-run-dot: var(--ui-text-dimmed);
53108

‎app/pages/@[login]/index.vue‎

Lines changed: 0 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -126,34 +126,6 @@ function cleanDescription(description: string | null): string {
126126
return description?.replace(/^>\s*-\s*/, '').trim() ?? ''
127127
}
128128
129-
function githubSegment(value: string): string {
130-
return encodeURIComponent(value)
131-
}
132-
133-
function githubPath(value: string): string {
134-
return value
135-
.split('/')
136-
.filter(Boolean)
137-
.map(githubSegment)
138-
.join('/')
139-
}
140-
141-
function skillSourceUrl(skill: {
142-
owner: string
143-
repo: string
144-
skill_path: string | null
145-
source_owner: string | null
146-
source_repo: string | null
147-
default_branch: string | null
148-
}): string | null {
149-
if (!skill.skill_path)
150-
return null
151-
const owner = githubSegment(skill.source_owner || skill.owner)
152-
const repo = githubSegment(skill.source_repo || skill.repo)
153-
const branch = githubSegment(skill.default_branch || 'main')
154-
return `https://github.com/${owner}/${repo}/blob/${branch}/${githubPath(skill.skill_path)}`
155-
}
156-
157129
function profileSkill(skill: AuthorSkill): SkillCardSkill {
158130
return {
159131
owner: skill.owner,
@@ -163,7 +135,6 @@ function profileSkill(skill: AuthorSkill): SkillCardSkill {
163135
description: cleanDescription(skill.description),
164136
likeCount: skill.likeCount,
165137
modifiedAt: skill.modified_at,
166-
skillFileUrl: skillSourceUrl(skill),
167138
}
168139
}
169140

‎app/pages/index.vue‎

Lines changed: 83 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -488,27 +488,29 @@ function recentRepoDescription(item: RecentRepoUpdate): string {
488488
:description="!item.changeSummary"
489489
surface="home-recent-updates"
490490
/>
491-
<!-- A whole Repository changed, not one Skill, so it keeps its own line. -->
491+
<!-- A whole Repository changed, not one Skill, so it gets its own line, drawn like the compact card beside it. -->
492492
<NuxtLink
493493
v-else
494494
:to="repoHubPath(item.owner, item.repo)"
495-
class="home-freshness-row home-freshness-row--primary group"
495+
class="home-repo-row"
496496
>
497497
<img
498498
:src="avatarProxyUrl(item.avatarUrl)"
499499
alt=""
500-
width="36"
501-
height="36"
502-
class="home-freshness-avatar home-freshness-avatar--primary"
500+
width="28"
501+
height="28"
502+
class="home-repo-row__avatar"
503503
loading="lazy"
504504
decoding="async"
505505
>
506506
<span class="min-w-0 flex-1">
507-
<span class="home-freshness-row-title">{{ item.owner }}/{{ item.repo }}</span>
508-
<span class="home-freshness-row-description">{{ recentRepoDescription(item) }}</span>
507+
<span class="home-repo-row__name">{{ item.owner }}/{{ item.repo }}</span>
508+
<span class="home-repo-row__by">
509+
<span>{{ `${item.skillCount} ${item.skillCount === 1 ? 'skill' : 'skills'} changed` }}</span>
510+
<NuxtTime :datetime="item.occurredAt * 1000" locale="en" relative numeric="auto" relative-style="short" class="font-mono" />
511+
</span>
512+
<span class="home-repo-row__note">{{ recentRepoDescription(item) }}</span>
509513
</span>
510-
<span class="home-freshness-time">{{ formatRelative(item.occurredAt) }}</span>
511-
<UIcon name="i-lucide-arrow-up-right" class="home-freshness-arrow size-4 shrink-0" aria-hidden="true" />
512514
</NuxtLink>
513515
</li>
514516
</ul>
@@ -653,6 +655,78 @@ function recentRepoDescription(item: RecentRepoUpdate): string {
653655
padding: 0.875rem 0.75rem;
654656
}
655657
658+
/* A Repository line in the same feed, on the compact card's measures. */
659+
.home-repo-row {
660+
display: flex;
661+
min-inline-size: 0;
662+
align-items: flex-start;
663+
gap: 0.625rem;
664+
padding: 0.875rem 0.75rem;
665+
transition: background-color 200ms ease-out;
666+
}
667+
668+
.home-repo-row:focus-visible {
669+
outline: 2px solid var(--ui-primary);
670+
outline-offset: -2px;
671+
}
672+
673+
@media (hover: hover) {
674+
.home-repo-row:hover {
675+
background: var(--ui-bg-elevated);
676+
}
677+
}
678+
679+
.home-repo-row__avatar {
680+
flex: none;
681+
inline-size: 1.75rem;
682+
block-size: 1.75rem;
683+
border: 1px solid var(--ui-border);
684+
border-radius: 9999px;
685+
background: var(--ui-bg-muted);
686+
}
687+
688+
.home-repo-row__name {
689+
display: block;
690+
overflow: hidden;
691+
font-family: var(--font-mono);
692+
font-size: 0.8125rem;
693+
font-weight: 600;
694+
line-height: 1.25rem;
695+
letter-spacing: -0.01em;
696+
text-overflow: ellipsis;
697+
white-space: nowrap;
698+
color: var(--ui-text-highlighted);
699+
}
700+
701+
.home-repo-row__by {
702+
display: flex;
703+
min-inline-size: 0;
704+
gap: 0.375rem;
705+
margin-block-start: 0.0625rem;
706+
font-size: 0.75rem;
707+
line-height: 1.125rem;
708+
white-space: nowrap;
709+
color: var(--ui-text-muted);
710+
}
711+
712+
.home-repo-row__note {
713+
display: -webkit-box;
714+
margin-block-start: 0.375rem;
715+
overflow: hidden;
716+
font-size: 0.75rem;
717+
line-height: 1.125rem;
718+
color: var(--ui-text);
719+
-webkit-box-orient: vertical;
720+
-webkit-line-clamp: 2;
721+
line-clamp: 2;
722+
}
723+
724+
@media (prefers-reduced-motion: reduce) {
725+
.home-repo-row {
726+
transition: none;
727+
}
728+
}
729+
656730
/* Above the lifecycle band, so the search panel overlays it instead of sliding under. */
657731
.home-hero {
658732
position: relative;

0 commit comments

Comments
 (0)