Skip to content
Merged
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
2 changes: 1 addition & 1 deletion DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -470,4 +470,4 @@ These are editorial sets, not necessarily database collections yet. If a set has
- **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 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.
- **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.
- **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.
- **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.
90 changes: 0 additions & 90 deletions app/assets/css/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -590,86 +590,6 @@
border-bottom: 1px solid var(--ui-border);
}

.home-freshness-row {
display: grid;
min-width: 0;
grid-template-columns: auto minmax(0, 1fr) auto auto;
align-items: center;
gap: 0.75rem;
padding-inline: 0.75rem;
transition: background-color 200ms ease-out;
}

.home-freshness-row--primary {
min-block-size: 5rem;
}

.home-freshness-row--secondary {
min-block-size: 4.5rem;
padding-inline: 0.5rem;
}

.home-freshness-row:focus-visible {
z-index: 1;
outline: 2px solid var(--ui-color-primary-500) !important;
outline-offset: -3px !important;
}

.home-freshness-avatar {
flex: none;
border: 2px solid var(--ui-bg);
border-radius: 9999px;
outline: 1px solid var(--ui-border);
background: var(--ui-bg-muted);
object-fit: cover;
}

.home-freshness-avatar--primary {
width: 2.25rem;
height: 2.25rem;
}

.home-freshness-avatar--secondary {
width: 2rem;
height: 2rem;
}

.home-freshness-row-title,
.home-freshness-row-description {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.home-freshness-row-title {
font-family: var(--font-mono);
font-size: 0.875rem;
font-weight: 500;
line-height: 1.25rem;
}

.home-freshness-row-description {
margin-top: 0.125rem;
font-size: 0.8125rem;
line-height: 1.25rem;
color: var(--ui-text-muted);
}

.home-freshness-time {
flex: none;
font-family: var(--font-mono);
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
line-height: 1rem;
color: var(--ui-text-muted);
}

.home-freshness-arrow {
color: var(--ui-text-muted);
transition: color 200ms ease-out;
}

.home-freshness-state {
display: flex;
min-width: 0;
Expand Down Expand Up @@ -718,16 +638,6 @@
}
}

@media (hover: hover) {
.home-freshness-row:hover {
background: var(--ui-bg-muted);
}

.home-freshness-row:hover .home-freshness-arrow {
color: var(--ui-text);
}
}

@media (forced-colors: active) {
.editorial-atmosphere {
display: none;
Expand Down
2 changes: 1 addition & 1 deletion app/components/SkillCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ const { copy, copied } = useInstallCopy(
const view = computed(() => buildSkillCardView(
skill,
{ layout, byline, metric, actions, description, rank, note, trending, surface },
{ copied: copied.value, copy: () => { void copy(runCommand.value) } },
{ copied: copied.value, copy: () => copy(runCommand.value) },
))

const rankText = computed(() => view.value.rank == null ? null : String(view.value.rank).padStart(2, '0'))
Expand Down
87 changes: 71 additions & 16 deletions app/components/skill-card/_SkillCardRun.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import type { SkillCardView } from '~/types/skill-card'
*
* The dot stays stone until a visitor reaches for it, so a grid of cards
* spends no rose at rest.
*
* If the clipboard refuses, the command appears beside the pill, selected,
* as the run chip does, so a visitor can still copy it with the keyboard.
*/
const { run, title } = defineProps<{
run: NonNullable<SkillCardView['run']>
Expand All @@ -17,9 +20,27 @@ const { run, title } = defineProps<{
}>()

const marching = ref(false)
const refused = ref(false)
const status = ref('')
const commandRef = useTemplateRef<HTMLElement>('command')

function selectCommand() {
const el = commandRef.value
const selection = window.getSelection()
if (el && selection)
selection.selectAllChildren(el)
}

async function onCopy() {
run.copy()
const result = await run.copy()
if (result._tag === 'error') {
refused.value = true
status.value = 'Selected. Copy it with your keyboard.'
await nextTick()
selectCommand()
return
}
status.value = 'Copied.'
// Restart the march if a second copy lands before the first one ends.
marching.value = false
await nextTick()
Expand All @@ -30,24 +51,58 @@ async function onCopy() {
</script>

<template>
<button
type="button"
class="skill-run"
:class="[run.copied && 'skill-run--copied', marching && 'skill-run--march']"
:title="run.command"
@click.stop.prevent="onCopy"
>
<svg class="skill-run__edge" aria-hidden="true" focusable="false">
<rect width="100%" height="100%" rx="7.5" ry="7.5" @animationend="marching = false" />
</svg>
<span class="skill-run__dot" aria-hidden="true" />
<span aria-hidden="true">{{ run.copied ? 'copied' : 'run' }}</span>
<span class="sr-only">Copy run command for {{ title }}</span>
<span class="sr-only" aria-live="polite">{{ run.copied ? 'Copied.' : '' }}</span>
</button>
<span class="skill-run-wrap">
<button
type="button"
class="skill-run"
:class="[run.copied && 'skill-run--copied', marching && 'skill-run--march']"
:title="run.command"
@click.stop.prevent="onCopy"
>
<svg class="skill-run__edge" aria-hidden="true" focusable="false">
<rect width="100%" height="100%" rx="7.5" ry="7.5" @animationend="marching = false" />
</svg>
<span class="skill-run__dot" aria-hidden="true" />
<span aria-hidden="true">{{ run.copied ? 'copied' : 'run' }}</span>
<span class="sr-only">Copy run command for {{ title }}</span>
</button>
<code v-if="refused" ref="command" class="skill-run__manual">{{ run.command }}</code>
<span class="skill-run__status" :class="{ 'sr-only': !refused }" aria-live="polite">{{ status }}</span>
</span>
</template>

<style scoped>
.skill-run-wrap {
position: relative;
display: inline-flex;
min-inline-size: 0;
max-inline-size: 100%;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 0.25rem 0.5rem;
}

/* Selected on a refused copy, so the keyboard can take it. */
.skill-run__manual {
min-inline-size: 0;
max-inline-size: 100%;
overflow-wrap: anywhere;
font-family: var(--font-mono);
font-size: 0.6875rem;
line-height: 1.4;
color: var(--ui-text);
user-select: all;
}

.skill-run__status {
flex-basis: 100%;
text-align: end;
font-family: var(--font-mono);
font-size: 0.6875rem;
color: var(--ui-text-muted);
}

.skill-run {
--skill-run-dot: var(--ui-text-dimmed);

Expand Down
29 changes: 0 additions & 29 deletions app/pages/@[login]/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -126,34 +126,6 @@ function cleanDescription(description: string | null): string {
return description?.replace(/^>\s*-\s*/, '').trim() ?? ''
}

function githubSegment(value: string): string {
return encodeURIComponent(value)
}

function githubPath(value: string): string {
return value
.split('/')
.filter(Boolean)
.map(githubSegment)
.join('/')
}

function skillSourceUrl(skill: {
owner: string
repo: string
skill_path: string | null
source_owner: string | null
source_repo: string | null
default_branch: string | null
}): string | null {
if (!skill.skill_path)
return null
const owner = githubSegment(skill.source_owner || skill.owner)
const repo = githubSegment(skill.source_repo || skill.repo)
const branch = githubSegment(skill.default_branch || 'main')
return `https://github.com/${owner}/${repo}/blob/${branch}/${githubPath(skill.skill_path)}`
}

function profileSkill(skill: AuthorSkill): SkillCardSkill {
return {
owner: skill.owner,
Expand All @@ -163,7 +135,6 @@ function profileSkill(skill: AuthorSkill): SkillCardSkill {
description: cleanDescription(skill.description),
likeCount: skill.likeCount,
modifiedAt: skill.modified_at,
skillFileUrl: skillSourceUrl(skill),
}
}

Expand Down
92 changes: 83 additions & 9 deletions app/pages/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -519,27 +519,29 @@ function recentRepoDescription(item: RecentRepoUpdate): string {
:description="!item.changeSummary"
surface="home-recent-updates"
/>
<!-- A whole Repository changed, not one Skill, so it keeps its own line. -->
<!-- A whole Repository changed, not one Skill, so it gets its own line, drawn like the compact card beside it. -->
<NuxtLink
v-else
:to="repoHubPath(item.owner, item.repo)"
class="home-freshness-row home-freshness-row--primary group"
class="home-repo-row"
>
<img
:src="avatarProxyUrl(item.avatarUrl)"
alt=""
width="36"
height="36"
class="home-freshness-avatar home-freshness-avatar--primary"
width="28"
height="28"
class="home-repo-row__avatar"
loading="lazy"
decoding="async"
>
<span class="min-w-0 flex-1">
<span class="home-freshness-row-title">{{ item.owner }}/{{ item.repo }}</span>
<span class="home-freshness-row-description">{{ recentRepoDescription(item) }}</span>
<span class="home-repo-row__name">{{ item.owner }}/{{ item.repo }}</span>
<span class="home-repo-row__by">
<span>{{ `${item.skillCount} ${item.skillCount === 1 ? 'skill' : 'skills'} changed` }}</span>
<NuxtTime :datetime="item.occurredAt * 1000" locale="en" relative numeric="auto" relative-style="short" class="font-mono" />
</span>
<span class="home-repo-row__note">{{ recentRepoDescription(item) }}</span>
</span>
<span class="home-freshness-time">{{ formatRelative(item.occurredAt) }}</span>
<UIcon name="i-lucide-arrow-up-right" class="home-freshness-arrow size-4 shrink-0" aria-hidden="true" />
</NuxtLink>
</li>
</ul>
Expand Down Expand Up @@ -684,6 +686,78 @@ function recentRepoDescription(item: RecentRepoUpdate): string {
padding: 0.875rem 0.75rem;
}

/* A Repository line in the same feed, on the compact card's measures. */
.home-repo-row {
display: flex;
min-inline-size: 0;
align-items: flex-start;
gap: 0.625rem;
padding: 0.875rem 0.75rem;
transition: background-color 200ms ease-out;
}

.home-repo-row:focus-visible {
outline: 2px solid var(--ui-primary);
outline-offset: -2px;
}

@media (hover: hover) {
.home-repo-row:hover {
background: var(--ui-bg-elevated);
}
}

.home-repo-row__avatar {
flex: none;
inline-size: 1.75rem;
block-size: 1.75rem;
border: 1px solid var(--ui-border);
border-radius: 9999px;
background: var(--ui-bg-muted);
}

.home-repo-row__name {
display: block;
overflow: hidden;
font-family: var(--font-mono);
font-size: 0.8125rem;
font-weight: 600;
line-height: 1.25rem;
letter-spacing: -0.01em;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--ui-text-highlighted);
}

.home-repo-row__by {
display: flex;
min-inline-size: 0;
gap: 0.375rem;
margin-block-start: 0.0625rem;
font-size: 0.75rem;
line-height: 1.125rem;
white-space: nowrap;
color: var(--ui-text-muted);
}

.home-repo-row__note {
display: -webkit-box;
margin-block-start: 0.375rem;
overflow: hidden;
font-size: 0.75rem;
line-height: 1.125rem;
color: var(--ui-text);
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
}

@media (prefers-reduced-motion: reduce) {
.home-repo-row {
transition: none;
}
}

/* Above the lifecycle band, so the search panel overlays it instead of sliding under. */
.home-hero {
position: relative;
Expand Down
Loading